summaryrefslogtreecommitdiff
path: root/lib/swp/table
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-10-23 18:44:19 +0900
committerjoonhoekim <26rote@gmail.com>2025-10-23 18:44:19 +0900
commit04bd1965c3699a4b29ed9c9627574bfeedd3d6c6 (patch)
tree691b9a6e844a788937a240d47e77e8cfa848a88a /lib/swp/table
parent535e234dbd674bf2e5ecf344e03ed8ae5b2cbd6c (diff)
(김준회) SWP 문서 업로드 (Submisssion) 초기 개발건
Diffstat (limited to 'lib/swp/table')
-rw-r--r--lib/swp/table/swp-table-columns.tsx394
-rw-r--r--lib/swp/table/swp-table-toolbar.tsx340
-rw-r--r--lib/swp/table/swp-table.tsx394
3 files changed, 1128 insertions, 0 deletions
diff --git a/lib/swp/table/swp-table-columns.tsx b/lib/swp/table/swp-table-columns.tsx
new file mode 100644
index 00000000..dd605453
--- /dev/null
+++ b/lib/swp/table/swp-table-columns.tsx
@@ -0,0 +1,394 @@
+"use client";
+
+import { ColumnDef } from "@tanstack/react-table";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { ChevronDown, ChevronRight, FileIcon } from "lucide-react";
+import { formatDistanceToNow } from "date-fns";
+import { ko } from "date-fns/locale";
+import type { SwpDocumentWithStats } from "../actions";
+
+export const swpDocumentColumns: ColumnDef<SwpDocumentWithStats>[] = [
+ {
+ id: "expander",
+ header: () => null,
+ cell: ({ row }) => {
+ return row.getCanExpand() ? (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={row.getToggleExpandedHandler()}
+ className="h-8 w-8 p-0"
+ >
+ {row.getIsExpanded() ? (
+ <ChevronDown className="h-4 w-4" />
+ ) : (
+ <ChevronRight className="h-4 w-4" />
+ )}
+ </Button>
+ ) : null;
+ },
+ size: 50,
+ },
+ {
+ accessorKey: "DOC_NO",
+ header: "문서번호",
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">{row.original.DOC_NO}</div>
+ ),
+ size: 250,
+ },
+ {
+ accessorKey: "DOC_TITLE",
+ header: "문서제목",
+ cell: ({ row }) => (
+ <div className="max-w-md truncate" title={row.original.DOC_TITLE}>
+ {row.original.DOC_TITLE}
+ </div>
+ ),
+ size: 300,
+ },
+ {
+ accessorKey: "PROJ_NO",
+ header: "프로젝트",
+ cell: ({ row }) => (
+ <div>
+ <div className="font-medium">{row.original.PROJ_NO}</div>
+ {row.original.PROJ_NM && (
+ <div className="text-xs text-muted-foreground truncate max-w-[150px]">
+ {row.original.PROJ_NM}
+ </div>
+ )}
+ </div>
+ ),
+ size: 150,
+ },
+ {
+ accessorKey: "PKG_NO",
+ header: "패키지",
+ cell: ({ row }) => row.original.PKG_NO || "-",
+ size: 100,
+ },
+ {
+ accessorKey: "VNDR_CD",
+ header: "업체",
+ cell: ({ row }) => (
+ <div>
+ {row.original.VNDR_CD && (
+ <div className="text-xs text-muted-foreground">{row.original.VNDR_CD}</div>
+ )}
+ {row.original.CPY_NM && (
+ <div className="text-sm truncate max-w-[120px]" title={row.original.CPY_NM}>
+ {row.original.CPY_NM}
+ </div>
+ )}
+ </div>
+ ),
+ size: 120,
+ },
+ {
+ accessorKey: "STAGE",
+ header: "스테이지",
+ cell: ({ row }) => {
+ const stage = row.original.STAGE;
+ if (!stage) return "-";
+
+ const color =
+ stage === "IFC" ? "bg-green-100 text-green-800" :
+ stage === "IFA" ? "bg-blue-100 text-blue-800" :
+ "bg-gray-100 text-gray-800";
+
+ return (
+ <Badge variant="outline" className={color}>
+ {stage}
+ </Badge>
+ );
+ },
+ size: 80,
+ },
+ {
+ accessorKey: "LTST_REV_NO",
+ header: "최신 REV",
+ cell: ({ row }) => row.original.LTST_REV_NO || "-",
+ size: 80,
+ },
+ {
+ id: "stats",
+ header: "REV/파일",
+ cell: ({ row }) => (
+ <div className="text-center">
+ <div className="text-sm font-medium">
+ {row.original.revision_count} / {row.original.file_count}
+ </div>
+ </div>
+ ),
+ size: 100,
+ },
+ {
+ accessorKey: "sync_status",
+ header: "상태",
+ cell: ({ row }) => {
+ const status = row.original.sync_status;
+ const color =
+ status === "synced" ? "bg-green-100 text-green-800" :
+ status === "pending" ? "bg-yellow-100 text-yellow-800" :
+ "bg-red-100 text-red-800";
+
+ return (
+ <Badge variant="outline" className={color}>
+ {status}
+ </Badge>
+ );
+ },
+ size: 80,
+ },
+ {
+ accessorKey: "last_synced_at",
+ header: "동기화",
+ cell: ({ row }) => (
+ <div className="text-xs text-muted-foreground">
+ {formatDistanceToNow(new Date(row.original.last_synced_at), {
+ addSuffix: true,
+ locale: ko,
+ })}
+ </div>
+ ),
+ size: 100,
+ },
+];
+
+// ============================================================================
+// 리비전 컬럼 (서브 테이블용)
+// ============================================================================
+
+export interface RevisionRow {
+ id: number;
+ DOC_NO: string;
+ REV_NO: string;
+ STAGE: string;
+ ACTV_NO: string | null;
+ OFDC_NO: string | null;
+ sync_status: "synced" | "pending" | "error";
+ last_synced_at: Date;
+ file_count: number;
+}
+
+export const swpRevisionColumns: ColumnDef<RevisionRow>[] = [
+ {
+ id: "expander",
+ header: () => null,
+ cell: ({ row }) => {
+ return row.getCanExpand() ? (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={row.getToggleExpandedHandler()}
+ className="h-8 w-8 p-0 ml-8"
+ >
+ {row.getIsExpanded() ? (
+ <ChevronDown className="h-4 w-4" />
+ ) : (
+ <ChevronRight className="h-4 w-4" />
+ )}
+ </Button>
+ ) : null;
+ },
+ size: 100,
+ },
+ {
+ accessorKey: "REV_NO",
+ header: "리비전",
+ cell: ({ row }) => (
+ <Badge variant="secondary" className="font-mono">
+ REV {row.original.REV_NO}
+ </Badge>
+ ),
+ size: 100,
+ },
+ {
+ accessorKey: "STAGE",
+ header: "스테이지",
+ cell: ({ row }) => {
+ const stage = row.original.STAGE;
+ const color =
+ stage === "IFC" ? "bg-green-100 text-green-800" :
+ stage === "IFA" ? "bg-blue-100 text-blue-800" :
+ "bg-gray-100 text-gray-800";
+
+ return (
+ <Badge variant="outline" className={color}>
+ {stage}
+ </Badge>
+ );
+ },
+ size: 100,
+ },
+ {
+ accessorKey: "OFDC_NO",
+ header: "OFDC 번호",
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">{row.original.OFDC_NO || "-"}</div>
+ ),
+ size: 200,
+ },
+ {
+ accessorKey: "ACTV_NO",
+ header: "Activity",
+ cell: ({ row }) => (
+ <div className="font-mono text-xs text-muted-foreground">
+ {row.original.ACTV_NO || "-"}
+ </div>
+ ),
+ size: 250,
+ },
+ {
+ id: "file_count",
+ header: "파일 수",
+ cell: ({ row }) => (
+ <div className="flex items-center gap-2">
+ <FileIcon className="h-4 w-4 text-muted-foreground" />
+ <span className="font-medium">{row.original.file_count}</span>
+ </div>
+ ),
+ size: 100,
+ },
+ {
+ accessorKey: "sync_status",
+ header: "상태",
+ cell: ({ row }) => {
+ const status = row.original.sync_status;
+ const color =
+ status === "synced" ? "bg-green-100 text-green-800" :
+ status === "pending" ? "bg-yellow-100 text-yellow-800" :
+ "bg-red-100 text-red-800";
+
+ return (
+ <Badge variant="outline" className={color}>
+ {status}
+ </Badge>
+ );
+ },
+ size: 80,
+ },
+ {
+ accessorKey: "last_synced_at",
+ header: "동기화",
+ cell: ({ row }) => (
+ <div className="text-xs text-muted-foreground">
+ {formatDistanceToNow(new Date(row.original.last_synced_at), {
+ addSuffix: true,
+ locale: ko,
+ })}
+ </div>
+ ),
+ size: 100,
+ },
+];
+
+// ============================================================================
+// 파일 컬럼 (서브 서브 테이블용)
+// ============================================================================
+
+export interface FileRow {
+ id: number;
+ FILE_NM: string;
+ FILE_SEQ: string;
+ FILE_SZ: string | null;
+ FLD_PATH: string | null;
+ STAT: string | null;
+ STAT_NM: string | null;
+ sync_status: "synced" | "pending" | "error";
+ created_at: Date;
+}
+
+export const swpFileColumns: ColumnDef<FileRow>[] = [
+ {
+ id: "spacer",
+ header: () => null,
+ cell: () => <div className="w-16" />,
+ size: 150,
+ },
+ {
+ accessorKey: "FILE_SEQ",
+ header: "순서",
+ cell: ({ row }) => (
+ <Badge variant="outline" className="font-mono">
+ #{row.original.FILE_SEQ}
+ </Badge>
+ ),
+ size: 80,
+ },
+ {
+ accessorKey: "FILE_NM",
+ header: "파일명",
+ cell: ({ row }) => (
+ <div className="flex items-center gap-2">
+ <FileIcon className="h-4 w-4 text-blue-500" />
+ <span className="font-mono text-sm">{row.original.FILE_NM}</span>
+ </div>
+ ),
+ size: 400,
+ },
+ {
+ accessorKey: "FILE_SZ",
+ header: "크기",
+ cell: ({ row }) => {
+ const size = row.original.FILE_SZ;
+ if (!size) return "-";
+
+ const bytes = parseInt(size, 10);
+ if (isNaN(bytes)) return size;
+
+ const kb = bytes / 1024;
+ const mb = kb / 1024;
+
+ return mb >= 1
+ ? `${mb.toFixed(2)} MB`
+ : `${kb.toFixed(2)} KB`;
+ },
+ size: 100,
+ },
+ {
+ accessorKey: "STAT_NM",
+ header: "상태",
+ cell: ({ row }) => {
+ const status = row.original.STAT_NM;
+ if (!status) return "-";
+
+ const color = status === "Complete"
+ ? "bg-green-100 text-green-800"
+ : "bg-gray-100 text-gray-800";
+
+ return (
+ <Badge variant="outline" className={color}>
+ {status}
+ </Badge>
+ );
+ },
+ size: 100,
+ },
+ {
+ accessorKey: "FLD_PATH",
+ header: "경로",
+ cell: ({ row }) => (
+ <div className="font-mono text-xs text-muted-foreground truncate max-w-[200px]" title={row.original.FLD_PATH || ""}>
+ {row.original.FLD_PATH || "-"}
+ </div>
+ ),
+ size: 200,
+ },
+ {
+ accessorKey: "created_at",
+ header: "생성일",
+ cell: ({ row }) => (
+ <div className="text-xs text-muted-foreground">
+ {formatDistanceToNow(new Date(row.original.created_at), {
+ addSuffix: true,
+ locale: ko,
+ })}
+ </div>
+ ),
+ size: 100,
+ },
+];
+
diff --git a/lib/swp/table/swp-table-toolbar.tsx b/lib/swp/table/swp-table-toolbar.tsx
new file mode 100644
index 00000000..656dfd4a
--- /dev/null
+++ b/lib/swp/table/swp-table-toolbar.tsx
@@ -0,0 +1,340 @@
+"use client";
+
+import { useState, useTransition, useMemo } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { Label } from "@/components/ui/label";
+import { RefreshCw, Download, Search, X, Check, ChevronsUpDown } from "lucide-react";
+import { syncSwpProjectAction, type SwpTableFilters } from "../actions";
+import { useToast } from "@/hooks/use-toast";
+import { useRouter } from "next/navigation";
+import { cn } from "@/lib/utils";
+
+interface SwpTableToolbarProps {
+ filters: SwpTableFilters;
+ onFiltersChange: (filters: SwpTableFilters) => void;
+ projects?: Array<{ PROJ_NO: string; PROJ_NM: string }>;
+ mode?: "admin" | "vendor"; // admin: SWP 동기화 가능, vendor: 읽기 전용
+}
+
+export function SwpTableToolbar({
+ filters,
+ onFiltersChange,
+ projects = [],
+ mode = "admin",
+}: SwpTableToolbarProps) {
+ const [isSyncing, startSync] = useTransition();
+ const [localFilters, setLocalFilters] = useState<SwpTableFilters>(filters);
+ const { toast } = useToast();
+ const router = useRouter();
+ const [projectSearchOpen, setProjectSearchOpen] = useState(false);
+ const [projectSearch, setProjectSearch] = useState("");
+
+ // 동기화 핸들러
+ const handleSync = () => {
+ const projectNo = localFilters.projNo;
+
+ if (!projectNo) {
+ toast({
+ variant: "destructive",
+ title: "프로젝트 선택 필요",
+ description: "동기화할 프로젝트를 먼저 선택해주세요.",
+ });
+ return;
+ }
+
+ startSync(async () => {
+ try {
+ toast({
+ title: "동기화 시작",
+ description: `프로젝트 ${projectNo} 동기화를 시작합니다...`,
+ });
+
+ const result = await syncSwpProjectAction(projectNo, "V");
+
+ if (result.success) {
+ toast({
+ title: "동기화 완료",
+ description: `문서 ${result.stats.documents.total}개, 파일 ${result.stats.files.total}개 동기화 완료`,
+ });
+
+ // 페이지 새로고침
+ router.refresh();
+ } else {
+ throw new Error(result.errors.join(", "));
+ }
+ } catch (error) {
+ console.error("동기화 실패:", error);
+ toast({
+ variant: "destructive",
+ title: "동기화 실패",
+ description: error instanceof Error ? error.message : "알 수 없는 오류",
+ });
+ }
+ });
+ };
+
+ // 검색 적용
+ const handleSearch = () => {
+ onFiltersChange(localFilters);
+ };
+
+ // 검색 초기화
+ const handleReset = () => {
+ const resetFilters: SwpTableFilters = {};
+ setLocalFilters(resetFilters);
+ onFiltersChange(resetFilters);
+ };
+
+ // 프로젝트 필터링
+ const filteredProjects = useMemo(() => {
+ if (!projectSearch) return projects;
+
+ const search = projectSearch.toLowerCase();
+ return projects.filter(
+ (proj) =>
+ proj.PROJ_NO.toLowerCase().includes(search) ||
+ proj.PROJ_NM.toLowerCase().includes(search)
+ );
+ }, [projects, projectSearch]);
+
+ return (
+ <div className="space-y-4">
+ {/* 상단 액션 바 */}
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ {mode === "admin" && (
+ <Button
+ onClick={handleSync}
+ disabled={isSyncing || !localFilters.projNo}
+ size="sm"
+ >
+ <RefreshCw className={`h-4 w-4 mr-2 ${isSyncing ? "animate-spin" : ""}`} />
+ {isSyncing ? "동기화 중..." : "SWP 동기화"}
+ </Button>
+ )}
+
+ <Button variant="outline" size="sm" disabled>
+ <Download className="h-4 w-4 mr-2" />
+ Excel 내보내기
+ </Button>
+ </div>
+
+ <div className="text-sm text-muted-foreground">
+ {mode === "vendor" ? "문서 조회 및 업로드" : "SWP 문서 관리 시스템"}
+ </div>
+ </div>
+
+ {/* 검색 필터 */}
+ <div className="rounded-lg border p-4 space-y-4">
+ <div className="flex items-center justify-between">
+ <h3 className="text-sm font-semibold">검색 필터</h3>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={handleReset}
+ className="h-8"
+ >
+ <X className="h-4 w-4 mr-1" />
+ 초기화
+ </Button>
+ </div>
+
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+ {/* 프로젝트 번호 */}
+ <div className="space-y-2">
+ <Label htmlFor="projNo">프로젝트 번호</Label>
+ {projects.length > 0 ? (
+ <Popover open={projectSearchOpen} onOpenChange={setProjectSearchOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={projectSearchOpen}
+ className="w-full justify-between"
+ >
+ {localFilters.projNo ? (
+ <span className="truncate">
+ {projects.find((p) => p.PROJ_NO === localFilters.projNo)?.PROJ_NO || localFilters.projNo}
+ {" - "}
+ {projects.find((p) => p.PROJ_NO === localFilters.projNo)?.PROJ_NM}
+ </span>
+ ) : (
+ <span className="text-muted-foreground">프로젝트 선택</span>
+ )}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0" align="start">
+ <div className="p-2">
+ <div className="flex items-center border rounded-md px-3">
+ <Search className="h-4 w-4 mr-2 opacity-50" />
+ <Input
+ placeholder="프로젝트 번호 또는 이름으로 검색..."
+ value={projectSearch}
+ onChange={(e) => setProjectSearch(e.target.value)}
+ className="border-0 focus-visible:ring-0 focus-visible:ring-offset-0"
+ />
+ </div>
+ </div>
+ <div className="max-h-[300px] overflow-y-auto">
+ <div className="p-1">
+ <Button
+ variant="ghost"
+ className="w-full justify-start font-normal"
+ onClick={() => {
+ setLocalFilters({ ...localFilters, projNo: undefined });
+ setProjectSearchOpen(false);
+ setProjectSearch("");
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ !localFilters.projNo ? "opacity-100" : "opacity-0"
+ )}
+ />
+ 전체
+ </Button>
+ {filteredProjects.map((proj) => (
+ <Button
+ key={proj.PROJ_NO}
+ variant="ghost"
+ className="w-full justify-start font-normal"
+ onClick={() => {
+ setLocalFilters({ ...localFilters, projNo: proj.PROJ_NO });
+ setProjectSearchOpen(false);
+ setProjectSearch("");
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ localFilters.projNo === proj.PROJ_NO ? "opacity-100" : "opacity-0"
+ )}
+ />
+ <div className="flex flex-col items-start">
+ <span className="font-mono text-sm">{proj.PROJ_NO}</span>
+ <span className="text-xs text-muted-foreground">{proj.PROJ_NM}</span>
+ </div>
+ </Button>
+ ))}
+ {filteredProjects.length === 0 && (
+ <div className="py-6 text-center text-sm text-muted-foreground">
+ 검색 결과가 없습니다.
+ </div>
+ )}
+ </div>
+ </div>
+ </PopoverContent>
+ </Popover>
+ ) : (
+ <Input
+ id="projNo"
+ placeholder="예: SN2190"
+ value={localFilters.projNo || ""}
+ onChange={(e) =>
+ setLocalFilters({ ...localFilters, projNo: e.target.value })
+ }
+ />
+ )}
+ </div>
+
+ {/* 문서 번호 */}
+ <div className="space-y-2">
+ <Label htmlFor="docNo">문서 번호</Label>
+ <Input
+ id="docNo"
+ placeholder="문서 번호 검색"
+ value={localFilters.docNo || ""}
+ onChange={(e) =>
+ setLocalFilters({ ...localFilters, docNo: e.target.value })
+ }
+ />
+ </div>
+
+ {/* 문서 제목 */}
+ <div className="space-y-2">
+ <Label htmlFor="docTitle">문서 제목</Label>
+ <Input
+ id="docTitle"
+ placeholder="제목 검색"
+ value={localFilters.docTitle || ""}
+ onChange={(e) =>
+ setLocalFilters({ ...localFilters, docTitle: e.target.value })
+ }
+ />
+ </div>
+
+ {/* 패키지 번호 */}
+ <div className="space-y-2">
+ <Label htmlFor="pkgNo">패키지</Label>
+ <Input
+ id="pkgNo"
+ placeholder="패키지 번호"
+ value={localFilters.pkgNo || ""}
+ onChange={(e) =>
+ setLocalFilters({ ...localFilters, pkgNo: e.target.value })
+ }
+ />
+ </div>
+
+ {/* 업체 코드 */}
+ <div className="space-y-2">
+ <Label htmlFor="vndrCd">업체 코드</Label>
+ <Input
+ id="vndrCd"
+ placeholder="업체 코드"
+ value={localFilters.vndrCd || ""}
+ onChange={(e) =>
+ setLocalFilters({ ...localFilters, vndrCd: e.target.value })
+ }
+ />
+ </div>
+
+ {/* 스테이지 */}
+ <div className="space-y-2">
+ <Label htmlFor="stage">스테이지</Label>
+ <Select
+ value={localFilters.stage || "__all__"}
+ onValueChange={(value) =>
+ setLocalFilters({ ...localFilters, stage: value === "__all__" ? undefined : value })
+ }
+ >
+ <SelectTrigger id="stage">
+ <SelectValue placeholder="전체" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="__all__">전체</SelectItem>
+ <SelectItem value="IFA">IFA</SelectItem>
+ <SelectItem value="IFC">IFC</SelectItem>
+ <SelectItem value="AFC">AFC</SelectItem>
+ <SelectItem value="BFC">BFC</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+
+ <div className="flex justify-end">
+ <Button onClick={handleSearch} size="sm">
+ <Search className="h-4 w-4 mr-2" />
+ 검색
+ </Button>
+ </div>
+ </div>
+ </div>
+ );
+}
+
diff --git a/lib/swp/table/swp-table.tsx b/lib/swp/table/swp-table.tsx
new file mode 100644
index 00000000..4024c711
--- /dev/null
+++ b/lib/swp/table/swp-table.tsx
@@ -0,0 +1,394 @@
+"use client";
+
+import { useState } from "react";
+import {
+ useReactTable,
+ getCoreRowModel,
+ getExpandedRowModel,
+ flexRender,
+ ExpandedState,
+} from "@tanstack/react-table";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { Button } from "@/components/ui/button";
+import { Loader2 } from "lucide-react";
+import { swpDocumentColumns, swpRevisionColumns, swpFileColumns, type RevisionRow, type FileRow } from "./swp-table-columns";
+import { fetchDocumentRevisions, fetchRevisionFiles, type SwpDocumentWithStats } from "../actions";
+
+interface SwpTableProps {
+ initialData: SwpDocumentWithStats[];
+ total: number;
+ page: number;
+ pageSize: number;
+ totalPages: number;
+ onPageChange: (page: number) => void;
+ mode?: "admin" | "vendor";
+}
+
+export function SwpTable({
+ initialData,
+ total,
+ page,
+ pageSize,
+ totalPages,
+ onPageChange,
+ mode = "admin",
+}: SwpTableProps) {
+ const [expanded, setExpanded] = useState<ExpandedState>({});
+ const [revisionData, setRevisionData] = useState<Record<string, RevisionRow[]>>({});
+ const [fileData, setFileData] = useState<Record<number, FileRow[]>>({});
+ const [loadingRevisions, setLoadingRevisions] = useState<Set<string>>(new Set());
+ const [loadingFiles, setLoadingFiles] = useState<Set<number>>(new Set());
+
+ const table = useReactTable({
+ data: initialData,
+ columns: swpDocumentColumns,
+ state: {
+ expanded,
+ },
+ onExpandedChange: setExpanded,
+ getCoreRowModel: getCoreRowModel(),
+ getExpandedRowModel: getExpandedRowModel(),
+ getRowCanExpand: (row) => true, // 모든 문서는 확장 가능
+ });
+
+ // 리비전 로드
+ const loadRevisions = async (docNo: string) => {
+ if (revisionData[docNo]) return; // 이미 로드됨
+
+ setLoadingRevisions((prev) => {
+ const newSet = new Set(prev);
+ newSet.add(docNo);
+ return newSet;
+ });
+
+ try {
+ const revisions = await fetchDocumentRevisions(docNo);
+ setRevisionData((prev) => ({ ...prev, [docNo]: revisions }));
+ } catch (error) {
+ console.error("리비전 로드 실패:", error);
+ } finally {
+ setLoadingRevisions((prev) => {
+ const next = new Set(prev);
+ next.delete(docNo);
+ return next;
+ });
+ }
+ };
+
+ // 파일 로드
+ const loadFiles = async (revisionId: number) => {
+ if (fileData[revisionId]) return; // 이미 로드됨
+
+ setLoadingFiles((prev) => {
+ const newSet = new Set(prev);
+ newSet.add(revisionId);
+ return newSet;
+ });
+
+ try {
+ const files = await fetchRevisionFiles(revisionId);
+ setFileData((prev) => ({ ...prev, [revisionId]: files }));
+ } catch (error) {
+ console.error("파일 로드 실패:", error);
+ } finally {
+ setLoadingFiles((prev) => {
+ const next = new Set(prev);
+ next.delete(revisionId);
+ return next;
+ });
+ }
+ };
+
+ // 문서 행 확장 핸들러
+ const handleDocumentExpand = (docNo: string, isExpanded: boolean) => {
+ if (!isExpanded) {
+ loadRevisions(docNo);
+ }
+ };
+
+ return (
+ <div className="space-y-4">
+ {/* 테이블 */}
+ <div className="rounded-md border">
+ <Table>
+ <TableHeader>
+ {table.getHeaderGroups().map((headerGroup) => (
+ <TableRow key={headerGroup.id}>
+ {headerGroup.headers.map((header) => (
+ <TableHead key={header.id}>
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+ </TableHead>
+ ))}
+ </TableRow>
+ ))}
+ </TableHeader>
+ <TableBody>
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+ <>
+ {/* 문서 행 */}
+ <TableRow
+ key={row.id}
+ data-state={row.getIsSelected() && "selected"}
+ className="hover:bg-muted/50"
+ >
+ {row.getVisibleCells().map((cell) => (
+ <TableCell key={cell.id}>
+ {cell.column.id === "expander" ? (
+ <div
+ onClick={() => {
+ row.toggleExpanded();
+ handleDocumentExpand(row.original.DOC_NO, row.getIsExpanded());
+ }}
+ >
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+ </div>
+ ) : (
+ flexRender(cell.column.columnDef.cell, cell.getContext())
+ )}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 리비전 행들 (확장 시) */}
+ {row.getIsExpanded() && (
+ <TableRow>
+ <TableCell colSpan={swpDocumentColumns.length} className="p-0 bg-muted/30">
+ {loadingRevisions.has(row.original.DOC_NO) ? (
+ <div className="flex items-center justify-center p-8">
+ <Loader2 className="h-6 w-6 animate-spin" />
+ <span className="ml-2">리비전 로딩 중...</span>
+ </div>
+ ) : revisionData[row.original.DOC_NO]?.length ? (
+ <RevisionSubTable
+ revisions={revisionData[row.original.DOC_NO]}
+ fileData={fileData}
+ loadingFiles={loadingFiles}
+ onLoadFiles={loadFiles}
+ />
+ ) : (
+ <div className="p-8 text-center text-muted-foreground">
+ 리비전 없음
+ </div>
+ )}
+ </TableCell>
+ </TableRow>
+ )}
+ </>
+ ))
+ ) : (
+ <TableRow>
+ <TableCell colSpan={swpDocumentColumns.length} className="h-24 text-center">
+ 데이터 없음
+ </TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
+ </div>
+
+ {/* 페이지네이션 */}
+ <div className="flex items-center justify-between">
+ <div className="text-sm text-muted-foreground">
+ 총 {total}개 중 {(page - 1) * pageSize + 1}-
+ {Math.min(page * pageSize, total)}개 표시
+ </div>
+ <div className="flex items-center space-x-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => onPageChange(page - 1)}
+ disabled={page === 1}
+ >
+ 이전
+ </Button>
+ <div className="text-sm">
+ {page} / {totalPages}
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => onPageChange(page + 1)}
+ disabled={page === totalPages}
+ >
+ 다음
+ </Button>
+ </div>
+ </div>
+ </div>
+ );
+}
+
+// ============================================================================
+// 리비전 서브 테이블
+// ============================================================================
+
+interface RevisionSubTableProps {
+ revisions: RevisionRow[];
+ fileData: Record<number, FileRow[]>;
+ loadingFiles: Set<number>;
+ onLoadFiles: (revisionId: number) => void;
+}
+
+function RevisionSubTable({
+ revisions,
+ fileData,
+ loadingFiles,
+ onLoadFiles,
+}: RevisionSubTableProps) {
+ const [expandedRevisions, setExpandedRevisions] = useState<ExpandedState>({});
+
+ const revisionTable = useReactTable({
+ data: revisions,
+ columns: swpRevisionColumns,
+ state: {
+ expanded: expandedRevisions,
+ },
+ onExpandedChange: setExpandedRevisions,
+ getCoreRowModel: getCoreRowModel(),
+ getExpandedRowModel: getExpandedRowModel(),
+ getRowCanExpand: () => true,
+ });
+
+ const handleRevisionExpand = (revisionId: number, isExpanded: boolean) => {
+ if (!isExpanded) {
+ onLoadFiles(revisionId);
+ }
+ };
+
+ return (
+ <div className="border-l-4 border-blue-200">
+ <Table>
+ <TableHeader>
+ {revisionTable.getHeaderGroups().map((headerGroup) => (
+ <TableRow key={headerGroup.id} className="bg-muted/50">
+ {headerGroup.headers.map((header) => (
+ <TableHead key={header.id} className="font-semibold">
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+ </TableHead>
+ ))}
+ </TableRow>
+ ))}
+ </TableHeader>
+ <TableBody>
+ {revisionTable.getRowModel().rows.map((row) => (
+ <>
+ {/* 리비전 행 */}
+ <TableRow key={row.id} className="bg-muted/20">
+ {row.getVisibleCells().map((cell) => (
+ <TableCell key={cell.id}>
+ {cell.column.id === "expander" ? (
+ <div
+ onClick={() => {
+ row.toggleExpanded();
+ handleRevisionExpand(row.original.id, row.getIsExpanded());
+ }}
+ >
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+ </div>
+ ) : (
+ flexRender(cell.column.columnDef.cell, cell.getContext())
+ )}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 파일 행들 (확장 시) */}
+ {row.getIsExpanded() && (
+ <TableRow>
+ <TableCell colSpan={swpRevisionColumns.length} className="p-0 bg-blue-50/30">
+ {loadingFiles.has(row.original.id) ? (
+ <div className="flex items-center justify-center p-4">
+ <Loader2 className="h-5 w-5 animate-spin" />
+ <span className="ml-2 text-sm">파일 로딩 중...</span>
+ </div>
+ ) : fileData[row.original.id]?.length ? (
+ <FileSubTable files={fileData[row.original.id]} />
+ ) : (
+ <div className="p-4 text-center text-sm text-muted-foreground">
+ 파일 없음
+ </div>
+ )}
+ </TableCell>
+ </TableRow>
+ )}
+ </>
+ ))}
+ </TableBody>
+ </Table>
+ </div>
+ );
+}
+
+// ============================================================================
+// 파일 서브 테이블
+// ============================================================================
+
+interface FileSubTableProps {
+ files: FileRow[];
+}
+
+function FileSubTable({ files }: FileSubTableProps) {
+ const fileTable = useReactTable({
+ data: files,
+ columns: swpFileColumns,
+ getCoreRowModel: getCoreRowModel(),
+ });
+
+ return (
+ <div className="border-l-4 border-green-200">
+ <Table>
+ <TableHeader>
+ {fileTable.getHeaderGroups().map((headerGroup) => (
+ <TableRow key={headerGroup.id} className="bg-blue-50/50">
+ {headerGroup.headers.map((header) => (
+ <TableHead key={header.id} className="font-semibold text-xs">
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+ </TableHead>
+ ))}
+ </TableRow>
+ ))}
+ </TableHeader>
+ <TableBody>
+ {fileTable.getRowModel().rows.map((row) => (
+ <TableRow key={row.id} className="bg-green-50/20 hover:bg-green-50/40">
+ {row.getVisibleCells().map((cell) => (
+ <TableCell key={cell.id} className="py-2">
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+ </TableCell>
+ ))}
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </div>
+ );
+}