diff options
Diffstat (limited to 'lib/rfq-last/vendor/rfq-vendor-table.tsx')
| -rw-r--r-- | lib/rfq-last/vendor/rfq-vendor-table.tsx | 746 |
1 files changed, 746 insertions, 0 deletions
diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx new file mode 100644 index 00000000..b6d42804 --- /dev/null +++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx @@ -0,0 +1,746 @@ +"use client"; + +import * as React from "react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Plus, + Send, + Eye, + Edit, + Trash2, + Building2, + Calendar, + DollarSign, + FileText, + RefreshCw, + Mail, + CheckCircle, + Clock, + XCircle, + AlertCircle, + Settings2, + ClipboardList, + Globe, + Package, + MapPin, + Info +} from "lucide-react"; +import { format } from "date-fns"; +import { ko } from "date-fns/locale"; +import { type ColumnDef } from "@tanstack/react-table"; +import { Checkbox } from "@/components/ui/checkbox"; +import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header"; +import { ClientDataTable } from "@/components/client-data-table/data-table"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import type { DataTableAdvancedFilterField } from "@/types/table"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; +import { AddVendorDialog } from "./add-vendor-dialog"; +import { BatchUpdateConditionsDialog } from "./batch-update-conditions-dialog"; +// import { VendorDetailDialog } from "./vendor-detail-dialog"; + +// 타입 정의 +interface RfqDetail { + detailId: number; + vendorId: number | null; + vendorName: string | null; + vendorCode: string | null; + vendorCountry: string | null; + vendorCategory?: string | null; // 업체분류 + vendorGrade?: string | null; // AVL 등급 + basicContract?: string | null; // 기본계약 + shortList: boolean; + currency: string | null; + paymentTermsCode: string | null; + paymentTermsDescription: string | null; + incotermsCode: string | null; + incotermsDescription: string | null; + incotermsDetail?: string | null; + deliveryDate: Date | null; + contractDuration: string | null; + taxCode: string | null; + placeOfShipping?: string | null; + placeOfDestination?: string | null; + materialPriceRelatedYn?: boolean | null; + sparepartYn?: boolean | null; + firstYn?: boolean | null; + firstDescription?: string | null; + sparepartDescription?: string | null; + updatedAt?: Date | null; + updatedByUserName?: string | null; +} + +interface VendorResponse { + id: number; + vendorId: number; + status: "초대됨" | "작성중" | "제출완료" | "수정요청" | "최종확정" | "취소"; + responseVersion: number; + isLatest: boolean; + submittedAt: Date | null; + totalAmount: number | null; + currency: string | null; + vendorDeliveryDate: Date | null; + quotedItemCount?: number; + attachmentCount?: number; +} + +interface RfqVendorTableProps { + rfqId: number; + rfqCode?: string; + rfqDetails: RfqDetail[]; + vendorResponses: VendorResponse[]; +} + +// 상태별 아이콘 반환 +const getStatusIcon = (status: string) => { + switch (status) { + case "초대됨": return <Mail className="h-4 w-4" />; + case "작성중": return <Clock className="h-4 w-4" />; + case "제출완료": return <CheckCircle className="h-4 w-4" />; + case "수정요청": return <AlertCircle className="h-4 w-4" />; + case "최종확정": return <FileText className="h-4 w-4" />; + case "취소": return <XCircle className="h-4 w-4" />; + default: return <Clock className="h-4 w-4" />; + } +}; + +// 상태별 색상 +const getStatusVariant = (status: string) => { + switch (status) { + case "초대됨": return "secondary"; + case "작성중": return "outline"; + case "제출완료": return "default"; + case "수정요청": return "warning"; + case "최종확정": return "success"; + case "취소": return "destructive"; + default: return "outline"; + } +}; + +// 데이터 병합 (rfqDetails + vendorResponses) +const mergeVendorData = ( + rfqDetails: RfqDetail[], + vendorResponses: VendorResponse[], + rfqCode?: string +): (RfqDetail & { response?: VendorResponse; rfqCode?: string })[] => { + return rfqDetails.map(detail => { + const response = vendorResponses.find( + r => r.vendorId === detail.vendorId && r.isLatest + ); + return { ...detail, response, rfqCode }; + }); +}; + +// 추가 조건 포맷팅 +const formatAdditionalConditions = (data: any) => { + const conditions = []; + if (data.firstYn) conditions.push("초도품"); + if (data.materialPriceRelatedYn) conditions.push("연동제"); + if (data.sparepartYn) conditions.push("스페어"); + return conditions.length > 0 ? conditions.join(", ") : "-"; +}; + +export function RfqVendorTable({ + rfqId, + rfqCode, + rfqDetails, + vendorResponses, +}: RfqVendorTableProps) { + const [isRefreshing, setIsRefreshing] = React.useState(false); + const [selectedRows, setSelectedRows] = React.useState<any[]>([]); + const [isAddDialogOpen, setIsAddDialogOpen] = React.useState(false); + const [isBatchUpdateOpen, setIsBatchUpdateOpen] = React.useState(false); + const [selectedVendor, setSelectedVendor] = React.useState<any | null>(null); + + // 데이터 병합 + const mergedData = React.useMemo( + () => mergeVendorData(rfqDetails, vendorResponses, rfqCode), + [rfqDetails, vendorResponses, rfqCode] + ); + + // 액션 처리 + const handleAction = React.useCallback(async (action: string, vendor: any) => { + switch (action) { + case "view": + setSelectedVendor(vendor); + break; + + case "send": + // RFQ 발송 로직 + toast.info(`${vendor.vendorName}에게 RFQ를 발송합니다.`); + break; + + case "edit": + // 수정 로직 + toast.info("수정 기능은 준비중입니다."); + break; + + case "delete": + // 삭제 로직 + if (confirm(`${vendor.vendorName}을(를) 삭제하시겠습니까?`)) { + toast.success(`${vendor.vendorName}이(가) 삭제되었습니다.`); + } + break; + + case "response-detail": + // 회신 상세 보기 + toast.info(`${vendor.vendorName}의 회신 상세를 확인합니다.`); + break; + } + }, []); + + // 선택된 벤더들에게 일괄 발송 + const handleBulkSend = React.useCallback(async () => { + if (selectedRows.length === 0) { + toast.warning("발송할 벤더를 선택해주세요."); + return; + } + + const vendorNames = selectedRows.map(r => r.vendorName).join(", "); + if (confirm(`선택한 ${selectedRows.length}개 벤더에게 RFQ를 발송하시겠습니까?\n\n${vendorNames}`)) { + toast.success(`${selectedRows.length}개 벤더에게 RFQ를 발송했습니다.`); + setSelectedRows([]); + } + }, [selectedRows]); + + + // 컬럼 정의 (확장된 버전) + const columns: ColumnDef<any>[] = React.useMemo(() => [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")} + onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)} + aria-label="select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(v) => row.toggleSelected(!!v)} + aria-label="select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "rfqCode", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="ITB/RFQ/견적 No." />, + cell: ({ row }) => { + return ( + <span className="font-mono text-xs">{row.original.rfqCode || "-"}</span> + ); + }, + size: 120, + }, + // { + // accessorKey: "response.responseVersion", + // header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="Rev" />, + // cell: ({ row }) => { + // const version = row.original.response?.responseVersion; + // return version ? ( + // <Badge variant="outline" className="font-mono">v{version}</Badge> + // ) : ( + // <span className="text-muted-foreground">-</span> + // ); + // }, + // size: 60, + // }, + { + accessorKey: "vendorName", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="협력업체정보" />, + cell: ({ row }) => { + const vendor = row.original; + return ( + <div className="flex items-center gap-2"> + <Building2 className="h-4 w-4 text-muted-foreground" /> + <div className="flex flex-col"> + <span className="text-sm font-medium">{vendor.vendorName || "-"}</span> + <span className="text-xs text-muted-foreground">{vendor.vendorCode}</span> + </div> + </div> + ); + }, + size: 180, + }, + { + accessorKey: "vendorCategory", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="업체분류" />, + cell: ({ row }) => row.original.vendorCategory || "-", + size: 100, + }, + { + accessorKey: "vendorCountry", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="내외자 (위치)" />, + cell: ({ row }) => { + const country = row.original.vendorCountry; + const isLocal = country === "KR" || country === "한국"; + return ( + <Badge variant={isLocal ? "default" : "secondary"}> + {country || "-"} + </Badge> + ); + }, + size: 100, + }, + { + accessorKey: "vendorGrade", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="AVL 정보 (등급)" />, + cell: ({ row }) => { + const grade = row.original.vendorGrade; + if (!grade) return <span className="text-muted-foreground">-</span>; + + const gradeColor = { + "A": "text-green-600", + "B": "text-blue-600", + "C": "text-yellow-600", + "D": "text-red-600", + }[grade] || "text-gray-600"; + + return <span className={cn("font-semibold", gradeColor)}>{grade}</span>; + }, + size: 100, + }, + { + accessorKey: "basicContract", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="기본계약" />, + cell: ({ row }) => row.original.basicContract || "-", + size: 100, + }, + { + accessorKey: "currency", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="요청 통화" />, + cell: ({ row }) => { + const currency = row.original.currency; + return currency ? ( + <Badge variant="outline">{currency}</Badge> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 80, + }, + { + accessorKey: "paymentTermsCode", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="지급조건" />, + cell: ({ row }) => { + const code = row.original.paymentTermsCode; + const desc = row.original.paymentTermsDescription; + return ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <span className="text-sm">{code || "-"}</span> + </TooltipTrigger> + {desc && ( + <TooltipContent> + <p>{desc}</p> + </TooltipContent> + )} + </Tooltip> + </TooltipProvider> + ); + }, + size: 100, + }, + { + accessorKey: "taxCode", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="Tax" />, + cell: ({ row }) => row.original.taxCode || "-", + size: 60, + }, + { + accessorKey: "deliveryDate", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="계약납기일/기간" />, + cell: ({ row }) => { + const deliveryDate = row.original.deliveryDate; + const contractDuration = row.original.contractDuration; + + return ( + <div className="flex flex-col gap-0.5"> + {deliveryDate && ( + <span className="text-xs"> + {format(new Date(deliveryDate), "yyyy-MM-dd")} + </span> + )} + {contractDuration && ( + <span className="text-xs text-muted-foreground">{contractDuration}</span> + )} + {!deliveryDate && !contractDuration && ( + <span className="text-muted-foreground">-</span> + )} + </div> + ); + }, + size: 120, + }, + { + accessorKey: "incotermsCode", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="Incoterms" />, + cell: ({ row }) => { + const code = row.original.incotermsCode; + const detail = row.original.incotermsDetail; + + return ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <div className="flex items-center gap-1"> + <Globe className="h-3 w-3 text-muted-foreground" /> + <span className="text-sm">{code || "-"}</span> + </div> + </TooltipTrigger> + {detail && ( + <TooltipContent> + <p>{detail}</p> + </TooltipContent> + )} + </Tooltip> + </TooltipProvider> + ); + }, + size: 100, + }, + { + accessorKey: "placeOfShipping", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="선적지" />, + cell: ({ row }) => { + const place = row.original.placeOfShipping; + return place ? ( + <div className="flex items-center gap-1"> + <MapPin className="h-3 w-3 text-muted-foreground" /> + <span className="text-xs">{place}</span> + </div> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 100, + }, + { + accessorKey: "placeOfDestination", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="도착지" />, + cell: ({ row }) => { + const place = row.original.placeOfDestination; + return place ? ( + <div className="flex items-center gap-1"> + <Package className="h-3 w-3 text-muted-foreground" /> + <span className="text-xs">{place}</span> + </div> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 100, + }, + { + id: "additionalConditions", + header: "추가조건", + cell: ({ row }) => { + const conditions = formatAdditionalConditions(row.original); + if (conditions === "-") { + return <span className="text-muted-foreground">-</span>; + } + + const items = conditions.split(", "); + return ( + <div className="flex flex-wrap gap-1"> + {items.map((item, idx) => ( + <Badge key={idx} variant="outline" className="text-xs"> + {item} + </Badge> + ))} + </div> + ); + }, + size: 120, + }, + { + accessorKey: "response.submittedAt", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="참여여부 (회신일)" />, + cell: ({ row }) => { + const submittedAt = row.original.response?.submittedAt; + const status = row.original.response?.status; + + if (!submittedAt) { + return <Badge variant="outline">미참여</Badge>; + } + + return ( + <div className="flex flex-col gap-0.5"> + <Badge variant="default" className="text-xs">참여</Badge> + <span className="text-xs text-muted-foreground"> + {format(new Date(submittedAt), "MM-dd")} + </span> + </div> + ); + }, + size: 100, + }, + { + id: "responseDetail", + header: "회신상세", + cell: ({ row }) => { + const hasResponse = !!row.original.response?.submittedAt; + + if (!hasResponse) { + return <span className="text-muted-foreground text-xs">-</span>; + } + + return ( + <Button + variant="ghost" + size="sm" + onClick={() => handleAction("response-detail", row.original)} + className="h-7 px-2" + > + <Eye className="h-3 w-3 mr-1" /> + 상세 + </Button> + ); + }, + size: 80, + }, + { + accessorKey: "shortList", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="Short List" />, + cell: ({ row }) => ( + row.original.shortList ? ( + <Badge variant="default">선정</Badge> + ) : ( + <Badge variant="outline">대기</Badge> + ) + ), + size: 80, + }, + { + accessorKey: "updatedAt", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="최신수정일" />, + cell: ({ row }) => { + const date = row.original.updatedAt; + return date ? ( + <span className="text-xs text-muted-foreground"> + {format(new Date(date), "MM-dd HH:mm")} + </span> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 100, + }, + { + accessorKey: "updatedByUserName", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="최신수정자" />, + cell: ({ row }) => { + const name = row.original.updatedByUserName; + return name ? ( + <span className="text-xs">{name}</span> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 100, + }, + { + id: "actions", + header: "작업", + cell: ({ row }) => { + const vendor = row.original; + const hasResponse = !!vendor.response; + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" className="h-8 w-8 p-0"> + <span className="sr-only">메뉴 열기</span> + <svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M3.625 7.5C3.625 8.12132 3.12132 8.625 2.5 8.625C1.87868 8.625 1.375 8.12132 1.375 7.5C1.375 6.87868 1.87868 6.375 2.5 6.375C3.12132 6.375 3.625 6.87868 3.625 7.5ZM8.625 7.5C8.625 8.12132 8.12132 8.625 7.5 8.625C6.87868 8.625 6.375 8.12132 6.375 7.5C6.375 6.87868 6.87868 6.375 7.5 6.375C8.12132 6.375 8.625 6.87868 8.625 7.5ZM12.5 8.625C13.1213 8.625 13.625 8.12132 13.625 7.5C13.625 6.87868 13.1213 6.375 12.5 6.375C11.8787 6.375 11.375 6.87868 11.375 7.5C11.375 8.12132 11.8787 8.625 12.5 8.625Z" fill="currentColor" fillRule="evenodd" clipRule="evenodd"></path> + </svg> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem onClick={() => handleAction("view", vendor)}> + <Eye className="mr-2 h-4 w-4" /> + 상세보기 + </DropdownMenuItem> + {!hasResponse && ( + <DropdownMenuItem onClick={() => handleAction("send", vendor)}> + <Send className="mr-2 h-4 w-4" /> + RFQ 발송 + </DropdownMenuItem> + )} + <DropdownMenuItem onClick={() => handleAction("edit", vendor)}> + <Edit className="mr-2 h-4 w-4" /> + 조건 수정 + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem + onClick={() => handleAction("delete", vendor)} + className="text-red-600" + > + <Trash2 className="mr-2 h-4 w-4" /> + 삭제 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ); + }, + size: 60, + }, + ], [handleAction]); + + const advancedFilterFields: DataTableAdvancedFilterField<any>[] = [ + { id: "vendorName", label: "벤더명", type: "text" }, + { id: "vendorCode", label: "벤더코드", type: "text" }, + { id: "vendorCountry", label: "국가", type: "text" }, + { + id: "response.status", + label: "응답 상태", + type: "select", + options: [ + { label: "초대됨", value: "초대됨" }, + { label: "작성중", value: "작성중" }, + { label: "제출완료", value: "제출완료" }, + { label: "수정요청", value: "수정요청" }, + { label: "최종확정", value: "최종확정" }, + { label: "취소", value: "취소" }, + ] + }, + { + id: "shortList", + label: "Short List", + type: "select", + options: [ + { label: "선정", value: "true" }, + { label: "대기", value: "false" }, + ] + }, + ]; + + // 선택된 벤더 정보 (BatchUpdate용) + const selectedVendorsForBatch = React.useMemo(() => { + return selectedRows.map(row => ({ + id: row.vendorId, + vendorName: row.vendorName, + vendorCode: row.vendorCode, + })); + }, [selectedRows]); + + // 추가 액션 버튼들 + const additionalActions = React.useMemo(() => ( + <div className="flex items-center gap-2"> + <Button + variant="outline" + size="sm" + onClick={() => setIsAddDialogOpen(true)} + > + <Plus className="h-4 w-4 mr-2" /> + 벤더 추가 + </Button> + {selectedRows.length > 0 && ( + <> + <Button + variant="outline" + size="sm" + onClick={() => setIsBatchUpdateOpen(true)} + > + <Settings2 className="h-4 w-4 mr-2" /> + 정보 일괄 입력 ({selectedRows.length}) + </Button> + <Button + variant="outline" + size="sm" + onClick={handleBulkSend} + > + <Send className="h-4 w-4 mr-2" /> + 선택 발송 ({selectedRows.length}) + </Button> + </> + )} + <Button + variant="outline" + size="sm" + onClick={() => { + setIsRefreshing(true); + setTimeout(() => { + setIsRefreshing(false); + toast.success("데이터를 새로고침했습니다."); + }, 1000); + }} + disabled={isRefreshing} + > + <RefreshCw className={cn("h-4 w-4 mr-2", isRefreshing && "animate-spin")} /> + 새로고침 + </Button> + </div> + ), [selectedRows, isRefreshing, handleBulkSend]); + + return ( + <> + <ClientDataTable + columns={columns} + data={mergedData} + advancedFilterFields={advancedFilterFields} + autoSizeColumns={false} + compact={true} + maxHeight="34rem" + onSelectedRowsChange={setSelectedRows} + > + {additionalActions} + </ClientDataTable> + + {/* 벤더 추가 다이얼로그 */} + <AddVendorDialog + open={isAddDialogOpen} + onOpenChange={setIsAddDialogOpen} + rfqId={rfqId} + onSuccess={() => { + toast.success("벤더가 추가되었습니다."); + setIsAddDialogOpen(false); + }} + /> + + {/* 조건 일괄 설정 다이얼로그 */} + <BatchUpdateConditionsDialog + open={isBatchUpdateOpen} + onOpenChange={setIsBatchUpdateOpen} + rfqId={rfqId} + rfqCode={rfqCode} + selectedVendors={selectedVendorsForBatch} + onSuccess={() => { + toast.success("조건이 업데이트되었습니다."); + setIsBatchUpdateOpen(false); + setSelectedRows([]); + }} + /> + + {/* 벤더 상세 다이얼로그 */} + {/* {selectedVendor && ( + <VendorDetailDialog + open={!!selectedVendor} + onOpenChange={(open) => !open && setSelectedVendor(null)} + vendor={selectedVendor} + rfqId={rfqId} + /> + )} */} + </> + ); +}
\ No newline at end of file |
