summaryrefslogtreecommitdiff
path: root/lib/rfq-last/attachment/rfq-attachments-table.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/rfq-last/attachment/rfq-attachments-table.tsx')
-rw-r--r--lib/rfq-last/attachment/rfq-attachments-table.tsx539
1 files changed, 539 insertions, 0 deletions
diff --git a/lib/rfq-last/attachment/rfq-attachments-table.tsx b/lib/rfq-last/attachment/rfq-attachments-table.tsx
new file mode 100644
index 00000000..a66e12a2
--- /dev/null
+++ b/lib/rfq-last/attachment/rfq-attachments-table.tsx
@@ -0,0 +1,539 @@
+"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";
+import { Badge } from "@/components/ui/badge";
+import {
+ Download,
+ FileText,
+ Upload,
+ RefreshCw,
+ Eye,
+ Trash2,
+ History,
+ Plus,
+ File,
+ FileImage,
+ FileSpreadsheet,
+ FileCode
+} from "lucide-react";
+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 {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} 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 { 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";
+
+// 타입 정의
+interface RfqAttachment {
+ id: number;
+ attachmentType: "설계" | "구매";
+ serialNo: string | null;
+ rfqId: number;
+ currentRevision: string | null;
+ latestRevisionId: number | null;
+ description: string | null;
+ createdBy: number;
+ createdAt: Date;
+ updatedAt: Date;
+ fileName: string | null;
+ originalFileName: string | null;
+ filePath: string | null;
+ fileSize: number | null;
+ fileType: string | null;
+ revisionComment: string | null;
+ createdByName: string | null;
+}
+
+interface RfqAttachmentsTableProps {
+ rfqId: number;
+ initialDesignData: Awaited<ReturnType<typeof getRfqLastAttachments>>;
+ initialPurchaseData: Awaited<ReturnType<typeof getRfqLastAttachments>>;
+ className?: string;
+}
+
+// 파일 타입별 아이콘 반환
+const getFileIcon = (fileType: string | null) => {
+ if (!fileType) return <File className="h-4 w-4" />;
+
+ const type = fileType.toLowerCase();
+ if (type.includes('image') || ['jpg', 'jpeg', 'png', 'gif'].includes(type)) {
+ return <FileImage className="h-4 w-4 text-blue-500" />;
+ }
+ if (type.includes('excel') || type.includes('spreadsheet') || ['xls', 'xlsx'].includes(type)) {
+ return <FileSpreadsheet className="h-4 w-4 text-green-500" />;
+ }
+ if (type.includes('pdf')) {
+ return <FileText className="h-4 w-4 text-red-500" />;
+ }
+ if (type.includes('code') || ['js', 'ts', 'tsx', 'jsx', 'html', 'css'].includes(type)) {
+ return <FileCode className="h-4 w-4 text-purple-500" />;
+ }
+ return <File className="h-4 w-4 text-gray-500" />;
+};
+
+// 파일 크기 포맷팅
+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]}`;
+};
+
+export function RfqAttachmentsTable({
+ rfqId,
+ initialDesignData,
+ initialPurchaseData,
+ className
+}: 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 [selectedAttachment, setSelectedAttachment] = React.useState<RfqAttachment | null>(null);
+ const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
+ const [updateRevisionDialogOpen, setUpdateRevisionDialogOpen] = React.useState(false);
+ const [isRefreshing, setIsRefreshing] = React.useState(false);
+
+ // 새로고침 (router.refresh 사용)
+ const handleRefresh = React.useCallback(() => {
+ setIsRefreshing(true);
+ router.refresh();
+ setTimeout(() => setIsRefreshing(false), 1000);
+ }, [router]);
+
+ // 액션 처리
+ const handleAction = React.useCallback(async (action: DataTableRowAction<RfqAttachment>) => {
+ 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;
+
+ case "history":
+ // 리비전 이력 보기 - 별도 구현 필요
+ console.log("History:", attachment);
+ break;
+
+ case "update":
+ setSelectedAttachment(attachment);
+ setUpdateRevisionDialogOpen(true);
+ break;
+
+ case "delete":
+ setSelectedAttachment(attachment);
+ setDeleteDialogOpen(true);
+ break;
+ }
+ }, []);
+
+ // 컬럼 정의
+ const getAttachmentColumns = React.useCallback((
+ onAction: (action: DataTableRowAction<RfqAttachment>) => void
+ ): ColumnDef<RfqAttachment>[] => [
+ {
+ 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: "serialNo",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="일련번호" />,
+ cell: ({ row }) => (
+ <span className="font-mono text-sm">{row.original.serialNo || "-"}</span>
+ ),
+ size: 100,
+ },
+ {
+ accessorKey: "originalFileName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="파일명" />,
+ cell: ({ row }) => {
+ const file = row.original;
+ return (
+ <div className="flex items-center gap-2">
+ {getFileIcon(file.fileType)}
+ <div className="flex flex-col">
+ <span className="text-sm font-medium truncate max-w-[250px]" title={file.originalFileName || ""}>
+ {file.originalFileName || file.fileName || "-"}
+ </span>
+ {file.fileName && file.fileName !== file.originalFileName && (
+ <span className="text-xs text-muted-foreground truncate max-w-[250px]">
+ ({file.fileName})
+ </span>
+ )}
+ </div>
+ </div>
+ );
+ },
+ size: 300,
+ },
+ {
+ accessorKey: "description",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="설명" />,
+ cell: ({ row }) => (
+ <div className="max-w-[200px] truncate" title={row.original.description || ""}>
+ {row.original.description || "-"}
+ </div>
+ ),
+ size: 200,
+ },
+ {
+ accessorKey: "currentRevision",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="리비전" />,
+ cell: ({ row }) => {
+ const revision = row.original.currentRevision;
+ return revision ? (
+ <Badge variant="outline" className="font-mono">
+ Rev. {revision}
+ </Badge>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ );
+ },
+ size: 100,
+ },
+ {
+ accessorKey: "fileSize",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="크기" />,
+ cell: ({ row }) => (
+ <span className="text-sm text-muted-foreground">
+ {formatFileSize(row.original.fileSize)}
+ </span>
+ ),
+ size: 80,
+ },
+ {
+ accessorKey: "fileType",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="타입" />,
+ cell: ({ row }) => {
+ const type = row.original.fileType;
+ return type ? (
+ <Badge variant="secondary" className="text-xs">
+ {type.toUpperCase()}
+ </Badge>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ );
+ },
+ size: 80,
+ },
+ {
+ accessorKey: "createdByName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="업로드자" />,
+ cell: ({ row }) => row.original.createdByName || "-",
+ size: 100,
+ },
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="업로드일" />,
+ cell: ({ row }) => {
+ const date = row.original.createdAt;
+ return date ? (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <span className="text-sm cursor-help">
+ {format(new Date(date), "MM-dd HH:mm")}
+ </span>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>{format(new Date(date), "yyyy년 MM월 dd일 HH시 mm분")}</p>
+ <p className="text-xs text-muted-foreground">
+ ({formatDistanceToNow(new Date(date), { addSuffix: true, locale: ko })})
+ </p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ ) : (
+ "-"
+ );
+ },
+ size: 100,
+ },
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="수정일" />,
+ cell: ({ row }) => {
+ const date = row.original.updatedAt;
+ return date ? format(new Date(date), "MM-dd HH:mm") : "-";
+ },
+ size: 100,
+ },
+ {
+ id: "actions",
+ header: "작업",
+ cell: ({ row }) => {
+ 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={() => onAction({ row, type: "download" })}>
+ <Download className="mr-2 h-4 w-4" />
+ 다운로드
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={() => onAction({ row, type: "preview" })}>
+ <Eye className="mr-2 h-4 w-4" />
+ 미리보기
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem onClick={() => onAction({ row, type: "history" })}>
+ <History className="mr-2 h-4 w-4" />
+ 리비전 이력
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={() => onAction({ row, type: "update" })}>
+ <Upload className="mr-2 h-4 w-4" />
+ 새 버전 업로드
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onClick={() => onAction({ row, type: "delete" })}
+ className="text-red-600"
+ >
+ <Trash2 className="mr-2 h-4 w-4" />
+ 삭제
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ );
+ },
+ size: 60,
+ },
+ ], []);
+
+ const columns = React.useMemo(() => getAttachmentColumns(handleAction), [getAttachmentColumns, handleAction]);
+
+ const filterFields: DataTableFilterField<RfqAttachment>[] = [
+ { id: "serialNo", label: "일련번호" },
+ { id: "originalFileName", label: "파일명" },
+ { id: "description", label: "설명" },
+ { id: "createdByName", label: "업로드자" },
+ ];
+
+ const advancedFilterFields: DataTableAdvancedFilterField<RfqAttachment>[] = [
+ { id: "serialNo", label: "일련번호", type: "text" },
+ { id: "originalFileName", label: "파일명", type: "text" },
+ { id: "description", label: "설명", type: "text" },
+ { id: "currentRevision", label: "리비전", type: "text" },
+ {
+ id: "fileType",
+ label: "파일 타입",
+ type: "select",
+ options: [
+ { label: "PDF", value: "pdf" },
+ { label: "Excel", value: "xlsx" },
+ { label: "Word", value: "docx" },
+ { label: "이미지", value: "image" },
+ { label: "기타", value: "other" },
+ ]
+ },
+ { id: "createdByName", label: "업로드자", type: "text" },
+ { id: "createdAt", label: "업로드일", type: "date" },
+ { 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,
+ });
+
+
+ React.useEffect(() => {
+ router.refresh();
+ }, [activeTab]);
+
+ return (
+ <div className={cn("w-full space-y-4", className)}>
+ <Tabs value={activeTab} onValueChange={setActiveTab}>
+ <div className="flex items-center justify-between mb-4">
+ <TabsList>
+ <TabsTrigger value="설계">
+ 설계 첨부파일
+ <Badge variant="secondary" className="ml-2">
+ {designData.data.length}
+ </Badge>
+ </TabsTrigger>
+ <TabsTrigger value="구매">
+ 구매 첨부파일
+ <Badge variant="secondary" className="ml-2">
+ {purchaseData.data.length}
+ </Badge>
+ </TabsTrigger>
+ </TabsList>
+
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleRefresh}
+ disabled={isRefreshing}
+ >
+ <RefreshCw className={cn("h-4 w-4 mr-2", isRefreshing && "animate-spin")} />
+ 새로고침
+ </Button>
+
+ {/* 구매 탭에서만 파일 업로드 버튼 표시 */}
+ {activeTab === "구매" && (
+ <AddAttachmentDialog
+ rfqId={rfqId}
+ attachmentType="구매"
+ onSuccess={handleRefresh}
+ />
+ )}
+ </div>
+ </div>
+
+ <TabsContent value="설계" className="mt-0">
+ <Card>
+ <CardContent className="p-0">
+ <DataTable table={designTable}>
+ <DataTableAdvancedToolbar
+ table={designTable}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ />
+ </DataTable>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ <TabsContent value="구매" className="mt-0">
+ <Card>
+ <CardContent className="p-0">
+ <DataTable table={purchaseTable}>
+ <DataTableAdvancedToolbar
+ table={purchaseTable}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ />
+ </DataTable>
+ </CardContent>
+ </Card>
+ </TabsContent>
+ </Tabs>
+
+ {/* 삭제 다이얼로그 */}
+ {selectedAttachment && (
+ <DeleteAttachmentsDialog
+ open={deleteDialogOpen}
+ onOpenChange={setDeleteDialogOpen}
+ attachments={[selectedAttachment]}
+ onSuccess={handleRefresh}
+ />
+ )}
+
+ {/* 새 버전 업로드 다이얼로그 */}
+ {selectedAttachment && (
+ <UpdateRevisionDialog
+ open={updateRevisionDialogOpen}
+ onOpenChange={setUpdateRevisionDialogOpen}
+ attachment={selectedAttachment}
+ onSuccess={handleRefresh}
+ />
+ )}
+ </div>
+ );
+} \ No newline at end of file