From 50adedf48ee4674ebe00f1ee72d93485183cdc51 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Fri, 5 Sep 2025 11:44:32 +0000 Subject: (대표님, 최겸, 임수민) EDP 입력 진행률, 견적목록관리, EDP excel import 오류수정, GTC-Contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attachment/revision-historty-dialog.tsx | 305 ++++++++++++ lib/rfq-last/attachment/rfq-attachments-table.tsx | 370 ++++++++------- lib/rfq-last/attachment/vendor-response-table.tsx | 519 +++++++++++++++++++++ 3 files changed, 1029 insertions(+), 165 deletions(-) create mode 100644 lib/rfq-last/attachment/revision-historty-dialog.tsx create mode 100644 lib/rfq-last/attachment/vendor-response-table.tsx (limited to 'lib/rfq-last/attachment') diff --git a/lib/rfq-last/attachment/revision-historty-dialog.tsx b/lib/rfq-last/attachment/revision-historty-dialog.tsx new file mode 100644 index 00000000..6e4772cb --- /dev/null +++ b/lib/rfq-last/attachment/revision-historty-dialog.tsx @@ -0,0 +1,305 @@ +// @/lib/rfq-last/attachment/revision-history-dialog.tsx + +"use client"; + +import * as React from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + Download, + Eye, + FileText, + Clock, + User, + MessageSquare, + AlertCircle, + CheckCircle, +} from "lucide-react"; +import { format, formatDistanceToNow } from "date-fns"; +import { ko } from "date-fns/locale"; +import { toast } from "sonner"; +import { downloadFile } from "@/lib/file-download"; +import { + getRevisionHistory, + type AttachmentWithHistory, + type RevisionHistory, +} from "../service"; +import { formatFileSize } from "@/lib/utils"; + +interface RevisionHistoryDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + attachmentId: number; + attachmentName?: string; +} + +export function RevisionHistoryDialog({ + open, + onOpenChange, + attachmentId, + attachmentName, +}: RevisionHistoryDialogProps) { + const [loading, setLoading] = React.useState(false); + const [historyData, setHistoryData] = React.useState(null); + const [error, setError] = React.useState(null); + + // 다이얼로그가 열릴 때 데이터 로드 + React.useEffect(() => { + if (open && attachmentId) { + loadRevisionHistory(); + } + }, [open, attachmentId]); + + const loadRevisionHistory = async () => { + setLoading(true); + setError(null); + try { + const result = await getRevisionHistory(attachmentId); + if (result.success && result.data) { + setHistoryData(result.data); + } else { + setError(result.error || "리비전 히스토리를 불러올 수 없습니다."); + } + } catch (err) { + console.error("Load revision history error:", err); + setError("리비전 히스토리 조회 중 오류가 발생했습니다."); + } finally { + setLoading(false); + } + }; + + // 리비전 다운로드 + const handleDownloadRevision = async (revision: RevisionHistory) => { + try { + await downloadFile(revision.filePath, revision.originalFileName, { + action: 'download', + showToast: true, + }); + } catch (err) { + console.error("Download revision error:", err); + toast.error("파일 다운로드 중 오류가 발생했습니다."); + } + }; + + // 리비전 미리보기 + const handlePreviewRevision = async (revision: RevisionHistory) => { + try { + await downloadFile(revision.filePath, revision.originalFileName, { + action: 'preview', + showToast: true, + }); + } catch (err) { + console.error("Preview revision error:", err); + toast.error("파일 미리보기 중 오류가 발생했습니다."); + } + }; + + // 리비전 번호에 따른 색상 결정 + const getRevisionBadgeVariant = (isLatest: boolean) => { + return isLatest ? "default" : "secondary"; + }; + + return ( + + + + + + 리비전 히스토리 + + + {historyData?.originalFileName || attachmentName || "파일"}의 모든 버전 히스토리를 확인할 수 있습니다. + + + +
+ {loading ? ( +
+ + + +
+ ) : error ? ( + + + {error} + + ) : historyData ? ( + <> + {/* 파일 정보 헤더 */} +
+
+
+ 일련번호:{" "} + {historyData.serialNo || "-"} +
+
+ 현재 리비전:{" "} + + Rev. {historyData.currentRevision || "A"} + +
+ {historyData.description && ( +
+ 설명:{" "} + {historyData.description} +
+ )} +
+
+ + {/* 리비전 테이블 */} + + + + + 리비전 + 파일명 + 크기 + 업로드자 + 업로드일시 + 코멘트 + 작업 + + + + {historyData.revisions.length > 0 ? ( + historyData.revisions.map((revision) => ( + + + + Rev. {revision.revisionNo} + {revision.isLatest && ( + + )} + + + +
+ + {revision.originalFileName} + + {revision.fileName !== revision.originalFileName && ( + + ({revision.fileName}) + + )} +
+
+ + + {formatFileSize(revision.fileSize)} + + + +
+ + + {revision.createdByName || "Unknown"} + +
+
+ +
+ + + {format(new Date(revision.createdAt), "yyyy-MM-dd HH:mm")} + +
+ + {formatDistanceToNow(new Date(revision.createdAt), { + addSuffix: true, + locale: ko, + })} + +
+ + {revision.revisionComment ? ( +
+ + + {revision.revisionComment} + +
+ ) : ( + - + )} +
+ +
+ + +
+
+
+ )) + ) : ( + + + 리비전 히스토리가 없습니다. + + + )} +
+
+
+ + {/* 요약 정보 */} +
+ 총 {historyData.revisions.length}개의 리비전 + + 최초 업로드:{" "} + {historyData.revisions.length > 0 + ? format( + new Date( + historyData.revisions[historyData.revisions.length - 1].createdAt + ), + "yyyy년 MM월 dd일" + ) + : "-"} + +
+ + ) : null} +
+
+
+ ); +} \ No newline at end of file diff --git a/lib/rfq-last/attachment/rfq-attachments-table.tsx b/lib/rfq-last/attachment/rfq-attachments-table.tsx index a66e12a2..155fd412 100644 --- a/lib/rfq-last/attachment/rfq-attachments-table.tsx +++ b/lib/rfq-last/attachment/rfq-attachments-table.tsx @@ -1,7 +1,6 @@ "use client"; import * as React from "react"; -import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Card, CardContent } from "@/components/ui/card"; @@ -24,10 +23,8 @@ import { format, formatDistanceToNow } from "date-fns"; import { ko } from "date-fns/locale"; import { type ColumnDef } from "@tanstack/react-table"; import { Checkbox } from "@/components/ui/checkbox"; -import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"; -import { useDataTable } from "@/hooks/use-data-table"; -import { DataTable } from "@/components/data-table/data-table"; -import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"; +import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header"; +import { ClientDataTable } from "@/components/client-data-table/data-table"; import { DropdownMenu, DropdownMenuContent, @@ -43,16 +40,16 @@ import { } from "@/components/ui/tooltip"; import type { DataTableAdvancedFilterField, - DataTableFilterField, DataTableRowAction, } from "@/types/table"; import { cn } from "@/lib/utils"; -import { getRfqLastAttachments } from "@/lib/rfq-last/service"; +import { getRfqAllAttachments } from "@/lib/rfq-last/service"; import { downloadFile } from "@/lib/file-download"; import { DeleteAttachmentsDialog } from "./delete-attachments-dialog"; import { AddAttachmentDialog } from "./add-attachment-dialog"; import { UpdateRevisionDialog } from "./update-revision-dialog"; -import { useQueryState ,parseAsStringEnum} from "nuqs"; +import { toast } from "sonner"; +import { RevisionHistoryDialog } from "./revision-historty-dialog"; // 타입 정의 interface RfqAttachment { @@ -77,9 +74,7 @@ interface RfqAttachment { interface RfqAttachmentsTableProps { rfqId: number; - initialDesignData: Awaited>; - initialPurchaseData: Awaited>; - className?: string; + initialData: RfqAttachment[]; } // 파일 타입별 아이콘 반환 @@ -112,31 +107,41 @@ const formatFileSize = (bytes: number | null) => { export function RfqAttachmentsTable({ rfqId, - initialDesignData, - initialPurchaseData, - className + initialData, }: RfqAttachmentsTableProps) { - const router = useRouter(); - const [activeTab, setActiveTab] = useQueryState( - 'tab', - parseAsStringEnum(['설계', '구매']) - .withDefault('설계') - .withOptions({ shallow: false }) - ); - - const [designData] = React.useState(initialDesignData); - const [purchaseData] = React.useState(initialPurchaseData); + const [activeTab, setActiveTab] = React.useState<'설계' | '구매'>('설계'); + const [data, setData] = React.useState(initialData); const [selectedAttachment, setSelectedAttachment] = React.useState(null); const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false); const [updateRevisionDialogOpen, setUpdateRevisionDialogOpen] = React.useState(false); + const [revisionHistoryDialogOpen, setRevisionHistoryDialogOpen] = React.useState(false); + const [addDialogOpen, setAddDialogOpen] = React.useState(false); const [isRefreshing, setIsRefreshing] = React.useState(false); + const [selectedRows, setSelectedRows] = React.useState([]); + + // 탭에 따른 데이터 필터링 + const filteredData = React.useMemo(() => { + return data.filter(item => item.attachmentType === activeTab); + }, [data, activeTab]); - // 새로고침 (router.refresh 사용) - const handleRefresh = React.useCallback(() => { + // 데이터 새로고침 + const handleRefresh = React.useCallback(async () => { setIsRefreshing(true); - router.refresh(); - setTimeout(() => setIsRefreshing(false), 1000); - }, [router]); + try { + const result = await getRfqAllAttachments(rfqId); + if (result.success && result.data) { + setData(result.data); + toast.success("데이터를 새로고침했습니다."); + } else { + toast.error("데이터를 불러오는데 실패했습니다."); + } + } catch (error) { + console.error("Refresh error:", error); + toast.error("새로고침 중 오류가 발생했습니다."); + } finally { + setIsRefreshing(false); + } + }, [rfqId]); // 액션 처리 const handleAction = React.useCallback(async (action: DataTableRowAction) => { @@ -162,8 +167,8 @@ export function RfqAttachmentsTable({ break; case "history": - // 리비전 이력 보기 - 별도 구현 필요 - console.log("History:", attachment); + setSelectedAttachment(attachment); + setRevisionHistoryDialogOpen(true); break; case "update": @@ -178,10 +183,35 @@ export function RfqAttachmentsTable({ } }, []); + // 선택된 항목 일괄 삭제 + const handleBulkDelete = React.useCallback(() => { + if (selectedRows.length === 0) { + toast.warning("삭제할 항목을 선택해주세요."); + return; + } + setDeleteDialogOpen(true); + }, [selectedRows]); + + // 선택된 항목 일괄 다운로드 + 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 getAttachmentColumns = React.useCallback(( - onAction: (action: DataTableRowAction) => void - ): ColumnDef[] => [ + const columns: ColumnDef[] = React.useMemo(() => [ { id: "select", header: ({ table }) => ( @@ -203,18 +233,21 @@ export function RfqAttachmentsTable({ size: 40, enableSorting: false, enableHiding: false, + enablePinning: true, }, { accessorKey: "serialNo", - header: ({ column }) => , + header: ({ column }) => , cell: ({ row }) => ( {row.original.serialNo || "-"} ), size: 100, + meta: { excelHeader: "일련번호" }, + enablePinning: true, }, { accessorKey: "originalFileName", - header: ({ column }) => , + header: ({ column }) => , cell: ({ row }) => { const file = row.original; return ( @@ -224,11 +257,6 @@ export function RfqAttachmentsTable({ {file.originalFileName || file.fileName || "-"} - {file.fileName && file.fileName !== file.originalFileName && ( - - ({file.fileName}) - - )} ); @@ -237,7 +265,7 @@ export function RfqAttachmentsTable({ }, { accessorKey: "description", - header: ({ column }) => , + header: ({ column }) => , cell: ({ row }) => (
{row.original.description || "-"} @@ -247,7 +275,7 @@ export function RfqAttachmentsTable({ }, { accessorKey: "currentRevision", - header: ({ column }) => , + header: ({ column }) => , cell: ({ row }) => { const revision = row.original.currentRevision; return revision ? ( @@ -262,7 +290,7 @@ export function RfqAttachmentsTable({ }, { accessorKey: "fileSize", - header: ({ column }) => , + header: ({ column }) => , cell: ({ row }) => ( {formatFileSize(row.original.fileSize)} @@ -270,30 +298,15 @@ export function RfqAttachmentsTable({ ), size: 80, }, - { - accessorKey: "fileType", - header: ({ column }) => , - cell: ({ row }) => { - const type = row.original.fileType; - return type ? ( - - {type.toUpperCase()} - - ) : ( - - - ); - }, - size: 80, - }, { accessorKey: "createdByName", - header: ({ column }) => , + header: ({ column }) => , cell: ({ row }) => row.original.createdByName || "-", size: 100, }, { accessorKey: "createdAt", - header: ({ column }) => , + header: ({ column }) => , cell: ({ row }) => { const date = row.original.createdAt; return date ? ( @@ -320,7 +333,7 @@ export function RfqAttachmentsTable({ }, { accessorKey: "updatedAt", - header: ({ column }) => , + header: ({ column }) => , cell: ({ row }) => { const date = row.original.updatedAt; return date ? format(new Date(date), "MM-dd HH:mm") : "-"; @@ -342,26 +355,26 @@ export function RfqAttachmentsTable({ - onAction({ row, type: "download" })}> + handleAction({ row, type: "download" })}> 다운로드 - onAction({ row, type: "preview" })}> + handleAction({ row, type: "preview" })}> 미리보기 - onAction({ row, type: "history" })}> + handleAction({ row, type: "history" })}> 리비전 이력 - onAction({ row, type: "update" })}> + handleAction({ row, type: "update" })}> 새 버전 업로드 onAction({ row, type: "delete" })} + onClick={() => handleAction({ row, type: "delete" })} className="text-red-600" > @@ -372,17 +385,9 @@ export function RfqAttachmentsTable({ ); }, size: 60, + enablePinning: true, }, - ], []); - - const columns = React.useMemo(() => getAttachmentColumns(handleAction), [getAttachmentColumns, handleAction]); - - const filterFields: DataTableFilterField[] = [ - { id: "serialNo", label: "일련번호" }, - { id: "originalFileName", label: "파일명" }, - { id: "description", label: "설명" }, - { id: "createdByName", label: "업로드자" }, - ]; + ], [handleAction]); const advancedFilterFields: DataTableAdvancedFilterField[] = [ { id: "serialNo", label: "일련번호", type: "text" }, @@ -406,121 +411,136 @@ export function RfqAttachmentsTable({ { id: "updatedAt", label: "수정일", type: "date" }, ]; - const { table: designTable } = useDataTable({ - data: designData.data, - columns, - pageCount: designData.pageCount, - rowCount: designData.data.length, - filterFields, - enableAdvancedFilter: true, - // 설계 탭용 파라미터 prefix - paramPrefix: 'design_', - initialState: { - sorting: [{ id: "createdAt", desc: true }], - }, - getRowId: (row) => String(row.id), - shallow: false, - clearOnDefault: true, - }); - - const { table: purchaseTable } = useDataTable({ - data: purchaseData.data, - columns, - pageCount: purchaseData.pageCount, - rowCount: purchaseData.data.length, - filterFields, - enableAdvancedFilter: true, - // 구매 탭용 파라미터 prefix - paramPrefix: 'purchase_', - initialState: { - sorting: [{ id: "createdAt", desc: true }], - }, - getRowId: (row) => String(row.id), - shallow: false, - clearOnDefault: true, - }); - + // 탭별 데이터 카운트 + const designCount = React.useMemo(() => + data.filter(item => item.attachmentType === "설계").length, [data] + ); + const purchaseCount = React.useMemo(() => + data.filter(item => item.attachmentType === "구매").length, [data] + ); - React.useEffect(() => { - router.refresh(); - }, [activeTab]); + // 추가 액션 버튼들 + const additionalActions = React.useMemo(() => ( +
+ {selectedRows.length > 0 && ( + <> + + + + )} + + + {/* 구매 탭에서만 파일 업로드 버튼 표시 */} + {activeTab === "구매" && ( + + )} +
+ ), [selectedRows, activeTab, isRefreshing, addDialogOpen, handleBulkDownload, handleBulkDelete, handleRefresh, rfqId]); return ( -
- +
+ setActiveTab(value as '설계' | '구매')} + >
설계 첨부파일 - {designData.data.length} + {designCount} 구매 첨부파일 - {purchaseData.data.length} + {purchaseCount} - -
- - - {/* 구매 탭에서만 파일 업로드 버튼 표시 */} - {activeTab === "구매" && ( - - )} -
- - - - - - - + + + {additionalActions} + + - - - - - - - + + + {additionalActions} + +
{/* 삭제 다이얼로그 */} - {selectedAttachment && ( + {(selectedAttachment || selectedRows.length > 0) && ( { + setDeleteDialogOpen(open); + if (!open) { + setSelectedAttachment(null); + } + }} + attachments={selectedAttachment ? [selectedAttachment] : selectedRows} onSuccess={handleRefresh} /> )} @@ -529,11 +549,31 @@ export function RfqAttachmentsTable({ {selectedAttachment && ( { + setUpdateRevisionDialogOpen(open); + if (!open) { + setSelectedAttachment(null); + } + }} attachment={selectedAttachment} onSuccess={handleRefresh} /> )} + + {/* 리비전 히스토리 다이얼로그 */} + {selectedAttachment && ( + { + setRevisionHistoryDialogOpen(open); + if (!open) { + setSelectedAttachment(null); + } + }} + attachmentId={selectedAttachment.id} + attachmentName={selectedAttachment.originalFileName || selectedAttachment.fileName || undefined} + /> + )}
); } \ No newline at end of file diff --git a/lib/rfq-last/attachment/vendor-response-table.tsx b/lib/rfq-last/attachment/vendor-response-table.tsx new file mode 100644 index 00000000..6e1a02c8 --- /dev/null +++ b/lib/rfq-last/attachment/vendor-response-table.tsx @@ -0,0 +1,519 @@ +// @/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 +} 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 { getRfqVendorAttachments } from "@/lib/rfq-last/service"; +import { downloadFile } from "@/lib/file-download"; +import { toast } from "sonner"; + +// 타입 정의 +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 handleRefresh = React.useCallback(async () => { + setIsRefreshing(true); + try { + const result = await getRfqVendorAttachments(rfqId); + if (result.success && result.data) { + setData(result.data); + toast.success("데이터를 새로고침했습니다."); + } else { + toast.error("데이터를 불러오는데 실패했습니다."); + } + } catch (error) { + console.error("Refresh error:", error); + toast.error("새로고침 중 오류가 발생했습니다."); + } finally { + setIsRefreshing(false); + } + }, [rfqId]); + + // 액션 처리 + 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: "description", + header: ({ column }) => , + cell: ({ row }) => ( +
+ {row.original.description || "-"} +
+ ), + size: 200, + }, + { + accessorKey: "validTo", + header: ({ column }) => , + cell: ({ row }) => { + const { validFrom, validTo } = row.original; + const validity = checkValidity(validTo); + + if (!validTo) return -; + + return ( + + + +
+ {validity === "expired" && ( + + )} + {validity === "expiring-soon" && ( + + )} + + {format(new Date(validTo), "yyyy-MM-dd")} + +
+
+ +

유효기간: {validFrom ? format(new Date(validFrom), "yyyy-MM-dd") : "?"} ~ {format(new Date(validTo), "yyyy-MM-dd")}

+ {validity === "expired" &&

만료됨

} + {validity === "expiring-soon" &&

곧 만료 예정

} +
+
+
+ ); + }, + size: 120, + }, + { + 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: "기술제안서" }, + { label: "인증서", value: "인증서" }, + { label: "카탈로그", value: "카탈로그" }, + { label: "도면", value: "도면" }, + { 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: "validFrom", label: "유효시작일", type: "date" }, + { id: "validTo", label: "유효종료일", type: "date" }, + { id: "uploadedAt", label: "업로드일", type: "date" }, + ]; + + // 추가 액션 버튼들 + const additionalActions = React.useMemo(() => ( +
+ {selectedRows.length > 0 && ( + + )} + +
+ ), [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 ( +
+ {/* 벤더별 요약 정보 */} +
+ {Array.from(vendorCounts.entries()).map(([vendor, count]) => ( + + {vendor}: {count} + + ))} +
+ + + {additionalActions} + +
+ ); +} \ No newline at end of file -- cgit v1.2.3