From f93493f68c9f368e10f1c3379f1c1384068e3b14 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 8 Sep 2025 10:29:19 +0000 Subject: (대표님, 최겸) rfqLast, bidding, prequote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/rfq-last/service.ts | 480 ++++++++++++++++- .../vendor/batch-update-conditions-dialog.tsx | 81 ++- lib/rfq-last/vendor/rfq-vendor-table.tsx | 208 ++++++-- lib/rfq-last/vendor/send-rfq-dialog.tsx | 578 +++++++++++++++++++++ 4 files changed, 1266 insertions(+), 81 deletions(-) create mode 100644 lib/rfq-last/vendor/send-rfq-dialog.tsx (limited to 'lib/rfq-last') 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 { + 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`COUNT(*)` }) + .from(rfqPrItems) + .where(eq(rfqPrItems.rfqsLastId, rfqId)); + + const majorItemsCount = await db + .select({ count: sql`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([]); const [paymentTerms, setPaymentTerms] = React.useState([]); const [shippingPlaces, setShippingPlaces] = React.useState([]); const [destinationPlaces, setDestinationPlaces] = React.useState([]); - + // 로딩 상태 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({ - 체크박스를 선택한 항목만 업데이트됩니다. + 체크박스를 선택한 항목만 업데이트됩니다. 선택하지 않은 항목은 기존 값이 유지됩니다. @@ -387,7 +387,7 @@ export function BatchUpdateConditionsDialog({
+ onCheckedChange={(checked) => setFieldsToUpdate({ ...fieldsToUpdate, currency: !!checked }) } /> @@ -419,7 +419,13 @@ export function BatchUpdateConditionsDialog({ - + { + e.stopPropagation(); // 이벤트 전파 차단 + const target = e.currentTarget; + target.scrollTop += e.deltaY; // 직접 스크롤 처리 + }} + > 검색 결과가 없습니다. {currencies.map((currency) => ( @@ -454,7 +460,7 @@ export function BatchUpdateConditionsDialog({
+ onCheckedChange={(checked) => setFieldsToUpdate({ ...fieldsToUpdate, paymentTermsCode: !!checked }) } /> @@ -496,7 +502,13 @@ export function BatchUpdateConditionsDialog({ - + { + e.stopPropagation(); // 이벤트 전파 차단 + const target = e.currentTarget; + target.scrollTop += e.deltaY; // 직접 스크롤 처리 + }} + > 검색 결과가 없습니다. {paymentTerms.map((term) => ( @@ -538,7 +550,7 @@ export function BatchUpdateConditionsDialog({ + onCheckedChange={(checked) => setFieldsToUpdate({ ...fieldsToUpdate, incoterms: !!checked }) } /> @@ -581,7 +593,12 @@ export function BatchUpdateConditionsDialog({ - + { + e.stopPropagation(); // 이벤트 전파 차단 + const target = e.currentTarget; + target.scrollTop += e.deltaY; // 직접 스크롤 처리 + }}> 검색 결과가 없습니다. {incoterms.map((incoterm) => ( @@ -640,7 +657,7 @@ export function BatchUpdateConditionsDialog({
+ onCheckedChange={(checked) => setFieldsToUpdate({ ...fieldsToUpdate, deliveryDate: !!checked }) } /> @@ -701,7 +718,7 @@ export function BatchUpdateConditionsDialog({
+ onCheckedChange={(checked) => setFieldsToUpdate({ ...fieldsToUpdate, contractDuration: !!checked }) } /> @@ -736,7 +753,7 @@ export function BatchUpdateConditionsDialog({
+ onCheckedChange={(checked) => setFieldsToUpdate({ ...fieldsToUpdate, taxCode: !!checked }) } /> @@ -770,7 +787,7 @@ export function BatchUpdateConditionsDialog({ + onCheckedChange={(checked) => setFieldsToUpdate({ ...fieldsToUpdate, shipping: !!checked }) } /> @@ -813,7 +830,13 @@ export function BatchUpdateConditionsDialog({ - + { + e.stopPropagation(); // 이벤트 전파 차단 + const target = e.currentTarget; + target.scrollTop += e.deltaY; // 직접 스크롤 처리 + }} + > 검색 결과가 없습니다. {shippingPlaces.map((place) => ( @@ -848,7 +871,7 @@ export function BatchUpdateConditionsDialog({ )} /> - + - + { + e.stopPropagation(); // 이벤트 전파 차단 + const target = e.currentTarget; + target.scrollTop += e.deltaY; // 직접 스크롤 처리 + }} + > 검색 결과가 없습니다. {destinationPlaces.map((place) => ( @@ -937,7 +966,7 @@ export function BatchUpdateConditionsDialog({
+ onCheckedChange={(checked) => setFieldsToUpdate({ ...fieldsToUpdate, materialPrice: !!checked }) } /> @@ -973,7 +1002,7 @@ export function BatchUpdateConditionsDialog({
+ onCheckedChange={(checked) => setFieldsToUpdate({ ...fieldsToUpdate, sparepart: !!checked }) } /> @@ -1028,7 +1057,7 @@ export function BatchUpdateConditionsDialog({
+ onCheckedChange={(checked) => setFieldsToUpdate({ ...fieldsToUpdate, first: !!checked }) } /> @@ -1086,7 +1115,7 @@ export function BatchUpdateConditionsDialog({
- {getUpdateCount() > 0 + {getUpdateCount() > 0 ? `${getUpdateCount()}개 항목 선택됨` : '변경할 항목을 선택하세요' } @@ -1100,12 +1129,12 @@ export function BatchUpdateConditionsDialog({ > 취소 - +
+ +
+ {attachments.length > 0 ? ( + attachments.map((attachment) => ( +
+
+ toggleAttachment(attachment.id)} + /> + {getAttachmentIcon(attachment.attachmentType)} +
+
+ + {attachment.fileName || `${attachment.attachmentType}_${attachment.serialNo}`} + + + {attachment.currentRevision} + +
+ {attachment.description && ( +

+ {attachment.description} +

+ )} +
+
+
+ + {formatFileSize(attachment.fileSize)} + +
+
+ )) + ) : ( +
+ +

첨부파일이 없습니다.

+
+ )} +
+
+ + + + {/* 수신 업체 섹션 */} +
+
+
+ + 수신 업체 ({selectedVendors.length}) +
+ + + 총 {totalRecipientCount}명 + +
+ +
+ {vendorsWithRecipients.map((vendor, index) => ( +
+ {/* 업체 정보 */} +
+
+
+ {index + 1} +
+
+
+ {vendor.vendorName} + + {vendor.vendorCountry} + +
+ {vendor.vendorCode && ( + + {vendor.vendorCode} + + )} +
+
+ + 주 수신: {vendor.vendorEmail || "vendor@example.com"} + +
+ + {/* 추가 수신처 */} +
+
+ + + + + + + +

참조로 RFQ를 받을 추가 이메일 주소를 입력하세요.

+
+
+
+
+ + {/* 추가된 이메일 목록 */} +
+ {vendor.additionalRecipients.map((email, idx) => ( + + + {email} + + + ))} +
+ + {/* 이메일 입력 필드 */} +
+ { + if (e.key === "Enter") { + e.preventDefault(); + const input = e.target as HTMLInputElement; + handleAddRecipient(vendor.vendorId, input.value); + input.value = ""; + } + }} + /> + +
+
+
+ ))} +
+
+ + + + {/* 추가 메시지 (선택사항) */} +
+ +