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, 0 insertions, 665 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 deleted file mode 100644 index 72cf187c..00000000 --- a/lib/procurement-rfqs/table/detail-table/vendor-quotation-comparison-dialog.tsx +++ /dev/null @@ -1,665 +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" - -// 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> - ) -} |
