diff options
24 files changed, 1823 insertions, 2585 deletions
diff --git a/app/[lng]/partners/(partners)/techsales/rfq-offshore-hull/page.tsx b/app/[lng]/partners/(partners)/techsales/rfq-offshore-hull/page.tsx index 1c830535..5506825d 100644 --- a/app/[lng]/partners/(partners)/techsales/rfq-offshore-hull/page.tsx +++ b/app/[lng]/partners/(partners)/techsales/rfq-offshore-hull/page.tsx @@ -3,16 +3,9 @@ import Link from "next/link"; import { Metadata } from "next"; import { getServerSession } from "next-auth/next"; import { authOptions } from "@/app/api/auth/[...nextauth]/route"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { LogIn } from "lucide-react"; import { Shell } from "@/components/shell"; -import { - TECH_SALES_QUOTATION_STATUSES, - TECH_SALES_QUOTATION_STATUS_CONFIG -} from "@/db/schema"; - -import { getQuotationStatusCounts } from "@/lib/techsales-rfq/service"; import { VendorQuotationsTable } from "@/lib/techsales-rfq/vendor-response/table/vendor-quotations-table"; export const metadata: Metadata = { @@ -62,9 +55,6 @@ export default async function VendorQuotationsHullPage() { ); } - // 견적서 상태별 개수 조회 - const statusCountsPromise = getQuotationStatusCounts(vendorId.toString(), "HULL"); - return ( <Shell variant="fullscreen" className="h-full"> {/* 고정 헤더 영역 */} @@ -78,30 +68,6 @@ export default async function VendorQuotationsHullPage() { </div> </div> - {/* 상태별 개수 카드 */} - <div className="flex-shrink-0"> - <React.Suspense - fallback={ - <div className="w-full overflow-x-auto"> - <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 min-w-fit"> - {Array.from({ length: 5 }).map((_, i) => ( - <Card key={i} className="min-w-[160px]"> - <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> - <CardTitle className="text-sm font-medium truncate">로딩중...</CardTitle> - </CardHeader> - <CardContent> - <div className="text-2xl font-bold">-</div> - </CardContent> - </Card> - ))} - </div> - </div> - } - > - <StatusCards statusCountsPromise={statusCountsPromise} /> - </React.Suspense> - </div> - {/* 견적서 테이블 */} <div className="flex-1 min-h-0 overflow-hidden"> <div className="h-full overflow-auto"> @@ -112,65 +78,3 @@ export default async function VendorQuotationsHullPage() { </Shell> ); } - -// 상태별 개수 카드 컴포넌트 -async function StatusCards({ - statusCountsPromise, -}: { - statusCountsPromise: Promise<{ - data: { status: string; count: number }[] | null; - error: string | null; - }>; -}) { - const { data: statusCounts, error } = await statusCountsPromise; - - if (error || !statusCounts) { - return ( - <div className="w-full overflow-x-auto"> - <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 min-w-fit"> - <Card className="min-w-[160px]"> - <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> - <CardTitle className="text-sm font-medium truncate">오류</CardTitle> - </CardHeader> - <CardContent> - <div className="text-2xl font-bold text-red-600">-</div> - <p className="text-xs text-muted-foreground truncate"> - 데이터를 불러올 수 없습니다 - </p> - </CardContent> - </Card> - </div> - </div> - ); - } - - // 중앙화된 상태 설정 사용 - const statusEntries = Object.entries(TECH_SALES_QUOTATION_STATUSES).map(([, statusValue]) => ({ - key: statusValue, - ...TECH_SALES_QUOTATION_STATUS_CONFIG[statusValue] - })); - - console.log(statusCounts, "statusCounts") - - return ( - <div className="w-full overflow-x-auto"> - <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 min-w-fit"> - {statusEntries.map((status) => ( - <Card key={status.key} className="min-w-[160px]"> - <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> - <CardTitle className="text-sm font-medium truncate">{status.label}</CardTitle> - </CardHeader> - <CardContent> - <div className={`text-2xl font-bold ${status.color}`}> - {statusCounts.find(item => item.status === status.key)?.count || 0} - </div> - <p className="text-xs text-muted-foreground truncate"> - {status.description} - </p> - </CardContent> - </Card> - ))} - </div> - </div> - ); -}
\ No newline at end of file diff --git a/app/[lng]/partners/(partners)/techsales/rfq-offshore-top/page.tsx b/app/[lng]/partners/(partners)/techsales/rfq-offshore-top/page.tsx index b9c957f0..408b5318 100644 --- a/app/[lng]/partners/(partners)/techsales/rfq-offshore-top/page.tsx +++ b/app/[lng]/partners/(partners)/techsales/rfq-offshore-top/page.tsx @@ -3,16 +3,10 @@ import Link from "next/link"; import { Metadata } from "next"; import { getServerSession } from "next-auth/next"; import { authOptions } from "@/app/api/auth/[...nextauth]/route"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { LogIn } from "lucide-react"; import { Shell } from "@/components/shell"; -import { - TECH_SALES_QUOTATION_STATUSES, - TECH_SALES_QUOTATION_STATUS_CONFIG -} from "@/db/schema"; -import { getQuotationStatusCounts } from "@/lib/techsales-rfq/service"; import { VendorQuotationsTable } from "@/lib/techsales-rfq/vendor-response/table/vendor-quotations-table"; export const metadata: Metadata = { @@ -63,8 +57,6 @@ export default async function VendorQuotationsTopPage() { } // 견적서 상태별 개수 조회 - const statusCountsPromise = getQuotationStatusCounts(vendorId.toString(), "TOP"); - return ( <Shell variant="fullscreen" className="h-full"> {/* 고정 헤더 영역 */} @@ -78,29 +70,6 @@ export default async function VendorQuotationsTopPage() { </div> </div> - {/* 상태별 개수 카드 */} - <div className="flex-shrink-0"> - <React.Suspense - fallback={ - <div className="w-full overflow-x-auto"> - <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 min-w-fit"> - {Array.from({ length: 5 }).map((_, i) => ( - <Card key={i} className="min-w-[160px]"> - <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> - <CardTitle className="text-sm font-medium truncate">로딩중...</CardTitle> - </CardHeader> - <CardContent> - <div className="text-2xl font-bold">-</div> - </CardContent> - </Card> - ))} - </div> - </div> - } - > - <StatusCards statusCountsPromise={statusCountsPromise} /> - </React.Suspense> - </div> {/* 견적서 테이블 */} <div className="flex-1 min-h-0 overflow-hidden"> @@ -111,66 +80,4 @@ export default async function VendorQuotationsTopPage() { </div> </Shell> ); -} - -// 상태별 개수 카드 컴포넌트 -async function StatusCards({ - statusCountsPromise, -}: { - statusCountsPromise: Promise<{ - data: { status: string; count: number }[] | null; - error: string | null; - }>; -}) { - const { data: statusCounts, error } = await statusCountsPromise; - - if (error || !statusCounts) { - return ( - <div className="w-full overflow-x-auto"> - <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 min-w-fit"> - <Card className="min-w-[160px]"> - <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> - <CardTitle className="text-sm font-medium truncate">오류</CardTitle> - </CardHeader> - <CardContent> - <div className="text-2xl font-bold text-red-600">-</div> - <p className="text-xs text-muted-foreground truncate"> - 데이터를 불러올 수 없습니다 - </p> - </CardContent> - </Card> - </div> - </div> - ); - } - - // 중앙화된 상태 설정 사용 - const statusEntries = Object.entries(TECH_SALES_QUOTATION_STATUSES).map(([, statusValue]) => ({ - key: statusValue, - ...TECH_SALES_QUOTATION_STATUS_CONFIG[statusValue] - })); - - console.log(statusCounts, "statusCounts") - - return ( - <div className="w-full overflow-x-auto"> - <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 min-w-fit"> - {statusEntries.map((status) => ( - <Card key={status.key} className="min-w-[160px]"> - <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> - <CardTitle className="text-sm font-medium truncate">{status.label}</CardTitle> - </CardHeader> - <CardContent> - <div className={`text-2xl font-bold ${status.color}`}> - {statusCounts.find(item => item.status === status.key)?.count || 0} - </div> - <p className="text-xs text-muted-foreground truncate"> - {status.description} - </p> - </CardContent> - </Card> - ))} - </div> - </div> - ); }
\ No newline at end of file diff --git a/app/[lng]/partners/(partners)/techsales/rfq-ship/page.tsx b/app/[lng]/partners/(partners)/techsales/rfq-ship/page.tsx index 07797c9b..40c6bb1f 100644 --- a/app/[lng]/partners/(partners)/techsales/rfq-ship/page.tsx +++ b/app/[lng]/partners/(partners)/techsales/rfq-ship/page.tsx @@ -4,16 +4,9 @@ import Link from "next/link"; import { Metadata } from "next"; import { getServerSession } from "next-auth/next"; import { authOptions } from "@/app/api/auth/[...nextauth]/route"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { LogIn } from "lucide-react"; import { Shell } from "@/components/shell"; -import { - TECH_SALES_QUOTATION_STATUSES, - TECH_SALES_QUOTATION_STATUS_CONFIG -} from "@/db/schema"; - -import { getQuotationStatusCounts } from "@/lib/techsales-rfq/service"; import { VendorQuotationsTable } from "@/lib/techsales-rfq/vendor-response/table/vendor-quotations-table"; export const metadata: Metadata = { @@ -67,45 +60,19 @@ export default async function VendorQuotationsPage() { } // 견적서 상태별 개수 조회 - const statusCountsPromise = getQuotationStatusCounts(vendorId.toString(), "SHIP"); - return ( <Shell variant="fullscreen" className="h-full"> {/* 고정 헤더 영역 */} <div className="flex-shrink-0"> <div className="flex-shrink-0 flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> <div> - <h1 className="text-3xl font-bold tracking-tight">기술영업 견적서</h1> + <h1 className="text-3xl font-bold tracking-tight">기술영업 조선 견적서</h1> <p className="text-muted-foreground"> - 할당받은 RFQ에 대한 견적서를 작성하고 관리합니다. + 할당받은 조선 RFQ에 대한 견적서를 작성하고 관리합니다. </p> </div> </div> - {/* 상태별 개수 카드 */} - <div className="flex-shrink-0"> - <React.Suspense - fallback={ - <div className="w-full overflow-x-auto"> - <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 min-w-fit"> - {Array.from({ length: 5 }).map((_, i) => ( - <Card key={i} className="min-w-[160px]"> - <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> - <CardTitle className="text-sm font-medium truncate">로딩중...</CardTitle> - </CardHeader> - <CardContent> - <div className="text-2xl font-bold">-</div> - </CardContent> - </Card> - ))} - </div> - </div> - } - > - <StatusCards statusCountsPromise={statusCountsPromise} /> - </React.Suspense> - </div> - {/* 견적서 테이블 */} <div className="flex-1 min-h-0 overflow-hidden"> <div className="h-full overflow-auto"> @@ -117,64 +84,3 @@ export default async function VendorQuotationsPage() { ); } -// 상태별 개수 카드 컴포넌트 -async function StatusCards({ - statusCountsPromise, -}: { - statusCountsPromise: Promise<{ - data: { status: string; count: number }[] | null; - error: string | null; - }>; -}) { - const { data: statusCounts, error } = await statusCountsPromise; - - if (error || !statusCounts) { - return ( - <div className="w-full overflow-x-auto"> - <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 min-w-fit"> - <Card className="min-w-[160px]"> - <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> - <CardTitle className="text-sm font-medium truncate">오류</CardTitle> - </CardHeader> - <CardContent> - <div className="text-2xl font-bold text-red-600">-</div> - <p className="text-xs text-muted-foreground truncate"> - 데이터를 불러올 수 없습니다 - </p> - </CardContent> - </Card> - </div> - </div> - ); - } - - // 중앙화된 상태 설정 사용 - const statusEntries = Object.entries(TECH_SALES_QUOTATION_STATUSES).map(([, statusValue]) => ({ - key: statusValue, - ...TECH_SALES_QUOTATION_STATUS_CONFIG[statusValue] - })); - - console.log(statusCounts, "statusCounts") - - return ( - <div className="w-full overflow-x-auto"> - <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 min-w-fit"> - {statusEntries.map((status) => ( - <Card key={status.key} className="min-w-[160px]"> - <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> - <CardTitle className="text-sm font-medium truncate">{status.label}</CardTitle> - </CardHeader> - <CardContent> - <div className={`text-2xl font-bold ${status.color}`}> - {statusCounts.find(item => item.status === status.key)?.count || 0} - </div> - <p className="text-xs text-muted-foreground truncate"> - {status.description} - </p> - </CardContent> - </Card> - ))} - </div> - </div> - ); -}
\ No newline at end of file diff --git a/lib/techsales-rfq/actions.ts b/lib/techsales-rfq/actions.ts index 1171271f..5d5d5118 100644 --- a/lib/techsales-rfq/actions.ts +++ b/lib/techsales-rfq/actions.ts @@ -2,12 +2,9 @@ import { revalidatePath } from "next/cache" import { - acceptTechSalesVendorQuotation, - rejectTechSalesVendorQuotation + acceptTechSalesVendorQuotation } from "./service" -// ... existing code ... - /** * 기술영업 벤더 견적 승인 (벤더 선택) Server Action */ @@ -32,28 +29,3 @@ export async function acceptTechSalesVendorQuotationAction(quotationId: number) } } } - -// /** -// * 기술영업 벤더 견적 거절 Server Action -// */ -// export async function rejectTechSalesVendorQuotationAction(quotationId: number, rejectionReason?: string) { -// try { -// const result = await rejectTechSalesVendorQuotation(quotationId, rejectionReason) - -// if (result.success) { -// // 관련 페이지들 재검증 -// revalidatePath("/evcp/budgetary-tech-sales-ship") -// revalidatePath("/partners/techsales") - -// return { success: true, message: "견적이 성공적으로 거절되었습니다" } -// } else { -// return { success: false, error: result.error } -// } -// } catch (error) { -// console.error("견적 거절 액션 오류:", error) -// return { -// success: false, -// error: error instanceof Error ? error.message : "견적 거절에 실패했습니다" -// } -// } -// }
\ No newline at end of file diff --git a/lib/techsales-rfq/repository.ts b/lib/techsales-rfq/repository.ts index e9ad3925..1aaf4b3d 100644 --- a/lib/techsales-rfq/repository.ts +++ b/lib/techsales-rfq/repository.ts @@ -117,11 +117,24 @@ export async function selectTechSalesRfqsWithJoin( 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' )`, // 벤더 견적 개수 @@ -258,6 +271,20 @@ export async function selectTechSalesVendorQuotationsWithJoin( 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}`) diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts index 96d6a3c9..25e1f379 100644 --- a/lib/techsales-rfq/service.ts +++ b/lib/techsales-rfq/service.ts @@ -5,7 +5,9 @@ import db from "@/db/db"; import { techSalesRfqs, techSalesVendorQuotations, + techSalesVendorQuotationRevisions, techSalesAttachments, + techSalesVendorQuotationAttachments, users, techSalesRfqComments, techSalesRfqItems, @@ -30,6 +32,7 @@ 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"; // 정렬 타입 정의 // 의도적으로 any 사용 - drizzle ORM의 orderBy 타입이 복잡함 @@ -79,16 +82,6 @@ async function generateRfqCodes(tx: any, count: number, year?: number): Promise< return codes; } -/** - * 기술영업 조선 RFQ 생성 액션 - * - * 받을 파라미터 (생성시 입력하는 것) - * 1. RFQ 관련 - * 2. 프로젝트 관련 - * 3. 자재 관련 (자재그룹) - * - * 나머지 벤더, 첨부파일 등은 생성 이후 처리 - */ /** * 직접 조인을 사용하여 RFQ 데이터 조회하는 함수 @@ -309,8 +302,29 @@ export async function getTechSalesVendorQuotationsWithJoin(input: { 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, total }; + return { data: dataWithAttachments, total }; }); const pageCount = Math.ceil(total / input.perPage); @@ -414,160 +428,6 @@ export async function getTechSalesDashboardWithJoin(input: { } } - - -/** - * 기술영업 RFQ에서 벤더 제거 (Draft 상태 체크 포함) - */ -export async function removeVendorFromTechSalesRfq(input: { - rfqId: number; - vendorId: number; -}) { - unstable_noStore(); - try { - // 먼저 해당 벤더의 견적서 상태 확인 - const existingQuotation = await db - .select() - .from(techSalesVendorQuotations) - .where( - and( - eq(techSalesVendorQuotations.rfqId, input.rfqId), - eq(techSalesVendorQuotations.vendorId, input.vendorId) - ) - ) - .limit(1); - - if (existingQuotation.length === 0) { - return { - data: null, - error: "해당 벤더가 이 RFQ에 존재하지 않습니다." - }; - } - - // Draft 상태가 아닌 경우 삭제 불가 - if (existingQuotation[0].status !== "Draft") { - return { - data: null, - error: "Draft 상태의 벤더만 삭제할 수 있습니다." - }; - } - - // 해당 벤더의 견적서 삭제 - const deletedQuotations = await db - .delete(techSalesVendorQuotations) - .where( - and( - eq(techSalesVendorQuotations.rfqId, input.rfqId), - eq(techSalesVendorQuotations.vendorId, input.vendorId) - ) - ) - .returning(); - - // RFQ 타입 조회 및 캐시 무효화 - const rfqForCache = await db.query.techSalesRfqs.findFirst({ - where: eq(techSalesRfqs.id, input.rfqId), - columns: { rfqType: true } - }); - - revalidateTag("techSalesVendorQuotations"); - revalidateTag(`techSalesRfq-${input.rfqId}`); - revalidateTag(`vendor-${input.vendorId}-quotations`); - revalidatePath(getTechSalesRevalidationPath(rfqForCache?.rfqType || "SHIP")); - - return { data: deletedQuotations[0], error: null }; - } catch (err) { - console.error("Error removing vendor from RFQ:", err); - return { data: null, error: getErrorMessage(err) }; - } -} - -/** - * 기술영업 RFQ에서 여러 벤더 일괄 제거 (Draft 상태 체크 포함) - */ -export async function removeVendorsFromTechSalesRfq(input: { - rfqId: number; - vendorIds: number[]; -}) { - unstable_noStore(); - try { - const results: typeof techSalesVendorQuotations.$inferSelect[] = []; - const errors: string[] = []; - - // 트랜잭션으로 처리 - await db.transaction(async (tx) => { - for (const vendorId of input.vendorIds) { - try { - // 먼저 해당 벤더의 견적서 상태 확인 - const existingQuotation = await tx - .select() - .from(techSalesVendorQuotations) - .where( - and( - eq(techSalesVendorQuotations.rfqId, input.rfqId), - eq(techSalesVendorQuotations.vendorId, vendorId) - ) - ) - .limit(1); - - if (existingQuotation.length === 0) { - errors.push(`벤더 ID ${vendorId}가 이 RFQ에 존재하지 않습니다.`); - continue; - } - - // Draft 상태가 아닌 경우 삭제 불가 - if (existingQuotation[0].status !== "Draft") { - errors.push(`벤더 ID ${vendorId}는 Draft 상태가 아니므로 삭제할 수 없습니다.`); - continue; - } - - // 해당 벤더의 견적서 삭제 - const deletedQuotations = await tx - .delete(techSalesVendorQuotations) - .where( - and( - eq(techSalesVendorQuotations.rfqId, input.rfqId), - eq(techSalesVendorQuotations.vendorId, vendorId) - ) - ) - .returning(); - - if (deletedQuotations.length > 0) { - results.push(deletedQuotations[0]); - } - } catch (vendorError) { - console.error(`Error removing vendor ${vendorId}:`, vendorError); - errors.push(`벤더 ID ${vendorId} 삭제 중 오류가 발생했습니다.`); - } - } - }); - - // RFQ 타입 조회 및 캐시 무효화 - const rfqForCache2 = await db.query.techSalesRfqs.findFirst({ - where: eq(techSalesRfqs.id, input.rfqId), - columns: { rfqType: true } - }); - - revalidateTag("techSalesVendorQuotations"); - revalidateTag(`techSalesRfq-${input.rfqId}`); - revalidatePath(getTechSalesRevalidationPath(rfqForCache2?.rfqType || "SHIP")); - - // 벤더별 캐시도 무효화 - for (const vendorId of input.vendorIds) { - revalidateTag(`vendor-${vendorId}-quotations`); - } - - return { - data: results, - error: errors.length > 0 ? errors.join(", ") : null, - successCount: results.length, - errorCount: errors.length - }; - } catch (err) { - console.error("Error removing vendors from RFQ:", err); - return { data: null, error: getErrorMessage(err) }; - } -} - /** * 특정 RFQ의 벤더 목록 조회 */ @@ -716,6 +576,19 @@ export async function sendTechSalesRfqToVendors(input: { .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; @@ -847,6 +720,12 @@ export async function getTechSalesVendorQuotation(quotationId: number) { 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, @@ -911,7 +790,16 @@ export async function getTechSalesVendorQuotation(quotationId: number) { 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 }; @@ -922,7 +810,8 @@ export async function getTechSalesVendorQuotation(quotationId: number) { } /** - * 기술영업 벤더 견적서 업데이트 (임시저장) + * 기술영업 벤더 견적서 업데이트 (임시저장), + * 현재는 submit으로 처리, revision 을 아래의 함수로 사용가능함. */ export async function updateTechSalesVendorQuotation(data: { id: number @@ -931,46 +820,78 @@ export async function updateTechSalesVendorQuotation(data: { validUntil: Date remark?: string updatedBy: number + changeReason?: string }) { try { - // 현재 견적서 상태 및 벤더 ID 확인 - const currentQuotation = await db.query.techSalesVendorQuotations.findFirst({ - where: eq(techSalesVendorQuotations.id, data.id), - columns: { - status: true, - vendorId: true, + 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: "견적서를 찾을 수 없습니다." }; } - }); - if (!currentQuotation) { - return { data: null, error: "견적서를 찾을 수 없습니다." }; - } + // Accepted나 Rejected 상태가 아니면 수정 가능 + if (["Rejected"].includes(currentQuotation.status)) { + return { data: null, error: "승인되거나 거절된 견적서는 수정할 수 없습니다." }; + } - // Draft 또는 Revised 상태에서만 수정 가능 - if (!["Draft", "Revised"].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); - const result = await db - .update(techSalesVendorQuotations) - .set({ - currency: data.currency, - totalPrice: data.totalPrice, - validUntil: data.validUntil, - remark: data.remark || null, - updatedAt: new Date(), - }) - .where(eq(techSalesVendorQuotations.id, data.id)) - .returning() + if (!hasChanges) { + return { data: currentQuotation, error: null }; + } - // 캐시 무효화 - revalidateTag("techSalesVendorQuotations") - revalidatePath(`/partners/techsales/rfq-ship/${data.id}`) + // 현재 버전을 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, + }); - return { data: result[0], error: null } + // 새로운 버전으로 업데이트 + 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: "견적서 업데이트 중 오류가 발생했습니다" } + console.error("Error updating tech sales vendor quotation:", error); + return { data: null, error: "견적서 업데이트 중 오류가 발생했습니다" }; + } finally { + // 캐시 무효화 + revalidateTag("techSalesVendorQuotations"); + revalidatePath(`/partners/techsales/rfq-ship/${data.id}`); } } @@ -983,63 +904,134 @@ export async function submitTechSalesVendorQuotation(data: { totalPrice: string validUntil: Date remark?: string + attachments?: Array<{ + fileName: string + filePath: string + fileSize: number + }> updatedBy: number }) { try { - // 현재 견적서 상태 확인 - const currentQuotation = await db.query.techSalesVendorQuotations.findFirst({ - where: eq(techSalesVendorQuotations.id, data.id), - columns: { - status: true, - vendorId: true, + 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: "견적서를 찾을 수 없습니다." }; } - }); - 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, + // }); + // } + + // 항상 revision 저장 (변경사항 여부와 관계없이) + 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, + }); - // Draft 또는 Revised 상태에서만 제출 가능 - if (!["Draft", "Revised"].includes(currentQuotation.status)) { - return { data: null, error: "현재 상태에서는 견적서를 제출할 수 없습니다." }; - } + // 새로운 버전 번호 계산 (항상 1 증가) + const newRevisionId = (currentQuotation.quotationVersion || 1) + 1; - const result = await db - .update(techSalesVendorQuotations) - .set({ - currency: data.currency, - totalPrice: data.totalPrice, - validUntil: data.validUntil, - remark: data.remark || null, - status: "Submitted", - submittedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(techSalesVendorQuotations.id, data.id)) - .returning() + // 새로운 버전으로 업데이트 + 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(); - // 메일 발송 (백그라운드에서 실행) - if (result[0]) { - // 벤더에게 견적 제출 확인 메일 발송 - sendQuotationSubmittedNotificationToVendor(data.id).catch(error => { - console.error("벤더 견적 제출 확인 메일 발송 실패:", error); - }); + // 첨부파일 처리 (새로운 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, + }); + } + } - // 담당자에게 견적 접수 알림 메일 발송 - sendQuotationSubmittedNotificationToManager(data.id).catch(error => { - console.error("담당자 견적 접수 알림 메일 발송 실패:", error); - }); - } + // 메일 발송 (백그라운드에서 실행) + if (result[0]) { + // 벤더에게 견적 제출 확인 메일 발송 + sendQuotationSubmittedNotificationToVendor(data.id).catch(error => { + console.error("벤더 견적 제출 확인 메일 발송 실패:", error); + }); - // 캐시 무효화 - revalidateTag("techSalesVendorQuotations") - revalidateTag(`vendor-${currentQuotation.vendorId}-quotations`) - revalidatePath(`/partners/techsales/rfq-ship/${data.id}`) + // 담당자에게 견적 접수 알림 메일 발송 + sendQuotationSubmittedNotificationToManager(data.id).catch(error => { + console.error("담당자 견적 접수 알림 메일 발송 실패:", error); + }); + } - return { data: result[0], error: null } + return { data: result[0], error: null }; + }); } catch (error) { - console.error("Error submitting tech sales vendor quotation:", error) - return { data: null, error: "견적서 제출 중 오류가 발생했습니다" } + console.error("Error submitting tech sales vendor quotation:", error); + return { data: null, error: "견적서 제출 중 오류가 발생했습니다" }; + } finally { + // 캐시 무효화 + revalidateTag("techSalesVendorQuotations"); + revalidatePath(`/partners/techsales/rfq-ship`); } } @@ -1095,14 +1087,17 @@ export async function getVendorQuotations(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)]; + const baseConditions = [ + eq(techSalesVendorQuotations.vendorId, vendorIdNum), + sql`${techSalesVendorQuotations.status} != 'Assigned'` // Assigned 상태 제외 + ]; // rfqType 필터링 추가 if (input.rfqType) { @@ -1210,9 +1205,13 @@ export async function getVendorQuotations(input: { description: techSalesRfqs.description, // 프로젝트 정보 (직접 조인) projNm: biddingProjects.projNm, - // 아이템 정보 추가 (임시로 description 사용) - // itemName: techSalesRfqs.description, - // 첨부파일 개수 + // 아이템 개수 + itemCount: sql<number>`( + SELECT COUNT(*) + FROM tech_sales_rfq_items + WHERE tech_sales_rfq_items.rfq_id = ${techSalesRfqs.id} + )`, + // RFQ 첨부파일 개수 attachmentCount: sql<number>`( SELECT COUNT(*) FROM tech_sales_attachments @@ -1221,6 +1220,7 @@ export async function getVendorQuotations(input: { }) .from(techSalesVendorQuotations) .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id)) + .leftJoin(techSalesAttachments, eq(techSalesRfqs.id, techSalesAttachments.techSalesRfqId)) .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id)) .where(finalWhere) .orderBy(...orderBy) @@ -1256,48 +1256,6 @@ export async function getVendorQuotations(input: { } /** - * 벤더용 기술영업 견적서 상태별 개수 조회 - */ -export async function getQuotationStatusCounts(vendorId: string, rfqType?: "SHIP" | "TOP" | "HULL") { - return unstable_cache( - async () => { - try { - const query = db - .select({ - status: techSalesVendorQuotations.status, - count: sql<number>`count(*)`, - }) - .from(techSalesVendorQuotations) - .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id)); - - // 조건 설정 - const conditions = [eq(techSalesVendorQuotations.vendorId, parseInt(vendorId))]; - if (rfqType) { - conditions.push(eq(techSalesRfqs.rfqType, rfqType)); - } - - const result = await query - .where(and(...conditions)) - .groupBy(techSalesVendorQuotations.status); - - return { data: result, error: null }; - } catch (err) { - console.error("Error fetching quotation status counts:", err); - return { data: null, error: getErrorMessage(err) }; - } - }, - [vendorId], // 캐싱 키 - { - revalidate: 60, // 1분간 캐시 - tags: [ - "techSalesVendorQuotations", - `vendor-${vendorId}-quotations` - ], - } - )(); -} - -/** * 기술영업 벤더 견적 승인 (벤더 선택) */ export async function acceptTechSalesVendorQuotation(quotationId: number) { @@ -1358,6 +1316,9 @@ export async function acceptTechSalesVendorQuotation(quotationId: number) { 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) { @@ -1370,7 +1331,7 @@ export async function acceptTechSalesVendorQuotation(quotationId: number) { } /** - * 기술영업 RFQ 첨부파일 생성 (파일 업로드) + * 기술영업 RFQ 첨부파일 생성 (파일 업로드), 사용x */ export async function createTechSalesRfqAttachments(params: { techSalesRfqId: number @@ -1415,8 +1376,7 @@ export async function createTechSalesRfqAttachments(params: { await fs.mkdir(rfqDir, { recursive: true }); for (const file of files) { - const ab = await file.arrayBuffer(); - const buffer = Buffer.from(ab); + const decryptedBuffer = await decryptWithServerAction(file); // 고유 파일명 생성 const uniqueName = `${randomUUID()}-${file.name}`; @@ -1424,7 +1384,7 @@ export async function createTechSalesRfqAttachments(params: { const absolutePath = path.join(process.cwd(), "public", relativePath); // 파일 저장 - await fs.writeFile(absolutePath, buffer); + await fs.writeFile(absolutePath, Buffer.from(decryptedBuffer)); // DB에 첨부파일 레코드 생성 const [newAttachment] = await tx.insert(techSalesAttachments).values({ @@ -1488,6 +1448,39 @@ export async function getTechSalesRfqAttachments(techSalesRfqId: number) { } /** + * 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) { @@ -1561,7 +1554,7 @@ export async function deleteTechSalesRfqAttachment(attachmentId: number) { */ export async function processTechSalesRfqAttachments(params: { techSalesRfqId: number - newFiles: { file: File; attachmentType: "RFQ_COMMON" | "VENDOR_SPECIFIC"; description?: string }[] + newFiles: { file: File; attachmentType: "RFQ_COMMON" | "VENDOR_SPECIFIC" | "TBE_RESULT" | "CBE_RESULT"; description?: string }[] deleteAttachmentIds: number[] createdBy: number }) { @@ -1623,16 +1616,16 @@ export async function processTechSalesRfqAttachments(params: { await fs.mkdir(rfqDir, { recursive: true }); for (const { file, attachmentType, description } of newFiles) { - const ab = await file.arrayBuffer(); - const buffer = Buffer.from(ab); + // 파일 복호화 + const decryptedBuffer = await decryptWithServerAction(file); // 고유 파일명 생성 const uniqueName = `${randomUUID()}-${file.name}`; const relativePath = path.join("techsales-rfq", String(techSalesRfqId), uniqueName); const absolutePath = path.join(process.cwd(), "public", relativePath); - // 파일 저장 - await fs.writeFile(absolutePath, buffer); + // 복호화된 파일 저장 + await fs.writeFile(absolutePath, Buffer.from(decryptedBuffer)); // DB에 첨부파일 레코드 생성 const [newAttachment] = await tx.insert(techSalesAttachments).values({ @@ -2213,6 +2206,8 @@ export async function markTechSalesMessagesAsRead(rfqId: number, vendorId?: numb } } +// ==================== RFQ 조선/해양 관련 ==================== + /** * 기술영업 조선 RFQ 생성 (1:N 관계) */ @@ -2223,9 +2218,7 @@ export async function createTechSalesShipRfq(input: { description?: string; createdBy: number; }) { - unstable_noStore(); - console.log('🔍 createTechSalesShipRfq 호출됨:', input); - + unstable_noStore(); try { return await db.transaction(async (tx) => { // 프로젝트 정보 조회 (유효성 검증) @@ -2474,46 +2467,7 @@ export async function getTechSalesHullVendorQuotationsWithJoin(input: { return getTechSalesVendorQuotationsWithJoin({ ...input, rfqType: "HULL" }); } -/** - * 조선 대시보드 전용 조회 함수 - */ -export async function getTechSalesShipDashboardWithJoin(input: { - search?: string; - filters?: Filter<typeof techSalesRfqs>[]; - sort?: { id: string; desc: boolean }[]; - page: number; - perPage: number; -}) { - return getTechSalesDashboardWithJoin({ ...input, rfqType: "SHIP" }); -} - -/** - * 해양 TOP 대시보드 전용 조회 함수 - */ -export async function getTechSalesTopDashboardWithJoin(input: { - search?: string; - filters?: Filter<typeof techSalesRfqs>[]; - sort?: { id: string; desc: boolean }[]; - page: number; - perPage: number; -}) { - return getTechSalesDashboardWithJoin({ ...input, rfqType: "TOP" }); -} - -/** - * 해양 HULL 대시보드 전용 조회 함수 - */ -export async function getTechSalesHullDashboardWithJoin(input: { - search?: string; - filters?: Filter<typeof techSalesRfqs>[]; - sort?: { id: string; desc: boolean }[]; - page: number; - perPage: number; -}) { - return getTechSalesDashboardWithJoin({ ...input, rfqType: "HULL" }); -} - -/** +/** * 기술영업 RFQ의 아이템 목록 조회 */ export async function getTechSalesRfqItems(rfqId: number) { @@ -2700,53 +2654,6 @@ export async function getTechSalesRfqCandidateVendors(rfqId: number) { } /** - * 기술영업 RFQ에 벤더 추가 (techVendors 기반) - */ -export async function addTechVendorToTechSalesRfq(input: { - rfqId: number; - vendorId: number; - createdBy: number; -}) { - unstable_noStore(); - - try { - return await db.transaction(async (tx) => { - // 벤더가 이미 추가되어 있는지 확인 - const existingQuotation = await tx.query.techSalesVendorQuotations.findFirst({ - where: and( - eq(techSalesVendorQuotations.rfqId, input.rfqId), - eq(techSalesVendorQuotations.vendorId, input.vendorId) - ) - }); - - if (existingQuotation) { - return { data: null, error: "이미 추가된 벤더입니다." }; - } - - // 새로운 견적서 레코드 생성 - const [quotation] = await tx - .insert(techSalesVendorQuotations) - .values({ - rfqId: input.rfqId, - vendorId: input.vendorId, - status: "Draft", - createdBy: input.createdBy, - updatedBy: input.createdBy, - }) - .returning({ id: techSalesVendorQuotations.id }); - - // 캐시 무효화 - revalidateTag("techSalesRfqs"); - - return { data: quotation, error: null }; - }); - } catch (err) { - console.error("Error adding tech vendor to RFQ:", err); - return { data: null, error: getErrorMessage(err) }; - } -} - -/** * RFQ 타입에 따른 캐시 무효화 경로 반환 */ function getTechSalesRevalidationPath(rfqType: "SHIP" | "TOP" | "HULL"): string { @@ -2764,6 +2671,7 @@ function getTechSalesRevalidationPath(rfqType: "SHIP" | "TOP" | "HULL"): string /** * 기술영업 RFQ에 여러 벤더 추가 (techVendors 기반) + * 벤더 추가 시에는 견적서를 생성하지 않고, RFQ 전송 시에 견적서를 생성 */ export async function addTechVendorsToTechSalesRfq(input: { rfqId: number; @@ -2783,7 +2691,7 @@ export async function addTechVendorsToTechSalesRfq(input: { columns: { id: true, status: true, - rfqType: true + rfqType: true, } }); @@ -2791,10 +2699,10 @@ export async function addTechVendorsToTechSalesRfq(input: { throw new Error("RFQ를 찾을 수 없습니다"); } - // 2. 각 벤더에 대해 처리 + // 2. 각 벤더에 대해 처리 (이미 추가된 벤더는 견적서가 있는지 확인) for (const vendorId of input.vendorIds) { try { - // 벤더가 이미 추가되어 있는지 확인 + // 이미 추가된 벤더인지 확인 (견적서 존재 여부로 확인) const existingQuotation = await tx.query.techSalesVendorQuotations.findFirst({ where: and( eq(techSalesVendorQuotations.rfqId, input.rfqId), @@ -2807,19 +2715,30 @@ export async function addTechVendorsToTechSalesRfq(input: { 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" 상태로만 생성 const [quotation] = await tx .insert(techSalesVendorQuotations) .values({ rfqId: input.rfqId, vendorId: vendorId, - status: "Draft", + status: "Assigned", // Draft가 아닌 Assigned 상태로 생성 createdBy: input.createdBy, updatedBy: input.createdBy, }) .returning({ id: techSalesVendorQuotations.id }); - - results.push(quotation); + + results.push({ id: quotation.id, vendorId, vendorName: vendor.vendorName }); } catch (vendorError) { console.error(`Error adding vendor ${vendorId}:`, vendorError); errors.push(`벤더 ID ${vendorId} 추가 중 오류가 발생했습니다.`); @@ -2843,11 +2762,6 @@ export async function addTechVendorsToTechSalesRfq(input: { revalidateTag(`techSalesRfq-${input.rfqId}`); revalidatePath(getTechSalesRevalidationPath(rfq.rfqType || "SHIP")); - // 벤더별 캐시도 무효화 - for (const vendorId of input.vendorIds) { - revalidateTag(`vendor-${vendorId}-quotations`); - } - return { data: results, error: errors.length > 0 ? errors.join(", ") : null, @@ -2921,9 +2835,9 @@ export async function removeTechVendorFromTechSalesRfq(input: { return { data: null, error: "해당 벤더가 이 RFQ에 존재하지 않습니다." }; } - // Draft 상태가 아닌 경우 삭제 불가 - if (existingQuotation.status !== "Draft") { - return { data: null, error: "Draft 상태의 벤더만 삭제할 수 있습니다." }; + // Assigned 상태가 아닌 경우 삭제 불가 + if (existingQuotation.status !== "Assigned") { + return { data: null, error: "Assigned 상태의 벤더만 삭제할 수 있습니다." }; } // 해당 벤더의 견적서 삭제 @@ -2977,9 +2891,9 @@ export async function removeTechVendorsFromTechSalesRfq(input: { continue; } - // Draft 상태가 아닌 경우 삭제 불가 - if (existingQuotation.status !== "Draft") { - errors.push(`벤더 ID ${vendorId}는 Draft 상태가 아니므로 삭제할 수 없습니다.`); + // Assigned 상태가 아닌 경우 삭제 불가 + if (existingQuotation.status !== "Assigned") { + errors.push(`벤더 ID ${vendorId}는 Assigned 상태가 아니므로 삭제할 수 없습니다.`); continue; } @@ -3060,6 +2974,242 @@ export async function searchTechVendors(searchTerm: string, limit = 100, rfqType } } + +/** + * 벤더 견적서 거절 처리 (벤더가 직접 거절) + */ +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 정보 포함) */ @@ -3076,9 +3226,10 @@ export async function getAcceptedTechSalesVendorQuotations(input: { try { const offset = (input.page - 1) * input.perPage; - // 기본 WHERE 조건: status = 'Accepted'만 조회 + // 기본 WHERE 조건: status = 'Accepted'만 조회, rfqType이 'SHIP'이 아닌 것만 const baseConditions = [ - eq(techSalesVendorQuotations.status, 'Accepted') + eq(techSalesVendorQuotations.status, 'Accepted'), + sql`${techSalesRfqs.rfqType} != 'SHIP'` // 조선 RFQ 타입 제외 ]; // 검색 조건 추가 @@ -3126,10 +3277,10 @@ export async function getAcceptedTechSalesVendorQuotations(input: { // 필터 조건 추가 const filterConditions = []; if (input.filters?.length) { - const { filterWhere, joinOperator } = filterColumns({ + const filterWhere = filterColumns({ table: techSalesVendorQuotations, filters: input.filters, - joinOperator: input.joinOperator ?? "and", + joinOperator: "and", }); if (filterWhere) { filterConditions.push(filterWhere); @@ -3221,74 +3372,4 @@ export async function getAcceptedTechSalesVendorQuotations(input: { console.error("getAcceptedTechSalesVendorQuotations 오류:", error); throw new Error(`Accepted quotations 조회 실패: ${getErrorMessage(error)}`); } -} - -/** - * 벤더 견적서 거절 처리 (벤더가 직접 거절) - */ -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) - }; - } }
\ No newline at end of file diff --git a/lib/techsales-rfq/table/create-rfq-hull-dialog.tsx b/lib/techsales-rfq/table/create-rfq-hull-dialog.tsx index 4ba98cc7..7bbbfa75 100644 --- a/lib/techsales-rfq/table/create-rfq-hull-dialog.tsx +++ b/lib/techsales-rfq/table/create-rfq-hull-dialog.tsx @@ -362,18 +362,16 @@ export function CreateHullRfqDialog({ onCreated }: CreateHullRfqDialogProps) { )} /> - <Separator className="my-4" /> - {/* RFQ 설명 */} <FormField control={form.control} name="description" render={({ field }) => ( <FormItem> - <FormLabel>RFQ 설명</FormLabel> + <FormLabel>RFQ Title</FormLabel> <FormControl> <Input - placeholder="RFQ 설명을 입력하세요 (선택사항)" + placeholder="RFQ Title을 입력하세요 (선택사항)" {...field} /> </FormControl> @@ -381,9 +379,7 @@ export function CreateHullRfqDialog({ onCreated }: CreateHullRfqDialogProps) { </FormItem> )} /> - <Separator className="my-4" /> - {/* 마감일 설정 */} <FormField control={form.control} diff --git a/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx b/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx index 8a66f26e..b616f526 100644 --- a/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx +++ b/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx @@ -385,10 +385,10 @@ export function CreateShipRfqDialog({ onCreated }: CreateShipRfqDialogProps) { name="description" render={({ field }) => ( <FormItem> - <FormLabel>RFQ 설명</FormLabel> + <FormLabel>RFQ Title</FormLabel> <FormControl> <Input - placeholder="RFQ 설명을 입력하세요 (선택사항)" + placeholder="RFQ Title을 입력하세요 (선택사항)" {...field} /> </FormControl> diff --git a/lib/techsales-rfq/table/create-rfq-top-dialog.tsx b/lib/techsales-rfq/table/create-rfq-top-dialog.tsx index 70f56ebd..6536e230 100644 --- a/lib/techsales-rfq/table/create-rfq-top-dialog.tsx +++ b/lib/techsales-rfq/table/create-rfq-top-dialog.tsx @@ -3,7 +3,6 @@ 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" @@ -43,6 +42,7 @@ import { } 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 { @@ -354,7 +354,24 @@ export function CreateTopRfqDialog({ onCreated }: CreateTopRfqDialogProps) { /> <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} 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 3574111f..8f2fe948 100644 --- a/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx +++ b/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx @@ -29,6 +29,8 @@ type VendorFormValues = z.infer<typeof vendorFormSchema> 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 } @@ -118,10 +120,8 @@ export function AddVendorDialog({ setIsSearching(true) try { // 선택된 RFQ의 타입을 기반으로 벤더 검색 - const rfqType = selectedRfq?.rfqCode?.includes("SHIP") ? "SHIP" : - selectedRfq?.rfqCode?.includes("TOP") ? "TOP" : - selectedRfq?.rfqCode?.includes("HULL") ? "HULL" : undefined; - + const rfqType = selectedRfq?.rfqType || undefined; + console.log("rfqType", rfqType) // 디버깅용 const results = await searchTechVendors(term, 100, rfqType) // 이미 추가된 벤더 제외 @@ -136,7 +136,7 @@ export function AddVendorDialog({ setIsSearching(false) } }, - [existingVendorIds] + [existingVendorIds, selectedRfq?.rfqType] ) // 검색어 변경 시 디바운스 적용 diff --git a/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx b/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx new file mode 100644 index 00000000..7832fa2b --- /dev/null +++ b/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx @@ -0,0 +1,312 @@ +"use client"
+
+import * as React from "react"
+import { useState, useEffect } from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+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 { formatDate } from "@/lib/utils"
+import { toast } from "sonner"
+
+interface QuotationAttachment {
+ id: number
+ quotationId: number
+ revisionId: number
+ fileName: string
+ originalFileName: string
+ fileSize: number
+ fileType: string | null
+ filePath: string
+ description: string | null
+ isVendorUpload: boolean
+ createdAt: Date
+ updatedAt: Date
+}
+
+interface QuotationSnapshot {
+ currency: string | null
+ totalPrice: string | null
+ validUntil: Date | null
+ remark: string | null
+ status: string | null
+ quotationVersion: number | null
+ submittedAt: Date | null
+ acceptedAt: Date | null
+ updatedAt: Date | null
+}
+
+interface QuotationRevision {
+ id: number
+ version: number
+ snapshot: QuotationSnapshot
+ changeReason: string | null
+ revisionNote: string | null
+ revisedBy: number | null
+ revisedAt: Date
+ revisedByName: string | null
+ attachments: QuotationAttachment[]
+}
+
+interface QuotationHistoryData {
+ current: {
+ id: number
+ currency: string | null
+ totalPrice: string | null
+ validUntil: Date | null
+ remark: string | null
+ status: string
+ quotationVersion: number | null
+ submittedAt: Date | null
+ acceptedAt: Date | null
+ updatedAt: Date | null
+ attachments: QuotationAttachment[]
+ }
+ revisions: QuotationRevision[]
+}
+
+interface QuotationHistoryDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ quotationId: number | null
+}
+
+const statusConfig = {
+ "Draft": { label: "초안", color: "bg-yellow-100 text-yellow-800" },
+ "Submitted": { label: "제출됨", color: "bg-blue-100 text-blue-800" },
+ "Revised": { label: "수정됨", color: "bg-purple-100 text-purple-800" },
+ "Accepted": { label: "승인됨", color: "bg-green-100 text-green-800" },
+ "Rejected": { label: "거절됨", color: "bg-red-100 text-red-800" },
+}
+
+function QuotationCard({
+ data,
+ version,
+ isCurrent = false,
+ changeReason,
+ revisedBy,
+ revisedAt,
+ attachments
+}: {
+ data: QuotationSnapshot | QuotationHistoryData["current"]
+ version: number
+ isCurrent?: boolean
+ changeReason?: string | null
+ revisedBy?: string | null
+ revisedAt?: Date
+ attachments?: QuotationAttachment[]
+}) {
+ const statusInfo = statusConfig[data.status as keyof typeof statusConfig] ||
+ { label: data.status || "알 수 없음", color: "bg-gray-100 text-gray-800" }
+
+ return (
+ <Card className={`${isCurrent ? "border-blue-500 shadow-md" : "border-gray-200"}`}>
+ <CardHeader className="pb-3">
+ <div className="flex items-center justify-between">
+ <CardTitle className="text-lg flex items-center gap-2">
+ <span>버전 {version}</span>
+ {isCurrent && <Badge variant="default">현재</Badge>}
+ </CardTitle>
+ <Badge className={statusInfo.color}>
+ {statusInfo.label}
+ </Badge>
+ </div>
+ {changeReason && (
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <FileText className="size-4" />
+ <span>{changeReason}</span>
+ </div>
+ )}
+ </CardHeader>
+ <CardContent className="space-y-3">
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <p className="text-sm font-medium text-muted-foreground">견적 금액</p>
+ <p className="text-lg font-semibold">
+ {data.totalPrice ? `${data.currency} ${Number(data.totalPrice).toLocaleString()}` : "미입력"}
+ </p>
+ </div>
+ <div>
+ <p className="text-sm font-medium text-muted-foreground">유효 기한</p>
+ <p className="text-sm">
+ {data.validUntil ? formatDate(data.validUntil) : "미설정"}
+ </p>
+ </div>
+ </div>
+
+ {data.remark && (
+ <div>
+ <p className="text-sm font-medium text-muted-foreground">비고</p>
+ <p className="text-sm bg-gray-50 p-2 rounded">{data.remark}</p>
+ </div>
+ )}
+
+ {/* 첨부파일 섹션 */}
+ {attachments && attachments.length > 0 && (
+ <div>
+ <p className="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-1">
+ <Paperclip className="size-3" />
+ 첨부파일 ({attachments.length}개)
+ </p>
+ <div className="space-y-1">
+ {attachments.map((attachment) => (
+ <div key={attachment.id} className="flex items-center justify-between p-2 bg-gray-50 rounded text-xs">
+ <div className="flex items-center gap-2 min-w-0 flex-1">
+ <div className="min-w-0 flex-1">
+ <p className="font-medium truncate" title={attachment.originalFileName}>
+ {attachment.originalFileName}
+ </p>
+ {attachment.description && (
+ <p className="text-muted-foreground truncate" title={attachment.description}>
+ {attachment.description}
+ </p>
+ )}
+ </div>
+ </div>
+ <div className="text-muted-foreground whitespace-nowrap ml-2">
+ {(attachment.fileSize / 1024 / 1024).toFixed(2)} MB
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ <Separator />
+
+ <div className="flex items-center justify-between text-xs text-muted-foreground">
+ <div className="flex items-center gap-1">
+ <Clock className="size-3" />
+ <span>
+ {isCurrent
+ ? `수정: ${data.updatedAt ? formatDate(data.updatedAt) : "N/A"}`
+ : `변경: ${revisedAt ? formatDate(revisedAt) : "N/A"}`
+ }
+ </span>
+ </div>
+ {revisedBy && (
+ <div className="flex items-center gap-1">
+ <User className="size-3" />
+ <span>{revisedBy}</span>
+ </div>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+ )
+}
+
+export function QuotationHistoryDialog({
+ open,
+ onOpenChange,
+ quotationId
+}: QuotationHistoryDialogProps) {
+ const [data, setData] = useState<QuotationHistoryData | null>(null)
+ const [isLoading, setIsLoading] = useState(false)
+
+ useEffect(() => {
+ if (open && quotationId) {
+ loadQuotationHistory()
+ }
+ }, [open, quotationId])
+
+ const loadQuotationHistory = async () => {
+ if (!quotationId) return
+
+ try {
+ setIsLoading(true)
+ const { getTechSalesVendorQuotationWithRevisions } = await import("@/lib/techsales-rfq/service")
+
+ const result = await getTechSalesVendorQuotationWithRevisions(quotationId)
+
+ if (result.error) {
+ toast.error(result.error)
+ return
+ }
+
+ setData(result.data as QuotationHistoryData)
+ } catch (error) {
+ console.error("견적 히스토리 로드 오류:", error)
+ toast.error("견적 히스토리를 불러오는 중 오류가 발생했습니다")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const handleOpenChange = (newOpen: boolean) => {
+ onOpenChange(newOpen)
+ if (!newOpen) {
+ setData(null) // 다이얼로그 닫을 때 데이터 초기화
+ }
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={handleOpenChange}>
+ <DialogContent className=" max-h-[80vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>견적서 수정 히스토리</DialogTitle>
+ <DialogDescription>
+ 견적서의 변경 이력을 확인할 수 있습니다. 최신 버전부터 순서대로 표시됩니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="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-32" />
+ <Skeleton className="h-32 w-full" />
+ </div>
+ ))}
+ </div>
+ ) : data ? (
+ <>
+ {/* 현재 버전 */}
+ <QuotationCard
+ data={data.current}
+ version={data.current.quotationVersion || 1}
+ isCurrent={true}
+ attachments={data.current.attachments}
+ />
+
+ {/* 이전 버전들 */}
+ {data.revisions.length > 0 ? (
+ data.revisions.map((revision) => (
+ <QuotationCard
+ key={revision.id}
+ data={revision.snapshot}
+ version={revision.version}
+ changeReason={revision.changeReason}
+ revisedBy={revision.revisedByName}
+ revisedAt={revision.revisedAt}
+ attachments={revision.attachments}
+ />
+ ))
+ ) : (
+ <div className="text-center py-8 text-muted-foreground">
+ <AlertCircle className="size-12 mx-auto mb-2 opacity-50" />
+ <p>수정 이력이 없습니다.</p>
+ <p className="text-sm">이 견적서는 아직 수정되지 않았습니다.</p>
+ </div>
+ )}
+ </>
+ ) : (
+ <div className="text-center py-8 text-muted-foreground">
+ <AlertCircle className="size-12 mx-auto mb-2 opacity-50" />
+ <p>견적서 정보를 불러올 수 없습니다.</p>
+ </div>
+ )}
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+}
\ No newline at end of file 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 3e50a516..e921fcaa 100644 --- a/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx +++ b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx @@ -5,7 +5,7 @@ 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 } from "lucide-react"; +import { MessageCircle, MoreHorizontal, Trash2, Paperclip } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { @@ -38,6 +38,24 @@ export interface RfqDetailView { 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> { @@ -45,11 +63,15 @@ interface GetColumnsProps<TData> { 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 = {} + unreadMessages = {}, + onQuotationClick, + openQuotationAttachmentsSheet }: GetColumnsProps<RfqDetailView>): ColumnDef<RfqDetailView>[] { return [ { @@ -66,15 +88,15 @@ export function getRfqDetailColumns({ ), cell: ({ row }) => { const status = row.original.status; - const isDraft = status === "Draft"; + const isSelectable = status ? !["Accepted", "Rejected"].includes(status) : true; return ( <Checkbox checked={row.getIsSelected()} onCheckedChange={(value) => row.toggleSelected(!!value)} - disabled={!isDraft} + disabled={!isSelectable} aria-label="행 선택" - className={!isDraft ? "opacity-50 cursor-not-allowed" : ""} + className={!isSelectable ? "opacity-50 cursor-not-allowed" : ""} /> ); }, @@ -163,15 +185,31 @@ export function getRfqDetailColumns({ 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"> - {isNaN(numValue) ? value : numValue.toLocaleString()} {currency} + {displayValue} {currency} </div> ); }, @@ -182,6 +220,57 @@ export function getRfqDetailColumns({ 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="통화" /> 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 f2eda8d9..1d701bd5 100644 --- a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx +++ b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx @@ -12,12 +12,14 @@ 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, BarChart2, Send, Trash2 } from "lucide-react" +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 { VendorQuotationComparisonDialog } from "./vendor-quotation-comparison-dialog" 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 { @@ -30,6 +32,8 @@ interface TechSalesRfq { rfqSendDate?: Date | null dueDate?: Date | null createdByName?: string | null + rfqType: "SHIP" | "TOP" | "HULL" | null + ptypeNm?: string | null } // 프로퍼티 정의 @@ -58,9 +62,6 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps // 읽지 않은 메시지 개수 const [unreadMessages, setUnreadMessages] = useState<Record<number, number>>({}) - // 견적 비교 다이얼로그 상태 관리 - const [comparisonDialogOpen, setComparisonDialogOpen] = useState(false) - // 테이블 선택 상태 관리 const [selectedRows, setSelectedRows] = useState<RfqDetailView[]>([]) const [isSendingRfq, setIsSendingRfq] = useState(false) @@ -69,6 +70,16 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps // 벤더 삭제 확인 다이얼로그 상태 추가 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]) @@ -108,6 +119,8 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps detailId: item.id, rfqId: selectedRfqId, rfqCode: selectedRfq?.rfqCode || null, + rfqType: selectedRfq?.rfqType || null, + ptypeNm: selectedRfq?.ptypeNm || null, vendorId: item.vendorId ? Number(item.vendorId) : undefined, })) || [] @@ -121,7 +134,7 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps console.error("데이터 새로고침 오류:", error) toast.error("데이터를 새로고침하는 중 오류가 발생했습니다") } - }, [selectedRfqId, selectedRfq?.rfqCode, loadUnreadMessages]) + }, [selectedRfqId, selectedRfq?.rfqCode, selectedRfq?.rfqType, selectedRfq?.ptypeNm, loadUnreadMessages]) // 벤더 추가 핸들러 메모이제이션 const handleAddVendor = useCallback(async () => { @@ -180,6 +193,54 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps } }, [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) { @@ -246,27 +307,47 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps await handleDeleteVendors(); }, [handleDeleteVendors]); - // 견적 비교 다이얼로그 열기 핸들러 메모이제이션 - const handleOpenComparisonDialog = useCallback(() => { - // 제출된 견적이 있는 벤더가 최소 1개 이상 있는지 확인 - const hasSubmittedQuotations = details.some(detail => - detail.status === "Submitted" // RfqDetailView의 실제 필드 사용 - ); - if (!hasSubmittedQuotations) { - toast.warning("제출된 견적이 없습니다."); - return; - } + // 견적 히스토리 다이얼로그 열기 핸들러 메모이제이션 + const handleOpenHistoryDialog = useCallback((quotationId: number) => { + setSelectedQuotationId(quotationId); + setHistoryDialogOpen(true); + }, []) - setComparisonDialogOpen(true); - }, [details]) + // 견적서 첨부파일 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 - }), [unreadMessages]) + unreadMessages, + onQuotationClick: handleOpenHistoryDialog, + openQuotationAttachmentsSheet: handleOpenQuotationAttachmentsSheet + }), [unreadMessages, handleOpenHistoryDialog, handleOpenQuotationAttachmentsSheet]) // 필터 필드 정의 (메모이제이션) const advancedFilterFields = useMemo( @@ -493,6 +574,22 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps )} </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" @@ -525,22 +622,6 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps <span>벤더 삭제</span> </Button> - {/* 견적 비교 버튼 */} - <Button - variant="outline" - size="sm" - onClick={handleOpenComparisonDialog} - className="gap-2" - disabled={ - !selectedRfq || - details.length === 0 || - vendorsWithQuotations === 0 - } - > - <BarChart2 className="size-4" aria-hidden="true" /> - <span>견적 비교/선택</span> - </Button> - {/* 벤더 추가 버튼 */} <Button variant="outline" @@ -586,7 +667,7 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps <AddVendorDialog open={vendorDialogOpen} onOpenChange={setVendorDialogOpen} - selectedRfq={selectedRfq} + selectedRfq={selectedRfq as unknown as TechSalesRfq} existingVendorIds={existingVendorIds} onSuccess={handleRefreshData} /> @@ -600,13 +681,6 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps onSuccess={handleRefreshData} /> - {/* 견적 비교 다이얼로그 */} - <VendorQuotationComparisonDialog - open={comparisonDialogOpen} - onOpenChange={setComparisonDialogOpen} - selectedRfq={selectedRfq} - /> - {/* 다중 벤더 삭제 확인 다이얼로그 */} <DeleteVendorsDialog open={deleteConfirmDialogOpen} @@ -615,6 +689,22 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps onConfirm={executeDeleteVendors} isLoading={isDeletingVendors} /> + + {/* 견적 히스토리 다이얼로그 */} + <QuotationHistoryDialog + open={historyDialogOpen} + onOpenChange={setHistoryDialogOpen} + quotationId={selectedQuotationId} + /> + + {/* 견적서 첨부파일 Sheet */} + <TechSalesQuotationAttachmentsSheet + open={quotationAttachmentsSheetOpen} + onOpenChange={setQuotationAttachmentsSheetOpen} + quotation={selectedQuotationInfo} + attachments={quotationAttachments} + isLoading={isLoadingAttachments} + /> </div> ) }
\ No newline at end of file diff --git a/lib/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx b/lib/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx deleted file mode 100644 index 0a6caa5c..00000000 --- a/lib/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx +++ /dev/null @@ -1,341 +0,0 @@ -"use client" - -import * as React from "react" -import { useEffect, useState } from "react" -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { Skeleton } from "@/components/ui/skeleton" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" -import { toast } from "sonner" -import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog" - -// Lucide 아이콘 -import { Plus, Minus, CheckCircle, Loader2 } from "lucide-react" - -import { getTechSalesVendorQuotationsWithJoin } from "@/lib/techsales-rfq/service" -import { acceptTechSalesVendorQuotationAction } from "@/lib/techsales-rfq/actions" -import { formatCurrency, formatDate } from "@/lib/utils" -import { techSalesVendorQuotations } from "@/db/schema/techSales" - -// 기술영업 견적 정보 타입 -interface TechSalesVendorQuotation { - id: number - rfqId: number - vendorId: number - vendorName?: string | null - totalPrice: string | null - currency: string | null - validUntil: Date | null - status: string - remark: string | null - submittedAt: Date | null - acceptedAt: Date | null - createdAt: Date - updatedAt: Date -} - -interface VendorQuotationComparisonDialogProps { - 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 -} - -export function VendorQuotationComparisonDialog({ - open, - onOpenChange, - selectedRfq, -}: VendorQuotationComparisonDialogProps) { - const [isLoading, setIsLoading] = useState(false) - const [quotations, setQuotations] = useState<TechSalesVendorQuotation[]>([]) - const [selectedVendorId, setSelectedVendorId] = useState<number | null>(null) - const [isAccepting, setIsAccepting] = useState(false) - const [showConfirmDialog, setShowConfirmDialog] = useState(false) - - useEffect(() => { - async function loadQuotationData() { - if (!open || !selectedRfq?.id) return - - try { - setIsLoading(true) - // 기술영업 견적 목록 조회 (제출된 견적만) - const result = await getTechSalesVendorQuotationsWithJoin({ - rfqId: selectedRfq.id, - page: 1, - perPage: 100, - filters: [ - { - id: "status" as keyof typeof techSalesVendorQuotations, - value: "Submitted", - type: "select" as const, - operator: "eq" as const, - rowId: "status" - } - ] - }) - - setQuotations(result.data || []) - } catch (error) { - console.error("견적 데이터 로드 오류:", error) - toast.error("견적 데이터를 불러오는 데 실패했습니다") - } finally { - setIsLoading(false) - } - } - - loadQuotationData() - }, [open, selectedRfq]) - - // 견적 상태 -> 뱃지 색 - const getStatusBadgeVariant = (status: string) => { - switch (status) { - case "Submitted": - return "default" - case "Accepted": - return "default" - case "Rejected": - return "destructive" - case "Revised": - return "destructive" - default: - return "secondary" - } - } - - // 벤더 선택 핸들러 - const handleSelectVendor = (vendorId: number) => { - setSelectedVendorId(vendorId) - setShowConfirmDialog(true) - } - - // 벤더 선택 확정 - const handleConfirmSelection = async () => { - if (!selectedVendorId) return - - try { - setIsAccepting(true) - - // 선택된 견적의 ID 찾기 - const selectedQuotation = quotations.find(q => q.vendorId === selectedVendorId) - if (!selectedQuotation) { - toast.error("선택된 견적을 찾을 수 없습니다") - return - } - - // 벤더 선택 API 호출 - const result = await acceptTechSalesVendorQuotationAction(selectedQuotation.id) - - if (result.success) { - toast.success(result.message || "벤더가 선택되었습니다") - setShowConfirmDialog(false) - onOpenChange(false) - - // 페이지 새로고침 또는 데이터 재로드 - window.location.reload() - } else { - toast.error(result.error || "벤더 선택에 실패했습니다") - } - } catch (error) { - console.error("벤더 선택 오류:", error) - toast.error("벤더 선택에 실패했습니다") - } finally { - setIsAccepting(false) - } - } - - const selectedVendor = quotations.find(q => q.vendorId === selectedVendorId) - - return ( - <> - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="max-w-[90vw] lg:max-w-5xl max-h-[90vh]"> - <DialogHeader> - <DialogTitle>벤더 견적 비교 및 선택</DialogTitle> - <DialogDescription> - {selectedRfq - ? `RFQ ${selectedRfq.rfqCode} - 제출된 견적을 비교하고 벤더를 선택하세요` - : ""} - </DialogDescription> - </DialogHeader> - - {isLoading ? ( - <div className="space-y-4"> - <Skeleton className="h-8 w-1/2" /> - <Skeleton className="h-48 w-full" /> - </div> - ) : quotations.length === 0 ? ( - <div className="py-8 text-center text-muted-foreground"> - 제출된(Submitted) 견적이 없습니다 - </div> - ) : ( - <div className="border rounded-md max-h-[60vh] overflow-auto"> - <table className="table-fixed w-full border-collapse"> - <thead className="sticky top-0 bg-background z-10"> - <TableRow> - <TableHead className="sticky left-0 top-0 z-20 bg-background p-2 w-32"> - 항목 - </TableHead> - {quotations.map((q) => ( - <TableHead key={q.id} className="p-2 text-center whitespace-nowrap w-48"> - <div className="flex flex-col items-center gap-2"> - <span>{q.vendorName || `벤더 ID: ${q.vendorId}`}</span> - <Button - size="sm" - variant={q.status === "Accepted" ? "default" : "outline"} - onClick={() => handleSelectVendor(q.vendorId)} - disabled={q.status === "Accepted"} - className="gap-1" - > - {q.status === "Accepted" ? ( - <> - <CheckCircle className="h-4 w-4" /> - 선택됨 - </> - ) : ( - "선택" - )} - </Button> - </div> - </TableHead> - ))} - </TableRow> - </thead> - <tbody> - {/* 견적 상태 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 견적 상태 - </TableCell> - {quotations.map((q) => ( - <TableCell key={`status-${q.id}`} className="p-2 text-center"> - <Badge variant={getStatusBadgeVariant(q.status)}> - {q.status} - </Badge> - </TableCell> - ))} - </TableRow> - - {/* 총 금액 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 총 금액 - </TableCell> - {quotations.map((q) => ( - <TableCell key={`total-${q.id}`} className="p-2 font-semibold text-center"> - {q.totalPrice ? formatCurrency(Number(q.totalPrice), q.currency || 'USD') : '-'} - </TableCell> - ))} - </TableRow> - - {/* 통화 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 통화 - </TableCell> - {quotations.map((q) => ( - <TableCell key={`currency-${q.id}`} className="p-2 text-center"> - {q.currency || '-'} - </TableCell> - ))} - </TableRow> - - {/* 유효기간 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 유효 기간 - </TableCell> - {quotations.map((q) => ( - <TableCell key={`valid-${q.id}`} className="p-2 text-center"> - {q.validUntil ? formatDate(q.validUntil, "KR") : '-'} - </TableCell> - ))} - </TableRow> - - {/* 제출일 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 제출일 - </TableCell> - {quotations.map((q) => ( - <TableCell key={`submitted-${q.id}`} className="p-2 text-center"> - {q.submittedAt ? formatDate(q.submittedAt, "KR") : '-'} - </TableCell> - ))} - </TableRow> - - {/* 비고 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 비고 - </TableCell> - {quotations.map((q) => ( - <TableCell - key={`remark-${q.id}`} - className="p-2 whitespace-pre-wrap text-center" - > - {q.remark || "-"} - </TableCell> - ))} - </TableRow> - </tbody> - </table> - </div> - )} - - <DialogFooter> - <Button variant="outline" onClick={() => onOpenChange(false)}> - 닫기 - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - - {/* 벤더 선택 확인 다이얼로그 */} - <AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}> - <AlertDialogContent> - <AlertDialogHeader> - <AlertDialogTitle>벤더 선택 확인</AlertDialogTitle> - <AlertDialogDescription> - <strong>{selectedVendor?.vendorName || `벤더 ID: ${selectedVendorId}`}</strong>를 선택하시겠습니까? - <br /> - <br /> - 선택된 벤더의 견적이 승인되며, 다른 벤더들의 견적은 자동으로 거절됩니다. - 이 작업은 되돌릴 수 없습니다. - </AlertDialogDescription> - </AlertDialogHeader> - <AlertDialogFooter> - <AlertDialogCancel disabled={isAccepting}>취소</AlertDialogCancel> - <AlertDialogAction - onClick={handleConfirmSelection} - disabled={isAccepting} - className="gap-2" - > - {isAccepting && <Loader2 className="h-4 w-4 animate-spin" />} - 확인 - </AlertDialogAction> - </AlertDialogFooter> - </AlertDialogContent> - </AlertDialog> - </> - ) -} diff --git a/lib/techsales-rfq/table/rfq-items-view-dialog.tsx b/lib/techsales-rfq/table/rfq-items-view-dialog.tsx index 10bc9f1f..289ad312 100644 --- a/lib/techsales-rfq/table/rfq-items-view-dialog.tsx +++ b/lib/techsales-rfq/table/rfq-items-view-dialog.tsx @@ -30,10 +30,10 @@ interface RfqItemsViewDialogProps { onOpenChange: (open: boolean) => void;
rfq: {
id: number;
- rfqCode?: string;
+ rfqCode?: string | null;
status?: string;
description?: string;
- rfqType?: "SHIP" | "TOP" | "HULL";
+ rfqType?: "SHIP" | "TOP" | "HULL" | null;
} | null;
}
diff --git a/lib/techsales-rfq/table/rfq-table-column.tsx b/lib/techsales-rfq/table/rfq-table-column.tsx index 51c143a4..3009e036 100644 --- a/lib/techsales-rfq/table/rfq-table-column.tsx +++ b/lib/techsales-rfq/table/rfq-table-column.tsx @@ -6,13 +6,14 @@ 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 } from "lucide-react" +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" @@ -33,6 +34,8 @@ type TechSalesRfq = { projMsrm: number ptypeNm: string attachmentCount: number + hasTbeAttachments: boolean + hasCbeAttachments: boolean quotationCount: number itemCount: number // 나머지 필드는 사용할 때마다 추가 @@ -41,7 +44,7 @@ type TechSalesRfq = { interface GetColumnsProps { setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TechSalesRfq> | null>>; - openAttachmentsSheet: (rfqId: number) => void; + openAttachmentsSheet: (rfqId: number, attachmentType?: 'RFQ_COMMON' | 'TBE_RESULT' | 'CBE_RESULT') => void; openItemsDialog: (rfq: TechSalesRfq) => void; } @@ -110,6 +113,18 @@ export function getColumns({ 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="프로젝트명" /> @@ -286,14 +301,14 @@ export function getColumns({ { id: "attachments", header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="첨부파일" /> + <DataTableColumnHeaderSimple column={column} title="RFQ 첨부파일" /> ), cell: ({ row }) => { const rfq = row.original const attachmentCount = rfq.attachmentCount || 0 const handleClick = () => { - openAttachmentsSheet(rfq.id) + openAttachmentsSheet(rfq.id, 'RFQ_COMMON') } return ( @@ -325,5 +340,81 @@ export function getColumns({ 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.tsx b/lib/techsales-rfq/table/rfq-table.tsx index 424ca70e..615753cd 100644 --- a/lib/techsales-rfq/table/rfq-table.tsx +++ b/lib/techsales-rfq/table/rfq-table.tsx @@ -57,6 +57,7 @@ interface TechSalesRfq { ptypeNm: string attachmentCount: number quotationCount: number + rfqType: "SHIP" | "TOP" | "HULL" | null // 필요에 따라 다른 필드들 추가 [key: string]: unknown } @@ -135,7 +136,7 @@ export function RFQListTable({ to: searchParams?.get('to') || undefined, columnVisibility: {}, columnOrder: [], - pinnedColumns: { left: [], right: ["items", "attachments"] }, + pinnedColumns: { left: [], right: ["items", "attachments", "tbe-attachments", "cbe-attachments"] }, groupBy: [], expandedRows: [] }), [searchParams]) @@ -170,6 +171,7 @@ export function RFQListTable({ setSelectedRfq({ id: rfqData.id, rfqCode: rfqData.rfqCode, + rfqType: rfqData.rfqType, // 빠뜨린 rfqType 필드 추가 biddingProjectId: rfqData.biddingProjectId, materialCode: rfqData.materialCode, dueDate: rfqData.dueDate, @@ -201,6 +203,7 @@ export function RFQListTable({ setProjectDetailRfq({ id: projectRfqData.id, rfqCode: projectRfqData.rfqCode, + rfqType: projectRfqData.rfqType, // 빠뜨린 rfqType 필드 추가 biddingProjectId: projectRfqData.biddingProjectId, materialCode: projectRfqData.materialCode, dueDate: projectRfqData.dueDate, @@ -238,8 +241,11 @@ export function RFQListTable({ } }, [rowAction]) + // 첨부파일 시트 상태에 타입 추가 + const [attachmentType, setAttachmentType] = React.useState<"RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT">("RFQ_COMMON") + // 첨부파일 시트 열기 함수 - const openAttachmentsSheet = React.useCallback(async (rfqId: number) => { + 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) @@ -248,6 +254,9 @@ export function RFQListTable({ return } + // attachmentType을 RFQ_COMMON, TBE_RESULT, CBE_RESULT 중 하나로 변환 + const validAttachmentType=attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT" + // 실제 첨부파일 목록 조회 API 호출 const result = await getTechSalesRfqAttachments(rfqId) @@ -256,8 +265,11 @@ export function RFQListTable({ return } + // 해당 타입의 첨부파일만 필터링 + const filteredAttachments = result.data.filter(att => att.attachmentType === validAttachmentType) + // API 응답을 ExistingTechSalesAttachment 형식으로 변환 - const attachments: ExistingTechSalesAttachment[] = result.data.map(att => ({ + const attachments: ExistingTechSalesAttachment[] = filteredAttachments.map(att => ({ id: att.id, techSalesRfqId: att.techSalesRfqId || rfqId, // null인 경우 rfqId 사용 fileName: att.fileName, @@ -265,12 +277,13 @@ export function RFQListTable({ filePath: att.filePath, fileSize: att.fileSize || undefined, fileType: att.fileType || undefined, - attachmentType: att.attachmentType as "RFQ_COMMON" | "VENDOR_SPECIFIC", + 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) @@ -561,6 +574,7 @@ export function RFQListTable({ onOpenChange={setAttachmentsOpen} defaultAttachments={attachmentsDefault} rfq={selectedRfqForAttachments} + attachmentType={attachmentType} onAttachmentsUpdated={handleAttachmentsUpdated} /> diff --git a/lib/techsales-rfq/table/tech-sales-quotation-attachments-sheet.tsx b/lib/techsales-rfq/table/tech-sales-quotation-attachments-sheet.tsx new file mode 100644 index 00000000..21c61773 --- /dev/null +++ b/lib/techsales-rfq/table/tech-sales-quotation-attachments-sheet.tsx @@ -0,0 +1,231 @@ +"use client"
+
+import * as React from "react"
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+ SheetDescription,
+} from "@/components/ui/sheet"
+import { Button } from "@/components/ui/button"
+import { Download, FileText, File, ImageIcon, AlertCircle } from "lucide-react"
+import { Badge } from "@/components/ui/badge"
+import { Separator } from "@/components/ui/separator"
+import { formatDate } from "@/lib/utils"
+import prettyBytes from "pretty-bytes"
+
+// 견적서 첨부파일 타입 정의
+export interface QuotationAttachment {
+ id: number
+ quotationId: number
+ revisionId: number
+ fileName: string
+ originalFileName: string
+ fileSize: number
+ fileType: string | null
+ filePath: string
+ description: string | null
+ uploadedBy: number
+ vendorId: number
+ isVendorUpload: boolean
+ createdAt: Date
+ updatedAt: Date
+}
+
+// 견적서 정보 타입
+interface QuotationInfo {
+ id: number
+ quotationCode: string | null
+ vendorName?: string
+ rfqCode?: string
+}
+
+interface TechSalesQuotationAttachmentsSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ quotation: QuotationInfo | null
+ attachments: QuotationAttachment[]
+ isLoading?: boolean
+}
+
+export function TechSalesQuotationAttachmentsSheet({
+ quotation,
+ attachments,
+ isLoading = false,
+ ...props
+}: TechSalesQuotationAttachmentsSheetProps) {
+
+ // 파일 아이콘 선택 함수
+ const getFileIcon = (fileName: string) => {
+ const ext = fileName.split('.').pop()?.toLowerCase();
+ if (!ext) return <File className="h-5 w-5 text-gray-500" />;
+
+ // 이미지 파일
+ if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp'].includes(ext)) {
+ return <ImageIcon className="h-5 w-5 text-blue-500" />;
+ }
+ // PDF 파일
+ if (ext === 'pdf') {
+ return <FileText className="h-5 w-5 text-red-500" />;
+ }
+ // Excel 파일
+ if (['xlsx', 'xls', 'csv'].includes(ext)) {
+ return <FileText className="h-5 w-5 text-green-500" />;
+ }
+ // Word 파일
+ if (['docx', 'doc'].includes(ext)) {
+ return <FileText className="h-5 w-5 text-blue-500" />;
+ }
+ // 기본 파일
+ return <File className="h-5 w-5 text-gray-500" />;
+ };
+
+ // 파일 다운로드 처리
+ const handleDownload = (attachment: QuotationAttachment) => {
+ 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 groupedAttachments = React.useMemo(() => {
+ const groups = new Map<number, QuotationAttachment[]>();
+
+ attachments.forEach(attachment => {
+ const revisionId = attachment.revisionId;
+ if (!groups.has(revisionId)) {
+ groups.set(revisionId, []);
+ }
+ groups.get(revisionId)!.push(attachment);
+ });
+
+ // 리비전 ID 기준 내림차순 정렬 (최신 버전이 위에)
+ return Array.from(groups.entries())
+ .sort(([a], [b]) => b - a)
+ .map(([revisionId, files]) => ({
+ revisionId,
+ files: files.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
+ }));
+ }, [attachments]);
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>견적서 첨부파일</SheetTitle>
+ <SheetDescription>
+ <div className="space-y-1">
+ <div>견적서: {quotation?.quotationCode || "N/A"}</div>
+ {quotation?.vendorName && (
+ <div>벤더: {quotation.vendorName}</div>
+ )}
+ {quotation?.rfqCode && (
+ <div>RFQ: {quotation.rfqCode}</div>
+ )}
+ </div>
+ </SheetDescription>
+ </SheetHeader>
+
+ <div className="flex-1 overflow-auto">
+ {isLoading ? (
+ <div className="flex items-center justify-center py-8">
+ <div className="text-center">
+ <div className="animate-spin h-8 w-8 border-2 border-primary border-t-transparent rounded-full mx-auto mb-2" />
+ <p className="text-sm text-muted-foreground">첨부파일 로딩 중...</p>
+ </div>
+ </div>
+ ) : attachments.length === 0 ? (
+ <div className="flex flex-col items-center justify-center py-8 text-center">
+ <AlertCircle className="h-12 w-12 text-muted-foreground mb-4" />
+ <p className="text-muted-foreground mb-2">첨부파일이 없습니다</p>
+ <p className="text-sm text-muted-foreground">
+ 이 견적서에는 첨부된 파일이 없습니다.
+ </p>
+ </div>
+ ) : (
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <h6 className="font-semibold text-sm">
+ 첨부파일 ({attachments.length}개)
+ </h6>
+ </div>
+
+ {groupedAttachments.map((group, groupIndex) => (
+ <div key={group.revisionId} className="space-y-3">
+ {/* 리비전 헤더 */}
+ <div className="flex items-center gap-2">
+ <Badge variant={group.revisionId === 0 ? "secondary" : "outline"} className="text-xs">
+ {group.revisionId === 0 ? "초기 버전" : `버전 ${group.revisionId}`}
+ </Badge>
+ <span className="text-xs text-muted-foreground">
+ ({group.files.length}개 파일)
+ </span>
+ </div>
+
+ {/* 해당 리비전의 첨부파일들 */}
+ {group.files.map((attachment) => (
+ <div
+ key={attachment.id}
+ 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)}
+ </div>
+
+ <div className="flex-1 min-w-0">
+ <div className="flex items-start justify-between gap-2">
+ <div className="min-w-0 flex-1">
+ <p className="text-sm font-medium break-words leading-tight">
+ {attachment.originalFileName || attachment.fileName}
+ </p>
+ <div className="flex items-center gap-2 mt-1">
+ <span className="text-xs text-muted-foreground">
+ {prettyBytes(attachment.fileSize)}
+ </span>
+ <Badge variant="outline" className="text-xs">
+ {attachment.isVendorUpload ? "벤더 업로드" : "시스템"}
+ </Badge>
+ </div>
+ <p className="text-xs text-muted-foreground mt-1">
+ {formatDate(attachment.createdAt)}
+ </p>
+ {attachment.description && (
+ <p className="text-xs text-muted-foreground mt-1 break-words">
+ {attachment.description}
+ </p>
+ )}
+ </div>
+ </div>
+ </div>
+
+ <div className="flex flex-col gap-1">
+ {/* 다운로드 버튼 */}
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-8 w-8"
+ onClick={() => handleDownload(attachment)}
+ title="다운로드"
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ ))}
+
+ {/* 그룹 간 구분선 (마지막 그룹 제외) */}
+ {groupIndex < groupedAttachments.length - 1 && (
+ <Separator className="my-4" />
+ )}
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ </SheetContent>
+ </Sheet>
+ )
+}
\ No newline at end of file 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 ecdf6d81..a7b487e1 100644 --- a/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx +++ b/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx @@ -27,7 +27,6 @@ import { import { Loader, Download, X, Eye, AlertCircle } from "lucide-react" import { toast } from "sonner" import { Badge } from "@/components/ui/badge" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Dropzone, @@ -63,7 +62,7 @@ export interface ExistingTechSalesAttachment { filePath: string fileSize?: number fileType?: string - attachmentType: "RFQ_COMMON" | "VENDOR_SPECIFIC" + attachmentType: "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT" description?: string createdBy: number createdAt: Date @@ -72,7 +71,7 @@ export interface ExistingTechSalesAttachment { /** 새로 업로드할 파일 */ const newUploadSchema = z.object({ fileObj: z.any().optional(), // 실제 File - attachmentType: z.enum(["RFQ_COMMON", "VENDOR_SPECIFIC"]).default("RFQ_COMMON"), + attachmentType: z.enum(["RFQ_COMMON", "TBE_RESULT", "CBE_RESULT"]).default("RFQ_COMMON"), description: z.string().optional(), }) @@ -85,7 +84,7 @@ const existingAttachSchema = z.object({ filePath: z.string(), fileSize: z.number().optional(), fileType: z.string().optional(), - attachmentType: z.enum(["RFQ_COMMON", "VENDOR_SPECIFIC"]), + attachmentType: z.enum(["RFQ_COMMON", "TBE_RESULT", "CBE_RESULT"]), description: z.string().optional(), createdBy: z.number(), createdAt: z.custom<Date>(), @@ -112,27 +111,54 @@ interface TechSalesRfqAttachmentsSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { defaultAttachments?: ExistingTechSalesAttachment[] rfq: TechSalesRfq | null + /** 첨부파일 타입 */ + attachmentType?: "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT" /** 업로드/삭제 후 상위 테이블에 attachmentCount 등을 업데이트하기 위한 콜백 */ - onAttachmentsUpdated?: (rfqId: number, newAttachmentCount: number) => void - /** 강제 읽기 전용 모드 (파트너/벤더용) */ - readOnly?: boolean + // onAttachmentsUpdated?: (rfqId: number, newAttachmentCount: number) => void + } export function TechSalesRfqAttachmentsSheet({ defaultAttachments = [], - onAttachmentsUpdated, + // onAttachmentsUpdated, rfq, - readOnly = false, + attachmentType = "RFQ_COMMON", ...props }: TechSalesRfqAttachmentsSheetProps) { const [isPending, setIsPending] = React.useState(false) - // RFQ 상태에 따른 편집 가능 여부 결정 (readOnly prop이 true면 항상 false) - const isEditable = React.useMemo(() => { - if (!rfq || readOnly) return false - // RFQ Created, RFQ Vendor Assignned 상태에서만 편집 가능 - return ["RFQ Created", "RFQ Vendor Assignned"].includes(rfq.status) - }, [rfq, readOnly]) + // 첨부파일 타입별 제목과 설명 설정 + const attachmentConfig = React.useMemo(() => { + switch (attachmentType) { + case "TBE_RESULT": + return { + title: "TBE 결과 첨부파일", + description: "기술 평가(TBE) 결과 파일을 관리합니다.", + fileTypeLabel: "TBE 결과", + canEdit: true + } + case "CBE_RESULT": + return { + title: "CBE 결과 첨부파일", + description: "상업성 평가(CBE) 결과 파일을 관리합니다.", + fileTypeLabel: "CBE 결과", + canEdit: true + } + default: // RFQ_COMMON + return { + title: "RFQ 첨부파일", + description: "RFQ 공통 첨부파일을 관리합니다.", + fileTypeLabel: "공통", + canEdit: true + } + } + }, [attachmentType, rfq?.status]) + + // // 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), @@ -236,7 +262,7 @@ export function TechSalesRfqAttachmentsSheet({ .filter(upload => upload.fileObj) .map(upload => ({ file: upload.fileObj as File, - attachmentType: upload.attachmentType, + attachmentType: attachmentType, description: upload.description, })) @@ -268,50 +294,50 @@ export function TechSalesRfqAttachmentsSheet({ 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" | "VENDOR_SPECIFIC", - 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: [], - }) + // // 즉시 첨부파일 목록 새로고침 + // 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 }) - } - } + // // 즉시 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) + // // 콜백으로 상위 컴포넌트에 변경사항 알림 + // const newAttachmentCount = refreshResult.error ? + // (data.existing.length + newFiles.length - deleteAttachmentIds.length) : + // refreshResult.data.length + // onAttachmentsUpdated?.(rfq.id, newAttachmentCount) } catch (error) { console.error("첨부파일 저장 오류:", error) @@ -325,10 +351,11 @@ export function TechSalesRfqAttachmentsSheet({ <Sheet {...props}> <SheetContent className="flex flex-col gap-6 sm:max-w-md"> <SheetHeader className="text-left"> - <SheetTitle>첨부파일 관리</SheetTitle> + <SheetTitle>{attachmentConfig.title}</SheetTitle> <SheetDescription> - RFQ: {rfq?.rfqCode || "N/A"} - {!isEditable && ( + <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> @@ -345,7 +372,7 @@ export function TechSalesRfqAttachmentsSheet({ 기존 첨부파일 ({existingFields.length}개) </h6> {existingFields.map((field, index) => { - const typeLabel = field.attachmentType === "RFQ_COMMON" ? "공통" : "벤더별" + const typeLabel = attachmentConfig.fileTypeLabel const sizeText = field.fileSize ? prettyBytes(field.fileSize) : "알 수 없음" const dateText = field.createdAt ? formatDate(field.createdAt) : "" @@ -384,7 +411,7 @@ export function TechSalesRfqAttachmentsSheet({ </a> )} {/* Remove button - 편집 가능할 때만 표시 */} - {isEditable && ( + {attachmentConfig.canEdit && ( <Button type="button" variant="ghost" @@ -402,7 +429,7 @@ export function TechSalesRfqAttachmentsSheet({ </div> {/* 2) Dropzone for new uploads - 편집 가능할 때만 표시 */} - {isEditable ? ( + {attachmentConfig.canEdit ? ( <> <Dropzone maxSize={MAX_FILE_SIZE} @@ -467,30 +494,6 @@ export function TechSalesRfqAttachmentsSheet({ </FileListAction> </FileListHeader> - {/* 파일별 설정 */} - <div className="px-4 pb-3 space-y-3"> - <FormField - control={form.control} - name={`newUploads.${idx}.attachmentType`} - render={({ field: formField }) => ( - <FormItem> - <FormLabel className="text-xs">파일 타입</FormLabel> - <Select onValueChange={formField.onChange} defaultValue={formField.value}> - <FormControl> - <SelectTrigger className="h-8"> - <SelectValue placeholder="파일 타입 선택" /> - </SelectTrigger> - </FormControl> - <SelectContent> - <SelectItem value="RFQ_COMMON">공통 파일</SelectItem> - {/* <SelectItem value="VENDOR_SPECIFIC">벤더별 파일</SelectItem> */} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - </div> </FileListItem> ) })} @@ -510,10 +513,10 @@ export function TechSalesRfqAttachmentsSheet({ <SheetFooter className="gap-2 pt-2 sm:space-x-0"> <SheetClose asChild> <Button type="button" variant="outline"> - {isEditable ? "취소" : "닫기"} + {attachmentConfig.canEdit ? "취소" : "닫기"} </Button> </SheetClose> - {isEditable && ( + {attachmentConfig.canEdit && ( <Button type="submit" disabled={ 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 3449dcb6..20b2703c 100644 --- a/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx +++ b/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx @@ -1,7 +1,7 @@ "use client" import * as React from "react" -import { useState } 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" @@ -10,7 +10,7 @@ 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, Save, Send, AlertCircle } from "lucide-react" +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" @@ -26,6 +26,13 @@ interface QuotationResponseTabProps { 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 @@ -58,38 +65,93 @@ export function QuotationResponseTab({ quotation }: QuotationResponseTabProps) { ) 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() + // // 초기 첨부파일 데이터 로드 + // 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 = quotation.status === "Draft" && !isDueDatePassed - const canEdit = ["Draft", "Revised"].includes(quotation.status) && !isDueDatePassed + 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 = [] - const handleSaveDraft = async () => { - setIsLoading(true) try { - const { updateTechSalesVendorQuotation } = await import("@/lib/techsales-rfq/service") - - const result = await updateTechSalesVendorQuotation({ - id: quotation.id, - currency, - totalPrice, - validUntil: validUntil!, - remark, - updatedBy: 1 // TODO: 실제 사용자 ID로 변경 - }) + 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 (result.error) { - toast.error(result.error) - } else { - toast.success("임시 저장되었습니다.") - // 페이지 새로고침 대신 router.refresh() 사용 - router.refresh() + if (!response.ok) throw new Error('파일 업로드 실패') + + const result = await response.json() + uploadedFiles.push({ + fileName: result.fileName, + filePath: result.url, + fileSize: attachment.fileSize + }) } - } catch { - toast.error("저장 중 오류가 발생했습니다.") + return uploadedFiles + } catch (error) { + console.error('파일 업로드 오류:', error) + toast.error('파일 업로드 중 오류가 발생했습니다.') + return [] } finally { - setIsLoading(false) + setIsUploadingFiles(false) } } @@ -101,6 +163,9 @@ export function QuotationResponseTab({ quotation }: QuotationResponseTabProps) { setIsLoading(true) try { + // 파일 업로드 먼저 처리 + const uploadedFiles = await uploadFiles() + const { submitTechSalesVendorQuotation } = await import("@/lib/techsales-rfq/service") const result = await submitTechSalesVendorQuotation({ @@ -109,6 +174,7 @@ export function QuotationResponseTab({ quotation }: QuotationResponseTabProps) { totalPrice, validUntil: validUntil!, remark, + attachments: uploadedFiles, updatedBy: 1 // TODO: 실제 사용자 ID로 변경 }) @@ -116,8 +182,10 @@ export function QuotationResponseTab({ quotation }: QuotationResponseTabProps) { toast.error(result.error) } else { toast.success("견적서가 제출되었습니다.") - // 페이지 새로고침 대신 router.refresh() 사용 - router.refresh() + // // 페이지 새로고침 대신 router.refresh() 사용 + // router.refresh() + // 페이지 새로고침 + window.location.reload() } } catch { toast.error("제출 중 오류가 발생했습니다.") @@ -312,28 +380,98 @@ export function QuotationResponseTab({ quotation }: QuotationResponseTabProps) { /> </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 && ( - <div className="flex gap-2 pt-4"> + {canEdit && canSubmit && ( + <div className="flex justify-center pt-4"> <Button - variant="outline" - onClick={handleSaveDraft} - disabled={isLoading} - className="flex-1" + onClick={handleSubmit} + disabled={isLoading || !totalPrice || !currency || !validUntil} + className="w-full " > - <Save className="mr-2 h-4 w-4" /> - 임시 저장 + <Send className="mr-2 h-4 w-4" /> + 견적서 제출 </Button> - {canSubmit && ( - <Button - onClick={handleSubmit} - disabled={isLoading || !totalPrice || !currency || !validUntil} - className="flex-1" - > - <Send className="mr-2 h-4 w-4" /> - 견적서 제출 - </Button> - )} </div> )} </CardContent> diff --git a/lib/techsales-rfq/vendor-response/quotation-editor.tsx b/lib/techsales-rfq/vendor-response/quotation-editor.tsx deleted file mode 100644 index 54058214..00000000 --- a/lib/techsales-rfq/vendor-response/quotation-editor.tsx +++ /dev/null @@ -1,559 +0,0 @@ -"use client" - -import * as React from "react" -import { useState, useEffect } from "react" -import { useForm } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import * as z from "zod" -import { toast } from "sonner" -import { useRouter } from "next/navigation" -import { CalendarIcon, Save, Send, ArrowLeft } from "lucide-react" - -import { Button } from "@/components/ui/button" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { Input } from "@/components/ui/input" -import { Textarea } from "@/components/ui/textarea" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import { Badge } from "@/components/ui/badge" -import { Separator } from "@/components/ui/separator" -import { DatePicker } from "@/components/ui/date-picker" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import { Skeleton } from "@/components/ui/skeleton" - -import { formatCurrency, formatDate } from "@/lib/utils" -import { - updateTechSalesVendorQuotation, - submitTechSalesVendorQuotation, - fetchCurrencies -} from "../service" - -// 견적서 폼 스키마 (techsales용 단순화) -const quotationFormSchema = z.object({ - currency: z.string().min(1, "통화를 선택해주세요"), - totalPrice: z.string().min(1, "총액을 입력해주세요"), - validUntil: z.date({ - required_error: "견적 유효기간을 선택해주세요", - invalid_type_error: "유효한 날짜를 선택해주세요", - }), - remark: z.string().optional(), -}) - -type QuotationFormValues = z.infer<typeof quotationFormSchema> - -// 통화 타입 -interface Currency { - code: string - name: string -} - -// 이 컴포넌트에 전달되는 견적서 데이터 타입 (techsales용 단순화) -interface TechSalesVendorQuotation { - id: number - rfqId: number - vendorId: number - quotationCode: string | null - quotationVersion: number | null - totalPrice: string | null - currency: string | null - validUntil: Date | null - status: "Draft" | "Submitted" | "Revised" | "Rejected" | "Accepted" - remark: string | null - rejectionReason: string | null - submittedAt: Date | null - acceptedAt: Date | null - createdAt: Date - updatedAt: Date - rfq: { - id: number - rfqCode: string | null - dueDate: Date | null - status: string | null - materialCode: string | null - remark: string | null - projectSnapshot?: { - pspid?: string - projNm?: string - sector?: string - projMsrm?: number - kunnr?: string - kunnrNm?: string - ptypeNm?: string - } | null - seriesSnapshot?: Array<{ - pspid: string - sersNo: string - scDt?: string - klDt?: string - lcDt?: string - dlDt?: string - dockNo?: string - dockNm?: string - projNo?: string - post1?: string - }> | null - item?: { - id: number - itemCode: string | null - itemList: string | null - } | null - biddingProject?: { - id: number - pspid: string | null - projNm: string | null - } | null - createdByUser?: { - id: number - name: string | null - email: string | null - } | null - } - vendor: { - id: number - vendorName: string - vendorCode: string | null - } -} - -interface TechSalesQuotationEditorProps { - quotation: TechSalesVendorQuotation -} - -export default function TechSalesQuotationEditor({ quotation }: TechSalesQuotationEditorProps) { - const router = useRouter() - const [isSubmitting, setIsSubmitting] = useState(false) - const [isSaving, setIsSaving] = useState(false) - const [currencies, setCurrencies] = useState<Currency[]>([]) - const [loadingCurrencies, setLoadingCurrencies] = useState(true) - - // 폼 초기화 - const form = useForm<QuotationFormValues>({ - resolver: zodResolver(quotationFormSchema), - defaultValues: { - currency: quotation.currency || "USD", - totalPrice: quotation.totalPrice || "", - validUntil: quotation.validUntil || undefined, - remark: quotation.remark || "", - }, - }) - - // 통화 목록 로드 - useEffect(() => { - const loadCurrencies = async () => { - try { - const { data, error } = await fetchCurrencies() - if (error) { - toast.error("통화 목록을 불러오는데 실패했습니다") - return - } - setCurrencies(data || []) - } catch (error) { - console.error("Error loading currencies:", error) - toast.error("통화 목록을 불러오는데 실패했습니다") - } finally { - setLoadingCurrencies(false) - } - } - - loadCurrencies() - }, []) - - // 마감일 확인 - const isBeforeDueDate = () => { - if (!quotation.rfq.dueDate) return true - return new Date() <= new Date(quotation.rfq.dueDate) - } - - // 편집 가능 여부 확인 - const isEditable = () => { - return quotation.status === "Draft" || quotation.status === "Rejected" - } - - // 제출 가능 여부 확인 - const isSubmitReady = () => { - const values = form.getValues() - return values.currency && - values.totalPrice && - parseFloat(values.totalPrice) > 0 && - values.validUntil && - isBeforeDueDate() - } - - // 저장 핸들러 - const handleSave = async () => { - if (!isEditable()) { - toast.error("편집할 수 없는 상태입니다") - return - } - - setIsSaving(true) - try { - const values = form.getValues() - const { data, error } = await updateTechSalesVendorQuotation({ - id: quotation.id, - currency: values.currency, - totalPrice: values.totalPrice, - validUntil: values.validUntil, - remark: values.remark, - updatedBy: quotation.vendorId, // 임시로 vendorId 사용 - }) - - if (error) { - toast.error(error) - return - } - - toast.success("견적서가 저장되었습니다") - router.refresh() - } catch (error) { - console.error("Error saving quotation:", error) - toast.error("견적서 저장 중 오류가 발생했습니다") - } finally { - setIsSaving(false) - } - } - - // 제출 핸들러 - const handleSubmit = async () => { - if (!isEditable()) { - toast.error("제출할 수 없는 상태입니다") - return - } - - if (!isSubmitReady()) { - toast.error("필수 항목을 모두 입력해주세요") - return - } - - if (!isBeforeDueDate()) { - toast.error("마감일이 지났습니다") - return - } - - setIsSubmitting(true) - try { - const values = form.getValues() - const { data, error } = await submitTechSalesVendorQuotation({ - id: quotation.id, - currency: values.currency, - totalPrice: values.totalPrice, - validUntil: values.validUntil, - remark: values.remark, - updatedBy: quotation.vendorId, // 임시로 vendorId 사용 - }) - - if (error) { - toast.error(error) - return - } - - toast.success("견적서가 제출되었습니다") - router.push("/ko/partners/techsales/rfq-ship") - } catch (error) { - console.error("Error submitting quotation:", error) - toast.error("견적서 제출 중 오류가 발생했습니다") - } finally { - setIsSubmitting(false) - } - } - - // 상태 배지 - const getStatusBadge = (status: string) => { - const statusConfig = { - "Draft": { label: "초안", variant: "secondary" as const }, - "Submitted": { label: "제출됨", variant: "default" as const }, - "Revised": { label: "수정됨", variant: "outline" as const }, - "Rejected": { label: "반려됨", variant: "destructive" as const }, - "Accepted": { label: "승인됨", variant: "success" as const }, - } - - const config = statusConfig[status as keyof typeof statusConfig] || { - label: status, - variant: "secondary" as const - } - - return <Badge variant={config.variant}>{config.label}</Badge> - } - - return ( - <div className="container max-w-4xl mx-auto py-6 space-y-6"> - {/* 헤더 */} - <div className="flex items-center justify-between"> - <div className="flex items-center space-x-4"> - <Button - variant="ghost" - size="sm" - onClick={() => router.back()} - > - <ArrowLeft className="h-4 w-4 mr-2" /> - 뒤로가기 - </Button> - <div> - <h1 className="text-2xl font-bold">기술영업 견적서</h1> - <p className="text-muted-foreground"> - RFQ: {quotation.rfq.rfqCode} | {getStatusBadge(quotation.status)} - </p> - </div> - </div> - <div className="flex items-center space-x-2"> - {isEditable() && ( - <> - <Button - variant="outline" - onClick={handleSave} - disabled={isSaving} - > - <Save className="h-4 w-4 mr-2" /> - {isSaving ? "저장 중..." : "저장"} - </Button> - <Button - onClick={handleSubmit} - disabled={isSubmitting || !isSubmitReady()} - > - <Send className="h-4 w-4 mr-2" /> - {isSubmitting ? "제출 중..." : "제출"} - </Button> - </> - )} - </div> - </div> - - <div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> - {/* 왼쪽: RFQ 정보 */} - <div className="lg:col-span-1 space-y-6"> - {/* RFQ 기본 정보 */} - <Card> - <CardHeader> - <CardTitle>RFQ 정보</CardTitle> - </CardHeader> - <CardContent className="space-y-4"> - <div> - <label className="text-sm font-medium text-muted-foreground">RFQ 번호</label> - <p className="font-mono">{quotation.rfq.rfqCode}</p> - </div> - <div> - <label className="text-sm font-medium text-muted-foreground">자재 그룹</label> - <p>{quotation.rfq.materialCode || "N/A"}</p> - </div> - <div> - <label className="text-sm font-medium text-muted-foreground">자재명</label> - <p>{quotation.rfq.item?.itemList || "N/A"}</p> - </div> - <div> - <label className="text-sm font-medium text-muted-foreground">마감일</label> - <p className={!isBeforeDueDate() ? "text-red-600 font-medium" : ""}> - {quotation.rfq.dueDate ? formatDate(quotation.rfq.dueDate) : "N/A"} - </p> - </div> - {quotation.rfq.remark && ( - <div> - <label className="text-sm font-medium text-muted-foreground">비고</label> - <p className="text-sm">{quotation.rfq.remark}</p> - </div> - )} - </CardContent> - </Card> - - {/* 프로젝트 정보 */} - {quotation.rfq.projectSnapshot && ( - <Card> - <CardHeader> - <CardTitle>프로젝트 정보</CardTitle> - </CardHeader> - <CardContent className="space-y-3"> - <div> - <label className="text-sm font-medium text-muted-foreground">프로젝트 번호</label> - <p className="font-mono">{quotation.rfq.projectSnapshot.pspid}</p> - </div> - <div> - <label className="text-sm font-medium text-muted-foreground">프로젝트명</label> - <p>{quotation.rfq.projectSnapshot.projNm || "N/A"}</p> - </div> - <div> - <label className="text-sm font-medium text-muted-foreground">선종</label> - <p>{quotation.rfq.projectSnapshot.ptypeNm || "N/A"}</p> - </div> - <div> - <label className="text-sm font-medium text-muted-foreground">척수</label> - <p>{quotation.rfq.projectSnapshot.projMsrm || "N/A"}</p> - </div> - <div> - <label className="text-sm font-medium text-muted-foreground">선주</label> - <p>{quotation.rfq.projectSnapshot.kunnrNm || "N/A"}</p> - </div> - </CardContent> - </Card> - )} - - {/* 시리즈 정보 */} - {quotation.rfq.seriesSnapshot && quotation.rfq.seriesSnapshot.length > 0 && ( - <Card> - <CardHeader> - <CardTitle>시리즈 일정</CardTitle> - </CardHeader> - <CardContent> - <div className="space-y-3"> - {quotation.rfq.seriesSnapshot.map((series, index) => ( - <div key={index} className="border rounded p-3"> - <div className="font-medium mb-2">시리즈 {series.sersNo}</div> - <div className="grid grid-cols-2 gap-2 text-sm"> - {series.klDt && ( - <div> - <span className="text-muted-foreground">K/L:</span> {formatDate(series.klDt)} - </div> - )} - {series.dlDt && ( - <div> - <span className="text-muted-foreground">인도:</span> {formatDate(series.dlDt)} - </div> - )} - </div> - </div> - ))} - </div> - </CardContent> - </Card> - )} - </div> - - {/* 오른쪽: 견적서 입력 폼 */} - <div className="lg:col-span-2"> - <Card> - <CardHeader> - <CardTitle>견적서 작성</CardTitle> - <CardDescription> - 총액 기반으로 견적을 작성해주세요. - </CardDescription> - </CardHeader> - <CardContent> - <Form {...form}> - <form className="space-y-6"> - {/* 통화 선택 */} - <FormField - control={form.control} - name="currency" - render={({ field }) => ( - <FormItem> - <FormLabel>통화 *</FormLabel> - <Select - onValueChange={field.onChange} - defaultValue={field.value} - disabled={!isEditable()} - > - <FormControl> - <SelectTrigger> - <SelectValue placeholder="통화를 선택하세요" /> - </SelectTrigger> - </FormControl> - <SelectContent> - {loadingCurrencies ? ( - <div className="p-2"> - <Skeleton className="h-4 w-full" /> - </div> - ) : ( - currencies.map((currency) => ( - <SelectItem key={currency.code} value={currency.code}> - {currency.code} - {currency.name} - </SelectItem> - )) - )} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - {/* 총액 입력 */} - <FormField - control={form.control} - name="totalPrice" - render={({ field }) => ( - <FormItem> - <FormLabel>총액 *</FormLabel> - <FormControl> - <Input - type="number" - step="0.01" - placeholder="총액을 입력하세요" - disabled={!isEditable()} - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 유효기간 */} - <FormField - control={form.control} - name="validUntil" - render={({ field }) => ( - <FormItem> - <FormLabel>견적 유효기간 *</FormLabel> - <FormControl> - <DatePicker - date={field.value} - onDateChange={field.onChange} - disabled={!isEditable()} - placeholder="유효기간을 선택하세요" - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 비고 */} - <FormField - control={form.control} - name="remark" - render={({ field }) => ( - <FormItem> - <FormLabel>비고</FormLabel> - <FormControl> - <Textarea - placeholder="추가 설명이나 특이사항을 입력하세요" - disabled={!isEditable()} - rows={4} - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 반려 사유 (반려된 경우에만 표시) */} - {quotation.status === "Rejected" && quotation.rejectionReason && ( - <div className="p-4 bg-red-50 border border-red-200 rounded-lg"> - <label className="text-sm font-medium text-red-800">반려 사유</label> - <p className="text-sm text-red-700 mt-1">{quotation.rejectionReason}</p> - </div> - )} - - {/* 제출 정보 */} - {quotation.submittedAt && ( - <div className="p-4 bg-blue-50 border border-blue-200 rounded-lg"> - <label className="text-sm font-medium text-blue-800">제출 정보</label> - <p className="text-sm text-blue-700 mt-1"> - 제출일: {formatDate(quotation.submittedAt)} - </p> - </div> - )} - </form> - </Form> - </CardContent> - </Card> - </div> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/lib/techsales-rfq/vendor-response/quotation-item-editor.tsx b/lib/techsales-rfq/vendor-response/quotation-item-editor.tsx deleted file mode 100644 index 92bec96a..00000000 --- a/lib/techsales-rfq/vendor-response/quotation-item-editor.tsx +++ /dev/null @@ -1,664 +0,0 @@ -"use client" - -import * as React from "react" -import { useState, useEffect, useRef } from "react" -import { toast } from "sonner" -import { format } from "date-fns" - -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Checkbox } from "@/components/ui/checkbox" -import { DatePicker } from "@/components/ui/date-picker" -import { - Table, - TableBody, - TableCaption, - TableCell, - TableHead, - TableHeader, - TableRow -} from "@/components/ui/table" -import { Badge } from "@/components/ui/badge" -import { ScrollArea } from "@/components/ui/scroll-area" -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger -} from "@/components/ui/tooltip" -import { - Info, - Clock, - CalendarIcon, - ClipboardCheck, - AlertTriangle, - CheckCircle2, - RefreshCw, - Save, - FileText, - Sparkles -} from "lucide-react" - -import { formatCurrency } from "@/lib/utils" -import { updateQuotationItem } from "../services" -import { Textarea } from "@/components/ui/textarea" - -// 견적 아이템 타입 -interface QuotationItem { - id: number - quotationId: number - prItemId: number - materialCode: string | null - materialDescription: string | null - quantity: number - uom: string | null - unitPrice: number - totalPrice: number - currency: string - vendorMaterialCode: string | null - vendorMaterialDescription: string | null - deliveryDate: Date | null - leadTimeInDays: number | null - taxRate: number | null - taxAmount: number | null - discountRate: number | null - discountAmount: number | null - remark: string | null - isAlternative: boolean - isRecommended: boolean // 남겨두지만 UI에서는 사용하지 않음 - createdAt: Date - updatedAt: Date - prItem?: { - id: number - materialCode: string | null - materialDescription: string | null - // 기타 필요한 정보 - } -} - -// debounce 함수 구현 -function debounce<T extends (...args: any[]) => any>( - func: T, - wait: number -): (...args: Parameters<T>) => void { - let timeout: NodeJS.Timeout | null = null; - - return function (...args: Parameters<T>) { - if (timeout) clearTimeout(timeout); - timeout = setTimeout(() => func(...args), wait); - }; -} - -interface QuotationItemEditorProps { - items: QuotationItem[] - onItemsChange: (items: QuotationItem[]) => void - disabled?: boolean - currency: string -} - -export function QuotationItemEditor({ - items, - onItemsChange, - disabled = false, - currency -}: QuotationItemEditorProps) { - const [editingItem, setEditingItem] = useState<number | null>(null) - const [isSaving, setIsSaving] = useState(false) - - // 저장이 필요한 항목들을 추적 - const [pendingChanges, setPendingChanges] = useState<Set<number>>(new Set()) - - // 로컬 상태 업데이트 함수 - 화면에 즉시 반영하지만 서버에는 즉시 저장하지 않음 - const updateLocalItem = <K extends keyof QuotationItem>( - index: number, - field: K, - value: QuotationItem[K] - ) => { - // 로컬 상태 업데이트 - const updatedItems = [...items] - const item = { ...updatedItems[index] } - - // 필드 업데이트 - item[field] = value - - // 대체품 체크 해제 시 관련 필드 초기화 - if (field === 'isAlternative' && value === false) { - item.vendorMaterialCode = null; - item.vendorMaterialDescription = null; - item.remark = null; - } - - // 단가나 수량이 변경되면 총액 계산 - if (field === 'unitPrice' || field === 'quantity') { - item.totalPrice = Number(item.unitPrice) * Number(item.quantity) - - // 세금이 있으면 세액 계산 - if (item.taxRate) { - item.taxAmount = item.totalPrice * (item.taxRate / 100) - } - - // 할인이 있으면 할인액 계산 - if (item.discountRate) { - item.discountAmount = item.totalPrice * (item.discountRate / 100) - } - } - - // 세율이 변경되면 세액 계산 - if (field === 'taxRate') { - item.taxAmount = item.totalPrice * (value as number / 100) - } - - // 할인율이 변경되면 할인액 계산 - if (field === 'discountRate') { - item.discountAmount = item.totalPrice * (value as number / 100) - } - - // 변경된 아이템으로 교체 - updatedItems[index] = item - - // 미저장 항목으로 표시 - setPendingChanges(prev => new Set(prev).add(item.id)) - - // 부모 컴포넌트에 변경 사항 알림 - onItemsChange(updatedItems) - - // 저장 필요함을 표시 - return item - } - - // 서버에 저장하는 함수 - const saveItemToServer = async (item: QuotationItem, field: keyof QuotationItem, value: any) => { - if (disabled) return - - try { - setIsSaving(true) - - const result = await updateQuotationItem({ - id: item.id, - [field]: value, - totalPrice: item.totalPrice, - taxAmount: item.taxAmount ?? 0, - discountAmount: item.discountAmount ?? 0 - }) - - // 저장 완료 후 pendingChanges에서 제거 - setPendingChanges(prev => { - const newSet = new Set(prev) - newSet.delete(item.id) - return newSet - }) - - if (!result.success) { - toast.error(result.message || "항목 저장 중 오류가 발생했습니다") - } - } catch (error) { - console.error("항목 저장 오류:", error) - toast.error("항목 저장 중 오류가 발생했습니다") - } finally { - setIsSaving(false) - } - } - - // debounce된 저장 함수 - const debouncedSave = useRef(debounce( - (item: QuotationItem, field: keyof QuotationItem, value: any) => { - saveItemToServer(item, field, value) - }, - 800 // 800ms 지연 - )).current - - // 견적 항목 업데이트 함수 - const handleItemUpdate = (index: number, field: keyof QuotationItem, value: any) => { - const updatedItem = updateLocalItem(index, field, value) - - // debounce를 통해 서버 저장 지연 - if (!disabled) { - debouncedSave(updatedItem, field, value) - } - } - - // 모든 변경 사항 저장 - const saveAllChanges = async () => { - if (disabled || pendingChanges.size === 0) return - - setIsSaving(true) - toast.info(`${pendingChanges.size}개 항목 저장 중...`) - - try { - // 변경된 모든 항목 저장 - for (const itemId of pendingChanges) { - const index = items.findIndex(item => item.id === itemId) - if (index !== -1) { - const item = items[index] - await updateQuotationItem({ - id: item.id, - unitPrice: item.unitPrice, - totalPrice: item.totalPrice, - taxRate: item.taxRate ?? 0, - taxAmount: item.taxAmount ?? 0, - discountRate: item.discountRate ?? 0, - discountAmount: item.discountAmount ?? 0, - deliveryDate: item.deliveryDate, - leadTimeInDays: item.leadTimeInDays ?? 0, - vendorMaterialCode: item.vendorMaterialCode ?? "", - vendorMaterialDescription: item.vendorMaterialDescription ?? "", - isAlternative: item.isAlternative, - isRecommended: false, // 항상 false로 설정 (사용하지 않음) - remark: item.remark ?? "" - }) - } - } - - // 모든 변경 사항 저장 완료 - setPendingChanges(new Set()) - toast.success("모든 변경 사항이 저장되었습니다") - } catch (error) { - console.error("변경 사항 저장 오류:", error) - toast.error("변경 사항 저장 중 오류가 발생했습니다") - } finally { - setIsSaving(false) - } - } - - // blur 이벤트로 저장 트리거 (사용자가 입력 완료 후) - const handleBlur = (index: number, field: keyof QuotationItem, value: any) => { - const itemId = items[index].id - - // 해당 항목이 pendingChanges에 있다면 즉시 저장 - if (pendingChanges.has(itemId)) { - const item = items[index] - saveItemToServer(item, field, value) - } - } - - // 전체 단가 업데이트 (일괄 반영) - const handleBulkUnitPriceUpdate = () => { - if (items.length === 0) return - - // 첫 번째 아이템의 단가 가져오기 - const firstUnitPrice = items[0].unitPrice - - if (!firstUnitPrice) { - toast.error("첫 번째 항목의 단가를 먼저 입력해주세요") - return - } - - // 모든 아이템에 동일한 단가 적용 - const updatedItems = items.map(item => ({ - ...item, - unitPrice: firstUnitPrice, - totalPrice: firstUnitPrice * item.quantity, - taxAmount: item.taxRate ? (firstUnitPrice * item.quantity) * (item.taxRate / 100) : item.taxAmount, - discountAmount: item.discountRate ? (firstUnitPrice * item.quantity) * (item.discountRate / 100) : item.discountAmount - })) - - // 모든 아이템을 변경 필요 항목으로 표시 - setPendingChanges(new Set(updatedItems.map(item => item.id))) - - // 부모 컴포넌트에 변경 사항 알림 - onItemsChange(updatedItems) - - toast.info("모든 항목의 단가가 업데이트되었습니다. 변경 사항을 저장하려면 '저장' 버튼을 클릭하세요.") - } - - // 입력 핸들러 - const handleNumberInputChange = ( - index: number, - field: keyof QuotationItem, - e: React.ChangeEvent<HTMLInputElement> - ) => { - const value = e.target.value === '' ? 0 : parseFloat(e.target.value) - handleItemUpdate(index, field, value) - } - - const handleTextInputChange = ( - index: number, - field: keyof QuotationItem, - e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement> - ) => { - handleItemUpdate(index, field, e.target.value) - } - - const handleDateChange = ( - index: number, - field: keyof QuotationItem, - date: Date | undefined - ) => { - handleItemUpdate(index, field, date || null) - } - - const handleCheckboxChange = ( - index: number, - field: keyof QuotationItem, - checked: boolean - ) => { - handleItemUpdate(index, field, checked) - } - - // 날짜 형식 지정 - const formatDeliveryDate = (date: Date | null) => { - if (!date) return "-" - return format(date, "yyyy-MM-dd") - } - - // 입력 폼 필드 렌더링 - const renderInputField = (item: QuotationItem, index: number, field: keyof QuotationItem) => { - if (field === 'unitPrice' || field === 'taxRate' || field === 'discountRate' || field === 'leadTimeInDays') { - return ( - <Input - type="number" - min={0} - step={field === 'unitPrice' ? 0.01 : field === 'taxRate' || field === 'discountRate' ? 0.1 : 1} - value={item[field] as number || 0} - onChange={(e) => handleNumberInputChange(index, field, e)} - onBlur={(e) => handleBlur(index, field, parseFloat(e.target.value) || 0)} - disabled={disabled || isSaving} - className="w-full" - /> - ) - } else if (field === 'vendorMaterialCode' || field === 'vendorMaterialDescription') { - return ( - <Input - type="text" - value={item[field] as string || ''} - onChange={(e) => handleTextInputChange(index, field, e)} - onBlur={(e) => handleBlur(index, field, e.target.value)} - disabled={disabled || isSaving || !item.isAlternative} - className="w-full" - placeholder={field === 'vendorMaterialCode' ? "벤더 자재그룹" : "벤더 자재명"} - /> - ) - } else if (field === 'deliveryDate') { - return ( - <DatePicker - date={item.deliveryDate ? new Date(item.deliveryDate) : undefined} - onSelect={(date) => { - handleDateChange(index, field, date); - // DatePicker는 blur 이벤트가 없으므로 즉시 저장 트리거 - if (date) handleBlur(index, field, date); - }} - disabled={disabled || isSaving} - /> - ) - } else if (field === 'isAlternative') { - return ( - <div className="flex items-center gap-1"> - <Checkbox - checked={item.isAlternative} - onCheckedChange={(checked) => { - handleCheckboxChange(index, field, checked as boolean); - handleBlur(index, field, checked as boolean); - }} - disabled={disabled || isSaving} - /> - <span className="text-xs">대체품</span> - </div> - ) - } - - return null - } - - // 대체품 필드 렌더링 - const renderAlternativeFields = (item: QuotationItem, index: number) => { - if (!item.isAlternative) return null; - - return ( - <div className="mt-2 p-3 bg-blue-50 rounded-md space-y-2 text-sm"> - {/* <div className="flex flex-col gap-2"> - <label className="text-xs font-medium text-blue-700">벤더 자재그룹</label> - <Input - value={item.vendorMaterialCode || ""} - onChange={(e) => handleTextInputChange(index, 'vendorMaterialCode', e)} - onBlur={(e) => handleBlur(index, 'vendorMaterialCode', e.target.value)} - disabled={disabled || isSaving} - className="h-8 text-sm" - placeholder="벤더 자재그룹 입력" - /> - </div> */} - - <div className="flex flex-col gap-2"> - <label className="text-xs font-medium text-blue-700">벤더 자재명</label> - <Input - value={item.vendorMaterialDescription || ""} - onChange={(e) => handleTextInputChange(index, 'vendorMaterialDescription', e)} - onBlur={(e) => handleBlur(index, 'vendorMaterialDescription', e.target.value)} - disabled={disabled || isSaving} - className="h-8 text-sm" - placeholder="벤더 자재명 입력" - /> - </div> - - <div className="flex flex-col gap-2"> - <label className="text-xs font-medium text-blue-700">대체품 설명</label> - <Textarea - value={item.remark || ""} - onChange={(e) => handleTextInputChange(index, 'remark', e)} - onBlur={(e) => handleBlur(index, 'remark', e.target.value)} - disabled={disabled || isSaving} - className="min-h-[60px] text-sm" - placeholder="원본과의 차이점, 대체 사유, 장점 등을 설명해주세요" - /> - </div> - </div> - ); - }; - - // 항목의 저장 상태 아이콘 표시 - const renderSaveStatus = (itemId: number) => { - if (pendingChanges.has(itemId)) { - return ( - <TooltipProvider> - <Tooltip> - <TooltipTrigger> - <RefreshCw className="h-4 w-4 text-yellow-500 animate-spin" /> - </TooltipTrigger> - <TooltipContent> - <p>저장되지 않은 변경 사항이 있습니다</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - ) - } - - return null - } - - return ( - <div className="space-y-4"> - <div className="flex justify-between items-center"> - <div className="flex items-center gap-2"> - <h3 className="text-lg font-medium">항목 목록 ({items.length}개)</h3> - {pendingChanges.size > 0 && ( - <Badge variant="outline" className="bg-yellow-50"> - 변경 {pendingChanges.size}개 - </Badge> - )} - </div> - - <div className="flex items-center gap-2"> - {pendingChanges.size > 0 && !disabled && ( - <Button - variant="default" - size="sm" - onClick={saveAllChanges} - disabled={isSaving} - > - {isSaving ? ( - <RefreshCw className="h-4 w-4 mr-2 animate-spin" /> - ) : ( - <Save className="h-4 w-4 mr-2" /> - )} - 변경사항 저장 ({pendingChanges.size}개) - </Button> - )} - - {!disabled && ( - <Button - variant="outline" - size="sm" - onClick={handleBulkUnitPriceUpdate} - disabled={items.length === 0 || isSaving} - > - 첫 항목 단가로 일괄 적용 - </Button> - )} - </div> - </div> - - <ScrollArea className="h-[500px] rounded-md border"> - <Table> - <TableHeader className="sticky top-0 bg-background"> - <TableRow> - <TableHead className="w-[50px]">번호</TableHead> - <TableHead>자재그룹</TableHead> - <TableHead>자재명</TableHead> - <TableHead>수량</TableHead> - <TableHead>단위</TableHead> - <TableHead>단가</TableHead> - <TableHead>금액</TableHead> - <TableHead> - <div className="flex items-center gap-1"> - 세율(%) - <TooltipProvider> - <Tooltip> - <TooltipTrigger> - <Info className="h-4 w-4" /> - </TooltipTrigger> - <TooltipContent> - <p>세율을 입력하면 자동으로 세액이 계산됩니다.</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - </div> - </TableHead> - <TableHead> - <div className="flex items-center gap-1"> - 납품일 - <TooltipProvider> - <Tooltip> - <TooltipTrigger> - <Info className="h-4 w-4" /> - </TooltipTrigger> - <TooltipContent> - <p>납품 가능한 날짜를 선택해주세요.</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - </div> - </TableHead> - <TableHead>리드타임(일)</TableHead> - <TableHead> - <div className="flex items-center gap-1"> - 대체품 - <TooltipProvider> - <Tooltip> - <TooltipTrigger> - <Info className="h-4 w-4" /> - </TooltipTrigger> - <TooltipContent> - <p>요청된 제품의 대체품을 제안할 경우 선택하세요.</p> - <p>대체품을 선택하면 추가 정보를 입력할 수 있습니다.</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - </div> - </TableHead> - <TableHead className="w-[50px]">상태</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {items.length === 0 ? ( - <TableRow> - <TableCell colSpan={12} className="text-center py-10"> - 견적 항목이 없습니다 - </TableCell> - </TableRow> - ) : ( - items.map((item, index) => ( - <React.Fragment key={item.id}> - <TableRow className={pendingChanges.has(item.id) ? "bg-yellow-50/30" : ""}> - <TableCell> - {index + 1} - </TableCell> - <TableCell> - {item.materialCode || "-"} - </TableCell> - <TableCell> - <div className="font-medium max-w-xs truncate"> - {item.materialDescription || "-"} - </div> - </TableCell> - <TableCell> - {item.quantity} - </TableCell> - <TableCell> - {item.uom || "-"} - </TableCell> - <TableCell> - {renderInputField(item, index, 'unitPrice')} - </TableCell> - <TableCell> - {formatCurrency(item.totalPrice, currency)} - </TableCell> - <TableCell> - {renderInputField(item, index, 'taxRate')} - </TableCell> - <TableCell> - {renderInputField(item, index, 'deliveryDate')} - </TableCell> - <TableCell> - {renderInputField(item, index, 'leadTimeInDays')} - </TableCell> - <TableCell> - {renderInputField(item, index, 'isAlternative')} - </TableCell> - <TableCell> - {renderSaveStatus(item.id)} - </TableCell> - </TableRow> - - {/* 대체품으로 선택된 경우 추가 정보 행 표시 */} - {item.isAlternative && ( - <TableRow className={pendingChanges.has(item.id) ? "bg-blue-50/40" : "bg-blue-50/20"}> - <TableCell colSpan={1}></TableCell> - <TableCell colSpan={10}> - {renderAlternativeFields(item, index)} - </TableCell> - <TableCell colSpan={1}></TableCell> - </TableRow> - )} - </React.Fragment> - )) - )} - </TableBody> - </Table> - </ScrollArea> - - {isSaving && ( - <div className="flex items-center justify-center text-sm text-muted-foreground"> - <Clock className="h-4 w-4 animate-spin mr-2" /> - 변경 사항을 저장 중입니다... - </div> - )} - - <div className="bg-muted p-4 rounded-md"> - <h4 className="text-sm font-medium mb-2">안내 사항</h4> - <ul className="text-sm space-y-1 text-muted-foreground"> - <li className="flex items-start gap-2"> - <AlertTriangle className="h-4 w-4 mt-0.5 flex-shrink-0" /> - <span>단가와 납품일은 필수로 입력해야 합니다.</span> - </li> - <li className="flex items-start gap-2"> - <ClipboardCheck className="h-4 w-4 mt-0.5 flex-shrink-0" /> - <span>입력 후 다른 필드로 이동하면 자동으로 저장됩니다. 여러 항목을 변경한 후 '저장' 버튼을 사용할 수도 있습니다.</span> - </li> - <li className="flex items-start gap-2"> - <FileText className="h-4 w-4 mt-0.5 flex-shrink-0" /> - <span><strong>대체품</strong>으로 제안하는 경우 자재명, 대체품 설명을 입력해주세요.</span> - </li> - </ul> - </div> - </div> - ) -}
\ 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 b89f8953..39de94ed 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 @@ -30,7 +30,6 @@ interface QuotationWithRfqCode extends TechSalesVendorQuotations { // 아이템 정보 itemName?: string; - itemCount?: number; // 프로젝트 정보 @@ -38,6 +37,9 @@ interface QuotationWithRfqCode extends TechSalesVendorQuotations { pspid?: string; sector?: string; + // RFQ 정보 + description?: string; + // 벤더 정보 vendorName?: string; vendorCode?: string; @@ -194,6 +196,33 @@ export function getColumns({ router, openAttachmentsSheet, openItemsDialog }: Ge // 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="프로젝트명" /> @@ -313,7 +342,6 @@ export function getColumns({ router, openAttachmentsSheet, openItemsDialog }: Ge cell: ({ row }) => { const quotation = row.original const attachmentCount = quotation.attachmentCount || 0 - const handleClick = () => { openAttachmentsSheet(quotation.rfqId) } 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 5e5d4f39..4c5cdf8e 100644 --- a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx +++ b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx @@ -38,12 +38,15 @@ interface QuotationWithRfqCode extends TechSalesVendorQuotations { rfqStatus?: string; itemName?: string | null; projNm?: string | null; - quotationCode?: string | null; - - rejectionReason?: string | null; - acceptedAt?: Date | 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 { @@ -380,7 +383,7 @@ export function VendorQuotationsTable({ vendorId, rfqType }: VendorQuotationsTab // useDataTable 훅 사용 const { table } = useDataTable({ data: stableData, - columns, + columns: columns as any, pageCount, rowCount: total, filterFields, @@ -391,7 +394,7 @@ export function VendorQuotationsTable({ vendorId, rfqType }: VendorQuotationsTab enableRowSelection: true, // 행 선택 활성화 initialState: { sorting: initialSettings.sort, - columnPinning: { right: ["actions"] }, + columnPinning: { right: ["actions", "items", "attachments"] }, }, getRowId: (originalRow) => String(originalRow.id), shallow: false, @@ -417,13 +420,6 @@ export function VendorQuotationsTable({ vendorId, rfqType }: VendorQuotationsTab <div className="w-full"> <div className="overflow-x-auto"> <div className="relative"> - {/* 로딩 오버레이 (재로딩 시) */} - {/* {!isInitialLoad && isLoading && ( - <div className="absolute h-full w-full inset-0 bg-background/90 backdrop-blur-md z-10 flex items-center justify-center"> - <CenterLoadingIndicator /> - </div> - )} */} - <DataTable table={table} className="min-w-full" |
