diff options
Diffstat (limited to 'lib/project-doc-templates/table/doc-template-table.tsx')
| -rw-r--r-- | lib/project-doc-templates/table/doc-template-table.tsx | 716 |
1 files changed, 716 insertions, 0 deletions
diff --git a/lib/project-doc-templates/table/doc-template-table.tsx b/lib/project-doc-templates/table/doc-template-table.tsx new file mode 100644 index 00000000..7d8210d8 --- /dev/null +++ b/lib/project-doc-templates/table/doc-template-table.tsx @@ -0,0 +1,716 @@ +"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<string, string> = { + 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<ProjectDocTemplate>[] { + + // 체크박스 컬럼 + const selectColumn: ColumnDef<ProjectDocTemplate> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + maxSize: 30, + enableSorting: false, + enableHiding: false, + }; + + // 다운로드 컬럼 + const downloadColumn: ColumnDef<ProjectDocTemplate> = { + id: "download", + header: "", + cell: ({ row }) => { + const template = row.original; + return ( + <Button + variant="ghost" + size="icon" + onClick={() => handleFileDownload(template.filePath, template.fileName)} + title={`${template.fileName} 다운로드`} + className="hover:bg-muted" + > + <Paperclip className="h-4 w-4" /> + <span className="sr-only">다운로드</span> + </Button> + ); + }, + maxSize: 30, + enableSorting: false, + }; + + // 액션 컬럼 + const actionsColumn: ColumnDef<ProjectDocTemplate> = { + id: "actions", + enableHiding: false, + cell: ({ row }) => { + const template = row.original; + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-44"> + <DropdownMenuItem onSelect={() => onViewDetail(template)}> + <Eye className="mr-2 h-4 w-4" /> + 상세보기 + </DropdownMenuItem> + + <DropdownMenuItem onSelect={() => onEdit(template)}> + 수정하기 + </DropdownMenuItem> + + <DropdownMenuSeparator /> + + <DropdownMenuItem onSelect={() => onCreateVersion(template)}> + <GitBranch className="mr-2 h-4 w-4" /> + 새 버전 생성 + </DropdownMenuItem> + + <DropdownMenuSeparator /> + + <DropdownMenuItem + onSelect={() => onDelete(template)} + className="text-destructive" + > + 삭제하기 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ); + }, + maxSize: 30, + }; + + // 데이터 컬럼들 + const dataColumns: ColumnDef<ProjectDocTemplate>[] = [ + { + accessorKey: "status", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="상태" />, + cell: ({ row }) => { + const status = row.getValue("status") as string; + const statusMap: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = { + 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 ( + <Badge variant={statusInfo.variant}> + {statusInfo.label} + </Badge> + ); + }, + size: 80, + enableResizing: true, + }, + { + accessorKey: "templateName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="템플릿명" />, + cell: ({ row }) => { + const template = row.original; + + return ( + <div className="flex flex-col min-w-0"> + <button + onClick={() => onViewDetail(template)} + className="truncate text-left hover:text-blue-600 hover:underline cursor-pointer transition-colors" + title="클릭하여 상세보기" + > + {template.templateName} + </button> + {template.templateCode && ( + <span className="text-xs text-muted-foreground">{template.templateCode}</span> + )} + </div> + ); + }, + size: 250, + enableResizing: true, + }, + { + accessorKey: "projectCode", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트" />, + cell: ({ row }) => { + const template = row.original; + if (!template.projectCode) return <span className="text-muted-foreground">-</span>; + + return ( + <div className="flex flex-col min-w-0"> + <span className="font-medium truncate">{template.projectCode}</span> + {template.projectName && ( + <span className="text-xs text-muted-foreground truncate">{template.projectName}</span> + )} + </div> + ); + }, + size: 150, + enableResizing: true, + }, + { + accessorKey: "version", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="버전" />, + cell: ({ row }) => { + const template = row.original; + return ( + <div className="flex items-center gap-2"> + <Badge variant="outline" className="text-xs"> + v{template.version} + </Badge> + {template.isLatest && ( + <Badge variant="secondary" className="text-xs"> + 최신 + </Badge> + )} + </div> + ); + }, + size: 100, + enableResizing: true, + }, + { + id: "variables", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="변수" />, + cell: ({ row }) => { + const template = row.original; + const variableCount = template.variables?.length || 0; + const requiredCount = template.requiredVariables?.length || 0; + + return ( + <div className="text-xs"> + <span>{variableCount}개</span> + {requiredCount > 0 && ( + <span className="text-muted-foreground ml-1"> + (필수: {requiredCount}) + </span> + )} + </div> + ); + }, + size: 100, + enableResizing: true, + }, + { + accessorKey: "fileName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="파일명" />, + 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 ( + <div className="min-w-0 max-w-full"> + <span className="block truncate text-sm" title={fileName}> + {fileName} + </span> + {fileSizeMB && ( + <span className="text-xs text-muted-foreground">{fileSizeMB} MB</span> + )} + </div> + ); + }, + size: 200, + enableResizing: true, + }, + { + accessorKey: "createdAt", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="생성일" />, + cell: ({ row }) => { + const date = row.getValue("createdAt") as Date; + const template = row.original; + return ( + <div className="text-xs"> + <div>{date ? formatDateTime(date, "KR") : "-"}</div> + {template.createdByName && ( + <span className="text-muted-foreground">{template.createdByName}</span> + )} + </div> + ); + }, + size: 140, + enableResizing: true, + }, + { + accessorKey: "updatedAt", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="수정일" />, + cell: ({ row }) => { + const date = row.getValue("updatedAt") as Date; + const template = row.original; + return ( + <div className="text-xs"> + <div>{date ? formatDateTime(date, "KR") : "-"}</div> + {template.updatedByName && ( + <span className="text-muted-foreground">{template.updatedByName}</span> + )} + </div> + ); + }, + size: 140, + enableResizing: true, + }, + ]; + + return [selectColumn, downloadColumn, ...dataColumns, actionsColumn]; +} + +// 메인 테이블 컴포넌트 +interface ProjectDocTemplateTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getProjectDocTemplates>>, + ] +> +} + +export function ProjectDocTemplateTable({ + promises +}: ProjectDocTemplateTableProps) { + const router = useRouter(); + const [selectedTemplate, setSelectedTemplate] = React.useState<ProjectDocTemplate | null>(null); + const [detailOpen, setDetailOpen] = React.useState(false); + const [editOpen, setEditOpen] = React.useState(false); + const [versionTemplate, setVersionTemplate] = React.useState<ProjectDocTemplate | null>(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<ProjectDocTemplate>[] = [ + { 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 ( + <> + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <div className="flex items-center gap-2"> + <AddProjectDocTemplateDialog /> + {table.getFilteredSelectedRowModel().rows.length > 0 && ( + <Button + variant="outline" + size="sm" + onClick={() => { + const selectedRows = table.getFilteredSelectedRowModel().rows; + console.log("Selected templates:", selectedRows.map(r => r.original)); + toast.info(`${selectedRows.length}개 템플릿 선택됨`); + }} + > + 선택 항목 처리 ({table.getFilteredSelectedRowModel().rows.length}) + </Button> + )} + </div> + </DataTableAdvancedToolbar> + </DataTable> + + {/* 상세보기 다이얼로그 */} + {selectedTemplate && ( + <TemplateDetailDialog + template={selectedTemplate} + open={detailOpen} + onOpenChange={setDetailOpen} + /> + )} + + {/* 수정 Sheet */} + {selectedTemplate && ( + <TemplateEditSheet + template={selectedTemplate} + open={editOpen} + onOpenChange={setEditOpen} + onSuccess={() => { + router.refresh(); + }} + /> + )} + + {/* 새 버전 생성 다이얼로그 */} + {versionTemplate && ( + <CreateVersionDialog + template={versionTemplate} + open={versionOpen} + onOpenChange={(open) => { + 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.usseState(false); + const [selectedFile, setSelectedFile] = React.useState<File | null>(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 ( + <Dialog open={true} onOpenChange={onClose}> + <DialogContent className="sm:max-w-md"> + <DialogHeader> + <DialogTitle>새 버전 생성</DialogTitle> + <DialogDescription> + {template.templateName}의 새 버전을 생성합니다. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {/* 버전 정보 표시 */} + <div className="rounded-lg border p-3 bg-muted/50"> + <div className="flex items-center justify-between"> + <span className="text-sm font-medium">현재 버전</span> + <Badge variant="outline">v{template.version}</Badge> + </div> + <div className="flex items-center justify-between mt-2"> + <span className="text-sm font-medium">새 버전</span> + <Badge variant="default">v{template.version + 1}</Badge> + </div> + </div> + + {/* 파일 선택 */} + <div> + <Label htmlFor="file"> + 새 템플릿 파일 <span className="text-red-500">*</span> + </Label> + <div className="mt-2"> + <Input + id="file" + type="file" + accept=".doc,.docx" + onChange={(e) => setSelectedFile(e.target.files?.[0] || null)} + disabled={isLoading} + /> + </div> + {selectedFile && ( + <p className="mt-2 text-sm text-muted-foreground"> + 선택된 파일: {selectedFile.name} ({(selectedFile.size / 1024 / 1024).toFixed(2)} MB) + </p> + )} + </div> + + {/* 업로드 진행률 */} + {showProgress && ( + <div className="space-y-2"> + <div className="flex justify-between text-sm"> + <span>업로드 진행률</span> + <span>{uploadProgress}%</span> + </div> + <Progress value={uploadProgress} /> + </div> + )} + + {/* 안내 메시지 */} + <div className="rounded-lg border border-yellow-200 bg-yellow-50 p-3"> + <div className="flex"> + <AlertCircle className="h-4 w-4 text-yellow-600 mr-2 flex-shrink-0 mt-0.5" /> + <div className="text-sm text-yellow-800"> + <p className="font-medium">주의사항</p> + <ul className="mt-1 list-disc list-inside text-xs space-y-1"> + <li>새 버전 생성 후에는 이전 버전으로 되돌릴 수 없습니다.</li> + <li>기존 변수 설정은 그대로 유지됩니다.</li> + <li>파일 형식은 기존과 동일해야 합니다 (.doc/.docx).</li> + </ul> + </div> + </div> + </div> + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={onClose} + disabled={isLoading} + > + 취소 + </Button> + <Button + onClick={handleSubmit} + disabled={isLoading || !selectedFile} + > + {isLoading ? ( + <> + <RefreshCw className="mr-2 h-4 w-4 animate-spin" /> + 생성 중... + </> + ) : ( + '버전 생성' + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file |
