summaryrefslogtreecommitdiff
path: root/lib/rfq-last/attachment/vendor-response-table.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/rfq-last/attachment/vendor-response-table.tsx')
-rw-r--r--lib/rfq-last/attachment/vendor-response-table.tsx519
1 files changed, 519 insertions, 0 deletions
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 <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]}`;
+};
+
+// 응답 상태별 색상
+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<VendorAttachment[]>(initialData);
+ const [isRefreshing, setIsRefreshing] = React.useState(false);
+ const [selectedRows, setSelectedRows] = React.useState<VendorAttachment[]>([]);
+
+ // 데이터 새로고침
+ 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<VendorAttachment>) => {
+ 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<VendorAttachment>[] = 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,
+ enablePinning: true,
+ },
+ {
+ 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: 150,
+ enablePinning: true,
+ },
+ {
+ accessorKey: "attachmentType",
+ header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="문서 유형" />,
+ cell: ({ row }) => {
+ const type = row.original.attachmentType;
+ return (
+ <Badge variant="outline" className="font-mono">
+ {type}
+ </Badge>
+ );
+ },
+ size: 100,
+ },
+ {
+ accessorKey: "documentNo",
+ header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="문서번호" />,
+ cell: ({ row }) => (
+ <span className="font-mono text-sm">{row.original.documentNo || "-"}</span>
+ ),
+ size: 120,
+ },
+ {
+ accessorKey: "originalFileName",
+ header: ({ column }) => <ClientDataTableColumnHeaderSimple 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>
+ </div>
+ </div>
+ );
+ },
+ size: 300,
+ },
+ {
+ accessorKey: "description",
+ header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="설명" />,
+ cell: ({ row }) => (
+ <div className="max-w-[200px] truncate" title={row.original.description || ""}>
+ {row.original.description || "-"}
+ </div>
+ ),
+ size: 200,
+ },
+ {
+ accessorKey: "validTo",
+ header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="유효기간" />,
+ cell: ({ row }) => {
+ const { validFrom, validTo } = row.original;
+ const validity = checkValidity(validTo);
+
+ if (!validTo) return <span className="text-muted-foreground">-</span>;
+
+ return (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <div className="flex items-center gap-2">
+ {validity === "expired" && (
+ <AlertCircle className="h-4 w-4 text-red-500" />
+ )}
+ {validity === "expiring-soon" && (
+ <AlertCircle className="h-4 w-4 text-yellow-500" />
+ )}
+ <span className={cn(
+ "text-sm",
+ validity === "expired" && "text-red-500",
+ validity === "expiring-soon" && "text-yellow-500"
+ )}>
+ {format(new Date(validTo), "yyyy-MM-dd")}
+ </span>
+ </div>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>유효기간: {validFrom ? format(new Date(validFrom), "yyyy-MM-dd") : "?"} ~ {format(new Date(validTo), "yyyy-MM-dd")}</p>
+ {validity === "expired" && <p className="text-red-500">만료됨</p>}
+ {validity === "expiring-soon" && <p className="text-yellow-500">곧 만료 예정</p>}
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ );
+ },
+ size: 120,
+ },
+ {
+ accessorKey: "responseStatus",
+ header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="응답 상태" />,
+ cell: ({ row }) => {
+ const status = row.original.responseStatus;
+ return status ? (
+ <Badge variant={getStatusVariant(status)}>
+ {status}
+ </Badge>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ );
+ },
+ size: 100,
+ },
+ {
+ accessorKey: "fileSize",
+ header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="크기" />,
+ cell: ({ row }) => (
+ <span className="text-sm text-muted-foreground">
+ {formatFileSize(row.original.fileSize)}
+ </span>
+ ),
+ size: 80,
+ },
+ {
+ accessorKey: "uploadedAt",
+ header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="업로드일" />,
+ cell: ({ row }) => {
+ const date = row.original.uploadedAt;
+ 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,
+ },
+ {
+ 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={() => handleAction({ row, type: "download" })}>
+ <Download className="mr-2 h-4 w-4" />
+ 다운로드
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={() => handleAction({ row, type: "preview" })}>
+ <Eye className="mr-2 h-4 w-4" />
+ 미리보기
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ );
+ },
+ size: 60,
+ enablePinning: true,
+ },
+ ], [handleAction]);
+
+ const advancedFilterFields: DataTableAdvancedFilterField<VendorAttachment>[] = [
+ { 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(() => (
+ <div className="flex items-center gap-2">
+ {selectedRows.length > 0 && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleBulkDownload}
+ >
+ <Download className="h-4 w-4 mr-2" />
+ 다운로드 ({selectedRows.length})
+ </Button>
+ )}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleRefresh}
+ disabled={isRefreshing}
+ >
+ <RefreshCw className={cn("h-4 w-4 mr-2", isRefreshing && "animate-spin")} />
+ 새로고침
+ </Button>
+ </div>
+ ), [selectedRows, isRefreshing, handleBulkDownload, handleRefresh]);
+
+ // 벤더별 그룹 카운트
+ const vendorCounts = React.useMemo(() => {
+ const counts = new Map<string, number>();
+ data.forEach(item => {
+ const vendor = item.vendorName || "Unknown";
+ counts.set(vendor, (counts.get(vendor) || 0) + 1);
+ });
+ return counts;
+ }, [data]);
+
+ return (
+ <div className={cn("w-full space-y-4")}>
+ {/* 벤더별 요약 정보 */}
+ <div className="flex gap-2 flex-wrap">
+ {Array.from(vendorCounts.entries()).map(([vendor, count]) => (
+ <Badge key={vendor} variant="secondary">
+ {vendor}: {count}
+ </Badge>
+ ))}
+ </div>
+
+ <ClientDataTable
+ columns={columns}
+ data={data}
+ advancedFilterFields={advancedFilterFields}
+ autoSizeColumns={true}
+ compact={true}
+ maxHeight="34rem"
+ onSelectedRowsChange={setSelectedRows}
+ initialColumnPinning={{
+ left: ["select", "vendorName"],
+ right: ["actions"],
+ }}
+ >
+ {additionalActions}
+ </ClientDataTable>
+ </div>
+ );
+} \ No newline at end of file