summaryrefslogtreecommitdiff
path: root/lib/rfq-last/attachment
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-09-05 11:44:32 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-09-05 11:44:32 +0000
commit50adedf48ee4674ebe00f1ee72d93485183cdc51 (patch)
tree18053ab04d94c750028eee5d5d2f16ba4f38f50e /lib/rfq-last/attachment
parent66d64b482f2b6b52b0dd396ef998f27d491c70dd (diff)
(대표님, 최겸, 임수민) EDP 입력 진행률, 견적목록관리, EDP excel import 오류수정, GTC-Contract
Diffstat (limited to 'lib/rfq-last/attachment')
-rw-r--r--lib/rfq-last/attachment/revision-historty-dialog.tsx305
-rw-r--r--lib/rfq-last/attachment/rfq-attachments-table.tsx370
-rw-r--r--lib/rfq-last/attachment/vendor-response-table.tsx519
3 files changed, 1029 insertions, 165 deletions
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<AttachmentWithHistory | null>(null);
+ const [error, setError] = React.useState<string | null>(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 (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-5xl max-h-[80vh]">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <FileText className="h-5 w-5" />
+ 리비전 히스토리
+ </DialogTitle>
+ <DialogDescription>
+ {historyData?.originalFileName || attachmentName || "파일"}의 모든 버전 히스토리를 확인할 수 있습니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="mt-4">
+ {loading ? (
+ <div className="space-y-3">
+ <Skeleton className="h-10 w-full" />
+ <Skeleton className="h-10 w-full" />
+ <Skeleton className="h-10 w-full" />
+ </div>
+ ) : error ? (
+ <Alert variant="destructive">
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>{error}</AlertDescription>
+ </Alert>
+ ) : historyData ? (
+ <>
+ {/* 파일 정보 헤더 */}
+ <div className="mb-4 p-3 bg-muted rounded-lg">
+ <div className="grid grid-cols-2 gap-2 text-sm">
+ <div>
+ <span className="text-muted-foreground">일련번호:</span>{" "}
+ <span className="font-medium font-mono">{historyData.serialNo || "-"}</span>
+ </div>
+ <div>
+ <span className="text-muted-foreground">현재 리비전:</span>{" "}
+ <Badge variant="default" className="ml-1">
+ Rev. {historyData.currentRevision || "A"}
+ </Badge>
+ </div>
+ {historyData.description && (
+ <div className="col-span-2">
+ <span className="text-muted-foreground">설명:</span>{" "}
+ <span className="font-medium">{historyData.description}</span>
+ </div>
+ )}
+ </div>
+ </div>
+
+ {/* 리비전 테이블 */}
+ <ScrollArea className="h-[400px] rounded-md border">
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-[80px]">리비전</TableHead>
+ <TableHead>파일명</TableHead>
+ <TableHead className="w-[80px]">크기</TableHead>
+ <TableHead className="w-[100px]">업로드자</TableHead>
+ <TableHead className="w-[150px]">업로드일시</TableHead>
+ <TableHead>코멘트</TableHead>
+ <TableHead className="w-[100px] text-center">작업</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {historyData.revisions.length > 0 ? (
+ historyData.revisions.map((revision) => (
+ <TableRow key={revision.id}>
+ <TableCell>
+ <Badge
+ variant={getRevisionBadgeVariant(revision.isLatest)}
+ className="font-mono"
+ >
+ Rev. {revision.revisionNo}
+ {revision.isLatest && (
+ <CheckCircle className="ml-1 h-3 w-3" />
+ )}
+ </Badge>
+ </TableCell>
+ <TableCell>
+ <div className="flex flex-col">
+ <span className="text-sm font-medium truncate max-w-[200px]" title={revision.originalFileName}>
+ {revision.originalFileName}
+ </span>
+ {revision.fileName !== revision.originalFileName && (
+ <span className="text-xs text-muted-foreground truncate max-w-[200px]">
+ ({revision.fileName})
+ </span>
+ )}
+ </div>
+ </TableCell>
+ <TableCell>
+ <span className="text-sm text-muted-foreground">
+ {formatFileSize(revision.fileSize)}
+ </span>
+ </TableCell>
+ <TableCell>
+ <div className="flex items-center gap-1">
+ <User className="h-3 w-3 text-muted-foreground" />
+ <span className="text-sm">
+ {revision.createdByName || "Unknown"}
+ </span>
+ </div>
+ </TableCell>
+ <TableCell>
+ <div className="flex items-center gap-1">
+ <Clock className="h-3 w-3 text-muted-foreground" />
+ <span className="text-sm">
+ {format(new Date(revision.createdAt), "yyyy-MM-dd HH:mm")}
+ </span>
+ </div>
+ <span className="text-xs text-muted-foreground">
+ {formatDistanceToNow(new Date(revision.createdAt), {
+ addSuffix: true,
+ locale: ko,
+ })}
+ </span>
+ </TableCell>
+ <TableCell>
+ {revision.revisionComment ? (
+ <div className="flex items-start gap-1">
+ <MessageSquare className="h-3 w-3 text-muted-foreground mt-0.5" />
+ <span className="text-sm text-muted-foreground">
+ {revision.revisionComment}
+ </span>
+ </div>
+ ) : (
+ <span className="text-sm text-muted-foreground">-</span>
+ )}
+ </TableCell>
+ <TableCell>
+ <div className="flex items-center justify-center gap-1">
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-8 w-8"
+ onClick={() => handleDownloadRevision(revision)}
+ title="다운로드"
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-8 w-8"
+ onClick={() => handlePreviewRevision(revision)}
+ title="미리보기"
+ >
+ <Eye className="h-4 w-4" />
+ </Button>
+ </div>
+ </TableCell>
+ </TableRow>
+ ))
+ ) : (
+ <TableRow>
+ <TableCell colSpan={7} className="text-center text-muted-foreground py-8">
+ 리비전 히스토리가 없습니다.
+ </TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
+ </ScrollArea>
+
+ {/* 요약 정보 */}
+ <div className="mt-4 flex items-center justify-between text-sm text-muted-foreground">
+ <span>총 {historyData.revisions.length}개의 리비전</span>
+ <span>
+ 최초 업로드:{" "}
+ {historyData.revisions.length > 0
+ ? format(
+ new Date(
+ historyData.revisions[historyData.revisions.length - 1].createdAt
+ ),
+ "yyyy년 MM월 dd일"
+ )
+ : "-"}
+ </span>
+ </div>
+ </>
+ ) : null}
+ </div>
+ </DialogContent>
+ </Dialog>
+ );
+} \ 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<ReturnType<typeof getRfqLastAttachments>>;
- initialPurchaseData: Awaited<ReturnType<typeof getRfqLastAttachments>>;
- 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<RfqAttachment[]>(initialData);
const [selectedAttachment, setSelectedAttachment] = React.useState<RfqAttachment | null>(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<RfqAttachment[]>([]);
+
+ // 탭에 따른 데이터 필터링
+ 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<RfqAttachment>) => {
@@ -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<RfqAttachment>) => void
- ): ColumnDef<RfqAttachment>[] => [
+ const columns: ColumnDef<RfqAttachment>[] = 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 }) => <DataTableColumnHeaderSimple column={column} title="일련번호" />,
+ header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="일련번호" />,
cell: ({ row }) => (
<span className="font-mono text-sm">{row.original.serialNo || "-"}</span>
),
size: 100,
+ meta: { excelHeader: "일련번호" },
+ enablePinning: true,
},
{
accessorKey: "originalFileName",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="파일명" />,
+ header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="파일명" />,
cell: ({ row }) => {
const file = row.original;
return (
@@ -224,11 +257,6 @@ export function RfqAttachmentsTable({
<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>
);
@@ -237,7 +265,7 @@ export function RfqAttachmentsTable({
},
{
accessorKey: "description",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="설명" />,
+ header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="설명" />,
cell: ({ row }) => (
<div className="max-w-[200px] truncate" title={row.original.description || ""}>
{row.original.description || "-"}
@@ -247,7 +275,7 @@ export function RfqAttachmentsTable({
},
{
accessorKey: "currentRevision",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="리비전" />,
+ header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="리비전" />,
cell: ({ row }) => {
const revision = row.original.currentRevision;
return revision ? (
@@ -262,7 +290,7 @@ export function RfqAttachmentsTable({
},
{
accessorKey: "fileSize",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="크기" />,
+ header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="크기" />,
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{formatFileSize(row.original.fileSize)}
@@ -271,29 +299,14 @@ export function RfqAttachmentsTable({
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="업로드자" />,
+ header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="업로드자" />,
cell: ({ row }) => row.original.createdByName || "-",
size: 100,
},
{
accessorKey: "createdAt",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="업로드일" />,
+ header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="업로드일" />,
cell: ({ row }) => {
const date = row.original.createdAt;
return date ? (
@@ -320,7 +333,7 @@ export function RfqAttachmentsTable({
},
{
accessorKey: "updatedAt",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="수정일" />,
+ header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="수정일" />,
cell: ({ row }) => {
const date = row.original.updatedAt;
return date ? format(new Date(date), "MM-dd HH:mm") : "-";
@@ -342,26 +355,26 @@ export function RfqAttachmentsTable({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
- <DropdownMenuItem onClick={() => onAction({ row, type: "download" })}>
+ <DropdownMenuItem onClick={() => handleAction({ row, type: "download" })}>
<Download className="mr-2 h-4 w-4" />
다운로드
</DropdownMenuItem>
- <DropdownMenuItem onClick={() => onAction({ row, type: "preview" })}>
+ <DropdownMenuItem onClick={() => handleAction({ row, type: "preview" })}>
<Eye className="mr-2 h-4 w-4" />
미리보기
</DropdownMenuItem>
<DropdownMenuSeparator />
- <DropdownMenuItem onClick={() => onAction({ row, type: "history" })}>
+ <DropdownMenuItem onClick={() => handleAction({ row, type: "history" })}>
<History className="mr-2 h-4 w-4" />
리비전 이력
</DropdownMenuItem>
- <DropdownMenuItem onClick={() => onAction({ row, type: "update" })}>
+ <DropdownMenuItem onClick={() => handleAction({ row, type: "update" })}>
<Upload className="mr-2 h-4 w-4" />
새 버전 업로드
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
- onClick={() => onAction({ row, type: "delete" })}
+ onClick={() => handleAction({ row, type: "delete" })}
className="text-red-600"
>
<Trash2 className="mr-2 h-4 w-4" />
@@ -372,17 +385,9 @@ export function RfqAttachmentsTable({
);
},
size: 60,
+ enablePinning: true,
},
- ], []);
-
- const columns = React.useMemo(() => getAttachmentColumns(handleAction), [getAttachmentColumns, handleAction]);
-
- const filterFields: DataTableFilterField<RfqAttachment>[] = [
- { id: "serialNo", label: "일련번호" },
- { id: "originalFileName", label: "파일명" },
- { id: "description", label: "설명" },
- { id: "createdByName", label: "업로드자" },
- ];
+ ], [handleAction]);
const advancedFilterFields: DataTableAdvancedFilterField<RfqAttachment>[] = [
{ 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(() => (
+ <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={handleBulkDelete}
+ className="text-red-600"
+ >
+ <Trash2 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>
+
+ {/* 구매 탭에서만 파일 업로드 버튼 표시 */}
+ {activeTab === "구매" && (
+ <AddAttachmentDialog
+ rfqId={rfqId}
+ attachmentType="구매"
+ onSuccess={handleRefresh}
+ open={addDialogOpen}
+ onOpenChange={setAddDialogOpen}
+ />
+ )}
+ </div>
+ ), [selectedRows, activeTab, isRefreshing, addDialogOpen, handleBulkDownload, handleBulkDelete, handleRefresh, rfqId]);
return (
- <div className={cn("w-full space-y-4", className)}>
- <Tabs value={activeTab} onValueChange={setActiveTab}>
+ <div className={cn("w-full space-y-4")}>
+ <Tabs
+ value={activeTab}
+ onValueChange={(value) => setActiveTab(value as '설계' | '구매')}
+ >
<div className="flex items-center justify-between mb-4">
<TabsList>
<TabsTrigger value="설계">
설계 첨부파일
<Badge variant="secondary" className="ml-2">
- {designData.data.length}
+ {designCount}
</Badge>
</TabsTrigger>
<TabsTrigger value="구매">
구매 첨부파일
<Badge variant="secondary" className="ml-2">
- {purchaseData.data.length}
+ {purchaseCount}
</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>
+
+ <ClientDataTable
+ columns={columns}
+ data={filteredData}
+ advancedFilterFields={advancedFilterFields}
+ autoSizeColumns={true}
+ compact={true}
+ maxHeight="34rem"
+ onSelectedRowsChange={setSelectedRows}
+ initialColumnPinning={{
+ left: ["select", "serialNo"],
+ right: ["actions"],
+ }}
+ >
+ {additionalActions}
+ </ClientDataTable>
+
</TabsContent>
<TabsContent value="구매" className="mt-0">
- <Card>
- <CardContent className="p-0">
- <DataTable table={purchaseTable}>
- <DataTableAdvancedToolbar
- table={purchaseTable}
- filterFields={advancedFilterFields}
- shallow={false}
- />
- </DataTable>
- </CardContent>
- </Card>
+
+ <ClientDataTable
+ columns={columns}
+ data={filteredData}
+ advancedFilterFields={advancedFilterFields}
+ autoSizeColumns={true}
+ compact={true}
+ maxHeight="34rem"
+ onSelectedRowsChange={setSelectedRows}
+ initialColumnPinning={{
+ left: ["select", "serialNo"],
+ right: ["actions"],
+ }}
+ >
+ {additionalActions}
+ </ClientDataTable>
+
</TabsContent>
</Tabs>
{/* 삭제 다이얼로그 */}
- {selectedAttachment && (
+ {(selectedAttachment || selectedRows.length > 0) && (
<DeleteAttachmentsDialog
open={deleteDialogOpen}
- onOpenChange={setDeleteDialogOpen}
- attachments={[selectedAttachment]}
+ onOpenChange={(open) => {
+ setDeleteDialogOpen(open);
+ if (!open) {
+ setSelectedAttachment(null);
+ }
+ }}
+ attachments={selectedAttachment ? [selectedAttachment] : selectedRows}
onSuccess={handleRefresh}
/>
)}
@@ -529,11 +549,31 @@ export function RfqAttachmentsTable({
{selectedAttachment && (
<UpdateRevisionDialog
open={updateRevisionDialogOpen}
- onOpenChange={setUpdateRevisionDialogOpen}
+ onOpenChange={(open) => {
+ setUpdateRevisionDialogOpen(open);
+ if (!open) {
+ setSelectedAttachment(null);
+ }
+ }}
attachment={selectedAttachment}
onSuccess={handleRefresh}
/>
)}
+
+ {/* 리비전 히스토리 다이얼로그 */}
+ {selectedAttachment && (
+ <RevisionHistoryDialog
+ open={revisionHistoryDialogOpen}
+ onOpenChange={(open) => {
+ setRevisionHistoryDialogOpen(open);
+ if (!open) {
+ setSelectedAttachment(null);
+ }
+ }}
+ attachmentId={selectedAttachment.id}
+ attachmentName={selectedAttachment.originalFileName || selectedAttachment.fileName || undefined}
+ />
+ )}
</div>
);
} \ 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 <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