From 2650b7c0bb0ea12b68a58c0439f72d61df04b2f1 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Fri, 25 Jul 2025 07:51:15 +0000 Subject: (대표님) 정기평가 대상, 미들웨어 수정, nextauth 토큰 처리 개선, GTC 등 (최겸) 기술영업 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/techsales-rfq/repository.ts | 1 + lib/techsales-rfq/service.ts | 493 ++++++++++++++++----- .../quotation-contacts-view-dialog.tsx | 6 + .../detail-table/quotation-history-dialog.tsx | 163 ++++--- .../table/detail-table/rfq-detail-column.tsx | 18 + .../table/detail-table/rfq-detail-table.tsx | 4 +- .../detail-table/vendor-communication-drawer.tsx | 6 +- .../vendor-contact-selection-dialog.tsx | 6 + lib/techsales-rfq/table/rfq-filter-sheet.tsx | 135 +++--- lib/techsales-rfq/table/rfq-table-column.tsx | 33 ++ lib/techsales-rfq/table/rfq-table.tsx | 28 +- lib/techsales-rfq/table/update-rfq-sheet.tsx | 267 +++++++++++ .../vendor-response/detail/project-info-tab.tsx | 4 +- .../detail/quotation-response-tab.tsx | 17 +- .../table/vendor-quotations-table-columns.tsx | 38 +- 15 files changed, 935 insertions(+), 284 deletions(-) create mode 100644 lib/techsales-rfq/table/update-rfq-sheet.tsx (limited to 'lib/techsales-rfq') diff --git a/lib/techsales-rfq/repository.ts b/lib/techsales-rfq/repository.ts index 07c9ddf8..abf831c1 100644 --- a/lib/techsales-rfq/repository.ts +++ b/lib/techsales-rfq/repository.ts @@ -260,6 +260,7 @@ export async function selectTechSalesVendorQuotationsWithJoin( validUntil: techSalesVendorQuotations.validUntil, status: techSalesVendorQuotations.status, remark: techSalesVendorQuotations.remark, + quotationVersion: techSalesVendorQuotations.quotationVersion, rejectionReason: techSalesVendorQuotations.rejectionReason, // 날짜 정보 diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts index 44537876..afbd2f55 100644 --- a/lib/techsales-rfq/service.ts +++ b/lib/techsales-rfq/service.ts @@ -33,6 +33,7 @@ import { getServerSession } from "next-auth/next"; import { authOptions } from "@/app/api/auth/[...nextauth]/route"; import { sendEmail } from "../mail/sendEmail"; import { formatDate } from "../utils"; +import { itemShipbuilding, itemOffshoreTop, itemOffshoreHull } from "@/db/schema/items"; import { techVendors, techVendorPossibleItems, techVendorContacts } from "@/db/schema/techVendors"; import { deleteFile, saveDRMFile, saveFile } from "@/lib/file-stroage"; import { decryptWithServerAction } from "@/components/drm/drmUtils"; @@ -101,22 +102,26 @@ export async function getTechSalesRfqsWithJoin(input: GetTechSalesRfqsSchema & { try { // 마감일이 지났고 아직 Closed가 아닌 RFQ를 일괄 Closed로 변경 await db.update(techSalesRfqs) - .set({ status: "Closed", updatedAt: new Date() }) - .where( - and( - lt(techSalesRfqs.dueDate, new Date()), - ne(techSalesRfqs.status, "Closed") - ) - ); + .set({ status: "Closed", updatedAt: new Date() }) + .where( + and( + lt(techSalesRfqs.dueDate, new Date()), + ne(techSalesRfqs.status, "Closed") + ) + ); const offset = (input.page - 1) * input.perPage; // 기본 필터 처리 - RFQFilterBox에서 오는 필터 const basicFilters = input.basicFilters || []; const basicJoinOperator = input.basicJoinOperator || "and"; - // 고급 필터 처리 - 테이블의 DataTableFilterList에서 오는 필터 - const advancedFilters = input.filters || []; + + // 고급 필터 처리 - workTypes을 먼저 제외 + const advancedFilters = (input.filters || []).filter(f => f.id !== "workTypes"); const advancedJoinOperator = input.joinOperator || "and"; + // workTypes 필터는 별도로 추출 + const workTypesFilter = (input.filters || []).find(f => f.id === "workTypes"); + // 기본 필터 조건 생성 let basicWhere; if (basicFilters.length > 0) { @@ -127,7 +132,7 @@ export async function getTechSalesRfqsWithJoin(input: GetTechSalesRfqsSchema & { }); } - // 고급 필터 조건 생성 + // 고급 필터 조건 생성 (workTypes 제외) let advancedWhere; if (advancedFilters.length > 0) { advancedWhere = filterColumns({ @@ -149,11 +154,33 @@ export async function getTechSalesRfqsWithJoin(input: GetTechSalesRfqsSchema & { ); } + // workTypes 필터 처리 (고급 필터에서 제외된 workTypes만 별도 처리) + let workTypesWhere; + if (workTypesFilter && Array.isArray(workTypesFilter.value) && workTypesFilter.value.length > 0) { + // RFQ 아이템 테이블들과 조인하여 workType이 포함된 RFQ만 추출 + // (조선, 해양TOP, 해양HULL 모두 포함) + const rfqIdsWithWorkTypes = db + .selectDistinct({ rfqId: techSalesRfqItems.rfqId }) + .from(techSalesRfqItems) + .leftJoin(itemShipbuilding, eq(techSalesRfqItems.itemShipbuildingId, itemShipbuilding.id)) + .leftJoin(itemOffshoreTop, eq(techSalesRfqItems.itemOffshoreTopId, itemOffshoreTop.id)) + .leftJoin(itemOffshoreHull, eq(techSalesRfqItems.itemOffshoreHullId, itemOffshoreHull.id)) + .where( + or( + inArray(itemShipbuilding.workType, workTypesFilter.value), + inArray(itemOffshoreTop.workType, workTypesFilter.value), + inArray(itemOffshoreHull.workType, workTypesFilter.value) + ) + ); + workTypesWhere = inArray(techSalesRfqs.id, rfqIdsWithWorkTypes); + } + // 모든 조건 결합 const whereConditions = []; if (basicWhere) whereConditions.push(basicWhere); if (advancedWhere) whereConditions.push(advancedWhere); if (globalWhere) whereConditions.push(globalWhere); + if (workTypesWhere) whereConditions.push(workTypesWhere); // 조건이 있을 때만 and() 사용 const finalWhere = whereConditions.length > 0 @@ -743,13 +770,51 @@ export async function sendTechSalesRfqToVendors(input: { // 5. 담당자별 아이템 매핑 정보 저장 (중복 방지) for (const item of rfqItems) { - // tech_vendor_possible_items에서 해당 벤더의 아이템 찾기 - const vendorPossibleItem = await tx.query.techVendorPossibleItems.findFirst({ - where: and( - eq(techVendorPossibleItems.vendorId, quotation.vendor!.id), - eq(techVendorPossibleItems.itemCode, item.itemCode || '') - ) - }); + let vendorPossibleItem = null; + // 조선: 아이템코드 + 선종으로 조선아이템테이블에서 찾기, 해양: 아이템코드로만 찾기 + if (item.itemType === "SHIP" && item.itemCode && item.shipTypes) { + // 조선: itemShipbuilding에서 itemCode, shipTypes로 찾기 + const shipbuildingItem = await tx.query.itemShipbuilding.findFirst({ + where: and( + eq(itemShipbuilding.itemCode, item.itemCode), + eq(itemShipbuilding.shipTypes, item.shipTypes) + ) + }); + if (shipbuildingItem?.id) { + vendorPossibleItem = await tx.query.techVendorPossibleItems.findFirst({ + where: and( + eq(techVendorPossibleItems.vendorId, quotation.vendor!.id), + eq(techVendorPossibleItems.shipbuildingItemId, shipbuildingItem.id) + ) + }); + } + } else if (item.itemType === "TOP" && item.itemCode) { + // 해양 TOP: itemOffshoreTop에서 itemCode로 찾기 + const offshoreTopItem = await tx.query.itemOffshoreTop.findFirst({ + where: eq(itemOffshoreTop.itemCode, item.itemCode) + }); + if (offshoreTopItem?.id) { + vendorPossibleItem = await tx.query.techVendorPossibleItems.findFirst({ + where: and( + eq(techVendorPossibleItems.vendorId, quotation.vendor!.id), + eq(techVendorPossibleItems.offshoreTopItemId, offshoreTopItem.id) + ) + }); + } + } else if (item.itemType === "HULL" && item.itemCode) { + // 해양 HULL: itemOffshoreHull에서 itemCode로 찾기 + const offshoreHullItem = await tx.query.itemOffshoreHull.findFirst({ + where: eq(itemOffshoreHull.itemCode, item.itemCode) + }); + if (offshoreHullItem?.id) { + vendorPossibleItem = await tx.query.techVendorPossibleItems.findFirst({ + where: and( + eq(techVendorPossibleItems.vendorId, quotation.vendor!.id), + eq(techVendorPossibleItems.offshoreHullItemId, offshoreHullItem.id) + ) + }); + } + } if (vendorPossibleItem) { // contact_possible_items 중복 체크 @@ -2665,75 +2730,157 @@ export async function getTechSalesRfqCandidateVendors(rfqId: number) { return { data: [], error: null }; } - // 3. 아이템 코드들 추출 - const itemCodes: string[] = []; + // 3. 아이템 ID들 추출 (타입별로) + const shipItemIds: number[] = []; + const topItemIds: number[] = []; + const hullItemIds: number[] = []; + rfqItems.forEach(item => { - if (item.itemType === "SHIP" && item.itemShipbuilding?.itemCode) { - itemCodes.push(item.itemShipbuilding.itemCode); - } else if (item.itemType === "TOP" && item.itemOffshoreTop?.itemCode) { - itemCodes.push(item.itemOffshoreTop.itemCode); - } else if (item.itemType === "HULL" && item.itemOffshoreHull?.itemCode) { - itemCodes.push(item.itemOffshoreHull.itemCode); + if (item.itemType === "SHIP" && item.itemShipbuilding?.id) { + shipItemIds.push(item.itemShipbuilding.id); + } else if (item.itemType === "TOP" && item.itemOffshoreTop?.id) { + topItemIds.push(item.itemOffshoreTop.id); + } else if (item.itemType === "HULL" && item.itemOffshoreHull?.id) { + hullItemIds.push(item.itemOffshoreHull.id); } }); - if (itemCodes.length === 0) { + if (shipItemIds.length === 0 && topItemIds.length === 0 && hullItemIds.length === 0) { return { data: [], error: null }; } - // 4. RFQ 타입에 따른 벤더 타입 매핑 - const vendorTypeFilter = rfq.rfqType === "SHIP" ? "SHIP" : - rfq.rfqType === "TOP" ? "OFFSHORE_TOP" : - rfq.rfqType === "HULL" ? "OFFSHORE_HULL" : null; + // 4. 각 타입별로 매칭되는 벤더들 조회 + const candidateVendorsMap = new Map(); + + // 조선 아이템 매칭 벤더들 + if (shipItemIds.length > 0) { + const shipVendors = await tx + .select({ + id: techVendors.id, + vendorId: techVendors.id, + vendorName: techVendors.vendorName, + vendorCode: techVendors.vendorCode, + country: techVendors.country, + email: techVendors.email, + phone: techVendors.phone, + status: techVendors.status, + techVendorType: techVendors.techVendorType, + matchedItemCode: itemShipbuilding.itemCode, + }) + .from(techVendorPossibleItems) + .innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id)) + .innerJoin(itemShipbuilding, eq(techVendorPossibleItems.shipbuildingItemId, itemShipbuilding.id)) + .where( + and( + inArray(techVendorPossibleItems.shipbuildingItemId, shipItemIds), + or( + eq(techVendors.status, "ACTIVE"), + eq(techVendors.status, "QUOTE_COMPARISON") + ) + ) + ); + + shipVendors.forEach(vendor => { + const key = vendor.vendorId; + if (!candidateVendorsMap.has(key)) { + candidateVendorsMap.set(key, { + ...vendor, + matchedItemCodes: [], + matchedItemCount: 0 + }); + } + candidateVendorsMap.get(key).matchedItemCodes.push(vendor.matchedItemCode); + candidateVendorsMap.get(key).matchedItemCount++; + }); + } + + // 해양 TOP 아이템 매칭 벤더들 + if (topItemIds.length > 0) { + const topVendors = await tx + .select({ + id: techVendors.id, + vendorId: techVendors.id, + vendorName: techVendors.vendorName, + vendorCode: techVendors.vendorCode, + country: techVendors.country, + email: techVendors.email, + phone: techVendors.phone, + status: techVendors.status, + techVendorType: techVendors.techVendorType, + matchedItemCode: itemOffshoreTop.itemCode, + }) + .from(techVendorPossibleItems) + .innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id)) + .innerJoin(itemOffshoreTop, eq(techVendorPossibleItems.offshoreTopItemId, itemOffshoreTop.id)) + .where( + and( + inArray(techVendorPossibleItems.offshoreTopItemId, topItemIds), + or( + eq(techVendors.status, "ACTIVE"), + eq(techVendors.status, "QUOTE_COMPARISON") + ) + ) + ); - if (!vendorTypeFilter) { - return { data: [], error: "지원되지 않는 RFQ 타입입니다." }; + topVendors.forEach(vendor => { + const key = vendor.vendorId; + if (!candidateVendorsMap.has(key)) { + candidateVendorsMap.set(key, { + ...vendor, + matchedItemCodes: [], + matchedItemCount: 0 + }); + } + candidateVendorsMap.get(key).matchedItemCodes.push(vendor.matchedItemCode); + candidateVendorsMap.get(key).matchedItemCount++; + }); } - // 5. 매칭되는 벤더들 조회 (타입 필터링 포함) - const candidateVendors = await tx - .select({ - id: techVendors.id, // 벤더 ID를 id로 명명하여 key 문제 해결 - vendorId: techVendors.id, // 호환성을 위해 유지 - vendorName: techVendors.vendorName, - vendorCode: techVendors.vendorCode, - country: techVendors.country, - email: techVendors.email, - phone: techVendors.phone, - status: techVendors.status, - techVendorType: techVendors.techVendorType, - matchedItemCodes: sql` - array_agg(DISTINCT ${techVendorPossibleItems.itemCode}) - `, - matchedItemCount: sql` - count(DISTINCT ${techVendorPossibleItems.itemCode}) - `, - }) - .from(techVendorPossibleItems) - .innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id)) - .where( - and( - inArray(techVendorPossibleItems.itemCode, itemCodes), - or( - eq(techVendors.status, "ACTIVE"), - eq(techVendors.status, "QUOTE_COMPARISON") // 견적비교용 벤더도 RFQ 초대 가능 + // 해양 HULL 아이템 매칭 벤더들 + if (hullItemIds.length > 0) { + const hullVendors = await tx + .select({ + id: techVendors.id, + vendorId: techVendors.id, + vendorName: techVendors.vendorName, + vendorCode: techVendors.vendorCode, + country: techVendors.country, + email: techVendors.email, + phone: techVendors.phone, + status: techVendors.status, + techVendorType: techVendors.techVendorType, + matchedItemCode: itemOffshoreHull.itemCode, + }) + .from(techVendorPossibleItems) + .innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id)) + .innerJoin(itemOffshoreHull, eq(techVendorPossibleItems.offshoreHullItemId, itemOffshoreHull.id)) + .where( + and( + inArray(techVendorPossibleItems.offshoreHullItemId, hullItemIds), + or( + eq(techVendors.status, "ACTIVE"), + eq(techVendors.status, "QUOTE_COMPARISON") + ) ) - // 벤더 타입 필터링 임시 제거 - 데이터 확인 후 다시 추가 - // eq(techVendors.techVendorType, vendorTypeFilter) - ) - ) - .groupBy( - techVendorPossibleItems.vendorId, - techVendors.id, - techVendors.vendorName, - techVendors.vendorCode, - techVendors.country, - techVendors.email, - techVendors.phone, - techVendors.status, - techVendors.techVendorType - ) - .orderBy(desc(sql`count(DISTINCT ${techVendorPossibleItems.itemCode})`)); + ); + + hullVendors.forEach(vendor => { + const key = vendor.vendorId; + if (!candidateVendorsMap.has(key)) { + candidateVendorsMap.set(key, { + ...vendor, + matchedItemCodes: [], + matchedItemCount: 0 + }); + } + candidateVendorsMap.get(key).matchedItemCodes.push(vendor.matchedItemCode); + candidateVendorsMap.get(key).matchedItemCount++; + }); + } + + // 5. 결과 정렬 (매칭된 아이템 수 기준 내림차순) + const candidateVendors = Array.from(candidateVendorsMap.values()) + .sort((a, b) => b.matchedItemCount - a.matchedItemCount); return { data: candidateVendors, error: null }; }); @@ -2830,44 +2977,78 @@ export async function addTechVendorsToTechSalesRfq(input: { }) .returning({ id: techSalesVendorQuotations.id }); - // 🆕 RFQ의 아이템 코드들을 tech_vendor_possible_items에 추가 + // 🆕 RFQ의 아이템들을 tech_vendor_possible_items에 추가 try { // RFQ의 아이템들 조회 const rfqItemsResult = await getTechSalesRfqItems(input.rfqId); if (rfqItemsResult.data && rfqItemsResult.data.length > 0) { for (const item of rfqItemsResult.data) { - const { - itemCode, - itemList, - workType, // 공종 - shipTypes, // 선종 (배열일 수 있음) - subItemList // 서브아이템리스트 (있을 수도 있음) - } = item; - - // 동적 where 조건 생성: 값이 있으면 비교, 없으면 비교하지 않음 - const whereConds = [ - eq(techVendorPossibleItems.vendorId, vendorId), - itemCode ? eq(techVendorPossibleItems.itemCode, itemCode) : undefined, - itemList ? eq(techVendorPossibleItems.itemList, itemList) : undefined, - workType ? eq(techVendorPossibleItems.workType, workType) : undefined, - shipTypes ? eq(techVendorPossibleItems.shipTypes, shipTypes) : undefined, - subItemList ? eq(techVendorPossibleItems.subItemList, subItemList) : undefined, - ].filter(Boolean); - - const existing = await tx.query.techVendorPossibleItems.findFirst({ - where: and(...whereConds) - }); - - if (!existing) { - await tx.insert(techVendorPossibleItems).values({ - vendorId : vendorId, - itemCode: itemCode ?? null, - itemList: itemList ?? null, - workType: workType ?? null, - shipTypes: shipTypes ?? null, - subItemList: subItemList ?? null, + let vendorPossibleItem = null; + // 조선: 아이템코드 + 선종으로 조선아이템테이블에서 찾기, 해양: 아이템코드로만 찾기 + if (item.itemType === "SHIP" && item.itemCode && item.shipTypes) { + // 조선: itemShipbuilding에서 itemCode, shipTypes로 찾기 + const shipbuildingItem = await tx.query.itemShipbuilding.findFirst({ + where: and( + eq(itemShipbuilding.itemCode, item.itemCode), + eq(itemShipbuilding.shipTypes, item.shipTypes) + ) }); + if (shipbuildingItem?.id) { + vendorPossibleItem = await tx.query.techVendorPossibleItems.findFirst({ + where: and( + eq(techVendorPossibleItems.vendorId, vendorId), + eq(techVendorPossibleItems.shipbuildingItemId, shipbuildingItem.id) + ) + }); + + if (!vendorPossibleItem) { + await tx.insert(techVendorPossibleItems).values({ + vendorId: vendorId, + shipbuildingItemId: shipbuildingItem.id, + }); + } + } + } else if (item.itemType === "TOP" && item.itemCode) { + // 해양 TOP: itemOffshoreTop에서 itemCode로 찾기 + const offshoreTopItem = await tx.query.itemOffshoreTop.findFirst({ + where: eq(itemOffshoreTop.itemCode, item.itemCode) + }); + if (offshoreTopItem?.id) { + vendorPossibleItem = await tx.query.techVendorPossibleItems.findFirst({ + where: and( + eq(techVendorPossibleItems.vendorId, vendorId), + eq(techVendorPossibleItems.offshoreTopItemId, offshoreTopItem.id) + ) + }); + + if (!vendorPossibleItem) { + await tx.insert(techVendorPossibleItems).values({ + vendorId: vendorId, + offshoreTopItemId: offshoreTopItem.id, + }); + } + } + } else if (item.itemType === "HULL" && item.itemCode) { + // 해양 HULL: itemOffshoreHull에서 itemCode로 찾기 + const offshoreHullItem = await tx.query.itemOffshoreHull.findFirst({ + where: eq(itemOffshoreHull.itemCode, item.itemCode) + }); + if (offshoreHullItem?.id) { + vendorPossibleItem = await tx.query.techVendorPossibleItems.findFirst({ + where: and( + eq(techVendorPossibleItems.vendorId, vendorId), + eq(techVendorPossibleItems.offshoreHullItemId, offshoreHullItem.id) + ) + }); + + if (!vendorPossibleItem) { + await tx.insert(techVendorPossibleItems).values({ + vendorId: vendorId, + offshoreHullItemId: offshoreHullItem.id, + }); + } + } } } } @@ -3367,11 +3548,6 @@ export async function getAcceptedTechSalesVendorQuotations(input: { try { const offset = (input.page - 1) * input.perPage; - // 기본 WHERE 조건: status = 'Accepted'만 조회, rfqType이 'SHIP'이 아닌 것만 - // const baseConditions = [ - // eq(techSalesVendorQuotations.status, 'Accepted'), - // sql`${techSalesRfqs.rfqType} != 'SHIP'` // 조선 RFQ 타입 제외 - // ]; // 기본 WHERE 조건: status = 'Accepted'만 조회, rfqType이 'SHIP'이 아닌 것만 const baseConditions = [or( eq(techSalesVendorQuotations.status, 'Submitted'), @@ -3566,6 +3742,7 @@ export async function getTechVendorsContacts(vendorIds: number[]) { contactId: techVendorContacts.id, contactName: techVendorContacts.contactName, contactPosition: techVendorContacts.contactPosition, + contactTitle: techVendorContacts.contactTitle, contactEmail: techVendorContacts.contactEmail, contactPhone: techVendorContacts.contactPhone, isPrimary: techVendorContacts.isPrimary, @@ -3599,6 +3776,7 @@ export async function getTechVendorsContacts(vendorIds: number[]) { id: row.contactId, contactName: row.contactName, contactPosition: row.contactPosition, + contactTitle: row.contactTitle, contactEmail: row.contactEmail, contactPhone: row.contactPhone, isPrimary: row.isPrimary @@ -3614,6 +3792,7 @@ export async function getTechVendorsContacts(vendorIds: number[]) { id: number; contactName: string; contactPosition: string | null; + contactTitle: string | null; contactEmail: string; contactPhone: string | null; isPrimary: boolean; @@ -3710,4 +3889,96 @@ export async function uploadQuotationAttachments( error: error instanceof Error ? error.message : '파일 업로드 중 오류가 발생했습니다.' }; } +} + +/** + * Update SHI Comment (revisionNote) for the current revision of a quotation. + * Only the revisionNote is updated in the tech_sales_vendor_quotation_revisions table. + */ +export async function updateSHIComment(revisionId: number, revisionNote: string) { + try { + const updatedRevision = await db + .update(techSalesVendorQuotationRevisions) + .set({ + revisionNote: revisionNote, + }) + .where(eq(techSalesVendorQuotationRevisions.id, revisionId)) + .returning(); + + if (updatedRevision.length === 0) { + return { data: null, error: "revision을 업데이트할 수 없습니다." }; + } + + return { data: updatedRevision[0], error: null }; + } catch (error) { + console.error("SHI Comment 업데이트 중 오류:", error); + return { data: null, error: "SHI Comment 업데이트 중 오류가 발생했습니다." }; + } +} + +// RFQ 단일 조회 함수 추가 +export async function getTechSalesRfqById(id: number) { + try { + const rfq = await db.query.techSalesRfqs.findFirst({ + where: eq(techSalesRfqs.id, id), + }); + const project = await db + .select({ + id: biddingProjects.id, + projectCode: biddingProjects.pspid, + projectName: biddingProjects.projNm, + pjtType: biddingProjects.pjtType, + ptypeNm: biddingProjects.ptypeNm, + projMsrm: biddingProjects.projMsrm, + }) + .from(biddingProjects) + .where(eq(biddingProjects.id, rfq?.biddingProjectId ?? 0)); + + if (!rfq) { + return { data: null, error: "RFQ를 찾을 수 없습니다." }; + } + + return { data: { ...rfq, project }, error: null }; + } catch (err) { + console.error("Error fetching RFQ:", err); + return { data: null, error: getErrorMessage(err) }; + } +} + +// RFQ 업데이트 함수 수정 (description으로 통일) +export async function updateTechSalesRfq(data: { + id: number; + description: string; + dueDate: Date; + updatedBy: number; +}) { + try { + return await db.transaction(async (tx) => { + const rfq = await tx.query.techSalesRfqs.findFirst({ + where: eq(techSalesRfqs.id, data.id), + }); + + if (!rfq) { + return { data: null, error: "RFQ를 찾을 수 없습니다." }; + } + + const [updatedRfq] = await tx + .update(techSalesRfqs) + .set({ + description: data.description, // description 필드로 업데이트 + dueDate: data.dueDate, + updatedAt: new Date(), + }) + .where(eq(techSalesRfqs.id, data.id)) + .returning(); + + revalidateTag("techSalesRfqs"); + revalidatePath(getTechSalesRevalidationPath(rfq.rfqType || "SHIP")); + + return { data: updatedRfq, error: null }; + }); + } catch (err) { + console.error("Error updating RFQ:", err); + return { data: null, error: getErrorMessage(err) }; + } } \ No newline at end of file diff --git a/lib/techsales-rfq/table/detail-table/quotation-contacts-view-dialog.tsx b/lib/techsales-rfq/table/detail-table/quotation-contacts-view-dialog.tsx index 3e793b62..61c97b1b 100644 --- a/lib/techsales-rfq/table/detail-table/quotation-contacts-view-dialog.tsx +++ b/lib/techsales-rfq/table/detail-table/quotation-contacts-view-dialog.tsx @@ -20,6 +20,7 @@ interface QuotationContact { contactId: number contactName: string contactPosition: string | null + contactTitle: string | null contactEmail: string contactPhone: string | null contactCountry: string | null @@ -129,6 +130,11 @@ export function QuotationContactsViewDialog({ {contact.contactPosition}

)} + {contact.contactTitle && ( +

+ {contact.contactTitle} +

+ )} {contact.contactCountry && (

{contact.contactCountry} diff --git a/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx b/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx index 0f5158d9..7d972b91 100644 --- a/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx +++ b/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx @@ -1,5 +1,4 @@ "use client" - import * as React from "react" import { useState, useEffect } from "react" import { @@ -16,6 +15,8 @@ import { Skeleton } from "@/components/ui/skeleton" import { Clock, User, AlertCircle, Paperclip } from "lucide-react" import { formatDate } from "@/lib/utils" import { toast } from "sonner" +import { Button } from "@/components/ui/button" +import { updateSHIComment } from "@/lib/techsales-rfq/service"; interface QuotationAttachment { id: number @@ -37,7 +38,7 @@ interface QuotationSnapshot { totalPrice: string | null validUntil: Date | null remark: string | null - status: string | null + status: string quotationVersion: number | null submittedAt: Date | null acceptedAt: Date | null @@ -93,7 +94,9 @@ function QuotationCard({ isCurrent = false, revisedBy, revisedAt, - attachments + attachments, + revisionId, + revisionNote, }: { data: QuotationSnapshot | QuotationHistoryData["current"] version: number @@ -101,9 +104,36 @@ function QuotationCard({ revisedBy?: string | null revisedAt?: Date attachments?: QuotationAttachment[] + revisionId?: number + revisionNote?: string | null }) { const statusInfo = statusConfig[data.status as keyof typeof statusConfig] || { label: data.status || "알 수 없음", color: "bg-gray-100 text-gray-800" } + + const [editValue, setEditValue] = React.useState(revisionNote || ""); + const [isSaving, setIsSaving] = React.useState(false); + + React.useEffect(() => { + setEditValue(revisionNote || ""); + }, [revisionNote]); + + const handleSave = async () => { + if (!revisionId) return; + + setIsSaving(true); + try { + const result = await updateSHIComment(revisionId, editValue); + if (result.error) { + toast.error(result.error); + } else { + toast.success("저장 완료"); + } + } catch (error) { + toast.error("저장 중 오류가 발생했습니다"); + } finally { + setIsSaving(false); + } + }; return ( @@ -117,12 +147,6 @@ function QuotationCard({ {statusInfo.label} - {/* {changeReason && ( -

- - {changeReason} -
- )} */}
@@ -147,6 +171,21 @@ function QuotationCard({
)} + {revisionId && ( +
+

SHI Comment

+