From 04bd1965c3699a4b29ed9c9627574bfeedd3d6c6 Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Thu, 23 Oct 2025 18:44:19 +0900 Subject: (김준회) SWP 문서 업로드 (Submisssion) 초기 개발건 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/swp/table/swp-table-columns.tsx | 394 ++++++++++++++++++++++++++++++++++++ lib/swp/table/swp-table-toolbar.tsx | 340 +++++++++++++++++++++++++++++++ lib/swp/table/swp-table.tsx | 394 ++++++++++++++++++++++++++++++++++++ 3 files changed, 1128 insertions(+) create mode 100644 lib/swp/table/swp-table-columns.tsx create mode 100644 lib/swp/table/swp-table-toolbar.tsx create mode 100644 lib/swp/table/swp-table.tsx (limited to 'lib/swp/table') 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[] = [ + { + id: "expander", + header: () => null, + cell: ({ row }) => { + return row.getCanExpand() ? ( + + ) : null; + }, + size: 50, + }, + { + accessorKey: "DOC_NO", + header: "문서번호", + cell: ({ row }) => ( +
{row.original.DOC_NO}
+ ), + size: 250, + }, + { + accessorKey: "DOC_TITLE", + header: "문서제목", + cell: ({ row }) => ( +
+ {row.original.DOC_TITLE} +
+ ), + size: 300, + }, + { + accessorKey: "PROJ_NO", + header: "프로젝트", + cell: ({ row }) => ( +
+
{row.original.PROJ_NO}
+ {row.original.PROJ_NM && ( +
+ {row.original.PROJ_NM} +
+ )} +
+ ), + size: 150, + }, + { + accessorKey: "PKG_NO", + header: "패키지", + cell: ({ row }) => row.original.PKG_NO || "-", + size: 100, + }, + { + accessorKey: "VNDR_CD", + header: "업체", + cell: ({ row }) => ( +
+ {row.original.VNDR_CD && ( +
{row.original.VNDR_CD}
+ )} + {row.original.CPY_NM && ( +
+ {row.original.CPY_NM} +
+ )} +
+ ), + 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 ( + + {stage} + + ); + }, + size: 80, + }, + { + accessorKey: "LTST_REV_NO", + header: "최신 REV", + cell: ({ row }) => row.original.LTST_REV_NO || "-", + size: 80, + }, + { + id: "stats", + header: "REV/파일", + cell: ({ row }) => ( +
+
+ {row.original.revision_count} / {row.original.file_count} +
+
+ ), + 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 ( + + {status} + + ); + }, + size: 80, + }, + { + accessorKey: "last_synced_at", + header: "동기화", + cell: ({ row }) => ( +
+ {formatDistanceToNow(new Date(row.original.last_synced_at), { + addSuffix: true, + locale: ko, + })} +
+ ), + 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[] = [ + { + id: "expander", + header: () => null, + cell: ({ row }) => { + return row.getCanExpand() ? ( + + ) : null; + }, + size: 100, + }, + { + accessorKey: "REV_NO", + header: "리비전", + cell: ({ row }) => ( + + REV {row.original.REV_NO} + + ), + 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 ( + + {stage} + + ); + }, + size: 100, + }, + { + accessorKey: "OFDC_NO", + header: "OFDC 번호", + cell: ({ row }) => ( +
{row.original.OFDC_NO || "-"}
+ ), + size: 200, + }, + { + accessorKey: "ACTV_NO", + header: "Activity", + cell: ({ row }) => ( +
+ {row.original.ACTV_NO || "-"} +
+ ), + size: 250, + }, + { + id: "file_count", + header: "파일 수", + cell: ({ row }) => ( +
+ + {row.original.file_count} +
+ ), + 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 ( + + {status} + + ); + }, + size: 80, + }, + { + accessorKey: "last_synced_at", + header: "동기화", + cell: ({ row }) => ( +
+ {formatDistanceToNow(new Date(row.original.last_synced_at), { + addSuffix: true, + locale: ko, + })} +
+ ), + 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[] = [ + { + id: "spacer", + header: () => null, + cell: () =>
, + size: 150, + }, + { + accessorKey: "FILE_SEQ", + header: "순서", + cell: ({ row }) => ( + + #{row.original.FILE_SEQ} + + ), + size: 80, + }, + { + accessorKey: "FILE_NM", + header: "파일명", + cell: ({ row }) => ( +
+ + {row.original.FILE_NM} +
+ ), + 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 ( + + {status} + + ); + }, + size: 100, + }, + { + accessorKey: "FLD_PATH", + header: "경로", + cell: ({ row }) => ( +
+ {row.original.FLD_PATH || "-"} +
+ ), + size: 200, + }, + { + accessorKey: "created_at", + header: "생성일", + cell: ({ row }) => ( +
+ {formatDistanceToNow(new Date(row.original.created_at), { + addSuffix: true, + locale: ko, + })} +
+ ), + 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(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 ( +
+ {/* 상단 액션 바 */} +
+
+ {mode === "admin" && ( + + )} + + +
+ +
+ {mode === "vendor" ? "문서 조회 및 업로드" : "SWP 문서 관리 시스템"} +
+
+ + {/* 검색 필터 */} +
+
+

검색 필터

+ +
+ +
+ {/* 프로젝트 번호 */} +
+ + {projects.length > 0 ? ( + + + + + +
+
+ + setProjectSearch(e.target.value)} + className="border-0 focus-visible:ring-0 focus-visible:ring-offset-0" + /> +
+
+
+
+ + {filteredProjects.map((proj) => ( + + ))} + {filteredProjects.length === 0 && ( +
+ 검색 결과가 없습니다. +
+ )} +
+
+
+
+ ) : ( + + setLocalFilters({ ...localFilters, projNo: e.target.value }) + } + /> + )} +
+ + {/* 문서 번호 */} +
+ + + setLocalFilters({ ...localFilters, docNo: e.target.value }) + } + /> +
+ + {/* 문서 제목 */} +
+ + + setLocalFilters({ ...localFilters, docTitle: e.target.value }) + } + /> +
+ + {/* 패키지 번호 */} +
+ + + setLocalFilters({ ...localFilters, pkgNo: e.target.value }) + } + /> +
+ + {/* 업체 코드 */} +
+ + + setLocalFilters({ ...localFilters, vndrCd: e.target.value }) + } + /> +
+ + {/* 스테이지 */} +
+ + +
+
+ +
+ +
+
+
+ ); +} + 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({}); + const [revisionData, setRevisionData] = useState>({}); + const [fileData, setFileData] = useState>({}); + const [loadingRevisions, setLoadingRevisions] = useState>(new Set()); + const [loadingFiles, setLoadingFiles] = useState>(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 ( +
+ {/* 테이블 */} +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + <> + {/* 문서 행 */} + + {row.getVisibleCells().map((cell) => ( + + {cell.column.id === "expander" ? ( +
{ + row.toggleExpanded(); + handleDocumentExpand(row.original.DOC_NO, row.getIsExpanded()); + }} + > + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} +
+ ) : ( + flexRender(cell.column.columnDef.cell, cell.getContext()) + )} +
+ ))} +
+ + {/* 리비전 행들 (확장 시) */} + {row.getIsExpanded() && ( + + + {loadingRevisions.has(row.original.DOC_NO) ? ( +
+ + 리비전 로딩 중... +
+ ) : revisionData[row.original.DOC_NO]?.length ? ( + + ) : ( +
+ 리비전 없음 +
+ )} +
+
+ )} + + )) + ) : ( + + + 데이터 없음 + + + )} +
+
+
+ + {/* 페이지네이션 */} +
+
+ 총 {total}개 중 {(page - 1) * pageSize + 1}- + {Math.min(page * pageSize, total)}개 표시 +
+
+ +
+ {page} / {totalPages} +
+ +
+
+
+ ); +} + +// ============================================================================ +// 리비전 서브 테이블 +// ============================================================================ + +interface RevisionSubTableProps { + revisions: RevisionRow[]; + fileData: Record; + loadingFiles: Set; + onLoadFiles: (revisionId: number) => void; +} + +function RevisionSubTable({ + revisions, + fileData, + loadingFiles, + onLoadFiles, +}: RevisionSubTableProps) { + const [expandedRevisions, setExpandedRevisions] = useState({}); + + 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 ( +
+ + + {revisionTable.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ))} + + ))} + + + {revisionTable.getRowModel().rows.map((row) => ( + <> + {/* 리비전 행 */} + + {row.getVisibleCells().map((cell) => ( + + {cell.column.id === "expander" ? ( +
{ + row.toggleExpanded(); + handleRevisionExpand(row.original.id, row.getIsExpanded()); + }} + > + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} +
+ ) : ( + flexRender(cell.column.columnDef.cell, cell.getContext()) + )} +
+ ))} +
+ + {/* 파일 행들 (확장 시) */} + {row.getIsExpanded() && ( + + + {loadingFiles.has(row.original.id) ? ( +
+ + 파일 로딩 중... +
+ ) : fileData[row.original.id]?.length ? ( + + ) : ( +
+ 파일 없음 +
+ )} +
+
+ )} + + ))} +
+
+
+ ); +} + +// ============================================================================ +// 파일 서브 테이블 +// ============================================================================ + +interface FileSubTableProps { + files: FileRow[]; +} + +function FileSubTable({ files }: FileSubTableProps) { + const fileTable = useReactTable({ + data: files, + columns: swpFileColumns, + getCoreRowModel: getCoreRowModel(), + }); + + return ( +
+ + + {fileTable.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ))} + + ))} + + + {fileTable.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ))} + +
+
+ ); +} -- cgit v1.2.3