summaryrefslogtreecommitdiff
path: root/lib/basic-contract/template
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-07-24 11:06:32 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-07-24 11:06:32 +0000
commit1dc24d48e52f2e490f5603ceb02842586ecae533 (patch)
tree8fca2c5b5b52cc10557b5ba6e55b937ae3c57cf6 /lib/basic-contract/template
parented0d6fcc98f671280c2ccde797b50693da88152e (diff)
(대표님) 정기평가 피드백 반영, 설계 피드백 반영, (최겸) 기술영업 피드백 반영
Diffstat (limited to 'lib/basic-contract/template')
-rw-r--r--lib/basic-contract/template/add-basic-contract-template-dialog.tsx268
-rw-r--r--lib/basic-contract/template/basic-contract-template-columns.tsx41
-rw-r--r--lib/basic-contract/template/basic-contract-template.tsx13
-rw-r--r--lib/basic-contract/template/create-revision-dialog.tsx564
-rw-r--r--lib/basic-contract/template/update-basicContract-sheet.tsx106
5 files changed, 784 insertions, 208 deletions
diff --git a/lib/basic-contract/template/add-basic-contract-template-dialog.tsx b/lib/basic-contract/template/add-basic-contract-template-dialog.tsx
index 9b036445..fd1bd333 100644
--- a/lib/basic-contract/template/add-basic-contract-template-dialog.tsx
+++ b/lib/basic-contract/template/add-basic-contract-template-dialog.tsx
@@ -40,10 +40,14 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Separator } from "@/components/ui/separator";
import { useRouter } from "next/navigation";
import { BUSINESS_UNITS } from "@/config/basicContractColumnsConfig";
+import { getExistingTemplateNames } from "../service";
-// 템플릿 이름 옵션 정의
+// ✅ 서버 액션 import
+
+// 전체 템플릿 후보
const TEMPLATE_NAME_OPTIONS = [
- "준법서약",
+ "준법서약 (한글)",
+ "준법서약 (영문)",
"기술자료 요구서",
"비밀유지 계약서",
"표준하도급기본 계약서",
@@ -56,14 +60,11 @@ const TEMPLATE_NAME_OPTIONS = [
"직납자재 하도급대급등 연동제 의향서"
] as const;
-// 업데이트된 계약서 템플릿 스키마 정의 (워드파일만 허용)
const templateFormSchema = z.object({
templateName: z.enum(TEMPLATE_NAME_OPTIONS, {
required_error: "템플릿 이름을 선택해주세요.",
}),
- revision: z.coerce.number().int().min(1).default(1),
legalReviewRequired: z.boolean().default(false),
-
// 적용 범위
shipBuildingApplicable: z.boolean().default(false),
windApplicable: z.boolean().default(false),
@@ -73,31 +74,42 @@ const templateFormSchema = z.object({
gyApplicable: z.boolean().default(false),
sysApplicable: z.boolean().default(false),
infraApplicable: z.boolean().default(false),
-
- file: z
- .instanceof(File, { message: "파일을 업로드해주세요." })
- .refine((file) => file.size <= 100 * 1024 * 1024, {
- message: "파일 크기는 100MB 이하여야 합니다.",
- })
- .refine(
- (file) =>
- file.type === 'application/msword' ||
- file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
- { message: "워드 파일(.doc, .docx)만 업로드 가능합니다." }
- ),
- status: z.enum(["ACTIVE", "DISPOSED"]).default("ACTIVE"),
-}).refine((data) => {
- // 적어도 하나의 적용 범위는 선택되어야 함
+ file: z.instanceof(File).optional(),
+})
+.refine((data) => {
+ if (data.templateName !== "General GTC" && !data.file) return false;
+ return true;
+}, {
+ message: "파일을 업로드해주세요.",
+ path: ["file"],
+})
+.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 isValidType = data.file.type === 'application/msword' ||
+ data.file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
+ return isValidType;
+ }
+ return true;
+}, {
+ message: "워드 파일(.doc, .docx)만 업로드 가능합니다.",
+ path: ["file"],
+})
+.refine((data) => {
const scopeFields = [
'shipBuildingApplicable', 'windApplicable', 'pcApplicable', 'nbApplicable',
'rcApplicable', 'gyApplicable', 'sysApplicable', 'infraApplicable'
];
-
- const hasAnyScope = scopeFields.some(field => data[field as keyof typeof data] === true);
- return hasAnyScope;
+ return scopeFields.some(field => data[field as keyof typeof data] === true);
}, {
message: "적어도 하나의 적용 범위를 선택해야 합니다.",
- path: ["shipBuildingApplicable"], // 에러를 첫 번째 체크박스에 표시
+ path: ["shipBuildingApplicable"],
});
type TemplateFormValues = z.infer<typeof templateFormSchema>;
@@ -108,12 +120,12 @@ export function AddTemplateDialog() {
const [selectedFile, setSelectedFile] = React.useState<File | null>(null);
const [uploadProgress, setUploadProgress] = React.useState(0);
const [showProgress, setShowProgress] = React.useState(false);
+ const [availableTemplateNames, setAvailableTemplateNames] = React.useState<typeof TEMPLATE_NAME_OPTIONS[number][]>(TEMPLATE_NAME_OPTIONS);
const router = useRouter();
- // 기본값 설정 (templateCode 제거)
+ // 기본값
const defaultValues: Partial<TemplateFormValues> = {
templateName: undefined,
- revision: 1,
legalReviewRequired: false,
shipBuildingApplicable: false,
windApplicable: false,
@@ -123,17 +135,33 @@ export function AddTemplateDialog() {
gyApplicable: false,
sysApplicable: false,
infraApplicable: false,
- status: "ACTIVE",
};
- // 폼 초기화
const form = useForm<TemplateFormValues>({
resolver: zodResolver(templateFormSchema),
defaultValues,
mode: "onChange",
});
- // 파일 선택 핸들러
+ // 🔸 마운트 시 이미 등록된 templateName 목록 가져와서 필터링
+ React.useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ try {
+ const usedNames = await getExistingTemplateNames();
+ if (cancelled) return;
+
+ // 이미 있는 이름 제외
+ const filtered = TEMPLATE_NAME_OPTIONS.filter(name => !usedNames.includes(name));
+ setAvailableTemplateNames(filtered);
+ } catch (err) {
+ console.error("Failed to fetch existing template names", err);
+ // 실패 시 전체 옵션 보여주거나, 오류 알려주기
+ }
+ })();
+ return () => { cancelled = true; };
+ }, []);
+
const handleFileChange = (files: File[]) => {
if (files.length > 0) {
const file = files[0];
@@ -142,88 +170,71 @@ export function AddTemplateDialog() {
}
};
- // 모든 적용 범위 선택/해제
const handleSelectAllScopes = (checked: boolean) => {
BUSINESS_UNITS.forEach(unit => {
form.setValue(unit.key as keyof TemplateFormValues, checked);
});
};
- // 청크 크기 설정 (1MB)
+ // 청크 업로드 설정
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);
-
- try {
- const response = await fetch('/api/upload/basicContract/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;
+
+ const response = await fetch('/api/upload/basicContract/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;
}
}
};
- // 폼 제출 핸들러 (templateCode 제거)
async function onSubmit(formData: TemplateFormValues) {
setIsLoading(true);
try {
- if (!formData.file) {
- throw new Error("파일이 선택되지 않았습니다.");
- }
-
- // 고유 파일 ID 생성
- const fileId = uuidv4();
-
- // 파일 청크 업로드
- const uploadResult = await uploadFileInChunks(formData.file, fileId);
-
- if (!uploadResult.success) {
- throw new Error("파일 업로드에 실패했습니다.");
+ let uploadResult = null;
+
+ if (formData.file) {
+ const fileId = uuidv4();
+ uploadResult = await uploadFileInChunks(formData.file, fileId);
+
+ if (!uploadResult?.success) {
+ throw new Error("파일 업로드에 실패했습니다.");
+ }
}
-
- // 메타데이터 저장 (templateCode 제거됨)
+
const saveResponse = await fetch('/api/upload/basicContract/complete', {
method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
+ headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
templateName: formData.templateName,
- revision: formData.revision,
+ revision: 1,
legalReviewRequired: formData.legalReviewRequired,
shipBuildingApplicable: formData.shipBuildingApplicable,
windApplicable: formData.windApplicable,
@@ -233,35 +244,32 @@ export function AddTemplateDialog() {
gyApplicable: formData.gyApplicable,
sysApplicable: formData.sysApplicable,
infraApplicable: formData.infraApplicable,
- status: formData.status,
- fileName: uploadResult.fileName,
- filePath: uploadResult.filePath,
+ status: "ACTIVE",
+ fileName: uploadResult?.fileName || `${formData.templateName}_v1.docx`,
+ filePath: uploadResult?.filePath || "",
}),
next: { tags: ["basic-contract-templates"] },
});
-
+
const saveResult = await saveResponse.json();
-
if (!saveResult.success) {
- throw new Error("템플릿 정보 저장에 실패했습니다.");
+ throw new Error(saveResult.error || "템플릿 정보 저장에 실패했습니다.");
}
-
+
toast.success('템플릿이 성공적으로 추가되었습니다.');
form.reset();
setSelectedFile(null);
setOpen(false);
setShowProgress(false);
-
router.refresh();
} catch (error) {
console.error("Submit error:", error);
- toast.error("템플릿 추가 중 오류가 발생했습니다.");
+ toast.error(error instanceof Error ? error.message : "템플릿 추가 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
}
- // 모달이 닫힐 때 폼 초기화
React.useEffect(() => {
if (!open) {
form.reset();
@@ -278,11 +286,17 @@ export function AddTemplateDialog() {
setOpen(nextOpen);
}
- // 현재 선택된 적용 범위 수
- const selectedScopesCount = BUSINESS_UNITS.filter(unit =>
+ const selectedScopesCount = BUSINESS_UNITS.filter(unit =>
form.watch(unit.key as keyof TemplateFormValues)
).length;
+ const templateNameIsRequired = form.watch("templateName") !== "General GTC";
+
+ const isSubmitDisabled = isLoading ||
+ !form.watch("templateName") ||
+ (templateNameIsRequired && !form.watch("file")) ||
+ !BUSINESS_UNITS.some(unit => form.watch(unit.key as keyof TemplateFormValues));
+
return (
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
<DialogTrigger asChild>
@@ -291,16 +305,14 @@ export function AddTemplateDialog() {
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[600px] h-[90vh] flex flex-col p-0">
- {/* 고정된 헤더 */}
<DialogHeader className="p-6 pb-4 border-b">
<DialogTitle>새 기본계약서 템플릿 추가</DialogTitle>
<DialogDescription>
- 템플릿 정보를 입력하고 계약서 파일을 업로드하세요.
+ 템플릿 정보를 입력하고 계약서 파일을 업로드하세요. (리비전은 자동으로 1로 설정됩니다)
<span className="text-red-500 mt-1 block text-sm">* 표시된 항목은 필수 입력사항입니다.</span>
</DialogDescription>
</DialogHeader>
- {/* 스크롤 가능한 컨텐츠 영역 */}
<div className="flex-1 overflow-y-auto px-6">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 py-4">
@@ -310,7 +322,7 @@ export function AddTemplateDialog() {
<CardTitle className="text-lg">기본 정보</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
- <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div className="grid grid-cols-1 gap-4">
<FormField
control={form.control}
name="templateName"
@@ -319,14 +331,18 @@ export function AddTemplateDialog() {
<FormLabel>
템플릿 이름 <span className="text-red-500">*</span>
</FormLabel>
- <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <Select onValueChange={field.onChange} value={field.value}>
<FormControl>
- <SelectTrigger>
- <SelectValue placeholder="템플릿 이름을 선택하세요" />
+ <SelectTrigger disabled={availableTemplateNames.length === 0}>
+ <SelectValue placeholder={
+ availableTemplateNames.length === 0
+ ? "사용 가능한 템플릿이 없습니다"
+ : "템플릿 이름을 선택하세요"
+ } />
</SelectTrigger>
</FormControl>
<SelectContent>
- {TEMPLATE_NAME_OPTIONS.map((option) => (
+ {availableTemplateNames.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
@@ -334,33 +350,7 @@ export function AddTemplateDialog() {
</SelectContent>
</Select>
<FormDescription>
- 미리 정의된 템플릿 중에서 선택
- </FormDescription>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="revision"
- render={({ field }) => (
- <FormItem>
- <FormLabel>리비전</FormLabel>
- <FormControl>
- <Input
- type="number"
- min="1"
- {...field}
- onChange={(e) => field.onChange(parseInt(e.target.value) || 1)}
- />
- </FormControl>
- <FormDescription>
- 템플릿 버전 (기본값: 1)
- <br />
- <span className="text-xs text-muted-foreground">
- 동일한 템플릿 이름의 리비전은 중복될 수 없습니다.
- </span>
+ 이미 등록되지 않은 템플릿만 표시됩니다. (리비전 1로 생성)
</FormDescription>
<FormMessage />
</FormItem>
@@ -412,9 +402,9 @@ export function AddTemplateDialog() {
전체 선택
</label>
</div>
-
+
<Separator />
-
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{BUSINESS_UNITS.map((unit) => (
<FormField
@@ -439,7 +429,7 @@ export function AddTemplateDialog() {
/>
))}
</div>
-
+
{form.formState.errors.shipBuildingApplicable && (
<p className="text-sm text-destructive">
{form.formState.errors.shipBuildingApplicable.message}
@@ -452,6 +442,11 @@ export function AddTemplateDialog() {
<Card>
<CardHeader>
<CardTitle className="text-lg">파일 업로드</CardTitle>
+ <CardDescription>
+ {form.watch("templateName") === "General GTC"
+ ? "General GTC는 파일 업로드가 선택사항입니다"
+ : "템플릿 파일을 업로드하세요"}
+ </CardDescription>
</CardHeader>
<CardContent>
<FormField
@@ -460,7 +455,13 @@ export function AddTemplateDialog() {
render={() => (
<FormItem>
<FormLabel>
- 계약서 파일 <span className="text-red-500">*</span>
+ 템플릿 파일
+ {form.watch("templateName") !== "General GTC" && (
+ <span className="text-red-500"> *</span>
+ )}
+ {form.watch("templateName") === "General GTC" && (
+ <span className="text-muted-foreground"> (선택사항)</span>
+ )}
</FormLabel>
<FormControl>
<Dropzone
@@ -478,7 +479,9 @@ export function AddTemplateDialog() {
<DropzoneDescription>
{selectedFile
? `파일 크기: ${(selectedFile.size / (1024 * 1024)).toFixed(2)} MB`
- : "또는 클릭하여 워드 파일(.doc, .docx)을 선택하세요 (최대 100MB)"}
+ : form.watch("templateName") === "General GTC"
+ ? "또는 클릭하여 워드 파일(.doc, .docx)을 선택하세요 (선택사항, 최대 100MB)"
+ : "또는 클릭하여 워드 파일(.doc, .docx)을 선택하세요 (최대 100MB)"}
</DropzoneDescription>
<DropzoneInput />
</DropzoneZone>
@@ -488,7 +491,7 @@ export function AddTemplateDialog() {
</FormItem>
)}
/>
-
+
{showProgress && (
<div className="space-y-2 mt-4">
<div className="flex justify-between text-sm">
@@ -504,7 +507,6 @@ export function AddTemplateDialog() {
</Form>
</div>
- {/* 고정된 푸터 */}
<DialogFooter className="p-6 pt-4 border-t">
<Button
type="button"
@@ -517,7 +519,7 @@ export function AddTemplateDialog() {
<Button
type="button"
onClick={form.handleSubmit(onSubmit)}
- disabled={isLoading || !form.formState.isValid}
+ disabled={isSubmitDisabled}
>
{isLoading ? "처리 중..." : "추가"}
</Button>
@@ -525,4 +527,4 @@ export function AddTemplateDialog() {
</DialogContent>
</Dialog>
);
-} \ No newline at end of file
+}
diff --git a/lib/basic-contract/template/basic-contract-template-columns.tsx b/lib/basic-contract/template/basic-contract-template-columns.tsx
index b7c2fa08..5783ca27 100644
--- a/lib/basic-contract/template/basic-contract-template-columns.tsx
+++ b/lib/basic-contract/template/basic-contract-template-columns.tsx
@@ -3,7 +3,7 @@
import * as React from "react"
import { type DataTableRowAction } from "@/types/table"
import { type ColumnDef } from "@tanstack/react-table"
-import { Download, Ellipsis, Paperclip, CheckCircle, XCircle, Eye } from "lucide-react"
+import { Download, Ellipsis, Paperclip, CheckCircle, XCircle, Eye, Copy, GitBranch } from "lucide-react"
import { toast } from "sonner"
import { getErrorMessage } from "@/lib/handle-error"
@@ -119,7 +119,13 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
const template = row.original;
const handleViewDetails = () => {
- router.push(`/evcp/basic-contract-template/${template.id}`);
+ // templateName이 "General GTC"인 경우 특별한 라우팅
+ if (template.templateName === "General GTC") {
+ router.push(`/evcp/basic-contract-template/gtc`);
+ } else {
+ // 일반적인 경우는 기존과 동일
+ router.push(`/evcp/basic-contract-template/${template.id}`);
+ }
};
return (
@@ -133,25 +139,34 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
<Ellipsis className="size-4" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem onSelect={handleViewDetails}>
<Eye className="mr-2 h-4 w-4" />
View Details
</DropdownMenuItem>
-
+
+ <DropdownMenuSeparator />
+
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "createRevision" })}
+ >
+ <GitBranch className="mr-2 h-4 w-4" />
+ 리비전 생성
+ </DropdownMenuItem>
+
<DropdownMenuSeparator />
-
+
<DropdownMenuItem
onSelect={() => setRowAction({ row, type: "update" })}
>
- Edit
+ 수정하기
</DropdownMenuItem>
{template.status === 'ACTIVE' && (
<DropdownMenuItem
onSelect={() => setRowAction({ row, type: "dispose" })}
>
- Dispose
+ 폐기하기
</DropdownMenuItem>
)}
@@ -159,7 +174,7 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
<DropdownMenuItem
onSelect={() => setRowAction({ row, type: "restore" })}
>
- Restore
+ 복구하기
</DropdownMenuItem>
)}
@@ -204,7 +219,7 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약문서명" />,
cell: ({ row }) => {
const template = row.original;
-
+
const handleClick = () => {
router.push(`/evcp/basic-contract-template/${template.id}`);
};
@@ -230,10 +245,14 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
cell: ({ row }) => {
const template = row.original;
return (
- <span className="text-xs text-muted-foreground">v{template.revision}</span>
+ <div className="flex items-center gap-2">
+ <Badge variant="outline" className="text-xs">
+ v{template.revision}
+ </Badge>
+ </div>
);
},
- size: 60,
+ size: 80,
enableResizing: true,
},
{
diff --git a/lib/basic-contract/template/basic-contract-template.tsx b/lib/basic-contract/template/basic-contract-template.tsx
index 4fc70af4..470bc925 100644
--- a/lib/basic-contract/template/basic-contract-template.tsx
+++ b/lib/basic-contract/template/basic-contract-template.tsx
@@ -13,6 +13,7 @@ import { getBasicContractTemplates} from "../service";
import { getColumns } from "./basic-contract-template-columns";
import { DeleteTemplatesDialog } from "./delete-basicContract-dialog";
import { UpdateTemplateSheet } from "./update-basicContract-sheet";
+import { CreateRevisionDialog } from "./create-revision-dialog";
import { TemplateTableToolbarActions } from "./basicContract-table-toolbar-actions";
import { BasicContractTemplate } from "@/db/schema";
@@ -30,7 +31,7 @@ export function BasicContractTemplateTable({ promises }: BasicTemplateTableProps
React.useState<DataTableRowAction<BasicContractTemplate> | null>(null)
const [{ data, pageCount }] =
React.use(promises)
-
+
// 컬럼 설정 - router를 전달
const columns = React.useMemo(
() => getColumns({ setRowAction, router }),
@@ -90,6 +91,16 @@ export function BasicContractTemplateTable({ promises }: BasicTemplateTableProps
onOpenChange={() => setRowAction(null)}
template={rowAction?.row.original ?? null}
/>
+
+ <CreateRevisionDialog
+ open={rowAction?.type === "createRevision"}
+ onOpenChange={() => setRowAction(null)}
+ baseTemplate={rowAction?.row.original ?? null}
+ onSuccess={() => {
+ setRowAction(null);
+ router.refresh();
+ }}
+ />
</>
);
} \ No newline at end of file
diff --git a/lib/basic-contract/template/create-revision-dialog.tsx b/lib/basic-contract/template/create-revision-dialog.tsx
new file mode 100644
index 00000000..262df6ba
--- /dev/null
+++ b/lib/basic-contract/template/create-revision-dialog.tsx
@@ -0,0 +1,564 @@
+"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 { Copy, FileText, Loader } 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 { Checkbox } from "@/components/ui/checkbox";
+import { Switch } from "@/components/ui/switch";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+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 { Separator } from "@/components/ui/separator";
+import { Badge } from "@/components/ui/badge";
+import { useRouter } from "next/navigation";
+import { BUSINESS_UNITS } from "@/config/basicContractColumnsConfig";
+import { BasicContractTemplate } from "@/db/schema";
+
+// 템플릿 이름 옵션 정의
+const TEMPLATE_NAME_OPTIONS = [
+ "준법서약 (한글)",
+ "준법서약 (영문)",
+ "기술자료 요구서",
+ "비밀유지 계약서",
+ "표준하도급기본 계약서",
+ "General GTC",
+ "안전보건관리 약정서",
+ "동반성장",
+ "윤리규범 준수 서약서",
+ "기술자료 동의서",
+ "내국신용장 미개설 합의서",
+ "직납자재 하도급대급등 연동제 의향서"
+] as const;
+
+// 리비전 생성 스키마 정의
+const createRevisionSchema = z.object({
+ revision: z.coerce.number().int().min(1),
+ legalReviewRequired: z.boolean().default(false),
+
+ // 적용 범위
+ shipBuildingApplicable: z.boolean().default(false),
+ windApplicable: z.boolean().default(false),
+ pcApplicable: z.boolean().default(false),
+ nbApplicable: z.boolean().default(false),
+ rcApplicable: z.boolean().default(false),
+ gyApplicable: z.boolean().default(false),
+ sysApplicable: z.boolean().default(false),
+ infraApplicable: z.boolean().default(false),
+
+ file: z
+ .instanceof(File, { message: "파일을 업로드해주세요." })
+ .refine((file) => file.size <= 100 * 1024 * 1024, {
+ message: "파일 크기는 100MB 이하여야 합니다.",
+ })
+ .refine(
+ (file) =>
+ file.type === 'application/msword' ||
+ file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ { message: "워드 파일(.doc, .docx)만 업로드 가능합니다." }
+ ),
+}).refine((data) => {
+ // 적어도 하나의 적용 범위는 선택되어야 함
+ const scopeFields = [
+ 'shipBuildingApplicable', 'windApplicable', 'pcApplicable', 'nbApplicable',
+ 'rcApplicable', 'gyApplicable', 'sysApplicable', 'infraApplicable'
+ ];
+
+ const hasAnyScope = scopeFields.some(field => data[field as keyof typeof data] === true);
+ return hasAnyScope;
+}, {
+ message: "적어도 하나의 적용 범위를 선택해야 합니다.",
+ path: ["shipBuildingApplicable"],
+});
+
+type CreateRevisionFormValues = z.infer<typeof createRevisionSchema>;
+
+interface CreateRevisionDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ baseTemplate: BasicContractTemplate | null;
+ onSuccess?: () => void;
+}
+
+export function CreateRevisionDialog({
+ open,
+ onOpenChange,
+ baseTemplate,
+ onSuccess
+}: CreateRevisionDialogProps) {
+ 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 [suggestedRevision, setSuggestedRevision] = React.useState<number>(1);
+ const router = useRouter();
+
+ // 기본 템플릿의 다음 리비전 번호 계산
+ React.useEffect(() => {
+ if (baseTemplate) {
+ setSuggestedRevision(baseTemplate.revision + 1);
+ }
+ }, [baseTemplate]);
+
+ // 기본값 설정 (기존 템플릿의 설정을 상속)
+ const defaultValues: Partial<CreateRevisionFormValues> = React.useMemo(() => {
+ if (!baseTemplate) return {};
+
+ return {
+ revision: suggestedRevision,
+ legalReviewRequired: baseTemplate.legalReviewRequired,
+ shipBuildingApplicable: baseTemplate.shipBuildingApplicable,
+ windApplicable: baseTemplate.windApplicable,
+ pcApplicable: baseTemplate.pcApplicable,
+ nbApplicable: baseTemplate.nbApplicable,
+ rcApplicable: baseTemplate.rcApplicable,
+ gyApplicable: baseTemplate.gyApplicable,
+ sysApplicable: baseTemplate.sysApplicable,
+ infraApplicable: baseTemplate.infraApplicable,
+ };
+ }, [baseTemplate, suggestedRevision]);
+
+ // 폼 초기화
+ const form = useForm<CreateRevisionFormValues>({
+ resolver: zodResolver(createRevisionSchema),
+ defaultValues,
+ mode: "onChange",
+ });
+
+ // baseTemplate이 변경될 때 폼 값 재설정
+ React.useEffect(() => {
+ if (baseTemplate && defaultValues) {
+ form.reset(defaultValues);
+ }
+ }, [baseTemplate, defaultValues, form]);
+
+ // 파일 선택 핸들러
+ const handleFileChange = (files: File[]) => {
+ if (files.length > 0) {
+ const file = files[0];
+ setSelectedFile(file);
+ form.setValue("file", file);
+ }
+ };
+
+ // 모든 적용 범위 선택/해제
+ const handleSelectAllScopes = (checked: boolean) => {
+ BUSINESS_UNITS.forEach(unit => {
+ form.setValue(unit.key as keyof CreateRevisionFormValues, checked);
+ });
+ };
+
+ // 이전 설정 복사
+ const handleCopyPreviousSettings = () => {
+ if (!baseTemplate) return;
+
+ BUSINESS_UNITS.forEach(unit => {
+ const value = baseTemplate[unit.key as keyof BasicContractTemplate] as boolean;
+ form.setValue(unit.key as keyof CreateRevisionFormValues, value);
+ });
+
+ form.setValue("legalReviewRequired", baseTemplate.legalReviewRequired);
+ toast.success("이전 설정이 복사되었습니다.");
+ };
+
+ // 청크 크기 설정 (1MB)
+ 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);
+
+ try {
+ const response = await fetch('/api/upload/basicContract/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) return;
+
+ setIsLoading(true);
+ try {
+ if (!formData.file) {
+ throw new Error("파일이 선택되지 않았습니다.");
+ }
+
+ // 고유 파일 ID 생성
+ const fileId = uuidv4();
+
+ // 파일 청크 업로드
+ const uploadResult = await uploadFileInChunks(formData.file, fileId);
+
+ if (!uploadResult.success) {
+ throw new Error("파일 업로드에 실패했습니다.");
+ }
+
+ // 새 리비전 생성 API 호출
+ const createResponse = await fetch('/api/basicContract/create-revision', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ baseTemplateId: baseTemplate.id,
+ templateName: baseTemplate.templateName,
+ revision: formData.revision,
+ legalReviewRequired: formData.legalReviewRequired,
+ shipBuildingApplicable: formData.shipBuildingApplicable,
+ windApplicable: formData.windApplicable,
+ pcApplicable: formData.pcApplicable,
+ nbApplicable: formData.nbApplicable,
+ rcApplicable: formData.rcApplicable,
+ gyApplicable: formData.gyApplicable,
+ sysApplicable: formData.sysApplicable,
+ infraApplicable: formData.infraApplicable,
+ fileName: uploadResult.fileName,
+ filePath: uploadResult.filePath,
+ }),
+ next: { tags: ["basic-contract-templates"] },
+ });
+
+ const createResult = await createResponse.json();
+
+ if (!createResult.success) {
+ throw new Error(createResult.error || "리비전 생성에 실패했습니다.");
+ }
+
+ toast.success(`${baseTemplate.templateName} v${formData.revision} 리비전이 성공적으로 생성되었습니다.`);
+ form.reset();
+ setSelectedFile(null);
+ onOpenChange(false);
+ setShowProgress(false);
+ onSuccess?.();
+ router.refresh();
+ } catch (error) {
+ console.error("Submit error:", error);
+ toast.error("리비전 생성 중 오류가 발생했습니다.");
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ // 모달이 닫힐 때 폼 초기화
+ React.useEffect(() => {
+ if (!open) {
+ form.reset();
+ setSelectedFile(null);
+ setShowProgress(false);
+ setUploadProgress(0);
+ }
+ }, [open, form]);
+
+ // 현재 선택된 적용 범위 수
+ const selectedScopesCount = BUSINESS_UNITS.filter(unit =>
+ form.watch(unit.key as keyof CreateRevisionFormValues)
+ ).length;
+
+ 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.templateName}</span>
+ <Badge variant="outline">현재 v{baseTemplate.revision}</Badge>
+ <span>→</span>
+ <Badge variant="default">새 v{form.watch("revision")}</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={baseTemplate.revision + 1}
+ {...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">
+ 적용 범위 <span className="text-red-500">*</span>
+ </CardTitle>
+ <CardDescription>
+ 이 리비전이 적용될 사업부를 선택하세요. ({selectedScopesCount}개 선택됨)
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="select-all"
+ checked={selectedScopesCount === BUSINESS_UNITS.length}
+ onCheckedChange={handleSelectAllScopes}
+ />
+ <label htmlFor="select-all" className="text-sm font-medium">
+ 전체 선택
+ </label>
+ </div>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={handleCopyPreviousSettings}
+ >
+ <Copy className="h-4 w-4 mr-1" />
+ 이전 설정 복사
+ </Button>
+ </div>
+
+ <Separator />
+
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
+ {BUSINESS_UNITS.map((unit) => (
+ <FormField
+ key={unit.key}
+ control={form.control}
+ name={unit.key as keyof CreateRevisionFormValues}
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-start space-x-3 space-y-0">
+ <FormControl>
+ <Checkbox
+ checked={field.value as boolean}
+ onCheckedChange={field.onChange}
+ />
+ </FormControl>
+ <div className="space-y-1 leading-none">
+ <FormLabel className="text-sm font-normal">
+ {unit.label}
+ </FormLabel>
+ </div>
+ </FormItem>
+ )}
+ />
+ ))}
+ </div>
+
+ {form.formState.errors.shipBuildingApplicable && (
+ <p className="text-sm text-destructive">
+ {form.formState.errors.shipBuildingApplicable.message}
+ </p>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 파일 업로드 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg">파일 업로드</CardTitle>
+ <CardDescription>
+ 새 리비전의 템플릿 파일을 업로드하세요
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <FormField
+ control={form.control}
+ name="file"
+ render={() => (
+ <FormItem>
+ <FormLabel>
+ 템플릿 파일 <span className="text-red-500">*</span>
+ </FormLabel>
+ <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`
+ : "또는 클릭하여 워드 파일(.doc, .docx)을 선택하세요 (최대 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>
+ )}
+ </CardContent>
+ </Card>
+ </form>
+ </Form>
+ </div>
+
+ {/* 고정된 푸터 */}
+ <DialogFooter className="p-6 pt-4 border-t">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isLoading}
+ >
+ 취소
+ </Button>
+ <Button
+ type="button"
+ onClick={form.handleSubmit(onSubmit)}
+ disabled={
+ isLoading ||
+ !form.watch("file") ||
+ !BUSINESS_UNITS.some(unit => form.watch(unit.key as keyof CreateRevisionFormValues)) ||
+ form.watch("revision") <= baseTemplate.revision
+ }
+ >
+ {isLoading && <Loader className="mr-2 h-4 w-4 animate-spin" />}
+ {isLoading ? "처리 중..." : "리비전 생성"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+} \ No newline at end of file
diff --git a/lib/basic-contract/template/update-basicContract-sheet.tsx b/lib/basic-contract/template/update-basicContract-sheet.tsx
index 88783461..66037601 100644
--- a/lib/basic-contract/template/update-basicContract-sheet.tsx
+++ b/lib/basic-contract/template/update-basicContract-sheet.tsx
@@ -36,7 +36,6 @@ import {
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
-import { Input } from "@/components/ui/input"
import {
Dropzone,
DropzoneZone,
@@ -47,14 +46,16 @@ import {
} from "@/components/ui/dropzone"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Separator } from "@/components/ui/separator"
+import { Badge } from "@/components/ui/badge"
import { updateTemplate } from "../service"
import { BasicContractTemplate } from "@/db/schema"
import { BUSINESS_UNITS, scopeHelpers } from "@/config/basicContractColumnsConfig"
// 템플릿 이름 옵션 정의
const TEMPLATE_NAME_OPTIONS = [
- "준법서약",
- "기술자료 요구서",
+ "준법서약 (한글)",
+ "준법서약 (영문)",
+ "기술자료 요구서",
"비밀유지 계약서",
"표준하도급기본 계약서",
"General GTC",
@@ -66,14 +67,13 @@ const TEMPLATE_NAME_OPTIONS = [
"직납자재 하도급대급등 연동제 의향서"
] as const;
-// 업데이트 템플릿 스키마 정의 (templateCode, status 제거, 워드파일만 허용)
+// 업데이트 템플릿 스키마 정의 (리비전 필드 제거, 워드파일만 허용)
export const updateTemplateSchema = z.object({
templateName: z.enum(TEMPLATE_NAME_OPTIONS, {
required_error: "템플릿 이름을 선택해주세요.",
}),
- revision: z.coerce.number().int().min(1, "리비전은 1 이상이어야 합니다."),
legalReviewRequired: z.boolean(),
-
+
// 적용 범위
shipBuildingApplicable: z.boolean(),
windApplicable: z.boolean(),
@@ -83,22 +83,22 @@ export const updateTemplateSchema = z.object({
gyApplicable: z.boolean(),
sysApplicable: z.boolean(),
infraApplicable: z.boolean(),
-
+
file: z
.instanceof(File, { message: "파일을 업로드해주세요." })
.refine((file) => file.size <= 100 * 1024 * 1024, {
message: "파일 크기는 100MB 이하여야 합니다.",
})
.refine(
- (file) =>
- file.type === 'application/msword' ||
+ (file) =>
+ file.type === 'application/msword' ||
file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
{ message: "워드 파일(.doc, .docx)만 업로드 가능합니다." }
)
.optional(),
}).refine((data) => {
// 적어도 하나의 적용 범위는 선택되어야 함
- const hasAnyScope = BUSINESS_UNITS.some(unit =>
+ const hasAnyScope = BUSINESS_UNITS.some(unit =>
data[unit.key as keyof typeof data] as boolean
);
return hasAnyScope;
@@ -122,8 +122,7 @@ export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTem
const form = useForm<UpdateTemplateSchema>({
resolver: zodResolver(updateTemplateSchema),
defaultValues: {
- templateName: template?.templateName as typeof TEMPLATE_NAME_OPTIONS[number] ?? "준법서약",
- revision: template?.revision ?? 1,
+ templateName: template?.templateName as typeof TEMPLATE_NAME_OPTIONS[number] ?? "준법서약 (한글)",
legalReviewRequired: template?.legalReviewRequired ?? false,
shipBuildingApplicable: template?.shipBuildingApplicable ?? false,
windApplicable: template?.windApplicable ?? false,
@@ -147,9 +146,10 @@ export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTem
};
// 모든 적용 범위 선택/해제
- const handleSelectAllScopes = (checked: boolean) => {
+ const handleSelectAllScopes = (checked: boolean | "indeterminate") => {
+ const value = checked === true;
BUSINESS_UNITS.forEach(unit => {
- form.setValue(unit.key as keyof UpdateTemplateSchema, checked);
+ form.setValue(unit.key as keyof UpdateTemplateSchema, value);
});
};
@@ -158,7 +158,6 @@ export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTem
if (template) {
form.reset({
templateName: template.templateName as typeof TEMPLATE_NAME_OPTIONS[number],
- revision: template.revision ?? 1,
legalReviewRequired: template.legalReviewRequired ?? false,
shipBuildingApplicable: template.shipBuildingApplicable ?? false,
windApplicable: template.windApplicable ?? false,
@@ -173,7 +172,7 @@ export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTem
}, [template, form]);
// 현재 선택된 적용 범위 수
- const selectedScopesCount = BUSINESS_UNITS.filter(unit =>
+ const selectedScopesCount = BUSINESS_UNITS.filter(unit =>
form.watch(unit.key as keyof UpdateTemplateSchema)
).length;
@@ -181,22 +180,21 @@ export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTem
startUpdateTransition(async () => {
if (!template) return
- // FormData 객체 생성하여 파일과 데이터를 함께 전송 (templateCode, status 제거)
+ // FormData 객체 생성하여 파일과 데이터를 함께 전송
const formData = new FormData();
formData.append("templateName", input.templateName);
- formData.append("revision", input.revision.toString());
formData.append("legalReviewRequired", input.legalReviewRequired.toString());
-
+
// 적용 범위 추가
BUSINESS_UNITS.forEach(unit => {
const value = input[unit.key as keyof UpdateTemplateSchema] as boolean;
formData.append(unit.key, value.toString());
});
-
+
if (input.file) {
formData.append("file", input.file);
}
-
+
try {
// 서비스 함수 호출
const { error } = await updateTemplate({
@@ -223,6 +221,15 @@ export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTem
if (!template) return null;
+ const scopeSelected = BUSINESS_UNITS.some(
+ (unit) => form.watch(unit.key as keyof UpdateTemplateSchema)
+ );
+
+ const isDisabled =
+ isUpdatePending ||
+ !form.watch("templateName") ||
+ !scopeSelected;
+
return (
<Sheet {...props}>
<SheetContent className="sm:max-w-[600px] h-[100vh] flex flex-col p-0">
@@ -234,7 +241,7 @@ export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTem
<span className="text-red-500 mt-1 block text-sm">* 표시된 항목은 필수 입력사항입니다.</span>
</SheetDescription>
</SheetHeader>
-
+
{/* 스크롤 가능한 컨텐츠 영역 */}
<div className="flex-1 overflow-y-auto px-6">
<Form {...form}>
@@ -247,11 +254,13 @@ export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTem
<CardHeader>
<CardTitle className="text-lg">기본 정보</CardTitle>
<CardDescription>
+ 현재 리비전: <Badge variant="outline">v{template.revision}</Badge>
+ <br />
현재 적용 범위: {scopeHelpers.getScopeDisplayText(template)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
- <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div className="grid grid-cols-1 gap-4">
<FormField
control={form.control}
name="templateName"
@@ -283,32 +292,6 @@ export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTem
</FormItem>
)}
/>
-
- <FormField
- control={form.control}
- name="revision"
- render={({ field }) => (
- <FormItem>
- <FormLabel>리비전</FormLabel>
- <FormControl>
- <Input
- type="number"
- min="1"
- {...field}
- onChange={(e) => field.onChange(parseInt(e.target.value) || 1)}
- />
- </FormControl>
- <FormDescription>
- 템플릿 버전을 업데이트하세요.
- <br />
- <span className="text-xs text-muted-foreground">
- 동일한 템플릿 이름의 리비전은 중복될 수 없습니다.
- </span>
- </FormDescription>
- <FormMessage />
- </FormItem>
- )}
- />
</div>
<FormField
@@ -346,7 +329,7 @@ export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTem
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center space-x-2">
- <Checkbox
+ <Checkbox
id="select-all"
checked={selectedScopesCount === BUSINESS_UNITS.length}
onCheckedChange={handleSelectAllScopes}
@@ -355,9 +338,9 @@ export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTem
전체 선택
</label>
</div>
-
+
<Separator />
-
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{BUSINESS_UNITS.map((unit) => (
<FormField
@@ -382,7 +365,7 @@ export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTem
/>
))}
</div>
-
+
{form.formState.errors.shipBuildingApplicable && (
<p className="text-sm text-destructive">
{form.formState.errors.shipBuildingApplicable.message}
@@ -417,13 +400,13 @@ export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTem
<DropzoneZone>
<DropzoneUploadIcon className="h-10 w-10 text-muted-foreground" />
<DropzoneTitle>
- {selectedFile
- ? selectedFile.name
+ {selectedFile
+ ? selectedFile.name
: "새 워드 파일을 드래그하세요 (선택사항)"}
</DropzoneTitle>
<DropzoneDescription>
- {selectedFile
- ? `파일 크기: ${(selectedFile.size / (1024 * 1024)).toFixed(2)} MB`
+ {selectedFile
+ ? `파일 크기: ${(selectedFile.size / (1024 * 1024)).toFixed(2)} MB`
: "또는 클릭하여 워드 파일(.doc, .docx)을 선택하세요 (최대 100MB)"}
</DropzoneDescription>
<DropzoneInput />
@@ -447,16 +430,13 @@ export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTem
취소
</Button>
</SheetClose>
- <Button
+ <Button
type="button"
onClick={form.handleSubmit(onSubmit)}
- disabled={isUpdatePending || !form.formState.isValid}
+ disabled={isDisabled}
>
{isUpdatePending && (
- <Loader
- className="mr-2 size-4 animate-spin"
- aria-hidden="true"
- />
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
)}
저장
</Button>