"use client"; import * as React from "react"; import { useRouter } from "next/navigation"; import { type ColumnDef } from "@tanstack/react-table"; import { Download, Ellipsis, Paperclip, Eye, Copy, GitBranch, Globe, Lock, FolderOpen, FileText } from "lucide-react"; import { toast } from "sonner"; import { formatDateTime } from "@/lib/utils"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { DataTable } from "@/components/data-table/data-table"; import { useDataTable } from "@/hooks/use-data-table"; import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"; import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"; import type { DataTableAdvancedFilterField, DataTableRowAction, } from "@/types/table"; import { getProjectDocTemplates, deleteProjectDocTemplate, createTemplateVersion } from "@/lib/project-doc-templates/service"; import type { ProjectDocTemplate } from "@/db/schema/project-doc-templates"; import { quickDownload } from "@/lib/file-download"; import { AddProjectDocTemplateDialog } from "./add-project-doc-template-dialog"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Progress } from "@/components/ui/progress"; import { AlertCircle, RefreshCw } from "lucide-react"; import { TemplateDetailDialog } from "./template-detail-dialog"; import { TemplateEditSheet } from "./template-edit-sheet"; // 문서 타입 라벨 매핑 const DOCUMENT_TYPE_LABELS: Record = { CONTRACT: "계약서", SPECIFICATION: "사양서", REPORT: "보고서", DRAWING: "도면", MANUAL: "매뉴얼", PROCEDURE: "절차서", STANDARD: "표준문서", OTHER: "기타", }; // 파일 다운로드 함수 const handleFileDownload = async (filePath: string, fileName: string) => { try { await quickDownload(filePath, fileName); } catch (error) { console.error("파일 다운로드 오류:", error); toast.error("파일 다운로드 중 오류가 발생했습니다."); } }; // 컬럼 정의 함수 - 핸들러들을 props로 받음 export function getColumns({ onViewDetail, onEdit, onDelete, onCreateVersion, }: { onViewDetail: (template: ProjectDocTemplate) => void; onEdit: (template: ProjectDocTemplate) => void; onDelete: (template: ProjectDocTemplate) => void; onCreateVersion: (template: ProjectDocTemplate) => void; }): ColumnDef[] { // 체크박스 컬럼 const selectColumn: ColumnDef = { id: "select", header: ({ table }) => ( table.toggleAllPageRowsSelected(!!value)} aria-label="Select all" className="translate-y-0.5" /> ), cell: ({ row }) => ( row.toggleSelected(!!value)} aria-label="Select row" className="translate-y-0.5" /> ), maxSize: 30, enableSorting: false, enableHiding: false, }; // 다운로드 컬럼 const downloadColumn: ColumnDef = { id: "download", header: "", cell: ({ row }) => { const template = row.original; return ( ); }, maxSize: 30, enableSorting: false, }; // 액션 컬럼 const actionsColumn: ColumnDef = { id: "actions", enableHiding: false, cell: ({ row }) => { const template = row.original; return ( onViewDetail(template)}> 상세보기 onEdit(template)}> 수정하기 onCreateVersion(template)}> 새 버전 생성 onDelete(template)} className="text-destructive" > 삭제하기 ); }, maxSize: 30, }; // 데이터 컬럼들 const dataColumns: ColumnDef[] = [ { accessorKey: "status", header: ({ column }) => , cell: ({ row }) => { const status = row.getValue("status") as string; const statusMap: Record = { ACTIVE: { label: "활성", variant: "default" }, INACTIVE: { label: "비활성", variant: "secondary" }, DRAFT: { label: "초안", variant: "outline" }, ARCHIVED: { label: "보관", variant: "secondary" }, }; const statusInfo = statusMap[status] || { label: status, variant: "outline" }; return ( {statusInfo.label} ); }, size: 80, enableResizing: true, }, { accessorKey: "templateName", header: ({ column }) => , cell: ({ row }) => { const template = row.original; return (
{template.templateCode && ( {template.templateCode} )}
); }, size: 250, enableResizing: true, }, { accessorKey: "projectCode", header: ({ column }) => , cell: ({ row }) => { const template = row.original; if (!template.projectCode) return -; return (
{template.projectCode} {template.projectName && ( {template.projectName} )}
); }, size: 150, enableResizing: true, }, { accessorKey: "version", header: ({ column }) => , cell: ({ row }) => { const template = row.original; return (
v{template.version} {template.isLatest && ( 최신 )}
); }, size: 100, enableResizing: true, }, { id: "variables", header: ({ column }) => , cell: ({ row }) => { const template = row.original; const variableCount = template.variables?.length || 0; const requiredCount = template.requiredVariables?.length || 0; return (
{variableCount}개 {requiredCount > 0 && ( (필수: {requiredCount}) )}
); }, size: 100, enableResizing: true, }, { accessorKey: "fileName", header: ({ column }) => , cell: ({ row }) => { const fileName = row.getValue("fileName") as string; const template = row.original; const fileSizeMB = template.fileSize ? (template.fileSize / (1024 * 1024)).toFixed(2) : null; return (
{fileName} {fileSizeMB && ( {fileSizeMB} MB )}
); }, size: 200, enableResizing: true, }, { accessorKey: "createdAt", header: ({ column }) => , cell: ({ row }) => { const date = row.getValue("createdAt") as Date; const template = row.original; return (
{date ? formatDateTime(date, "KR") : "-"}
{template.createdByName && ( {template.createdByName} )}
); }, size: 140, enableResizing: true, }, { accessorKey: "updatedAt", header: ({ column }) => , cell: ({ row }) => { const date = row.getValue("updatedAt") as Date; const template = row.original; return (
{date ? formatDateTime(date, "KR") : "-"}
{template.updatedByName && ( {template.updatedByName} )}
); }, size: 140, enableResizing: true, }, ]; return [selectColumn, downloadColumn, ...dataColumns, actionsColumn]; } // 메인 테이블 컴포넌트 interface ProjectDocTemplateTableProps { promises: Promise< [ Awaited>, ] > } export function ProjectDocTemplateTable({ promises }: ProjectDocTemplateTableProps) { const router = useRouter(); const [selectedTemplate, setSelectedTemplate] = React.useState(null); const [detailOpen, setDetailOpen] = React.useState(false); const [editOpen, setEditOpen] = React.useState(false); const [versionTemplate, setVersionTemplate] = React.useState(null); const [versionOpen, setVersionOpen] = React.useState(false); const [{ data, pageCount }] = React.use(promises); // 액션 핸들러들 const handleViewDetail = (template: ProjectDocTemplate) => { setSelectedTemplate(template); setDetailOpen(true); }; const handleEdit = (template: ProjectDocTemplate) => { setSelectedTemplate(template); setEditOpen(true); }; const handleDelete = async (template: ProjectDocTemplate) => { if (confirm("정말로 이 템플릿을 삭제하시겠습니까?")) { const result = await deleteProjectDocTemplate(template.id); if (result.success) { toast.success("템플릿이 삭제되었습니다."); router.refresh(); } else { toast.error(result.error || "삭제에 실패했습니다."); } } }; const handleCreateVersion = (template: ProjectDocTemplate) => { setVersionTemplate(template); setVersionOpen(true); }; // 컬럼 설정 - 핸들러들을 전달 const columns = React.useMemo( () => getColumns({ onViewDetail: handleViewDetail, onEdit: handleEdit, onDelete: handleDelete, onCreateVersion: handleCreateVersion, }), [] ); // 필터 필드 설정 const advancedFilterFields: DataTableAdvancedFilterField[] = [ { id: "templateName", label: "템플릿명", type: "text" }, { id: "status", label: "상태", type: "select", options: [ { label: "활성", value: "ACTIVE" }, { label: "비활성", value: "INACTIVE" }, { label: "초안", value: "DRAFT" }, { label: "보관", value: "ARCHIVED" }, ], }, { id: "projectCode", label: "프로젝트 코드", type: "text" }, { id: "createdAt", label: "생성일", type: "date" }, { id: "updatedAt", label: "수정일", type: "date" }, ]; const { table } = useDataTable({ data, columns, pageCount, filterFields: advancedFilterFields, enablePinning: true, enableAdvancedFilter: true, initialState: { sorting: [{ id: "createdAt", desc: true }], columnPinning: { right: ["actions"] }, }, getRowId: (originalRow) => String(originalRow.id), shallow: false, clearOnDefault: true, }); return ( <>
{table.getFilteredSelectedRowModel().rows.length > 0 && ( )}
{/* 상세보기 다이얼로그 */} {selectedTemplate && ( )} {/* 수정 Sheet */} {selectedTemplate && ( { router.refresh(); }} /> )} {/* 새 버전 생성 다이얼로그 */} {versionTemplate && ( { setVersionOpen(open); if (!open) setVersionTemplate(null); }} onSuccess={() => { setVersionOpen(false); setVersionTemplate(null); router.refresh(); toast.success("새 버전이 생성되었습니다."); }} /> )} ); } // 새 버전 생성 다이얼로그 컴포넌트 interface CreateVersionDialogProps { template: ProjectDocTemplate; open: boolean; onOpenChange: (open: boolean) => void; onSuccess: () => void; } function CreateVersionDialog({ template, onClose, onSuccess }: CreateVersionDialogProps) { const [isLoading, setIsLoading] = React.useState(false); const [selectedFile, setSelectedFile] = React.useState(null); const [uploadProgress, setUploadProgress] = React.useState(0); const [showProgress, setShowProgress] = React.useState(false); // 청크 업로드 함수 const CHUNK_SIZE = 1 * 1024 * 1024; // 1MB const uploadFileInChunks = async (file: File, fileId: string) => { const totalChunks = Math.ceil(file.size / CHUNK_SIZE); setShowProgress(true); setUploadProgress(0); for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { const start = chunkIndex * CHUNK_SIZE; const end = Math.min(start + CHUNK_SIZE, file.size); const chunk = file.slice(start, end); const formData = new FormData(); formData.append('chunk', chunk); formData.append('filename', file.name); formData.append('chunkIndex', chunkIndex.toString()); formData.append('totalChunks', totalChunks.toString()); formData.append('fileId', fileId); const response = await fetch('/api/upload/project-doc-template/chunk', { method: 'POST', body: formData, }); if (!response.ok) { throw new Error(`청크 업로드 실패: ${response.statusText}`); } const progress = Math.round(((chunkIndex + 1) / totalChunks) * 100); setUploadProgress(progress); const result = await response.json(); if (chunkIndex === totalChunks - 1) { return result; } } }; const handleSubmit = async () => { if (!selectedFile) { toast.error("파일을 선택해주세요."); return; } setIsLoading(true); try { // 1. 파일 업로드 (청크 방식) const fileId = `version_${template.id}_${Date.now()}`; const uploadResult = await uploadFileInChunks(selectedFile, fileId); if (!uploadResult?.success) { throw new Error("파일 업로드에 실패했습니다."); } // 2. 업로드된 파일 정보로 새 버전 생성 const result = await createTemplateVersion(template.id, { filePath: uploadResult.filePath, fileName: uploadResult.fileName, fileSize: uploadResult.fileSize || selectedFile.size, mimeType: uploadResult.mimeType || selectedFile.type, variables: template.variables, // 기존 변수 유지 }); if (result.success) { toast.success(`버전 ${template.version + 1}이 생성되었습니다.`); onSuccess(); } else { throw new Error(result.error); } } catch (error) { console.error("Failed to create version:", error); toast.error(error instanceof Error ? error.message : "새 버전 생성에 실패했습니다."); } finally { setIsLoading(false); setShowProgress(false); setUploadProgress(0); } }; return ( 새 버전 생성 {template.templateName}의 새 버전을 생성합니다.
{/* 버전 정보 표시 */}
현재 버전 v{template.version}
새 버전 v{template.version + 1}
{/* 파일 선택 */}
setSelectedFile(e.target.files?.[0] || null)} disabled={isLoading} />
{selectedFile && (

선택된 파일: {selectedFile.name} ({(selectedFile.size / 1024 / 1024).toFixed(2)} MB)

)}
{/* 업로드 진행률 */} {showProgress && (
업로드 진행률 {uploadProgress}%
)} {/* 안내 메시지 */}

주의사항

  • 새 버전 생성 후에는 이전 버전으로 되돌릴 수 없습니다.
  • 기존 변수 설정은 그대로 유지됩니다.
  • 파일 형식은 기존과 동일해야 합니다 (.doc/.docx).
); }