diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-15 04:40:22 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-15 04:40:22 +0000 |
| commit | c5002d77087b256599b174ada611621657fcc523 (patch) | |
| tree | 515aab399709755cf3d57d9927e2d81467dea700 | |
| parent | 9f3b8915ab20f177edafd3c4a4cc1ca0da0fc766 (diff) | |
(최겸) 기술영업 조선,해양RFQ 수정
24 files changed, 3770 insertions, 1807 deletions
diff --git a/app/[lng]/evcp/(evcp)/budgetary-tech-sales-hull/page.tsx b/app/[lng]/evcp/(evcp)/budgetary-tech-sales-hull/page.tsx new file mode 100644 index 00000000..b1be29db --- /dev/null +++ b/app/[lng]/evcp/(evcp)/budgetary-tech-sales-hull/page.tsx @@ -0,0 +1,61 @@ +import { searchParamsHullCache } from "@/lib/techsales-rfq/validations" +import { getTechSalesHullRfqsWithJoin } from "@/lib/techsales-rfq/service" +import { getValidFilters } from "@/lib/data-table" +import { Shell } from "@/components/shell" +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" +import { RFQListTable } from "@/lib/techsales-rfq/table/rfq-table" +import { type SearchParams } from "@/types/table" +import * as React from "react" + +interface HullRfqPageProps { + searchParams: Promise<SearchParams> +} + +export default async function HullRfqPage(props: HullRfqPageProps) { + // searchParams를 await하여 resolve + const searchParams = await props.searchParams + + // 해양 HULL용 파라미터 파싱 + const search = searchParamsHullCache.parse(searchParams); + const validFilters = getValidFilters(search.filters); + + // 기술영업 해양 Hull RFQ 데이터를 Promise.all로 감싸서 전달 + const promises = Promise.all([ + getTechSalesHullRfqsWithJoin({ + ...search, // 모든 파라미터 전달 (page, perPage, sort, basicFilters, filters 등) + filters: validFilters, // 고급 필터를 명시적으로 오버라이드 (파싱된 버전) + }) + ]) + + return ( + <Shell variant="fullscreen" className="h-full"> {/* fullscreen variant 사용 */} + {/* 고정 헤더 영역 */} + <div className="flex-shrink-0"> + <div className="flex items-center justify-between"> + <div> + <h2 className="text-2xl font-bold tracking-tight"> + 기술영업-해양 Hull RFQ + </h2> + </div> + </div> + </div> + + {/* 테이블 영역 - 남은 공간 모두 차지 */} + <div className="flex-1 min-h-0"> + <React.Suspense + fallback={ + <DataTableSkeleton + columnCount={8} + searchableColumnCount={2} + filterableColumnCount={3} + cellWidths={["10rem", "15rem", "12rem", "12rem", "8rem", "8rem", "10rem", "8rem"]} + shrinkZero + /> + } + > + <RFQListTable promises={promises} className="h-full" rfqType="HULL" /> + </React.Suspense> + </div> + </Shell> + ) +}
\ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/budgetary-tech-sales-top/page.tsx b/app/[lng]/evcp/(evcp)/budgetary-tech-sales-top/page.tsx new file mode 100644 index 00000000..f84a9794 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/budgetary-tech-sales-top/page.tsx @@ -0,0 +1,61 @@ +import { searchParamsTopCache } from "@/lib/techsales-rfq/validations" +import { getTechSalesTopRfqsWithJoin } from "@/lib/techsales-rfq/service" +import { getValidFilters } from "@/lib/data-table" +import { Shell } from "@/components/shell" +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" +import { RFQListTable } from "@/lib/techsales-rfq/table/rfq-table" +import { type SearchParams } from "@/types/table" +import * as React from "react" + +interface HullRfqPageProps { + searchParams: Promise<SearchParams> +} + +export default async function HullRfqPage(props: HullRfqPageProps) { + // searchParams를 await하여 resolve + const searchParams = await props.searchParams + + // 해양 TOP용 파라미터 파싱 + const search = searchParamsTopCache.parse(searchParams); + const validFilters = getValidFilters(search.filters); + + // 기술영업 해양 TOP RFQ 데이터를 Promise.all로 감싸서 전달 + const promises = Promise.all([ + getTechSalesTopRfqsWithJoin({ + ...search, // 모든 파라미터 전달 (page, perPage, sort, basicFilters, filters 등) + filters: validFilters, // 고급 필터를 명시적으로 오버라이드 (파싱된 버전) + }) + ]) + + return ( + <Shell variant="fullscreen" className="h-full"> {/* fullscreen variant 사용 */} + {/* 고정 헤더 영역 */} + <div className="flex-shrink-0"> + <div className="flex items-center justify-between"> + <div> + <h2 className="text-2xl font-bold tracking-tight"> + 기술영업-해양 TOP RFQ + </h2> + </div> + </div> + </div> + + {/* 테이블 영역 - 남은 공간 모두 차지 */} + <div className="flex-1 min-h-0"> + <React.Suspense + fallback={ + <DataTableSkeleton + columnCount={8} + searchableColumnCount={2} + filterableColumnCount={3} + cellWidths={["10rem", "15rem", "12rem", "12rem", "8rem", "8rem", "10rem", "8rem"]} + shrinkZero + /> + } + > + <RFQListTable promises={promises} className="h-full" rfqType="TOP" /> + </React.Suspense> + </div> + </Shell> + ) +}
\ No newline at end of file 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<any, any, any>, - params: { - where?: any; + options: { + where?: SQL; orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc> | SQL<unknown>)[]; 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<string | null>`sent_user.name`, - // 프로젝트 정보 (스냅샷) - projectSnapshot: techSalesRfqs.projectSnapshot, - seriesSnapshot: techSalesRfqs.seriesSnapshot, - - // 프로젝트 핵심 정보 - pspid: sql<string>`${techSalesRfqs.projectSnapshot}->>'pspid'`, - projNm: sql<string>`${techSalesRfqs.projectSnapshot}->>'projNm'`, - sector: sql<string>`${techSalesRfqs.projectSnapshot}->>'sector'`, - projMsrm: sql<number>`(${techSalesRfqs.projectSnapshot}->>'projMsrm')::int`, - ptypeNm: sql<string>`${techSalesRfqs.projectSnapshot}->>'ptypeNm'`, + // 프로젝트 정보 (조인) + pspid: biddingProjects.pspid, + projNm: biddingProjects.projNm, + sector: biddingProjects.sector, + projMsrm: biddingProjects.projMsrm, + ptypeNm: biddingProjects.ptypeNm, // 첨부파일 개수 attachmentCount: sql<number>`( @@ -130,20 +130,43 @@ export async function selectTechSalesRfqsWithJoin( FROM tech_sales_vendor_quotations WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id} )`, + + // 아이템 개수 + itemCount: sql<number>`( + 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<any, any, any>, - 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<any, any, any>, params: { where?: any; - orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc> | SQL<unknown>)[]; + orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[]; 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<string>`${techSalesRfqs.projectSnapshot}->>'pspid'`, - projNm: sql<string>`${techSalesRfqs.projectSnapshot}->>'projNm'`, - sector: sql<string>`${techSalesRfqs.projectSnapshot}->>'sector'`, + // 프로젝트 핵심 정보 - null 체크 추가 + pspid: techSalesRfqs.biddingProjectId, + projNm: biddingProjects.projNm, + sector: biddingProjects.sector, // 첨부파일 개수 attachmentCount: sql<number>`( @@ -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<any, any, any>, - 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<typeof asc> | ReturnType<typeof desc> | SQL<unknown>)[]; 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<string>`${techSalesRfqs.projectSnapshot}->>'pspid'`, - projNm: sql<string>`${techSalesRfqs.projectSnapshot}->>'projNm'`, - sector: sql<string>`${techSalesRfqs.projectSnapshot}->>'sector'`, - projMsrm: sql<number>`(${techSalesRfqs.projectSnapshot}->>'projMsrm')::int`, - ptypeNm: sql<string>`${techSalesRfqs.projectSnapshot}->>'ptypeNm'`, + + // 프로젝트 정보 - null 체크 추가 + pspid: techSalesRfqs.biddingProjectId, + projNm: biddingProjects.projNm, + sector: biddingProjects.sector, + projMsrm: biddingProjects.projMsrm, + ptypeNm: biddingProjects.ptypeNm, // 벤더 견적 통계 vendorCount: sql<number>`( @@ -362,20 +415,152 @@ export async function selectTechSalesDashboardWithJoin( createdAt: techSalesRfqs.createdAt, updatedAt: techSalesRfqs.updatedAt, createdByName: sql<string>`created_user.name`, + + // 아이템 정보 - rfqType에 따라 다른 테이블에서 조회 + itemName: sql<string>` + 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<any, any, any>, + 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<string | null>`rfq_created_user.name`, + rfqCreatedByEmail: sql<string | null>`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<typeof techSalesRfqs>[], 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<typeof techSalesVendorQuotations>[], - 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<typeof techSalesVendorQuotations>[], + 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<string>`${techSalesRfqs.projectSnapshot}->>'projNm'`, + description: techSalesRfqs.description, + // 프로젝트 정보 (직접 조인) + projNm: biddingProjects.projNm, + // 아이템 정보 추가 (임시로 description 사용) + // itemName: techSalesRfqs.description, // 첨부파일 개수 attachmentCount: sql<number>`( 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<number>`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<number>`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<string, unknown>) || {}; + // 프로젝트 시리즈 정보 조회 + 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<string, unknown>) || {}; + // 프로젝트 시리즈 정보 조회 + 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<string, unknown>) || {}; + // 프로젝트 시리즈 정보 조회 + 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<string, unknown>) || {}; + // 프로젝트 시리즈 정보 조회 + 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<typeof techSalesVendorQuotations>[]; + 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<typeof techSalesVendorQuotations>[]; + 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<typeof techSalesVendorQuotations>[]; + sort?: { id: string; desc: boolean }[]; + page: number; + perPage: number; +}) { + return getTechSalesVendorQuotationsWithJoin({ ...input, rfqType: "HULL" }); +} + +/** + * 조선 대시보드 전용 조회 함수 + */ +export async function getTechSalesShipDashboardWithJoin(input: { + search?: string; + filters?: Filter<typeof techSalesRfqs>[]; + sort?: { id: string; desc: boolean }[]; + page: number; + perPage: number; +}) { + return getTechSalesDashboardWithJoin({ ...input, rfqType: "SHIP" }); +} + +/** + * 해양 TOP 대시보드 전용 조회 함수 + */ +export async function getTechSalesTopDashboardWithJoin(input: { + search?: string; + filters?: Filter<typeof techSalesRfqs>[]; + sort?: { id: string; desc: boolean }[]; + page: number; + perPage: number; +}) { + return getTechSalesDashboardWithJoin({ ...input, rfqType: "TOP" }); +} + +/** + * 해양 HULL 대시보드 전용 조회 함수 + */ +export async function getTechSalesHullDashboardWithJoin(input: { + search?: string; + filters?: Filter<typeof techSalesRfqs>[]; + 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<string[]>` + array_agg(DISTINCT ${techVendorPossibleItems.itemCode}) + `, + matchedItemCount: sql<number>` + 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-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<typeof createHullRfqSchema> + +// 공종 타입 정의 +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<Project | null>(null) + + // 검색 및 필터링 상태 + const [itemSearchQuery, setItemSearchQuery] = React.useState("") + const [selectedWorkType, setSelectedWorkType] = React.useState<OffshoreHullWorkType | null>(null) + const [selectedItems, setSelectedItems] = React.useState<OffshoreHullTechItem[]>([]) + + // 데이터 상태 + const [workTypes, setWorkTypes] = React.useState<WorkTypeOption[]>([]) + const [allItems, setAllItems] = React.useState<OffshoreHullTechItem[]>([]) + const [isLoadingItems, setIsLoadingItems] = React.useState(false) + const [dataLoadError, setDataLoadError] = React.useState<string | null>(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<CreateHullRfqFormValues>({ + 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 ( + <Dialog + open={isDialogOpen} + onOpenChange={(open) => { + setIsDialogOpen(open) + if (!open) { + form.reset({ + biddingProjectId: undefined, + itemIds: [], + dueDate: undefined, + description: "", + }) + setSelectedProject(null) + setItemSearchQuery("") + setSelectedWorkType(null) + setSelectedItems([]) + setDataLoadError(null) + setRetryCount(0) + } + }} + > + <DialogTrigger asChild> + <Button + variant="default" + size="sm" + className="gap-2" + disabled={isProcessing} + > + <Plus className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">해양 Hull RFQ 생성</span> + </Button> + </DialogTrigger> + <DialogContent + className="max-w-none h-[90vh] overflow-y-auto flex flex-col" + style={{ width: '1200px' }} + > + <DialogHeader className="border-b pb-4"> + <DialogTitle>해양 Hull RFQ 생성</DialogTitle> + </DialogHeader> + + <div className="space-y-6 p-1 overflow-y-auto"> + <Form {...form}> + <form onSubmit={form.handleSubmit(handleCreateRfq)} className="space-y-6"> + {/* 프로젝트 선택 */} + <div className="space-y-4"> + <FormField + control={form.control} + name="biddingProjectId" + render={({ field }) => ( + <FormItem> + <FormLabel>입찰 프로젝트</FormLabel> + <FormControl> + <EstimateProjectSelector + selectedProjectId={field.value} + onProjectSelect={handleProjectSelect} + placeholder="입찰 프로젝트를 선택하세요" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <Separator className="my-4" /> + + {/* RFQ 설명 */} + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>RFQ 설명</FormLabel> + <FormControl> + <Input + placeholder="RFQ 설명을 입력하세요 (선택사항)" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <Separator className="my-4" /> + + {/* 마감일 설정 */} + <FormField + control={form.control} + name="dueDate" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel>마감일</FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + className={cn( + "w-full pl-3 text-left font-normal", + !field.value && "text-muted-foreground" + )} + > + {field.value ? ( + format(field.value, "PPP", { locale: ko }) + ) : ( + <span>마감일을 선택하세요</span> + )} + <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <Calendar + mode="single" + selected={field.value} + onSelect={field.onChange} + disabled={(date) => + date < new Date() || date < new Date("1900-01-01") + } + initialFocus + /> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + + <Separator className="my-4" /> + + <div className="space-y-6"> + {/* 아이템 선택 영역 */} + <div className="space-y-4"> + <div> + <FormLabel>해양 Hull 아이템 선택</FormLabel> + <FormDescription> + 해양 Hull 아이템을 선택하세요 + </FormDescription> + </div> + + {/* 데이터 로딩 에러 표시 */} + {dataLoadError && ( + <div className="p-3 bg-destructive/10 border border-destructive/20 rounded-md"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <X className="h-4 w-4 text-destructive" /> + <span className="text-sm text-destructive">{dataLoadError}</span> + </div> + <Button + variant="outline" + size="sm" + onClick={handleRefreshData} + disabled={isLoadingItems} + className="h-8 text-xs" + > + {isLoadingItems ? ( + <> + <Loader2 className="h-3 w-3 animate-spin mr-1" /> + 재시도 중... + </> + ) : ( + "다시 시도" + )} + </Button> + </div> + </div> + )} + + {/* 아이템 검색 및 필터 */} + <div className="space-y-2"> + <div className="flex space-x-2"> + <div className="relative flex-1"> + <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> + <Input + placeholder="아이템 검색..." + value={itemSearchQuery} + onChange={(e) => setItemSearchQuery(e.target.value)} + className="pl-8 pr-8" + disabled={isLoadingItems || dataLoadError !== null} + /> + {itemSearchQuery && ( + <Button + variant="ghost" + size="sm" + className="absolute right-0 top-0 h-full px-3" + onClick={() => setItemSearchQuery("")} + disabled={isLoadingItems || dataLoadError !== null} + > + <X className="h-4 w-4" /> + </Button> + )} + </div> + + {/* 공종 필터 */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + variant="outline" + className="gap-1" + disabled={isLoadingItems || dataLoadError !== null} + > + {selectedWorkType ? workTypes.find(wt => wt.code === selectedWorkType)?.name : "전체 공종"} + <ArrowUpDown className="ml-2 h-4 w-4 opacity-50" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuCheckboxItem + checked={selectedWorkType === null} + onCheckedChange={() => setSelectedWorkType(null)} + > + 전체 공종 + </DropdownMenuCheckboxItem> + {workTypes.map(workType => ( + <DropdownMenuCheckboxItem + key={workType.code} + checked={selectedWorkType === workType.code} + onCheckedChange={() => setSelectedWorkType(workType.code)} + > + {workType.name} + </DropdownMenuCheckboxItem> + ))} + </DropdownMenuContent> + </DropdownMenu> + </div> + </div> + + {/* 아이템 목록 */} + <div className="border rounded-md"> + <ScrollArea className="h-[300px]"> + <div className="p-2 space-y-1"> + {dataLoadError ? ( + <div className="text-center py-8"> + <div className="p-4 bg-destructive/10 border border-destructive/20 rounded-md mx-4"> + <div className="flex flex-col items-center gap-3"> + <X className="h-8 w-8 text-destructive" /> + <div className="text-center"> + <p className="text-sm text-destructive font-medium">데이터 로딩에 실패했습니다</p> + <p className="text-xs text-muted-foreground mt-1">{dataLoadError}</p> + </div> + <Button + variant="outline" + size="sm" + onClick={handleRefreshData} + disabled={isLoadingItems} + className="h-8" + > + {isLoadingItems ? ( + <> + <Loader2 className="h-3 w-3 animate-spin mr-1" /> + 재시도 중... + </> + ) : ( + "다시 시도" + )} + </Button> + </div> + </div> + </div> + ) : isLoadingItems ? ( + <div className="text-center py-8 text-muted-foreground"> + <Loader2 className="h-6 w-6 animate-spin mx-auto mb-2" /> + 아이템을 불러오는 중... + {retryCount > 0 && ( + <p className="text-xs mt-1">재시도 {retryCount}회</p> + )} + </div> + ) : 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 ( + <div + key={item.id} + className={cn( + "flex items-center space-x-2 p-2 rounded-md cursor-pointer hover:bg-muted", + isSelected && "bg-muted" + )} + onClick={() => handleItemToggle(item)} + > + <div className="flex items-center space-x-2 flex-1"> + {isSelected ? ( + <CheckSquare className="h-4 w-4" /> + ) : ( + <Square className="h-4 w-4" /> + )} + <div className="flex-1"> + {/* Hull 아이템 표시: "item_list / sub_item_list" / item_code / 공종 */} + <div className="font-medium"> + {item.itemList || '아이템명 없음'} + {item.subItemList && ` / ${item.subItemList}`} + </div> + <div className="text-sm text-muted-foreground"> + {item.itemCode || '아이템코드 없음'} + </div> + <div className="text-xs text-muted-foreground"> + 공종: {item.workType} + </div> + </div> + </div> + </div> + ) + }) + ) : ( + <div className="text-center py-8 text-muted-foreground"> + {itemSearchQuery ? "검색 결과가 없습니다" : "아이템이 없습니다"} + </div> + )} + </div> + </ScrollArea> + </div> + </div> + </div> + </div> + </form> + </Form> + </div> + + {/* Footer - Sticky 버튼 영역 */} + <div className="sticky bottom-0 left-0 z-20 bg-background border-t pt-4 mt-4"> + <div className="flex justify-end space-x-2"> + <Button + type="button" + variant="outline" + onClick={() => setIsDialogOpen(false)} + disabled={isProcessing} + > + 취소 + </Button> + <Button + type="button" + onClick={form.handleSubmit(handleCreateRfq)} + disabled={ + isProcessing || + !selectedProject || + selectedItems.length === 0 + } + > + {isProcessing ? "처리 중..." : `${selectedItems.length}개 아이템으로 해양 Hull RFQ 생성하기`} + </Button> + </div> + </div> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/techsales-rfq/table/create-rfq-dialog.tsx b/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx index 81c85649..8a66f26e 100644 --- a/lib/techsales-rfq/table/create-rfq-dialog.tsx +++ b/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx @@ -32,10 +32,9 @@ 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 { createTechSalesShipRfq } 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, @@ -44,16 +43,8 @@ import { } 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 import { getWorkTypes, getAllShipbuildingItemsForCache, @@ -62,21 +53,23 @@ import { type WorkType } from "@/lib/items-tech/service" -// 유효성 검증 스키마 - 자재코드(item_code) 배열로 변경 -const createRfqSchema = z.object({ + +// 유효성 검증 스키마 +const createShipRfqSchema = z.object({ biddingProjectId: z.number({ required_error: "프로젝트를 선택해주세요.", }), - materialCodes: z.array(z.string()).min(1, { - message: "적어도 하나의 자재코드를 선택해야 합니다.", + itemIds: z.array(z.number()).min(1, { + message: "적어도 하나의 아이템을 선택해야 합니다.", }), dueDate: z.date({ required_error: "마감일을 선택해주세요.", }), + description: z.string().optional(), }) // 폼 데이터 타입 -type CreateRfqFormValues = z.infer<typeof createRfqSchema> +type CreateShipRfqFormValues = z.infer<typeof createShipRfqSchema> // 공종 타입 정의 interface WorkTypeOption { @@ -85,11 +78,11 @@ interface WorkTypeOption { description: string } -interface CreateRfqDialogProps { +interface CreateShipRfqDialogProps { onCreated?: () => void; } -export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { +export function CreateShipRfqDialog({ onCreated }: CreateShipRfqDialogProps) { const { data: session } = useSession() const [isProcessing, setIsProcessing] = React.useState(false) const [isDialogOpen, setIsDialogOpen] = React.useState(false) @@ -109,7 +102,7 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { const [dataLoadError, setDataLoadError] = React.useState<string | null>(null) const [retryCount, setRetryCount] = React.useState(0) - // 데이터 로딩 함수를 useCallback으로 메모이제이션 + // 데이터 로딩 함수 const loadData = React.useCallback(async (isRetry = false) => { try { if (!isRetry) { @@ -117,7 +110,7 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { setDataLoadError(null) } - console.log(`데이터 로딩 시작... ${isRetry ? `(재시도 ${retryCount + 1}회)` : ''}`) + console.log(`조선 RFQ 데이터 로딩 시작... ${isRetry ? `(재시도 ${retryCount + 1}회)` : ''}`) const [workTypesResult, itemsResult, shipTypesResult] = await Promise.all([ getWorkTypes(), @@ -125,25 +118,23 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { getShipTypes() ]) - console.log("WorkTypes 결과:", workTypesResult) - console.log("Items 결과:", itemsResult) - console.log("ShipTypes 결과:", shipTypesResult) + console.log("Ship - WorkTypes 결과:", workTypesResult) + console.log("Ship - Items 결과:", itemsResult) + console.log("Ship - 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, "개") + console.log("Ship 아이템 설정 완료:", itemsResult.data.length, "개") } else { - console.error("아이템 로딩 실패:", itemsResult.error) - throw new Error(itemsResult.error || "아이템 데이터를 불러올 수 없습니다.") + throw new Error(itemsResult.error || "Ship 아이템 데이터를 불러올 수 없습니다.") } // ShipTypes 설정 @@ -151,18 +142,17 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { setShipTypes(shipTypesResult.data) console.log("선종 설정 완료:", shipTypesResult.data) } else { - console.error("선종 로딩 실패:", shipTypesResult.error) throw new Error(shipTypesResult.error || "선종 데이터를 불러올 수 없습니다.") } // 성공 시 재시도 카운터 리셋 setRetryCount(0) setDataLoadError(null) - console.log("데이터 로딩 완료") + console.log("조선 RFQ 데이터 로딩 완료") } catch (error) { const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.' - console.error("데이터 로딩 오류:", errorMessage) + console.error("조선 RFQ 데이터 로딩 오류:", errorMessage) setDataLoadError(errorMessage) @@ -187,19 +177,11 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { // 다이얼로그가 열릴 때마다 데이터 로딩 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]) + }, [isDialogOpen, loadData]) // 수동 새로고침 함수 const handleRefreshData = React.useCallback(() => { @@ -209,12 +191,13 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { }, [loadData]) // RFQ 생성 폼 - const form = useForm<CreateRfqFormValues>({ - resolver: zodResolver(createRfqSchema), + const form = useForm<CreateShipRfqFormValues>({ + resolver: zodResolver(createShipRfqSchema), defaultValues: { biddingProjectId: undefined, - materialCodes: [], - dueDate: undefined, // 기본값 제거 + itemIds: [], + dueDate: undefined, + description: "", } }) @@ -258,7 +241,7 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { setSelectedShipType(null) setSelectedWorkType(null) setItemSearchQuery("") - form.setValue("materialCodes", []) + form.setValue("itemIds", []) } // 아이템 선택/해제 처리 @@ -266,27 +249,18 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { 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)) + form.setValue("itemIds", newSelectedItems.map(item => item.id)) } else { - // 아이템 선택 추가 const newSelectedItems = [...selectedItems, item] setSelectedItems(newSelectedItems) - form.setValue("materialCodes", newSelectedItems.map(item => item.itemCode)) + form.setValue("itemIds", newSelectedItems.map(item => item.id)) } } - // 아이템 제거 처리 - 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) => { + const handleCreateRfq = async (data: CreateShipRfqFormValues) => { try { setIsProcessing(true) @@ -295,73 +269,34 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { 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<string, typeof selectedItems>) - - 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 - } + // 조선 RFQ 생성 - 1:N 관계로 한 번에 생성 + const result = await createTechSalesShipRfq({ + biddingProjectId: data.biddingProjectId, + itemIds: data.itemIds, + dueDate: data.dueDate, + description: data.description, + createdBy: Number(session.user.id), }) - - // 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(', ')) + if (result.error) { + throw new Error(result.error) } // 성공적으로 생성되면 다이얼로그 닫기 및 메시지 표시 - const totalRfqs = results.reduce((sum, result) => sum + (result.data?.length || 0), 0) - toast.success(`${rfqGroups.length}개 아이템 그룹으로 총 ${totalRfqs}개의 RFQ가 성공적으로 생성되었습니다`) + toast.success(`${selectedItems.length}개 아이템으로 조선 RFQ가 성공적으로 생성되었습니다`) + setIsDialogOpen(false) form.reset({ biddingProjectId: undefined, - materialCodes: [], - dueDate: undefined, // 기본값 제거 + itemIds: [], + dueDate: undefined, + description: "", }) setSelectedProject(null) setItemSearchQuery("") setSelectedWorkType(null) setSelectedShipType(null) setSelectedItems([]) - // 에러 상태 및 재시도 카운터 초기화 setDataLoadError(null) setRetryCount(0) @@ -371,8 +306,8 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { } } catch (error) { - console.error("RFQ 생성 오류:", error) - toast.error(`RFQ 생성 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`) + console.error("조선 RFQ 생성 오류:", error) + toast.error(`조선 RFQ 생성 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`) } finally { setIsProcessing(false) } @@ -386,15 +321,15 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { if (!open) { form.reset({ biddingProjectId: undefined, - materialCodes: [], - dueDate: undefined, // 기본값 제거 + itemIds: [], + dueDate: undefined, + description: "", }) setSelectedProject(null) setItemSearchQuery("") setSelectedWorkType(null) setSelectedShipType(null) setSelectedItems([]) - // 에러 상태 및 재시도 카운터 초기화 setDataLoadError(null) setRetryCount(0) } @@ -408,7 +343,7 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { disabled={isProcessing} > <Plus className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">RFQ 생성</span> + <span className="hidden sm:inline">조선 RFQ 생성</span> </Button> </DialogTrigger> <DialogContent @@ -416,7 +351,7 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { style={{ width: '1200px' }} > <DialogHeader className="border-b pb-4"> - <DialogTitle>RFQ 생성</DialogTitle> + <DialogTitle>조선 RFQ 생성</DialogTitle> </DialogHeader> <div className="space-y-6 p-1 overflow-y-auto"> @@ -444,6 +379,26 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { <Separator className="my-4" /> + {/* RFQ 설명 */} + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>RFQ 설명</FormLabel> + <FormControl> + <Input + placeholder="RFQ 설명을 입력하세요 (선택사항)" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <Separator className="my-4" /> + {/* 선종 선택 */} <div className="space-y-4"> <div> @@ -495,7 +450,7 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { ) : selectedShipType ? ( selectedShipType ) : ( - "전체조회: 선종을 선택해야 생성가능합니다." + "선종을 선택하세요" )} <ArrowUpDown className="ml-2 h-4 w-4 opacity-50" /> </Button> @@ -506,7 +461,7 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { onCheckedChange={() => { setSelectedShipType(null) setSelectedItems([]) - form.setValue("materialCodes", []) + form.setValue("itemIds", []) }} > 전체 선종 @@ -518,7 +473,7 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { onCheckedChange={() => { setSelectedShipType(shipType) setSelectedItems([]) - form.setValue("materialCodes", []) + form.setValue("itemIds", []) }} > {shipType} @@ -581,7 +536,10 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { <div> <FormLabel>조선 아이템 선택</FormLabel> <FormDescription> - {selectedShipType ? `선종 ${selectedShipType}의 공종별 아이템을 선택하세요` : "먼저 선종을 선택해주세요"} + {selectedShipType + ? `선종 ${selectedShipType}의 공종별 아이템을 선택하세요` + : "먼저 선종을 선택해주세요" + } </FormDescription> </div> @@ -686,13 +644,13 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { ) : 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 ( <div key={item.id} @@ -731,124 +689,6 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { </div> </ScrollArea> </div> - - {/* 선택된 아이템 목록 */} - <FormField - control={form.control} - name="materialCodes" - render={() => ( - <FormItem> - <FormLabel>선택된 아이템 ({selectedItems.length}개)</FormLabel> - <div className="min-h-[80px] p-3 border rounded-md bg-muted/50"> - {selectedItems.length > 0 ? ( - <div className="flex flex-wrap gap-2"> - {selectedItems.map((item) => ( - <Badge - key={item.id} - variant="secondary" - className="flex items-center gap-1" - > - {item.itemList || '아이템명 없음'} ({item.itemCode}) - <X - className="h-3 w-3 cursor-pointer hover:text-destructive" - onClick={() => handleRemoveItem(item.id)} - /> - </Badge> - ))} - </div> - ) : ( - <div className="flex items-center justify-center h-full text-sm text-muted-foreground"> - 선택된 아이템이 없습니다 - </div> - )} - </div> - <FormMessage /> - </FormItem> - )} - /> - - {/* RFQ 그룹핑 미리보기 */} - <div className="space-y-3"> - <FormLabel>생성될 RFQ 그룹 미리보기</FormLabel> - <div className="border rounded-md bg-background"> - {(() => { - // 아이템명(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<string, typeof selectedItems>) - - 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 ( - <div className="space-y-3"> - <div className="text-sm text-muted-foreground p-3 border-b"> - 총 {rfqGroups.length}개의 RFQ가 생성됩니다 (아이템명별로 그룹핑) - </div> - <ScrollArea className="h-[200px]"> - <Table> - <TableHeader className="sticky top-0 bg-background"> - <TableRow> - <TableHead className="w-[80px]">RFQ #</TableHead> - <TableHead>아이템명</TableHead> - <TableHead className="w-[120px]">자재그룹코드 개수</TableHead> - <TableHead className="w-[100px]">길이</TableHead> - <TableHead className="w-[80px]">상태</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {rfqGroups.map((group, index) => ( - <TableRow - key={group.actualItemName} - className={group.isOverLimit ? "bg-destructive/5" : ""} - > - <TableCell className="font-medium">#{index + 1}</TableCell> - <TableCell> - <div className="max-w-[200px] truncate" title={group.actualItemName}> - {group.actualItemName} - </div> - </TableCell> - <TableCell>{group.itemCodes.length}개</TableCell> - <TableCell> - <span className={group.isOverLimit ? "text-destructive font-medium" : ""}> - {group.codeLength}/255자 - </span> - </TableCell> - <TableCell> - {group.isOverLimit ? ( - <Badge variant="destructive" className="text-xs">초과</Badge> - ) : ( - <Badge variant="secondary" className="text-xs">정상</Badge> - )} - </TableCell> - </TableRow> - ))} - </TableBody> - </Table> - </ScrollArea> - </div> - ) - })()} - </div> - </div> </div> </div> </div> @@ -873,41 +713,10 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { disabled={ isProcessing || !selectedProject || - selectedItems.length === 0 || - // 255자 초과 그룹이 있는지 확인 - (() => { - const groupedItems = selectedItems.reduce((groups, item) => { - const actualItemName = item.itemList // 실제 조선 아이템명 - if (!actualItemName) { - return groups // itemList가 없는 경우 제외 - } - if (!groups[actualItemName]) { - groups[actualItemName] = [] - } - groups[actualItemName].push(item.itemCode) - return groups - }, {} as Record<string, string[]>) - - return Object.values(groupedItems).some(itemCodes => itemCodes.join(',').length > 255) - })() + selectedItems.length === 0 } > - {isProcessing ? "처리 중..." : (() => { - const groupedItems = selectedItems.reduce((groups, item) => { - const actualItemName = item.itemList // 실제 조선 아이템명 - if (!actualItemName) { - return groups // itemList가 없는 경우 제외 - } - if (!groups[actualItemName]) { - groups[actualItemName] = [] - } - groups[actualItemName].push(item.itemCode) - return groups - }, {} as Record<string, string[]>) - - const groupCount = Object.keys(groupedItems).length - return `${groupCount}개 아이템 그룹으로 생성하기` - })()} + {isProcessing ? "처리 중..." : `${selectedItems.length}개 아이템으로 조선 RFQ 생성하기`} </Button> </div> </div> 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<typeof createTopRfqSchema> + +// 공종 타입 정의 +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<Project | null>(null) + + // 검색 및 필터링 상태 + const [itemSearchQuery, setItemSearchQuery] = React.useState("") + const [selectedWorkType, setSelectedWorkType] = React.useState<OffshoreTopWorkType | null>(null) + const [selectedItems, setSelectedItems] = React.useState<OffshoreTopTechItem[]>([]) + + // 데이터 상태 + const [workTypes, setWorkTypes] = React.useState<WorkTypeOption[]>([]) + const [allItems, setAllItems] = React.useState<OffshoreTopTechItem[]>([]) + const [isLoadingItems, setIsLoadingItems] = React.useState(false) + const [dataLoadError, setDataLoadError] = React.useState<string | null>(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<CreateTopRfqFormValues>({ + 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 ( + <Dialog + open={isDialogOpen} + onOpenChange={(open) => { + setIsDialogOpen(open) + if (!open) { + form.reset({ + biddingProjectId: undefined, + itemIds: [], + dueDate: undefined, + description: "", + }) + setSelectedProject(null) + setItemSearchQuery("") + setSelectedWorkType(null) + setSelectedItems([]) + setDataLoadError(null) + setRetryCount(0) + } + }} + > + <DialogTrigger asChild> + <Button + variant="default" + size="sm" + className="gap-2" + disabled={isProcessing} + > + <Plus className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">해양 TOP RFQ 생성</span> + </Button> + </DialogTrigger> + <DialogContent + className="max-w-none h-[90vh] overflow-y-auto flex flex-col" + style={{ width: '1200px' }} + > + <DialogHeader className="border-b pb-4"> + <DialogTitle>해양 TOP RFQ 생성</DialogTitle> + </DialogHeader> + + <div className="space-y-6 p-1 overflow-y-auto"> + <Form {...form}> + <form onSubmit={form.handleSubmit(handleCreateRfq)} className="space-y-6"> + {/* 프로젝트 선택 */} + <div className="space-y-4"> + <FormField + control={form.control} + name="biddingProjectId" + render={({ field }) => ( + <FormItem> + <FormLabel>입찰 프로젝트</FormLabel> + <FormControl> + <EstimateProjectSelector + selectedProjectId={field.value} + onProjectSelect={handleProjectSelect} + placeholder="입찰 프로젝트를 선택하세요" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <Separator className="my-4" /> + + {/* 마감일 설정 */} + <FormField + control={form.control} + name="dueDate" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel>마감일</FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + className={cn( + "w-full pl-3 text-left font-normal", + !field.value && "text-muted-foreground" + )} + > + {field.value ? ( + format(field.value, "PPP", { locale: ko }) + ) : ( + <span>마감일을 선택하세요</span> + )} + <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <Calendar + mode="single" + selected={field.value} + onSelect={field.onChange} + disabled={(date) => + date < new Date() || date < new Date("1900-01-01") + } + initialFocus + /> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + + <Separator className="my-4" /> + + <div className="space-y-6"> + {/* 아이템 선택 영역 */} + <div className="space-y-4"> + <div> + <FormLabel>아이템 선택</FormLabel> + <FormDescription> + 해양 TOP RFQ를 생성하려면 아이템을 선택하세요 + </FormDescription> + </div> + + {/* 아이템 검색 및 필터 */} + <div className="space-y-2"> + <div className="flex space-x-2"> + <div className="relative flex-1"> + <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> + <Input + placeholder="아이템 검색..." + value={itemSearchQuery} + onChange={(e) => setItemSearchQuery(e.target.value)} + className="pl-8 pr-8" + disabled={isLoadingItems || dataLoadError !== null} + /> + {itemSearchQuery && ( + <Button + variant="ghost" + size="sm" + className="absolute right-0 top-0 h-full px-3" + onClick={() => setItemSearchQuery("")} + disabled={isLoadingItems || dataLoadError !== null} + > + <X className="h-4 w-4" /> + </Button> + )} + </div> + + {/* 공종 필터 */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + variant="outline" + className="gap-1" + disabled={isLoadingItems || dataLoadError !== null} + > + {selectedWorkType ? workTypes.find(wt => wt.code === selectedWorkType)?.name : "전체 공종"} + <ArrowUpDown className="ml-2 h-4 w-4 opacity-50" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuCheckboxItem + checked={selectedWorkType === null} + onCheckedChange={() => setSelectedWorkType(null)} + > + 전체 공종 + </DropdownMenuCheckboxItem> + {workTypes.map(workType => ( + <DropdownMenuCheckboxItem + key={workType.code} + checked={selectedWorkType === workType.code} + onCheckedChange={() => setSelectedWorkType(workType.code)} + > + {workType.name} + </DropdownMenuCheckboxItem> + ))} + </DropdownMenuContent> + </DropdownMenu> + </div> + </div> + + {/* 아이템 목록 */} + <div className="border rounded-md"> + <ScrollArea className="h-[300px]"> + <div className="p-2 space-y-1"> + {dataLoadError ? ( + <div className="text-center py-8"> + <div className="p-4 bg-destructive/10 border border-destructive/20 rounded-md mx-4"> + <div className="flex flex-col items-center gap-3"> + <X className="h-8 w-8 text-destructive" /> + <div className="text-center"> + <p className="text-sm text-destructive font-medium">데이터 로딩에 실패했습니다</p> + <p className="text-xs text-muted-foreground mt-1">{dataLoadError}</p> + </div> + <Button + variant="outline" + size="sm" + onClick={handleRefreshData} + disabled={isLoadingItems} + className="h-8" + > + {isLoadingItems ? ( + <> + <Loader2 className="h-3 w-3 animate-spin mr-1" /> + 재시도 중... + </> + ) : ( + "다시 시도" + )} + </Button> + </div> + </div> + </div> + ) : isLoadingItems ? ( + <div className="text-center py-8 text-muted-foreground"> + <Loader2 className="h-6 w-6 animate-spin mx-auto mb-2" /> + 아이템을 불러오는 중... + {retryCount > 0 && ( + <p className="text-xs mt-1">재시도 {retryCount}회</p> + )} + </div> + ) : 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 ( + <div + key={item.id} + className={cn( + "flex items-center space-x-2 p-2 rounded-md cursor-pointer hover:bg-muted", + isSelected && "bg-muted" + )} + onClick={() => handleItemToggle(item)} + > + <div className="flex items-center space-x-2 flex-1"> + {isSelected ? ( + <CheckSquare className="h-4 w-4" /> + ) : ( + <Square className="h-4 w-4" /> + )} + <div className="flex-1"> + <div className="font-medium"> + {item.itemList || '아이템명 없음'} + {item.subItemList && ` / ${item.subItemList}`} + </div> + <div className="text-sm text-muted-foreground"> + {item.itemCode || '아이템코드 없음'} + </div> + <div className="text-xs text-muted-foreground"> + 공종: {item.workType} + </div> + </div> + </div> + </div> + ) + }) + ) : ( + <div className="text-center py-8 text-muted-foreground"> + {itemSearchQuery ? "검색 결과가 없습니다" : "아이템이 없습니다"} + </div> + )} + </div> + </ScrollArea> + </div> + </div> + </div> + </div> + </form> + </Form> + </div> + + {/* Footer - Sticky 버튼 영역 */} + <div className="sticky bottom-0 left-0 z-20 bg-background border-t pt-4 mt-4"> + <div className="flex justify-end space-x-2"> + <Button + type="button" + variant="outline" + onClick={() => setIsDialogOpen(false)} + disabled={isProcessing} + > + 취소 + </Button> + <Button + type="button" + onClick={form.handleSubmit(handleCreateRfq)} + disabled={ + isProcessing || + !selectedProject || + selectedItems.length === 0 + } + > + {isProcessing ? "처리 중..." : `${selectedItems.length}개 아이템으로 해양 TOP RFQ 생성하기`} + </Button> + </div> + </div> + </DialogContent> + </Dialog> + ) +}
\ 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<VendorSearchResult[]>([]) + const [candidateVendors, setCandidateVendors] = useState<VendorSearchResult[]>([]) const [isSearching, setIsSearching] = useState(false) + const [isLoadingCandidates, setIsLoadingCandidates] = useState(false) const [hasSearched, setHasSearched] = useState(false) + const [hasCandidatesLoaded, setHasCandidatesLoaded] = useState(false) // 선택된 벤더들을 별도로 관리하여 검색과 독립적으로 유지 const [selectedVendorData, setSelectedVendorData] = useState<VendorSearchResult[]>([]) + const [activeTab, setActiveTab] = useState("candidates") const form = useForm<VendorFormValues>({ 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) => ( + <ScrollArea className="h-60 border rounded-md"> + <div className="p-2 space-y-1"> + {vendors.length > 0 ? ( + vendors.map((vendor, index) => ( + <div + key={`${vendor.id}-${index}`} // 고유한 키 생성 + className={`flex items-center space-x-2 p-2 rounded-md cursor-pointer hover:bg-muted ${ + selectedVendorIds.includes(vendor.id) ? "bg-muted" : "" + }`} + onClick={() => handleVendorToggle(vendor)} + > + <div className="flex items-center space-x-2 flex-1"> + <Check + className={`h-4 w-4 ${ + selectedVendorIds.includes(vendor.id) + ? "opacity-100" + : "opacity-0" + }`} + /> + <div className="flex-1"> + <div className="flex items-center gap-2"> + <span className="font-medium">{vendor.vendorName}</span> + {showMatchCount && vendor.matchedItemCount && vendor.matchedItemCount > 0 && ( + <Badge variant="secondary" className="text-xs flex items-center gap-1"> + <Star className="h-3 w-3" /> + {vendor.matchedItemCount}개 매칭 + </Badge> + )} + {vendor.techVendorType && ( + <Badge variant="outline" className="text-xs"> + {vendor.techVendorType} + </Badge> + )} + </div> + <div className="text-sm text-muted-foreground"> + {vendor.vendorCode || 'N/A'} {vendor.country && `• ${vendor.country}`} + </div> + </div> + </div> + </div> + )) + ) : ( + <div className="text-center py-8 text-muted-foreground"> + {showMatchCount ? "매칭되는 후보 벤더가 없습니다" : "검색 결과가 없습니다"} + </div> + )} + </div> + </ScrollArea> + ) + return ( <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="sm:max-w-[700px] max-h-[80vh] flex flex-col"> + <DialogContent className="sm:max-w-[800px] max-h-[80vh] flex flex-col"> {/* 헤더 */} <DialogHeader> <DialogTitle>벤더 추가</DialogTitle> @@ -217,73 +317,91 @@ export function AddVendorDialog({ <div className="flex-1 overflow-y-auto"> <Form {...form}> <form id="vendor-form" onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> - {/* 벤더 검색 필드 */} - <div className="space-y-2"> - <label className="text-sm font-medium">벤더 검색</label> - <div className="relative"> - <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" /> - <Input - placeholder="벤더명 또는 벤더코드로 검색..." - value={searchTerm} - onChange={(e) => setSearchTerm(e.target.value)} - className="pl-10" - /> - {isSearching && ( - <Loader2 className="absolute right-3 top-1/2 transform -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" /> - )} - </div> - </div> + {/* 탭 메뉴 */} + <Tabs value={activeTab} onValueChange={setActiveTab}> + <TabsList className="grid w-full grid-cols-2"> + <TabsTrigger value="candidates"> + 후보 벤더 ({candidateVendors.length}) + </TabsTrigger> + <TabsTrigger value="search"> + 벤더 검색 + </TabsTrigger> + </TabsList> - {/* 검색 결과 */} - {hasSearched && ( - <div className="space-y-2"> - <div className="text-sm font-medium"> - 검색 결과 ({searchResults.length}개) - </div> - <ScrollArea className="h-60 border rounded-md"> - <div className="p-2 space-y-1"> - {searchResults.length > 0 ? ( - searchResults.map((vendor) => ( - <div - key={vendor.id} - className={`flex items-center space-x-2 p-2 rounded-md cursor-pointer hover:bg-muted ${ - selectedVendorIds.includes(vendor.id) ? "bg-muted" : "" - }`} - onClick={() => handleVendorToggle(vendor)} - > - <div className="flex items-center space-x-2 flex-1"> - <Check - className={`h-4 w-4 ${ - selectedVendorIds.includes(vendor.id) - ? "opacity-100" - : "opacity-0" - }`} - /> - <div className="flex-1"> - <div className="font-medium">{vendor.vendorName}</div> - <div className="text-sm text-muted-foreground"> - {vendor.vendorCode || 'N/A'} {vendor.country && `• ${vendor.country}`} - </div> - </div> - </div> - </div> - )) - ) : ( - <div className="text-center py-8 text-muted-foreground"> - 검색 결과가 없습니다 + {/* 후보 벤더 탭 */} + <TabsContent value="candidates" className="space-y-4"> + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <label className="text-sm font-medium">추천 후보 벤더</label> + <Button + type="button" + variant="outline" + size="sm" + onClick={() => { + setHasCandidatesLoaded(false) + loadCandidateVendors() + }} + disabled={isLoadingCandidates} + > + {isLoadingCandidates ? ( + <Loader2 className="h-4 w-4 animate-spin" /> + ) : ( + "새로고침" + )} + </Button> + </div> + + {isLoadingCandidates ? ( + <div className="h-60 border rounded-md flex items-center justify-center"> + <div className="flex items-center gap-2"> + <Loader2 className="h-4 w-4 animate-spin" /> + <span>후보 벤더를 불러오는 중...</span> </div> + </div> + ) : ( + renderVendorList(candidateVendors, true) + )} + + <div className="text-xs text-muted-foreground bg-blue-50 p-2 rounded"> + 💡 RFQ 아이템과 매칭되는 벤더들이 매칭 아이템 수가 많은 순으로 표시됩니다. + </div> + </div> + </TabsContent> + + {/* 벤더 검색 탭 */} + <TabsContent value="search" className="space-y-4"> + {/* 벤더 검색 필드 */} + <div className="space-y-2"> + <label className="text-sm font-medium">벤더 검색</label> + <div className="relative"> + <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" /> + <Input + placeholder="벤더명 또는 벤더코드로 검색..." + value={searchTerm} + onChange={(e) => setSearchTerm(e.target.value)} + className="pl-10" + /> + {isSearching && ( + <Loader2 className="absolute right-3 top-1/2 transform -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" /> )} </div> - </ScrollArea> - </div> - )} + </div> - {/* 검색 안내 메시지 */} - {!hasSearched && !searchTerm && ( - <div className="text-center py-8 text-muted-foreground border rounded-md"> - 벤더명 또는 벤더코드를 입력하여 검색해주세요 - </div> - )} + {/* 검색 결과 */} + {hasSearched ? ( + <div className="space-y-2"> + <div className="text-sm font-medium"> + 검색 결과 ({searchResults.length}개) + </div> + {renderVendorList(searchResults)} + </div> + ) : ( + <div className="text-center py-8 text-muted-foreground border rounded-md"> + 벤더명 또는 벤더코드를 입력하여 검색해주세요 + </div> + )} + </TabsContent> + </Tabs> {/* 선택된 벤더 목록 - 하단에 항상 표시 */} <FormField @@ -324,10 +442,9 @@ export function AddVendorDialog({ {/* 안내 메시지 */} <div className="text-sm text-muted-foreground bg-muted p-3 rounded-md"> - {/* <p>• 검색은 ACTIVE 상태의 벤더만 대상으로 합니다.</p> */} + <p>• 후보 벤더는 RFQ 아이템 코드와 매칭되는 기술영업 벤더들입니다.</p> <p>• 선택된 벤더들은 Draft 상태로 추가됩니다.</p> <p>• 벤더별 견적 정보는 추가 후 개별적으로 입력할 수 있습니다.</p> - <p>• 이미 추가된 벤더는 검색 결과에서 체크됩니다.</p> </div> </form> </Form> 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 ( <Dialog open={open} onOpenChange={onOpenChange}> <DialogContent className="max-w-4xl w-[80vw] max-h-[80vh] overflow-hidden flex flex-col"> @@ -141,171 +104,6 @@ export function ProjectDetailDialog({ </div> </div> </div> - - <Separator /> - - {/* 프로젝트 스냅샷 정보 */} - {projectSnapshot && ( - <div className="space-y-4"> - <h3 className="text-lg font-semibold">프로젝트 스냅샷</h3> - <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 gap-4"> - {projectSnapshot.scDt && ( - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">S/C</div> - <div className="text-sm">{formatDateToQuarter(projectSnapshot.scDt)}</div> - </div> - )} - {projectSnapshot.klDt && ( - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">K/L</div> - <div className="text-sm">{formatDateToQuarter(projectSnapshot.klDt)}</div> - </div> - )} - {projectSnapshot.lcDt && ( - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">L/C</div> - <div className="text-sm">{formatDateToQuarter(projectSnapshot.lcDt)}</div> - </div> - )} - {projectSnapshot.dlDt && ( - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">D/L</div> - <div className="text-sm">{formatDateToQuarter(projectSnapshot.dlDt)}</div> - </div> - )} - {projectSnapshot.dockNo && ( - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">도크번호</div> - <div className="text-sm">{projectSnapshot.dockNo}</div> - </div> - )} - {projectSnapshot.dockNm && ( - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">도크명</div> - <div className="text-sm">{projectSnapshot.dockNm}</div> - </div> - )} - {projectSnapshot.projNo && ( - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">공사번호</div> - <div className="text-sm">{projectSnapshot.projNo}</div> - </div> - )} - {projectSnapshot.projNm && ( - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">공사명</div> - <div className="text-sm">{projectSnapshot.projNm}</div> - </div> - )} - {projectSnapshot.ownerNm && ( - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">선주</div> - <div className="text-sm">{projectSnapshot.ownerNm}</div> - </div> - )} - {projectSnapshot.kunnrNm && ( - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">선주명</div> - <div className="text-sm">{projectSnapshot.kunnrNm}</div> - </div> - )} - {projectSnapshot.cls1Nm && ( - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">선급명</div> - <div className="text-sm">{projectSnapshot.cls1Nm}</div> - </div> - )} - {projectSnapshot.projMsrm && ( - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">척수</div> - <div className="text-sm">{projectSnapshot.projMsrm}</div> - </div> - )} - {projectSnapshot.ptypeNm && ( - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">선종명</div> - <div className="text-sm">{projectSnapshot.ptypeNm}</div> - </div> - )} - {projectSnapshot.sector && ( - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">섹터</div> - <div className="text-sm">{projectSnapshot.sector}</div> - </div> - )} - {projectSnapshot.estmPm && ( - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">견적 PM</div> - <div className="text-sm">{projectSnapshot.estmPm}</div> - </div> - )} - </div> - </div> - )} - - {/* 시리즈 스냅샷 정보 */} - {seriesSnapshot && Array.isArray(seriesSnapshot) && seriesSnapshot.length > 0 && ( - <> - <Separator /> - <div className="space-y-4"> - <h3 className="text-lg font-semibold">시리즈 정보 스냅샷</h3> - <div className="space-y-4"> - {seriesSnapshot.map((series: SeriesSnapshot, index: number) => ( - <div key={index} className="border rounded-lg p-4 space-y-3"> - <div className="flex items-center gap-2"> - <Badge variant="secondary">시리즈 {series.sersNo || index + 1}</Badge> - </div> - <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> - {series.scDt && ( - <div className="space-y-1"> - <div className="text-xs font-medium text-muted-foreground">S/C</div> - <div className="text-sm">{formatDateToQuarter(series.scDt)}</div> - </div> - )} - {series.klDt && ( - <div className="space-y-1"> - <div className="text-xs font-medium text-muted-foreground">K/L</div> - <div className="text-sm">{formatDateToQuarter(series.klDt)}</div> - </div> - )} - {series.lcDt && ( - <div className="space-y-1"> - <div className="text-xs font-medium text-muted-foreground">L/C</div> - <div className="text-sm">{formatDateToQuarter(series.lcDt)}</div> - </div> - )} - {series.dlDt && ( - <div className="space-y-1"> - <div className="text-xs font-medium text-muted-foreground">D/L</div> - <div className="text-sm">{formatDateToQuarter(series.dlDt)}</div> - </div> - )} - {series.dockNo && ( - <div className="space-y-1"> - <div className="text-xs font-medium text-muted-foreground">도크번호</div> - <div className="text-sm">{series.dockNo}</div> - </div> - )} - {series.dockNm && ( - <div className="space-y-1"> - <div className="text-xs font-medium text-muted-foreground">도크명</div> - <div className="text-sm">{series.dockNm}</div> - </div> - )} - </div> - </div> - ))} - </div> - </div> - </> - )} - - {/* 추가 정보가 없는 경우 */} - {!projectSnapshot && !seriesSnapshot && ( - <div className="text-center py-8 text-muted-foreground"> - 추가 프로젝트 상세정보가 없습니다. - </div> - )} </div> {/* 닫기 버튼 */} 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({ )} /> - {/* 자재코드 */} + {/* 자재그룹 */} <FormField control={form.control} name="materialCode" render={({ field }) => ( <FormItem> - <FormLabel>{t("자재코드")}</FormLabel> + <FormLabel>{t("자재그룹")}</FormLabel> <FormControl> <div className="relative"> <Input - placeholder={t("자재코드 입력")} + placeholder={t("자재그룹 입력")} {...field} className={cn(field.value && "pr-8", "bg-white")} disabled={isInitializing} diff --git a/lib/techsales-rfq/table/rfq-items-view-dialog.tsx b/lib/techsales-rfq/table/rfq-items-view-dialog.tsx new file mode 100644 index 00000000..10bc9f1f --- /dev/null +++ b/lib/techsales-rfq/table/rfq-items-view-dialog.tsx @@ -0,0 +1,198 @@ +"use client"
+
+import * as React from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { Package, FileText, X } from "lucide-react"
+import { getTechSalesRfqItems } from "../service"
+
+interface RfqItem {
+ id: number;
+ rfqId: number;
+ itemType: "SHIP" | "TOP" | "HULL";
+ itemCode: string;
+ itemList: string;
+ workType: string;
+ shipType?: string; // 조선용
+ subItemName?: string; // 해양용
+}
+
+interface RfqItemsViewDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => 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<RfqItem[]>([]);
+ 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 (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-none w-[1200px]">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ RFQ 아이템 조회
+ <Badge variant="outline" className="ml-2">
+ {rfq?.rfqCode || `RFQ #${rfq?.id}`}
+ </Badge>
+ </DialogTitle>
+ <DialogDescription>
+ RFQ에 등록된 아이템 목록을 확인할 수 있습니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="overflow-x-auto w-full">
+ <div className="space-y-4">
+ {loading ? (
+ <div className="flex items-center justify-center py-8">
+ <div className="text-center space-y-2">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
+ <p className="text-sm text-muted-foreground">아이템을 불러오는 중...</p>
+ </div>
+ </div>
+ ) : items.length === 0 ? (
+ <div className="flex flex-col items-center justify-center py-12 text-center">
+ <FileText className="h-12 w-12 text-muted-foreground mb-3" />
+ <h3 className="text-lg font-medium mb-1">아이템이 없습니다</h3>
+ <p className="text-sm text-muted-foreground">
+ 이 RFQ에 등록된 아이템이 없습니다.
+ </p>
+ </div>
+ ) : (
+ <>
+ {/* 헤더 행 (라벨) */}
+ <div className="flex items-center gap-2 border-b pb-2 font-medium text-sm">
+ <div className="w-[50px] text-center">No.</div>
+ <div className="w-[120px] pl-2">타입</div>
+ <div className="w-[200px] ">자재 그룹</div>
+ <div className="w-[150px] ">공종</div>
+ <div className="w-[300px] ">자재명</div>
+ <div className="w-[150px] ">선종/자재명(상세)</div>
+ </div>
+
+ {/* 아이템 행들 */}
+ <div className="max-h-[50vh] overflow-y-auto pr-1 space-y-2">
+ {items.map((item, index) => (
+ <div
+ key={item.id}
+ className="flex items-center gap-2 group hover:bg-gray-50 p-2 rounded-md transition-colors border"
+ >
+ <div className="w-[50px] text-center text-sm font-medium text-muted-foreground">
+ {index + 1}
+ </div>
+ <div className="w-[120px] pl-2">
+ <Badge variant="secondary" className={`text-xs ${getTypeColor(item.itemType)}`}>
+ {getTypeLabel(item.itemType)}
+ </Badge>
+ </div>
+ <div className="w-[200px] pl-2 font-mono text-sm">
+ {item.itemCode}
+ </div>
+ <div className="w-[150px] pl-2 text-sm">
+ {item.workType}
+ </div>
+ <div className="w-[300px] pl-2 font-medium">
+ {item.itemList}
+ </div>
+ <div className="w-[150px] pl-2 text-sm">
+ {item.itemType === 'SHIP' ? item.shipType : item.subItemName}
+ </div>
+ </div>
+ ))}
+ </div>
+
+ <div className="flex justify-between items-center pt-2 border-t">
+ <div className="flex items-center gap-2">
+ <Package className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm text-muted-foreground">
+ 총 {items.length}개 아이템
+ </span>
+ </div>
+ </div>
+ </>
+ )}
+ </div>
+ </div>
+
+ <DialogFooter className="mt-6">
+ <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
+ <X className="mr-2 h-4 w-4" />
+ 닫기
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
\ 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<string, unknown> // legacy 호환성을 위해 유지 - seriesSnapshot: Array<{ - pspid: string; - sersNo: string; - scDt?: string; - klDt?: string; - lcDt?: string; - dlDt?: string; - dockNo?: string; - dockNm?: string; - projNo?: string; - post1?: string; - }> | Record<string, unknown> // 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<React.SetStateAction<DataTableRowAction<TechSalesRfq> | null>>; openAttachmentsSheet: (rfqId: number) => void; + openItemsDialog: (rfq: TechSalesRfq) => void; } export function getColumns({ setRowAction, openAttachmentsSheet, + openItemsDialog, }: GetColumnsProps): ColumnDef<TechSalesRfq>[] { return [ { @@ -144,34 +110,6 @@ export function getColumns({ size: 120, }, { - accessorKey: "materialCode", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="자재코드" /> - ), - cell: ({ row }) => <div>{row.getValue("materialCode")}</div>, - meta: { - excelHeader: "자재코드" - }, - enableResizing: true, - minSize: 80, - size: 250, - }, - { - accessorKey: "itemName", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="자재명" /> - ), - cell: ({ row }) => { - const itemName = row.getValue("itemName") as string | null; - return <div>{itemName || "자재명 없음"}</div>; - }, - meta: { - excelHeader: "자재명" - }, - enableResizing: true, - size: 250, - }, - { accessorKey: "projNm", header: ({ column }) => ( <DataTableColumnHeaderSimple column={column} title="프로젝트명" /> @@ -194,85 +132,43 @@ export function getColumns({ enableResizing: true, size: 160, }, - { - accessorKey: "projMsrm", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="척수" /> - ), - cell: ({ row }) => <div>{row.getValue("projMsrm")}</div>, - meta: { - excelHeader: "척수" - }, - enableResizing: true, - minSize: 60, - size: 80, - }, - { - accessorKey: "ptypeNm", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="선종" /> - ), - cell: ({ row }) => <div>{row.getValue("ptypeNm")}</div>, - meta: { - excelHeader: "선종" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "quotationCount", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="견적수" /> - ), - cell: ({ row }) => <div>{row.getValue("quotationCount")}</div>, - meta: { - excelHeader: "견적수" - }, - enableResizing: true, - size: 80, - }, - { - id: "attachments", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="첨부파일" /> - ), - cell: ({ row }) => { - const rfq = row.original - const attachmentCount = rfq.attachmentCount || 0 - - const handleClick = () => { - openAttachmentsSheet(rfq.id) - } - - return ( - <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={handleClick} - aria-label={ - attachmentCount > 0 ? `View ${attachmentCount} attachments` : "Add attachments" - } - > - <Paperclip className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - {attachmentCount > 0 && ( - <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-primary px-1 text-[0.625rem] font-medium leading-none text-primary-foreground"> - {attachmentCount} - </span> - )} - <span className="sr-only"> - {attachmentCount > 0 ? `${attachmentCount} 첨부파일` : "첨부파일 추가"} - </span> - </Button> - ) - }, - enableSorting: false, - enableResizing: true, - size: 80, - meta: { - excelHeader: "첨부파일" - }, - }, + // { + // accessorKey: "projMsrm", + // header: ({ column }) => ( + // <DataTableColumnHeaderSimple column={column} title="척수" /> + // ), + // cell: ({ row }) => <div>{row.getValue("projMsrm")}</div>, + // meta: { + // excelHeader: "척수" + // }, + // enableResizing: true, + // minSize: 60, + // size: 80, + // }, + // { + // accessorKey: "ptypeNm", + // header: ({ column }) => ( + // <DataTableColumnHeaderSimple column={column} title="선종" /> + // ), + // cell: ({ row }) => <div>{row.getValue("ptypeNm")}</div>, + // meta: { + // excelHeader: "선종" + // }, + // enableResizing: true, + // size: 120, + // }, + // { + // accessorKey: "quotationCount", + // header: ({ column }) => ( + // <DataTableColumnHeaderSimple column={column} title="견적수" /> + // ), + // cell: ({ row }) => <div>{row.getValue("quotationCount")}</div>, + // 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 }) => ( + <DataTableColumnHeaderSimple column={column} title="아이템" /> + ), + cell: ({ row }) => { + const rfq = row.original + const itemCount = rfq.itemCount || 0 + + const handleClick = () => { + openItemsDialog(rfq) + } + + return ( + <Button + variant="ghost" + size="sm" + className="relative h-8 w-8 p-0 group" + onClick={handleClick} + aria-label={`View ${itemCount} items`} + > + <Package className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> + {itemCount > 0 && ( + <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-primary px-1 text-[0.625rem] font-medium leading-none text-primary-foreground"> + {itemCount} + </span> + )} + <span className="sr-only"> + {itemCount > 0 ? `${itemCount} 아이템` : "아이템 없음"} + </span> + </Button> + ) + }, + enableSorting: false, + enableResizing: false, + size: 80, + meta: { + excelHeader: "아이템" + }, + }, + { + id: "attachments", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="첨부파일" /> + ), + cell: ({ row }) => { + const rfq = row.original + const attachmentCount = rfq.attachmentCount || 0 + + const handleClick = () => { + openAttachmentsSheet(rfq.id) + } + + return ( + <Button + variant="ghost" + size="sm" + className="relative h-8 w-8 p-0 group" + onClick={handleClick} + aria-label={ + attachmentCount > 0 ? `View ${attachmentCount} attachments` : "Add attachments" + } + > + <Paperclip className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> + {attachmentCount > 0 && ( + <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-primary px-1 text-[0.625rem] font-medium leading-none text-primary-foreground"> + {attachmentCount} + </span> + )} + <span className="sr-only"> + {attachmentCount > 0 ? `${attachmentCount} 첨부파일` : "첨부파일 추가"} + </span> + </Button> + ) + }, + 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<TData> { selection: Table<TData>; onRefresh?: () => void; + rfqType?: "SHIP" | "TOP" | "HULL"; } export function RFQTableToolbarActions<TData>({ selection, - onRefresh + onRefresh, + rfqType = "SHIP" }: RFQTableToolbarActionsProps<TData>) { // 데이터 새로고침 @@ -27,10 +31,23 @@ export function RFQTableToolbarActions<TData>({ } } + // RFQ 타입에 따른 다이얼로그 렌더링 + const renderRfqDialog = () => { + switch (rfqType) { + case "TOP": + return <CreateTopRfqDialog onCreated={onRefresh} />; + case "HULL": + return <CreateHullRfqDialog onCreated={onRefresh} />; + case "SHIP": + default: + return <CreateShipRfqDialog onCreated={onRefresh} />; + } + } + return ( <div className="flex items-center gap-2"> {/* RFQ 생성 다이얼로그 */} - <CreateRfqDialog onCreated={onRefresh} /> + {renderRfqDialog()} {/* 새로고침 버튼 */} <Button diff --git a/lib/techsales-rfq/table/rfq-table.tsx b/lib/techsales-rfq/table/rfq-table.tsx index f1570577..424ca70e 100644 --- a/lib/techsales-rfq/table/rfq-table.tsx +++ b/lib/techsales-rfq/table/rfq-table.tsx @@ -23,24 +23,22 @@ import { RFQTableToolbarActions } from "./rfq-table-toolbar-actions" import { getTechSalesRfqsWithJoin, getTechSalesRfqAttachments } from "@/lib/techsales-rfq/service" import { toast } from "sonner" import { useTablePresets } from "@/components/data-table/use-table-presets" -import { TablePresetManager } from "@/components/data-table/data-table-preset" import { RfqDetailTables } from "./detail-table/rfq-detail-table" import { cn } from "@/lib/utils" import { ProjectDetailDialog } from "./project-detail-dialog" import { RFQFilterSheet } from "./rfq-filter-sheet" import { TechSalesRfqAttachmentsSheet, ExistingTechSalesAttachment } from "./tech-sales-rfq-attachments-sheet" - +import { RfqItemsViewDialog } from "./rfq-items-view-dialog" // 기본적인 RFQ 타입 정의 (repository selectTechSalesRfqsWithJoin 반환 타입에 맞춤) interface TechSalesRfq { id: number rfqCode: string | null - itemId: number - itemName: string | null + biddingProjectId: number | null materialCode: string | null dueDate: Date rfqSendDate: Date | null status: "RFQ Created" | "RFQ Vendor Assignned" | "RFQ Sent" | "Quotation Analysis" | "Closed" - picCode: string | null + description: string | null remark: string | null cancelReason: string | null createdAt: Date @@ -51,40 +49,7 @@ interface 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<string, unknown> // legacy 호환성을 위해 유지 - seriesSnapshot: Array<{ - pspid: string; - sersNo: string; - scDt?: string; - klDt?: string; - lcDt?: string; - dlDt?: string; - dockNo?: string; - dockNm?: string; - projNo?: string; - post1?: string; - }> | Record<string, unknown> // legacy 호환성을 위해 유지 + // 조인된 프로젝트 정보 pspid: string projNm: string sector: string @@ -100,12 +65,14 @@ interface RFQListTableProps { promises: Promise<[Awaited<ReturnType<typeof getTechSalesRfqsWithJoin>>]> className?: string; calculatedHeight?: string; // 계산된 높이 추가 + rfqType: "SHIP" | "TOP" | "HULL"; } export function RFQListTable({ promises, className, - calculatedHeight + calculatedHeight, + rfqType }: RFQListTableProps) { const searchParams = useSearchParams() @@ -124,6 +91,10 @@ export function RFQListTable({ const [selectedRfqForAttachments, setSelectedRfqForAttachments] = React.useState<TechSalesRfq | null>(null) const [attachmentsDefault, setAttachmentsDefault] = React.useState<ExistingTechSalesAttachment[]>([]) + // 아이템 다이얼로그 상태 + const [itemsDialogOpen, setItemsDialogOpen] = React.useState(false) + const [selectedRfqForItems, setSelectedRfqForItems] = React.useState<TechSalesRfq | null>(null) + // 패널 collapse 상태 const [panelHeight, setPanelHeight] = React.useState<number>(55) @@ -164,23 +135,23 @@ export function RFQListTable({ to: searchParams?.get('to') || undefined, columnVisibility: {}, columnOrder: [], - pinnedColumns: { left: [], right: [] }, + pinnedColumns: { left: [], right: ["items", "attachments"] }, groupBy: [], expandedRows: [] }), [searchParams]) // DB 기반 프리셋 훅 사용 const { - presets, - activePresetId, - hasUnsavedChanges, - isLoading: presetsLoading, - createPreset, - applyPreset, - updatePreset, - deletePreset, - setDefaultPreset, - renamePreset, + // presets, + // activePresetId, + // hasUnsavedChanges, + // isLoading: presetsLoading, + // createPreset, + // applyPreset, + // updatePreset, + // deletePreset, + // setDefaultPreset, + // renamePreset, getCurrentSettings, } = useTablePresets<TechSalesRfq>('rfq-list-table', initialSettings) @@ -199,13 +170,12 @@ export function RFQListTable({ setSelectedRfq({ id: rfqData.id, rfqCode: rfqData.rfqCode, - itemId: rfqData.itemId, - itemName: rfqData.itemName, + biddingProjectId: rfqData.biddingProjectId, materialCode: rfqData.materialCode, dueDate: rfqData.dueDate, rfqSendDate: rfqData.rfqSendDate, status: rfqData.status, - picCode: rfqData.picCode, + description: rfqData.description, remark: rfqData.remark, cancelReason: rfqData.cancelReason, createdAt: rfqData.createdAt, @@ -216,8 +186,6 @@ export function RFQListTable({ updatedByName: rfqData.updatedByName, sentBy: rfqData.sentBy, sentByName: rfqData.sentByName, - projectSnapshot: rfqData.projectSnapshot, - seriesSnapshot: rfqData.seriesSnapshot, pspid: rfqData.pspid, projNm: rfqData.projNm, sector: rfqData.sector, @@ -233,13 +201,12 @@ export function RFQListTable({ setProjectDetailRfq({ id: projectRfqData.id, rfqCode: projectRfqData.rfqCode, - itemId: projectRfqData.itemId, - itemName: projectRfqData.itemName, + biddingProjectId: projectRfqData.biddingProjectId, materialCode: projectRfqData.materialCode, dueDate: projectRfqData.dueDate, rfqSendDate: projectRfqData.rfqSendDate, status: projectRfqData.status, - picCode: projectRfqData.picCode, + description: projectRfqData.description, remark: projectRfqData.remark, cancelReason: projectRfqData.cancelReason, createdAt: projectRfqData.createdAt, @@ -250,8 +217,6 @@ export function RFQListTable({ updatedByName: projectRfqData.updatedByName, sentBy: projectRfqData.sentBy, sentByName: projectRfqData.sentByName, - projectSnapshot: projectRfqData.projectSnapshot || {}, - seriesSnapshot: projectRfqData.seriesSnapshot || {}, pspid: projectRfqData.pspid, projNm: projectRfqData.projNm, sector: projectRfqData.sector, @@ -307,11 +272,7 @@ export function RFQListTable({ })) setAttachmentsDefault(attachments) - setSelectedRfqForAttachments({ - ...rfq, - projectSnapshot: rfq.projectSnapshot || {}, - seriesSnapshot: Array.isArray(rfq.seriesSnapshot) ? rfq.seriesSnapshot : {}, - }) + setSelectedRfqForAttachments(rfq as unknown as TechSalesRfq) setAttachmentsOpen(true) } catch (error) { console.error("첨부파일 조회 오류:", error) @@ -332,12 +293,20 @@ export function RFQListTable({ }, 500) }, []) + // 아이템 다이얼로그 열기 함수 + const openItemsDialog = React.useCallback((rfq: TechSalesRfq) => { + console.log("Opening items dialog for RFQ:", rfq.id, rfq) + setSelectedRfqForItems(rfq as unknown as TechSalesRfq) + setItemsDialogOpen(true) + }, []) + const columns = React.useMemo( () => getColumns({ setRowAction, - openAttachmentsSheet + openAttachmentsSheet, + openItemsDialog }), - [openAttachmentsSheet] + [openAttachmentsSheet, openItemsDialog] ) // 고급 필터 필드 정의 @@ -348,13 +317,8 @@ export function RFQListTable({ type: "text", }, { - id: "materialCode", - label: "자재코드", - type: "text", - }, - { - id: "itemName", - label: "자재명", + id: "description", + label: "설명", type: "text", }, { @@ -363,11 +327,6 @@ export function RFQListTable({ type: "text", }, { - id: "ptypeNm", - label: "선종명", - type: "text", - }, - { id: "rfqSendDate", label: "RFQ 전송일", type: "date", @@ -563,6 +522,7 @@ export function RFQListTable({ <RFQTableToolbarActions selection={table} onRefresh={() => {}} + rfqType={rfqType} /> </div> </DataTableAdvancedToolbar> @@ -603,6 +563,13 @@ export function RFQListTable({ rfq={selectedRfqForAttachments} onAttachmentsUpdated={handleAttachmentsUpdated} /> + + {/* 아이템 보기 다이얼로그 */} + <RfqItemsViewDialog + open={itemsDialogOpen} + onOpenChange={setItemsDialogOpen} + rfq={selectedRfqForItems} + /> </div> ) }
\ 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<ReturnType<typeof searchParamsDashboardCache.parse>>; +// 조선 RFQ용 SearchParams +export const searchParamsShipCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<typeof techSalesRfqs>().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<ReturnType<typeof searchParamsShipCache.parse>>; + +// 해양 TOP RFQ용 SearchParams +export const searchParamsTopCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<typeof techSalesRfqs>().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<ReturnType<typeof searchParamsTopCache.parse>>; + +// 해양 HULL RFQ용 SearchParams +export const searchParamsHullCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<typeof techSalesRfqs>().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<ReturnType<typeof searchParamsHullCache.parse>>; + // 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<typeof createTechSalesRfqSchema>; 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,15 +77,10 @@ export function ProjectInfoTab({ quotation }: ProjectInfoTabProps) { <div className="text-sm">{rfq.rfqCode || "미할당"}</div> </div> <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">자재 코드</div> + <div className="text-sm font-medium text-muted-foreground">자재 그룹</div> <div className="text-sm">{rfq.materialCode || "N/A"}</div> </div> <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">자재명</div> - {/* TODO : 타입 작업 (시연을 위해 빌드 중단 상태임. 추후 수정) */} - <div className="text-sm"><strong>{rfq.itemShipbuilding?.itemList || "N/A"}</strong></div> - </div> - <div className="space-y-2"> <div className="text-sm font-medium text-muted-foreground">마감일</div> <div className="text-sm"> {rfq.dueDate ? formatDate(rfq.dueDate) : "N/A"} @@ -164,108 +126,23 @@ export function ProjectInfoTab({ quotation }: ProjectInfoTabProps) { <div className="text-sm font-medium text-muted-foreground">프로젝트명</div> <div className="text-sm">{rfq.biddingProject.projNm || "N/A"}</div> </div> - </div> - </CardContent> - </Card> - )} - - {/* 프로젝트 스냅샷 정보 */} - {projectSnapshot && ( - <Card> - <CardHeader> - <CardTitle>프로젝트 스냅샷</CardTitle> - <CardDescription> - RFQ 생성 시점의 프로젝트 상세 정보 - </CardDescription> - </CardHeader> - <CardContent className="space-y-4"> - <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> - {projectSnapshot.projNo && ( - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">공사번호</div> - <div className="text-sm">{projectSnapshot.projNo}</div> - </div> - )} - {projectSnapshot.projNm && ( - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">공사명</div> - <div className="text-sm">{projectSnapshot.projNm}</div> - </div> - )} - {projectSnapshot.estmPm && ( - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">견적 PM</div> - <div className="text-sm">{projectSnapshot.estmPm}</div> - </div> - )} - {projectSnapshot.kunnrNm && ( - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">선주</div> - <div className="text-sm">{projectSnapshot.kunnrNm}</div> - </div> - )} - {projectSnapshot.cls1Nm && ( - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">선급</div> - <div className="text-sm">{projectSnapshot.cls1Nm}</div> - </div> - )} - {projectSnapshot.projMsrm && ( - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">척수</div> - <div className="text-sm">{projectSnapshot.projMsrm}</div> - </div> - )} - {projectSnapshot.ptypeNm && ( - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">선종</div> - <div className="text-sm">{projectSnapshot.ptypeNm}</div> - </div> - )} - </div> - </CardContent> - </Card> - )} - - {/* 시리즈 스냅샷 정보 */} - {seriesSnapshot && Array.isArray(seriesSnapshot) && seriesSnapshot.length > 0 && ( - <Card> - <CardHeader> - <CardTitle>시리즈 정보 스냅샷</CardTitle> - <CardDescription> - 프로젝트의 시리즈별 K/L 일정 정보 - </CardDescription> - </CardHeader> - <CardContent className="space-y-4"> - {seriesSnapshot.map((series: SeriesSnapshot, index: number) => ( - <div key={index} className="border rounded-lg p-4 space-y-3"> - <div className="flex items-center gap-2"> - <Badge variant="secondary">시리즈 {series.sersNo || index + 1}</Badge> - </div> - <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> - {series.klDt && ( - <div className="space-y-1"> - <div className="text-xs font-medium text-muted-foreground">K/L</div> - <div className="text-sm">{formatDateToQuarter(series.klDt)}</div> - </div> - )} - </div> + <div className="space-y-2"> + <div className="text-sm font-medium text-muted-foreground">프로젝트 섹터</div> + <div className="text-sm">{rfq.biddingProject.sector || "N/A"}</div> + </div> + <div className="space-y-2"> + <div className="text-sm font-medium text-muted-foreground">프로젝트 규모</div> + <div className="text-sm">{rfq.biddingProject.projMsrm || "N/A"}</div> + </div> + <div className="space-y-2"> + <div className="text-sm font-medium text-muted-foreground">프로젝트 타입</div> + <div className="text-sm">{rfq.biddingProject.ptypeNm || "N/A"}</div> </div> - ))} - </CardContent> - </Card> - )} - - {/* 정보가 없는 경우 */} - {!projectSnapshot && !seriesSnapshot && ( - <Card> - <CardContent className="text-center py-8"> - <div className="text-muted-foreground"> - 추가 프로젝트 상세정보가 없습니다. </div> </CardContent> </Card> )} + </div> </ScrollArea> ) 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 <p className="font-mono">{quotation.rfq.rfqCode}</p> </div> <div> - <label className="text-sm font-medium text-muted-foreground">자재 코드</label> + <label className="text-sm font-medium text-muted-foreground">자재 그룹</label> <p>{quotation.rfq.materialCode || "N/A"}</p> </div> <div> 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 ( <div className="mt-2 p-3 bg-blue-50 rounded-md space-y-2 text-sm"> {/* <div className="flex flex-col gap-2"> - <label className="text-xs font-medium text-blue-700">벤더 자재코드</label> + <label className="text-xs font-medium text-blue-700">벤더 자재그룹</label> <Input value={item.vendorMaterialCode || ""} onChange={(e) => handleTextInputChange(index, 'vendorMaterialCode', e)} onBlur={(e) => handleBlur(index, 'vendorMaterialCode', e.target.value)} disabled={disabled || isSaving} className="h-8 text-sm" - placeholder="벤더 자재코드 입력" + placeholder="벤더 자재그룹 입력" /> </div> */} @@ -511,7 +511,7 @@ export function QuotationItemEditor({ <TableHeader className="sticky top-0 bg-background"> <TableRow> <TableHead className="w-[50px]">번호</TableHead> - <TableHead>자재코드</TableHead> + <TableHead>자재그룹</TableHead> <TableHead>자재명</TableHead> <TableHead>수량</TableHead> <TableHead>단위</TableHead> 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<QuotationWithRfqCode>[] { +export function getColumns({ router, openAttachmentsSheet, openItemsDialog }: GetColumnsProps): ColumnDef<QuotationWithRfqCode>[] { return [ { id: "select", @@ -151,7 +145,7 @@ export function getColumns({ router, openAttachmentsSheet }: GetColumnsProps): C // { // accessorKey: "materialCode", // header: ({ column }) => ( - // <DataTableColumnHeaderSimple column={column} title="자재 코드" /> + // <DataTableColumnHeaderSimple column={column} title="자재 그룹" /> // ), // cell: ({ row }) => { // const materialCode = row.getValue("materialCode") as string; @@ -251,6 +245,59 @@ export function getColumns({ router, openAttachmentsSheet }: GetColumnsProps): C // enableHiding: true, // }, { + id: "items", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="아이템" /> + ), + 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 ( + <div className="w-20"> + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="sm" + className="relative h-8 w-8 p-0 group" + onClick={handleClick} + aria-label={`View ${itemCount} items`} + > + <Package className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> + {itemCount > 0 && ( + <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-primary px-1 text-[0.625rem] font-medium leading-none text-primary-foreground"> + {itemCount} + </span> + )} + <span className="sr-only"> + {itemCount > 0 ? `${itemCount} 아이템` : "아이템 없음"} + </span> + </Button> + </TooltipTrigger> + <TooltipContent> + <p>{itemCount > 0 ? `${itemCount}개 아이템 보기` : "아이템 없음"}</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + </div> + ) + }, + enableSorting: false, + enableHiding: true, + }, + { id: "attachments", header: ({ column }) => ( <DataTableColumnHeaderSimple column={column} title="첨부파일" /> 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 ( - <div className="flex flex-col items-center justify-center py-12 space-y-4"> - <div className="relative"> - <div className="w-12 h-12 border-4 border-gray-200 border-t-blue-600 rounded-full animate-spin"></div> - </div> - <div className="text-center space-y-1"> - <p className="text-sm font-medium text-gray-900">데이터를 불러오는 중...</p> - <p className="text-xs text-gray-500">잠시만 기다려주세요.</p> - </div> - </div> - ) -} -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<ExistingTechSalesAttachment[]>([]) + // 아이템 다이얼로그 상태 + 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<QuotationWithRfqCode[]>([]) 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<DataTableFilterField<QuotationWithRfqCode>[]>(() => [ @@ -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} // 벤더 쪽에서는 항상 읽기 전용 /> + + {/* 아이템 보기 다이얼로그 */} + <RfqItemsViewDialog + open={itemsDialogOpen} + onOpenChange={setItemsDialogOpen} + rfq={selectedRfqForItems} + /> </div> ); }
\ No newline at end of file |
