From c5002d77087b256599b174ada611621657fcc523 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Sun, 15 Jun 2025 04:40:22 +0000 Subject: (최겸) 기술영업 조선,해양RFQ 수정 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/techsales-rfq/actions.ts | 44 +- lib/techsales-rfq/repository.ts | 325 +++- lib/techsales-rfq/service.ts | 1958 +++++++++++++------- lib/techsales-rfq/table/create-rfq-dialog.tsx | 917 --------- lib/techsales-rfq/table/create-rfq-hull-dialog.tsx | 652 +++++++ lib/techsales-rfq/table/create-rfq-ship-dialog.tsx | 726 ++++++++ lib/techsales-rfq/table/create-rfq-top-dialog.tsx | 594 ++++++ .../table/detail-table/add-vendor-dialog.tsx | 271 ++- .../table/detail-table/rfq-detail-table.tsx | 25 +- .../vendor-quotation-comparison-dialog.tsx | 1 + lib/techsales-rfq/table/project-detail-dialog.tsx | 202 -- lib/techsales-rfq/table/rfq-filter-sheet.tsx | 6 +- lib/techsales-rfq/table/rfq-items-view-dialog.tsx | 198 ++ lib/techsales-rfq/table/rfq-table-column.tsx | 269 ++- .../table/rfq-table-toolbar-actions.tsx | 23 +- lib/techsales-rfq/table/rfq-table.tsx | 127 +- lib/techsales-rfq/validations.ts | 73 + .../vendor-response/detail/project-info-tab.tsx | 157 +- .../vendor-response/detail/quotation-tabs.tsx | 40 +- .../vendor-response/quotation-editor.tsx | 2 +- .../vendor-response/quotation-item-editor.tsx | 8 +- .../table/vendor-quotations-table-columns.tsx | 71 +- .../table/vendor-quotations-table.tsx | 56 +- 23 files changed, 4293 insertions(+), 2452 deletions(-) delete mode 100644 lib/techsales-rfq/table/create-rfq-dialog.tsx create mode 100644 lib/techsales-rfq/table/create-rfq-hull-dialog.tsx create mode 100644 lib/techsales-rfq/table/create-rfq-ship-dialog.tsx create mode 100644 lib/techsales-rfq/table/create-rfq-top-dialog.tsx create mode 100644 lib/techsales-rfq/table/rfq-items-view-dialog.tsx (limited to 'lib') diff --git a/lib/techsales-rfq/actions.ts b/lib/techsales-rfq/actions.ts index 9bcb20e5..1171271f 100644 --- a/lib/techsales-rfq/actions.ts +++ b/lib/techsales-rfq/actions.ts @@ -33,27 +33,27 @@ export async function acceptTechSalesVendorQuotationAction(quotationId: number) } } -/** - * 기술영업 벤더 견적 거절 Server Action - */ -export async function rejectTechSalesVendorQuotationAction(quotationId: number, rejectionReason?: string) { - try { - const result = await rejectTechSalesVendorQuotation(quotationId, rejectionReason) +// /** +// * 기술영업 벤더 견적 거절 Server Action +// */ +// export async function rejectTechSalesVendorQuotationAction(quotationId: number, rejectionReason?: string) { +// try { +// const result = await rejectTechSalesVendorQuotation(quotationId, rejectionReason) - if (result.success) { - // 관련 페이지들 재검증 - revalidatePath("/evcp/budgetary-tech-sales-ship") - revalidatePath("/partners/techsales") +// if (result.success) { +// // 관련 페이지들 재검증 +// revalidatePath("/evcp/budgetary-tech-sales-ship") +// revalidatePath("/partners/techsales") - return { success: true, message: "견적이 성공적으로 거절되었습니다" } - } else { - return { success: false, error: result.error } - } - } catch (error) { - console.error("견적 거절 액션 오류:", error) - return { - success: false, - error: error instanceof Error ? error.message : "견적 거절에 실패했습니다" - } - } -} \ No newline at end of file +// return { success: true, message: "견적이 성공적으로 거절되었습니다" } +// } else { +// return { success: false, error: result.error } +// } +// } catch (error) { +// console.error("견적 거절 액션 오류:", error) +// return { +// success: false, +// error: error instanceof Error ? error.message : "견적 거절에 실패했습니다" +// } +// } +// } \ No newline at end of file diff --git a/lib/techsales-rfq/repository.ts b/lib/techsales-rfq/repository.ts index 66c0b345..e9ad3925 100644 --- a/lib/techsales-rfq/repository.ts +++ b/lib/techsales-rfq/repository.ts @@ -3,17 +3,18 @@ import { techSalesRfqs, techSalesVendorQuotations, - vendors, users, - itemShipbuilding + biddingProjects } from "@/db/schema"; +import { techVendors } from "@/db/schema/techVendors"; import { asc, - desc, count, SQL, sql + desc, count, SQL, sql, eq } from "drizzle-orm"; import { PgTransaction } from "drizzle-orm/pg-core"; + export type NewTechSalesRfq = typeof techSalesRfqs.$inferInsert; /** * 기술영업 RFQ 생성 @@ -61,27 +62,29 @@ export async function countTechSalesRfqs( return res[0]?.count ?? 0; } + /** - * RFQ 정보 직접 조인 조회 (뷰 대신 테이블 조인 사용) + * 기술영업 RFQ 조회 with 조인 (Repository) */ export async function selectTechSalesRfqsWithJoin( tx: PgTransaction, - params: { - where?: any; + options: { + where?: SQL; orderBy?: (ReturnType | ReturnType | SQL)[]; offset?: number; limit?: number; + rfqType?: "SHIP" | "TOP" | "HULL"; } ) { - const { where, orderBy, offset = 0, limit = 10 } = params; + const { where, orderBy, offset = 0, limit = 10, rfqType } = options; // 별칭 방식을 변경하여 select, join, where, orderBy 등을 순차적으로 수행 - const query = tx.select({ + let query = tx.select({ // RFQ 기본 정보 id: techSalesRfqs.id, rfqCode: techSalesRfqs.rfqCode, - itemShipbuildingId: techSalesRfqs.itemShipbuildingId, - itemName: itemShipbuilding.itemList, + rfqType: techSalesRfqs.rfqType, + biddingProjectId: techSalesRfqs.biddingProjectId, materialCode: techSalesRfqs.materialCode, // 날짜 및 상태 정보 @@ -93,6 +96,7 @@ export async function selectTechSalesRfqsWithJoin( picCode: techSalesRfqs.picCode, remark: techSalesRfqs.remark, cancelReason: techSalesRfqs.cancelReason, + description: techSalesRfqs.description, // 생성/수정 정보 createdAt: techSalesRfqs.createdAt, @@ -106,16 +110,12 @@ export async function selectTechSalesRfqsWithJoin( sentBy: techSalesRfqs.sentBy, sentByName: sql`sent_user.name`, - // 프로젝트 정보 (스냅샷) - projectSnapshot: techSalesRfqs.projectSnapshot, - seriesSnapshot: techSalesRfqs.seriesSnapshot, - - // 프로젝트 핵심 정보 - pspid: sql`${techSalesRfqs.projectSnapshot}->>'pspid'`, - projNm: sql`${techSalesRfqs.projectSnapshot}->>'projNm'`, - sector: sql`${techSalesRfqs.projectSnapshot}->>'sector'`, - projMsrm: sql`(${techSalesRfqs.projectSnapshot}->>'projMsrm')::int`, - ptypeNm: sql`${techSalesRfqs.projectSnapshot}->>'ptypeNm'`, + // 프로젝트 정보 (조인) + pspid: biddingProjects.pspid, + projNm: biddingProjects.projNm, + sector: biddingProjects.sector, + projMsrm: biddingProjects.projMsrm, + ptypeNm: biddingProjects.ptypeNm, // 첨부파일 개수 attachmentCount: sql`( @@ -130,20 +130,43 @@ export async function selectTechSalesRfqsWithJoin( FROM tech_sales_vendor_quotations WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id} )`, + + // 아이템 개수 + itemCount: sql`( + SELECT COUNT(*) + FROM tech_sales_rfq_items + WHERE tech_sales_rfq_items.rfq_id = ${techSalesRfqs.id} + )`, }) .from(techSalesRfqs) - .leftJoin(itemShipbuilding, sql`${techSalesRfqs.itemShipbuildingId} = ${itemShipbuilding.id}`) + + // 프로젝트 정보 조인 추가 + .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id)) + + // 사용자 정보 조인 .leftJoin(sql`${users} AS created_user`, sql`${techSalesRfqs.createdBy} = created_user.id`) .leftJoin(sql`${users} AS updated_user`, sql`${techSalesRfqs.updatedBy} = updated_user.id`) - .leftJoin(sql`${users} AS sent_user`, sql`${techSalesRfqs.sentBy} = sent_user.id`); + .leftJoin(sql`${users} AS sent_user`, sql`${techSalesRfqs.sentBy} = sent_user.id`) + +; - // where 조건 적용 - const queryWithWhere = where ? query.where(where) : query; + // rfqType 필터링 + const conditions = []; + if (rfqType) { + conditions.push(sql`${techSalesRfqs.rfqType} = ${rfqType}`); + } + if (where) { + conditions.push(where); + } + + if (conditions.length > 0) { + query = query.where(sql`${sql.join(conditions, sql` AND `)}`); + } // orderBy 적용 const queryWithOrderBy = orderBy?.length - ? queryWithWhere.orderBy(...orderBy) - : queryWithWhere.orderBy(desc(techSalesRfqs.createdAt)); + ? query.orderBy(...orderBy) + : query.orderBy(desc(techSalesRfqs.createdAt)); // offset과 limit 적용 후 실행 return queryWithOrderBy.offset(offset).limit(limit); @@ -154,13 +177,23 @@ export async function selectTechSalesRfqsWithJoin( */ export async function countTechSalesRfqsWithJoin( tx: PgTransaction, - where?: any + where?: any, + rfqType?: "SHIP" | "TOP" | "HULL" ) { + const conditions = []; + if (rfqType) { + conditions.push(sql`${techSalesRfqs.rfqType} = ${rfqType}`); + } + if (where) { + conditions.push(where); + } + + const finalWhere = conditions.length > 0 ? sql`${sql.join(conditions, sql` AND `)}` : undefined; + const res = await tx .select({ count: count() }) .from(techSalesRfqs) - .leftJoin(itemShipbuilding, sql`${techSalesRfqs.itemShipbuildingId} = ${itemShipbuilding.id}`) - .where(where ?? undefined); + .where(finalWhere); return res[0]?.count ?? 0; } @@ -171,22 +204,24 @@ export async function selectTechSalesVendorQuotationsWithJoin( tx: PgTransaction, params: { where?: any; - orderBy?: (ReturnType | ReturnType | SQL)[]; + orderBy?: (ReturnType | ReturnType)[]; offset?: number; limit?: number; + rfqType?: "SHIP" | "TOP" | "HULL"; } ) { - const { where, orderBy, offset = 0, limit = 10 } = params; + const { where, orderBy, offset = 0, limit = 10, rfqType } = params; // 별칭 방식을 변경하여 select, join, where, orderBy 등을 순차적으로 수행 - const query = tx.select({ + let query = tx.select({ // 견적 기본 정보 id: techSalesVendorQuotations.id, rfqId: techSalesVendorQuotations.rfqId, rfqCode: techSalesRfqs.rfqCode, + rfqType: techSalesRfqs.rfqType, vendorId: techSalesVendorQuotations.vendorId, - vendorName: vendors.vendorName, - vendorCode: vendors.vendorCode, + vendorName: techVendors.vendorName, + vendorCode: techVendors.vendorCode, // 견적 상세 정보 totalPrice: techSalesVendorQuotations.totalPrice, @@ -210,13 +245,11 @@ export async function selectTechSalesVendorQuotationsWithJoin( // 프로젝트 정보 materialCode: techSalesRfqs.materialCode, - itemShipbuildingId: techSalesRfqs.itemShipbuildingId, - itemName: itemShipbuilding.itemList, - // 프로젝트 핵심 정보 - pspid: sql`${techSalesRfqs.projectSnapshot}->>'pspid'`, - projNm: sql`${techSalesRfqs.projectSnapshot}->>'projNm'`, - sector: sql`${techSalesRfqs.projectSnapshot}->>'sector'`, + // 프로젝트 핵심 정보 - null 체크 추가 + pspid: techSalesRfqs.biddingProjectId, + projNm: biddingProjects.projNm, + sector: biddingProjects.sector, // 첨부파일 개수 attachmentCount: sql`( @@ -224,21 +257,33 @@ export async function selectTechSalesVendorQuotationsWithJoin( FROM tech_sales_attachments WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id} )`, + }) .from(techSalesVendorQuotations) .leftJoin(techSalesRfqs, sql`${techSalesVendorQuotations.rfqId} = ${techSalesRfqs.id}`) - .leftJoin(vendors, sql`${techSalesVendorQuotations.vendorId} = ${vendors.id}`) - .leftJoin(itemShipbuilding, sql`${techSalesRfqs.itemShipbuildingId} = ${itemShipbuilding.id}`) + .leftJoin(techVendors, sql`${techSalesVendorQuotations.vendorId} = ${techVendors.id}`) + // 프로젝트 정보 조인 추가 + .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id)) .leftJoin(sql`${users} AS created_user`, sql`${techSalesVendorQuotations.createdBy} = created_user.id`) .leftJoin(sql`${users} AS updated_user`, sql`${techSalesVendorQuotations.updatedBy} = updated_user.id`); - // where 조건 적용 - const queryWithWhere = where ? query.where(where) : query; + // rfqType 필터링 + const conditions = []; + if (rfqType) { + conditions.push(sql`${techSalesRfqs.rfqType} = ${rfqType}`); + } + if (where) { + conditions.push(where); + } + + if (conditions.length > 0) { + query = query.where(sql`${sql.join(conditions, sql` AND `)}`); + } // orderBy 적용 const queryWithOrderBy = orderBy?.length - ? queryWithWhere.orderBy(...orderBy) - : queryWithWhere.orderBy(desc(techSalesVendorQuotations.createdAt)); + ? query.orderBy(...orderBy) + : query.orderBy(desc(techSalesVendorQuotations.createdAt)); // offset과 limit 적용 후 실행 return queryWithOrderBy.offset(offset).limit(limit); @@ -249,15 +294,25 @@ export async function selectTechSalesVendorQuotationsWithJoin( */ export async function countTechSalesVendorQuotationsWithJoin( tx: PgTransaction, - where?: any + where?: any, + rfqType?: "SHIP" | "TOP" | "HULL" ) { + const conditions = []; + if (rfqType) { + conditions.push(sql`${techSalesRfqs.rfqType} = ${rfqType}`); + } + if (where) { + conditions.push(where); + } + + const finalWhere = conditions.length > 0 ? sql`${sql.join(conditions, sql` AND `)}` : undefined; + const res = await tx .select({ count: count() }) .from(techSalesVendorQuotations) .leftJoin(techSalesRfqs, sql`${techSalesVendorQuotations.rfqId} = ${techSalesRfqs.id}`) - .leftJoin(vendors, sql`${techSalesVendorQuotations.vendorId} = ${vendors.id}`) - .leftJoin(itemShipbuilding, sql`${techSalesRfqs.itemShipbuildingId} = ${itemShipbuilding.id}`) - .where(where ?? undefined); + .leftJoin(techVendors, sql`${techSalesVendorQuotations.vendorId} = ${techVendors.id}`) + .where(finalWhere); return res[0]?.count ?? 0; } @@ -271,30 +326,28 @@ export async function selectTechSalesDashboardWithJoin( orderBy?: (ReturnType | ReturnType | SQL)[]; offset?: number; limit?: number; + rfqType?: "SHIP" | "TOP" | "HULL"; } ) { - const { where, orderBy, offset = 0, limit = 10 } = params; + const { where, orderBy, offset = 0, limit = 10, rfqType } = params; // 별칭 방식을 변경하여 select, join, where, orderBy 등을 순차적으로 수행 - const query = tx.select({ + let query = tx.select({ // RFQ 기본 정보 id: techSalesRfqs.id, rfqCode: techSalesRfqs.rfqCode, + rfqType: techSalesRfqs.rfqType, status: techSalesRfqs.status, dueDate: techSalesRfqs.dueDate, rfqSendDate: techSalesRfqs.rfqSendDate, materialCode: techSalesRfqs.materialCode, - - // 아이템 정보 - itemShipbuildingId: techSalesRfqs.itemShipbuildingId, - itemName: itemShipbuilding.itemList, - - // 프로젝트 정보 - pspid: sql`${techSalesRfqs.projectSnapshot}->>'pspid'`, - projNm: sql`${techSalesRfqs.projectSnapshot}->>'projNm'`, - sector: sql`${techSalesRfqs.projectSnapshot}->>'sector'`, - projMsrm: sql`(${techSalesRfqs.projectSnapshot}->>'projMsrm')::int`, - ptypeNm: sql`${techSalesRfqs.projectSnapshot}->>'ptypeNm'`, + + // 프로젝트 정보 - null 체크 추가 + pspid: techSalesRfqs.biddingProjectId, + projNm: biddingProjects.projNm, + sector: biddingProjects.sector, + projMsrm: biddingProjects.projMsrm, + ptypeNm: biddingProjects.ptypeNm, // 벤더 견적 통계 vendorCount: sql`( @@ -362,20 +415,152 @@ export async function selectTechSalesDashboardWithJoin( createdAt: techSalesRfqs.createdAt, updatedAt: techSalesRfqs.updatedAt, createdByName: sql`created_user.name`, + + // 아이템 정보 - rfqType에 따라 다른 테이블에서 조회 + itemName: sql` + CASE + WHEN ${techSalesRfqs.rfqType} = 'SHIP' THEN ship_items.item_list + WHEN ${techSalesRfqs.rfqType} = 'TOP' THEN top_items.item_list + WHEN ${techSalesRfqs.rfqType} = 'HULL' THEN hull_items.item_list + ELSE NULL + END + `, }) .from(techSalesRfqs) - .leftJoin(itemShipbuilding, sql`${techSalesRfqs.itemShipbuildingId} = ${itemShipbuilding.id}`) - .leftJoin(sql`${users} AS created_user`, sql`${techSalesRfqs.createdBy} = created_user.id`); - - // where 조건 적용 - const queryWithWhere = where ? query.where(where) : query; + .leftJoin(sql`${users} AS created_user`, sql`${techSalesRfqs.createdBy} = created_user.id`) + // 아이템 정보 조인 + .leftJoin( + sql`( + SELECT DISTINCT ON (rfq_id) + tri.rfq_id, + ship.item_list + FROM tech_sales_rfq_items tri + LEFT JOIN item_shipbuilding ship ON tri.item_shipbuilding_id = ship.id + WHERE tri.item_type = 'SHIP' + ORDER BY rfq_id, tri.id + ) AS ship_items`, + sql`ship_items.rfq_id = ${techSalesRfqs.id} AND ${techSalesRfqs.rfqType} = 'SHIP'` + ) + .leftJoin( + sql`( + SELECT DISTINCT ON (rfq_id) + tri.rfq_id, + top.item_list + FROM tech_sales_rfq_items tri + LEFT JOIN item_offshore_top top ON tri.item_offshore_top_id = top.id + WHERE tri.item_type = 'TOP' + ORDER BY rfq_id, tri.id + ) AS top_items`, + sql`top_items.rfq_id = ${techSalesRfqs.id} AND ${techSalesRfqs.rfqType} = 'TOP'` + ) + .leftJoin( + sql`( + SELECT DISTINCT ON (rfq_id) + tri.rfq_id, + hull.item_list + FROM tech_sales_rfq_items tri + LEFT JOIN item_offshore_hull hull ON tri.item_offshore_hull_id = hull.id + WHERE tri.item_type = 'HULL' + ORDER BY rfq_id, tri.id + ) AS hull_items`, + sql`hull_items.rfq_id = ${techSalesRfqs.id} AND ${techSalesRfqs.rfqType} = 'HULL'` + ); + + // rfqType 필터링 + const conditions = []; + if (rfqType) { + conditions.push(sql`${techSalesRfqs.rfqType} = ${rfqType}`); + } + if (where) { + conditions.push(where); + } + + if (conditions.length > 0) { + query = query.where(sql`${sql.join(conditions, sql` AND `)}`); + } + // orderBy 적용 const queryWithOrderBy = orderBy?.length - ? queryWithWhere.orderBy(...orderBy) - : queryWithWhere.orderBy(desc(techSalesRfqs.updatedAt)); + ? query.orderBy(...orderBy) + : query.orderBy(desc(techSalesRfqs.updatedAt)); // offset과 limit 적용 후 실행 return queryWithOrderBy.offset(offset).limit(limit); } +/** + * 단일 벤더 견적서 직접 조인 조회 (단일 견적서 상세용) + */ +export async function selectSingleTechSalesVendorQuotationWithJoin( + tx: PgTransaction, + quotationId: number +) { + const result = await tx.select({ + // 견적 기본 정보 + id: techSalesVendorQuotations.id, + rfqId: techSalesVendorQuotations.rfqId, + vendorId: techSalesVendorQuotations.vendorId, + + // 견적 상세 정보 + quotationCode: techSalesVendorQuotations.quotationCode, + quotationVersion: techSalesVendorQuotations.quotationVersion, + totalPrice: techSalesVendorQuotations.totalPrice, + currency: techSalesVendorQuotations.currency, + validUntil: techSalesVendorQuotations.validUntil, + status: techSalesVendorQuotations.status, + remark: techSalesVendorQuotations.remark, + rejectionReason: techSalesVendorQuotations.rejectionReason, + + // 날짜 정보 + submittedAt: techSalesVendorQuotations.submittedAt, + acceptedAt: techSalesVendorQuotations.acceptedAt, + createdAt: techSalesVendorQuotations.createdAt, + updatedAt: techSalesVendorQuotations.updatedAt, + + // 생성/수정 사용자 + createdBy: techSalesVendorQuotations.createdBy, + updatedBy: techSalesVendorQuotations.updatedBy, + + // RFQ 정보 + rfqCode: techSalesRfqs.rfqCode, + rfqType: techSalesRfqs.rfqType, + rfqStatus: techSalesRfqs.status, + dueDate: techSalesRfqs.dueDate, + rfqSendDate: techSalesRfqs.rfqSendDate, + materialCode: techSalesRfqs.materialCode, + description: techSalesRfqs.description, + rfqRemark: techSalesRfqs.remark, + picCode: techSalesRfqs.picCode, + + // RFQ 생성자 정보 + rfqCreatedBy: techSalesRfqs.createdBy, + rfqCreatedByName: sql`rfq_created_user.name`, + rfqCreatedByEmail: sql`rfq_created_user.email`, + + // 벤더 정보 + vendorName: techVendors.vendorName, + vendorCode: techVendors.vendorCode, + vendorCountry: techVendors.country, + vendorEmail: techVendors.email, + vendorPhone: techVendors.phone, + + // 프로젝트 정보 + biddingProjectId: techSalesRfqs.biddingProjectId, + pspid: biddingProjects.pspid, + projNm: biddingProjects.projNm, + sector: biddingProjects.sector, + projMsrm: biddingProjects.projMsrm, + ptypeNm: biddingProjects.ptypeNm, + + }) + .from(techSalesVendorQuotations) + .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id)) + .leftJoin(techVendors, eq(techSalesVendorQuotations.vendorId, techVendors.id)) + .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id)) + .leftJoin(sql`${users} AS rfq_created_user`, sql`${techSalesRfqs.createdBy} = rfq_created_user.id`) + .where(eq(techSalesVendorQuotations.id, quotationId)); + + return result[0] || null; +} + diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts index 26117452..d74c54b4 100644 --- a/lib/techsales-rfq/service.ts +++ b/lib/techsales-rfq/service.ts @@ -6,11 +6,13 @@ import { techSalesRfqs, techSalesVendorQuotations, techSalesAttachments, - itemShipbuilding, users, - techSalesRfqComments + techSalesRfqComments, + techSalesRfqItems, + projectSeries, + biddingProjects } from "@/db/schema"; -import { and, desc, eq, ilike, or, sql, ne } from "drizzle-orm"; +import { and, desc, eq, ilike, or, sql, inArray } from "drizzle-orm"; import { unstable_cache } from "@/lib/unstable-cache"; import { filterColumns } from "@/lib/filter-columns"; import { getErrorMessage } from "@/lib/handle-error"; @@ -20,117 +22,22 @@ import { countTechSalesRfqsWithJoin, selectTechSalesVendorQuotationsWithJoin, countTechSalesVendorQuotationsWithJoin, - selectTechSalesDashboardWithJoin + selectTechSalesDashboardWithJoin, + selectSingleTechSalesVendorQuotationWithJoin } from "./repository"; import { GetTechSalesRfqsSchema } from "./validations"; import { getServerSession } from "next-auth/next"; import { authOptions } from "@/app/api/auth/[...nextauth]/route"; import { sendEmail } from "../mail/sendEmail"; import { formatDate, formatDateToQuarter } from "../utils"; +import { techVendors, techVendorPossibleItems } from "@/db/schema/techVendors"; // 정렬 타입 정의 // 의도적으로 any 사용 - drizzle ORM의 orderBy 타입이 복잡함 // eslint-disable-next-line @typescript-eslint/no-explicit-any type OrderByType = any; -// 시리즈 스냅샷 타입 정의 -interface SeriesSnapshot { - pspid: string; - sersNo: string; - scDt?: string; - klDt?: string; - lcDt?: string; - dlDt?: string; - dockNo?: string; - dockNm?: string; - projNo?: string; - post1?: string; -} - -// JSON 필드 식별 함수 -function isJsonField(fieldId: string): boolean { - const jsonFields = ['projNm', 'ptypeNm', 'projMsrm', 'sector', 'pspid']; - return jsonFields.includes(fieldId); -} - -// JSON 필드 필터링 함수 -function filterJsonFields(filters: Filter[], joinOperator: "and" | "or") { - const joinFn = joinOperator === "and" ? and : or; - - const conditions = filters.map(filter => { - const fieldId = filter.id as string; - const value = filter.value; - - switch (fieldId) { - case 'projNm': - return createJsonFieldCondition('projNm', filter.operator, value); - case 'ptypeNm': - return createJsonFieldCondition('ptypeNm', filter.operator, value); - case 'sector': - return createJsonFieldCondition('sector', filter.operator, value); - case 'pspid': - return createJsonFieldCondition('pspid', filter.operator, value); - case 'projMsrm': - // 숫자 필드는 특별 처리 - return createJsonNumberFieldCondition('projMsrm', filter.operator, value); - default: - return undefined; - } - }).filter(Boolean); - - return conditions.length > 0 ? joinFn(...conditions) : undefined; -} - -// JSON 텍스트 필드 조건 생성 -function createJsonFieldCondition(fieldName: string, operator: string, value: unknown) { - const jsonPath = `${techSalesRfqs.projectSnapshot}->>'${fieldName}'`; - - switch (operator) { - case 'eq': - return sql`${sql.raw(jsonPath)} = ${value}`; - case 'ne': - return sql`${sql.raw(jsonPath)} != ${value}`; - case 'iLike': - return sql`${sql.raw(jsonPath)} ILIKE ${'%' + value + '%'}`; - case 'notILike': - return sql`${sql.raw(jsonPath)} NOT ILIKE ${'%' + value + '%'}`; - case 'isEmpty': - return sql`(${sql.raw(jsonPath)} IS NULL OR ${sql.raw(jsonPath)} = '')`; - case 'isNotEmpty': - return sql`(${sql.raw(jsonPath)} IS NOT NULL AND ${sql.raw(jsonPath)} != '')`; - default: - return undefined; - } -} -// JSON 숫자 필드 조건 생성 -function createJsonNumberFieldCondition(fieldName: string, operator: string, value: unknown) { - const jsonPath = `(${techSalesRfqs.projectSnapshot}->>'${fieldName}')::int`; - const numValue = parseInt(value as string, 10); - - if (isNaN(numValue)) return undefined; - - switch (operator) { - case 'eq': - return sql`${sql.raw(jsonPath)} = ${numValue}`; - case 'ne': - return sql`${sql.raw(jsonPath)} != ${numValue}`; - case 'gt': - return sql`${sql.raw(jsonPath)} > ${numValue}`; - case 'gte': - return sql`${sql.raw(jsonPath)} >= ${numValue}`; - case 'lt': - return sql`${sql.raw(jsonPath)} < ${numValue}`; - case 'lte': - return sql`${sql.raw(jsonPath)} <= ${numValue}`; - case 'isEmpty': - return sql`${techSalesRfqs.projectSnapshot}->>'${fieldName}' IS NULL`; - case 'isNotEmpty': - return sql`${techSalesRfqs.projectSnapshot}->>'${fieldName}' IS NOT NULL`; - default: - return undefined; - } -} /** * 연도별 순차 RFQ 코드 생성 함수 (다중 생성 지원) @@ -183,141 +90,142 @@ async function generateRfqCodes(tx: any, count: number, year?: number): Promise< * * 나머지 벤더, 첨부파일 등은 생성 이후 처리 */ -export async function createTechSalesRfq(input: { - // 프로젝트 관련 - biddingProjectId: number; - // 조선 아이템 관련 - itemShipbuildingId: number; - // 자재 관련 (자재그룹 코드들을 CSV로) - materialGroupCodes: string[]; - // 기본 정보 - dueDate?: Date; - remark?: string; - createdBy: number; -}) { - unstable_noStore(); - console.log('🔍 createTechSalesRfq 호출됨:', { - biddingProjectId: input.biddingProjectId, - itemShipbuildingId: input.itemShipbuildingId, - materialGroupCodes: input.materialGroupCodes, - dueDate: input.dueDate, - remark: input.remark, - createdBy: input.createdBy - }); +// export async function createTechSalesRfq(input: { +// // 프로젝트 관련 +// biddingProjectId: number; +// // 조선 아이템 관련 +// itemShipbuildingId: number; +// // 자재 관련 (자재그룹 코드들을 CSV로) +// materialGroupCodes: string[]; +// // 기본 정보 +// dueDate?: Date; +// remark?: string; +// createdBy: number; +// }) { +// unstable_noStore(); +// console.log('🔍 createTechSalesRfq 호출됨:', { +// biddingProjectId: input.biddingProjectId, +// itemShipbuildingId: input.itemShipbuildingId, +// materialGroupCodes: input.materialGroupCodes, +// dueDate: input.dueDate, +// remark: input.remark, +// createdBy: input.createdBy +// }); - try { - let result: typeof techSalesRfqs.$inferSelect | undefined; +// try { +// let result: typeof techSalesRfqs.$inferSelect | undefined; - // 트랜잭션으로 처리 - await db.transaction(async (tx) => { - // 실제 프로젝트 정보 조회 - const biddingProject = await tx.query.biddingProjects.findFirst({ - where: (biddingProjects, { eq }) => eq(biddingProjects.id, input.biddingProjectId) - }); - - if (!biddingProject) { - throw new Error(`프로젝트 ID ${input.biddingProjectId}를 찾을 수 없습니다.`); - } - - // 프로젝트 시리즈 정보 조회 - const seriesInfo = await tx.query.projectSeries.findMany({ - where: (projectSeries, { eq }) => eq(projectSeries.pspid, biddingProject.pspid) - }); - - // 프로젝트 스냅샷 생성 - const projectSnapshot = { - pspid: biddingProject.pspid, - projNm: biddingProject.projNm || undefined, - sector: biddingProject.sector || undefined, - projMsrm: biddingProject.projMsrm ? Number(biddingProject.projMsrm) : undefined, - kunnr: biddingProject.kunnr || undefined, - kunnrNm: biddingProject.kunnrNm || undefined, - cls1: biddingProject.cls1 || undefined, - cls1Nm: biddingProject.cls1Nm || undefined, - ptype: biddingProject.ptype || undefined, - ptypeNm: biddingProject.ptypeNm || undefined, - pmodelCd: biddingProject.pmodelCd || undefined, - pmodelNm: biddingProject.pmodelNm || undefined, - pmodelSz: biddingProject.pmodelSz || undefined, - pmodelUom: biddingProject.pmodelUom || undefined, - txt04: biddingProject.txt04 || undefined, - txt30: biddingProject.txt30 || undefined, - estmPm: biddingProject.estmPm || undefined, - pspCreatedAt: biddingProject.createdAt, - pspUpdatedAt: biddingProject.updatedAt, - }; - - // 시리즈 스냅샷 생성 - const seriesSnapshot = seriesInfo.map(series => ({ - pspid: series.pspid, - sersNo: series.sersNo.toString(), - scDt: series.scDt || undefined, - klDt: series.klDt || undefined, - lcDt: series.lcDt || undefined, - dlDt: series.dlDt || undefined, - dockNo: series.dockNo || undefined, - dockNm: series.dockNm || undefined, - projNo: series.projNo || undefined, - post1: series.post1 || undefined, - })); - - // RFQ 코드 생성 - const rfqCode = await generateRfqCodes(tx, 1); +// // 트랜잭션으로 처리 +// await db.transaction(async (tx) => { +// // 실제 프로젝트 정보 조회 +// const biddingProject = await tx.query.biddingProjects.findFirst({ +// where: (biddingProjects, { eq }) => eq(biddingProjects.id, input.biddingProjectId) +// }); + +// if (!biddingProject) { +// throw new Error(`프로젝트 ID ${input.biddingProjectId}를 찾을 수 없습니다.`); +// } + +// // 프로젝트 시리즈 정보 조회 +// const seriesInfo = await tx.query.projectSeries.findMany({ +// where: (projectSeries, { eq }) => eq(projectSeries.pspid, biddingProject.pspid) +// }); + +// // 프로젝트 스냅샷 생성 +// const projectSnapshot = { +// pspid: biddingProject.pspid, +// projNm: biddingProject.projNm || undefined, +// sector: biddingProject.sector || undefined, +// projMsrm: biddingProject.projMsrm ? Number(biddingProject.projMsrm) : undefined, +// kunnr: biddingProject.kunnr || undefined, +// kunnrNm: biddingProject.kunnrNm || undefined, +// cls1: biddingProject.cls1 || undefined, +// cls1Nm: biddingProject.cls1Nm || undefined, +// ptype: biddingProject.ptype || undefined, +// ptypeNm: biddingProject.ptypeNm || undefined, +// pmodelCd: biddingProject.pmodelCd || undefined, +// pmodelNm: biddingProject.pmodelNm || undefined, +// pmodelSz: biddingProject.pmodelSz || undefined, +// pmodelUom: biddingProject.pmodelUom || undefined, +// txt04: biddingProject.txt04 || undefined, +// txt30: biddingProject.txt30 || undefined, +// estmPm: biddingProject.estmPm || undefined, +// pspCreatedAt: biddingProject.createdAt, +// pspUpdatedAt: biddingProject.updatedAt, +// }; + +// // 시리즈 스냅샷 생성 +// const seriesSnapshot = seriesInfo.map(series => ({ +// pspid: series.pspid, +// sersNo: series.sersNo.toString(), +// scDt: series.scDt || undefined, +// klDt: series.klDt || undefined, +// lcDt: series.lcDt || undefined, +// dlDt: series.dlDt || undefined, +// dockNo: series.dockNo || undefined, +// dockNm: series.dockNm || undefined, +// projNo: series.projNo || undefined, +// post1: series.post1 || undefined, +// })); + +// // RFQ 코드 생성 +// const rfqCode = await generateRfqCodes(tx, 1); - // 기본 due date 설정 (7일 후) - const dueDate = input.dueDate || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); +// // 기본 due date 설정 (7일 후) +// const dueDate = input.dueDate || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); - // itemShipbuildingId 유효성 검증 - console.log('🔍 itemShipbuildingId 검증:', input.itemShipbuildingId); - const existingItemShipbuilding = await tx.query.itemShipbuilding.findFirst({ - where: (itemShipbuilding, { eq }) => eq(itemShipbuilding.id, input.itemShipbuildingId), - columns: { id: true, itemCode: true, itemList: true } - }); +// // itemShipbuildingId 유효성 검증 +// console.log('🔍 itemShipbuildingId 검증:', input.itemShipbuildingId); +// const existingItemShipbuilding = await tx.query.itemShipbuilding.findFirst({ +// where: (itemShipbuilding, { eq }) => eq(itemShipbuilding.id, input.itemShipbuildingId), +// columns: { id: true, itemCode: true, itemList: true } +// }); - if (!existingItemShipbuilding) { - throw new Error(`itemShipbuildingId ${input.itemShipbuildingId}에 해당하는 itemShipbuilding 레코드를 찾을 수 없습니다.`); - } +// if (!existingItemShipbuilding) { +// throw new Error(`itemShipbuildingId ${input.itemShipbuildingId}에 해당하는 itemShipbuilding 레코드를 찾을 수 없습니다.`); +// } - console.log('✅ itemShipbuilding 찾음:', existingItemShipbuilding); +// console.log('✅ itemShipbuilding 찾음:', existingItemShipbuilding); - // 새 기술영업 RFQ 작성 (스냅샷 포함) - const [newRfq] = await tx.insert(techSalesRfqs).values({ - rfqCode: rfqCode[0], - itemShipbuildingId: input.itemShipbuildingId, - biddingProjectId: input.biddingProjectId, - materialCode: input.materialGroupCodes.join(','), // 모든 materialCode를 CSV로 저장 - dueDate, - remark: input.remark, - createdBy: input.createdBy, - updatedBy: input.createdBy, - // 스냅샷 데이터 추가 - projectSnapshot, - seriesSnapshot, - }).returning(); +// // 새 기술영업 RFQ 작성 (스냅샷 포함) +// const [newRfq] = await tx.insert(techSalesRfqs).values({ +// rfqCode: rfqCode[0], +// rfqType: "SHIP", +// itemShipbuildingId: input.itemShipbuildingId, +// biddingProjectId: input.biddingProjectId, +// materialCode: input.materialGroupCodes.join(','), // 모든 materialCode를 CSV로 저장 +// dueDate, +// remark: input.remark, +// createdBy: input.createdBy, +// updatedBy: input.createdBy, +// // 스냅샷 데이터 추가 +// projectSnapshot, +// seriesSnapshot, +// }).returning(); - result = newRfq; - }); +// result = newRfq; +// }); - // 캐시 무효화 - revalidateTag("techSalesRfqs"); - revalidatePath("/evcp/budgetary-tech-sales-ship"); +// // 캐시 무효화 +// revalidateTag("techSalesRfqs"); +// revalidatePath("/evcp/budgetary-tech-sales-ship"); - if (!result) { - throw new Error(`RFQ 생성에 실패했습니다. 입력값: ${JSON.stringify(input)}`); - } +// if (!result) { +// throw new Error(`RFQ 생성에 실패했습니다. 입력값: ${JSON.stringify(input)}`); +// } - return { data: [result], error: null }; - } catch (err) { - console.error("Error creating RFQ:", err); - return { data: null, error: getErrorMessage(err) }; - } -} +// return { data: [result], error: null }; +// } catch (err) { +// console.error("Error creating RFQ:", err); +// return { data: null, error: getErrorMessage(err) }; +// } +// } /** * 직접 조인을 사용하여 RFQ 데이터 조회하는 함수 * 페이지네이션, 필터링, 정렬 등 지원 */ -export async function getTechSalesRfqsWithJoin(input: GetTechSalesRfqsSchema) { +export async function getTechSalesRfqsWithJoin(input: GetTechSalesRfqsSchema & { rfqType?: "SHIP" | "TOP" | "HULL" }) { return unstable_cache( async () => { try { @@ -341,26 +249,14 @@ export async function getTechSalesRfqsWithJoin(input: GetTechSalesRfqsSchema) { }); } - // 고급 필터 조건 생성 (JSON 필드 지원) + // 고급 필터 조건 생성 let advancedWhere; if (advancedFilters.length > 0) { - // 일반 필드와 JSON 필드 분리 - const normalFilters = advancedFilters.filter(f => !isJsonField(f.id as string)); - const jsonFilters = advancedFilters.filter(f => isJsonField(f.id as string)); - - const normalWhere = normalFilters.length > 0 ? filterColumns({ + advancedWhere = filterColumns({ table: techSalesRfqs, - filters: normalFilters, + filters: advancedFilters, joinOperator: advancedJoinOperator, - }) : undefined; - - const jsonWhere = jsonFilters.length > 0 ? filterJsonFields(jsonFilters, advancedJoinOperator) : undefined; - - if (normalWhere && jsonWhere) { - advancedWhere = advancedJoinOperator === "and" ? and(normalWhere, jsonWhere) : or(normalWhere, jsonWhere); - } else { - advancedWhere = normalWhere || jsonWhere; - } + }); } // 전역 검색 조건 @@ -370,10 +266,8 @@ export async function getTechSalesRfqsWithJoin(input: GetTechSalesRfqsSchema) { globalWhere = or( ilike(techSalesRfqs.rfqCode, s), ilike(techSalesRfqs.materialCode, s), - // JSON 필드 검색 - sql`${techSalesRfqs.projectSnapshot}->>'pspid' ILIKE ${s}`, - sql`${techSalesRfqs.projectSnapshot}->>'projNm' ILIKE ${s}`, - sql`${techSalesRfqs.projectSnapshot}->>'ptypeNm' ILIKE ${s}` + ilike(techSalesRfqs.description, s), + ilike(techSalesRfqs.remark, s) ); } @@ -404,49 +298,16 @@ export async function getTechSalesRfqsWithJoin(input: GetTechSalesRfqsSchema) { return item.desc ? desc(techSalesRfqs.rfqCode) : techSalesRfqs.rfqCode; case 'materialCode': return item.desc ? desc(techSalesRfqs.materialCode) : techSalesRfqs.materialCode; - case 'itemName': - // itemName은 조인된 itemShipbuilding.itemList 필드 - return item.desc ? desc(sql`item_shipbuilding.item_list`) : sql`item_shipbuilding.item_list`; + case 'description': + return item.desc ? desc(techSalesRfqs.description) : techSalesRfqs.description; case 'status': return item.desc ? desc(techSalesRfqs.status) : techSalesRfqs.status; case 'dueDate': return item.desc ? desc(techSalesRfqs.dueDate) : techSalesRfqs.dueDate; case 'rfqSendDate': return item.desc ? desc(techSalesRfqs.rfqSendDate) : techSalesRfqs.rfqSendDate; - case 'projNm': - // JSON 필드에서 추출된 프로젝트명 - return item.desc ? desc(sql`${techSalesRfqs.projectSnapshot}->>'projNm'`) : sql`${techSalesRfqs.projectSnapshot}->>'projNm'`; - case 'projMsrm': - // JSON 필드에서 추출된 척수 (정수 캐스팅) - return item.desc ? desc(sql`(${techSalesRfqs.projectSnapshot}->>'projMsrm')::int`) : sql`(${techSalesRfqs.projectSnapshot}->>'projMsrm')::int`; - case 'ptypeNm': - // JSON 필드에서 추출된 선종명 - return item.desc ? desc(sql`${techSalesRfqs.projectSnapshot}->>'ptypeNm'`) : sql`${techSalesRfqs.projectSnapshot}->>'ptypeNm'`; - case 'quotationCount': - // 서브쿼리로 계산된 견적수 - repository의 SELECT에서 정의한 컬럼명 사용 - return item.desc ? desc(sql`( - SELECT COUNT(*) - FROM tech_sales_vendor_quotations - WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id} - )`) : sql`( - SELECT COUNT(*) - FROM tech_sales_vendor_quotations - WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id} - )`; - case 'attachmentCount': - // 서브쿼리로 계산된 첨부파일수 - repository의 SELECT에서 정의한 컬럼명 사용 - return item.desc ? desc(sql`( - SELECT COUNT(*) - FROM tech_sales_attachments - WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id} - )`) : sql`( - SELECT COUNT(*) - FROM tech_sales_attachments - WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id} - )`; - case 'createdByName': - // 조인된 사용자명 - return item.desc ? desc(sql`created_user.name`) : sql`created_user.name`; + case 'remark': + return item.desc ? desc(techSalesRfqs.remark) : techSalesRfqs.remark; case 'createdAt': return item.desc ? desc(techSalesRfqs.createdAt) : techSalesRfqs.createdAt; case 'updatedAt': @@ -457,30 +318,30 @@ export async function getTechSalesRfqsWithJoin(input: GetTechSalesRfqsSchema) { }); } - // 트랜잭션 내부에서 Repository 호출 - const { data, total } = await db.transaction(async (tx) => { - const data = await selectTechSalesRfqsWithJoin(tx, { - where: finalWhere, - orderBy, - offset, - limit: input.perPage, - }); - - const total = await countTechSalesRfqsWithJoin(tx, finalWhere); - return { data, total }; + // Repository 함수 호출 - rfqType 매개변수 추가 + return await db.transaction(async (tx) => { + const [data, total] = await Promise.all([ + selectTechSalesRfqsWithJoin(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + rfqType: input.rfqType, + }), + countTechSalesRfqsWithJoin(tx, finalWhere, input.rfqType), + ]); + + const pageCount = Math.ceil(Number(total) / input.perPage); + return { data, pageCount, total: Number(total) }; }); - - const pageCount = Math.ceil(total / input.perPage); - - return { data, pageCount, total }; } catch (err) { console.error("Error fetching RFQs with join:", err); return { data: [], pageCount: 0, total: 0 }; } }, - [JSON.stringify(input)], // 캐싱 키 + [JSON.stringify(input)], { - revalidate: 60, // 1분간 캐시 + revalidate: 60, tags: ["techSalesRfqs"], } )(); @@ -497,6 +358,7 @@ export async function getTechSalesVendorQuotationsWithJoin(input: { sort?: { id: string; desc: boolean }[]; page: number; perPage: number; + rfqType?: "SHIP" | "TOP" | "HULL"; // rfqType 매개변수 추가 }) { return unstable_cache( async () => { @@ -610,6 +472,7 @@ export async function getTechSalesDashboardWithJoin(input: { sort?: { id: string; desc: boolean }[]; page: number; perPage: number; + rfqType?: "SHIP" | "TOP" | "HULL"; // rfqType 매개변수 추가 }) { unstable_noStore(); // 대시보드는 항상 최신 데이터를 보여주기 위해 캐시하지 않음 @@ -630,9 +493,7 @@ export async function getTechSalesDashboardWithJoin(input: { globalWhere = or( ilike(techSalesRfqs.rfqCode, s), ilike(techSalesRfqs.materialCode, s), - // JSON 필드 검색 - sql`${techSalesRfqs.projectSnapshot}->>'pspid' ILIKE ${s}`, - sql`${techSalesRfqs.projectSnapshot}->>'projNm' ILIKE ${s}` + ilike(techSalesRfqs.description, s) ); } @@ -673,6 +534,7 @@ export async function getTechSalesDashboardWithJoin(input: { orderBy, offset, limit: input.perPage, + rfqType: input.rfqType, // rfqType 매개변수 추가 }); }); @@ -1036,26 +898,11 @@ export async function sendTechSalesRfqToVendors(input: { rfqSendDate: true, remark: true, materialCode: true, - projectSnapshot: true, - seriesSnapshot: true, + description: true, + rfqType: true, }, with: { - itemShipbuilding: { - columns: { - id: true, - itemCode: true, - itemList: true, - } - }, - biddingProject: { - columns: { - id: true, - pspid: true, - projNm: true, - sector: true, - ptypeNm: true, - } - }, + biddingProject: true, createdByUser: { columns: { id: true, @@ -1174,18 +1021,24 @@ export async function sendTechSalesRfqToVendors(input: { // 대표 언어 결정 (첫 번째 사용자의 언어 또는 기본값) const language = vendorUsers[0]?.language || "ko"; - // 시리즈 정보 처리 - const seriesInfo = rfq.seriesSnapshot ? rfq.seriesSnapshot.map((series: SeriesSnapshot) => ({ - sersNo: series.sersNo, - klQuarter: series.klDt ? formatDateToQuarter(series.klDt) : '', - scDt: series.scDt, - lcDt: series.lcDt, - dlDt: series.dlDt, - dockNo: series.dockNo, - dockNm: series.dockNm, - projNo: series.projNo, - post1: series.post1, - })) : []; + // 시리즈 정보 처리 - 직접 조회 + const seriesInfo = rfq.biddingProject?.pspid ? await db.query.projectSeries.findMany({ + where: eq(projectSeries.pspid, rfq.biddingProject.pspid) + }).then(series => series.map(s => ({ + sersNo: s.sersNo.toString(), + klQuarter: s.klDt ? formatDateToQuarter(s.klDt) : '', + scDt: s.scDt, + lcDt: s.lcDt, + dlDt: s.dlDt, + dockNo: s.dockNo, + dockNm: s.dockNm, + projNo: s.projNo, + post1: s.post1, + }))) : []; + + // RFQ 아이템 목록 조회 + const rfqItemsResult = await getTechSalesRfqItems(rfq.id); + const rfqItems = rfqItemsResult.data || []; // 이메일 컨텍스트 구성 const emailContext = { @@ -1193,13 +1046,22 @@ export async function sendTechSalesRfqToVendors(input: { rfq: { id: rfq.id, code: rfq.rfqCode, - title: rfq.itemShipbuilding?.itemList || '', + title: rfqItems.length > 0 ? rfqItems.map(item => item.itemList).join(', ') : '', projectCode: rfq.biddingProject?.pspid || '', projectName: rfq.biddingProject?.projNm || '', description: rfq.remark || '', dueDate: rfq.dueDate ? formatDate(rfq.dueDate) : 'N/A', materialCode: rfq.materialCode || '', + type: rfq.rfqType || 'SHIP', }, + items: rfqItems.map(item => ({ + itemCode: item.itemCode, + itemList: item.itemList, + workType: item.workType, + shipType: item.shipType, + subItemName: item.subItemName, + itemType: item.itemType, + })), vendor: { id: quotation.vendor.id, code: quotation.vendor.vendorCode || '', @@ -1211,24 +1073,24 @@ export async function sendTechSalesRfqToVendors(input: { }, project: { // 기본 정보 - id: rfq.projectSnapshot?.pspid || rfq.biddingProject?.pspid || '', - name: rfq.projectSnapshot?.projNm || rfq.biddingProject?.projNm || '', - sector: rfq.projectSnapshot?.sector || rfq.biddingProject?.sector || '', - shipType: rfq.projectSnapshot?.ptypeNm || rfq.biddingProject?.ptypeNm || '', + id: rfq.biddingProject?.pspid || '', + name: rfq.biddingProject?.projNm || '', + sector: rfq.biddingProject?.sector || '', + shipType: rfq.biddingProject?.ptypeNm || '', // 추가 프로젝트 정보 - shipCount: rfq.projectSnapshot?.projMsrm || 0, - ownerCode: rfq.projectSnapshot?.kunnr || '', - ownerName: rfq.projectSnapshot?.kunnrNm || '', - classCode: rfq.projectSnapshot?.cls1 || '', - className: rfq.projectSnapshot?.cls1Nm || '', - shipTypeCode: rfq.projectSnapshot?.ptype || '', - shipModelCode: rfq.projectSnapshot?.pmodelCd || '', - shipModelName: rfq.projectSnapshot?.pmodelNm || '', - shipModelSize: rfq.projectSnapshot?.pmodelSz || '', - shipModelUnit: rfq.projectSnapshot?.pmodelUom || '', - estimateStatus: rfq.projectSnapshot?.txt30 || '', - projectManager: rfq.projectSnapshot?.estmPm || '', + shipCount: rfq.biddingProject?.projMsrm || 0, + ownerCode: rfq.biddingProject?.kunnr || '', + ownerName: rfq.biddingProject?.kunnrNm || '', + classCode: rfq.biddingProject?.cls1 || '', + className: rfq.biddingProject?.cls1Nm || '', + shipTypeCode: rfq.biddingProject?.ptype || '', + shipModelCode: rfq.biddingProject?.pmodelCd || '', + shipModelName: rfq.biddingProject?.pmodelNm || '', + shipModelSize: rfq.biddingProject?.pmodelSz || '', + shipModelUnit: rfq.biddingProject?.pmodelUom || '', + estimateStatus: rfq.biddingProject?.txt30 || '', + projectManager: rfq.biddingProject?.estmPm || '', }, series: seriesInfo, details: { @@ -1244,8 +1106,8 @@ export async function sendTechSalesRfqToVendors(input: { await sendEmail({ to: vendorEmailsString, subject: isResend - ? `[기술영업 RFQ 재전송] ${rfq.rfqCode} - ${rfq.itemShipbuilding?.itemList || '견적 요청'} ${emailContext.versionInfo}` - : `[기술영업 RFQ] ${rfq.rfqCode} - ${rfq.itemShipbuilding?.itemList || '견적 요청'}`, + ? `[기술영업 RFQ 재전송] ${rfq.rfqCode} - ${rfqItems.length > 0 ? rfqItems.map(item => item.itemList).join(', ') : '견적 요청'} ${emailContext.versionInfo}` + : `[기술영업 RFQ] ${rfq.rfqCode} - ${rfqItems.length > 0 ? rfqItems.map(item => item.itemList).join(', ') : '견적 요청'}`, template: 'tech-sales-rfq-invite-ko', // 기술영업용 템플릿 context: emailContext, cc: sender.email, // 발신자를 CC에 추가 @@ -1275,36 +1137,91 @@ export async function sendTechSalesRfqToVendors(input: { } /** - * 벤더용 기술영업 RFQ 견적서 조회 + * 벤더용 기술영업 RFQ 견적서 조회 (withJoin 사용) */ export async function getTechSalesVendorQuotation(quotationId: number) { unstable_noStore(); try { - const quotation = await db.query.techSalesVendorQuotations.findFirst({ - where: eq(techSalesVendorQuotations.id, quotationId), - with: { - rfq: { - with: { - itemShipbuilding: true, - biddingProject: true, - createdByUser: { - columns: { - id: true, - name: true, - email: true, - } - } - } - }, - vendor: true, - } + const quotation = await db.transaction(async (tx) => { + return await selectSingleTechSalesVendorQuotationWithJoin(tx, quotationId); }); if (!quotation) { return { data: null, error: "견적서를 찾을 수 없습니다." }; } - return { data: quotation, error: null }; + // RFQ 아이템 정보도 함께 조회 + const itemsResult = await getTechSalesRfqItems(quotation.rfqId); + const items = itemsResult.data || []; + + // 기존 구조와 호환되도록 데이터 재구성 + const formattedQuotation = { + id: quotation.id, + rfqId: quotation.rfqId, + vendorId: quotation.vendorId, + quotationCode: quotation.quotationCode, + quotationVersion: quotation.quotationVersion, + totalPrice: quotation.totalPrice, + currency: quotation.currency, + validUntil: quotation.validUntil, + status: quotation.status, + remark: quotation.remark, + rejectionReason: quotation.rejectionReason, + submittedAt: quotation.submittedAt, + acceptedAt: quotation.acceptedAt, + createdAt: quotation.createdAt, + updatedAt: quotation.updatedAt, + createdBy: quotation.createdBy, + updatedBy: quotation.updatedBy, + + // RFQ 정보 + rfq: { + id: quotation.rfqId, + rfqCode: quotation.rfqCode, + rfqType: quotation.rfqType, + status: quotation.rfqStatus, + dueDate: quotation.dueDate, + rfqSendDate: quotation.rfqSendDate, + materialCode: quotation.materialCode, + description: quotation.description, + remark: quotation.rfqRemark, + picCode: quotation.picCode, + createdBy: quotation.rfqCreatedBy, + biddingProjectId: quotation.biddingProjectId, + + // 아이템 정보 추가 + items: items, + + // 생성자 정보 + createdByUser: { + id: quotation.rfqCreatedBy, + name: quotation.rfqCreatedByName, + email: quotation.rfqCreatedByEmail, + }, + + // 프로젝트 정보 + biddingProject: quotation.biddingProjectId ? { + id: quotation.biddingProjectId, + pspid: quotation.pspid, + projNm: quotation.projNm, + sector: quotation.sector, + projMsrm: quotation.projMsrm, + ptypeNm: quotation.ptypeNm, + } : null, + }, + + // 벤더 정보 + vendor: { + id: quotation.vendorId, + vendorName: quotation.vendorName, + vendorCode: quotation.vendorCode, + country: quotation.vendorCountry, + email: quotation.vendorEmail, + phone: quotation.vendorPhone, + } + }; + + return { data: formattedQuotation, error: null }; } catch (err) { console.error("Error fetching vendor quotation:", err); return { data: null, error: getErrorMessage(err) }; @@ -1469,34 +1386,16 @@ export async function getVendorQuotations(input: { search?: string; from?: string; to?: string; + rfqType?: "SHIP" | "TOP" | "HULL"; }, vendorId: string) { return unstable_cache( async () => { try { - // 디버깅 로그 추가 - console.log('🔍 [getVendorQuotations] 받은 파라미터:'); - console.log(' 📊 기본 정보:', { + console.log('🔍 [getVendorQuotations] 호출됨:', { vendorId, - page: input.page, - perPage: input.perPage, - }); - console.log(' 🔧 정렬 정보:', { - sort: input.sort, - sortLength: input.sort?.length, - sortDetails: input.sort?.map(s => `${s.id}:${s.desc ? 'DESC' : 'ASC'}`) - }); - console.log(' 🔍 필터 정보:', { - filters: input.filters, - filtersLength: input.filters?.length, - joinOperator: input.joinOperator, - basicFilters: input.basicFilters, - basicFiltersLength: input.basicFilters?.length, - basicJoinOperator: input.basicJoinOperator - }); - console.log(' 🔎 검색 정보:', { - search: input.search, - from: input.from, - to: input.to + vendorIdParsed: parseInt(vendorId), + rfqType: input.rfqType, + inputData: input }); const { page, perPage, sort, filters = [], search = "", from = "", to = "" } = input; @@ -1504,7 +1403,18 @@ export async function getVendorQuotations(input: { const limit = perPage; // 기본 조건: 해당 벤더의 견적서만 조회 - const baseConditions = [eq(techSalesVendorQuotations.vendorId, parseInt(vendorId))]; + const vendorIdNum = parseInt(vendorId); + if (isNaN(vendorIdNum)) { + console.error('❌ [getVendorQuotations] Invalid vendorId:', vendorId); + return { data: [], pageCount: 0, total: 0 }; + } + + const baseConditions = [eq(techSalesVendorQuotations.vendorId, vendorIdNum)]; + + // rfqType 필터링 추가 + if (input.rfqType) { + baseConditions.push(eq(techSalesRfqs.rfqType, input.rfqType)); + } // 검색 조건 추가 if (search) { @@ -1528,125 +1438,13 @@ export async function getVendorQuotations(input: { // 고급 필터 처리 if (filters.length > 0) { - // 조인된 테이블의 컬럼들을 분리 - const joinedColumnFilters = []; - const baseTableFilters = []; - - for (const filter of filters) { - const filterId = filter.id as string; - - // 조인된 컬럼들인지 확인 - if (['rfqCode', 'materialCode', 'dueDate', 'rfqStatus', 'itemName', 'projNm', 'pspid', 'sector', 'kunnrNm'].includes(filterId)) { - joinedColumnFilters.push(filter); - } else { - baseTableFilters.push(filter); - } - } - - // 기본 테이블 필터 처리 - if (baseTableFilters.length > 0) { - const filterWhere = filterColumns({ - table: techSalesVendorQuotations, - filters: baseTableFilters as Filter[], - joinOperator: input.joinOperator || "and", - }); - if (filterWhere) { - baseConditions.push(filterWhere); - } - } - - // 조인된 컬럼 필터 처리 - if (joinedColumnFilters.length > 0) { - const joinedConditions = joinedColumnFilters.map(filter => { - const filterId = filter.id as string; - const value = filter.value; - const operator = filter.operator || 'eq'; - - switch (filterId) { - case 'rfqCode': - if (operator === 'iLike') { - return ilike(techSalesRfqs.rfqCode, `%${value}%`); - } else if (operator === 'eq') { - return eq(techSalesRfqs.rfqCode, value as string); - } - break; - - case 'materialCode': - if (operator === 'iLike') { - return ilike(techSalesRfqs.materialCode, `%${value}%`); - } else if (operator === 'eq') { - return eq(techSalesRfqs.materialCode, value as string); - } - break; - - case 'dueDate': - if (operator === 'eq') { - return eq(techSalesRfqs.dueDate, new Date(value as string)); - } else if (operator === 'gte') { - return sql`${techSalesRfqs.dueDate} >= ${new Date(value as string)}`; - } else if (operator === 'lte') { - return sql`${techSalesRfqs.dueDate} <= ${new Date(value as string)}`; - } - break; - - case 'rfqStatus': - if (Array.isArray(value) && value.length > 0) { - return sql`${techSalesRfqs.status} IN (${value.map(v => `'${v}'`).join(',')})`; - } else if (typeof value === 'string') { - return eq(techSalesRfqs.status, value); - } - break; - - case 'itemName': - if (operator === 'iLike') { - return ilike(itemShipbuilding.itemList, `%${value}%`); - } else if (operator === 'eq') { - return eq(itemShipbuilding.itemList, value as string); - } - break; - - case 'projNm': - if (operator === 'iLike') { - return sql`${techSalesRfqs.projectSnapshot}->>'projNm' ILIKE ${'%' + value + '%'}`; - } else if (operator === 'eq') { - return sql`${techSalesRfqs.projectSnapshot}->>'projNm' = ${value}`; - } - break; - - case 'pspid': - if (operator === 'iLike') { - return sql`${techSalesRfqs.projectSnapshot}->>'pspid' ILIKE ${'%' + value + '%'}`; - } else if (operator === 'eq') { - return sql`${techSalesRfqs.projectSnapshot}->>'pspid' = ${value}`; - } - break; - - case 'sector': - if (operator === 'iLike') { - return sql`${techSalesRfqs.projectSnapshot}->>'sector' ILIKE ${'%' + value + '%'}`; - } else if (operator === 'eq') { - return sql`${techSalesRfqs.projectSnapshot}->>'sector' = ${value}`; - } - break; - - case 'kunnrNm': - if (operator === 'iLike') { - return sql`${techSalesRfqs.projectSnapshot}->>'kunnrNm' ILIKE ${'%' + value + '%'}`; - } else if (operator === 'eq') { - return sql`${techSalesRfqs.projectSnapshot}->>'kunnrNm' = ${value}`; - } - break; - } - return undefined; - }).filter(Boolean); - - if (joinedConditions.length > 0) { - const joinOperator = input.joinOperator || "and"; - const combinedCondition = joinOperator === "and" - ? and(...joinedConditions) - : or(...joinedConditions); - baseConditions.push(combinedCondition); - } + const filterWhere = filterColumns({ + table: techSalesVendorQuotations, + filters: filters as Filter[], + joinOperator: input.joinOperator || "and", + }); + if (filterWhere) { + baseConditions.push(filterWhere); } } @@ -1677,19 +1475,6 @@ export async function getVendorQuotations(input: { return item.desc ? desc(techSalesVendorQuotations.createdAt) : techSalesVendorQuotations.createdAt; case 'updatedAt': return item.desc ? desc(techSalesVendorQuotations.updatedAt) : techSalesVendorQuotations.updatedAt; - case 'createdBy': - return item.desc ? desc(techSalesVendorQuotations.createdBy) : techSalesVendorQuotations.createdBy; - case 'updatedBy': - return item.desc ? desc(techSalesVendorQuotations.updatedBy) : techSalesVendorQuotations.updatedBy; - case 'quotationCode': - return item.desc ? desc(techSalesVendorQuotations.quotationCode) : techSalesVendorQuotations.quotationCode; - case 'quotationVersion': - return item.desc ? desc(techSalesVendorQuotations.quotationVersion) : techSalesVendorQuotations.quotationVersion; - case 'rejectionReason': - return item.desc ? desc(techSalesVendorQuotations.rejectionReason) : techSalesVendorQuotations.rejectionReason; - case 'acceptedAt': - return item.desc ? desc(techSalesVendorQuotations.acceptedAt) : techSalesVendorQuotations.acceptedAt; - // 조인된 RFQ 정보 정렬 case 'rfqCode': return item.desc ? desc(techSalesRfqs.rfqCode) : techSalesRfqs.rfqCode; case 'materialCode': @@ -1698,38 +1483,6 @@ export async function getVendorQuotations(input: { return item.desc ? desc(techSalesRfqs.dueDate) : techSalesRfqs.dueDate; case 'rfqStatus': return item.desc ? desc(techSalesRfqs.status) : techSalesRfqs.status; - // 조인된 아이템 정보 정렬 - case 'itemName': - return item.desc ? desc(itemShipbuilding.itemList) : itemShipbuilding.itemList; - // JSON 필드에서 추출된 프로젝트 정보 정렬 - case 'projNm': - return item.desc ? desc(sql`${techSalesRfqs.projectSnapshot}->>'projNm'`) : sql`${techSalesRfqs.projectSnapshot}->>'projNm'`; - case 'projMsrm': - // 척수 (정수 캐스팅) - return item.desc ? desc(sql`(${techSalesRfqs.projectSnapshot}->>'projMsrm')::int`) : sql`(${techSalesRfqs.projectSnapshot}->>'projMsrm')::int`; - case 'ptypeNm': - // 선종명 - return item.desc ? desc(sql`${techSalesRfqs.projectSnapshot}->>'ptypeNm'`) : sql`${techSalesRfqs.projectSnapshot}->>'ptypeNm'`; - case 'pspid': - // 프로젝트 ID - return item.desc ? desc(sql`${techSalesRfqs.projectSnapshot}->>'pspid'`) : sql`${techSalesRfqs.projectSnapshot}->>'pspid'`; - case 'sector': - // 섹터 - return item.desc ? desc(sql`${techSalesRfqs.projectSnapshot}->>'sector'`) : sql`${techSalesRfqs.projectSnapshot}->>'sector'`; - case 'kunnrNm': - // 고객명 - return item.desc ? desc(sql`${techSalesRfqs.projectSnapshot}->>'kunnrNm'`) : sql`${techSalesRfqs.projectSnapshot}->>'kunnrNm'`; - // 계산된 필드 정렬 - case 'attachmentCount': - return item.desc ? desc(sql`( - SELECT COUNT(*) - FROM tech_sales_attachments - WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id} - )`) : sql`( - SELECT COUNT(*) - FROM tech_sales_attachments - WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id} - )`; default: return item.desc ? desc(techSalesVendorQuotations.updatedAt) : techSalesVendorQuotations.updatedAt; } @@ -1761,10 +1514,11 @@ export async function getVendorQuotations(input: { materialCode: techSalesRfqs.materialCode, dueDate: techSalesRfqs.dueDate, rfqStatus: techSalesRfqs.status, - // 아이템 정보 - itemName: itemShipbuilding.itemList, - // 프로젝트 정보 (JSON에서 추출) - projNm: sql`${techSalesRfqs.projectSnapshot}->>'projNm'`, + description: techSalesRfqs.description, + // 프로젝트 정보 (직접 조인) + projNm: biddingProjects.projNm, + // 아이템 정보 추가 (임시로 description 사용) + // itemName: techSalesRfqs.description, // 첨부파일 개수 attachmentCount: sql`( SELECT COUNT(*) @@ -1774,7 +1528,7 @@ export async function getVendorQuotations(input: { }) .from(techSalesVendorQuotations) .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id)) - .leftJoin(itemShipbuilding, eq(techSalesRfqs.itemShipbuildingId, itemShipbuilding.id)) + .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id)) .where(finalWhere) .orderBy(...orderBy) .limit(limit) @@ -1785,7 +1539,7 @@ export async function getVendorQuotations(input: { .select({ count: sql`count(*)` }) .from(techSalesVendorQuotations) .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id)) - .leftJoin(itemShipbuilding, eq(techSalesRfqs.itemShipbuildingId, itemShipbuilding.id)) + .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id)) .where(finalWhere); const total = totalResult[0]?.count || 0; @@ -1811,17 +1565,26 @@ export async function getVendorQuotations(input: { /** * 벤더용 기술영업 견적서 상태별 개수 조회 */ -export async function getQuotationStatusCounts(vendorId: string) { +export async function getQuotationStatusCounts(vendorId: string, rfqType?: "SHIP" | "TOP" | "HULL") { return unstable_cache( async () => { try { - const result = await db + const query = db .select({ status: techSalesVendorQuotations.status, count: sql`count(*)`, }) .from(techSalesVendorQuotations) - .where(eq(techSalesVendorQuotations.vendorId, parseInt(vendorId))) + .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id)); + + // 조건 설정 + const conditions = [eq(techSalesVendorQuotations.vendorId, parseInt(vendorId))]; + if (rfqType) { + conditions.push(eq(techSalesRfqs.rfqType, rfqType)); + } + + const result = await query + .where(and(...conditions)) .groupBy(techSalesVendorQuotations.status); return { data: result, error: null }; @@ -1870,21 +1633,21 @@ export async function acceptTechSalesVendorQuotation(quotationId: number) { }) .where(eq(techSalesVendorQuotations.id, quotationId)) - // 3. 같은 RFQ의 다른 견적들을 Rejected로 변경 - await tx - .update(techSalesVendorQuotations) - .set({ - status: "Rejected", - rejectionReason: "다른 벤더가 선택됨", - updatedAt: new Date(), - }) - .where( - and( - eq(techSalesVendorQuotations.rfqId, quotation.rfqId), - ne(techSalesVendorQuotations.id, quotationId), - eq(techSalesVendorQuotations.status, "Submitted") - ) - ) + // // 3. 같은 RFQ의 다른 견적들을 Rejected로 변경 + // await tx + // .update(techSalesVendorQuotations) + // .set({ + // status: "Rejected", + // rejectionReason: "다른 벤더가 선택됨", + // updatedAt: new Date(), + // }) + // .where( + // and( + // eq(techSalesVendorQuotations.rfqId, quotation.rfqId), + // ne(techSalesVendorQuotations.id, quotationId), + // eq(techSalesVendorQuotations.status, "Submitted") + // ) + // ) // 4. RFQ 상태를 Closed로 변경 await tx @@ -1904,27 +1667,27 @@ export async function acceptTechSalesVendorQuotation(quotationId: number) { console.error("벤더 견적 선택 알림 메일 발송 실패:", error); }); - // 거절된 견적들에 대한 알림 메일 발송 - 트랜잭션 완료 후 별도로 처리 - setTimeout(async () => { - try { - const rejectedQuotations = await db.query.techSalesVendorQuotations.findMany({ - where: and( - eq(techSalesVendorQuotations.rfqId, result.rfqId), - ne(techSalesVendorQuotations.id, quotationId), - eq(techSalesVendorQuotations.status, "Rejected") - ), - columns: { id: true } - }); - - for (const rejectedQuotation of rejectedQuotations) { - sendQuotationRejectedNotification(rejectedQuotation.id).catch(error => { - console.error("벤더 견적 거절 알림 메일 발송 실패:", error); - }); - } - } catch (error) { - console.error("거절된 견적 알림 메일 발송 중 오류:", error); - } - }, 1000); // 1초 후 실행 + // // 거절된 견적들에 대한 알림 메일 발송 - 트랜잭션 완료 후 별도로 처리 + // setTimeout(async () => { + // try { + // const rejectedQuotations = await db.query.techSalesVendorQuotations.findMany({ + // where: and( + // eq(techSalesVendorQuotations.rfqId, result.rfqId), + // ne(techSalesVendorQuotations.id, quotationId), + // eq(techSalesVendorQuotations.status, "Rejected") + // ), + // columns: { id: true } + // }); + + // for (const rejectedQuotation of rejectedQuotations) { + // sendQuotationRejectedNotification(rejectedQuotation.id).catch(error => { + // console.error("벤더 견적 거절 알림 메일 발송 실패:", error); + // }); + // } + // } catch (error) { + // console.error("거절된 견적 알림 메일 발송 중 오류:", error); + // } + // }, 1000); // 1초 후 실행 // 캐시 무효화 revalidateTag("techSalesVendorQuotations") @@ -1951,44 +1714,44 @@ export async function acceptTechSalesVendorQuotation(quotationId: number) { } } -/** - * 기술영업 벤더 견적 거절 - */ -export async function rejectTechSalesVendorQuotation(quotationId: number, rejectionReason?: string) { - try { - const result = await db - .update(techSalesVendorQuotations) - .set({ - status: "Rejected", - rejectionReason: rejectionReason || "기술영업 담당자에 의해 거절됨", - updatedAt: new Date(), - }) - .where(eq(techSalesVendorQuotations.id, quotationId)) - .returning() - - if (result.length === 0) { - throw new Error("견적을 찾을 수 없습니다") - } - - // 메일 발송 (백그라운드에서 실행) - sendQuotationRejectedNotification(quotationId).catch(error => { - console.error("벤더 견적 거절 알림 메일 발송 실패:", error); - }); - - // 캐시 무효화 - revalidateTag("techSalesVendorQuotations") - revalidateTag(`techSalesRfq-${result[0].rfqId}`) - revalidateTag(`vendor-${result[0].vendorId}-quotations`) - - return { success: true, data: result[0] } - } catch (error) { - console.error("벤더 견적 거절 오류:", error) - return { - success: false, - error: error instanceof Error ? error.message : "벤더 견적 거절에 실패했습니다" - } - } -} +// /** +// * 기술영업 벤더 견적 거절 +// */ +// export async function rejectTechSalesVendorQuotation(quotationId: number, rejectionReason?: string) { +// // try { +// // const result = await db +// // .update(techSalesVendorQuotations) +// // .set({ +// // status: "Rejected" as any, +// // rejectionReason: rejectionReason || "기술영업 담당자에 의해 거절됨", +// // updatedAt: new Date(), +// // }) +// // .where(eq(techSalesVendorQuotations.id, quotationId)) +// // .returning() + +// // if (result.length === 0) { +// // throw new Error("견적을 찾을 수 없습니다") +// // } + +// // // 메일 발송 (백그라운드에서 실행) +// // sendQuotationRejectedNotification(quotationId).catch(error => { +// // console.error("벤더 견적 거절 알림 메일 발송 실패:", error); +// // }); + +// // // 캐시 무효화 +// // revalidateTag("techSalesVendorQuotations") +// // revalidateTag(`techSalesRfq-${result[0].rfqId}`) +// // revalidateTag(`vendor-${result[0].vendorId}-quotations`) + +// // return { success: true, data: result[0] } +// // } catch (error) { +// // console.error("벤더 견적 거절 오류:", error) +// // return { +// // success: false, +// // error: error instanceof Error ? error.message : "벤더 견적 거절에 실패했습니다" +// // } +// // } +// } /** * 기술영업 RFQ 첨부파일 생성 (파일 업로드) @@ -2288,13 +2051,12 @@ export async function processTechSalesRfqAttachments(params: { */ export async function sendQuotationSubmittedNotificationToVendor(quotationId: number) { try { - // 견적서 정보 조회 + // 견적서 정보 조회 (projectSeries 조인 추가) const quotation = await db.query.techSalesVendorQuotations.findFirst({ where: eq(techSalesVendorQuotations.id, quotationId), with: { rfq: { with: { - itemShipbuilding: true, biddingProject: true, createdByUser: { columns: { @@ -2341,12 +2103,16 @@ export async function sendQuotationSubmittedNotificationToVendor(quotationId: nu return { success: false, error: "벤더 이메일 주소가 없습니다" }; } - // 프로젝트 정보 준비 - const projectInfo = (quotation.rfq.projectSnapshot as Record) || {}; + // 프로젝트 시리즈 정보 조회 + const seriesData = quotation.rfq.biddingProject?.pspid + ? await db.query.projectSeries.findMany({ + where: eq(projectSeries.pspid, quotation.rfq.biddingProject.pspid) + }) + : []; // 시리즈 정보 처리 - const seriesInfo = quotation.rfq.seriesSnapshot ? quotation.rfq.seriesSnapshot.map((series: SeriesSnapshot) => ({ - sersNo: series.sersNo, + const seriesInfo = seriesData.map(series => ({ + sersNo: series.sersNo?.toString() || '', klQuarter: series.klDt ? formatDateToQuarter(series.klDt) : '', scDt: series.scDt, lcDt: series.lcDt, @@ -2355,7 +2121,7 @@ export async function sendQuotationSubmittedNotificationToVendor(quotationId: nu dockNm: series.dockNm, projNo: series.projNo, post1: series.post1, - })) : []; + })); // 이메일 컨텍스트 구성 const emailContext = { @@ -2371,9 +2137,9 @@ export async function sendQuotationSubmittedNotificationToVendor(quotationId: nu rfq: { id: quotation.rfq.id, code: quotation.rfq.rfqCode, - title: quotation.rfq.itemShipbuilding?.itemList || '', - projectCode: projectInfo.pspid || quotation.rfq.biddingProject?.pspid || '', - projectName: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '', + title: quotation.rfq.description || '', + projectCode: quotation.rfq.biddingProject?.pspid || '', + projectName: quotation.rfq.biddingProject?.projNm || '', dueDate: quotation.rfq.dueDate, materialCode: quotation.rfq.materialCode, description: quotation.rfq.remark, @@ -2384,12 +2150,12 @@ export async function sendQuotationSubmittedNotificationToVendor(quotationId: nu name: quotation.vendor.vendorName, }, project: { - name: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '', - sector: projectInfo.sector || quotation.rfq.biddingProject?.sector || '', - shipCount: projectInfo.projMsrm || 0, - ownerName: projectInfo.kunnrNm || '', - className: projectInfo.cls1Nm || '', - shipModelName: projectInfo.pmodelNm || '', + name: quotation.rfq.biddingProject?.projNm || '', + sector: quotation.rfq.biddingProject?.sector || '', + shipCount: quotation.rfq.biddingProject?.projMsrm ? Number(quotation.rfq.biddingProject.projMsrm) : 0, + ownerName: quotation.rfq.biddingProject?.kunnrNm || '', + className: quotation.rfq.biddingProject?.cls1Nm || '', + shipModelName: quotation.rfq.biddingProject?.pmodelNm || '', }, series: seriesInfo, manager: { @@ -2404,7 +2170,7 @@ export async function sendQuotationSubmittedNotificationToVendor(quotationId: nu // 이메일 발송 await sendEmail({ to: vendorEmails, - subject: `[견적 제출 확인] ${quotation.rfq.rfqCode} - ${quotation.rfq.itemShipbuilding?.itemList || '견적 요청'}`, + subject: `[견적 제출 확인] ${quotation.rfq.rfqCode} - 견적 요청`, template: 'tech-sales-quotation-submitted-vendor-ko', context: emailContext, }); @@ -2428,7 +2194,6 @@ export async function sendQuotationSubmittedNotificationToManager(quotationId: n with: { rfq: { with: { - itemShipbuilding: true, biddingProject: true, createdByUser: { columns: { @@ -2460,12 +2225,16 @@ export async function sendQuotationSubmittedNotificationToManager(quotationId: n return { success: false, error: "담당자 이메일 주소가 없습니다" }; } - // 프로젝트 정보 준비 - const projectInfo = (quotation.rfq.projectSnapshot as Record) || {}; + // 프로젝트 시리즈 정보 조회 + const seriesData = quotation.rfq.biddingProject?.pspid + ? await db.query.projectSeries.findMany({ + where: eq(projectSeries.pspid, quotation.rfq.biddingProject.pspid) + }) + : []; // 시리즈 정보 처리 - const seriesInfo = quotation.rfq.seriesSnapshot ? quotation.rfq.seriesSnapshot.map((series: SeriesSnapshot) => ({ - sersNo: series.sersNo, + const seriesInfo = seriesData.map(series => ({ + sersNo: series.sersNo?.toString() || '', klQuarter: series.klDt ? formatDateToQuarter(series.klDt) : '', scDt: series.scDt, lcDt: series.lcDt, @@ -2474,7 +2243,7 @@ export async function sendQuotationSubmittedNotificationToManager(quotationId: n dockNm: series.dockNm, projNo: series.projNo, post1: series.post1, - })) : []; + })); // 이메일 컨텍스트 구성 const emailContext = { @@ -2490,9 +2259,9 @@ export async function sendQuotationSubmittedNotificationToManager(quotationId: n rfq: { id: quotation.rfq.id, code: quotation.rfq.rfqCode, - title: quotation.rfq.itemShipbuilding?.itemList || '', - projectCode: projectInfo.pspid || quotation.rfq.biddingProject?.pspid || '', - projectName: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '', + title: quotation.rfq.description || '', + projectCode: quotation.rfq.biddingProject?.pspid || '', + projectName: quotation.rfq.biddingProject?.projNm || '', dueDate: quotation.rfq.dueDate, materialCode: quotation.rfq.materialCode, description: quotation.rfq.remark, @@ -2503,12 +2272,12 @@ export async function sendQuotationSubmittedNotificationToManager(quotationId: n name: quotation.vendor.vendorName, }, project: { - name: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '', - sector: projectInfo.sector || quotation.rfq.biddingProject?.sector || '', - shipCount: projectInfo.projMsrm || 0, - ownerName: projectInfo.kunnrNm || '', - className: projectInfo.cls1Nm || '', - shipModelName: projectInfo.pmodelNm || '', + name: quotation.rfq.biddingProject?.projNm || '', + sector: quotation.rfq.biddingProject?.sector || '', + shipCount: quotation.rfq.biddingProject?.projMsrm ? Number(quotation.rfq.biddingProject.projMsrm) : 0, + ownerName: quotation.rfq.biddingProject?.kunnrNm || '', + className: quotation.rfq.biddingProject?.cls1Nm || '', + shipModelName: quotation.rfq.biddingProject?.pmodelNm || '', }, series: seriesInfo, manager: { @@ -2547,7 +2316,6 @@ export async function sendQuotationAcceptedNotification(quotationId: number) { with: { rfq: { with: { - itemShipbuilding: true, biddingProject: true, createdByUser: { columns: { @@ -2594,12 +2362,16 @@ export async function sendQuotationAcceptedNotification(quotationId: number) { return { success: false, error: "벤더 이메일 주소가 없습니다" }; } - // 프로젝트 정보 준비 - const projectInfo = (quotation.rfq.projectSnapshot as Record) || {}; + // 프로젝트 시리즈 정보 조회 + const seriesData = quotation.rfq.biddingProject?.pspid + ? await db.query.projectSeries.findMany({ + where: eq(projectSeries.pspid, quotation.rfq.biddingProject.pspid) + }) + : []; // 시리즈 정보 처리 - const seriesInfo = quotation.rfq.seriesSnapshot ? quotation.rfq.seriesSnapshot.map((series: SeriesSnapshot) => ({ - sersNo: series.sersNo, + const seriesInfo = seriesData.map(series => ({ + sersNo: series.sersNo?.toString() || '', klQuarter: series.klDt ? formatDateToQuarter(series.klDt) : '', scDt: series.scDt, lcDt: series.lcDt, @@ -2608,7 +2380,7 @@ export async function sendQuotationAcceptedNotification(quotationId: number) { dockNm: series.dockNm, projNo: series.projNo, post1: series.post1, - })) : []; + })); // 이메일 컨텍스트 구성 const emailContext = { @@ -2624,9 +2396,9 @@ export async function sendQuotationAcceptedNotification(quotationId: number) { rfq: { id: quotation.rfq.id, code: quotation.rfq.rfqCode, - title: quotation.rfq.itemShipbuilding?.itemList || '', - projectCode: projectInfo.pspid || quotation.rfq.biddingProject?.pspid || '', - projectName: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '', + title: quotation.rfq.description || '', + projectCode: quotation.rfq.biddingProject?.pspid || '', + projectName: quotation.rfq.biddingProject?.projNm || '', dueDate: quotation.rfq.dueDate, materialCode: quotation.rfq.materialCode, description: quotation.rfq.remark, @@ -2637,12 +2409,12 @@ export async function sendQuotationAcceptedNotification(quotationId: number) { name: quotation.vendor.vendorName, }, project: { - name: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '', - sector: projectInfo.sector || quotation.rfq.biddingProject?.sector || '', - shipCount: projectInfo.projMsrm || 0, - ownerName: projectInfo.kunnrNm || '', - className: projectInfo.cls1Nm || '', - shipModelName: projectInfo.pmodelNm || '', + name: quotation.rfq.biddingProject?.projNm || '', + sector: quotation.rfq.biddingProject?.sector || '', + shipCount: quotation.rfq.biddingProject?.projMsrm ? Number(quotation.rfq.biddingProject.projMsrm) : 0, + ownerName: quotation.rfq.biddingProject?.kunnrNm || '', + className: quotation.rfq.biddingProject?.cls1Nm || '', + shipModelName: quotation.rfq.biddingProject?.pmodelNm || '', }, series: seriesInfo, manager: { @@ -2681,7 +2453,6 @@ export async function sendQuotationRejectedNotification(quotationId: number) { with: { rfq: { with: { - itemShipbuilding: true, biddingProject: true, createdByUser: { columns: { @@ -2728,12 +2499,16 @@ export async function sendQuotationRejectedNotification(quotationId: number) { return { success: false, error: "벤더 이메일 주소가 없습니다" }; } - // 프로젝트 정보 준비 - const projectInfo = (quotation.rfq.projectSnapshot as Record) || {}; + // 프로젝트 시리즈 정보 조회 + const seriesData = quotation.rfq.biddingProject?.pspid + ? await db.query.projectSeries.findMany({ + where: eq(projectSeries.pspid, quotation.rfq.biddingProject.pspid) + }) + : []; // 시리즈 정보 처리 - const seriesInfo = quotation.rfq.seriesSnapshot ? quotation.rfq.seriesSnapshot.map((series: SeriesSnapshot) => ({ - sersNo: series.sersNo, + const seriesInfo = seriesData.map(series => ({ + sersNo: series.sersNo?.toString() || '', klQuarter: series.klDt ? formatDateToQuarter(series.klDt) : '', scDt: series.scDt, lcDt: series.lcDt, @@ -2742,7 +2517,7 @@ export async function sendQuotationRejectedNotification(quotationId: number) { dockNm: series.dockNm, projNo: series.projNo, post1: series.post1, - })) : []; + })); // 이메일 컨텍스트 구성 const emailContext = { @@ -2758,9 +2533,9 @@ export async function sendQuotationRejectedNotification(quotationId: number) { rfq: { id: quotation.rfq.id, code: quotation.rfq.rfqCode, - title: quotation.rfq.itemShipbuilding?.itemList || '', - projectCode: projectInfo.pspid || quotation.rfq.biddingProject?.pspid || '', - projectName: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '', + title: quotation.rfq.description || '', + projectCode: quotation.rfq.biddingProject?.pspid || '', + projectName: quotation.rfq.biddingProject?.projNm || '', dueDate: quotation.rfq.dueDate, materialCode: quotation.rfq.materialCode, description: quotation.rfq.remark, @@ -2771,12 +2546,12 @@ export async function sendQuotationRejectedNotification(quotationId: number) { name: quotation.vendor.vendorName, }, project: { - name: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '', - sector: projectInfo.sector || quotation.rfq.biddingProject?.sector || '', - shipCount: projectInfo.projMsrm || 0, - ownerName: projectInfo.kunnrNm || '', - className: projectInfo.cls1Nm || '', - shipModelName: projectInfo.pmodelNm || '', + name: quotation.rfq.biddingProject?.projNm || '', + sector: quotation.rfq.biddingProject?.sector || '', + shipCount: quotation.rfq.biddingProject?.projMsrm ? Number(quotation.rfq.biddingProject.projMsrm) : 0, + ownerName: quotation.rfq.biddingProject?.kunnrNm || '', + className: quotation.rfq.biddingProject?.cls1Nm || '', + shipModelName: quotation.rfq.biddingProject?.pmodelNm || '', }, series: seriesInfo, manager: { @@ -2791,7 +2566,7 @@ export async function sendQuotationRejectedNotification(quotationId: number) { // 이메일 발송 await sendEmail({ to: vendorEmails, - subject: `[견적 검토 결과] ${quotation.rfq.rfqCode} - 견적 검토 결과를 안내드립니다`, + subject: `[견적 거절 알림] ${quotation.rfq.rfqCode} - 견적 결과 안내`, template: 'tech-sales-quotation-rejected-ko', context: emailContext, }); @@ -2977,4 +2752,787 @@ export async function markTechSalesMessagesAsRead(rfqId: number, vendorId?: numb console.error('techSales 메시지 읽음 표시 오류:', error) throw error } +} + +/** + * 기술영업 조선 RFQ 생성 (1:N 관계) + */ +export async function createTechSalesShipRfq(input: { + biddingProjectId: number; + itemIds: number[]; // 조선 아이템 ID 배열 + dueDate: Date; + description?: string; + createdBy: number; +}) { + unstable_noStore(); + console.log('🔍 createTechSalesShipRfq 호출됨:', input); + + try { + return await db.transaction(async (tx) => { + // 프로젝트 정보 조회 (유효성 검증) + const biddingProject = await tx.query.biddingProjects.findFirst({ + where: (biddingProjects, { eq }) => eq(biddingProjects.id, input.biddingProjectId) + }); + + if (!biddingProject) { + throw new Error(`프로젝트 ID ${input.biddingProjectId}를 찾을 수 없습니다.`); + } + + // RFQ 코드 생성 (SHIP 타입) + const rfqCode = await generateRfqCodes(tx, 1); + + // RFQ 생성 + const [rfq] = await tx + .insert(techSalesRfqs) + .values({ + rfqCode: rfqCode[0], + biddingProjectId: input.biddingProjectId, + description: input.description, + dueDate: input.dueDate, + status: "RFQ Created", + rfqType: "SHIP", + createdBy: input.createdBy, + updatedBy: input.createdBy, + }) + .returning({ id: techSalesRfqs.id }); + + // 아이템들 추가 + for (const itemId of input.itemIds) { + await tx + .insert(techSalesRfqItems) + .values({ + rfqId: rfq.id, + itemShipbuildingId: itemId, + itemType: "SHIP", + }); + } + + // 캐시 무효화 + revalidateTag("techSalesRfqs"); + revalidatePath("/evcp/budgetary-tech-sales-ship"); + + return { data: rfq, error: null }; + }); + } catch (err) { + console.error("Error creating Ship RFQ:", err); + return { data: null, error: getErrorMessage(err) }; + } +} + +/** + * 기술영업 해양 Hull RFQ 생성 (1:N 관계) + */ +export async function createTechSalesHullRfq(input: { + biddingProjectId: number; + itemIds: number[]; // Hull 아이템 ID 배열 + dueDate: Date; + description?: string; + createdBy: number; +}) { + unstable_noStore(); + console.log('🔍 createTechSalesHullRfq 호출됨:', input); + + try { + return await db.transaction(async (tx) => { + // 프로젝트 정보 조회 (유효성 검증) + const biddingProject = await tx.query.biddingProjects.findFirst({ + where: (biddingProjects, { eq }) => eq(biddingProjects.id, input.biddingProjectId) + }); + + if (!biddingProject) { + throw new Error(`프로젝트 ID ${input.biddingProjectId}를 찾을 수 없습니다.`); + } + + // RFQ 코드 생성 (HULL 타입) + const hullRfqCode = await generateRfqCodes(tx, 1); + + // RFQ 생성 + const [rfq] = await tx + .insert(techSalesRfqs) + .values({ + rfqCode: hullRfqCode[0], + biddingProjectId: input.biddingProjectId, + description: input.description, + dueDate: input.dueDate, + status: "RFQ Created", + rfqType: "HULL", + createdBy: input.createdBy, + updatedBy: input.createdBy, + }) + .returning({ id: techSalesRfqs.id }); + + // 아이템들 추가 + for (const itemId of input.itemIds) { + await tx + .insert(techSalesRfqItems) + .values({ + rfqId: rfq.id, + itemOffshoreHullId: itemId, + itemType: "HULL", + }); + } + + // 캐시 무효화 + revalidateTag("techSalesRfqs"); + revalidatePath("/evcp/budgetary-tech-sales-hull"); + + return { data: rfq, error: null }; + }); + } catch (err) { + console.error("Error creating Hull RFQ:", err); + return { data: null, error: getErrorMessage(err) }; + } +} + +/** + * 기술영업 해양 TOP RFQ 생성 (1:N 관계) + */ +export async function createTechSalesTopRfq(input: { + biddingProjectId: number; + itemIds: number[]; // TOP 아이템 ID 배열 + dueDate: Date; + description?: string; + createdBy: number; +}) { + unstable_noStore(); + console.log('🔍 createTechSalesTopRfq 호출됨:', input); + + try { + return await db.transaction(async (tx) => { + // 프로젝트 정보 조회 (유효성 검증) + const biddingProject = await tx.query.biddingProjects.findFirst({ + where: (biddingProjects, { eq }) => eq(biddingProjects.id, input.biddingProjectId) + }); + + if (!biddingProject) { + throw new Error(`프로젝트 ID ${input.biddingProjectId}를 찾을 수 없습니다.`); + } + + // RFQ 코드 생성 (TOP 타입) + const topRfqCode = await generateRfqCodes(tx, 1); + + // RFQ 생성 + const [rfq] = await tx + .insert(techSalesRfqs) + .values({ + rfqCode: topRfqCode[0], + biddingProjectId: input.biddingProjectId, + description: input.description, + dueDate: input.dueDate, + status: "RFQ Created", + rfqType: "TOP", + createdBy: input.createdBy, + updatedBy: input.createdBy, + }) + .returning({ id: techSalesRfqs.id }); + + // 아이템들 추가 + for (const itemId of input.itemIds) { + await tx + .insert(techSalesRfqItems) + .values({ + rfqId: rfq.id, + itemOffshoreTopId: itemId, + itemType: "TOP", + }); + } + + // 캐시 무효화 + revalidateTag("techSalesRfqs"); + revalidatePath("/evcp/budgetary-tech-sales-top"); + + return { data: rfq, error: null }; + }); + } catch (err) { + console.error("Error creating TOP RFQ:", err); + return { data: null, error: getErrorMessage(err) }; + } +} + +/** + * 조선 RFQ 전용 조회 함수 + */ +export async function getTechSalesShipRfqsWithJoin(input: GetTechSalesRfqsSchema) { + return getTechSalesRfqsWithJoin({ ...input, rfqType: "SHIP" }); +} + +/** + * 해양 TOP RFQ 전용 조회 함수 + */ +export async function getTechSalesTopRfqsWithJoin(input: GetTechSalesRfqsSchema) { + return getTechSalesRfqsWithJoin({ ...input, rfqType: "TOP" }); +} + +/** + * 해양 HULL RFQ 전용 조회 함수 + */ +export async function getTechSalesHullRfqsWithJoin(input: GetTechSalesRfqsSchema) { + return getTechSalesRfqsWithJoin({ ...input, rfqType: "HULL" }); +} + +/** + * 조선 벤더 견적서 전용 조회 함수 + */ +export async function getTechSalesShipVendorQuotationsWithJoin(input: { + rfqId?: number; + vendorId?: number; + search?: string; + filters?: Filter[]; + sort?: { id: string; desc: boolean }[]; + page: number; + perPage: number; +}) { + return getTechSalesVendorQuotationsWithJoin({ ...input, rfqType: "SHIP" }); +} + +/** + * 해양 TOP 벤더 견적서 전용 조회 함수 + */ +export async function getTechSalesTopVendorQuotationsWithJoin(input: { + rfqId?: number; + vendorId?: number; + search?: string; + filters?: Filter[]; + sort?: { id: string; desc: boolean }[]; + page: number; + perPage: number; +}) { + return getTechSalesVendorQuotationsWithJoin({ ...input, rfqType: "TOP" }); +} + +/** + * 해양 HULL 벤더 견적서 전용 조회 함수 + */ +export async function getTechSalesHullVendorQuotationsWithJoin(input: { + rfqId?: number; + vendorId?: number; + search?: string; + filters?: Filter[]; + sort?: { id: string; desc: boolean }[]; + page: number; + perPage: number; +}) { + return getTechSalesVendorQuotationsWithJoin({ ...input, rfqType: "HULL" }); +} + +/** + * 조선 대시보드 전용 조회 함수 + */ +export async function getTechSalesShipDashboardWithJoin(input: { + search?: string; + filters?: Filter[]; + sort?: { id: string; desc: boolean }[]; + page: number; + perPage: number; +}) { + return getTechSalesDashboardWithJoin({ ...input, rfqType: "SHIP" }); +} + +/** + * 해양 TOP 대시보드 전용 조회 함수 + */ +export async function getTechSalesTopDashboardWithJoin(input: { + search?: string; + filters?: Filter[]; + sort?: { id: string; desc: boolean }[]; + page: number; + perPage: number; +}) { + return getTechSalesDashboardWithJoin({ ...input, rfqType: "TOP" }); +} + +/** + * 해양 HULL 대시보드 전용 조회 함수 + */ +export async function getTechSalesHullDashboardWithJoin(input: { + search?: string; + filters?: Filter[]; + sort?: { id: string; desc: boolean }[]; + page: number; + perPage: number; +}) { + return getTechSalesDashboardWithJoin({ ...input, rfqType: "HULL" }); +} + +/** + * 기술영업 RFQ의 아이템 목록 조회 + */ +export async function getTechSalesRfqItems(rfqId: number) { + unstable_noStore(); + try { + const items = await db.query.techSalesRfqItems.findMany({ + where: eq(techSalesRfqItems.rfqId, rfqId), + with: { + itemShipbuilding: { + columns: { + id: true, + itemCode: true, + itemList: true, + workType: true, + shipTypes: true, + } + }, + itemOffshoreTop: { + columns: { + id: true, + itemCode: true, + itemList: true, + workType: true, + subItemList: true, + } + }, + itemOffshoreHull: { + columns: { + id: true, + itemCode: true, + itemList: true, + workType: true, + subItemList: true, + } + } + }, + orderBy: [techSalesRfqItems.id] + }); + + // 아이템 타입에 따라 정보 매핑 + const mappedItems = items.map(item => { + let itemInfo = null; + + switch (item.itemType) { + case 'SHIP': + itemInfo = item.itemShipbuilding; + break; + case 'TOP': + itemInfo = item.itemOffshoreTop; + break; + case 'HULL': + itemInfo = item.itemOffshoreHull; + break; + } + + return { + id: item.id, + rfqId: item.rfqId, + itemType: item.itemType, + itemCode: itemInfo?.itemCode || '', + itemList: itemInfo?.itemList || '', + workType: itemInfo?.workType || '', + // 조선이면 shipType, 해양이면 subItemList + shipType: item.itemType === 'SHIP' ? (itemInfo as { shipTypes?: string })?.shipTypes || '' : undefined, + subItemName: item.itemType !== 'SHIP' ? (itemInfo as { subItemList?: string })?.subItemList || '' : undefined, + }; + }); + + return { data: mappedItems, error: null }; + } catch (err) { + console.error("Error fetching RFQ items:", err); + return { data: [], error: getErrorMessage(err) }; + } +} + +/** + * RFQ 아이템들과 매칭되는 후보 벤더들을 찾는 함수 + */ +export async function getTechSalesRfqCandidateVendors(rfqId: number) { + unstable_noStore(); + + try { + return await db.transaction(async (tx) => { + // 1. RFQ 정보 조회 (타입 확인) + const rfq = await tx.query.techSalesRfqs.findFirst({ + where: eq(techSalesRfqs.id, rfqId), + columns: { + id: true, + rfqType: true + } + }); + + if (!rfq) { + return { data: [], error: "RFQ를 찾을 수 없습니다." }; + } + + // 2. RFQ 아이템들 조회 + const rfqItems = await tx.query.techSalesRfqItems.findMany({ + where: eq(techSalesRfqItems.rfqId, rfqId), + with: { + itemShipbuilding: true, + itemOffshoreTop: true, + itemOffshoreHull: true, + } + }); + + if (rfqItems.length === 0) { + return { data: [], error: null }; + } + + // 3. 아이템 코드들 추출 + const itemCodes: string[] = []; + 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 (itemCodes.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; + + if (!vendorTypeFilter) { + return { data: [], error: "지원되지 않는 RFQ 타입입니다." }; + } + + // 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), + eq(techVendors.status, "ACTIVE") + // 벤더 타입 필터링 임시 제거 - 데이터 확인 후 다시 추가 + // 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})`)); + + return { data: candidateVendors, error: null }; + }); + } catch (err) { + console.error("Error fetching candidate vendors:", err); + return { data: [], error: getErrorMessage(err) }; + } +} + +/** + * 기술영업 RFQ에 벤더 추가 (techVendors 기반) + */ +export async function addTechVendorToTechSalesRfq(input: { + rfqId: number; + vendorId: number; + createdBy: number; +}) { + unstable_noStore(); + + try { + return await db.transaction(async (tx) => { + // 벤더가 이미 추가되어 있는지 확인 + const existingQuotation = await tx.query.techSalesVendorQuotations.findFirst({ + where: and( + eq(techSalesVendorQuotations.rfqId, input.rfqId), + eq(techSalesVendorQuotations.vendorId, input.vendorId) + ) + }); + + if (existingQuotation) { + return { data: null, error: "이미 추가된 벤더입니다." }; + } + + // 새로운 견적서 레코드 생성 + const [quotation] = await tx + .insert(techSalesVendorQuotations) + .values({ + rfqId: input.rfqId, + vendorId: input.vendorId, + status: "Draft", + createdBy: input.createdBy, + updatedBy: input.createdBy, + }) + .returning({ id: techSalesVendorQuotations.id }); + + // 캐시 무효화 + revalidateTag("techSalesRfqs"); + + return { data: quotation, error: null }; + }); + } catch (err) { + console.error("Error adding tech vendor to RFQ:", err); + return { data: null, error: getErrorMessage(err) }; + } +} + +/** + * 기술영업 RFQ에 여러 벤더 추가 (techVendors 기반) + */ +export async function addTechVendorsToTechSalesRfq(input: { + rfqId: number; + vendorIds: number[]; + createdBy: number; +}) { + unstable_noStore(); + + try { + return await db.transaction(async (tx) => { + const results = []; + + for (const vendorId of input.vendorIds) { + // 벤더가 이미 추가되어 있는지 확인 + const existingQuotation = await tx.query.techSalesVendorQuotations.findFirst({ + where: and( + eq(techSalesVendorQuotations.rfqId, input.rfqId), + eq(techSalesVendorQuotations.vendorId, vendorId) + ) + }); + + if (!existingQuotation) { + // 새로운 견적서 레코드 생성 + const [quotation] = await tx + .insert(techSalesVendorQuotations) + .values({ + rfqId: input.rfqId, + vendorId: vendorId, + status: "Draft", + createdBy: input.createdBy, + updatedBy: input.createdBy, + }) + .returning({ id: techSalesVendorQuotations.id }); + + results.push(quotation); + } + } + + // 캐시 무효화 + revalidateTag("techSalesRfqs"); + + return { data: results, error: null }; + }); + } catch (err) { + console.error("Error adding tech vendors to RFQ:", err); + return { data: [], error: getErrorMessage(err) }; + } +} + +/** + * 기술영업 RFQ의 벤더 목록 조회 (techVendors 기반) + */ +export async function getTechSalesRfqTechVendors(rfqId: number) { + unstable_noStore(); + + try { + return await db.transaction(async (tx) => { + const vendors = await tx + .select({ + id: techSalesVendorQuotations.id, + vendorId: techVendors.id, + vendorName: techVendors.vendorName, + vendorCode: techVendors.vendorCode, + country: techVendors.country, + email: techVendors.email, + phone: techVendors.phone, + status: techSalesVendorQuotations.status, + totalPrice: techSalesVendorQuotations.totalPrice, + currency: techSalesVendorQuotations.currency, + validUntil: techSalesVendorQuotations.validUntil, + submittedAt: techSalesVendorQuotations.submittedAt, + createdAt: techSalesVendorQuotations.createdAt, + }) + .from(techSalesVendorQuotations) + .innerJoin(techVendors, eq(techSalesVendorQuotations.vendorId, techVendors.id)) + .where(eq(techSalesVendorQuotations.rfqId, rfqId)) + .orderBy(desc(techSalesVendorQuotations.createdAt)); + + return { data: vendors, error: null }; + }); + } catch (err) { + console.error("Error fetching RFQ tech vendors:", err); + return { data: [], error: getErrorMessage(err) }; + } +} + +/** + * 기술영업 RFQ에서 기술영업 벤더 제거 (techVendors 기반) + */ +export async function removeTechVendorFromTechSalesRfq(input: { + rfqId: number; + vendorId: number; +}) { + unstable_noStore(); + + try { + return await db.transaction(async (tx) => { + // 해당 벤더의 견적서 상태 확인 + const existingQuotation = await tx.query.techSalesVendorQuotations.findFirst({ + where: and( + eq(techSalesVendorQuotations.rfqId, input.rfqId), + eq(techSalesVendorQuotations.vendorId, input.vendorId) + ) + }); + + if (!existingQuotation) { + return { data: null, error: "해당 벤더가 이 RFQ에 존재하지 않습니다." }; + } + + // Draft 상태가 아닌 경우 삭제 불가 + if (existingQuotation.status !== "Draft") { + return { data: null, error: "Draft 상태의 벤더만 삭제할 수 있습니다." }; + } + + // 해당 벤더의 견적서 삭제 + const [deletedQuotation] = await tx + .delete(techSalesVendorQuotations) + .where( + and( + eq(techSalesVendorQuotations.rfqId, input.rfqId), + eq(techSalesVendorQuotations.vendorId, input.vendorId) + ) + ) + .returning({ id: techSalesVendorQuotations.id }); + + // 캐시 무효화 + revalidateTag("techSalesRfqs"); + revalidateTag("techSalesVendorQuotations"); + + return { data: deletedQuotation, error: null }; + }); + } catch (err) { + console.error("Error removing tech vendor from RFQ:", err); + return { data: null, error: getErrorMessage(err) }; + } +} + +/** + * 기술영업 RFQ에서 여러 기술영업 벤더 제거 (techVendors 기반) + */ +export async function removeTechVendorsFromTechSalesRfq(input: { + rfqId: number; + vendorIds: number[]; +}) { + unstable_noStore(); + + try { + return await db.transaction(async (tx) => { + const results = []; + const errors: string[] = []; + + for (const vendorId of input.vendorIds) { + // 해당 벤더의 견적서 상태 확인 + const existingQuotation = await tx.query.techSalesVendorQuotations.findFirst({ + where: and( + eq(techSalesVendorQuotations.rfqId, input.rfqId), + eq(techSalesVendorQuotations.vendorId, vendorId) + ) + }); + + if (!existingQuotation) { + errors.push(`벤더 ID ${vendorId}가 이 RFQ에 존재하지 않습니다.`); + continue; + } + + // Draft 상태가 아닌 경우 삭제 불가 + if (existingQuotation.status !== "Draft") { + errors.push(`벤더 ID ${vendorId}는 Draft 상태가 아니므로 삭제할 수 없습니다.`); + continue; + } + + // 해당 벤더의 견적서 삭제 + const [deletedQuotation] = await tx + .delete(techSalesVendorQuotations) + .where( + and( + eq(techSalesVendorQuotations.rfqId, input.rfqId), + eq(techSalesVendorQuotations.vendorId, vendorId) + ) + ) + .returning({ id: techSalesVendorQuotations.id }); + + results.push(deletedQuotation); + } + + // 캐시 무효화 + revalidateTag("techSalesRfqs"); + revalidateTag("techSalesVendorQuotations"); + + return { + data: results, + error: errors.length > 0 ? errors.join(", ") : null, + successCount: results.length, + errorCount: errors.length + }; + }); + } catch (err) { + console.error("Error removing tech vendors from RFQ:", err); + return { data: [], error: getErrorMessage(err) }; + } +} + +/** + * 기술영업 벤더 검색 + */ +export async function searchTechVendors(searchTerm: string, limit = 100, rfqType?: "SHIP" | "TOP" | "HULL") { + unstable_noStore(); + + try { + // RFQ 타입에 따른 벤더 타입 매핑 + const vendorTypeFilter = rfqType === "SHIP" ? "SHIP" : + rfqType === "TOP" ? "OFFSHORE_TOP" : + rfqType === "HULL" ? "OFFSHORE_HULL" : null; + + const whereConditions = [ + eq(techVendors.status, "ACTIVE"), + or( + ilike(techVendors.vendorName, `%${searchTerm}%`), + ilike(techVendors.vendorCode, `%${searchTerm}%`) + ) + ]; + + // RFQ 타입이 지정된 경우 벤더 타입 필터링 추가 + if (vendorTypeFilter) { + whereConditions.push(eq(techVendors.techVendorType, vendorTypeFilter)); + } + + const results = await db + .select({ + id: techVendors.id, + vendorName: techVendors.vendorName, + vendorCode: techVendors.vendorCode, + status: techVendors.status, + country: techVendors.country, + techVendorType: techVendors.techVendorType, + }) + .from(techVendors) + .where(and(...whereConditions)) + .limit(limit) + .orderBy(techVendors.vendorName); + + return results; + } catch (err) { + console.error("Error searching tech vendors:", err); + throw new Error(getErrorMessage(err)); + } } \ No newline at end of file diff --git a/lib/techsales-rfq/table/create-rfq-dialog.tsx b/lib/techsales-rfq/table/create-rfq-dialog.tsx deleted file mode 100644 index 81c85649..00000000 --- a/lib/techsales-rfq/table/create-rfq-dialog.tsx +++ /dev/null @@ -1,917 +0,0 @@ -"use client" - -import * as React from "react" -import { toast } from "sonner" -import { ArrowUpDown, CheckSquare, Plus, Search, Square, X, Loader2 } from "lucide-react" -import { Input } from "@/components/ui/input" -import { Calendar } from "@/components/ui/calendar" -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" -import { CalendarIcon } from "lucide-react" -import { format } from "date-fns" -import { ko } from "date-fns/locale" - -import { Button } from "@/components/ui/button" -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, - FormDescription, -} from "@/components/ui/form" -import { zodResolver } from "@hookform/resolvers/zod" -import { useForm } from "react-hook-form" -import * as z from "zod" -import { EstimateProjectSelector } from "@/components/BidProjectSelector" -import { type Project } from "@/lib/rfqs/service" -import { createTechSalesRfq } from "@/lib/techsales-rfq/service" -import { useSession } from "next-auth/react" -import { Separator } from "@/components/ui/separator" -import { Badge } from "@/components/ui/badge" -import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { cn } from "@/lib/utils" -import { ScrollArea } from "@/components/ui/scroll-area" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" - -// 실제 데이터 서비스 import -import { - getWorkTypes, - getAllShipbuildingItemsForCache, - getShipTypes, - type ShipbuildingItem, - type WorkType -} from "@/lib/items-tech/service" - -// 유효성 검증 스키마 - 자재코드(item_code) 배열로 변경 -const createRfqSchema = z.object({ - biddingProjectId: z.number({ - required_error: "프로젝트를 선택해주세요.", - }), - materialCodes: z.array(z.string()).min(1, { - message: "적어도 하나의 자재코드를 선택해야 합니다.", - }), - dueDate: z.date({ - required_error: "마감일을 선택해주세요.", - }), -}) - -// 폼 데이터 타입 -type CreateRfqFormValues = z.infer - -// 공종 타입 정의 -interface WorkTypeOption { - code: WorkType - name: string - description: string -} - -interface CreateRfqDialogProps { - onCreated?: () => void; -} - -export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { - const { data: session } = useSession() - const [isProcessing, setIsProcessing] = React.useState(false) - const [isDialogOpen, setIsDialogOpen] = React.useState(false) - const [selectedProject, setSelectedProject] = React.useState(null) - - // 검색 및 필터링 상태 - const [itemSearchQuery, setItemSearchQuery] = React.useState("") - const [selectedWorkType, setSelectedWorkType] = React.useState(null) - const [selectedShipType, setSelectedShipType] = React.useState(null) - const [selectedItems, setSelectedItems] = React.useState([]) - - // 데이터 상태 - const [workTypes, setWorkTypes] = React.useState([]) - const [allItems, setAllItems] = React.useState([]) - const [shipTypes, setShipTypes] = React.useState([]) - const [isLoadingItems, setIsLoadingItems] = React.useState(false) - const [dataLoadError, setDataLoadError] = React.useState(null) - const [retryCount, setRetryCount] = React.useState(0) - - // 데이터 로딩 함수를 useCallback으로 메모이제이션 - const loadData = React.useCallback(async (isRetry = false) => { - try { - if (!isRetry) { - setIsLoadingItems(true) - setDataLoadError(null) - } - - console.log(`데이터 로딩 시작... ${isRetry ? `(재시도 ${retryCount + 1}회)` : ''}`) - - const [workTypesResult, itemsResult, shipTypesResult] = await Promise.all([ - getWorkTypes(), - getAllShipbuildingItemsForCache(), - getShipTypes() - ]) - - console.log("WorkTypes 결과:", workTypesResult) - console.log("Items 결과:", itemsResult) - console.log("ShipTypes 결과:", shipTypesResult) - - // WorkTypes 설정 - if (Array.isArray(workTypesResult)) { - setWorkTypes(workTypesResult) - } else { - console.error("WorkTypes 데이터 형식 오류:", workTypesResult) - throw new Error("공종 데이터를 불러올 수 없습니다.") - } - - // Items 설정 - if (!itemsResult.error && itemsResult.data && Array.isArray(itemsResult.data)) { - setAllItems(itemsResult.data) - console.log("아이템 설정 완료:", itemsResult.data.length, "개") - } else { - console.error("아이템 로딩 실패:", itemsResult.error) - throw new Error(itemsResult.error || "아이템 데이터를 불러올 수 없습니다.") - } - - // ShipTypes 설정 - if (!shipTypesResult.error && shipTypesResult.data && Array.isArray(shipTypesResult.data)) { - setShipTypes(shipTypesResult.data) - console.log("선종 설정 완료:", shipTypesResult.data) - } else { - console.error("선종 로딩 실패:", shipTypesResult.error) - throw new Error(shipTypesResult.error || "선종 데이터를 불러올 수 없습니다.") - } - - // 성공 시 재시도 카운터 리셋 - setRetryCount(0) - setDataLoadError(null) - console.log("데이터 로딩 완료") - - } catch (error) { - const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.' - console.error("데이터 로딩 오류:", errorMessage) - - setDataLoadError(errorMessage) - - // 3회까지 자동 재시도 (500ms 간격) - if (retryCount < 2) { - console.log(`${500 * (retryCount + 1)}ms 후 재시도...`) - setTimeout(() => { - setRetryCount(prev => prev + 1) - loadData(true) - }, 500 * (retryCount + 1)) - } else { - // 재시도 실패 시 사용자에게 알림 - toast.error(`데이터 로딩에 실패했습니다: ${errorMessage}`) - } - } finally { - if (!isRetry) { - setIsLoadingItems(false) - } - } - }, [retryCount]) - - // 다이얼로그가 열릴 때마다 데이터 로딩 - React.useEffect(() => { - if (isDialogOpen) { - // 다이얼로그가 열릴 때마다 데이터 상태 초기화 및 로딩 - setDataLoadError(null) - setRetryCount(0) - - // 이미 데이터가 있고 에러가 없다면 로딩하지 않음 (성능 최적화) - if (allItems.length > 0 && workTypes.length > 0 && shipTypes.length > 0 && !dataLoadError) { - console.log("기존 데이터 사용 (캐시)") - return - } - - loadData() - } - }, [isDialogOpen, loadData, allItems.length, workTypes.length, shipTypes.length, dataLoadError]) - - // 수동 새로고침 함수 - const handleRefreshData = React.useCallback(() => { - setDataLoadError(null) - setRetryCount(0) - loadData() - }, [loadData]) - - // RFQ 생성 폼 - const form = useForm({ - resolver: zodResolver(createRfqSchema), - defaultValues: { - biddingProjectId: undefined, - materialCodes: [], - dueDate: undefined, // 기본값 제거 - } - }) - - // 필터링된 아이템 목록 가져오기 - const availableItems = React.useMemo(() => { - let filtered = [...allItems] - - // 선종 필터 - if (selectedShipType) { - filtered = filtered.filter(item => item.shipTypes === selectedShipType) - } - - // 공종 필터 - if (selectedWorkType) { - filtered = filtered.filter(item => item.workType === selectedWorkType) - } - - // 검색어 필터 - if (itemSearchQuery && itemSearchQuery.trim()) { - const query = itemSearchQuery.toLowerCase().trim() - filtered = filtered.filter(item => - item.itemCode.toLowerCase().includes(query) || - (item.itemList && item.itemList.toLowerCase().includes(query)) - ) - } - - return filtered - }, [allItems, itemSearchQuery, selectedWorkType, selectedShipType]) - - // 사용 가능한 선종 목록 가져오기 - const availableShipTypes = React.useMemo(() => { - return shipTypes - }, [shipTypes]) - - // 프로젝트 선택 처리 - const handleProjectSelect = (project: Project) => { - setSelectedProject(project) - form.setValue("biddingProjectId", project.id) - // 선택 초기화 - setSelectedItems([]) - setSelectedShipType(null) - setSelectedWorkType(null) - setItemSearchQuery("") - form.setValue("materialCodes", []) - } - - // 아이템 선택/해제 처리 - const handleItemToggle = (item: ShipbuildingItem) => { - const isSelected = selectedItems.some(selected => selected.id === item.id) - - if (isSelected) { - // 아이템 선택 해제 - const newSelectedItems = selectedItems.filter(selected => selected.id !== item.id) - setSelectedItems(newSelectedItems) - form.setValue("materialCodes", newSelectedItems.map(item => item.itemCode)) - } else { - // 아이템 선택 추가 - const newSelectedItems = [...selectedItems, item] - setSelectedItems(newSelectedItems) - form.setValue("materialCodes", newSelectedItems.map(item => item.itemCode)) - } - } - - // 아이템 제거 처리 - const handleRemoveItem = (itemId: number) => { - const newSelectedItems = selectedItems.filter(item => item.id !== itemId) - setSelectedItems(newSelectedItems) - form.setValue("materialCodes", newSelectedItems.map(item => item.itemCode)) - } - - // RFQ 생성 함수 - const handleCreateRfq = async (data: CreateRfqFormValues) => { - try { - setIsProcessing(true) - - // 사용자 인증 확인 - if (!session?.user?.id) { - throw new Error("로그인이 필요합니다") - } - - // 선택된 아이템들을 아이템명(itemList)으로 그룹핑 - const groupedItems = selectedItems.reduce((groups, item) => { - const actualItemName = item.itemList // 실제 조선 아이템명 - if (!actualItemName) { - throw new Error(`아이템 "${item.itemCode}"의 아이템명(itemList)이 없습니다.`) - } - if (!groups[actualItemName]) { - groups[actualItemName] = [] - } - groups[actualItemName].push(item) - return groups - }, {} as Record) - - const rfqGroups = Object.entries(groupedItems).map(([actualItemName, items]) => { - const itemCodes = items.map(item => item.itemCode) // 자재그룹코드들 - const joinedItemCodes = itemCodes.join(',') - return { - actualItemName, - items, - itemCodes, - joinedItemCodes, - codeLength: joinedItemCodes.length, - isOverLimit: joinedItemCodes.length > 255 - } - }) - - // 255자 초과 그룹 확인 - const overLimitGroups = rfqGroups.filter(group => group.isOverLimit) - if (overLimitGroups.length > 0) { - const groupNames = overLimitGroups.map(g => `"${g.actualItemName}" (${g.codeLength}자)`).join(', ') - throw new Error(`다음 아이템 그룹의 자재코드가 255자를 초과합니다: ${groupNames}`) - } - - // 각 그룹별로 RFQ 생성 - const createPromises = rfqGroups.map(group => - createTechSalesRfq({ - biddingProjectId: data.biddingProjectId, - itemShipbuildingId: group.items[0].id, // 그룹의 첫 번째 아이템의 shipbuilding ID 사용 - materialGroupCodes: group.itemCodes, // 해당 그룹의 자재코드들 - createdBy: Number(session.user.id), - dueDate: data.dueDate, - }) - ) - - const results = await Promise.all(createPromises) - - // 오류 확인 - const errors = results.filter(result => result.error) - if (errors.length > 0) { - throw new Error(errors.map(e => e.error).join(', ')) - } - - // 성공적으로 생성되면 다이얼로그 닫기 및 메시지 표시 - const totalRfqs = results.reduce((sum, result) => sum + (result.data?.length || 0), 0) - toast.success(`${rfqGroups.length}개 아이템 그룹으로 총 ${totalRfqs}개의 RFQ가 성공적으로 생성되었습니다`) - setIsDialogOpen(false) - form.reset({ - biddingProjectId: undefined, - materialCodes: [], - dueDate: undefined, // 기본값 제거 - }) - setSelectedProject(null) - setItemSearchQuery("") - setSelectedWorkType(null) - setSelectedShipType(null) - setSelectedItems([]) - // 에러 상태 및 재시도 카운터 초기화 - setDataLoadError(null) - setRetryCount(0) - - // 생성 후 콜백 실행 - if (onCreated) { - onCreated() - } - - } catch (error) { - console.error("RFQ 생성 오류:", error) - toast.error(`RFQ 생성 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`) - } finally { - setIsProcessing(false) - } - } - - return ( - { - setIsDialogOpen(open) - if (!open) { - form.reset({ - biddingProjectId: undefined, - materialCodes: [], - dueDate: undefined, // 기본값 제거 - }) - setSelectedProject(null) - setItemSearchQuery("") - setSelectedWorkType(null) - setSelectedShipType(null) - setSelectedItems([]) - // 에러 상태 및 재시도 카운터 초기화 - setDataLoadError(null) - setRetryCount(0) - } - }} - > - - - - - - RFQ 생성 - - -
-
- - {/* 프로젝트 선택 */} -
- ( - - 입찰 프로젝트 - - - - - - )} - /> - - - - {/* 선종 선택 */} -
-
- 선종 선택 -
- - {/* 데이터 로딩 에러 표시 */} - {dataLoadError && ( -
-
-
- - {dataLoadError} -
- -
-
- )} - - - - - - - { - setSelectedShipType(null) - setSelectedItems([]) - form.setValue("materialCodes", []) - }} - > - 전체 선종 - - {availableShipTypes.map(shipType => ( - { - setSelectedShipType(shipType) - setSelectedItems([]) - form.setValue("materialCodes", []) - }} - > - {shipType} - - ))} - - -
- - - - {/* 마감일 설정 */} - ( - - 마감일 - - - - - - - - - date < new Date() || date < new Date("1900-01-01") - } - initialFocus - /> - - - - - )} - /> - - - -
- {/* 아이템 선택 영역 */} -
-
- 조선 아이템 선택 - - {selectedShipType ? `선종 ${selectedShipType}의 공종별 아이템을 선택하세요` : "먼저 선종을 선택해주세요"} - -
- - {/* 아이템 검색 및 필터 */} -
-
-
- - setItemSearchQuery(e.target.value)} - className="pl-8 pr-8" - disabled={!selectedShipType || isLoadingItems || dataLoadError !== null} - /> - {itemSearchQuery && ( - - )} -
- - {/* 공종 필터 */} - - - - - - setSelectedWorkType(null)} - > - 전체 공종 - - {workTypes.map(workType => ( - setSelectedWorkType(workType.code)} - > - {workType.name} - - ))} - - -
-
- - {/* 아이템 목록 */} -
- -
- {dataLoadError ? ( -
-
-
- -
-

데이터 로딩에 실패했습니다

-

{dataLoadError}

-
- -
-
-
- ) : isLoadingItems ? ( -
- - 아이템을 불러오는 중... - {retryCount > 0 && ( -

재시도 {retryCount}회

- )} -
- ) : availableItems.length > 0 ? ( - [...availableItems] - .sort((a, b) => { - // itemList 기준으로 정렬 (없는 경우 itemName 사용, 둘 다 없으면 맨 뒤로) - const aName = a.itemList || 'zzz' - const bName = b.itemList || 'zzz' - return aName.localeCompare(bName, 'ko', { numeric: true }) - }) - .map((item) => { - const isSelected = selectedItems.some(selected => selected.id === item.id) - return ( -
handleItemToggle(item)} - > -
- {isSelected ? ( - - ) : ( - - )} -
-
- {item.itemList || '아이템명 없음'} -
-
- {item.itemCode || '자재그룹코드 없음'} -
-
- 공종: {item.workType} • 선종: {item.shipTypes} -
-
-
-
- ) - }) - ) : ( -
- {itemSearchQuery ? "검색 결과가 없습니다" : "아이템이 없습니다"} -
- )} -
-
-
- - {/* 선택된 아이템 목록 */} - ( - - 선택된 아이템 ({selectedItems.length}개) -
- {selectedItems.length > 0 ? ( -
- {selectedItems.map((item) => ( - - {item.itemList || '아이템명 없음'} ({item.itemCode}) - handleRemoveItem(item.id)} - /> - - ))} -
- ) : ( -
- 선택된 아이템이 없습니다 -
- )} -
- -
- )} - /> - - {/* RFQ 그룹핑 미리보기 */} -
- 생성될 RFQ 그룹 미리보기 -
- {(() => { - // 아이템명(itemList)으로 그룹핑 - const groupedItems = selectedItems.reduce((groups, item) => { - const actualItemName = item.itemList // 실제 조선 아이템명 - if (!actualItemName) { - return groups // itemList가 없는 경우 제외 - } - if (!groups[actualItemName]) { - groups[actualItemName] = [] - } - groups[actualItemName].push(item) - return groups - }, {} as Record) - - const rfqGroups = Object.entries(groupedItems).map(([actualItemName, items]) => { - const itemCodes = items.map(item => item.itemCode) // 자재그룹코드들 - const joinedItemCodes = itemCodes.join(',') - return { - actualItemName, - items, - itemCodes, - joinedItemCodes, - codeLength: joinedItemCodes.length, - isOverLimit: joinedItemCodes.length > 255 - } - }) - - return ( -
-
- 총 {rfqGroups.length}개의 RFQ가 생성됩니다 (아이템명별로 그룹핑) -
- - - - - RFQ # - 아이템명 - 자재그룹코드 개수 - 길이 - 상태 - - - - {rfqGroups.map((group, index) => ( - - #{index + 1} - -
- {group.actualItemName} -
-
- {group.itemCodes.length}개 - - - {group.codeLength}/255자 - - - - {group.isOverLimit ? ( - 초과 - ) : ( - 정상 - )} - -
- ))} -
-
-
-
- ) - })()} -
-
-
-
-
-
- -
- - {/* Footer - Sticky 버튼 영역 */} -
-
- - -
-
-
-
- ) -} \ No newline at end of file diff --git a/lib/techsales-rfq/table/create-rfq-hull-dialog.tsx b/lib/techsales-rfq/table/create-rfq-hull-dialog.tsx new file mode 100644 index 00000000..4ba98cc7 --- /dev/null +++ b/lib/techsales-rfq/table/create-rfq-hull-dialog.tsx @@ -0,0 +1,652 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" +import { ArrowUpDown, CheckSquare, Plus, Search, Square, X, Loader2 } from "lucide-react" +import { Input } from "@/components/ui/input" +import { Calendar } from "@/components/ui/calendar" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { CalendarIcon } from "lucide-react" +import { format } from "date-fns" +import { ko } from "date-fns/locale" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "@/components/ui/form" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import * as z from "zod" +import { EstimateProjectSelector } from "@/components/BidProjectSelector" +import { type Project } from "@/lib/rfqs/service" +import { createTechSalesHullRfq } from "@/lib/techsales-rfq/service" +import { useSession } from "next-auth/react" +import { Separator } from "@/components/ui/separator" +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { cn } from "@/lib/utils" +import { ScrollArea } from "@/components/ui/scroll-area" +// import { +// Table, +// TableBody, +// TableCell, +// TableHead, +// TableHeader, +// TableRow, +// } from "@/components/ui/table" + +// 공종 타입 import +import { + getOffshoreHullWorkTypes, + getAllOffshoreHullItemsForCache, + type OffshoreHullWorkType, + type OffshoreHullTechItem +} from "@/lib/items-tech/service" + +// 해양 HULL 아이템 타입 정의 (이미 service에서 import하므로 제거) + +// 유효성 검증 스키마 +const createHullRfqSchema = z.object({ + biddingProjectId: z.number({ + required_error: "프로젝트를 선택해주세요.", + }), + itemIds: z.array(z.number()).min(1, { + message: "적어도 하나의 아이템을 선택해야 합니다.", + }), + dueDate: z.date({ + required_error: "마감일을 선택해주세요.", + }), + description: z.string().optional(), +}) + +// 폼 데이터 타입 +type CreateHullRfqFormValues = z.infer + +// 공종 타입 정의 +interface WorkTypeOption { + code: OffshoreHullWorkType + name: string + description: string +} + +interface CreateHullRfqDialogProps { + onCreated?: () => void; +} + +export function CreateHullRfqDialog({ onCreated }: CreateHullRfqDialogProps) { + const { data: session } = useSession() + + const [isProcessing, setIsProcessing] = React.useState(false) + const [isDialogOpen, setIsDialogOpen] = React.useState(false) + const [selectedProject, setSelectedProject] = React.useState(null) + + // 검색 및 필터링 상태 + const [itemSearchQuery, setItemSearchQuery] = React.useState("") + const [selectedWorkType, setSelectedWorkType] = React.useState(null) + const [selectedItems, setSelectedItems] = React.useState([]) + + // 데이터 상태 + const [workTypes, setWorkTypes] = React.useState([]) + const [allItems, setAllItems] = React.useState([]) + const [isLoadingItems, setIsLoadingItems] = React.useState(false) + const [dataLoadError, setDataLoadError] = React.useState(null) + const [retryCount, setRetryCount] = React.useState(0) + + // 데이터 로딩 함수 + const loadData = React.useCallback(async (isRetry = false) => { + try { + if (!isRetry) { + setIsLoadingItems(true) + setDataLoadError(null) + } + + console.log(`해양 Hull RFQ 데이터 로딩 시작... ${isRetry ? `(재시도 ${retryCount + 1}회)` : ''}`) + + const [workTypesResult, hullItemsResult] = await Promise.all([ + getOffshoreHullWorkTypes(), + getAllOffshoreHullItemsForCache() + ]) + + console.log("Hull - WorkTypes 결과:", workTypesResult) + console.log("Hull - Items 결과:", hullItemsResult) + + // WorkTypes 설정 + if (Array.isArray(workTypesResult)) { + setWorkTypes(workTypesResult) + } else { + throw new Error("공종 데이터를 불러올 수 없습니다.") + } + + // Hull Items 설정 + if (hullItemsResult.data && Array.isArray(hullItemsResult.data)) { + setAllItems(hullItemsResult.data as OffshoreHullTechItem[]) + console.log("Hull 아이템 설정 완료:", hullItemsResult.data.length, "개") + } else { + throw new Error(hullItemsResult.error || "Hull 아이템 데이터를 불러올 수 없습니다.") + } + + // 성공 시 재시도 카운터 리셋 + setRetryCount(0) + setDataLoadError(null) + console.log("해양 Hull RFQ 데이터 로딩 완료") + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.' + console.error("해양 Hull RFQ 데이터 로딩 오류:", errorMessage) + + setDataLoadError(errorMessage) + + // 3회까지 자동 재시도 (500ms 간격) + if (retryCount < 2) { + console.log(`${500 * (retryCount + 1)}ms 후 재시도...`) + setTimeout(() => { + setRetryCount(prev => prev + 1) + loadData(true) + }, 500 * (retryCount + 1)) + } else { + // 재시도 실패 시 사용자에게 알림 + toast.error(`데이터 로딩에 실패했습니다: ${errorMessage}`) + } + } finally { + if (!isRetry) { + setIsLoadingItems(false) + } + } + }, [retryCount]) + + // 다이얼로그가 열릴 때마다 데이터 로딩 + React.useEffect(() => { + if (isDialogOpen) { + setDataLoadError(null) + setRetryCount(0) + loadData() + } + }, [isDialogOpen, loadData]) + + // 수동 새로고침 함수 + const handleRefreshData = React.useCallback(() => { + setDataLoadError(null) + setRetryCount(0) + loadData() + }, [loadData]) + + // RFQ 생성 폼 + const form = useForm({ + resolver: zodResolver(createHullRfqSchema), + defaultValues: { + biddingProjectId: undefined, + itemIds: [], + dueDate: undefined, + description: "", + } + }) + + // 필터링된 아이템 목록 가져오기 + const availableItems = React.useMemo(() => { + let filtered = [...allItems] + + // 공종 필터 + if (selectedWorkType) { + filtered = filtered.filter(item => item.workType === selectedWorkType as OffshoreHullTechItem['workType']) + } + + // 검색어 필터 + if (itemSearchQuery && itemSearchQuery.trim()) { + const query = itemSearchQuery.toLowerCase().trim() + filtered = filtered.filter(item => + item.itemCode.toLowerCase().includes(query) || + (item.itemList && item.itemList.toLowerCase().includes(query)) || + (item.subItemList && item.subItemList.toLowerCase().includes(query)) + ) + } + + return filtered + }, [allItems, itemSearchQuery, selectedWorkType]) + + // 프로젝트 선택 처리 + const handleProjectSelect = (project: Project) => { + setSelectedProject(project) + form.setValue("biddingProjectId", project.id) + // 선택 초기화 + setSelectedItems([]) + setSelectedWorkType(null) + setItemSearchQuery("") + form.setValue("itemIds", []) + } + + // 아이템 선택/해제 처리 + const handleItemToggle = (item: OffshoreHullTechItem) => { + const isSelected = selectedItems.some(selected => selected.id === item.id) + + if (isSelected) { + const newSelectedItems = selectedItems.filter(selected => selected.id !== item.id) + setSelectedItems(newSelectedItems) + form.setValue("itemIds", newSelectedItems.map(item => item.id)) + } else { + const newSelectedItems = [...selectedItems, item] + setSelectedItems(newSelectedItems) + form.setValue("itemIds", newSelectedItems.map(item => item.id)) + } + } + + // RFQ 생성 함수 + const handleCreateRfq = async (data: CreateHullRfqFormValues) => { + try { + setIsProcessing(true) + + // 사용자 인증 확인 + if (!session?.user?.id) { + throw new Error("로그인이 필요합니다") + } + + // 해양 Hull RFQ 생성 - 1:N 관계로 한 번에 생성 + const result = await createTechSalesHullRfq({ + biddingProjectId: data.biddingProjectId, + itemIds: data.itemIds, + dueDate: data.dueDate, + description: data.description, + createdBy: Number(session.user.id), + }) + + if (result.error) { + throw new Error(result.error) + } + + // 성공적으로 생성되면 다이얼로그 닫기 및 메시지 표시 + toast.success(`${selectedItems.length}개 아이템으로 해양 Hull RFQ가 성공적으로 생성되었습니다`) + + setIsDialogOpen(false) + form.reset({ + biddingProjectId: undefined, + itemIds: [], + dueDate: undefined, + description: "", + }) + setSelectedProject(null) + setItemSearchQuery("") + setSelectedWorkType(null) + setSelectedItems([]) + setDataLoadError(null) + setRetryCount(0) + + // 생성 후 콜백 실행 + if (onCreated) { + onCreated() + } + + } catch (error) { + console.error("해양 Hull RFQ 생성 오류:", error) + toast.error(`해양 Hull RFQ 생성 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`) + } finally { + setIsProcessing(false) + } + } + + return ( + { + setIsDialogOpen(open) + if (!open) { + form.reset({ + biddingProjectId: undefined, + itemIds: [], + dueDate: undefined, + description: "", + }) + setSelectedProject(null) + setItemSearchQuery("") + setSelectedWorkType(null) + setSelectedItems([]) + setDataLoadError(null) + setRetryCount(0) + } + }} + > + + + + + + 해양 Hull RFQ 생성 + + +
+
+ + {/* 프로젝트 선택 */} +
+ ( + + 입찰 프로젝트 + + + + + + )} + /> + + + + {/* RFQ 설명 */} + ( + + RFQ 설명 + + + + + + )} + /> + + + + {/* 마감일 설정 */} + ( + + 마감일 + + + + + + + + + date < new Date() || date < new Date("1900-01-01") + } + initialFocus + /> + + + + + )} + /> + + + +
+ {/* 아이템 선택 영역 */} +
+
+ 해양 Hull 아이템 선택 + + 해양 Hull 아이템을 선택하세요 + +
+ + {/* 데이터 로딩 에러 표시 */} + {dataLoadError && ( +
+
+
+ + {dataLoadError} +
+ +
+
+ )} + + {/* 아이템 검색 및 필터 */} +
+
+
+ + setItemSearchQuery(e.target.value)} + className="pl-8 pr-8" + disabled={isLoadingItems || dataLoadError !== null} + /> + {itemSearchQuery && ( + + )} +
+ + {/* 공종 필터 */} + + + + + + setSelectedWorkType(null)} + > + 전체 공종 + + {workTypes.map(workType => ( + setSelectedWorkType(workType.code)} + > + {workType.name} + + ))} + + +
+
+ + {/* 아이템 목록 */} +
+ +
+ {dataLoadError ? ( +
+
+
+ +
+

데이터 로딩에 실패했습니다

+

{dataLoadError}

+
+ +
+
+
+ ) : isLoadingItems ? ( +
+ + 아이템을 불러오는 중... + {retryCount > 0 && ( +

재시도 {retryCount}회

+ )} +
+ ) : availableItems.length > 0 ? ( + [...availableItems] + .sort((a, b) => { + const aName = a.itemList || 'zzz' + const bName = b.itemList || 'zzz' + return aName.localeCompare(bName, 'ko', { numeric: true }) + }) + .map((item) => { + const isSelected = selectedItems.some(selected => selected.id === item.id) + + return ( +
handleItemToggle(item)} + > +
+ {isSelected ? ( + + ) : ( + + )} +
+ {/* Hull 아이템 표시: "item_list / sub_item_list" / item_code / 공종 */} +
+ {item.itemList || '아이템명 없음'} + {item.subItemList && ` / ${item.subItemList}`} +
+
+ {item.itemCode || '아이템코드 없음'} +
+
+ 공종: {item.workType} +
+
+
+
+ ) + }) + ) : ( +
+ {itemSearchQuery ? "검색 결과가 없습니다" : "아이템이 없습니다"} +
+ )} +
+
+
+
+
+
+
+ +
+ + {/* Footer - Sticky 버튼 영역 */} +
+
+ + +
+
+
+
+ ) +} \ No newline at end of file diff --git a/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx b/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx new file mode 100644 index 00000000..8a66f26e --- /dev/null +++ b/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx @@ -0,0 +1,726 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" +import { ArrowUpDown, CheckSquare, Plus, Search, Square, X, Loader2 } from "lucide-react" +import { Input } from "@/components/ui/input" +import { Calendar } from "@/components/ui/calendar" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { CalendarIcon } from "lucide-react" +import { format } from "date-fns" +import { ko } from "date-fns/locale" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "@/components/ui/form" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import * as z from "zod" +import { EstimateProjectSelector } from "@/components/BidProjectSelector" +import { type Project } from "@/lib/rfqs/service" +import { createTechSalesShipRfq } from "@/lib/techsales-rfq/service" +import { useSession } from "next-auth/react" +import { Separator } from "@/components/ui/separator" +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { cn } from "@/lib/utils" +import { ScrollArea } from "@/components/ui/scroll-area" + +// 조선 아이템 서비스 import +import { + getWorkTypes, + getAllShipbuildingItemsForCache, + getShipTypes, + type ShipbuildingItem, + type WorkType +} from "@/lib/items-tech/service" + + +// 유효성 검증 스키마 +const createShipRfqSchema = z.object({ + biddingProjectId: z.number({ + required_error: "프로젝트를 선택해주세요.", + }), + itemIds: z.array(z.number()).min(1, { + message: "적어도 하나의 아이템을 선택해야 합니다.", + }), + dueDate: z.date({ + required_error: "마감일을 선택해주세요.", + }), + description: z.string().optional(), +}) + +// 폼 데이터 타입 +type CreateShipRfqFormValues = z.infer + +// 공종 타입 정의 +interface WorkTypeOption { + code: WorkType + name: string + description: string +} + +interface CreateShipRfqDialogProps { + onCreated?: () => void; +} + +export function CreateShipRfqDialog({ onCreated }: CreateShipRfqDialogProps) { + const { data: session } = useSession() + const [isProcessing, setIsProcessing] = React.useState(false) + const [isDialogOpen, setIsDialogOpen] = React.useState(false) + const [selectedProject, setSelectedProject] = React.useState(null) + + // 검색 및 필터링 상태 + const [itemSearchQuery, setItemSearchQuery] = React.useState("") + const [selectedWorkType, setSelectedWorkType] = React.useState(null) + const [selectedShipType, setSelectedShipType] = React.useState(null) + const [selectedItems, setSelectedItems] = React.useState([]) + + // 데이터 상태 + const [workTypes, setWorkTypes] = React.useState([]) + const [allItems, setAllItems] = React.useState([]) + const [shipTypes, setShipTypes] = React.useState([]) + const [isLoadingItems, setIsLoadingItems] = React.useState(false) + const [dataLoadError, setDataLoadError] = React.useState(null) + const [retryCount, setRetryCount] = React.useState(0) + + // 데이터 로딩 함수 + const loadData = React.useCallback(async (isRetry = false) => { + try { + if (!isRetry) { + setIsLoadingItems(true) + setDataLoadError(null) + } + + console.log(`조선 RFQ 데이터 로딩 시작... ${isRetry ? `(재시도 ${retryCount + 1}회)` : ''}`) + + const [workTypesResult, itemsResult, shipTypesResult] = await Promise.all([ + getWorkTypes(), + getAllShipbuildingItemsForCache(), + getShipTypes() + ]) + + console.log("Ship - WorkTypes 결과:", workTypesResult) + console.log("Ship - Items 결과:", itemsResult) + console.log("Ship - ShipTypes 결과:", shipTypesResult) + + // WorkTypes 설정 + if (Array.isArray(workTypesResult)) { + setWorkTypes(workTypesResult) + } else { + throw new Error("공종 데이터를 불러올 수 없습니다.") + } + + // Items 설정 + if (!itemsResult.error && itemsResult.data && Array.isArray(itemsResult.data)) { + setAllItems(itemsResult.data) + console.log("Ship 아이템 설정 완료:", itemsResult.data.length, "개") + } else { + throw new Error(itemsResult.error || "Ship 아이템 데이터를 불러올 수 없습니다.") + } + + // ShipTypes 설정 + if (!shipTypesResult.error && shipTypesResult.data && Array.isArray(shipTypesResult.data)) { + setShipTypes(shipTypesResult.data) + console.log("선종 설정 완료:", shipTypesResult.data) + } else { + throw new Error(shipTypesResult.error || "선종 데이터를 불러올 수 없습니다.") + } + + // 성공 시 재시도 카운터 리셋 + setRetryCount(0) + setDataLoadError(null) + console.log("조선 RFQ 데이터 로딩 완료") + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.' + console.error("조선 RFQ 데이터 로딩 오류:", errorMessage) + + setDataLoadError(errorMessage) + + // 3회까지 자동 재시도 (500ms 간격) + if (retryCount < 2) { + console.log(`${500 * (retryCount + 1)}ms 후 재시도...`) + setTimeout(() => { + setRetryCount(prev => prev + 1) + loadData(true) + }, 500 * (retryCount + 1)) + } else { + // 재시도 실패 시 사용자에게 알림 + toast.error(`데이터 로딩에 실패했습니다: ${errorMessage}`) + } + } finally { + if (!isRetry) { + setIsLoadingItems(false) + } + } + }, [retryCount]) + + // 다이얼로그가 열릴 때마다 데이터 로딩 + React.useEffect(() => { + if (isDialogOpen) { + setDataLoadError(null) + setRetryCount(0) + loadData() + } + }, [isDialogOpen, loadData]) + + // 수동 새로고침 함수 + const handleRefreshData = React.useCallback(() => { + setDataLoadError(null) + setRetryCount(0) + loadData() + }, [loadData]) + + // RFQ 생성 폼 + const form = useForm({ + resolver: zodResolver(createShipRfqSchema), + defaultValues: { + biddingProjectId: undefined, + itemIds: [], + dueDate: undefined, + description: "", + } + }) + + // 필터링된 아이템 목록 가져오기 + const availableItems = React.useMemo(() => { + let filtered = [...allItems] + + // 선종 필터 + if (selectedShipType) { + filtered = filtered.filter(item => item.shipTypes === selectedShipType) + } + + // 공종 필터 + if (selectedWorkType) { + filtered = filtered.filter(item => item.workType === selectedWorkType) + } + + // 검색어 필터 + if (itemSearchQuery && itemSearchQuery.trim()) { + const query = itemSearchQuery.toLowerCase().trim() + filtered = filtered.filter(item => + item.itemCode.toLowerCase().includes(query) || + (item.itemList && item.itemList.toLowerCase().includes(query)) + ) + } + + return filtered + }, [allItems, itemSearchQuery, selectedWorkType, selectedShipType]) + + // 사용 가능한 선종 목록 가져오기 + const availableShipTypes = React.useMemo(() => { + return shipTypes + }, [shipTypes]) + + // 프로젝트 선택 처리 + const handleProjectSelect = (project: Project) => { + setSelectedProject(project) + form.setValue("biddingProjectId", project.id) + // 선택 초기화 + setSelectedItems([]) + setSelectedShipType(null) + setSelectedWorkType(null) + setItemSearchQuery("") + form.setValue("itemIds", []) + } + + // 아이템 선택/해제 처리 + const handleItemToggle = (item: ShipbuildingItem) => { + const isSelected = selectedItems.some(selected => selected.id === item.id) + + if (isSelected) { + const newSelectedItems = selectedItems.filter(selected => selected.id !== item.id) + setSelectedItems(newSelectedItems) + form.setValue("itemIds", newSelectedItems.map(item => item.id)) + } else { + const newSelectedItems = [...selectedItems, item] + setSelectedItems(newSelectedItems) + form.setValue("itemIds", newSelectedItems.map(item => item.id)) + } + } + + // RFQ 생성 함수 + const handleCreateRfq = async (data: CreateShipRfqFormValues) => { + try { + setIsProcessing(true) + + // 사용자 인증 확인 + if (!session?.user?.id) { + throw new Error("로그인이 필요합니다") + } + + // 조선 RFQ 생성 - 1:N 관계로 한 번에 생성 + const result = await createTechSalesShipRfq({ + biddingProjectId: data.biddingProjectId, + itemIds: data.itemIds, + dueDate: data.dueDate, + description: data.description, + createdBy: Number(session.user.id), + }) + + if (result.error) { + throw new Error(result.error) + } + + // 성공적으로 생성되면 다이얼로그 닫기 및 메시지 표시 + toast.success(`${selectedItems.length}개 아이템으로 조선 RFQ가 성공적으로 생성되었습니다`) + + setIsDialogOpen(false) + form.reset({ + biddingProjectId: undefined, + itemIds: [], + dueDate: undefined, + description: "", + }) + setSelectedProject(null) + setItemSearchQuery("") + setSelectedWorkType(null) + setSelectedShipType(null) + setSelectedItems([]) + setDataLoadError(null) + setRetryCount(0) + + // 생성 후 콜백 실행 + if (onCreated) { + onCreated() + } + + } catch (error) { + console.error("조선 RFQ 생성 오류:", error) + toast.error(`조선 RFQ 생성 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`) + } finally { + setIsProcessing(false) + } + } + + return ( + { + setIsDialogOpen(open) + if (!open) { + form.reset({ + biddingProjectId: undefined, + itemIds: [], + dueDate: undefined, + description: "", + }) + setSelectedProject(null) + setItemSearchQuery("") + setSelectedWorkType(null) + setSelectedShipType(null) + setSelectedItems([]) + setDataLoadError(null) + setRetryCount(0) + } + }} + > + + + + + + 조선 RFQ 생성 + + +
+
+ + {/* 프로젝트 선택 */} +
+ ( + + 입찰 프로젝트 + + + + + + )} + /> + + + + {/* RFQ 설명 */} + ( + + RFQ 설명 + + + + + + )} + /> + + + + {/* 선종 선택 */} +
+
+ 선종 선택 +
+ + {/* 데이터 로딩 에러 표시 */} + {dataLoadError && ( +
+
+
+ + {dataLoadError} +
+ +
+
+ )} + + + + + + + { + setSelectedShipType(null) + setSelectedItems([]) + form.setValue("itemIds", []) + }} + > + 전체 선종 + + {availableShipTypes.map(shipType => ( + { + setSelectedShipType(shipType) + setSelectedItems([]) + form.setValue("itemIds", []) + }} + > + {shipType} + + ))} + + +
+ + + + {/* 마감일 설정 */} + ( + + 마감일 + + + + + + + + + date < new Date() || date < new Date("1900-01-01") + } + initialFocus + /> + + + + + )} + /> + + + +
+ {/* 아이템 선택 영역 */} +
+
+ 조선 아이템 선택 + + {selectedShipType + ? `선종 ${selectedShipType}의 공종별 아이템을 선택하세요` + : "먼저 선종을 선택해주세요" + } + +
+ + {/* 아이템 검색 및 필터 */} +
+
+
+ + setItemSearchQuery(e.target.value)} + className="pl-8 pr-8" + disabled={!selectedShipType || isLoadingItems || dataLoadError !== null} + /> + {itemSearchQuery && ( + + )} +
+ + {/* 공종 필터 */} + + + + + + setSelectedWorkType(null)} + > + 전체 공종 + + {workTypes.map(workType => ( + setSelectedWorkType(workType.code)} + > + {workType.name} + + ))} + + +
+
+ + {/* 아이템 목록 */} +
+ +
+ {dataLoadError ? ( +
+
+
+ +
+

데이터 로딩에 실패했습니다

+

{dataLoadError}

+
+ +
+
+
+ ) : isLoadingItems ? ( +
+ + 아이템을 불러오는 중... + {retryCount > 0 && ( +

재시도 {retryCount}회

+ )} +
+ ) : availableItems.length > 0 ? ( + [...availableItems] + .sort((a, b) => { + const aName = a.itemList || 'zzz' + const bName = b.itemList || 'zzz' + return aName.localeCompare(bName, 'ko', { numeric: true }) + }) + .map((item) => { + const isSelected = selectedItems.some(selected => selected.id === item.id) + + return ( +
handleItemToggle(item)} + > +
+ {isSelected ? ( + + ) : ( + + )} +
+
+ {item.itemList || '아이템명 없음'} +
+
+ {item.itemCode || '자재그룹코드 없음'} +
+
+ 공종: {item.workType} • 선종: {item.shipTypes} +
+
+
+
+ ) + }) + ) : ( +
+ {itemSearchQuery ? "검색 결과가 없습니다" : "아이템이 없습니다"} +
+ )} +
+
+
+
+
+
+
+ +
+ + {/* Footer - Sticky 버튼 영역 */} +
+
+ + +
+
+
+
+ ) +} \ No newline at end of file diff --git a/lib/techsales-rfq/table/create-rfq-top-dialog.tsx b/lib/techsales-rfq/table/create-rfq-top-dialog.tsx new file mode 100644 index 00000000..70f56ebd --- /dev/null +++ b/lib/techsales-rfq/table/create-rfq-top-dialog.tsx @@ -0,0 +1,594 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" +import { ArrowUpDown, CheckSquare, Plus, Search, Square, X, Loader2 } from "lucide-react" +import { Input } from "@/components/ui/input" +import { Calendar } from "@/components/ui/calendar" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { CalendarIcon } from "lucide-react" +import { format } from "date-fns" +import { ko } from "date-fns/locale" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "@/components/ui/form" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import * as z from "zod" +import { EstimateProjectSelector } from "@/components/BidProjectSelector" +import { type Project } from "@/lib/rfqs/service" +import { createTechSalesTopRfq } from "@/lib/techsales-rfq/service" +import { useSession } from "next-auth/react" +import { Separator } from "@/components/ui/separator" +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { cn } from "@/lib/utils" +import { ScrollArea } from "@/components/ui/scroll-area" + +// 공종 타입 import +import { + getOffshoreTopWorkTypes, + getAllOffshoreTopItemsForCache, + type OffshoreTopWorkType, + type OffshoreTopTechItem +} from "@/lib/items-tech/service" + +// 해양 TOP 아이템 타입 정의 (이미 service에서 import하므로 제거) + +// 유효성 검증 스키마 +const createTopRfqSchema = z.object({ + biddingProjectId: z.number({ + required_error: "프로젝트를 선택해주세요.", + }), + itemIds: z.array(z.number()).min(1, { + message: "적어도 하나의 아이템을 선택해야 합니다.", + }), + dueDate: z.date({ + required_error: "마감일을 선택해주세요.", + }), + description: z.string().optional(), +}) + +// 폼 데이터 타입 +type CreateTopRfqFormValues = z.infer + +// 공종 타입 정의 +interface WorkTypeOption { + code: OffshoreTopWorkType + name: string + description: string +} + +interface CreateTopRfqDialogProps { + onCreated?: () => void; +} + +export function CreateTopRfqDialog({ onCreated }: CreateTopRfqDialogProps) { + const { data: session } = useSession() + const [isProcessing, setIsProcessing] = React.useState(false) + const [isDialogOpen, setIsDialogOpen] = React.useState(false) + const [selectedProject, setSelectedProject] = React.useState(null) + + // 검색 및 필터링 상태 + const [itemSearchQuery, setItemSearchQuery] = React.useState("") + const [selectedWorkType, setSelectedWorkType] = React.useState(null) + const [selectedItems, setSelectedItems] = React.useState([]) + + // 데이터 상태 + const [workTypes, setWorkTypes] = React.useState([]) + const [allItems, setAllItems] = React.useState([]) + const [isLoadingItems, setIsLoadingItems] = React.useState(false) + const [dataLoadError, setDataLoadError] = React.useState(null) + const [retryCount, setRetryCount] = React.useState(0) + + // 데이터 로딩 함수 + const loadData = React.useCallback(async (isRetry = false) => { + try { + if (!isRetry) { + setIsLoadingItems(true) + setDataLoadError(null) + } + + console.log(`해양 TOP RFQ 데이터 로딩 시작... ${isRetry ? `(재시도 ${retryCount + 1}회)` : ''}`) + + const [workTypesResult, topItemsResult] = await Promise.all([ + getOffshoreTopWorkTypes(), + getAllOffshoreTopItemsForCache() + ]) + + console.log("TOP - WorkTypes 결과:", workTypesResult) + console.log("TOP - Items 결과:", topItemsResult) + + // WorkTypes 설정 + if (Array.isArray(workTypesResult)) { + setWorkTypes(workTypesResult) + } else { + throw new Error("공종 데이터를 불러올 수 없습니다.") + } + + // TOP Items 설정 + if (topItemsResult.data && Array.isArray(topItemsResult.data)) { + setAllItems(topItemsResult.data as OffshoreTopTechItem[]) + console.log("TOP 아이템 설정 완료:", topItemsResult.data.length, "개") + } else { + throw new Error("TOP 아이템 데이터를 불러올 수 없습니다.") + } + + // 성공 시 재시도 카운터 리셋 + setRetryCount(0) + setDataLoadError(null) + console.log("해양 TOP RFQ 데이터 로딩 완료") + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.' + console.error("해양 TOP RFQ 데이터 로딩 오류:", errorMessage) + + setDataLoadError(errorMessage) + + // 3회까지 자동 재시도 (500ms 간격) + if (retryCount < 2) { + console.log(`${500 * (retryCount + 1)}ms 후 재시도...`) + setTimeout(() => { + setRetryCount(prev => prev + 1) + loadData(true) + }, 500 * (retryCount + 1)) + } else { + // 재시도 실패 시 사용자에게 알림 + toast.error(`데이터 로딩에 실패했습니다: ${errorMessage}`) + } + } finally { + if (!isRetry) { + setIsLoadingItems(false) + } + } + }, [retryCount]) + + // 다이얼로그가 열릴 때마다 데이터 로딩 + React.useEffect(() => { + if (isDialogOpen) { + setDataLoadError(null) + setRetryCount(0) + loadData() + } + }, [isDialogOpen, loadData]) + + // 수동 새로고침 함수 + const handleRefreshData = React.useCallback(() => { + setDataLoadError(null) + setRetryCount(0) + loadData() + }, [loadData]) + + // RFQ 생성 폼 + const form = useForm({ + resolver: zodResolver(createTopRfqSchema), + defaultValues: { + biddingProjectId: undefined, + itemIds: [], + dueDate: undefined, + description: "", + } + }) + + // 필터링된 아이템 목록 가져오기 + const availableItems = React.useMemo(() => { + let filtered = [...allItems] + + // 공종 필터 + if (selectedWorkType) { + filtered = filtered.filter(item => item.workType === selectedWorkType as OffshoreTopTechItem['workType']) + } + + // 검색어 필터 + if (itemSearchQuery && itemSearchQuery.trim()) { + const query = itemSearchQuery.toLowerCase().trim() + filtered = filtered.filter(item => + item.itemCode.toLowerCase().includes(query) || + (item.itemList && item.itemList.toLowerCase().includes(query)) || + (item.subItemList && item.subItemList.toLowerCase().includes(query)) + ) + } + + return filtered + }, [allItems, itemSearchQuery, selectedWorkType]) + + // 프로젝트 선택 처리 + const handleProjectSelect = (project: Project) => { + setSelectedProject(project) + form.setValue("biddingProjectId", project.id) + // 선택 초기화 + setSelectedItems([]) + setSelectedWorkType(null) + setItemSearchQuery("") + form.setValue("itemIds", []) + } + + // 아이템 선택/해제 처리 + const handleItemToggle = (item: OffshoreTopTechItem) => { + const isSelected = selectedItems.some(selected => selected.id === item.id) + + if (isSelected) { + const newSelectedItems = selectedItems.filter(selected => selected.id !== item.id) + setSelectedItems(newSelectedItems) + form.setValue("itemIds", newSelectedItems.map(item => item.id)) + } else { + const newSelectedItems = [...selectedItems, item] + setSelectedItems(newSelectedItems) + form.setValue("itemIds", newSelectedItems.map(item => item.id)) + } + } + + // RFQ 생성 함수 + const handleCreateRfq = async (data: CreateTopRfqFormValues) => { + try { + setIsProcessing(true) + + // 사용자 인증 확인 + if (!session?.user?.id) { + throw new Error("로그인이 필요합니다") + } + + // 해양 TOP RFQ 생성 - 1:N 관계로 한 번에 생성 + const result = await createTechSalesTopRfq({ + biddingProjectId: data.biddingProjectId, + itemIds: data.itemIds, + dueDate: data.dueDate, + description: data.description, + createdBy: Number(session.user.id), + }) + + if (result.error) { + throw new Error(result.error) + } + + // 성공적으로 생성되면 다이얼로그 닫기 및 메시지 표시 + toast.success(`${selectedItems.length}개 아이템으로 해양 TOP RFQ가 성공적으로 생성되었습니다`) + + setIsDialogOpen(false) + form.reset({ + biddingProjectId: undefined, + itemIds: [], + dueDate: undefined, + description: "", + }) + setSelectedProject(null) + setItemSearchQuery("") + setSelectedWorkType(null) + setSelectedItems([]) + setDataLoadError(null) + setRetryCount(0) + + // 생성 후 콜백 실행 + if (onCreated) { + onCreated() + } + + } catch (error) { + console.error("해양 TOP RFQ 생성 오류:", error) + toast.error(`해양 TOP RFQ 생성 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`) + } finally { + setIsProcessing(false) + } + } + + return ( + { + setIsDialogOpen(open) + if (!open) { + form.reset({ + biddingProjectId: undefined, + itemIds: [], + dueDate: undefined, + description: "", + }) + setSelectedProject(null) + setItemSearchQuery("") + setSelectedWorkType(null) + setSelectedItems([]) + setDataLoadError(null) + setRetryCount(0) + } + }} + > + + + + + + 해양 TOP RFQ 생성 + + +
+
+ + {/* 프로젝트 선택 */} +
+ ( + + 입찰 프로젝트 + + + + + + )} + /> + + + + {/* 마감일 설정 */} + ( + + 마감일 + + + + + + + + + date < new Date() || date < new Date("1900-01-01") + } + initialFocus + /> + + + + + )} + /> + + + +
+ {/* 아이템 선택 영역 */} +
+
+ 아이템 선택 + + 해양 TOP RFQ를 생성하려면 아이템을 선택하세요 + +
+ + {/* 아이템 검색 및 필터 */} +
+
+
+ + setItemSearchQuery(e.target.value)} + className="pl-8 pr-8" + disabled={isLoadingItems || dataLoadError !== null} + /> + {itemSearchQuery && ( + + )} +
+ + {/* 공종 필터 */} + + + + + + setSelectedWorkType(null)} + > + 전체 공종 + + {workTypes.map(workType => ( + setSelectedWorkType(workType.code)} + > + {workType.name} + + ))} + + +
+
+ + {/* 아이템 목록 */} +
+ +
+ {dataLoadError ? ( +
+
+
+ +
+

데이터 로딩에 실패했습니다

+

{dataLoadError}

+
+ +
+
+
+ ) : isLoadingItems ? ( +
+ + 아이템을 불러오는 중... + {retryCount > 0 && ( +

재시도 {retryCount}회

+ )} +
+ ) : availableItems.length > 0 ? ( + [...availableItems] + .sort((a, b) => { + const aName = a.itemList || 'zzz' + const bName = b.itemList || 'zzz' + return aName.localeCompare(bName, 'ko', { numeric: true }) + }) + .map((item) => { + const isSelected = selectedItems.some(selected => selected.id === item.id) + + return ( +
handleItemToggle(item)} + > +
+ {isSelected ? ( + + ) : ( + + )} +
+
+ {item.itemList || '아이템명 없음'} + {item.subItemList && ` / ${item.subItemList}`} +
+
+ {item.itemCode || '아이템코드 없음'} +
+
+ 공종: {item.workType} +
+
+
+
+ ) + }) + ) : ( +
+ {itemSearchQuery ? "검색 결과가 없습니다" : "아이템이 없습니다"} +
+ )} +
+
+
+
+
+
+
+ +
+ + {/* Footer - Sticky 버튼 영역 */} +
+
+ + +
+
+
+
+ ) +} \ No newline at end of file diff --git a/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx b/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx index b66f4d77..3574111f 100644 --- a/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx +++ b/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx @@ -6,7 +6,7 @@ import { useForm } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import { z } from "zod" import { toast } from "sonner" -import { Check, X, Search, Loader2 } from "lucide-react" +import { Check, X, Search, Loader2, Star } from "lucide-react" import { useSession } from "next-auth/react" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" @@ -15,8 +15,8 @@ import { Form, FormField, FormItem, FormLabel, FormMessage } from "@/components/ import { Input } from "@/components/ui/input" import { ScrollArea } from "@/components/ui/scroll-area" import { Badge } from "@/components/ui/badge" -import { addVendorsToTechSalesRfq } from "@/lib/techsales-rfq/service" -import { searchVendors } from "@/lib/vendors/service" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { addTechVendorsToTechSalesRfq, getTechSalesRfqCandidateVendors, searchTechVendors } from "@/lib/techsales-rfq/service" // 폼 유효성 검증 스키마 - 간단화 const vendorFormSchema = z.object({ @@ -33,13 +33,15 @@ type TechSalesRfq = { [key: string]: any // eslint-disable-line @typescript-eslint/no-explicit-any } -// 벤더 검색 결과 타입 (searchVendors 함수 반환 타입과 일치) +// 벤더 검색 결과 타입 (techVendor 기반) type VendorSearchResult = { id: number vendorName: string vendorCode: string | null status: string country: string | null + techVendorType?: string | null + matchedItemCount?: number // 후보 벤더 정보 } interface AddVendorDialogProps { @@ -61,10 +63,14 @@ export function AddVendorDialog({ const [isSubmitting, setIsSubmitting] = useState(false) const [searchTerm, setSearchTerm] = useState("") const [searchResults, setSearchResults] = useState([]) + const [candidateVendors, setCandidateVendors] = useState([]) const [isSearching, setIsSearching] = useState(false) + const [isLoadingCandidates, setIsLoadingCandidates] = useState(false) const [hasSearched, setHasSearched] = useState(false) + const [hasCandidatesLoaded, setHasCandidatesLoaded] = useState(false) // 선택된 벤더들을 별도로 관리하여 검색과 독립적으로 유지 const [selectedVendorData, setSelectedVendorData] = useState([]) + const [activeTab, setActiveTab] = useState("candidates") const form = useForm({ resolver: zodResolver(vendorFormSchema), @@ -75,7 +81,32 @@ export function AddVendorDialog({ const selectedVendorIds = form.watch("vendorIds") - // 검색 함수 (디바운스 적용) + // 후보 벤더 로드 함수 + const loadCandidateVendors = useCallback(async () => { + if (!selectedRfq?.id) return + + setIsLoadingCandidates(true) + try { + const result = await getTechSalesRfqCandidateVendors(selectedRfq.id) + if (result.error) { + toast.error(result.error) + setCandidateVendors([]) + } else { + // 이미 추가된 벤더 제외 + const filteredCandidates = result.data?.filter(vendor => !existingVendorIds.includes(vendor.id)) || [] + setCandidateVendors(filteredCandidates) + } + setHasCandidatesLoaded(true) + } catch (error) { + console.error("후보 벤더 로드 오류:", error) + toast.error("후보 벤더를 불러오는 중 오류가 발생했습니다") + setCandidateVendors([]) + } finally { + setIsLoadingCandidates(false) + } + }, [selectedRfq?.id, existingVendorIds]) + + // 벤더 검색 함수 (techVendor 기반) const searchVendorsDebounced = useCallback( async (term: string) => { if (!term.trim()) { @@ -86,9 +117,15 @@ export function AddVendorDialog({ setIsSearching(true) try { - const results = await searchVendors(term, 100) + // 선택된 RFQ의 타입을 기반으로 벤더 검색 + const rfqType = selectedRfq?.rfqCode?.includes("SHIP") ? "SHIP" : + selectedRfq?.rfqCode?.includes("TOP") ? "TOP" : + selectedRfq?.rfqCode?.includes("HULL") ? "HULL" : undefined; + + const results = await searchTechVendors(term, 100, rfqType) + // 이미 추가된 벤더 제외 - const filteredResults = results.filter(vendor => !existingVendorIds.includes(vendor.id)) + const filteredResults = results.filter((vendor: VendorSearchResult) => !existingVendorIds.includes(vendor.id)) setSearchResults(filteredResults) setHasSearched(true) } catch (error) { @@ -111,6 +148,13 @@ export function AddVendorDialog({ return () => clearTimeout(timer) }, [searchTerm, searchVendorsDebounced]) + // 다이얼로그 열릴 때 후보 벤더 로드 + useEffect(() => { + if (open && selectedRfq?.id && !hasCandidatesLoaded) { + loadCandidateVendors() + } + }, [open, selectedRfq?.id, hasCandidatesLoaded, loadCandidateVendors]) + // 벤더 선택/해제 핸들러 const handleVendorToggle = (vendor: VendorSearchResult) => { const currentIds = form.getValues("vendorIds") @@ -155,8 +199,8 @@ export function AddVendorDialog({ try { setIsSubmitting(true) - // 서비스 함수 호출 - const result = await addVendorsToTechSalesRfq({ + // 새로운 서비스 함수 호출 + const result = await addTechVendorsToTechSalesRfq({ rfqId: selectedRfq.id, vendorIds: values.vendorIds, createdBy: Number(session.user.id), @@ -165,15 +209,16 @@ export function AddVendorDialog({ if (result.error) { toast.error(result.error) } else { - const successMessage = `${result.successCount}개의 벤더가 성공적으로 추가되었습니다` - const errorMessage = result.errorCount && result.errorCount > 0 ? ` (${result.errorCount}개 실패)` : "" - toast.success(successMessage + errorMessage) + const successCount = result.data?.length || 0 + toast.success(`${successCount}개의 벤더가 성공적으로 추가되었습니다`) onOpenChange(false) form.reset() setSearchTerm("") setSearchResults([]) + setCandidateVendors([]) setHasSearched(false) + setHasCandidatesLoaded(false) setSelectedVendorData([]) onSuccess?.() } @@ -191,14 +236,69 @@ export function AddVendorDialog({ form.reset() setSearchTerm("") setSearchResults([]) + setCandidateVendors([]) setHasSearched(false) + setHasCandidatesLoaded(false) setSelectedVendorData([]) + setActiveTab("candidates") } }, [open, form]) + // 벤더 목록 렌더링 함수 + const renderVendorList = (vendors: VendorSearchResult[], showMatchCount = false) => ( + +
+ {vendors.length > 0 ? ( + vendors.map((vendor, index) => ( +
handleVendorToggle(vendor)} + > +
+ +
+
+ {vendor.vendorName} + {showMatchCount && vendor.matchedItemCount && vendor.matchedItemCount > 0 && ( + + + {vendor.matchedItemCount}개 매칭 + + )} + {vendor.techVendorType && ( + + {vendor.techVendorType} + + )} +
+
+ {vendor.vendorCode || 'N/A'} {vendor.country && `• ${vendor.country}`} +
+
+
+
+ )) + ) : ( +
+ {showMatchCount ? "매칭되는 후보 벤더가 없습니다" : "검색 결과가 없습니다"} +
+ )} +
+
+ ) + return ( - + {/* 헤더 */} 벤더 추가 @@ -217,73 +317,91 @@ export function AddVendorDialog({
- {/* 벤더 검색 필드 */} -
- -
- - setSearchTerm(e.target.value)} - className="pl-10" - /> - {isSearching && ( - - )} -
-
+ {/* 탭 메뉴 */} + + + + 후보 벤더 ({candidateVendors.length}) + + + 벤더 검색 + + - {/* 검색 결과 */} - {hasSearched && ( -
-
- 검색 결과 ({searchResults.length}개) -
- -
- {searchResults.length > 0 ? ( - searchResults.map((vendor) => ( -
handleVendorToggle(vendor)} - > -
- -
-
{vendor.vendorName}
-
- {vendor.vendorCode || 'N/A'} {vendor.country && `• ${vendor.country}`} -
-
-
-
- )) - ) : ( -
- 검색 결과가 없습니다 + {/* 후보 벤더 탭 */} + +
+
+ + +
+ + {isLoadingCandidates ? ( +
+
+ + 후보 벤더를 불러오는 중...
+
+ ) : ( + renderVendorList(candidateVendors, true) + )} + +
+ 💡 RFQ 아이템과 매칭되는 벤더들이 매칭 아이템 수가 많은 순으로 표시됩니다. +
+
+
+ + {/* 벤더 검색 탭 */} + + {/* 벤더 검색 필드 */} +
+ +
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> + {isSearching && ( + )}
- -
- )} +
- {/* 검색 안내 메시지 */} - {!hasSearched && !searchTerm && ( -
- 벤더명 또는 벤더코드를 입력하여 검색해주세요 -
- )} + {/* 검색 결과 */} + {hasSearched ? ( +
+
+ 검색 결과 ({searchResults.length}개) +
+ {renderVendorList(searchResults)} +
+ ) : ( +
+ 벤더명 또는 벤더코드를 입력하여 검색해주세요 +
+ )} + + {/* 선택된 벤더 목록 - 하단에 항상 표시 */} - {/*

• 검색은 ACTIVE 상태의 벤더만 대상으로 합니다.

*/} +

• 후보 벤더는 RFQ 아이템 코드와 매칭되는 기술영업 벤더들입니다.

• 선택된 벤더들은 Draft 상태로 추가됩니다.

• 벤더별 견적 정보는 추가 후 개별적으로 입력할 수 있습니다.

-

• 이미 추가된 벤더는 검색 결과에서 체크됩니다.

diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx index ba530fe3..f2eda8d9 100644 --- a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx +++ b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx @@ -30,8 +30,6 @@ interface TechSalesRfq { rfqSendDate?: Date | null dueDate?: Date | null createdByName?: string | null - // 필요에 따라 다른 필드들 추가 - [key: string]: any // eslint-disable-line @typescript-eslint/no-explicit-any } // 프로퍼티 정의 @@ -100,16 +98,12 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps try { // 실제 벤더 견적 데이터 다시 로딩 - const { getTechSalesVendorQuotationsWithJoin } = await import("@/lib/techsales-rfq/service") + const { getTechSalesRfqTechVendors } = await import("@/lib/techsales-rfq/service") - const result = await getTechSalesVendorQuotationsWithJoin({ - rfqId: selectedRfqId, - page: 1, - perPage: 1000, - }) + const result = await getTechSalesRfqTechVendors(selectedRfqId) // 데이터 변환 - const transformedData = result.data?.map(item => ({ + const transformedData = result.data?.map((item: any) => ({ ...item, detailId: item.id, rfqId: selectedRfqId, @@ -209,9 +203,9 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps } // 서비스 함수 호출 - const { removeVendorsFromTechSalesRfq } = await import("@/lib/techsales-rfq/service"); + const { removeTechVendorsFromTechSalesRfq } = await import("@/lib/techsales-rfq/service"); - const result = await removeVendorsFromTechSalesRfq({ + const result = await removeTechVendorsFromTechSalesRfq({ rfqId: selectedRfqId, vendorIds: vendorIds }); @@ -219,9 +213,8 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps if (result.error) { toast.error(result.error); } else { - const successMessage = `${result.successCount}개의 벤더가 성공적으로 삭제되었습니다`; - const errorMessage = result.errorCount && result.errorCount > 0 ? ` (${result.errorCount}개 실패)` : ""; - toast.success(successMessage + errorMessage); + const successCount = result.data?.length || 0 + toast.success(`${successCount}개의 벤더가 성공적으로 삭제되었습니다`); } // 선택 해제 @@ -395,9 +388,9 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps } // 개별 벤더 삭제 - const { removeVendorFromTechSalesRfq } = await import("@/lib/techsales-rfq/service"); + const { removeTechVendorFromTechSalesRfq } = await import("@/lib/techsales-rfq/service"); - const result = await removeVendorFromTechSalesRfq({ + const result = await removeTechVendorFromTechSalesRfq({ rfqId: selectedRfqId, vendorId: vendor.vendorId }); diff --git a/lib/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx b/lib/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx index d58dbd00..0a6caa5c 100644 --- a/lib/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx +++ b/lib/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx @@ -31,6 +31,7 @@ import { Plus, Minus, CheckCircle, Loader2 } from "lucide-react" import { getTechSalesVendorQuotationsWithJoin } from "@/lib/techsales-rfq/service" import { acceptTechSalesVendorQuotationAction } from "@/lib/techsales-rfq/actions" import { formatCurrency, formatDate } from "@/lib/utils" +import { techSalesVendorQuotations } from "@/db/schema/techSales" // 기술영업 견적 정보 타입 interface TechSalesVendorQuotation { diff --git a/lib/techsales-rfq/table/project-detail-dialog.tsx b/lib/techsales-rfq/table/project-detail-dialog.tsx index b8219d7f..68f13960 100644 --- a/lib/techsales-rfq/table/project-detail-dialog.tsx +++ b/lib/techsales-rfq/table/project-detail-dialog.tsx @@ -9,39 +9,7 @@ import { DialogTitle, } from "@/components/ui/dialog" import { Badge } from "@/components/ui/badge" -import { Separator } from "@/components/ui/separator" import { Button } from "@/components/ui/button" -import { formatDateToQuarter } from "@/lib/utils" - -// 프로젝트 스냅샷 타입 정의 -interface ProjectSnapshot { - scDt?: string - klDt?: string - lcDt?: string - dlDt?: string - dockNo?: string - dockNm?: string - projNo?: string - projNm?: string - ownerNm?: string - kunnrNm?: string - cls1Nm?: string - projMsrm?: number - ptypeNm?: string - sector?: string - estmPm?: string -} - -// 시리즈 스냅샷 타입 정의 -interface SeriesSnapshot { - sersNo?: string - scDt?: string - klDt?: string - lcDt?: string - dlDt?: string - dockNo?: string - dockNm?: string -} // 기본적인 RFQ 타입 정의 (rfq-table.tsx와 일치) interface TechSalesRfq { @@ -64,8 +32,6 @@ interface TechSalesRfq { updatedByName: string sentBy: number | null sentByName: string | null - projectSnapshot: ProjectSnapshot | null - seriesSnapshot: SeriesSnapshot[] | null pspid: string projNm: string sector: string @@ -90,9 +56,6 @@ export function ProjectDetailDialog({ return null } - const projectSnapshot = selectedRfq.projectSnapshot - const seriesSnapshot = selectedRfq.seriesSnapshot - return ( @@ -141,171 +104,6 @@ export function ProjectDetailDialog({
- - - - {/* 프로젝트 스냅샷 정보 */} - {projectSnapshot && ( -
-

프로젝트 스냅샷

-
- {projectSnapshot.scDt && ( -
-
S/C
-
{formatDateToQuarter(projectSnapshot.scDt)}
-
- )} - {projectSnapshot.klDt && ( -
-
K/L
-
{formatDateToQuarter(projectSnapshot.klDt)}
-
- )} - {projectSnapshot.lcDt && ( -
-
L/C
-
{formatDateToQuarter(projectSnapshot.lcDt)}
-
- )} - {projectSnapshot.dlDt && ( -
-
D/L
-
{formatDateToQuarter(projectSnapshot.dlDt)}
-
- )} - {projectSnapshot.dockNo && ( -
-
도크번호
-
{projectSnapshot.dockNo}
-
- )} - {projectSnapshot.dockNm && ( -
-
도크명
-
{projectSnapshot.dockNm}
-
- )} - {projectSnapshot.projNo && ( -
-
공사번호
-
{projectSnapshot.projNo}
-
- )} - {projectSnapshot.projNm && ( -
-
공사명
-
{projectSnapshot.projNm}
-
- )} - {projectSnapshot.ownerNm && ( -
-
선주
-
{projectSnapshot.ownerNm}
-
- )} - {projectSnapshot.kunnrNm && ( -
-
선주명
-
{projectSnapshot.kunnrNm}
-
- )} - {projectSnapshot.cls1Nm && ( -
-
선급명
-
{projectSnapshot.cls1Nm}
-
- )} - {projectSnapshot.projMsrm && ( -
-
척수
-
{projectSnapshot.projMsrm}
-
- )} - {projectSnapshot.ptypeNm && ( -
-
선종명
-
{projectSnapshot.ptypeNm}
-
- )} - {projectSnapshot.sector && ( -
-
섹터
-
{projectSnapshot.sector}
-
- )} - {projectSnapshot.estmPm && ( -
-
견적 PM
-
{projectSnapshot.estmPm}
-
- )} -
-
- )} - - {/* 시리즈 스냅샷 정보 */} - {seriesSnapshot && Array.isArray(seriesSnapshot) && seriesSnapshot.length > 0 && ( - <> - -
-

시리즈 정보 스냅샷

-
- {seriesSnapshot.map((series: SeriesSnapshot, index: number) => ( -
-
- 시리즈 {series.sersNo || index + 1} -
-
- {series.scDt && ( -
-
S/C
-
{formatDateToQuarter(series.scDt)}
-
- )} - {series.klDt && ( -
-
K/L
-
{formatDateToQuarter(series.klDt)}
-
- )} - {series.lcDt && ( -
-
L/C
-
{formatDateToQuarter(series.lcDt)}
-
- )} - {series.dlDt && ( -
-
D/L
-
{formatDateToQuarter(series.dlDt)}
-
- )} - {series.dockNo && ( -
-
도크번호
-
{series.dockNo}
-
- )} - {series.dockNm && ( -
-
도크명
-
{series.dockNm}
-
- )} -
-
- ))} -
-
- - )} - - {/* 추가 정보가 없는 경우 */} - {!projectSnapshot && !seriesSnapshot && ( -
- 추가 프로젝트 상세정보가 없습니다. -
- )} {/* 닫기 버튼 */} diff --git a/lib/techsales-rfq/table/rfq-filter-sheet.tsx b/lib/techsales-rfq/table/rfq-filter-sheet.tsx index 6021699f..9b6acfb2 100644 --- a/lib/techsales-rfq/table/rfq-filter-sheet.tsx +++ b/lib/techsales-rfq/table/rfq-filter-sheet.tsx @@ -409,17 +409,17 @@ export function RFQFilterSheet({ )} /> - {/* 자재코드 */} + {/* 자재그룹 */} ( - {t("자재코드")} + {t("자재그룹")}
void; + rfq: { + id: number; + rfqCode?: string; + status?: string; + description?: string; + rfqType?: "SHIP" | "TOP" | "HULL"; + } | null; +} + +export function RfqItemsViewDialog({ + open, + onOpenChange, + rfq, +}: RfqItemsViewDialogProps) { + const [items, setItems] = React.useState([]); + const [loading, setLoading] = React.useState(false); + + console.log("RfqItemsViewDialog render:", { open, rfq }); + + React.useEffect(() => { + console.log("RfqItemsViewDialog useEffect:", { open, rfqId: rfq?.id }); + if (open && rfq?.id) { + loadItems(); + } + }, [open, rfq?.id]); + + const loadItems = async () => { + if (!rfq?.id) return; + + console.log("Loading items for RFQ:", rfq.id); + setLoading(true); + try { + const result = await getTechSalesRfqItems(rfq.id); + console.log("Items loaded:", result); + if (result.data) { + setItems(result.data); + } + } catch (error) { + console.error("Failed to load items:", error); + } finally { + setLoading(false); + } + }; + + const getTypeLabel = (type: string) => { + switch (type) { + case "SHIP": + return "조선"; + case "TOP": + return "해양TOP"; + case "HULL": + return "해양HULL"; + default: + return type; + } + }; + + const getTypeColor = (type: string) => { + switch (type) { + case "SHIP": + return "bg-blue-100 text-blue-800"; + case "TOP": + return "bg-green-100 text-green-800"; + case "HULL": + return "bg-purple-100 text-purple-800"; + default: + return "bg-gray-100 text-gray-800"; + } + }; + + return ( + + + + + RFQ 아이템 조회 + + {rfq?.rfqCode || `RFQ #${rfq?.id}`} + + + + RFQ에 등록된 아이템 목록을 확인할 수 있습니다. + + + +
+
+ {loading ? ( +
+
+
+

아이템을 불러오는 중...

+
+
+ ) : items.length === 0 ? ( +
+ +

아이템이 없습니다

+

+ 이 RFQ에 등록된 아이템이 없습니다. +

+
+ ) : ( + <> + {/* 헤더 행 (라벨) */} +
+
No.
+
타입
+
자재 그룹
+
공종
+
자재명
+
선종/자재명(상세)
+
+ + {/* 아이템 행들 */} +
+ {items.map((item, index) => ( +
+
+ {index + 1} +
+
+ + {getTypeLabel(item.itemType)} + +
+
+ {item.itemCode} +
+
+ {item.workType} +
+
+ {item.itemList} +
+
+ {item.itemType === 'SHIP' ? item.shipType : item.subItemName} +
+
+ ))} +
+ +
+
+ + + 총 {items.length}개 아이템 + +
+
+ + )} +
+
+ + + + +
+
+ ) +} \ No newline at end of file diff --git a/lib/techsales-rfq/table/rfq-table-column.tsx b/lib/techsales-rfq/table/rfq-table-column.tsx index 2740170b..51c143a4 100644 --- a/lib/techsales-rfq/table/rfq-table-column.tsx +++ b/lib/techsales-rfq/table/rfq-table-column.tsx @@ -6,16 +6,13 @@ import { formatDate, formatDateTime } from "@/lib/utils" import { Checkbox } from "@/components/ui/checkbox" import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" import { DataTableRowAction } from "@/types/table" -import { Paperclip } from "lucide-react" +import { Paperclip, Package } from "lucide-react" import { Button } from "@/components/ui/button" // 기본적인 RFQ 타입 정의 (rfq-table.tsx 파일과 일치해야 함) type TechSalesRfq = { id: number rfqCode: string | null - itemId: number - itemName: string | null - materialCode: string | null dueDate: Date rfqSendDate: Date | null status: "RFQ Created" | "RFQ Vendor Assignned" | "RFQ Sent" | "Quotation Analysis" | "Closed" @@ -30,40 +27,6 @@ type TechSalesRfq = { updatedByName: string sentBy: number | null sentByName: string | null - // 스키마와 일치하도록 타입 수정 - projectSnapshot: { - pspid: string; - projNm?: string; - sector?: string; - projMsrm?: number; - kunnr?: string; - kunnrNm?: string; - cls1?: string; - cls1Nm?: string; - ptype?: string; - ptypeNm?: string; - pmodelCd?: string; - pmodelNm?: string; - pmodelSz?: string; - pmodelUom?: string; - txt04?: string; - txt30?: string; - estmPm?: string; - pspCreatedAt?: Date | string; - pspUpdatedAt?: Date | string; - } | Record // legacy 호환성을 위해 유지 - seriesSnapshot: Array<{ - pspid: string; - sersNo: string; - scDt?: string; - klDt?: string; - lcDt?: string; - dlDt?: string; - dockNo?: string; - dockNm?: string; - projNo?: string; - post1?: string; - }> | Record // legacy 호환성을 위해 유지 pspid: string projNm: string sector: string @@ -71,6 +34,7 @@ type TechSalesRfq = { ptypeNm: string attachmentCount: number quotationCount: number + itemCount: number // 나머지 필드는 사용할 때마다 추가 [key: string]: unknown } @@ -78,11 +42,13 @@ type TechSalesRfq = { interface GetColumnsProps { setRowAction: React.Dispatch | null>>; openAttachmentsSheet: (rfqId: number) => void; + openItemsDialog: (rfq: TechSalesRfq) => void; } export function getColumns({ setRowAction, openAttachmentsSheet, + openItemsDialog, }: GetColumnsProps): ColumnDef[] { return [ { @@ -143,34 +109,6 @@ export function getColumns({ enableResizing: true, size: 120, }, - { - accessorKey: "materialCode", - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.getValue("materialCode")}
, - meta: { - excelHeader: "자재코드" - }, - enableResizing: true, - minSize: 80, - size: 250, - }, - { - accessorKey: "itemName", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const itemName = row.getValue("itemName") as string | null; - return
{itemName || "자재명 없음"}
; - }, - meta: { - excelHeader: "자재명" - }, - enableResizing: true, - size: 250, - }, { accessorKey: "projNm", header: ({ column }) => ( @@ -194,85 +132,43 @@ export function getColumns({ enableResizing: true, size: 160, }, - { - accessorKey: "projMsrm", - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.getValue("projMsrm")}
, - meta: { - excelHeader: "척수" - }, - enableResizing: true, - minSize: 60, - size: 80, - }, - { - accessorKey: "ptypeNm", - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.getValue("ptypeNm")}
, - meta: { - excelHeader: "선종" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "quotationCount", - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.getValue("quotationCount")}
, - meta: { - excelHeader: "견적수" - }, - enableResizing: true, - size: 80, - }, - { - id: "attachments", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const rfq = row.original - const attachmentCount = rfq.attachmentCount || 0 - - const handleClick = () => { - openAttachmentsSheet(rfq.id) - } - - return ( - - ) - }, - enableSorting: false, - enableResizing: true, - size: 80, - meta: { - excelHeader: "첨부파일" - }, - }, + // { + // accessorKey: "projMsrm", + // header: ({ column }) => ( + // + // ), + // cell: ({ row }) =>
{row.getValue("projMsrm")}
, + // meta: { + // excelHeader: "척수" + // }, + // enableResizing: true, + // minSize: 60, + // size: 80, + // }, + // { + // accessorKey: "ptypeNm", + // header: ({ column }) => ( + // + // ), + // cell: ({ row }) =>
{row.getValue("ptypeNm")}
, + // meta: { + // excelHeader: "선종" + // }, + // enableResizing: true, + // size: 120, + // }, + // { + // accessorKey: "quotationCount", + // header: ({ column }) => ( + // + // ), + // cell: ({ row }) =>
{row.getValue("quotationCount")}
, + // meta: { + // excelHeader: "견적수" + // }, + // enableResizing: true, + // size: 80, + // }, { accessorKey: "rfqSendDate", header: ({ column }) => ( @@ -346,5 +242,88 @@ export function getColumns({ enableResizing: true, size: 160, }, + // 우측 고정 컬럼들 + { + id: "items", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const rfq = row.original + const itemCount = rfq.itemCount || 0 + + const handleClick = () => { + openItemsDialog(rfq) + } + + return ( + + ) + }, + enableSorting: false, + enableResizing: false, + size: 80, + meta: { + excelHeader: "아이템" + }, + }, + { + id: "attachments", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const rfq = row.original + const attachmentCount = rfq.attachmentCount || 0 + + const handleClick = () => { + openAttachmentsSheet(rfq.id) + } + + return ( + + ) + }, + enableSorting: false, + enableResizing: false, + size: 80, + meta: { + excelHeader: "첨부파일" + }, + }, ] } \ No newline at end of file diff --git a/lib/techsales-rfq/table/rfq-table-toolbar-actions.tsx b/lib/techsales-rfq/table/rfq-table-toolbar-actions.tsx index da716eeb..a8c2d08c 100644 --- a/lib/techsales-rfq/table/rfq-table-toolbar-actions.tsx +++ b/lib/techsales-rfq/table/rfq-table-toolbar-actions.tsx @@ -7,16 +7,20 @@ import { toast } from "sonner" import { exportTableToExcel } from "@/lib/export" import { Button } from "@/components/ui/button" import { type Table } from "@tanstack/react-table" -import { CreateRfqDialog } from "./create-rfq-dialog" +import { CreateShipRfqDialog } from "./create-rfq-ship-dialog" +import { CreateTopRfqDialog } from "./create-rfq-top-dialog" +import { CreateHullRfqDialog } from "./create-rfq-hull-dialog" interface RFQTableToolbarActionsProps { selection: Table; onRefresh?: () => void; + rfqType?: "SHIP" | "TOP" | "HULL"; } export function RFQTableToolbarActions({ selection, - onRefresh + onRefresh, + rfqType = "SHIP" }: RFQTableToolbarActionsProps) { // 데이터 새로고침 @@ -27,10 +31,23 @@ export function RFQTableToolbarActions({ } } + // RFQ 타입에 따른 다이얼로그 렌더링 + const renderRfqDialog = () => { + switch (rfqType) { + case "TOP": + return ; + case "HULL": + return ; + case "SHIP": + default: + return ; + } + } + return (
{/* RFQ 생성 다이얼로그 */} - + {renderRfqDialog()} {/* 새로고침 버튼 */}
@@ -603,6 +563,13 @@ export function RFQListTable({ rfq={selectedRfqForAttachments} onAttachmentsUpdated={handleAttachmentsUpdated} /> + + {/* 아이템 보기 다이얼로그 */} +
) } \ No newline at end of file diff --git a/lib/techsales-rfq/validations.ts b/lib/techsales-rfq/validations.ts index 9d960525..c373b576 100644 --- a/lib/techsales-rfq/validations.ts +++ b/lib/techsales-rfq/validations.ts @@ -79,6 +79,78 @@ export const searchParamsDashboardCache = createSearchParamsCache({ export type GetTechSalesDashboardSchema = Awaited>; +// 조선 RFQ용 SearchParams +export const searchParamsShipCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser().withDefault([ + { id: "updatedAt", desc: true }, + ]), + + // 고급 필터 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 기본 필터 (RFQFilterBox) + basicFilters: getFiltersStateParser().withDefault([]), + basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + search: parseAsString.withDefault(""), + from: parseAsString.withDefault(""), + to: parseAsString.withDefault(""), +}); + +export type GetTechSalesShipRfqsSchema = Awaited>; + +// 해양 TOP RFQ용 SearchParams +export const searchParamsTopCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser().withDefault([ + { id: "updatedAt", desc: true }, + ]), + + // 고급 필터 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 기본 필터 (RFQFilterBox) + basicFilters: getFiltersStateParser().withDefault([]), + basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + search: parseAsString.withDefault(""), + from: parseAsString.withDefault(""), + to: parseAsString.withDefault(""), +}); + +export type GetTechSalesTopRfqsSchema = Awaited>; + +// 해양 HULL RFQ용 SearchParams +export const searchParamsHullCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser().withDefault([ + { id: "updatedAt", desc: true }, + ]), + + // 고급 필터 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 기본 필터 (RFQFilterBox) + basicFilters: getFiltersStateParser().withDefault([]), + basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + search: parseAsString.withDefault(""), + from: parseAsString.withDefault(""), + to: parseAsString.withDefault(""), +}); + +export type GetTechSalesHullRfqsSchema = Awaited>; + // RFQ 생성 스키마 export const createTechSalesRfqSchema = z.object({ itemId: z.number(), @@ -89,6 +161,7 @@ export const createTechSalesRfqSchema = z.object({ rfqSealedYn: z.boolean().default(false), picCode: z.string().optional(), remark: z.string().optional().nullable(), + rfqType: z.enum(["SHIP", "TOP", "HULL"]).default("SHIP"), }); export type CreateTechSalesRfqSchema = z.infer; diff --git a/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx b/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx index e4b1b8c3..a8f44474 100644 --- a/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx +++ b/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx @@ -4,34 +4,7 @@ import * as React from "react" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" import { ScrollArea } from "@/components/ui/scroll-area" -import { formatDateToQuarter, formatDate } from "@/lib/utils" - -interface ProjectSnapshot { - pspid?: string - projNm?: string - projMsrm?: number - kunnr?: string - kunnrNm?: string - cls1?: string - cls1Nm?: string - ptype?: string - ptypeNm?: string - estmPm?: string - scDt?: string - klDt?: string - lcDt?: string - dlDt?: string - dockNo?: string - dockNm?: string - projNo?: string - ownerNm?: string - pspUpdatedAt?: string | Date -} - -interface SeriesSnapshot { - sersNo?: string - klDt?: string -} +import { formatDate } from "@/lib/utils" interface ProjectInfoTabProps { quotation: { @@ -43,17 +16,13 @@ interface ProjectInfoTabProps { dueDate: Date | null status: string | null remark: string | null - projectSnapshot?: ProjectSnapshot | null - seriesSnapshot?: SeriesSnapshot[] | null - item?: { - id: number - itemCode: string | null - itemList: string | null - } | null biddingProject?: { id: number pspid: string | null projNm: string | null + sector: string | null + projMsrm: string | null + ptypeNm: string | null } | null createdByUser?: { id: number @@ -71,8 +40,6 @@ interface ProjectInfoTabProps { export function ProjectInfoTab({ quotation }: ProjectInfoTabProps) { const rfq = quotation.rfq - const projectSnapshot = rfq?.projectSnapshot - const seriesSnapshot = rfq?.seriesSnapshot console.log("rfq: ", rfq) @@ -110,14 +77,9 @@ export function ProjectInfoTab({ quotation }: ProjectInfoTabProps) {
{rfq.rfqCode || "미할당"}
-
자재 코드
+
자재 그룹
{rfq.materialCode || "N/A"}
-
-
자재명
- {/* TODO : 타입 작업 (시연을 위해 빌드 중단 상태임. 추후 수정) */} -
{rfq.itemShipbuilding?.itemList || "N/A"}
-
마감일
@@ -164,108 +126,23 @@ export function ProjectInfoTab({ quotation }: ProjectInfoTabProps) {
프로젝트명
{rfq.biddingProject.projNm || "N/A"}
-
- - - )} - - {/* 프로젝트 스냅샷 정보 */} - {projectSnapshot && ( - - - 프로젝트 스냅샷 - - RFQ 생성 시점의 프로젝트 상세 정보 - - - -
- {projectSnapshot.projNo && ( -
-
공사번호
-
{projectSnapshot.projNo}
-
- )} - {projectSnapshot.projNm && ( -
-
공사명
-
{projectSnapshot.projNm}
-
- )} - {projectSnapshot.estmPm && ( -
-
견적 PM
-
{projectSnapshot.estmPm}
-
- )} - {projectSnapshot.kunnrNm && ( -
-
선주
-
{projectSnapshot.kunnrNm}
-
- )} - {projectSnapshot.cls1Nm && ( -
-
선급
-
{projectSnapshot.cls1Nm}
-
- )} - {projectSnapshot.projMsrm && ( -
-
척수
-
{projectSnapshot.projMsrm}
-
- )} - {projectSnapshot.ptypeNm && ( -
-
선종
-
{projectSnapshot.ptypeNm}
-
- )} -
-
-
- )} - - {/* 시리즈 스냅샷 정보 */} - {seriesSnapshot && Array.isArray(seriesSnapshot) && seriesSnapshot.length > 0 && ( - - - 시리즈 정보 스냅샷 - - 프로젝트의 시리즈별 K/L 일정 정보 - - - - {seriesSnapshot.map((series: SeriesSnapshot, index: number) => ( -
-
- 시리즈 {series.sersNo || index + 1} -
-
- {series.klDt && ( -
-
K/L
-
{formatDateToQuarter(series.klDt)}
-
- )} -
+
+
프로젝트 섹터
+
{rfq.biddingProject.sector || "N/A"}
+
+
+
프로젝트 규모
+
{rfq.biddingProject.projMsrm || "N/A"}
+
+
+
프로젝트 타입
+
{rfq.biddingProject.ptypeNm || "N/A"}
- ))} - - - )} - - {/* 정보가 없는 경우 */} - {!projectSnapshot && !seriesSnapshot && ( - - -
- 추가 프로젝트 상세정보가 없습니다.
)} +
) diff --git a/lib/techsales-rfq/vendor-response/detail/quotation-tabs.tsx b/lib/techsales-rfq/vendor-response/detail/quotation-tabs.tsx index 97bba2bd..2e2f5d70 100644 --- a/lib/techsales-rfq/vendor-response/detail/quotation-tabs.tsx +++ b/lib/techsales-rfq/vendor-response/detail/quotation-tabs.tsx @@ -7,36 +7,6 @@ import { ProjectInfoTab } from "./project-info-tab" import { QuotationResponseTab } from "./quotation-response-tab" import { CommunicationTab } from "./communication-tab" -// 프로젝트 스냅샷 타입 정의 -interface ProjectSnapshot { - scDt?: string - klDt?: string - lcDt?: string - dlDt?: string - dockNo?: string - dockNm?: string - projNo?: string - projNm?: string - ownerNm?: string - kunnrNm?: string - cls1Nm?: string - projMsrm?: number - ptypeNm?: string - sector?: string - estmPm?: string -} - -// 시리즈 스냅샷 타입 정의 -interface SeriesSnapshot { - sersNo?: string - scDt?: string - klDt?: string - lcDt?: string - dlDt?: string - dockNo?: string - dockNm?: string -} - interface QuotationData { id: number status: string @@ -51,17 +21,13 @@ interface QuotationData { dueDate: Date | null status: string | null remark: string | null - projectSnapshot?: ProjectSnapshot | null - seriesSnapshot?: SeriesSnapshot[] | null - item?: { - id: number - itemCode: string | null - itemList: string | null - } | null biddingProject?: { id: number pspid: string | null projNm: string | null + sector: string | null + projMsrm: string | null + ptypeNm: string | null } | null createdByUser?: { id: number diff --git a/lib/techsales-rfq/vendor-response/quotation-editor.tsx b/lib/techsales-rfq/vendor-response/quotation-editor.tsx index b30f612c..54058214 100644 --- a/lib/techsales-rfq/vendor-response/quotation-editor.tsx +++ b/lib/techsales-rfq/vendor-response/quotation-editor.tsx @@ -338,7 +338,7 @@ export default function TechSalesQuotationEditor({ quotation }: TechSalesQuotati

{quotation.rfq.rfqCode}

- +

{quotation.rfq.materialCode || "N/A"}

diff --git a/lib/techsales-rfq/vendor-response/quotation-item-editor.tsx b/lib/techsales-rfq/vendor-response/quotation-item-editor.tsx index e11864dc..92bec96a 100644 --- a/lib/techsales-rfq/vendor-response/quotation-item-editor.tsx +++ b/lib/techsales-rfq/vendor-response/quotation-item-editor.tsx @@ -365,7 +365,7 @@ export function QuotationItemEditor({ onBlur={(e) => handleBlur(index, field, e.target.value)} disabled={disabled || isSaving || !item.isAlternative} className="w-full" - placeholder={field === 'vendorMaterialCode' ? "벤더 자재코드" : "벤더 자재명"} + placeholder={field === 'vendorMaterialCode' ? "벤더 자재그룹" : "벤더 자재명"} /> ) } else if (field === 'deliveryDate') { @@ -406,14 +406,14 @@ export function QuotationItemEditor({ return (
{/*
- + handleTextInputChange(index, 'vendorMaterialCode', e)} onBlur={(e) => handleBlur(index, 'vendorMaterialCode', e.target.value)} disabled={disabled || isSaving} className="h-8 text-sm" - placeholder="벤더 자재코드 입력" + placeholder="벤더 자재그룹 입력" />
*/} @@ -511,7 +511,7 @@ export function QuotationItemEditor({ 번호 - 자재코드 + 자재그룹 자재명 수량 단위 diff --git a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx index cf1dac42..ddee2317 100644 --- a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx +++ b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { type ColumnDef } from "@tanstack/react-table" -import { Edit, Paperclip } from "lucide-react" +import { Edit, Paperclip, Package } from "lucide-react" import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" @@ -29,7 +29,8 @@ interface QuotationWithRfqCode extends TechSalesVendorQuotations { // 아이템 정보 itemName?: string; - itemShipbuildingId?: number; + + itemCount?: number; // 프로젝트 정보 projNm?: string; @@ -44,14 +45,6 @@ interface QuotationWithRfqCode extends TechSalesVendorQuotations { createdByName?: string | null; updatedByName?: string | null; - // 견적 코드 및 버전 - quotationCode?: string | null; - quotationVersion?: number | null; - - // 추가 상태 정보 - rejectionReason?: string | null; - acceptedAt?: Date | null; - // 첨부파일 개수 attachmentCount?: number; } @@ -59,9 +52,10 @@ interface QuotationWithRfqCode extends TechSalesVendorQuotations { interface GetColumnsProps { router: AppRouterInstance; openAttachmentsSheet: (rfqId: number) => void; + openItemsDialog: (rfq: { id: number; rfqCode?: string; status?: string; rfqType?: "SHIP" | "TOP" | "HULL"; }) => void; } -export function getColumns({ router, openAttachmentsSheet }: GetColumnsProps): ColumnDef[] { +export function getColumns({ router, openAttachmentsSheet, openItemsDialog }: GetColumnsProps): ColumnDef[] { return [ { id: "select", @@ -151,7 +145,7 @@ export function getColumns({ router, openAttachmentsSheet }: GetColumnsProps): C // { // accessorKey: "materialCode", // header: ({ column }) => ( - // + // // ), // cell: ({ row }) => { // const materialCode = row.getValue("materialCode") as string; @@ -250,6 +244,59 @@ export function getColumns({ router, openAttachmentsSheet }: GetColumnsProps): C // enableSorting: true, // enableHiding: true, // }, + { + id: "items", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const quotation = row.original + const itemCount = quotation.itemCount || 0 + + const handleClick = () => { + const rfq = { + id: quotation.rfqId, + rfqCode: quotation.rfqCode, + status: quotation.rfqStatus, + rfqType: "SHIP" as const, // 기본값 + } + openItemsDialog(rfq) + } + + return ( +
+ + + + + + +

{itemCount > 0 ? `${itemCount}개 아이템 보기` : "아이템 없음"}

+
+
+
+
+ ) + }, + enableSorting: false, + enableHiding: true, + }, { id: "attachments", header: ({ column }) => ( diff --git a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx index e98d6bdc..55dcad92 100644 --- a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx +++ b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx @@ -11,6 +11,7 @@ import { TechSalesVendorQuotations, TECH_SALES_QUOTATION_STATUSES, TECH_SALES_QU import { useRouter } from "next/navigation" import { getColumns } from "./vendor-quotations-table-columns" import { TechSalesRfqAttachmentsSheet, ExistingTechSalesAttachment } from "../../table/tech-sales-rfq-attachments-sheet" +import { RfqItemsViewDialog } from "../../table/rfq-items-view-dialog" import { getTechSalesRfqAttachments, getVendorQuotations } from "@/lib/techsales-rfq/service" import { toast } from "sonner" import { Skeleton } from "@/components/ui/skeleton" @@ -23,14 +24,16 @@ interface QuotationWithRfqCode extends TechSalesVendorQuotations { itemName?: string | null; projNm?: string | null; quotationCode?: string | null; - quotationVersion?: number | null; + rejectionReason?: string | null; acceptedAt?: Date | null; attachmentCount?: number; + itemCount?: number; } interface VendorQuotationsTableProps { vendorId: string; + rfqType?: "SHIP" | "TOP" | "HULL"; } // 로딩 스켈레톤 컴포넌트 @@ -92,22 +95,9 @@ function TableLoadingSkeleton() { ) } -// 중앙 로딩 인디케이터 컴포넌트 -function CenterLoadingIndicator() { - return ( -
-
-
-
-
-

데이터를 불러오는 중...

-

잠시만 기다려주세요.

-
-
- ) -} -export function VendorQuotationsTable({ vendorId }: VendorQuotationsTableProps) { + +export function VendorQuotationsTable({ vendorId, rfqType }: VendorQuotationsTableProps) { const searchParams = useSearchParams() const router = useRouter() @@ -116,6 +106,10 @@ export function VendorQuotationsTable({ vendorId }: VendorQuotationsTableProps) const [selectedRfqForAttachments, setSelectedRfqForAttachments] = React.useState<{ id: number; rfqCode: string | null; status: string } | null>(null) const [attachmentsDefault, setAttachmentsDefault] = React.useState([]) + // 아이템 다이얼로그 상태 + const [itemsDialogOpen, setItemsDialogOpen] = React.useState(false) + const [selectedRfqForItems, setSelectedRfqForItems] = React.useState<{ id: number; rfqCode?: string; status?: string; rfqType?: "SHIP" | "TOP" | "HULL"; } | null>(null) + // 데이터 로딩 상태 const [data, setData] = React.useState([]) const [pageCount, setPageCount] = React.useState(0) @@ -158,6 +152,7 @@ export function VendorQuotationsTable({ vendorId }: VendorQuotationsTableProps) search: initialSettings.search, from: initialSettings.from, to: initialSettings.to, + rfqType: rfqType, }, vendorId) console.log('🔍 [VendorQuotationsTable] 데이터 로드 결과:', { @@ -176,7 +171,7 @@ export function VendorQuotationsTable({ vendorId }: VendorQuotationsTableProps) setIsLoading(false) setIsInitialLoad(false) } - }, [vendorId, initialSettings]) + }, [vendorId, initialSettings, rfqType]) // URL 파라미터 변경 감지 및 데이터 재로드 (초기 로드 포함) React.useEffect(() => { @@ -192,8 +187,9 @@ export function VendorQuotationsTable({ vendorId }: VendorQuotationsTableProps) searchParams?.get('search'), searchParams?.get('from'), searchParams?.get('to'), - // vendorId 변경도 감지 - vendorId + // vendorId와 rfqType 변경도 감지 + vendorId, + rfqType ]) // 데이터 안정성을 위한 메모이제이션 @@ -246,12 +242,19 @@ export function VendorQuotationsTable({ vendorId }: VendorQuotationsTableProps) toast.error("첨부파일 조회 중 오류가 발생했습니다.") } }, [data]) + + // 아이템 다이얼로그 열기 함수 + const openItemsDialog = React.useCallback((rfq: { id: number; rfqCode?: string; status?: string; rfqType?: "SHIP" | "TOP" | "HULL"; }) => { + setSelectedRfqForItems(rfq) + setItemsDialogOpen(true) + }, []) // 테이블 컬럼 정의 const columns = React.useMemo(() => getColumns({ router, openAttachmentsSheet, - }), [router, openAttachmentsSheet]) + openItemsDialog, + }), [router, openAttachmentsSheet, openItemsDialog]) // 필터 필드 const filterFields = React.useMemo[]>(() => [ @@ -270,8 +273,8 @@ export function VendorQuotationsTable({ vendorId }: VendorQuotationsTableProps) }, { id: "materialCode", - label: "자재 코드", - placeholder: "자재 코드 검색...", + label: "자재 그룹", + placeholder: "자재 그룹 검색...", } ], []) @@ -284,7 +287,7 @@ export function VendorQuotationsTable({ vendorId }: VendorQuotationsTableProps) }, { id: "materialCode", - label: "자재 코드", + label: "자재 그룹", type: "text", }, { @@ -383,6 +386,13 @@ export function VendorQuotationsTable({ vendorId }: VendorQuotationsTableProps) onAttachmentsUpdated={() => {}} // 읽기 전용이므로 빈 함수 readOnly={true} // 벤더 쪽에서는 항상 읽기 전용 /> + + {/* 아이템 보기 다이얼로그 */} +
); } \ No newline at end of file -- cgit v1.2.3