summaryrefslogtreecommitdiff
path: root/lib/techsales-rfq
diff options
context:
space:
mode:
Diffstat (limited to 'lib/techsales-rfq')
-rw-r--r--lib/techsales-rfq/actions.ts44
-rw-r--r--lib/techsales-rfq/repository.ts325
-rw-r--r--lib/techsales-rfq/service.ts1958
-rw-r--r--lib/techsales-rfq/table/create-rfq-hull-dialog.tsx652
-rw-r--r--lib/techsales-rfq/table/create-rfq-ship-dialog.tsx (renamed from lib/techsales-rfq/table/create-rfq-dialog.tsx)353
-rw-r--r--lib/techsales-rfq/table/create-rfq-top-dialog.tsx594
-rw-r--r--lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx271
-rw-r--r--lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx25
-rw-r--r--lib/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx1
-rw-r--r--lib/techsales-rfq/table/project-detail-dialog.tsx202
-rw-r--r--lib/techsales-rfq/table/rfq-filter-sheet.tsx6
-rw-r--r--lib/techsales-rfq/table/rfq-items-view-dialog.tsx198
-rw-r--r--lib/techsales-rfq/table/rfq-table-column.tsx269
-rw-r--r--lib/techsales-rfq/table/rfq-table-toolbar-actions.tsx23
-rw-r--r--lib/techsales-rfq/table/rfq-table.tsx127
-rw-r--r--lib/techsales-rfq/validations.ts73
-rw-r--r--lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx157
-rw-r--r--lib/techsales-rfq/vendor-response/detail/quotation-tabs.tsx40
-rw-r--r--lib/techsales-rfq/vendor-response/quotation-editor.tsx2
-rw-r--r--lib/techsales-rfq/vendor-response/quotation-item-editor.tsx8
-rw-r--r--lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx71
-rw-r--r--lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx56
22 files changed, 3648 insertions, 1807 deletions
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