summaryrefslogtreecommitdiff
path: root/lib/procurement-rfqs/table/detail-table/vendor-quotation-comparison-dialog.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/procurement-rfqs/table/detail-table/vendor-quotation-comparison-dialog.tsx')
-rw-r--r--lib/procurement-rfqs/table/detail-table/vendor-quotation-comparison-dialog.tsx665
1 files changed, 665 insertions, 0 deletions
diff --git a/lib/procurement-rfqs/table/detail-table/vendor-quotation-comparison-dialog.tsx b/lib/procurement-rfqs/table/detail-table/vendor-quotation-comparison-dialog.tsx
new file mode 100644
index 00000000..72cf187c
--- /dev/null
+++ b/lib/procurement-rfqs/table/detail-table/vendor-quotation-comparison-dialog.tsx
@@ -0,0 +1,665 @@
+"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"
+
+// Lucide 아이콘
+import { Plus, Minus } from "lucide-react"
+
+import { ProcurementRfqsView } from "@/db/schema"
+import { fetchVendorQuotations, fetchQuotationItems } from "@/lib/procurement-rfqs/services"
+import { formatCurrency, formatDate } from "@/lib/utils"
+
+// 견적 정보 타입
+interface VendorQuotation {
+ id: number
+ rfqId: number
+ vendorId: number
+ vendorName?: string | null
+ quotationCode: string
+ quotationVersion: number
+ totalItemsCount: number
+ subTotal: string
+ taxTotal: string
+ discountTotal: string
+ totalPrice: string
+ currency: string
+ validUntil: string | Date // 수정: string | Date 허용
+ estimatedDeliveryDate: string | Date // 수정: string | Date 허용
+ paymentTermsCode: string
+ paymentTermsDescription?: string | null
+ incotermsCode: string
+ incotermsDescription?: string | null
+ incotermsDetail: string
+ status: string
+ remark: string
+ rejectionReason: string
+ submittedAt: string | Date // 수정: string | Date 허용
+ acceptedAt: string | Date // 수정: string | Date 허용
+ createdAt: string | Date // 수정: string | Date 허용
+ updatedAt: string | Date // 수정: string | Date 허용
+}
+
+// 견적 아이템 타입
+interface QuotationItem {
+ id: number
+ quotationId: number
+ prItemId: number
+ materialCode: string | null // Changed from string to string | null
+ materialDescription: string | null // Changed from string to string | null
+ quantity: string
+ uom: string | null // Changed assuming this might be null
+ unitPrice: string
+ totalPrice: string
+ currency: string | null // Changed from string to string | null
+ vendorMaterialCode: string | null // Changed from string to string | null
+ vendorMaterialDescription: string | null // Changed from string to string | null
+ deliveryDate: Date | null // Changed from string to string | null
+ leadTimeInDays: number | null // Changed from number to number | null
+ taxRate: string | null // Changed from string to string | null
+ taxAmount: string | null // Changed from string to string | null
+ discountRate: string | null // Changed from string to string | null
+ discountAmount: string | null // Changed from string to string | null
+ remark: string | null // Changed from string to string | null
+ isAlternative: boolean | null // Changed from boolean to boolean | null
+ isRecommended: boolean | null // Changed from boolean to boolean | null
+}
+
+interface VendorQuotationComparisonDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ selectedRfq: ProcurementRfqsView | null
+}
+
+export function VendorQuotationComparisonDialog({
+ open,
+ onOpenChange,
+ selectedRfq,
+}: VendorQuotationComparisonDialogProps) {
+ const [isLoading, setIsLoading] = useState(false)
+ const [quotations, setQuotations] = useState<VendorQuotation[]>([])
+ const [quotationItems, setQuotationItems] = useState<Record<number, QuotationItem[]>>({})
+ const [activeTab, setActiveTab] = useState("summary")
+
+ // 벤더별 접힘 상태 (true=접힘, false=펼침), 기본값: 접힘
+ const [collapsedVendors, setCollapsedVendors] = useState<Record<number, boolean>>({})
+
+ useEffect(() => {
+ async function loadQuotationData() {
+ if (!open || !selectedRfq?.id) return
+
+ try {
+ setIsLoading(true)
+ // 1) 견적 목록
+ const quotationsResult = await fetchVendorQuotations(selectedRfq.id)
+ const rawQuotationsData = quotationsResult.data || []
+
+ const quotationsData = rawQuotationsData.map((rawData): VendorQuotation => ({
+ id: rawData.id,
+ rfqId: rawData.rfqId,
+ vendorId: rawData.vendorId,
+ vendorName: rawData.vendorName || null,
+ quotationCode: rawData.quotationCode || '',
+ quotationVersion: rawData.quotationVersion || 0,
+ totalItemsCount: rawData.totalItemsCount || 0,
+ subTotal: rawData.subTotal || '0',
+ taxTotal: rawData.taxTotal || '0',
+ discountTotal: rawData.discountTotal || '0',
+ totalPrice: rawData.totalPrice || '0',
+ currency: rawData.currency || 'KRW',
+ validUntil: rawData.validUntil || '',
+ estimatedDeliveryDate: rawData.estimatedDeliveryDate || '',
+ paymentTermsCode: rawData.paymentTermsCode || '',
+ paymentTermsDescription: rawData.paymentTermsDescription || null,
+ incotermsCode: rawData.incotermsCode || '',
+ incotermsDescription: rawData.incotermsDescription || null,
+ incotermsDetail: rawData.incotermsDetail || '',
+ status: rawData.status || '',
+ remark: rawData.remark || '',
+ rejectionReason: rawData.rejectionReason || '',
+ submittedAt: rawData.submittedAt || '',
+ acceptedAt: rawData.acceptedAt || '',
+ createdAt: rawData.createdAt || '',
+ updatedAt: rawData.updatedAt || '',
+ }));
+
+ setQuotations(quotationsData);
+
+ // 벤더별로 접힘 상태 기본값(true) 설정
+ const collapsedInit: Record<number, boolean> = {}
+ quotationsData.forEach((q) => {
+ collapsedInit[q.id] = true
+ })
+ setCollapsedVendors(collapsedInit)
+
+ // 2) 견적 아이템
+ const qIds = quotationsData.map((q) => q.id)
+ if (qIds.length > 0) {
+ const itemsResult = await fetchQuotationItems(qIds)
+ const itemsData = itemsResult.data || []
+
+ const itemsByQuotation: Record<number, QuotationItem[]> = {}
+ itemsData.forEach((item) => {
+ if (!itemsByQuotation[item.quotationId]) {
+ itemsByQuotation[item.quotationId] = []
+ }
+ itemsByQuotation[item.quotationId].push(item)
+ })
+ setQuotationItems(itemsByQuotation)
+ }
+ } 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"
+ }
+ }
+
+ // 모든 prItemId 모음
+ const allItemIds = React.useMemo(() => {
+ const itemSet = new Set<number>()
+ Object.values(quotationItems).forEach((items) => {
+ items.forEach((it) => itemSet.add(it.prItemId))
+ })
+ return Array.from(itemSet)
+ }, [quotationItems])
+
+ // 아이템 찾는 함수
+ const findItemByQuotationId = (prItemId: number, qid: number) => {
+ const items = quotationItems[qid] || []
+ return items.find((i) => i.prItemId === prItemId)
+ }
+
+ // 접힘 상태 토글
+ const toggleVendor = (qid: number) => {
+ setCollapsedVendors((prev) => ({
+ ...prev,
+ [qid]: !prev[qid],
+ }))
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ {/* 다이얼로그 자체는 max-h, max-w 설정, 내부에 스크롤 컨테이너를 둠 */}
+ <DialogContent className="max-w-[90vw] lg:max-w-5xl max-h-[90vh]" style={{ maxWidth: '90vw', maxHeight: '90vh' }}>
+ <DialogHeader>
+ <DialogTitle>벤더 견적 비교</DialogTitle>
+ <DialogDescription>
+ {selectedRfq
+ ? `RFQ ${selectedRfq.rfqCode} - ${selectedRfq.itemName || ""}`
+ : ""}
+ </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>
+ ) : (
+ <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
+ <TabsList className="grid w-full grid-cols-2">
+ <TabsTrigger value="summary">견적 요약 비교</TabsTrigger>
+ <TabsTrigger value="items">아이템별 비교</TabsTrigger>
+ </TabsList>
+
+ {/* ======================== 요약 비교 탭 ======================== */}
+ <TabsContent value="summary" className="mt-4">
+ {/*
+ table-fixed + 가로 너비를 크게 잡아줌 (예: w-[1200px])
+ -> 컨테이너보다 넓으면 수평 스크롤 발생.
+ */}
+ <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"
+ >
+ 항목
+ </TableHead>
+ {quotations.map((q) => (
+ <TableHead key={q.id} className="p-2 text-center whitespace-nowrap">
+ {q.vendorName || `벤더 ID: ${q.vendorId}`}
+ </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">
+ <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={`version-${q.id}`} className="p-2">
+ v{q.quotationVersion}
+ </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">
+ {formatCurrency(Number(q.totalPrice), 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={`subtotal-${q.id}`} className="p-2">
+ {formatCurrency(Number(q.subTotal), 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={`tax-${q.id}`} className="p-2">
+ {formatCurrency(Number(q.taxTotal), 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={`discount-${q.id}`} className="p-2">
+ {formatCurrency(Number(q.discountTotal), 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={`currency-${q.id}`} className="p-2">
+ {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">
+ {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={`delivery-${q.id}`} className="p-2">
+ {formatDate(q.estimatedDeliveryDate, "KR")}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 지불 조건 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 지불 조건
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`payment-${q.id}`} className="p-2">
+ {q.paymentTermsDescription || q.paymentTermsCode}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 인코텀즈 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 인코텀즈
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`incoterms-${q.id}`} className="p-2">
+ {q.incotermsDescription || q.incotermsCode}
+ {q.incotermsDetail && (
+ <div className="text-xs text-muted-foreground mt-1">
+ {q.incotermsDetail}
+ </div>
+ )}
+ </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">
+ {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"
+ >
+ {q.remark || "-"}
+ </TableCell>
+ ))}
+ </TableRow>
+ </tbody>
+ </table>
+ </div>
+ </TabsContent>
+
+ {/* ====================== 아이템별 비교 탭 ====================== */}
+ <TabsContent value="items" className="mt-4">
+ {/* 컨테이너에 테이블 관련 클래스 직접 적용 */}
+ <div className="border rounded-md max-h-[60vh] overflow-y-auto overflow-x-auto" >
+ <div className="min-w-full w-max" style={{ maxWidth: '70vw' }}>
+ <table className="w-full border-collapse">
+ <thead className="sticky top-0 bg-background z-10">
+ {/* 첫 번째 헤더 행 */}
+ <tr>
+ {/* 첫 행: 자재(코드) 컬럼 */}
+ <th
+ rowSpan={2}
+ className="sticky left-0 top-0 z-20 p-2 border border-gray-200 text-left"
+ style={{
+ width: '250px',
+ minWidth: '250px',
+ backgroundColor: 'white',
+ }}
+ >
+ 자재 (코드)
+ </th>
+
+ {/* 벤더 헤더 (접힘/펼침) */}
+ {quotations.map((q, index) => {
+ const collapsed = collapsedVendors[q.id]
+ // 접힌 상태면 1칸, 펼친 상태면 6칸
+ return (
+ <th
+ key={q.id}
+ className="p-2 text-center whitespace-nowrap border border-gray-200"
+ colSpan={collapsed ? 1 : 6}
+ style={{
+ borderRight: index < quotations.length - 1 ? '1px solid #e5e7eb' : '',
+ backgroundColor: 'white',
+ }}
+ >
+ {/* + / - 버튼 */}
+ <div className="flex items-center gap-2 justify-center">
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-7 w-7 p-1"
+ onClick={() => toggleVendor(q.id)}
+ >
+ {collapsed ? <Plus size={16} /> : <Minus size={16} />}
+ </Button>
+ <span>{q.vendorName || `벤더 ID: ${q.vendorId}`}</span>
+ </div>
+ </th>
+ )
+ })}
+ </tr>
+
+ {/* 두 번째 헤더 행 - 하위 컬럼들 */}
+ <tr className="border-b border-b-gray-200">
+ {/* 펼쳐진 벤더의 하위 컬럼들만 표시 */}
+ {quotations.flatMap((q, qIndex) => {
+ // 접힌 상태면 추가 헤더 없음
+ if (collapsedVendors[q.id]) {
+ return [
+ <th
+ key={`${q.id}-collapsed`}
+ className="p-2 text-center whitespace-nowrap border border-gray-200"
+ style={{ backgroundColor: 'white' }}
+ >
+ 총액
+ </th>
+ ];
+ }
+
+ // 펼친 상태면 6개 컬럼 표시
+ const columns = [
+ { key: 'unitprice', label: '단가' },
+ { key: 'totalprice', label: '총액' },
+ { key: 'tax', label: '세금' },
+ { key: 'discount', label: '할인' },
+ { key: 'leadtime', label: '리드타임' },
+ { key: 'alternative', label: '대체품' },
+ ];
+
+ return columns.map((col, colIndex) => {
+ const isFirstInGroup = colIndex === 0;
+ const isLastInGroup = colIndex === columns.length - 1;
+
+ return (
+ <th
+ key={`${q.id}-${col.key}`}
+ className={`p-2 text-center whitespace-nowrap border border-gray-200 ${
+ isFirstInGroup ? 'border-l border-l-gray-200' : ''
+ } ${
+ isLastInGroup ? 'border-r border-r-gray-200' : ''
+ }`}
+ style={{ backgroundColor: 'white' }}
+ >
+ {col.label}
+ </th>
+ );
+ });
+ })}
+ </tr>
+ </thead>
+
+ {/* 테이블 바디 */}
+ <tbody>
+ {allItemIds.map((itemId) => {
+ // 자재 기본 정보는 첫 번째 벤더 아이템 기준
+ const firstQid = quotations[0]?.id
+ const sampleItem = firstQid
+ ? findItemByQuotationId(itemId, firstQid)
+ : undefined
+
+ return (
+ <tr key={itemId} className="border-b border-gray-100">
+ {/* 자재 (코드) 셀 */}
+ <td
+ className="sticky left-0 z-10 p-2 align-top border-r border-gray-100"
+ style={{
+ width: '250px',
+ minWidth: '250px',
+ backgroundColor: 'white',
+ }}
+ >
+ {sampleItem?.materialDescription || sampleItem?.materialCode || ""}
+ {sampleItem && (
+ <div className="text-xs text-muted-foreground mt-1">
+ 코드: {sampleItem.materialCode} | 수량:{" "}
+ {sampleItem.quantity} {sampleItem.uom}
+ </div>
+ )}
+ </td>
+
+ {/* 벤더별 아이템 데이터 */}
+ {quotations.flatMap((q, qIndex) => {
+ const collapsed = collapsedVendors[q.id]
+ const itemData = findItemByQuotationId(itemId, q.id)
+
+ // 접힌 상태면 총액만 표시
+ if (collapsed) {
+ return [
+ <td
+ key={`${q.id}-collapsed`}
+ className="p-2 text-center text-sm font-medium whitespace-nowrap border-r border-gray-100"
+ >
+ {itemData
+ ? formatCurrency(Number(itemData.totalPrice), itemData.currency)
+ : "N/A"}
+ </td>
+ ];
+ }
+
+ // 펼친 상태 - 아이템 없음
+ if (!itemData) {
+ return [
+ <td
+ key={`${q.id}-empty`}
+ colSpan={6}
+ className="p-2 text-center text-sm border-r border-gray-100"
+ >
+ 없음
+ </td>
+ ];
+ }
+
+ // 펼친 상태 - 모든 컬럼 표시
+ const columns = [
+ { key: 'unitprice', render: () => formatCurrency(Number(itemData.unitPrice), itemData.currency), align: 'right' },
+ { key: 'totalprice', render: () => formatCurrency(Number(itemData.totalPrice), itemData.currency), align: 'right', bold: true },
+ { key: 'tax', render: () => itemData.taxRate ? `${itemData.taxRate}% (${formatCurrency(Number(itemData.taxAmount), itemData.currency)})` : "-", align: 'right' },
+ { key: 'discount', render: () => itemData.discountRate ? `${itemData.discountRate}% (${formatCurrency(Number(itemData.discountAmount), itemData.currency)})` : "-", align: 'right' },
+ { key: 'leadtime', render: () => itemData.leadTimeInDays ? `${itemData.leadTimeInDays}일` : "-", align: 'center' },
+ { key: 'alternative', render: () => itemData.isAlternative ? "대체품" : "표준품", align: 'center' },
+ ];
+
+ return columns.map((col, colIndex) => {
+ const isFirstInGroup = colIndex === 0;
+ const isLastInGroup = colIndex === columns.length - 1;
+
+ return (
+ <td
+ key={`${q.id}-${col.key}`}
+ className={`p-2 text-${col.align} ${col.bold ? 'font-semibold' : ''} ${
+ isFirstInGroup ? 'border-l border-l-gray-100' : ''
+ } ${
+ isLastInGroup ? 'border-r border-r-gray-100' : 'border-r border-gray-100'
+ }`}
+ >
+ {col.render()}
+ </td>
+ );
+ });
+ })}
+ </tr>
+ );
+ })}
+
+ {/* 아이템이 전혀 없는 경우 */}
+ {allItemIds.length === 0 && (
+ <tr>
+ <td
+ colSpan={100} // 충분히 큰 수로 설정해 모든 컬럼을 커버
+ className="text-center p-4 border border-gray-100"
+ >
+ 아이템 정보가 없습니다
+ </td>
+ </tr>
+ )}
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </TabsContent>
+ </Tabs>
+ )}
+
+ <DialogFooter>
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ 닫기
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}