diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-12 08:01:02 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-12 08:01:02 +0000 |
| commit | a9575387c3a765a1a65ebc179dae16a21af6eb25 (patch) | |
| tree | 347f2b17b07039080fb2f116460004ba0b75a779 /lib/general-contract-template/template/create-revision-dialog.tsx | |
| parent | 47e527f5f763658600696ee58451fb666e692f5a (diff) | |
(임수민) 일반 계약 템플릿 구현 및 basic contract 필터 수정
Diffstat (limited to 'lib/general-contract-template/template/create-revision-dialog.tsx')
| -rw-r--r-- | lib/general-contract-template/template/create-revision-dialog.tsx | 435 |
1 files changed, 435 insertions, 0 deletions
diff --git a/lib/general-contract-template/template/create-revision-dialog.tsx b/lib/general-contract-template/template/create-revision-dialog.tsx new file mode 100644 index 00000000..86939f7c --- /dev/null +++ b/lib/general-contract-template/template/create-revision-dialog.tsx @@ -0,0 +1,435 @@ +"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 { FileText, Loader, Copy } from "lucide-react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +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 { useRouter } from "next/navigation"; +import { GeneralContractTemplate } from "@/db/schema"; +import { createGeneralContractTemplateRevisionAction } from "../actions"; + +// 리비전 생성 스키마 정의 +const createRevisionSchema = z.object({ + contractTemplateName: z.string().min(1, "계약 문서명을 입력해주세요."), + contractTemplateType: z.string().min(1, "계약 종류를 입력해주세요."), + revision: z.number().min(1, "리비전 번호는 1 이상이어야 합니다."), + legalReviewRequired: z.boolean(), + file: z.instanceof(File).optional(), +}); + +type CreateRevisionFormValues = z.infer<typeof createRevisionSchema>; + +interface CreateRevisionDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + baseTemplate: GeneralContractTemplate | null; + onSuccess?: () => void; +} + +export function CreateRevisionDialog({ + open, + onOpenChange, + baseTemplate, + onSuccess +}: CreateRevisionDialogProps) { + const router = useRouter(); + const [isLoading, setIsLoading] = React.useState(false); + const [uploadProgress, setUploadProgress] = React.useState(0); + const [suggestedRevision, setSuggestedRevision] = React.useState<number>(1); + + // 기본 템플릿의 다음 리비전 번호 계산 + React.useEffect(() => { + if (baseTemplate) { + setSuggestedRevision(baseTemplate.revision + 1); + } + }, [baseTemplate]); + + // 기본값 설정 (기존 템플릿의 설정을 상속) + const defaultValues: Partial<CreateRevisionFormValues> = React.useMemo(() => { + if (!baseTemplate) return {}; + + return { + contractTemplateName: baseTemplate.contractTemplateName, + contractTemplateType: baseTemplate.contractTemplateType, + revision: suggestedRevision, + legalReviewRequired: baseTemplate.legalReviewRequired || false, + }; + }, [baseTemplate, suggestedRevision]); + + // 폼 초기화 + const form = useForm<CreateRevisionFormValues>({ + resolver: zodResolver(createRevisionSchema), + defaultValues, + mode: "onChange", + }); + + // baseTemplate이 변경될 때 폼 값 재설정 + React.useEffect(() => { + if (baseTemplate && defaultValues) { + form.reset(defaultValues); + } + }, [baseTemplate, defaultValues, form]); + + // 파일 업로드 핸들러 (basic-contract와 동일한 방식) + const uploadFileInChunks = async (file: File, fileId: string) => { + const chunkSize = 1024 * 1024; // 1MB chunks + const totalChunks = Math.ceil(file.size / chunkSize); + + for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { + const start = chunkIndex * chunkSize; + const end = Math.min(start + chunkSize, file.size); + const chunk = file.slice(start, end); + + const formData = new FormData(); + formData.append('file', chunk); + formData.append('chunkIndex', chunkIndex.toString()); + formData.append('totalChunks', totalChunks.toString()); + formData.append('fileId', fileId); + formData.append('fileName', file.name); + + try { + const response = await fetch('/api/upload/generalContract/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; + } + } catch (error) { + console.error(`청크 ${chunkIndex} 업로드 오류:`, error); + throw error; + } + } + }; + + async function onSubmit(formData: CreateRevisionFormValues) { + if (!baseTemplate) { + toast.error("기본 템플릿 정보가 없습니다."); + return; + } + + setIsLoading(true); + setUploadProgress(0); + + try { + let fileName = baseTemplate.fileName || ""; + let filePath = baseTemplate.filePath || ""; + + // 새 파일이 업로드된 경우 + if (formData.file) { + const fileId = uuidv4(); + const uploadResult = await uploadFileInChunks(formData.file, fileId); + + if (!uploadResult.success) { + throw new Error("파일 업로드에 실패했습니다."); + } + + fileName = uploadResult.fileName; + filePath = uploadResult.filePath; + } + + // Server Action으로 리비전 생성 + const result = await createGeneralContractTemplateRevisionAction({ + baseTemplateId: baseTemplate.id, + contractTemplateName: formData.contractTemplateName, + contractTemplateType: formData.contractTemplateType, + revision: formData.revision, + legalReviewRequired: formData.legalReviewRequired, + fileName, + filePath, + }); + + toast.success(result.message); + + onSuccess?.(); + onOpenChange(false); + form.reset(); + + // 페이지 새로고침 + window.location.reload(); + + } catch (error) { + console.error("리비전 생성 오류:", error); + toast.error("리비전 생성 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + setUploadProgress(0); + } + } + + if (!baseTemplate) return null; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[600px] h-[90vh] flex flex-col p-0"> + {/* 고정된 헤더 */} + <DialogHeader className="p-6 pb-4 border-b"> + <DialogTitle className="flex items-center gap-2"> + <Copy className="h-5 w-5" /> + 새 리비전 생성 + </DialogTitle> + <DialogDescription> + <div className="space-y-2"> + <div className="flex items-center gap-2"> + <FileText className="h-4 w-4" /> + <span className="font-medium">{baseTemplate.contractTemplateName}</span> + <Badge variant="outline">현재 v{baseTemplate.revision}</Badge> + <span>→</span> + <Badge variant="default">새 v{suggestedRevision}</Badge> + </div> + <p className="text-sm"> + 기존 템플릿을 기반으로 새로운 리비전을 생성합니다. + <span className="text-red-500 mt-1 block">* 표시된 항목은 필수 입력사항입니다.</span> + </p> + </div> + </DialogDescription> + </DialogHeader> + + {/* 스크롤 가능한 컨텐츠 영역 */} + <div className="flex-1 overflow-y-auto px-6"> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 py-4"> + {/* 리비전 정보 */} + <Card> + <CardHeader> + <CardTitle className="text-lg">리비전 정보</CardTitle> + <CardDescription> + 새로 생성할 리비전의 번호를 설정하세요 + </CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + <FormField + control={form.control} + name="revision" + render={({ field }) => ( + <FormItem> + <FormLabel> + 리비전 번호 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input + type="number" + min={suggestedRevision} + {...field} + onChange={(e) => field.onChange(parseInt(e.target.value) || suggestedRevision)} + /> + </FormControl> + <FormDescription> + 권장 리비전: {suggestedRevision} (현재 리비전보다 큰 숫자여야 합니다) + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="legalReviewRequired" + render={({ field }) => ( + <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm"> + <div className="space-y-0.5"> + <FormLabel>법무검토 필요</FormLabel> + <FormDescription> + 법무팀 검토가 필요한 템플릿인지 설정 + </FormDescription> + </div> + <FormControl> + <Switch + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + </FormItem> + )} + /> + </CardContent> + </Card> + + {/* 기본 정보 */} + <Card> + <CardHeader> + <CardTitle className="text-lg">기본 정보</CardTitle> + <CardDescription> + 템플릿의 기본 정보를 입력하세요 + </CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + <FormField + control={form.control} + name="contractTemplateName" + render={({ field }) => ( + <FormItem> + <FormLabel>계약 문서명 <span className="text-red-500">*</span></FormLabel> + <FormControl> + <Input + placeholder="계약 문서명을 입력하세요" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="contractTemplateType" + render={({ field }) => ( + <FormItem> + <FormLabel>계약 종류 <span className="text-red-500">*</span></FormLabel> + <FormControl> + <Input + placeholder="계약 종류를 입력하세요 (예: LO, FA, PO, CS, EU)" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </CardContent> + </Card> + + {/* 파일 업로드 */} + <Card> + <CardHeader> + <CardTitle className="text-lg">파일 업로드</CardTitle> + <CardDescription> + 새로운 파일을 업로드하거나 기존 파일을 사용할 수 있습니다. + </CardDescription> + </CardHeader> + <CardContent> + <FormField + control={form.control} + name="file" + render={({ field: { onChange, value, ...field } }) => ( + <FormItem> + <FormLabel>새 파일 (선택사항)</FormLabel> + <FormControl> + <Dropzone + onDrop={(acceptedFiles) => { + if (acceptedFiles.length > 0) { + onChange(acceptedFiles[0]); + } + }} + accept={{ + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], + 'application/msword': ['.doc'], + }} + maxFiles={1} + > + <DropzoneZone> + <DropzoneUploadIcon className="mx-auto h-10 w-10 text-muted-foreground" /> + <DropzoneTitle>파일을 드래그하거나 클릭하여 업로드</DropzoneTitle> + <DropzoneDescription> + DOCX 또는 DOC 파일만 업로드 가능합니다. + </DropzoneDescription> + <DropzoneInput /> + </DropzoneZone> + </Dropzone> + </FormControl> + {value && ( + <div className="mt-2 flex items-center gap-2"> + <FileText className="h-4 w-4" /> + <span className="text-sm">{value.name}</span> + </div> + )} + {uploadProgress > 0 && uploadProgress < 100 && ( + <div className="mt-2"> + <Progress value={uploadProgress} className="w-full" /> + <p className="text-xs text-muted-foreground mt-1"> + 업로드 중... {Math.round(uploadProgress)}% + </p> + </div> + )} + <FormMessage /> + </FormItem> + )} + /> + + {baseTemplate?.fileName && ( + <div className="mt-4 p-3 bg-muted rounded-lg"> + <p className="text-sm text-muted-foreground mb-2">기존 파일:</p> + <div className="flex items-center gap-2"> + <FileText className="h-4 w-4" /> + <span className="text-sm">{baseTemplate.fileName}</span> + <Badge variant="secondary">기존 파일 사용</Badge> + </div> + </div> + )} + </CardContent> + </Card> + </form> + </Form> + </div> + + {/* 고정된 푸터 */} + <DialogFooter className="p-6 pt-4 border-t bg-muted/50"> + <div className="flex justify-end gap-3 w-full"> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isLoading} + > + 취소 + </Button> + <Button + type="submit" + onClick={form.handleSubmit(onSubmit)} + disabled={isLoading} + > + {isLoading ? ( + <> + <Loader className="mr-2 h-4 w-4 animate-spin" /> + 리비전 생성 중... + </> + ) : ( + "리비전 생성" + )} + </Button> + </div> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} |
