summaryrefslogtreecommitdiff
path: root/lib/project-doc-templates/table/add-project-doc-template-dialog.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/project-doc-templates/table/add-project-doc-template-dialog.tsx')
-rw-r--r--lib/project-doc-templates/table/add-project-doc-template-dialog.tsx642
1 files changed, 642 insertions, 0 deletions
diff --git a/lib/project-doc-templates/table/add-project-doc-template-dialog.tsx b/lib/project-doc-templates/table/add-project-doc-template-dialog.tsx
new file mode 100644
index 00000000..fb36aebd
--- /dev/null
+++ b/lib/project-doc-templates/table/add-project-doc-template-dialog.tsx
@@ -0,0 +1,642 @@
+"use client";
+
+import * as React from "react";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import * as z from "zod";
+import { toast } from "sonner";
+import { v4 as uuidv4 } from "uuid";
+import {
+ Dialog,
+ DialogTrigger,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter
+} from "@/components/ui/dialog";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormDescription,
+} from "@/components/ui/form";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { Switch } from "@/components/ui/switch";
+import {
+ Dropzone,
+ DropzoneZone,
+ DropzoneUploadIcon,
+ DropzoneTitle,
+ DropzoneDescription,
+ DropzoneInput
+} from "@/components/ui/dropzone";
+import { Progress } from "@/components/ui/progress";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Plus, X, FileText, AlertCircle } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { createProjectDocTemplate } from "@/lib/project-doc-templates/service";
+import { ProjectSelector } from "@/components/ProjectSelector";
+import type { TemplateVariable } from "@/db/schema/project-doc-templates";
+import type { Project } from "@/lib/rfqs/service";
+
+// 기본 변수들 (읽기 전용)
+const DEFAULT_VARIABLES_DISPLAY: TemplateVariable[] = [
+ { name: "document_number", displayName: "문서번호", type: "text", required: true, description: "문서 고유 번호" },
+ { name: "project_code", displayName: "프로젝트 코드", type: "text", required: true, description: "프로젝트 식별 코드" },
+ { name: "project_name", displayName: "프로젝트명", type: "text", required: true, description: "프로젝트 이름" },
+];
+
+const templateFormSchema = z.object({
+ templateName: z.string().min(1, "템플릿 이름을 입력해주세요."),
+ templateCode: z.string().optional(),
+ description: z.string().optional(),
+ projectId: z.number({
+ required_error: "프로젝트를 선택해주세요.",
+ }),
+ customVariables: z.array(z.object({
+ name: z.string().min(1, "변수명을 입력해주세요."),
+ displayName: z.string().min(1, "표시명을 입력해주세요."),
+ type: z.enum(["text", "number", "date", "select"]),
+ required: z.boolean(),
+ defaultValue: z.string().optional(),
+ description: z.string().optional(),
+ })).default([]),
+ file: z.instanceof(File, {
+ message: "파일을 업로드해주세요.",
+ }),
+})
+.refine((data) => {
+ if (data.file && data.file.size > 100 * 1024 * 1024) return false;
+ return true;
+}, {
+ message: "파일 크기는 100MB 이하여야 합니다.",
+ path: ["file"],
+})
+.refine((data) => {
+ if (data.file) {
+ const validTypes = [
+ 'application/msword',
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
+ ];
+ return validTypes.includes(data.file.type);
+ }
+ return true;
+}, {
+ message: "워드 파일(.doc, .docx)만 업로드 가능합니다.",
+ path: ["file"],
+});
+
+type TemplateFormValues = z.infer<typeof templateFormSchema>;
+
+export function AddProjectDocTemplateDialog() {
+ const [open, setOpen] = React.useState(false);
+ const [isLoading, setIsLoading] = React.useState(false);
+ const [selectedFile, setSelectedFile] = React.useState<File | null>(null);
+ const [uploadProgress, setUploadProgress] = React.useState(0);
+ const [showProgress, setShowProgress] = React.useState(false);
+ const [selectedProject, setSelectedProject] = React.useState<Project | null>(null);
+ const router = useRouter();
+
+ const form = useForm<TemplateFormValues>({
+ resolver: zodResolver(templateFormSchema),
+ defaultValues: {
+ templateName: "",
+ templateCode: "",
+ description: "",
+ customVariables: [],
+ },
+ mode: "onChange",
+ });
+
+ // 프로젝트 선택 시 처리
+ const handleProjectSelect = (project: Project) => {
+ setSelectedProject(project);
+ form.setValue("projectId", project.id);
+ // 템플릿 이름 자동 설정 (원하면)
+ if (!form.getValues("templateName")) {
+ form.setValue("templateName", `${project.projectCode} 벤더문서 커버 템플릿`);
+ }
+ };
+
+ const handleFileChange = (files: File[]) => {
+ if (files.length > 0) {
+ const file = files[0];
+ setSelectedFile(file);
+ form.setValue("file", file);
+ }
+ };
+
+ // 사용자 정의 변수 추가
+ const addCustomVariable = () => {
+ const currentVars = form.getValues("customVariables");
+ form.setValue("customVariables", [
+ ...currentVars,
+ {
+ name: "",
+ displayName: "",
+ type: "text",
+ required: false,
+ defaultValue: "",
+ description: "",
+ },
+ ]);
+ };
+
+ // 사용자 정의 변수 제거
+ const removeCustomVariable = (index: number) => {
+ const currentVars = form.getValues("customVariables");
+ form.setValue("customVariables", currentVars.filter((_, i) => i !== index));
+ };
+
+ // 청크 업로드
+ const CHUNK_SIZE = 1 * 1024 * 1024;
+
+ 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;
+ }
+ }
+ };
+
+ async function onSubmit(formData: TemplateFormValues) {
+ setIsLoading(true);
+ try {
+ // 파일 업로드
+ const fileId = uuidv4();
+ const uploadResult = await uploadFileInChunks(formData.file, fileId);
+
+ if (!uploadResult?.success) {
+ throw new Error("파일 업로드에 실패했습니다.");
+ }
+
+ // 템플릿 생성 (고정값들 적용)
+ const result = await createProjectDocTemplate({
+ templateName: formData.templateName,
+ templateCode: formData.templateCode,
+ description: formData.description,
+ projectId: formData.projectId,
+ templateType: "PROJECT", // 고정
+ documentType: "VENDOR_DOC_COVER", // 벤더문서 커버로 고정
+ filePath: uploadResult.filePath,
+ fileName: uploadResult.fileName,
+ fileSize: formData.file.size,
+ mimeType: formData.file.type,
+ variables: formData.customVariables,
+ isPublic: false, // 고정
+ requiresApproval: false, // 고정
+ });
+
+ if (!result.success) {
+ throw new Error(result.error || "템플릿 생성에 실패했습니다.");
+ }
+
+ toast.success("템플릿이 성공적으로 추가되었습니다.");
+ form.reset();
+ setSelectedFile(null);
+ setSelectedProject(null);
+ setOpen(false);
+ setShowProgress(false);
+ router.refresh();
+ } catch (error) {
+ console.error("Submit error:", error);
+ toast.error(error instanceof Error ? error.message : "템플릿 추가 중 오류가 발생했습니다.");
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ const customVariables = form.watch("customVariables");
+
+ // 다이얼로그 닫을 때 폼 초기화
+ React.useEffect(() => {
+ if (!open) {
+ form.reset();
+ setSelectedFile(null);
+ setSelectedProject(null);
+ setShowProgress(false);
+ setUploadProgress(0);
+ }
+ }, [open, form]);
+
+ return (
+ <Dialog open={open} onOpenChange={setOpen}>
+ <DialogTrigger asChild>
+ <Button variant="default" size="sm">
+ <Plus className="mr-2 h-4 w-4" />
+ 템플릿 추가
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="max-w-4xl h-[90vh] flex flex-col p-0">
+ {/* 헤더 - 고정 */}
+ <DialogHeader className="px-6 py-4 border-b">
+ <DialogTitle>프로젝트 벤더문서 커버 템플릿 추가</DialogTitle>
+ <DialogDescription>
+ 프로젝트별 벤더문서 커버 템플릿을 등록합니다. 기본 변수(document_number, project_code, project_name)는 자동으로 포함됩니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* 본문 - 스크롤 영역 */}
+ <div className="flex-1 overflow-y-auto px-6 py-4">
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
+ {/* 프로젝트 선택 및 기본 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg">기본 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {/* 프로젝트 선택 - 필수 */}
+ <FormField
+ control={form.control}
+ name="projectId"
+ render={() => (
+ <FormItem>
+ <FormLabel>
+ 프로젝트 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <ProjectSelector
+ selectedProjectId={selectedProject?.id}
+ onProjectSelect={handleProjectSelect}
+ placeholder="프로젝트를 선택하세요..."
+ filterType="plant" // 또는 필요한 타입
+ />
+ </FormControl>
+ <FormDescription>
+ 템플릿을 적용할 프로젝트를 선택하세요.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {selectedProject && (
+ <div className="p-3 bg-muted rounded-lg">
+ <p className="text-sm">
+ <span className="font-medium">선택된 프로젝트:</span> {selectedProject.projectCode} - {selectedProject.projectName}
+ </p>
+ </div>
+ )}
+
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="templateName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 템플릿 이름 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Input placeholder="예: S123 벤더문서 커버 템플릿" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="templateCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>템플릿 코드</FormLabel>
+ <FormControl>
+ <Input placeholder="자동 생성됨 (선택사항)" {...field} />
+ </FormControl>
+ <FormDescription>비워두면 자동으로 생성됩니다.</FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>설명</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="템플릿에 대한 설명을 입력하세요."
+ className="min-h-[80px]"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 고정 정보 표시 */}
+ <div className="flex gap-2">
+ <Badge variant="outline">템플릿 타입: 프로젝트</Badge>
+ <Badge variant="outline">문서 타입: 벤더문서 커버</Badge>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 변수 설정 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg">템플릿 변수</CardTitle>
+ <CardDescription>
+ 문서에서 사용할 변수를 정의합니다. 템플릿에서 {'{{변수명}}'} 형식으로 사용됩니다.
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {/* 기본 변수 표시 */}
+ <div>
+ <div className="flex items-center mb-2">
+ <Badge variant="outline" className="text-xs">
+ 기본 변수 (자동 포함)
+ </Badge>
+ </div>
+ <div className="space-y-2">
+ {DEFAULT_VARIABLES_DISPLAY.map((variable) => (
+ <div key={variable.name} className="flex items-center gap-2 p-2 bg-muted/50 rounded">
+ <Badge variant="secondary">
+ {`{{${variable.name}}}`}
+ </Badge>
+ <span className="text-sm">{variable.displayName}</span>
+ <span className="text-xs text-muted-foreground">({variable.description})</span>
+ {variable.required && (
+ <Badge variant="destructive" className="text-xs">필수</Badge>
+ )}
+ </div>
+ ))}
+ </div>
+ </div>
+
+ {/* 사용자 정의 변수 */}
+ <div>
+ <div className="flex items-center justify-between mb-2">
+ <Badge variant="outline" className="text-xs">
+ 사용자 정의 변수
+ </Badge>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={addCustomVariable}
+ >
+ <Plus className="mr-2 h-4 w-4" />
+ 변수 추가
+ </Button>
+ </div>
+
+ {customVariables.length > 0 ? (
+ <div className="space-y-3">
+ {customVariables.map((_, index) => (
+ <div key={index} className="p-3 border rounded-lg space-y-3">
+ <div className="flex items-center justify-between">
+ <span className="text-sm font-medium">변수 #{index + 1}</span>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removeCustomVariable(index)}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
+
+ <div className="grid grid-cols-3 gap-3">
+ <FormField
+ control={form.control}
+ name={`customVariables.${index}.name`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>변수명</FormLabel>
+ <FormControl>
+ <Input placeholder="예: vendor_name" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name={`customVariables.${index}.displayName`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>표시명</FormLabel>
+ <FormControl>
+ <Input placeholder="예: 벤더명" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name={`customVariables.${index}.type`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>타입</FormLabel>
+ <FormControl>
+ <select
+ className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background"
+ {...field}
+ >
+ <option value="text">텍스트</option>
+ <option value="number">숫자</option>
+ <option value="date">날짜</option>
+ <option value="select">선택</option>
+ </select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <div className="grid grid-cols-2 gap-3">
+ <FormField
+ control={form.control}
+ name={`customVariables.${index}.defaultValue`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>기본값</FormLabel>
+ <FormControl>
+ <Input placeholder="선택사항" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name={`customVariables.${index}.required`}
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-center justify-between rounded-lg border p-2">
+ <FormLabel className="text-sm">필수</FormLabel>
+ <FormControl>
+ <Switch
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <FormField
+ control={form.control}
+ name={`customVariables.${index}.description`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>설명</FormLabel>
+ <FormControl>
+ <Input placeholder="변수 설명 (선택사항)" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ ))}
+ </div>
+ ) : (
+ <div className="text-center py-4 text-sm text-muted-foreground">
+ 추가된 사용자 정의 변수가 없습니다.
+ </div>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 파일 업로드 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg">템플릿 파일</CardTitle>
+ <CardDescription>
+ 워드 파일(.doc, .docx)을 업로드하세요. 파일 내 {'{{변수명}}'} 형식으로 변수를 사용할 수 있습니다.
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <FormField
+ control={form.control}
+ name="file"
+ render={() => (
+ <FormItem>
+ <FormControl>
+ <Dropzone
+ onDrop={handleFileChange}
+ accept={{
+ 'application/msword': ['.doc'],
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx']
+ }}
+ >
+ <DropzoneZone>
+ <DropzoneUploadIcon className="h-10 w-10 text-muted-foreground" />
+ <DropzoneTitle>
+ {selectedFile ? selectedFile.name : "워드 파일을 여기에 드래그하세요"}
+ </DropzoneTitle>
+ <DropzoneDescription>
+ {selectedFile
+ ? `파일 크기: ${(selectedFile.size / (1024 * 1024)).toFixed(2)} MB`
+ : "또는 클릭하여 파일을 선택하세요 (최대 100MB)"}
+ </DropzoneDescription>
+ <DropzoneInput />
+ </DropzoneZone>
+ </Dropzone>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {showProgress && (
+ <div className="space-y-2 mt-4">
+ <div className="flex justify-between text-sm">
+ <span>업로드 진행률</span>
+ <span>{uploadProgress}%</span>
+ </div>
+ <Progress value={uploadProgress} />
+ </div>
+ )}
+
+ {/* 변수 사용 안내 */}
+ <div className="mt-4 p-3 bg-blue-50 rounded-lg">
+ <div className="flex items-start">
+ <AlertCircle className="h-4 w-4 text-blue-600 mt-0.5 mr-2 flex-shrink-0" />
+ <div className="text-sm text-blue-900">
+ <p className="font-medium mb-1">변수 사용 방법</p>
+ <ul className="list-disc list-inside space-y-1 text-xs">
+ <li>워드 문서에서 {'{{변수명}}'} 형식으로 변수를 삽입하세요.</li>
+ <li>예시: {'{{document_number}}'}, {'{{project_code}}'}, {'{{project_name}}'}</li>
+ <li>문서 생성 시 변수가 실제 값으로 치환됩니다.</li>
+ </ul>
+ </div>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ </form>
+ </Form>
+ </div>
+
+ {/* 푸터 - 고정 */}
+ <DialogFooter className="px-6 py-4 border-t">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setOpen(false)}
+ disabled={isLoading}
+ >
+ 취소
+ </Button>
+ <Button
+ type="button"
+ onClick={form.handleSubmit(onSubmit)}
+ disabled={isLoading || !form.watch("file") || !form.watch("projectId")}
+ >
+ {isLoading ? "처리 중..." : "템플릿 추가"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+} \ No newline at end of file