diff options
Diffstat (limited to 'lib/techsales-rfq')
32 files changed, 15134 insertions, 14103 deletions
diff --git a/lib/techsales-rfq/actions.ts b/lib/techsales-rfq/actions.ts index 5d5d5118..80b831e0 100644 --- a/lib/techsales-rfq/actions.ts +++ b/lib/techsales-rfq/actions.ts @@ -1,31 +1,31 @@ -"use server" - -import { revalidatePath } from "next/cache" -import { - acceptTechSalesVendorQuotation -} from "./service" - -/** - * 기술영업 벤더 견적 승인 (벤더 선택) Server Action - */ -export async function acceptTechSalesVendorQuotationAction(quotationId: number) { - try { - const result = await acceptTechSalesVendorQuotation(quotationId) - - 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 : "벤더 선택에 실패했습니다" - } - } -} +"use server"
+
+import { revalidatePath } from "next/cache"
+import {
+ acceptTechSalesVendorQuotation
+} from "./service"
+
+/**
+ * 기술영업 벤더 견적 승인 (벤더 선택) Server Action
+ */
+export async function acceptTechSalesVendorQuotationAction(quotationId: number) {
+ try {
+ const result = await acceptTechSalesVendorQuotation(quotationId)
+
+ 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 : "벤더 선택에 실패했습니다"
+ }
+ }
+}
diff --git a/lib/techsales-rfq/repository.ts b/lib/techsales-rfq/repository.ts index 1aaf4b3d..07c9ddf8 100644 --- a/lib/techsales-rfq/repository.ts +++ b/lib/techsales-rfq/repository.ts @@ -1,593 +1,611 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ - -import { - techSalesRfqs, - techSalesVendorQuotations, - users, - biddingProjects -} from "@/db/schema"; -import { techVendors } from "@/db/schema/techVendors"; -import { - asc, - desc, count, SQL, sql, eq -} from "drizzle-orm"; -import { PgTransaction } from "drizzle-orm/pg-core"; - - - -export type NewTechSalesRfq = typeof techSalesRfqs.$inferInsert; -/** - * 기술영업 RFQ 생성 - * ID 및 생성일 리턴 - */ -export async function insertTechSalesRfq( - tx: PgTransaction<any, any, any>, - data: NewTechSalesRfq -) { - return tx - .insert(techSalesRfqs) - .values(data) - .returning({ id: techSalesRfqs.id, createdAt: techSalesRfqs.createdAt }); -} - -/** - * 단건/복수 조회 시 공통으로 사용 가능한 SELECT 함수 예시 - * - 트랜잭션(tx)을 받아서 사용하도록 구현 - */ -export async function selectTechSalesRfqs( - tx: PgTransaction<any, any, any>, - params: { - where?: any; - orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[]; - offset?: number; - limit?: number; - } -) { - const { where, orderBy, offset = 0, limit = 10 } = params; - - return tx - .select() - .from(techSalesRfqs) - .where(where ?? undefined) - .orderBy(...(orderBy ?? [])) - .offset(offset) - .limit(limit); -} -/** 총 개수 count */ -export async function countTechSalesRfqs( - tx: PgTransaction<any, any, any>, - where?: any -) { - const res = await tx.select({ count: count() }).from(techSalesRfqs).where(where); - return res[0]?.count ?? 0; -} - - -/** - * 기술영업 RFQ 조회 with 조인 (Repository) - */ -export async function selectTechSalesRfqsWithJoin( - tx: PgTransaction<any, any, 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, rfqType } = options; - - // 별칭 방식을 변경하여 select, join, where, orderBy 등을 순차적으로 수행 - let query = tx.select({ - // RFQ 기본 정보 - id: techSalesRfqs.id, - rfqCode: techSalesRfqs.rfqCode, - rfqType: techSalesRfqs.rfqType, - biddingProjectId: techSalesRfqs.biddingProjectId, - materialCode: techSalesRfqs.materialCode, - - // 날짜 및 상태 정보 - dueDate: techSalesRfqs.dueDate, - rfqSendDate: techSalesRfqs.rfqSendDate, - status: techSalesRfqs.status, - - // 담당자 및 비고 - picCode: techSalesRfqs.picCode, - remark: techSalesRfqs.remark, - cancelReason: techSalesRfqs.cancelReason, - description: techSalesRfqs.description, - - // 생성/수정 정보 - createdAt: techSalesRfqs.createdAt, - updatedAt: techSalesRfqs.updatedAt, - - // 사용자 정보 - createdBy: techSalesRfqs.createdBy, - createdByName: sql<string>`created_user.name`, - updatedBy: techSalesRfqs.updatedBy, - updatedByName: sql<string>`updated_user.name`, - sentBy: techSalesRfqs.sentBy, - sentByName: sql<string | null>`sent_user.name`, - - // 프로젝트 정보 (조인) - pspid: biddingProjects.pspid, - projNm: biddingProjects.projNm, - sector: biddingProjects.sector, - projMsrm: biddingProjects.projMsrm, - ptypeNm: biddingProjects.ptypeNm, - - // 첨부파일 개수 (타입별로 분리) - attachmentCount: sql<number>`( - SELECT COUNT(*) - FROM tech_sales_attachments - WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id} - AND tech_sales_attachments.attachment_type = 'RFQ_COMMON' - )`, - hasTbeAttachments: sql<boolean>`( - SELECT CASE WHEN COUNT(*) > 0 THEN TRUE ELSE FALSE END - FROM tech_sales_attachments - WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id} - AND tech_sales_attachments.attachment_type = 'TBE_RESULT' - )`, - hasCbeAttachments: sql<boolean>`( - SELECT CASE WHEN COUNT(*) > 0 THEN TRUE ELSE FALSE END - FROM tech_sales_attachments - WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id} - AND tech_sales_attachments.attachment_type = 'CBE_RESULT' - )`, - - // 벤더 견적 개수 - quotationCount: sql<number>`( - SELECT COUNT(*) - 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(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`) - -; - - // 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 - ? query.orderBy(...orderBy) - : query.orderBy(desc(techSalesRfqs.createdAt)); - - // offset과 limit 적용 후 실행 - return queryWithOrderBy.offset(offset).limit(limit); -} - -/** - * RFQ 개수 직접 조회 (뷰 대신 테이블 조인 사용) - */ -export async function countTechSalesRfqsWithJoin( - tx: PgTransaction<any, any, 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) - .where(finalWhere); - return res[0]?.count ?? 0; -} - -/** - * 벤더 견적서 직접 조인 조회 (뷰 대신 테이블 조인 사용) - */ -export async function selectTechSalesVendorQuotationsWithJoin( - tx: PgTransaction<any, any, any>, - params: { - where?: any; - orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[]; - offset?: number; - limit?: number; - rfqType?: "SHIP" | "TOP" | "HULL"; - } -) { - const { where, orderBy, offset = 0, limit = 10, rfqType } = params; - - // 별칭 방식을 변경하여 select, join, where, orderBy 등을 순차적으로 수행 - let query = tx.select({ - // 견적 기본 정보 - id: techSalesVendorQuotations.id, - rfqId: techSalesVendorQuotations.rfqId, - rfqCode: techSalesRfqs.rfqCode, - rfqType: techSalesRfqs.rfqType, - vendorId: techSalesVendorQuotations.vendorId, - vendorName: techVendors.vendorName, - vendorCode: techVendors.vendorCode, - - // 견적 상세 정보 - 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, - createdByName: sql<string | null>`created_user.name`, - updatedBy: techSalesVendorQuotations.updatedBy, - updatedByName: sql<string | null>`updated_user.name`, - - // 프로젝트 정보 - materialCode: techSalesRfqs.materialCode, - - // 프로젝트 핵심 정보 - null 체크 추가 - pspid: techSalesRfqs.biddingProjectId, - projNm: biddingProjects.projNm, - sector: biddingProjects.sector, - - // 첨부파일 개수 - attachmentCount: sql<number>`( - SELECT COUNT(*) - FROM tech_sales_attachments - WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id} - )`, - - // 견적서 첨부파일 개수 - quotationAttachmentCount: sql<number>`( - SELECT COUNT(*) - FROM tech_sales_vendor_quotation_attachments - WHERE tech_sales_vendor_quotation_attachments.quotation_id = ${techSalesVendorQuotations.id} - )`, - - // RFQ 아이템 개수 - itemCount: sql<number>`( - SELECT COUNT(*) - FROM tech_sales_rfq_items - WHERE tech_sales_rfq_items.rfq_id = ${techSalesRfqs.id} - )`, - - }) - .from(techSalesVendorQuotations) - .leftJoin(techSalesRfqs, sql`${techSalesVendorQuotations.rfqId} = ${techSalesRfqs.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`); - - // 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 - ? query.orderBy(...orderBy) - : query.orderBy(desc(techSalesVendorQuotations.createdAt)); - - // offset과 limit 적용 후 실행 - return queryWithOrderBy.offset(offset).limit(limit); -} - -/** - * 벤더 견적서 개수 직접 조회 (뷰 대신 테이블 조인 사용) - */ -export async function countTechSalesVendorQuotationsWithJoin( - tx: PgTransaction<any, any, 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(techVendors, sql`${techSalesVendorQuotations.vendorId} = ${techVendors.id}`) - .where(finalWhere); - return res[0]?.count ?? 0; -} - -/** - * RFQ 대시보드 데이터 직접 조인 조회 (뷰 대신 테이블 조인 사용) - */ -export async function selectTechSalesDashboardWithJoin( - tx: PgTransaction<any, any, any>, - params: { - where?: any; - orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc> | SQL<unknown>)[]; - offset?: number; - limit?: number; - rfqType?: "SHIP" | "TOP" | "HULL"; - } -) { - const { where, orderBy, offset = 0, limit = 10, rfqType } = params; - - // 별칭 방식을 변경하여 select, join, where, orderBy 등을 순차적으로 수행 - 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, - - // 프로젝트 정보 - null 체크 추가 - pspid: techSalesRfqs.biddingProjectId, - projNm: biddingProjects.projNm, - sector: biddingProjects.sector, - projMsrm: biddingProjects.projMsrm, - ptypeNm: biddingProjects.ptypeNm, - - // 벤더 견적 통계 - vendorCount: sql<number>`( - SELECT COUNT(DISTINCT vendor_id) - FROM tech_sales_vendor_quotations - WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id} - )`, - - quotationCount: sql<number>`( - SELECT COUNT(*) - FROM tech_sales_vendor_quotations - WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id} - )`, - - submittedQuotationCount: sql<number>`( - SELECT COUNT(*) - FROM tech_sales_vendor_quotations - WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id} - AND tech_sales_vendor_quotations.status = 'Submitted' - )`, - - minPrice: sql<string | null>`( - SELECT MIN(total_price) - FROM tech_sales_vendor_quotations - WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id} - AND tech_sales_vendor_quotations.status = 'Submitted' - )`, - - maxPrice: sql<string | null>`( - SELECT MAX(total_price) - FROM tech_sales_vendor_quotations - WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id} - AND tech_sales_vendor_quotations.status = 'Submitted' - )`, - - avgPrice: sql<string | null>`( - SELECT AVG(total_price) - FROM tech_sales_vendor_quotations - WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id} - AND tech_sales_vendor_quotations.status = 'Submitted' - )`, - - // 첨부파일 통계 - attachmentCount: sql<number>`( - SELECT COUNT(*) - FROM tech_sales_attachments - WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id} - )`, - - // 코멘트 통계 - commentCount: sql<number>`( - SELECT COUNT(*) - FROM tech_sales_rfq_comments - WHERE tech_sales_rfq_comments.rfq_id = ${techSalesRfqs.id} - )`, - - unreadCommentCount: sql<number>`( - SELECT COUNT(*) - FROM tech_sales_rfq_comments - WHERE tech_sales_rfq_comments.rfq_id = ${techSalesRfqs.id} - AND tech_sales_rfq_comments.is_read = false - )`, - - // 생성/수정 정보 - 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(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 - ? 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; -} - +/* eslint-disable @typescript-eslint/no-explicit-any */
+
+import {
+ techSalesRfqs,
+ techSalesVendorQuotations,
+ users,
+ biddingProjects
+} from "@/db/schema";
+import { techVendors } from "@/db/schema/techVendors";
+import {
+ asc,
+ desc, count, SQL, sql, eq
+} from "drizzle-orm";
+import { PgTransaction } from "drizzle-orm/pg-core";
+
+
+
+export type NewTechSalesRfq = typeof techSalesRfqs.$inferInsert;
+/**
+ * 기술영업 RFQ 생성
+ * ID 및 생성일 리턴
+ */
+export async function insertTechSalesRfq(
+ tx: PgTransaction<any, any, any>,
+ data: NewTechSalesRfq
+) {
+ return tx
+ .insert(techSalesRfqs)
+ .values(data)
+ .returning({ id: techSalesRfqs.id, createdAt: techSalesRfqs.createdAt });
+}
+
+/**
+ * 단건/복수 조회 시 공통으로 사용 가능한 SELECT 함수 예시
+ * - 트랜잭션(tx)을 받아서 사용하도록 구현
+ */
+export async function selectTechSalesRfqs(
+ tx: PgTransaction<any, any, any>,
+ params: {
+ where?: any;
+ orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[];
+ offset?: number;
+ limit?: number;
+ }
+) {
+ const { where, orderBy, offset = 0, limit = 10 } = params;
+
+ return tx
+ .select()
+ .from(techSalesRfqs)
+ .where(where ?? undefined)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset)
+ .limit(limit);
+}
+/** 총 개수 count */
+export async function countTechSalesRfqs(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+) {
+ const res = await tx.select({ count: count() }).from(techSalesRfqs).where(where);
+ return res[0]?.count ?? 0;
+}
+
+
+/**
+ * 기술영업 RFQ 조회 with 조인 (Repository)
+ */
+export async function selectTechSalesRfqsWithJoin(
+ tx: PgTransaction<any, any, 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, rfqType } = options;
+
+ // 별칭 방식을 변경하여 select, join, where, orderBy 등을 순차적으로 수행
+ let query = tx.select({
+ // RFQ 기본 정보
+ id: techSalesRfqs.id,
+ rfqCode: techSalesRfqs.rfqCode,
+ rfqType: techSalesRfqs.rfqType,
+ biddingProjectId: techSalesRfqs.biddingProjectId,
+ materialCode: techSalesRfqs.materialCode,
+
+ // 날짜 및 상태 정보
+ dueDate: techSalesRfqs.dueDate,
+ rfqSendDate: techSalesRfqs.rfqSendDate,
+ status: techSalesRfqs.status,
+
+ // 담당자 및 비고
+ picCode: techSalesRfqs.picCode,
+ remark: techSalesRfqs.remark,
+ cancelReason: techSalesRfqs.cancelReason,
+ description: techSalesRfqs.description,
+
+ // 생성/수정 정보
+ createdAt: techSalesRfqs.createdAt,
+ updatedAt: techSalesRfqs.updatedAt,
+
+ // 사용자 정보
+ createdBy: techSalesRfqs.createdBy,
+ createdByName: sql<string>`created_user.name`,
+ updatedBy: techSalesRfqs.updatedBy,
+ updatedByName: sql<string>`updated_user.name`,
+ sentBy: techSalesRfqs.sentBy,
+ sentByName: sql<string | null>`sent_user.name`,
+
+ // 프로젝트 정보 (조인)
+ pspid: biddingProjects.pspid,
+ projNm: biddingProjects.projNm,
+ sector: biddingProjects.sector,
+ projMsrm: biddingProjects.projMsrm,
+ ptypeNm: biddingProjects.ptypeNm,
+
+ // 첨부파일 개수 (타입별로 분리)
+ attachmentCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM tech_sales_attachments
+ WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id}
+ AND tech_sales_attachments.attachment_type = 'RFQ_COMMON'
+ )`,
+ hasTbeAttachments: sql<boolean>`(
+ SELECT CASE WHEN COUNT(*) > 0 THEN TRUE ELSE FALSE END
+ FROM tech_sales_attachments
+ WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id}
+ AND tech_sales_attachments.attachment_type = 'TBE_RESULT'
+ )`,
+ hasCbeAttachments: sql<boolean>`(
+ SELECT CASE WHEN COUNT(*) > 0 THEN TRUE ELSE FALSE END
+ FROM tech_sales_attachments
+ WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id}
+ AND tech_sales_attachments.attachment_type = 'CBE_RESULT'
+ )`,
+
+ // 벤더 견적 개수
+ quotationCount: sql<number>`(
+ SELECT COUNT(*)
+ 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}
+ )`,
+
+ // WorkTypes aggregation - RFQ에 연결된 모든 아이템들의 workType을 콤마로 구분하여 반환
+ workTypes: sql<string>`(
+ SELECT STRING_AGG(DISTINCT
+ CASE
+ WHEN tri.item_type = 'SHIP' THEN ship.work_type
+ WHEN tri.item_type = 'TOP' THEN top.work_type
+ WHEN tri.item_type = 'HULL' THEN hull.work_type
+ ELSE NULL
+ END, ', '
+ )
+ FROM tech_sales_rfq_items tri
+ LEFT JOIN item_shipbuilding ship ON tri.item_shipbuilding_id = ship.id AND tri.item_type = 'SHIP'
+ LEFT JOIN item_offshore_top top ON tri.item_offshore_top_id = top.id AND tri.item_type = 'TOP'
+ LEFT JOIN item_offshore_hull hull ON tri.item_offshore_hull_id = hull.id AND tri.item_type = 'HULL'
+ WHERE tri.rfq_id = ${techSalesRfqs.id}
+ AND (ship.work_type IS NOT NULL OR top.work_type IS NOT NULL OR hull.work_type IS NOT NULL)
+ )`,
+ })
+ .from(techSalesRfqs)
+
+ // 프로젝트 정보 조인 추가
+ .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`)
+
+;
+
+ // 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
+ ? query.orderBy(...orderBy)
+ : query.orderBy(desc(techSalesRfqs.createdAt));
+
+ // offset과 limit 적용 후 실행
+ return queryWithOrderBy.offset(offset).limit(limit);
+}
+
+/**
+ * RFQ 개수 직접 조회 (뷰 대신 테이블 조인 사용)
+ */
+export async function countTechSalesRfqsWithJoin(
+ tx: PgTransaction<any, any, 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)
+ .where(finalWhere);
+ return res[0]?.count ?? 0;
+}
+
+/**
+ * 벤더 견적서 직접 조인 조회 (뷰 대신 테이블 조인 사용)
+ */
+export async function selectTechSalesVendorQuotationsWithJoin(
+ tx: PgTransaction<any, any, any>,
+ params: {
+ where?: any;
+ orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[];
+ offset?: number;
+ limit?: number;
+ rfqType?: "SHIP" | "TOP" | "HULL";
+ }
+) {
+ const { where, orderBy, offset = 0, limit = 10, rfqType } = params;
+
+ // 별칭 방식을 변경하여 select, join, where, orderBy 등을 순차적으로 수행
+ let query = tx.select({
+ // 견적 기본 정보
+ id: techSalesVendorQuotations.id,
+ rfqId: techSalesVendorQuotations.rfqId,
+ rfqCode: techSalesRfqs.rfqCode,
+ rfqType: techSalesRfqs.rfqType,
+ vendorId: techSalesVendorQuotations.vendorId,
+ vendorName: techVendors.vendorName,
+ vendorCode: techVendors.vendorCode,
+
+ // 견적 상세 정보
+ 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,
+ createdByName: sql<string | null>`created_user.name`,
+ updatedBy: techSalesVendorQuotations.updatedBy,
+ updatedByName: sql<string | null>`updated_user.name`,
+
+ // 프로젝트 정보
+ materialCode: techSalesRfqs.materialCode,
+
+ // 프로젝트 핵심 정보 - null 체크 추가
+ pspid: techSalesRfqs.biddingProjectId,
+ projNm: biddingProjects.projNm,
+ sector: biddingProjects.sector,
+
+ // 첨부파일 개수
+ attachmentCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM tech_sales_attachments
+ WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id}
+ )`,
+
+ // 견적서 첨부파일 개수
+ quotationAttachmentCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM tech_sales_vendor_quotation_attachments
+ WHERE tech_sales_vendor_quotation_attachments.quotation_id = ${techSalesVendorQuotations.id}
+ )`,
+
+ // RFQ 아이템 개수
+ itemCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM tech_sales_rfq_items
+ WHERE tech_sales_rfq_items.rfq_id = ${techSalesRfqs.id}
+ )`,
+
+ })
+ .from(techSalesVendorQuotations)
+ .leftJoin(techSalesRfqs, sql`${techSalesVendorQuotations.rfqId} = ${techSalesRfqs.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`);
+
+ // 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
+ ? query.orderBy(...orderBy)
+ : query.orderBy(desc(techSalesVendorQuotations.createdAt));
+
+ // offset과 limit 적용 후 실행
+ return queryWithOrderBy.offset(offset).limit(limit);
+}
+
+/**
+ * 벤더 견적서 개수 직접 조회 (뷰 대신 테이블 조인 사용)
+ */
+export async function countTechSalesVendorQuotationsWithJoin(
+ tx: PgTransaction<any, any, 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(techVendors, sql`${techSalesVendorQuotations.vendorId} = ${techVendors.id}`)
+ .where(finalWhere);
+ return res[0]?.count ?? 0;
+}
+
+/**
+ * RFQ 대시보드 데이터 직접 조인 조회 (뷰 대신 테이블 조인 사용)
+ */
+export async function selectTechSalesDashboardWithJoin(
+ tx: PgTransaction<any, any, any>,
+ params: {
+ where?: any;
+ orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc> | SQL<unknown>)[];
+ offset?: number;
+ limit?: number;
+ rfqType?: "SHIP" | "TOP" | "HULL";
+ }
+) {
+ const { where, orderBy, offset = 0, limit = 10, rfqType } = params;
+
+ // 별칭 방식을 변경하여 select, join, where, orderBy 등을 순차적으로 수행
+ 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,
+
+ // 프로젝트 정보 - null 체크 추가
+ pspid: techSalesRfqs.biddingProjectId,
+ projNm: biddingProjects.projNm,
+ sector: biddingProjects.sector,
+ projMsrm: biddingProjects.projMsrm,
+ ptypeNm: biddingProjects.ptypeNm,
+
+ // 벤더 견적 통계
+ vendorCount: sql<number>`(
+ SELECT COUNT(DISTINCT vendor_id)
+ FROM tech_sales_vendor_quotations
+ WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id}
+ )`,
+
+ quotationCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM tech_sales_vendor_quotations
+ WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id}
+ )`,
+
+ submittedQuotationCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM tech_sales_vendor_quotations
+ WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id}
+ AND tech_sales_vendor_quotations.status = 'Submitted'
+ )`,
+
+ minPrice: sql<string | null>`(
+ SELECT MIN(total_price)
+ FROM tech_sales_vendor_quotations
+ WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id}
+ AND tech_sales_vendor_quotations.status = 'Submitted'
+ )`,
+
+ maxPrice: sql<string | null>`(
+ SELECT MAX(total_price)
+ FROM tech_sales_vendor_quotations
+ WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id}
+ AND tech_sales_vendor_quotations.status = 'Submitted'
+ )`,
+
+ avgPrice: sql<string | null>`(
+ SELECT AVG(total_price)
+ FROM tech_sales_vendor_quotations
+ WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id}
+ AND tech_sales_vendor_quotations.status = 'Submitted'
+ )`,
+
+ // 첨부파일 통계
+ attachmentCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM tech_sales_attachments
+ WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id}
+ )`,
+
+ // 코멘트 통계
+ commentCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM tech_sales_rfq_comments
+ WHERE tech_sales_rfq_comments.rfq_id = ${techSalesRfqs.id}
+ )`,
+
+ unreadCommentCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM tech_sales_rfq_comments
+ WHERE tech_sales_rfq_comments.rfq_id = ${techSalesRfqs.id}
+ AND tech_sales_rfq_comments.is_read = false
+ )`,
+
+ // 생성/수정 정보
+ 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(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
+ ? 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 c991aa42..fd50b7a6 100644 --- a/lib/techsales-rfq/service.ts +++ b/lib/techsales-rfq/service.ts @@ -1,3416 +1,3698 @@ -'use server' - -import { unstable_noStore, revalidateTag, revalidatePath } from "next/cache"; -import db from "@/db/db"; -import { - techSalesRfqs, - techSalesVendorQuotations, - techSalesVendorQuotationRevisions, - techSalesAttachments, - techSalesVendorQuotationAttachments, - users, - techSalesRfqComments, - techSalesRfqItems, - biddingProjects -} from "@/db/schema"; -import { and, desc, eq, ilike, or, sql, inArray, count, asc } from "drizzle-orm"; -import { unstable_cache } from "@/lib/unstable-cache"; -import { filterColumns } from "@/lib/filter-columns"; -import { getErrorMessage } from "@/lib/handle-error"; -import type { Filter } from "@/types/table"; -import { - selectTechSalesRfqsWithJoin, - countTechSalesRfqsWithJoin, - selectTechSalesVendorQuotationsWithJoin, - countTechSalesVendorQuotationsWithJoin, - 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 } from "../utils"; -import { techVendors, techVendorPossibleItems } from "@/db/schema/techVendors"; -import { decryptWithServerAction } from "@/components/drm/drmUtils"; -import { deleteFile, saveDRMFile } from "../file-stroage"; - -// 정렬 타입 정의 -// 의도적으로 any 사용 - drizzle ORM의 orderBy 타입이 복잡함 -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type OrderByType = any; - -export type Project = { - id: number; - projectCode: string; - projectName: string; - pjtType: "SHIP" | "TOP" | "HULL"; -} - -/** - * 연도별 순차 RFQ 코드 생성 함수 (다중 생성 지원) - * 형식: RFQ-YYYY-001, RFQ-YYYY-002, ... - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -async function generateRfqCodes(tx: any, count: number, year?: number): Promise<string[]> { - const currentYear = year || new Date().getFullYear(); - const yearPrefix = `RFQ-${currentYear}-`; - - // 해당 연도의 가장 최근 RFQ 코드 조회 - const latestRfq = await tx - .select({ rfqCode: techSalesRfqs.rfqCode }) - .from(techSalesRfqs) - .where(ilike(techSalesRfqs.rfqCode, `${yearPrefix}%`)) - .orderBy(desc(techSalesRfqs.rfqCode)) - .limit(1); - - let nextNumber = 1; - - if (latestRfq.length > 0) { - // 기존 코드에서 번호 추출 (RFQ-2024-001 -> 001) - const lastCode = latestRfq[0].rfqCode; - const numberPart = lastCode.split('-').pop(); - if (numberPart) { - const lastNumber = parseInt(numberPart, 10); - if (!isNaN(lastNumber)) { - nextNumber = lastNumber + 1; - } - } - } - - // 요청된 개수만큼 순차적으로 코드 생성 - const codes: string[] = []; - for (let i = 0; i < count; i++) { - const paddedNumber = (nextNumber + i).toString().padStart(3, '0'); - codes.push(`${yearPrefix}${paddedNumber}`); - } - - return codes; -} - - -/** - * 직접 조인을 사용하여 RFQ 데이터 조회하는 함수 - * 페이지네이션, 필터링, 정렬 등 지원 - */ -export async function getTechSalesRfqsWithJoin(input: GetTechSalesRfqsSchema & { rfqType?: "SHIP" | "TOP" | "HULL" }) { - return unstable_cache( - async () => { - try { - const offset = (input.page - 1) * input.perPage; - - // 기본 필터 처리 - RFQFilterBox에서 오는 필터 - const basicFilters = input.basicFilters || []; - const basicJoinOperator = input.basicJoinOperator || "and"; - - // 고급 필터 처리 - 테이블의 DataTableFilterList에서 오는 필터 - const advancedFilters = input.filters || []; - const advancedJoinOperator = input.joinOperator || "and"; - - // 기본 필터 조건 생성 - let basicWhere; - if (basicFilters.length > 0) { - basicWhere = filterColumns({ - table: techSalesRfqs, - filters: basicFilters, - joinOperator: basicJoinOperator, - }); - } - - // 고급 필터 조건 생성 - let advancedWhere; - if (advancedFilters.length > 0) { - advancedWhere = filterColumns({ - table: techSalesRfqs, - filters: advancedFilters, - joinOperator: advancedJoinOperator, - }); - } - - // 전역 검색 조건 - let globalWhere; - if (input.search) { - const s = `%${input.search}%`; - globalWhere = or( - ilike(techSalesRfqs.rfqCode, s), - ilike(techSalesRfqs.materialCode, s), - ilike(techSalesRfqs.description, s), - ilike(techSalesRfqs.remark, s) - ); - } - - // 모든 조건 결합 - const whereConditions = []; - if (basicWhere) whereConditions.push(basicWhere); - if (advancedWhere) whereConditions.push(advancedWhere); - if (globalWhere) whereConditions.push(globalWhere); - - // 조건이 있을 때만 and() 사용 - const finalWhere = whereConditions.length > 0 - ? and(...whereConditions) - : undefined; - - // 정렬 기준 설정 - let orderBy: OrderByType[] = [desc(techSalesRfqs.createdAt)]; // 기본 정렬 - - if (input.sort?.length) { - // 안전하게 접근하여 정렬 기준 설정 - orderBy = input.sort.map(item => { - // TypeScript 에러 방지를 위한 타입 단언 - const sortField = item.id as string; - - switch (sortField) { - case 'id': - return item.desc ? desc(techSalesRfqs.id) : techSalesRfqs.id; - case 'rfqCode': - return item.desc ? desc(techSalesRfqs.rfqCode) : techSalesRfqs.rfqCode; - case 'materialCode': - return item.desc ? desc(techSalesRfqs.materialCode) : techSalesRfqs.materialCode; - 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 'remark': - return item.desc ? desc(techSalesRfqs.remark) : techSalesRfqs.remark; - case 'createdAt': - return item.desc ? desc(techSalesRfqs.createdAt) : techSalesRfqs.createdAt; - case 'updatedAt': - return item.desc ? desc(techSalesRfqs.updatedAt) : techSalesRfqs.updatedAt; - default: - return item.desc ? desc(techSalesRfqs.createdAt) : techSalesRfqs.createdAt; - } - }); - } - - // 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) }; - }); - } catch (err) { - console.error("Error fetching RFQs with join:", err); - return { data: [], pageCount: 0, total: 0 }; - } - }, - [JSON.stringify(input)], - { - revalidate: 60, - tags: ["techSalesRfqs"], - } - )(); -} - -/** - * 직접 조인을 사용하여 벤더 견적서 조회하는 함수 - */ -export async function getTechSalesVendorQuotationsWithJoin(input: { - rfqId?: number; - vendorId?: number; - search?: string; - filters?: Filter<typeof techSalesVendorQuotations>[]; - sort?: { id: string; desc: boolean }[]; - page: number; - perPage: number; - rfqType?: "SHIP" | "TOP" | "HULL"; // rfqType 매개변수 추가 -}) { - return unstable_cache( - async () => { - try { - const offset = (input.page - 1) * input.perPage; - - // 기본 필터 조건들 - const whereConditions = []; - - // RFQ ID 필터 - if (input.rfqId) { - whereConditions.push(eq(techSalesVendorQuotations.rfqId, input.rfqId)); - } - - // 벤더 ID 필터 - if (input.vendorId) { - whereConditions.push(eq(techSalesVendorQuotations.vendorId, input.vendorId)); - } - - // 검색 조건 - if (input.search) { - const s = `%${input.search}%`; - const searchCondition = or( - ilike(techSalesVendorQuotations.currency, s), - ilike(techSalesVendorQuotations.status, s) - ); - if (searchCondition) { - whereConditions.push(searchCondition); - } - } - - // 고급 필터 처리 - if (input.filters && input.filters.length > 0) { - const filterWhere = filterColumns({ - table: techSalesVendorQuotations, - filters: input.filters as Filter<typeof techSalesVendorQuotations>[], - joinOperator: "and", - }); - if (filterWhere) { - whereConditions.push(filterWhere); - } - } - - // 최종 WHERE 조건 - const finalWhere = whereConditions.length > 0 - ? and(...whereConditions) - : undefined; - - // 정렬 기준 설정 - let orderBy: OrderByType[] = [desc(techSalesVendorQuotations.createdAt)]; - - if (input.sort?.length) { - orderBy = input.sort.map(item => { - switch (item.id) { - case 'id': - return item.desc ? desc(techSalesVendorQuotations.id) : techSalesVendorQuotations.id; - case 'status': - return item.desc ? desc(techSalesVendorQuotations.status) : techSalesVendorQuotations.status; - case 'currency': - return item.desc ? desc(techSalesVendorQuotations.currency) : techSalesVendorQuotations.currency; - case 'totalPrice': - return item.desc ? desc(techSalesVendorQuotations.totalPrice) : techSalesVendorQuotations.totalPrice; - case 'createdAt': - return item.desc ? desc(techSalesVendorQuotations.createdAt) : techSalesVendorQuotations.createdAt; - case 'updatedAt': - return item.desc ? desc(techSalesVendorQuotations.updatedAt) : techSalesVendorQuotations.updatedAt; - default: - return item.desc ? desc(techSalesVendorQuotations.createdAt) : techSalesVendorQuotations.createdAt; - } - }); - } - - // 트랜잭션 내부에서 Repository 호출 - const { data, total } = await db.transaction(async (tx) => { - const data = await selectTechSalesVendorQuotationsWithJoin(tx, { - where: finalWhere, - orderBy, - offset, - limit: input.perPage, - }); - - // 각 견적서의 첨부파일 정보 조회 - const dataWithAttachments = await Promise.all( - data.map(async (quotation) => { - const attachments = await db.query.techSalesVendorQuotationAttachments.findMany({ - where: eq(techSalesVendorQuotationAttachments.quotationId, quotation.id), - orderBy: [desc(techSalesVendorQuotationAttachments.createdAt)], - }); - - return { - ...quotation, - quotationAttachments: attachments.map(att => ({ - id: att.id, - fileName: att.fileName, - fileSize: att.fileSize, - filePath: att.filePath, - description: att.description, - })) - }; - }) - ); - - const total = await countTechSalesVendorQuotationsWithJoin(tx, finalWhere); - return { data: dataWithAttachments, total }; - }); - - const pageCount = Math.ceil(total / input.perPage); - - return { data, pageCount, total }; - } catch (err) { - console.error("Error fetching vendor quotations with join:", err); - return { data: [], pageCount: 0, total: 0 }; - } - }, - [JSON.stringify(input)], - { - revalidate: 60, - tags: [ - "techSalesVendorQuotations", - ...(input.rfqId ? [`techSalesRfq-${input.rfqId}`] : []) - ], - } - )(); -} - -/** - * 직접 조인을 사용하여 RFQ 대시보드 데이터 조회하는 함수 - */ -export async function getTechSalesDashboardWithJoin(input: { - search?: string; - filters?: Filter<typeof techSalesRfqs>[]; - sort?: { id: string; desc: boolean }[]; - page: number; - perPage: number; - rfqType?: "SHIP" | "TOP" | "HULL"; // rfqType 매개변수 추가 -}) { - unstable_noStore(); // 대시보드는 항상 최신 데이터를 보여주기 위해 캐시하지 않음 - - try { - const offset = (input.page - 1) * input.perPage; - - // Advanced filtering - const advancedWhere = input.filters ? filterColumns({ - table: techSalesRfqs, - filters: input.filters as Filter<typeof techSalesRfqs>[], - joinOperator: 'and', - }) : undefined; - - // Global search - let globalWhere; - if (input.search) { - const s = `%${input.search}%`; - globalWhere = or( - ilike(techSalesRfqs.rfqCode, s), - ilike(techSalesRfqs.materialCode, s), - ilike(techSalesRfqs.description, s) - ); - } - - const finalWhere = and( - advancedWhere, - globalWhere - ); - - // 정렬 기준 설정 - let orderBy: OrderByType[] = [desc(techSalesRfqs.updatedAt)]; // 기본 정렬 - - if (input.sort?.length) { - // 안전하게 접근하여 정렬 기준 설정 - orderBy = input.sort.map(item => { - switch (item.id) { - case 'id': - return item.desc ? desc(techSalesRfqs.id) : techSalesRfqs.id; - case 'rfqCode': - return item.desc ? desc(techSalesRfqs.rfqCode) : techSalesRfqs.rfqCode; - case 'status': - return item.desc ? desc(techSalesRfqs.status) : techSalesRfqs.status; - case 'dueDate': - return item.desc ? desc(techSalesRfqs.dueDate) : techSalesRfqs.dueDate; - case 'createdAt': - return item.desc ? desc(techSalesRfqs.createdAt) : techSalesRfqs.createdAt; - case 'updatedAt': - return item.desc ? desc(techSalesRfqs.updatedAt) : techSalesRfqs.updatedAt; - default: - return item.desc ? desc(techSalesRfqs.updatedAt) : techSalesRfqs.updatedAt; - } - }); - } - - // 트랜잭션 내부에서 Repository 호출 - const data = await db.transaction(async (tx) => { - return await selectTechSalesDashboardWithJoin(tx, { - where: finalWhere, - orderBy, - offset, - limit: input.perPage, - rfqType: input.rfqType, // rfqType 매개변수 추가 - }); - }); - - return { data, success: true }; - } catch (err) { - console.error("Error fetching dashboard data with join:", err); - return { data: [], success: false, error: getErrorMessage(err) }; - } -} - -/** - * 특정 RFQ의 벤더 목록 조회 - */ -export async function getTechSalesRfqVendors(rfqId: number) { - unstable_noStore(); - try { - // Repository 함수를 사용하여 벤더 견적 목록 조회 - const result = await getTechSalesVendorQuotationsWithJoin({ - rfqId, - page: 1, - perPage: 1000, // 충분히 큰 수로 설정하여 모든 벤더 조회 - }); - - return { data: result.data, error: null }; - } catch (err) { - console.error("Error fetching RFQ vendors:", err); - return { data: [], error: getErrorMessage(err) }; - } -} - -/** - * 기술영업 RFQ 발송 (선택된 벤더들에게) - */ -export async function sendTechSalesRfqToVendors(input: { - rfqId: number; - vendorIds: number[]; -}) { - unstable_noStore(); - try { - // 인증 확인 - const session = await getServerSession(authOptions); - - if (!session?.user) { - return { - success: false, - message: "인증이 필요합니다", - }; - } - - // RFQ 정보 조회 - const rfq = await db.query.techSalesRfqs.findFirst({ - where: eq(techSalesRfqs.id, input.rfqId), - columns: { - id: true, - rfqCode: true, - status: true, - dueDate: true, - rfqSendDate: true, - remark: true, - materialCode: true, - description: true, - rfqType: true, - }, - with: { - biddingProject: true, - createdByUser: { - columns: { - id: true, - name: true, - email: true, - } - } - } - }); - - if (!rfq) { - return { - success: false, - message: "RFQ를 찾을 수 없습니다", - }; - } - - // 발송 가능한 상태인지 확인 - if (rfq.status !== "RFQ Vendor Assignned" && rfq.status !== "RFQ Sent") { - return { - success: false, - message: "벤더가 할당된 RFQ 또는 이미 전송된 RFQ만 다시 전송할 수 있습니다", - }; - } - - const isResend = rfq.status === "RFQ Sent"; - - // 현재 사용자 정보 조회 - const sender = await db.query.users.findFirst({ - where: eq(users.id, Number(session.user.id)), - columns: { - id: true, - email: true, - name: true, - } - }); - - if (!sender || !sender.email) { - return { - success: false, - message: "보내는 사람의 이메일 정보를 찾을 수 없습니다", - }; - } - - // 선택된 벤더들의 견적서 정보 조회 - const vendorQuotations = await db.query.techSalesVendorQuotations.findMany({ - where: and( - eq(techSalesVendorQuotations.rfqId, input.rfqId), - inArray(techSalesVendorQuotations.vendorId, input.vendorIds) - ), - columns: { - id: true, - vendorId: true, - status: true, - currency: true, - }, - with: { - vendor: { - columns: { - id: true, - vendorName: true, - vendorCode: true, - } - } - } - }); - - if (vendorQuotations.length === 0) { - return { - success: false, - message: "선택된 벤더가 이 RFQ에 할당되어 있지 않습니다", - }; - } - - // 트랜잭션 시작 - await db.transaction(async (tx) => { - // 1. RFQ 상태 업데이트 (최초 발송인 경우 rfqSendDate 설정) - const updateData: Partial<typeof techSalesRfqs.$inferInsert> = { - status: "RFQ Sent", - sentBy: Number(session.user.id), - updatedBy: Number(session.user.id), - updatedAt: new Date(), - }; - - // rfqSendDate가 null인 경우에만 최초 전송일 설정 - if (!rfq.rfqSendDate) { - updateData.rfqSendDate = new Date(); - } - - await tx.update(techSalesRfqs) - .set(updateData) - .where(eq(techSalesRfqs.id, input.rfqId)); - - // 2. 선택된 벤더들의 견적서 상태를 "Assigned"에서 "Draft"로 변경 - for (const quotation of vendorQuotations) { - if (quotation.status === "Assigned") { - await tx.update(techSalesVendorQuotations) - .set({ - status: "Draft", - updatedBy: Number(session.user.id), - updatedAt: new Date(), - }) - .where(eq(techSalesVendorQuotations.id, quotation.id)); - } - } - - // 2. 각 벤더에 대해 이메일 발송 처리 - for (const quotation of vendorQuotations) { - if (!quotation.vendorId || !quotation.vendor) continue; - - // 벤더에 속한 모든 사용자 조회 - const vendorUsers = await db.query.users.findMany({ - where: eq(users.companyId, quotation.vendor.id), - columns: { - id: true, - email: true, - name: true, - language: true - } - }); - - // 유효한 이메일 주소만 필터링 - const vendorEmailsString = vendorUsers - .filter(user => user.email) - .map(user => user.email) - .join(", "); - - if (vendorEmailsString) { - // 대표 언어 결정 (첫 번째 사용자의 언어 또는 기본값) - const language = vendorUsers[0]?.language || "ko"; - - // RFQ 아이템 목록 조회 - const rfqItemsResult = await getTechSalesRfqItems(rfq.id); - const rfqItems = rfqItemsResult.data || []; - - // 이메일 컨텍스트 구성 (시리즈 정보 제거, 프로젝트 정보 간소화) - const emailContext = { - language: language, - rfq: { - id: rfq.id, - code: rfq.rfqCode, - 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, "KR") : '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 || '', - name: quotation.vendor.vendorName, - }, - sender: { - fullName: sender.name || '', - email: sender.email, - }, - project: { - // 기본 정보만 유지 - id: rfq.biddingProject?.pspid || '', - name: rfq.biddingProject?.projNm || '', - sector: rfq.biddingProject?.sector || '', - shipType: rfq.biddingProject?.ptypeNm || '', - shipCount: rfq.biddingProject?.projMsrm || 0, - ownerName: rfq.biddingProject?.kunnrNm || '', - className: rfq.biddingProject?.cls1Nm || '', - }, - details: { - currency: quotation.currency || 'USD', - }, - quotationCode: `${rfq.rfqCode}-${quotation.vendorId}`, - systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://60.101.108.100/ko/partners', - isResend: isResend, - versionInfo: isResend ? '(재전송)' : '', - }; - - // 이메일 전송 - await sendEmail({ - to: vendorEmailsString, - subject: isResend - ? `[기술영업 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에 추가 - }); - } - } - }); - - // 캐시 무효화 - revalidateTag("techSalesRfqs"); - revalidateTag("techSalesVendorQuotations"); - revalidateTag(`techSalesRfq-${input.rfqId}`); - revalidatePath(getTechSalesRevalidationPath(rfq?.rfqType || "SHIP")); - - return { - success: true, - message: `${vendorQuotations.length}개 벤더에게 RFQ가 성공적으로 발송되었습니다`, - sentCount: vendorQuotations.length, - }; - } catch (err) { - console.error("기술영업 RFQ 발송 오류:", err); - return { - success: false, - message: "RFQ 발송 중 오류가 발생했습니다", - }; - } -} - -/** - * 벤더용 기술영업 RFQ 견적서 조회 (withJoin 사용) - */ -export async function getTechSalesVendorQuotation(quotationId: number) { - unstable_noStore(); - try { - const quotation = await db.transaction(async (tx) => { - return await selectSingleTechSalesVendorQuotationWithJoin(tx, quotationId); - }); - - if (!quotation) { - return { data: null, error: "견적서를 찾을 수 없습니다." }; - } - - // RFQ 아이템 정보도 함께 조회 - const itemsResult = await getTechSalesRfqItems(quotation.rfqId); - const items = itemsResult.data || []; - - // 견적서 첨부파일 조회 - const quotationAttachments = await db.query.techSalesVendorQuotationAttachments.findMany({ - where: eq(techSalesVendorQuotationAttachments.quotationId, quotationId), - orderBy: [desc(techSalesVendorQuotationAttachments.createdAt)], - }); - - // 기존 구조와 호환되도록 데이터 재구성 - 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, - }, - - // 첨부파일 정보 - quotationAttachments: quotationAttachments.map(attachment => ({ - id: attachment.id, - fileName: attachment.fileName, - fileSize: attachment.fileSize, - filePath: attachment.filePath, - description: attachment.description, - })) - }; - - return { data: formattedQuotation, error: null }; - } catch (err) { - console.error("Error fetching vendor quotation:", err); - return { data: null, error: getErrorMessage(err) }; - } -} - -/** - * 기술영업 벤더 견적서 업데이트 (임시저장), - * 현재는 submit으로 처리, revision 을 아래의 함수로 사용가능함. - */ -export async function updateTechSalesVendorQuotation(data: { - id: number - currency: string - totalPrice: string - validUntil: Date - remark?: string - updatedBy: number - changeReason?: string -}) { - try { - return await db.transaction(async (tx) => { - // 현재 견적서 전체 데이터 조회 (revision 저장용) - const currentQuotation = await tx.query.techSalesVendorQuotations.findFirst({ - where: eq(techSalesVendorQuotations.id, data.id), - }); - - if (!currentQuotation) { - return { data: null, error: "견적서를 찾을 수 없습니다." }; - } - - // Accepted나 Rejected 상태가 아니면 수정 가능 - if (["Rejected"].includes(currentQuotation.status)) { - return { data: null, error: "승인되거나 거절된 견적서는 수정할 수 없습니다." }; - } - - // 실제 변경사항이 있는지 확인 - const hasChanges = - currentQuotation.currency !== data.currency || - currentQuotation.totalPrice !== data.totalPrice || - currentQuotation.validUntil?.getTime() !== data.validUntil.getTime() || - currentQuotation.remark !== (data.remark || null); - - if (!hasChanges) { - return { data: currentQuotation, error: null }; - } - - // 현재 버전을 revision history에 저장 - await tx.insert(techSalesVendorQuotationRevisions).values({ - quotationId: data.id, - version: currentQuotation.quotationVersion || 1, - snapshot: { - currency: currentQuotation.currency, - totalPrice: currentQuotation.totalPrice, - validUntil: currentQuotation.validUntil, - remark: currentQuotation.remark, - status: currentQuotation.status, - quotationVersion: currentQuotation.quotationVersion, - submittedAt: currentQuotation.submittedAt, - acceptedAt: currentQuotation.acceptedAt, - updatedAt: currentQuotation.updatedAt, - }, - changeReason: data.changeReason || "견적서 수정", - revisedBy: data.updatedBy, - }); - - // 새로운 버전으로 업데이트 - const result = await tx - .update(techSalesVendorQuotations) - .set({ - currency: data.currency, - totalPrice: data.totalPrice, - validUntil: data.validUntil, - remark: data.remark || null, - quotationVersion: (currentQuotation.quotationVersion || 1) + 1, - status: "Revised", // 수정된 상태로 변경 - updatedAt: new Date(), - }) - .where(eq(techSalesVendorQuotations.id, data.id)) - .returning(); - - return { data: result[0], error: null }; - }); - } catch (error) { - console.error("Error updating tech sales vendor quotation:", error); - return { data: null, error: "견적서 업데이트 중 오류가 발생했습니다" }; - } finally { - // 캐시 무효화 - revalidateTag("techSalesVendorQuotations"); - revalidatePath(`/partners/techsales/rfq-ship/${data.id}`); - } -} - -/** - * 기술영업 벤더 견적서 제출 - */ -export async function submitTechSalesVendorQuotation(data: { - id: number - currency: string - totalPrice: string - validUntil: Date - remark?: string - attachments?: Array<{ - fileName: string - filePath: string - fileSize: number - }> - updatedBy: number -}) { - try { - return await db.transaction(async (tx) => { - // 현재 견적서 전체 데이터 조회 (revision 저장용) - const currentQuotation = await tx.query.techSalesVendorQuotations.findFirst({ - where: eq(techSalesVendorQuotations.id, data.id), - }); - - if (!currentQuotation) { - return { data: null, error: "견적서를 찾을 수 없습니다." }; - } - - // Rejected 상태에서는 제출 불가 - if (["Rejected"].includes(currentQuotation.status)) { - return { data: null, error: "거절된 견적서는 제출할 수 없습니다." }; - } - - // // 실제 변경사항이 있는지 확인 - // const hasChanges = - // currentQuotation.currency !== data.currency || - // currentQuotation.totalPrice !== data.totalPrice || - // currentQuotation.validUntil?.getTime() !== data.validUntil.getTime() || - // currentQuotation.remark !== (data.remark || null); - - // // 변경사항이 있거나 처음 제출하는 경우 revision 저장 - // if (hasChanges || currentQuotation.status === "Draft") { - // await tx.insert(techSalesVendorQuotationRevisions).values({ - // quotationId: data.id, - // version: currentQuotation.quotationVersion || 1, - // snapshot: { - // currency: currentQuotation.currency, - // totalPrice: currentQuotation.totalPrice, - // validUntil: currentQuotation.validUntil, - // remark: currentQuotation.remark, - // status: currentQuotation.status, - // quotationVersion: currentQuotation.quotationVersion, - // submittedAt: currentQuotation.submittedAt, - // acceptedAt: currentQuotation.acceptedAt, - // updatedAt: currentQuotation.updatedAt, - // }, - // changeReason: "견적서 제출", - // revisedBy: data.updatedBy, - // }); - // } - - // 첫 제출인지 확인 (quotationVersion이 null인 경우) - const isFirstSubmission = currentQuotation.quotationVersion === null; - - // 첫 제출이 아닌 경우에만 revision 저장 (변경사항 이력 관리) - if (!isFirstSubmission) { - await tx.insert(techSalesVendorQuotationRevisions).values({ - quotationId: data.id, - version: currentQuotation.quotationVersion || 1, - snapshot: { - currency: currentQuotation.currency, - totalPrice: currentQuotation.totalPrice, - validUntil: currentQuotation.validUntil, - remark: currentQuotation.remark, - status: currentQuotation.status, - quotationVersion: currentQuotation.quotationVersion, - submittedAt: currentQuotation.submittedAt, - acceptedAt: currentQuotation.acceptedAt, - updatedAt: currentQuotation.updatedAt, - }, - changeReason: "견적서 제출", - revisedBy: data.updatedBy, - }); - } - - // 새로운 버전 번호 계산 (첫 제출은 1, 재제출은 1 증가) - const newRevisionId = isFirstSubmission ? 1 : (currentQuotation.quotationVersion || 1) + 1; - - // 새로운 버전으로 업데이트 - const result = await tx - .update(techSalesVendorQuotations) - .set({ - currency: data.currency, - totalPrice: data.totalPrice, - validUntil: data.validUntil, - remark: data.remark || null, - quotationVersion: newRevisionId, - status: "Submitted", - submittedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(techSalesVendorQuotations.id, data.id)) - .returning(); - - // 첨부파일 처리 (새로운 revisionId 사용) - if (data.attachments && data.attachments.length > 0) { - for (const attachment of data.attachments) { - await tx.insert(techSalesVendorQuotationAttachments).values({ - quotationId: data.id, - revisionId: newRevisionId, // 새로운 리비전 ID 사용 - fileName: attachment.fileName, - originalFileName: attachment.fileName, - fileSize: attachment.fileSize, - filePath: attachment.filePath, - fileType: attachment.fileName.split('.').pop() || 'unknown', - uploadedBy: data.updatedBy, - isVendorUpload: true, - }); - } - } - - // 메일 발송 (백그라운드에서 실행) - if (result[0]) { - // 벤더에게 견적 제출 확인 메일 발송 - sendQuotationSubmittedNotificationToVendor(data.id).catch(error => { - console.error("벤더 견적 제출 확인 메일 발송 실패:", error); - }); - - // 담당자에게 견적 접수 알림 메일 발송 - sendQuotationSubmittedNotificationToManager(data.id).catch(error => { - console.error("담당자 견적 접수 알림 메일 발송 실패:", error); - }); - } - - return { data: result[0], error: null }; - }); - } catch (error) { - console.error("Error submitting tech sales vendor quotation:", error); - return { data: null, error: "견적서 제출 중 오류가 발생했습니다" }; - } finally { - // 캐시 무효화 - revalidateTag("techSalesVendorQuotations"); - revalidatePath(`/partners/techsales/rfq-ship`); - } -} - -/** - * 통화 목록 조회 - */ -export async function fetchCurrencies() { - try { - // 기본 통화 목록 (실제로는 DB에서 가져와야 함) - const currencies = [ - { code: "USD", name: "미국 달러" }, - { code: "KRW", name: "한국 원" }, - { code: "EUR", name: "유로" }, - { code: "JPY", name: "일본 엔" }, - { code: "CNY", name: "중국 위안" }, - ] - - return { data: currencies, error: null } - } catch (error) { - console.error("Error fetching currencies:", error) - return { data: null, error: "통화 목록 조회 중 오류가 발생했습니다" } - } -} - -/** - * 벤더용 기술영업 견적서 목록 조회 (페이지네이션 포함) - */ -export async function getVendorQuotations(input: { - flags?: string[]; - page: number; - perPage: number; - sort?: { id: string; desc: boolean }[]; - filters?: Filter<typeof techSalesVendorQuotations>[]; - joinOperator?: "and" | "or"; - basicFilters?: Filter<typeof techSalesVendorQuotations>[]; - basicJoinOperator?: "and" | "or"; - search?: string; - from?: string; - to?: string; - rfqType?: "SHIP" | "TOP" | "HULL"; -}, vendorId: string) { - return unstable_cache( - async () => { - try { - console.log('🔍 [getVendorQuotations] 호출됨:', { - vendorId, - vendorIdParsed: parseInt(vendorId), - rfqType: input.rfqType, - inputData: input - }); - - const { page, perPage, sort, filters = [], search = "", from = "", to = "" } = input; - const offset = (page - 1) * perPage; - const limit = perPage; - - // 기본 조건: 해당 벤더의 견적서만 조회 (Assigned 상태 제외) - 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), - sql`${techSalesVendorQuotations.status} != 'Assigned'` // Assigned 상태 제외 - ]; - - // rfqType 필터링 추가 - if (input.rfqType) { - baseConditions.push(eq(techSalesRfqs.rfqType, input.rfqType)); - } - - // 검색 조건 추가 - if (search) { - const s = `%${search}%`; - const searchCondition = or( - ilike(techSalesVendorQuotations.currency, s), - ilike(techSalesVendorQuotations.status, s) - ); - if (searchCondition) { - baseConditions.push(searchCondition); - } - } - - // 날짜 범위 필터 - if (from) { - baseConditions.push(sql`${techSalesVendorQuotations.createdAt} >= ${from}`); - } - if (to) { - baseConditions.push(sql`${techSalesVendorQuotations.createdAt} <= ${to}`); - } - - // 고급 필터 처리 - if (filters.length > 0) { - const filterWhere = filterColumns({ - table: techSalesVendorQuotations, - filters: filters as Filter<typeof techSalesVendorQuotations>[], - joinOperator: input.joinOperator || "and", - }); - if (filterWhere) { - baseConditions.push(filterWhere); - } - } - - // 최종 WHERE 조건 - const finalWhere = baseConditions.length > 0 - ? and(...baseConditions) - : undefined; - - // 정렬 기준 설정 - let orderBy: OrderByType[] = [desc(techSalesVendorQuotations.updatedAt)]; - - if (sort?.length) { - orderBy = sort.map(item => { - switch (item.id) { - case 'id': - return item.desc ? desc(techSalesVendorQuotations.id) : techSalesVendorQuotations.id; - case 'status': - return item.desc ? desc(techSalesVendorQuotations.status) : techSalesVendorQuotations.status; - case 'currency': - return item.desc ? desc(techSalesVendorQuotations.currency) : techSalesVendorQuotations.currency; - case 'totalPrice': - return item.desc ? desc(techSalesVendorQuotations.totalPrice) : techSalesVendorQuotations.totalPrice; - case 'validUntil': - return item.desc ? desc(techSalesVendorQuotations.validUntil) : techSalesVendorQuotations.validUntil; - case 'submittedAt': - return item.desc ? desc(techSalesVendorQuotations.submittedAt) : techSalesVendorQuotations.submittedAt; - case 'createdAt': - return item.desc ? desc(techSalesVendorQuotations.createdAt) : techSalesVendorQuotations.createdAt; - case 'updatedAt': - return item.desc ? desc(techSalesVendorQuotations.updatedAt) : techSalesVendorQuotations.updatedAt; - case 'rfqCode': - return item.desc ? desc(techSalesRfqs.rfqCode) : techSalesRfqs.rfqCode; - case 'materialCode': - return item.desc ? desc(techSalesRfqs.materialCode) : techSalesRfqs.materialCode; - case 'dueDate': - return item.desc ? desc(techSalesRfqs.dueDate) : techSalesRfqs.dueDate; - case 'rfqStatus': - return item.desc ? desc(techSalesRfqs.status) : techSalesRfqs.status; - default: - return item.desc ? desc(techSalesVendorQuotations.updatedAt) : techSalesVendorQuotations.updatedAt; - } - }); - } - - // 조인을 포함한 데이터 조회 (중복 제거를 위해 techSalesAttachments JOIN 제거) - const data = await db - .select({ - id: techSalesVendorQuotations.id, - rfqId: techSalesVendorQuotations.rfqId, - vendorId: techSalesVendorQuotations.vendorId, - status: techSalesVendorQuotations.status, - currency: techSalesVendorQuotations.currency, - totalPrice: techSalesVendorQuotations.totalPrice, - validUntil: techSalesVendorQuotations.validUntil, - submittedAt: techSalesVendorQuotations.submittedAt, - remark: techSalesVendorQuotations.remark, - createdAt: techSalesVendorQuotations.createdAt, - updatedAt: techSalesVendorQuotations.updatedAt, - createdBy: techSalesVendorQuotations.createdBy, - updatedBy: techSalesVendorQuotations.updatedBy, - quotationCode: techSalesVendorQuotations.quotationCode, - quotationVersion: techSalesVendorQuotations.quotationVersion, - rejectionReason: techSalesVendorQuotations.rejectionReason, - acceptedAt: techSalesVendorQuotations.acceptedAt, - // RFQ 정보 - rfqCode: techSalesRfqs.rfqCode, - materialCode: techSalesRfqs.materialCode, - dueDate: techSalesRfqs.dueDate, - rfqStatus: techSalesRfqs.status, - description: techSalesRfqs.description, - // 프로젝트 정보 (직접 조인) - projNm: biddingProjects.projNm, - // 아이템 개수 - itemCount: sql<number>`( - SELECT COUNT(*) - FROM tech_sales_rfq_items - WHERE tech_sales_rfq_items.rfq_id = ${techSalesRfqs.id} - )`, - // RFQ 첨부파일 개수 (RFQ_COMMON 타입만 카운트) - attachmentCount: sql<number>`( - SELECT COUNT(*) - FROM tech_sales_attachments - WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id} - AND tech_sales_attachments.attachment_type = 'RFQ_COMMON' - )`, - }) - .from(techSalesVendorQuotations) - .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id)) - .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id)) - .where(finalWhere) - .orderBy(...orderBy) - .limit(limit) - .offset(offset); - - // 총 개수 조회 - const totalResult = await db - .select({ count: sql<number>`count(*)` }) - .from(techSalesVendorQuotations) - .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id)) - .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id)) - .where(finalWhere); - - const total = totalResult[0]?.count || 0; - const pageCount = Math.ceil(total / perPage); - - return { data, pageCount, total }; - } catch (err) { - console.error("Error fetching vendor quotations:", err); - return { data: [], pageCount: 0, total: 0 }; - } - }, - [JSON.stringify(input), vendorId], // 캐싱 키 - { - revalidate: 60, // 1분간 캐시 - tags: [ - "techSalesVendorQuotations", - `vendor-${vendorId}-quotations` - ], - } - )(); -} - -/** - * 기술영업 벤더 견적 승인 (벤더 선택) - */ -export async function acceptTechSalesVendorQuotation(quotationId: number) { - try { - const result = await db.transaction(async (tx) => { - // 1. 선택된 견적 정보 조회 - const selectedQuotation = await tx - .select() - .from(techSalesVendorQuotations) - .where(eq(techSalesVendorQuotations.id, quotationId)) - .limit(1) - - if (selectedQuotation.length === 0) { - throw new Error("견적을 찾을 수 없습니다") - } - - const quotation = selectedQuotation[0] - - // 2. 선택된 견적을 Accepted로 변경 - await tx - .update(techSalesVendorQuotations) - .set({ - status: "Accepted", - acceptedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(techSalesVendorQuotations.id, quotationId)) - - // 4. RFQ 상태를 Closed로 변경 - await tx - .update(techSalesRfqs) - .set({ - status: "Closed", - updatedAt: new Date(), - }) - .where(eq(techSalesRfqs.id, quotation.rfqId)) - - return quotation - }) - - // 메일 발송 (백그라운드에서 실행) - // 선택된 벤더에게 견적 선택 알림 메일 발송 - sendQuotationAcceptedNotification(quotationId).catch(error => { - console.error("벤더 견적 선택 알림 메일 발송 실패:", error); - }); - - // 캐시 무효화 - revalidateTag("techSalesVendorQuotations") - revalidateTag(`techSalesRfq-${result.rfqId}`) - revalidateTag("techSalesRfqs") - - // 해당 RFQ의 모든 벤더 캐시 무효화 (선택된 벤더와 거절된 벤더들) - const allVendorsInRfq = await db.query.techSalesVendorQuotations.findMany({ - where: eq(techSalesVendorQuotations.rfqId, result.rfqId), - columns: { vendorId: true } - }); - - for (const vendorQuotation of allVendorsInRfq) { - revalidateTag(`vendor-${vendorQuotation.vendorId}-quotations`); - } - revalidatePath("/evcp/budgetary-tech-sales-ship") - revalidatePath("/partners/techsales") - - - return { success: true, data: result } - } catch (error) { - console.error("벤더 견적 승인 오류:", error) - return { - success: false, - error: error instanceof Error ? error.message : "벤더 견적 승인에 실패했습니다" - } - } -} - -/** - * 기술영업 RFQ 첨부파일 생성 (파일 업로드), 사용x - */ -export async function createTechSalesRfqAttachments(params: { - techSalesRfqId: number - files: File[] - createdBy: number - attachmentType?: "RFQ_COMMON" | "VENDOR_SPECIFIC" - description?: string -}) { - unstable_noStore(); - try { - const { techSalesRfqId, files, createdBy, attachmentType = "RFQ_COMMON", description } = params; - - if (!files || files.length === 0) { - return { data: null, error: "업로드할 파일이 없습니다." }; - } - - // RFQ 존재 확인 - const rfq = await db.query.techSalesRfqs.findFirst({ - where: eq(techSalesRfqs.id, techSalesRfqId), - columns: { id: true, status: true } - }); - - if (!rfq) { - return { data: null, error: "RFQ를 찾을 수 없습니다." }; - } - - // 편집 가능한 상태 확인 - if (!["RFQ Created", "RFQ Vendor Assignned"].includes(rfq.status)) { - return { data: null, error: "현재 상태에서는 첨부파일을 추가할 수 없습니다." }; - } - - const results: typeof techSalesAttachments.$inferSelect[] = []; - - // 트랜잭션으로 처리 - await db.transaction(async (tx) => { - - for (const file of files) { - const saveResult = await saveDRMFile(file, decryptWithServerAction,`techsales-rfq/${techSalesRfqId}` ) - - // DB에 첨부파일 레코드 생성 - const [newAttachment] = await tx.insert(techSalesAttachments).values({ - techSalesRfqId, - attachmentType, - fileName: saveResult.fileName, - originalFileName: file.name, - filePath: saveResult.publicPath, - fileSize: file.size, - fileType: file.type || undefined, - description: description || undefined, - createdBy, - }).returning(); - - results.push(newAttachment); - } - }); - - // RFQ 타입 조회하여 캐시 무효화 - const rfqType = await db.query.techSalesRfqs.findFirst({ - where: eq(techSalesRfqs.id, techSalesRfqId), - columns: { rfqType: true } - }); - - revalidateTag("techSalesRfqs"); - revalidateTag(`techSalesRfq-${techSalesRfqId}`); - revalidatePath(getTechSalesRevalidationPath(rfqType?.rfqType || "SHIP")); - - return { data: results, error: null }; - } catch (err) { - console.error("기술영업 RFQ 첨부파일 생성 오류:", err); - return { data: null, error: getErrorMessage(err) }; - } -} - -/** - * 기술영업 RFQ 첨부파일 조회 - */ -export async function getTechSalesRfqAttachments(techSalesRfqId: number) { - unstable_noStore(); - try { - const attachments = await db.query.techSalesAttachments.findMany({ - where: eq(techSalesAttachments.techSalesRfqId, techSalesRfqId), - orderBy: [desc(techSalesAttachments.createdAt)], - with: { - createdByUser: { - columns: { - id: true, - name: true, - email: true, - } - } - } - }); - - return { data: attachments, error: null }; - } catch (err) { - console.error("기술영업 RFQ 첨부파일 조회 오류:", err); - return { data: [], error: getErrorMessage(err) }; - } -} - -/** - * RFQ 첨부파일 타입별 조회 - */ -export async function getTechSalesRfqAttachmentsByType( - techSalesRfqId: number, - attachmentType: "RFQ_COMMON" | "VENDOR_SPECIFIC" | "TBE_RESULT" | "CBE_RESULT" -) { - unstable_noStore(); - try { - const attachments = await db.query.techSalesAttachments.findMany({ - where: and( - eq(techSalesAttachments.techSalesRfqId, techSalesRfqId), - eq(techSalesAttachments.attachmentType, attachmentType) - ), - orderBy: [desc(techSalesAttachments.createdAt)], - with: { - createdByUser: { - columns: { - id: true, - name: true, - email: true, - } - } - } - }); - - return { data: attachments, error: null }; - } catch (err) { - console.error(`기술영업 RFQ ${attachmentType} 첨부파일 조회 오류:`, err); - return { data: [], error: getErrorMessage(err) }; - } -} - -/** - * 기술영업 RFQ 첨부파일 삭제 - */ -export async function deleteTechSalesRfqAttachment(attachmentId: number) { - unstable_noStore(); - try { - // 첨부파일 정보 조회 - const attachment = await db.query.techSalesAttachments.findFirst({ - where: eq(techSalesAttachments.id, attachmentId), - }); - - if (!attachment) { - return { data: null, error: "첨부파일을 찾을 수 없습니다." }; - } - - // RFQ 상태 확인 - const rfq = await db.query.techSalesRfqs.findFirst({ - where: eq(techSalesRfqs.id, attachment.techSalesRfqId!), // Non-null assertion since we know it exists - columns: { id: true, status: true } - }); - - if (!rfq) { - return { data: null, error: "RFQ를 찾을 수 없습니다." }; - } - - // 편집 가능한 상태 확인 - if (!["RFQ Created", "RFQ Vendor Assignned"].includes(rfq.status)) { - return { data: null, error: "현재 상태에서는 첨부파일을 삭제할 수 없습니다." }; - } - - // 트랜잭션으로 처리 - const result = await db.transaction(async (tx) => { - // DB에서 레코드 삭제 - const deletedAttachment = await tx.delete(techSalesAttachments) - .where(eq(techSalesAttachments.id, attachmentId)) - .returning(); - - // 파일 시스템에서 파일 삭제 - try { - await deleteFile(`${attachment.filePath}`) - - } catch (fileError) { - console.warn("파일 삭제 실패:", fileError); - // 파일 삭제 실패는 심각한 오류가 아니므로 계속 진행 - } - - return deletedAttachment[0]; - }); - - // RFQ 타입 조회하여 캐시 무효화 - const attachmentRfq = await db.query.techSalesRfqs.findFirst({ - where: eq(techSalesRfqs.id, attachment.techSalesRfqId!), - columns: { rfqType: true } - }); - - revalidateTag("techSalesRfqs"); - revalidateTag(`techSalesRfq-${attachment.techSalesRfqId}`); - revalidatePath(getTechSalesRevalidationPath(attachmentRfq?.rfqType || "SHIP")); - - return { data: result, error: null }; - } catch (err) { - console.error("기술영업 RFQ 첨부파일 삭제 오류:", err); - return { data: null, error: getErrorMessage(err) }; - } -} - -/** - * 기술영업 RFQ 첨부파일 일괄 처리 (업로드 + 삭제) - */ -export async function processTechSalesRfqAttachments(params: { - techSalesRfqId: number - newFiles: { file: File; attachmentType: "RFQ_COMMON" | "VENDOR_SPECIFIC" | "TBE_RESULT" | "CBE_RESULT"; description?: string }[] - deleteAttachmentIds: number[] - createdBy: number -}) { - unstable_noStore(); - try { - const { techSalesRfqId, newFiles, deleteAttachmentIds, createdBy } = params; - - // RFQ 존재 및 상태 확인 - const rfq = await db.query.techSalesRfqs.findFirst({ - where: eq(techSalesRfqs.id, techSalesRfqId), - columns: { id: true, status: true } - }); - - if (!rfq) { - return { data: null, error: "RFQ를 찾을 수 없습니다." }; - } - - if (!["RFQ Created", "RFQ Vendor Assignned"].includes(rfq.status)) { - return { data: null, error: "현재 상태에서는 첨부파일을 수정할 수 없습니다." }; - } - - const results = { - uploaded: [] as typeof techSalesAttachments.$inferSelect[], - deleted: [] as typeof techSalesAttachments.$inferSelect[], - }; - - await db.transaction(async (tx) => { - - // 1. 삭제할 첨부파일 처리 - if (deleteAttachmentIds.length > 0) { - const attachmentsToDelete = await tx.query.techSalesAttachments.findMany({ - where: sql`${techSalesAttachments.id} IN (${deleteAttachmentIds.join(',')})` - }); - - for (const attachment of attachmentsToDelete) { - // DB에서 레코드 삭제 - const [deletedAttachment] = await tx.delete(techSalesAttachments) - .where(eq(techSalesAttachments.id, attachment.id)) - .returning(); - - results.deleted.push(deletedAttachment); - await deleteFile(attachment.filePath) - - } - } - - // 2. 새 파일 업로드 처리 - if (newFiles.length > 0) { - for (const { file, attachmentType, description } of newFiles) { - const saveResult = await saveDRMFile(file, decryptWithServerAction,`techsales-rfq/${techSalesRfqId}` ) - - // DB에 첨부파일 레코드 생성 - const [newAttachment] = await tx.insert(techSalesAttachments).values({ - techSalesRfqId, - attachmentType, - fileName: saveResult.fileName, - originalFileName: file.name, - filePath: saveResult.publicPath, - fileSize: file.size, - fileType: file.type || undefined, - description: description || undefined, - createdBy, - }).returning(); - - results.uploaded.push(newAttachment); - } - } - }); - - // 캐시 무효화 - revalidateTag("techSalesRfqs"); - revalidateTag(`techSalesRfq-${techSalesRfqId}`); - revalidatePath("/evcp/budgetary-tech-sales-ship"); - - return { - data: results, - error: null, - message: `${results.uploaded.length}개 업로드, ${results.deleted.length}개 삭제 완료` - }; - } catch (err) { - console.error("기술영업 RFQ 첨부파일 일괄 처리 오류:", err); - return { data: null, error: getErrorMessage(err) }; - } -} - -// ======================================== -// 메일 발송 관련 함수들 -// ======================================== - -/** - * 벤더 견적 제출 확인 메일 발송 (벤더용) - */ -export async function sendQuotationSubmittedNotificationToVendor(quotationId: number) { - try { - // 견적서 정보 조회 (projectSeries 조인 추가) - const quotation = await db.query.techSalesVendorQuotations.findFirst({ - where: eq(techSalesVendorQuotations.id, quotationId), - with: { - rfq: { - with: { - biddingProject: true, - createdByUser: { - columns: { - id: true, - name: true, - email: true, - } - } - } - }, - vendor: { - columns: { - id: true, - vendorName: true, - vendorCode: true, - } - } - } - }); - - if (!quotation || !quotation.rfq || !quotation.vendor) { - console.error("견적서 또는 관련 정보를 찾을 수 없습니다"); - return { success: false, error: "견적서 정보를 찾을 수 없습니다" }; - } - - // 벤더 사용자들 조회 - const vendorUsers = await db.query.users.findMany({ - where: eq(users.companyId, quotation.vendor.id), - columns: { - id: true, - email: true, - name: true, - language: true - } - }); - - const vendorEmails = vendorUsers - .filter(user => user.email) - .map(user => user.email) - .join(", "); - - if (!vendorEmails) { - console.warn(`벤더 ID ${quotation.vendor.id}에 등록된 이메일 주소가 없습니다`); - return { success: false, error: "벤더 이메일 주소가 없습니다" }; - } - - // RFQ 아이템 정보 조회 - const rfqItemsResult = await getTechSalesRfqItems(quotation.rfq.id); - const rfqItems = rfqItemsResult.data || []; - - // 이메일 컨텍스트 구성 (시리즈 정보 제거, 프로젝트 정보 간소화) - const emailContext = { - language: vendorUsers[0]?.language || "ko", - quotation: { - id: quotation.id, - currency: quotation.currency, - totalPrice: quotation.totalPrice, - validUntil: quotation.validUntil, - submittedAt: quotation.submittedAt, - remark: quotation.remark, - }, - rfq: { - id: quotation.rfq.id, - code: quotation.rfq.rfqCode, - 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, - }, - 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, - name: quotation.vendor.vendorName, - }, - project: { - 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 || '', - }, - manager: { - name: quotation.rfq.createdByUser?.name || '', - email: quotation.rfq.createdByUser?.email || '', - }, - systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://60.101.108.100/ko/partners', - companyName: 'Samsung Heavy Industries', - year: new Date().getFullYear(), - }; - - // 이메일 발송 - await sendEmail({ - to: vendorEmails, - subject: `[견적 제출 확인] ${quotation.rfq.rfqCode} - 견적 요청`, - template: 'tech-sales-quotation-submitted-vendor-ko', - context: emailContext, - }); - - console.log(`벤더 견적 제출 확인 메일 발송 완료: ${vendorEmails}`); - return { success: true }; - } catch (error) { - console.error("벤더 견적 제출 확인 메일 발송 오류:", error); - return { success: false, error: "메일 발송 중 오류가 발생했습니다" }; - } -} - -/** - * 벤더 견적 접수 알림 메일 발송 (담당자용) - */ -export async function sendQuotationSubmittedNotificationToManager(quotationId: number) { - try { - // 견적서 정보 조회 - const quotation = await db.query.techSalesVendorQuotations.findFirst({ - where: eq(techSalesVendorQuotations.id, quotationId), - with: { - rfq: { - with: { - biddingProject: true, - createdByUser: { - columns: { - id: true, - name: true, - email: true, - } - } - } - }, - vendor: { - columns: { - id: true, - vendorName: true, - vendorCode: true, - } - } - } - }); - - if (!quotation || !quotation.rfq || !quotation.vendor) { - console.error("견적서 또는 관련 정보를 찾을 수 없습니다"); - return { success: false, error: "견적서 정보를 찾을 수 없습니다" }; - } - - const manager = quotation.rfq.createdByUser; - if (!manager?.email) { - console.warn("담당자 이메일 주소가 없습니다"); - return { success: false, error: "담당자 이메일 주소가 없습니다" }; - } - - // RFQ 아이템 정보 조회 - const rfqItemsResult = await getTechSalesRfqItems(quotation.rfq.id); - const rfqItems = rfqItemsResult.data || []; - - // 이메일 컨텍스트 구성 (시리즈 정보 제거, 프로젝트 정보 간소화) - const emailContext = { - language: "ko", - quotation: { - id: quotation.id, - currency: quotation.currency, - totalPrice: quotation.totalPrice, - validUntil: quotation.validUntil, - submittedAt: quotation.submittedAt, - remark: quotation.remark, - }, - rfq: { - id: quotation.rfq.id, - code: quotation.rfq.rfqCode, - 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, - }, - 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, - name: quotation.vendor.vendorName, - }, - project: { - 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 || '', - }, - manager: { - name: manager.name || '', - email: manager.email, - }, - systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://60.101.108.100/ko/evcp', - companyName: 'Samsung Heavy Industries', - year: new Date().getFullYear(), - }; - - // 이메일 발송 - await sendEmail({ - to: manager.email, - subject: `[견적 접수 알림] ${quotation.vendor.vendorName}에서 ${quotation.rfq.rfqCode} 견적서를 제출했습니다`, - template: 'tech-sales-quotation-submitted-manager-ko', - context: emailContext, - }); - - console.log(`담당자 견적 접수 알림 메일 발송 완료: ${manager.email}`); - return { success: true }; - } catch (error) { - console.error("담당자 견적 접수 알림 메일 발송 오류:", error); - return { success: false, error: "메일 발송 중 오류가 발생했습니다" }; - } -} - -/** - * 벤더 견적 선택 알림 메일 발송 - */ -export async function sendQuotationAcceptedNotification(quotationId: number) { - try { - // 견적서 정보 조회 - const quotation = await db.query.techSalesVendorQuotations.findFirst({ - where: eq(techSalesVendorQuotations.id, quotationId), - with: { - rfq: { - with: { - biddingProject: true, - createdByUser: { - columns: { - id: true, - name: true, - email: true, - } - } - } - }, - vendor: { - columns: { - id: true, - vendorName: true, - vendorCode: true, - } - } - } - }); - - if (!quotation || !quotation.rfq || !quotation.vendor) { - console.error("견적서 또는 관련 정보를 찾을 수 없습니다"); - return { success: false, error: "견적서 정보를 찾을 수 없습니다" }; - } - - // 벤더 사용자들 조회 - const vendorUsers = await db.query.users.findMany({ - where: eq(users.companyId, quotation.vendor.id), - columns: { - id: true, - email: true, - name: true, - language: true - } - }); - - const vendorEmails = vendorUsers - .filter(user => user.email) - .map(user => user.email) - .join(", "); - - if (!vendorEmails) { - console.warn(`벤더 ID ${quotation.vendor.id}에 등록된 이메일 주소가 없습니다`); - return { success: false, error: "벤더 이메일 주소가 없습니다" }; - } - - // RFQ 아이템 정보 조회 - const rfqItemsResult = await getTechSalesRfqItems(quotation.rfq.id); - const rfqItems = rfqItemsResult.data || []; - - // 이메일 컨텍스트 구성 (시리즈 정보 제거, 프로젝트 정보 간소화) - const emailContext = { - language: vendorUsers[0]?.language || "ko", - quotation: { - id: quotation.id, - currency: quotation.currency, - totalPrice: quotation.totalPrice, - validUntil: quotation.validUntil, - acceptedAt: quotation.acceptedAt, - remark: quotation.remark, - }, - rfq: { - id: quotation.rfq.id, - code: quotation.rfq.rfqCode, - 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, - }, - 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, - name: quotation.vendor.vendorName, - }, - project: { - 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 || '', - }, - manager: { - name: quotation.rfq.createdByUser?.name || '', - email: quotation.rfq.createdByUser?.email || '', - }, - systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://60.101.108.100/ko/partners', - companyName: 'Samsung Heavy Industries', - year: new Date().getFullYear(), - }; - - // 이메일 발송 - await sendEmail({ - to: vendorEmails, - subject: `[견적 선택 알림] ${quotation.rfq.rfqCode} - 귀하의 견적이 선택되었습니다`, - template: 'tech-sales-quotation-accepted-ko', - context: emailContext, - }); - - console.log(`벤더 견적 선택 알림 메일 발송 완료: ${vendorEmails}`); - return { success: true }; - } catch (error) { - console.error("벤더 견적 선택 알림 메일 발송 오류:", error); - return { success: false, error: "메일 발송 중 오류가 발생했습니다" }; - } -} - -// ==================== Vendor Communication 관련 ==================== - -export interface TechSalesAttachment { - id: number - fileName: string - fileSize: number - fileType: string | null // <- null 허용 - filePath: string - uploadedAt: Date -} - -export interface TechSalesComment { - id: number - rfqId: number - vendorId: number | null // null 허용으로 변경 - userId?: number | null // null 허용으로 변경 - content: string - isVendorComment: boolean | null // null 허용으로 변경 - createdAt: Date - updatedAt: Date - userName?: string | null // null 허용으로 변경 - vendorName?: string | null // null 허용으로 변경 - attachments: TechSalesAttachment[] - isRead: boolean | null // null 허용으로 변경 -} - -/** - * 특정 RFQ의 벤더별 읽지 않은 메시지 개수를 조회하는 함수 - * - * @param rfqId RFQ ID - * @returns 벤더별 읽지 않은 메시지 개수 (vendorId: count) - */ -export async function getTechSalesUnreadMessageCounts(rfqId: number): Promise<Record<number, number>> { - try { - // 벤더가 보낸 읽지 않은 메시지를 벤더별로 카운트 - const unreadCounts = await db - .select({ - vendorId: techSalesRfqComments.vendorId, - count: sql<number>`count(*)`, - }) - .from(techSalesRfqComments) - .where( - and( - eq(techSalesRfqComments.rfqId, rfqId), - eq(techSalesRfqComments.isVendorComment, true), // 벤더가 보낸 메시지 - eq(techSalesRfqComments.isRead, false), // 읽지 않은 메시지 - sql`${techSalesRfqComments.vendorId} IS NOT NULL` // vendorId가 null이 아닌 것 - ) - ) - .groupBy(techSalesRfqComments.vendorId); - - // Record<number, number> 형태로 변환 - const result: Record<number, number> = {}; - unreadCounts.forEach(item => { - if (item.vendorId) { - result[item.vendorId] = item.count; - } - }); - - return result; - } catch (error) { - console.error('techSales 읽지 않은 메시지 개수 조회 오류:', error); - return {}; - } -} - -/** - * 특정 RFQ와 벤더 간의 커뮤니케이션 메시지를 가져오는 서버 액션 - * - * @param rfqId RFQ ID - * @param vendorId 벤더 ID - * @returns 코멘트 목록 - */ -export async function fetchTechSalesVendorComments(rfqId: number, vendorId?: number): Promise<TechSalesComment[]> { - if (!vendorId) { - return [] - } - - try { - // 인증 확인 - const session = await getServerSession(authOptions); - - if (!session?.user) { - throw new Error("인증이 필요합니다") - } - - // 코멘트 쿼리 - const comments = await db.query.techSalesRfqComments.findMany({ - where: and( - eq(techSalesRfqComments.rfqId, rfqId), - eq(techSalesRfqComments.vendorId, vendorId) - ), - orderBy: [techSalesRfqComments.createdAt], - with: { - user: { - columns: { - name: true - } - }, - vendor: { - columns: { - vendorName: true - } - }, - attachments: true, - } - }) - - // 결과 매핑 - return comments.map(comment => ({ - id: comment.id, - rfqId: comment.rfqId, - vendorId: comment.vendorId, - userId: comment.userId || undefined, - content: comment.content, - isVendorComment: comment.isVendorComment, - createdAt: comment.createdAt, - updatedAt: comment.updatedAt, - userName: comment.user?.name, - vendorName: comment.vendor?.vendorName, - isRead: comment.isRead, - attachments: comment.attachments.map(att => ({ - id: att.id, - fileName: att.fileName, - fileSize: att.fileSize, - fileType: att.fileType, - filePath: att.filePath, - uploadedAt: att.uploadedAt - })) - })) - } catch (error) { - console.error('techSales 벤더 코멘트 가져오기 오류:', error) - throw error - } -} - -/** - * 코멘트를 읽음 상태로 표시하는 서버 액션 - * - * @param rfqId RFQ ID - * @param vendorId 벤더 ID - */ -export async function markTechSalesMessagesAsRead(rfqId: number, vendorId?: number): Promise<void> { - if (!vendorId) { - return - } - - try { - // 인증 확인 - const session = await getServerSession(authOptions); - - if (!session?.user) { - throw new Error("인증이 필요합니다") - } - - // 벤더가 작성한 읽지 않은 코멘트 업데이트 - await db.update(techSalesRfqComments) - .set({ isRead: true }) - .where( - and( - eq(techSalesRfqComments.rfqId, rfqId), - eq(techSalesRfqComments.vendorId, vendorId), - eq(techSalesRfqComments.isVendorComment, true), - eq(techSalesRfqComments.isRead, false) - ) - ) - - // 캐시 무효화 - revalidateTag(`tech-sales-rfq-${rfqId}-comments`) - } catch (error) { - console.error('techSales 메시지 읽음 표시 오류:', error) - throw error - } -} - -// ==================== RFQ 조선/해양 관련 ==================== - -/** - * 기술영업 조선 RFQ 생성 (1:N 관계) - */ -export async function createTechSalesShipRfq(input: { - biddingProjectId: number; - itemIds: number[]; // 조선 아이템 ID 배열 - dueDate: Date; - description?: string; - createdBy: number; -}) { - unstable_noStore(); - 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" }); -} - -/** - * 기술영업 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 타입에 따른 캐시 무효화 경로 반환 - */ -function getTechSalesRevalidationPath(rfqType: "SHIP" | "TOP" | "HULL"): string { - switch (rfqType) { - case "SHIP": - return "/evcp/budgetary-tech-sales-ship"; - case "TOP": - return "/evcp/budgetary-tech-sales-top"; - case "HULL": - return "/evcp/budgetary-tech-sales-hull"; - default: - return "/evcp/budgetary-tech-sales-ship"; - } -} - -/** - * 기술영업 RFQ에 여러 벤더 추가 (techVendors 기반) - * 벤더 추가 시에는 견적서를 생성하지 않고, RFQ 전송 시에 견적서를 생성 - */ -export async function addTechVendorsToTechSalesRfq(input: { - rfqId: number; - vendorIds: number[]; - createdBy: number; -}) { - unstable_noStore(); - - try { - return await db.transaction(async (tx) => { - const results = []; - const errors: string[] = []; - - // 1. RFQ 상태 및 타입 확인 - const rfq = await tx.query.techSalesRfqs.findFirst({ - where: eq(techSalesRfqs.id, input.rfqId), - columns: { - id: true, - status: true, - rfqType: true, - } - }); - - if (!rfq) { - throw new Error("RFQ를 찾을 수 없습니다"); - } - - // 2. 각 벤더에 대해 처리 (이미 추가된 벤더는 견적서가 있는지 확인) - for (const vendorId of input.vendorIds) { - try { - // 이미 추가된 벤더인지 확인 (견적서 존재 여부로 확인) - const existingQuotation = await tx.query.techSalesVendorQuotations.findFirst({ - where: and( - eq(techSalesVendorQuotations.rfqId, input.rfqId), - eq(techSalesVendorQuotations.vendorId, vendorId) - ) - }); - - if (existingQuotation) { - errors.push(`벤더 ID ${vendorId}는 이미 추가되어 있습니다.`); - continue; - } - - // 벤더가 실제로 존재하는지 확인 - const vendor = await tx.query.techVendors.findFirst({ - where: eq(techVendors.id, vendorId), - columns: { id: true, vendorName: true } - }); - - if (!vendor) { - errors.push(`벤더 ID ${vendorId}를 찾을 수 없습니다.`); - continue; - } - - // 🔥 중요: 벤더 추가 시에는 견적서를 생성하지 않고, "Assigned" 상태로만 생성 - // quotation_version은 null로 설정하여 벤더가 실제 견적 제출 시에만 리비전 생성 - const [quotation] = await tx - .insert(techSalesVendorQuotations) - .values({ - rfqId: input.rfqId, - vendorId: vendorId, - status: "Assigned", // Draft가 아닌 Assigned 상태로 생성 - quotationVersion: null, // 리비전은 견적 제출 시에만 생성 - createdBy: input.createdBy, - updatedBy: input.createdBy, - }) - .returning({ id: techSalesVendorQuotations.id }); - - // 🆕 RFQ의 아이템 코드들을 tech_vendor_possible_items에 추가 - try { - // RFQ의 아이템들 조회 - const rfqItemsResult = await getTechSalesRfqItems(input.rfqId); - - if (rfqItemsResult.data && rfqItemsResult.data.length > 0) { - const itemCodes = rfqItemsResult.data - .map(item => item.itemCode) - .filter(code => code); // 빈 코드 제외 - - // 각 아이템 코드에 대해 tech_vendor_possible_items에 추가 (중복 체크) - for (const itemCode of itemCodes) { - // 이미 존재하는지 확인 - const existing = await tx.query.techVendorPossibleItems.findFirst({ - where: and( - eq(techVendorPossibleItems.vendorId, vendorId), - eq(techVendorPossibleItems.itemCode, itemCode) - ) - }); - - // 존재하지 않으면 추가 - if (!existing) { - await tx.insert(techVendorPossibleItems).values({ - vendorId: vendorId, - itemCode: itemCode, - }); - } - } - } - } catch (possibleItemError) { - // tech_vendor_possible_items 추가 실패는 전체 실패로 처리하지 않음 - console.warn(`벤더 ${vendorId}의 가능 아이템 추가 실패:`, possibleItemError); - } - - results.push({ id: quotation.id, vendorId, vendorName: vendor.vendorName }); - } catch (vendorError) { - console.error(`Error adding vendor ${vendorId}:`, vendorError); - errors.push(`벤더 ID ${vendorId} 추가 중 오류가 발생했습니다.`); - } - } - - // 3. RFQ 상태가 "RFQ Created"이고 성공적으로 추가된 벤더가 있는 경우 상태 업데이트 - if (rfq.status === "RFQ Created" && results.length > 0) { - await tx.update(techSalesRfqs) - .set({ - status: "RFQ Vendor Assignned", - updatedBy: input.createdBy, - updatedAt: new Date() - }) - .where(eq(techSalesRfqs.id, input.rfqId)); - } - - // 캐시 무효화 (RFQ 타입에 따른 동적 경로) - revalidateTag("techSalesRfqs"); - revalidateTag("techSalesVendorQuotations"); - revalidateTag(`techSalesRfq-${input.rfqId}`); - revalidatePath(getTechSalesRevalidationPath(rfq.rfqType || "SHIP")); - - return { - data: results, - error: errors.length > 0 ? errors.join(", ") : null, - successCount: results.length, - errorCount: errors.length - }; - }); - } 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에 존재하지 않습니다." }; - } - - // Assigned 상태가 아닌 경우 삭제 불가 - if (existingQuotation.status !== "Assigned") { - return { data: null, error: "Assigned 상태의 벤더만 삭제할 수 있습니다." }; - } - - // 해당 벤더의 견적서 삭제 - 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; - } - - // Assigned 상태가 아닌 경우 삭제 불가 - if (existingQuotation.status !== "Assigned") { - errors.push(`벤더 ID ${vendorId}는 Assigned 상태가 아니므로 삭제할 수 없습니다.`); - 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" ? "조선" : - rfqType === "TOP" ? "해양TOP" : - rfqType === "HULL" ? "해양HULL" : null; - - const whereConditions = [ - eq(techVendors.status, "ACTIVE"), - or( - ilike(techVendors.vendorName, `%${searchTerm}%`), - ilike(techVendors.vendorCode, `%${searchTerm}%`) - ) - ]; - - // RFQ 타입이 지정된 경우 벤더 타입 필터링 추가 (컴마 구분 문자열에서 검색) - if (vendorTypeFilter) { - whereConditions.push(sql`${techVendors.techVendorType} LIKE ${'%' + 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)); - } -} - - -/** - * 벤더 견적서 거절 처리 (벤더가 직접 거절) - */ -export async function rejectTechSalesVendorQuotations(input: { - quotationIds: number[]; - rejectionReason?: string; -}) { - try { - const session = await getServerSession(authOptions); - if (!session?.user?.id) { - throw new Error("인증이 필요합니다."); - } - - const result = await db.transaction(async (tx) => { - // 견적서들이 존재하고 벤더가 권한이 있는지 확인 - const quotations = await tx - .select({ - id: techSalesVendorQuotations.id, - status: techSalesVendorQuotations.status, - vendorId: techSalesVendorQuotations.vendorId, - }) - .from(techSalesVendorQuotations) - .where(inArray(techSalesVendorQuotations.id, input.quotationIds)); - - if (quotations.length !== input.quotationIds.length) { - throw new Error("일부 견적서를 찾을 수 없습니다."); - } - - // 이미 거절된 견적서가 있는지 확인 - const alreadyRejected = quotations.filter(q => q.status === "Rejected"); - if (alreadyRejected.length > 0) { - throw new Error("이미 거절된 견적서가 포함되어 있습니다."); - } - - // 승인된 견적서가 있는지 확인 - const alreadyAccepted = quotations.filter(q => q.status === "Accepted"); - if (alreadyAccepted.length > 0) { - throw new Error("이미 승인된 견적서는 거절할 수 없습니다."); - } - - // 견적서 상태를 거절로 변경 - await tx - .update(techSalesVendorQuotations) - .set({ - status: "Rejected", - rejectionReason: input.rejectionReason || null, - updatedBy: parseInt(session.user.id), - updatedAt: new Date(), - }) - .where(inArray(techSalesVendorQuotations.id, input.quotationIds)); - - return { success: true, updatedCount: quotations.length }; - }); - revalidateTag("techSalesRfqs"); - revalidateTag("techSalesVendorQuotations"); - revalidatePath("/partners/techsales/rfq-ship", "page"); - return { - success: true, - message: `${result.updatedCount}개의 견적서가 거절되었습니다.`, - data: result - }; - } catch (error) { - console.error("견적서 거절 오류:", error); - return { - success: false, - error: getErrorMessage(error) - }; - } -} - -// ==================== Revision 관련 ==================== - -/** - * 견적서 revision 히스토리 조회 - */ -export async function getTechSalesVendorQuotationRevisions(quotationId: number) { - try { - const revisions = await db - .select({ - id: techSalesVendorQuotationRevisions.id, - version: techSalesVendorQuotationRevisions.version, - snapshot: techSalesVendorQuotationRevisions.snapshot, - changeReason: techSalesVendorQuotationRevisions.changeReason, - revisionNote: techSalesVendorQuotationRevisions.revisionNote, - revisedBy: techSalesVendorQuotationRevisions.revisedBy, - revisedAt: techSalesVendorQuotationRevisions.revisedAt, - // 수정자 정보 조인 - revisedByName: users.name, - }) - .from(techSalesVendorQuotationRevisions) - .leftJoin(users, eq(techSalesVendorQuotationRevisions.revisedBy, users.id)) - .where(eq(techSalesVendorQuotationRevisions.quotationId, quotationId)) - .orderBy(desc(techSalesVendorQuotationRevisions.version)); - - return { data: revisions, error: null }; - } catch (error) { - console.error("견적서 revision 히스토리 조회 오류:", error); - return { data: null, error: "견적서 히스토리를 조회하는 중 오류가 발생했습니다." }; - } -} - -/** - * 견적서의 현재 버전과 revision 히스토리를 함께 조회 (각 리비전의 첨부파일 포함) - */ -export async function getTechSalesVendorQuotationWithRevisions(quotationId: number) { - try { - // 먼저 현재 견적서 조회 - const currentQuotation = await db.query.techSalesVendorQuotations.findFirst({ - where: eq(techSalesVendorQuotations.id, quotationId), - with: { - // 벤더 정보와 RFQ 정보도 함께 조회 (필요한 경우) - } - }); - - if (!currentQuotation) { - return { data: null, error: "견적서를 찾을 수 없습니다." }; - } - - // 이제 현재 견적서의 정보를 알고 있으므로 병렬로 나머지 정보 조회 - const [revisionsResult, currentAttachments] = await Promise.all([ - getTechSalesVendorQuotationRevisions(quotationId), - getTechSalesVendorQuotationAttachmentsByRevision(quotationId, currentQuotation.quotationVersion || 0) - ]); - - // 현재 견적서에 첨부파일 정보 추가 - const currentWithAttachments = { - ...currentQuotation, - attachments: currentAttachments.data || [] - }; - - // 각 리비전의 첨부파일 정보 추가 - const revisionsWithAttachments = await Promise.all( - (revisionsResult.data || []).map(async (revision) => { - const attachmentsResult = await getTechSalesVendorQuotationAttachmentsByRevision(quotationId, revision.version); - return { - ...revision, - attachments: attachmentsResult.data || [] - }; - }) - ); - - return { - data: { - current: currentWithAttachments, - revisions: revisionsWithAttachments - }, - error: null - }; - } catch (error) { - console.error("견적서 전체 히스토리 조회 오류:", error); - return { data: null, error: "견적서 정보를 조회하는 중 오류가 발생했습니다." }; - } -} - -/** - * 견적서 첨부파일 조회 (리비전 ID 기준 오름차순 정렬) - */ -export async function getTechSalesVendorQuotationAttachments(quotationId: number) { - return unstable_cache( - async () => { - try { - const attachments = await db - .select({ - id: techSalesVendorQuotationAttachments.id, - quotationId: techSalesVendorQuotationAttachments.quotationId, - revisionId: techSalesVendorQuotationAttachments.revisionId, - fileName: techSalesVendorQuotationAttachments.fileName, - originalFileName: techSalesVendorQuotationAttachments.originalFileName, - fileSize: techSalesVendorQuotationAttachments.fileSize, - fileType: techSalesVendorQuotationAttachments.fileType, - filePath: techSalesVendorQuotationAttachments.filePath, - description: techSalesVendorQuotationAttachments.description, - uploadedBy: techSalesVendorQuotationAttachments.uploadedBy, - vendorId: techSalesVendorQuotationAttachments.vendorId, - isVendorUpload: techSalesVendorQuotationAttachments.isVendorUpload, - createdAt: techSalesVendorQuotationAttachments.createdAt, - updatedAt: techSalesVendorQuotationAttachments.updatedAt, - }) - .from(techSalesVendorQuotationAttachments) - .where(eq(techSalesVendorQuotationAttachments.quotationId, quotationId)) - .orderBy(desc(techSalesVendorQuotationAttachments.createdAt)); - - return { data: attachments }; - } catch (error) { - console.error("견적서 첨부파일 조회 오류:", error); - return { error: "견적서 첨부파일 조회 중 오류가 발생했습니다." }; - } - }, - [`quotation-attachments-${quotationId}`], - { - revalidate: 60, - tags: [`quotation-${quotationId}`, "quotation-attachments"], - } - )(); -} - -/** - * 특정 리비전의 견적서 첨부파일 조회 - */ -export async function getTechSalesVendorQuotationAttachmentsByRevision(quotationId: number, revisionId: number) { - try { - const attachments = await db - .select({ - id: techSalesVendorQuotationAttachments.id, - quotationId: techSalesVendorQuotationAttachments.quotationId, - revisionId: techSalesVendorQuotationAttachments.revisionId, - fileName: techSalesVendorQuotationAttachments.fileName, - originalFileName: techSalesVendorQuotationAttachments.originalFileName, - fileSize: techSalesVendorQuotationAttachments.fileSize, - fileType: techSalesVendorQuotationAttachments.fileType, - filePath: techSalesVendorQuotationAttachments.filePath, - description: techSalesVendorQuotationAttachments.description, - uploadedBy: techSalesVendorQuotationAttachments.uploadedBy, - vendorId: techSalesVendorQuotationAttachments.vendorId, - isVendorUpload: techSalesVendorQuotationAttachments.isVendorUpload, - createdAt: techSalesVendorQuotationAttachments.createdAt, - updatedAt: techSalesVendorQuotationAttachments.updatedAt, - }) - .from(techSalesVendorQuotationAttachments) - .where(and( - eq(techSalesVendorQuotationAttachments.quotationId, quotationId), - eq(techSalesVendorQuotationAttachments.revisionId, revisionId) - )) - .orderBy(desc(techSalesVendorQuotationAttachments.createdAt)); - - return { data: attachments }; - } catch (error) { - console.error("리비전별 견적서 첨부파일 조회 오류:", error); - return { error: "첨부파일 조회 중 오류가 발생했습니다." }; - } -} - - -// ==================== Project AVL 관련 ==================== - -/** - * Accepted 상태의 Tech Sales Vendor Quotations 조회 (RFQ, Vendor 정보 포함) - */ -export async function getAcceptedTechSalesVendorQuotations(input: { - search?: string; - filters?: Filter<typeof techSalesVendorQuotations>[]; - sort?: { id: string; desc: boolean }[]; - page: number; - perPage: number; - rfqType?: "SHIP" | "TOP" | "HULL"; -}) { - unstable_noStore(); - - try { - const offset = (input.page - 1) * input.perPage; - - // 기본 WHERE 조건: status = 'Accepted'만 조회, rfqType이 'SHIP'이 아닌 것만 - const baseConditions = [ - eq(techSalesVendorQuotations.status, 'Accepted'), - sql`${techSalesRfqs.rfqType} != 'SHIP'` // 조선 RFQ 타입 제외 - ]; - - // 검색 조건 추가 - const searchConditions = []; - if (input.search) { - searchConditions.push( - ilike(techSalesRfqs.rfqCode, `%${input.search}%`), - ilike(techSalesRfqs.description, `%${input.search}%`), - ilike(sql`vendors.vendor_name`, `%${input.search}%`), - ilike(sql`vendors.vendor_code`, `%${input.search}%`) - ); - } - - // 정렬 조건 변환 - const orderByConditions: OrderByType[] = []; - if (input.sort?.length) { - input.sort.forEach((sortItem) => { - switch (sortItem.id) { - case "rfqCode": - orderByConditions.push(sortItem.desc ? desc(techSalesRfqs.rfqCode) : asc(techSalesRfqs.rfqCode)); - break; - case "description": - orderByConditions.push(sortItem.desc ? desc(techSalesRfqs.description) : asc(techSalesRfqs.description)); - break; - case "vendorName": - orderByConditions.push(sortItem.desc ? desc(sql`vendors.vendor_name`) : asc(sql`vendors.vendor_name`)); - break; - case "vendorCode": - orderByConditions.push(sortItem.desc ? desc(sql`vendors.vendor_code`) : asc(sql`vendors.vendor_code`)); - break; - case "totalPrice": - orderByConditions.push(sortItem.desc ? desc(techSalesVendorQuotations.totalPrice) : asc(techSalesVendorQuotations.totalPrice)); - break; - case "acceptedAt": - orderByConditions.push(sortItem.desc ? desc(techSalesVendorQuotations.acceptedAt) : asc(techSalesVendorQuotations.acceptedAt)); - break; - default: - orderByConditions.push(desc(techSalesVendorQuotations.acceptedAt)); - } - }); - } else { - orderByConditions.push(desc(techSalesVendorQuotations.acceptedAt)); - } - - // 필터 조건 추가 - const filterConditions = []; - if (input.filters?.length) { - const filterWhere = filterColumns({ - table: techSalesVendorQuotations, - filters: input.filters, - joinOperator: "and", - }); - if (filterWhere) { - filterConditions.push(filterWhere); - } - } - - // RFQ 타입 필터 - if (input.rfqType) { - filterConditions.push(eq(techSalesRfqs.rfqType, input.rfqType)); - } - - // 모든 조건 결합 - const allConditions = [ - ...baseConditions, - ...filterConditions, - ...(searchConditions.length > 0 ? [or(...searchConditions)] : []) - ]; - - const whereCondition = allConditions.length > 1 - ? and(...allConditions) - : allConditions[0]; - - // 데이터 조회 - const data = await db - .select({ - // Quotation 정보 - 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, - submittedAt: techSalesVendorQuotations.submittedAt, - acceptedAt: techSalesVendorQuotations.acceptedAt, - createdAt: techSalesVendorQuotations.createdAt, - updatedAt: techSalesVendorQuotations.updatedAt, - - // RFQ 정보 - rfqCode: techSalesRfqs.rfqCode, - rfqType: techSalesRfqs.rfqType, - description: techSalesRfqs.description, - dueDate: techSalesRfqs.dueDate, - rfqStatus: techSalesRfqs.status, - materialCode: techSalesRfqs.materialCode, - - // Vendor 정보 - vendorName: sql<string>`vendors.vendor_name`, - vendorCode: sql<string | null>`vendors.vendor_code`, - vendorEmail: sql<string | null>`vendors.email`, - vendorCountry: sql<string | null>`vendors.country`, - - // Project 정보 - projNm: biddingProjects.projNm, - pspid: biddingProjects.pspid, - sector: biddingProjects.sector, - }) - .from(techSalesVendorQuotations) - .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id)) - .leftJoin(sql`vendors`, eq(techSalesVendorQuotations.vendorId, sql`vendors.id`)) - .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id)) - .where(whereCondition) - .orderBy(...orderByConditions) - .limit(input.perPage) - .offset(offset); - - // 총 개수 조회 - const totalCount = await db - .select({ count: count() }) - .from(techSalesVendorQuotations) - .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id)) - .leftJoin(sql`vendors`, eq(techSalesVendorQuotations.vendorId, sql`vendors.id`)) - .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id)) - .where(whereCondition); - - const total = totalCount[0]?.count ?? 0; - const pageCount = Math.ceil(total / input.perPage); - - return { - data, - pageCount, - total, - }; - - } catch (error) { - console.error("getAcceptedTechSalesVendorQuotations 오류:", error); - throw new Error(`Accepted quotations 조회 실패: ${getErrorMessage(error)}`); - } -} - -export async function getBidProjects(pjtType: 'SHIP' | 'TOP' | 'HULL'): Promise<Project[]> { - try { - // 트랜잭션을 사용하여 프로젝트 데이터 조회 - const projectList = await db.transaction(async (tx) => { - // 기본 쿼리 구성 - const query = tx - .select({ - id: biddingProjects.id, - projectCode: biddingProjects.pspid, - projectName: biddingProjects.projNm, - pjtType: biddingProjects.pjtType, - }) - .from(biddingProjects) - .where(eq(biddingProjects.pjtType, pjtType)); - - const results = await query.orderBy(biddingProjects.id); - return results; - }); - - // Handle null projectName values and ensure pjtType is not null - const validProjectList = projectList.map(project => ({ - ...project, - projectName: project.projectName || '', // Replace null with empty string - pjtType: project.pjtType as "SHIP" | "TOP" | "HULL" // Type assertion since WHERE filters ensure non-null - })); - - return validProjectList; - } catch (error) { - console.error("프로젝트 목록 가져오기 실패:", error); - return []; // 오류 발생 시 빈 배열 반환 - } +'use server'
+
+import { unstable_noStore, revalidateTag, revalidatePath } from "next/cache";
+import db from "@/db/db";
+import {
+ techSalesRfqs,
+ techSalesVendorQuotations,
+ techSalesVendorQuotationRevisions,
+ techSalesAttachments,
+ techSalesVendorQuotationAttachments,
+ techSalesVendorQuotationContacts,
+ techSalesContactPossibleItems,
+ users,
+ techSalesRfqComments,
+ techSalesRfqItems,
+ biddingProjects
+} from "@/db/schema";
+import { and, desc, eq, ilike, or, sql, inArray, count, asc } from "drizzle-orm";
+import { unstable_cache } from "@/lib/unstable-cache";
+import { filterColumns } from "@/lib/filter-columns";
+import { getErrorMessage } from "@/lib/handle-error";
+import type { Filter } from "@/types/table";
+import {
+ selectTechSalesRfqsWithJoin,
+ countTechSalesRfqsWithJoin,
+ selectTechSalesVendorQuotationsWithJoin,
+ countTechSalesVendorQuotationsWithJoin,
+ 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 } from "../utils";
+import { techVendors, techVendorPossibleItems, techVendorContacts } from "@/db/schema/techVendors";
+import { deleteFile, saveDRMFile, saveFile } from "@/lib/file-stroage";
+import { decryptWithServerAction } from "@/components/drm/drmUtils";
+
+// 정렬 타입 정의
+// 의도적으로 any 사용 - drizzle ORM의 orderBy 타입이 복잡함
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type OrderByType = any;
+
+export type Project = {
+ id: number;
+ projectCode: string;
+ projectName: string;
+ pjtType: "SHIP" | "TOP" | "HULL";
+}
+
+/**
+ * 연도별 순차 RFQ 코드 생성 함수 (다중 생성 지원)
+ * 형식: RFQ-YYYY-001, RFQ-YYYY-002, ...
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+async function generateRfqCodes(tx: any, count: number, year?: number): Promise<string[]> {
+ const currentYear = year || new Date().getFullYear();
+ const yearPrefix = `RFQ-${currentYear}-`;
+
+ // 해당 연도의 가장 최근 RFQ 코드 조회
+ const latestRfq = await tx
+ .select({ rfqCode: techSalesRfqs.rfqCode })
+ .from(techSalesRfqs)
+ .where(ilike(techSalesRfqs.rfqCode, `${yearPrefix}%`))
+ .orderBy(desc(techSalesRfqs.rfqCode))
+ .limit(1);
+
+ let nextNumber = 1;
+
+ if (latestRfq.length > 0) {
+ // 기존 코드에서 번호 추출 (RFQ-2024-001 -> 001)
+ const lastCode = latestRfq[0].rfqCode;
+ const numberPart = lastCode.split('-').pop();
+ if (numberPart) {
+ const lastNumber = parseInt(numberPart, 10);
+ if (!isNaN(lastNumber)) {
+ nextNumber = lastNumber + 1;
+ }
+ }
+ }
+
+ // 요청된 개수만큼 순차적으로 코드 생성
+ const codes: string[] = [];
+ for (let i = 0; i < count; i++) {
+ const paddedNumber = (nextNumber + i).toString().padStart(3, '0');
+ codes.push(`${yearPrefix}${paddedNumber}`);
+ }
+
+ return codes;
+}
+
+
+/**
+ * 직접 조인을 사용하여 RFQ 데이터 조회하는 함수
+ * 페이지네이션, 필터링, 정렬 등 지원
+ */
+export async function getTechSalesRfqsWithJoin(input: GetTechSalesRfqsSchema & { rfqType?: "SHIP" | "TOP" | "HULL" }) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // 기본 필터 처리 - RFQFilterBox에서 오는 필터
+ const basicFilters = input.basicFilters || [];
+ const basicJoinOperator = input.basicJoinOperator || "and";
+ // 고급 필터 처리 - 테이블의 DataTableFilterList에서 오는 필터
+ const advancedFilters = input.filters || [];
+ const advancedJoinOperator = input.joinOperator || "and";
+
+ // 기본 필터 조건 생성
+ let basicWhere;
+ if (basicFilters.length > 0) {
+ basicWhere = filterColumns({
+ table: techSalesRfqs,
+ filters: basicFilters,
+ joinOperator: basicJoinOperator,
+ });
+ }
+
+ // 고급 필터 조건 생성
+ let advancedWhere;
+ if (advancedFilters.length > 0) {
+ advancedWhere = filterColumns({
+ table: techSalesRfqs,
+ filters: advancedFilters,
+ joinOperator: advancedJoinOperator,
+ });
+ }
+
+ // 전역 검색 조건
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(techSalesRfqs.rfqCode, s),
+ ilike(techSalesRfqs.materialCode, s),
+ ilike(techSalesRfqs.description, s),
+ ilike(techSalesRfqs.remark, s)
+ );
+ }
+
+ // 모든 조건 결합
+ const whereConditions = [];
+ if (basicWhere) whereConditions.push(basicWhere);
+ if (advancedWhere) whereConditions.push(advancedWhere);
+ if (globalWhere) whereConditions.push(globalWhere);
+
+ // 조건이 있을 때만 and() 사용
+ const finalWhere = whereConditions.length > 0
+ ? and(...whereConditions)
+ : undefined;
+
+ // 정렬 기준 설정
+ let orderBy: OrderByType[] = [desc(techSalesRfqs.createdAt)]; // 기본 정렬
+
+ if (input.sort?.length) {
+ // 안전하게 접근하여 정렬 기준 설정
+ orderBy = input.sort.map(item => {
+ // TypeScript 에러 방지를 위한 타입 단언
+ const sortField = item.id as string;
+
+ switch (sortField) {
+ case 'id':
+ return item.desc ? desc(techSalesRfqs.id) : techSalesRfqs.id;
+ case 'rfqCode':
+ return item.desc ? desc(techSalesRfqs.rfqCode) : techSalesRfqs.rfqCode;
+ case 'materialCode':
+ return item.desc ? desc(techSalesRfqs.materialCode) : techSalesRfqs.materialCode;
+ 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 'remark':
+ return item.desc ? desc(techSalesRfqs.remark) : techSalesRfqs.remark;
+ case 'createdAt':
+ return item.desc ? desc(techSalesRfqs.createdAt) : techSalesRfqs.createdAt;
+ case 'updatedAt':
+ return item.desc ? desc(techSalesRfqs.updatedAt) : techSalesRfqs.updatedAt;
+ default:
+ return item.desc ? desc(techSalesRfqs.createdAt) : techSalesRfqs.createdAt;
+ }
+ });
+ }
+
+ // 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) };
+ });
+ } catch (err) {
+ console.error("Error fetching RFQs with join:", err);
+ return { data: [], pageCount: 0, total: 0 };
+ }
+ },
+ [JSON.stringify(input)],
+ {
+ revalidate: 60,
+ tags: ["techSalesRfqs"],
+ }
+ )();
+}
+
+/**
+ * 직접 조인을 사용하여 벤더 견적서 조회하는 함수
+ */
+export async function getTechSalesVendorQuotationsWithJoin(input: {
+ rfqId?: number;
+ vendorId?: number;
+ search?: string;
+ filters?: Filter<typeof techSalesVendorQuotations>[];
+ sort?: { id: string; desc: boolean }[];
+ page: number;
+ perPage: number;
+ rfqType?: "SHIP" | "TOP" | "HULL"; // rfqType 매개변수 추가
+}) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // 기본 필터 조건들
+ const whereConditions = [];
+
+ // RFQ ID 필터
+ if (input.rfqId) {
+ whereConditions.push(eq(techSalesVendorQuotations.rfqId, input.rfqId));
+ }
+
+ // 벤더 ID 필터
+ if (input.vendorId) {
+ whereConditions.push(eq(techSalesVendorQuotations.vendorId, input.vendorId));
+ }
+
+ // 검색 조건
+ if (input.search) {
+ const s = `%${input.search}%`;
+ const searchCondition = or(
+ ilike(techSalesVendorQuotations.currency, s),
+ ilike(techSalesVendorQuotations.status, s)
+ );
+ if (searchCondition) {
+ whereConditions.push(searchCondition);
+ }
+ }
+
+ // 고급 필터 처리
+ if (input.filters && input.filters.length > 0) {
+ const filterWhere = filterColumns({
+ table: techSalesVendorQuotations,
+ filters: input.filters as Filter<typeof techSalesVendorQuotations>[],
+ joinOperator: "and",
+ });
+ if (filterWhere) {
+ whereConditions.push(filterWhere);
+ }
+ }
+
+ // 최종 WHERE 조건
+ const finalWhere = whereConditions.length > 0
+ ? and(...whereConditions)
+ : undefined;
+
+ // 정렬 기준 설정
+ let orderBy: OrderByType[] = [desc(techSalesVendorQuotations.createdAt)];
+
+ if (input.sort?.length) {
+ orderBy = input.sort.map(item => {
+ switch (item.id) {
+ case 'id':
+ return item.desc ? desc(techSalesVendorQuotations.id) : techSalesVendorQuotations.id;
+ case 'status':
+ return item.desc ? desc(techSalesVendorQuotations.status) : techSalesVendorQuotations.status;
+ case 'currency':
+ return item.desc ? desc(techSalesVendorQuotations.currency) : techSalesVendorQuotations.currency;
+ case 'totalPrice':
+ return item.desc ? desc(techSalesVendorQuotations.totalPrice) : techSalesVendorQuotations.totalPrice;
+ case 'createdAt':
+ return item.desc ? desc(techSalesVendorQuotations.createdAt) : techSalesVendorQuotations.createdAt;
+ case 'updatedAt':
+ return item.desc ? desc(techSalesVendorQuotations.updatedAt) : techSalesVendorQuotations.updatedAt;
+ default:
+ return item.desc ? desc(techSalesVendorQuotations.createdAt) : techSalesVendorQuotations.createdAt;
+ }
+ });
+ }
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectTechSalesVendorQuotationsWithJoin(tx, {
+ where: finalWhere,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+ // 각 견적서의 첨부파일 정보 조회
+ const dataWithAttachments = await Promise.all(
+ data.map(async (quotation) => {
+ const attachments = await db.query.techSalesVendorQuotationAttachments.findMany({
+ where: eq(techSalesVendorQuotationAttachments.quotationId, quotation.id),
+ orderBy: [desc(techSalesVendorQuotationAttachments.createdAt)],
+ });
+
+ return {
+ ...quotation,
+ quotationAttachments: attachments.map(att => ({
+ id: att.id,
+ fileName: att.fileName,
+ fileSize: att.fileSize,
+ filePath: att.filePath,
+ description: att.description,
+ }))
+ };
+ })
+ );
+
+ const total = await countTechSalesVendorQuotationsWithJoin(tx, finalWhere);
+ return { data: dataWithAttachments, total };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data, pageCount, total };
+ } catch (err) {
+ console.error("Error fetching vendor quotations with join:", err);
+ return { data: [], pageCount: 0, total: 0 };
+ }
+ },
+ [JSON.stringify(input)],
+ {
+ revalidate: 60,
+ tags: [
+ "techSalesVendorQuotations",
+ ...(input.rfqId ? [`techSalesRfq-${input.rfqId}`] : [])
+ ],
+ }
+ )();
+}
+
+/**
+ * 직접 조인을 사용하여 RFQ 대시보드 데이터 조회하는 함수
+ */
+export async function getTechSalesDashboardWithJoin(input: {
+ search?: string;
+ filters?: Filter<typeof techSalesRfqs>[];
+ sort?: { id: string; desc: boolean }[];
+ page: number;
+ perPage: number;
+ rfqType?: "SHIP" | "TOP" | "HULL"; // rfqType 매개변수 추가
+}) {
+ unstable_noStore(); // 대시보드는 항상 최신 데이터를 보여주기 위해 캐시하지 않음
+
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // Advanced filtering
+ const advancedWhere = input.filters ? filterColumns({
+ table: techSalesRfqs,
+ filters: input.filters as Filter<typeof techSalesRfqs>[],
+ joinOperator: 'and',
+ }) : undefined;
+
+ // Global search
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(techSalesRfqs.rfqCode, s),
+ ilike(techSalesRfqs.materialCode, s),
+ ilike(techSalesRfqs.description, s)
+ );
+ }
+
+ const finalWhere = and(
+ advancedWhere,
+ globalWhere
+ );
+
+ // 정렬 기준 설정
+ let orderBy: OrderByType[] = [desc(techSalesRfqs.updatedAt)]; // 기본 정렬
+
+ if (input.sort?.length) {
+ // 안전하게 접근하여 정렬 기준 설정
+ orderBy = input.sort.map(item => {
+ switch (item.id) {
+ case 'id':
+ return item.desc ? desc(techSalesRfqs.id) : techSalesRfqs.id;
+ case 'rfqCode':
+ return item.desc ? desc(techSalesRfqs.rfqCode) : techSalesRfqs.rfqCode;
+ case 'status':
+ return item.desc ? desc(techSalesRfqs.status) : techSalesRfqs.status;
+ case 'dueDate':
+ return item.desc ? desc(techSalesRfqs.dueDate) : techSalesRfqs.dueDate;
+ case 'createdAt':
+ return item.desc ? desc(techSalesRfqs.createdAt) : techSalesRfqs.createdAt;
+ case 'updatedAt':
+ return item.desc ? desc(techSalesRfqs.updatedAt) : techSalesRfqs.updatedAt;
+ default:
+ return item.desc ? desc(techSalesRfqs.updatedAt) : techSalesRfqs.updatedAt;
+ }
+ });
+ }
+
+ // 트랜잭션 내부에서 Repository 호출
+ const data = await db.transaction(async (tx) => {
+ return await selectTechSalesDashboardWithJoin(tx, {
+ where: finalWhere,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ rfqType: input.rfqType, // rfqType 매개변수 추가
+ });
+ });
+
+ return { data, success: true };
+ } catch (err) {
+ console.error("Error fetching dashboard data with join:", err);
+ return { data: [], success: false, error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 특정 RFQ의 벤더 목록 조회
+ */
+export async function getTechSalesRfqVendors(rfqId: number) {
+ unstable_noStore();
+ try {
+ // Repository 함수를 사용하여 벤더 견적 목록 조회
+ const result = await getTechSalesVendorQuotationsWithJoin({
+ rfqId,
+ page: 1,
+ perPage: 1000, // 충분히 큰 수로 설정하여 모든 벤더 조회
+ });
+
+ return { data: result.data, error: null };
+ } catch (err) {
+ console.error("Error fetching RFQ vendors:", err);
+ return { data: [], error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 기술영업 RFQ 발송 (선택된 벤더들의 선택된 contact들에게)
+ */
+export async function sendTechSalesRfqToVendors(input: {
+ rfqId: number;
+ vendorIds: number[];
+ selectedContacts?: Array<{
+ vendorId: number;
+ contactId: number;
+ contactEmail: string;
+ contactName: string;
+ }>;
+}) {
+ unstable_noStore();
+ try {
+ // 인증 확인
+ const session = await getServerSession(authOptions);
+
+ if (!session?.user) {
+ return {
+ success: false,
+ message: "인증이 필요합니다",
+ };
+ }
+
+ // RFQ 정보 조회
+ const rfq = await db.query.techSalesRfqs.findFirst({
+ where: eq(techSalesRfqs.id, input.rfqId),
+ columns: {
+ id: true,
+ rfqCode: true,
+ status: true,
+ dueDate: true,
+ rfqSendDate: true,
+ remark: true,
+ materialCode: true,
+ description: true,
+ rfqType: true,
+ },
+ with: {
+ biddingProject: true,
+ createdByUser: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ }
+ }
+ }
+ });
+
+ if (!rfq) {
+ return {
+ success: false,
+ message: "RFQ를 찾을 수 없습니다",
+ };
+ }
+
+ // 발송 가능한 상태인지 확인
+ if (rfq.status !== "RFQ Vendor Assignned" && rfq.status !== "RFQ Sent") {
+ return {
+ success: false,
+ message: "벤더가 할당된 RFQ 또는 이미 전송된 RFQ만 다시 전송할 수 있습니다",
+ };
+ }
+
+ const isResend = rfq.status === "RFQ Sent";
+
+ // 현재 사용자 정보 조회
+ const sender = await db.query.users.findFirst({
+ where: eq(users.id, Number(session.user.id)),
+ columns: {
+ id: true,
+ email: true,
+ name: true,
+ }
+ });
+
+ if (!sender || !sender.email) {
+ return {
+ success: false,
+ message: "보내는 사람의 이메일 정보를 찾을 수 없습니다",
+ };
+ }
+
+ // 선택된 벤더들의 견적서 정보 조회
+ const vendorQuotations = await db.query.techSalesVendorQuotations.findMany({
+ where: and(
+ eq(techSalesVendorQuotations.rfqId, input.rfqId),
+ inArray(techSalesVendorQuotations.vendorId, input.vendorIds)
+ ),
+ columns: {
+ id: true,
+ vendorId: true,
+ status: true,
+ currency: true,
+ },
+ with: {
+ vendor: {
+ columns: {
+ id: true,
+ vendorName: true,
+ vendorCode: true,
+ }
+ }
+ }
+ });
+
+ if (vendorQuotations.length === 0) {
+ return {
+ success: false,
+ message: "선택된 벤더가 이 RFQ에 할당되어 있지 않습니다",
+ };
+ }
+
+ // 트랜잭션 시작
+ await db.transaction(async (tx) => {
+ // 1. RFQ 상태 업데이트 (최초 발송인 경우 rfqSendDate 설정)
+ const updateData: Partial<typeof techSalesRfqs.$inferInsert> = {
+ status: "RFQ Sent",
+ sentBy: Number(session.user.id),
+ updatedBy: Number(session.user.id),
+ updatedAt: new Date(),
+ };
+
+ // rfqSendDate가 null인 경우에만 최초 전송일 설정
+ if (!rfq.rfqSendDate) {
+ updateData.rfqSendDate = new Date();
+ }
+
+ await tx.update(techSalesRfqs)
+ .set(updateData)
+ .where(eq(techSalesRfqs.id, input.rfqId));
+
+ // 2. 선택된 벤더들의 견적서 상태를 "Assigned"에서 "Draft"로 변경
+ for (const quotation of vendorQuotations) {
+ if (quotation.status === "Assigned") {
+ await tx.update(techSalesVendorQuotations)
+ .set({
+ status: "Draft",
+ updatedBy: Number(session.user.id),
+ updatedAt: new Date(),
+ })
+ .where(eq(techSalesVendorQuotations.id, quotation.id));
+ }
+ }
+
+ // 3. 각 벤더에 대해 이메일 발송 처리
+ for (const quotation of vendorQuotations) {
+ if (!quotation.vendorId || !quotation.vendor) continue;
+
+ let vendorEmailsString = "";
+
+ // contact 기반 발송 또는 기존 방식 (모든 벤더 사용자)
+ if (input.selectedContacts && input.selectedContacts.length > 0) {
+ // 선택된 contact들에게만 발송
+ const vendorContacts = input.selectedContacts.filter(
+ contact => contact.vendorId === quotation.vendor!.id
+ );
+
+ if (vendorContacts.length > 0) {
+ vendorEmailsString = vendorContacts
+ .map(contact => contact.contactEmail)
+ .join(", ");
+ }
+ } else {
+ // 기존 방식: 벤더에 속한 모든 사용자에게 발송
+ const vendorUsers = await db.query.users.findMany({
+ where: eq(users.companyId, quotation.vendor.id),
+ columns: {
+ id: true,
+ email: true,
+ name: true,
+ language: true
+ }
+ });
+
+ vendorEmailsString = vendorUsers
+ .filter(user => user.email)
+ .map(user => user.email)
+ .join(", ");
+ }
+
+ if (vendorEmailsString) {
+ // 대표 언어 결정 (기본값 한국어)
+ const language = "ko";
+
+ // RFQ 아이템 목록 조회
+ const rfqItemsResult = await getTechSalesRfqItems(rfq.id);
+ const rfqItems = rfqItemsResult.data || [];
+
+ // 이메일 컨텍스트 구성
+ const emailContext = {
+ language: language,
+ rfq: {
+ id: rfq.id,
+ code: rfq.rfqCode,
+ 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, "KR") : 'N/A',
+ materialCode: rfq.materialCode || '',
+ type: rfq.rfqType || 'SHIP',
+ },
+ items: rfqItems.map(item => ({
+ itemCode: item.itemCode,
+ itemList: item.itemList,
+ workType: item.workType,
+ shipTypes: item.shipTypes,
+ subItemList: item.subItemList,
+ itemType: item.itemType,
+ })),
+ vendor: {
+ id: quotation.vendor.id,
+ code: quotation.vendor.vendorCode || '',
+ name: quotation.vendor.vendorName,
+ },
+ sender: {
+ fullName: sender.name || '',
+ email: sender.email,
+ },
+ project: {
+ id: rfq.biddingProject?.pspid || '',
+ name: rfq.biddingProject?.projNm || '',
+ sector: rfq.biddingProject?.sector || '',
+ shipType: rfq.biddingProject?.ptypeNm || '',
+ shipCount: rfq.biddingProject?.projMsrm || 0,
+ ownerName: rfq.biddingProject?.kunnrNm || '',
+ className: rfq.biddingProject?.cls1Nm || '',
+ },
+ details: {
+ currency: quotation.currency || 'USD',
+ },
+ quotationCode: `${rfq.rfqCode}-${quotation.vendorId}`,
+ systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://60.101.108.100/ko/partners',
+ isResend: isResend,
+ versionInfo: isResend ? '(재전송)' : '',
+ }
+
+
+
+ // 이메일 전송
+ await sendEmail({
+ to: vendorEmailsString,
+ subject: isResend
+ ? `[기술영업 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에 추가
+ });
+
+ // 4. 선택된 담당자 정보를 quotation_contacts 테이블에 저장
+ if (input.selectedContacts && input.selectedContacts.length > 0) {
+ const vendorContacts = input.selectedContacts.filter(
+ contact => contact.vendorId === quotation.vendor!.id
+ );
+
+ for (const contact of vendorContacts) {
+ // quotation_contacts 중복 체크
+ const existingQuotationContact = await tx.query.techSalesVendorQuotationContacts.findFirst({
+ where: and(
+ eq(techSalesVendorQuotationContacts.quotationId, quotation.id),
+ eq(techSalesVendorQuotationContacts.contactId, contact.contactId)
+ )
+ });
+
+ if (!existingQuotationContact) {
+ await tx.insert(techSalesVendorQuotationContacts).values({
+ quotationId: quotation.id,
+ contactId: contact.contactId,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ });
+ }
+
+ // 5. 담당자별 아이템 매핑 정보 저장 (중복 방지)
+ for (const item of rfqItems) {
+ // tech_vendor_possible_items에서 해당 벤더의 아이템 찾기
+ const vendorPossibleItem = await tx.query.techVendorPossibleItems.findFirst({
+ where: and(
+ eq(techVendorPossibleItems.vendorId, quotation.vendor!.id),
+ eq(techVendorPossibleItems.itemCode, item.itemCode || '')
+ )
+ });
+
+ if (vendorPossibleItem) {
+ // contact_possible_items 중복 체크
+ const existingContactPossibleItem = await tx.query.techSalesContactPossibleItems.findFirst({
+ where: and(
+ eq(techSalesContactPossibleItems.contactId, contact.contactId),
+ eq(techSalesContactPossibleItems.vendorPossibleItemId, vendorPossibleItem.id)
+ )
+ });
+
+ if (!existingContactPossibleItem) {
+ await tx.insert(techSalesContactPossibleItems).values({
+ contactId: contact.contactId,
+ vendorPossibleItemId: vendorPossibleItem.id,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ });
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ });
+
+ // 캐시 무효화
+ revalidateTag("techSalesRfqs");
+ revalidateTag("techSalesVendorQuotations");
+ revalidateTag(`techSalesRfq-${input.rfqId}`);
+ revalidatePath(getTechSalesRevalidationPath(rfq?.rfqType || "SHIP"));
+
+ const sentContactCount = input.selectedContacts?.length || vendorQuotations.length;
+ const messageDetail = input.selectedContacts && input.selectedContacts.length > 0
+ ? `${sentContactCount}명의 연락처에게 RFQ가 성공적으로 발송되었습니다`
+ : `${vendorQuotations.length}개 벤더에게 RFQ가 성공적으로 발송되었습니다`;
+
+ return {
+ success: true,
+ message: messageDetail,
+ sentCount: sentContactCount,
+ };
+ } catch (err) {
+ console.error("기술영업 RFQ 발송 오류:", err);
+ return {
+ success: false,
+ message: "RFQ 발송 중 오류가 발생했습니다",
+ };
+ }
+}
+
+/**
+ * 벤더용 기술영업 RFQ 견적서 조회 (withJoin 사용)
+ */
+export async function getTechSalesVendorQuotation(quotationId: number) {
+ unstable_noStore();
+ try {
+ const quotation = await db.transaction(async (tx) => {
+ return await selectSingleTechSalesVendorQuotationWithJoin(tx, quotationId);
+ });
+
+ if (!quotation) {
+ return { data: null, error: "견적서를 찾을 수 없습니다." };
+ }
+
+ // RFQ 아이템 정보도 함께 조회
+ const itemsResult = await getTechSalesRfqItems(quotation.rfqId);
+ const items = itemsResult.data || [];
+
+ // 견적서 첨부파일 조회
+ const quotationAttachments = await db.query.techSalesVendorQuotationAttachments.findMany({
+ where: eq(techSalesVendorQuotationAttachments.quotationId, quotationId),
+ orderBy: [desc(techSalesVendorQuotationAttachments.createdAt)],
+ });
+
+ // 기존 구조와 호환되도록 데이터 재구성
+ 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,
+ },
+
+ // 첨부파일 정보
+ quotationAttachments: quotationAttachments.map(attachment => ({
+ id: attachment.id,
+ fileName: attachment.fileName,
+ fileSize: attachment.fileSize,
+ filePath: attachment.filePath,
+ description: attachment.description,
+ }))
+ };
+
+ return { data: formattedQuotation, error: null };
+ } catch (err) {
+ console.error("Error fetching vendor quotation:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 기술영업 벤더 견적서 업데이트 (임시저장),
+ * 현재는 submit으로 처리, revision 을 아래의 함수로 사용가능함.
+ */
+export async function updateTechSalesVendorQuotation(data: {
+ id: number
+ currency: string
+ totalPrice: string
+ validUntil: Date
+ remark?: string
+ updatedBy: number
+ changeReason?: string
+}) {
+ try {
+ return await db.transaction(async (tx) => {
+ // 현재 견적서 전체 데이터 조회 (revision 저장용)
+ const currentQuotation = await tx.query.techSalesVendorQuotations.findFirst({
+ where: eq(techSalesVendorQuotations.id, data.id),
+ });
+
+ if (!currentQuotation) {
+ return { data: null, error: "견적서를 찾을 수 없습니다." };
+ }
+
+ // Accepted나 Rejected 상태가 아니면 수정 가능
+ if (["Rejected"].includes(currentQuotation.status)) {
+ return { data: null, error: "승인되거나 거절된 견적서는 수정할 수 없습니다." };
+ }
+
+ // 실제 변경사항이 있는지 확인
+ const hasChanges =
+ currentQuotation.currency !== data.currency ||
+ currentQuotation.totalPrice !== data.totalPrice ||
+ currentQuotation.validUntil?.getTime() !== data.validUntil.getTime() ||
+ currentQuotation.remark !== (data.remark || null);
+
+ if (!hasChanges) {
+ return { data: currentQuotation, error: null };
+ }
+
+ // 현재 버전을 revision history에 저장
+ await tx.insert(techSalesVendorQuotationRevisions).values({
+ quotationId: data.id,
+ version: currentQuotation.quotationVersion || 1,
+ snapshot: {
+ currency: currentQuotation.currency,
+ totalPrice: currentQuotation.totalPrice,
+ validUntil: currentQuotation.validUntil,
+ remark: currentQuotation.remark,
+ status: currentQuotation.status,
+ quotationVersion: currentQuotation.quotationVersion,
+ submittedAt: currentQuotation.submittedAt,
+ acceptedAt: currentQuotation.acceptedAt,
+ updatedAt: currentQuotation.updatedAt,
+ },
+ changeReason: data.changeReason || "견적서 수정",
+ revisedBy: data.updatedBy,
+ });
+
+ // 새로운 버전으로 업데이트
+ const result = await tx
+ .update(techSalesVendorQuotations)
+ .set({
+ currency: data.currency,
+ totalPrice: data.totalPrice,
+ validUntil: data.validUntil,
+ remark: data.remark || null,
+ quotationVersion: (currentQuotation.quotationVersion || 1) + 1,
+ status: "Revised", // 수정된 상태로 변경
+ updatedAt: new Date(),
+ })
+ .where(eq(techSalesVendorQuotations.id, data.id))
+ .returning();
+
+ return { data: result[0], error: null };
+ });
+ } catch (error) {
+ console.error("Error updating tech sales vendor quotation:", error);
+ return { data: null, error: "견적서 업데이트 중 오류가 발생했습니다" };
+ } finally {
+ // 캐시 무효화
+ revalidateTag("techSalesVendorQuotations");
+ revalidatePath(`/partners/techsales/rfq-ship/${data.id}`);
+ }
+}
+
+/**
+ * 기술영업 벤더 견적서 제출
+ */
+export async function submitTechSalesVendorQuotation(data: {
+ id: number
+ currency: string
+ totalPrice: string
+ validUntil: Date
+ remark?: string
+ attachments?: Array<{
+ fileName: string
+ originalFileName: string
+ filePath: string
+ fileSize: number
+ }>
+ updatedBy: number
+}) {
+ try {
+ return await db.transaction(async (tx) => {
+ // 현재 견적서 전체 데이터 조회 (revision 저장용)
+ const currentQuotation = await tx.query.techSalesVendorQuotations.findFirst({
+ where: eq(techSalesVendorQuotations.id, data.id),
+ });
+
+ if (!currentQuotation) {
+ return { data: null, error: "견적서를 찾을 수 없습니다." };
+ }
+
+ // Rejected 상태에서는 제출 불가
+ if (["Rejected"].includes(currentQuotation.status)) {
+ return { data: null, error: "거절된 견적서는 제출할 수 없습니다." };
+ }
+
+ // // 실제 변경사항이 있는지 확인
+ // const hasChanges =
+ // currentQuotation.currency !== data.currency ||
+ // currentQuotation.totalPrice !== data.totalPrice ||
+ // currentQuotation.validUntil?.getTime() !== data.validUntil.getTime() ||
+ // currentQuotation.remark !== (data.remark || null);
+
+ // // 변경사항이 있거나 처음 제출하는 경우 revision 저장
+ // if (hasChanges || currentQuotation.status === "Draft") {
+ // await tx.insert(techSalesVendorQuotationRevisions).values({
+ // quotationId: data.id,
+ // version: currentQuotation.quotationVersion || 1,
+ // snapshot: {
+ // currency: currentQuotation.currency,
+ // totalPrice: currentQuotation.totalPrice,
+ // validUntil: currentQuotation.validUntil,
+ // remark: currentQuotation.remark,
+ // status: currentQuotation.status,
+ // quotationVersion: currentQuotation.quotationVersion,
+ // submittedAt: currentQuotation.submittedAt,
+ // acceptedAt: currentQuotation.acceptedAt,
+ // updatedAt: currentQuotation.updatedAt,
+ // },
+ // changeReason: "견적서 제출",
+ // revisedBy: data.updatedBy,
+ // });
+ // }
+
+ // 첫 제출인지 확인 (quotationVersion이 null인 경우)
+ const isFirstSubmission = currentQuotation.quotationVersion === null;
+
+ // 첫 제출이 아닌 경우에만 revision 저장 (변경사항 이력 관리)
+ if (!isFirstSubmission) {
+ await tx.insert(techSalesVendorQuotationRevisions).values({
+ quotationId: data.id,
+ version: currentQuotation.quotationVersion || 1,
+ snapshot: {
+ currency: currentQuotation.currency,
+ totalPrice: currentQuotation.totalPrice,
+ validUntil: currentQuotation.validUntil,
+ remark: currentQuotation.remark,
+ status: currentQuotation.status,
+ quotationVersion: currentQuotation.quotationVersion,
+ submittedAt: currentQuotation.submittedAt,
+ acceptedAt: currentQuotation.acceptedAt,
+ updatedAt: currentQuotation.updatedAt,
+ },
+ changeReason: "견적서 제출",
+ revisedBy: data.updatedBy,
+ });
+ }
+
+ // 새로운 버전 번호 계산 (첫 제출은 1, 재제출은 1 증가)
+ const newRevisionId = isFirstSubmission ? 1 : (currentQuotation.quotationVersion || 1) + 1;
+
+ // 새로운 버전으로 업데이트
+ const result = await tx
+ .update(techSalesVendorQuotations)
+ .set({
+ currency: data.currency,
+ totalPrice: data.totalPrice,
+ validUntil: data.validUntil,
+ remark: data.remark || null,
+ quotationVersion: newRevisionId,
+ status: "Submitted",
+ submittedAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .where(eq(techSalesVendorQuotations.id, data.id))
+ .returning();
+
+ // 첨부파일 처리 (새로운 revisionId 사용)
+ if (data.attachments && data.attachments.length > 0) {
+ for (const attachment of data.attachments) {
+ await tx.insert(techSalesVendorQuotationAttachments).values({
+ quotationId: data.id,
+ revisionId: newRevisionId, // 새로운 리비전 ID 사용
+ fileName: attachment.fileName, // 해시된 파일명 (저장용)
+ originalFileName: attachment.originalFileName, // 원본 파일명 (표시용)
+ fileSize: attachment.fileSize,
+ filePath: attachment.filePath,
+ fileType: attachment.originalFileName.split('.').pop() || 'unknown',
+ uploadedBy: data.updatedBy,
+ isVendorUpload: true,
+ });
+ }
+ }
+
+ // 메일 발송 (백그라운드에서 실행)
+ if (result[0]) {
+ // 벤더에게 견적 제출 확인 메일 발송
+ sendQuotationSubmittedNotificationToVendor(data.id).catch(error => {
+ console.error("벤더 견적 제출 확인 메일 발송 실패:", error);
+ });
+
+ // 담당자에게 견적 접수 알림 메일 발송
+ sendQuotationSubmittedNotificationToManager(data.id).catch(error => {
+ console.error("담당자 견적 접수 알림 메일 발송 실패:", error);
+ });
+ }
+
+ return { data: result[0], error: null };
+ });
+ } catch (error) {
+ console.error("Error submitting tech sales vendor quotation:", error);
+ return { data: null, error: "견적서 제출 중 오류가 발생했습니다" };
+ } finally {
+ // 캐시 무효화
+ revalidateTag("techSalesVendorQuotations");
+ revalidatePath(`/partners/techsales/rfq-ship`);
+ }
+}
+
+/**
+ * 통화 목록 조회
+ */
+export async function fetchCurrencies() {
+ try {
+ // 기본 통화 목록 (실제로는 DB에서 가져와야 함)
+ const currencies = [
+ { code: "USD", name: "미국 달러" },
+ { code: "KRW", name: "한국 원" },
+ { code: "EUR", name: "유로" },
+ { code: "JPY", name: "일본 엔" },
+ { code: "CNY", name: "중국 위안" },
+ ]
+
+ return { data: currencies, error: null }
+ } catch (error) {
+ console.error("Error fetching currencies:", error)
+ return { data: null, error: "통화 목록 조회 중 오류가 발생했습니다" }
+ }
+}
+
+/**
+ * 벤더용 기술영업 견적서 목록 조회 (페이지네이션 포함)
+ */
+export async function getVendorQuotations(input: {
+ flags?: string[];
+ page: number;
+ perPage: number;
+ sort?: { id: string; desc: boolean }[];
+ filters?: Filter<typeof techSalesVendorQuotations>[];
+ joinOperator?: "and" | "or";
+ basicFilters?: Filter<typeof techSalesVendorQuotations>[];
+ basicJoinOperator?: "and" | "or";
+ search?: string;
+ from?: string;
+ to?: string;
+ rfqType?: "SHIP" | "TOP" | "HULL";
+}, vendorId: string) {
+ return unstable_cache(
+ async () => {
+ try {
+
+
+ const { page, perPage, sort, filters = [], search = "", from = "", to = "" } = input;
+ const offset = (page - 1) * perPage;
+ const limit = perPage;
+
+ // 기본 조건: 해당 벤더의 견적서만 조회 (Assigned 상태 제외)
+ 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),
+ sql`${techSalesVendorQuotations.status} != 'Assigned'` // Assigned 상태 제외
+ ];
+
+ // rfqType 필터링 추가
+ if (input.rfqType) {
+ baseConditions.push(eq(techSalesRfqs.rfqType, input.rfqType));
+ }
+
+ // 검색 조건 추가
+ if (search) {
+ const s = `%${search}%`;
+ const searchCondition = or(
+ ilike(techSalesVendorQuotations.currency, s),
+ ilike(techSalesVendorQuotations.status, s)
+ );
+ if (searchCondition) {
+ baseConditions.push(searchCondition);
+ }
+ }
+
+ // 날짜 범위 필터
+ if (from) {
+ baseConditions.push(sql`${techSalesVendorQuotations.createdAt} >= ${from}`);
+ }
+ if (to) {
+ baseConditions.push(sql`${techSalesVendorQuotations.createdAt} <= ${to}`);
+ }
+
+ // 고급 필터 처리
+ if (filters.length > 0) {
+ const filterWhere = filterColumns({
+ table: techSalesVendorQuotations,
+ filters: filters as Filter<typeof techSalesVendorQuotations>[],
+ joinOperator: input.joinOperator || "and",
+ });
+ if (filterWhere) {
+ baseConditions.push(filterWhere);
+ }
+ }
+
+ // 최종 WHERE 조건
+ const finalWhere = baseConditions.length > 0
+ ? and(...baseConditions)
+ : undefined;
+
+ // 정렬 기준 설정
+ let orderBy: OrderByType[] = [desc(techSalesVendorQuotations.updatedAt)];
+
+ if (sort?.length) {
+ orderBy = sort.map(item => {
+ switch (item.id) {
+ case 'id':
+ return item.desc ? desc(techSalesVendorQuotations.id) : techSalesVendorQuotations.id;
+ case 'status':
+ return item.desc ? desc(techSalesVendorQuotations.status) : techSalesVendorQuotations.status;
+ case 'currency':
+ return item.desc ? desc(techSalesVendorQuotations.currency) : techSalesVendorQuotations.currency;
+ case 'totalPrice':
+ return item.desc ? desc(techSalesVendorQuotations.totalPrice) : techSalesVendorQuotations.totalPrice;
+ case 'validUntil':
+ return item.desc ? desc(techSalesVendorQuotations.validUntil) : techSalesVendorQuotations.validUntil;
+ case 'submittedAt':
+ return item.desc ? desc(techSalesVendorQuotations.submittedAt) : techSalesVendorQuotations.submittedAt;
+ case 'createdAt':
+ return item.desc ? desc(techSalesVendorQuotations.createdAt) : techSalesVendorQuotations.createdAt;
+ case 'updatedAt':
+ return item.desc ? desc(techSalesVendorQuotations.updatedAt) : techSalesVendorQuotations.updatedAt;
+ case 'rfqCode':
+ return item.desc ? desc(techSalesRfqs.rfqCode) : techSalesRfqs.rfqCode;
+ case 'materialCode':
+ return item.desc ? desc(techSalesRfqs.materialCode) : techSalesRfqs.materialCode;
+ case 'dueDate':
+ return item.desc ? desc(techSalesRfqs.dueDate) : techSalesRfqs.dueDate;
+ case 'rfqStatus':
+ return item.desc ? desc(techSalesRfqs.status) : techSalesRfqs.status;
+ default:
+ return item.desc ? desc(techSalesVendorQuotations.updatedAt) : techSalesVendorQuotations.updatedAt;
+ }
+ });
+ }
+
+ // 조인을 포함한 데이터 조회 (중복 제거를 위해 techSalesAttachments JOIN 제거)
+ const data = await db
+ .select({
+ id: techSalesVendorQuotations.id,
+ rfqId: techSalesVendorQuotations.rfqId,
+ vendorId: techSalesVendorQuotations.vendorId,
+ status: techSalesVendorQuotations.status,
+ currency: techSalesVendorQuotations.currency,
+ totalPrice: techSalesVendorQuotations.totalPrice,
+ validUntil: techSalesVendorQuotations.validUntil,
+ submittedAt: techSalesVendorQuotations.submittedAt,
+ remark: techSalesVendorQuotations.remark,
+ createdAt: techSalesVendorQuotations.createdAt,
+ updatedAt: techSalesVendorQuotations.updatedAt,
+ createdBy: techSalesVendorQuotations.createdBy,
+ updatedBy: techSalesVendorQuotations.updatedBy,
+ quotationCode: techSalesVendorQuotations.quotationCode,
+ quotationVersion: techSalesVendorQuotations.quotationVersion,
+ rejectionReason: techSalesVendorQuotations.rejectionReason,
+ acceptedAt: techSalesVendorQuotations.acceptedAt,
+ // RFQ 정보
+ rfqCode: techSalesRfqs.rfqCode,
+ materialCode: techSalesRfqs.materialCode,
+ dueDate: techSalesRfqs.dueDate,
+ rfqStatus: techSalesRfqs.status,
+ description: techSalesRfqs.description,
+ // 프로젝트 정보 (직접 조인)
+ projNm: biddingProjects.projNm,
+ // 아이템 개수
+ itemCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM tech_sales_rfq_items
+ WHERE tech_sales_rfq_items.rfq_id = ${techSalesRfqs.id}
+ )`,
+ // RFQ 첨부파일 개수 (RFQ_COMMON 타입만 카운트)
+ attachmentCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM tech_sales_attachments
+ WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id}
+ AND tech_sales_attachments.attachment_type = 'RFQ_COMMON'
+ )`,
+ })
+ .from(techSalesVendorQuotations)
+ .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id))
+ .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id))
+ .where(finalWhere)
+ .orderBy(...orderBy)
+ .limit(limit)
+ .offset(offset);
+
+ // 총 개수 조회
+ const totalResult = await db
+ .select({ count: sql<number>`count(*)` })
+ .from(techSalesVendorQuotations)
+ .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id))
+ .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id))
+ .where(finalWhere);
+
+ const total = totalResult[0]?.count || 0;
+ const pageCount = Math.ceil(total / perPage);
+
+ return { data, pageCount, total };
+ } catch (err) {
+ console.error("Error fetching vendor quotations:", err);
+ return { data: [], pageCount: 0, total: 0 };
+ }
+ },
+ [JSON.stringify(input), vendorId], // 캐싱 키
+ {
+ revalidate: 60, // 1분간 캐시
+ tags: [
+ "techSalesVendorQuotations",
+ `vendor-${vendorId}-quotations`
+ ],
+ }
+ )();
+}
+
+/**
+ * 기술영업 벤더 견적 승인 (벤더 선택)
+ */
+export async function acceptTechSalesVendorQuotation(quotationId: number) {
+ try {
+ const result = await db.transaction(async (tx) => {
+ // 1. 선택된 견적 정보 조회
+ const selectedQuotation = await tx
+ .select()
+ .from(techSalesVendorQuotations)
+ .where(eq(techSalesVendorQuotations.id, quotationId))
+ .limit(1)
+
+ if (selectedQuotation.length === 0) {
+ throw new Error("견적을 찾을 수 없습니다")
+ }
+
+ const quotation = selectedQuotation[0]
+
+ // 2. 선택된 견적을 Accepted로 변경
+ await tx
+ .update(techSalesVendorQuotations)
+ .set({
+ status: "Accepted",
+ acceptedAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .where(eq(techSalesVendorQuotations.id, quotationId))
+
+ // 4. RFQ 상태를 Closed로 변경
+ await tx
+ .update(techSalesRfqs)
+ .set({
+ status: "Closed",
+ updatedAt: new Date(),
+ })
+ .where(eq(techSalesRfqs.id, quotation.rfqId))
+
+ return quotation
+ })
+
+ // 메일 발송 (백그라운드에서 실행)
+ // 선택된 벤더에게 견적 선택 알림 메일 발송
+ sendQuotationAcceptedNotification(quotationId).catch(error => {
+ console.error("벤더 견적 선택 알림 메일 발송 실패:", error);
+ });
+
+ // 캐시 무효화
+ revalidateTag("techSalesVendorQuotations")
+ revalidateTag(`techSalesRfq-${result.rfqId}`)
+ revalidateTag("techSalesRfqs")
+
+ // 해당 RFQ의 모든 벤더 캐시 무효화 (선택된 벤더와 거절된 벤더들)
+ const allVendorsInRfq = await db.query.techSalesVendorQuotations.findMany({
+ where: eq(techSalesVendorQuotations.rfqId, result.rfqId),
+ columns: { vendorId: true }
+ });
+
+ for (const vendorQuotation of allVendorsInRfq) {
+ revalidateTag(`vendor-${vendorQuotation.vendorId}-quotations`);
+ }
+ revalidatePath("/evcp/budgetary-tech-sales-ship")
+ revalidatePath("/partners/techsales")
+
+
+ return { success: true, data: result }
+ } catch (error) {
+ console.error("벤더 견적 승인 오류:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "벤더 견적 승인에 실패했습니다"
+ }
+ }
+}
+
+/**
+ * 기술영업 RFQ 첨부파일 생성
+ */
+export async function createTechSalesRfqAttachments(params: {
+ techSalesRfqId: number
+ files: File[]
+ createdBy: number
+ attachmentType?: "RFQ_COMMON" | "VENDOR_SPECIFIC"
+ description?: string
+}) {
+ unstable_noStore();
+ try {
+ const { techSalesRfqId, files, createdBy, attachmentType = "RFQ_COMMON", description } = params;
+
+
+
+ if (!files || files.length === 0) {
+ return { data: null, error: "업로드할 파일이 없습니다." };
+ }
+
+ // RFQ 존재 확인
+ const rfq = await db.query.techSalesRfqs.findFirst({
+ where: eq(techSalesRfqs.id, techSalesRfqId),
+ columns: { id: true, status: true }
+ });
+
+ if (!rfq) {
+ return { data: null, error: "RFQ를 찾을 수 없습니다." };
+ }
+
+ // // 편집 가능한 상태 확인
+ // if (!["RFQ Created", "RFQ Vendor Assignned"].includes(rfq.status)) {
+ // return { data: null, error: "현재 상태에서는 첨부파일을 추가할 수 없습니다." };
+ // }
+
+ const results: typeof techSalesAttachments.$inferSelect[] = [];
+
+ // 트랜잭션으로 처리
+ await db.transaction(async (tx) => {
+
+ for (const file of files) {
+
+
+ const saveResult = await saveDRMFile(
+ file,
+ decryptWithServerAction,
+ `techsales-rfq/${techSalesRfqId}`
+ );
+
+ if (!saveResult.success) {
+ throw new Error(saveResult.error || "파일 저장에 실패했습니다.");
+ }
+
+ // DB에 첨부파일 레코드 생성
+ const [newAttachment] = await tx.insert(techSalesAttachments).values({
+ techSalesRfqId,
+ attachmentType,
+ fileName: saveResult.fileName!,
+ originalFileName: file.name,
+ filePath: saveResult.publicPath!,
+ fileSize: file.size,
+ fileType: file.type || undefined,
+ description: description || undefined,
+ createdBy,
+ }).returning();
+
+ results.push(newAttachment);
+ }
+ });
+
+
+
+ // RFQ 타입 조회하여 캐시 무효화
+ const rfqType = await db.query.techSalesRfqs.findFirst({
+ where: eq(techSalesRfqs.id, techSalesRfqId),
+ columns: { rfqType: true }
+ });
+
+ revalidateTag("techSalesRfqs");
+ revalidateTag(`techSalesRfq-${techSalesRfqId}`);
+ revalidatePath(getTechSalesRevalidationPath(rfqType?.rfqType || "SHIP"));
+ revalidatePath("/partners/techsales");
+ return { data: results, error: null };
+ } catch (err) {
+ console.error("기술영업 RFQ 첨부파일 생성 오류:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 기술영업 RFQ 첨부파일 조회
+ */
+export async function getTechSalesRfqAttachments(techSalesRfqId: number) {
+ unstable_noStore();
+ try {
+ const attachments = await db.query.techSalesAttachments.findMany({
+ where: eq(techSalesAttachments.techSalesRfqId, techSalesRfqId),
+ orderBy: [desc(techSalesAttachments.createdAt)],
+ with: {
+ createdByUser: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ }
+ }
+ }
+ });
+
+ return { data: attachments, error: null };
+ } catch (err) {
+ console.error("기술영업 RFQ 첨부파일 조회 오류:", err);
+ return { data: [], error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * RFQ 첨부파일 타입별 조회
+ */
+export async function getTechSalesRfqAttachmentsByType(
+ techSalesRfqId: number,
+ attachmentType: "RFQ_COMMON" | "VENDOR_SPECIFIC" | "TBE_RESULT" | "CBE_RESULT"
+) {
+ unstable_noStore();
+ try {
+ const attachments = await db.query.techSalesAttachments.findMany({
+ where: and(
+ eq(techSalesAttachments.techSalesRfqId, techSalesRfqId),
+ eq(techSalesAttachments.attachmentType, attachmentType)
+ ),
+ orderBy: [desc(techSalesAttachments.createdAt)],
+ with: {
+ createdByUser: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ }
+ }
+ }
+ });
+
+ return { data: attachments, error: null };
+ } catch (err) {
+ console.error(`기술영업 RFQ ${attachmentType} 첨부파일 조회 오류:`, err);
+ return { data: [], error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 기술영업 RFQ 첨부파일 삭제
+ */
+export async function deleteTechSalesRfqAttachment(attachmentId: number) {
+ unstable_noStore();
+ try {
+ // 첨부파일 정보 조회
+ const attachment = await db.query.techSalesAttachments.findFirst({
+ where: eq(techSalesAttachments.id, attachmentId),
+ });
+
+ if (!attachment) {
+ return { data: null, error: "첨부파일을 찾을 수 없습니다." };
+ }
+
+ // RFQ 상태 확인
+ const rfq = await db.query.techSalesRfqs.findFirst({
+ where: eq(techSalesRfqs.id, attachment.techSalesRfqId!), // Non-null assertion since we know it exists
+ columns: { id: true, status: true }
+ });
+
+ if (!rfq) {
+ return { data: null, error: "RFQ를 찾을 수 없습니다." };
+ }
+
+ // // 편집 가능한 상태 확인
+ // if (!["RFQ Created", "RFQ Vendor Assignned"].includes(rfq.status)) {
+ // return { data: null, error: "현재 상태에서는 첨부파일을 삭제할 수 없습니다." };
+ // }
+
+ // 트랜잭션으로 처리
+ const result = await db.transaction(async (tx) => {
+ // DB에서 레코드 삭제
+ const deletedAttachment = await tx.delete(techSalesAttachments)
+ .where(eq(techSalesAttachments.id, attachmentId))
+ .returning();
+
+ // 파일 시스템에서 파일 삭제
+ try {
+ deleteFile(attachment.filePath)
+
+ } catch (fileError) {
+ console.warn("파일 삭제 실패:", fileError);
+ // 파일 삭제 실패는 심각한 오류가 아니므로 계속 진행
+ }
+
+ return deletedAttachment[0];
+ });
+
+ // RFQ 타입 조회하여 캐시 무효화
+ const attachmentRfq = await db.query.techSalesRfqs.findFirst({
+ where: eq(techSalesRfqs.id, attachment.techSalesRfqId!),
+ columns: { rfqType: true }
+ });
+
+ revalidateTag("techSalesRfqs");
+ revalidateTag(`techSalesRfq-${attachment.techSalesRfqId}`);
+ revalidatePath(getTechSalesRevalidationPath(attachmentRfq?.rfqType || "SHIP"));
+
+ return { data: result, error: null };
+ } catch (err) {
+ console.error("기술영업 RFQ 첨부파일 삭제 오류:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 기술영업 RFQ 첨부파일 일괄 처리 (업로드 + 삭제)
+ */
+export async function processTechSalesRfqAttachments(params: {
+ techSalesRfqId: number
+ newFiles: { file: File; attachmentType: "RFQ_COMMON" | "VENDOR_SPECIFIC" | "TBE_RESULT" | "CBE_RESULT"; description?: string }[]
+ deleteAttachmentIds: number[]
+ createdBy: number
+}) {
+ unstable_noStore();
+ try {
+ const { techSalesRfqId, newFiles, deleteAttachmentIds, createdBy } = params;
+
+
+
+ // RFQ 존재 및 상태 확인
+ const rfq = await db.query.techSalesRfqs.findFirst({
+ where: eq(techSalesRfqs.id, techSalesRfqId),
+ columns: { id: true, status: true }
+ });
+
+ if (!rfq) {
+ return { data: null, error: "RFQ를 찾을 수 없습니다." };
+ }
+ // // 편집 가능한 상태 확인
+ // if (!["RFQ Created", "RFQ Vendor Assignned"].includes(rfq.status)) {
+ // return { data: null, error: "현재 상태에서는 첨부파일을 수정할 수 없습니다." };
+ // }
+
+ const results = {
+ uploaded: [] as typeof techSalesAttachments.$inferSelect[],
+ deleted: [] as typeof techSalesAttachments.$inferSelect[],
+ };
+
+ await db.transaction(async (tx) => {
+
+ // 1. 삭제할 첨부파일 처리
+ if (deleteAttachmentIds.length > 0) {
+ const attachmentsToDelete = await tx.query.techSalesAttachments.findMany({
+ where: sql`${techSalesAttachments.id} IN (${deleteAttachmentIds.join(',')})`
+ });
+
+ for (const attachment of attachmentsToDelete) {
+ // DB에서 레코드 삭제
+ const [deletedAttachment] = await tx.delete(techSalesAttachments)
+ .where(eq(techSalesAttachments.id, attachment.id))
+ .returning();
+
+ results.deleted.push(deletedAttachment);
+ await deleteFile(attachment.filePath);
+ }
+ }
+
+ // 2. 새 파일 업로드 처리
+ if (newFiles.length > 0) {
+ for (const { file, attachmentType, description } of newFiles) {
+ const saveResult = await saveDRMFile(
+ file,
+ decryptWithServerAction,
+ `techsales-rfq/${techSalesRfqId}`
+ );
+
+ if (!saveResult.success) {
+ throw new Error(saveResult.error || "파일 저장에 실패했습니다.");
+ }
+
+ // DB에 첨부파일 레코드 생성
+ const [newAttachment] = await tx.insert(techSalesAttachments).values({
+ techSalesRfqId,
+ attachmentType,
+ fileName: saveResult.fileName!,
+ originalFileName: file.name,
+ filePath: saveResult.publicPath!,
+ fileSize: file.size,
+ fileType: file.type || undefined,
+ description: description || undefined,
+ createdBy,
+ }).returning();
+
+ results.uploaded.push(newAttachment);
+ }
+ }
+ });
+
+
+
+ // 캐시 무효화
+ revalidateTag("techSalesRfqs");
+ revalidateTag(`techSalesRfq-${techSalesRfqId}`);
+ revalidatePath("/evcp/budgetary-tech-sales-ship");
+
+ return {
+ data: results,
+ error: null,
+ message: `${results.uploaded.length}개 업로드, ${results.deleted.length}개 삭제 완료`
+ };
+ } catch (err) {
+ console.error("기술영업 RFQ 첨부파일 일괄 처리 오류:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+// ========================================
+// 메일 발송 관련 함수들
+// ========================================
+
+/**
+ * 벤더 견적 제출 확인 메일 발송 (벤더용)
+ */
+export async function sendQuotationSubmittedNotificationToVendor(quotationId: number) {
+ try {
+ // 견적서 정보 조회 (projectSeries 조인 추가)
+ const quotation = await db.query.techSalesVendorQuotations.findFirst({
+ where: eq(techSalesVendorQuotations.id, quotationId),
+ with: {
+ rfq: {
+ with: {
+ biddingProject: true,
+ createdByUser: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ }
+ }
+ }
+ },
+ vendor: {
+ columns: {
+ id: true,
+ vendorName: true,
+ vendorCode: true,
+ }
+ }
+ }
+ });
+
+ if (!quotation || !quotation.rfq || !quotation.vendor) {
+ console.error("견적서 또는 관련 정보를 찾을 수 없습니다");
+ return { success: false, error: "견적서 정보를 찾을 수 없습니다" };
+ }
+
+ // 벤더 사용자들 조회
+ const vendorUsers = await db.query.users.findMany({
+ where: eq(users.companyId, quotation.vendor.id),
+ columns: {
+ id: true,
+ email: true,
+ name: true,
+ language: true
+ }
+ });
+
+ const vendorEmails = vendorUsers
+ .filter(user => user.email)
+ .map(user => user.email)
+ .join(", ");
+
+ if (!vendorEmails) {
+ console.warn(`벤더 ID ${quotation.vendor.id}에 등록된 이메일 주소가 없습니다`);
+ return { success: false, error: "벤더 이메일 주소가 없습니다" };
+ }
+
+ // RFQ 아이템 정보 조회
+ const rfqItemsResult = await getTechSalesRfqItems(quotation.rfq.id);
+ const rfqItems = rfqItemsResult.data || [];
+
+ // 이메일 컨텍스트 구성 (시리즈 정보 제거, 프로젝트 정보 간소화)
+ const emailContext = {
+ language: vendorUsers[0]?.language || "ko",
+ quotation: {
+ id: quotation.id,
+ currency: quotation.currency,
+ totalPrice: quotation.totalPrice,
+ validUntil: quotation.validUntil,
+ submittedAt: quotation.submittedAt,
+ remark: quotation.remark,
+ },
+ rfq: {
+ id: quotation.rfq.id,
+ code: quotation.rfq.rfqCode,
+ 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,
+ },
+ items: rfqItems.map(item => ({
+ itemCode: item.itemCode,
+ itemList: item.itemList,
+ workType: item.workType,
+ shipTypes: item.shipTypes,
+ subItemList: item.subItemList,
+ itemType: item.itemType,
+ })),
+ vendor: {
+ id: quotation.vendor.id,
+ code: quotation.vendor.vendorCode,
+ name: quotation.vendor.vendorName,
+ },
+ project: {
+ 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 || '',
+ },
+ manager: {
+ name: quotation.rfq.createdByUser?.name || '',
+ email: quotation.rfq.createdByUser?.email || '',
+ },
+ systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://60.101.108.100/ko/partners',
+ companyName: 'Samsung Heavy Industries',
+ year: new Date().getFullYear(),
+ };
+
+ // 이메일 발송
+ await sendEmail({
+ to: vendorEmails,
+ subject: `[견적 제출 확인] ${quotation.rfq.rfqCode} - 견적 요청`,
+ template: 'tech-sales-quotation-submitted-vendor-ko',
+ context: emailContext,
+ });
+
+ console.log(`벤더 견적 제출 확인 메일 발송 완료: ${vendorEmails}`);
+ return { success: true };
+ } catch (error) {
+ console.error("벤더 견적 제출 확인 메일 발송 오류:", error);
+ return { success: false, error: "메일 발송 중 오류가 발생했습니다" };
+ }
+}
+
+/**
+ * 벤더 견적 접수 알림 메일 발송 (담당자용)
+ */
+export async function sendQuotationSubmittedNotificationToManager(quotationId: number) {
+ try {
+ // 견적서 정보 조회
+ const quotation = await db.query.techSalesVendorQuotations.findFirst({
+ where: eq(techSalesVendorQuotations.id, quotationId),
+ with: {
+ rfq: {
+ with: {
+ biddingProject: true,
+ createdByUser: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ }
+ }
+ }
+ },
+ vendor: {
+ columns: {
+ id: true,
+ vendorName: true,
+ vendorCode: true,
+ }
+ }
+ }
+ });
+
+ if (!quotation || !quotation.rfq || !quotation.vendor) {
+ console.error("견적서 또는 관련 정보를 찾을 수 없습니다");
+ return { success: false, error: "견적서 정보를 찾을 수 없습니다" };
+ }
+
+ const manager = quotation.rfq.createdByUser;
+ if (!manager?.email) {
+ console.warn("담당자 이메일 주소가 없습니다");
+ return { success: false, error: "담당자 이메일 주소가 없습니다" };
+ }
+
+ // RFQ 아이템 정보 조회
+ const rfqItemsResult = await getTechSalesRfqItems(quotation.rfq.id);
+ const rfqItems = rfqItemsResult.data || [];
+
+ // 이메일 컨텍스트 구성 (시리즈 정보 제거, 프로젝트 정보 간소화)
+ const emailContext = {
+ language: "ko",
+ quotation: {
+ id: quotation.id,
+ currency: quotation.currency,
+ totalPrice: quotation.totalPrice,
+ validUntil: quotation.validUntil,
+ submittedAt: quotation.submittedAt,
+ remark: quotation.remark,
+ },
+ rfq: {
+ id: quotation.rfq.id,
+ code: quotation.rfq.rfqCode,
+ 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,
+ },
+ items: rfqItems.map(item => ({
+ itemCode: item.itemCode,
+ itemList: item.itemList,
+ workType: item.workType,
+ shipTypes: item.shipTypes,
+ subItemList: item.subItemList,
+ itemType: item.itemType,
+ })),
+ vendor: {
+ id: quotation.vendor.id,
+ code: quotation.vendor.vendorCode,
+ name: quotation.vendor.vendorName,
+ },
+ project: {
+ 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 || '',
+ },
+ manager: {
+ name: manager.name || '',
+ email: manager.email,
+ },
+ systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://60.101.108.100/ko/evcp',
+ companyName: 'Samsung Heavy Industries',
+ year: new Date().getFullYear(),
+ };
+
+ // 이메일 발송
+ await sendEmail({
+ to: manager.email,
+ subject: `[견적 접수 알림] ${quotation.vendor.vendorName}에서 ${quotation.rfq.rfqCode} 견적서를 제출했습니다`,
+ template: 'tech-sales-quotation-submitted-manager-ko',
+ context: emailContext,
+ });
+
+ console.log(`담당자 견적 접수 알림 메일 발송 완료: ${manager.email}`);
+ return { success: true };
+ } catch (error) {
+ console.error("담당자 견적 접수 알림 메일 발송 오류:", error);
+ return { success: false, error: "메일 발송 중 오류가 발생했습니다" };
+ }
+}
+
+/**
+ * 벤더 견적 선택 알림 메일 발송
+ */
+export async function sendQuotationAcceptedNotification(quotationId: number) {
+ try {
+ // 견적서 정보 조회
+ const quotation = await db.query.techSalesVendorQuotations.findFirst({
+ where: eq(techSalesVendorQuotations.id, quotationId),
+ with: {
+ rfq: {
+ with: {
+ biddingProject: true,
+ createdByUser: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ }
+ }
+ }
+ },
+ vendor: {
+ columns: {
+ id: true,
+ vendorName: true,
+ vendorCode: true,
+ }
+ }
+ }
+ });
+
+ if (!quotation || !quotation.rfq || !quotation.vendor) {
+ console.error("견적서 또는 관련 정보를 찾을 수 없습니다");
+ return { success: false, error: "견적서 정보를 찾을 수 없습니다" };
+ }
+
+ // 벤더 사용자들 조회
+ const vendorUsers = await db.query.users.findMany({
+ where: eq(users.companyId, quotation.vendor.id),
+ columns: {
+ id: true,
+ email: true,
+ name: true,
+ language: true
+ }
+ });
+
+ const vendorEmails = vendorUsers
+ .filter(user => user.email)
+ .map(user => user.email)
+ .join(", ");
+
+ if (!vendorEmails) {
+ console.warn(`벤더 ID ${quotation.vendor.id}에 등록된 이메일 주소가 없습니다`);
+ return { success: false, error: "벤더 이메일 주소가 없습니다" };
+ }
+
+ // RFQ 아이템 정보 조회
+ const rfqItemsResult = await getTechSalesRfqItems(quotation.rfq.id);
+ const rfqItems = rfqItemsResult.data || [];
+
+ // 이메일 컨텍스트 구성 (시리즈 정보 제거, 프로젝트 정보 간소화)
+ const emailContext = {
+ language: vendorUsers[0]?.language || "ko",
+ quotation: {
+ id: quotation.id,
+ currency: quotation.currency,
+ totalPrice: quotation.totalPrice,
+ validUntil: quotation.validUntil,
+ acceptedAt: quotation.acceptedAt,
+ remark: quotation.remark,
+ },
+ rfq: {
+ id: quotation.rfq.id,
+ code: quotation.rfq.rfqCode,
+ 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,
+ },
+ items: rfqItems.map(item => ({
+ itemCode: item.itemCode,
+ itemList: item.itemList,
+ workType: item.workType,
+ shipTypes: item.shipTypes,
+ subItemList: item.subItemList,
+ itemType: item.itemType,
+ })),
+ vendor: {
+ id: quotation.vendor.id,
+ code: quotation.vendor.vendorCode,
+ name: quotation.vendor.vendorName,
+ },
+ project: {
+ 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 || '',
+ },
+ manager: {
+ name: quotation.rfq.createdByUser?.name || '',
+ email: quotation.rfq.createdByUser?.email || '',
+ },
+ systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://60.101.108.100/ko/partners',
+ companyName: 'Samsung Heavy Industries',
+ year: new Date().getFullYear(),
+ };
+
+ // 이메일 발송
+ await sendEmail({
+ to: vendorEmails,
+ subject: `[견적 선택 알림] ${quotation.rfq.rfqCode} - 귀하의 견적이 선택되었습니다`,
+ template: 'tech-sales-quotation-accepted-ko',
+ context: emailContext,
+ });
+
+ console.log(`벤더 견적 선택 알림 메일 발송 완료: ${vendorEmails}`);
+ return { success: true };
+ } catch (error) {
+ console.error("벤더 견적 선택 알림 메일 발송 오류:", error);
+ return { success: false, error: "메일 발송 중 오류가 발생했습니다" };
+ }
+}
+
+// ==================== Vendor Communication 관련 ====================
+
+export interface TechSalesAttachment {
+ id: number
+ fileName: string
+ fileSize: number
+ fileType: string | null // <- null 허용
+ filePath: string
+ uploadedAt: Date
+}
+
+export interface TechSalesComment {
+ id: number
+ rfqId: number
+ vendorId: number | null // null 허용으로 변경
+ userId?: number | null // null 허용으로 변경
+ content: string
+ isVendorComment: boolean | null // null 허용으로 변경
+ createdAt: Date
+ updatedAt: Date
+ userName?: string | null // null 허용으로 변경
+ vendorName?: string | null // null 허용으로 변경
+ attachments: TechSalesAttachment[]
+ isRead: boolean | null // null 허용으로 변경
+}
+
+/**
+ * 특정 RFQ의 벤더별 읽지 않은 메시지 개수를 조회하는 함수
+ *
+ * @param rfqId RFQ ID
+ * @returns 벤더별 읽지 않은 메시지 개수 (vendorId: count)
+ */
+export async function getTechSalesUnreadMessageCounts(rfqId: number): Promise<Record<number, number>> {
+ try {
+ // 벤더가 보낸 읽지 않은 메시지를 벤더별로 카운트
+ const unreadCounts = await db
+ .select({
+ vendorId: techSalesRfqComments.vendorId,
+ count: sql<number>`count(*)`,
+ })
+ .from(techSalesRfqComments)
+ .where(
+ and(
+ eq(techSalesRfqComments.rfqId, rfqId),
+ eq(techSalesRfqComments.isVendorComment, true), // 벤더가 보낸 메시지
+ eq(techSalesRfqComments.isRead, false), // 읽지 않은 메시지
+ sql`${techSalesRfqComments.vendorId} IS NOT NULL` // vendorId가 null이 아닌 것
+ )
+ )
+ .groupBy(techSalesRfqComments.vendorId);
+
+ // Record<number, number> 형태로 변환
+ const result: Record<number, number> = {};
+ unreadCounts.forEach(item => {
+ if (item.vendorId) {
+ result[item.vendorId] = item.count;
+ }
+ });
+
+ return result;
+ } catch (error) {
+ console.error('techSales 읽지 않은 메시지 개수 조회 오류:', error);
+ return {};
+ }
+}
+
+/**
+ * 특정 RFQ와 벤더 간의 커뮤니케이션 메시지를 가져오는 서버 액션
+ *
+ * @param rfqId RFQ ID
+ * @param vendorId 벤더 ID
+ * @returns 코멘트 목록
+ */
+export async function fetchTechSalesVendorComments(rfqId: number, vendorId?: number): Promise<TechSalesComment[]> {
+ if (!vendorId) {
+ return []
+ }
+
+ try {
+ // 인증 확인
+ const session = await getServerSession(authOptions);
+
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다")
+ }
+
+ // 코멘트 쿼리
+ const comments = await db.query.techSalesRfqComments.findMany({
+ where: and(
+ eq(techSalesRfqComments.rfqId, rfqId),
+ eq(techSalesRfqComments.vendorId, vendorId)
+ ),
+ orderBy: [techSalesRfqComments.createdAt],
+ with: {
+ user: {
+ columns: {
+ name: true
+ }
+ },
+ vendor: {
+ columns: {
+ vendorName: true
+ }
+ },
+ attachments: true,
+ }
+ })
+
+ // 결과 매핑
+ return comments.map(comment => ({
+ id: comment.id,
+ rfqId: comment.rfqId,
+ vendorId: comment.vendorId,
+ userId: comment.userId || undefined,
+ content: comment.content,
+ isVendorComment: comment.isVendorComment,
+ createdAt: comment.createdAt,
+ updatedAt: comment.updatedAt,
+ userName: comment.user?.name,
+ vendorName: comment.vendor?.vendorName,
+ isRead: comment.isRead,
+ attachments: comment.attachments.map(att => ({
+ id: att.id,
+ fileName: att.fileName,
+ fileSize: att.fileSize,
+ fileType: att.fileType,
+ filePath: att.filePath,
+ originalFileName: att.originalFileName,
+ uploadedAt: att.uploadedAt
+ }))
+ }))
+ } catch (error) {
+ console.error('techSales 벤더 코멘트 가져오기 오류:', error)
+ throw error
+ }
+}
+
+/**
+ * 코멘트를 읽음 상태로 표시하는 서버 액션
+ *
+ * @param rfqId RFQ ID
+ * @param vendorId 벤더 ID
+ */
+export async function markTechSalesMessagesAsRead(rfqId: number, vendorId?: number): Promise<void> {
+ if (!vendorId) {
+ return
+ }
+
+ try {
+ // 인증 확인
+ const session = await getServerSession(authOptions);
+
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다")
+ }
+
+ // 벤더가 작성한 읽지 않은 코멘트 업데이트
+ await db.update(techSalesRfqComments)
+ .set({ isRead: true })
+ .where(
+ and(
+ eq(techSalesRfqComments.rfqId, rfqId),
+ eq(techSalesRfqComments.vendorId, vendorId),
+ eq(techSalesRfqComments.isVendorComment, true),
+ eq(techSalesRfqComments.isRead, false)
+ )
+ )
+
+ // 캐시 무효화
+ revalidateTag(`tech-sales-rfq-${rfqId}-comments`)
+ } catch (error) {
+ console.error('techSales 메시지 읽음 표시 오류:', error)
+ throw error
+ }
+}
+
+// ==================== RFQ 조선/해양 관련 ====================
+
+/**
+ * 기술영업 조선 RFQ 생성 (1:N 관계)
+ */
+export async function createTechSalesShipRfq(input: {
+ biddingProjectId: number;
+ itemIds: number[]; // 조선 아이템 ID 배열
+ dueDate: Date;
+ description?: string;
+ createdBy: number;
+}) {
+ unstable_noStore();
+ 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" });
+}
+
+/**
+ * 기술영업 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
+ shipTypes: item.itemType === 'SHIP' ? (itemInfo as { shipTypes?: string })?.shipTypes || '' : undefined,
+ subItemList: 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),
+ or(
+ eq(techVendors.status, "ACTIVE"),
+ eq(techVendors.status, "QUOTE_COMPARISON") // 견적비교용 벤더도 RFQ 초대 가능
+ )
+ // 벤더 타입 필터링 임시 제거 - 데이터 확인 후 다시 추가
+ // 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 타입에 따른 캐시 무효화 경로 반환
+ */
+function getTechSalesRevalidationPath(rfqType: "SHIP" | "TOP" | "HULL"): string {
+ switch (rfqType) {
+ case "SHIP":
+ return "/evcp/budgetary-tech-sales-ship";
+ case "TOP":
+ return "/evcp/budgetary-tech-sales-top";
+ case "HULL":
+ return "/evcp/budgetary-tech-sales-hull";
+ default:
+ return "/evcp/budgetary-tech-sales-ship";
+ }
+}
+
+/**
+ * 기술영업 RFQ에 여러 벤더 추가 (techVendors 기반)
+ * 벤더 추가 시에는 견적서를 생성하지 않고, RFQ 전송 시에 견적서를 생성
+ */
+export async function addTechVendorsToTechSalesRfq(input: {
+ rfqId: number;
+ vendorIds: number[];
+ createdBy: number;
+}) {
+ unstable_noStore();
+
+ try {
+ return await db.transaction(async (tx) => {
+ const results = [];
+ const errors: string[] = [];
+
+ // 1. RFQ 상태 및 타입 확인
+ const rfq = await tx.query.techSalesRfqs.findFirst({
+ where: eq(techSalesRfqs.id, input.rfqId),
+ columns: {
+ id: true,
+ status: true,
+ rfqType: true,
+ }
+ });
+
+ if (!rfq) {
+ throw new Error("RFQ를 찾을 수 없습니다");
+ }
+
+ // 2. 각 벤더에 대해 처리 (이미 추가된 벤더는 견적서가 있는지 확인)
+ for (const vendorId of input.vendorIds) {
+ try {
+ // 이미 추가된 벤더인지 확인 (견적서 존재 여부로 확인)
+ const existingQuotation = await tx.query.techSalesVendorQuotations.findFirst({
+ where: and(
+ eq(techSalesVendorQuotations.rfqId, input.rfqId),
+ eq(techSalesVendorQuotations.vendorId, vendorId)
+ )
+ });
+
+ if (existingQuotation) {
+ errors.push(`벤더 ID ${vendorId}는 이미 추가되어 있습니다.`);
+ continue;
+ }
+
+ // 벤더가 실제로 존재하는지 확인
+ const vendor = await tx.query.techVendors.findFirst({
+ where: eq(techVendors.id, vendorId),
+ columns: { id: true, vendorName: true }
+ });
+
+ if (!vendor) {
+ errors.push(`벤더 ID ${vendorId}를 찾을 수 없습니다.`);
+ continue;
+ }
+
+ // 🔥 중요: 벤더 추가 시에는 견적서를 생성하지 않고, "Assigned" 상태로만 생성
+ // quotation_version은 null로 설정하여 벤더가 실제 견적 제출 시에만 리비전 생성
+ const [quotation] = await tx
+ .insert(techSalesVendorQuotations)
+ .values({
+ rfqId: input.rfqId,
+ vendorId: vendorId,
+ status: "Assigned", // Draft가 아닌 Assigned 상태로 생성
+ quotationVersion: null, // 리비전은 견적 제출 시에만 생성
+ createdBy: input.createdBy,
+ updatedBy: input.createdBy,
+ })
+ .returning({ id: techSalesVendorQuotations.id });
+
+ // 🆕 RFQ의 아이템 코드들을 tech_vendor_possible_items에 추가
+ try {
+ // RFQ의 아이템들 조회
+ const rfqItemsResult = await getTechSalesRfqItems(input.rfqId);
+
+ if (rfqItemsResult.data && rfqItemsResult.data.length > 0) {
+ for (const item of rfqItemsResult.data) {
+ const {
+ itemCode,
+ itemList,
+ workType, // 공종
+ shipTypes, // 선종 (배열일 수 있음)
+ subItemList // 서브아이템리스트 (있을 수도 있음)
+ } = item;
+
+ // 동적 where 조건 생성: 값이 있으면 비교, 없으면 비교하지 않음
+ const whereConds = [
+ eq(techVendorPossibleItems.vendorId, vendorId),
+ itemCode ? eq(techVendorPossibleItems.itemCode, itemCode) : undefined,
+ itemList ? eq(techVendorPossibleItems.itemList, itemList) : undefined,
+ workType ? eq(techVendorPossibleItems.workType, workType) : undefined,
+ shipTypes ? eq(techVendorPossibleItems.shipTypes, shipTypes) : undefined,
+ subItemList ? eq(techVendorPossibleItems.subItemList, subItemList) : undefined,
+ ].filter(Boolean);
+
+ const existing = await tx.query.techVendorPossibleItems.findFirst({
+ where: and(...whereConds)
+ });
+
+ if (!existing) {
+ await tx.insert(techVendorPossibleItems).values({
+ vendorId : vendorId,
+ itemCode: itemCode ?? null,
+ itemList: itemList ?? null,
+ workType: workType ?? null,
+ shipTypes: shipTypes ?? null,
+ subItemList: subItemList ?? null,
+ });
+ }
+ }
+ }
+ } catch (possibleItemError) {
+ // tech_vendor_possible_items 추가 실패는 전체 실패로 처리하지 않음
+ console.warn(`벤더 ${vendorId}의 가능 아이템 추가 실패:`, possibleItemError);
+ }
+
+ results.push({ id: quotation.id, vendorId, vendorName: vendor.vendorName });
+ } catch (vendorError) {
+ console.error(`Error adding vendor ${vendorId}:`, vendorError);
+ errors.push(`벤더 ID ${vendorId} 추가 중 오류가 발생했습니다.`);
+ }
+ }
+
+ // 3. RFQ 상태가 "RFQ Created"이고 성공적으로 추가된 벤더가 있는 경우 상태 업데이트
+ if (rfq.status === "RFQ Created" && results.length > 0) {
+ await tx.update(techSalesRfqs)
+ .set({
+ status: "RFQ Vendor Assignned",
+ updatedBy: input.createdBy,
+ updatedAt: new Date()
+ })
+ .where(eq(techSalesRfqs.id, input.rfqId));
+ }
+
+ // 캐시 무효화 (RFQ 타입에 따른 동적 경로)
+ revalidateTag("techSalesRfqs");
+ revalidateTag("techSalesVendorQuotations");
+ revalidateTag(`techSalesRfq-${input.rfqId}`);
+ revalidatePath(getTechSalesRevalidationPath(rfq.rfqType || "SHIP"));
+
+ return {
+ data: results,
+ error: errors.length > 0 ? errors.join(", ") : null,
+ successCount: results.length,
+ errorCount: errors.length
+ };
+ });
+ } 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에 존재하지 않습니다." };
+ }
+
+ // Assigned 상태가 아닌 경우 삭제 불가
+ if (existingQuotation.status !== "Assigned") {
+ return { data: null, error: "Assigned 상태의 벤더만 삭제할 수 있습니다." };
+ }
+
+ // 해당 벤더의 견적서 삭제
+ 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;
+ }
+
+ // Assigned 상태가 아닌 경우 삭제 불가
+ if (existingQuotation.status !== "Assigned") {
+ errors.push(`벤더 ID ${vendorId}는 Assigned 상태가 아니므로 삭제할 수 없습니다.`);
+ 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" ? "조선" :
+ rfqType === "TOP" ? "해양TOP" :
+ rfqType === "HULL" ? "해양HULL" : null;
+
+ const whereConditions = [
+ or(
+ eq(techVendors.status, "ACTIVE"),
+ eq(techVendors.status, "QUOTE_COMPARISON")
+ ),
+ or(
+ ilike(techVendors.vendorName, `%${searchTerm}%`),
+ ilike(techVendors.vendorCode, `%${searchTerm}%`)
+ )
+ ];
+
+ // RFQ 타입이 지정된 경우 벤더 타입 필터링 추가 (컴마 구분 문자열에서 검색)
+ if (vendorTypeFilter) {
+ whereConditions.push(sql`${techVendors.techVendorType} LIKE ${'%' + 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));
+ }
+}
+
+
+/**
+ * 벤더 견적서 거절 처리 (벤더가 직접 거절)
+ */
+export async function rejectTechSalesVendorQuotations(input: {
+ quotationIds: number[];
+ rejectionReason?: string;
+}) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ throw new Error("인증이 필요합니다.");
+ }
+
+ const result = await db.transaction(async (tx) => {
+ // 견적서들이 존재하고 벤더가 권한이 있는지 확인
+ const quotations = await tx
+ .select({
+ id: techSalesVendorQuotations.id,
+ status: techSalesVendorQuotations.status,
+ vendorId: techSalesVendorQuotations.vendorId,
+ })
+ .from(techSalesVendorQuotations)
+ .where(inArray(techSalesVendorQuotations.id, input.quotationIds));
+
+ if (quotations.length !== input.quotationIds.length) {
+ throw new Error("일부 견적서를 찾을 수 없습니다.");
+ }
+
+ // 이미 거절된 견적서가 있는지 확인
+ const alreadyRejected = quotations.filter(q => q.status === "Rejected");
+ if (alreadyRejected.length > 0) {
+ throw new Error("이미 거절된 견적서가 포함되어 있습니다.");
+ }
+
+ // 승인된 견적서가 있는지 확인
+ const alreadyAccepted = quotations.filter(q => q.status === "Accepted");
+ if (alreadyAccepted.length > 0) {
+ throw new Error("이미 승인된 견적서는 거절할 수 없습니다.");
+ }
+
+ // 견적서 상태를 거절로 변경
+ await tx
+ .update(techSalesVendorQuotations)
+ .set({
+ status: "Rejected",
+ rejectionReason: input.rejectionReason || null,
+ updatedBy: parseInt(session.user.id),
+ updatedAt: new Date(),
+ })
+ .where(inArray(techSalesVendorQuotations.id, input.quotationIds));
+
+ return { success: true, updatedCount: quotations.length };
+ });
+ revalidateTag("techSalesRfqs");
+ revalidateTag("techSalesVendorQuotations");
+ revalidatePath("/partners/techsales/rfq-ship", "page");
+ return {
+ success: true,
+ message: `${result.updatedCount}개의 견적서가 거절되었습니다.`,
+ data: result
+ };
+ } catch (error) {
+ console.error("견적서 거절 오류:", error);
+ return {
+ success: false,
+ error: getErrorMessage(error)
+ };
+ }
+}
+
+// ==================== Revision 관련 ====================
+
+/**
+ * 견적서 revision 히스토리 조회
+ */
+export async function getTechSalesVendorQuotationRevisions(quotationId: number) {
+ try {
+ const revisions = await db
+ .select({
+ id: techSalesVendorQuotationRevisions.id,
+ version: techSalesVendorQuotationRevisions.version,
+ snapshot: techSalesVendorQuotationRevisions.snapshot,
+ changeReason: techSalesVendorQuotationRevisions.changeReason,
+ revisionNote: techSalesVendorQuotationRevisions.revisionNote,
+ revisedBy: techSalesVendorQuotationRevisions.revisedBy,
+ revisedAt: techSalesVendorQuotationRevisions.revisedAt,
+ // 수정자 정보 조인
+ revisedByName: users.name,
+ })
+ .from(techSalesVendorQuotationRevisions)
+ .leftJoin(users, eq(techSalesVendorQuotationRevisions.revisedBy, users.id))
+ .where(eq(techSalesVendorQuotationRevisions.quotationId, quotationId))
+ .orderBy(desc(techSalesVendorQuotationRevisions.version));
+
+ return { data: revisions, error: null };
+ } catch (error) {
+ console.error("견적서 revision 히스토리 조회 오류:", error);
+ return { data: null, error: "견적서 히스토리를 조회하는 중 오류가 발생했습니다." };
+ }
+}
+
+/**
+ * 견적서의 현재 버전과 revision 히스토리를 함께 조회 (각 리비전의 첨부파일 포함)
+ */
+export async function getTechSalesVendorQuotationWithRevisions(quotationId: number) {
+ try {
+ // 먼저 현재 견적서 조회
+ const currentQuotation = await db.query.techSalesVendorQuotations.findFirst({
+ where: eq(techSalesVendorQuotations.id, quotationId),
+ with: {
+ // 벤더 정보와 RFQ 정보도 함께 조회 (필요한 경우)
+ }
+ });
+
+ if (!currentQuotation) {
+ return { data: null, error: "견적서를 찾을 수 없습니다." };
+ }
+
+ // 이제 현재 견적서의 정보를 알고 있으므로 병렬로 나머지 정보 조회
+ const [revisionsResult, currentAttachments] = await Promise.all([
+ getTechSalesVendorQuotationRevisions(quotationId),
+ getTechSalesVendorQuotationAttachmentsByRevision(quotationId, currentQuotation.quotationVersion || 0)
+ ]);
+
+ // 현재 견적서에 첨부파일 정보 추가
+ const currentWithAttachments = {
+ ...currentQuotation,
+ attachments: currentAttachments.data || []
+ };
+
+ // 각 리비전의 첨부파일 정보 추가
+ const revisionsWithAttachments = await Promise.all(
+ (revisionsResult.data || []).map(async (revision) => {
+ const attachmentsResult = await getTechSalesVendorQuotationAttachmentsByRevision(quotationId, revision.version);
+ return {
+ ...revision,
+ attachments: attachmentsResult.data || []
+ };
+ })
+ );
+
+ return {
+ data: {
+ current: currentWithAttachments,
+ revisions: revisionsWithAttachments
+ },
+ error: null
+ };
+ } catch (error) {
+ console.error("견적서 전체 히스토리 조회 오류:", error);
+ return { data: null, error: "견적서 정보를 조회하는 중 오류가 발생했습니다." };
+ }
+}
+
+/**
+ * 견적서 첨부파일 조회 (리비전 ID 기준 오름차순 정렬)
+ */
+export async function getTechSalesVendorQuotationAttachments(quotationId: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ const attachments = await db
+ .select({
+ id: techSalesVendorQuotationAttachments.id,
+ quotationId: techSalesVendorQuotationAttachments.quotationId,
+ revisionId: techSalesVendorQuotationAttachments.revisionId,
+ fileName: techSalesVendorQuotationAttachments.fileName,
+ originalFileName: techSalesVendorQuotationAttachments.originalFileName,
+ fileSize: techSalesVendorQuotationAttachments.fileSize,
+ fileType: techSalesVendorQuotationAttachments.fileType,
+ filePath: techSalesVendorQuotationAttachments.filePath,
+ description: techSalesVendorQuotationAttachments.description,
+ uploadedBy: techSalesVendorQuotationAttachments.uploadedBy,
+ vendorId: techSalesVendorQuotationAttachments.vendorId,
+ isVendorUpload: techSalesVendorQuotationAttachments.isVendorUpload,
+ createdAt: techSalesVendorQuotationAttachments.createdAt,
+ updatedAt: techSalesVendorQuotationAttachments.updatedAt,
+ })
+ .from(techSalesVendorQuotationAttachments)
+ .where(eq(techSalesVendorQuotationAttachments.quotationId, quotationId))
+ .orderBy(desc(techSalesVendorQuotationAttachments.createdAt));
+
+ return { data: attachments };
+ } catch (error) {
+ console.error("견적서 첨부파일 조회 오류:", error);
+ return { error: "견적서 첨부파일 조회 중 오류가 발생했습니다." };
+ }
+ },
+ [`quotation-attachments-${quotationId}`],
+ {
+ revalidate: 60,
+ tags: [`quotation-${quotationId}`, "quotation-attachments"],
+ }
+ )();
+}
+
+/**
+ * 특정 리비전의 견적서 첨부파일 조회
+ */
+export async function getTechSalesVendorQuotationAttachmentsByRevision(quotationId: number, revisionId: number) {
+ try {
+ const attachments = await db
+ .select({
+ id: techSalesVendorQuotationAttachments.id,
+ quotationId: techSalesVendorQuotationAttachments.quotationId,
+ revisionId: techSalesVendorQuotationAttachments.revisionId,
+ fileName: techSalesVendorQuotationAttachments.fileName,
+ originalFileName: techSalesVendorQuotationAttachments.originalFileName,
+ fileSize: techSalesVendorQuotationAttachments.fileSize,
+ fileType: techSalesVendorQuotationAttachments.fileType,
+ filePath: techSalesVendorQuotationAttachments.filePath,
+ description: techSalesVendorQuotationAttachments.description,
+ uploadedBy: techSalesVendorQuotationAttachments.uploadedBy,
+ vendorId: techSalesVendorQuotationAttachments.vendorId,
+ isVendorUpload: techSalesVendorQuotationAttachments.isVendorUpload,
+ createdAt: techSalesVendorQuotationAttachments.createdAt,
+ updatedAt: techSalesVendorQuotationAttachments.updatedAt,
+ })
+ .from(techSalesVendorQuotationAttachments)
+ .where(and(
+ eq(techSalesVendorQuotationAttachments.quotationId, quotationId),
+ eq(techSalesVendorQuotationAttachments.revisionId, revisionId)
+ ))
+ .orderBy(desc(techSalesVendorQuotationAttachments.createdAt));
+
+ return { data: attachments };
+ } catch (error) {
+ console.error("리비전별 견적서 첨부파일 조회 오류:", error);
+ return { error: "첨부파일 조회 중 오류가 발생했습니다." };
+ }
+}
+
+
+// ==================== Project AVL 관련 ====================
+
+/**
+ * Accepted 상태의 Tech Sales Vendor Quotations 조회 (RFQ, Vendor 정보 포함)
+ */
+export async function getAcceptedTechSalesVendorQuotations(input: {
+ search?: string;
+ filters?: Filter<typeof techSalesVendorQuotations>[];
+ sort?: { id: string; desc: boolean }[];
+ page: number;
+ perPage: number;
+ rfqType?: "SHIP" | "TOP" | "HULL";
+}) {
+ unstable_noStore();
+
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // 기본 WHERE 조건: status = 'Accepted'만 조회, rfqType이 'SHIP'이 아닌 것만
+ const baseConditions = [
+ eq(techSalesVendorQuotations.status, 'Accepted'),
+ sql`${techSalesRfqs.rfqType} != 'SHIP'` // 조선 RFQ 타입 제외
+ ];
+
+ // 검색 조건 추가
+ const searchConditions = [];
+ if (input.search) {
+ searchConditions.push(
+ ilike(techSalesRfqs.rfqCode, `%${input.search}%`),
+ ilike(techSalesRfqs.description, `%${input.search}%`),
+ ilike(sql`vendors.vendor_name`, `%${input.search}%`),
+ ilike(sql`vendors.vendor_code`, `%${input.search}%`)
+ );
+ }
+
+ // 정렬 조건 변환
+ const orderByConditions: OrderByType[] = [];
+ if (input.sort?.length) {
+ input.sort.forEach((sortItem) => {
+ switch (sortItem.id) {
+ case "rfqCode":
+ orderByConditions.push(sortItem.desc ? desc(techSalesRfqs.rfqCode) : asc(techSalesRfqs.rfqCode));
+ break;
+ case "description":
+ orderByConditions.push(sortItem.desc ? desc(techSalesRfqs.description) : asc(techSalesRfqs.description));
+ break;
+ case "vendorName":
+ orderByConditions.push(sortItem.desc ? desc(sql`vendors.vendor_name`) : asc(sql`vendors.vendor_name`));
+ break;
+ case "vendorCode":
+ orderByConditions.push(sortItem.desc ? desc(sql`vendors.vendor_code`) : asc(sql`vendors.vendor_code`));
+ break;
+ case "totalPrice":
+ orderByConditions.push(sortItem.desc ? desc(techSalesVendorQuotations.totalPrice) : asc(techSalesVendorQuotations.totalPrice));
+ break;
+ case "acceptedAt":
+ orderByConditions.push(sortItem.desc ? desc(techSalesVendorQuotations.acceptedAt) : asc(techSalesVendorQuotations.acceptedAt));
+ break;
+ default:
+ orderByConditions.push(desc(techSalesVendorQuotations.acceptedAt));
+ }
+ });
+ } else {
+ orderByConditions.push(desc(techSalesVendorQuotations.acceptedAt));
+ }
+
+ // 필터 조건 추가
+ const filterConditions = [];
+ if (input.filters?.length) {
+ const filterWhere = filterColumns({
+ table: techSalesVendorQuotations,
+ filters: input.filters,
+ joinOperator: "and",
+ });
+ if (filterWhere) {
+ filterConditions.push(filterWhere);
+ }
+ }
+
+ // RFQ 타입 필터
+ if (input.rfqType) {
+ filterConditions.push(eq(techSalesRfqs.rfqType, input.rfqType));
+ }
+
+ // 모든 조건 결합
+ const allConditions = [
+ ...baseConditions,
+ ...filterConditions,
+ ...(searchConditions.length > 0 ? [or(...searchConditions)] : [])
+ ];
+
+ const whereCondition = allConditions.length > 1
+ ? and(...allConditions)
+ : allConditions[0];
+
+ // 데이터 조회
+ const data = await db
+ .select({
+ // Quotation 정보
+ 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,
+ submittedAt: techSalesVendorQuotations.submittedAt,
+ acceptedAt: techSalesVendorQuotations.acceptedAt,
+ createdAt: techSalesVendorQuotations.createdAt,
+ updatedAt: techSalesVendorQuotations.updatedAt,
+
+ // RFQ 정보
+ rfqCode: techSalesRfqs.rfqCode,
+ rfqType: techSalesRfqs.rfqType,
+ description: techSalesRfqs.description,
+ dueDate: techSalesRfqs.dueDate,
+ rfqStatus: techSalesRfqs.status,
+ materialCode: techSalesRfqs.materialCode,
+
+ // Vendor 정보
+ vendorName: sql<string>`vendors.vendor_name`,
+ vendorCode: sql<string | null>`vendors.vendor_code`,
+ vendorEmail: sql<string | null>`vendors.email`,
+ vendorCountry: sql<string | null>`vendors.country`,
+
+ // Project 정보
+ projNm: biddingProjects.projNm,
+ pspid: biddingProjects.pspid,
+ sector: biddingProjects.sector,
+ })
+ .from(techSalesVendorQuotations)
+ .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id))
+ .leftJoin(sql`vendors`, eq(techSalesVendorQuotations.vendorId, sql`vendors.id`))
+ .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id))
+ .where(whereCondition)
+ .orderBy(...orderByConditions)
+ .limit(input.perPage)
+ .offset(offset);
+
+ // 총 개수 조회
+ const totalCount = await db
+ .select({ count: count() })
+ .from(techSalesVendorQuotations)
+ .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id))
+ .leftJoin(sql`vendors`, eq(techSalesVendorQuotations.vendorId, sql`vendors.id`))
+ .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id))
+ .where(whereCondition);
+
+ const total = totalCount[0]?.count ?? 0;
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return {
+ data,
+ pageCount,
+ total,
+ };
+
+ } catch (error) {
+ console.error("getAcceptedTechSalesVendorQuotations 오류:", error);
+ throw new Error(`Accepted quotations 조회 실패: ${getErrorMessage(error)}`);
+ }
+}
+
+export async function getBidProjects(pjtType: 'SHIP' | 'TOP' | 'HULL'): Promise<Project[]> {
+ try {
+ // 트랜잭션을 사용하여 프로젝트 데이터 조회
+ const projectList = await db.transaction(async (tx) => {
+ // 기본 쿼리 구성
+ const query = tx
+ .select({
+ id: biddingProjects.id,
+ projectCode: biddingProjects.pspid,
+ projectName: biddingProjects.projNm,
+ pjtType: biddingProjects.pjtType,
+ })
+ .from(biddingProjects)
+ .where(eq(biddingProjects.pjtType, pjtType));
+
+ const results = await query.orderBy(biddingProjects.id);
+ return results;
+ });
+
+ // Handle null projectName values and ensure pjtType is not null
+ const validProjectList = projectList.map(project => ({
+ ...project,
+ projectName: project.projectName || '', // Replace null with empty string
+ pjtType: project.pjtType as "SHIP" | "TOP" | "HULL" // Type assertion since WHERE filters ensure non-null
+ }));
+
+ return validProjectList;
+ } catch (error) {
+ console.error("프로젝트 목록 가져오기 실패:", error);
+ return []; // 오류 발생 시 빈 배열 반환
+ }
+}
+
+/**
+ * 여러 벤더의 contact 정보 조회
+ */
+export async function getTechVendorsContacts(vendorIds: number[]) {
+ unstable_noStore();
+ try {
+ // 직접 조인으로 벤더와 contact 정보 조회
+ const contactsWithVendor = await db
+ .select({
+ contactId: techVendorContacts.id,
+ contactName: techVendorContacts.contactName,
+ contactPosition: techVendorContacts.contactPosition,
+ contactEmail: techVendorContacts.contactEmail,
+ contactPhone: techVendorContacts.contactPhone,
+ isPrimary: techVendorContacts.isPrimary,
+ vendorId: techVendorContacts.vendorId,
+ vendorName: techVendors.vendorName,
+ vendorCode: techVendors.vendorCode
+ })
+ .from(techVendorContacts)
+ .leftJoin(techVendors, eq(techVendorContacts.vendorId, techVendors.id))
+ .where(inArray(techVendorContacts.vendorId, vendorIds))
+ .orderBy(
+ asc(techVendorContacts.vendorId),
+ desc(techVendorContacts.isPrimary),
+ asc(techVendorContacts.contactName)
+ );
+
+ // 벤더별로 그룹화
+ const contactsByVendor = contactsWithVendor.reduce((acc, row) => {
+ const vendorId = row.vendorId;
+ if (!acc[vendorId]) {
+ acc[vendorId] = {
+ vendor: {
+ id: vendorId,
+ vendorName: row.vendorName || '',
+ vendorCode: row.vendorCode || ''
+ },
+ contacts: []
+ };
+ }
+ acc[vendorId].contacts.push({
+ id: row.contactId,
+ contactName: row.contactName,
+ contactPosition: row.contactPosition,
+ contactEmail: row.contactEmail,
+ contactPhone: row.contactPhone,
+ isPrimary: row.isPrimary
+ });
+ return acc;
+ }, {} as Record<number, {
+ vendor: {
+ id: number;
+ vendorName: string;
+ vendorCode: string | null;
+ };
+ contacts: Array<{
+ id: number;
+ contactName: string;
+ contactPosition: string | null;
+ contactEmail: string;
+ contactPhone: string | null;
+ isPrimary: boolean;
+ }>;
+ }>);
+
+ return { data: contactsByVendor, error: null };
+ } catch (err) {
+ console.error("벤더 contact 조회 오류:", err);
+ return { data: {}, error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * quotation별 발송된 담당자 정보 조회
+ */
+export async function getQuotationContacts(quotationId: number) {
+ unstable_noStore();
+ try {
+ // quotation에 연결된 담당자들 조회
+ const quotationContacts = await db
+ .select({
+ id: techSalesVendorQuotationContacts.id,
+ contactId: techSalesVendorQuotationContacts.contactId,
+ contactName: techVendorContacts.contactName,
+ contactPosition: techVendorContacts.contactPosition,
+ contactEmail: techVendorContacts.contactEmail,
+ contactPhone: techVendorContacts.contactPhone,
+ contactCountry: techVendorContacts.contactCountry,
+ isPrimary: techVendorContacts.isPrimary,
+ createdAt: techSalesVendorQuotationContacts.createdAt,
+ })
+ .from(techSalesVendorQuotationContacts)
+ .innerJoin(
+ techVendorContacts,
+ eq(techSalesVendorQuotationContacts.contactId, techVendorContacts.id)
+ )
+ .where(eq(techSalesVendorQuotationContacts.quotationId, quotationId))
+ .orderBy(techSalesVendorQuotationContacts.createdAt);
+
+ return {
+ success: true,
+ data: quotationContacts,
+ error: null,
+ };
+ } catch (error) {
+ console.error("Quotation contacts 조회 오류:", error);
+ return {
+ success: false,
+ data: [],
+ error: getErrorMessage(error),
+ };
+ }
+}
+
+/**
+ * 견적서 첨부파일 업로드 (클라이언트용)
+ */
+export async function uploadQuotationAttachments(
+ quotationId: number,
+ files: File[],
+ userId: number
+): Promise<{ success: boolean; attachments?: Array<{ fileName: string; originalFileName: string; filePath: string; fileSize: number }>; error?: string }> {
+ try {
+ const uploadedAttachments = [];
+
+ for (const file of files) {
+ const saveResult = await saveFile({
+ file,
+ directory: `techsales-quotations/${quotationId}`,
+ userId: userId.toString(),
+ });
+
+ if (!saveResult.success) {
+ throw new Error(saveResult.error || '파일 저장에 실패했습니다.');
+ }
+
+ uploadedAttachments.push({
+ fileName: saveResult.fileName!, // 해시된 파일명 (저장용)
+ originalFileName: saveResult.originalName!, // 원본 파일명 (표시용)
+ filePath: saveResult.publicPath!,
+ fileSize: file.size,
+ });
+ }
+
+ return {
+ success: true,
+ attachments: uploadedAttachments
+ };
+ } 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/table/README.md b/lib/techsales-rfq/table/README.md deleted file mode 100644 index 74d0005f..00000000 --- a/lib/techsales-rfq/table/README.md +++ /dev/null @@ -1,41 +0,0 @@ - -# 기술영업 RFQ - -1. 마스터 테이블 ----컬럼--- -상태 -견적프로젝트 이름 -rfqCode (RFQ-YYYY-001) -프로젝트 상세보기 액션컬럼 >> 다이얼로그로 해당 프로젝트 정보 보여줌. (SHI/벤더 동일) - -- 견적 프로젝트명 -- 척수 -- 선주명 -- 선급코드(선급명) -- 선종명 -- 선형명 -- 시리즈 상세보기 >> 시리즈별 K/L 연도분기 >> 2026.2Q 형식 -dueDate (마감일) -sentDate (발송일) -sentBy (발송자) -createdBy (생성자) -updatedBy (수정자) -createdAt (생성일) -updatedAt (수정일) -첨부파일 첨부 테이블 -취소 이유 (삼중이 취소했을 때) -데이터 없으면 취소하기 버튼으로 보여주기. -코멘트 액션컬럼 ----컬럼--- - -2. 디테일 테이블 -디테일 테이블에서는 마스터 테이블의 레코드를 선택했을 때 해당 레코드의 상세내역을 보여줌. -여기서는 벤더별 rfq 송신 및 현황 확인을 응답을 확인할 수 있도록, 발주용 견적과 유사하게 처리 ----컬럼--- -벤더명 -상태 -응답 (가격) -발송일 -발송자 -응답일 -응답자 diff --git a/lib/techsales-rfq/table/create-rfq-hull-dialog.tsx b/lib/techsales-rfq/table/create-rfq-hull-dialog.tsx index 23c57491..5870c785 100644 --- a/lib/techsales-rfq/table/create-rfq-hull-dialog.tsx +++ b/lib/techsales-rfq/table/create-rfq-hull-dialog.tsx @@ -1,648 +1,648 @@ -"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 -} - -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="입찰 프로젝트를 선택하세요" - pjtType="HULL" - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* RFQ 설명 */} - <FormField - control={form.control} - name="description" - render={({ field }) => ( - <FormItem> - <FormLabel>RFQ Title</FormLabel> - <FormControl> - <Input - placeholder="RFQ Title을 입력하세요 (선택사항)" - {...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> - ) +"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
+}
+
+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="입찰 프로젝트를 선택하세요"
+ pjtType="HULL"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* RFQ 설명 */}
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>RFQ Title</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="RFQ Title을 입력하세요 (선택사항)"
+ {...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 aCode = a.itemCode || 'zzz'
+ const bCode = b.itemCode || 'zzz'
+ return aCode.localeCompare(bCode, '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-ship-dialog.tsx b/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx index efa4e164..114bd04d 100644 --- a/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx +++ b/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx @@ -1,726 +1,726 @@ -"use client" - -import * as React from "react" -import { toast } from "sonner" -import { ArrowUpDown, CheckSquare, Plus, Search, Square, X, Loader2 } from "lucide-react" -import { Input } from "@/components/ui/input" -import { Calendar } from "@/components/ui/calendar" -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" -import { CalendarIcon } from "lucide-react" -import { format } from "date-fns" -import { ko } from "date-fns/locale" - -import { Button } from "@/components/ui/button" -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, - FormDescription, -} from "@/components/ui/form" -import { zodResolver } from "@hookform/resolvers/zod" -import { useForm } from "react-hook-form" -import * as z from "zod" -import { EstimateProjectSelector } from "@/components/BidProjectSelector" -import { type Project } from "@/lib/rfqs/service" -import { createTechSalesShipRfq } from "@/lib/techsales-rfq/service" -import { useSession } from "next-auth/react" -import { Separator } from "@/components/ui/separator" -import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { cn } from "@/lib/utils" -import { ScrollArea } from "@/components/ui/scroll-area" - -// 조선 아이템 서비스 import -import { - getWorkTypes, - getAllShipbuildingItemsForCache, - getShipTypes, - type ShipbuildingItem, - type ShipbuildingWorkType -} from "@/lib/items-tech/service" - - -// 유효성 검증 스키마 -const createShipRfqSchema = z.object({ - biddingProjectId: z.number({ - required_error: "프로젝트를 선택해주세요.", - }), - itemIds: z.array(z.number()).min(1, { - message: "적어도 하나의 아이템을 선택해야 합니다.", - }), - dueDate: z.date({ - required_error: "마감일을 선택해주세요.", - }), - description: z.string().optional(), -}) - -// 폼 데이터 타입 -type CreateShipRfqFormValues = z.infer<typeof createShipRfqSchema> - -// 공종 타입 정의 -interface WorkTypeOption { - code: ShipbuildingWorkType - name: string -} - -interface CreateShipRfqDialogProps { - onCreated?: () => void; -} - -export function CreateShipRfqDialog({ onCreated }: CreateShipRfqDialogProps) { - const { data: session } = useSession() - const [isProcessing, setIsProcessing] = React.useState(false) - const [isDialogOpen, setIsDialogOpen] = React.useState(false) - const [selectedProject, setSelectedProject] = React.useState<Project | null>(null) - - // 검색 및 필터링 상태 - const [itemSearchQuery, setItemSearchQuery] = React.useState("") - const [selectedWorkType, setSelectedWorkType] = React.useState<ShipbuildingWorkType | null>(null) - const [selectedShipType, setSelectedShipType] = React.useState<string | null>(null) - const [selectedItems, setSelectedItems] = React.useState<ShipbuildingItem[]>([]) - - // 데이터 상태 - const [workTypes, setWorkTypes] = React.useState<WorkTypeOption[]>([]) - const [allItems, setAllItems] = React.useState<ShipbuildingItem[]>([]) - const [shipTypes, setShipTypes] = React.useState<string[]>([]) - 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(`조선 RFQ 데이터 로딩 시작... ${isRetry ? `(재시도 ${retryCount + 1}회)` : ''}`) - - const [workTypesResult, itemsResult, shipTypesResult] = await Promise.all([ - getWorkTypes(), - getAllShipbuildingItemsForCache(), - getShipTypes() - ]) - - console.log("Ship - WorkTypes 결과:", workTypesResult) - console.log("Ship - Items 결과:", itemsResult) - console.log("Ship - ShipTypes 결과:", shipTypesResult) - - // WorkTypes 설정 - if (Array.isArray(workTypesResult)) { - setWorkTypes(workTypesResult) - } else { - throw new Error("공종 데이터를 불러올 수 없습니다.") - } - - // Items 설정 - if (!itemsResult.error && itemsResult.data && Array.isArray(itemsResult.data)) { - setAllItems(itemsResult.data) - console.log("Ship 아이템 설정 완료:", itemsResult.data.length, "개") - } else { - throw new Error(itemsResult.error || "Ship 아이템 데이터를 불러올 수 없습니다.") - } - - // ShipTypes 설정 - if (!shipTypesResult.error && shipTypesResult.data && Array.isArray(shipTypesResult.data)) { - setShipTypes(shipTypesResult.data) - console.log("선종 설정 완료:", shipTypesResult.data) - } else { - throw new Error(shipTypesResult.error || "선종 데이터를 불러올 수 없습니다.") - } - - // 성공 시 재시도 카운터 리셋 - setRetryCount(0) - setDataLoadError(null) - console.log("조선 RFQ 데이터 로딩 완료") - - } catch (error) { - const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.' - console.error("조선 RFQ 데이터 로딩 오류:", errorMessage) - - setDataLoadError(errorMessage) - - // 3회까지 자동 재시도 (500ms 간격) - if (retryCount < 2) { - console.log(`${500 * (retryCount + 1)}ms 후 재시도...`) - setTimeout(() => { - setRetryCount(prev => prev + 1) - loadData(true) - }, 500 * (retryCount + 1)) - } else { - // 재시도 실패 시 사용자에게 알림 - toast.error(`데이터 로딩에 실패했습니다: ${errorMessage}`) - } - } finally { - if (!isRetry) { - setIsLoadingItems(false) - } - } - }, [retryCount]) - - // 다이얼로그가 열릴 때마다 데이터 로딩 - React.useEffect(() => { - if (isDialogOpen) { - setDataLoadError(null) - setRetryCount(0) - loadData() - } - }, [isDialogOpen, loadData]) - - // 수동 새로고침 함수 - const handleRefreshData = React.useCallback(() => { - setDataLoadError(null) - setRetryCount(0) - loadData() - }, [loadData]) - - // RFQ 생성 폼 - const form = useForm<CreateShipRfqFormValues>({ - resolver: zodResolver(createShipRfqSchema), - defaultValues: { - biddingProjectId: undefined, - itemIds: [], - dueDate: undefined, - description: "", - } - }) - - // 필터링된 아이템 목록 가져오기 - const availableItems = React.useMemo(() => { - let filtered = [...allItems] - - // 선종 필터 - if (selectedShipType) { - filtered = filtered.filter(item => item.shipTypes === selectedShipType) - } - - // 공종 필터 - if (selectedWorkType) { - filtered = filtered.filter(item => item.workType === selectedWorkType) - } - - // 검색어 필터 - if (itemSearchQuery && itemSearchQuery.trim()) { - const query = itemSearchQuery.toLowerCase().trim() - filtered = filtered.filter(item => - item.itemCode.toLowerCase().includes(query) || - (item.itemList && item.itemList.toLowerCase().includes(query)) - ) - } - - return filtered - }, [allItems, itemSearchQuery, selectedWorkType, selectedShipType]) - - // 사용 가능한 선종 목록 가져오기 - const availableShipTypes = React.useMemo(() => { - return shipTypes - }, [shipTypes]) - - // 프로젝트 선택 처리 - const handleProjectSelect = (project: Project) => { - setSelectedProject(project) - form.setValue("biddingProjectId", project.id) - // 선택 초기화 - setSelectedItems([]) - setSelectedShipType(null) - setSelectedWorkType(null) - setItemSearchQuery("") - form.setValue("itemIds", []) - } - - // 아이템 선택/해제 처리 - const handleItemToggle = (item: ShipbuildingItem) => { - const isSelected = selectedItems.some(selected => selected.id === item.id) - - if (isSelected) { - const newSelectedItems = selectedItems.filter(selected => selected.id !== item.id) - setSelectedItems(newSelectedItems) - form.setValue("itemIds", newSelectedItems.map(item => item.id)) - } else { - const newSelectedItems = [...selectedItems, item] - setSelectedItems(newSelectedItems) - form.setValue("itemIds", newSelectedItems.map(item => item.id)) - } - } - - // RFQ 생성 함수 - const handleCreateRfq = async (data: CreateShipRfqFormValues) => { - try { - setIsProcessing(true) - - // 사용자 인증 확인 - if (!session?.user?.id) { - throw new Error("로그인이 필요합니다") - } - - // 조선 RFQ 생성 - 1:N 관계로 한 번에 생성 - const result = await createTechSalesShipRfq({ - biddingProjectId: data.biddingProjectId, - itemIds: data.itemIds, - dueDate: data.dueDate, - description: data.description, - createdBy: Number(session.user.id), - }) - - if (result.error) { - throw new Error(result.error) - } - - // 성공적으로 생성되면 다이얼로그 닫기 및 메시지 표시 - toast.success(`${selectedItems.length}개 아이템으로 조선 RFQ가 성공적으로 생성되었습니다`) - - setIsDialogOpen(false) - form.reset({ - biddingProjectId: undefined, - itemIds: [], - dueDate: undefined, - description: "", - }) - setSelectedProject(null) - setItemSearchQuery("") - setSelectedWorkType(null) - setSelectedShipType(null) - setSelectedItems([]) - setDataLoadError(null) - setRetryCount(0) - - // 생성 후 콜백 실행 - if (onCreated) { - onCreated() - } - - } catch (error) { - console.error("조선 RFQ 생성 오류:", error) - toast.error(`조선 RFQ 생성 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`) - } finally { - setIsProcessing(false) - } - } - - return ( - <Dialog - open={isDialogOpen} - onOpenChange={(open) => { - setIsDialogOpen(open) - if (!open) { - form.reset({ - biddingProjectId: undefined, - itemIds: [], - dueDate: undefined, - description: "", - }) - setSelectedProject(null) - setItemSearchQuery("") - setSelectedWorkType(null) - setSelectedShipType(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">조선 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>조선 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="입찰 프로젝트를 선택하세요" - pjtType="SHIP" - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <Separator className="my-4" /> - - {/* RFQ 설명 */} - <FormField - control={form.control} - name="description" - render={({ field }) => ( - <FormItem> - <FormLabel>RFQ Title</FormLabel> - <FormControl> - <Input - placeholder="RFQ Title을 입력하세요 (선택사항)" - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <Separator className="my-4" /> - - {/* 선종 선택 */} - <div className="space-y-4"> - <div> - <FormLabel>선종 선택</FormLabel> - </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> - )} - - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - variant="outline" - className="w-full justify-between" - disabled={!selectedProject || isLoadingItems || dataLoadError !== null} - > - {isLoadingItems ? ( - <> - <Loader2 className="h-4 w-4 animate-spin mr-2" /> - 데이터 로딩 중... - </> - ) : dataLoadError ? ( - "데이터 로딩 실패" - ) : selectedShipType ? ( - selectedShipType - ) : ( - "선종을 선택하세요" - )} - <ArrowUpDown className="ml-2 h-4 w-4 opacity-50" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent className="w-full max-h-60 overflow-y-auto"> - <DropdownMenuCheckboxItem - checked={selectedShipType === null} - onCheckedChange={() => { - setSelectedShipType(null) - setSelectedItems([]) - form.setValue("itemIds", []) - }} - > - 전체 선종 - </DropdownMenuCheckboxItem> - {availableShipTypes.map(shipType => ( - <DropdownMenuCheckboxItem - key={shipType} - checked={selectedShipType === shipType} - onCheckedChange={() => { - setSelectedShipType(shipType) - setSelectedItems([]) - form.setValue("itemIds", []) - }} - > - {shipType} - </DropdownMenuCheckboxItem> - ))} - </DropdownMenuContent> - </DropdownMenu> - </div> - - <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> - {selectedShipType - ? `선종 ${selectedShipType}의 공종별 아이템을 선택하세요` - : "먼저 선종을 선택해주세요" - } - </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={!selectedShipType || isLoadingItems || dataLoadError !== null} - /> - {itemSearchQuery && ( - <Button - variant="ghost" - size="sm" - className="absolute right-0 top-0 h-full px-3" - onClick={() => setItemSearchQuery("")} - disabled={!selectedShipType || isLoadingItems || dataLoadError !== null} - > - <X className="h-4 w-4" /> - </Button> - )} - </div> - - {/* 공종 필터 */} - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - variant="outline" - className="gap-1" - disabled={!selectedShipType || 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 || '아이템명 없음'} - </div> - <div className="text-sm text-muted-foreground"> - {item.itemCode || '자재그룹코드 없음'} - </div> - <div className="text-xs text-muted-foreground"> - 공종: {item.workType} • 선종: {item.shipTypes} - </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}개 아이템으로 조선 RFQ 생성하기`} - </Button> - </div> - </div> - </DialogContent> - </Dialog> - ) +"use client"
+
+import * as React from "react"
+import { toast } from "sonner"
+import { ArrowUpDown, CheckSquare, Plus, Search, Square, X, Loader2 } from "lucide-react"
+import { Input } from "@/components/ui/input"
+import { Calendar } from "@/components/ui/calendar"
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
+import { CalendarIcon } from "lucide-react"
+import { format } from "date-fns"
+import { ko } from "date-fns/locale"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormDescription,
+} from "@/components/ui/form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useForm } from "react-hook-form"
+import * as z from "zod"
+import { EstimateProjectSelector } from "@/components/BidProjectSelector"
+import { type Project } from "@/lib/rfqs/service"
+import { createTechSalesShipRfq } from "@/lib/techsales-rfq/service"
+import { useSession } from "next-auth/react"
+import { Separator } from "@/components/ui/separator"
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { cn } from "@/lib/utils"
+import { ScrollArea } from "@/components/ui/scroll-area"
+
+// 조선 아이템 서비스 import
+import {
+ getWorkTypes,
+ getAllShipbuildingItemsForCache,
+ getShipTypes,
+ type ShipbuildingItem,
+ type ShipbuildingWorkType
+} from "@/lib/items-tech/service"
+
+
+// 유효성 검증 스키마
+const createShipRfqSchema = z.object({
+ biddingProjectId: z.number({
+ required_error: "프로젝트를 선택해주세요.",
+ }),
+ itemIds: z.array(z.number()).min(1, {
+ message: "적어도 하나의 아이템을 선택해야 합니다.",
+ }),
+ dueDate: z.date({
+ required_error: "마감일을 선택해주세요.",
+ }),
+ description: z.string().optional(),
+})
+
+// 폼 데이터 타입
+type CreateShipRfqFormValues = z.infer<typeof createShipRfqSchema>
+
+// 공종 타입 정의
+interface WorkTypeOption {
+ code: ShipbuildingWorkType
+ name: string
+}
+
+interface CreateShipRfqDialogProps {
+ onCreated?: () => void;
+}
+
+export function CreateShipRfqDialog({ onCreated }: CreateShipRfqDialogProps) {
+ const { data: session } = useSession()
+ const [isProcessing, setIsProcessing] = React.useState(false)
+ const [isDialogOpen, setIsDialogOpen] = React.useState(false)
+ const [selectedProject, setSelectedProject] = React.useState<Project | null>(null)
+
+ // 검색 및 필터링 상태
+ const [itemSearchQuery, setItemSearchQuery] = React.useState("")
+ const [selectedWorkType, setSelectedWorkType] = React.useState<ShipbuildingWorkType | null>(null)
+ const [selectedShipType, setSelectedShipType] = React.useState<string | null>(null)
+ const [selectedItems, setSelectedItems] = React.useState<ShipbuildingItem[]>([])
+
+ // 데이터 상태
+ const [workTypes, setWorkTypes] = React.useState<WorkTypeOption[]>([])
+ const [allItems, setAllItems] = React.useState<ShipbuildingItem[]>([])
+ const [shipTypes, setShipTypes] = React.useState<string[]>([])
+ 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(`조선 RFQ 데이터 로딩 시작... ${isRetry ? `(재시도 ${retryCount + 1}회)` : ''}`)
+
+ const [workTypesResult, itemsResult, shipTypesResult] = await Promise.all([
+ getWorkTypes(),
+ getAllShipbuildingItemsForCache(),
+ getShipTypes()
+ ])
+
+ console.log("Ship - WorkTypes 결과:", workTypesResult)
+ console.log("Ship - Items 결과:", itemsResult)
+ console.log("Ship - ShipTypes 결과:", shipTypesResult)
+
+ // WorkTypes 설정
+ if (Array.isArray(workTypesResult)) {
+ setWorkTypes(workTypesResult)
+ } else {
+ throw new Error("공종 데이터를 불러올 수 없습니다.")
+ }
+
+ // Items 설정
+ if (!itemsResult.error && itemsResult.data && Array.isArray(itemsResult.data)) {
+ setAllItems(itemsResult.data)
+ console.log("Ship 아이템 설정 완료:", itemsResult.data.length, "개")
+ } else {
+ throw new Error(itemsResult.error || "Ship 아이템 데이터를 불러올 수 없습니다.")
+ }
+
+ // ShipTypes 설정
+ if (!shipTypesResult.error && shipTypesResult.data && Array.isArray(shipTypesResult.data)) {
+ setShipTypes(shipTypesResult.data)
+ console.log("선종 설정 완료:", shipTypesResult.data)
+ } else {
+ throw new Error(shipTypesResult.error || "선종 데이터를 불러올 수 없습니다.")
+ }
+
+ // 성공 시 재시도 카운터 리셋
+ setRetryCount(0)
+ setDataLoadError(null)
+ console.log("조선 RFQ 데이터 로딩 완료")
+
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'
+ console.error("조선 RFQ 데이터 로딩 오류:", errorMessage)
+
+ setDataLoadError(errorMessage)
+
+ // 3회까지 자동 재시도 (500ms 간격)
+ if (retryCount < 2) {
+ console.log(`${500 * (retryCount + 1)}ms 후 재시도...`)
+ setTimeout(() => {
+ setRetryCount(prev => prev + 1)
+ loadData(true)
+ }, 500 * (retryCount + 1))
+ } else {
+ // 재시도 실패 시 사용자에게 알림
+ toast.error(`데이터 로딩에 실패했습니다: ${errorMessage}`)
+ }
+ } finally {
+ if (!isRetry) {
+ setIsLoadingItems(false)
+ }
+ }
+ }, [retryCount])
+
+ // 다이얼로그가 열릴 때마다 데이터 로딩
+ React.useEffect(() => {
+ if (isDialogOpen) {
+ setDataLoadError(null)
+ setRetryCount(0)
+ loadData()
+ }
+ }, [isDialogOpen, loadData])
+
+ // 수동 새로고침 함수
+ const handleRefreshData = React.useCallback(() => {
+ setDataLoadError(null)
+ setRetryCount(0)
+ loadData()
+ }, [loadData])
+
+ // RFQ 생성 폼
+ const form = useForm<CreateShipRfqFormValues>({
+ resolver: zodResolver(createShipRfqSchema),
+ defaultValues: {
+ biddingProjectId: undefined,
+ itemIds: [],
+ dueDate: undefined,
+ description: "",
+ }
+ })
+
+ // 필터링된 아이템 목록 가져오기
+ const availableItems = React.useMemo(() => {
+ let filtered = [...allItems]
+
+ // 선종 필터
+ if (selectedShipType) {
+ filtered = filtered.filter(item => item.shipTypes === selectedShipType)
+ }
+
+ // 공종 필터
+ if (selectedWorkType) {
+ filtered = filtered.filter(item => item.workType === selectedWorkType)
+ }
+
+ // 검색어 필터
+ if (itemSearchQuery && itemSearchQuery.trim()) {
+ const query = itemSearchQuery.toLowerCase().trim()
+ filtered = filtered.filter(item =>
+ item.itemCode.toLowerCase().includes(query) ||
+ (item.itemList && item.itemList.toLowerCase().includes(query))
+ )
+ }
+
+ return filtered
+ }, [allItems, itemSearchQuery, selectedWorkType, selectedShipType])
+
+ // 사용 가능한 선종 목록 가져오기
+ const availableShipTypes = React.useMemo(() => {
+ return shipTypes
+ }, [shipTypes])
+
+ // 프로젝트 선택 처리
+ const handleProjectSelect = (project: Project) => {
+ setSelectedProject(project)
+ form.setValue("biddingProjectId", project.id)
+ // 선택 초기화
+ setSelectedItems([])
+ setSelectedShipType(null)
+ setSelectedWorkType(null)
+ setItemSearchQuery("")
+ form.setValue("itemIds", [])
+ }
+
+ // 아이템 선택/해제 처리
+ const handleItemToggle = (item: ShipbuildingItem) => {
+ const isSelected = selectedItems.some(selected => selected.id === item.id)
+
+ if (isSelected) {
+ const newSelectedItems = selectedItems.filter(selected => selected.id !== item.id)
+ setSelectedItems(newSelectedItems)
+ form.setValue("itemIds", newSelectedItems.map(item => item.id))
+ } else {
+ const newSelectedItems = [...selectedItems, item]
+ setSelectedItems(newSelectedItems)
+ form.setValue("itemIds", newSelectedItems.map(item => item.id))
+ }
+ }
+
+ // RFQ 생성 함수
+ const handleCreateRfq = async (data: CreateShipRfqFormValues) => {
+ try {
+ setIsProcessing(true)
+
+ // 사용자 인증 확인
+ if (!session?.user?.id) {
+ throw new Error("로그인이 필요합니다")
+ }
+
+ // 조선 RFQ 생성 - 1:N 관계로 한 번에 생성
+ const result = await createTechSalesShipRfq({
+ biddingProjectId: data.biddingProjectId,
+ itemIds: data.itemIds,
+ dueDate: data.dueDate,
+ description: data.description,
+ createdBy: Number(session.user.id),
+ })
+
+ if (result.error) {
+ throw new Error(result.error)
+ }
+
+ // 성공적으로 생성되면 다이얼로그 닫기 및 메시지 표시
+ toast.success(`${selectedItems.length}개 아이템으로 조선 RFQ가 성공적으로 생성되었습니다`)
+
+ setIsDialogOpen(false)
+ form.reset({
+ biddingProjectId: undefined,
+ itemIds: [],
+ dueDate: undefined,
+ description: "",
+ })
+ setSelectedProject(null)
+ setItemSearchQuery("")
+ setSelectedWorkType(null)
+ setSelectedShipType(null)
+ setSelectedItems([])
+ setDataLoadError(null)
+ setRetryCount(0)
+
+ // 생성 후 콜백 실행
+ if (onCreated) {
+ onCreated()
+ }
+
+ } catch (error) {
+ console.error("조선 RFQ 생성 오류:", error)
+ toast.error(`조선 RFQ 생성 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`)
+ } finally {
+ setIsProcessing(false)
+ }
+ }
+
+ return (
+ <Dialog
+ open={isDialogOpen}
+ onOpenChange={(open) => {
+ setIsDialogOpen(open)
+ if (!open) {
+ form.reset({
+ biddingProjectId: undefined,
+ itemIds: [],
+ dueDate: undefined,
+ description: "",
+ })
+ setSelectedProject(null)
+ setItemSearchQuery("")
+ setSelectedWorkType(null)
+ setSelectedShipType(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">조선 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>조선 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="입찰 프로젝트를 선택하세요"
+ pjtType="SHIP"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <Separator className="my-4" />
+
+ {/* RFQ 설명 */}
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>RFQ Title</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="RFQ Title을 입력하세요 (선택사항)"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <Separator className="my-4" />
+
+ {/* 선종 선택 */}
+ <div className="space-y-4">
+ <div>
+ <FormLabel>선종 선택</FormLabel>
+ </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>
+ )}
+
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="outline"
+ className="w-full justify-between"
+ disabled={!selectedProject || isLoadingItems || dataLoadError !== null}
+ >
+ {isLoadingItems ? (
+ <>
+ <Loader2 className="h-4 w-4 animate-spin mr-2" />
+ 데이터 로딩 중...
+ </>
+ ) : dataLoadError ? (
+ "데이터 로딩 실패"
+ ) : selectedShipType ? (
+ selectedShipType
+ ) : (
+ "선종을 선택하세요"
+ )}
+ <ArrowUpDown className="ml-2 h-4 w-4 opacity-50" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent className="w-full max-h-60 overflow-y-auto">
+ <DropdownMenuCheckboxItem
+ checked={selectedShipType === null}
+ onCheckedChange={() => {
+ setSelectedShipType(null)
+ setSelectedItems([])
+ form.setValue("itemIds", [])
+ }}
+ >
+ 전체 선종
+ </DropdownMenuCheckboxItem>
+ {availableShipTypes.map(shipType => (
+ <DropdownMenuCheckboxItem
+ key={shipType}
+ checked={selectedShipType === shipType}
+ onCheckedChange={() => {
+ setSelectedShipType(shipType)
+ setSelectedItems([])
+ form.setValue("itemIds", [])
+ }}
+ >
+ {shipType}
+ </DropdownMenuCheckboxItem>
+ ))}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+
+ <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>
+ {selectedShipType
+ ? `선종 ${selectedShipType}의 공종별 아이템을 선택하세요`
+ : "먼저 선종을 선택해주세요"
+ }
+ </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={!selectedShipType || isLoadingItems || dataLoadError !== null}
+ />
+ {itemSearchQuery && (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="absolute right-0 top-0 h-full px-3"
+ onClick={() => setItemSearchQuery("")}
+ disabled={!selectedShipType || isLoadingItems || dataLoadError !== null}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+
+ {/* 공종 필터 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="outline"
+ className="gap-1"
+ disabled={!selectedShipType || 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.itemCode || 'zzz'
+ const bName = b.itemCode || '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 || '아이템명 없음'}
+ </div>
+ <div className="text-sm text-muted-foreground">
+ {item.itemCode || '자재그룹코드 없음'}
+ </div>
+ <div className="text-xs text-muted-foreground">
+ 공종: {item.workType} • 선종: {item.shipTypes}
+ </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}개 아이템으로 조선 RFQ 생성하기`}
+ </Button>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
}
\ No newline at end of file diff --git a/lib/techsales-rfq/table/create-rfq-top-dialog.tsx b/lib/techsales-rfq/table/create-rfq-top-dialog.tsx index ef2229ac..49fb35ca 100644 --- a/lib/techsales-rfq/table/create-rfq-top-dialog.tsx +++ b/lib/techsales-rfq/table/create-rfq-top-dialog.tsx @@ -1,611 +1,611 @@ -"use client" - -import * as React from "react" -import { toast } from "sonner" -import { ArrowUpDown, CheckSquare, Plus, Search, Square, X, Loader2 } from "lucide-react" -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 { Input } from "@/components/ui/input" - -// 공종 타입 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 -} - -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="입찰 프로젝트를 선택하세요" - pjtType="TOP" - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <Separator className="my-4" /> - {/* RFQ 설명 */} - <FormField - control={form.control} - name="description" - render={({ field }) => ( - <FormItem> - <FormLabel>RFQ Title</FormLabel> - <FormControl> - <Input - placeholder="RFQ Title을 입력하세요 (선택사항)" - {...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>아이템 선택</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> - ) +"use client"
+
+import * as React from "react"
+import { toast } from "sonner"
+import { ArrowUpDown, CheckSquare, Plus, Search, Square, X, Loader2 } from "lucide-react"
+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 { Input } from "@/components/ui/input"
+
+// 공종 타입 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
+}
+
+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="입찰 프로젝트를 선택하세요"
+ pjtType="TOP"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <Separator className="my-4" />
+ {/* RFQ 설명 */}
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>RFQ Title</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="RFQ Title을 입력하세요 (선택사항)"
+ {...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>아이템 선택</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.itemCode || 'zzz'
+ const bName = b.itemCode || '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/delete-vendors-dialog.tsx b/lib/techsales-rfq/table/delete-vendors-dialog.tsx index 35c3b067..788ef1cc 100644 --- a/lib/techsales-rfq/table/delete-vendors-dialog.tsx +++ b/lib/techsales-rfq/table/delete-vendors-dialog.tsx @@ -1,119 +1,119 @@ -"use client" - -import * as React from "react" -import { type RfqDetailView } from "./rfq-detail-column" -import { Loader, Trash } from "lucide-react" - -import { useMediaQuery } from "@/hooks/use-media-query" -import { Button } from "@/components/ui/button" -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { - Drawer, - DrawerClose, - DrawerContent, - DrawerDescription, - DrawerFooter, - DrawerHeader, - DrawerTitle, -} from "@/components/ui/drawer" - -interface DeleteVendorsDialogProps - extends React.ComponentPropsWithoutRef<typeof Dialog> { - vendors: RfqDetailView[] - onConfirm: () => void - isLoading?: boolean -} - -export function DeleteVendorsDialog({ - vendors, - onConfirm, - isLoading = false, - ...props -}: DeleteVendorsDialogProps) { - const isDesktop = useMediaQuery("(min-width: 640px)") - - const vendorNames = vendors.map(v => v.vendorName).filter(Boolean).join(", ") - - if (isDesktop) { - return ( - <Dialog {...props}> - <DialogContent> - <DialogHeader> - <DialogTitle>벤더 삭제 확인</DialogTitle> - <DialogDescription> - 정말로 선택한 <span className="font-medium">{vendors.length}개</span>의 벤더를 삭제하시겠습니까? - <br /> - <br /> - 삭제될 벤더: <span className="font-medium">{vendorNames}</span> - <br /> - <br /> - 이 작업은 되돌릴 수 없습니다. - </DialogDescription> - </DialogHeader> - <DialogFooter className="gap-2 sm:space-x-0"> - <DialogClose asChild> - <Button variant="outline" disabled={isLoading}>취소</Button> - </DialogClose> - <Button - aria-label="선택한 벤더들 삭제" - variant="destructive" - onClick={onConfirm} - disabled={isLoading} - > - {isLoading && ( - <Loader - className="mr-2 size-4 animate-spin" - aria-hidden="true" - /> - )} - 삭제 - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) - } - - return ( - <Drawer {...props}> - <DrawerContent> - <DrawerHeader> - <DrawerTitle>벤더 삭제 확인</DrawerTitle> - <DrawerDescription> - 정말로 선택한 <span className="font-medium">{vendors.length}개</span>의 벤더를 삭제하시겠습니까? - <br /> - <br /> - 삭제될 벤더: <span className="font-medium">{vendorNames}</span> - <br /> - <br /> - 이 작업은 되돌릴 수 없습니다. - </DrawerDescription> - </DrawerHeader> - <DrawerFooter className="gap-2 sm:space-x-0"> - <DrawerClose asChild> - <Button variant="outline" disabled={isLoading}>취소</Button> - </DrawerClose> - <Button - aria-label="선택한 벤더들 삭제" - variant="destructive" - onClick={onConfirm} - disabled={isLoading} - > - {isLoading && ( - <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> - )} - 삭제 - </Button> - </DrawerFooter> - </DrawerContent> - </Drawer> - ) +"use client"
+
+import * as React from "react"
+import { type RfqDetailView } from "./detail-table/rfq-detail-column"
+import { Loader } from "lucide-react"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+} from "@/components/ui/drawer"
+
+interface DeleteVendorsDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ vendors: RfqDetailView[]
+ onConfirm: () => void
+ isLoading?: boolean
+}
+
+export function DeleteVendorsDialog({
+ vendors,
+ onConfirm,
+ isLoading = false,
+ ...props
+}: DeleteVendorsDialogProps) {
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ const vendorNames = vendors.map(v => v.vendorName).filter(Boolean).join(", ")
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>벤더 삭제 확인</DialogTitle>
+ <DialogDescription>
+ 정말로 선택한 <span className="font-medium">{vendors.length}개</span>의 벤더를 삭제하시겠습니까?
+ <br />
+ <br />
+ 삭제될 벤더: <span className="font-medium">{vendorNames}</span>
+ <br />
+ <br />
+ 이 작업은 되돌릴 수 없습니다.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline" disabled={isLoading}>취소</Button>
+ </DialogClose>
+ <Button
+ aria-label="선택한 벤더들 삭제"
+ variant="destructive"
+ onClick={onConfirm}
+ disabled={isLoading}
+ >
+ {isLoading && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 삭제
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>벤더 삭제 확인</DrawerTitle>
+ <DrawerDescription>
+ 정말로 선택한 <span className="font-medium">{vendors.length}개</span>의 벤더를 삭제하시겠습니까?
+ <br />
+ <br />
+ 삭제될 벤더: <span className="font-medium">{vendorNames}</span>
+ <br />
+ <br />
+ 이 작업은 되돌릴 수 없습니다.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline" disabled={isLoading}>취소</Button>
+ </DrawerClose>
+ <Button
+ aria-label="선택한 벤더들 삭제"
+ variant="destructive"
+ onClick={onConfirm}
+ disabled={isLoading}
+ >
+ {isLoading && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ 삭제
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
}
\ 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 8f2fe948..69953217 100644 --- a/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx +++ b/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx @@ -1,474 +1,474 @@ -"use client" - -import * as React from "react" -import { useState, useEffect, useCallback } from "react" -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, Star } from "lucide-react" -import { useSession } from "next-auth/react" - -import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" -import { Button } from "@/components/ui/button" -import { Form, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form" -import { Input } from "@/components/ui/input" -import { ScrollArea } from "@/components/ui/scroll-area" -import { Badge } from "@/components/ui/badge" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { addTechVendorsToTechSalesRfq, getTechSalesRfqCandidateVendors, searchTechVendors } from "@/lib/techsales-rfq/service" - -// 폼 유효성 검증 스키마 - 간단화 -const vendorFormSchema = z.object({ - vendorIds: z.array(z.number()).min(1, "최소 하나의 벤더를 선택해주세요"), -}) - -type VendorFormValues = z.infer<typeof vendorFormSchema> - -// 기술영업 RFQ 타입 정의 -type TechSalesRfq = { - id: number - rfqCode: string | null - rfqType: "SHIP" | "TOP" | "HULL" | null - ptypeNm: string | null // 프로젝트 타입명 추가 - status: string - [key: string]: any // eslint-disable-line @typescript-eslint/no-explicit-any -} - -// 벤더 검색 결과 타입 (techVendor 기반) -type VendorSearchResult = { - id: number - vendorName: string - vendorCode: string | null - status: string - country: string | null - techVendorType?: string | null - matchedItemCount?: number // 후보 벤더 정보 -} - -interface AddVendorDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - selectedRfq: TechSalesRfq | null - onSuccess?: () => void - existingVendorIds?: number[] -} - -export function AddVendorDialog({ - open, - onOpenChange, - selectedRfq, - onSuccess, - existingVendorIds = [], -}: AddVendorDialogProps) { - const { data: session } = useSession() - 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), - defaultValues: { - vendorIds: [], - }, - }) - - 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()) { - setSearchResults([]) - setHasSearched(false) - return - } - - setIsSearching(true) - try { - // 선택된 RFQ의 타입을 기반으로 벤더 검색 - const rfqType = selectedRfq?.rfqType || undefined; - console.log("rfqType", rfqType) // 디버깅용 - const results = await searchTechVendors(term, 100, rfqType) - - // 이미 추가된 벤더 제외 - const filteredResults = results.filter((vendor: VendorSearchResult) => !existingVendorIds.includes(vendor.id)) - setSearchResults(filteredResults) - setHasSearched(true) - } catch (error) { - console.error("벤더 검색 오류:", error) - toast.error("벤더 검색 중 오류가 발생했습니다") - setSearchResults([]) - } finally { - setIsSearching(false) - } - }, - [existingVendorIds, selectedRfq?.rfqType] - ) - - // 검색어 변경 시 디바운스 적용 - useEffect(() => { - const timer = setTimeout(() => { - searchVendorsDebounced(searchTerm) - }, 300) - - 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") - const isSelected = currentIds.includes(vendor.id) - - if (isSelected) { - // 선택 해제 - const newIds = currentIds.filter(id => id !== vendor.id) - const newSelectedData = selectedVendorData.filter(v => v.id !== vendor.id) - form.setValue("vendorIds", newIds, { shouldValidate: true }) - setSelectedVendorData(newSelectedData) - } else { - // 선택 추가 - const newIds = [...currentIds, vendor.id] - const newSelectedData = [...selectedVendorData, vendor] - form.setValue("vendorIds", newIds, { shouldValidate: true }) - setSelectedVendorData(newSelectedData) - } - } - - // 선택된 벤더 제거 핸들러 - const handleRemoveVendor = (vendorId: number) => { - const currentIds = form.getValues("vendorIds") - const newIds = currentIds.filter(id => id !== vendorId) - const newSelectedData = selectedVendorData.filter(v => v.id !== vendorId) - form.setValue("vendorIds", newIds, { shouldValidate: true }) - setSelectedVendorData(newSelectedData) - } - - // 폼 제출 핸들러 - async function onSubmit(values: VendorFormValues) { - if (!selectedRfq) { - toast.error("선택된 RFQ가 없습니다") - return - } - - if (!session?.user?.id) { - toast.error("로그인이 필요합니다") - return - } - - try { - setIsSubmitting(true) - - // 새로운 서비스 함수 호출 - const result = await addTechVendorsToTechSalesRfq({ - rfqId: selectedRfq.id, - vendorIds: values.vendorIds, - createdBy: Number(session.user.id), - }) - - if (result.error) { - toast.error(result.error) - } else { - const successCount = result.data?.length || 0 - toast.success(`${successCount}개의 벤더가 성공적으로 추가되었습니다`) - - onOpenChange(false) - form.reset() - setSearchTerm("") - setSearchResults([]) - setCandidateVendors([]) - setHasSearched(false) - setHasCandidatesLoaded(false) - setSelectedVendorData([]) - onSuccess?.() - } - } catch (error) { - console.error("벤더 추가 오류:", error) - toast.error("벤더 추가 중 오류가 발생했습니다") - } finally { - setIsSubmitting(false) - } - } - - // 다이얼로그 닫기 시 폼 리셋 - React.useEffect(() => { - if (!open) { - 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-[800px] max-h-[80vh] flex flex-col"> - {/* 헤더 */} - <DialogHeader> - <DialogTitle>벤더 추가</DialogTitle> - <DialogDescription> - {selectedRfq ? ( - <> - <span className="font-medium">{selectedRfq.rfqCode}</span> RFQ에 벤더를 추가합니다. - </> - ) : ( - "RFQ에 벤더를 추가합니다." - )} - </DialogDescription> - </DialogHeader> - - {/* 콘텐츠 */} - <div className="flex-1 overflow-y-auto"> - <Form {...form}> - <form id="vendor-form" onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> - {/* 탭 메뉴 */} - <Tabs value={activeTab} onValueChange={setActiveTab}> - <TabsList className="grid w-full grid-cols-2"> - <TabsTrigger value="candidates"> - 후보 벤더 ({candidateVendors.length}) - </TabsTrigger> - <TabsTrigger value="search"> - 벤더 검색 - </TabsTrigger> - </TabsList> - - {/* 후보 벤더 탭 */} - <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> - </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 - control={form.control} - name="vendorIds" - render={() => ( - <FormItem> - <div className="space-y-2"> - <FormLabel>선택된 벤더 ({selectedVendorData.length}개)</FormLabel> - <div className="min-h-[60px] p-3 border rounded-md bg-muted/50"> - {selectedVendorData.length > 0 ? ( - <div className="flex flex-wrap gap-2"> - {selectedVendorData.map((vendor) => ( - <Badge - key={vendor.id} - variant="secondary" - className="flex items-center gap-1" - > - {vendor.vendorName} ({vendor.vendorCode || 'N/A'}) - <X - className="h-3 w-3 cursor-pointer hover:text-destructive" - onClick={() => handleRemoveVendor(vendor.id)} - /> - </Badge> - ))} - </div> - ) : ( - <div className="flex items-center justify-center h-full text-sm text-muted-foreground"> - 선택된 벤더가 없습니다 - </div> - )} - </div> - </div> - <FormMessage /> - </FormItem> - )} - /> - - {/* 안내 메시지 */} - <div className="text-sm text-muted-foreground bg-muted p-3 rounded-md"> - <p>• 후보 벤더는 RFQ 아이템 코드와 매칭되는 기술영업 벤더들입니다.</p> - <p>• 선택된 벤더들은 Draft 상태로 추가됩니다.</p> - <p>• 벤더별 견적 정보는 추가 후 개별적으로 입력할 수 있습니다.</p> - </div> - </form> - </Form> - </div> - - {/* 푸터 */} - <DialogFooter> - <Button - type="button" - variant="outline" - onClick={() => onOpenChange(false)} - disabled={isSubmitting} - > - 취소 - </Button> - <Button - type="submit" - form="vendor-form" - disabled={isSubmitting || selectedVendorIds.length === 0} - > - {isSubmitting ? "처리 중..." : `${selectedVendorIds.length}개 벤더 추가`} - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) +"use client"
+
+import * as React from "react"
+import { useState, useEffect, useCallback } from "react"
+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, Star } from "lucide-react"
+import { useSession } from "next-auth/react"
+
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Form, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { Badge } from "@/components/ui/badge"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { addTechVendorsToTechSalesRfq, getTechSalesRfqCandidateVendors, searchTechVendors } from "@/lib/techsales-rfq/service"
+
+// 폼 유효성 검증 스키마 - 간단화
+const vendorFormSchema = z.object({
+ vendorIds: z.array(z.number()).min(1, "최소 하나의 벤더를 선택해주세요"),
+})
+
+type VendorFormValues = z.infer<typeof vendorFormSchema>
+
+// 기술영업 RFQ 타입 정의
+type TechSalesRfq = {
+ id: number
+ rfqCode: string | null
+ rfqType: "SHIP" | "TOP" | "HULL" | null
+ ptypeNm: string | null // 프로젝트 타입명 추가
+ status: string
+ [key: string]: any // eslint-disable-line @typescript-eslint/no-explicit-any
+}
+
+// 벤더 검색 결과 타입 (techVendor 기반)
+type VendorSearchResult = {
+ id: number
+ vendorName: string
+ vendorCode: string | null
+ status: string
+ country: string | null
+ techVendorType?: string | null
+ matchedItemCount?: number // 후보 벤더 정보
+}
+
+interface AddVendorDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ selectedRfq: TechSalesRfq | null
+ onSuccess?: () => void
+ existingVendorIds?: number[]
+}
+
+export function AddVendorDialog({
+ open,
+ onOpenChange,
+ selectedRfq,
+ onSuccess,
+ existingVendorIds = [],
+}: AddVendorDialogProps) {
+ const { data: session } = useSession()
+ 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),
+ defaultValues: {
+ vendorIds: [],
+ },
+ })
+
+ 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()) {
+ setSearchResults([])
+ setHasSearched(false)
+ return
+ }
+
+ setIsSearching(true)
+ try {
+ // 선택된 RFQ의 타입을 기반으로 벤더 검색
+ const rfqType = selectedRfq?.rfqType || undefined;
+ console.log("rfqType", rfqType) // 디버깅용
+ const results = await searchTechVendors(term, 100, rfqType)
+
+ // 이미 추가된 벤더 제외
+ const filteredResults = results.filter((vendor: VendorSearchResult) => !existingVendorIds.includes(vendor.id))
+ setSearchResults(filteredResults)
+ setHasSearched(true)
+ } catch (error) {
+ console.error("벤더 검색 오류:", error)
+ toast.error("벤더 검색 중 오류가 발생했습니다")
+ setSearchResults([])
+ } finally {
+ setIsSearching(false)
+ }
+ },
+ [existingVendorIds, selectedRfq?.rfqType]
+ )
+
+ // 검색어 변경 시 디바운스 적용
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ searchVendorsDebounced(searchTerm)
+ }, 300)
+
+ 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")
+ const isSelected = currentIds.includes(vendor.id)
+
+ if (isSelected) {
+ // 선택 해제
+ const newIds = currentIds.filter(id => id !== vendor.id)
+ const newSelectedData = selectedVendorData.filter(v => v.id !== vendor.id)
+ form.setValue("vendorIds", newIds, { shouldValidate: true })
+ setSelectedVendorData(newSelectedData)
+ } else {
+ // 선택 추가
+ const newIds = [...currentIds, vendor.id]
+ const newSelectedData = [...selectedVendorData, vendor]
+ form.setValue("vendorIds", newIds, { shouldValidate: true })
+ setSelectedVendorData(newSelectedData)
+ }
+ }
+
+ // 선택된 벤더 제거 핸들러
+ const handleRemoveVendor = (vendorId: number) => {
+ const currentIds = form.getValues("vendorIds")
+ const newIds = currentIds.filter(id => id !== vendorId)
+ const newSelectedData = selectedVendorData.filter(v => v.id !== vendorId)
+ form.setValue("vendorIds", newIds, { shouldValidate: true })
+ setSelectedVendorData(newSelectedData)
+ }
+
+ // 폼 제출 핸들러
+ async function onSubmit(values: VendorFormValues) {
+ if (!selectedRfq) {
+ toast.error("선택된 RFQ가 없습니다")
+ return
+ }
+
+ if (!session?.user?.id) {
+ toast.error("로그인이 필요합니다")
+ return
+ }
+
+ try {
+ setIsSubmitting(true)
+
+ // 새로운 서비스 함수 호출
+ const result = await addTechVendorsToTechSalesRfq({
+ rfqId: selectedRfq.id,
+ vendorIds: values.vendorIds,
+ createdBy: Number(session.user.id),
+ })
+
+ if (result.error) {
+ toast.error(result.error)
+ } else {
+ const successCount = result.data?.length || 0
+ toast.success(`${successCount}개의 벤더가 성공적으로 추가되었습니다`)
+
+ onOpenChange(false)
+ form.reset()
+ setSearchTerm("")
+ setSearchResults([])
+ setCandidateVendors([])
+ setHasSearched(false)
+ setHasCandidatesLoaded(false)
+ setSelectedVendorData([])
+ onSuccess?.()
+ }
+ } catch (error) {
+ console.error("벤더 추가 오류:", error)
+ toast.error("벤더 추가 중 오류가 발생했습니다")
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ // 다이얼로그 닫기 시 폼 리셋
+ React.useEffect(() => {
+ if (!open) {
+ 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-[800px] max-h-[80vh] flex flex-col">
+ {/* 헤더 */}
+ <DialogHeader>
+ <DialogTitle>벤더 추가</DialogTitle>
+ <DialogDescription>
+ {selectedRfq ? (
+ <>
+ <span className="font-medium">{selectedRfq.rfqCode}</span> RFQ에 벤더를 추가합니다.
+ </>
+ ) : (
+ "RFQ에 벤더를 추가합니다."
+ )}
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* 콘텐츠 */}
+ <div className="flex-1 overflow-y-auto">
+ <Form {...form}>
+ <form id="vendor-form" onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+ {/* 탭 메뉴 */}
+ <Tabs value={activeTab} onValueChange={setActiveTab}>
+ <TabsList className="grid w-full grid-cols-2">
+ <TabsTrigger value="candidates">
+ 후보 벤더 ({candidateVendors.length})
+ </TabsTrigger>
+ <TabsTrigger value="search">
+ 벤더 검색
+ </TabsTrigger>
+ </TabsList>
+
+ {/* 후보 벤더 탭 */}
+ <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>
+ </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
+ control={form.control}
+ name="vendorIds"
+ render={() => (
+ <FormItem>
+ <div className="space-y-2">
+ <FormLabel>선택된 벤더 ({selectedVendorData.length}개)</FormLabel>
+ <div className="min-h-[60px] p-3 border rounded-md bg-muted/50">
+ {selectedVendorData.length > 0 ? (
+ <div className="flex flex-wrap gap-2">
+ {selectedVendorData.map((vendor) => (
+ <Badge
+ key={vendor.id}
+ variant="secondary"
+ className="flex items-center gap-1"
+ >
+ {vendor.vendorName} ({vendor.vendorCode || 'N/A'})
+ <X
+ className="h-3 w-3 cursor-pointer hover:text-destructive"
+ onClick={() => handleRemoveVendor(vendor.id)}
+ />
+ </Badge>
+ ))}
+ </div>
+ ) : (
+ <div className="flex items-center justify-center h-full text-sm text-muted-foreground">
+ 선택된 벤더가 없습니다
+ </div>
+ )}
+ </div>
+ </div>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 안내 메시지
+ <div className="text-sm text-muted-foreground bg-muted p-3 rounded-md">
+ <p>• 후보 벤더는 RFQ 아이템 코드와 매칭되는 기술영업 벤더들입니다.</p>
+ <p>• 선택된 벤더들은 Draft 상태로 추가됩니다.</p>
+ <p>• 벤더별 견적 정보는 추가 후 개별적으로 입력할 수 있습니다.</p>
+ </div> */}
+ </form>
+ </Form>
+ </div>
+
+ {/* 푸터 */}
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ form="vendor-form"
+ disabled={isSubmitting || selectedVendorIds.length === 0}
+ >
+ {isSubmitting ? "처리 중..." : `${selectedVendorIds.length}개 벤더 추가`}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
}
\ No newline at end of file diff --git a/lib/techsales-rfq/table/detail-table/delete-vendors-dialog.tsx b/lib/techsales-rfq/table/detail-table/delete-vendors-dialog.tsx index d7e3403b..d86dcea2 100644 --- a/lib/techsales-rfq/table/detail-table/delete-vendors-dialog.tsx +++ b/lib/techsales-rfq/table/detail-table/delete-vendors-dialog.tsx @@ -1,150 +1,149 @@ -"use client" - -import * as React from "react" -import { type RfqDetailView } from "./rfq-detail-column" -import { type Row } from "@tanstack/react-table" -import { Loader, Trash } from "lucide-react" -import { toast } from "sonner" - -import { useMediaQuery } from "@/hooks/use-media-query" -import { Button } from "@/components/ui/button" -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog" -import { - Drawer, - DrawerClose, - DrawerContent, - DrawerDescription, - DrawerFooter, - DrawerHeader, - DrawerTitle, - DrawerTrigger, -} from "@/components/ui/drawer" -import { deleteRfqDetail } from "@/lib/procurement-rfqs/services" - - -interface DeleteRfqDetailDialogProps - extends React.ComponentPropsWithoutRef<typeof Dialog> { - detail: RfqDetailView | null - showTrigger?: boolean - onSuccess?: () => void -} - -export function DeleteVendorDialog({ - detail, - showTrigger = true, - onSuccess, - ...props -}: DeleteRfqDetailDialogProps) { - const [isDeletePending, startDeleteTransition] = React.useTransition() - const isDesktop = useMediaQuery("(min-width: 640px)") - - function onDelete() { - if (!detail) return - - startDeleteTransition(async () => { - try { - const result = await deleteRfqDetail(detail.id) - - if (!result.success) { - toast.error(result.message || "삭제 중 오류가 발생했습니다") - return - } - - props.onOpenChange?.(false) - toast.success("RFQ 벤더 정보가 삭제되었습니다") - onSuccess?.() - } catch (error) { - console.error("RFQ 벤더 삭제 오류:", error) - toast.error("삭제 중 오류가 발생했습니다") - } - }) - } - - if (isDesktop) { - return ( - <Dialog {...props}> - {showTrigger ? ( - <DialogTrigger asChild> - <Button variant="destructive" size="sm"> - <Trash className="mr-2 size-4" aria-hidden="true" /> - 삭제 - </Button> - </DialogTrigger> - ) : null} - <DialogContent> - <DialogHeader> - <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle> - <DialogDescription> - 이 작업은 되돌릴 수 없습니다. 벤더 "{detail?.vendorName}"({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다. - </DialogDescription> - </DialogHeader> - <DialogFooter className="gap-2 sm:space-x-0"> - <DialogClose asChild> - <Button variant="outline">취소</Button> - </DialogClose> - <Button - aria-label="선택한 RFQ 벤더 정보 삭제" - variant="destructive" - onClick={onDelete} - disabled={isDeletePending} - > - {isDeletePending && ( - <Loader - className="mr-2 size-4 animate-spin" - aria-hidden="true" - /> - )} - 삭제 - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) - } - - return ( - <Drawer {...props}> - {showTrigger ? ( - <DrawerTrigger asChild> - <Button variant="destructive" size="sm"> - <Trash className="mr-2 size-4" aria-hidden="true" /> - 삭제 - </Button> - </DrawerTrigger> - ) : null} - <DrawerContent> - <DrawerHeader> - <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle> - <DrawerDescription> - 이 작업은 되돌릴 수 없습니다. 벤더 "{detail?.vendorName}"({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다. - </DrawerDescription> - </DrawerHeader> - <DrawerFooter className="gap-2 sm:space-x-0"> - <DrawerClose asChild> - <Button variant="outline">취소</Button> - </DrawerClose> - <Button - aria-label="선택한 RFQ 벤더 정보 삭제" - variant="destructive" - onClick={onDelete} - disabled={isDeletePending} - > - {isDeletePending && ( - <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> - )} - 삭제 - </Button> - </DrawerFooter> - </DrawerContent> - </Drawer> - ) +"use client"
+
+import * as React from "react"
+import { type RfqDetailView } from "./rfq-detail-column"
+import { Loader, Trash } from "lucide-react"
+import { toast } from "sonner"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+import { deleteRfqDetail } from "@/lib/procurement-rfqs/services"
+
+
+interface DeleteRfqDetailDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ detail: RfqDetailView | null
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeleteVendorDialog({
+ detail,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeleteRfqDetailDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onDelete() {
+ if (!detail) return
+
+ startDeleteTransition(async () => {
+ try {
+ const result = await deleteRfqDetail(detail.id)
+
+ if (!result.success) {
+ toast.error(result.message || "삭제 중 오류가 발생했습니다")
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("RFQ 벤더 정보가 삭제되었습니다")
+ onSuccess?.()
+ } catch (error) {
+ console.error("RFQ 벤더 삭제 오류:", error)
+ toast.error("삭제 중 오류가 발생했습니다")
+ }
+ })
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="destructive" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle>
+ <DialogDescription>
+ 이 작업은 되돌릴 수 없습니다. 벤더 "{detail?.vendorName}"({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">취소</Button>
+ </DialogClose>
+ <Button
+ aria-label="선택한 RFQ 벤더 정보 삭제"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 삭제
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="destructive" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle>
+ <DrawerDescription>
+ 이 작업은 되돌릴 수 없습니다. 벤더 "{detail?.vendorName}"({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">취소</Button>
+ </DrawerClose>
+ <Button
+ aria-label="선택한 RFQ 벤더 정보 삭제"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ 삭제
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
}
\ No newline at end of file diff --git a/lib/techsales-rfq/table/detail-table/quotation-contacts-view-dialog.tsx b/lib/techsales-rfq/table/detail-table/quotation-contacts-view-dialog.tsx new file mode 100644 index 00000000..3e793b62 --- /dev/null +++ b/lib/techsales-rfq/table/detail-table/quotation-contacts-view-dialog.tsx @@ -0,0 +1,173 @@ +"use client"
+
+import * as React from "react"
+import { useState, useEffect, useCallback } from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { Skeleton } from "@/components/ui/skeleton"
+import { Mail, Phone, User, Users } from "lucide-react"
+import { getQuotationContacts } from "../../service"
+
+interface QuotationContact {
+ id: number
+ contactId: number
+ contactName: string
+ contactPosition: string | null
+ contactEmail: string
+ contactPhone: string | null
+ contactCountry: string | null
+ isPrimary: boolean
+ createdAt: Date
+}
+
+interface QuotationContactsViewDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ quotationId: number | null
+ vendorName?: string
+}
+
+export function QuotationContactsViewDialog({
+ open,
+ onOpenChange,
+ quotationId,
+ vendorName
+}: QuotationContactsViewDialogProps) {
+ const [contacts, setContacts] = useState<QuotationContact[]>([])
+ const [isLoading, setIsLoading] = useState(false)
+
+ // 담당자 정보 로드
+ const loadQuotationContacts = useCallback(async () => {
+ if (!quotationId) return
+
+ setIsLoading(true)
+ try {
+ const result = await getQuotationContacts(quotationId)
+ if (result.success) {
+ setContacts(result.data || [])
+ } else {
+ console.error("담당자 정보 로드 실패:", result.error)
+ setContacts([])
+ }
+ } catch (error) {
+ console.error("담당자 정보 로드 오류:", error)
+ setContacts([])
+ } finally {
+ setIsLoading(false)
+ }
+ }, [quotationId])
+
+ // Dialog가 열릴 때 데이터 로드
+ useEffect(() => {
+ if (open && quotationId) {
+ loadQuotationContacts()
+ }
+ }, [open, quotationId, loadQuotationContacts])
+
+ // Dialog가 닫힐 때 상태 초기화
+ useEffect(() => {
+ if (!open) {
+ setContacts([])
+ }
+ }, [open])
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-2xl max-h-[70vh] overflow-hidden flex flex-col">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <Users className="size-5" />
+ RFQ 발송 담당자 목록
+ </DialogTitle>
+ <DialogDescription>
+ {vendorName && (
+ <span className="font-medium">{vendorName}</span>
+ )} 에게 발송된 RFQ의 담당자 정보입니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="flex-1 overflow-y-auto">
+ {isLoading ? (
+ <div className="space-y-3">
+ {[1, 2, 3].map((i) => (
+ <Skeleton key={i} className="h-20 w-full" />
+ ))}
+ </div>
+ ) : contacts.length === 0 ? (
+ <div className="text-center py-8 text-muted-foreground">
+ <Mail className="size-12 mx-auto mb-2 opacity-50" />
+ <p>발송된 담당자 정보가 없습니다.</p>
+ <p className="text-sm">아직 RFQ가 발송되지 않았거나 담당자 정보가 기록되지 않았습니다.</p>
+ </div>
+ ) : (
+ <div className="space-y-3">
+ {contacts.map((contact) => (
+ <div
+ key={contact.id}
+ className="flex items-center justify-between p-4 border rounded-lg bg-gray-50"
+ >
+ <div className="flex items-center gap-3">
+ <User className="size-4 text-muted-foreground" />
+ <div>
+ <div className="flex items-center gap-2">
+ <span className="font-medium">{contact.contactName}</span>
+ {contact.isPrimary && (
+ <Badge variant="secondary" className="text-xs">
+ 주담당자
+ </Badge>
+ )}
+ </div>
+ {contact.contactPosition && (
+ <p className="text-sm text-muted-foreground">
+ {contact.contactPosition}
+ </p>
+ )}
+ {contact.contactCountry && (
+ <p className="text-xs text-muted-foreground">
+ {contact.contactCountry}
+ </p>
+ )}
+ </div>
+ </div>
+
+ <div className="flex flex-col items-end gap-1 text-sm">
+ <div className="flex items-center gap-1">
+ <Mail className="size-4 text-muted-foreground" />
+ <span>{contact.contactEmail}</span>
+ </div>
+ {contact.contactPhone && (
+ <div className="flex items-center gap-1">
+ <Phone className="size-4 text-muted-foreground" />
+ <span>{contact.contactPhone}</span>
+ </div>
+ )}
+ <div className="text-xs text-muted-foreground">
+ 발송일: {new Date(contact.createdAt).toLocaleDateString('ko-KR')}
+ </div>
+ </div>
+ </div>
+ ))}
+
+ <div className="text-center pt-4 text-sm text-muted-foreground border-t">
+ 총 {contacts.length}명의 담당자에게 발송됨
+ </div>
+ </div>
+ )}
+ </div>
+
+ <div className="flex justify-end pt-4">
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ 닫기
+ </Button>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+}
\ No newline at end of file diff --git a/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx b/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx index ce701e13..0f5158d9 100644 --- a/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx +++ b/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx @@ -13,7 +13,7 @@ import { Badge } from "@/components/ui/badge" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Separator } from "@/components/ui/separator"
import { Skeleton } from "@/components/ui/skeleton"
-import { Clock, User, FileText, AlertCircle, Paperclip } from "lucide-react"
+import { Clock, User, AlertCircle, Paperclip } from "lucide-react"
import { formatDate } from "@/lib/utils"
import { toast } from "sonner"
@@ -91,7 +91,6 @@ function QuotationCard({ data,
version,
isCurrent = false,
- changeReason,
revisedBy,
revisedAt,
attachments
@@ -99,7 +98,6 @@ function QuotationCard({ data: QuotationSnapshot | QuotationHistoryData["current"]
version: number
isCurrent?: boolean
- changeReason?: string | null
revisedBy?: string | null
revisedAt?: Date
attachments?: QuotationAttachment[]
@@ -137,7 +135,7 @@ function QuotationCard({ <div>
<p className="text-sm font-medium text-muted-foreground">유효 기한</p>
<p className="text-sm">
- {data.validUntil ? formatDate(data.validUntil, "KR") : "미설정"}
+ {data.validUntil ? formatDate(data.validUntil) : "미설정"}
</p>
</div>
</div>
@@ -187,8 +185,8 @@ function QuotationCard({ <Clock className="size-3" />
<span>
{isCurrent
- ? `수정: ${data.updatedAt ? formatDate(data.updatedAt, "KR") : "N/A"}`
- : `변경: ${revisedAt ? formatDate(revisedAt, "KR") : "N/A"}`
+ ? `수정: ${data.updatedAt ? formatDate(data.updatedAt) : "N/A"}`
+ : `변경: ${revisedAt ? formatDate(revisedAt) : "N/A"}`
}
</span>
</div>
diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx index e921fcaa..e4141520 100644 --- a/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx +++ b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx @@ -1,401 +1,451 @@ -"use client" - -import * as React from "react" -import type { ColumnDef, Row } from "@tanstack/react-table"; -import { formatDate } from "@/lib/utils" -import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" -import { Checkbox } from "@/components/ui/checkbox"; -import { MessageCircle, MoreHorizontal, Trash2, Paperclip } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; - -export interface DataTableRowAction<TData> { - row: Row<TData>; - type: "communicate" | "delete"; -} - -// 벤더 견적 데이터 타입 정의 -export interface RfqDetailView { - id: number - rfqId: number - vendorId?: number | null - vendorName: string | null - vendorCode: string | null - totalPrice: string | number | null - currency: string | null - validUntil: Date | null - status: string | null - remark: string | null - submittedAt: Date | null - acceptedAt: Date | null - rejectionReason: string | null - createdAt: Date | null - updatedAt: Date | null - createdByName: string | null - quotationCode?: string | null - rfqCode?: string | null - quotationAttachments?: Array<{ - id: number - revisionId: number - fileName: string - fileSize: number - filePath: string - description?: string | null - }> -} - -// 견적서 정보 타입 (Sheet용) -export interface QuotationInfo { - id: number - quotationCode: string | null - vendorName?: string - rfqCode?: string -} - -interface GetColumnsProps<TData> { - setRowAction: React.Dispatch< - React.SetStateAction<DataTableRowAction<TData> | null> - >; - unreadMessages?: Record<number, number>; // 읽지 않은 메시지 개수 - onQuotationClick?: (quotationId: number) => void; // 견적 클릭 핸들러 - openQuotationAttachmentsSheet?: (quotationId: number, quotationInfo: QuotationInfo) => void; // 견적서 첨부파일 sheet 열기 -} - -export function getRfqDetailColumns({ - setRowAction, - unreadMessages = {}, - onQuotationClick, - openQuotationAttachmentsSheet -}: GetColumnsProps<RfqDetailView>): ColumnDef<RfqDetailView>[] { - return [ - { - id: "select", - header: ({ table }) => ( - <Checkbox - checked={ - table.getIsAllPageRowsSelected() || - (table.getIsSomePageRowsSelected() && "indeterminate") - } - onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} - aria-label="모두 선택" - /> - ), - cell: ({ row }) => { - const status = row.original.status; - const isSelectable = status ? !["Accepted", "Rejected"].includes(status) : true; - - return ( - <Checkbox - checked={row.getIsSelected()} - onCheckedChange={(value) => row.toggleSelected(!!value)} - disabled={!isSelectable} - aria-label="행 선택" - className={!isSelectable ? "opacity-50 cursor-not-allowed" : ""} - /> - ); - }, - enableSorting: false, - enableHiding: false, - size: 40, - }, - { - accessorKey: "status", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="견적 상태" /> - ), - cell: ({ row }) => { - const status = row.getValue("status") as string; - // 상태에 따른 배지 색상 설정 - let variant: "default" | "secondary" | "outline" | "destructive" = "outline"; - - if (status === "Submitted") { - variant = "default"; // 제출됨 - 기본 색상 - } else if (status === "Accepted") { - variant = "secondary"; // 승인됨 - 보조 색상 - } else if (status === "Rejected") { - variant = "destructive"; // 거부됨 - 위험 색상 - } - - return ( - <Badge variant={variant}>{status || "Draft"}</Badge> - ); - }, - meta: { - excelHeader: "견적 상태" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "vendorCode", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="벤더 코드" /> - ), - cell: ({ row }) => <div>{row.getValue("vendorCode")}</div>, - meta: { - excelHeader: "벤더 코드" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "vendorName", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="벤더명" /> - ), - cell: ({ row }) => { - const vendorName = row.getValue("vendorName") as string | null; - const vendorId = row.original.vendorId; - - if (!vendorName) return <div>-</div>; - - if (vendorId) { - return ( - <Button - variant="link" - className="p-0 h-auto font-normal text-left justify-start hover:underline" - onClick={() => { - window.open(`/ko/evcp/tech-vendors/${vendorId}/info`, '_blank'); - }} - > - {vendorName} - </Button> - ); - } - - return <div>{vendorName}</div>; - }, - meta: { - excelHeader: "벤더명" - }, - enableResizing: true, - size: 160, - }, - { - accessorKey: "totalPrice", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="견적 금액" /> - ), - cell: ({ row }) => { - const value = row.getValue("totalPrice") as string | number | null; - const currency = row.getValue("currency") as string | null; - const quotationId = row.original.id; - - if (value === null || value === undefined) return "-"; - - // 숫자로 변환 시도 - const numValue = typeof value === 'string' ? parseFloat(value) : value; - const displayValue = isNaN(numValue) ? value : numValue.toLocaleString(); - - // 견적값이 있고 클릭 핸들러가 있는 경우 클릭 가능한 버튼으로 표시 - if (onQuotationClick && quotationId) { - return ( - <Button - variant="link" - className="p-0 h-auto font-medium text-left justify-start hover:underline" - onClick={() => onQuotationClick(quotationId)} - title="견적 히스토리 보기" - > - {displayValue} {currency} - </Button> - ); - } - - return ( - <div className="font-medium"> - {displayValue} {currency} - </div> - ); - }, - meta: { - excelHeader: "견적 금액" - }, - enableResizing: true, - size: 140, - }, - { - accessorKey: "quotationAttachments", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="첨부파일" /> - ), - cell: ({ row }) => { - const attachments = row.original.quotationAttachments || []; - const attachmentCount = attachments.length; - - if (attachmentCount === 0) { - return <div className="text-muted-foreground">-</div>; - } - - return ( - <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={() => { - // 견적서 첨부파일 sheet 열기 - if (openQuotationAttachmentsSheet) { - const quotation = row.original; - openQuotationAttachmentsSheet(quotation.id, { - id: quotation.id, - quotationCode: quotation.quotationCode || null, - vendorName: quotation.vendorName || undefined, - rfqCode: quotation.rfqCode || undefined, - }); - } - }} - title={ - attachmentCount === 1 - ? `${attachments[0].fileName} (${(attachments[0].fileSize / 1024 / 1024).toFixed(2)} MB)` - : `${attachmentCount}개의 첨부파일:\n${attachments.map(att => att.fileName).join('\n')}` - } - > - <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> - )} - </Button> - ); - }, - meta: { - excelHeader: "첨부파일" - }, - enableResizing: false, - size: 80, - }, - { - accessorKey: "currency", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="통화" /> - ), - cell: ({ row }) => <div>{row.getValue("currency")}</div>, - meta: { - excelHeader: "통화" - }, - enableResizing: true, - size: 80, - }, - { - accessorKey: "validUntil", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="유효기간" /> - ), - cell: ({ cell }) => { - const value = cell.getValue() as Date | null; - return value ? formatDate(value, "KR") : "-"; - }, - meta: { - excelHeader: "유효기간" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "submittedAt", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="제출일" /> - ), - cell: ({ cell }) => { - const value = cell.getValue() as Date | null; - return value ? formatDate(value, "KR") : "-"; - }, - meta: { - excelHeader: "제출일" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "createdByName", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="등록자" /> - ), - cell: ({ row }) => <div>{row.getValue("createdByName")}</div>, - meta: { - excelHeader: "등록자" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "remark", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="비고" /> - ), - cell: ({ row }) => <div>{row.getValue("remark") || "-"}</div>, - meta: { - excelHeader: "비고" - }, - enableResizing: true, - size: 200, - }, - { - id: "actions", - header: () => <div className="text-right">동작</div>, - cell: function Cell({ row }) { - const vendorId = row.original.vendorId; - const unreadCount = vendorId ? unreadMessages[vendorId] || 0 : 0; - const status = row.original.status; - const isDraft = status === "Draft"; - - return ( - <div className="text-right flex items-center justify-end gap-1"> - {/* 커뮤니케이션 버튼 */} - <div className="relative"> - <Button - variant="ghost" - size="sm" - className="h-8 w-8 p-0" - onClick={() => setRowAction({ row, type: "communicate" })} - title="벤더와 커뮤니케이션" - > - <MessageCircle className="h-4 w-4" /> - </Button> - {unreadCount > 0 && ( - <Badge - variant="destructive" - className="absolute -top-1 -right-1 h-4 w-4 p-0 text-xs flex items-center justify-center" - > - {unreadCount > 9 ? '9+' : unreadCount} - </Badge> - )} - </div> - - {/* 컨텍스트 메뉴 */} - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - variant="ghost" - size="sm" - className="h-8 w-8 p-0" - title="더 많은 작업" - > - <MoreHorizontal className="h-4 w-4" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - <DropdownMenuItem - onClick={() => setRowAction({ row, type: "delete" })} - disabled={!isDraft} - className={!isDraft ? "opacity-50 cursor-not-allowed" : "text-destructive focus:text-destructive"} - > - <Trash2 className="mr-2 h-4 w-4" /> - 벤더 삭제 - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - </div> - ); - }, - enableResizing: false, - size: 120, - }, - ]; +"use client"
+
+import * as React from "react"
+import type { ColumnDef, Row } from "@tanstack/react-table";
+import { formatDate } from "@/lib/utils"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { Checkbox } from "@/components/ui/checkbox";
+import { MessageCircle, MoreHorizontal, Trash2, Paperclip, Users } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+
+export interface DataTableRowAction<TData> {
+ row: Row<TData>;
+ type: "communicate" | "delete";
+}
+
+// 벤더 견적 데이터 타입 정의
+export interface RfqDetailView {
+ id: number
+ rfqId: number
+ vendorId?: number | null
+ vendorName: string | null
+ vendorCode: string | null
+ totalPrice: string | number | null
+ currency: string | null
+ validUntil: Date | null
+ status: string | null
+ remark: string | null
+ submittedAt: Date | null
+ acceptedAt: Date | null
+ rejectionReason: string | null
+ createdAt: Date | null
+ updatedAt: Date | null
+ createdByName: string | null
+ quotationCode?: string | null
+ rfqCode?: string | null
+ quotationAttachments?: Array<{
+ id: number
+ revisionId: number
+ fileName: string
+ fileSize: number
+ filePath: string
+ description?: string | null
+ }>
+}
+
+// 견적서 정보 타입 (Sheet용)
+export interface QuotationInfo {
+ id: number
+ quotationCode: string | null
+ vendorName?: string
+ rfqCode?: string
+}
+
+interface GetColumnsProps<TData> {
+ setRowAction: React.Dispatch<
+ React.SetStateAction<DataTableRowAction<TData> | null>
+ >;
+ unreadMessages?: Record<number, number>; // 읽지 않은 메시지 개수
+ onQuotationClick?: (quotationId: number) => void; // 견적 클릭 핸들러
+ openQuotationAttachmentsSheet?: (quotationId: number, quotationInfo: QuotationInfo) => void; // 견적서 첨부파일 sheet 열기
+ openContactsDialog?: (quotationId: number, vendorName?: string) => void; // 담당자 조회 다이얼로그 열기
+}
+
+export function getRfqDetailColumns({
+ setRowAction,
+ unreadMessages = {},
+ onQuotationClick,
+ openQuotationAttachmentsSheet,
+ openContactsDialog
+}: GetColumnsProps<RfqDetailView>): ColumnDef<RfqDetailView>[] {
+ return [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="모두 선택"
+ />
+ ),
+ cell: ({ row }) => {
+ const status = row.original.status;
+ const isSelectable = status ? !["Accepted", "Rejected"].includes(status) : true;
+
+ return (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ disabled={!isSelectable}
+ aria-label="행 선택"
+ className={!isSelectable ? "opacity-50 cursor-not-allowed" : ""}
+ />
+ );
+ },
+ enableSorting: false,
+ enableHiding: false,
+ size: 40,
+ },
+ {
+ accessorKey: "status",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="견적 상태" />
+ ),
+ cell: ({ row }) => {
+ const status = row.getValue("status") as string;
+ // 상태에 따른 배지 색상 설정
+ let variant: "default" | "secondary" | "outline" | "destructive" = "outline";
+
+ if (status === "Submitted") {
+ variant = "default"; // 제출됨 - 기본 색상
+ } else if (status === "Accepted") {
+ variant = "secondary"; // 승인됨 - 보조 색상
+ } else if (status === "Rejected") {
+ variant = "destructive"; // 거부됨 - 위험 색상
+ }
+
+ return (
+ <Badge variant={variant}>{status || "Draft"}</Badge>
+ );
+ },
+ meta: {
+ excelHeader: "견적 상태"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "vendorCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="벤더 코드" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("vendorCode")}</div>,
+ meta: {
+ excelHeader: "벤더 코드"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "vendorName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="벤더명" />
+ ),
+ cell: ({ row }) => {
+ const vendorName = row.getValue("vendorName") as string | null;
+ const vendorId = row.original.vendorId;
+
+ if (!vendorName) return <div>-</div>;
+
+ if (vendorId) {
+ return (
+ <Button
+ variant="link"
+ className="p-0 h-auto font-normal text-left justify-start hover:underline"
+ onClick={() => {
+ window.open(`/ko/evcp/tech-vendors/${vendorId}/info`, '_blank');
+ }}
+ >
+ {vendorName}
+ </Button>
+ );
+ }
+
+ return <div>{vendorName}</div>;
+ },
+ meta: {
+ excelHeader: "벤더명"
+ },
+ enableResizing: true,
+ size: 160,
+ },
+ {
+ accessorKey: "totalPrice",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="견적 금액" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("totalPrice") as string | number | null;
+ const currency = row.getValue("currency") as string | null;
+ const quotationId = row.original.id;
+
+ if (value === null || value === undefined) return "-";
+
+ // 숫자로 변환 시도
+ const numValue = typeof value === 'string' ? parseFloat(value) : value;
+ const displayValue = isNaN(numValue) ? value : numValue.toLocaleString();
+
+ // 견적값이 있고 클릭 핸들러가 있는 경우 클릭 가능한 버튼으로 표시
+ if (onQuotationClick && quotationId) {
+ return (
+ <Button
+ variant="link"
+ className="p-0 h-auto font-medium text-left justify-start hover:underline"
+ onClick={() => onQuotationClick(quotationId)}
+ title="견적 히스토리 보기"
+ >
+ {displayValue} {currency}
+ </Button>
+ );
+ }
+
+ return (
+ <div className="font-medium">
+ {displayValue} {currency}
+ </div>
+ );
+ },
+ meta: {
+ excelHeader: "견적 금액"
+ },
+ enableResizing: true,
+ size: 140,
+ },
+ {
+ accessorKey: "quotationAttachments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="첨부파일" />
+ ),
+ cell: ({ row }) => {
+ const attachments = row.original.quotationAttachments || [];
+ const attachmentCount = attachments.length;
+
+ if (attachmentCount === 0) {
+ return <div className="text-muted-foreground">-</div>;
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={() => {
+ // 견적서 첨부파일 sheet 열기
+ if (openQuotationAttachmentsSheet) {
+ const quotation = row.original;
+ openQuotationAttachmentsSheet(quotation.id, {
+ id: quotation.id,
+ quotationCode: quotation.quotationCode || null,
+ vendorName: quotation.vendorName || undefined,
+ rfqCode: quotation.rfqCode || undefined,
+ });
+ }
+ }}
+ title={
+ attachmentCount === 1
+ ? `${attachments[0].fileName} (${(attachments[0].fileSize / 1024 / 1024).toFixed(2)} MB)`
+ : `${attachmentCount}개의 첨부파일:\n${attachments.map(att => att.fileName).join('\n')}`
+ }
+ >
+ <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>
+ )}
+ </Button>
+ );
+ },
+ meta: {
+ excelHeader: "첨부파일"
+ },
+ enableResizing: false,
+ size: 80,
+ },
+ {
+ id: "contacts",
+ header: "담당자",
+ cell: ({ row }) => {
+ const quotation = row.original;
+
+ const handleClick = () => {
+ if (openContactsDialog) {
+ openContactsDialog(quotation.id, quotation.vendorName || undefined);
+ }
+ };
+
+ return (
+ <div className="w-20">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label="담당자 정보 보기"
+ >
+ <Users className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ <span className="sr-only">담당자 정보 보기</span>
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>RFQ 발송 담당자 보기</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ );
+ },
+ meta: {
+ excelHeader: "담당자"
+ },
+ enableResizing: false,
+ size: 80,
+ },
+ {
+ accessorKey: "currency",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="통화" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("currency")}</div>,
+ meta: {
+ excelHeader: "통화"
+ },
+ enableResizing: true,
+ size: 80,
+ },
+ {
+ accessorKey: "validUntil",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="유효기간" />
+ ),
+ cell: ({ cell }) => {
+ const value = cell.getValue() as Date | null;
+ return value ? formatDate(value, "KR") : "-";
+ },
+ meta: {
+ excelHeader: "유효기간"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "submittedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="제출일" />
+ ),
+ cell: ({ cell }) => {
+ const value = cell.getValue() as Date | null;
+ return value ? formatDate(value, "KR") : "-";
+ },
+ meta: {
+ excelHeader: "제출일"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "createdByName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="등록자" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("createdByName")}</div>,
+ meta: {
+ excelHeader: "등록자"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "remark",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="비고" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("remark") || "-"}</div>,
+ meta: {
+ excelHeader: "비고"
+ },
+ enableResizing: true,
+ size: 200,
+ },
+ {
+ id: "actions",
+ header: () => <div className="text-right">동작</div>,
+ cell: function Cell({ row }) {
+ const vendorId = row.original.vendorId;
+ const unreadCount = vendorId ? unreadMessages[vendorId] || 0 : 0;
+ const status = row.original.status;
+ const isDraft = status === "Draft";
+
+ return (
+ <div className="text-right flex items-center justify-end gap-1">
+ {/* 커뮤니케이션 버튼 */}
+ <div className="relative">
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-8 w-8 p-0"
+ onClick={() => setRowAction({ row, type: "communicate" })}
+ title="벤더와 커뮤니케이션"
+ >
+ <MessageCircle className="h-4 w-4" />
+ </Button>
+ {unreadCount > 0 && (
+ <Badge
+ variant="destructive"
+ className="absolute -top-1 -right-1 h-4 w-4 p-0 text-xs flex items-center justify-center"
+ >
+ {unreadCount > 9 ? '9+' : unreadCount}
+ </Badge>
+ )}
+ </div>
+
+ {/* 컨텍스트 메뉴 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-8 w-8 p-0"
+ title="더 많은 작업"
+ >
+ <MoreHorizontal className="h-4 w-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem
+ onClick={() => setRowAction({ row, type: "delete" })}
+ disabled={!isDraft}
+ className={!isDraft ? "opacity-50 cursor-not-allowed" : "text-destructive focus:text-destructive"}
+ >
+ <Trash2 className="mr-2 h-4 w-4" />
+ 벤더 삭제
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+ );
+ },
+ enableResizing: false,
+ size: 120,
+ },
+ ];
}
\ No newline at end of file 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 1d701bd5..41572a93 100644 --- a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx +++ b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx @@ -1,710 +1,775 @@ -"use client" - -import * as React from "react" -import { useEffect, useState, useCallback, useMemo } from "react" -import { - DataTableRowAction, - getRfqDetailColumns, - RfqDetailView -} from "./rfq-detail-column" -import { toast } from "sonner" - -import { Skeleton } from "@/components/ui/skeleton" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { Loader2, UserPlus, Send, Trash2, CheckCircle } from "lucide-react" -import { ClientDataTable } from "@/components/client-data-table/data-table" -import { AddVendorDialog } from "./add-vendor-dialog" -import { VendorCommunicationDrawer } from "./vendor-communication-drawer" -import { DeleteVendorsDialog } from "../delete-vendors-dialog" -import { QuotationHistoryDialog } from "@/lib/techsales-rfq/table/detail-table/quotation-history-dialog" -import { TechSalesQuotationAttachmentsSheet, type QuotationAttachment } from "../tech-sales-quotation-attachments-sheet" -import type { QuotationInfo } from "./rfq-detail-column" - -// 기본적인 RFQ 타입 정의 -interface TechSalesRfq { - id: number - rfqCode: string | null - status: string - materialCode?: string | null - itemName?: string | null - remark?: string | null - rfqSendDate?: Date | null - dueDate?: Date | null - createdByName?: string | null - rfqType: "SHIP" | "TOP" | "HULL" | null - ptypeNm?: string | null -} - -// 프로퍼티 정의 -interface RfqDetailTablesProps { - selectedRfq: TechSalesRfq | null - maxHeight?: string | number -} - - -export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps) { - // console.log("selectedRfq", selectedRfq) - - // 상태 관리 - const [isLoading, setIsLoading] = useState(false) - const [details, setDetails] = useState<RfqDetailView[]>([]) - const [vendorDialogOpen, setVendorDialogOpen] = React.useState(false) - - const [isAdddialogLoading, setIsAdddialogLoading] = useState(false) - - const [rowAction, setRowAction] = React.useState<DataTableRowAction<RfqDetailView> | null>(null) - - // 벤더 커뮤니케이션 상태 관리 - const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false) - const [selectedVendor, setSelectedVendor] = useState<RfqDetailView | null>(null) - - // 읽지 않은 메시지 개수 - const [unreadMessages, setUnreadMessages] = useState<Record<number, number>>({}) - - // 테이블 선택 상태 관리 - const [selectedRows, setSelectedRows] = useState<RfqDetailView[]>([]) - const [isSendingRfq, setIsSendingRfq] = useState(false) - const [isDeletingVendors, setIsDeletingVendors] = useState(false) - - // 벤더 삭제 확인 다이얼로그 상태 추가 - const [deleteConfirmDialogOpen, setDeleteConfirmDialogOpen] = useState(false) - - // 견적 히스토리 다이얼로그 상태 관리 - const [historyDialogOpen, setHistoryDialogOpen] = useState(false) - const [selectedQuotationId, setSelectedQuotationId] = useState<number | null>(null) - - // 견적서 첨부파일 sheet 상태 관리 - const [quotationAttachmentsSheetOpen, setQuotationAttachmentsSheetOpen] = useState(false) - const [selectedQuotationInfo, setSelectedQuotationInfo] = useState<QuotationInfo | null>(null) - const [quotationAttachments, setQuotationAttachments] = useState<QuotationAttachment[]>([]) - const [isLoadingAttachments, setIsLoadingAttachments] = useState(false) - - // selectedRfq ID 메모이제이션 (객체 참조 변경 방지) - const selectedRfqId = useMemo(() => selectedRfq?.id, [selectedRfq?.id]) - - // existingVendorIds 메모이제이션 - const existingVendorIds = useMemo(() => { - return details.map(detail => Number(detail.vendorId)).filter(Boolean); - }, [details]); - - // 읽지 않은 메시지 로드 함수 메모이제이션 - const loadUnreadMessages = useCallback(async () => { - if (!selectedRfqId) return; - - try { - // 기술영업용 읽지 않은 메시지 수 가져오기 함수 구현 - const { getTechSalesUnreadMessageCounts } = await import("@/lib/techsales-rfq/service"); - const unreadData = await getTechSalesUnreadMessageCounts(selectedRfqId); - setUnreadMessages(unreadData); - } catch (error) { - console.error("읽지 않은 메시지 로드 오류:", error); - setUnreadMessages({}); - } - }, [selectedRfqId]); - - // 데이터 새로고침 함수 메모이제이션 - const handleRefreshData = useCallback(async () => { - if (!selectedRfqId) return - - try { - // 실제 벤더 견적 데이터 다시 로딩 - const { getTechSalesRfqTechVendors } = await import("@/lib/techsales-rfq/service") - - const result = await getTechSalesRfqTechVendors(selectedRfqId) - - // 데이터 변환 - const transformedData = result.data?.map((item: any) => ({ - ...item, - detailId: item.id, - rfqId: selectedRfqId, - rfqCode: selectedRfq?.rfqCode || null, - rfqType: selectedRfq?.rfqType || null, - ptypeNm: selectedRfq?.ptypeNm || null, - vendorId: item.vendorId ? Number(item.vendorId) : undefined, - })) || [] - - setDetails(transformedData) - - // 읽지 않은 메시지 개수 업데이트 - await loadUnreadMessages(); - - toast.success("데이터를 성공적으로 새로고침했습니다") - } catch (error) { - console.error("데이터 새로고침 오류:", error) - toast.error("데이터를 새로고침하는 중 오류가 발생했습니다") - } - }, [selectedRfqId, selectedRfq?.rfqCode, selectedRfq?.rfqType, selectedRfq?.ptypeNm, loadUnreadMessages]) - - // 벤더 추가 핸들러 메모이제이션 - const handleAddVendor = useCallback(async () => { - try { - setIsAdddialogLoading(true) - setVendorDialogOpen(true) - } catch (error) { - console.error("데이터 로드 오류:", error) - toast.error("벤더 정보를 불러오는 중 오류가 발생했습니다") - } finally { - setIsAdddialogLoading(false) - } - }, []) - - // RFQ 발송 핸들러 메모이제이션 - const handleSendRfq = useCallback(async () => { - if (selectedRows.length === 0) { - toast.warning("발송할 벤더를 선택해주세요."); - return; - } - - if (!selectedRfqId) { - toast.error("선택된 RFQ가 없습니다."); - return; - } - - try { - setIsSendingRfq(true); - - // 기술영업 RFQ 발송 서비스 함수 호출 - const vendorIds = selectedRows.map(row => row.vendorId).filter(Boolean); - const { sendTechSalesRfqToVendors } = await import("@/lib/techsales-rfq/service"); - - const result = await sendTechSalesRfqToVendors({ - rfqId: selectedRfqId, - vendorIds: vendorIds as number[] - }); - - if (result.success) { - toast.success(result.message || `${selectedRows.length}개 벤더에게 RFQ가 발송되었습니다.`); - } else { - toast.error(result.message || "RFQ 발송 중 오류가 발생했습니다."); - } - - // 선택 해제 - setSelectedRows([]); - - // 데이터 새로고침 - await handleRefreshData(); - - } catch (error) { - console.error("RFQ 발송 오류:", error); - toast.error("RFQ 발송 중 오류가 발생했습니다."); - } finally { - setIsSendingRfq(false); - } - }, [selectedRows, selectedRfqId, handleRefreshData]); - - // 벤더 선택 핸들러 추가 - const [isAcceptingVendors, setIsAcceptingVendors] = useState(false); - - const handleAcceptVendors = useCallback(async () => { - if (selectedRows.length === 0) { - toast.warning("선택할 벤더를 선택해주세요."); - return; - } - - if (selectedRows.length > 1) { - toast.warning("하나의 벤더만 선택할 수 있습니다."); - return; - } - - const selectedQuotation = selectedRows[0]; - if (selectedQuotation.status !== "Submitted") { - toast.warning("제출된 견적서만 선택할 수 있습니다."); - return; - } - - try { - setIsAcceptingVendors(true); - - // 벤더 견적 승인 서비스 함수 호출 - const { acceptTechSalesVendorQuotationAction } = await import("@/lib/techsales-rfq/actions"); - - const result = await acceptTechSalesVendorQuotationAction(selectedQuotation.id); - - if (result.success) { - toast.success(result.message || "벤더가 성공적으로 선택되었습니다."); - } else { - toast.error(result.error || "벤더 선택 중 오류가 발생했습니다."); - } - - // 선택 해제 - setSelectedRows([]); - - // 데이터 새로고침 - await handleRefreshData(); - - } catch (error) { - console.error("벤더 선택 오류:", error); - toast.error("벤더 선택 중 오류가 발생했습니다."); - } finally { - setIsAcceptingVendors(false); - } - }, [selectedRows, handleRefreshData]); - - // 벤더 삭제 핸들러 메모이제이션 - const handleDeleteVendors = useCallback(async () => { - if (selectedRows.length === 0) { - toast.warning("삭제할 벤더를 선택해주세요."); - return; - } - - if (!selectedRfqId) { - toast.error("선택된 RFQ가 없습니다."); - return; - } - - try { - setIsDeletingVendors(true); - - const vendorIds = selectedRows.map(row => row.vendorId).filter(Boolean) as number[]; - - if (vendorIds.length === 0) { - toast.error("유효한 벤더 ID가 없습니다."); - return; - } - - // 서비스 함수 호출 - const { removeTechVendorsFromTechSalesRfq } = await import("@/lib/techsales-rfq/service"); - - const result = await removeTechVendorsFromTechSalesRfq({ - rfqId: selectedRfqId, - vendorIds: vendorIds - }); - - if (result.error) { - toast.error(result.error); - } else { - const successCount = result.data?.length || 0 - toast.success(`${successCount}개의 벤더가 성공적으로 삭제되었습니다`); - } - - // 선택 해제 - setSelectedRows([]); - - // 데이터 새로고침 - await handleRefreshData(); - - } catch (error) { - console.error("벤더 삭제 오류:", error); - toast.error("벤더 삭제 중 오류가 발생했습니다."); - } finally { - setIsDeletingVendors(false); - } - }, [selectedRows, selectedRfqId, handleRefreshData]); - - // 벤더 삭제 확인 핸들러 - const handleDeleteVendorsConfirm = useCallback(() => { - if (selectedRows.length === 0) { - toast.warning("삭제할 벤더를 선택해주세요."); - return; - } - setDeleteConfirmDialogOpen(true); - }, [selectedRows]); - - // 벤더 삭제 확정 실행 - const executeDeleteVendors = useCallback(async () => { - setDeleteConfirmDialogOpen(false); - await handleDeleteVendors(); - }, [handleDeleteVendors]); - - - // 견적 히스토리 다이얼로그 열기 핸들러 메모이제이션 - const handleOpenHistoryDialog = useCallback((quotationId: number) => { - setSelectedQuotationId(quotationId); - setHistoryDialogOpen(true); - }, []) - - // 견적서 첨부파일 sheet 열기 핸들러 메모이제이션 - const handleOpenQuotationAttachmentsSheet = useCallback(async (quotationId: number, quotationInfo: QuotationInfo) => { - try { - setIsLoadingAttachments(true); - setSelectedQuotationInfo(quotationInfo); - setQuotationAttachmentsSheetOpen(true); - - // 견적서 첨부파일 조회 - const { getTechSalesVendorQuotationAttachments } = await import("@/lib/techsales-rfq/service"); - const result = await getTechSalesVendorQuotationAttachments(quotationId); - - if (result.error) { - toast.error(result.error); - setQuotationAttachments([]); - } else { - setQuotationAttachments(result.data || []); - } - } catch (error) { - console.error("견적서 첨부파일 조회 오류:", error); - toast.error("견적서 첨부파일을 불러오는 중 오류가 발생했습니다."); - setQuotationAttachments([]); - } finally { - setIsLoadingAttachments(false); - } - }, []) - - // 칼럼 정의 - unreadMessages 상태 전달 (메모이제이션) - const columns = useMemo(() => - getRfqDetailColumns({ - setRowAction, - unreadMessages, - onQuotationClick: handleOpenHistoryDialog, - openQuotationAttachmentsSheet: handleOpenQuotationAttachmentsSheet - }), [unreadMessages, handleOpenHistoryDialog, handleOpenQuotationAttachmentsSheet]) - - // 필터 필드 정의 (메모이제이션) - const advancedFilterFields = useMemo( - () => [ - { - id: "vendorName", - label: "벤더명", - type: "text", - }, - { - id: "vendorCode", - label: "벤더 코드", - type: "text", - }, - { - id: "currency", - label: "통화", - type: "text", - }, - ], - [] - ) - - // 계산된 값들 메모이제이션 - const vendorsWithQuotations = useMemo(() => - details.filter(detail => detail.status === "Submitted").length, - [details] - ); - - // RFQ ID가 변경될 때 데이터 로드 - useEffect(() => { - async function loadRfqDetails() { - if (!selectedRfqId) { - setDetails([]) - return - } - - try { - setIsLoading(true) - - // 실제 벤더 견적 데이터 로딩 - const { getTechSalesVendorQuotationsWithJoin } = await import("@/lib/techsales-rfq/service") - - const result = await getTechSalesVendorQuotationsWithJoin({ - rfqId: selectedRfqId, - page: 1, - perPage: 1000, // 모든 데이터 가져오기 - }) - - // 데이터 변환 (procurement 패턴에 맞게) - const transformedData = result.data?.map(item => ({ - ...item, - detailId: item.id, - rfqId: selectedRfqId, - rfqCode: selectedRfq?.rfqCode || null, - vendorId: item.vendorId ? Number(item.vendorId) : undefined, - // 기타 필요한 필드 변환 - })) || [] - - setDetails(transformedData) - - // 읽지 않은 메시지 개수 로드 - await loadUnreadMessages(); - - } catch (error) { - console.error("RFQ 디테일 로드 오류:", error) - setDetails([]) - toast.error("RFQ 세부정보를 불러오는 중 오류가 발생했습니다") - } finally { - setIsLoading(false) - } - } - - loadRfqDetails() - }, [selectedRfqId, selectedRfq?.rfqCode, loadUnreadMessages]) - - // 주기적으로 읽지 않은 메시지 갱신 (60초마다) - 메모이제이션된 함수 사용 - useEffect(() => { - if (!selectedRfqId) return; - - const intervalId = setInterval(() => { - loadUnreadMessages(); - }, 60000); // 60초마다 갱신 - - return () => clearInterval(intervalId); - }, [selectedRfqId, loadUnreadMessages]); - - // rowAction 처리 - procurement 패턴 적용 (메모이제이션) - useEffect(() => { - if (!rowAction) return - - const handleRowAction = async () => { - try { - // 통신 액션인 경우 드로어 열기 - if (rowAction.type === "communicate") { - setSelectedVendor(rowAction.row.original); - setCommunicationDrawerOpen(true); - - // rowAction 초기화 - setRowAction(null); - return; - } - - // 삭제 액션인 경우 개별 벤더 삭제 - if (rowAction.type === "delete") { - const vendor = rowAction.row.original; - - if (!vendor.vendorId || !selectedRfqId) { - toast.error("벤더 정보가 없습니다."); - setRowAction(null); - return; - } - - // Draft 상태 체크 - if (vendor.status !== "Draft") { - toast.error("Draft 상태의 벤더만 삭제할 수 있습니다."); - setRowAction(null); - return; - } - - // 개별 벤더 삭제 - const { removeTechVendorFromTechSalesRfq } = await import("@/lib/techsales-rfq/service"); - - const result = await removeTechVendorFromTechSalesRfq({ - rfqId: selectedRfqId, - vendorId: vendor.vendorId - }); - - if (result.error) { - toast.error(result.error); - } else { - toast.success(`${vendor.vendorName || '벤더'}가 성공적으로 삭제되었습니다.`); - // 데이터 새로고침 - await handleRefreshData(); - } - - // rowAction 초기화 - setRowAction(null); - return; - } - } catch (error) { - console.error("액션 처리 오류:", error); - toast.error("작업을 처리하는 중 오류가 발생했습니다"); - } - }; - - handleRowAction(); - }, [rowAction, selectedRfqId, handleRefreshData]) - - // 선택된 행 변경 핸들러 메모이제이션 - const handleSelectedRowsChange = useCallback((selectedRowsData: RfqDetailView[]) => { - setSelectedRows(selectedRowsData); - }, []); - - // 커뮤니케이션 드로어 변경 핸들러 메모이제이션 - const handleCommunicationDrawerChange = useCallback((open: boolean) => { - setCommunicationDrawerOpen(open); - // 드로어가 닫힐 때 해당 벤더의 메시지를 읽음 처리하고 읽지 않은 메시지 개수 갱신 - if (!open && selectedVendor?.vendorId && selectedRfqId) { - // 메시지를 읽음으로 처리 - import("@/lib/techsales-rfq/service").then(({ markTechSalesMessagesAsRead }) => { - markTechSalesMessagesAsRead(selectedRfqId, selectedVendor.vendorId || undefined).catch(error => { - console.error("메시지 읽음 처리 오류:", error); - }); - }); - - // 해당 벤더의 읽지 않은 메시지를 0으로 즉시 업데이트 - setUnreadMessages(prev => ({ - ...prev, - [selectedVendor.vendorId!]: 0 - })); - - // 전체 읽지 않은 메시지 개수 갱신 - loadUnreadMessages(); - } - }, [selectedVendor, selectedRfqId, loadUnreadMessages]); - - if (!selectedRfq) { - return ( - <div className="flex items-center justify-center h-full text-muted-foreground"> - RFQ를 선택하세요 - </div> - ) - } - - // 로딩 중인 경우 - if (isLoading) { - return ( - <div className="p-4 space-y-4"> - <Skeleton className="h-8 w-1/2" /> - <Skeleton className="h-24 w-full" /> - <Skeleton className="h-48 w-full" /> - </div> - ) - } - - return ( - <div className="h-full overflow-hidden pt-4"> - {/* 테이블 또는 빈 상태 표시 */} - {details.length > 0 ? ( - <ClientDataTable - columns={columns} - data={details} - advancedFilterFields={advancedFilterFields} - maxHeight={maxHeight} - onSelectedRowsChange={handleSelectedRowsChange} - > - <div className="flex justify-between items-center"> - <div className="flex items-center gap-2 mr-2"> - {selectedRows.length > 0 && ( - <Badge variant="default" className="h-6"> - {selectedRows.length}개 선택됨 - </Badge> - )} - {/* {totalUnreadMessages > 0 && ( - <Badge variant="destructive" className="h-6"> - 읽지 않은 메시지: {totalUnreadMessages}건 - </Badge> - )} */} - {vendorsWithQuotations > 0 && ( - <Badge variant="outline" className="h-6"> - 견적 제출: {vendorsWithQuotations}개 벤더 - </Badge> - )} - </div> - <div className="flex gap-2"> - {/* 벤더 선택 버튼 */} - <Button - variant="default" - size="sm" - onClick={handleAcceptVendors} - disabled={selectedRows.length === 0 || isAcceptingVendors} - className="gap-2" - > - {isAcceptingVendors ? ( - <Loader2 className="size-4 animate-spin" aria-hidden="true" /> - ) : ( - <CheckCircle className="size-4" aria-hidden="true" /> - )} - <span>벤더 선택</span> - </Button> - - {/* RFQ 발송 버튼 */} - <Button - variant="outline" - size="sm" - onClick={handleSendRfq} - disabled={selectedRows.length === 0 || isSendingRfq} - className="gap-2" - > - {isSendingRfq ? ( - <Loader2 className="size-4 animate-spin" aria-hidden="true" /> - ) : ( - <Send className="size-4" aria-hidden="true" /> - )} - <span>RFQ 발송</span> - </Button> - - {/* 벤더 삭제 버튼 */} - <Button - variant="outline" - size="sm" - onClick={handleDeleteVendorsConfirm} - disabled={selectedRows.length === 0 || isDeletingVendors} - className="gap-2" - > - {isDeletingVendors ? ( - <Loader2 className="size-4 animate-spin" aria-hidden="true" /> - ) : ( - <Trash2 className="size-4" aria-hidden="true" /> - )} - <span>벤더 삭제</span> - </Button> - - {/* 벤더 추가 버튼 */} - <Button - variant="outline" - size="sm" - onClick={handleAddVendor} - disabled={isAdddialogLoading} - className="gap-2" - > - {isAdddialogLoading ? ( - <Loader2 className="size-4 animate-spin" aria-hidden="true" /> - ) : ( - <UserPlus className="size-4" aria-hidden="true" /> - )} - <span>벤더 추가</span> - </Button> - </div> - </div> - </ClientDataTable> - ) : ( - <div className="flex h-full items-center justify-center text-muted-foreground"> - <div className="text-center"> - <p className="text-lg font-medium">벤더가 없습니다</p> - <p className="text-sm">벤더를 추가하여 RFQ를 시작하세요</p> - <Button - variant="outline" - size="sm" - onClick={handleAddVendor} - disabled={isAdddialogLoading} - className="mt-4 gap-2" - > - {isAdddialogLoading ? ( - <Loader2 className="size-4 animate-spin" aria-hidden="true" /> - ) : ( - <UserPlus className="size-4" aria-hidden="true" /> - )} - <span>벤더 추가</span> - </Button> - </div> - </div> - )} - - {/* 다이얼로그들 */} - <AddVendorDialog - open={vendorDialogOpen} - onOpenChange={setVendorDialogOpen} - selectedRfq={selectedRfq as unknown as TechSalesRfq} - existingVendorIds={existingVendorIds} - onSuccess={handleRefreshData} - /> - - {/* 벤더 커뮤니케이션 드로어 */} - <VendorCommunicationDrawer - open={communicationDrawerOpen} - onOpenChange={handleCommunicationDrawerChange} - selectedRfq={selectedRfq} - selectedVendor={selectedVendor} - onSuccess={handleRefreshData} - /> - - {/* 다중 벤더 삭제 확인 다이얼로그 */} - <DeleteVendorsDialog - open={deleteConfirmDialogOpen} - onOpenChange={setDeleteConfirmDialogOpen} - vendors={selectedRows} - onConfirm={executeDeleteVendors} - isLoading={isDeletingVendors} - /> - - {/* 견적 히스토리 다이얼로그 */} - <QuotationHistoryDialog - open={historyDialogOpen} - onOpenChange={setHistoryDialogOpen} - quotationId={selectedQuotationId} - /> - - {/* 견적서 첨부파일 Sheet */} - <TechSalesQuotationAttachmentsSheet - open={quotationAttachmentsSheetOpen} - onOpenChange={setQuotationAttachmentsSheetOpen} - quotation={selectedQuotationInfo} - attachments={quotationAttachments} - isLoading={isLoadingAttachments} - /> - </div> - ) +"use client"
+
+import * as React from "react"
+import { useEffect, useState, useCallback, useMemo } from "react"
+import {
+ DataTableRowAction,
+ getRfqDetailColumns,
+ RfqDetailView
+} from "./rfq-detail-column"
+import { toast } from "sonner"
+
+import { Skeleton } from "@/components/ui/skeleton"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Loader2, UserPlus, Send, Trash2, CheckCircle } from "lucide-react"
+import { ClientDataTable } from "@/components/client-data-table/data-table"
+import { AddVendorDialog } from "./add-vendor-dialog"
+import { VendorCommunicationDrawer } from "./vendor-communication-drawer"
+import { DeleteVendorDialog } from "./delete-vendors-dialog"
+import { QuotationHistoryDialog } from "@/lib/techsales-rfq/table/detail-table/quotation-history-dialog"
+import { TechSalesQuotationAttachmentsSheet, type QuotationAttachment } from "../tech-sales-quotation-attachments-sheet"
+import type { QuotationInfo } from "./rfq-detail-column"
+import { VendorContactSelectionDialog } from "./vendor-contact-selection-dialog"
+import { QuotationContactsViewDialog } from "./quotation-contacts-view-dialog"
+
+// 기본적인 RFQ 타입 정의
+interface TechSalesRfq {
+ id: number
+ rfqCode: string | null
+ status: string
+ materialCode?: string | null
+ itemName?: string | null
+ remark?: string | null
+ rfqSendDate?: Date | null
+ dueDate?: Date | null
+ createdByName?: string | null
+ rfqType: "SHIP" | "TOP" | "HULL" | null
+ ptypeNm?: string | null
+}
+
+// 프로퍼티 정의
+interface RfqDetailTablesProps {
+ selectedRfq: TechSalesRfq | null
+ maxHeight?: string | number
+}
+
+
+export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps) {
+ // console.log("selectedRfq", selectedRfq)
+
+ // 상태 관리
+ const [isLoading, setIsLoading] = useState(false)
+ const [details, setDetails] = useState<RfqDetailView[]>([])
+ const [vendorDialogOpen, setVendorDialogOpen] = React.useState(false)
+
+ const [isAdddialogLoading, setIsAdddialogLoading] = useState(false)
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<RfqDetailView> | null>(null)
+
+ // 벤더 커뮤니케이션 상태 관리
+ const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false)
+ const [selectedVendor, setSelectedVendor] = useState<RfqDetailView | null>(null)
+
+ // 읽지 않은 메시지 개수
+ const [unreadMessages, setUnreadMessages] = useState<Record<number, number>>({})
+
+ // 테이블 선택 상태 관리
+ const [selectedRows, setSelectedRows] = useState<RfqDetailView[]>([])
+ const [isSendingRfq, setIsSendingRfq] = useState(false)
+ const [isDeletingVendors, setIsDeletingVendors] = useState(false)
+
+ // 벤더 삭제 확인 다이얼로그 상태 추가
+ const [deleteConfirmDialogOpen, setDeleteConfirmDialogOpen] = useState(false)
+
+ // 견적 히스토리 다이얼로그 상태 관리
+ const [historyDialogOpen, setHistoryDialogOpen] = useState(false)
+ const [selectedQuotationId, setSelectedQuotationId] = useState<number | null>(null)
+
+ // 견적서 첨부파일 sheet 상태 관리
+ const [quotationAttachmentsSheetOpen, setQuotationAttachmentsSheetOpen] = useState(false)
+ const [selectedQuotationInfo, setSelectedQuotationInfo] = useState<QuotationInfo | null>(null)
+ const [quotationAttachments, setQuotationAttachments] = useState<QuotationAttachment[]>([])
+ const [isLoadingAttachments, setIsLoadingAttachments] = useState(false)
+
+ // 벤더 contact 선택 다이얼로그 상태 관리
+ const [contactSelectionDialogOpen, setContactSelectionDialogOpen] = useState(false)
+
+ // 담당자 조회 다이얼로그 상태 관리
+ const [contactsDialogOpen, setContactsDialogOpen] = useState(false)
+ const [selectedQuotationForContacts, setSelectedQuotationForContacts] = useState<{ id: number; vendorName?: string } | null>(null)
+
+ // selectedRfq ID 메모이제이션 (객체 참조 변경 방지)
+ const selectedRfqId = useMemo(() => selectedRfq?.id, [selectedRfq?.id])
+
+ // existingVendorIds 메모이제이션
+ const existingVendorIds = useMemo(() => {
+ return details.map(detail => Number(detail.vendorId)).filter(Boolean);
+ }, [details]);
+
+ // 읽지 않은 메시지 로드 함수 메모이제이션
+ const loadUnreadMessages = useCallback(async () => {
+ if (!selectedRfqId) return;
+
+ try {
+ // 기술영업용 읽지 않은 메시지 수 가져오기 함수 구현
+ const { getTechSalesUnreadMessageCounts } = await import("@/lib/techsales-rfq/service");
+ const unreadData = await getTechSalesUnreadMessageCounts(selectedRfqId);
+ setUnreadMessages(unreadData);
+ } catch (error) {
+ console.error("읽지 않은 메시지 로드 오류:", error);
+ setUnreadMessages({});
+ }
+ }, [selectedRfqId]);
+
+ // 데이터 새로고침 함수 메모이제이션
+ const handleRefreshData = useCallback(async () => {
+ if (!selectedRfqId) return
+
+ try {
+ // 실제 벤더 견적 데이터 다시 로딩
+ const { getTechSalesRfqTechVendors } = await import("@/lib/techsales-rfq/service")
+
+ const result = await getTechSalesRfqTechVendors(selectedRfqId)
+
+ // 데이터 변환
+ const transformedData = result.data?.map((item: any) => ({
+ ...item,
+ detailId: item.id,
+ rfqId: selectedRfqId,
+ rfqCode: selectedRfq?.rfqCode || null,
+ rfqType: selectedRfq?.rfqType || null,
+ ptypeNm: selectedRfq?.ptypeNm || null,
+ vendorId: item.vendorId ? Number(item.vendorId) : undefined,
+ })) || []
+
+ setDetails(transformedData)
+
+ // 읽지 않은 메시지 개수 업데이트
+ await loadUnreadMessages();
+
+ toast.success("데이터를 성공적으로 새로고침했습니다")
+ } catch (error) {
+ console.error("데이터 새로고침 오류:", error)
+ toast.error("데이터를 새로고침하는 중 오류가 발생했습니다")
+ }
+ }, [selectedRfqId, selectedRfq?.rfqCode, selectedRfq?.rfqType, selectedRfq?.ptypeNm, loadUnreadMessages])
+
+ // 벤더 추가 핸들러 메모이제이션
+ const handleAddVendor = useCallback(async () => {
+ try {
+ setIsAdddialogLoading(true)
+ setVendorDialogOpen(true)
+ } catch (error) {
+ console.error("데이터 로드 오류:", error)
+ toast.error("벤더 정보를 불러오는 중 오류가 발생했습니다")
+ } finally {
+ setIsAdddialogLoading(false)
+ }
+ }, [])
+
+ // RFQ 발송 핸들러 메모이제이션 - contact selection dialog 사용
+ const handleSendRfq = useCallback(async () => {
+ if (selectedRows.length === 0) {
+ toast.warning("발송할 벤더를 선택해주세요.");
+ return;
+ }
+
+ if (!selectedRfqId) {
+ toast.error("선택된 RFQ가 없습니다.");
+ return;
+ }
+
+ // 선택된 벤더들의 status가 모두 'Assigned'인지 확인
+ const nonAssignedVendors = selectedRows.filter(row => row.status !== "Assigned");
+ if (nonAssignedVendors.length > 0) {
+ toast.warning("Assigned 상태의 벤더만 RFQ를 발송할 수 있습니다.");
+ return;
+ }
+
+ // contact selection dialog 열기
+ setContactSelectionDialogOpen(true);
+ }, [selectedRows, selectedRfqId]);
+
+ // contact 기반 RFQ 발송 핸들러
+ const handleSendRfqWithContacts = useCallback(async (selectedContacts: Array<{
+ vendorId: number;
+ contactId: number;
+ contactEmail: string;
+ contactName: string;
+ }>) => {
+ if (!selectedRfqId) {
+ toast.error("선택된 RFQ가 없습니다.");
+ return;
+ }
+
+ try {
+ setIsSendingRfq(true);
+
+ // 기술영업 RFQ 발송 서비스 함수 호출 (contact 정보 포함)
+ const vendorIds = selectedRows.map(row => row.vendorId).filter(Boolean);
+ const { sendTechSalesRfqToVendors } = await import("@/lib/techsales-rfq/service");
+
+ const result = await sendTechSalesRfqToVendors({
+ rfqId: selectedRfqId,
+ vendorIds: vendorIds as number[],
+ selectedContacts: selectedContacts
+ });
+
+ if (result.success) {
+ toast.success(result.message || `${selectedContacts.length}명의 연락처에게 RFQ가 발송되었습니다.`);
+ } else {
+ toast.error(result.message || "RFQ 발송 중 오류가 발생했습니다.");
+ }
+
+ // 선택 해제
+ setSelectedRows([]);
+
+ // 데이터 새로고침
+ await handleRefreshData();
+
+ } catch (error) {
+ console.error("RFQ 발송 오류:", error);
+ toast.error("RFQ 발송 중 오류가 발생했습니다.");
+ } finally {
+ setIsSendingRfq(false);
+ }
+ }, [selectedRfqId, selectedRows, handleRefreshData]);
+
+ // 벤더 선택 핸들러 추가
+ const [isAcceptingVendors, setIsAcceptingVendors] = useState(false);
+
+ const handleAcceptVendors = useCallback(async () => {
+ if (selectedRows.length === 0) {
+ toast.warning("선택할 벤더를 선택해주세요.");
+ return;
+ }
+
+ if (selectedRows.length > 1) {
+ toast.warning("하나의 벤더만 선택할 수 있습니다.");
+ return;
+ }
+
+ const selectedQuotation = selectedRows[0];
+ if (selectedQuotation.status !== "Submitted") {
+ toast.warning("제출된 견적서만 선택할 수 있습니다.");
+ return;
+ }
+
+ try {
+ setIsAcceptingVendors(true);
+
+ // 벤더 견적 승인 서비스 함수 호출
+ const { acceptTechSalesVendorQuotationAction } = await import("@/lib/techsales-rfq/actions");
+
+ const result = await acceptTechSalesVendorQuotationAction(selectedQuotation.id);
+
+ if (result.success) {
+ toast.success(result.message || "벤더가 성공적으로 선택되었습니다.");
+ } else {
+ toast.error(result.error || "벤더 선택 중 오류가 발생했습니다.");
+ }
+
+ // 선택 해제
+ setSelectedRows([]);
+
+ // 데이터 새로고침
+ await handleRefreshData();
+
+ } catch (error) {
+ console.error("벤더 선택 오류:", error);
+ toast.error("벤더 선택 중 오류가 발생했습니다.");
+ } finally {
+ setIsAcceptingVendors(false);
+ }
+ }, [selectedRows, handleRefreshData]);
+
+ // 벤더 삭제 핸들러 메모이제이션
+ const handleDeleteVendors = useCallback(async () => {
+ if (selectedRows.length === 0) {
+ toast.warning("삭제할 벤더를 선택해주세요.");
+ return;
+ }
+
+ if (!selectedRfqId) {
+ toast.error("선택된 RFQ가 없습니다.");
+ return;
+ }
+
+ try {
+ setIsDeletingVendors(true);
+
+ const vendorIds = selectedRows.map(row => row.vendorId).filter(Boolean) as number[];
+
+ if (vendorIds.length === 0) {
+ toast.error("유효한 벤더 ID가 없습니다.");
+ return;
+ }
+
+ // 서비스 함수 호출
+ const { removeTechVendorsFromTechSalesRfq } = await import("@/lib/techsales-rfq/service");
+
+ const result = await removeTechVendorsFromTechSalesRfq({
+ rfqId: selectedRfqId,
+ vendorIds: vendorIds
+ });
+
+ if (result.error) {
+ toast.error(result.error);
+ } else {
+ const successCount = result.data?.length || 0
+ toast.success(`${successCount}개의 벤더가 성공적으로 삭제되었습니다`);
+ }
+
+ // 선택 해제
+ setSelectedRows([]);
+
+ // 데이터 새로고침
+ await handleRefreshData();
+
+ } catch (error) {
+ console.error("벤더 삭제 오류:", error);
+ toast.error("벤더 삭제 중 오류가 발생했습니다.");
+ } finally {
+ setIsDeletingVendors(false);
+ }
+ }, [selectedRows, selectedRfqId, handleRefreshData]);
+
+ // 벤더 삭제 확인 핸들러
+ const handleDeleteVendorsConfirm = useCallback(() => {
+ if (selectedRows.length === 0) {
+ toast.warning("삭제할 벤더를 선택해주세요.");
+ return;
+ }
+ setDeleteConfirmDialogOpen(true);
+ }, [selectedRows]);
+
+ // 벤더 삭제 확정 실행
+ const executeDeleteVendors = useCallback(async () => {
+ setDeleteConfirmDialogOpen(false);
+ await handleDeleteVendors();
+ }, [handleDeleteVendors]);
+
+
+ // 견적 히스토리 다이얼로그 열기 핸들러 메모이제이션
+ const handleOpenHistoryDialog = useCallback((quotationId: number) => {
+ setSelectedQuotationId(quotationId);
+ setHistoryDialogOpen(true);
+ }, [])
+
+ // 견적서 첨부파일 sheet 열기 핸들러 메모이제이션
+ const handleOpenQuotationAttachmentsSheet = useCallback(async (quotationId: number, quotationInfo: QuotationInfo) => {
+ try {
+ setIsLoadingAttachments(true);
+ setSelectedQuotationInfo(quotationInfo);
+ setQuotationAttachmentsSheetOpen(true);
+
+ // 견적서 첨부파일 조회
+ const { getTechSalesVendorQuotationAttachments } = await import("@/lib/techsales-rfq/service");
+ const result = await getTechSalesVendorQuotationAttachments(quotationId);
+
+ if (result.error) {
+ toast.error(result.error);
+ setQuotationAttachments([]);
+ } else {
+ setQuotationAttachments(result.data || []);
+ }
+ } catch (error) {
+ console.error("견적서 첨부파일 조회 오류:", error);
+ toast.error("견적서 첨부파일을 불러오는 중 오류가 발생했습니다.");
+ setQuotationAttachments([]);
+ } finally {
+ setIsLoadingAttachments(false);
+ }
+ }, [])
+
+ // 담당자 조회 다이얼로그 열기 함수
+ const handleOpenContactsDialog = useCallback((quotationId: number, vendorName?: string) => {
+ setSelectedQuotationForContacts({ id: quotationId, vendorName })
+ setContactsDialogOpen(true)
+ }, [])
+
+ // 칼럼 정의 - unreadMessages 상태 전달 (메모이제이션)
+ const columns = useMemo(() =>
+ getRfqDetailColumns({
+ setRowAction,
+ unreadMessages,
+ onQuotationClick: handleOpenHistoryDialog,
+ openQuotationAttachmentsSheet: handleOpenQuotationAttachmentsSheet,
+ openContactsDialog: handleOpenContactsDialog
+ }), [unreadMessages, handleOpenHistoryDialog, handleOpenQuotationAttachmentsSheet, handleOpenContactsDialog])
+
+ // 필터 필드 정의 (메모이제이션)
+ const advancedFilterFields = useMemo(
+ () => [
+ {
+ id: "vendorName",
+ label: "벤더명",
+ type: "text",
+ },
+ {
+ id: "vendorCode",
+ label: "벤더 코드",
+ type: "text",
+ },
+ {
+ id: "currency",
+ label: "통화",
+ type: "text",
+ },
+ ],
+ []
+ )
+
+ // 계산된 값들 메모이제이션
+ const vendorsWithQuotations = useMemo(() =>
+ details.filter(detail => detail.status === "Submitted").length,
+ [details]
+ );
+
+ // RFQ ID가 변경될 때 데이터 로드
+ useEffect(() => {
+ async function loadRfqDetails() {
+ if (!selectedRfqId) {
+ setDetails([])
+ return
+ }
+
+ try {
+ setIsLoading(true)
+
+ // 실제 벤더 견적 데이터 로딩
+ const { getTechSalesVendorQuotationsWithJoin } = await import("@/lib/techsales-rfq/service")
+
+ const result = await getTechSalesVendorQuotationsWithJoin({
+ rfqId: selectedRfqId,
+ page: 1,
+ perPage: 1000, // 모든 데이터 가져오기
+ })
+
+ // 데이터 변환 (procurement 패턴에 맞게)
+ const transformedData = result.data?.map(item => ({
+ ...item,
+ detailId: item.id,
+ rfqId: selectedRfqId,
+ rfqCode: selectedRfq?.rfqCode || null,
+ vendorId: item.vendorId ? Number(item.vendorId) : undefined,
+ // 기타 필요한 필드 변환
+ })) || []
+
+ setDetails(transformedData)
+
+ // 읽지 않은 메시지 개수 로드
+ await loadUnreadMessages();
+
+ } catch (error) {
+ console.error("RFQ 디테일 로드 오류:", error)
+ setDetails([])
+ toast.error("RFQ 세부정보를 불러오는 중 오류가 발생했습니다")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ loadRfqDetails()
+ }, [selectedRfqId, selectedRfq?.rfqCode, loadUnreadMessages])
+
+ // 주기적으로 읽지 않은 메시지 갱신 (60초마다) - 메모이제이션된 함수 사용
+ useEffect(() => {
+ if (!selectedRfqId) return;
+
+ const intervalId = setInterval(() => {
+ loadUnreadMessages();
+ }, 60000); // 60초마다 갱신
+
+ return () => clearInterval(intervalId);
+ }, [selectedRfqId, loadUnreadMessages]);
+
+ // rowAction 처리 - procurement 패턴 적용 (메모이제이션)
+ useEffect(() => {
+ if (!rowAction) return
+
+ const handleRowAction = async () => {
+ try {
+ // 통신 액션인 경우 드로어 열기
+ if (rowAction.type === "communicate") {
+ setSelectedVendor(rowAction.row.original);
+ setCommunicationDrawerOpen(true);
+
+ // rowAction 초기화
+ setRowAction(null);
+ return;
+ }
+
+ // 삭제 액션인 경우 개별 벤더 삭제
+ if (rowAction.type === "delete") {
+ const vendor = rowAction.row.original;
+
+ if (!vendor.vendorId || !selectedRfqId) {
+ toast.error("벤더 정보가 없습니다.");
+ setRowAction(null);
+ return;
+ }
+
+ // Draft 상태 체크
+ if (vendor.status !== "Draft") {
+ toast.error("Draft 상태의 벤더만 삭제할 수 있습니다.");
+ setRowAction(null);
+ return;
+ }
+
+ // 개별 벤더 삭제
+ const { removeTechVendorFromTechSalesRfq } = await import("@/lib/techsales-rfq/service");
+
+ const result = await removeTechVendorFromTechSalesRfq({
+ rfqId: selectedRfqId,
+ vendorId: vendor.vendorId
+ });
+
+ if (result.error) {
+ toast.error(result.error);
+ } else {
+ toast.success(`${vendor.vendorName || '벤더'}가 성공적으로 삭제되었습니다.`);
+ // 데이터 새로고침
+ await handleRefreshData();
+ }
+
+ // rowAction 초기화
+ setRowAction(null);
+ return;
+ }
+ } catch (error) {
+ console.error("액션 처리 오류:", error);
+ toast.error("작업을 처리하는 중 오류가 발생했습니다");
+ }
+ };
+
+ handleRowAction();
+ }, [rowAction, selectedRfqId, handleRefreshData])
+
+ // 선택된 행 변경 핸들러 메모이제이션
+ const handleSelectedRowsChange = useCallback((selectedRowsData: RfqDetailView[]) => {
+ setSelectedRows(selectedRowsData);
+ }, []);
+
+ // 커뮤니케이션 드로어 변경 핸들러 메모이제이션
+ const handleCommunicationDrawerChange = useCallback((open: boolean) => {
+ setCommunicationDrawerOpen(open);
+ // 드로어가 닫힐 때 해당 벤더의 메시지를 읽음 처리하고 읽지 않은 메시지 개수 갱신
+ if (!open && selectedVendor?.vendorId && selectedRfqId) {
+ // 메시지를 읽음으로 처리
+ import("@/lib/techsales-rfq/service").then(({ markTechSalesMessagesAsRead }) => {
+ markTechSalesMessagesAsRead(selectedRfqId, selectedVendor.vendorId || undefined).catch(error => {
+ console.error("메시지 읽음 처리 오류:", error);
+ });
+ });
+
+ // 해당 벤더의 읽지 않은 메시지를 0으로 즉시 업데이트
+ setUnreadMessages(prev => ({
+ ...prev,
+ [selectedVendor.vendorId!]: 0
+ }));
+
+ // 전체 읽지 않은 메시지 개수 갱신
+ loadUnreadMessages();
+ }
+ }, [selectedVendor, selectedRfqId, loadUnreadMessages]);
+
+ if (!selectedRfq) {
+ return (
+ <div className="flex items-center justify-center h-full text-muted-foreground">
+ RFQ를 선택하세요
+ </div>
+ )
+ }
+
+ // 로딩 중인 경우
+ if (isLoading) {
+ return (
+ <div className="p-4 space-y-4">
+ <Skeleton className="h-8 w-1/2" />
+ <Skeleton className="h-24 w-full" />
+ <Skeleton className="h-48 w-full" />
+ </div>
+ )
+ }
+
+ return (
+ <div className="h-full overflow-hidden pt-4">
+ {/* 테이블 또는 빈 상태 표시 */}
+ {details.length > 0 ? (
+ <ClientDataTable
+ columns={columns}
+ data={details}
+ advancedFilterFields={advancedFilterFields}
+ maxHeight={maxHeight}
+ onSelectedRowsChange={handleSelectedRowsChange}
+ >
+ <div className="flex justify-between items-center">
+ <div className="flex items-center gap-2 mr-2">
+ {selectedRows.length > 0 && (
+ <Badge variant="default" className="h-6">
+ {selectedRows.length}개 선택됨
+ </Badge>
+ )}
+ {/* {totalUnreadMessages > 0 && (
+ <Badge variant="destructive" className="h-6">
+ 읽지 않은 메시지: {totalUnreadMessages}건
+ </Badge>
+ )} */}
+ {vendorsWithQuotations > 0 && (
+ <Badge variant="outline" className="h-6">
+ 견적 제출: {vendorsWithQuotations}개 벤더
+ </Badge>
+ )}
+ </div>
+ <div className="flex gap-2">
+ {/* 벤더 선택 버튼 */}
+ <Button
+ variant="default"
+ size="sm"
+ onClick={handleAcceptVendors}
+ disabled={
+ selectedRows.length === 0 ||
+ isAcceptingVendors ||
+ selectedRows.length > 1 ||
+ selectedRows.some(row => row.status !== "Submitted")
+ }
+ className="gap-2"
+ >
+ {isAcceptingVendors ? (
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
+ ) : (
+ <CheckCircle className="size-4" aria-hidden="true" />
+ )}
+ <span>벤더 선택</span>
+ </Button>
+
+ {/* RFQ 발송 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleSendRfq}
+ disabled={
+ selectedRows.length === 0 ||
+ isSendingRfq ||
+ selectedRows.some(row => row.status !== "Assigned")
+ }
+ className="gap-2"
+ >
+ {isSendingRfq ? (
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
+ ) : (
+ <Send className="size-4" aria-hidden="true" />
+ )}
+ <span>RFQ 발송</span>
+ </Button>
+
+ {/* 벤더 삭제 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleDeleteVendorsConfirm}
+ disabled={selectedRows.length === 0 || isDeletingVendors}
+ className="gap-2"
+ >
+ {isDeletingVendors ? (
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
+ ) : (
+ <Trash2 className="size-4" aria-hidden="true" />
+ )}
+ <span>벤더 삭제</span>
+ </Button>
+
+ {/* 벤더 추가 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleAddVendor}
+ disabled={isAdddialogLoading}
+ className="gap-2"
+ >
+ {isAdddialogLoading ? (
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
+ ) : (
+ <UserPlus className="size-4" aria-hidden="true" />
+ )}
+ <span>벤더 추가</span>
+ </Button>
+ </div>
+ </div>
+ </ClientDataTable>
+ ) : (
+ <div className="flex h-full items-center justify-center text-muted-foreground">
+ <div className="text-center">
+ <p className="text-lg font-medium">벤더가 없습니다</p>
+ <p className="text-sm">벤더를 추가하여 RFQ를 시작하세요</p>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleAddVendor}
+ disabled={isAdddialogLoading}
+ className="mt-4 gap-2"
+ >
+ {isAdddialogLoading ? (
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
+ ) : (
+ <UserPlus className="size-4" aria-hidden="true" />
+ )}
+ <span>벤더 추가</span>
+ </Button>
+ </div>
+ </div>
+ )}
+
+ {/* 다이얼로그들 */}
+ <AddVendorDialog
+ open={vendorDialogOpen}
+ onOpenChange={setVendorDialogOpen}
+ selectedRfq={selectedRfq as unknown as TechSalesRfq}
+ existingVendorIds={existingVendorIds}
+ onSuccess={handleRefreshData}
+ />
+
+ {/* 벤더 커뮤니케이션 드로어 */}
+ <VendorCommunicationDrawer
+ open={communicationDrawerOpen}
+ onOpenChange={handleCommunicationDrawerChange}
+ selectedRfq={selectedRfq}
+ selectedVendor={selectedVendor}
+ onSuccess={handleRefreshData}
+ />
+
+ {/* 다중 벤더 삭제 확인 다이얼로그 */}
+ <DeleteVendorDialog
+ open={deleteConfirmDialogOpen}
+ onOpenChange={setDeleteConfirmDialogOpen}
+ vendors={selectedRows}
+ onConfirm={executeDeleteVendors}
+ isLoading={isDeletingVendors}
+ />
+
+ {/* 견적 히스토리 다이얼로그 */}
+ <QuotationHistoryDialog
+ open={historyDialogOpen}
+ onOpenChange={setHistoryDialogOpen}
+ quotationId={selectedQuotationId}
+ />
+
+ {/* 견적서 첨부파일 Sheet */}
+ <TechSalesQuotationAttachmentsSheet
+ open={quotationAttachmentsSheetOpen}
+ onOpenChange={setQuotationAttachmentsSheetOpen}
+ quotation={selectedQuotationInfo}
+ attachments={quotationAttachments}
+ isLoading={isLoadingAttachments}
+ />
+
+ {/* 벤더 contact 선택 다이얼로그 */}
+ <VendorContactSelectionDialog
+ open={contactSelectionDialogOpen}
+ onOpenChange={setContactSelectionDialogOpen}
+ vendorIds={selectedRows.map(row => row.vendorId).filter(Boolean) as number[]}
+ onSendRfq={handleSendRfqWithContacts}
+ />
+
+ {/* 담당자 조회 다이얼로그 */}
+ <QuotationContactsViewDialog
+ open={contactsDialogOpen}
+ onOpenChange={setContactsDialogOpen}
+ quotationId={selectedQuotationForContacts?.id || null}
+ vendorName={selectedQuotationForContacts?.vendorName}
+ />
+ </div>
+ )
}
\ No newline at end of file diff --git a/lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx b/lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx index 0312451d..5b60ef0f 100644 --- a/lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx +++ b/lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx @@ -1,619 +1,621 @@ -"use client" - -import * as React from "react" -import { useState, useEffect, useRef } from "react" -import { RfqDetailView } from "./rfq-detail-column" -import { Button } from "@/components/ui/button" -import { Textarea } from "@/components/ui/textarea" -import { Avatar, AvatarFallback } from "@/components/ui/avatar" -import { - Drawer, - DrawerClose, - DrawerContent, - DrawerDescription, - DrawerFooter, - DrawerHeader, - DrawerTitle, -} from "@/components/ui/drawer" -import { Badge } from "@/components/ui/badge" -import { toast } from "sonner" -import { - Send, - Paperclip, - DownloadCloud, - File, - FileText, - Image as ImageIcon, - AlertCircle, - X -} from "lucide-react" -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { formatDateTime } from "@/lib/utils" -import { formatFileSize } from "@/lib/utils" // formatFileSize 유틸리티 임포트 -import { fetchTechSalesVendorComments, markTechSalesMessagesAsRead } from "@/lib/techsales-rfq/service" - -// 타입 정의 -interface Comment { - id: number; - rfqId: number; - vendorId: number | null // null 허용으로 변경 - userId?: number | null // null 허용으로 변경 - content: string; - isVendorComment: boolean | null; // null 허용으로 변경 - createdAt: Date; - updatedAt: Date; - userName?: string | null // null 허용으로 변경 - vendorName?: string | null // null 허용으로 변경 - attachments: Attachment[]; - isRead: boolean | null // null 허용으로 변경 -} - -interface Attachment { - id: number; - fileName: string; - fileSize: number; - fileType: string | null; - filePath: string; - uploadedAt: Date; -} - -// 프롭스 정의 -interface VendorCommunicationDrawerProps { - open: boolean; - onOpenChange: (open: boolean) => void; - selectedRfq: { - id: number; - rfqCode: string | null; - status: string; - [key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any - } | null; - selectedVendor: RfqDetailView | null; - onSuccess?: () => void; -} - -async function sendComment(params: { - rfqId: number; - vendorId: number; - content: string; - attachments?: File[]; -}): Promise<Comment> { - try { - // 폼 데이터 생성 (파일 첨부를 위해) - const formData = new FormData(); - formData.append('rfqId', params.rfqId.toString()); - formData.append('vendorId', params.vendorId.toString()); - formData.append('content', params.content); - formData.append('isVendorComment', 'false'); - - // 첨부파일 추가 - if (params.attachments && params.attachments.length > 0) { - params.attachments.forEach((file) => { - formData.append(`attachments`, file); - }); - } - - // API 엔드포인트 구성 - techSales용으로 변경 - const url = `/api/tech-sales-rfqs/${params.rfqId}/vendors/${params.vendorId}/comments`; - - // API 호출 - const response = await fetch(url, { - method: 'POST', - body: formData, // multipart/form-data 형식 사용 - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`API 요청 실패: ${response.status} ${errorText}`); - } - - // 응답 데이터 파싱 - const result = await response.json(); - - if (!result.success || !result.data) { - throw new Error(result.message || '코멘트 전송 중 오류가 발생했습니다'); - } - - return result.data.comment; - } catch (error) { - console.error('코멘트 전송 오류:', error); - throw error; - } -} - -export function VendorCommunicationDrawer({ - open, - onOpenChange, - selectedRfq, - selectedVendor, - onSuccess -}: VendorCommunicationDrawerProps) { - // 상태 관리 - const [comments, setComments] = useState<Comment[]>([]); - const [newComment, setNewComment] = useState(""); - const [attachments, setAttachments] = useState<File[]>([]); - const [isLoading, setIsLoading] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); - const fileInputRef = useRef<HTMLInputElement>(null); - const messagesEndRef = useRef<HTMLDivElement>(null); - - // 자동 새로고침 관련 상태 - const [autoRefresh, setAutoRefresh] = useState(true); - const [lastMessageCount, setLastMessageCount] = useState(0); - const intervalRef = useRef<NodeJS.Timeout | null>(null); - - // 첨부파일 관련 상태 - const [previewDialogOpen, setPreviewDialogOpen] = useState(false); - const [selectedAttachment, setSelectedAttachment] = useState<Attachment | null>(null); - - // 드로어가 열릴 때 데이터 로드 - useEffect(() => { - if (open && selectedRfq && selectedVendor) { - loadComments(); - // 자동 새로고침 시작 - if (autoRefresh) { - startAutoRefresh(); - } - } else { - // 드로어가 닫히면 자동 새로고침 중지 - stopAutoRefresh(); - } - - // 컴포넌트 언마운트 시 정리 - return () => { - stopAutoRefresh(); - }; - }, [open, selectedRfq, selectedVendor, autoRefresh]); - - // 스크롤 최하단으로 이동 - useEffect(() => { - if (messagesEndRef.current) { - messagesEndRef.current.scrollIntoView({ behavior: "smooth" }); - } - }, [comments]); - - // 자동 새로고침 시작 - const startAutoRefresh = () => { - stopAutoRefresh(); // 기존 interval 정리 - intervalRef.current = setInterval(() => { - if (open && selectedRfq && selectedVendor && !isSubmitting) { - loadComments(true); // 자동 새로고침임을 표시 - } - }, 60000); // 60초마다 새로고침 - }; - - // 자동 새로고침 중지 - const stopAutoRefresh = () => { - if (intervalRef.current) { - clearInterval(intervalRef.current); - intervalRef.current = null; - } - }; - - // 자동 새로고침 토글 - const toggleAutoRefresh = () => { - setAutoRefresh(prev => { - const newValue = !prev; - if (newValue && open) { - startAutoRefresh(); - } else { - stopAutoRefresh(); - } - return newValue; - }); - }; - - // 코멘트 로드 함수 (자동 새로고침 여부 파라미터 추가) - const loadComments = async (isAutoRefresh = false) => { - if (!selectedRfq || !selectedVendor) return; - - try { - // 자동 새로고침일 때는 로딩 표시하지 않음 - if (!isAutoRefresh) { - setIsLoading(true); - } - - // Server Action을 사용하여 코멘트 데이터 가져오기 - const commentsData = await fetchTechSalesVendorComments(selectedRfq.id, selectedVendor.vendorId || 0); - - // 새 메시지가 있는지 확인 (자동 새로고침일 때만) - if (isAutoRefresh) { - const newMessageCount = commentsData.length; - if (newMessageCount > lastMessageCount && lastMessageCount > 0) { - // 새 메시지 알림 (선택사항) - toast.success(`새 메시지 ${newMessageCount - lastMessageCount}개가 도착했습니다`); - } - setLastMessageCount(newMessageCount); - } else { - setLastMessageCount(commentsData.length); - } - - setComments(commentsData as Comment[]); // 구체적인 타입으로 캐스팅 - - // Server Action을 사용하여 읽지 않은 메시지를 읽음 상태로 변경 - await markTechSalesMessagesAsRead(selectedRfq.id, selectedVendor.vendorId || 0); - } catch (error) { - console.error("코멘트 로드 오류:", error); - if (!isAutoRefresh) { // 자동 새로고침일 때는 에러 토스트 표시하지 않음 - toast.error("메시지를 불러오는 중 오류가 발생했습니다"); - } - } finally { - // 항상 로딩 상태를 해제하되, 최소 200ms는 유지하여 깜빡거림 방지 - if (!isAutoRefresh) { - setTimeout(() => { - setIsLoading(false); - }, 200); - } - } - }; - - // 파일 선택 핸들러 - const handleFileSelect = () => { - fileInputRef.current?.click(); - }; - - // 파일 변경 핸들러 - const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { - if (e.target.files && e.target.files.length > 0) { - const newFiles = Array.from(e.target.files); - setAttachments(prev => [...prev, ...newFiles]); - } - }; - - // 파일 제거 핸들러 - const handleRemoveFile = (index: number) => { - setAttachments(prev => prev.filter((_, i) => i !== index)); - }; - - console.log(newComment) - - // 코멘트 전송 핸들러 - const handleSubmitComment = async () => { - console.log("버튼 클릭1", selectedRfq,selectedVendor, selectedVendor?.vendorId ) - console.log(!newComment.trim() && attachments.length === 0) - - if (!newComment.trim() && attachments.length === 0) return; - if (!selectedRfq || !selectedVendor || !selectedVendor.vendorId) return; - - console.log("버튼 클릭") - - try { - setIsSubmitting(true); - - // API를 사용하여 새 코멘트 전송 (파일 업로드 때문에 FormData 사용) - const newCommentObj = await sendComment({ - rfqId: selectedRfq.id, - vendorId: selectedVendor.vendorId, - content: newComment, - attachments: attachments - }); - - // 상태 업데이트 - setComments(prev => [...prev, newCommentObj]); - setNewComment(""); - setAttachments([]); - - toast.success("메시지가 전송되었습니다"); - - // 데이터 새로고침 - if (onSuccess) { - onSuccess(); - } - } catch (error) { - console.error("코멘트 전송 오류:", error); - toast.error("메시지 전송 중 오류가 발생했습니다"); - } finally { - setIsSubmitting(false); - } - }; - - // 첨부파일 미리보기 - const handleAttachmentPreview = (attachment: Attachment) => { - setSelectedAttachment(attachment); - setPreviewDialogOpen(true); - }; - - // 첨부파일 다운로드 - const handleAttachmentDownload = (attachment: Attachment) => { - // TODO: 실제 다운로드 구현 - window.open(attachment.filePath, '_blank'); - }; - - // 파일 아이콘 선택 - const getFileIcon = (fileType: string) => { - if (fileType.startsWith("image/")) return <ImageIcon className="h-5 w-5 text-blue-500" />; - if (fileType.includes("pdf")) return <FileText className="h-5 w-5 text-red-500" />; - if (fileType.includes("spreadsheet") || fileType.includes("excel")) - return <FileText className="h-5 w-5 text-green-500" />; - if (fileType.includes("document") || fileType.includes("word")) - return <FileText className="h-5 w-5 text-blue-500" />; - return <File className="h-5 w-5 text-gray-500" />; - }; - - // 첨부파일 미리보기 다이얼로그 - const renderAttachmentPreviewDialog = () => { - if (!selectedAttachment) return null; - - const isImage = selectedAttachment.fileType?.startsWith("image/"); - const isPdf = selectedAttachment.fileType?.includes("pdf"); - - return ( - <Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}> - <DialogContent className="max-w-3xl"> - <DialogHeader> - <DialogTitle className="flex items-center gap-2"> - {getFileIcon(selectedAttachment.fileType || '')} - {selectedAttachment.fileName} - </DialogTitle> - <DialogDescription> - {formatFileSize(selectedAttachment.fileSize)} • {formatDateTime(selectedAttachment.uploadedAt, "KR")} - </DialogDescription> - </DialogHeader> - - <div className="min-h-[300px] flex items-center justify-center p-4"> - {isImage ? ( - <img - src={selectedAttachment.filePath} - alt={selectedAttachment.fileName} - className="max-h-[500px] max-w-full object-contain" - /> - ) : isPdf ? ( - <iframe - src={`${selectedAttachment.filePath}#toolbar=0`} - className="w-full h-[500px]" - title={selectedAttachment.fileName} - /> - ) : ( - <div className="flex flex-col items-center gap-4 p-8"> - {getFileIcon(selectedAttachment.fileType || '')} - <p className="text-muted-foreground text-sm">미리보기를 지원하지 않는 파일 형식입니다.</p> - <Button - variant="outline" - onClick={() => handleAttachmentDownload(selectedAttachment)} - > - <DownloadCloud className="h-4 w-4 mr-2" /> - 다운로드 - </Button> - </div> - )} - </div> - </DialogContent> - </Dialog> - ); - }; - - if (!selectedRfq || !selectedVendor) { - return null; - } - - return ( - <Drawer open={open} onOpenChange={onOpenChange}> - <DrawerContent className="max-h-[80vh] flex flex-col"> - <DrawerHeader className="border-b flex-shrink-0"> - <DrawerTitle className="flex items-center gap-2"> - <Avatar className="h-8 w-8"> - <AvatarFallback className="bg-primary/10"> - {selectedVendor.vendorName?.[0] || 'V'} - </AvatarFallback> - </Avatar> - <div> - <span>{selectedVendor.vendorName}</span> - <Badge variant="outline" className="ml-2">{selectedVendor.vendorCode}</Badge> - </div> - </DrawerTitle> - <DrawerDescription> - RFQ: {selectedRfq.rfqCode} • 프로젝트: {selectedRfq.projectName} - </DrawerDescription> - </DrawerHeader> - - <div className="flex flex-col flex-1 min-h-0"> - {/* 메시지 목록 */} - <div className="flex-1 p-4 overflow-y-auto min-h-[300px]"> - {isLoading && comments.length === 0 ? ( - <div className="flex h-full items-center justify-center"> - <p className="text-muted-foreground">메시지 로딩 중...</p> - </div> - ) : comments.length === 0 ? ( - <div className="flex h-full items-center justify-center"> - <div className="flex flex-col items-center gap-2"> - <AlertCircle className="h-6 w-6 text-muted-foreground" /> - <p className="text-muted-foreground">아직 메시지가 없습니다</p> - </div> - </div> - ) : ( - <div className="space-y-4 relative"> - {isLoading && ( - <div className="absolute top-0 right-0 z-10 bg-background/80 backdrop-blur-sm rounded-md px-2 py-1"> - <div className="flex items-center gap-2"> - <div className="w-2 h-2 bg-primary rounded-full animate-pulse" /> - <span className="text-xs text-muted-foreground">새로고침 중...</span> - </div> - </div> - )} - {comments.map(comment => ( - <div - key={comment.id} - className={`flex gap-3 ${comment.isVendorComment ? 'justify-start' : 'justify-end'}`} - > - {comment.isVendorComment && ( - <Avatar className="h-8 w-8 mt-1"> - <AvatarFallback className="bg-primary/10"> - {comment.vendorName?.[0] || 'V'} - </AvatarFallback> - </Avatar> - )} - - <div className={`rounded-lg p-3 max-w-[80%] ${ - comment.isVendorComment - ? 'bg-muted' - : 'bg-primary text-primary-foreground' - }`}> - <div className="text-sm font-medium mb-1"> - {comment.isVendorComment ? comment.vendorName : comment.userName} - </div> - - {comment.content && ( - <div className="text-sm whitespace-pre-wrap break-words"> - {comment.content} - </div> - )} - - {/* 첨부파일 표시 */} - {comment.attachments.length > 0 && ( - <div className={`mt-2 pt-2 ${ - comment.isVendorComment - ? 'border-t border-t-border/30' - : 'border-t border-t-primary-foreground/20' - }`}> - {comment.attachments.map(attachment => ( - <div - key={attachment.id} - className="flex items-center text-xs gap-2 mb-1 p-1 rounded hover:bg-black/5 cursor-pointer" - onClick={() => handleAttachmentPreview(attachment)} - > - {getFileIcon(attachment.fileType || '')} - <span className="flex-1 truncate">{attachment.fileName}</span> - <span className="text-xs opacity-70"> - {formatFileSize(attachment.fileSize)} - </span> - <Button - variant="ghost" - size="icon" - className="h-6 w-6 rounded-full" - onClick={(e) => { - e.stopPropagation(); - handleAttachmentDownload(attachment); - }} - > - <DownloadCloud className="h-3 w-3" /> - </Button> - </div> - ))} - </div> - )} - - <div className="text-xs mt-1 opacity-70 flex items-center gap-1 justify-end"> - {formatDateTime(comment.createdAt)} - </div> - </div> - - {!comment.isVendorComment && ( - <Avatar className="h-8 w-8 mt-1"> - <AvatarFallback className="bg-primary/20"> - {comment.userName?.[0] || 'U'} - </AvatarFallback> - </Avatar> - )} - </div> - ))} - <div ref={messagesEndRef} /> - </div> - )} - </div> - - {/* 선택된 첨부파일 표시 */} - {attachments.length > 0 && ( - <div className="p-2 bg-muted mx-4 rounded-md mb-2 flex-shrink-0"> - <div className="text-xs font-medium mb-1">첨부파일</div> - <div className="flex flex-wrap gap-2"> - {attachments.map((file, index) => ( - <div key={index} className="flex items-center bg-background rounded-md p-1 pr-2 text-xs"> - {file.type.startsWith("image/") ? ( - <ImageIcon className="h-4 w-4 mr-1 text-blue-500" /> - ) : ( - <File className="h-4 w-4 mr-1 text-gray-500" /> - )} - <span className="truncate max-w-[100px]">{file.name}</span> - <Button - variant="ghost" - size="icon" - className="h-4 w-4 ml-1 p-0" - onClick={() => handleRemoveFile(index)} - > - <X className="h-3 w-3" /> - </Button> - </div> - ))} - </div> - </div> - )} - - {/* 메시지 입력 영역 */} - <div className="p-4 border-t flex-shrink-0"> - <div className="flex gap-2 items-end"> - <div className="flex-1"> - <Textarea - placeholder="메시지를 입력하세요..." - className="min-h-[80px] resize-none" - value={newComment} - onChange={(e) => setNewComment(e.target.value)} - /> - </div> - <div className="flex flex-col gap-2"> - <input - type="file" - ref={fileInputRef} - className="hidden" - multiple - onChange={handleFileChange} - /> - <Button - variant="outline" - size="icon" - onClick={handleFileSelect} - title="파일 첨부" - > - <Paperclip className="h-4 w-4" /> - </Button> - <Button - onClick={handleSubmitComment} - disabled={(!newComment.trim() && attachments.length === 0) || isSubmitting} - > - <Send className="h-4 w-4" /> - </Button> - </div> - </div> - </div> - </div> - - <DrawerFooter className="border-t flex-shrink-0"> - <div className="flex justify-between items-center"> - <div className="flex items-center gap-2"> - <Button variant="outline" onClick={() => loadComments()}> - 새로고침 - </Button> - <Button - variant={autoRefresh ? "default" : "outline"} - size="sm" - onClick={toggleAutoRefresh} - className="gap-2" - > - {autoRefresh ? ( - <> - <div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" /> - 자동 새로고침 ON - </> - ) : ( - <> - <div className="w-2 h-2 bg-gray-400 rounded-full" /> - 자동 새로고침 OFF - </> - )} - </Button> - </div> - <DrawerClose asChild> - <Button variant="outline">닫기</Button> - </DrawerClose> - </div> - </DrawerFooter> - </DrawerContent> - - {renderAttachmentPreviewDialog()} - </Drawer> - ); +"use client"
+
+import * as React from "react"
+import { useState, useEffect, useRef } from "react"
+import { RfqDetailView } from "./rfq-detail-column"
+import { Button } from "@/components/ui/button"
+import { Textarea } from "@/components/ui/textarea"
+import { Avatar, AvatarFallback } from "@/components/ui/avatar"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+} from "@/components/ui/drawer"
+import { Badge } from "@/components/ui/badge"
+import { toast } from "sonner"
+import {
+ Send,
+ Paperclip,
+ DownloadCloud,
+ File,
+ FileText,
+ Image as ImageIcon,
+ AlertCircle,
+ X
+} from "lucide-react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { formatDateTime } from "@/lib/utils"
+import { formatFileSize } from "@/lib/utils" // formatFileSize 유틸리티 임포트
+import { fetchTechSalesVendorComments, markTechSalesMessagesAsRead } from "@/lib/techsales-rfq/service"
+
+// 타입 정의
+interface Comment {
+ id: number;
+ rfqId: number;
+ vendorId: number | null // null 허용으로 변경
+ userId?: number | null // null 허용으로 변경
+ content: string;
+ isVendorComment: boolean | null; // null 허용으로 변경
+ createdAt: Date;
+ updatedAt: Date;
+ userName?: string | null // null 허용으로 변경
+ vendorName?: string | null // null 허용으로 변경
+ attachments: Attachment[];
+ isRead: boolean | null // null 허용으로 변경
+}
+
+interface Attachment {
+ id: number;
+ fileName: string;
+ originalFileName: string;
+ fileSize: number;
+ fileType: string | null;
+ filePath: string;
+ uploadedAt: Date;
+}
+
+// 프롭스 정의
+interface VendorCommunicationDrawerProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ selectedRfq: {
+ id: number;
+ rfqCode: string | null;
+ status: string;
+ [key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any
+ } | null;
+ selectedVendor: RfqDetailView | null;
+ onSuccess?: () => void;
+}
+
+async function sendComment(params: {
+ rfqId: number;
+ vendorId: number;
+ content: string;
+ attachments?: File[];
+}): Promise<Comment> {
+ try {
+ // 폼 데이터 생성 (파일 첨부를 위해)
+ const formData = new FormData();
+ formData.append('rfqId', params.rfqId.toString());
+ formData.append('vendorId', params.vendorId.toString());
+ formData.append('content', params.content);
+ formData.append('isVendorComment', 'false');
+
+ // 첨부파일 추가
+ if (params.attachments && params.attachments.length > 0) {
+ params.attachments.forEach((file) => {
+ formData.append(`attachments`, file);
+ });
+ }
+
+ // API 엔드포인트 구성 - techSales용으로 변경
+ const url = `/api/tech-sales-rfqs/${params.rfqId}/vendors/${params.vendorId}/comments`;
+
+ // API 호출
+ const response = await fetch(url, {
+ method: 'POST',
+ body: formData, // multipart/form-data 형식 사용
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(`API 요청 실패: ${response.status} ${errorText}`);
+ }
+
+ // 응답 데이터 파싱
+ const result = await response.json();
+
+ if (!result.success || !result.data) {
+ throw new Error(result.message || '코멘트 전송 중 오류가 발생했습니다');
+ }
+
+ return result.data.comment;
+ } catch (error) {
+ console.error('코멘트 전송 오류:', error);
+ throw error;
+ }
+}
+
+export function VendorCommunicationDrawer({
+ open,
+ onOpenChange,
+ selectedRfq,
+ selectedVendor,
+ onSuccess
+}: VendorCommunicationDrawerProps) {
+ // 상태 관리
+ const [comments, setComments] = useState<Comment[]>([]);
+ const [newComment, setNewComment] = useState("");
+ const [attachments, setAttachments] = useState<File[]>([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const fileInputRef = useRef<HTMLInputElement>(null);
+ const messagesEndRef = useRef<HTMLDivElement>(null);
+
+ // 자동 새로고침 관련 상태
+ const [autoRefresh, setAutoRefresh] = useState(true);
+ const [lastMessageCount, setLastMessageCount] = useState(0);
+ const intervalRef = useRef<NodeJS.Timeout | null>(null);
+
+ // 첨부파일 관련 상태
+ const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
+ const [selectedAttachment, setSelectedAttachment] = useState<Attachment | null>(null);
+
+ // 드로어가 열릴 때 데이터 로드
+ useEffect(() => {
+ if (open && selectedRfq && selectedVendor) {
+ loadComments();
+ // 자동 새로고침 시작
+ if (autoRefresh) {
+ startAutoRefresh();
+ }
+ } else {
+ // 드로어가 닫히면 자동 새로고침 중지
+ stopAutoRefresh();
+ }
+
+ // 컴포넌트 언마운트 시 정리
+ return () => {
+ stopAutoRefresh();
+ };
+ }, [open, selectedRfq, selectedVendor, autoRefresh]);
+
+ // 스크롤 최하단으로 이동
+ useEffect(() => {
+ if (messagesEndRef.current) {
+ messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
+ }
+ }, [comments]);
+
+ // 자동 새로고침 시작
+ const startAutoRefresh = () => {
+ stopAutoRefresh(); // 기존 interval 정리
+ intervalRef.current = setInterval(() => {
+ if (open && selectedRfq && selectedVendor && !isSubmitting) {
+ loadComments(true); // 자동 새로고침임을 표시
+ }
+ }, 60000); // 60초마다 새로고침
+ };
+
+ // 자동 새로고침 중지
+ const stopAutoRefresh = () => {
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current);
+ intervalRef.current = null;
+ }
+ };
+
+ // 자동 새로고침 토글
+ const toggleAutoRefresh = () => {
+ setAutoRefresh(prev => {
+ const newValue = !prev;
+ if (newValue && open) {
+ startAutoRefresh();
+ } else {
+ stopAutoRefresh();
+ }
+ return newValue;
+ });
+ };
+
+ // 코멘트 로드 함수 (자동 새로고침 여부 파라미터 추가)
+ const loadComments = async (isAutoRefresh = false) => {
+ if (!selectedRfq || !selectedVendor) return;
+
+ try {
+ // 자동 새로고침일 때는 로딩 표시하지 않음
+ if (!isAutoRefresh) {
+ setIsLoading(true);
+ }
+
+ // Server Action을 사용하여 코멘트 데이터 가져오기
+ const commentsData = await fetchTechSalesVendorComments(selectedRfq.id, selectedVendor.vendorId || 0);
+
+ // 새 메시지가 있는지 확인 (자동 새로고침일 때만)
+ if (isAutoRefresh) {
+ const newMessageCount = commentsData.length;
+ if (newMessageCount > lastMessageCount && lastMessageCount > 0) {
+ // 새 메시지 알림 (선택사항)
+ toast.success(`새 메시지 ${newMessageCount - lastMessageCount}개가 도착했습니다`);
+ }
+ setLastMessageCount(newMessageCount);
+ } else {
+ setLastMessageCount(commentsData.length);
+ }
+
+ setComments(commentsData as Comment[]); // 구체적인 타입으로 캐스팅
+
+ // Server Action을 사용하여 읽지 않은 메시지를 읽음 상태로 변경
+ await markTechSalesMessagesAsRead(selectedRfq.id, selectedVendor.vendorId || 0);
+ } catch (error) {
+ console.error("코멘트 로드 오류:", error);
+ if (!isAutoRefresh) { // 자동 새로고침일 때는 에러 토스트 표시하지 않음
+ toast.error("메시지를 불러오는 중 오류가 발생했습니다");
+ }
+ } finally {
+ // 항상 로딩 상태를 해제하되, 최소 200ms는 유지하여 깜빡거림 방지
+ if (!isAutoRefresh) {
+ setTimeout(() => {
+ setIsLoading(false);
+ }, 200);
+ }
+ }
+ };
+
+ // 파일 선택 핸들러
+ const handleFileSelect = () => {
+ fileInputRef.current?.click();
+ };
+
+ // 파일 변경 핸들러
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ if (e.target.files && e.target.files.length > 0) {
+ const newFiles = Array.from(e.target.files);
+ setAttachments(prev => [...prev, ...newFiles]);
+ }
+ };
+
+ // 파일 제거 핸들러
+ const handleRemoveFile = (index: number) => {
+ setAttachments(prev => prev.filter((_, i) => i !== index));
+ };
+
+ console.log(newComment)
+
+ // 코멘트 전송 핸들러
+ const handleSubmitComment = async () => {
+ console.log("버튼 클릭1", selectedRfq,selectedVendor, selectedVendor?.vendorId )
+ console.log(!newComment.trim() && attachments.length === 0)
+
+ if (!newComment.trim() && attachments.length === 0) return;
+ if (!selectedRfq || !selectedVendor || !selectedVendor.vendorId) return;
+
+ console.log("버튼 클릭")
+
+ try {
+ setIsSubmitting(true);
+
+ // API를 사용하여 새 코멘트 전송 (파일 업로드 때문에 FormData 사용)
+ const newCommentObj = await sendComment({
+ rfqId: selectedRfq.id,
+ vendorId: selectedVendor.vendorId,
+ content: newComment,
+ attachments: attachments
+ });
+
+ // 상태 업데이트
+ setComments(prev => [...prev, newCommentObj]);
+ setNewComment("");
+ setAttachments([]);
+
+ toast.success("메시지가 전송되었습니다");
+
+ // 데이터 새로고침
+ if (onSuccess) {
+ onSuccess();
+ }
+ } catch (error) {
+ console.error("코멘트 전송 오류:", error);
+ toast.error("메시지 전송 중 오류가 발생했습니다");
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ // 첨부파일 미리보기
+ const handleAttachmentPreview = (attachment: Attachment) => {
+ setSelectedAttachment(attachment);
+ setPreviewDialogOpen(true);
+ };
+
+ // 첨부파일 다운로드
+ const handleAttachmentDownload = (attachment: Attachment) => {
+ // TODO: 실제 다운로드 구현
+ window.open(attachment.filePath, '_blank');
+ };
+
+ // 파일 아이콘 선택
+ const getFileIcon = (fileType: string) => {
+ if (fileType.startsWith("image/")) return <ImageIcon className="h-5 w-5 text-blue-500" />;
+ if (fileType.includes("pdf")) return <FileText className="h-5 w-5 text-red-500" />;
+ if (fileType.includes("spreadsheet") || fileType.includes("excel"))
+ return <FileText className="h-5 w-5 text-green-500" />;
+ if (fileType.includes("document") || fileType.includes("word"))
+ return <FileText className="h-5 w-5 text-blue-500" />;
+ return <File className="h-5 w-5 text-gray-500" />;
+ };
+
+ // 첨부파일 미리보기 다이얼로그
+ const renderAttachmentPreviewDialog = () => {
+ if (!selectedAttachment) return null;
+
+ const isImage = selectedAttachment.fileType?.startsWith("image/");
+ const isPdf = selectedAttachment.fileType?.includes("pdf");
+
+ return (
+ <Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
+ <DialogContent className="max-w-3xl">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ {getFileIcon(selectedAttachment.fileType || '')}
+ {selectedAttachment.originalFileName || selectedAttachment.fileName}
+ </DialogTitle>
+ <DialogDescription>
+ {formatFileSize(selectedAttachment.fileSize)} • {formatDateTime(selectedAttachment.uploadedAt, "KR")}
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="min-h-[300px] flex items-center justify-center p-4">
+ {isImage ? (
+ // eslint-disable-next-line @next/next/no-img-element
+ <img
+ src={selectedAttachment.filePath}
+ alt={selectedAttachment.originalFileName || selectedAttachment.fileName}
+ className="max-h-[500px] max-w-full object-contain"
+ />
+ ) : isPdf ? (
+ <iframe
+ src={`${selectedAttachment.filePath}#toolbar=0`}
+ className="w-full h-[500px]"
+ title={selectedAttachment.originalFileName || selectedAttachment.fileName}
+ />
+ ) : (
+ <div className="flex flex-col items-center gap-4 p-8">
+ {getFileIcon(selectedAttachment.fileType || '')}
+ <p className="text-muted-foreground text-sm">미리보기를 지원하지 않는 파일 형식입니다.</p>
+ <Button
+ variant="outline"
+ onClick={() => handleAttachmentDownload(selectedAttachment)}
+ >
+ <DownloadCloud className="h-4 w-4 mr-2" />
+ 다운로드
+ </Button>
+ </div>
+ )}
+ </div>
+ </DialogContent>
+ </Dialog>
+ );
+ };
+
+ if (!selectedRfq || !selectedVendor) {
+ return null;
+ }
+
+ return (
+ <Drawer open={open} onOpenChange={onOpenChange}>
+ <DrawerContent className="max-h-[80vh] flex flex-col">
+ <DrawerHeader className="border-b flex-shrink-0">
+ <DrawerTitle className="flex items-center gap-2">
+ <Avatar className="h-8 w-8">
+ <AvatarFallback className="bg-primary/10">
+ {selectedVendor.vendorName?.[0] || 'V'}
+ </AvatarFallback>
+ </Avatar>
+ <div>
+ <span>{selectedVendor.vendorName}</span>
+ <Badge variant="outline" className="ml-2">{selectedVendor.vendorCode}</Badge>
+ </div>
+ </DrawerTitle>
+ <DrawerDescription>
+ RFQ: {selectedRfq.rfqCode} • 프로젝트: {selectedRfq.projectName}
+ </DrawerDescription>
+ </DrawerHeader>
+
+ <div className="flex flex-col flex-1 min-h-0">
+ {/* 메시지 목록 */}
+ <div className="flex-1 p-4 overflow-y-auto min-h-[300px]">
+ {isLoading && comments.length === 0 ? (
+ <div className="flex h-full items-center justify-center">
+ <p className="text-muted-foreground">메시지 로딩 중...</p>
+ </div>
+ ) : comments.length === 0 ? (
+ <div className="flex h-full items-center justify-center">
+ <div className="flex flex-col items-center gap-2">
+ <AlertCircle className="h-6 w-6 text-muted-foreground" />
+ <p className="text-muted-foreground">아직 메시지가 없습니다</p>
+ </div>
+ </div>
+ ) : (
+ <div className="space-y-4 relative">
+ {isLoading && (
+ <div className="absolute top-0 right-0 z-10 bg-background/80 backdrop-blur-sm rounded-md px-2 py-1">
+ <div className="flex items-center gap-2">
+ <div className="w-2 h-2 bg-primary rounded-full animate-pulse" />
+ <span className="text-xs text-muted-foreground">새로고침 중...</span>
+ </div>
+ </div>
+ )}
+ {comments.map(comment => (
+ <div
+ key={comment.id}
+ className={`flex gap-3 ${comment.isVendorComment ? 'justify-start' : 'justify-end'}`}
+ >
+ {comment.isVendorComment && (
+ <Avatar className="h-8 w-8 mt-1">
+ <AvatarFallback className="bg-primary/10">
+ {comment.vendorName?.[0] || 'V'}
+ </AvatarFallback>
+ </Avatar>
+ )}
+
+ <div className={`rounded-lg p-3 max-w-[80%] ${
+ comment.isVendorComment
+ ? 'bg-muted'
+ : 'bg-primary text-primary-foreground'
+ }`}>
+ <div className="text-sm font-medium mb-1">
+ {comment.isVendorComment ? comment.vendorName : comment.userName}
+ </div>
+
+ {comment.content && (
+ <div className="text-sm whitespace-pre-wrap break-words">
+ {comment.content}
+ </div>
+ )}
+
+ {/* 첨부파일 표시 */}
+ {comment.attachments.length > 0 && (
+ <div className={`mt-2 pt-2 ${
+ comment.isVendorComment
+ ? 'border-t border-t-border/30'
+ : 'border-t border-t-primary-foreground/20'
+ }`}>
+ {comment.attachments.map(attachment => (
+ <div
+ key={attachment.id}
+ className="flex items-center text-xs gap-2 mb-1 p-1 rounded hover:bg-black/5 cursor-pointer"
+ onClick={() => handleAttachmentPreview(attachment)}
+ >
+ {getFileIcon(attachment.fileType || '')}
+ <span className="flex-1 truncate">{attachment.originalFileName || attachment.fileName}</span>
+ <span className="text-xs opacity-70">
+ {formatFileSize(attachment.fileSize)}
+ </span>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-6 w-6 rounded-full"
+ onClick={(e) => {
+ e.stopPropagation();
+ handleAttachmentDownload(attachment);
+ }}
+ >
+ <DownloadCloud className="h-3 w-3" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ )}
+
+ <div className="text-xs mt-1 opacity-70 flex items-center gap-1 justify-end">
+ {formatDateTime(comment.createdAt)}
+ </div>
+ </div>
+
+ {!comment.isVendorComment && (
+ <Avatar className="h-8 w-8 mt-1">
+ <AvatarFallback className="bg-primary/20">
+ {comment.userName?.[0] || 'U'}
+ </AvatarFallback>
+ </Avatar>
+ )}
+ </div>
+ ))}
+ <div ref={messagesEndRef} />
+ </div>
+ )}
+ </div>
+
+ {/* 선택된 첨부파일 표시 */}
+ {attachments.length > 0 && (
+ <div className="p-2 bg-muted mx-4 rounded-md mb-2 flex-shrink-0">
+ <div className="text-xs font-medium mb-1">첨부파일</div>
+ <div className="flex flex-wrap gap-2">
+ {attachments.map((file, index) => (
+ <div key={index} className="flex items-center bg-background rounded-md p-1 pr-2 text-xs">
+ {file.type.startsWith("image/") ? (
+ <ImageIcon className="h-4 w-4 mr-1 text-blue-500" />
+ ) : (
+ <File className="h-4 w-4 mr-1 text-gray-500" />
+ )}
+ <span className="truncate max-w-[100px]">{file.name}</span>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-4 w-4 ml-1 p-0"
+ onClick={() => handleRemoveFile(index)}
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* 메시지 입력 영역 */}
+ <div className="p-4 border-t flex-shrink-0">
+ <div className="flex gap-2 items-end">
+ <div className="flex-1">
+ <Textarea
+ placeholder="메시지를 입력하세요..."
+ className="min-h-[80px] resize-none"
+ value={newComment}
+ onChange={(e) => setNewComment(e.target.value)}
+ />
+ </div>
+ <div className="flex flex-col gap-2">
+ <input
+ type="file"
+ ref={fileInputRef}
+ className="hidden"
+ multiple
+ onChange={handleFileChange}
+ />
+ <Button
+ variant="outline"
+ size="icon"
+ onClick={handleFileSelect}
+ title="파일 첨부"
+ >
+ <Paperclip className="h-4 w-4" />
+ </Button>
+ <Button
+ onClick={handleSubmitComment}
+ disabled={(!newComment.trim() && attachments.length === 0) || isSubmitting}
+ >
+ <Send className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <DrawerFooter className="border-t flex-shrink-0">
+ <div className="flex justify-between items-center">
+ <div className="flex items-center gap-2">
+ <Button variant="outline" onClick={() => loadComments()}>
+ 새로고침
+ </Button>
+ <Button
+ variant={autoRefresh ? "default" : "outline"}
+ size="sm"
+ onClick={toggleAutoRefresh}
+ className="gap-2"
+ >
+ {autoRefresh ? (
+ <>
+ <div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
+ 자동 새로고침 ON
+ </>
+ ) : (
+ <>
+ <div className="w-2 h-2 bg-gray-400 rounded-full" />
+ 자동 새로고침 OFF
+ </>
+ )}
+ </Button>
+ </div>
+ <DrawerClose asChild>
+ <Button variant="outline">닫기</Button>
+ </DrawerClose>
+ </div>
+ </DrawerFooter>
+ </DrawerContent>
+
+ {renderAttachmentPreviewDialog()}
+ </Drawer>
+ );
}
\ No newline at end of file diff --git a/lib/techsales-rfq/table/detail-table/vendor-contact-selection-dialog.tsx b/lib/techsales-rfq/table/detail-table/vendor-contact-selection-dialog.tsx new file mode 100644 index 00000000..aa6f6c2f --- /dev/null +++ b/lib/techsales-rfq/table/detail-table/vendor-contact-selection-dialog.tsx @@ -0,0 +1,343 @@ +"use client"
+
+import * as React from "react"
+import { useState, useEffect, useCallback } from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import { Badge } from "@/components/ui/badge"
+import { Skeleton } from "@/components/ui/skeleton"
+import { Mail, Phone, User, Send, Loader2 } from "lucide-react"
+import { toast } from "sonner"
+
+interface VendorContact {
+ id: number
+ contactName: string
+ contactPosition: string | null
+ contactEmail: string
+ contactPhone: string | null
+ isPrimary: boolean
+}
+
+interface VendorWithContacts {
+ vendor: {
+ id: number
+ vendorName: string
+ vendorCode: string | null
+ }
+ contacts: VendorContact[]
+}
+
+interface SelectedContact {
+ vendorId: number
+ contactId: number
+ contactEmail: string
+ contactName: string
+}
+
+interface VendorContactSelectionDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ vendorIds: number[]
+ onSendRfq: (selectedContacts: SelectedContact[]) => Promise<void>
+}
+
+export function VendorContactSelectionDialog({
+ open,
+ onOpenChange,
+ vendorIds,
+ onSendRfq
+}: VendorContactSelectionDialogProps) {
+ const [vendorsWithContacts, setVendorsWithContacts] = useState<Record<number, VendorWithContacts>>({})
+ const [selectedContacts, setSelectedContacts] = useState<SelectedContact[]>([])
+ const [isLoading, setIsLoading] = useState(false)
+ const [isSending, setIsSending] = useState(false)
+
+ // 벤더 contact 정보 조회
+ useEffect(() => {
+ if (open && vendorIds.length > 0) {
+ loadVendorsContacts()
+ }
+ }, [open, vendorIds])
+
+ // 다이얼로그 닫힐 때 상태 초기화
+ useEffect(() => {
+ if (!open) {
+ setVendorsWithContacts({})
+ setSelectedContacts([])
+ setIsLoading(false)
+ }
+ }, [open])
+
+ const loadVendorsContacts = useCallback(async () => {
+ try {
+ setIsLoading(true)
+ const { getTechVendorsContacts } = await import("@/lib/techsales-rfq/service")
+
+ const result = await getTechVendorsContacts(vendorIds)
+
+ if (result.error) {
+ toast.error(result.error)
+ return
+ }
+
+ setVendorsWithContacts(result.data)
+
+ // 기본 선택: 모든 contact 선택
+ const defaultSelected: SelectedContact[] = []
+ Object.values(result.data).forEach(vendorData => {
+ vendorData.contacts.forEach(contact => {
+ defaultSelected.push({
+ vendorId: vendorData.vendor.id,
+ contactId: contact.id,
+ contactEmail: contact.contactEmail,
+ contactName: contact.contactName
+ })
+ })
+ })
+ setSelectedContacts(defaultSelected)
+
+ } catch (error) {
+ console.error("벤더 contact 조회 오류:", error)
+ toast.error("벤더 연락처를 불러오는 중 오류가 발생했습니다")
+ } finally {
+ setIsLoading(false)
+ }
+ }, [vendorIds])
+
+ // contact 선택/해제 핸들러
+ const handleContactToggle = (vendorId: number, contact: VendorContact) => {
+ const isSelected = selectedContacts.some(
+ sc => sc.vendorId === vendorId && sc.contactId === contact.id
+ )
+
+ if (isSelected) {
+ // 선택 해제
+ setSelectedContacts(prev =>
+ prev.filter(sc => !(sc.vendorId === vendorId && sc.contactId === contact.id))
+ )
+ } else {
+ // 선택 추가
+ setSelectedContacts(prev => [
+ ...prev,
+ {
+ vendorId,
+ contactId: contact.id,
+ contactEmail: contact.contactEmail,
+ contactName: contact.contactName
+ }
+ ])
+ }
+ }
+
+ // 벤더별 전체 선택/해제
+ const handleVendorToggle = (vendorId: number, vendorData: VendorWithContacts) => {
+ const vendorContacts = vendorData.contacts
+ const selectedVendorContacts = selectedContacts.filter(sc => sc.vendorId === vendorId)
+
+ if (selectedVendorContacts.length === vendorContacts.length) {
+ // 전체 해제
+ setSelectedContacts(prev => prev.filter(sc => sc.vendorId !== vendorId))
+ } else {
+ // 전체 선택
+ const newSelected = vendorContacts.map(contact => ({
+ vendorId,
+ contactId: contact.id,
+ contactEmail: contact.contactEmail,
+ contactName: contact.contactName
+ }))
+
+ setSelectedContacts(prev => [
+ ...prev.filter(sc => sc.vendorId !== vendorId),
+ ...newSelected
+ ])
+ }
+ }
+
+ // RFQ 발송 핸들러
+ const handleSendRfq = async () => {
+ if (selectedContacts.length === 0) {
+ toast.warning("발송할 연락처를 선택해주세요.")
+ return
+ }
+
+ try {
+ setIsSending(true)
+ await onSendRfq(selectedContacts)
+ onOpenChange(false)
+ } catch (error) {
+ console.error("RFQ 발송 오류:", error)
+ } finally {
+ setIsSending(false)
+ }
+ }
+
+ // 선택된 contact가 있는지 확인
+ const isContactSelected = (vendorId: number, contactId: number) => {
+ return selectedContacts.some(sc => sc.vendorId === vendorId && sc.contactId === contactId)
+ }
+
+ // 벤더별 선택 상태 확인
+ const getVendorSelectionState = (vendorId: number, vendorData: VendorWithContacts) => {
+ const selectedVendorContacts = selectedContacts.filter(sc => sc.vendorId === vendorId)
+ const totalContacts = vendorData.contacts.length
+
+ if (selectedVendorContacts.length === 0) return "none"
+ if (selectedVendorContacts.length === totalContacts) return "all"
+ return "partial"
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col">
+ <DialogHeader>
+ <DialogTitle>RFQ 발송 대상 선택</DialogTitle>
+ <DialogDescription>
+ 각 벤더의 연락처를 선택하여 RFQ를 발송하세요. 기본적으로 모든 연락처가 선택되어 있습니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="flex-1 overflow-y-auto space-y-4">
+ {isLoading ? (
+ <div className="space-y-4">
+ {[1, 2, 3].map((i) => (
+ <div key={i} className="space-y-3">
+ <Skeleton className="h-6 w-40" />
+ <div className="space-y-2 pl-4">
+ <Skeleton className="h-16 w-full" />
+ <Skeleton className="h-16 w-full" />
+ </div>
+ </div>
+ ))}
+ </div>
+ ) : Object.keys(vendorsWithContacts).length === 0 ? (
+ <div className="text-center py-8 text-muted-foreground">
+ <Mail className="size-12 mx-auto mb-2 opacity-50" />
+ <p>연락처 정보가 없습니다.</p>
+ <p className="text-sm">벤더의 연락처를 먼저 등록해주세요.</p>
+ </div>
+ ) : (
+ Object.entries(vendorsWithContacts).map(([vendorId, vendorData]) => {
+ const selectionState = getVendorSelectionState(Number(vendorId), vendorData)
+
+ return (
+ <div key={vendorId} className="border rounded-lg p-4">
+ <div className="flex items-center justify-between mb-3">
+ <div className="flex items-center gap-3">
+ <Checkbox
+ checked={selectionState === "all"}
+ ref={(el) => {
+ if (el) {
+ const input = el.querySelector('input[type="checkbox"]') as HTMLInputElement
+ if (input) {
+ input.indeterminate = selectionState === "partial"
+ }
+ }
+ }}
+ onCheckedChange={() => handleVendorToggle(Number(vendorId), vendorData)}
+ />
+ <div>
+ <h3 className="font-medium">{vendorData.vendor.vendorName}</h3>
+ {vendorData.vendor.vendorCode && (
+ <p className="text-sm text-muted-foreground">
+ 코드: {vendorData.vendor.vendorCode}
+ </p>
+ )}
+ </div>
+ </div>
+ <Badge variant="outline">
+ {selectedContacts.filter(sc => sc.vendorId === Number(vendorId)).length} / {vendorData.contacts.length} 선택됨
+ </Badge>
+ </div>
+
+ <div className="space-y-2 pl-6">
+ {vendorData.contacts.map((contact) => (
+ <div
+ key={contact.id}
+ className={`flex items-center justify-between p-3 rounded border ${
+ isContactSelected(Number(vendorId), contact.id)
+ ? "bg-blue-50 border-blue-200"
+ : "bg-gray-50 border-gray-200"
+ }`}
+ >
+ <div className="flex items-center gap-3">
+ <Checkbox
+ checked={isContactSelected(Number(vendorId), contact.id)}
+ onCheckedChange={() => handleContactToggle(Number(vendorId), contact)}
+ />
+ <div className="flex items-center gap-2">
+ <User className="size-4 text-muted-foreground" />
+ <div>
+ <div className="flex items-center gap-2">
+ <span className="font-medium">{contact.contactName}</span>
+ </div>
+ {contact.contactPosition && (
+ <p className="text-sm text-muted-foreground">
+ {contact.contactPosition}
+ </p>
+ )}
+ </div>
+ </div>
+ </div>
+
+ <div className="flex items-center gap-4 text-sm">
+ <div className="flex items-center gap-1">
+ <Mail className="size-4 text-muted-foreground" />
+ <span>{contact.contactEmail}</span>
+ </div>
+ {contact.contactPhone && (
+ <div className="flex items-center gap-1">
+ <Phone className="size-4 text-muted-foreground" />
+ <span>{contact.contactPhone}</span>
+ </div>
+ )}
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )
+ })
+ )}
+ </div>
+
+ <DialogFooter>
+ <div className="flex items-center justify-between w-full">
+ <div className="text-sm text-muted-foreground">
+ 총 {selectedContacts.length}명의 연락처가 선택됨
+ </div>
+ <div className="flex gap-2">
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ 취소
+ </Button>
+ <Button
+ onClick={handleSendRfq}
+ disabled={selectedContacts.length === 0 || isSending}
+ className="flex items-center gap-2"
+ >
+ {isSending ? (
+ <>
+ <Loader2 className="size-4 animate-spin" />
+ 발송 중...
+ </>
+ ) : (
+ <>
+ <Send className="size-4" />
+ RFQ 발송 ({selectedContacts.length}명)
+ </>
+ )}
+ </Button>
+ </div>
+ </div>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
\ No newline at end of file diff --git a/lib/techsales-rfq/table/project-detail-dialog.tsx b/lib/techsales-rfq/table/project-detail-dialog.tsx index 68f13960..00202501 100644 --- a/lib/techsales-rfq/table/project-detail-dialog.tsx +++ b/lib/techsales-rfq/table/project-detail-dialog.tsx @@ -1,120 +1,120 @@ -"use client" - -import * as React from "react" -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" - -// 기본적인 RFQ 타입 정의 (rfq-table.tsx와 일치) -interface 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" - picCode: string | null - remark: string | null - cancelReason: string | null - createdAt: Date - updatedAt: Date - createdBy: number | null - createdByName: string - updatedBy: number | null - updatedByName: string - sentBy: number | null - sentByName: string | null - pspid: string - projNm: string - sector: string - projMsrm: number - ptypeNm: string - attachmentCount: number - quotationCount: number -} - -interface ProjectDetailDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - selectedRfq: TechSalesRfq | null -} - -export function ProjectDetailDialog({ - open, - onOpenChange, - selectedRfq, -}: ProjectDetailDialogProps) { - if (!selectedRfq) { - return null - } - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="max-w-4xl w-[80vw] max-h-[80vh] overflow-hidden flex flex-col"> - <DialogHeader className="border-b pb-4"> - <DialogTitle className="flex items-center gap-2"> - 프로젝트 상세정보 - <Badge variant="outline">{selectedRfq.pspid}</Badge> - </DialogTitle> - <DialogDescription className="space-y-1"> - <div className="flex items-center gap-2 text-base font-medium"> - <span>RFQ:</span> - <Badge variant="secondary">{selectedRfq.rfqCode || "미할당"}</Badge> - <span>|</span> - <span>자재:</span> - <span className="text-foreground">{selectedRfq.materialCode || "N/A"}</span> - </div> - <div className="text-sm text-muted-foreground"> - {selectedRfq.projNm} - {selectedRfq.ptypeNm} ({selectedRfq.itemName || "자재명 없음"}) - </div> - </DialogDescription> - </DialogHeader> - <div className="space-y-6 p-1 overflow-y-auto"> - {/* 기본 프로젝트 정보 */} - <div className="space-y-4"> - <h3 className="text-lg font-semibold">기본 정보</h3> - <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">프로젝트 ID</div> - <div className="text-sm">{selectedRfq.pspid}</div> - </div> - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">프로젝트명</div> - <div className="text-sm">{selectedRfq.projNm}</div> - </div> - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">선종</div> - <div className="text-sm">{selectedRfq.ptypeNm}</div> - </div> - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">척수</div> - <div className="text-sm">{selectedRfq.projMsrm}</div> - </div> - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">섹터</div> - <div className="text-sm">{selectedRfq.sector}</div> - </div> - </div> - </div> - </div> - - {/* 닫기 버튼 */} - <div className="sticky bottom-0 left-0 z-20 bg-background border-t pt-4 mt-4"> - <div className="flex justify-end"> - <Button variant="outline" onClick={() => onOpenChange(false)}> - 닫기 - </Button> - </div> - </div> - </DialogContent> - </Dialog> - ) +"use client"
+
+import * as React from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+
+// 기본적인 RFQ 타입 정의 (rfq-table.tsx와 일치)
+interface 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"
+ picCode: string | null
+ remark: string | null
+ cancelReason: string | null
+ createdAt: Date
+ updatedAt: Date
+ createdBy: number | null
+ createdByName: string
+ updatedBy: number | null
+ updatedByName: string
+ sentBy: number | null
+ sentByName: string | null
+ pspid: string
+ projNm: string
+ sector: string
+ projMsrm: number
+ ptypeNm: string
+ attachmentCount: number
+ quotationCount: number
+}
+
+interface ProjectDetailDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ selectedRfq: TechSalesRfq | null
+}
+
+export function ProjectDetailDialog({
+ open,
+ onOpenChange,
+ selectedRfq,
+}: ProjectDetailDialogProps) {
+ if (!selectedRfq) {
+ return null
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl w-[80vw] max-h-[80vh] overflow-hidden flex flex-col">
+ <DialogHeader className="border-b pb-4">
+ <DialogTitle className="flex items-center gap-2">
+ 프로젝트 상세정보
+ <Badge variant="outline">{selectedRfq.pspid}</Badge>
+ </DialogTitle>
+ <DialogDescription className="space-y-1">
+ <div className="flex items-center gap-2 text-base font-medium">
+ <span>RFQ:</span>
+ <Badge variant="secondary">{selectedRfq.rfqCode || "미할당"}</Badge>
+ <span>|</span>
+ <span>자재:</span>
+ <span className="text-foreground">{selectedRfq.materialCode || "N/A"}</span>
+ </div>
+ <div className="text-sm text-muted-foreground">
+ {selectedRfq.projNm} - {selectedRfq.ptypeNm} ({selectedRfq.itemName || "자재명 없음"})
+ </div>
+ </DialogDescription>
+ </DialogHeader>
+ <div className="space-y-6 p-1 overflow-y-auto">
+ {/* 기본 프로젝트 정보 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-semibold">기본 정보</h3>
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">프로젝트 ID</div>
+ <div className="text-sm">{selectedRfq.pspid}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">프로젝트명</div>
+ <div className="text-sm">{selectedRfq.projNm}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">선종</div>
+ <div className="text-sm">{selectedRfq.ptypeNm}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">척수</div>
+ <div className="text-sm">{selectedRfq.projMsrm}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">섹터</div>
+ <div className="text-sm">{selectedRfq.sector}</div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* 닫기 버튼 */}
+ <div className="sticky bottom-0 left-0 z-20 bg-background border-t pt-4 mt-4">
+ <div className="flex justify-end">
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ 닫기
+ </Button>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
}
\ No newline at end of file diff --git a/lib/techsales-rfq/table/rfq-filter-sheet.tsx b/lib/techsales-rfq/table/rfq-filter-sheet.tsx index 9b6acfb2..a03e6167 100644 --- a/lib/techsales-rfq/table/rfq-filter-sheet.tsx +++ b/lib/techsales-rfq/table/rfq-filter-sheet.tsx @@ -1,759 +1,759 @@ -"use client" - -import { useEffect, useTransition, useState, useRef } from "react" -import { useRouter, useParams } from "next/navigation" -import { z } from "zod" -import { useForm } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import { Search, X } from "lucide-react" -import { customAlphabet } from "nanoid" -import { parseAsStringEnum, useQueryState } from "nuqs" - -import { Button } from "@/components/ui/button" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { Input } from "@/components/ui/input" -import { DateRangePicker } from "@/components/date-range-picker" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { cn } from "@/lib/utils" -import { useTranslation } from '@/i18n/client' -import { getFiltersStateParser } from "@/lib/parsers" - -// nanoid 생성기 -const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6) - -// 필터 스키마 정의 (TechSales RFQ에 맞게 수정) -const filterSchema = z.object({ - rfqCode: z.string().optional(), - materialCode: z.string().optional(), - itemName: z.string().optional(), - pspid: z.string().optional(), - projNm: z.string().optional(), - ptypeNm: z.string().optional(), - createdByName: z.string().optional(), - status: z.string().optional(), - dateRange: z.object({ - from: z.date().optional(), - to: z.date().optional(), - }).optional(), -}) - -// 상태 옵션 정의 (TechSales RFQ 상태에 맞게 수정) -const statusOptions = [ - { value: "RFQ Created", label: "RFQ Created" }, - { value: "RFQ Vendor Assignned", label: "RFQ Vendor Assignned" }, - { value: "RFQ Sent", label: "RFQ Sent" }, - { value: "Quotation Analysis", label: "Quotation Analysis" }, - { value: "Closed", label: "Closed" }, -] - -type FilterFormValues = z.infer<typeof filterSchema> - -interface RFQFilterSheetProps { - isOpen: boolean; - onClose: () => void; - onSearch?: () => void; - isLoading?: boolean; -} - -// Updated component for inline use (not a sheet anymore) -export function RFQFilterSheet({ - isOpen, - onClose, - onSearch, - isLoading = false -}: RFQFilterSheetProps) { - const router = useRouter() - const params = useParams(); - const lng = params ? (params.lng as string) : 'ko'; - const { t } = useTranslation(lng); - - const [isPending, startTransition] = useTransition() - - // 초기화 상태 추가 - 폼 초기화 중에는 상태 변경을 방지 - const [isInitializing, setIsInitializing] = useState(false) - // 마지막으로 적용된 필터를 추적하기 위한 ref - const lastAppliedFilters = useRef<string>("") - - // nuqs로 URL 상태 관리 - 파라미터명을 'basicFilters'로 변경 - const [filters, setFilters] = useQueryState( - "basicFilters", - getFiltersStateParser().withDefault([]) - ) - - // joinOperator 설정 - const [joinOperator, setJoinOperator] = useQueryState( - "basicJoinOperator", - parseAsStringEnum(["and", "or"]).withDefault("and") - ) - - // 현재 URL의 페이지 파라미터도 가져옴 - const [page, setPage] = useQueryState("page", { defaultValue: "1" }) - - // 폼 상태 초기화 - const form = useForm<FilterFormValues>({ - resolver: zodResolver(filterSchema), - defaultValues: { - rfqCode: "", - materialCode: "", - itemName: "", - pspid: "", - projNm: "", - ptypeNm: "", - createdByName: "", - status: "", - dateRange: { - from: undefined, - to: undefined, - }, - }, - }) - - // URL 필터에서 초기 폼 상태 설정 - 개선된 버전 - useEffect(() => { - // 현재 필터를 문자열로 직렬화 - const currentFiltersString = JSON.stringify(filters); - - // 패널이 열렸고, 필터가 있고, 마지막에 적용된 필터와 다를 때만 업데이트 - if (isOpen && filters && filters.length > 0 && currentFiltersString !== lastAppliedFilters.current) { - setIsInitializing(true); - - const formValues = { ...form.getValues() }; - let formUpdated = false; - - filters.forEach(filter => { - if (filter.id === "rfqSendDate" && Array.isArray(filter.value) && filter.value.length > 0) { - formValues.dateRange = { - from: filter.value[0] ? new Date(filter.value[0]) : undefined, - to: filter.value[1] ? new Date(filter.value[1]) : undefined, - }; - formUpdated = true; - } else if (filter.id in formValues) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (formValues as any)[filter.id] = filter.value; - formUpdated = true; - } - }); - - // 폼 값이 변경된 경우에만 reset으로 한 번에 업데이트 - if (formUpdated) { - form.reset(formValues); - lastAppliedFilters.current = currentFiltersString; - } - - setIsInitializing(false); - } - }, [filters, isOpen, form]) // form 의존성 추가 - - // 현재 적용된 필터 카운트 - const getActiveFilterCount = () => { - return filters?.length || 0 - } - - // 조회 버튼 클릭 핸들러 - const handleSearch = () => { - // 필터 패널 닫기 로직이 있다면 여기에 추가 - if (onSearch) { - onSearch(); - } - } - - // 폼 제출 핸들러 - 개선된 버전 - async function onSubmit(data: FilterFormValues) { - // 초기화 중이면 제출 방지 - if (isInitializing) return; - - startTransition(async () => { - try { - // 필터 배열 생성 - const newFilters = [] - - if (data.rfqCode?.trim()) { - newFilters.push({ - id: "rfqCode", - value: data.rfqCode.trim(), - type: "text" as const, - operator: "iLike" as const, - rowId: generateId() - }) - } - - if (data.materialCode?.trim()) { - newFilters.push({ - id: "materialCode", - value: data.materialCode.trim(), - type: "text" as const, - operator: "iLike" as const, - rowId: generateId() - }) - } - - if (data.itemName?.trim()) { - newFilters.push({ - id: "itemName", - value: data.itemName.trim(), - type: "text" as const, - operator: "iLike" as const, - rowId: generateId() - }) - } - - if (data.pspid?.trim()) { - newFilters.push({ - id: "pspid", - value: data.pspid.trim(), - type: "text" as const, - operator: "iLike" as const, - rowId: generateId() - }) - } - - if (data.projNm?.trim()) { - newFilters.push({ - id: "projNm", - value: data.projNm.trim(), - type: "text" as const, - operator: "iLike" as const, - rowId: generateId() - }) - } - - if (data.ptypeNm?.trim()) { - newFilters.push({ - id: "ptypeNm", - value: data.ptypeNm.trim(), - type: "text" as const, - operator: "iLike" as const, - rowId: generateId() - }) - } - - if (data.createdByName?.trim()) { - newFilters.push({ - id: "createdByName", - value: data.createdByName.trim(), - type: "text" as const, - operator: "iLike" as const, - rowId: generateId() - }) - } - - if (data.status?.trim()) { - newFilters.push({ - id: "status", - value: data.status.trim(), - type: "select" as const, - operator: "eq" as const, - rowId: generateId() - }) - } - - // Add date range to params if it exists - if (data.dateRange?.from) { - newFilters.push({ - id: "rfqSendDate", - value: [ - data.dateRange.from.toISOString().split('T')[0], - data.dateRange.to ? data.dateRange.to.toISOString().split('T')[0] : undefined - ].filter(Boolean) as string[], - type: "date" as const, - operator: "isBetween" as const, - rowId: generateId() - }) - } - - console.log("기본 필터 적용:", newFilters); - - // 마지막 적용된 필터 업데이트 - lastAppliedFilters.current = JSON.stringify(newFilters); - - // 먼저 필터를 설정 - await setFilters(newFilters.length > 0 ? newFilters : null); - - // 그 다음 페이지를 1로 설정 - await setPage("1"); - - // 필터 업데이트 후 조회 핸들러 호출 (제공된 경우) - handleSearch(); - - // 페이지 새로고침으로 서버 데이터 다시 가져오기 - setTimeout(() => { - window.location.reload(); - }, 100); - } catch (error) { - console.error("필터 적용 오류:", error); - } - }) - } - - // 필터 초기화 핸들러 - 개선된 버전 - async function handleReset() { - try { - setIsInitializing(true); - - form.reset({ - rfqCode: "", - materialCode: "", - itemName: "", - pspid: "", - projNm: "", - ptypeNm: "", - createdByName: "", - status: "", - dateRange: { from: undefined, to: undefined }, - }); - - // 필터와 조인 연산자를 초기화 - await setFilters(null); - await setJoinOperator("and"); - await setPage("1"); - - // 마지막 적용된 필터 초기화 - lastAppliedFilters.current = ""; - - console.log("필터 초기화 완료"); - setIsInitializing(false); - - // 페이지 새로고침으로 서버 데이터 다시 가져오기 - setTimeout(() => { - window.location.reload(); - }, 100); - } catch (error) { - console.error("필터 초기화 오류:", error); - setIsInitializing(false); - } - } - - // Don't render if not open (for side panel use) - if (!isOpen) { - return null; - } - - return ( - <div className="flex flex-col h-full max-h-full p-4"> - {/* Filter Panel Header - 보더 제거, 배경 색상 적용 */} - <div className="flex items-center justify-between px-6 min-h-[60px] shrink-0"> - <h3 className="text-lg font-semibold whitespace-nowrap">검색 필터</h3> - </div> - - {/* Join Operator Selection - 보더 제거, 배경 색상 적용 */} - <div className="px-6 shrink-0"> - <label className="text-sm font-medium">조건 결합 방식</label> - <Select - value={joinOperator} - onValueChange={(value: "and" | "or") => setJoinOperator(value)} - disabled={isInitializing} - > - <SelectTrigger className="h-8 w-[180px] mt-2 bg-white"> - <SelectValue placeholder="조건 결합 방식" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="and">모든 조건 충족 (AND)</SelectItem> - <SelectItem value="or">하나라도 충족 (OR)</SelectItem> - </SelectContent> - </Select> - </div> - - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col h-full min-h-0"> - {/* Scrollable content area - 헤더와 버튼 사이에서 스크롤 */} - <div className="flex-1 min-h-0 overflow-y-auto px-6 pb-4"> - <div className="space-y-6 pt-4"> - {/* RFQ NO. */} - <FormField - control={form.control} - name="rfqCode" - render={({ field }) => ( - <FormItem> - <FormLabel>{t("RFQ NO.")}</FormLabel> - <FormControl> - <div className="relative"> - <Input - placeholder={t("RFQ 번호 입력")} - {...field} - className={cn(field.value && "pr-8", "bg-white")} - disabled={isInitializing} - /> - {field.value && ( - <Button - type="button" - variant="ghost" - size="icon" - className="absolute right-0 top-0 h-full px-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("rfqCode", ""); - }} - disabled={isInitializing} - > - <X className="size-3.5" /> - <span className="sr-only">Clear</span> - </Button> - )} - </div> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 자재그룹 */} - <FormField - control={form.control} - name="materialCode" - render={({ field }) => ( - <FormItem> - <FormLabel>{t("자재그룹")}</FormLabel> - <FormControl> - <div className="relative"> - <Input - placeholder={t("자재그룹 입력")} - {...field} - className={cn(field.value && "pr-8", "bg-white")} - disabled={isInitializing} - /> - {field.value && ( - <Button - type="button" - variant="ghost" - size="icon" - className="absolute right-0 top-0 h-full px-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("materialCode", ""); - }} - disabled={isInitializing} - > - <X className="size-3.5" /> - <span className="sr-only">Clear</span> - </Button> - )} - </div> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 자재명 */} - <FormField - control={form.control} - name="itemName" - render={({ field }) => ( - <FormItem> - <FormLabel>{t("자재명")}</FormLabel> - <FormControl> - <div className="relative"> - <Input - placeholder={t("자재명 입력")} - {...field} - className={cn(field.value && "pr-8", "bg-white")} - disabled={isInitializing} - /> - {field.value && ( - <Button - type="button" - variant="ghost" - size="icon" - className="absolute right-0 top-0 h-full px-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("itemName", ""); - }} - disabled={isInitializing} - > - <X className="size-3.5" /> - <span className="sr-only">Clear</span> - </Button> - )} - </div> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 프로젝트 ID */} - <FormField - control={form.control} - name="pspid" - render={({ field }) => ( - <FormItem> - <FormLabel>{t("프로젝트 ID")}</FormLabel> - <FormControl> - <div className="relative"> - <Input - placeholder={t("프로젝트 ID 입력")} - {...field} - className={cn(field.value && "pr-8", "bg-white")} - disabled={isInitializing} - /> - {field.value && ( - <Button - type="button" - variant="ghost" - size="icon" - className="absolute right-0 top-0 h-full px-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("pspid", ""); - }} - disabled={isInitializing} - > - <X className="size-3.5" /> - <span className="sr-only">Clear</span> - </Button> - )} - </div> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 프로젝트명 */} - <FormField - control={form.control} - name="projNm" - render={({ field }) => ( - <FormItem> - <FormLabel>{t("프로젝트명")}</FormLabel> - <FormControl> - <div className="relative"> - <Input - placeholder={t("프로젝트명 입력")} - {...field} - className={cn(field.value && "pr-8", "bg-white")} - disabled={isInitializing} - /> - {field.value && ( - <Button - type="button" - variant="ghost" - size="icon" - className="absolute right-0 top-0 h-full px-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("projNm", ""); - }} - disabled={isInitializing} - > - <X className="size-3.5" /> - <span className="sr-only">Clear</span> - </Button> - )} - </div> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 선종명 */} - <FormField - control={form.control} - name="ptypeNm" - render={({ field }) => ( - <FormItem> - <FormLabel>{t("선종명")}</FormLabel> - <FormControl> - <div className="relative"> - <Input - placeholder={t("선종명 입력")} - {...field} - className={cn(field.value && "pr-8", "bg-white")} - disabled={isInitializing} - /> - {field.value && ( - <Button - type="button" - variant="ghost" - size="icon" - className="absolute right-0 top-0 h-full px-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("ptypeNm", ""); - }} - disabled={isInitializing} - > - <X className="size-3.5" /> - <span className="sr-only">Clear</span> - </Button> - )} - </div> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 요청자 */} - <FormField - control={form.control} - name="createdByName" - render={({ field }) => ( - <FormItem> - <FormLabel>{t("요청자")}</FormLabel> - <FormControl> - <div className="relative"> - <Input - placeholder={t("요청자 입력")} - {...field} - className={cn(field.value && "pr-8", "bg-white")} - disabled={isInitializing} - /> - {field.value && ( - <Button - type="button" - variant="ghost" - size="icon" - className="absolute right-0 top-0 h-full px-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("createdByName", ""); - }} - disabled={isInitializing} - > - <X className="size-3.5" /> - <span className="sr-only">Clear</span> - </Button> - )} - </div> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* Status */} - <FormField - control={form.control} - name="status" - render={({ field }) => ( - <FormItem> - <FormLabel>{t("Status")}</FormLabel> - <Select - value={field.value} - onValueChange={field.onChange} - disabled={isInitializing} - > - <FormControl> - <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> - <div className="flex justify-between w-full"> - <SelectValue placeholder={t("Select status")} /> - {field.value && ( - <Button - type="button" - variant="ghost" - size="icon" - className="h-4 w-4 -mr-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("status", ""); - }} - disabled={isInitializing} - > - <X className="size-3" /> - <span className="sr-only">Clear</span> - </Button> - )} - </div> - </SelectTrigger> - </FormControl> - <SelectContent> - {statusOptions.map(option => ( - <SelectItem key={option.value} value={option.value}> - {option.label} - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - {/* RFQ 전송일 */} - <FormField - control={form.control} - name="dateRange" - render={({ field }) => ( - <FormItem> - <FormLabel>{t("RFQ 전송일")}</FormLabel> - <FormControl> - <div className="relative"> - <DateRangePicker - triggerSize="default" - triggerClassName="w-full bg-white" - align="start" - showClearButton={true} - placeholder={t("RFQ 전송일 범위를 고르세요")} - date={field.value || undefined} - onDateChange={field.onChange} - disabled={isInitializing} - /> - {(field.value?.from || field.value?.to) && ( - <Button - type="button" - variant="ghost" - size="icon" - className="absolute right-10 top-0 h-full px-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("dateRange", { from: undefined, to: undefined }); - }} - disabled={isInitializing} - > - <X className="size-3.5" /> - <span className="sr-only">Clear</span> - </Button> - )} - </div> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - </div> - - {/* Fixed buttons at bottom - 보더 제거, 배경 색상 적용 */} - <div className="p-4 shrink-0"> - <div className="flex gap-2 justify-end"> - <Button - type="button" - variant="outline" - onClick={handleReset} - disabled={isPending || getActiveFilterCount() === 0 || isInitializing} - className="px-4" - > - {t("초기화")} - </Button> - <Button - type="submit" - variant="samsung" - disabled={isPending || isLoading || isInitializing} - className="px-4" - > - <Search className="size-4 mr-2" /> - {isPending || isLoading ? t("조회 중...") : t("조회")} - </Button> - </div> - </div> - </form> - </Form> - </div> - ) +"use client"
+
+import { useEffect, useTransition, useState, useRef } from "react"
+import { useRouter, useParams } from "next/navigation"
+import { z } from "zod"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Search, X } from "lucide-react"
+import { customAlphabet } from "nanoid"
+import { parseAsStringEnum, useQueryState } from "nuqs"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { DateRangePicker } from "@/components/date-range-picker"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { cn } from "@/lib/utils"
+import { useTranslation } from '@/i18n/client'
+import { getFiltersStateParser } from "@/lib/parsers"
+
+// nanoid 생성기
+const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6)
+
+// 필터 스키마 정의 (TechSales RFQ에 맞게 수정)
+const filterSchema = z.object({
+ rfqCode: z.string().optional(),
+ materialCode: z.string().optional(),
+ itemName: z.string().optional(),
+ pspid: z.string().optional(),
+ projNm: z.string().optional(),
+ ptypeNm: z.string().optional(),
+ createdByName: z.string().optional(),
+ status: z.string().optional(),
+ dateRange: z.object({
+ from: z.date().optional(),
+ to: z.date().optional(),
+ }).optional(),
+})
+
+// 상태 옵션 정의 (TechSales RFQ 상태에 맞게 수정)
+const statusOptions = [
+ { value: "RFQ Created", label: "RFQ Created" },
+ { value: "RFQ Vendor Assignned", label: "RFQ Vendor Assignned" },
+ { value: "RFQ Sent", label: "RFQ Sent" },
+ { value: "Quotation Analysis", label: "Quotation Analysis" },
+ { value: "Closed", label: "Closed" },
+]
+
+type FilterFormValues = z.infer<typeof filterSchema>
+
+interface RFQFilterSheetProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSearch?: () => void;
+ isLoading?: boolean;
+}
+
+// Updated component for inline use (not a sheet anymore)
+export function RFQFilterSheet({
+ isOpen,
+ onClose,
+ onSearch,
+ isLoading = false
+}: RFQFilterSheetProps) {
+ const router = useRouter()
+ const params = useParams();
+ const lng = params ? (params.lng as string) : 'ko';
+ const { t } = useTranslation(lng);
+
+ const [isPending, startTransition] = useTransition()
+
+ // 초기화 상태 추가 - 폼 초기화 중에는 상태 변경을 방지
+ const [isInitializing, setIsInitializing] = useState(false)
+ // 마지막으로 적용된 필터를 추적하기 위한 ref
+ const lastAppliedFilters = useRef<string>("")
+
+ // nuqs로 URL 상태 관리 - 파라미터명을 'basicFilters'로 변경
+ const [filters, setFilters] = useQueryState(
+ "basicFilters",
+ getFiltersStateParser().withDefault([])
+ )
+
+ // joinOperator 설정
+ const [joinOperator, setJoinOperator] = useQueryState(
+ "basicJoinOperator",
+ parseAsStringEnum(["and", "or"]).withDefault("and")
+ )
+
+ // 현재 URL의 페이지 파라미터도 가져옴
+ const [page, setPage] = useQueryState("page", { defaultValue: "1" })
+
+ // 폼 상태 초기화
+ const form = useForm<FilterFormValues>({
+ resolver: zodResolver(filterSchema),
+ defaultValues: {
+ rfqCode: "",
+ materialCode: "",
+ itemName: "",
+ pspid: "",
+ projNm: "",
+ ptypeNm: "",
+ createdByName: "",
+ status: "",
+ dateRange: {
+ from: undefined,
+ to: undefined,
+ },
+ },
+ })
+
+ // URL 필터에서 초기 폼 상태 설정 - 개선된 버전
+ useEffect(() => {
+ // 현재 필터를 문자열로 직렬화
+ const currentFiltersString = JSON.stringify(filters);
+
+ // 패널이 열렸고, 필터가 있고, 마지막에 적용된 필터와 다를 때만 업데이트
+ if (isOpen && filters && filters.length > 0 && currentFiltersString !== lastAppliedFilters.current) {
+ setIsInitializing(true);
+
+ const formValues = { ...form.getValues() };
+ let formUpdated = false;
+
+ filters.forEach(filter => {
+ if (filter.id === "rfqSendDate" && Array.isArray(filter.value) && filter.value.length > 0) {
+ formValues.dateRange = {
+ from: filter.value[0] ? new Date(filter.value[0]) : undefined,
+ to: filter.value[1] ? new Date(filter.value[1]) : undefined,
+ };
+ formUpdated = true;
+ } else if (filter.id in formValues) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (formValues as any)[filter.id] = filter.value;
+ formUpdated = true;
+ }
+ });
+
+ // 폼 값이 변경된 경우에만 reset으로 한 번에 업데이트
+ if (formUpdated) {
+ form.reset(formValues);
+ lastAppliedFilters.current = currentFiltersString;
+ }
+
+ setIsInitializing(false);
+ }
+ }, [filters, isOpen, form]) // form 의존성 추가
+
+ // 현재 적용된 필터 카운트
+ const getActiveFilterCount = () => {
+ return filters?.length || 0
+ }
+
+ // 조회 버튼 클릭 핸들러
+ const handleSearch = () => {
+ // 필터 패널 닫기 로직이 있다면 여기에 추가
+ if (onSearch) {
+ onSearch();
+ }
+ }
+
+ // 폼 제출 핸들러 - 개선된 버전
+ async function onSubmit(data: FilterFormValues) {
+ // 초기화 중이면 제출 방지
+ if (isInitializing) return;
+
+ startTransition(async () => {
+ try {
+ // 필터 배열 생성
+ const newFilters = []
+
+ if (data.rfqCode?.trim()) {
+ newFilters.push({
+ id: "rfqCode",
+ value: data.rfqCode.trim(),
+ type: "text" as const,
+ operator: "iLike" as const,
+ rowId: generateId()
+ })
+ }
+
+ if (data.materialCode?.trim()) {
+ newFilters.push({
+ id: "materialCode",
+ value: data.materialCode.trim(),
+ type: "text" as const,
+ operator: "iLike" as const,
+ rowId: generateId()
+ })
+ }
+
+ if (data.itemName?.trim()) {
+ newFilters.push({
+ id: "itemName",
+ value: data.itemName.trim(),
+ type: "text" as const,
+ operator: "iLike" as const,
+ rowId: generateId()
+ })
+ }
+
+ if (data.pspid?.trim()) {
+ newFilters.push({
+ id: "pspid",
+ value: data.pspid.trim(),
+ type: "text" as const,
+ operator: "iLike" as const,
+ rowId: generateId()
+ })
+ }
+
+ if (data.projNm?.trim()) {
+ newFilters.push({
+ id: "projNm",
+ value: data.projNm.trim(),
+ type: "text" as const,
+ operator: "iLike" as const,
+ rowId: generateId()
+ })
+ }
+
+ if (data.ptypeNm?.trim()) {
+ newFilters.push({
+ id: "ptypeNm",
+ value: data.ptypeNm.trim(),
+ type: "text" as const,
+ operator: "iLike" as const,
+ rowId: generateId()
+ })
+ }
+
+ if (data.createdByName?.trim()) {
+ newFilters.push({
+ id: "createdByName",
+ value: data.createdByName.trim(),
+ type: "text" as const,
+ operator: "iLike" as const,
+ rowId: generateId()
+ })
+ }
+
+ if (data.status?.trim()) {
+ newFilters.push({
+ id: "status",
+ value: data.status.trim(),
+ type: "select" as const,
+ operator: "eq" as const,
+ rowId: generateId()
+ })
+ }
+
+ // Add date range to params if it exists
+ if (data.dateRange?.from) {
+ newFilters.push({
+ id: "rfqSendDate",
+ value: [
+ data.dateRange.from.toISOString().split('T')[0],
+ data.dateRange.to ? data.dateRange.to.toISOString().split('T')[0] : undefined
+ ].filter(Boolean) as string[],
+ type: "date" as const,
+ operator: "isBetween" as const,
+ rowId: generateId()
+ })
+ }
+
+ console.log("기본 필터 적용:", newFilters);
+
+ // 마지막 적용된 필터 업데이트
+ lastAppliedFilters.current = JSON.stringify(newFilters);
+
+ // 먼저 필터를 설정
+ await setFilters(newFilters.length > 0 ? newFilters : null);
+
+ // 그 다음 페이지를 1로 설정
+ await setPage("1");
+
+ // 필터 업데이트 후 조회 핸들러 호출 (제공된 경우)
+ handleSearch();
+
+ // 페이지 새로고침으로 서버 데이터 다시 가져오기
+ setTimeout(() => {
+ window.location.reload();
+ }, 100);
+ } catch (error) {
+ console.error("필터 적용 오류:", error);
+ }
+ })
+ }
+
+ // 필터 초기화 핸들러 - 개선된 버전
+ async function handleReset() {
+ try {
+ setIsInitializing(true);
+
+ form.reset({
+ rfqCode: "",
+ materialCode: "",
+ itemName: "",
+ pspid: "",
+ projNm: "",
+ ptypeNm: "",
+ createdByName: "",
+ status: "",
+ dateRange: { from: undefined, to: undefined },
+ });
+
+ // 필터와 조인 연산자를 초기화
+ await setFilters(null);
+ await setJoinOperator("and");
+ await setPage("1");
+
+ // 마지막 적용된 필터 초기화
+ lastAppliedFilters.current = "";
+
+ console.log("필터 초기화 완료");
+ setIsInitializing(false);
+
+ // 페이지 새로고침으로 서버 데이터 다시 가져오기
+ setTimeout(() => {
+ window.location.reload();
+ }, 100);
+ } catch (error) {
+ console.error("필터 초기화 오류:", error);
+ setIsInitializing(false);
+ }
+ }
+
+ // Don't render if not open (for side panel use)
+ if (!isOpen) {
+ return null;
+ }
+
+ return (
+ <div className="flex flex-col h-full max-h-full p-4">
+ {/* Filter Panel Header - 보더 제거, 배경 색상 적용 */}
+ <div className="flex items-center justify-between px-6 min-h-[60px] shrink-0">
+ <h3 className="text-lg font-semibold whitespace-nowrap">검색 필터</h3>
+ </div>
+
+ {/* Join Operator Selection - 보더 제거, 배경 색상 적용 */}
+ <div className="px-6 shrink-0">
+ <label className="text-sm font-medium">조건 결합 방식</label>
+ <Select
+ value={joinOperator}
+ onValueChange={(value: "and" | "or") => setJoinOperator(value)}
+ disabled={isInitializing}
+ >
+ <SelectTrigger className="h-8 w-[180px] mt-2 bg-white">
+ <SelectValue placeholder="조건 결합 방식" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="and">모든 조건 충족 (AND)</SelectItem>
+ <SelectItem value="or">하나라도 충족 (OR)</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col h-full min-h-0">
+ {/* Scrollable content area - 헤더와 버튼 사이에서 스크롤 */}
+ <div className="flex-1 min-h-0 overflow-y-auto px-6 pb-4">
+ <div className="space-y-6 pt-4">
+ {/* RFQ NO. */}
+ <FormField
+ control={form.control}
+ name="rfqCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("RFQ NO.")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder={t("RFQ 번호 입력")}
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("rfqCode", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 자재그룹 */}
+ <FormField
+ control={form.control}
+ name="materialCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("자재그룹")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder={t("자재그룹 입력")}
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("materialCode", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 자재명 */}
+ <FormField
+ control={form.control}
+ name="itemName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("자재명")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder={t("자재명 입력")}
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("itemName", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 프로젝트 ID */}
+ <FormField
+ control={form.control}
+ name="pspid"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("프로젝트 ID")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder={t("프로젝트 ID 입력")}
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("pspid", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 프로젝트명 */}
+ <FormField
+ control={form.control}
+ name="projNm"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("프로젝트명")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder={t("프로젝트명 입력")}
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("projNm", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 선종명 */}
+ <FormField
+ control={form.control}
+ name="ptypeNm"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("선종명")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder={t("선종명 입력")}
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("ptypeNm", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 요청자 */}
+ <FormField
+ control={form.control}
+ name="createdByName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("요청자")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder={t("요청자 입력")}
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("createdByName", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Status */}
+ <FormField
+ control={form.control}
+ name="status"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("Status")}</FormLabel>
+ <Select
+ value={field.value}
+ onValueChange={field.onChange}
+ disabled={isInitializing}
+ >
+ <FormControl>
+ <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
+ <div className="flex justify-between w-full">
+ <SelectValue placeholder={t("Select status")} />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="h-4 w-4 -mr-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("status", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {statusOptions.map(option => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* RFQ 전송일 */}
+ <FormField
+ control={form.control}
+ name="dateRange"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("RFQ 전송일")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <DateRangePicker
+ triggerSize="default"
+ triggerClassName="w-full bg-white"
+ align="start"
+ showClearButton={true}
+ placeholder={t("RFQ 전송일 범위를 고르세요")}
+ date={field.value || undefined}
+ onDateChange={field.onChange}
+ disabled={isInitializing}
+ />
+ {(field.value?.from || field.value?.to) && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-10 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("dateRange", { from: undefined, to: undefined });
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+
+ {/* Fixed buttons at bottom - 보더 제거, 배경 색상 적용 */}
+ <div className="p-4 shrink-0">
+ <div className="flex gap-2 justify-end">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handleReset}
+ disabled={isPending || getActiveFilterCount() === 0 || isInitializing}
+ className="px-4"
+ >
+ {t("초기화")}
+ </Button>
+ <Button
+ type="submit"
+ variant="samsung"
+ disabled={isPending || isLoading || isInitializing}
+ className="px-4"
+ >
+ <Search className="size-4 mr-2" />
+ {isPending || isLoading ? t("조회 중...") : t("조회")}
+ </Button>
+ </div>
+ </div>
+ </form>
+ </Form>
+ </div>
+ )
}
\ No newline at end of file diff --git a/lib/techsales-rfq/table/rfq-items-view-dialog.tsx b/lib/techsales-rfq/table/rfq-items-view-dialog.tsx index 289ad312..c0aaf477 100644 --- a/lib/techsales-rfq/table/rfq-items-view-dialog.tsx +++ b/lib/techsales-rfq/table/rfq-items-view-dialog.tsx @@ -21,8 +21,8 @@ interface RfqItem { itemCode: string;
itemList: string;
workType: string;
- shipType?: string; // 조선용
- subItemName?: string; // 해양용
+ shipTypes?: string; // 조선용
+ subItemList?: string; // 해양용
}
interface RfqItemsViewDialogProps {
@@ -167,7 +167,7 @@ export function RfqItemsViewDialog({ {item.itemList}
</div>
<div className="w-[150px] pl-2 text-sm">
- {item.itemType === 'SHIP' ? item.shipType : item.subItemName}
+ {item.itemType === 'SHIP' ? item.shipTypes : item.subItemList}
</div>
</div>
))}
diff --git a/lib/techsales-rfq/table/rfq-table-column.tsx b/lib/techsales-rfq/table/rfq-table-column.tsx index 89054d0e..f41857cd 100644 --- a/lib/techsales-rfq/table/rfq-table-column.tsx +++ b/lib/techsales-rfq/table/rfq-table-column.tsx @@ -1,420 +1,413 @@ -"use client" - -import * as React from "react" -import { ColumnDef } from "@tanstack/react-table" -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, Package, FileText, BarChart3 } from "lucide-react" -import { Button } from "@/components/ui/button" - -// 기본적인 RFQ 타입 정의 (rfq-table.tsx 파일과 일치해야 함) -type TechSalesRfq = { - id: number - rfqCode: string | null - description: string | null - dueDate: Date - rfqSendDate: Date | null - status: "RFQ Created" | "RFQ Vendor Assignned" | "RFQ Sent" | "Quotation Analysis" | "Closed" - picCode: string | null - remark: string | null - cancelReason: string | null - createdAt: Date - updatedAt: Date - createdBy: number | null - createdByName: string - updatedBy: number | null - updatedByName: string - sentBy: number | null - sentByName: string | null - pspid: string - projNm: string - sector: string - projMsrm: number - ptypeNm: string - attachmentCount: number - hasTbeAttachments: boolean - hasCbeAttachments: boolean - quotationCount: number - itemCount: number - // 나머지 필드는 사용할 때마다 추가 - [key: string]: unknown -} - -interface GetColumnsProps { - setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TechSalesRfq> | null>>; - openAttachmentsSheet: (rfqId: number, attachmentType?: 'RFQ_COMMON' | 'TBE_RESULT' | 'CBE_RESULT') => void; - openItemsDialog: (rfq: TechSalesRfq) => void; -} - -export function getColumns({ - setRowAction, - openAttachmentsSheet, - openItemsDialog, -}: GetColumnsProps): ColumnDef<TechSalesRfq>[] { - return [ - { - id: "select", - // Remove the "Select all" checkbox in header since we're doing single-select - header: () => <span className="sr-only">Select</span>, - cell: ({ row, table }) => ( - <Checkbox - checked={row.getIsSelected()} - onCheckedChange={(value) => { - // If selecting this row - if (value) { - // First deselect all rows (to ensure single selection) - table.toggleAllRowsSelected(false) - // Then select just this row - row.toggleSelected(true) - // Trigger the same action that was in the "Select" button - setRowAction({ row, type: "select" as const }) - } else { - // Just deselect this row - row.toggleSelected(false) - } - }} - aria-label="Select row" - className="translate-y-0.5" - /> - ), - enableSorting: false, - enableHiding: false, - enableResizing: false, - size: 40, - minSize: 40, - maxSize: 40, - }, - - { - accessorKey: "status", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="진행상태" /> - ), - cell: ({ row }) => <div>{row.getValue("status")}</div>, - meta: { - excelHeader: "진행상태" - }, - enableResizing: true, - minSize: 80, - size: 100, - }, - { - accessorKey: "rfqCode", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="RFQ No." /> - ), - cell: ({ row }) => <div>{row.getValue("rfqCode")}</div>, - meta: { - excelHeader: "RFQ No." - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "description", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="RFQ Title" /> - ), - cell: ({ row }) => <div>{row.getValue("description")}</div>, - meta: { - excelHeader: "RFQ Title" - }, - enableResizing: true, - size: 200, - }, - { - accessorKey: "projNm", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="프로젝트명" /> - ), - cell: ({ row }) => { - const projNm = row.getValue("projNm") as string; - return ( - <Button - variant="link" - className="p-0 h-auto font-normal text-left justify-start hover:underline" - onClick={() => setRowAction({ row, type: "view" as const })} - > - {projNm} - </Button> - ); - }, - meta: { - excelHeader: "프로젝트명" - }, - 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, - // }, - { - accessorKey: "rfqSendDate", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="최초 전송일" /> - ), - cell: ({ cell }) => { - const value = cell.getValue(); - return value ? formatDate(value as Date, "KR") : ""; - }, - meta: { - excelHeader: "최초 전송일" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "dueDate", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="RFQ 마감일" /> - ), - cell: ({ cell }) => { - const value = cell.getValue(); - return value ? formatDate(value as Date, "KR") : ""; - }, - meta: { - excelHeader: "RFQ 마감일" - }, - enableResizing: true, - minSize: 80, - size: 120, - }, - { - accessorKey: "createdByName", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="요청자" /> - ), - cell: ({ row }) => <div>{row.getValue("createdByName")}</div>, - meta: { - excelHeader: "요청자" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "createdAt", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="등록일" /> - ), - cell: ({ cell }) => { - const value = cell.getValue(); - return value ? formatDateTime(value as Date, "KR") : ""; - }, - meta: { - excelHeader: "등록일" - }, - enableResizing: true, - size: 160, - }, - { - accessorKey: "updatedAt", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="수정일" /> - ), - cell: ({ cell }) => { - const value = cell.getValue(); - return value ? formatDateTime(value as Date, "KR") : ""; - }, - meta: { - excelHeader: "수정일" - }, - 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="RFQ 첨부파일" /> - ), - cell: ({ row }) => { - const rfq = row.original - const attachmentCount = rfq.attachmentCount || 0 - - const handleClick = () => { - openAttachmentsSheet(rfq.id, 'RFQ_COMMON') - } - - 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: "첨부파일" - }, - }, - { - id: "tbe-attachments", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="TBE 결과" /> - ), - cell: ({ row }) => { - const rfq = row.original - const hasTbeAttachments = rfq.hasTbeAttachments - - const handleClick = () => { - openAttachmentsSheet(rfq.id, 'TBE_RESULT') - } - - return ( - <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={handleClick} - aria-label={hasTbeAttachments ? "TBE 첨부파일 있음" : "TBE 첨부파일 추가"} - > - <FileText className="h-4 w-4 text-muted-foreground group-hover:text-green-600 transition-colors" /> - {hasTbeAttachments && ( - <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-3 w-3 rounded-full bg-red-500"></span> - )} - <span className="sr-only"> - {hasTbeAttachments ? "TBE 첨부파일 있음" : "TBE 첨부파일 추가"} - </span> - </Button> - ) - }, - enableSorting: false, - enableResizing: false, - size: 80, - meta: { - excelHeader: "TBE 결과" - }, - }, - { - id: "cbe-attachments", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="CBE 결과" /> - ), - cell: ({ row }) => { - const rfq = row.original - const hasCbeAttachments = rfq.hasCbeAttachments - - const handleClick = () => { - openAttachmentsSheet(rfq.id, 'CBE_RESULT') - } - - return ( - <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={handleClick} - aria-label={hasCbeAttachments ? "CBE 첨부파일 있음" : "CBE 첨부파일 추가"} - > - <BarChart3 className="h-4 w-4 text-muted-foreground group-hover:text-blue-600 transition-colors" /> - {hasCbeAttachments && ( - <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-3 w-3 rounded-full bg-red-500"></span> - )} - <span className="sr-only"> - {hasCbeAttachments ? "CBE 첨부파일 있음" : "CBE 첨부파일 추가"} - </span> - </Button> - ) - }, - enableSorting: false, - enableResizing: false, - size: 80, - meta: { - excelHeader: "CBE 결과" - }, - }, - ] +"use client"
+
+import * as React from "react"
+import { ColumnDef } from "@tanstack/react-table"
+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, Package, FileText, BarChart3 } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import { TechSalesRfq } from "./rfq-table"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TechSalesRfq> | null>>;
+ openAttachmentsSheet: (rfqId: number, attachmentType?: 'RFQ_COMMON' | 'TBE_RESULT' | 'CBE_RESULT') => void;
+ openItemsDialog: (rfq: TechSalesRfq) => void;
+}
+
+export function getColumns({
+ setRowAction,
+ openAttachmentsSheet,
+ openItemsDialog,
+}: GetColumnsProps): ColumnDef<TechSalesRfq>[] {
+ return [
+ {
+ id: "select",
+ // Remove the "Select all" checkbox in header since we're doing single-select
+ header: () => <span className="sr-only">Select</span>,
+ cell: ({ row, table }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => {
+ // If selecting this row
+ if (value) {
+ // First deselect all rows (to ensure single selection)
+ table.toggleAllRowsSelected(false)
+ // Then select just this row
+ row.toggleSelected(true)
+ // Trigger the same action that was in the "Select" button
+ setRowAction({ row, type: "select" as const })
+ } else {
+ // Just deselect this row
+ row.toggleSelected(false)
+ }
+ }}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ enableResizing: false,
+ size: 40,
+ minSize: 40,
+ maxSize: 40,
+ },
+
+ {
+ accessorKey: "status",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="진행상태" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("status")}</div>,
+ meta: {
+ excelHeader: "진행상태"
+ },
+ enableResizing: true,
+ minSize: 80,
+ size: 100,
+ },
+ {
+ accessorKey: "rfqCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ No." />
+ ),
+ cell: ({ row }) => <div>{row.getValue("rfqCode")}</div>,
+ meta: {
+ excelHeader: "RFQ No."
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "description",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ Title" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("description")}</div>,
+ meta: {
+ excelHeader: "RFQ Title"
+ },
+ enableResizing: true,
+ size: 200,
+ },
+ {
+ accessorKey: "projNm",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="프로젝트명" />
+ ),
+ cell: ({ row }) => {
+ const projNm = row.getValue("projNm") as string;
+ return (
+ <Button
+ variant="link"
+ className="p-0 h-auto font-normal text-left justify-start hover:underline"
+ onClick={() => setRowAction({ row, type: "view" as const })}
+ >
+ {projNm}
+ </Button>
+ );
+ },
+ meta: {
+ excelHeader: "프로젝트명"
+ },
+ enableResizing: true,
+ size: 160,
+ },
+ {
+ accessorKey: "workTypes",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="공종" />
+ ),
+ cell: ({ row }) => {
+ const workTypes = row.getValue("workTypes") as string | null;
+ return (
+ <div className="max-w-[150px]">
+ {workTypes ? (
+ <span className="text-sm truncate block" title={workTypes}>
+ {workTypes}
+ </span>
+ ) : (
+ <span className="text-muted-foreground text-sm">-</span>
+ )}
+ </div>
+ );
+ },
+ meta: {
+ excelHeader: "공종"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ // {
+ // 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 }) => (
+ <DataTableColumnHeaderSimple column={column} title="최초 전송일" />
+ ),
+ cell: ({ cell }) => {
+ const value = cell.getValue();
+ return value ? formatDate(value as Date, "KR") : "";
+ },
+ meta: {
+ excelHeader: "최초 전송일"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "dueDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ 마감일" />
+ ),
+ cell: ({ cell }) => {
+ const value = cell.getValue();
+ return value ? formatDate(value as Date, "KR") : "";
+ },
+ meta: {
+ excelHeader: "RFQ 마감일"
+ },
+ enableResizing: true,
+ minSize: 80,
+ size: 120,
+ },
+ {
+ accessorKey: "createdByName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="요청자" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("createdByName")}</div>,
+ meta: {
+ excelHeader: "요청자"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="등록일" />
+ ),
+ cell: ({ cell }) => {
+ const value = cell.getValue();
+ return value ? formatDateTime(value as Date, "KR") : "";
+ },
+ meta: {
+ excelHeader: "등록일"
+ },
+ enableResizing: true,
+ size: 160,
+ },
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="수정일" />
+ ),
+ cell: ({ cell }) => {
+ const value = cell.getValue();
+ return value ? formatDateTime(value as Date, "KR") : "";
+ },
+ meta: {
+ excelHeader: "수정일"
+ },
+ 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="RFQ 첨부파일" />
+ ),
+ cell: ({ row }) => {
+ const rfq = row.original
+ const attachmentCount = rfq.attachmentCount || 0
+
+ const handleClick = () => {
+ openAttachmentsSheet(rfq.id, 'RFQ_COMMON')
+ }
+
+ 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: "첨부파일"
+ },
+ },
+ {
+ id: "tbe-attachments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="TBE 결과" />
+ ),
+ cell: ({ row }) => {
+ const rfq = row.original
+ const hasTbeAttachments = rfq.hasTbeAttachments
+
+ const handleClick = () => {
+ openAttachmentsSheet(rfq.id, 'TBE_RESULT')
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={hasTbeAttachments ? "TBE 첨부파일 있음" : "TBE 첨부파일 추가"}
+ >
+ <FileText className="h-4 w-4 text-muted-foreground group-hover:text-green-600 transition-colors" />
+ {hasTbeAttachments && (
+ <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-3 w-3 rounded-full bg-red-500"></span>
+ )}
+ <span className="sr-only">
+ {hasTbeAttachments ? "TBE 첨부파일 있음" : "TBE 첨부파일 추가"}
+ </span>
+ </Button>
+ )
+ },
+ enableSorting: false,
+ enableResizing: false,
+ size: 80,
+ meta: {
+ excelHeader: "TBE 결과"
+ },
+ },
+ {
+ id: "cbe-attachments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="CBE 결과" />
+ ),
+ cell: ({ row }) => {
+ const rfq = row.original
+ const hasCbeAttachments = rfq.hasCbeAttachments
+
+ const handleClick = () => {
+ openAttachmentsSheet(rfq.id, 'CBE_RESULT')
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={hasCbeAttachments ? "CBE 첨부파일 있음" : "CBE 첨부파일 추가"}
+ >
+ <BarChart3 className="h-4 w-4 text-muted-foreground group-hover:text-blue-600 transition-colors" />
+ {hasCbeAttachments && (
+ <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-3 w-3 rounded-full bg-red-500"></span>
+ )}
+ <span className="sr-only">
+ {hasCbeAttachments ? "CBE 첨부파일 있음" : "CBE 첨부파일 추가"}
+ </span>
+ </Button>
+ )
+ },
+ enableSorting: false,
+ enableResizing: false,
+ size: 80,
+ meta: {
+ excelHeader: "CBE 결과"
+ },
+ },
+ ]
}
\ 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 a8c2d08c..3ccca4eb 100644 --- a/lib/techsales-rfq/table/rfq-table-toolbar-actions.tsx +++ b/lib/techsales-rfq/table/rfq-table-toolbar-actions.tsx @@ -1,80 +1,80 @@ -"use client" - -import * as React from "react" -import { Download, RefreshCw } from "lucide-react" -import { toast } from "sonner" - -import { exportTableToExcel } from "@/lib/export" -import { Button } from "@/components/ui/button" -import { type Table } from "@tanstack/react-table" -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, - rfqType = "SHIP" -}: RFQTableToolbarActionsProps<TData>) { - - // 데이터 새로고침 - const handleRefresh = () => { - if (onRefresh) { - onRefresh(); - toast.success("데이터를 새로고침했습니다"); - } - } - - // 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 생성 다이얼로그 */} - {renderRfqDialog()} - - {/* 새로고침 버튼 */} - <Button - variant="outline" - size="sm" - onClick={handleRefresh} - className="gap-2" - > - <RefreshCw className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">새로고침</span> - </Button> - - {/* 내보내기 버튼 */} - <Button - variant="outline" - size="sm" - onClick={() => - exportTableToExcel(selection, { - filename: "tech_sales_rfq", - excludeColumns: ["select", "actions"], - }) - } - className="gap-2" - > - <Download className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">내보내기</span> - </Button> - </div> - ) +"use client"
+
+import * as React from "react"
+import { Download, RefreshCw } from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { type Table } from "@tanstack/react-table"
+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,
+ rfqType = "SHIP"
+}: RFQTableToolbarActionsProps<TData>) {
+
+ // 데이터 새로고침
+ const handleRefresh = () => {
+ if (onRefresh) {
+ onRefresh();
+ toast.success("데이터를 새로고침했습니다");
+ }
+ }
+
+ // 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 생성 다이얼로그 */}
+ {renderRfqDialog()}
+
+ {/* 새로고침 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleRefresh}
+ className="gap-2"
+ >
+ <RefreshCw className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">새로고침</span>
+ </Button>
+
+ {/* 내보내기 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(selection, {
+ filename: "tech_sales_rfq",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ className="gap-2"
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">내보내기</span>
+ </Button>
+ </div>
+ )
}
\ No newline at end of file diff --git a/lib/techsales-rfq/table/rfq-table.tsx b/lib/techsales-rfq/table/rfq-table.tsx index 615753cd..e3551625 100644 --- a/lib/techsales-rfq/table/rfq-table.tsx +++ b/lib/techsales-rfq/table/rfq-table.tsx @@ -1,589 +1,636 @@ -"use client" - -import * as React from "react" -import { useSearchParams } from "next/navigation" -import { Button } from "@/components/ui/button" -import { PanelLeftClose, PanelLeftOpen } from "lucide-react" -import type { - DataTableAdvancedFilterField, - DataTableRowAction, -} from "@/types/table" -import { - ResizablePanelGroup, - ResizablePanel, - ResizableHandle, -} from "@/components/ui/resizable" - -import { useDataTable } from "@/hooks/use-data-table" -import { DataTable } from "@/components/data-table/data-table" -import { getColumns } from "./rfq-table-column" -import { useEffect, useMemo } from "react" -import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" -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 { 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 - biddingProjectId: number | null - materialCode: string | null - dueDate: Date - rfqSendDate: Date | null - status: "RFQ Created" | "RFQ Vendor Assignned" | "RFQ Sent" | "Quotation Analysis" | "Closed" - description: string | null - remark: string | null - cancelReason: string | null - createdAt: Date - updatedAt: Date - createdBy: number | null - createdByName: string - updatedBy: number | null - updatedByName: string - sentBy: number | null - sentByName: string | null - // 조인된 프로젝트 정보 - pspid: string - projNm: string - sector: string - projMsrm: number - ptypeNm: string - attachmentCount: number - quotationCount: number - rfqType: "SHIP" | "TOP" | "HULL" | null - // 필요에 따라 다른 필드들 추가 - [key: string]: unknown -} - -interface RFQListTableProps { - promises: Promise<[Awaited<ReturnType<typeof getTechSalesRfqsWithJoin>>]> - className?: string; - calculatedHeight?: string; // 계산된 높이 추가 - rfqType: "SHIP" | "TOP" | "HULL"; -} - -export function RFQListTable({ - promises, - className, - calculatedHeight, - rfqType -}: RFQListTableProps) { - const searchParams = useSearchParams() - - // 필터 패널 상태 - const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false) - - // 선택된 RFQ 상태 - const [selectedRfq, setSelectedRfq] = React.useState<TechSalesRfq | null>(null) - - // 프로젝트 상세정보 다이얼로그 상태 - const [isProjectDetailOpen, setIsProjectDetailOpen] = React.useState(false) - const [projectDetailRfq, setProjectDetailRfq] = React.useState<TechSalesRfq | null>(null) - - // 첨부파일 시트 상태 - const [attachmentsOpen, setAttachmentsOpen] = React.useState(false) - 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) - - // 고정 높이 설정을 위한 상수 (실제 측정값으로 조정 필요) - const LAYOUT_HEADER_HEIGHT = 64 // Layout Header 높이 - const LAYOUT_FOOTER_HEIGHT = 60 // Layout Footer 높이 (있다면 실제 값) - const LOCAL_HEADER_HEIGHT = 72 // 로컬 헤더 바 높이 (p-4 + border) - const FILTER_PANEL_WIDTH = 400 // 필터 패널 너비 - - // 높이 계산 - // 필터 패널 높이 - Layout Header와 Footer 사이 - const FIXED_FILTER_HEIGHT = `calc(100vh - ${LAYOUT_HEADER_HEIGHT*2}px)` - - console.log(calculatedHeight) - - // 테이블 컨텐츠 높이 - 전달받은 높이에서 로컬 헤더 제외 - const FIXED_TABLE_HEIGHT = calculatedHeight - ? `calc(${calculatedHeight} - ${LOCAL_HEADER_HEIGHT}px)` - : `calc(100vh - ${LAYOUT_HEADER_HEIGHT + LAYOUT_FOOTER_HEIGHT + LOCAL_HEADER_HEIGHT+76}px)` // fallback - - // Suspense 방식으로 데이터 처리 - const [promiseData] = React.use(promises) - const tableData = promiseData - - const [rowAction, setRowAction] = React.useState<DataTableRowAction<TechSalesRfq> | null>(null) - - // 초기 설정 정의 - const initialSettings = React.useMemo(() => ({ - page: parseInt(searchParams?.get('page') || '1'), - perPage: parseInt(searchParams?.get('perPage') || '10'), - sort: searchParams?.get('sort') ? JSON.parse(searchParams.get('sort')!) : [{ id: "updatedAt", desc: true }], - filters: searchParams?.get('filters') ? JSON.parse(searchParams.get('filters')!) : [], - joinOperator: (searchParams?.get('joinOperator') as "and" | "or") || "and", - basicFilters: searchParams?.get('basicFilters') ? JSON.parse(searchParams.get('basicFilters')!) : [], - basicJoinOperator: (searchParams?.get('basicJoinOperator') as "and" | "or") || "and", - search: searchParams?.get('search') || '', - from: searchParams?.get('from') || undefined, - to: searchParams?.get('to') || undefined, - columnVisibility: {}, - columnOrder: [], - pinnedColumns: { left: [], right: ["items", "attachments", "tbe-attachments", "cbe-attachments"] }, - groupBy: [], - expandedRows: [] - }), [searchParams]) - - // DB 기반 프리셋 훅 사용 - const { - // presets, - // activePresetId, - // hasUnsavedChanges, - // isLoading: presetsLoading, - // createPreset, - // applyPreset, - // updatePreset, - // deletePreset, - // setDefaultPreset, - // renamePreset, - getCurrentSettings, - } = useTablePresets<TechSalesRfq>('rfq-list-table', initialSettings) - - // 조회 버튼 클릭 핸들러 - const handleSearch = () => { - setIsFilterPanelOpen(false) - } - - // 행 액션 처리 - useEffect(() => { - if (rowAction) { - switch (rowAction.type) { - case "select": - // 객체 참조 안정화를 위해 필요한 필드만 추출 - const rfqData = rowAction.row.original; - setSelectedRfq({ - id: rfqData.id, - rfqCode: rfqData.rfqCode, - rfqType: rfqData.rfqType, // 빠뜨린 rfqType 필드 추가 - biddingProjectId: rfqData.biddingProjectId, - materialCode: rfqData.materialCode, - dueDate: rfqData.dueDate, - rfqSendDate: rfqData.rfqSendDate, - status: rfqData.status, - description: rfqData.description, - remark: rfqData.remark, - cancelReason: rfqData.cancelReason, - createdAt: rfqData.createdAt, - updatedAt: rfqData.updatedAt, - createdBy: rfqData.createdBy, - createdByName: rfqData.createdByName, - updatedBy: rfqData.updatedBy, - updatedByName: rfqData.updatedByName, - sentBy: rfqData.sentBy, - sentByName: rfqData.sentByName, - pspid: rfqData.pspid, - projNm: rfqData.projNm, - sector: rfqData.sector, - projMsrm: rfqData.projMsrm, - ptypeNm: rfqData.ptypeNm, - attachmentCount: rfqData.attachmentCount, - quotationCount: rfqData.quotationCount, - }); - break; - case "view": - // 프로젝트 상세정보 다이얼로그 열기 - const projectRfqData = rowAction.row.original; - setProjectDetailRfq({ - id: projectRfqData.id, - rfqCode: projectRfqData.rfqCode, - rfqType: projectRfqData.rfqType, // 빠뜨린 rfqType 필드 추가 - biddingProjectId: projectRfqData.biddingProjectId, - materialCode: projectRfqData.materialCode, - dueDate: projectRfqData.dueDate, - rfqSendDate: projectRfqData.rfqSendDate, - status: projectRfqData.status, - description: projectRfqData.description, - remark: projectRfqData.remark, - cancelReason: projectRfqData.cancelReason, - createdAt: projectRfqData.createdAt, - updatedAt: projectRfqData.updatedAt, - createdBy: projectRfqData.createdBy, - createdByName: projectRfqData.createdByName, - updatedBy: projectRfqData.updatedBy, - updatedByName: projectRfqData.updatedByName, - sentBy: projectRfqData.sentBy, - sentByName: projectRfqData.sentByName, - pspid: projectRfqData.pspid, - projNm: projectRfqData.projNm, - sector: projectRfqData.sector, - projMsrm: projectRfqData.projMsrm, - ptypeNm: projectRfqData.ptypeNm, - attachmentCount: projectRfqData.attachmentCount, - quotationCount: projectRfqData.quotationCount, - }); - setIsProjectDetailOpen(true); - break; - case "update": - console.log("Update rfq:", rowAction.row.original) - break; - case "delete": - console.log("Delete rfq:", rowAction.row.original) - break; - } - setRowAction(null) - } - }, [rowAction]) - - // 첨부파일 시트 상태에 타입 추가 - const [attachmentType, setAttachmentType] = React.useState<"RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT">("RFQ_COMMON") - - // 첨부파일 시트 열기 함수 - const openAttachmentsSheet = React.useCallback(async (rfqId: number, attachmentType: 'RFQ_COMMON' | 'TBE_RESULT' | 'CBE_RESULT' = 'RFQ_COMMON') => { - try { - // 선택된 RFQ 찾기 - const rfq = tableData?.data?.find(r => r.id === rfqId) - if (!rfq) { - toast.error("RFQ를 찾을 수 없습니다.") - return - } - - // attachmentType을 RFQ_COMMON, TBE_RESULT, CBE_RESULT 중 하나로 변환 - const validAttachmentType=attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT" - - // 실제 첨부파일 목록 조회 API 호출 - const result = await getTechSalesRfqAttachments(rfqId) - - if (result.error) { - toast.error(result.error) - return - } - - // 해당 타입의 첨부파일만 필터링 - const filteredAttachments = result.data.filter(att => att.attachmentType === validAttachmentType) - - // API 응답을 ExistingTechSalesAttachment 형식으로 변환 - const attachments: ExistingTechSalesAttachment[] = filteredAttachments.map(att => ({ - id: att.id, - techSalesRfqId: att.techSalesRfqId || rfqId, // null인 경우 rfqId 사용 - fileName: att.fileName, - originalFileName: att.originalFileName, - filePath: att.filePath, - fileSize: att.fileSize || undefined, - fileType: att.fileType || undefined, - attachmentType: att.attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT", - description: att.description || undefined, - createdBy: att.createdBy, - createdAt: att.createdAt, - })) - - setAttachmentType(validAttachmentType) - setAttachmentsDefault(attachments) - setSelectedRfqForAttachments(rfq as unknown as TechSalesRfq) - setAttachmentsOpen(true) - } catch (error) { - console.error("첨부파일 조회 오류:", error) - toast.error("첨부파일 조회 중 오류가 발생했습니다.") - } - }, [tableData?.data]) - - // 첨부파일 업데이트 콜백 - const handleAttachmentsUpdated = React.useCallback((rfqId: number, newAttachmentCount: number) => { - // Service에서 이미 revalidateTag와 revalidatePath로 캐시 무효화 처리됨 - console.log(`RFQ ${rfqId}의 첨부파일 개수가 ${newAttachmentCount}개로 업데이트됨`) - - // 성공 피드백 - setTimeout(() => { - toast.success(`첨부파일 개수가 업데이트되었습니다. (${newAttachmentCount}개)`, { - duration: 3000 - }) - }, 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, - openItemsDialog - }), - [openAttachmentsSheet, openItemsDialog] - ) - - // 고급 필터 필드 정의 - const advancedFilterFields: DataTableAdvancedFilterField<TechSalesRfq>[] = [ - { - id: "rfqCode", - label: "RFQ No.", - type: "text", - }, - { - id: "description", - label: "설명", - type: "text", - }, - { - id: "projNm", - label: "프로젝트명", - type: "text", - }, - { - id: "rfqSendDate", - label: "RFQ 전송일", - type: "date", - }, - { - id: "dueDate", - label: "RFQ 마감일", - type: "date", - }, - { - id: "createdByName", - label: "요청자", - type: "text", - }, - { - id: "status", - label: "상태", - type: "text", - }, - ] - - // 현재 설정 가져오기 - const currentSettings = useMemo(() => { - return getCurrentSettings() - }, [getCurrentSettings]) - - // useDataTable 초기 상태 설정 - const initialState = useMemo(() => { - return { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sorting: initialSettings.sort.filter((sortItem: any) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const columnExists = columns.some((col: any) => col.accessorKey === sortItem.id) - return columnExists - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }) as any, - columnVisibility: currentSettings.columnVisibility, - columnPinning: currentSettings.pinnedColumns, - } - }, [currentSettings, initialSettings.sort, columns]) - - // useDataTable 훅 설정 - const { table } = useDataTable({ - data: tableData?.data || [], - // eslint-disable-next-line @typescript-eslint/no-explicit-any - columns: columns as any, - pageCount: tableData?.pageCount || 0, - rowCount: tableData?.total || 0, - filterFields: [], - enablePinning: true, - enableAdvancedFilter: true, - initialState, - getRowId: (originalRow) => String(originalRow.id), - shallow: false, - clearOnDefault: true, - columnResizeMode: "onEnd", - }) - - // Get active basic filter count - const getActiveBasicFilterCount = () => { - try { - const basicFilters = searchParams?.get('basicFilters') - return basicFilters ? JSON.parse(basicFilters).length : 0 - } catch { - return 0 - } - } - - console.log(panelHeight) - - return ( - <div - className={cn("flex flex-col relative", className)} - style={{ height: calculatedHeight }} - > - {/* Filter Panel - 계산된 높이 적용 */} - <div - className={cn( - "fixed left-0 bg-background border-r z-30 flex flex-col transition-all duration-300 ease-in-out overflow-hidden", - isFilterPanelOpen ? "border-r shadow-lg" : "border-r-0" - )} - style={{ - width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px', - top: `${LAYOUT_HEADER_HEIGHT*2}px`, - height: FIXED_FILTER_HEIGHT - }} - > - {/* Filter Content */} - <div className="h-full"> - <RFQFilterSheet - isOpen={isFilterPanelOpen} - onClose={() => setIsFilterPanelOpen(false)} - onSearch={handleSearch} - isLoading={false} - /> - </div> - </div> - - {/* Main Content */} - <div - className="flex flex-col transition-all duration-300 ease-in-out" - style={{ - width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : '100%', - marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px', - height: '100%' - }} - > - {/* Header Bar - 고정 높이 */} - <div - className="flex items-center justify-between p-4 bg-background border-b" - style={{ - height: `${LOCAL_HEADER_HEIGHT}px`, - flexShrink: 0 - }} - > - <div className="flex items-center gap-3"> - <Button - variant="outline" - size="sm" - type='button' - onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)} - className="flex items-center shadow-sm" - > - {isFilterPanelOpen ? <PanelLeftClose className="size-4"/> : <PanelLeftOpen className="size-4"/>} - {getActiveBasicFilterCount() > 0 && ( - <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs"> - {getActiveBasicFilterCount()} - </span> - )} - </Button> - </div> - - {/* Right side info */} - <div className="text-sm text-muted-foreground"> - {tableData && ( - <span>총 {tableData.total || 0}건</span> - )} - </div> - </div> - - {/* Table Content Area - 계산된 높이 사용 */} - <div - className="relative bg-background" - style={{ - height: FIXED_TABLE_HEIGHT, - display: 'grid', - gridTemplateRows: '1fr', - gridTemplateColumns: '1fr' - }} - > - <ResizablePanelGroup - direction="vertical" - className="w-full h-full" - > - <ResizablePanel - defaultSize={60} - minSize={25} - maxSize={75} - collapsible={false} - onResize={(size) => { - setPanelHeight(size) - }} - className="flex flex-col overflow-hidden" - > - {/* 상단 테이블 영역 */} - <div className="flex-1 min-h-0 overflow-hidden"> - <DataTable - table={table} - maxHeight={`${panelHeight*0.5}vh`} - > - <DataTableAdvancedToolbar - // eslint-disable-next-line @typescript-eslint/no-explicit-any - table={table as any} - filterFields={advancedFilterFields} - shallow={false} - > - <div className="flex items-center gap-2"> - {/* 아직 개발/테스트 전이라서 주석처리함. 지우지 말 것! 미사용 린터 에러는 무시. */} - {/* <TablePresetManager<TechSalesRfq> - presets={presets} - activePresetId={activePresetId} - currentSettings={currentSettings} - hasUnsavedChanges={hasUnsavedChanges} - isLoading={presetsLoading} - onCreatePreset={createPreset} - onUpdatePreset={updatePreset} - onDeletePreset={deletePreset} - onApplyPreset={applyPreset} - onSetDefaultPreset={setDefaultPreset} - onRenamePreset={renamePreset} - /> */} - - <RFQTableToolbarActions - selection={table} - onRefresh={() => {}} - rfqType={rfqType} - /> - </div> - </DataTableAdvancedToolbar> - </DataTable> - </div> - </ResizablePanel> - - <ResizableHandle withHandle /> - - <ResizablePanel - minSize={25} - defaultSize={40} - collapsible={false} - className="flex flex-col overflow-hidden" - > - {/* 하단 상세 테이블 영역 */} - <div className="flex-1 min-h-0 overflow-hidden bg-background"> - <RfqDetailTables selectedRfq={selectedRfq} maxHeight={`${(100-panelHeight)*0.4}vh`}/> - </div> - </ResizablePanel> - </ResizablePanelGroup> - </div> - </div> - - {/* 프로젝트 상세정보 다이얼로그 */} - <ProjectDetailDialog - open={isProjectDetailOpen} - onOpenChange={setIsProjectDetailOpen} - // eslint-disable-next-line @typescript-eslint/no-explicit-any - selectedRfq={projectDetailRfq as any} - /> - - {/* 첨부파일 관리 시트 */} - <TechSalesRfqAttachmentsSheet - open={attachmentsOpen} - onOpenChange={setAttachmentsOpen} - defaultAttachments={attachmentsDefault} - rfq={selectedRfqForAttachments} - attachmentType={attachmentType} - onAttachmentsUpdated={handleAttachmentsUpdated} - /> - - {/* 아이템 보기 다이얼로그 */} - <RfqItemsViewDialog - open={itemsDialogOpen} - onOpenChange={setItemsDialogOpen} - rfq={selectedRfqForItems} - /> - </div> - ) +"use client"
+
+import * as React from "react"
+import { useSearchParams } from "next/navigation"
+import { Button } from "@/components/ui/button"
+import { PanelLeftClose, PanelLeftOpen } from "lucide-react"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+import {
+ ResizablePanelGroup,
+ ResizablePanel,
+ ResizableHandle,
+} from "@/components/ui/resizable"
+
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { getColumns } from "./rfq-table-column"
+import { useEffect, useMemo } from "react"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+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 { 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 반환 타입에 맞춤)
+export interface TechSalesRfq {
+ id: number
+ rfqCode: string | null
+ biddingProjectId: number | null
+ materialCode: string | null
+ dueDate: Date
+ rfqSendDate: Date | null
+ status: "RFQ Created" | "RFQ Vendor Assignned" | "RFQ Sent" | "Quotation Analysis" | "Closed"
+ description: string | null
+ picCode: string | null
+ remark: string | null
+ cancelReason: string | null
+ createdAt: Date
+ updatedAt: Date
+ createdBy: number | null
+ createdByName: string
+ updatedBy: number | null
+ updatedByName: string
+ sentBy: number | null
+ sentByName: string | null
+ // 조인된 프로젝트 정보
+ pspid: string
+ projNm: string
+ sector: string
+ projMsrm: number
+ ptypeNm: string
+ attachmentCount: number
+ hasTbeAttachments: boolean
+ hasCbeAttachments: boolean
+ quotationCount: number
+ rfqType: "SHIP" | "TOP" | "HULL" | null
+ itemCount: number
+ workTypes: string | null
+ // 필요에 따라 다른 필드들 추가
+ [key: string]: unknown
+}
+
+interface RFQListTableProps {
+ promises: Promise<[Awaited<ReturnType<typeof getTechSalesRfqsWithJoin>>]>
+ className?: string;
+ calculatedHeight?: string; // 계산된 높이 추가
+ rfqType: "SHIP" | "TOP" | "HULL";
+}
+
+export function RFQListTable({
+ promises,
+ className,
+ calculatedHeight,
+ rfqType
+}: RFQListTableProps) {
+ const searchParams = useSearchParams()
+
+ // 필터 패널 상태
+ const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false)
+
+ // 선택된 RFQ 상태
+ const [selectedRfq, setSelectedRfq] = React.useState<TechSalesRfq | null>(null)
+
+ // 프로젝트 상세정보 다이얼로그 상태
+ const [isProjectDetailOpen, setIsProjectDetailOpen] = React.useState(false)
+ const [projectDetailRfq, setProjectDetailRfq] = React.useState<TechSalesRfq | null>(null)
+
+ // 첨부파일 시트 상태
+ const [attachmentsOpen, setAttachmentsOpen] = React.useState(false)
+ 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)
+
+ // 고정 높이 설정을 위한 상수 (실제 측정값으로 조정 필요)
+ const LAYOUT_HEADER_HEIGHT = 64 // Layout Header 높이
+ const LAYOUT_FOOTER_HEIGHT = 60 // Layout Footer 높이 (있다면 실제 값)
+ const LOCAL_HEADER_HEIGHT = 72 // 로컬 헤더 바 높이 (p-4 + border)
+ const FILTER_PANEL_WIDTH = 400 // 필터 패널 너비
+
+ // 높이 계산
+ // 필터 패널 높이 - Layout Header와 Footer 사이
+ const FIXED_FILTER_HEIGHT = `calc(100vh - ${LAYOUT_HEADER_HEIGHT*2}px)`
+
+ console.log(calculatedHeight)
+
+ // 테이블 컨텐츠 높이 - 전달받은 높이에서 로컬 헤더 제외
+ const FIXED_TABLE_HEIGHT = calculatedHeight
+ ? `calc(${calculatedHeight} - ${LOCAL_HEADER_HEIGHT}px)`
+ : `calc(100vh - ${LAYOUT_HEADER_HEIGHT + LAYOUT_FOOTER_HEIGHT + LOCAL_HEADER_HEIGHT+76}px)` // fallback
+
+ // Suspense 방식으로 데이터 처리
+ const [promiseData] = React.use(promises)
+ const tableData = promiseData
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<TechSalesRfq> | null>(null)
+
+ // 초기 설정 정의
+ const initialSettings = React.useMemo(() => ({
+ page: parseInt(searchParams?.get('page') || '1'),
+ perPage: parseInt(searchParams?.get('perPage') || '10'),
+ sort: searchParams?.get('sort') ? JSON.parse(searchParams.get('sort')!) : [{ id: "updatedAt", desc: true }],
+ filters: searchParams?.get('filters') ? JSON.parse(searchParams.get('filters')!) : [],
+ joinOperator: (searchParams?.get('joinOperator') as "and" | "or") || "and",
+ basicFilters: searchParams?.get('basicFilters') ? JSON.parse(searchParams.get('basicFilters')!) : [],
+ basicJoinOperator: (searchParams?.get('basicJoinOperator') as "and" | "or") || "and",
+ search: searchParams?.get('search') || '',
+ from: searchParams?.get('from') || undefined,
+ to: searchParams?.get('to') || undefined,
+ columnVisibility: {},
+ columnOrder: [],
+ pinnedColumns: { left: [], right: ["items", "attachments", "tbe-attachments", "cbe-attachments"] },
+ groupBy: [],
+ expandedRows: []
+ }), [searchParams])
+
+ // DB 기반 프리셋 훅 사용
+ const {
+ // presets,
+ // activePresetId,
+ // hasUnsavedChanges,
+ // isLoading: presetsLoading,
+ // createPreset,
+ // applyPreset,
+ // updatePreset,
+ // deletePreset,
+ // setDefaultPreset,
+ // renamePreset,
+ getCurrentSettings,
+ } = useTablePresets<TechSalesRfq>('rfq-list-table', initialSettings)
+
+ // 조회 버튼 클릭 핸들러
+ const handleSearch = () => {
+ setIsFilterPanelOpen(false)
+ }
+
+ // 행 액션 처리
+ useEffect(() => {
+ if (rowAction) {
+ switch (rowAction.type) {
+ case "select":
+ // 객체 참조 안정화를 위해 필요한 필드만 추출
+ const rfqData = rowAction.row.original;
+ setSelectedRfq({
+ id: rfqData.id,
+ rfqCode: rfqData.rfqCode,
+ rfqType: rfqData.rfqType, // 빠뜨린 rfqType 필드 추가
+ biddingProjectId: rfqData.biddingProjectId,
+ hasTbeAttachments: rfqData.hasTbeAttachments,
+ hasCbeAttachments: rfqData.hasCbeAttachments,
+ materialCode: rfqData.materialCode,
+ dueDate: rfqData.dueDate,
+ rfqSendDate: rfqData.rfqSendDate,
+ status: rfqData.status,
+ description: rfqData.description,
+ picCode: rfqData.picCode,
+ remark: rfqData.remark,
+ cancelReason: rfqData.cancelReason,
+ createdAt: rfqData.createdAt,
+ updatedAt: rfqData.updatedAt,
+ createdBy: rfqData.createdBy,
+ createdByName: rfqData.createdByName,
+ updatedBy: rfqData.updatedBy,
+ updatedByName: rfqData.updatedByName,
+ sentBy: rfqData.sentBy,
+ sentByName: rfqData.sentByName,
+ pspid: rfqData.pspid,
+ projNm: rfqData.projNm,
+ sector: rfqData.sector,
+ projMsrm: rfqData.projMsrm,
+ ptypeNm: rfqData.ptypeNm,
+ attachmentCount: rfqData.attachmentCount,
+ quotationCount: rfqData.quotationCount,
+ itemCount: rfqData.itemCount,
+ workTypes: rfqData.workTypes,
+ });
+ break;
+ case "view":
+ // 프로젝트 상세정보 다이얼로그 열기
+ const projectRfqData = rowAction.row.original;
+ setProjectDetailRfq({
+ id: projectRfqData.id,
+ rfqCode: projectRfqData.rfqCode,
+ rfqType: projectRfqData.rfqType, // 빠뜨린 rfqType 필드 추가
+ biddingProjectId: projectRfqData.biddingProjectId,
+ hasTbeAttachments: projectRfqData.hasTbeAttachments,
+ hasCbeAttachments: projectRfqData.hasCbeAttachments,
+ materialCode: projectRfqData.materialCode,
+ dueDate: projectRfqData.dueDate,
+ rfqSendDate: projectRfqData.rfqSendDate,
+ status: projectRfqData.status,
+ description: projectRfqData.description,
+ picCode: projectRfqData.picCode,
+ remark: projectRfqData.remark,
+ cancelReason: projectRfqData.cancelReason,
+ createdAt: projectRfqData.createdAt,
+ updatedAt: projectRfqData.updatedAt,
+ createdBy: projectRfqData.createdBy,
+ createdByName: projectRfqData.createdByName,
+ updatedBy: projectRfqData.updatedBy,
+ updatedByName: projectRfqData.updatedByName,
+ sentBy: projectRfqData.sentBy,
+ sentByName: projectRfqData.sentByName,
+ pspid: projectRfqData.pspid,
+ projNm: projectRfqData.projNm,
+ sector: projectRfqData.sector,
+ projMsrm: projectRfqData.projMsrm,
+ ptypeNm: projectRfqData.ptypeNm,
+ attachmentCount: projectRfqData.attachmentCount,
+ quotationCount: projectRfqData.quotationCount,
+ itemCount: projectRfqData.itemCount,
+ workTypes: projectRfqData.workTypes,
+ });
+ setIsProjectDetailOpen(true);
+ break;
+ case "update":
+ console.log("Update rfq:", rowAction.row.original)
+ break;
+ case "delete":
+ console.log("Delete rfq:", rowAction.row.original)
+ break;
+ }
+ setRowAction(null)
+ }
+ }, [rowAction])
+
+ // 첨부파일 시트 상태에 타입 추가
+ const [attachmentType, setAttachmentType] = React.useState<"RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT">("RFQ_COMMON")
+
+ // 첨부파일 시트 열기 함수
+ const openAttachmentsSheet = React.useCallback(async (rfqId: number, attachmentType: 'RFQ_COMMON' | 'TBE_RESULT' | 'CBE_RESULT' = 'RFQ_COMMON') => {
+ try {
+ // 선택된 RFQ 찾기
+ const rfq = tableData?.data?.find(r => r.id === rfqId)
+ if (!rfq) {
+ toast.error("RFQ를 찾을 수 없습니다.")
+ return
+ }
+
+ // attachmentType을 RFQ_COMMON, TBE_RESULT, CBE_RESULT 중 하나로 변환
+ const validAttachmentType=attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT"
+
+ // 실제 첨부파일 목록 조회 API 호출
+ const result = await getTechSalesRfqAttachments(rfqId)
+
+ if (result.error) {
+ toast.error(result.error)
+ return
+ }
+
+ // 해당 타입의 첨부파일만 필터링
+ const filteredAttachments = result.data.filter(att => att.attachmentType === validAttachmentType)
+
+ // API 응답을 ExistingTechSalesAttachment 형식으로 변환
+ const attachments: ExistingTechSalesAttachment[] = filteredAttachments.map(att => ({
+ id: att.id,
+ techSalesRfqId: att.techSalesRfqId || rfqId, // null인 경우 rfqId 사용
+ fileName: att.fileName,
+ originalFileName: att.originalFileName,
+ filePath: att.filePath,
+ fileSize: att.fileSize || undefined,
+ fileType: att.fileType || undefined,
+ attachmentType: att.attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT",
+ description: att.description || undefined,
+ createdBy: att.createdBy,
+ createdAt: att.createdAt,
+ }))
+
+ setAttachmentType(validAttachmentType)
+ setAttachmentsDefault(attachments)
+ setSelectedRfqForAttachments(rfq as unknown as TechSalesRfq)
+ setAttachmentsOpen(true)
+ } catch (error) {
+ console.error("첨부파일 조회 오류:", error)
+ toast.error("첨부파일 조회 중 오류가 발생했습니다.")
+ }
+ }, [tableData?.data])
+
+ // // 첨부파일 업데이트 콜백
+ // const handleAttachmentsUpdated = React.useCallback((rfqId: number, newAttachmentCount: number) => {
+ // // Service에서 이미 revalidateTag와 revalidatePath로 캐시 무효화 처리됨
+ // console.log(`RFQ ${rfqId}의 첨부파일 개수가 ${newAttachmentCount}개로 업데이트됨`)
+
+ // // 성공 피드백
+ // setTimeout(() => {
+ // toast.success(`첨부파일 개수가 업데이트되었습니다. (${newAttachmentCount}개)`, {
+ // duration: 3000
+ // })
+ // }, 500)
+ // }, [])
+
+ // 아이템 다이얼로그 열기 함수
+ const openItemsDialog = React.useCallback((rfq: TechSalesRfq) => {
+ console.log("Opening items dialog for RFQ:", rfq.id, rfq)
+ setSelectedRfqForItems(rfq)
+ setItemsDialogOpen(true)
+ }, [])
+
+ const columns = React.useMemo(
+ () => getColumns({
+ setRowAction,
+ openAttachmentsSheet,
+ openItemsDialog
+ }),
+ [openAttachmentsSheet, openItemsDialog, setRowAction]
+ )
+
+ // 고급 필터 필드 정의
+ const advancedFilterFields: DataTableAdvancedFilterField<TechSalesRfq>[] = [
+ {
+ id: "rfqCode",
+ label: "RFQ No.",
+ type: "text",
+ },
+ {
+ id: "description",
+ label: "설명",
+ type: "text",
+ },
+ {
+ id: "projNm",
+ label: "프로젝트명",
+ type: "text",
+ },
+ {
+ id: "rfqSendDate",
+ label: "RFQ 전송일",
+ type: "date",
+ },
+ {
+ id: "dueDate",
+ label: "RFQ 마감일",
+ type: "date",
+ },
+ {
+ id: "createdByName",
+ label: "요청자",
+ type: "text",
+ },
+ {
+ id: "status",
+ label: "상태",
+ type: "text",
+ },
+ {
+ id: "workTypes",
+ label: "Work Type",
+ type: "multi-select",
+ options: [
+ // 조선 workTypes
+ { label: "기장", value: "기장" },
+ { label: "전장", value: "전장" },
+ { label: "선실", value: "선실" },
+ { label: "배관", value: "배관" },
+ { label: "철의", value: "철의" },
+ { label: "선체", value: "선체" },
+ // 해양TOP workTypes
+ { label: "TM", value: "TM" },
+ { label: "TS", value: "TS" },
+ { label: "TE", value: "TE" },
+ { label: "TP", value: "TP" },
+ // 해양HULL workTypes
+ { label: "HA", value: "HA" },
+ { label: "HE", value: "HE" },
+ { label: "HH", value: "HH" },
+ { label: "HM", value: "HM" },
+ { label: "NC", value: "NC" },
+ { label: "HO", value: "HO" },
+ { label: "HP", value: "HP" },
+ ],
+ },
+ ]
+
+ // 현재 설정 가져오기
+ const currentSettings = useMemo(() => {
+ return getCurrentSettings()
+ }, [getCurrentSettings])
+
+ // useDataTable 초기 상태 설정
+ const initialState = useMemo(() => {
+ return {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ sorting: initialSettings.sort.filter((sortItem: any) => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const columnExists = columns.some((col: any) => col.accessorKey === sortItem.id)
+ return columnExists
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ }) as any,
+ columnVisibility: currentSettings.columnVisibility,
+ columnPinning: currentSettings.pinnedColumns,
+ }
+ }, [currentSettings, initialSettings.sort, columns])
+
+ // useDataTable 훅 설정
+ const { table } = useDataTable({
+ data: tableData?.data || [],
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ columns: columns as any,
+ pageCount: tableData?.pageCount || 0,
+ rowCount: tableData?.total || 0,
+ filterFields: [],
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState,
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ columnResizeMode: "onEnd",
+ })
+
+ // Get active basic filter count
+ const getActiveBasicFilterCount = () => {
+ try {
+ const basicFilters = searchParams?.get('basicFilters')
+ return basicFilters ? JSON.parse(basicFilters).length : 0
+ } catch {
+ return 0
+ }
+ }
+
+ console.log(panelHeight)
+
+ return (
+ <div
+ className={cn("flex flex-col relative", className)}
+ style={{ height: calculatedHeight }}
+ >
+ {/* Filter Panel - 계산된 높이 적용 */}
+ <div
+ className={cn(
+ "fixed left-0 bg-background border-r z-30 flex flex-col transition-all duration-300 ease-in-out overflow-hidden",
+ isFilterPanelOpen ? "border-r shadow-lg" : "border-r-0"
+ )}
+ style={{
+ width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px',
+ top: `${LAYOUT_HEADER_HEIGHT*2}px`,
+ height: FIXED_FILTER_HEIGHT
+ }}
+ >
+ {/* Filter Content */}
+ <div className="h-full">
+ <RFQFilterSheet
+ isOpen={isFilterPanelOpen}
+ onClose={() => setIsFilterPanelOpen(false)}
+ onSearch={handleSearch}
+ isLoading={false}
+ />
+ </div>
+ </div>
+
+ {/* Main Content */}
+ <div
+ className="flex flex-col transition-all duration-300 ease-in-out"
+ style={{
+ width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : '100%',
+ marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px',
+ height: '100%'
+ }}
+ >
+ {/* Header Bar - 고정 높이 */}
+ <div
+ className="flex items-center justify-between p-4 bg-background border-b"
+ style={{
+ height: `${LOCAL_HEADER_HEIGHT}px`,
+ flexShrink: 0
+ }}
+ >
+ <div className="flex items-center gap-3">
+ <Button
+ variant="outline"
+ size="sm"
+ type='button'
+ onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)}
+ className="flex items-center shadow-sm"
+ >
+ {isFilterPanelOpen ? <PanelLeftClose className="size-4"/> : <PanelLeftOpen className="size-4"/>}
+ {getActiveBasicFilterCount() > 0 && (
+ <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs">
+ {getActiveBasicFilterCount()}
+ </span>
+ )}
+ </Button>
+ </div>
+
+ {/* Right side info */}
+ <div className="text-sm text-muted-foreground">
+ {tableData && (
+ <span>총 {tableData.total || 0}건</span>
+ )}
+ </div>
+ </div>
+
+ {/* Table Content Area - 계산된 높이 사용 */}
+ <div
+ className="relative bg-background"
+ style={{
+ height: FIXED_TABLE_HEIGHT,
+ display: 'grid',
+ gridTemplateRows: '1fr',
+ gridTemplateColumns: '1fr'
+ }}
+ >
+ <ResizablePanelGroup
+ direction="vertical"
+ className="w-full h-full"
+ >
+ <ResizablePanel
+ defaultSize={60}
+ minSize={25}
+ maxSize={75}
+ collapsible={false}
+ onResize={(size) => {
+ setPanelHeight(size)
+ }}
+ className="flex flex-col overflow-hidden"
+ >
+ {/* 상단 테이블 영역 */}
+ <div className="flex-1 min-h-0 overflow-hidden">
+ <DataTable
+ table={table}
+ maxHeight={`${panelHeight*0.5}vh`}
+ >
+ <DataTableAdvancedToolbar
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ table={table as any}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <div className="flex items-center gap-2">
+ {/* 아직 개발/테스트 전이라서 주석처리함. 지우지 말 것! 미사용 린터 에러는 무시. */}
+ {/* <TablePresetManager<TechSalesRfq>
+ presets={presets}
+ activePresetId={activePresetId}
+ currentSettings={currentSettings}
+ hasUnsavedChanges={hasUnsavedChanges}
+ isLoading={presetsLoading}
+ onCreatePreset={createPreset}
+ onUpdatePreset={updatePreset}
+ onDeletePreset={deletePreset}
+ onApplyPreset={applyPreset}
+ onSetDefaultPreset={setDefaultPreset}
+ onRenamePreset={renamePreset}
+ /> */}
+
+ <RFQTableToolbarActions
+ selection={table}
+ onRefresh={() => {}}
+ rfqType={rfqType}
+ />
+ </div>
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </div>
+ </ResizablePanel>
+
+ <ResizableHandle withHandle />
+
+ <ResizablePanel
+ minSize={25}
+ defaultSize={40}
+ collapsible={false}
+ className="flex flex-col overflow-hidden"
+ >
+ {/* 하단 상세 테이블 영역 */}
+ <div className="flex-1 min-h-0 overflow-hidden bg-background">
+ <RfqDetailTables selectedRfq={selectedRfq} maxHeight={`${(100-panelHeight)*0.4}vh`}/>
+ </div>
+ </ResizablePanel>
+ </ResizablePanelGroup>
+ </div>
+ </div>
+
+ {/* 프로젝트 상세정보 다이얼로그 */}
+ <ProjectDetailDialog
+ open={isProjectDetailOpen}
+ onOpenChange={setIsProjectDetailOpen}
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ selectedRfq={projectDetailRfq as any}
+ />
+
+ {/* 첨부파일 관리 시트 */}
+ <TechSalesRfqAttachmentsSheet
+ open={attachmentsOpen}
+ onOpenChange={setAttachmentsOpen}
+ defaultAttachments={attachmentsDefault}
+ rfq={selectedRfqForAttachments}
+ attachmentType={attachmentType}
+ />
+
+ {/* 아이템 보기 다이얼로그 */}
+ <RfqItemsViewDialog
+ open={itemsDialogOpen}
+ onOpenChange={setItemsDialogOpen}
+ rfq={selectedRfqForItems ? {
+ id: selectedRfqForItems.id,
+ rfqCode: selectedRfqForItems.rfqCode,
+ status: selectedRfqForItems.status,
+ description: selectedRfqForItems.description || undefined,
+ rfqType: selectedRfqForItems.rfqType
+ } : null}
+ />
+ </div>
+ )
}
\ No newline at end of file diff --git a/lib/techsales-rfq/table/tech-sales-quotation-attachments-sheet.tsx b/lib/techsales-rfq/table/tech-sales-quotation-attachments-sheet.tsx index 08363535..a03839c1 100644 --- a/lib/techsales-rfq/table/tech-sales-quotation-attachments-sheet.tsx +++ b/lib/techsales-rfq/table/tech-sales-quotation-attachments-sheet.tsx @@ -14,7 +14,6 @@ import { Badge } from "@/components/ui/badge" import { Separator } from "@/components/ui/separator"
import { formatDate } from "@/lib/utils"
import prettyBytes from "pretty-bytes"
-import { downloadFile } from "@/lib/file-download"
// 견적서 첨부파일 타입 정의
export interface QuotationAttachment {
@@ -80,20 +79,26 @@ export function TechSalesQuotationAttachmentsSheet({ // 기본 파일
return <File className="h-5 w-5 text-gray-500" />;
};
-
- // 파일 다운로드 처리
- const handleDownload = (attachment: QuotationAttachment) => {
- downloadFile(attachment.filePath, attachment.originalFileName || attachment.fileName)
- /*
- const link = document.createElement('a');
- link.href = attachment.filePath;
- link.download = attachment.originalFileName || attachment.fileName;
- link.target = '_blank';
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
- */
- };
+
+ // 파일 다운로드 핸들러
+ const handleDownloadClick = React.useCallback(async (attachment: QuotationAttachment) => {
+ try {
+ const { downloadFile } = await import('@/lib/file-download');
+ await downloadFile(attachment.filePath, attachment.originalFileName || attachment.fileName, {
+ showToast: true,
+ onError: (error) => {
+ console.error('다운로드 오류:', error);
+ // TODO: toast 에러 메시지 추가 (sonner import 필요)
+ },
+ onSuccess: (fileName, fileSize) => {
+ console.log(`다운로드 성공: ${fileName} (${fileSize} bytes)`);
+ }
+ });
+ } catch (error) {
+ console.error('다운로드 오류:', error);
+ // TODO: toast 에러 메시지 추가 (sonner import 필요)
+ }
+ }, []);
// 리비전별로 첨부파일 그룹핑
const groupedAttachments = React.useMemo(() => {
@@ -176,7 +181,7 @@ export function TechSalesQuotationAttachmentsSheet({ className="flex items-start gap-3 p-3 border rounded-lg hover:bg-muted/50 transition-colors ml-4"
>
<div className="mt-1">
- {getFileIcon(attachment.fileName)}
+ {getFileIcon(attachment.originalFileName || attachment.fileName)}
</div>
<div className="flex-1 min-w-0">
@@ -211,7 +216,7 @@ export function TechSalesQuotationAttachmentsSheet({ variant="ghost"
size="icon"
className="h-8 w-8"
- onClick={() => handleDownload(attachment)}
+ onClick={() => handleDownloadClick(attachment)}
title="다운로드"
>
<Download className="h-4 w-4" />
diff --git a/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx b/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx index fccedf0a..f2ae1084 100644 --- a/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx +++ b/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx @@ -1,550 +1,570 @@ -"use client" - -import * as React from "react" -import { z } from "zod" -import { useForm, useFieldArray } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import { cn } from "@/lib/utils" -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, - SheetDescription, - SheetFooter, - SheetClose, -} from "@/components/ui/sheet" -import { Button } from "@/components/ui/button" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, - FormDescription -} from "@/components/ui/form" -import { Loader, Download, X, Eye, AlertCircle } from "lucide-react" -import { toast } from "sonner" -import { Badge } from "@/components/ui/badge" - -import { - Dropzone, - DropzoneDescription, - DropzoneInput, - DropzoneTitle, - DropzoneUploadIcon, - DropzoneZone, -} from "@/components/ui/dropzone" -import { - FileList, - FileListAction, - FileListDescription, - FileListHeader, - FileListIcon, - FileListInfo, - FileListItem, - FileListName, -} from "@/components/ui/file-list" - -import prettyBytes from "pretty-bytes" -import { formatDate } from "@/lib/utils" -import { processTechSalesRfqAttachments } from "@/lib/techsales-rfq/service" -import { useSession } from "next-auth/react" -import { downloadFile } from "@/lib/file-download" - -const MAX_FILE_SIZE = 6e8 // 600MB - -/** 기존 첨부 파일 정보 (techSalesAttachments 테이블 구조) */ -export interface ExistingTechSalesAttachment { - id: number - techSalesRfqId: number - fileName: string - originalFileName: string - filePath: string - fileSize?: number - fileType?: string - attachmentType: "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT" - description?: string - createdBy: number - createdAt: Date -} - -/** 새로 업로드할 파일 */ -const newUploadSchema = z.object({ - fileObj: z.any().optional(), // 실제 File - attachmentType: z.enum(["RFQ_COMMON", "TBE_RESULT", "CBE_RESULT"]).default("RFQ_COMMON"), - description: z.string().optional(), -}) - -/** 기존 첨부 (react-hook-form에서 관리) */ -const existingAttachSchema = z.object({ - id: z.number(), - techSalesRfqId: z.number(), - fileName: z.string(), - originalFileName: z.string(), - filePath: z.string(), - fileSize: z.number().optional(), - fileType: z.string().optional(), - attachmentType: z.enum(["RFQ_COMMON", "TBE_RESULT", "CBE_RESULT"]), - description: z.string().optional(), - createdBy: z.number(), - createdAt: z.custom<Date>(), -}) - -/** RHF 폼 전체 스키마 */ -const attachmentsFormSchema = z.object({ - techSalesRfqId: z.number().int(), - existing: z.array(existingAttachSchema), - newUploads: z.array(newUploadSchema), -}) - -type AttachmentsFormValues = z.infer<typeof attachmentsFormSchema> - -// TechSalesRfq 타입 (간단 버전) -interface TechSalesRfq { - id: number - rfqCode: string | null - status: string - // 필요한 다른 필드들... -} - -interface TechSalesRfqAttachmentsSheetProps - extends React.ComponentPropsWithRef<typeof Sheet> { - defaultAttachments?: ExistingTechSalesAttachment[] - rfq: TechSalesRfq | null - /** 첨부파일 타입 */ - attachmentType?: "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT" - /** 읽기 전용 모드 (벤더용) */ - readOnly?: boolean - /** 업로드/삭제 후 상위 테이블에 attachmentCount 등을 업데이트하기 위한 콜백 */ - // onAttachmentsUpdated?: (rfqId: number, newAttachmentCount: number) => void - -} - -export function TechSalesRfqAttachmentsSheet({ - defaultAttachments = [], - // onAttachmentsUpdated, - rfq, - attachmentType = "RFQ_COMMON", - readOnly = false, - ...props -}: TechSalesRfqAttachmentsSheetProps) { - const [isPending, setIsPending] = React.useState(false) - const session = useSession() - - // 첨부파일 타입별 제목과 설명 설정 - const attachmentConfig = React.useMemo(() => { - switch (attachmentType) { - case "TBE_RESULT": - return { - title: "TBE 결과 첨부파일", - description: "기술 평가(TBE) 결과 파일을 관리합니다.", - fileTypeLabel: "TBE 결과", - canEdit: !readOnly - } - case "CBE_RESULT": - return { - title: "CBE 결과 첨부파일", - description: "상업성 평가(CBE) 결과 파일을 관리합니다.", - fileTypeLabel: "CBE 결과", - canEdit: !readOnly - } - default: // RFQ_COMMON - return { - title: "RFQ 첨부파일", - description: readOnly ? "RFQ 공통 첨부파일을 조회합니다." : "RFQ 공통 첨부파일을 관리합니다.", - fileTypeLabel: "공통", - canEdit: !readOnly - } - } - }, [attachmentType, readOnly]) - - // // RFQ 상태에 따른 편집 가능 여부 결정 (readOnly prop이 true면 항상 false) - // const isEditable = React.useMemo(() => { - // if (!rfq) return false - // return attachmentConfig.canEdit - // }, [rfq, attachmentConfig.canEdit]) - - const form = useForm<AttachmentsFormValues>({ - resolver: zodResolver(attachmentsFormSchema), - defaultValues: { - techSalesRfqId: rfq?.id || 0, - existing: defaultAttachments.map(att => ({ - id: att.id, - techSalesRfqId: att.techSalesRfqId, - fileName: att.fileName, - originalFileName: att.originalFileName, - filePath: att.filePath, - fileSize: att.fileSize || undefined, - fileType: att.fileType || undefined, - attachmentType: att.attachmentType, - description: att.description || undefined, - createdBy: att.createdBy, - createdAt: att.createdAt, - })), - newUploads: [], - }, - }) - - // useFieldArray for existing and new uploads - const { - fields: existingFields, - remove: removeExisting, - } = useFieldArray({ - control: form.control, - name: "existing", - }) - - const { - fields: newUploadFields, - append: appendNewUpload, - remove: removeNewUpload, - } = useFieldArray({ - control: form.control, - name: "newUploads", - }) - - // Reset form when defaultAttachments changes - React.useEffect(() => { - if (defaultAttachments) { - form.reset({ - techSalesRfqId: rfq?.id || 0, - existing: defaultAttachments.map(att => ({ - id: att.id, - techSalesRfqId: att.techSalesRfqId, - fileName: att.fileName, - originalFileName: att.originalFileName, - filePath: att.filePath, - fileSize: att.fileSize || undefined, - fileType: att.fileType || undefined, - attachmentType: att.attachmentType, - description: att.description || undefined, - createdBy: att.createdBy, - createdAt: att.createdAt, - })), - newUploads: [], - }) - } - }, [defaultAttachments, rfq?.id, form]) - - // Handle dropzone accept - const handleDropAccepted = React.useCallback((acceptedFiles: File[]) => { - acceptedFiles.forEach((file) => { - appendNewUpload({ - fileObj: file, - attachmentType: "RFQ_COMMON", - description: "", - }) - }) - }, [appendNewUpload]) - - // Handle dropzone reject - const handleDropRejected = React.useCallback(() => { - toast.error("파일 크기가 너무 크거나 지원하지 않는 파일 형식입니다.") - }, []) - - // Handle remove existing attachment - const handleRemoveExisting = React.useCallback((index: number) => { - removeExisting(index) - }, [removeExisting]) - - // Handle form submission - const onSubmit = async (data: AttachmentsFormValues) => { - if (!rfq) { - toast.error("RFQ 정보를 찾을 수 없습니다.") - return - } - - setIsPending(true) - try { - // 삭제할 첨부파일 ID 수집 - const deleteAttachmentIds = defaultAttachments - .filter((original) => !data.existing.find(existing => existing.id === original.id)) - .map(attachment => attachment.id) - - // 새 파일 정보 수집 - const newFiles = data.newUploads - .filter(upload => upload.fileObj) - .map(upload => ({ - file: upload.fileObj as File, - attachmentType: attachmentType, - description: upload.description, - })) - - // 실제 API 호출 - const result = await processTechSalesRfqAttachments({ - techSalesRfqId: rfq.id, - newFiles, - deleteAttachmentIds, - createdBy: parseInt(session.data?.user.id || "0"), - }) - - if (result.error) { - toast.error(result.error) - return - } - - // 성공 메시지 표시 (업로드된 파일 수 포함) - const uploadedCount = newFiles.length - const deletedCount = deleteAttachmentIds.length - - let successMessage = "첨부파일이 저장되었습니다." - if (uploadedCount > 0 && deletedCount > 0) { - successMessage = `${uploadedCount}개 파일 업로드, ${deletedCount}개 파일 삭제 완료` - } else if (uploadedCount > 0) { - successMessage = `${uploadedCount}개 파일이 업로드되었습니다.` - } else if (deletedCount > 0) { - successMessage = `${deletedCount}개 파일이 삭제되었습니다.` - } - - toast.success(successMessage) - - // // 즉시 첨부파일 목록 새로고침 - // const refreshResult = await getTechSalesRfqAttachments(rfq.id) - // if (refreshResult.error) { - // console.error("첨부파일 목록 새로고침 실패:", refreshResult.error) - // toast.warning("첨부파일 목록 새로고침에 실패했습니다. 시트를 다시 열어주세요.") - // } else { - // // 새로운 첨부파일 목록으로 폼 업데이트 - // const refreshedAttachments = refreshResult.data.map(att => ({ - // id: att.id, - // techSalesRfqId: att.techSalesRfqId || rfq.id, - // fileName: att.fileName, - // originalFileName: att.originalFileName, - // filePath: att.filePath, - // fileSize: att.fileSize, - // fileType: att.fileType, - // attachmentType: att.attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT", - // description: att.description, - // createdBy: att.createdBy, - // createdAt: att.createdAt, - // })) - - // // 폼을 새로운 데이터로 리셋 (새 업로드 목록은 비움) - // form.reset({ - // techSalesRfqId: rfq.id, - // existing: refreshedAttachments.map(att => ({ - // ...att, - // fileSize: att.fileSize || undefined, - // fileType: att.fileType || undefined, - // description: att.description || undefined, - // })), - // newUploads: [], - // }) - - // // 즉시 UI 업데이트를 위한 추가 피드백 - // if (uploadedCount > 0) { - // toast.success("첨부파일 목록이 업데이트되었습니다.", { duration: 2000 }) - // } - // } - - // // 콜백으로 상위 컴포넌트에 변경사항 알림 - // const newAttachmentCount = refreshResult.error ? - // (data.existing.length + newFiles.length - deleteAttachmentIds.length) : - // refreshResult.data.length - // onAttachmentsUpdated?.(rfq.id, newAttachmentCount) - - } catch (error) { - console.error("첨부파일 저장 오류:", error) - toast.error("첨부파일 저장 중 오류가 발생했습니다.") - } finally { - setIsPending(false) - } - } - - return ( - <Sheet {...props}> - <SheetContent className="flex flex-col gap-6 sm:max-w-md"> - <SheetHeader className="text-left"> - <SheetTitle>{attachmentConfig.title}</SheetTitle> - <SheetDescription> - <div>RFQ: {rfq?.rfqCode || "N/A"}</div> - <div className="mt-1">{attachmentConfig.description}</div> - {!attachmentConfig.canEdit && ( - <div className="mt-2 flex items-center gap-2 text-amber-600"> - <AlertCircle className="h-4 w-4" /> - <span className="text-sm">현재 상태에서는 편집할 수 없습니다</span> - </div> - )} - </SheetDescription> - </SheetHeader> - - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-1 flex-col gap-6"> - {/* 1) Existing attachments */} - <div className="grid gap-4"> - <h6 className="font-semibold leading-none tracking-tight"> - 기존 첨부파일 ({existingFields.length}개) - </h6> - {existingFields.map((field, index) => { - const typeLabel = attachmentConfig.fileTypeLabel - const sizeText = field.fileSize ? prettyBytes(field.fileSize) : "알 수 없음" - const dateText = field.createdAt ? formatDate(field.createdAt, "KR") : "" - - return ( - <div key={field.id} className="flex items-start justify-between p-3 border rounded-md gap-3"> - <div className="flex-1 min-w-0 overflow-hidden"> - <div className="flex items-center gap-2 mb-1 flex-wrap"> - <p className="text-sm font-medium break-words leading-tight"> - {field.originalFileName || field.fileName} - </p> - <Badge variant="outline" className="text-xs shrink-0"> - {typeLabel} - </Badge> - </div> - <p className="text-xs text-muted-foreground"> - {sizeText} • {dateText} - </p> - {field.description && ( - <p className="text-xs text-muted-foreground mt-1 break-words"> - {field.description} - </p> - )} - </div> - - <div className="flex items-center gap-1 shrink-0"> - {/* Download button */} - {field.filePath && ( - <a - // href={`/api/tech-sales-rfq-download?path=${encodeURIComponent(field.filePath)}`} - // download={field.originalFileName || field.fileName} - onClick={() => downloadFile(field.filePath, field.originalFileName || field.fileName)} - className="inline-block" - > - <Button variant="ghost" size="icon" type="button" className="h-8 w-8"> - <Download className="h-4 w-4" /> - </Button> - </a> - )} - {/* Remove button - 편집 가능할 때만 표시 */} - {attachmentConfig.canEdit && ( - <Button - type="button" - variant="ghost" - size="icon" - className="h-8 w-8" - onClick={() => handleRemoveExisting(index)} - > - <X className="h-4 w-4" /> - </Button> - )} - </div> - </div> - ) - })} - </div> - - {/* 2) Dropzone for new uploads - 편집 가능할 때만 표시 */} - {attachmentConfig.canEdit ? ( - <> - <Dropzone - maxSize={MAX_FILE_SIZE} - onDropAccepted={handleDropAccepted} - onDropRejected={handleDropRejected} - > - {({ maxSize }) => ( - <FormField - control={form.control} - name="newUploads" - render={() => ( - <FormItem> - <FormLabel>새 파일 업로드</FormLabel> - <DropzoneZone className="flex justify-center"> - <FormControl> - <DropzoneInput /> - </FormControl> - <div className="flex items-center gap-6"> - <DropzoneUploadIcon /> - <div className="grid gap-0.5"> - <DropzoneTitle>파일을 드래그하거나 클릭하세요</DropzoneTitle> - <DropzoneDescription> - 최대 크기: {maxSize ? prettyBytes(maxSize) : "600MB"} - </DropzoneDescription> - </div> - </div> - </DropzoneZone> - <FormDescription>파일을 여러 개 선택할 수 있습니다.</FormDescription> - <FormMessage /> - </FormItem> - )} - /> - )} - </Dropzone> - - {/* newUpload fields -> FileList */} - {newUploadFields.length > 0 && ( - <div className="grid gap-4"> - <h6 className="font-semibold leading-none tracking-tight"> - 새 파일 ({newUploadFields.length}개) - </h6> - <FileList> - {newUploadFields.map((field, idx) => { - const fileObj = form.getValues(`newUploads.${idx}.fileObj`) - if (!fileObj) return null - - const fileName = fileObj.name - const fileSize = fileObj.size - return ( - <FileListItem key={field.id}> - <FileListHeader> - <FileListIcon /> - <FileListInfo> - <FileListName>{fileName}</FileListName> - <FileListDescription> - {prettyBytes(fileSize)} - </FileListDescription> - </FileListInfo> - <FileListAction onClick={() => removeNewUpload(idx)}> - <X /> - <span className="sr-only">제거</span> - </FileListAction> - </FileListHeader> - - </FileListItem> - ) - })} - </FileList> - </div> - )} - </> - ) : ( - <div className="p-3 bg-muted rounded-md flex items-center justify-center"> - <div className="text-center text-sm text-muted-foreground"> - <Eye className="h-4 w-4 mx-auto mb-2" /> - <p>보기 모드에서는 파일 첨부를 할 수 없습니다.</p> - </div> - </div> - )} - - <SheetFooter className="gap-2 pt-2 sm:space-x-0"> - <SheetClose asChild> - <Button type="button" variant="outline"> - {attachmentConfig.canEdit ? "취소" : "닫기"} - </Button> - </SheetClose> - {attachmentConfig.canEdit && ( - <Button - type="submit" - disabled={ - isPending || - ( - form.getValues().newUploads.length === 0 && - form.getValues().existing.length === defaultAttachments.length && - form.getValues().existing.every(existing => - defaultAttachments.some(original => original.id === existing.id) - ) - ) - } - > - {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />} - {isPending ? "저장 중..." : "저장"} - </Button> - )} - </SheetFooter> - </form> - </Form> - </SheetContent> - </Sheet> - ) +"use client"
+
+import * as React from "react"
+import { z } from "zod"
+import { useForm, useFieldArray } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+ SheetDescription,
+ SheetFooter,
+ SheetClose,
+} from "@/components/ui/sheet"
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormDescription
+} from "@/components/ui/form"
+import { Loader, Download, X, Eye, AlertCircle } from "lucide-react"
+import { toast } from "sonner"
+import { Badge } from "@/components/ui/badge"
+
+import {
+ Dropzone,
+ DropzoneDescription,
+ DropzoneInput,
+ DropzoneTitle,
+ DropzoneUploadIcon,
+ DropzoneZone,
+} from "@/components/ui/dropzone"
+import {
+ FileList,
+ FileListAction,
+ FileListDescription,
+ FileListHeader,
+ FileListIcon,
+ FileListInfo,
+ FileListItem,
+ FileListName,
+} from "@/components/ui/file-list"
+
+import prettyBytes from "pretty-bytes"
+import { formatDate } from "@/lib/utils"
+import { processTechSalesRfqAttachments } from "@/lib/techsales-rfq/service"
+import { useSession } from "next-auth/react"
+
+const MAX_FILE_SIZE = 6e8 // 600MB
+
+/** 기존 첨부 파일 정보 (techSalesAttachments 테이블 구조) */
+export interface ExistingTechSalesAttachment {
+ id: number
+ techSalesRfqId: number
+ fileName: string
+ originalFileName: string
+ filePath: string
+ fileSize?: number
+ fileType?: string
+ attachmentType: "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT"
+ description?: string
+ createdBy: number
+ createdAt: Date
+}
+
+/** 새로 업로드할 파일 */
+const newUploadSchema = z.object({
+ fileObj: z.any().optional(), // 실제 File
+ attachmentType: z.enum(["RFQ_COMMON", "TBE_RESULT", "CBE_RESULT"]).default("RFQ_COMMON"),
+ description: z.string().optional(),
+})
+
+/** 기존 첨부 (react-hook-form에서 관리) */
+const existingAttachSchema = z.object({
+ id: z.number(),
+ techSalesRfqId: z.number(),
+ fileName: z.string(),
+ originalFileName: z.string(),
+ filePath: z.string(),
+ fileSize: z.number().optional(),
+ fileType: z.string().optional(),
+ attachmentType: z.enum(["RFQ_COMMON", "TBE_RESULT", "CBE_RESULT"]),
+ description: z.string().optional(),
+ createdBy: z.number(),
+ createdAt: z.custom<Date>(),
+})
+
+/** RHF 폼 전체 스키마 */
+const attachmentsFormSchema = z.object({
+ techSalesRfqId: z.number().int(),
+ existing: z.array(existingAttachSchema),
+ newUploads: z.array(newUploadSchema),
+})
+
+type AttachmentsFormValues = z.infer<typeof attachmentsFormSchema>
+
+// TechSalesRfq 타입 (간단 버전)
+interface TechSalesRfq {
+ id: number
+ rfqCode: string | null
+ status: string
+ // 필요한 다른 필드들...
+}
+
+interface TechSalesRfqAttachmentsSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ defaultAttachments?: ExistingTechSalesAttachment[]
+ rfq: TechSalesRfq | null
+ /** 첨부파일 타입 */
+ attachmentType?: "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT"
+ /** 읽기 전용 모드 (벤더용) */
+ readOnly?: boolean
+ /** 업로드/삭제 후 상위 테이블에 attachmentCount 등을 업데이트하기 위한 콜백 */
+ // onAttachmentsUpdated?: (rfqId: number, newAttachmentCount: number) => void
+
+}
+
+export function TechSalesRfqAttachmentsSheet({
+ defaultAttachments = [],
+ // onAttachmentsUpdated,
+ rfq,
+ attachmentType = "RFQ_COMMON",
+ readOnly = false,
+ ...props
+}: TechSalesRfqAttachmentsSheetProps) {
+ const [isPending, setIsPending] = React.useState(false)
+ const session = useSession()
+
+ // 파일 다운로드 핸들러
+ const handleDownloadClick = React.useCallback(async (filePath: string, fileName: string) => {
+ try {
+ const { downloadFile } = await import('@/lib/file-download')
+ await downloadFile(filePath, fileName, {
+ showToast: true,
+ onError: (error) => {
+ console.error('다운로드 오류:', error)
+ toast.error(error)
+ },
+ onSuccess: (fileName, fileSize) => {
+ console.log(`다운로드 성공: ${fileName} (${fileSize} bytes)`)
+ }
+ })
+ } catch (error) {
+ console.error('다운로드 오류:', error)
+ toast.error('파일 다운로드 중 오류가 발생했습니다.')
+ }
+ }, [])
+ // 첨부파일 타입별 제목과 설명 설정
+ const attachmentConfig = React.useMemo(() => {
+ switch (attachmentType) {
+ case "TBE_RESULT":
+ return {
+ title: "TBE 결과 첨부파일",
+ description: "기술 평가(TBE) 결과 파일을 관리합니다.",
+ fileTypeLabel: "TBE 결과",
+ canEdit: !readOnly
+ }
+ case "CBE_RESULT":
+ return {
+ title: "CBE 결과 첨부파일",
+ description: "상업성 평가(CBE) 결과 파일을 관리합니다.",
+ fileTypeLabel: "CBE 결과",
+ canEdit: !readOnly
+ }
+ default: // RFQ_COMMON
+ return {
+ title: "RFQ 첨부파일",
+ description: readOnly ? "RFQ 공통 첨부파일을 조회합니다." : "RFQ 공통 첨부파일을 관리합니다.",
+ fileTypeLabel: "공통",
+ canEdit: !readOnly
+ }
+ }
+ }, [attachmentType, readOnly])
+
+ // // RFQ 상태에 따른 편집 가능 여부 결정 (readOnly prop이 true면 항상 false)
+ // const isEditable = React.useMemo(() => {
+ // if (!rfq) return false
+ // return attachmentConfig.canEdit
+ // }, [rfq, attachmentConfig.canEdit])
+
+ const form = useForm<AttachmentsFormValues>({
+ resolver: zodResolver(attachmentsFormSchema),
+ defaultValues: {
+ techSalesRfqId: rfq?.id || 0,
+ existing: defaultAttachments.map(att => ({
+ id: att.id,
+ techSalesRfqId: att.techSalesRfqId,
+ fileName: att.fileName,
+ originalFileName: att.originalFileName,
+ filePath: att.filePath,
+ fileSize: att.fileSize || undefined,
+ fileType: att.fileType || undefined,
+ attachmentType: att.attachmentType,
+ description: att.description || undefined,
+ createdBy: att.createdBy,
+ createdAt: att.createdAt,
+ })),
+ newUploads: [],
+ },
+ })
+
+ // useFieldArray for existing and new uploads
+ const {
+ fields: existingFields,
+ remove: removeExisting,
+ } = useFieldArray({
+ control: form.control,
+ name: "existing",
+ })
+
+ const {
+ fields: newUploadFields,
+ append: appendNewUpload,
+ remove: removeNewUpload,
+ } = useFieldArray({
+ control: form.control,
+ name: "newUploads",
+ })
+
+ // Reset form when defaultAttachments changes
+ React.useEffect(() => {
+ if (defaultAttachments) {
+ form.reset({
+ techSalesRfqId: rfq?.id || 0,
+ existing: defaultAttachments.map(att => ({
+ id: att.id,
+ techSalesRfqId: att.techSalesRfqId,
+ fileName: att.fileName,
+ originalFileName: att.originalFileName,
+ filePath: att.filePath,
+ fileSize: att.fileSize || undefined,
+ fileType: att.fileType || undefined,
+ attachmentType: att.attachmentType,
+ description: att.description || undefined,
+ createdBy: att.createdBy,
+ createdAt: att.createdAt,
+ })),
+ newUploads: [],
+ })
+ }
+ }, [defaultAttachments, rfq?.id, form])
+
+ // Handle dropzone accept
+ const handleDropAccepted = React.useCallback((acceptedFiles: File[]) => {
+ acceptedFiles.forEach((file) => {
+ appendNewUpload({
+ fileObj: file,
+ attachmentType: "RFQ_COMMON",
+ description: "",
+ })
+ })
+ }, [appendNewUpload])
+
+ // Handle dropzone reject
+ const handleDropRejected = React.useCallback(() => {
+ toast.error("파일 크기가 너무 크거나 지원하지 않는 파일 형식입니다.")
+ }, [])
+
+ // Handle remove existing attachment
+ const handleRemoveExisting = React.useCallback((index: number) => {
+ removeExisting(index)
+ }, [removeExisting])
+
+ // Handle form submission
+ const onSubmit = async (data: AttachmentsFormValues) => {
+ if (!rfq) {
+ toast.error("RFQ 정보를 찾을 수 없습니다.")
+ return
+ }
+
+ setIsPending(true)
+ try {
+ // 삭제할 첨부파일 ID 수집
+ const deleteAttachmentIds = defaultAttachments
+ .filter((original) => !data.existing.find(existing => existing.id === original.id))
+ .map(attachment => attachment.id)
+
+ // 새 파일 정보 수집
+ const newFiles = data.newUploads
+ .filter(upload => upload.fileObj)
+ .map(upload => ({
+ file: upload.fileObj as File,
+ attachmentType: attachmentType,
+ description: upload.description,
+ }))
+
+ // 실제 API 호출
+ const result = await processTechSalesRfqAttachments({
+ techSalesRfqId: rfq.id,
+ newFiles,
+ deleteAttachmentIds,
+ createdBy: parseInt(session.data?.user.id || "0"),
+ })
+
+ if (result.error) {
+ toast.error(result.error)
+ return
+ }
+
+ // 성공 메시지 표시 (업로드된 파일 수 포함)
+ const uploadedCount = newFiles.length
+ const deletedCount = deleteAttachmentIds.length
+
+ let successMessage = "첨부파일이 저장되었습니다."
+ if (uploadedCount > 0 && deletedCount > 0) {
+ successMessage = `${uploadedCount}개 파일 업로드, ${deletedCount}개 파일 삭제 완료`
+ } else if (uploadedCount > 0) {
+ successMessage = `${uploadedCount}개 파일이 업로드되었습니다.`
+ } else if (deletedCount > 0) {
+ successMessage = `${deletedCount}개 파일이 삭제되었습니다.`
+ }
+
+ toast.success(successMessage)
+
+ // 다이얼로그 자동 닫기
+ props.onOpenChange?.(false)
+
+ // // 즉시 첨부파일 목록 새로고침
+ // const refreshResult = await getTechSalesRfqAttachments(rfq.id)
+ // if (refreshResult.error) {
+ // console.error("첨부파일 목록 새로고침 실패:", refreshResult.error)
+ // toast.warning("첨부파일 목록 새로고침에 실패했습니다. 시트를 다시 열어주세요.")
+ // } else {
+ // // 새로운 첨부파일 목록으로 폼 업데이트
+ // const refreshedAttachments = refreshResult.data.map(att => ({
+ // id: att.id,
+ // techSalesRfqId: att.techSalesRfqId || rfq.id,
+ // fileName: att.fileName,
+ // originalFileName: att.originalFileName,
+ // filePath: att.filePath,
+ // fileSize: att.fileSize,
+ // fileType: att.fileType,
+ // attachmentType: att.attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT",
+ // description: att.description,
+ // createdBy: att.createdBy,
+ // createdAt: att.createdAt,
+ // }))
+
+ // // 폼을 새로운 데이터로 리셋 (새 업로드 목록은 비움)
+ // form.reset({
+ // techSalesRfqId: rfq.id,
+ // existing: refreshedAttachments.map(att => ({
+ // ...att,
+ // fileSize: att.fileSize || undefined,
+ // fileType: att.fileType || undefined,
+ // description: att.description || undefined,
+ // })),
+ // newUploads: [],
+ // })
+
+ // // 즉시 UI 업데이트를 위한 추가 피드백
+ // if (uploadedCount > 0) {
+ // toast.success("첨부파일 목록이 업데이트되었습니다.", { duration: 2000 })
+ // }
+ // }
+
+ // // 콜백으로 상위 컴포넌트에 변경사항 알림
+ // const newAttachmentCount = refreshResult.error ?
+ // (data.existing.length + newFiles.length - deleteAttachmentIds.length) :
+ // refreshResult.data.length
+ // onAttachmentsUpdated?.(rfq.id, newAttachmentCount)
+
+ } catch (error) {
+ console.error("첨부파일 저장 오류:", error)
+ toast.error("첨부파일 저장 중 오류가 발생했습니다.")
+ } finally {
+ setIsPending(false)
+ }
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>{attachmentConfig.title}</SheetTitle>
+ <SheetDescription>
+ <div>RFQ: {rfq?.rfqCode || "N/A"}</div>
+ <div className="mt-1">{attachmentConfig.description}</div>
+ {!attachmentConfig.canEdit && (
+ <div className="mt-2 flex items-center gap-2 text-amber-600">
+ <AlertCircle className="h-4 w-4" />
+ <span className="text-sm">현재 상태에서는 편집할 수 없습니다</span>
+ </div>
+ )}
+ </SheetDescription>
+ </SheetHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-1 flex-col gap-6">
+ {/* 1) Existing attachments */}
+ <div className="grid gap-4">
+ <h6 className="font-semibold leading-none tracking-tight">
+ 기존 첨부파일 ({existingFields.length}개)
+ </h6>
+ {existingFields.map((field, index) => {
+ const typeLabel = attachmentConfig.fileTypeLabel
+ const sizeText = field.fileSize ? prettyBytes(field.fileSize) : "알 수 없음"
+ const dateText = field.createdAt ? formatDate(field.createdAt, "KR") : ""
+
+ return (
+ <div key={field.id} className="flex items-start justify-between p-3 border rounded-md gap-3">
+ <div className="flex-1 min-w-0 overflow-hidden">
+ <div className="flex items-center gap-2 mb-1 flex-wrap">
+ <p className="text-sm font-medium break-words leading-tight">
+ {field.originalFileName || field.fileName}
+ </p>
+ <Badge variant="outline" className="text-xs shrink-0">
+ {typeLabel}
+ </Badge>
+ </div>
+ <p className="text-xs text-muted-foreground">
+ {sizeText} • {dateText}
+ </p>
+ {field.description && (
+ <p className="text-xs text-muted-foreground mt-1 break-words">
+ {field.description}
+ </p>
+ )}
+ </div>
+
+ <div className="flex items-center gap-1 shrink-0">
+ {/* Download button */}
+ {field.filePath && (
+ <Button
+ variant="ghost"
+ size="icon"
+ type="button"
+ className="h-8 w-8"
+ onClick={() => handleDownloadClick(field.filePath, field.originalFileName || field.fileName)}
+ title="다운로드"
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ )}
+ {/* Remove button - 편집 가능할 때만 표시 */}
+ {attachmentConfig.canEdit && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="h-8 w-8"
+ onClick={() => handleRemoveExisting(index)}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ </div>
+ )
+ })}
+ </div>
+
+ {/* 2) Dropzone for new uploads - 편집 가능할 때만 표시 */}
+ {attachmentConfig.canEdit ? (
+ <>
+ <Dropzone
+ maxSize={MAX_FILE_SIZE}
+ onDropAccepted={handleDropAccepted}
+ onDropRejected={handleDropRejected}
+ >
+ {({ maxSize }) => (
+ <FormField
+ control={form.control}
+ name="newUploads"
+ render={() => (
+ <FormItem>
+ <FormLabel>새 파일 업로드</FormLabel>
+ <DropzoneZone className="flex justify-center">
+ <FormControl>
+ <DropzoneInput />
+ </FormControl>
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>파일을 드래그하거나 클릭하세요</DropzoneTitle>
+ <DropzoneDescription>
+ 최대 크기: {maxSize ? prettyBytes(maxSize) : "600MB"}
+ </DropzoneDescription>
+ </div>
+ </div>
+ </DropzoneZone>
+ <FormDescription>파일을 여러 개 선택할 수 있습니다.</FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )}
+ </Dropzone>
+
+ {/* newUpload fields -> FileList */}
+ {newUploadFields.length > 0 && (
+ <div className="grid gap-4">
+ <h6 className="font-semibold leading-none tracking-tight">
+ 새 파일 ({newUploadFields.length}개)
+ </h6>
+ <FileList>
+ {newUploadFields.map((field, idx) => {
+ const fileObj = form.getValues(`newUploads.${idx}.fileObj`)
+ if (!fileObj) return null
+
+ const fileName = fileObj.name
+ const fileSize = fileObj.size
+ return (
+ <FileListItem key={field.id}>
+ <FileListHeader>
+ <FileListIcon />
+ <FileListInfo>
+ <FileListName>{fileName}</FileListName>
+ <FileListDescription>
+ {prettyBytes(fileSize)}
+ </FileListDescription>
+ </FileListInfo>
+ <FileListAction onClick={() => removeNewUpload(idx)}>
+ <X />
+ <span className="sr-only">제거</span>
+ </FileListAction>
+ </FileListHeader>
+
+ </FileListItem>
+ )
+ })}
+ </FileList>
+ </div>
+ )}
+ </>
+ ) : (
+ <div className="p-3 bg-muted rounded-md flex items-center justify-center">
+ <div className="text-center text-sm text-muted-foreground">
+ <Eye className="h-4 w-4 mx-auto mb-2" />
+ <p>보기 모드에서는 파일 첨부를 할 수 없습니다.</p>
+ </div>
+ </div>
+ )}
+
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ {attachmentConfig.canEdit ? "취소" : "닫기"}
+ </Button>
+ </SheetClose>
+ {attachmentConfig.canEdit && (
+ <Button
+ type="submit"
+ disabled={
+ isPending ||
+ (
+ form.getValues().newUploads.length === 0 &&
+ form.getValues().existing.length === defaultAttachments.length &&
+ form.getValues().existing.every(existing =>
+ defaultAttachments.some(original => original.id === existing.id)
+ )
+ )
+ }
+ >
+ {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />}
+ {isPending ? "저장 중..." : "저장"}
+ </Button>
+ )}
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
}
\ No newline at end of file diff --git a/lib/techsales-rfq/validations.ts b/lib/techsales-rfq/validations.ts index c373b576..ecd63b4f 100644 --- a/lib/techsales-rfq/validations.ts +++ b/lib/techsales-rfq/validations.ts @@ -1,192 +1,192 @@ -import { - createSearchParamsCache, - parseAsArrayOf, - parseAsInteger, - parseAsString, - parseAsStringEnum, -} from "nuqs/server"; -import * as z from "zod"; - -import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"; -import { techSalesRfqs, techSalesVendorQuotations } from "@/db/schema"; - - -// ======================= -// 1) SearchParams (목록 필터링/정렬) -// ======================= -export const searchParamsCache = 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 GetTechSalesRfqsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>; - - -export const searchParamsVendorQuotationsCache = createSearchParamsCache({ - flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), - page: parseAsInteger.withDefault(1), - perPage: parseAsInteger.withDefault(10), - sort: getSortingStateParser<typeof techSalesVendorQuotations>().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 GetTechSalesVendorQuotationsSchema = Awaited<ReturnType<typeof searchParamsVendorQuotationsCache.parse>>; - -export const searchParamsDashboardCache = 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"), - - search: parseAsString.withDefault(""), -}); - -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(), - biddingProjectId: z.number().optional(), - materialCode: z.string().optional(), - dueDate: z.date(), - status: z.string().default("RFQ Created"), - 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>; - -// 결합도 우려가 있지만 -// 벤더가 기술영업(조선) RFQ 조회할 때 쓸 밸리데이션 -export const searchParamsVendorRfqCache = createSearchParamsCache({ - flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), - page: parseAsInteger.withDefault(1), - perPage: parseAsInteger.withDefault(10), - sort: getSortingStateParser<typeof techSalesVendorQuotations>().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(""), -}); - +import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server";
+import * as z from "zod";
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers";
+import { techSalesRfqs, techSalesVendorQuotations } from "@/db/schema";
+
+
+// =======================
+// 1) SearchParams (목록 필터링/정렬)
+// =======================
+export const searchParamsCache = 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 GetTechSalesRfqsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>;
+
+
+export const searchParamsVendorQuotationsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<typeof techSalesVendorQuotations>().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 GetTechSalesVendorQuotationsSchema = Awaited<ReturnType<typeof searchParamsVendorQuotationsCache.parse>>;
+
+export const searchParamsDashboardCache = 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"),
+
+ search: parseAsString.withDefault(""),
+});
+
+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(),
+ biddingProjectId: z.number().optional(),
+ materialCode: z.string().optional(),
+ dueDate: z.date(),
+ status: z.string().default("RFQ Created"),
+ 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>;
+
+// 결합도 우려가 있지만
+// 벤더가 기술영업(조선) RFQ 조회할 때 쓸 밸리데이션
+export const searchParamsVendorRfqCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<typeof techSalesVendorQuotations>().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 GetQuotationsSchema = Awaited<ReturnType<typeof searchParamsVendorRfqCache.parse>>;
\ No newline at end of file diff --git a/lib/techsales-rfq/vendor-response/buyer-communication-drawer.tsx b/lib/techsales-rfq/vendor-response/buyer-communication-drawer.tsx index 4422a32c..c0f63ff7 100644 --- a/lib/techsales-rfq/vendor-response/buyer-communication-drawer.tsx +++ b/lib/techsales-rfq/vendor-response/buyer-communication-drawer.tsx @@ -1,711 +1,729 @@ -"use client" - -import * as React from "react" -import { useState, useEffect, useRef } from "react" -import { toast } from "sonner" -import { - Send, - Paperclip, - DownloadCloud, - File, - FileText, - Image as ImageIcon, - AlertCircle, - X, - User, - Building -} from "lucide-react" -import { - Drawer, - DrawerClose, - DrawerContent, - DrawerDescription, - DrawerFooter, - DrawerHeader, - DrawerTitle, -} from "@/components/ui/drawer" -import { Button } from "@/components/ui/button" -import { Textarea } from "@/components/ui/textarea" -import { Avatar, AvatarFallback } from "@/components/ui/avatar" -import { Badge } from "@/components/ui/badge" -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { formatDateTime, formatFileSize } from "@/lib/utils" -import { useSession } from "next-auth/react" - -// 타입 정의 -export interface TechSalesAttachment { - id: number - fileName: string - fileSize: number - fileType: string | null - filePath: string - uploadedAt: Date -} - -export interface TechSalesComment { - id: number - rfqId: number - vendorId: number | null - userId?: number | null - content: string - isVendorComment: boolean | null - createdAt: Date - updatedAt: Date - userName?: string | null - vendorName?: string | null - attachments: TechSalesAttachment[] - isRead: boolean | null -} - -// 프롭스 정의 -interface BuyerCommunicationDrawerProps { - open: boolean; - onOpenChange: (open: boolean) => void; - quotation: { - id: number; - rfqId: number; - vendorId: number; - quotationCode: string | null; - rfq?: { - rfqCode: string | null; - }; - } | null; - onSuccess?: () => void; -} - -// 클라이언트에서 API를 통해 코멘트를 가져오는 함수 -export async function fetchTechSalesVendorCommentsClient(rfqId: number, vendorId: number): Promise<TechSalesComment[]> { - const response = await fetch(`/api/tech-sales-rfqs/${rfqId}/vendors/${vendorId}/comments`); - - if (!response.ok) { - throw new Error(`API 요청 실패: ${response.status}`); - } - - const result = await response.json(); - - if (!result.success) { - throw new Error(result.message || '코멘트 조회 중 오류가 발생했습니다'); - } - - // API 응답 타입 정의 - interface ApiComment { - id: number; - rfqId: number; - vendorId: number | null; - userId?: number | null; - content: string; - isVendorComment: boolean | null; - createdAt: string; - updatedAt: string; - userName?: string | null; - vendorName?: string | null; - isRead: boolean | null; - attachments: Array<{ - id: number; - fileName: string; - fileSize: number; - fileType: string | null; - filePath: string; - uploadedAt: string; - }>; - } - - return result.data.map((comment: ApiComment) => ({ - ...comment, - createdAt: new Date(comment.createdAt), - updatedAt: new Date(comment.updatedAt), - attachments: comment.attachments.map((att) => ({ - ...att, - uploadedAt: new Date(att.uploadedAt) - })) - })); -} - -// 벤더 코멘트 전송 함수 -export function sendVendorCommentClient(params: { - rfqId: number; - vendorId: number; - content: string; - attachments?: File[]; -}): Promise<TechSalesComment> { - // 폼 데이터 생성 (파일 첨부를 위해) - const formData = new FormData(); - formData.append('rfqId', params.rfqId.toString()); - formData.append('vendorId', params.vendorId.toString()); - formData.append('content', params.content); - formData.append('isVendorComment', 'true'); // 벤더가 보내는 메시지이므로 true - - // 첨부파일 추가 - if (params.attachments && params.attachments.length > 0) { - params.attachments.forEach((file) => { - formData.append(`attachments`, file); - }); - } - - // API 엔드포인트 구성 (techsales API 경로) - const url = `/api/tech-sales-rfqs/${params.rfqId}/vendors/${params.vendorId}/comments`; - - console.log("API 요청 시작:", { url, params }); - - // API 호출 - return fetch(url, { - method: 'POST', - body: formData, // multipart/form-data 형식 사용 - }) - .then(response => { - console.log("API 응답 상태:", response.status); - - if (!response.ok) { - return response.text().then(text => { - console.error("API 에러 응답:", text); - throw new Error(`API 요청 실패: ${response.status} ${text}`); - }); - } - return response.json(); - }) - .then(result => { - console.log("API 응답 데이터:", result); - - if (!result.success || !result.data) { - throw new Error(result.message || '코멘트 전송 중 오류가 발생했습니다'); - } - - // API 응답 타입 정의 - interface ApiAttachment { - id: number; - fileName: string; - fileSize: number; - fileType: string | null; - filePath: string; - uploadedAt: string; - } - - interface ApiCommentResponse { - id: number; - rfqId: number; - vendorId: number | null; - userId?: number | null; - content: string; - isVendorComment: boolean | null; - createdAt: string; - updatedAt: string; - userName?: string | null; - isRead: boolean | null; - attachments: ApiAttachment[]; - } - - const commentData = result.data.comment as ApiCommentResponse; - - return { - ...commentData, - createdAt: new Date(commentData.createdAt), - updatedAt: new Date(commentData.updatedAt), - attachments: commentData.attachments.map((att) => ({ - ...att, - uploadedAt: new Date(att.uploadedAt) - })) - }; - }) - .catch(error => { - console.error("클라이언트 API 호출 에러:", error); - throw error; - }); -} - -export function BuyerCommunicationDrawer({ - open, - onOpenChange, - quotation, - onSuccess -}: BuyerCommunicationDrawerProps) { - // 세션 정보 - const { data: session } = useSession(); - - // 상태 관리 - const [comments, setComments] = useState<TechSalesComment[]>([]); - const [newComment, setNewComment] = useState(""); - const [attachments, setAttachments] = useState<File[]>([]); - const [isLoading, setIsLoading] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); - const fileInputRef = useRef<HTMLInputElement>(null); - const messagesEndRef = useRef<HTMLDivElement>(null); - - // 자동 새로고침 관련 상태 - const [autoRefresh, setAutoRefresh] = useState(true); - const [lastMessageCount, setLastMessageCount] = useState(0); - const intervalRef = useRef<NodeJS.Timeout | null>(null); - - // 첨부파일 관련 상태 - const [previewDialogOpen, setPreviewDialogOpen] = useState(false); - const [selectedAttachment, setSelectedAttachment] = useState<TechSalesAttachment | null>(null); - - // 드로어가 열릴 때 데이터 로드 - useEffect(() => { - if (open && quotation) { - loadComments(); - // 자동 새로고침 시작 - if (autoRefresh) { - startAutoRefresh(); - } - } else { - // 드로어가 닫히면 자동 새로고침 중지 - stopAutoRefresh(); - } - - // 컴포넌트 언마운트 시 정리 - return () => { - stopAutoRefresh(); - }; - }, [open, quotation, autoRefresh]); - - // 스크롤 최하단으로 이동 - useEffect(() => { - if (messagesEndRef.current) { - messagesEndRef.current.scrollIntoView({ behavior: "smooth" }); - } - }, [comments]); - - // 자동 새로고침 시작 - const startAutoRefresh = () => { - stopAutoRefresh(); // 기존 interval 정리 - intervalRef.current = setInterval(() => { - if (open && quotation && !isSubmitting) { - loadComments(true); // 자동 새로고침임을 표시 - } - }, 60000); // 60초마다 새로고침 - }; - - // 자동 새로고침 중지 - const stopAutoRefresh = () => { - if (intervalRef.current) { - clearInterval(intervalRef.current); - intervalRef.current = null; - } - }; - - // 자동 새로고침 토글 - const toggleAutoRefresh = () => { - setAutoRefresh(prev => { - const newValue = !prev; - if (newValue && open) { - startAutoRefresh(); - } else { - stopAutoRefresh(); - } - return newValue; - }); - }; - - // 코멘트 로드 함수 (자동 새로고침 여부 파라미터 추가) - const loadComments = async (isAutoRefresh = false) => { - if (!quotation) return; - - try { - // 자동 새로고침일 때는 로딩 표시하지 않음 - if (!isAutoRefresh) { - setIsLoading(true); - } - - // API를 사용하여 코멘트 데이터 가져오기 - const commentsData = await fetchTechSalesVendorCommentsClient(quotation.rfqId, quotation.vendorId); - - // 새 메시지가 있는지 확인 (자동 새로고침일 때만) - if (isAutoRefresh) { - const newMessageCount = commentsData.length; - if (newMessageCount > lastMessageCount && lastMessageCount > 0) { - // 새 메시지 알림 - toast.success(`새 메시지 ${newMessageCount - lastMessageCount}개가 도착했습니다`); - } - setLastMessageCount(newMessageCount); - } else { - setLastMessageCount(commentsData.length); - } - - setComments(commentsData); - - // 읽음 상태 처리는 API 측에서 처리되는 것으로 가정 - } catch (error) { - console.error("코멘트 로드 오류:", error); - if (!isAutoRefresh) { // 자동 새로고침일 때는 에러 토스트 표시하지 않음 - toast.error("메시지를 불러오는 중 오류가 발생했습니다"); - } - } finally { - // 항상 로딩 상태를 해제하되, 최소 200ms는 유지하여 깜빡거림 방지 - if (!isAutoRefresh) { - setTimeout(() => { - setIsLoading(false); - }, 200); - } - } - }; - - // 파일 선택 핸들러 - const handleFileSelect = () => { - fileInputRef.current?.click(); - }; - - // 파일 변경 핸들러 - const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { - if (e.target.files && e.target.files.length > 0) { - const newFiles = Array.from(e.target.files); - setAttachments(prev => [...prev, ...newFiles]); - } - }; - - // 파일 제거 핸들러 - const handleRemoveFile = (index: number) => { - setAttachments(prev => prev.filter((_, i) => i !== index)); - }; - - // 코멘트 전송 핸들러 - const handleSubmitComment = async () => { - if (!newComment.trim() && attachments.length === 0) return; - if (!quotation) return; - - try { - setIsSubmitting(true); - - // API를 사용하여 새 코멘트 전송 (파일 업로드 때문에 FormData 사용) - const newCommentObj = await sendVendorCommentClient({ - rfqId: quotation.rfqId, - vendorId: quotation.vendorId, - content: newComment, - attachments: attachments - }); - - // 상태 업데이트 - setComments(prev => [...prev, newCommentObj]); - setNewComment(""); - setAttachments([]); - - toast.success("메시지가 전송되었습니다"); - - // 데이터 새로고침 - if (onSuccess) { - onSuccess(); - } - } catch (error) { - console.error("코멘트 전송 오류:", error); - toast.error("메시지 전송 중 오류가 발생했습니다"); - } finally { - setIsSubmitting(false); - } - }; - - // 첨부파일 미리보기 - const handleAttachmentPreview = (attachment: TechSalesAttachment) => { - setSelectedAttachment(attachment); - setPreviewDialogOpen(true); - }; - - // 첨부파일 다운로드 - const handleAttachmentDownload = (attachment: TechSalesAttachment) => { - // 실제 다운로드 구현 - window.open(attachment.filePath, '_blank'); - }; - - // 파일 아이콘 선택 - const getFileIcon = (fileType: string | null) => { - if (!fileType) return <File className="h-5 w-5 text-gray-500" />; - if (fileType.startsWith("image/")) return <ImageIcon className="h-5 w-5 text-blue-500" />; - if (fileType.includes("pdf")) return <FileText className="h-5 w-5 text-red-500" />; - if (fileType.includes("spreadsheet") || fileType.includes("excel")) - return <FileText className="h-5 w-5 text-green-500" />; - if (fileType.includes("document") || fileType.includes("word")) - return <FileText className="h-5 w-5 text-blue-500" />; - return <File className="h-5 w-5 text-gray-500" />; - }; - - // 첨부파일 미리보기 다이얼로그 - const renderAttachmentPreviewDialog = () => { - if (!selectedAttachment) return null; - - const isImage = selectedAttachment.fileType?.startsWith("image/") || false; - const isPdf = selectedAttachment.fileType?.includes("pdf") || false; - - return ( - <Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}> - <DialogContent className="max-w-3xl"> - <DialogHeader> - <DialogTitle className="flex items-center gap-2"> - {getFileIcon(selectedAttachment.fileType)} - {selectedAttachment.fileName} - </DialogTitle> - <DialogDescription> - {formatFileSize(selectedAttachment.fileSize)} • {formatDateTime(selectedAttachment.uploadedAt)} - </DialogDescription> - </DialogHeader> - - <div className="min-h-[300px] flex items-center justify-center p-4"> - {isImage ? ( - <img - src={selectedAttachment.filePath} - alt={selectedAttachment.fileName} - className="max-h-[500px] max-w-full object-contain" - /> - ) : isPdf ? ( - <iframe - src={`${selectedAttachment.filePath}#toolbar=0`} - className="w-full h-[500px]" - title={selectedAttachment.fileName} - /> - ) : ( - <div className="flex flex-col items-center gap-4 p-8"> - {getFileIcon(selectedAttachment.fileType)} - <p className="text-muted-foreground text-sm">미리보기를 지원하지 않는 파일 형식입니다.</p> - <Button - variant="outline" - onClick={() => handleAttachmentDownload(selectedAttachment)} - > - <DownloadCloud className="h-4 w-4 mr-2" /> - 다운로드 - </Button> - </div> - )} - </div> - </DialogContent> - </Dialog> - ); - }; - - if (!quotation) { - return null; - } - - // 구매자 정보 (실제로는 API에서 가져와야 함) - const buyerName = "구매 담당자"; - - return ( - <Drawer open={open} onOpenChange={onOpenChange}> - <DrawerContent className="max-h-[80vh] flex flex-col"> - <DrawerHeader className="border-b flex-shrink-0"> - <DrawerTitle className="flex items-center gap-2"> - <Avatar className="h-8 w-8"> - <AvatarFallback className="bg-primary/10"> - <User className="h-4 w-4" /> - </AvatarFallback> - </Avatar> - <div> - <span>{buyerName}</span> - <Badge variant="outline" className="ml-2">구매자</Badge> - </div> - </DrawerTitle> - <DrawerDescription> - RFQ: {quotation.rfq?.rfqCode || "N/A"} • 견적서: {quotation.quotationCode} - </DrawerDescription> - </DrawerHeader> - - <div className="flex flex-col flex-1 min-h-0"> - {/* 메시지 목록 */} - <div className="flex-1 p-4 overflow-y-auto min-h-[300px]"> - {isLoading && comments.length === 0 ? ( - <div className="flex h-full items-center justify-center"> - <p className="text-muted-foreground">메시지 로딩 중...</p> - </div> - ) : comments.length === 0 ? ( - <div className="flex h-full items-center justify-center"> - <div className="flex flex-col items-center gap-2"> - <AlertCircle className="h-6 w-6 text-muted-foreground" /> - <p className="text-muted-foreground">아직 메시지가 없습니다</p> - </div> - </div> - ) : ( - <div className="space-y-4 relative"> - {isLoading && ( - <div className="absolute top-0 right-0 z-10 bg-background/80 backdrop-blur-sm rounded-md px-2 py-1"> - <div className="flex items-center gap-2"> - <div className="w-2 h-2 bg-primary rounded-full animate-pulse" /> - <span className="text-xs text-muted-foreground">새로고침 중...</span> - </div> - </div> - )} - {comments.map(comment => ( - <div - key={comment.id} - className={`flex gap-3 ${comment.isVendorComment ? 'justify-end' : 'justify-start'}`} - > - {!comment.isVendorComment && ( - <Avatar className="h-8 w-8 mt-1"> - <AvatarFallback className="bg-primary/10"> - <User className="h-4 w-4" /> - </AvatarFallback> - </Avatar> - )} - - <div className={`rounded-lg p-3 max-w-[80%] ${comment.isVendorComment - ? 'bg-primary text-primary-foreground' - : 'bg-muted' - }`}> - <div className="text-sm font-medium mb-1"> - {comment.isVendorComment ? ( - session?.user?.name || "벤더" - ) : ( - comment.userName || buyerName - )} - </div> - - {comment.content && ( - <div className="text-sm whitespace-pre-wrap break-words"> - {comment.content} - </div> - )} - - {/* 첨부파일 표시 */} - {comment.attachments.length > 0 && ( - <div className={`mt-2 pt-2 ${comment.isVendorComment - ? 'border-t border-t-primary-foreground/20' - : 'border-t border-t-border/30' - }`}> - {comment.attachments.map(attachment => ( - <div - key={attachment.id} - className="flex items-center text-xs gap-2 mb-1 p-1 rounded hover:bg-black/5 cursor-pointer" - onClick={() => handleAttachmentPreview(attachment)} - > - {getFileIcon(attachment.fileType)} - <span className="flex-1 truncate">{attachment.fileName}</span> - <span className="text-xs opacity-70"> - {formatFileSize(attachment.fileSize)} - </span> - <Button - variant="ghost" - size="icon" - className="h-6 w-6 rounded-full" - onClick={(e) => { - e.stopPropagation(); - handleAttachmentDownload(attachment); - }} - > - <DownloadCloud className="h-3 w-3" /> - </Button> - </div> - ))} - </div> - )} - - <div className="text-xs mt-1 opacity-70 flex items-center gap-1 justify-end"> - {formatDateTime(comment.createdAt)} - </div> - </div> - - {comment.isVendorComment && ( - <Avatar className="h-8 w-8 mt-1"> - <AvatarFallback className="bg-primary/20"> - <Building className="h-4 w-4" /> - </AvatarFallback> - </Avatar> - )} - </div> - ))} - <div ref={messagesEndRef} /> - </div> - )} - </div> - - {/* 선택된 첨부파일 표시 */} - {attachments.length > 0 && ( - <div className="p-2 bg-muted mx-4 rounded-md mb-2 flex-shrink-0"> - <div className="text-xs font-medium mb-1">첨부파일</div> - <div className="flex flex-wrap gap-2"> - {attachments.map((file, index) => ( - <div key={index} className="flex items-center bg-background rounded-md p-1 pr-2 text-xs"> - {file.type.startsWith("image/") ? ( - <ImageIcon className="h-4 w-4 mr-1 text-blue-500" /> - ) : ( - <File className="h-4 w-4 mr-1 text-gray-500" /> - )} - <span className="truncate max-w-[100px]">{file.name}</span> - <Button - variant="ghost" - size="icon" - className="h-4 w-4 ml-1 p-0" - onClick={() => handleRemoveFile(index)} - > - <X className="h-3 w-3" /> - </Button> - </div> - ))} - </div> - </div> - )} - - {/* 메시지 입력 영역 */} - <div className="p-4 border-t flex-shrink-0"> - <div className="flex gap-2 items-end"> - <div className="flex-1"> - <Textarea - placeholder="메시지를 입력하세요..." - className="min-h-[80px] resize-none" - value={newComment} - onChange={(e) => setNewComment(e.target.value)} - /> - </div> - <div className="flex flex-col gap-2"> - <input - type="file" - ref={fileInputRef} - className="hidden" - multiple - onChange={handleFileChange} - /> - <Button - variant="outline" - size="icon" - onClick={handleFileSelect} - title="파일 첨부" - > - <Paperclip className="h-4 w-4" /> - </Button> - <Button - onClick={handleSubmitComment} - disabled={(!newComment.trim() && attachments.length === 0) || isSubmitting} - > - <Send className="h-4 w-4" /> - </Button> - </div> - </div> - </div> - </div> - - <DrawerFooter className="border-t flex-shrink-0"> - <div className="flex justify-between items-center"> - <div className="flex items-center gap-2"> - <Button variant="outline" onClick={() => loadComments()}> - 새로고침 - </Button> - <Button - variant={autoRefresh ? "default" : "outline"} - size="sm" - onClick={toggleAutoRefresh} - className="gap-2" - > - {autoRefresh ? ( - <> - <div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" /> - 자동 새로고침 ON - </> - ) : ( - <> - <div className="w-2 h-2 bg-gray-400 rounded-full" /> - 자동 새로고침 OFF - </> - )} - </Button> - </div> - <DrawerClose asChild> - <Button variant="outline">닫기</Button> - </DrawerClose> - </div> - </DrawerFooter> - </DrawerContent> - - {renderAttachmentPreviewDialog()} - </Drawer> - ); +"use client"
+
+import * as React from "react"
+import { useState, useEffect, useRef } from "react"
+import { toast } from "sonner"
+import {
+ Send,
+ Paperclip,
+ DownloadCloud,
+ File,
+ FileText,
+ Image as ImageIcon,
+ AlertCircle,
+ X,
+ User,
+ Building
+} from "lucide-react"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+} from "@/components/ui/drawer"
+import { Button } from "@/components/ui/button"
+import { Textarea } from "@/components/ui/textarea"
+import { Avatar, AvatarFallback } from "@/components/ui/avatar"
+import { Badge } from "@/components/ui/badge"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { formatDateTime, formatFileSize } from "@/lib/utils"
+import { useSession } from "next-auth/react"
+
+// 타입 정의
+export interface TechSalesAttachment {
+ id: number
+ fileName: string
+ originalFileName: string
+ fileSize: number
+ fileType: string | null
+ filePath: string
+ uploadedAt: Date
+}
+
+export interface TechSalesComment {
+ id: number
+ rfqId: number
+ vendorId: number | null
+ userId?: number | null
+ content: string
+ isVendorComment: boolean | null
+ createdAt: Date
+ updatedAt: Date
+ userName?: string | null
+ vendorName?: string | null
+ attachments: TechSalesAttachment[]
+ isRead: boolean | null
+}
+
+// 프롭스 정의
+interface BuyerCommunicationDrawerProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ quotation: {
+ id: number;
+ rfqId: number;
+ vendorId: number;
+ quotationCode: string | null;
+ rfq?: {
+ rfqCode: string | null;
+ };
+ } | null;
+ onSuccess?: () => void;
+}
+
+// 클라이언트에서 API를 통해 코멘트를 가져오는 함수
+export async function fetchTechSalesVendorCommentsClient(rfqId: number, vendorId: number): Promise<TechSalesComment[]> {
+ const response = await fetch(`/api/tech-sales-rfqs/${rfqId}/vendors/${vendorId}/comments`);
+
+ if (!response.ok) {
+ throw new Error(`API 요청 실패: ${response.status}`);
+ }
+
+ const result = await response.json();
+
+ if (!result.success) {
+ throw new Error(result.message || '코멘트 조회 중 오류가 발생했습니다');
+ }
+
+ // API 응답 타입 정의
+ interface ApiComment {
+ id: number;
+ rfqId: number;
+ vendorId: number | null;
+ userId?: number | null;
+ content: string;
+ isVendorComment: boolean | null;
+ createdAt: string;
+ updatedAt: string;
+ userName?: string | null;
+ vendorName?: string | null;
+ isRead: boolean | null;
+ attachments: Array<{
+ id: number;
+ fileName: string;
+ originalFileName: string;
+ fileSize: number;
+ fileType: string | null;
+ filePath: string;
+ uploadedAt: string;
+ }>;
+ }
+
+ return result.data.map((comment: ApiComment) => ({
+ ...comment,
+ createdAt: new Date(comment.createdAt),
+ updatedAt: new Date(comment.updatedAt),
+ attachments: comment.attachments.map((att) => ({
+ ...att,
+ uploadedAt: new Date(att.uploadedAt)
+ }))
+ }));
+}
+
+// 벤더 코멘트 전송 함수
+export function sendVendorCommentClient(params: {
+ rfqId: number;
+ vendorId: number;
+ content: string;
+ attachments?: File[];
+}): Promise<TechSalesComment> {
+ // 폼 데이터 생성 (파일 첨부를 위해)
+ const formData = new FormData();
+ formData.append('rfqId', params.rfqId.toString());
+ formData.append('vendorId', params.vendorId.toString());
+ formData.append('content', params.content);
+ formData.append('isVendorComment', 'true'); // 벤더가 보내는 메시지이므로 true
+
+ // 첨부파일 추가
+ if (params.attachments && params.attachments.length > 0) {
+ params.attachments.forEach((file) => {
+ formData.append(`attachments`, file);
+ });
+ }
+
+ // API 엔드포인트 구성 (techsales API 경로)
+ const url = `/api/tech-sales-rfqs/${params.rfqId}/vendors/${params.vendorId}/comments`;
+
+ console.log("API 요청 시작:", { url, params });
+
+ // API 호출
+ return fetch(url, {
+ method: 'POST',
+ body: formData, // multipart/form-data 형식 사용
+ })
+ .then(response => {
+ console.log("API 응답 상태:", response.status);
+
+ if (!response.ok) {
+ return response.text().then(text => {
+ console.error("API 에러 응답:", text);
+ throw new Error(`API 요청 실패: ${response.status} ${text}`);
+ });
+ }
+ return response.json();
+ })
+ .then(result => {
+ console.log("API 응답 데이터:", result);
+
+ if (!result.success || !result.data) {
+ throw new Error(result.message || '코멘트 전송 중 오류가 발생했습니다');
+ }
+
+ // API 응답 타입 정의
+ interface ApiAttachment {
+ id: number;
+ fileName: string;
+ originalFileName: string;
+ fileSize: number;
+ fileType: string | null;
+ filePath: string;
+ uploadedAt: string;
+ }
+
+ interface ApiCommentResponse {
+ id: number;
+ rfqId: number;
+ vendorId: number | null;
+ userId?: number | null;
+ content: string;
+ isVendorComment: boolean | null;
+ createdAt: string;
+ updatedAt: string;
+ userName?: string | null;
+ isRead: boolean | null;
+ attachments: ApiAttachment[];
+ }
+
+ const commentData = result.data.comment as ApiCommentResponse;
+
+ return {
+ ...commentData,
+ createdAt: new Date(commentData.createdAt),
+ updatedAt: new Date(commentData.updatedAt),
+ attachments: commentData.attachments.map((att) => ({
+ ...att,
+ uploadedAt: new Date(att.uploadedAt)
+ }))
+ };
+ })
+ .catch(error => {
+ console.error("클라이언트 API 호출 에러:", error);
+ throw error;
+ });
+}
+
+export function BuyerCommunicationDrawer({
+ open,
+ onOpenChange,
+ quotation,
+ onSuccess
+}: BuyerCommunicationDrawerProps) {
+ // 세션 정보
+ const { data: session } = useSession();
+
+ // 상태 관리
+ const [comments, setComments] = useState<TechSalesComment[]>([]);
+ const [newComment, setNewComment] = useState("");
+ const [attachments, setAttachments] = useState<File[]>([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const fileInputRef = useRef<HTMLInputElement>(null);
+ const messagesEndRef = useRef<HTMLDivElement>(null);
+
+ // 자동 새로고침 관련 상태
+ const [autoRefresh, setAutoRefresh] = useState(true);
+ const [lastMessageCount, setLastMessageCount] = useState(0);
+ const intervalRef = useRef<NodeJS.Timeout | null>(null);
+
+ // 첨부파일 관련 상태
+ const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
+ const [selectedAttachment, setSelectedAttachment] = useState<TechSalesAttachment | null>(null);
+
+ // 드로어가 열릴 때 데이터 로드
+ useEffect(() => {
+ if (open && quotation) {
+ loadComments();
+ // 자동 새로고침 시작
+ if (autoRefresh) {
+ startAutoRefresh();
+ }
+ } else {
+ // 드로어가 닫히면 자동 새로고침 중지
+ stopAutoRefresh();
+ }
+
+ // 컴포넌트 언마운트 시 정리
+ return () => {
+ stopAutoRefresh();
+ };
+ }, [open, quotation, autoRefresh]);
+
+ // 스크롤 최하단으로 이동
+ useEffect(() => {
+ if (messagesEndRef.current) {
+ messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
+ }
+ }, [comments]);
+
+ // 자동 새로고침 시작
+ const startAutoRefresh = () => {
+ stopAutoRefresh(); // 기존 interval 정리
+ intervalRef.current = setInterval(() => {
+ if (open && quotation && !isSubmitting) {
+ loadComments(true); // 자동 새로고침임을 표시
+ }
+ }, 60000); // 60초마다 새로고침
+ };
+
+ // 자동 새로고침 중지
+ const stopAutoRefresh = () => {
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current);
+ intervalRef.current = null;
+ }
+ };
+
+ // 자동 새로고침 토글
+ const toggleAutoRefresh = () => {
+ setAutoRefresh(prev => {
+ const newValue = !prev;
+ if (newValue && open) {
+ startAutoRefresh();
+ } else {
+ stopAutoRefresh();
+ }
+ return newValue;
+ });
+ };
+
+ // 코멘트 로드 함수 (자동 새로고침 여부 파라미터 추가)
+ const loadComments = async (isAutoRefresh = false) => {
+ if (!quotation) return;
+
+ try {
+ // 자동 새로고침일 때는 로딩 표시하지 않음
+ if (!isAutoRefresh) {
+ setIsLoading(true);
+ }
+
+ // API를 사용하여 코멘트 데이터 가져오기
+ const commentsData = await fetchTechSalesVendorCommentsClient(quotation.rfqId, quotation.vendorId);
+
+ // 새 메시지가 있는지 확인 (자동 새로고침일 때만)
+ if (isAutoRefresh) {
+ const newMessageCount = commentsData.length;
+ if (newMessageCount > lastMessageCount && lastMessageCount > 0) {
+ // 새 메시지 알림
+ toast.success(`새 메시지 ${newMessageCount - lastMessageCount}개가 도착했습니다`);
+ }
+ setLastMessageCount(newMessageCount);
+ } else {
+ setLastMessageCount(commentsData.length);
+ }
+
+ setComments(commentsData);
+
+ // 읽음 상태 처리는 API 측에서 처리되는 것으로 가정
+ } catch (error) {
+ console.error("코멘트 로드 오류:", error);
+ if (!isAutoRefresh) { // 자동 새로고침일 때는 에러 토스트 표시하지 않음
+ toast.error("메시지를 불러오는 중 오류가 발생했습니다");
+ }
+ } finally {
+ // 항상 로딩 상태를 해제하되, 최소 200ms는 유지하여 깜빡거림 방지
+ if (!isAutoRefresh) {
+ setTimeout(() => {
+ setIsLoading(false);
+ }, 200);
+ }
+ }
+ };
+
+ // 파일 선택 핸들러
+ const handleFileSelect = () => {
+ fileInputRef.current?.click();
+ };
+
+ // 파일 변경 핸들러
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ if (e.target.files && e.target.files.length > 0) {
+ const newFiles = Array.from(e.target.files);
+ setAttachments(prev => [...prev, ...newFiles]);
+ }
+ };
+
+ // 파일 제거 핸들러
+ const handleRemoveFile = (index: number) => {
+ setAttachments(prev => prev.filter((_, i) => i !== index));
+ };
+
+ // 코멘트 전송 핸들러
+ const handleSubmitComment = async () => {
+ if (!newComment.trim() && attachments.length === 0) return;
+ if (!quotation) return;
+
+ try {
+ setIsSubmitting(true);
+
+ // API를 사용하여 새 코멘트 전송 (파일 업로드 때문에 FormData 사용)
+ const newCommentObj = await sendVendorCommentClient({
+ rfqId: quotation.rfqId,
+ vendorId: quotation.vendorId,
+ content: newComment,
+ attachments: attachments
+ });
+
+ // 상태 업데이트
+ setComments(prev => [...prev, newCommentObj]);
+ setNewComment("");
+ setAttachments([]);
+
+ toast.success("메시지가 전송되었습니다");
+
+ // 데이터 새로고침
+ if (onSuccess) {
+ onSuccess();
+ }
+ } catch (error) {
+ console.error("코멘트 전송 오류:", error);
+ toast.error("메시지 전송 중 오류가 발생했습니다");
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ // 첨부파일 미리보기
+ const handleAttachmentPreview = (attachment: TechSalesAttachment) => {
+ setSelectedAttachment(attachment);
+ setPreviewDialogOpen(true);
+ };
+
+ // 첨부파일 다운로드 핸들러
+ const handleDownloadClick = React.useCallback(async (attachment: TechSalesAttachment) => {
+ try {
+ const { downloadFile } = await import('@/lib/file-download');
+ await downloadFile(attachment.filePath, attachment.originalFileName || attachment.fileName, {
+ showToast: true,
+ onError: (error) => {
+ console.error('다운로드 오류:', error);
+ toast.error(error);
+ },
+ onSuccess: (fileName, fileSize) => {
+ console.log(`다운로드 성공: ${fileName} (${fileSize} bytes)`);
+ }
+ });
+ } catch (error) {
+ console.error('다운로드 오류:', error);
+ toast.error('파일 다운로드 중 오류가 발생했습니다.');
+ }
+ }, []);
+
+ // 파일 아이콘 선택
+ const getFileIcon = (fileType: string | null) => {
+ if (!fileType) return <File className="h-5 w-5 text-gray-500" />;
+ if (fileType.startsWith("image/")) return <ImageIcon className="h-5 w-5 text-blue-500" />;
+ if (fileType.includes("pdf")) return <FileText className="h-5 w-5 text-red-500" />;
+ if (fileType.includes("spreadsheet") || fileType.includes("excel"))
+ return <FileText className="h-5 w-5 text-green-500" />;
+ if (fileType.includes("document") || fileType.includes("word"))
+ return <FileText className="h-5 w-5 text-blue-500" />;
+ return <File className="h-5 w-5 text-gray-500" />;
+ };
+
+ // 첨부파일 미리보기 다이얼로그
+ const renderAttachmentPreviewDialog = () => {
+ if (!selectedAttachment) return null;
+
+ const isImage = selectedAttachment.fileType?.startsWith("image/") || false;
+ const isPdf = selectedAttachment.fileType?.includes("pdf") || false;
+
+ return (
+ <Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
+ <DialogContent className="max-w-3xl">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ {getFileIcon(selectedAttachment.fileType)}
+ {selectedAttachment.originalFileName || selectedAttachment.fileName}
+ </DialogTitle>
+ <DialogDescription>
+ {formatFileSize(selectedAttachment.fileSize)} • {formatDateTime(selectedAttachment.uploadedAt)}
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="min-h-[300px] flex items-center justify-center p-4">
+ {isImage ? (
+ // eslint-disable-next-line @next/next/no-img-element
+ <img
+ src={selectedAttachment.filePath}
+ alt={selectedAttachment.originalFileName || selectedAttachment.fileName}
+ className="max-h-[500px] max-w-full object-contain"
+ />
+ ) : isPdf ? (
+ <iframe
+ src={`${selectedAttachment.filePath}#toolbar=0`}
+ className="w-full h-[500px]"
+ title={selectedAttachment.originalFileName || selectedAttachment.fileName}
+ />
+ ) : (
+ <div className="flex flex-col items-center gap-4 p-8">
+ {getFileIcon(selectedAttachment.fileType)}
+ <p className="text-muted-foreground text-sm">미리보기를 지원하지 않는 파일 형식입니다.</p>
+ <Button
+ variant="outline"
+ onClick={() => handleDownloadClick(selectedAttachment)}
+ >
+ <DownloadCloud className="h-4 w-4 mr-2" />
+ 다운로드
+ </Button>
+ </div>
+ )}
+ </div>
+ </DialogContent>
+ </Dialog>
+ );
+ };
+
+ if (!quotation) {
+ return null;
+ }
+
+ // 구매자 정보 (실제로는 API에서 가져와야 함)
+ const buyerName = "구매 담당자";
+
+ return (
+ <Drawer open={open} onOpenChange={onOpenChange}>
+ <DrawerContent className="max-h-[80vh] flex flex-col">
+ <DrawerHeader className="border-b flex-shrink-0">
+ <DrawerTitle className="flex items-center gap-2">
+ <Avatar className="h-8 w-8">
+ <AvatarFallback className="bg-primary/10">
+ <User className="h-4 w-4" />
+ </AvatarFallback>
+ </Avatar>
+ <div>
+ <span>{buyerName}</span>
+ <Badge variant="outline" className="ml-2">구매자</Badge>
+ </div>
+ </DrawerTitle>
+ <DrawerDescription>
+ RFQ: {quotation.rfq?.rfqCode || "N/A"} • 견적서: {quotation.quotationCode}
+ </DrawerDescription>
+ </DrawerHeader>
+
+ <div className="flex flex-col flex-1 min-h-0">
+ {/* 메시지 목록 */}
+ <div className="flex-1 p-4 overflow-y-auto min-h-[300px]">
+ {isLoading && comments.length === 0 ? (
+ <div className="flex h-full items-center justify-center">
+ <p className="text-muted-foreground">메시지 로딩 중...</p>
+ </div>
+ ) : comments.length === 0 ? (
+ <div className="flex h-full items-center justify-center">
+ <div className="flex flex-col items-center gap-2">
+ <AlertCircle className="h-6 w-6 text-muted-foreground" />
+ <p className="text-muted-foreground">아직 메시지가 없습니다</p>
+ </div>
+ </div>
+ ) : (
+ <div className="space-y-4 relative">
+ {isLoading && (
+ <div className="absolute top-0 right-0 z-10 bg-background/80 backdrop-blur-sm rounded-md px-2 py-1">
+ <div className="flex items-center gap-2">
+ <div className="w-2 h-2 bg-primary rounded-full animate-pulse" />
+ <span className="text-xs text-muted-foreground">새로고침 중...</span>
+ </div>
+ </div>
+ )}
+ {comments.map(comment => (
+ <div
+ key={comment.id}
+ className={`flex gap-3 ${comment.isVendorComment ? 'justify-end' : 'justify-start'}`}
+ >
+ {!comment.isVendorComment && (
+ <Avatar className="h-8 w-8 mt-1">
+ <AvatarFallback className="bg-primary/10">
+ <User className="h-4 w-4" />
+ </AvatarFallback>
+ </Avatar>
+ )}
+
+ <div className={`rounded-lg p-3 max-w-[80%] ${comment.isVendorComment
+ ? 'bg-primary text-primary-foreground'
+ : 'bg-muted'
+ }`}>
+ <div className="text-sm font-medium mb-1">
+ {comment.isVendorComment ? (
+ session?.user?.name || "벤더"
+ ) : (
+ comment.userName || buyerName
+ )}
+ </div>
+
+ {comment.content && (
+ <div className="text-sm whitespace-pre-wrap break-words">
+ {comment.content}
+ </div>
+ )}
+
+ {/* 첨부파일 표시 */}
+ {comment.attachments.length > 0 && (
+ <div className={`mt-2 pt-2 ${comment.isVendorComment
+ ? 'border-t border-t-primary-foreground/20'
+ : 'border-t border-t-border/30'
+ }`}>
+ {comment.attachments.map(attachment => (
+ <div
+ key={attachment.id}
+ className="flex items-center text-xs gap-2 mb-1 p-1 rounded hover:bg-black/5 cursor-pointer"
+ onClick={() => handleAttachmentPreview(attachment)}
+ >
+ {getFileIcon(attachment.fileType)}
+ <span className="flex-1 truncate">{attachment.originalFileName || attachment.fileName}</span>
+ <span className="text-xs opacity-70">
+ {formatFileSize(attachment.fileSize)}
+ </span>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-6 w-6 rounded-full"
+ onClick={(e) => {
+ e.stopPropagation();
+ handleDownloadClick(attachment);
+ }}
+ >
+ <DownloadCloud className="h-3 w-3" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ )}
+
+ <div className="text-xs mt-1 opacity-70 flex items-center gap-1 justify-end">
+ {formatDateTime(comment.createdAt)}
+ </div>
+ </div>
+
+ {comment.isVendorComment && (
+ <Avatar className="h-8 w-8 mt-1">
+ <AvatarFallback className="bg-primary/20">
+ <Building className="h-4 w-4" />
+ </AvatarFallback>
+ </Avatar>
+ )}
+ </div>
+ ))}
+ <div ref={messagesEndRef} />
+ </div>
+ )}
+ </div>
+
+ {/* 선택된 첨부파일 표시 */}
+ {attachments.length > 0 && (
+ <div className="p-2 bg-muted mx-4 rounded-md mb-2 flex-shrink-0">
+ <div className="text-xs font-medium mb-1">첨부파일</div>
+ <div className="flex flex-wrap gap-2">
+ {attachments.map((file, index) => (
+ <div key={index} className="flex items-center bg-background rounded-md p-1 pr-2 text-xs">
+ {file.type.startsWith("image/") ? (
+ <ImageIcon className="h-4 w-4 mr-1 text-blue-500" />
+ ) : (
+ <File className="h-4 w-4 mr-1 text-gray-500" />
+ )}
+ <span className="truncate max-w-[100px]">{file.name}</span>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-4 w-4 ml-1 p-0"
+ onClick={() => handleRemoveFile(index)}
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* 메시지 입력 영역 */}
+ <div className="p-4 border-t flex-shrink-0">
+ <div className="flex gap-2 items-end">
+ <div className="flex-1">
+ <Textarea
+ placeholder="메시지를 입력하세요..."
+ className="min-h-[80px] resize-none"
+ value={newComment}
+ onChange={(e) => setNewComment(e.target.value)}
+ />
+ </div>
+ <div className="flex flex-col gap-2">
+ <input
+ type="file"
+ ref={fileInputRef}
+ className="hidden"
+ multiple
+ onChange={handleFileChange}
+ />
+ <Button
+ variant="outline"
+ size="icon"
+ onClick={handleFileSelect}
+ title="파일 첨부"
+ >
+ <Paperclip className="h-4 w-4" />
+ </Button>
+ <Button
+ onClick={handleSubmitComment}
+ disabled={(!newComment.trim() && attachments.length === 0) || isSubmitting}
+ >
+ <Send className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <DrawerFooter className="border-t flex-shrink-0">
+ <div className="flex justify-between items-center">
+ <div className="flex items-center gap-2">
+ <Button variant="outline" onClick={() => loadComments()}>
+ 새로고침
+ </Button>
+ <Button
+ variant={autoRefresh ? "default" : "outline"}
+ size="sm"
+ onClick={toggleAutoRefresh}
+ className="gap-2"
+ >
+ {autoRefresh ? (
+ <>
+ <div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
+ 자동 새로고침 ON
+ </>
+ ) : (
+ <>
+ <div className="w-2 h-2 bg-gray-400 rounded-full" />
+ 자동 새로고침 OFF
+ </>
+ )}
+ </Button>
+ </div>
+ <DrawerClose asChild>
+ <Button variant="outline">닫기</Button>
+ </DrawerClose>
+ </div>
+ </DrawerFooter>
+ </DrawerContent>
+
+ {renderAttachmentPreviewDialog()}
+ </Drawer>
+ );
}
\ No newline at end of file diff --git a/lib/techsales-rfq/vendor-response/detail/communication-tab.tsx b/lib/techsales-rfq/vendor-response/detail/communication-tab.tsx index 3f2a5280..5bed179e 100644 --- a/lib/techsales-rfq/vendor-response/detail/communication-tab.tsx +++ b/lib/techsales-rfq/vendor-response/detail/communication-tab.tsx @@ -1,209 +1,209 @@ -"use client" - -import * as React from "react" -import { useState, useEffect } from "react" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import { Button } from "@/components/ui/button" -import { Badge } from "@/components/ui/badge" -import { ScrollArea } from "@/components/ui/scroll-area" -import { Skeleton } from "@/components/ui/skeleton" -import { MessageSquare, Paperclip } from "lucide-react" -import { fetchTechSalesVendorCommentsClient, TechSalesComment } from "../buyer-communication-drawer" -import { BuyerCommunicationDrawer } from "../buyer-communication-drawer" - -interface CommunicationTabProps { - quotation: { - id: number - rfqId: number - vendorId: number - quotationCode: string | null - rfq: { - id: number - rfqCode: string | null - createdByUser?: { - id: number - name: string | null - email: string | null - } | null - } | null - vendor: { - vendorName: string - } | null - } -} - -export function CommunicationTab({ quotation }: CommunicationTabProps) { - const [comments, setComments] = useState<TechSalesComment[]>([]); - const [unreadCount, setUnreadCount] = useState(0); - const [loadingComments, setLoadingComments] = useState(false); - const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false); - - // 컴포넌트 마운트 시 메시지 미리 로드 - useEffect(() => { - if (quotation) { - loadCommunicationData(); - } - }, [quotation]); - - // 메시지 데이터 로드 함수 - const loadCommunicationData = async () => { - try { - setLoadingComments(true); - const commentsData = await fetchTechSalesVendorCommentsClient(quotation.rfqId, quotation.vendorId); - setComments(commentsData); - - // 읽지 않은 메시지 수 계산 (구매자가 보낸 메시지 중 읽지 않은 것) - const unread = commentsData.filter( - comment => !comment.isVendorComment && !comment.isRead - ).length; - setUnreadCount(unread); - } catch (error) { - console.error("메시지 데이터 로드 오류:", error); - } finally { - setLoadingComments(false); - } - }; - - // 커뮤니케이션 드로어가 닫힐 때 데이터 새로고침 - const handleCommunicationDrawerChange = (open: boolean) => { - setCommunicationDrawerOpen(open); - if (!open) { - loadCommunicationData(); // 드로어가 닫힐 때 데이터 새로고침 - } - }; - - return ( - <div className="h-full flex flex-col"> - {/* 헤더 */} - <Card className="mb-4"> - <CardHeader className="flex flex-row items-center justify-between"> - <div> - <CardTitle className="flex items-center gap-2"> - <MessageSquare className="h-5 w-5" /> - 커뮤니케이션 - {unreadCount > 0 && ( - <Badge variant="destructive" className="ml-2"> - 새 메시지 {unreadCount} - </Badge> - )} - </CardTitle> - <CardDescription> - RFQ {quotation.rfq?.rfqCode || "미할당"}에 대한 구매담당자와의 커뮤니케이션 - </CardDescription> - </div> - <Button - onClick={() => setCommunicationDrawerOpen(true)} - variant="outline" - size="sm" - > - <MessageSquare className="h-4 w-4 mr-2" /> - {unreadCount > 0 ? "새 메시지 확인" : "메시지 보내기"} - </Button> - </CardHeader> - <CardContent> - <div className="flex items-center gap-4 text-sm text-muted-foreground"> - <span>구매담당자: {quotation.rfq?.createdByUser?.name || "N/A"}</span> - <span>•</span> - <span>벤더: {quotation.vendor?.vendorName}</span> - </div> - </CardContent> - </Card> - - {/* 메시지 미리보기 */} - <Card className="flex-1 flex flex-col min-h-0"> - <CardHeader> - <CardTitle className="text-lg">메시지 ({comments.length})</CardTitle> - </CardHeader> - <CardContent> - {loadingComments ? ( - <div className="flex items-center justify-center p-8"> - <div className="text-center"> - <Skeleton className="h-4 w-32 mx-auto mb-2" /> - <Skeleton className="h-4 w-48 mx-auto" /> - </div> - </div> - ) : comments.length === 0 ? ( - <div className="min-h-[200px] flex flex-col items-center justify-center text-center p-8"> - <div className="max-w-md"> - <div className="mx-auto bg-primary/10 rounded-full w-12 h-12 flex items-center justify-center mb-4"> - <MessageSquare className="h-6 w-6 text-primary" /> - </div> - <h3 className="text-lg font-medium mb-2">아직 메시지가 없습니다</h3> - <p className="text-muted-foreground mb-4"> - 견적서에 대한 질문이나 의견이 있으신가요? 구매자와 메시지를 주고받으세요. - </p> - <Button - onClick={() => setCommunicationDrawerOpen(true)} - className="mx-auto" - > - 메시지 보내기 - </Button> - </div> - </div> - ) : ( - <div className="space-y-4"> - {/* 최근 메시지 3개 미리보기 */} - <div className="space-y-2"> - <h3 className="text-sm font-medium">최근 메시지</h3> - <ScrollArea className="h-[250px] rounded-md border p-4"> - {comments.slice(-3).map(comment => ( - <div - key={comment.id} - className={`p-3 mb-3 rounded-lg ${!comment.isVendorComment && !comment.isRead - ? 'bg-primary/10 border-l-4 border-primary' - : 'bg-muted/50' - }`} - > - <div className="flex justify-between items-center mb-1"> - <span className="text-sm font-medium"> - {comment.isVendorComment - ? '나' - : comment.userName || '구매 담당자'} - </span> - <span className="text-xs text-muted-foreground"> - {new Date(comment.createdAt).toLocaleDateString()} - </span> - </div> - <p className="text-sm line-clamp-2">{comment.content}</p> - {comment.attachments.length > 0 && ( - <div className="mt-1 text-xs text-muted-foreground"> - <Paperclip className="h-3 w-3 inline mr-1" /> - 첨부파일 {comment.attachments.length}개 - </div> - )} - </div> - ))} - </ScrollArea> - </div> - - <div className="flex justify-center"> - <Button - onClick={() => setCommunicationDrawerOpen(true)} - className="w-full" - > - 전체 메시지 보기 ({comments.length}개) - </Button> - </div> - </div> - )} - </CardContent> - </Card> - - {/* 커뮤니케이션 드로어 */} - <BuyerCommunicationDrawer - open={communicationDrawerOpen} - onOpenChange={handleCommunicationDrawerChange} - quotation={{ - id: quotation.id, - rfqId: quotation.rfqId, - vendorId: quotation.vendorId, - quotationCode: quotation.quotationCode, - rfq: quotation.rfq ? { - rfqCode: quotation.rfq.rfqCode - } : undefined - }} - onSuccess={loadCommunicationData} - /> - </div> - ) +"use client"
+
+import * as React from "react"
+import { useState, useEffect } from "react"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { Skeleton } from "@/components/ui/skeleton"
+import { MessageSquare, Paperclip } from "lucide-react"
+import { fetchTechSalesVendorCommentsClient, TechSalesComment } from "../buyer-communication-drawer"
+import { BuyerCommunicationDrawer } from "../buyer-communication-drawer"
+
+interface CommunicationTabProps {
+ quotation: {
+ id: number
+ rfqId: number
+ vendorId: number
+ quotationCode: string | null
+ rfq: {
+ id: number
+ rfqCode: string | null
+ createdByUser?: {
+ id: number
+ name: string | null
+ email: string | null
+ } | null
+ } | null
+ vendor: {
+ vendorName: string
+ } | null
+ }
+}
+
+export function CommunicationTab({ quotation }: CommunicationTabProps) {
+ const [comments, setComments] = useState<TechSalesComment[]>([]);
+ const [unreadCount, setUnreadCount] = useState(0);
+ const [loadingComments, setLoadingComments] = useState(false);
+ const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false);
+
+ // 컴포넌트 마운트 시 메시지 미리 로드
+ useEffect(() => {
+ if (quotation) {
+ loadCommunicationData();
+ }
+ }, [quotation]);
+
+ // 메시지 데이터 로드 함수
+ const loadCommunicationData = async () => {
+ try {
+ setLoadingComments(true);
+ const commentsData = await fetchTechSalesVendorCommentsClient(quotation.rfqId, quotation.vendorId);
+ setComments(commentsData);
+
+ // 읽지 않은 메시지 수 계산 (구매자가 보낸 메시지 중 읽지 않은 것)
+ const unread = commentsData.filter(
+ comment => !comment.isVendorComment && !comment.isRead
+ ).length;
+ setUnreadCount(unread);
+ } catch (error) {
+ console.error("메시지 데이터 로드 오류:", error);
+ } finally {
+ setLoadingComments(false);
+ }
+ };
+
+ // 커뮤니케이션 드로어가 닫힐 때 데이터 새로고침
+ const handleCommunicationDrawerChange = (open: boolean) => {
+ setCommunicationDrawerOpen(open);
+ if (!open) {
+ loadCommunicationData(); // 드로어가 닫힐 때 데이터 새로고침
+ }
+ };
+
+ return (
+ <div className="h-full flex flex-col">
+ {/* 헤더 */}
+ <Card className="mb-4">
+ <CardHeader className="flex flex-row items-center justify-between">
+ <div>
+ <CardTitle className="flex items-center gap-2">
+ <MessageSquare className="h-5 w-5" />
+ 커뮤니케이션
+ {unreadCount > 0 && (
+ <Badge variant="destructive" className="ml-2">
+ 새 메시지 {unreadCount}
+ </Badge>
+ )}
+ </CardTitle>
+ <CardDescription>
+ RFQ {quotation.rfq?.rfqCode || "미할당"}에 대한 구매담당자와의 커뮤니케이션
+ </CardDescription>
+ </div>
+ <Button
+ onClick={() => setCommunicationDrawerOpen(true)}
+ variant="outline"
+ size="sm"
+ >
+ <MessageSquare className="h-4 w-4 mr-2" />
+ {unreadCount > 0 ? "새 메시지 확인" : "메시지 보내기"}
+ </Button>
+ </CardHeader>
+ <CardContent>
+ <div className="flex items-center gap-4 text-sm text-muted-foreground">
+ <span>구매담당자: {quotation.rfq?.createdByUser?.name || "N/A"}</span>
+ <span>•</span>
+ <span>벤더: {quotation.vendor?.vendorName}</span>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 메시지 미리보기 */}
+ <Card className="flex-1 flex flex-col min-h-0">
+ <CardHeader>
+ <CardTitle className="text-lg">메시지 ({comments.length})</CardTitle>
+ </CardHeader>
+ <CardContent>
+ {loadingComments ? (
+ <div className="flex items-center justify-center p-8">
+ <div className="text-center">
+ <Skeleton className="h-4 w-32 mx-auto mb-2" />
+ <Skeleton className="h-4 w-48 mx-auto" />
+ </div>
+ </div>
+ ) : comments.length === 0 ? (
+ <div className="min-h-[200px] flex flex-col items-center justify-center text-center p-8">
+ <div className="max-w-md">
+ <div className="mx-auto bg-primary/10 rounded-full w-12 h-12 flex items-center justify-center mb-4">
+ <MessageSquare className="h-6 w-6 text-primary" />
+ </div>
+ <h3 className="text-lg font-medium mb-2">아직 메시지가 없습니다</h3>
+ <p className="text-muted-foreground mb-4">
+ 견적서에 대한 질문이나 의견이 있으신가요? 구매자와 메시지를 주고받으세요.
+ </p>
+ <Button
+ onClick={() => setCommunicationDrawerOpen(true)}
+ className="mx-auto"
+ >
+ 메시지 보내기
+ </Button>
+ </div>
+ </div>
+ ) : (
+ <div className="space-y-4">
+ {/* 최근 메시지 3개 미리보기 */}
+ <div className="space-y-2">
+ <h3 className="text-sm font-medium">최근 메시지</h3>
+ <ScrollArea className="h-[250px] rounded-md border p-4">
+ {comments.slice(-3).map(comment => (
+ <div
+ key={comment.id}
+ className={`p-3 mb-3 rounded-lg ${!comment.isVendorComment && !comment.isRead
+ ? 'bg-primary/10 border-l-4 border-primary'
+ : 'bg-muted/50'
+ }`}
+ >
+ <div className="flex justify-between items-center mb-1">
+ <span className="text-sm font-medium">
+ {comment.isVendorComment
+ ? '나'
+ : comment.userName || '구매 담당자'}
+ </span>
+ <span className="text-xs text-muted-foreground">
+ {new Date(comment.createdAt).toLocaleDateString()}
+ </span>
+ </div>
+ <p className="text-sm line-clamp-2">{comment.content}</p>
+ {comment.attachments.length > 0 && (
+ <div className="mt-1 text-xs text-muted-foreground">
+ <Paperclip className="h-3 w-3 inline mr-1" />
+ 첨부파일 {comment.attachments.length}개
+ </div>
+ )}
+ </div>
+ ))}
+ </ScrollArea>
+ </div>
+
+ <div className="flex justify-center">
+ <Button
+ onClick={() => setCommunicationDrawerOpen(true)}
+ className="w-full"
+ >
+ 전체 메시지 보기 ({comments.length}개)
+ </Button>
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 커뮤니케이션 드로어 */}
+ <BuyerCommunicationDrawer
+ open={communicationDrawerOpen}
+ onOpenChange={handleCommunicationDrawerChange}
+ quotation={{
+ id: quotation.id,
+ rfqId: quotation.rfqId,
+ vendorId: quotation.vendorId,
+ quotationCode: quotation.quotationCode,
+ rfq: quotation.rfq ? {
+ rfqCode: quotation.rfq.rfqCode
+ } : undefined
+ }}
+ onSuccess={loadCommunicationData}
+ />
+ </div>
+ )
}
\ No newline at end of file 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 a8f44474..771db896 100644 --- a/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx +++ b/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx @@ -1,149 +1,149 @@ -"use client" - -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 { formatDate } from "@/lib/utils" - -interface ProjectInfoTabProps { - quotation: { - id: number - rfq: { - id: number - rfqCode: string | null - materialCode: string | null - dueDate: Date | null - status: string | null - remark: string | null - biddingProject?: { - id: number - pspid: string | null - projNm: string | null - sector: string | null - projMsrm: string | null - ptypeNm: string | null - } | null - createdByUser?: { - id: number - name: string | null - email: string | null - } | null - } | null - vendor: { - id: number - vendorName: string - vendorCode: string | null - } | null - } -} - -export function ProjectInfoTab({ quotation }: ProjectInfoTabProps) { - const rfq = quotation.rfq - - console.log("rfq: ", rfq) - - if (!rfq) { - return ( - <div className="flex items-center justify-center h-full"> - <div className="text-center"> - <h3 className="text-lg font-medium">RFQ 정보를 찾을 수 없습니다</h3> - <p className="text-sm text-muted-foreground mt-1"> - 연결된 RFQ 정보가 없습니다. - </p> - </div> - </div> - ) - } - - return ( - <ScrollArea className="h-full"> - <div className="space-y-6 p-1"> - {/* RFQ 기본 정보 */} - <Card> - <CardHeader> - <CardTitle className="flex items-center gap-2"> - RFQ 기본 정보 - <Badge variant="outline">{rfq.rfqCode || "미할당"}</Badge> - </CardTitle> - <CardDescription> - 요청서 기본 정보 및 자재 정보 - </CardDescription> - </CardHeader> - <CardContent className="space-y-4"> - <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">RFQ 번호</div> - <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">{rfq.materialCode || "N/A"}</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"} - </div> - </div> - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">RFQ 상태</div> - <div className="text-sm">{rfq.status || "N/A"}</div> - </div> - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">담당자</div> - <div className="text-sm">{rfq.createdByUser?.name || "N/A"}</div> - </div> - </div> - {rfq.remark && ( - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">비고</div> - <div className="text-sm p-3 bg-muted rounded-md">{rfq.remark}</div> - </div> - )} - </CardContent> - </Card> - - {/* 프로젝트 기본 정보 */} - {rfq.biddingProject && ( - <Card> - <CardHeader> - <CardTitle className="flex items-center gap-2"> - 프로젝트 기본 정보 - <Badge variant="outline">{rfq.biddingProject.pspid || "N/A"}</Badge> - </CardTitle> - <CardDescription> - 연결된 프로젝트의 기본 정보 - </CardDescription> - </CardHeader> - <CardContent className="space-y-4"> - <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">프로젝트 ID</div> - <div className="text-sm">{rfq.biddingProject.pspid || "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.projNm || "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.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> - </div> - </CardContent> - </Card> - )} - - </div> - </ScrollArea> - ) +"use client"
+
+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 { formatDate } from "@/lib/utils"
+
+interface ProjectInfoTabProps {
+ quotation: {
+ id: number
+ rfq: {
+ id: number
+ rfqCode: string | null
+ materialCode: string | null
+ dueDate: Date | null
+ status: string | null
+ remark: string | null
+ biddingProject?: {
+ id: number
+ pspid: string | null
+ projNm: string | null
+ sector: string | null
+ projMsrm: string | null
+ ptypeNm: string | null
+ } | null
+ createdByUser?: {
+ id: number
+ name: string | null
+ email: string | null
+ } | null
+ } | null
+ vendor: {
+ id: number
+ vendorName: string
+ vendorCode: string | null
+ } | null
+ }
+}
+
+export function ProjectInfoTab({ quotation }: ProjectInfoTabProps) {
+ const rfq = quotation.rfq
+
+ console.log("rfq: ", rfq)
+
+ if (!rfq) {
+ return (
+ <div className="flex items-center justify-center h-full">
+ <div className="text-center">
+ <h3 className="text-lg font-medium">RFQ 정보를 찾을 수 없습니다</h3>
+ <p className="text-sm text-muted-foreground mt-1">
+ 연결된 RFQ 정보가 없습니다.
+ </p>
+ </div>
+ </div>
+ )
+ }
+
+ return (
+ <ScrollArea className="h-full">
+ <div className="space-y-6 p-1">
+ {/* RFQ 기본 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ RFQ 기본 정보
+ <Badge variant="outline">{rfq.rfqCode || "미할당"}</Badge>
+ </CardTitle>
+ <CardDescription>
+ 요청서 기본 정보 및 자재 정보
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">RFQ 번호</div>
+ <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">{rfq.materialCode || "N/A"}</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"}
+ </div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">RFQ 상태</div>
+ <div className="text-sm">{rfq.status || "N/A"}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">담당자</div>
+ <div className="text-sm">{rfq.createdByUser?.name || "N/A"}</div>
+ </div>
+ </div>
+ {rfq.remark && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">비고</div>
+ <div className="text-sm p-3 bg-muted rounded-md">{rfq.remark}</div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 프로젝트 기본 정보 */}
+ {rfq.biddingProject && (
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ 프로젝트 기본 정보
+ <Badge variant="outline">{rfq.biddingProject.pspid || "N/A"}</Badge>
+ </CardTitle>
+ <CardDescription>
+ 연결된 프로젝트의 기본 정보
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">프로젝트 ID</div>
+ <div className="text-sm">{rfq.biddingProject.pspid || "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.projNm || "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.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>
+ </div>
+ </CardContent>
+ </Card>
+ )}
+
+ </div>
+ </ScrollArea>
+ )
}
\ No newline at end of file diff --git a/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx b/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx index 0425ccc9..0a56b702 100644 --- a/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx +++ b/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx @@ -1,522 +1,523 @@ -"use client" - -import * as React from "react" -import { useState, useEffect } from "react" -import { useRouter } from "next/navigation" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" -import { Textarea } from "@/components/ui/textarea" -import { Badge } from "@/components/ui/badge" -import { ScrollArea } from "@/components/ui/scroll-area" -import { CalendarIcon, Send, AlertCircle, Upload, X, FileText, Download } from "lucide-react" -import { Calendar } from "@/components/ui/calendar" -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import { Alert, AlertDescription } from "@/components/ui/alert" -import { formatDate, cn } from "@/lib/utils" -import { toast } from "sonner" -import { useSession } from "next-auth/react" - -interface QuotationResponseTabProps { - quotation: { - id: number - status: string - totalPrice: string | null - currency: string | null - validUntil: Date | null - remark: string | null - quotationAttachments?: Array<{ - id: number - fileName: string - fileSize: number - filePath: string - description?: string | null - }> - rfq: { - id: number - rfqCode: string | null - materialCode: string | null - dueDate: Date | null - status: string | null - item?: { - itemName: string | null - } | null - } | null - vendor: { - vendorName: string - } | null - } -} - -const CURRENCIES = [ - { value: "KRW", label: "KRW (원)" }, - { value: "USD", label: "USD (달러)" }, - { value: "EUR", label: "EUR (유로)" }, - { value: "JPY", label: "JPY (엔)" }, - { value: "CNY", label: "CNY (위안)" }, -] - -export function QuotationResponseTab({ quotation }: QuotationResponseTabProps) { - const [totalPrice, setTotalPrice] = useState(quotation.totalPrice?.toString() || "") - const [currency, setCurrency] = useState(quotation.currency || "KRW") - const [validUntil, setValidUntil] = useState<Date | undefined>( - quotation.validUntil ? new Date(quotation.validUntil) : undefined - ) - const [remark, setRemark] = useState(quotation.remark || "") - const [isLoading, setIsLoading] = useState(false) - const [attachments, setAttachments] = useState<Array<{ - id?: number - fileName: string - fileSize: number - filePath: string - isNew?: boolean - file?: File - }>>([]) - const [isUploadingFiles, setIsUploadingFiles] = useState(false) - const router = useRouter() - const session = useSession() - - // // 초기 첨부파일 데이터 로드 - // useEffect(() => { - // if (quotation.quotationAttachments) { - // setAttachments(quotation.quotationAttachments.map(att => ({ - // id: att.id, - // fileName: att.fileName, - // fileSize: att.fileSize, - // filePath: att.filePath, - // isNew: false - // }))) - // } - // }, [quotation.quotationAttachments]) - - const rfq = quotation.rfq - const isDueDatePassed = rfq?.dueDate ? new Date(rfq.dueDate) < new Date() : false - const canSubmit = !["Accepted", "Rejected"].includes(quotation.status) && !isDueDatePassed - const canEdit = !["Accepted", "Rejected"].includes(quotation.status) && !isDueDatePassed - - // 파일 업로드 핸들러 - const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => { - const files = event.target.files - if (!files) return - - Array.from(files).forEach(file => { - setAttachments(prev => [ - ...prev, - { - fileName: file.name, - fileSize: file.size, - filePath: '', - isNew: true, - file - } - ]) - }) - } - - // 첨부파일 제거 - const removeAttachment = (index: number) => { - setAttachments(prev => prev.filter((_, i) => i !== index)) - } - - // 파일 업로드 함수 - const uploadFiles = async () => { - const newFiles = attachments.filter(att => att.isNew && att.file) - if (newFiles.length === 0) return [] - - setIsUploadingFiles(true) - const uploadedFiles = [] - - try { - for (const attachment of newFiles) { - const formData = new FormData() - formData.append('file', attachment.file!) - - const response = await fetch('/api/upload', { - method: 'POST', - body: formData - }) - - if (!response.ok) throw new Error('파일 업로드 실패') - - const result = await response.json() - uploadedFiles.push({ - fileName: result.fileName, - filePath: result.url, - fileSize: attachment.fileSize - }) - } - return uploadedFiles - } catch (error) { - console.error('파일 업로드 오류:', error) - toast.error('파일 업로드 중 오류가 발생했습니다.') - return [] - } finally { - setIsUploadingFiles(false) - } - } - - const handleSubmit = async () => { - if (!totalPrice || !currency || !validUntil) { - toast.error("모든 필수 항목을 입력해주세요.") - return - } - - setIsLoading(true) - try { - // 파일 업로드 먼저 처리 - const uploadedFiles = await uploadFiles() - - const { submitTechSalesVendorQuotation } = await import("@/lib/techsales-rfq/service") - - const result = await submitTechSalesVendorQuotation({ - id: quotation.id, - currency, - totalPrice, - validUntil: validUntil!, - remark, - attachments: uploadedFiles, - updatedBy: parseInt(session.data?.user.id || "0") - }) - - if (result.error) { - toast.error(result.error) - } else { - toast.success("견적서가 제출되었습니다.") - // // 페이지 새로고침 대신 router.refresh() 사용 - // router.refresh() - // 페이지 새로고침 - window.location.reload() - } - } catch { - toast.error("제출 중 오류가 발생했습니다.") - } finally { - setIsLoading(false) - } - } - - const getStatusBadgeVariant = (status: string) => { - switch (status) { - case "Draft": - return "secondary" - case "Submitted": - return "default" - case "Revised": - return "outline" - case "Rejected": - return "destructive" - case "Accepted": - return "success" - default: - return "secondary" - } - } - - const getStatusLabel = (status: string) => { - switch (status) { - case "Draft": - return "초안" - case "Submitted": - return "제출됨" - case "Revised": - return "수정됨" - case "Rejected": - return "반려됨" - case "Accepted": - return "승인됨" - default: - return status - } - } - - return ( - <ScrollArea className="h-full"> - <div className="space-y-6 p-1"> - {/* 견적서 상태 정보 */} - <Card> - <CardHeader> - <CardTitle className="flex items-center gap-2"> - 견적서 상태 - <Badge variant={getStatusBadgeVariant(quotation.status)}> - {getStatusLabel(quotation.status)} - </Badge> - </CardTitle> - <CardDescription> - 현재 견적서 상태 및 마감일 정보 - </CardDescription> - </CardHeader> - <CardContent className="space-y-4"> - <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">견적서 상태</div> - <div className="text-sm">{getStatusLabel(quotation.status)}</div> - </div> - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">RFQ 마감일</div> - <div className="text-sm"> - {rfq?.dueDate ? formatDate(rfq.dueDate) : "N/A"} - </div> - </div> - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">남은 시간</div> - <div className="text-sm"> - {isDueDatePassed ? ( - <span className="text-destructive">마감됨</span> - ) : rfq?.dueDate ? ( - <span className="text-green-600"> - {Math.ceil((new Date(rfq.dueDate).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24))}일 - </span> - ) : ( - "N/A" - )} - </div> - </div> - </div> - - {isDueDatePassed && ( - <Alert> - <AlertCircle className="h-4 w-4" /> - <AlertDescription> - RFQ 마감일이 지났습니다. 견적서를 수정하거나 제출할 수 없습니다. - </AlertDescription> - </Alert> - )} - - {!canEdit && !isDueDatePassed && ( - <Alert> - <AlertCircle className="h-4 w-4" /> - <AlertDescription> - 현재 상태에서는 견적서를 수정할 수 없습니다. - </AlertDescription> - </Alert> - )} - </CardContent> - </Card> - - {/* 견적 응답 폼 */} - <Card> - <CardHeader> - <CardTitle>견적 응답</CardTitle> - <CardDescription> - 총 가격, 통화, 유효기간을 입력해주세요. - </CardDescription> - </CardHeader> - <CardContent className="space-y-6"> - {/* 총 가격 */} - <div className="space-y-2"> - <Label htmlFor="totalPrice"> - 총 가격 <span className="text-destructive">*</span> - </Label> - <Input - id="totalPrice" - type="number" - placeholder="총 가격을 입력하세요" - value={totalPrice} - onChange={(e) => setTotalPrice(e.target.value)} - disabled={!canEdit} - className="text-right" - /> - </div> - - {/* 통화 */} - <div className="space-y-2"> - <Label htmlFor="currency"> - 통화 <span className="text-destructive">*</span> - </Label> - <Select value={currency} onValueChange={setCurrency} disabled={!canEdit}> - <SelectTrigger> - <SelectValue placeholder="통화를 선택하세요" /> - </SelectTrigger> - <SelectContent> - {CURRENCIES.map((curr) => ( - <SelectItem key={curr.value} value={curr.value}> - {curr.label} - </SelectItem> - ))} - </SelectContent> - </Select> - </div> - - {/* 유효기간 */} - <div className="space-y-2"> - <Label> - 견적 유효기간 <span className="text-destructive">*</span> - </Label> - <Popover> - <PopoverTrigger asChild> - <Button - variant="outline" - className={cn( - "w-full justify-start text-left font-normal", - !validUntil && "text-muted-foreground" - )} - disabled={!canEdit} - > - <CalendarIcon className="mr-2 h-4 w-4" /> - {validUntil ? formatDate(validUntil) : "날짜를 선택하세요"} - </Button> - </PopoverTrigger> - <PopoverContent className="w-auto p-0" align="start"> - <Calendar - mode="single" - selected={validUntil} - onSelect={setValidUntil} - disabled={(date) => date < new Date()} - initialFocus - /> - </PopoverContent> - </Popover> - </div> - - {/* 비고 */} - <div className="space-y-2"> - <Label htmlFor="remark">비고</Label> - <Textarea - id="remark" - placeholder="추가 설명이나 조건을 입력하세요" - value={remark} - onChange={(e) => setRemark(e.target.value)} - disabled={!canEdit} - rows={4} - /> - </div> - - {/* 첨부파일 */} - <div className="space-y-4"> - <Label>첨부파일</Label> - - {/* 파일 업로드 버튼 */} - {canEdit && ( - <div className="flex items-center gap-2"> - <Button - type="button" - variant="outline" - size="sm" - disabled={isUploadingFiles} - onClick={() => document.getElementById('file-input')?.click()} - > - <Upload className="h-4 w-4 mr-2" /> - 파일 선택 - </Button> - <input - id="file-input" - type="file" - multiple - onChange={handleFileSelect} - className="hidden" - accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.jpg,.jpeg,.png,.zip" - /> - <span className="text-sm text-muted-foreground"> - PDF, 문서파일, 이미지파일, 압축파일 등 - </span> - </div> - )} - - {/* 첨부파일 목록 */} - {attachments.length > 0 && ( - <div className="space-y-2"> - {attachments.map((attachment, index) => ( - <div - key={index} - className="flex items-center justify-between p-3 border rounded-lg bg-muted/50" - > - <div className="flex items-center gap-2"> - <FileText className="h-4 w-4 text-muted-foreground" /> - <div> - <div className="text-sm font-medium">{attachment.fileName}</div> - <div className="text-xs text-muted-foreground"> - {(attachment.fileSize / 1024 / 1024).toFixed(2)} MB - {attachment.isNew && ( - <Badge variant="secondary" className="ml-2"> - 새 파일 - </Badge> - )} - </div> - </div> - </div> - <div className="flex items-center gap-2"> - {!attachment.isNew && ( - <Button - type="button" - variant="ghost" - size="sm" - onClick={() => window.open(attachment.filePath, '_blank')} - > - <Download className="h-4 w-4" /> - </Button> - )} - {canEdit && ( - <Button - type="button" - variant="ghost" - size="sm" - onClick={() => removeAttachment(index)} - > - <X className="h-4 w-4" /> - </Button> - )} - </div> - </div> - ))} - </div> - )} - </div> - - {/* 액션 버튼 */} - {canEdit && canSubmit && ( - <div className="flex justify-center pt-4"> - <Button - onClick={handleSubmit} - disabled={isLoading || !totalPrice || !currency || !validUntil} - className="w-full " - > - <Send className="mr-2 h-4 w-4" /> - 견적서 제출 - </Button> - </div> - )} - </CardContent> - </Card> - - {/* 현재 견적 정보 (읽기 전용) */} - {quotation.totalPrice && ( - <Card> - <CardHeader> - <CardTitle>현재 견적 정보</CardTitle> - <CardDescription> - 저장된 견적 정보 - </CardDescription> - </CardHeader> - <CardContent className="space-y-4"> - <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">총 가격</div> - <div className="text-lg font-semibold"> - {parseFloat(quotation.totalPrice).toLocaleString()} {quotation.currency} - </div> - </div> - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">통화</div> - <div className="text-sm">{quotation.currency}</div> - </div> - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">유효기간</div> - <div className="text-sm"> - {quotation.validUntil ? formatDate(quotation.validUntil) : "N/A"} - </div> - </div> - </div> - {quotation.remark && ( - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">비고</div> - <div className="text-sm p-3 bg-muted rounded-md">{quotation.remark}</div> - </div> - )} - </CardContent> - </Card> - )} - </div> - </ScrollArea> - ) +"use client"
+
+import * as React from "react"
+import { useState } from "react"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Textarea } from "@/components/ui/textarea"
+import { Badge } from "@/components/ui/badge"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { CalendarIcon, Send, AlertCircle, X, FileText, Download, History, FileIcon } from "lucide-react"
+import { Calendar } from "@/components/ui/calendar"
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import { Alert, AlertDescription } from "@/components/ui/alert"
+import { formatDate, cn } from "@/lib/utils"
+import { toast } from "sonner"
+import { useSession } from "next-auth/react"
+import { QuotationHistoryDialog } from "@/lib/techsales-rfq/table/detail-table/quotation-history-dialog"
+
+interface QuotationResponseTabProps {
+ quotation: {
+ id: number
+ status: string
+ totalPrice: string | null
+ currency: string | null
+ validUntil: Date | null
+ remark: string | null
+ quotationAttachments?: Array<{
+ id: number
+ fileName: string
+ fileSize: number
+ filePath: string
+ description?: string | null
+ }>
+ rfq: {
+ id: number
+ rfqCode: string | null
+ materialCode: string | null
+ dueDate: Date | null
+ status: string | null
+ item?: {
+ itemName: string | null
+ } | null
+ } | null
+ vendor: {
+ vendorName: string
+ } | null
+ }
+}
+
+const CURRENCIES = [
+ { value: "KRW", label: "KRW (원)" },
+ { value: "USD", label: "USD (달러)" },
+ { value: "EUR", label: "EUR (유로)" },
+ { value: "JPY", label: "JPY (엔)" },
+ { value: "CNY", label: "CNY (위안)" },
+]
+
+export function QuotationResponseTab({ quotation }: QuotationResponseTabProps) {
+ const [totalPrice, setTotalPrice] = useState(quotation.totalPrice?.toString() || "")
+ const [currency, setCurrency] = useState(quotation.currency || "KRW")
+ const [validUntil, setValidUntil] = useState<Date | undefined>(
+ quotation.validUntil ? new Date(quotation.validUntil) : undefined
+ )
+ const [remark, setRemark] = useState(quotation.remark || "")
+ const [isLoading, setIsLoading] = useState(false)
+ const [attachments, setAttachments] = useState<Array<{
+ id?: number
+ fileName: string
+ fileSize: number
+ filePath: string
+ isNew?: boolean
+ file?: File
+ }>>([])
+ const [isUploadingFiles, setIsUploadingFiles] = useState(false)
+
+ // 견적 히스토리 다이얼로그 상태 관리
+ const [historyDialogOpen, setHistoryDialogOpen] = useState(false)
+
+ const session = useSession()
+
+ // // 초기 첨부파일 데이터 로드
+ // useEffect(() => {
+ // if (quotation.quotationAttachments) {
+ // setAttachments(quotation.quotationAttachments.map(att => ({
+ // id: att.id,
+ // fileName: att.fileName,
+ // fileSize: att.fileSize,
+ // filePath: att.filePath,
+ // isNew: false
+ // })))
+ // }
+ // }, [quotation.quotationAttachments])
+
+ const rfq = quotation.rfq
+ const isDueDatePassed = rfq?.dueDate ? new Date(rfq.dueDate) < new Date() : false
+ const canSubmit = !["Accepted", "Rejected"].includes(quotation.status) && !isDueDatePassed
+ const canEdit = !["Accepted", "Rejected"].includes(quotation.status) && !isDueDatePassed
+
+ // 파일 업로드 핸들러
+ const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
+ const files = event.target.files
+ if (!files) return
+
+ Array.from(files).forEach(file => {
+ setAttachments(prev => [
+ ...prev,
+ {
+ fileName: file.name,
+ fileSize: file.size,
+ filePath: '',
+ isNew: true,
+ file
+ }
+ ])
+ })
+ }
+
+ // 첨부파일 제거
+ const removeAttachment = (index: number) => {
+ setAttachments(prev => prev.filter((_, i) => i !== index))
+ }
+
+ // 파일 업로드 함수
+ const uploadFiles = async () => {
+ const newFiles = attachments.filter(att => att.isNew && att.file)
+ if (newFiles.length === 0) return []
+
+ setIsUploadingFiles(true)
+
+ try {
+ // 서비스 함수를 사용하여 파일 업로드
+ const { uploadQuotationAttachments } = await import('@/lib/techsales-rfq/service')
+
+ const files = newFiles.map(att => att.file!).filter(Boolean)
+ const userId = parseInt(session.data?.user.id || "0")
+
+ const result = await uploadQuotationAttachments(quotation.id, files, userId)
+
+ if (result.success && result.attachments) {
+ return result.attachments
+ } else {
+ throw new Error(result.error || '파일 저장에 실패했습니다.')
+ }
+ } catch (error) {
+ console.error('파일 업로드 오류:', error)
+ toast.error('파일 업로드 중 오류가 발생했습니다.')
+ return []
+ } finally {
+ setIsUploadingFiles(false)
+ }
+ }
+
+ const handleSubmit = async () => {
+ if (!totalPrice || !currency || !validUntil) {
+ toast.error("모든 필수 항목을 입력해주세요.")
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ // 파일 업로드 먼저 처리
+ const uploadedFiles = await uploadFiles()
+
+ const { submitTechSalesVendorQuotation } = await import("@/lib/techsales-rfq/service")
+
+ const result = await submitTechSalesVendorQuotation({
+ id: quotation.id,
+ currency,
+ totalPrice,
+ validUntil: validUntil!,
+ remark,
+ attachments: uploadedFiles,
+ updatedBy: parseInt(session.data?.user.id || "0")
+ })
+
+ if (result.error) {
+ toast.error(result.error)
+ } else {
+ toast.success("견적서가 제출되었습니다.")
+ // // 페이지 새로고침 대신 router.refresh() 사용
+ // router.refresh()
+ // 페이지 새로고침
+ window.location.reload()
+ }
+ } catch {
+ toast.error("제출 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const getStatusBadgeVariant = (status: string) => {
+ switch (status) {
+ case "Draft":
+ return "secondary"
+ case "Submitted":
+ return "default"
+ case "Revised":
+ return "outline"
+ case "Rejected":
+ return "destructive"
+ case "Accepted":
+ return "success"
+ default:
+ return "secondary"
+ }
+ }
+
+ const getStatusLabel = (status: string) => {
+ switch (status) {
+ case "Draft":
+ return "초안"
+ case "Submitted":
+ return "제출됨"
+ case "Revised":
+ return "수정됨"
+ case "Rejected":
+ return "반려됨"
+ case "Accepted":
+ return "승인됨"
+ default:
+ return status
+ }
+ }
+
+ return (
+ <ScrollArea className="h-full">
+ <div className="space-y-6 p-1">
+ {/* 견적서 상태 정보 */}
+ <Card>
+ <CardHeader>
+ <div className="flex items-center justify-between">
+ <div>
+ <CardTitle className="flex items-center gap-2">
+ 견적서 상태
+ <Badge variant={getStatusBadgeVariant(quotation.status)}>
+ {getStatusLabel(quotation.status)}
+ </Badge>
+ </CardTitle>
+ <CardDescription>
+ 현재 견적서 상태 및 마감일 정보
+ </CardDescription>
+ </div>
+
+ {/* 견적 히스토리 보기 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setHistoryDialogOpen(true)}
+ className="flex items-center gap-2"
+ >
+ <History className="h-4 w-4" />
+ 이전 견적 히스토리 보기
+ </Button>
+ </div>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">견적서 상태</div>
+ <div className="text-sm">{getStatusLabel(quotation.status)}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">RFQ 마감일</div>
+ <div className="text-sm">
+ {rfq?.dueDate ? formatDate(rfq.dueDate) : "N/A"}
+ </div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">남은 시간</div>
+ <div className="text-sm">
+ {isDueDatePassed ? (
+ <span className="text-destructive">마감됨</span>
+ ) : rfq?.dueDate ? (
+ <span className="text-green-600">
+ {Math.ceil((new Date(rfq.dueDate).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24))}일
+ </span>
+ ) : (
+ "N/A"
+ )}
+ </div>
+ </div>
+ </div>
+
+ {isDueDatePassed && (
+ <Alert>
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>
+ RFQ 마감일이 지났습니다. 견적서를 수정하거나 제출할 수 없습니다.
+ </AlertDescription>
+ </Alert>
+ )}
+
+ {!canEdit && !isDueDatePassed && (
+ <Alert>
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>
+ 현재 상태에서는 견적서를 수정할 수 없습니다.
+ </AlertDescription>
+ </Alert>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 견적 응답 폼 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>견적 응답</CardTitle>
+ <CardDescription>
+ 견적 정보를 입력하고 제출하세요
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="totalPrice">견적 금액 *</Label>
+ <Input
+ id="totalPrice"
+ type="number"
+ value={totalPrice}
+ onChange={(e) => setTotalPrice(e.target.value)}
+ placeholder="견적 금액을 입력하세요"
+ disabled={!canEdit}
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="currency">통화 *</Label>
+ <Select value={currency} onValueChange={setCurrency} disabled={!canEdit}>
+ <SelectTrigger>
+ <SelectValue placeholder="통화를 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {CURRENCIES.map((curr) => (
+ <SelectItem key={curr.value} value={curr.value}>
+ {curr.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+
+ <div className="space-y-2">
+ <Label>견적 유효기한 *</Label>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ className={cn(
+ "w-full justify-start text-left font-normal",
+ !validUntil && "text-muted-foreground"
+ )}
+ disabled={!canEdit}
+ >
+ <CalendarIcon className="mr-2 h-4 w-4" />
+ {validUntil ? formatDate(validUntil) : "유효기한을 선택하세요"}
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0">
+ <Calendar
+ mode="single"
+ selected={validUntil}
+ onSelect={setValidUntil}
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="remark">비고</Label>
+ <Textarea
+ id="remark"
+ value={remark}
+ onChange={(e) => setRemark(e.target.value)}
+ placeholder="추가 설명이나 특이사항을 입력하세요"
+ rows={3}
+ disabled={!canEdit}
+ />
+ </div>
+
+ {/* 첨부파일 섹션 */}
+ <div className="space-y-2">
+ <Label>첨부파일</Label>
+ <div className="border-2 border-dashed border-gray-300 rounded-lg p-4">
+ <input
+ type="file"
+ id="file-upload"
+ multiple
+ onChange={handleFileSelect}
+ className="hidden"
+ disabled={!canEdit}
+ />
+ <div className="text-center">
+ <FileText className="mx-auto h-12 w-12 text-gray-400" />
+ <div className="mt-2">
+ <Label htmlFor="file-upload" className="cursor-pointer">
+ <span className="text-sm font-medium text-blue-600 hover:text-blue-500">
+ 파일을 선택하세요
+ </span>
+ </Label>
+ </div>
+ <p className="text-xs text-gray-500 mt-1">
+ PDF, DOC, DOCX, XLS, XLSX, PNG, JPG 등
+ </p>
+ </div>
+ </div>
+
+ {/* 첨부파일 목록 */}
+ {attachments.length > 0 && (
+ <div className="space-y-2">
+ <Label>첨부된 파일</Label>
+ <div className="space-y-2">
+ {attachments.map((attachment, index) => (
+ <div key={index} className="flex items-center justify-between p-2 bg-gray-50 rounded">
+ <div className="flex items-center gap-2">
+ <FileIcon className="h-4 w-4 text-gray-500" />
+ <span className="text-sm">{attachment.fileName}</span>
+ <span className="text-xs text-gray-500">
+ ({(attachment.fileSize / 1024).toFixed(1)} KB)
+ </span>
+ </div>
+ <div className="flex items-center gap-2">
+ {!attachment.isNew && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => window.open(attachment.filePath, '_blank')}
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ )}
+ {canEdit && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => removeAttachment(index)}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+
+ {/* 제출 버튼 */}
+ <div className="flex justify-end">
+ <Button
+ onClick={handleSubmit}
+ disabled={!canSubmit || isLoading || isUploadingFiles}
+ className="flex items-center gap-2"
+ >
+ {isLoading ? (
+ <>
+ <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
+ 제출 중...
+ </>
+ ) : (
+ <>
+ <Send className="h-4 w-4" />
+ 견적서 제출
+ </>
+ )}
+ </Button>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 현재 견적 정보 (읽기 전용) */}
+ {quotation.totalPrice && (
+ <Card>
+ <CardHeader>
+ <CardTitle>현재 견적 정보</CardTitle>
+ <CardDescription>
+ 저장된 견적 정보
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">총 가격</div>
+ <div className="text-lg font-semibold">
+ {parseFloat(quotation.totalPrice).toLocaleString()} {quotation.currency}
+ </div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">통화</div>
+ <div className="text-sm">{quotation.currency}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">유효기간</div>
+ <div className="text-sm">
+ {quotation.validUntil ? formatDate(quotation.validUntil) : "N/A"}
+ </div>
+ </div>
+ </div>
+ {quotation.remark && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">비고</div>
+ <div className="text-sm p-3 bg-muted rounded-md">{quotation.remark}</div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+ )}
+ </div>
+
+ {/* 견적 히스토리 다이얼로그 */}
+ <QuotationHistoryDialog
+ open={historyDialogOpen}
+ onOpenChange={setHistoryDialogOpen}
+ quotationId={quotation.id}
+ />
+ </ScrollArea>
+ )
}
\ No newline at end of file diff --git a/lib/techsales-rfq/vendor-response/detail/quotation-tabs.tsx b/lib/techsales-rfq/vendor-response/detail/quotation-tabs.tsx index 2e2f5d70..7af50b24 100644 --- a/lib/techsales-rfq/vendor-response/detail/quotation-tabs.tsx +++ b/lib/techsales-rfq/vendor-response/detail/quotation-tabs.tsx @@ -1,84 +1,84 @@ -"use client" - -import * as React from "react" -import { useRouter, useSearchParams } from "next/navigation" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { ProjectInfoTab } from "./project-info-tab" -import { QuotationResponseTab } from "./quotation-response-tab" -import { CommunicationTab } from "./communication-tab" - -interface QuotationData { - id: number - status: string - totalPrice: string | null - currency: string | null - validUntil: Date | null - remark: string | null - rfq: { - id: number - rfqCode: string | null - materialCode: string | null - dueDate: Date | null - status: string | null - remark: string | null - biddingProject?: { - id: number - pspid: string | null - projNm: string | null - sector: string | null - projMsrm: string | null - ptypeNm: string | null - } | null - createdByUser?: { - id: number - name: string | null - email: string | null - } | null - } | null - vendor: { - id: number - vendorName: string - vendorCode: string | null - } | null -} - -interface TechSalesQuotationTabsProps { - quotation: QuotationData - defaultTab?: string -} - -export function TechSalesQuotationTabs({ quotation, defaultTab = "project" }: TechSalesQuotationTabsProps) { - const router = useRouter() - const searchParams = useSearchParams() - const currentTab = searchParams?.get("tab") || defaultTab - - const handleTabChange = (value: string) => { - const params = new URLSearchParams(searchParams?.toString() || "") - params.set("tab", value) - router.push(`?${params.toString()}`, { scroll: false }) - } - - return ( - <Tabs value={currentTab} onValueChange={handleTabChange} className="h-full flex flex-col"> - <TabsList className="grid w-full grid-cols-3"> - <TabsTrigger value="project">프로젝트 및 RFQ 정보</TabsTrigger> - <TabsTrigger value="quotation">견적 응답</TabsTrigger> - <TabsTrigger value="communication">커뮤니케이션</TabsTrigger> - </TabsList> - - <div className="flex-1 mt-4 overflow-hidden"> - <TabsContent value="project" className="h-full m-0"> - <ProjectInfoTab quotation={quotation} /> - </TabsContent> - - <TabsContent value="quotation" className="h-full m-0"> - <QuotationResponseTab quotation={quotation} /> - </TabsContent> - - <TabsContent value="communication" className="h-full m-0"> - <CommunicationTab quotation={quotation} /> - </TabsContent> - </div> - </Tabs> - ) +"use client"
+
+import * as React from "react"
+import { useRouter, useSearchParams } from "next/navigation"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { ProjectInfoTab } from "./project-info-tab"
+import { QuotationResponseTab } from "./quotation-response-tab"
+import { CommunicationTab } from "./communication-tab"
+
+interface QuotationData {
+ id: number
+ status: string
+ totalPrice: string | null
+ currency: string | null
+ validUntil: Date | null
+ remark: string | null
+ rfq: {
+ id: number
+ rfqCode: string | null
+ materialCode: string | null
+ dueDate: Date | null
+ status: string | null
+ remark: string | null
+ biddingProject?: {
+ id: number
+ pspid: string | null
+ projNm: string | null
+ sector: string | null
+ projMsrm: string | null
+ ptypeNm: string | null
+ } | null
+ createdByUser?: {
+ id: number
+ name: string | null
+ email: string | null
+ } | null
+ } | null
+ vendor: {
+ id: number
+ vendorName: string
+ vendorCode: string | null
+ } | null
+}
+
+interface TechSalesQuotationTabsProps {
+ quotation: QuotationData
+ defaultTab?: string
+}
+
+export function TechSalesQuotationTabs({ quotation, defaultTab = "project" }: TechSalesQuotationTabsProps) {
+ const router = useRouter()
+ const searchParams = useSearchParams()
+ const currentTab = searchParams?.get("tab") || defaultTab
+
+ const handleTabChange = (value: string) => {
+ const params = new URLSearchParams(searchParams?.toString() || "")
+ params.set("tab", value)
+ router.push(`?${params.toString()}`, { scroll: false })
+ }
+
+ return (
+ <Tabs value={currentTab} onValueChange={handleTabChange} className="h-full flex flex-col">
+ <TabsList className="grid w-full grid-cols-3">
+ <TabsTrigger value="project">프로젝트 및 RFQ 정보</TabsTrigger>
+ <TabsTrigger value="quotation">견적 응답</TabsTrigger>
+ <TabsTrigger value="communication">커뮤니케이션</TabsTrigger>
+ </TabsList>
+
+ <div className="flex-1 mt-4 overflow-hidden">
+ <TabsContent value="project" className="h-full m-0">
+ <ProjectInfoTab quotation={quotation} />
+ </TabsContent>
+
+ <TabsContent value="quotation" className="h-full m-0">
+ <QuotationResponseTab quotation={quotation} />
+ </TabsContent>
+
+ <TabsContent value="communication" className="h-full m-0">
+ <CommunicationTab quotation={quotation} />
+ </TabsContent>
+ </div>
+ </Tabs>
+ )
}
\ No newline at end of file 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 39de94ed..328def80 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 @@ -1,672 +1,710 @@ -"use client" - -import * as React from "react" -import { type ColumnDef } from "@tanstack/react-table" -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" -import { Checkbox } from "@/components/ui/checkbox" -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip" -import { - TechSalesVendorQuotations, - TECH_SALES_QUOTATION_STATUS_CONFIG, - TECH_SALES_QUOTATION_STATUSES -} from "@/db/schema" -import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime" -import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" - -interface QuotationWithRfqCode extends TechSalesVendorQuotations { - // RFQ 관련 정보 - rfqCode?: string; - materialCode?: string; - dueDate?: Date; - rfqStatus?: string; - - // 아이템 정보 - itemName?: string; - itemCount?: number; - - // 프로젝트 정보 - projNm?: string; - pspid?: string; - sector?: string; - - // RFQ 정보 - description?: string; - - // 벤더 정보 - vendorName?: string; - vendorCode?: string; - - // 사용자 정보 - createdByName?: string | null; - updatedByName?: string | null; - - // 첨부파일 개수 - attachmentCount?: number; -} - -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, openItemsDialog }: GetColumnsProps): ColumnDef<QuotationWithRfqCode>[] { - return [ - { - id: "select", - header: ({ table }) => ( - <Checkbox - checked={ - table.getIsAllPageRowsSelected() || - (table.getIsSomePageRowsSelected() && "indeterminate") - } - onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} - aria-label="모두 선택" - className="translate-y-0.5" - /> - ), - cell: ({ row }) => { - const isRejected = row.original.status === TECH_SALES_QUOTATION_STATUSES.REJECTED; - const isAccepted = row.original.status === TECH_SALES_QUOTATION_STATUSES.ACCEPTED; - const isDisabled = isRejected || isAccepted; - - return ( - <Checkbox - checked={row.getIsSelected()} - onCheckedChange={(value) => row.toggleSelected(!!value)} - aria-label="행 선택" - className="translate-y-0.5" - disabled={isDisabled} - /> - ); - }, - enableSorting: false, - enableHiding: false, - }, - // { - // accessorKey: "id", - // header: ({ column }) => ( - // <DataTableColumnHeaderSimple column={column} title="ID" /> - // ), - // cell: ({ row }) => ( - // <div className="w-20"> - // <span className="font-mono text-xs">{row.getValue("id")}</span> - // </div> - // ), - // enableSorting: true, - // enableHiding: true, - // }, - { - accessorKey: "rfqCode", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="RFQ 번호" /> - ), - cell: ({ row }) => { - const rfqCode = row.getValue("rfqCode") as string; - return ( - <div className="min-w-32"> - <span className="font-mono text-sm">{rfqCode || "N/A"}</span> - </div> - ); - }, - enableSorting: true, - enableHiding: false, - }, - // { - // accessorKey: "vendorName", - // header: ({ column }) => ( - // <DataTableColumnHeaderSimple column={column} title="벤더명" /> - // ), - // cell: ({ row }) => { - // const vendorName = row.getValue("vendorName") as string; - // return ( - // <div className="min-w-32"> - // <span className="text-sm">{vendorName || "N/A"}</span> - // </div> - // ); - // }, - // enableSorting: true, - // enableHiding: false, - // }, - // { - // accessorKey: "vendorCode", - // header: ({ column }) => ( - // <DataTableColumnHeaderSimple column={column} title="벤더 코드" /> - // ), - // cell: ({ row }) => { - // const vendorCode = row.getValue("vendorCode") as string; - // return ( - // <div className="min-w-24"> - // <span className="font-mono text-sm">{vendorCode || "N/A"}</span> - // </div> - // ); - // }, - // enableSorting: true, - // enableHiding: true, - // }, - // { - // accessorKey: "materialCode", - // header: ({ column }) => ( - // <DataTableColumnHeaderSimple column={column} title="자재 그룹" /> - // ), - // cell: ({ row }) => { - // const materialCode = row.getValue("materialCode") as string; - // return ( - // <div className="min-w-32"> - // <span className="font-mono text-sm">{materialCode || "N/A"}</span> - // </div> - // ); - // }, - // enableSorting: true, - // enableHiding: true, - // }, - // { - // accessorKey: "itemName", - // header: ({ column }) => ( - // <DataTableColumnHeaderSimple column={column} title="자재명" /> - // ), - // cell: ({ row }) => { - // const itemName = row.getValue("itemName") as string; - // return ( - // <div className="min-w-48 max-w-64"> - // <TooltipProvider> - // <Tooltip> - // <TooltipTrigger asChild> - // <span className="truncate block text-sm"> - // {itemName || "N/A"} - // </span> - // </TooltipTrigger> - // <TooltipContent> - // <p className="max-w-xs">{itemName || "N/A"}</p> - // </TooltipContent> - // </Tooltip> - // </TooltipProvider> - // </div> - // ); - // }, - // enableSorting: true, - // enableHiding: true, - // }, - { - accessorKey: "description", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="RFQ title" /> - ), - cell: ({ row }) => { - const description = row.getValue("description") as string; - return ( - <div className="min-w-48 max-w-64"> - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> - <span className="truncate block text-sm"> - {description || "N/A"} - </span> - </TooltipTrigger> - <TooltipContent> - <p className="max-w-xs">{description || "N/A"}</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - </div> - ); - }, - enableSorting: true, - enableHiding: true, - }, - { - accessorKey: "projNm", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="프로젝트명" /> - ), - cell: ({ row }) => { - const projNm = row.getValue("projNm") as string; - return ( - <div className="min-w-48 max-w-64"> - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> - <span className="truncate block text-sm"> - {projNm || "N/A"} - </span> - </TooltipTrigger> - <TooltipContent> - <p className="max-w-xs">{projNm || "N/A"}</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - </div> - ); - }, - enableSorting: true, - enableHiding: true, - }, - // { - // accessorKey: "quotationCode", - // header: ({ column }) => ( - // <DataTableColumnHeaderSimple column={column} title="견적서 번호" /> - // ), - // cell: ({ row }) => { - // const quotationCode = row.getValue("quotationCode") as string; - // return ( - // <div className="min-w-32"> - // <span className="font-mono text-sm">{quotationCode || "미부여"}</span> - // </div> - // ); - // }, - // enableSorting: true, - // enableHiding: true, - // }, - // { - // accessorKey: "quotationVersion", - // header: ({ column }) => ( - // <DataTableColumnHeaderSimple column={column} title="버전" /> - // ), - // cell: ({ row }) => { - // const quotationVersion = row.getValue("quotationVersion") as number; - // return ( - // <div className="w-16 text-center"> - // <span className="text-sm">{quotationVersion || 1}</span> - // </div> - // ); - // }, - // enableSorting: true, - // 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="첨부파일" /> - ), - cell: ({ row }) => { - const quotation = row.original - const attachmentCount = quotation.attachmentCount || 0 - const handleClick = () => { - openAttachmentsSheet(quotation.rfqId) - } - - 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={ - attachmentCount > 0 ? `View ${attachmentCount} attachments` : "No 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> - </TooltipTrigger> - <TooltipContent> - <p>{attachmentCount > 0 ? `${attachmentCount}개 첨부파일 보기` : "첨부파일 없음"}</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - </div> - ) - }, - enableSorting: false, - enableHiding: true, - }, - { - accessorKey: "status", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="상태" /> - ), - cell: ({ row }) => { - const status = row.getValue("status") as string; - - const statusConfig = TECH_SALES_QUOTATION_STATUS_CONFIG[status as keyof typeof TECH_SALES_QUOTATION_STATUS_CONFIG] || { - label: status, - variant: "secondary" as const - }; - - return ( - <div className="w-24"> - <Badge variant={statusConfig.variant} className="text-xs"> - {statusConfig.label} - </Badge> - </div> - ); - }, - enableSorting: true, - enableHiding: false, - filterFn: (row, id, value) => { - return value.includes(row.getValue(id)); - }, - }, - { - accessorKey: "currency", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="통화" /> - ), - cell: ({ row }) => { - const currency = row.getValue("currency") as string; - return ( - <div className="w-16"> - <span className="font-mono text-sm">{currency || "N/A"}</span> - </div> - ); - }, - enableSorting: true, - enableHiding: true, - }, - { - accessorKey: "totalPrice", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="총액" /> - ), - cell: ({ row }) => { - const totalPrice = row.getValue("totalPrice") as string; - const currency = row.getValue("currency") as string; - - if (!totalPrice || totalPrice === "0") { - return ( - <div className="w-32 text-right"> - <span className="text-muted-foreground text-sm">미입력</span> - </div> - ); - } - - return ( - <div className="w-32 text-right"> - <span className="font-mono text-sm"> - {formatCurrency(parseFloat(totalPrice), currency || "USD")} - </span> - </div> - ); - }, - enableSorting: true, - enableHiding: true, - }, - { - accessorKey: "validUntil", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="유효기간" /> - ), - cell: ({ row }) => { - const validUntil = row.getValue("validUntil") as Date; - return ( - <div className="w-28"> - <span className="text-sm"> - {validUntil ? formatDate(validUntil) : "N/A"} - </span> - </div> - ); - }, - enableSorting: true, - enableHiding: true, - }, - { - accessorKey: "submittedAt", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="제출일" /> - ), - cell: ({ row }) => { - const submittedAt = row.getValue("submittedAt") as Date; - return ( - <div className="w-36"> - <span className="text-sm"> - {submittedAt ? formatDateTime(submittedAt) : "미제출"} - </span> - </div> - ); - }, - enableSorting: true, - enableHiding: true, - }, - // { - // accessorKey: "acceptedAt", - // header: ({ column }) => ( - // <DataTableColumnHeaderSimple column={column} title="승인일" /> - // ), - // cell: ({ row }) => { - // const acceptedAt = row.getValue("acceptedAt") as Date; - // return ( - // <div className="w-36"> - // <span className="text-sm"> - // {acceptedAt ? formatDateTime(acceptedAt) : "미승인"} - // </span> - // </div> - // ); - // }, - // enableSorting: true, - // enableHiding: true, - // }, - { - accessorKey: "dueDate", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="마감일" /> - ), - cell: ({ row }) => { - const dueDate = row.getValue("dueDate") as Date; - const isOverdue = dueDate && new Date() > new Date(dueDate); - - return ( - <div className="w-28"> - <span className={`text-sm ${isOverdue ? "text-red-600 font-medium" : ""}`}> - {dueDate ? formatDate(dueDate) : "N/A"} - </span> - </div> - ); - }, - enableSorting: true, - enableHiding: true, - }, - // { - // accessorKey: "rejectionReason", - // header: ({ column }) => ( - // <DataTableColumnHeaderSimple column={column} title="반려사유" /> - // ), - // cell: ({ row }) => { - // const rejectionReason = row.getValue("rejectionReason") as string; - // return ( - // <div className="min-w-48 max-w-64"> - // {rejectionReason ? ( - // <TooltipProvider> - // <Tooltip> - // <TooltipTrigger asChild> - // <span className="truncate block text-sm text-red-600"> - // {rejectionReason} - // </span> - // </TooltipTrigger> - // <TooltipContent> - // <p className="max-w-xs">{rejectionReason}</p> - // </TooltipContent> - // </Tooltip> - // </TooltipProvider> - // ) : ( - // <span className="text-sm text-muted-foreground">N/A</span> - // )} - // </div> - // ); - // }, - // enableSorting: false, - // enableHiding: true, - // }, - { - accessorKey: "createdAt", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="생성일" /> - ), - cell: ({ row }) => { - const createdAt = row.getValue("createdAt") as Date; - return ( - <div className="w-36"> - <span className="text-sm"> - {createdAt ? formatDateTime(createdAt) : "N/A"} - </span> - </div> - ); - }, - enableSorting: true, - enableHiding: true, - }, - { - accessorKey: "updatedAt", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="수정일" /> - ), - cell: ({ row }) => { - const updatedAt = row.getValue("updatedAt") as Date; - return ( - <div className="w-36"> - <span className="text-sm"> - {updatedAt ? formatDateTime(updatedAt) : "N/A"} - </span> - </div> - ); - }, - enableSorting: true, - enableHiding: true, - }, - // { - // accessorKey: "createdByName", - // header: ({ column }) => ( - // <DataTableColumnHeaderSimple column={column} title="생성자" /> - // ), - // cell: ({ row }) => { - // const createdByName = row.getValue("createdByName") as string; - // return ( - // <div className="w-24"> - // <span className="text-sm">{createdByName || "N/A"}</span> - // </div> - // ); - // }, - // enableSorting: true, - // enableHiding: true, - // }, - // { - // accessorKey: "updatedByName", - // header: ({ column }) => ( - // <DataTableColumnHeaderSimple column={column} title="수정자" /> - // ), - // cell: ({ row }) => { - // const updatedByName = row.getValue("updatedByName") as string; - // return ( - // <div className="w-24"> - // <span className="text-sm">{updatedByName || "N/A"}</span> - // </div> - // ); - // }, - // enableSorting: true, - // enableHiding: true, - // }, - { - id: "actions", - header: "작업", - cell: ({ row }) => { - const quotation = row.original; - const rfqCode = quotation.rfqCode || "N/A"; - const tooltipText = `${rfqCode} 견적서 작성`; - const isRejected = quotation.status === "Rejected"; - const isAccepted = quotation.status === "Accepted"; - const isDisabled = isRejected || isAccepted; - - return ( - <div className="w-16"> - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="ghost" - size="icon" - onClick={() => { - if (!isDisabled) { - router.push(`/ko/partners/techsales/rfq-ship/${quotation.id}`); - } - }} - className="h-8 w-8" - disabled={isDisabled} - > - <Edit className="h-4 w-4" /> - <span className="sr-only">견적서 작성</span> - </Button> - </TooltipTrigger> - <TooltipContent> - <p>{isRejected ? "거절된 견적서는 편집할 수 없습니다" : isAccepted ? "승인된 견적서는 편집할 수 없습니다" : tooltipText}</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - </div> - ); - }, - enableSorting: false, - enableHiding: false, - }, - ]; +"use client"
+
+import * as React from "react"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Edit, Paperclip, Package, Users } from "lucide-react"
+import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import {
+ TechSalesVendorQuotations,
+ TECH_SALES_QUOTATION_STATUS_CONFIG,
+ TECH_SALES_QUOTATION_STATUSES
+} from "@/db/schema"
+import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+
+interface QuotationWithRfqCode extends TechSalesVendorQuotations {
+ // RFQ 관련 정보
+ rfqCode?: string;
+ materialCode?: string;
+ dueDate?: Date;
+ rfqStatus?: string;
+
+ // 아이템 정보
+ itemName?: string;
+ itemCount?: number;
+
+ // 프로젝트 정보
+ projNm?: string;
+ pspid?: string;
+ sector?: string;
+
+ // RFQ 정보
+ description?: string;
+
+ // 벤더 정보
+ vendorName?: string;
+ vendorCode?: string;
+
+ // 사용자 정보
+ createdByName?: string | null;
+ updatedByName?: string | null;
+
+ // 첨부파일 개수
+ attachmentCount?: number;
+}
+
+interface GetColumnsProps {
+ router: AppRouterInstance;
+ openAttachmentsSheet: (rfqId: number) => void;
+ openItemsDialog: (rfq: { id: number; rfqCode?: string; status?: string; rfqType?: "SHIP" | "TOP" | "HULL"; }) => void;
+ openContactsDialog: (quotationId: number, vendorName?: string) => void;
+}
+
+export function getColumns({ router, openAttachmentsSheet, openItemsDialog, openContactsDialog }: GetColumnsProps): ColumnDef<QuotationWithRfqCode>[] {
+ return [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="모두 선택"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => {
+ const isRejected = row.original.status === TECH_SALES_QUOTATION_STATUSES.REJECTED;
+ const isAccepted = row.original.status === TECH_SALES_QUOTATION_STATUSES.ACCEPTED;
+ const isDisabled = isRejected || isAccepted;
+
+ return (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="행 선택"
+ className="translate-y-0.5"
+ disabled={isDisabled}
+ />
+ );
+ },
+ enableSorting: false,
+ enableHiding: false,
+ },
+ // {
+ // accessorKey: "id",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="ID" />
+ // ),
+ // cell: ({ row }) => (
+ // <div className="w-20">
+ // <span className="font-mono text-xs">{row.getValue("id")}</span>
+ // </div>
+ // ),
+ // enableSorting: true,
+ // enableHiding: true,
+ // },
+ {
+ accessorKey: "rfqCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ 번호" />
+ ),
+ cell: ({ row }) => {
+ const rfqCode = row.getValue("rfqCode") as string;
+ return (
+ <div className="min-w-32">
+ <span className="font-mono text-sm">{rfqCode || "N/A"}</span>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: false,
+ },
+ // {
+ // accessorKey: "vendorName",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="벤더명" />
+ // ),
+ // cell: ({ row }) => {
+ // const vendorName = row.getValue("vendorName") as string;
+ // return (
+ // <div className="min-w-32">
+ // <span className="text-sm">{vendorName || "N/A"}</span>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // enableHiding: false,
+ // },
+ // {
+ // accessorKey: "vendorCode",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="벤더 코드" />
+ // ),
+ // cell: ({ row }) => {
+ // const vendorCode = row.getValue("vendorCode") as string;
+ // return (
+ // <div className="min-w-24">
+ // <span className="font-mono text-sm">{vendorCode || "N/A"}</span>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // enableHiding: true,
+ // },
+ // {
+ // accessorKey: "materialCode",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="자재 그룹" />
+ // ),
+ // cell: ({ row }) => {
+ // const materialCode = row.getValue("materialCode") as string;
+ // return (
+ // <div className="min-w-32">
+ // <span className="font-mono text-sm">{materialCode || "N/A"}</span>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // enableHiding: true,
+ // },
+ // {
+ // accessorKey: "itemName",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="자재명" />
+ // ),
+ // cell: ({ row }) => {
+ // const itemName = row.getValue("itemName") as string;
+ // return (
+ // <div className="min-w-48 max-w-64">
+ // <TooltipProvider>
+ // <Tooltip>
+ // <TooltipTrigger asChild>
+ // <span className="truncate block text-sm">
+ // {itemName || "N/A"}
+ // </span>
+ // </TooltipTrigger>
+ // <TooltipContent>
+ // <p className="max-w-xs">{itemName || "N/A"}</p>
+ // </TooltipContent>
+ // </Tooltip>
+ // </TooltipProvider>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // enableHiding: true,
+ // },
+ {
+ accessorKey: "description",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ title" />
+ ),
+ cell: ({ row }) => {
+ const description = row.getValue("description") as string;
+ return (
+ <div className="min-w-48 max-w-64">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <span className="truncate block text-sm">
+ {description || "N/A"}
+ </span>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p className="max-w-xs">{description || "N/A"}</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ },
+ {
+ accessorKey: "projNm",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="프로젝트명" />
+ ),
+ cell: ({ row }) => {
+ const projNm = row.getValue("projNm") as string;
+ return (
+ <div className="min-w-48 max-w-64">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <span className="truncate block text-sm">
+ {projNm || "N/A"}
+ </span>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p className="max-w-xs">{projNm || "N/A"}</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ },
+ // {
+ // accessorKey: "quotationCode",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="견적서 번호" />
+ // ),
+ // cell: ({ row }) => {
+ // const quotationCode = row.getValue("quotationCode") as string;
+ // return (
+ // <div className="min-w-32">
+ // <span className="font-mono text-sm">{quotationCode || "미부여"}</span>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // enableHiding: true,
+ // },
+ // {
+ // accessorKey: "quotationVersion",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="버전" />
+ // ),
+ // cell: ({ row }) => {
+ // const quotationVersion = row.getValue("quotationVersion") as number;
+ // return (
+ // <div className="w-16 text-center">
+ // <span className="text-sm">{quotationVersion || 1}</span>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // 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="첨부파일" />
+ ),
+ cell: ({ row }) => {
+ const quotation = row.original
+ const attachmentCount = quotation.attachmentCount || 0
+ const handleClick = () => {
+ openAttachmentsSheet(quotation.rfqId)
+ }
+
+ 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={
+ attachmentCount > 0 ? `View ${attachmentCount} attachments` : "No 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>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>{attachmentCount > 0 ? `${attachmentCount}개 첨부파일 보기` : "첨부파일 없음"}</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ )
+ },
+ enableSorting: false,
+ enableHiding: true,
+ },
+ {
+ accessorKey: "status",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="상태" />
+ ),
+ cell: ({ row }) => {
+ const status = row.getValue("status") as string;
+
+ const statusConfig = TECH_SALES_QUOTATION_STATUS_CONFIG[status as keyof typeof TECH_SALES_QUOTATION_STATUS_CONFIG] || {
+ label: status,
+ variant: "secondary" as const
+ };
+
+ return (
+ <div className="w-24">
+ <Badge variant={statusConfig.variant} className="text-xs">
+ {statusConfig.label}
+ </Badge>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: false,
+ filterFn: (row, id, value) => {
+ return value.includes(row.getValue(id));
+ },
+ },
+ {
+ accessorKey: "currency",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="통화" />
+ ),
+ cell: ({ row }) => {
+ const currency = row.getValue("currency") as string;
+ return (
+ <div className="w-16">
+ <span className="font-mono text-sm">{currency || "N/A"}</span>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ },
+ {
+ accessorKey: "totalPrice",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="총액" />
+ ),
+ cell: ({ row }) => {
+ const totalPrice = row.getValue("totalPrice") as string;
+ const currency = row.getValue("currency") as string;
+
+ if (!totalPrice || totalPrice === "0") {
+ return (
+ <div className="w-32 text-right">
+ <span className="text-muted-foreground text-sm">미입력</span>
+ </div>
+ );
+ }
+
+ return (
+ <div className="w-32 text-right">
+ <span className="font-mono text-sm">
+ {formatCurrency(parseFloat(totalPrice), currency || "USD")}
+ </span>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ },
+ {
+ accessorKey: "validUntil",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="유효기간" />
+ ),
+ cell: ({ row }) => {
+ const validUntil = row.getValue("validUntil") as Date;
+ return (
+ <div className="w-28">
+ <span className="text-sm">
+ {validUntil ? formatDate(validUntil) : "N/A"}
+ </span>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ },
+ {
+ accessorKey: "submittedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="제출일" />
+ ),
+ cell: ({ row }) => {
+ const submittedAt = row.getValue("submittedAt") as Date;
+ return (
+ <div className="w-36">
+ <span className="text-sm">
+ {submittedAt ? formatDateTime(submittedAt) : "미제출"}
+ </span>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ },
+ // {
+ // accessorKey: "acceptedAt",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="승인일" />
+ // ),
+ // cell: ({ row }) => {
+ // const acceptedAt = row.getValue("acceptedAt") as Date;
+ // return (
+ // <div className="w-36">
+ // <span className="text-sm">
+ // {acceptedAt ? formatDateTime(acceptedAt) : "미승인"}
+ // </span>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // enableHiding: true,
+ // },
+ {
+ accessorKey: "dueDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="마감일" />
+ ),
+ cell: ({ row }) => {
+ const dueDate = row.getValue("dueDate") as Date;
+ const isOverdue = dueDate && new Date() > new Date(dueDate);
+
+ return (
+ <div className="w-28">
+ <span className={`text-sm ${isOverdue ? "text-red-600 font-medium" : ""}`}>
+ {dueDate ? formatDate(dueDate) : "N/A"}
+ </span>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ },
+ // {
+ // accessorKey: "rejectionReason",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="반려사유" />
+ // ),
+ // cell: ({ row }) => {
+ // const rejectionReason = row.getValue("rejectionReason") as string;
+ // return (
+ // <div className="min-w-48 max-w-64">
+ // {rejectionReason ? (
+ // <TooltipProvider>
+ // <Tooltip>
+ // <TooltipTrigger asChild>
+ // <span className="truncate block text-sm text-red-600">
+ // {rejectionReason}
+ // </span>
+ // </TooltipTrigger>
+ // <TooltipContent>
+ // <p className="max-w-xs">{rejectionReason}</p>
+ // </TooltipContent>
+ // </Tooltip>
+ // </TooltipProvider>
+ // ) : (
+ // <span className="text-sm text-muted-foreground">N/A</span>
+ // )}
+ // </div>
+ // );
+ // },
+ // enableSorting: false,
+ // enableHiding: true,
+ // },
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="생성일" />
+ ),
+ cell: ({ row }) => {
+ const createdAt = row.getValue("createdAt") as Date;
+ return (
+ <div className="w-36">
+ <span className="text-sm">
+ {createdAt ? formatDateTime(createdAt) : "N/A"}
+ </span>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ },
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="수정일" />
+ ),
+ cell: ({ row }) => {
+ const updatedAt = row.getValue("updatedAt") as Date;
+ return (
+ <div className="w-36">
+ <span className="text-sm">
+ {updatedAt ? formatDateTime(updatedAt) : "N/A"}
+ </span>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ },
+ // {
+ // accessorKey: "createdByName",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="생성자" />
+ // ),
+ // cell: ({ row }) => {
+ // const createdByName = row.getValue("createdByName") as string;
+ // return (
+ // <div className="w-24">
+ // <span className="text-sm">{createdByName || "N/A"}</span>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // enableHiding: true,
+ // },
+ // {
+ // accessorKey: "updatedByName",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="수정자" />
+ // ),
+ // cell: ({ row }) => {
+ // const updatedByName = row.getValue("updatedByName") as string;
+ // return (
+ // <div className="w-24">
+ // <span className="text-sm">{updatedByName || "N/A"}</span>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // enableHiding: true,
+ // },
+ {
+ id: "actions",
+ header: "작업",
+ cell: ({ row }) => {
+ const quotation = row.original;
+ const rfqCode = quotation.rfqCode || "N/A";
+ const tooltipText = `${rfqCode} 견적서 작성`;
+ const isRejected = quotation.status === "Rejected";
+ const isAccepted = quotation.status === "Accepted";
+ const isDisabled = isRejected || isAccepted;
+
+ return (
+ <div className="w-16">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={() => {
+ if (!isDisabled) {
+ router.push(`/ko/partners/techsales/rfq-ship/${quotation.id}`);
+ }
+ }}
+ className="h-8 w-8"
+ disabled={isDisabled}
+ >
+ <Edit className="h-4 w-4" />
+ <span className="sr-only">견적서 작성</span>
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>{isRejected ? "거절된 견적서는 편집할 수 없습니다" : isAccepted ? "승인된 견적서는 편집할 수 없습니다" : tooltipText}</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ );
+ },
+ enableSorting: false,
+ enableHiding: false,
+ },
+ {
+ id: "contacts",
+ header: "담당자",
+ cell: ({ row }) => {
+ const quotation = row.original;
+
+ const handleClick = () => {
+ openContactsDialog(quotation.id, quotation.vendorName);
+ };
+
+ return (
+ <div className="w-20">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label="담당자 정보 보기"
+ >
+ <Users className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ <span className="sr-only">담당자 정보 보기</span>
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>RFQ 발송 담당자 보기</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ );
+ },
+ enableSorting: false,
+ enableHiding: true,
+ },
+ ];
}
\ No newline at end of file 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 e79d7c4d..5bb219bf 100644 --- a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx +++ b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx @@ -1,505 +1,525 @@ -// lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx -"use client" - -import * as React from "react" -import { useSearchParams } from "next/navigation" -import { type DataTableAdvancedFilterField, type DataTableFilterField } from "@/types/table" -import { useDataTable } from "@/hooks/use-data-table" -import { DataTable } from "@/components/data-table/data-table" -import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" -import { TechSalesVendorQuotations, TECH_SALES_QUOTATION_STATUSES, TECH_SALES_QUOTATION_STATUS_CONFIG } from "@/db/schema" -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, rejectTechSalesVendorQuotations } from "@/lib/techsales-rfq/service" -import { toast } from "sonner" -import { Skeleton } from "@/components/ui/skeleton" -import { Button } from "@/components/ui/button" -import { X } from "lucide-react" -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog" -import { Textarea } from "@/components/ui/textarea" -import { Label } from "@/components/ui/label" - -interface QuotationWithRfqCode extends TechSalesVendorQuotations { - rfqCode?: string | null; - materialCode?: string | null; - dueDate?: Date; - rfqStatus?: string; - itemName?: string | null; - projNm?: string | null; - description?: string | null; - attachmentCount?: number; - itemCount?: number; - pspid?: string | null; - sector?: string | null; - vendorName?: string | null; - vendorCode?: string | null; - createdByName?: string | null; - updatedByName?: string | null; -} - -interface VendorQuotationsTableProps { - vendorId: string; - rfqType?: "SHIP" | "TOP" | "HULL"; -} - -// 로딩 스켈레톤 컴포넌트 -function TableLoadingSkeleton() { - return ( - <div className="w-full space-y-3"> - {/* 툴바 스켈레톤 */} - <div className="flex items-center justify-between"> - <div className="flex items-center space-x-2"> - <Skeleton className="h-10 w-[250px]" /> - <Skeleton className="h-10 w-[100px]" /> - </div> - <div className="flex items-center space-x-2"> - <Skeleton className="h-10 w-[120px]" /> - <Skeleton className="h-10 w-[100px]" /> - </div> - </div> - - {/* 테이블 헤더 스켈레톤 */} - <div className="rounded-md border"> - <div className="border-b p-4"> - <div className="flex items-center space-x-4"> - <Skeleton className="h-4 w-[100px]" /> - <Skeleton className="h-4 w-[150px]" /> - <Skeleton className="h-4 w-[120px]" /> - <Skeleton className="h-4 w-[100px]" /> - <Skeleton className="h-4 w-[130px]" /> - <Skeleton className="h-4 w-[100px]" /> - <Skeleton className="h-4 w-[80px]" /> - </div> - </div> - - {/* 테이블 행 스켈레톤 */} - {Array.from({ length: 5 }).map((_, index) => ( - <div key={index} className="border-b p-4 last:border-b-0"> - <div className="flex items-center space-x-4"> - <Skeleton className="h-4 w-[100px]" /> - <Skeleton className="h-4 w-[150px]" /> - <Skeleton className="h-4 w-[120px]" /> - <Skeleton className="h-4 w-[100px]" /> - <Skeleton className="h-4 w-[130px]" /> - <Skeleton className="h-4 w-[100px]" /> - <Skeleton className="h-4 w-[80px]" /> - </div> - </div> - ))} - </div> - - {/* 페이지네이션 스켈레톤 */} - <div className="flex items-center justify-between"> - <Skeleton className="h-8 w-[200px]" /> - <div className="flex items-center space-x-2"> - <Skeleton className="h-8 w-[100px]" /> - <Skeleton className="h-8 w-[60px]" /> - <Skeleton className="h-8 w-[100px]" /> - </div> - </div> - </div> - ) -} - -export function VendorQuotationsTable({ vendorId, rfqType }: VendorQuotationsTableProps) { - const searchParams = useSearchParams() - const router = useRouter() - - // 첨부파일 시트 상태 - const [attachmentsOpen, setAttachmentsOpen] = React.useState(false) - 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 [rejectDialogOpen, setRejectDialogOpen] = React.useState(false) - const [rejectionReason, setRejectionReason] = React.useState("") - const [isRejecting, setIsRejecting] = React.useState(false) - - // 데이터 로딩 상태 - const [data, setData] = React.useState<QuotationWithRfqCode[]>([]) - const [pageCount, setPageCount] = React.useState(0) - const [total, setTotal] = React.useState(0) - const [isLoading, setIsLoading] = React.useState(true) - const [isInitialLoad, setIsInitialLoad] = React.useState(true) // 최초 로딩 구분 - - // URL 파라미터에서 설정 읽기 - const initialSettings = React.useMemo(() => ({ - page: parseInt(searchParams?.get('page') || '1'), - perPage: parseInt(searchParams?.get('perPage') || '10'), - sort: searchParams?.get('sort') ? JSON.parse(searchParams.get('sort')!) : [{ id: "updatedAt", desc: true }], - filters: searchParams?.get('filters') ? JSON.parse(searchParams.get('filters')!) : [], - joinOperator: (searchParams?.get('joinOperator') as "and" | "or") || "and", - basicFilters: searchParams?.get('basicFilters') ? JSON.parse(searchParams.get('basicFilters')!) : [], - basicJoinOperator: (searchParams?.get('basicJoinOperator') as "and" | "or") || "and", - search: searchParams?.get('search') || '', - from: searchParams?.get('from') || '', - to: searchParams?.get('to') || '', - }), [searchParams]) - - // 데이터 로드 함수 - const loadData = React.useCallback(async () => { - try { - setIsLoading(true) - - console.log('🔍 [VendorQuotationsTable] 데이터 로드 요청:', { - vendorId, - settings: initialSettings - }) - - const result = await getVendorQuotations({ - page: initialSettings.page, - perPage: initialSettings.perPage, - sort: initialSettings.sort, - filters: initialSettings.filters, - joinOperator: initialSettings.joinOperator, - basicFilters: initialSettings.basicFilters, - basicJoinOperator: initialSettings.basicJoinOperator, - search: initialSettings.search, - from: initialSettings.from, - to: initialSettings.to, - rfqType: rfqType, - }, vendorId) - - console.log('🔍 [VendorQuotationsTable] 데이터 로드 결과:', { - dataLength: result.data.length, - pageCount: result.pageCount, - total: result.total - }) - - setData(result.data as QuotationWithRfqCode[]) - setPageCount(result.pageCount) - setTotal(result.total) - } catch (error) { - console.error('데이터 로드 오류:', error) - toast.error('데이터를 불러오는 중 오류가 발생했습니다.') - } finally { - setIsLoading(false) - setIsInitialLoad(false) - } - }, [vendorId, initialSettings, rfqType]) - - // URL 파라미터 변경 감지 및 데이터 재로드 (초기 로드 포함) - React.useEffect(() => { - loadData() - }, [ - searchParams?.get('page'), - searchParams?.get('perPage'), - searchParams?.get('sort'), - searchParams?.get('filters'), - searchParams?.get('joinOperator'), - searchParams?.get('basicFilters'), - searchParams?.get('basicJoinOperator'), - searchParams?.get('search'), - searchParams?.get('from'), - searchParams?.get('to'), - // vendorId와 rfqType 변경도 감지 - vendorId, - rfqType - ]) - - // 데이터 안정성을 위한 메모이제이션 - const stableData = React.useMemo(() => { - return data; - }, [data.length, data.map(item => `${item.id}-${item.status}-${item.updatedAt}`).join(',')]); - - // 첨부파일 시트 열기 함수 (벤더는 RFQ_COMMON 타입만 조회) - const openAttachmentsSheet = React.useCallback(async (rfqId: number) => { - try { - // RFQ 정보 조회 (data에서 rfqId에 해당하는 데이터 찾기) - const quotationWithRfq = data.find(item => item.rfqId === rfqId) - if (!quotationWithRfq) { - toast.error("RFQ 정보를 찾을 수 없습니다.") - return - } - - // 실제 첨부파일 목록 조회 API 호출 - const result = await getTechSalesRfqAttachments(rfqId) - - if (result.error) { - toast.error(result.error) - return - } - - // API 응답을 ExistingTechSalesAttachment 형식으로 변환하고 RFQ_COMMON 타입만 필터링 - const attachments: ExistingTechSalesAttachment[] = result.data - .filter(att => att.attachmentType === "RFQ_COMMON") // 벤더는 RFQ_COMMON 타입만 조회 - .map(att => ({ - id: att.id, - techSalesRfqId: att.techSalesRfqId || rfqId, - fileName: att.fileName, - originalFileName: att.originalFileName, - filePath: att.filePath, - fileSize: att.fileSize || undefined, - fileType: att.fileType || undefined, - attachmentType: att.attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT", - description: att.description || undefined, - createdBy: att.createdBy, - createdAt: att.createdAt, - })) - - setAttachmentsDefault(attachments) - setSelectedRfqForAttachments({ - id: rfqId, - rfqCode: quotationWithRfq.rfqCode || null, - status: quotationWithRfq.rfqStatus || "Unknown" - }) - setAttachmentsOpen(true) - } catch (error) { - console.error("첨부파일 조회 오류:", error) - toast.error("첨부파일 조회 중 오류가 발생했습니다.") - } - }, [data]) - - // 아이템 다이얼로그 열기 함수 - const openItemsDialog = React.useCallback((rfq: { id: number; rfqCode?: string; status?: string; rfqType?: "SHIP" | "TOP" | "HULL"; }) => { - setSelectedRfqForItems(rfq) - setItemsDialogOpen(true) - }, []) - - // 거절 처리 함수 - const handleRejectQuotations = React.useCallback(async () => { - if (!table) return; - - const selectedRows = table.getFilteredSelectedRowModel().rows; - const quotationIds = selectedRows.map(row => row.original.id); - - if (quotationIds.length === 0) { - toast.error("거절할 견적서를 선택해주세요."); - return; - } - - // 거절할 수 없는 상태의 견적서가 있는지 확인 - const invalidStatuses = selectedRows.filter(row => - row.original.status === "Accepted" || row.original.status === "Rejected" - ); - - if (invalidStatuses.length > 0) { - toast.error("이미 승인되었거나 거절된 견적서는 거절할 수 없습니다."); - return; - } - - setIsRejecting(true); - - try { - const result = await rejectTechSalesVendorQuotations({ - quotationIds, - rejectionReason: rejectionReason.trim() || undefined, - }); - - if (result.success) { - toast.success(result.message); - setRejectDialogOpen(false); - setRejectionReason(""); - table.resetRowSelection(); - // 데이터 다시 로드 - await loadData(); - } else { - toast.error(result.error || "견적서 거절 중 오류가 발생했습니다."); - } - } catch (error) { - console.error("견적서 거절 오류:", error); - toast.error("견적서 거절 중 오류가 발생했습니다."); - } finally { - setIsRejecting(false); - } - }, [rejectionReason, loadData]); - - // 테이블 컬럼 정의 - const columns = React.useMemo(() => getColumns({ - router, - openAttachmentsSheet, - openItemsDialog, - }), [router, openAttachmentsSheet, openItemsDialog]) - - // 필터 필드 - const filterFields = React.useMemo<DataTableFilterField<QuotationWithRfqCode>[]>(() => [ - { - id: "status", - label: "상태", - options: Object.entries(TECH_SALES_QUOTATION_STATUSES).map(([, statusValue]) => ({ - label: TECH_SALES_QUOTATION_STATUS_CONFIG[statusValue].label, - value: statusValue, - })) - }, - { - id: "rfqCode", - label: "RFQ 번호", - placeholder: "RFQ 번호 검색...", - }, - { - id: "materialCode", - label: "자재 그룹", - placeholder: "자재 그룹 검색...", - } - ], []) - - // 고급 필터 필드 - const advancedFilterFields = React.useMemo<DataTableAdvancedFilterField<QuotationWithRfqCode>[]>(() => [ - { - id: "rfqCode", - label: "RFQ 번호", - type: "text", - }, - { - id: "materialCode", - label: "자재 그룹", - type: "text", - }, - { - id: "status", - label: "상태", - type: "multi-select", - options: Object.entries(TECH_SALES_QUOTATION_STATUSES).map(([, statusValue]) => ({ - label: TECH_SALES_QUOTATION_STATUS_CONFIG[statusValue].label, - value: statusValue, - })), - }, - { - id: "validUntil", - label: "유효기간", - type: "date", - }, - { - id: "submittedAt", - label: "제출일", - type: "date", - }, - ], []) - - // useDataTable 훅 사용 - const { table } = useDataTable({ - data: stableData, - columns: columns as any, // 타입 오류 임시 해결 - pageCount, - rowCount: total, - filterFields, - enablePinning: true, - enableAdvancedFilter: true, - enableColumnResizing: true, - columnResizeMode: 'onChange', - enableRowSelection: true, // 행 선택 활성화 - initialState: { - sorting: initialSettings.sort, - columnPinning: { right: ["actions", "items", "attachments"] }, - }, - getRowId: (originalRow) => String(originalRow.id), - shallow: false, - clearOnDefault: true, - defaultColumn: { - minSize: 50, - maxSize: 500, - }, - }) - - // 최초 로딩 시 전체 스켈레톤 표시 - if (isInitialLoad && isLoading) { - return ( - <div className="w-full"> - <div className="overflow-x-auto"> - <TableLoadingSkeleton /> - </div> - </div> - ) - } - - return ( - <div className="w-full"> - <div className="overflow-x-auto"> - <div className="relative"> - <DataTable - table={table} - className="min-w-full" - > - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - shallow={false} - > - {/* 선택된 행이 있을 때 거절 버튼 표시 */} - {table && table.getFilteredSelectedRowModel().rows.length > 0 && ( - <AlertDialog open={rejectDialogOpen} onOpenChange={setRejectDialogOpen}> - <AlertDialogTrigger asChild> - <Button variant="destructive" size="sm"> - <X className="mr-2 h-4 w-4" /> - 선택한 견적서 거절 ({table.getFilteredSelectedRowModel().rows.length}개) - </Button> - </AlertDialogTrigger> - <AlertDialogContent> - <AlertDialogHeader> - <AlertDialogTitle>견적서 거절</AlertDialogTitle> - <AlertDialogDescription> - 선택한 {table.getFilteredSelectedRowModel().rows.length}개의 견적서를 거절하시겠습니까? - 거절된 견적서는 다시 되돌릴 수 없습니다. - </AlertDialogDescription> - </AlertDialogHeader> - <div className="grid gap-4 py-4"> - <div className="grid gap-2"> - <Label htmlFor="rejection-reason">거절 사유 (선택사항)</Label> - <Textarea - id="rejection-reason" - placeholder="거절 사유를 입력하세요..." - value={rejectionReason} - onChange={(e) => setRejectionReason(e.target.value)} - /> - </div> - </div> - <AlertDialogFooter> - <AlertDialogCancel>취소</AlertDialogCancel> - <AlertDialogAction - onClick={handleRejectQuotations} - disabled={isRejecting} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - {isRejecting ? "처리 중..." : "거절"} - </AlertDialogAction> - </AlertDialogFooter> - </AlertDialogContent> - </AlertDialog> - )} - - {!isInitialLoad && isLoading && ( - <div className="flex items-center gap-2 text-sm text-muted-foreground"> - <div className="animate-spin h-4 w-4 border-2 border-current border-t-transparent rounded-full" /> - 데이터 업데이트 중... - </div> - )} - </DataTableAdvancedToolbar> - </DataTable> - </div> - </div> - - {/* 첨부파일 관리 시트 (읽기 전용) */} - <TechSalesRfqAttachmentsSheet - open={attachmentsOpen} - onOpenChange={setAttachmentsOpen} - defaultAttachments={attachmentsDefault} - rfq={selectedRfqForAttachments} - attachmentType="RFQ_COMMON" // 벤더는 RFQ_COMMON 타입만 조회 - readOnly={true} // 벤더는 항상 읽기 전용 - /> - - {/* 아이템 보기 다이얼로그 */} - <RfqItemsViewDialog - open={itemsDialogOpen} - onOpenChange={setItemsDialogOpen} - rfq={selectedRfqForItems} - /> - </div> - ); +// lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx
+"use client"
+
+import * as React from "react"
+import { useSearchParams } from "next/navigation"
+import { type DataTableAdvancedFilterField, type DataTableFilterField } from "@/types/table"
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { TechSalesVendorQuotations, TECH_SALES_QUOTATION_STATUSES, TECH_SALES_QUOTATION_STATUS_CONFIG } from "@/db/schema"
+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 { QuotationContactsViewDialog } from "../../table/detail-table/quotation-contacts-view-dialog"
+import { getTechSalesRfqAttachments, getVendorQuotations, rejectTechSalesVendorQuotations } from "@/lib/techsales-rfq/service"
+import { toast } from "sonner"
+import { Skeleton } from "@/components/ui/skeleton"
+import { Button } from "@/components/ui/button"
+import { X } from "lucide-react"
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog"
+import { Textarea } from "@/components/ui/textarea"
+import { Label } from "@/components/ui/label"
+
+interface QuotationWithRfqCode extends TechSalesVendorQuotations {
+ rfqCode?: string | null;
+ materialCode?: string | null;
+ dueDate?: Date;
+ rfqStatus?: string;
+ itemName?: string | null;
+ projNm?: string | null;
+ description?: string | null;
+ attachmentCount?: number;
+ itemCount?: number;
+ pspid?: string | null;
+ sector?: string | null;
+ vendorName?: string | null;
+ vendorCode?: string | null;
+ createdByName?: string | null;
+ updatedByName?: string | null;
+}
+
+interface VendorQuotationsTableProps {
+ vendorId: string;
+ rfqType?: "SHIP" | "TOP" | "HULL";
+}
+
+// 로딩 스켈레톤 컴포넌트
+function TableLoadingSkeleton() {
+ return (
+ <div className="w-full space-y-3">
+ {/* 툴바 스켈레톤 */}
+ <div className="flex items-center justify-between">
+ <div className="flex items-center space-x-2">
+ <Skeleton className="h-10 w-[250px]" />
+ <Skeleton className="h-10 w-[100px]" />
+ </div>
+ <div className="flex items-center space-x-2">
+ <Skeleton className="h-10 w-[120px]" />
+ <Skeleton className="h-10 w-[100px]" />
+ </div>
+ </div>
+
+ {/* 테이블 헤더 스켈레톤 */}
+ <div className="rounded-md border">
+ <div className="border-b p-4">
+ <div className="flex items-center space-x-4">
+ <Skeleton className="h-4 w-[100px]" />
+ <Skeleton className="h-4 w-[150px]" />
+ <Skeleton className="h-4 w-[120px]" />
+ <Skeleton className="h-4 w-[100px]" />
+ <Skeleton className="h-4 w-[130px]" />
+ <Skeleton className="h-4 w-[100px]" />
+ <Skeleton className="h-4 w-[80px]" />
+ </div>
+ </div>
+
+ {/* 테이블 행 스켈레톤 */}
+ {Array.from({ length: 5 }).map((_, index) => (
+ <div key={index} className="border-b p-4 last:border-b-0">
+ <div className="flex items-center space-x-4">
+ <Skeleton className="h-4 w-[100px]" />
+ <Skeleton className="h-4 w-[150px]" />
+ <Skeleton className="h-4 w-[120px]" />
+ <Skeleton className="h-4 w-[100px]" />
+ <Skeleton className="h-4 w-[130px]" />
+ <Skeleton className="h-4 w-[100px]" />
+ <Skeleton className="h-4 w-[80px]" />
+ </div>
+ </div>
+ ))}
+ </div>
+
+ {/* 페이지네이션 스켈레톤 */}
+ <div className="flex items-center justify-between">
+ <Skeleton className="h-8 w-[200px]" />
+ <div className="flex items-center space-x-2">
+ <Skeleton className="h-8 w-[100px]" />
+ <Skeleton className="h-8 w-[60px]" />
+ <Skeleton className="h-8 w-[100px]" />
+ </div>
+ </div>
+ </div>
+ )
+}
+
+export function VendorQuotationsTable({ vendorId, rfqType }: VendorQuotationsTableProps) {
+ const searchParams = useSearchParams()
+ const router = useRouter()
+
+ // 첨부파일 시트 상태
+ const [attachmentsOpen, setAttachmentsOpen] = React.useState(false)
+ 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 [contactsDialogOpen, setContactsDialogOpen] = React.useState(false)
+ const [selectedQuotationForContacts, setSelectedQuotationForContacts] = React.useState<{ id: number; vendorName?: string } | null>(null)
+
+ // 거절 다이얼로그 상태
+ const [rejectDialogOpen, setRejectDialogOpen] = React.useState(false)
+ const [rejectionReason, setRejectionReason] = React.useState("")
+ const [isRejecting, setIsRejecting] = React.useState(false)
+
+ // 데이터 로딩 상태
+ const [data, setData] = React.useState<QuotationWithRfqCode[]>([])
+ const [pageCount, setPageCount] = React.useState(0)
+ const [total, setTotal] = React.useState(0)
+ const [isLoading, setIsLoading] = React.useState(true)
+ const [isInitialLoad, setIsInitialLoad] = React.useState(true) // 최초 로딩 구분
+
+ // URL 파라미터에서 설정 읽기
+ const initialSettings = React.useMemo(() => ({
+ page: parseInt(searchParams?.get('page') || '1'),
+ perPage: parseInt(searchParams?.get('perPage') || '10'),
+ sort: searchParams?.get('sort') ? JSON.parse(searchParams.get('sort')!) : [{ id: "updatedAt", desc: true }],
+ filters: searchParams?.get('filters') ? JSON.parse(searchParams.get('filters')!) : [],
+ joinOperator: (searchParams?.get('joinOperator') as "and" | "or") || "and",
+ basicFilters: searchParams?.get('basicFilters') ? JSON.parse(searchParams.get('basicFilters')!) : [],
+ basicJoinOperator: (searchParams?.get('basicJoinOperator') as "and" | "or") || "and",
+ search: searchParams?.get('search') || '',
+ from: searchParams?.get('from') || '',
+ to: searchParams?.get('to') || '',
+ }), [searchParams])
+
+ // 데이터 로드 함수
+ const loadData = React.useCallback(async () => {
+ try {
+ setIsLoading(true)
+
+ console.log('🔍 [VendorQuotationsTable] 데이터 로드 요청:', {
+ vendorId,
+ settings: initialSettings
+ })
+
+ const result = await getVendorQuotations({
+ page: initialSettings.page,
+ perPage: initialSettings.perPage,
+ sort: initialSettings.sort,
+ filters: initialSettings.filters,
+ joinOperator: initialSettings.joinOperator,
+ basicFilters: initialSettings.basicFilters,
+ basicJoinOperator: initialSettings.basicJoinOperator,
+ search: initialSettings.search,
+ from: initialSettings.from,
+ to: initialSettings.to,
+ rfqType: rfqType,
+ }, vendorId)
+
+ console.log('🔍 [VendorQuotationsTable] 데이터 로드 결과:', {
+ dataLength: result.data.length,
+ pageCount: result.pageCount,
+ total: result.total
+ })
+
+ setData(result.data as QuotationWithRfqCode[])
+ setPageCount(result.pageCount)
+ setTotal(result.total)
+ } catch (error) {
+ console.error('데이터 로드 오류:', error)
+ toast.error('데이터를 불러오는 중 오류가 발생했습니다.')
+ } finally {
+ setIsLoading(false)
+ setIsInitialLoad(false)
+ }
+ }, [vendorId, initialSettings, rfqType])
+
+ // URL 파라미터 변경 감지 및 데이터 재로드 (초기 로드 포함)
+ React.useEffect(() => {
+ loadData()
+ }, [
+ searchParams?.get('page'),
+ searchParams?.get('perPage'),
+ searchParams?.get('sort'),
+ searchParams?.get('filters'),
+ searchParams?.get('joinOperator'),
+ searchParams?.get('basicFilters'),
+ searchParams?.get('basicJoinOperator'),
+ searchParams?.get('search'),
+ searchParams?.get('from'),
+ searchParams?.get('to'),
+ // vendorId와 rfqType 변경도 감지
+ vendorId,
+ rfqType
+ ])
+
+ // 데이터 안정성을 위한 메모이제이션
+ const stableData = React.useMemo(() => {
+ return data;
+ }, [data.length, data.map(item => `${item.id}-${item.status}-${item.updatedAt}`).join(',')]);
+
+ // 첨부파일 시트 열기 함수 (벤더는 RFQ_COMMON 타입만 조회)
+ const openAttachmentsSheet = React.useCallback(async (rfqId: number) => {
+ try {
+ // RFQ 정보 조회 (data에서 rfqId에 해당하는 데이터 찾기)
+ const quotationWithRfq = data.find(item => item.rfqId === rfqId)
+ if (!quotationWithRfq) {
+ toast.error("RFQ 정보를 찾을 수 없습니다.")
+ return
+ }
+
+ // 실제 첨부파일 목록 조회 API 호출
+ const result = await getTechSalesRfqAttachments(rfqId)
+
+ if (result.error) {
+ toast.error(result.error)
+ return
+ }
+
+ // API 응답을 ExistingTechSalesAttachment 형식으로 변환하고 RFQ_COMMON 타입만 필터링
+ const attachments: ExistingTechSalesAttachment[] = result.data
+ .filter(att => att.attachmentType === "RFQ_COMMON") // 벤더는 RFQ_COMMON 타입만 조회
+ .map(att => ({
+ id: att.id,
+ techSalesRfqId: att.techSalesRfqId || rfqId,
+ fileName: att.fileName,
+ originalFileName: att.originalFileName,
+ filePath: att.filePath,
+ fileSize: att.fileSize || undefined,
+ fileType: att.fileType || undefined,
+ attachmentType: att.attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT",
+ description: att.description || undefined,
+ createdBy: att.createdBy,
+ createdAt: att.createdAt,
+ }))
+
+ setAttachmentsDefault(attachments)
+ setSelectedRfqForAttachments({
+ id: rfqId,
+ rfqCode: quotationWithRfq.rfqCode || null,
+ status: quotationWithRfq.rfqStatus || "Unknown"
+ })
+ setAttachmentsOpen(true)
+ } catch (error) {
+ console.error("첨부파일 조회 오류:", error)
+ toast.error("첨부파일 조회 중 오류가 발생했습니다.")
+ }
+ }, [data])
+
+ // 아이템 다이얼로그 열기 함수
+ const openItemsDialog = React.useCallback((rfq: { id: number; rfqCode?: string; status?: string; rfqType?: "SHIP" | "TOP" | "HULL"; }) => {
+ setSelectedRfqForItems(rfq)
+ setItemsDialogOpen(true)
+ }, [])
+
+ // 담당자 조회 다이얼로그 열기 함수
+ const openContactsDialog = React.useCallback((quotationId: number, vendorName?: string) => {
+ setSelectedQuotationForContacts({ id: quotationId, vendorName })
+ setContactsDialogOpen(true)
+ }, [])
+
+ // 거절 처리 함수
+ const handleRejectQuotations = React.useCallback(async () => {
+ if (!table) return;
+
+ const selectedRows = table.getFilteredSelectedRowModel().rows;
+ const quotationIds = selectedRows.map(row => row.original.id);
+
+ if (quotationIds.length === 0) {
+ toast.error("거절할 견적서를 선택해주세요.");
+ return;
+ }
+
+ // 거절할 수 없는 상태의 견적서가 있는지 확인
+ const invalidStatuses = selectedRows.filter(row =>
+ row.original.status === "Accepted" || row.original.status === "Rejected"
+ );
+
+ if (invalidStatuses.length > 0) {
+ toast.error("이미 승인되었거나 거절된 견적서는 거절할 수 없습니다.");
+ return;
+ }
+
+ setIsRejecting(true);
+
+ try {
+ const result = await rejectTechSalesVendorQuotations({
+ quotationIds,
+ rejectionReason: rejectionReason.trim() || undefined,
+ });
+
+ if (result.success) {
+ toast.success(result.message);
+ setRejectDialogOpen(false);
+ setRejectionReason("");
+ table.resetRowSelection();
+ // 데이터 다시 로드
+ await loadData();
+ } else {
+ toast.error(result.error || "견적서 거절 중 오류가 발생했습니다.");
+ }
+ } catch (error) {
+ console.error("견적서 거절 오류:", error);
+ toast.error("견적서 거절 중 오류가 발생했습니다.");
+ } finally {
+ setIsRejecting(false);
+ }
+ }, [rejectionReason, loadData]);
+
+ // 테이블 컬럼 정의
+ const columns = React.useMemo(() => getColumns({
+ router,
+ openAttachmentsSheet,
+ openItemsDialog,
+ openContactsDialog,
+ }), [router, openAttachmentsSheet, openItemsDialog, openContactsDialog])
+
+ // 필터 필드
+ const filterFields = React.useMemo<DataTableFilterField<QuotationWithRfqCode>[]>(() => [
+ {
+ id: "status",
+ label: "상태",
+ options: Object.entries(TECH_SALES_QUOTATION_STATUSES).map(([, statusValue]) => ({
+ label: TECH_SALES_QUOTATION_STATUS_CONFIG[statusValue].label,
+ value: statusValue,
+ }))
+ },
+ {
+ id: "rfqCode",
+ label: "RFQ 번호",
+ placeholder: "RFQ 번호 검색...",
+ },
+ {
+ id: "materialCode",
+ label: "자재 그룹",
+ placeholder: "자재 그룹 검색...",
+ }
+ ], [])
+
+ // 고급 필터 필드
+ const advancedFilterFields = React.useMemo<DataTableAdvancedFilterField<QuotationWithRfqCode>[]>(() => [
+ {
+ id: "rfqCode",
+ label: "RFQ 번호",
+ type: "text",
+ },
+ {
+ id: "materialCode",
+ label: "자재 그룹",
+ type: "text",
+ },
+ {
+ id: "status",
+ label: "상태",
+ type: "multi-select",
+ options: Object.entries(TECH_SALES_QUOTATION_STATUSES).map(([, statusValue]) => ({
+ label: TECH_SALES_QUOTATION_STATUS_CONFIG[statusValue].label,
+ value: statusValue,
+ })),
+ },
+ {
+ id: "validUntil",
+ label: "유효기간",
+ type: "date",
+ },
+ {
+ id: "submittedAt",
+ label: "제출일",
+ type: "date",
+ },
+ ], [])
+
+ // useDataTable 훅 사용
+ const { table } = useDataTable({
+ data: stableData,
+ columns: columns as any, // 타입 오류 임시 해결
+ pageCount,
+ rowCount: total,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ enableColumnResizing: true,
+ columnResizeMode: 'onChange',
+ enableRowSelection: true, // 행 선택 활성화
+ initialState: {
+ sorting: initialSettings.sort,
+ columnPinning: { right: ["actions", "items", "attachments"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ defaultColumn: {
+ minSize: 50,
+ maxSize: 500,
+ },
+ })
+
+ // 최초 로딩 시 전체 스켈레톤 표시
+ if (isInitialLoad && isLoading) {
+ return (
+ <div className="w-full">
+ <div className="overflow-x-auto">
+ <TableLoadingSkeleton />
+ </div>
+ </div>
+ )
+ }
+
+ return (
+ <div className="w-full">
+ <div className="overflow-x-auto">
+ <div className="relative">
+ <DataTable
+ table={table}
+ className="min-w-full"
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ {/* 선택된 행이 있을 때 거절 버튼 표시 */}
+ {table && table.getFilteredSelectedRowModel().rows.length > 0 && (
+ <AlertDialog open={rejectDialogOpen} onOpenChange={setRejectDialogOpen}>
+ <AlertDialogTrigger asChild>
+ <Button variant="destructive" size="sm">
+ <X className="mr-2 h-4 w-4" />
+ 선택한 견적서 거절 ({table.getFilteredSelectedRowModel().rows.length}개)
+ </Button>
+ </AlertDialogTrigger>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>견적서 거절</AlertDialogTitle>
+ <AlertDialogDescription>
+ 선택한 {table.getFilteredSelectedRowModel().rows.length}개의 견적서를 거절하시겠습니까?
+ 거절된 견적서는 다시 되돌릴 수 없습니다.
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <div className="grid gap-4 py-4">
+ <div className="grid gap-2">
+ <Label htmlFor="rejection-reason">거절 사유 (선택사항)</Label>
+ <Textarea
+ id="rejection-reason"
+ placeholder="거절 사유를 입력하세요..."
+ value={rejectionReason}
+ onChange={(e) => setRejectionReason(e.target.value)}
+ />
+ </div>
+ </div>
+ <AlertDialogFooter>
+ <AlertDialogCancel>취소</AlertDialogCancel>
+ <AlertDialogAction
+ onClick={handleRejectQuotations}
+ disabled={isRejecting}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ {isRejecting ? "처리 중..." : "거절"}
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ )}
+
+ {!isInitialLoad && isLoading && (
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <div className="animate-spin h-4 w-4 border-2 border-current border-t-transparent rounded-full" />
+ 데이터 업데이트 중...
+ </div>
+ )}
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </div>
+ </div>
+
+ {/* 첨부파일 관리 시트 (읽기 전용) */}
+ <TechSalesRfqAttachmentsSheet
+ open={attachmentsOpen}
+ onOpenChange={setAttachmentsOpen}
+ defaultAttachments={attachmentsDefault}
+ rfq={selectedRfqForAttachments}
+ attachmentType="RFQ_COMMON" // 벤더는 RFQ_COMMON 타입만 조회
+ readOnly={true} // 벤더는 항상 읽기 전용
+ />
+
+ {/* 아이템 보기 다이얼로그 */}
+ <RfqItemsViewDialog
+ open={itemsDialogOpen}
+ onOpenChange={setItemsDialogOpen}
+ rfq={selectedRfqForItems}
+ />
+
+ {/* 담당자 조회 다이얼로그 */}
+ <QuotationContactsViewDialog
+ open={contactsDialogOpen}
+ onOpenChange={setContactsDialogOpen}
+ quotationId={selectedQuotationForContacts?.id || null}
+ vendorName={selectedQuotationForContacts?.vendorName}
+ />
+ </div>
+ );
}
\ No newline at end of file |
