diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-29 13:31:40 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-29 13:31:40 +0000 |
| commit | 4614210aa9878922cfa1e424ce677ef893a1b6b2 (patch) | |
| tree | 5e7edcce05fbee207230af0a43ed08cd351d7c4f /lib/project-doc-templates/table/add-project-doc-template-dialog.tsx | |
| parent | e41e3af4e72870d44a94b03e0f3246d6ccaaca48 (diff) | |
(대표님) 구매 권한설정, data room 등
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.tsx | 642 |
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 |
