summaryrefslogtreecommitdiff
path: root/lib/project-doc-templates/table/doc-template-table.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/project-doc-templates/table/doc-template-table.tsx')
-rw-r--r--lib/project-doc-templates/table/doc-template-table.tsx716
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