diff options
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.tsx | 665 |
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> + ) +} |
