summaryrefslogtreecommitdiff
path: root/lib/rfq-last
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-09-08 10:29:19 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-09-08 10:29:19 +0000
commitf93493f68c9f368e10f1c3379f1c1384068e3b14 (patch)
treea9dada58741750fa7ca6e04b210443ad99a6bccc /lib/rfq-last
parente832a508e1b3c531fb3e1b9761e18e1b55e3d76a (diff)
(대표님, 최겸) rfqLast, bidding, prequote
Diffstat (limited to 'lib/rfq-last')
-rw-r--r--lib/rfq-last/service.ts480
-rw-r--r--lib/rfq-last/vendor/batch-update-conditions-dialog.tsx81
-rw-r--r--lib/rfq-last/vendor/rfq-vendor-table.tsx208
-rw-r--r--lib/rfq-last/vendor/send-rfq-dialog.tsx578
4 files changed, 1266 insertions, 81 deletions
diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts
index 67cb901f..0c75e72f 100644
--- a/lib/rfq-last/service.ts
+++ b/lib/rfq-last/service.ts
@@ -571,9 +571,9 @@ export async function getRfqItemsAction(rfqId: number) {
materialDescription: item.materialDescription,
size: item.size,
deliveryDate: item.deliveryDate,
- quantity: item.quantity,
+ quantity: Number(item.quantity) || 0, // 여기서 숫자로 변환
uom: item.uom,
- grossWeight: item.grossWeight,
+ grossWeight: Number(item.grossWeight) || 0, // 여기서 숫자로 변환
gwUom: item.gwUom,
specNo: item.specNo,
specUrl: item.specUrl,
@@ -1835,4 +1835,480 @@ export async function getRfqWithDetails(rfqId: number) {
console.error("Get RFQ with details error:", error);
return { success: false, error: "데이터 조회 중 오류가 발생했습니다." };
}
+}
+
+
+// RFQ 정보 타입
+export interface RfqFullInfo {
+ // 기본 RFQ 정보
+ id: number;
+ rfqCode: string;
+ rfqType: string | null;
+ rfqTitle: string | null;
+ series: string | null;
+ rfqSealedYn: boolean | null;
+
+ // ITB 관련
+ projectCompany: string | null;
+ projectFlag: string | null;
+ projectSite: string | null;
+ smCode: string | null;
+
+ // RFQ 추가 필드
+ prNumber: string | null;
+ prIssueDate: Date | null;
+
+ // 프로젝트 정보
+ projectId: number | null;
+ projectCode: string | null;
+ projectName: string | null;
+
+ // 아이템 정보
+ itemCode: string | null;
+ itemName: string | null;
+
+ // 패키지 정보
+ packageNo: string | null;
+ packageName: string | null;
+
+ // 날짜 정보
+ dueDate: Date | null;
+ rfqSendDate: Date | null;
+
+ // 상태
+ status: string;
+
+ // 담당자 정보
+ picId: number | null;
+ picCode: string | null;
+ picName: string | null;
+ picUserName: string | null;
+ picTeam: string | null;
+
+ // 설계담당자
+ engPicName: string | null;
+ designTeam: string | null;
+
+ // 자재그룹 정보 (PR Items에서)
+ materialGroup: string | null;
+ materialGroupDesc: string | null;
+
+ // 카운트 정보
+ vendorCount: number;
+ shortListedVendorCount: number;
+ quotationReceivedCount: number;
+ prItemsCount: number;
+ majorItemsCount: number;
+
+ // 감사 정보
+ createdBy: number;
+ createdByUserName: string | null;
+ createdAt: Date;
+ updatedBy: number;
+ updatedByUserName: string | null;
+ updatedAt: Date;
+
+ sentBy: number | null;
+ sentByUserName: string | null;
+
+ remark: string | null;
+
+ // 평가 적용 여부 (추가 필드)
+ evaluationApply?: boolean;
+ quotationType?: string;
+ contractType?: string;
+
+ // 연관 데이터
+ vendors: VendorDetail[];
+ attachments: AttachmentInfo[];
+}
+
+// 벤더 상세 정보
+export interface VendorDetail {
+ detailId: number;
+ vendorId: number | null;
+ vendorName: string | null;
+ vendorCode: string | null;
+ vendorCountry: string | null;
+ vendorEmail?: string | null;
+ vendorCategory?: string | null;
+ vendorGrade?: string | null;
+ basicContract?: string | null;
+
+ // RFQ 조건
+ currency: string | null;
+ paymentTermsCode: string | null;
+ paymentTermsDescription: string | null;
+ incotermsCode: string | null;
+ incotermsDescription: string | null;
+ incotermsDetail: string | null;
+ deliveryDate: Date | null;
+ contractDuration: string | null;
+ taxCode: string | null;
+ placeOfShipping: string | null;
+ placeOfDestination: string | null;
+
+ // 상태
+ shortList: boolean;
+ returnYn: boolean;
+ returnedAt: Date | null;
+
+ // GTC/NDA
+ prjectGtcYn: boolean;
+ generalGtcYn: boolean;
+ ndaYn: boolean;
+ agreementYn: boolean;
+
+ // 추가 조건
+ materialPriceRelatedYn: boolean | null;
+ sparepartYn: boolean | null;
+ firstYn: boolean | null;
+ firstDescription: string | null;
+ sparepartDescription: string | null;
+
+ remark: string | null;
+ cancelReason: string | null;
+
+ // 회신 상태
+ quotationStatus?: string | null;
+ quotationSubmittedAt?: Date | null;
+
+ // 업데이트 정보
+ updatedBy: number;
+ updatedByUserName: string | null;
+ updatedAt: Date | null;
+}
+
+// 첨부파일 정보
+export interface AttachmentInfo {
+ id: number;
+ attachmentType: string;
+ serialNo: string;
+ currentRevision: string;
+ description: string | null;
+
+ // 최신 리비전 정보
+ fileName: string | null;
+ originalFileName: string | null;
+ filePath: string | null;
+ fileSize: number | null;
+ fileType: string | null;
+
+ createdBy: number;
+ createdByUserName: string | null;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+/**
+ * RFQ 전체 정보 조회
+ */
+export async function getRfqFullInfo(rfqId: number): Promise<RfqFullInfo> {
+ try {
+ // 1. RFQ 기본 정보 조회
+ const rfqData = await db
+ .select({
+ rfq: rfqsLast,
+ picUser: users,
+ })
+ .from(rfqsLast)
+ .leftJoin(users, eq(rfqsLast.pic, users.id))
+ .where(eq(rfqsLast.id, rfqId))
+ .limit(1);
+
+ if (!rfqData.length) {
+ throw new Error(`RFQ ID ${rfqId}를 찾을 수 없습니다.`);
+ }
+
+ const rfq = rfqData[0].rfq;
+ const picUser = rfqData[0].picUser;
+
+ // 2. PR Items에서 자재그룹 정보 조회 (Major Item)
+ const prItemsData = await db
+ .select({
+ materialCategory: rfqPrItems.materialCategory,
+ materialDescription: rfqPrItems.materialDescription,
+ prItemsCount: eq(rfqPrItems.majorYn, true),
+ })
+ .from(rfqPrItems)
+ .where(and(
+ eq(rfqPrItems.rfqsLastId, rfqId),
+ eq(rfqPrItems.majorYn, true)
+ ))
+ .limit(1);
+
+ const majorItem = prItemsData[0];
+
+ // 3. 벤더 정보 조회
+ const vendorsData = await db
+ .select({
+ detail: rfqLastDetails,
+ vendor: vendors,
+ paymentTerms: paymentTerms,
+ incoterms: incoterms,
+ updatedByUser: users,
+ })
+ .from(rfqLastDetails)
+ .leftJoin(vendors, eq(rfqLastDetails.vendorsId, vendors.id))
+ .leftJoin(paymentTerms, eq(rfqLastDetails.paymentTermsCode, paymentTerms.code))
+ .leftJoin(incoterms, eq(rfqLastDetails.incotermsCode, incoterms.code))
+ .leftJoin(users, eq(rfqLastDetails.updatedBy, users.id))
+ .where(eq(rfqLastDetails.rfqsLastId, rfqId));
+
+ const vendorDetails: VendorDetail[] = vendorsData.map(v => ({
+ detailId: v.detail.id,
+ vendorId: v.vendor?.id ?? null,
+ vendorName: v.vendor?.vendorName ?? null,
+ vendorCode: v.vendor?.vendorCode ?? null,
+ vendorCountry: v.vendor?.country ?? null,
+ vendorEmail: v.vendor?.email ?? null,
+ vendorCategory: v.vendor?.vendorCategory ?? null,
+ vendorGrade: v.vendor?.vendorGrade ?? null,
+ basicContract: v.vendor?.basicContract ?? null,
+
+ currency: v.detail.currency,
+ paymentTermsCode: v.detail.paymentTermsCode,
+ paymentTermsDescription: v.paymentTerms?.description ?? null,
+ incotermsCode: v.detail.incotermsCode,
+ incotermsDescription: v.incoterms?.description ?? null,
+ incotermsDetail: v.detail.incotermsDetail,
+ deliveryDate: v.detail.deliveryDate,
+ contractDuration: v.detail.contractDuration,
+ taxCode: v.detail.taxCode,
+ placeOfShipping: v.detail.placeOfShipping,
+ placeOfDestination: v.detail.placeOfDestination,
+
+ shortList: v.detail.shortList,
+ returnYn: v.detail.returnYn,
+ returnedAt: v.detail.returnedAt,
+
+ prjectGtcYn: v.detail.prjectGtcYn,
+ generalGtcYn: v.detail.generalGtcYn,
+ ndaYn: v.detail.ndaYn,
+ agreementYn: v.detail.agreementYn,
+
+ materialPriceRelatedYn: v.detail.materialPriceRelatedYn,
+ sparepartYn: v.detail.sparepartYn,
+ firstYn: v.detail.firstYn,
+ firstDescription: v.detail.firstDescription,
+ sparepartDescription: v.detail.sparepartDescription,
+
+ remark: v.detail.remark,
+ cancelReason: v.detail.cancelReason,
+
+ updatedBy: v.detail.updatedBy,
+ updatedByUserName: v.updatedByUser?.name ?? null,
+ updatedAt: v.detail.updatedAt,
+ }));
+
+ // 4. 첨부파일 정보 조회
+ const attachmentsData = await db
+ .select({
+ attachment: rfqLastAttachments,
+ revision: rfqLastAttachmentRevisions,
+ createdByUser: users,
+ })
+ .from(rfqLastAttachments)
+ .leftJoin(
+ rfqLastAttachmentRevisions,
+ and(
+ eq(rfqLastAttachments.latestRevisionId, rfqLastAttachmentRevisions.id),
+ eq(rfqLastAttachmentRevisions.isLatest, true)
+ )
+ )
+ .leftJoin(users, eq(rfqLastAttachments.createdBy, users.id))
+ .where(eq(rfqLastAttachments.rfqId, rfqId));
+
+ const attachments: AttachmentInfo[] = attachmentsData.map(a => ({
+ id: a.attachment.id,
+ attachmentType: a.attachment.attachmentType,
+ serialNo: a.attachment.serialNo,
+ currentRevision: a.attachment.currentRevision,
+ description: a.attachment.description,
+
+ fileName: a.revision?.fileName ?? null,
+ originalFileName: a.revision?.originalFileName ?? null,
+ filePath: a.revision?.filePath ?? null,
+ fileSize: a.revision?.fileSize ?? null,
+ fileType: a.revision?.fileType ?? null,
+
+ createdBy: a.attachment.createdBy,
+ createdByUserName: a.createdByUser?.name ?? null,
+ createdAt: a.attachment.createdAt,
+ updatedAt: a.attachment.updatedAt,
+ }));
+
+ // 5. 카운트 정보 계산
+ const vendorCount = vendorDetails.length;
+ const shortListedVendorCount = vendorDetails.filter(v => v.shortList).length;
+ const quotationReceivedCount = vendorDetails.filter(v => v.quotationSubmittedAt).length;
+
+ // PR Items 카운트 (별도 쿼리 필요)
+ const prItemsCount = await db
+ .select({ count: sql<number>`COUNT(*)` })
+ .from(rfqPrItems)
+ .where(eq(rfqPrItems.rfqsLastId, rfqId));
+
+ const majorItemsCount = await db
+ .select({ count: sql<number>`COUNT(*)` })
+ .from(rfqPrItems)
+ .where(and(
+ eq(rfqPrItems.rfqsLastId, rfqId),
+ eq(rfqPrItems.majorYn, true)
+ ));
+
+ // 6. 사용자 정보 조회 (createdBy, updatedBy, sentBy)
+ const [createdByUser] = await db
+ .select({ name: users.name })
+ .from(users)
+ .where(eq(users.id, rfq.createdBy))
+ .limit(1);
+
+ const [updatedByUser] = await db
+ .select({ name: users.name })
+ .from(users)
+ .where(eq(users.id, rfq.updatedBy))
+ .limit(1);
+
+ const [sentByUser] = rfq.sentBy
+ ? await db
+ .select({ name: users.name })
+ .from(users)
+ .where(eq(users.id, rfq.sentBy))
+ .limit(1)
+ : [null];
+
+ // 7. 전체 정보 조합
+ const rfqFullInfo: RfqFullInfo = {
+ // 기본 정보
+ id: rfq.id,
+ rfqCode: rfq.rfqCode ?? '',
+ rfqType: rfq.rfqType,
+ rfqTitle: rfq.rfqTitle,
+ series: rfq.series,
+ rfqSealedYn: rfq.rfqSealedYn,
+
+ // ITB 관련
+ projectCompany: rfq.projectCompany,
+ projectFlag: rfq.projectFlag,
+ projectSite: rfq.projectSite,
+ smCode: rfq.smCode,
+
+ // RFQ 추가 필드
+ prNumber: rfq.prNumber,
+ prIssueDate: rfq.prIssueDate,
+
+ // 프로젝트
+ projectId: rfq.projectId,
+ projectCode: null, // 프로젝트 조인 필요시 추가
+ projectName: null, // 프로젝트 조인 필요시 추가
+
+ // 아이템
+ itemCode: rfq.itemCode,
+ itemName: rfq.itemName,
+
+ // 패키지
+ packageNo: rfq.packageNo,
+ packageName: rfq.packageName,
+
+ // 날짜
+ dueDate: rfq.dueDate,
+ rfqSendDate: rfq.rfqSendDate,
+
+ // 상태
+ status: rfq.status,
+
+ // 구매 담당자
+ picId: rfq.pic,
+ picCode: rfq.picCode,
+ picName: rfq.picName,
+ picUserName: picUser?.name ?? null,
+ picTeam: picUser?.department ?? null, // users 테이블에 department 필드가 있다고 가정
+
+ // 설계 담당자
+ engPicName: rfq.EngPicName,
+ designTeam: null, // 추가 정보 필요시 입력
+
+ // 자재그룹 (PR Items에서)
+ materialGroup: majorItem?.materialCategory ?? null,
+ materialGroupDesc: majorItem?.materialDescription ?? null,
+
+ // 카운트
+ vendorCount,
+ shortListedVendorCount,
+ quotationReceivedCount,
+ prItemsCount: prItemsCount[0]?.count ?? 0,
+ majorItemsCount: majorItemsCount[0]?.count ?? 0,
+
+ // 감사 정보
+ createdBy: rfq.createdBy,
+ createdByUserName: createdByUser?.name ?? null,
+ createdAt: rfq.createdAt,
+ updatedBy: rfq.updatedBy,
+ updatedByUserName: updatedByUser?.name ?? null,
+ updatedAt: rfq.updatedAt,
+ sentBy: rfq.sentBy,
+ sentByUserName: sentByUser?.name ?? null,
+
+ remark: rfq.remark,
+
+ // 추가 필드 (필요시)
+ evaluationApply: true, // 기본값 또는 별도 로직
+ quotationType: rfq.rfqType ?? undefined,
+ contractType: undefined, // 별도 필드 필요
+
+ // 연관 데이터
+ vendors: vendorDetails,
+ attachments: attachments,
+ };
+
+ return rfqFullInfo;
+ } catch (error) {
+ console.error("RFQ 정보 조회 실패:", error);
+ throw error;
+ }
+}
+
+/**
+ * SendRfqDialog용 간단한 정보 조회
+ */
+export async function getRfqInfoForSend(rfqId: number) {
+ const fullInfo = await getRfqFullInfo(rfqId);
+
+ return {
+ rfqCode: fullInfo.rfqCode,
+ rfqTitle: fullInfo.rfqTitle || '',
+ rfqType: fullInfo.rfqType || '',
+ projectCode: fullInfo.projectCode,
+ projectName: fullInfo.projectName,
+ picName: fullInfo.picName,
+ picCode: fullInfo.picCode,
+ picTeam: fullInfo.picTeam,
+ packageNo: fullInfo.packageNo,
+ packageName: fullInfo.packageName,
+ designPicName: fullInfo.engPicName, // EngPicName이 설계담당자
+ designTeam: fullInfo.designTeam,
+ materialGroup: fullInfo.materialGroup,
+ materialGroupDesc: fullInfo.materialGroupDesc,
+ dueDate: fullInfo.dueDate || new Date(),
+ quotationType: fullInfo.quotationType,
+ evaluationApply: fullInfo.evaluationApply,
+ contractType: fullInfo.contractType,
+ };
+}
+
+/**
+ * 벤더 정보만 조회
+ */
+export async function getRfqVendors(rfqId: number) {
+ const fullInfo = await getRfqFullInfo(rfqId);
+ return fullInfo.vendors;
+}
+
+/**
+ * 첨부파일 정보만 조회
+ */
+export async function getRfqAttachments(rfqId: number) {
+ const fullInfo = await getRfqFullInfo(rfqId);
+ return fullInfo.attachments;
} \ No newline at end of file
diff --git a/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx b/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx
index 1b8fa528..7de8cfa4 100644
--- a/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx
+++ b/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx
@@ -50,11 +50,11 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { ScrollArea } from "@/components/ui/scroll-area";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Checkbox } from "@/components/ui/checkbox";
-import {
+import {
getIncotermsForSelection,
getPaymentTermsForSelection,
getPlaceOfShippingForSelection,
- getPlaceOfDestinationForSelection
+ getPlaceOfDestinationForSelection
} from "@/lib/procurement-select/service";
interface BatchUpdateConditionsDialogProps {
@@ -108,19 +108,19 @@ export function BatchUpdateConditionsDialog({
onSuccess,
}: BatchUpdateConditionsDialogProps) {
const [isLoading, setIsLoading] = React.useState(false);
-
+
// Select 옵션들 상태
const [incoterms, setIncoterms] = React.useState<SelectOption[]>([]);
const [paymentTerms, setPaymentTerms] = React.useState<SelectOption[]>([]);
const [shippingPlaces, setShippingPlaces] = React.useState<SelectOption[]>([]);
const [destinationPlaces, setDestinationPlaces] = React.useState<SelectOption[]>([]);
-
+
// 로딩 상태
const [incotermsLoading, setIncotermsLoading] = React.useState(false);
const [paymentTermsLoading, setPaymentTermsLoading] = React.useState(false);
const [shippingLoading, setShippingLoading] = React.useState(false);
const [destinationLoading, setDestinationLoading] = React.useState(false);
-
+
// Popover 열림 상태
const [incotermsOpen, setIncotermsOpen] = React.useState(false);
const [paymentTermsOpen, setPaymentTermsOpen] = React.useState(false);
@@ -254,7 +254,7 @@ export function BatchUpdateConditionsDialog({
// 선택된 필드만 포함하여 conditions 객체 생성
const conditions: any = {};
-
+
if (fieldsToUpdate.currency && data.currency) {
conditions.currency = data.currency;
}
@@ -372,7 +372,7 @@ export function BatchUpdateConditionsDialog({
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
- 체크박스를 선택한 항목만 업데이트됩니다.
+ 체크박스를 선택한 항목만 업데이트됩니다.
선택하지 않은 항목은 기존 값이 유지됩니다.
</AlertDescription>
</Alert>
@@ -387,7 +387,7 @@ export function BatchUpdateConditionsDialog({
<div className="flex items-center gap-4">
<Checkbox
checked={fieldsToUpdate.currency}
- onCheckedChange={(checked) =>
+ onCheckedChange={(checked) =>
setFieldsToUpdate({ ...fieldsToUpdate, currency: !!checked })
}
/>
@@ -419,7 +419,13 @@ export function BatchUpdateConditionsDialog({
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput placeholder="통화 검색..." />
- <CommandList>
+ <CommandList
+ onWheel={(e) => {
+ e.stopPropagation(); // 이벤트 전파 차단
+ const target = e.currentTarget;
+ target.scrollTop += e.deltaY; // 직접 스크롤 처리
+ }}
+ >
<CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
<CommandGroup>
{currencies.map((currency) => (
@@ -454,7 +460,7 @@ export function BatchUpdateConditionsDialog({
<div className="flex items-center gap-4">
<Checkbox
checked={fieldsToUpdate.paymentTermsCode}
- onCheckedChange={(checked) =>
+ onCheckedChange={(checked) =>
setFieldsToUpdate({ ...fieldsToUpdate, paymentTermsCode: !!checked })
}
/>
@@ -496,7 +502,13 @@ export function BatchUpdateConditionsDialog({
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput placeholder="코드 또는 설명으로 검색..." />
- <CommandList>
+ <CommandList
+ onWheel={(e) => {
+ e.stopPropagation(); // 이벤트 전파 차단
+ const target = e.currentTarget;
+ target.scrollTop += e.deltaY; // 직접 스크롤 처리
+ }}
+ >
<CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
<CommandGroup>
{paymentTerms.map((term) => (
@@ -538,7 +550,7 @@ export function BatchUpdateConditionsDialog({
<Checkbox
className="mt-3"
checked={fieldsToUpdate.incoterms}
- onCheckedChange={(checked) =>
+ onCheckedChange={(checked) =>
setFieldsToUpdate({ ...fieldsToUpdate, incoterms: !!checked })
}
/>
@@ -581,7 +593,12 @@ export function BatchUpdateConditionsDialog({
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput placeholder="코드 또는 설명으로 검색..." />
- <CommandList>
+ <CommandList
+ onWheel={(e) => {
+ e.stopPropagation(); // 이벤트 전파 차단
+ const target = e.currentTarget;
+ target.scrollTop += e.deltaY; // 직접 스크롤 처리
+ }}>
<CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
<CommandGroup>
{incoterms.map((incoterm) => (
@@ -640,7 +657,7 @@ export function BatchUpdateConditionsDialog({
<div className="flex items-center gap-4">
<Checkbox
checked={fieldsToUpdate.deliveryDate}
- onCheckedChange={(checked) =>
+ onCheckedChange={(checked) =>
setFieldsToUpdate({ ...fieldsToUpdate, deliveryDate: !!checked })
}
/>
@@ -701,7 +718,7 @@ export function BatchUpdateConditionsDialog({
<div className="flex items-center gap-4">
<Checkbox
checked={fieldsToUpdate.contractDuration}
- onCheckedChange={(checked) =>
+ onCheckedChange={(checked) =>
setFieldsToUpdate({ ...fieldsToUpdate, contractDuration: !!checked })
}
/>
@@ -736,7 +753,7 @@ export function BatchUpdateConditionsDialog({
<div className="flex items-center gap-4">
<Checkbox
checked={fieldsToUpdate.taxCode}
- onCheckedChange={(checked) =>
+ onCheckedChange={(checked) =>
setFieldsToUpdate({ ...fieldsToUpdate, taxCode: !!checked })
}
/>
@@ -770,7 +787,7 @@ export function BatchUpdateConditionsDialog({
<Checkbox
className="mt-3"
checked={fieldsToUpdate.shipping}
- onCheckedChange={(checked) =>
+ onCheckedChange={(checked) =>
setFieldsToUpdate({ ...fieldsToUpdate, shipping: !!checked })
}
/>
@@ -813,7 +830,13 @@ export function BatchUpdateConditionsDialog({
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput placeholder="선적지 검색..." />
- <CommandList>
+ <CommandList
+ onWheel={(e) => {
+ e.stopPropagation(); // 이벤트 전파 차단
+ const target = e.currentTarget;
+ target.scrollTop += e.deltaY; // 직접 스크롤 처리
+ }}
+ >
<CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
<CommandGroup>
{shippingPlaces.map((place) => (
@@ -848,7 +871,7 @@ export function BatchUpdateConditionsDialog({
</FormItem>
)}
/>
-
+
<FormField
control={form.control}
name="placeOfDestination"
@@ -887,7 +910,13 @@ export function BatchUpdateConditionsDialog({
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput placeholder="도착지 검색..." />
- <CommandList>
+ <CommandList
+ onWheel={(e) => {
+ e.stopPropagation(); // 이벤트 전파 차단
+ const target = e.currentTarget;
+ target.scrollTop += e.deltaY; // 직접 스크롤 처리
+ }}
+ >
<CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
<CommandGroup>
{destinationPlaces.map((place) => (
@@ -937,7 +966,7 @@ export function BatchUpdateConditionsDialog({
<div className="flex items-center gap-4">
<Checkbox
checked={fieldsToUpdate.materialPrice}
- onCheckedChange={(checked) =>
+ onCheckedChange={(checked) =>
setFieldsToUpdate({ ...fieldsToUpdate, materialPrice: !!checked })
}
/>
@@ -973,7 +1002,7 @@ export function BatchUpdateConditionsDialog({
<div className="flex items-center gap-4">
<Checkbox
checked={fieldsToUpdate.sparepart}
- onCheckedChange={(checked) =>
+ onCheckedChange={(checked) =>
setFieldsToUpdate({ ...fieldsToUpdate, sparepart: !!checked })
}
/>
@@ -1028,7 +1057,7 @@ export function BatchUpdateConditionsDialog({
<div className="flex items-center gap-4">
<Checkbox
checked={fieldsToUpdate.first}
- onCheckedChange={(checked) =>
+ onCheckedChange={(checked) =>
setFieldsToUpdate({ ...fieldsToUpdate, first: !!checked })
}
/>
@@ -1086,7 +1115,7 @@ export function BatchUpdateConditionsDialog({
<DialogFooter className="p-6 pt-4 border-t">
<div className="flex items-center justify-between w-full">
<div className="text-sm text-muted-foreground">
- {getUpdateCount() > 0
+ {getUpdateCount() > 0
? `${getUpdateCount()}개 항목 선택됨`
: '변경할 항목을 선택하세요'
}
@@ -1100,12 +1129,12 @@ export function BatchUpdateConditionsDialog({
>
취소
</Button>
- <Button
+ <Button
type="submit"
disabled={isLoading || getUpdateCount() === 0}
>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
- {getUpdateCount() > 0
+ {getUpdateCount() > 0
? `${getUpdateCount()}개 항목 업데이트`
: '조건 업데이트'
}
diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx
index b6d42804..7f7afe14 100644
--- a/lib/rfq-last/vendor/rfq-vendor-table.tsx
+++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx
@@ -3,7 +3,7 @@
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
-import {
+import {
Plus,
Send,
Eye,
@@ -32,7 +32,7 @@ import { type ColumnDef } from "@tanstack/react-table";
import { Checkbox } from "@/components/ui/checkbox";
import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header";
import { ClientDataTable } from "@/components/client-data-table/data-table";
-import {
+import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
@@ -50,7 +50,9 @@ import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { AddVendorDialog } from "./add-vendor-dialog";
import { BatchUpdateConditionsDialog } from "./batch-update-conditions-dialog";
+import { SendRfqDialog } from "./send-rfq-dialog";
// import { VendorDetailDialog } from "./vendor-detail-dialog";
+// import { sendRfqToVendors } from "@/app/actions/rfq/send-rfq.action";
// 타입 정의
interface RfqDetail {
@@ -59,9 +61,10 @@ interface RfqDetail {
vendorName: string | null;
vendorCode: string | null;
vendorCountry: string | null;
- vendorCategory?: string | null; // 업체분류
- vendorGrade?: string | null; // AVL 등급
- basicContract?: string | null; // 기본계약
+ vendorEmail?: string | null;
+ vendorCategory?: string | null;
+ vendorGrade?: string | null;
+ basicContract?: string | null;
shortList: boolean;
currency: string | null;
paymentTermsCode: string | null;
@@ -97,11 +100,42 @@ interface VendorResponse {
attachmentCount?: number;
}
+// Props 타입 정의 (중복 제거하고 하나로 통합)
interface RfqVendorTableProps {
rfqId: number;
rfqCode?: string;
rfqDetails: RfqDetail[];
vendorResponses: VendorResponse[];
+ // 추가 props
+ rfqInfo?: {
+ rfqTitle: string;
+ rfqType: string;
+ projectCode?: string;
+ projectName?: string;
+ picName?: string;
+ picCode?: string;
+ picTeam?: string;
+ packageNo?: string;
+ packageName?: string;
+ designPicName?: string;
+ designTeam?: string;
+ materialGroup?: string;
+ materialGroupDesc?: string;
+ dueDate: Date;
+ quotationType?: string;
+ evaluationApply?: boolean;
+ contractType?: string;
+ };
+ attachments?: Array<{
+ id: number;
+ attachmentType: string;
+ serialNo: string;
+ currentRevision: string;
+ description?: string;
+ fileName?: string;
+ fileSize?: number;
+ uploadedAt?: Date;
+ }>;
}
// 상태별 아이콘 반환
@@ -158,43 +192,94 @@ export function RfqVendorTable({
rfqCode,
rfqDetails,
vendorResponses,
+ rfqInfo,
+ attachments,
}: RfqVendorTableProps) {
const [isRefreshing, setIsRefreshing] = React.useState(false);
const [selectedRows, setSelectedRows] = React.useState<any[]>([]);
const [isAddDialogOpen, setIsAddDialogOpen] = React.useState(false);
const [isBatchUpdateOpen, setIsBatchUpdateOpen] = React.useState(false);
const [selectedVendor, setSelectedVendor] = React.useState<any | null>(null);
+ const [isSendDialogOpen, setIsSendDialogOpen] = React.useState(false);
// 데이터 병합
const mergedData = React.useMemo(
() => mergeVendorData(rfqDetails, vendorResponses, rfqCode),
[rfqDetails, vendorResponses, rfqCode]
);
-
+
+ // 일괄 발송 핸들러
+ const handleBulkSend = React.useCallback(async () => {
+ if (selectedRows.length === 0) {
+ toast.warning("발송할 벤더를 선택해주세요.");
+ return;
+ }
+
+ // 다이얼로그 열기
+ setIsSendDialogOpen(true);
+ }, [selectedRows]);
+
+ // RFQ 발송 핸들러
+ const handleSendRfq = React.useCallback(async (data: {
+ vendors: Array<{
+ vendorId: number;
+ vendorName: string;
+ vendorCode?: string | null;
+ vendorCountry?: string | null;
+ vendorEmail?: string | null;
+ currency?: string | null;
+ additionalRecipients: string[];
+ }>;
+ attachments: number[];
+ message?: string;
+ }) => {
+ try {
+ // 서버 액션 호출
+ // const result = await sendRfqToVendors({
+ // rfqId,
+ // rfqCode,
+ // vendors: data.vendors,
+ // attachmentIds: data.attachments,
+ // message: data.message,
+ // });
+
+ // 임시 성공 처리
+ console.log("RFQ 발송 데이터:", data);
+
+ // 성공 후 처리
+ setSelectedRows([]);
+ toast.success(`${data.vendors.length}개 업체에 RFQ를 발송했습니다.`);
+ } catch (error) {
+ console.error("RFQ 발송 실패:", error);
+ toast.error("RFQ 발송에 실패했습니다.");
+ throw error;
+ }
+ }, [rfqId, rfqCode]);
+
// 액션 처리
const handleAction = React.useCallback(async (action: string, vendor: any) => {
switch (action) {
case "view":
setSelectedVendor(vendor);
break;
-
+
case "send":
// RFQ 발송 로직
toast.info(`${vendor.vendorName}에게 RFQ를 발송합니다.`);
break;
-
+
case "edit":
// 수정 로직
toast.info("수정 기능은 준비중입니다.");
break;
-
+
case "delete":
// 삭제 로직
if (confirm(`${vendor.vendorName}을(를) 삭제하시겠습니까?`)) {
toast.success(`${vendor.vendorName}이(가) 삭제되었습니다.`);
}
break;
-
+
case "response-detail":
// 회신 상세 보기
toast.info(`${vendor.vendorName}의 회신 상세를 확인합니다.`);
@@ -202,21 +287,6 @@ export function RfqVendorTable({
}
}, []);
- // 선택된 벤더들에게 일괄 발송
- const handleBulkSend = React.useCallback(async () => {
- if (selectedRows.length === 0) {
- toast.warning("발송할 벤더를 선택해주세요.");
- return;
- }
-
- const vendorNames = selectedRows.map(r => r.vendorName).join(", ");
- if (confirm(`선택한 ${selectedRows.length}개 벤더에게 RFQ를 발송하시겠습니까?\n\n${vendorNames}`)) {
- toast.success(`${selectedRows.length}개 벤더에게 RFQ를 발송했습니다.`);
- setSelectedRows([]);
- }
- }, [selectedRows]);
-
-
// 컬럼 정의 (확장된 버전)
const columns: ColumnDef<any>[] = React.useMemo(() => [
{
@@ -251,19 +321,6 @@ export function RfqVendorTable({
},
size: 120,
},
- // {
- // accessorKey: "response.responseVersion",
- // header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="Rev" />,
- // cell: ({ row }) => {
- // const version = row.original.response?.responseVersion;
- // return version ? (
- // <Badge variant="outline" className="font-mono">v{version}</Badge>
- // ) : (
- // <span className="text-muted-foreground">-</span>
- // );
- // },
- // size: 60,
- // },
{
accessorKey: "vendorName",
header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="협력업체정보" />,
@@ -307,14 +364,14 @@ export function RfqVendorTable({
cell: ({ row }) => {
const grade = row.original.vendorGrade;
if (!grade) return <span className="text-muted-foreground">-</span>;
-
+
const gradeColor = {
"A": "text-green-600",
- "B": "text-blue-600",
+ "B": "text-blue-600",
"C": "text-yellow-600",
"D": "text-red-600",
}[grade] || "text-gray-600";
-
+
return <span className={cn("font-semibold", gradeColor)}>{grade}</span>;
},
size: 100,
@@ -373,15 +430,15 @@ export function RfqVendorTable({
cell: ({ row }) => {
const deliveryDate = row.original.deliveryDate;
const contractDuration = row.original.contractDuration;
-
+
return (
<div className="flex flex-col gap-0.5">
- {deliveryDate && (
+ {deliveryDate && !rfqCode?.startsWith("F") && (
<span className="text-xs">
{format(new Date(deliveryDate), "yyyy-MM-dd")}
</span>
)}
- {contractDuration && (
+ {contractDuration && rfqCode?.startsWith("F") && (
<span className="text-xs text-muted-foreground">{contractDuration}</span>
)}
{!deliveryDate && !contractDuration && (
@@ -398,7 +455,7 @@ export function RfqVendorTable({
cell: ({ row }) => {
const code = row.original.incotermsCode;
const detail = row.original.incotermsDetail;
-
+
return (
<TooltipProvider>
<Tooltip>
@@ -459,7 +516,7 @@ export function RfqVendorTable({
if (conditions === "-") {
return <span className="text-muted-foreground">-</span>;
}
-
+
const items = conditions.split(", ");
return (
<div className="flex flex-wrap gap-1">
@@ -479,11 +536,11 @@ export function RfqVendorTable({
cell: ({ row }) => {
const submittedAt = row.original.response?.submittedAt;
const status = row.original.response?.status;
-
+
if (!submittedAt) {
return <Badge variant="outline">미참여</Badge>;
}
-
+
return (
<div className="flex flex-col gap-0.5">
<Badge variant="default" className="text-xs">참여</Badge>
@@ -500,11 +557,11 @@ export function RfqVendorTable({
header: "회신상세",
cell: ({ row }) => {
const hasResponse = !!row.original.response?.submittedAt;
-
+
if (!hasResponse) {
return <span className="text-muted-foreground text-xs">-</span>;
}
-
+
return (
<Button
variant="ghost"
@@ -565,7 +622,7 @@ export function RfqVendorTable({
cell: ({ row }) => {
const vendor = row.original;
const hasResponse = !!vendor.response;
-
+
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -592,7 +649,7 @@ export function RfqVendorTable({
조건 수정
</DropdownMenuItem>
<DropdownMenuSeparator />
- <DropdownMenuItem
+ <DropdownMenuItem
onClick={() => handleAction("delete", vendor)}
className="text-red-600"
>
@@ -605,7 +662,7 @@ export function RfqVendorTable({
},
size: 60,
},
- ], [handleAction]);
+ ], [handleAction, rfqCode]);
const advancedFilterFields: DataTableAdvancedFilterField<any>[] = [
{ id: "vendorName", label: "벤더명", type: "text" },
@@ -644,6 +701,41 @@ export function RfqVendorTable({
}));
}, [selectedRows]);
+ // 선택된 벤더 정보 (Send용)
+ const selectedVendorsForSend = React.useMemo(() => {
+ return selectedRows.map(row => ({
+ vendorId: row.vendorId,
+ vendorName: row.vendorName,
+ vendorCode: row.vendorCode,
+ vendorCountry: row.vendorCountry,
+ vendorEmail: row.vendorEmail || `vendor${row.vendorId}@example.com`,
+ currency: row.currency,
+ }));
+ }, [selectedRows]);
+
+ // RFQ 정보 준비 (다이얼로그용)
+ const rfqInfoForDialog = React.useMemo(() => {
+ // props로 받은 rfqInfo 사용, 없으면 기본값
+ return rfqInfo || {
+ rfqCode: rfqCode || '',
+ rfqTitle: '테스트 RFQ',
+ rfqType: '정기견적',
+ projectCode: 'PN003',
+ projectName: 'PETRONAS ZLNG nearshore project',
+ picName: '김*종',
+ picCode: '86D',
+ picTeam: '해양구매팀(해양구매1)',
+ packageNo: 'MM03',
+ packageName: 'Deck Machinery',
+ designPicName: '이*진',
+ designTeam: '전장설계팀 (전장기기시스템)',
+ materialGroup: 'BE2101',
+ materialGroupDesc: 'Combined Windlass & Mooring Wi',
+ dueDate: new Date('2025-07-05'),
+ evaluationApply: true,
+ };
+ }, [rfqInfo, rfqCode]);
+
// 추가 액션 버튼들
const additionalActions = React.useMemo(() => (
<div className="flex items-center gap-2">
@@ -732,6 +824,16 @@ export function RfqVendorTable({
}}
/>
+ {/* RFQ 발송 다이얼로그 */}
+ <SendRfqDialog
+ open={isSendDialogOpen}
+ onOpenChange={setIsSendDialogOpen}
+ selectedVendors={selectedVendorsForSend}
+ rfqInfo={rfqInfoForDialog}
+ attachments={attachments || []}
+ onSend={handleSendRfq}
+ />
+
{/* 벤더 상세 다이얼로그 */}
{/* {selectedVendor && (
<VendorDetailDialog
diff --git a/lib/rfq-last/vendor/send-rfq-dialog.tsx b/lib/rfq-last/vendor/send-rfq-dialog.tsx
new file mode 100644
index 00000000..dc420cad
--- /dev/null
+++ b/lib/rfq-last/vendor/send-rfq-dialog.tsx
@@ -0,0 +1,578 @@
+"use client";
+
+import * as React from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Separator } from "@/components/ui/separator";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+ Send,
+ Building2,
+ User,
+ Calendar,
+ Package,
+ FileText,
+ Plus,
+ X,
+ Paperclip,
+ Download,
+ Mail,
+ Users,
+ AlertCircle,
+ Info,
+ File,
+ CheckCircle,
+ RefreshCw
+} from "lucide-react";
+import { format } from "date-fns";
+import { ko } from "date-fns/locale";
+import { toast } from "sonner";
+import { cn } from "@/lib/utils";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import {
+ Alert,
+ AlertDescription,
+} from "@/components/ui/alert";
+
+// 타입 정의
+interface Vendor {
+ vendorId: number;
+ vendorName: string;
+ vendorCode?: string | null;
+ vendorCountry?: string | null;
+ vendorEmail?: string | null;
+ currency?: string | null;
+}
+
+interface Attachment {
+ id: number;
+ attachmentType: string;
+ serialNo: string;
+ currentRevision: string;
+ description?: string;
+ fileName?: string;
+ fileSize?: number;
+ uploadedAt?: Date;
+}
+
+interface RfqInfo {
+ rfqCode: string;
+ rfqTitle: string;
+ rfqType: string;
+ projectCode?: string;
+ projectName?: string;
+ picName?: string;
+ picCode?: string;
+ picTeam?: string;
+ packageNo?: string;
+ packageName?: string;
+ designPicName?: string;
+ designTeam?: string;
+ materialGroup?: string;
+ materialGroupDesc?: string;
+ dueDate: Date;
+ quotationType?: string;
+ evaluationApply?: boolean;
+ contractType?: string;
+}
+
+interface VendorWithRecipients extends Vendor {
+ additionalRecipients: string[];
+}
+
+interface SendRfqDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ selectedVendors: Vendor[];
+ rfqInfo: RfqInfo;
+ attachments?: Attachment[];
+ onSend: (data: {
+ vendors: VendorWithRecipients[];
+ attachments: number[];
+ message?: string;
+ }) => Promise<void>;
+}
+
+// 첨부파일 타입별 아이콘
+const getAttachmentIcon = (type: string) => {
+ switch (type.toLowerCase()) {
+ case "technical":
+ return <FileText className="h-4 w-4 text-blue-500" />;
+ case "commercial":
+ return <File className="h-4 w-4 text-green-500" />;
+ case "drawing":
+ return <Package className="h-4 w-4 text-purple-500" />;
+ default:
+ return <Paperclip className="h-4 w-4 text-gray-500" />;
+ }
+};
+
+// 파일 크기 포맷
+const formatFileSize = (bytes?: number) => {
+ if (!bytes) return "0 KB";
+ const kb = bytes / 1024;
+ const mb = kb / 1024;
+ if (mb >= 1) return `${mb.toFixed(2)} MB`;
+ return `${kb.toFixed(2)} KB`;
+};
+
+export function SendRfqDialog({
+ open,
+ onOpenChange,
+ selectedVendors,
+ rfqInfo,
+ attachments = [],
+ onSend,
+}: SendRfqDialogProps) {
+ const [isSending, setIsSending] = React.useState(false);
+ const [vendorsWithRecipients, setVendorsWithRecipients] = React.useState<VendorWithRecipients[]>([]);
+ const [selectedAttachments, setSelectedAttachments] = React.useState<number[]>([]);
+ const [additionalMessage, setAdditionalMessage] = React.useState("");
+
+ // 초기화
+ React.useEffect(() => {
+ if (open && selectedVendors.length > 0) {
+ setVendorsWithRecipients(
+ selectedVendors.map(v => ({
+ ...v,
+ additionalRecipients: []
+ }))
+ );
+ // 모든 첨부파일 선택
+ setSelectedAttachments(attachments.map(a => a.id));
+ }
+ }, [open, selectedVendors, attachments]);
+
+ // 추가 수신처 이메일 추가
+ const handleAddRecipient = (vendorId: number, email: string) => {
+ if (!email) return;
+
+ // 이메일 유효성 검사
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ if (!emailRegex.test(email)) {
+ toast.error("올바른 이메일 형식이 아닙니다.");
+ return;
+ }
+
+ setVendorsWithRecipients(prev =>
+ prev.map(v =>
+ v.vendorId === vendorId
+ ? { ...v, additionalRecipients: [...v.additionalRecipients, email] }
+ : v
+ )
+ );
+ };
+
+ // 추가 수신처 이메일 제거
+ const handleRemoveRecipient = (vendorId: number, index: number) => {
+ setVendorsWithRecipients(prev =>
+ prev.map(v =>
+ v.vendorId === vendorId
+ ? {
+ ...v,
+ additionalRecipients: v.additionalRecipients.filter((_, i) => i !== index)
+ }
+ : v
+ )
+ );
+ };
+
+ // 첨부파일 선택 토글
+ const toggleAttachment = (attachmentId: number) => {
+ setSelectedAttachments(prev =>
+ prev.includes(attachmentId)
+ ? prev.filter(id => id !== attachmentId)
+ : [...prev, attachmentId]
+ );
+ };
+
+ // 전송 처리
+ const handleSend = async () => {
+ try {
+ setIsSending(true);
+
+ // 유효성 검사
+ if (selectedAttachments.length === 0) {
+ toast.warning("최소 하나 이상의 첨부파일을 선택해주세요.");
+ return;
+ }
+
+ await onSend({
+ vendors: vendorsWithRecipients,
+ attachments: selectedAttachments,
+ message: additionalMessage,
+ });
+
+ toast.success(`${vendorsWithRecipients.length}개 업체에 RFQ를 발송했습니다.`);
+ onOpenChange(false);
+ } catch (error) {
+ console.error("RFQ 발송 실패:", error);
+ toast.error("RFQ 발송에 실패했습니다.");
+ } finally {
+ setIsSending(false);
+ }
+ };
+
+ // 총 수신자 수 계산
+ const totalRecipientCount = React.useMemo(() => {
+ return vendorsWithRecipients.reduce((acc, v) =>
+ acc + 1 + v.additionalRecipients.length, 0
+ );
+ }, [vendorsWithRecipients]);
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <Send className="h-5 w-5" />
+ RFQ 일괄 발송
+ </DialogTitle>
+ <DialogDescription>
+ 선택한 {selectedVendors.length}개 업체에 RFQ를 발송합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <ScrollArea className="flex-1 max-h-[calc(90vh-200px)]">
+ <div className="space-y-6 pr-4">
+ {/* RFQ 정보 섹션 */}
+ <div className="space-y-4">
+ <div className="flex items-center gap-2 text-sm font-medium">
+ <Info className="h-4 w-4" />
+ RFQ 정보
+ </div>
+
+ <div className="bg-muted/50 rounded-lg p-4 space-y-3">
+ {/* 프로젝트 정보 */}
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div className="flex items-start gap-2">
+ <span className="text-muted-foreground min-w-[80px]">프로젝트:</span>
+ <span className="font-medium">
+ {rfqInfo.projectCode || "PN003"} ({rfqInfo.projectName || "PETRONAS ZLNG nearshore project"})
+ </span>
+ </div>
+ <div className="flex items-start gap-2">
+ <span className="text-muted-foreground min-w-[80px]">견적번호:</span>
+ <span className="font-medium font-mono">{rfqInfo.rfqCode}</span>
+ </div>
+ </div>
+
+ {/* 담당자 정보 */}
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div className="flex items-start gap-2">
+ <span className="text-muted-foreground min-w-[80px]">구매담당:</span>
+ <span>
+ {rfqInfo.picName || "김*종"} ({rfqInfo.picCode || "86D"}) {rfqInfo.picTeam || "해양구매팀(해양구매1)"}
+ </span>
+ </div>
+ <div className="flex items-start gap-2">
+ <span className="text-muted-foreground min-w-[80px]">설계담당:</span>
+ <span>
+ {rfqInfo.designPicName || "이*진"} {rfqInfo.designTeam || "전장설계팀 (전장기기시스템)"}
+ </span>
+ </div>
+ </div>
+
+ {/* PKG 및 자재 정보 */}
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div className="flex items-start gap-2">
+ <span className="text-muted-foreground min-w-[80px]">PKG 정보:</span>
+ <span>
+ {rfqInfo.packageNo || "MM03"} ({rfqInfo.packageName || "Deck Machinery"})
+ </span>
+ </div>
+ <div className="flex items-start gap-2">
+ <span className="text-muted-foreground min-w-[80px]">자재그룹:</span>
+ <span>
+ {rfqInfo.materialGroup || "BE2101"} ({rfqInfo.materialGroupDesc || "Combined Windlass & Mooring Wi"})
+ </span>
+ </div>
+ </div>
+
+ {/* 견적 정보 */}
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div className="flex items-start gap-2">
+ <span className="text-muted-foreground min-w-[80px]">견적마감일:</span>
+ <span className="font-medium text-red-600">
+ {format(rfqInfo.dueDate, "yyyy.MM.dd", { locale: ko })}
+ </span>
+ </div>
+ <div className="flex items-start gap-2">
+ <span className="text-muted-foreground min-w-[80px]">평가적용:</span>
+ <Badge variant={rfqInfo.evaluationApply ? "default" : "outline"}>
+ {rfqInfo.evaluationApply ? "Y" : "N"}
+ </Badge>
+ </div>
+ </div>
+
+ {/* 견적명 */}
+ <div className="flex items-start gap-2 text-sm">
+ <span className="text-muted-foreground min-w-[80px]">견적명:</span>
+ <span className="font-medium">{rfqInfo.rfqTitle}</span>
+ </div>
+
+ {/* 계약구분 (일반견적일 때만) */}
+ {rfqInfo.rfqType === "일반견적" && (
+ <div className="flex items-start gap-2 text-sm">
+ <span className="text-muted-foreground min-w-[80px]">계약구분:</span>
+ <span>{rfqInfo.contractType || "-"}</span>
+ </div>
+ )}
+ </div>
+ </div>
+
+ <Separator />
+
+ {/* 첨부파일 섹션 */}
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2 text-sm font-medium">
+ <Paperclip className="h-4 w-4" />
+ 첨부파일 ({selectedAttachments.length}/{attachments.length})
+ </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => {
+ if (selectedAttachments.length === attachments.length) {
+ setSelectedAttachments([]);
+ } else {
+ setSelectedAttachments(attachments.map(a => a.id));
+ }
+ }}
+ >
+ {selectedAttachments.length === attachments.length ? "전체 해제" : "전체 선택"}
+ </Button>
+ </div>
+
+ <div className="border rounded-lg divide-y">
+ {attachments.length > 0 ? (
+ attachments.map((attachment) => (
+ <div
+ key={attachment.id}
+ className="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
+ >
+ <div className="flex items-center gap-3">
+ <Checkbox
+ checked={selectedAttachments.includes(attachment.id)}
+ onCheckedChange={() => toggleAttachment(attachment.id)}
+ />
+ {getAttachmentIcon(attachment.attachmentType)}
+ <div>
+ <div className="flex items-center gap-2">
+ <span className="text-sm font-medium">
+ {attachment.fileName || `${attachment.attachmentType}_${attachment.serialNo}`}
+ </span>
+ <Badge variant="outline" className="text-xs">
+ {attachment.currentRevision}
+ </Badge>
+ </div>
+ {attachment.description && (
+ <p className="text-xs text-muted-foreground mt-0.5">
+ {attachment.description}
+ </p>
+ )}
+ </div>
+ </div>
+ <div className="flex items-center gap-2">
+ <span className="text-xs text-muted-foreground">
+ {formatFileSize(attachment.fileSize)}
+ </span>
+ </div>
+ </div>
+ ))
+ ) : (
+ <div className="p-8 text-center text-muted-foreground">
+ <Paperclip className="h-8 w-8 mx-auto mb-2 opacity-50" />
+ <p className="text-sm">첨부파일이 없습니다.</p>
+ </div>
+ )}
+ </div>
+ </div>
+
+ <Separator />
+
+ {/* 수신 업체 섹션 */}
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2 text-sm font-medium">
+ <Building2 className="h-4 w-4" />
+ 수신 업체 ({selectedVendors.length})
+ </div>
+ <Badge variant="outline" className="flex items-center gap-1">
+ <Users className="h-3 w-3" />
+ 총 {totalRecipientCount}명
+ </Badge>
+ </div>
+
+ <div className="space-y-3">
+ {vendorsWithRecipients.map((vendor, index) => (
+ <div
+ key={vendor.vendorId}
+ className="border rounded-lg p-4 space-y-3"
+ >
+ {/* 업체 정보 */}
+ <div className="flex items-start justify-between">
+ <div className="flex items-center gap-3">
+ <div className="flex items-center justify-center w-8 h-8 rounded-full bg-primary/10 text-primary text-sm font-medium">
+ {index + 1}
+ </div>
+ <div>
+ <div className="flex items-center gap-2">
+ <span className="font-medium">{vendor.vendorName}</span>
+ <Badge variant="outline" className="text-xs">
+ {vendor.vendorCountry}
+ </Badge>
+ </div>
+ {vendor.vendorCode && (
+ <span className="text-xs text-muted-foreground">
+ {vendor.vendorCode}
+ </span>
+ )}
+ </div>
+ </div>
+ <Badge variant="secondary">
+ 주 수신: {vendor.vendorEmail || "vendor@example.com"}
+ </Badge>
+ </div>
+
+ {/* 추가 수신처 */}
+ <div className="pl-11 space-y-2">
+ <div className="flex items-center gap-2">
+ <Label className="text-xs text-muted-foreground">추가 수신처:</Label>
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger>
+ <AlertCircle className="h-3 w-3 text-muted-foreground" />
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>참조로 RFQ를 받을 추가 이메일 주소를 입력하세요.</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+
+ {/* 추가된 이메일 목록 */}
+ <div className="flex flex-wrap gap-2">
+ {vendor.additionalRecipients.map((email, idx) => (
+ <Badge
+ key={idx}
+ variant="outline"
+ className="flex items-center gap-1 pr-1"
+ >
+ <Mail className="h-3 w-3" />
+ {email}
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-4 w-4 p-0 hover:bg-transparent"
+ onClick={() => handleRemoveRecipient(vendor.vendorId, idx)}
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ </Badge>
+ ))}
+ </div>
+
+ {/* 이메일 입력 필드 */}
+ <div className="flex gap-2">
+ <Input
+ type="email"
+ placeholder="추가 수신자 이메일 입력"
+ className="h-8 text-sm"
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ const input = e.target as HTMLInputElement;
+ handleAddRecipient(vendor.vendorId, input.value);
+ input.value = "";
+ }
+ }}
+ />
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={(e) => {
+ const input = (e.currentTarget.previousElementSibling as HTMLInputElement);
+ handleAddRecipient(vendor.vendorId, input.value);
+ input.value = "";
+ }}
+ >
+ <Plus className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+
+ <Separator />
+
+ {/* 추가 메시지 (선택사항) */}
+ <div className="space-y-2">
+ <Label htmlFor="message" className="text-sm font-medium">
+ 추가 메시지 (선택사항)
+ </Label>
+ <textarea
+ id="message"
+ className="w-full min-h-[80px] p-3 text-sm border rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-primary"
+ placeholder="업체에 전달할 추가 메시지를 입력하세요..."
+ value={additionalMessage}
+ onChange={(e) => setAdditionalMessage(e.target.value)}
+ />
+ </div>
+ </div>
+ </ScrollArea>
+
+ <DialogFooter className="flex-shrink-0">
+ <Alert className="mr-auto max-w-md">
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription className="text-xs">
+ 발송 후에는 취소할 수 없습니다. 발송 내용을 다시 한번 확인해주세요.
+ </AlertDescription>
+ </Alert>
+ <Button
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isSending}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={handleSend}
+ disabled={isSending || selectedAttachments.length === 0}
+ >
+ {isSending ? (
+ <>
+ <RefreshCw className="h-4 w-4 mr-2 animate-spin" />
+ 발송중...
+ </>
+ ) : (
+ <>
+ <Send className="h-4 w-4 mr-2" />
+ RFQ 발송
+ </>
+ )}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+} \ No newline at end of file