// @/lib/rfq-last/vendor/vendor-response-table.tsx "use client"; import * as React from "react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Download, FileText, RefreshCw, Eye, Trash2, File, FileImage, FileSpreadsheet, FileCode, Building2, Calendar, AlertCircle, X, CheckCircle2 } from "lucide-react"; import { format, formatDistanceToNow, isValid, isBefore, isAfter } 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, DataTableRowAction, } from "@/types/table"; import { cn } from "@/lib/utils"; import { confirmVendorDocuments, getRfqVendorAttachments, updateAttachmentTypes } from "@/lib/rfq-last/service"; import { downloadFile } from "@/lib/file-download"; import { toast } from "sonner"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; // 타입 정의 interface VendorAttachment { id: number; vendorResponseId: number; attachmentType: string; documentNo: string | null; fileName: string; originalFileName: string; filePath: string; fileSize: number | null; fileType: string | null; description: string | null; validFrom: Date | null; validTo: Date | null; uploadedBy: number; uploadedAt: Date; uploadedByName: string | null; vendorId: number | null; vendorName: string | null; vendorCode: string | null; responseStatus: "작성중" | "제출완료" | "수정요청" | "최종확정" | "취소" | null; responseVersion: number | null; } interface VendorResponseTableProps { rfqId: number; initialData: VendorAttachment[]; } // 파일 타입별 아이콘 반환 const getFileIcon = (fileType: string | null) => { if (!fileType) return ; const type = fileType.toLowerCase(); if (type.includes('image') || ['jpg', 'jpeg', 'png', 'gif'].includes(type)) { return ; } if (type.includes('excel') || type.includes('spreadsheet') || ['xls', 'xlsx'].includes(type)) { return ; } if (type.includes('pdf')) { return ; } if (type.includes('code') || ['js', 'ts', 'tsx', 'jsx', 'html', 'css'].includes(type)) { return ; } return ; }; // 파일 크기 포맷팅 const formatFileSize = (bytes: number | null) => { if (!bytes) return "-"; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(1024)); return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`; }; // 응답 상태별 색상 const getStatusVariant = (status: string | null) => { switch (status) { case "작성중": return "outline"; case "제출완료": return "default"; case "수정요청": return "secondary"; case "최종확정": return "success"; case "취소": return "destructive"; default: return "outline"; } }; // 유효기간 체크 const checkValidity = (validTo: Date | null) => { if (!validTo) return null; const today = new Date(); const expiry = new Date(validTo); if (isBefore(expiry, today)) { return "expired"; } else if (isBefore(expiry, new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000))) { return "expiring-soon"; // 30일 이내 만료 } return "valid"; }; export function VendorResponseTable({ rfqId, initialData, }: VendorResponseTableProps) { const [data, setData] = React.useState(initialData); const [isRefreshing, setIsRefreshing] = React.useState(false); const [selectedRows, setSelectedRows] = React.useState([]); const [isUpdating, setIsUpdating] = React.useState(false); const [showTypeDialog, setShowTypeDialog] = React.useState(false); const [selectedType, setSelectedType] = React.useState<"구매" | "설계" | "">(""); const [selectedVendor, setSelectedVendor] = React.useState(null); const [showConfirmDialog, setShowConfirmDialog] = React.useState(false); const [isConfirming, setIsConfirming] = React.useState(false); const [confirmedVendors, setConfirmedVendors] = React.useState>(new Set()); const filteredData = React.useMemo(() => { if (!selectedVendor) return data; return data.filter(item => item.vendorName === selectedVendor); }, [data, selectedVendor]); // 현재 선택된 벤더의 ID 가져오기 const selectedVendorId = React.useMemo(() => { if (!selectedVendor) return null; const vendorItem = data.find(item => item.vendorName === selectedVendor); return vendorItem?.vendorId || null; }, [selectedVendor, data]); // 데이터 새로고침 const handleRefresh = React.useCallback(async () => { setIsRefreshing(true); try { const result = await getRfqVendorAttachments(rfqId); if (result.vendorSuccess && result.vendorData) { setData(result.vendorData); toast.success("데이터를 새로고침했습니다."); } else { toast.error("데이터를 불러오는데 실패했습니다."); } } catch (error) { console.error("Refresh error:", error); toast.error("새로고침 중 오류가 발생했습니다."); } finally { setIsRefreshing(false); } }, [rfqId]); const toggleVendorFilter = (vendor: string) => { if (selectedVendor === vendor) { setSelectedVendor(null); // 이미 선택된 벤더를 다시 클릭하면 필터 해제 } else { setSelectedVendor(vendor); // 필터 변경 시 선택 초기화 (옵션) setSelectedRows([]); } }; // 문서 확정 처리 const handleConfirmDocuments = React.useCallback(async () => { if (!selectedVendorId || !selectedVendor) return; setIsConfirming(true); try { const result = await confirmVendorDocuments(rfqId, selectedVendorId); if (result.success) { toast.success(result.message); setConfirmedVendors(prev => new Set(prev).add(selectedVendorId)); setShowConfirmDialog(false); // 데이터 새로고침 await handleRefresh(); } else { toast.error(result.message); } } catch (error) { toast.error("문서 확정 중 오류가 발생했습니다."); } finally { setIsConfirming(false); } }, [selectedVendorId, selectedVendor, rfqId, handleRefresh]); // 문서 유형 일괄 변경 const handleBulkTypeChange = React.useCallback(async () => { if (!selectedType || selectedRows.length === 0) return; setIsUpdating(true); try { const ids = selectedRows.map(row => row.id); const result = await updateAttachmentTypes(ids, selectedType as "구매" | "설계"); if (result.success) { toast.success(result.message); // 데이터 새로고침 await handleRefresh(); // 선택 초기화 setSelectedRows([]); setShowTypeDialog(false); setSelectedType(""); } else { toast.error(result.message); } } catch (error) { toast.error("문서 유형 변경 중 오류가 발생했습니다."); } finally { setIsUpdating(false); } }, [selectedType, selectedRows, handleRefresh]); // 액션 처리 const handleAction = React.useCallback(async (action: DataTableRowAction) => { const attachment = action.row.original; switch (action.type) { case "download": if (attachment.filePath && attachment.originalFileName) { await downloadFile(attachment.filePath, attachment.originalFileName, { action: 'download', showToast: true }); } break; case "preview": if (attachment.filePath && attachment.originalFileName) { await downloadFile(attachment.filePath, attachment.originalFileName, { action: 'preview', showToast: true }); } break; } }, []); // 선택된 항목 일괄 다운로드 const handleBulkDownload = React.useCallback(async () => { if (selectedRows.length === 0) { toast.warning("다운로드할 항목을 선택해주세요."); return; } for (const attachment of selectedRows) { if (attachment.filePath && attachment.originalFileName) { await downloadFile(attachment.filePath, attachment.originalFileName, { action: 'download', showToast: false }); } } toast.success(`${selectedRows.length}개 파일을 다운로드했습니다.`); }, [selectedRows]); // 컬럼 정의 const columns: ColumnDef[] = React.useMemo(() => [ { id: "select", header: ({ table }) => ( table.toggleAllPageRowsSelected(!!v)} aria-label="select all" className="translate-y-0.5" /> ), cell: ({ row }) => ( row.toggleSelected(!!v)} aria-label="select row" className="translate-y-0.5" /> ), size: 40, enableSorting: false, enableHiding: false, enablePinning: true, }, { accessorKey: "vendorName", header: ({ column }) => , cell: ({ row }) => { const vendor = row.original; return ( {vendor.vendorName || "-"} {vendor.vendorCode} ); }, size: 150, enablePinning: true, }, { accessorKey: "attachmentType", header: ({ column }) => , cell: ({ row }) => { const type = row.original.attachmentType; return ( {type} ); }, size: 100, }, { accessorKey: "documentNo", header: ({ column }) => , cell: ({ row }) => ( {row.original.documentNo || "-"} ), size: 120, }, { accessorKey: "originalFileName", header: ({ column }) => , cell: ({ row }) => { const file = row.original; return ( {getFileIcon(file.fileType)} {file.originalFileName || file.fileName || "-"} ); }, size: 300, }, { accessorKey: "responseStatus", header: ({ column }) => , cell: ({ row }) => { const status = row.original.responseStatus; return status ? ( {status} ) : ( - ); }, size: 100, }, { accessorKey: "fileSize", header: ({ column }) => , cell: ({ row }) => ( {formatFileSize(row.original.fileSize)} ), size: 80, }, { accessorKey: "uploadedAt", header: ({ column }) => , cell: ({ row }) => { const date = row.original.uploadedAt; return date ? ( {format(new Date(date), "MM-dd HH:mm")} {format(new Date(date), "yyyy년 MM월 dd일 HH시 mm분")} ({formatDistanceToNow(new Date(date), { addSuffix: true, locale: ko })}) ) : ( "-" ); }, size: 100, }, { id: "actions", header: "작업", cell: ({ row }) => { return ( 메뉴 열기 handleAction({ row, type: "download" })}> 다운로드 {/* handleAction({ row, type: "preview" })}> 미리보기 */} ); }, size: 60, enablePinning: true, }, ], [handleAction]); const advancedFilterFields: DataTableAdvancedFilterField[] = [ { id: "vendorName", label: "벤더명", type: "text" }, { id: "vendorCode", label: "벤더코드", type: "text" }, { id: "attachmentType", label: "문서 유형", type: "select", options: [ { label: "구매", value: "구매" }, { label: "설계", value: "설계" }, ] }, { id: "documentNo", label: "문서번호", type: "text" }, { id: "originalFileName", label: "파일명", type: "text" }, { id: "description", label: "설명", type: "text" }, { id: "responseStatus", label: "응답 상태", type: "select", options: [ { label: "작성중", value: "작성중" }, { label: "제출완료", value: "제출완료" }, { label: "수정요청", value: "수정요청" }, { label: "최종확정", value: "최종확정" }, { label: "취소", value: "취소" }, ] }, { id: "uploadedAt", label: "업로드일", type: "date" }, ]; // 추가 액션 버튼들 수정 const additionalActions = React.useMemo(() => ( {selectedRows.length > 0 && ( <> {/* 문서 유형 변경 버튼 */} setShowTypeDialog(true)} > 유형 변경 ({selectedRows.length}) 다운로드 ({selectedRows.length}) > )} 새로고침 ), [selectedRows, isRefreshing, handleBulkDownload, handleRefresh]) // 벤더별 그룹 카운트 const vendorCounts = React.useMemo(() => { const counts = new Map(); data.forEach(item => { const vendor = item.vendorName || "Unknown"; counts.set(vendor, (counts.get(vendor) || 0) + 1); }); return counts; }, [data]); return ( {/* 벤더 필터 섹션 */} {/* 필터 헤더 */} 벤더별 필터 {/* 선택된 벤더의 문서 확정 버튼 */} {selectedVendor && selectedVendorId && ( setShowConfirmDialog(true)} className="h-7" disabled={confirmedVendors.has(selectedVendorId)} > {confirmedVendors.has(selectedVendorId) ? ( <> 확정완료 > ) : ( <> {selectedVendor} 문서 확정 > )} )} {selectedVendor && ( setSelectedVendor(null)} className="h-7 px-2 text-xs" > 필터 초기화 )} {/* 벤더 버튼들 */} {/* 전체 보기 버튼 */} setSelectedVendor(null)} className="h-7" > 전체 ({data.length}) {/* 각 벤더별 버튼 */} {Array.from(vendorCounts.entries()).map(([vendor, count]) => { const vendorItem = data.find(item => item.vendorName === vendor); const vendorId = vendorItem?.vendorId; const isConfirmed = vendorId ? confirmedVendors.has(vendorId) : false; return ( toggleVendorFilter(vendor)} className="h-7 relative" > {isConfirmed && ( )} {vendor} ({count}) ); })} {/* 현재 필터 상태 표시 */} {selectedVendor && ( "{selectedVendor}" 벤더의 {filteredData.length}개 항목만 표시 중 )} {additionalActions} {/* 문서 유형 변경 다이얼로그 */} 문서 유형 변경 선택한 {selectedRows.length}개 항목의 문서 유형을 변경합니다. 문서 유형 setSelectedType(value as "구매" | "설계")} > 구매 설계 {/* 현재 선택된 항목들의 정보 표시 */} 변경될 항목: {selectedRows.slice(0, 5).map((row) => ( • {row.vendorName} - {row.originalFileName} ))} {selectedRows.length > 5 && ( ... 외 {selectedRows.length - 5}개 )} { setShowTypeDialog(false); setSelectedType(""); }} disabled={isUpdating} > 취소 {isUpdating ? ( <> 변경 중... > ) : ( "변경" )} {/* 문서 확정 확인 다이얼로그 */} 문서 확정 확인 {selectedVendor} 벤더의 모든 문서를 확정하시겠습니까? 이 작업은 해당 벤더의 모든 응답 문서를 확정 처리합니다. ⚠️ 확정 후에는 되돌릴 수 없습니다. 취소 {isConfirming ? ( <> 확정 중... > ) : ( <> 확정 > )} ); }
{format(new Date(date), "yyyy년 MM월 dd일 HH시 mm분")}
({formatDistanceToNow(new Date(date), { addSuffix: true, locale: ko })})
변경될 항목:
{selectedVendor} 벤더의 모든 문서를 확정하시겠습니까?
이 작업은 해당 벤더의 모든 응답 문서를 확정 처리합니다.
⚠️ 확정 후에는 되돌릴 수 없습니다.