summaryrefslogtreecommitdiff
path: root/lib/basic-contract
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-08-21 06:57:36 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-08-21 06:57:36 +0000
commit02b1cf005cf3e1df64183d20ba42930eb2767a9f (patch)
treee932c54d5260b0e6fda2b46be2a6ba1c3ee30434 /lib/basic-contract
parentd78378ecd7ceede1429359f8058c7a99ac34b1b7 (diff)
(대표님, 최겸) 설계메뉴추가, 작업사항 업데이트
설계메뉴 - 문서관리 설계메뉴 - 벤더 데이터 gtc 메뉴 업데이트 정보시스템 - 메뉴리스트 및 정보 업데이트 파일 라우트 업데이트 엑셀임포트 개선 기본계약 개선 벤더 가입과정 변경 및 개선 벤더 기본정보 - pq 돌체 오류 수정 및 개선 벤더 로그인 과정 이메일 오류 수정
Diffstat (limited to 'lib/basic-contract')
-rw-r--r--lib/basic-contract/service.ts88
-rw-r--r--lib/basic-contract/template/add-basic-contract-template-dialog.tsx364
-rw-r--r--lib/basic-contract/template/basic-contract-template-columns.tsx314
-rw-r--r--lib/basic-contract/template/create-revision-dialog.tsx21
-rw-r--r--lib/basic-contract/template/template-editor-wrapper.tsx106
-rw-r--r--lib/basic-contract/template/update-basicContract-sheet.tsx245
-rw-r--r--lib/basic-contract/validations.ts17
-rw-r--r--lib/basic-contract/vendor-table/basic-contract-columns.tsx96
-rw-r--r--lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx313
-rw-r--r--lib/basic-contract/vendor-table/basic-contract-table.tsx98
-rw-r--r--lib/basic-contract/vendor-table/basicContract-table-toolbar-actions.tsx43
-rw-r--r--lib/basic-contract/viewer/AutoSignatureFieldDetector.tsx493
-rw-r--r--lib/basic-contract/viewer/basic-contract-sign-viewer.tsx1679
13 files changed, 2922 insertions, 955 deletions
diff --git a/lib/basic-contract/service.ts b/lib/basic-contract/service.ts
index 03b27f96..64a50d14 100644
--- a/lib/basic-contract/service.ts
+++ b/lib/basic-contract/service.ts
@@ -4,13 +4,14 @@ import { revalidateTag, unstable_noStore } from "next/cache";
import db from "@/db/db";
import { getErrorMessage } from "@/lib/handle-error";
import { unstable_cache } from "@/lib/unstable-cache";
-import { asc, desc, ilike, inArray, and, gte, lte, not, or, sql, eq, isNull, ne, isNotNull, count } from "drizzle-orm";
+import { asc, desc, ilike, inArray, and, gte, lte, not, or, sql, eq, isNull, ne, isNotNull, count,like } from "drizzle-orm";
import { v4 as uuidv4 } from "uuid";
import {
basicContract,
BasicContractTemplate,
basicContractTemplates,
basicContractView,
+ vendorAttachments,
vendors,
type BasicContractTemplate as DBBasicContractTemplate,
} from "@/db/schema";
@@ -195,15 +196,6 @@ export async function createBasicContractTemplate(input: CreateBasicContractTemp
const [row] = await insertBasicContractTemplate(tx, {
templateName: input.templateName,
revision: input.revision || 1,
- legalReviewRequired: input.legalReviewRequired,
- shipBuildingApplicable: input.shipBuildingApplicable,
- windApplicable: input.windApplicable,
- pcApplicable: input.pcApplicable,
- nbApplicable: input.nbApplicable,
- rcApplicable: input.rcApplicable,
- gyApplicable: input.gyApplicable,
- sysApplicable: input.sysApplicable,
- infraApplicable: input.infraApplicable,
status: input.status || "ACTIVE",
// 📝 null 처리 추가
@@ -675,8 +667,8 @@ export async function getBasicContractsByVendorId(
input: GetBasciContractsSchema,
vendorId: number
) {
- return unstable_cache(
- async () => {
+ // return unstable_cache(
+ // async () => {
try {
const offset = (input.page - 1) * input.perPage;
@@ -743,13 +735,13 @@ export async function getBasicContractsByVendorId(
// 에러 발생 시 디폴트
return { data: [], pageCount: 0 };
}
- },
- [JSON.stringify(input), String(vendorId)], // 캐싱 키에 vendorId 추가
- {
- revalidate: 3600,
- tags: ["basicContractView-vendor"], // revalidateTag("basicContractView") 호출 시 무효화
- }
- )();
+ // },
+ // [JSON.stringify(input), String(vendorId)], // 캐싱 키에 vendorId 추가
+ // {
+ // revalidate: 3600,
+ // tags: ["basicContractView-vendor"], // revalidateTag("basicContractView") 호출 시 무효화
+ // }
+ // )();
}
export async function getAllTemplates(): Promise<BasicContractTemplate[]> {
@@ -1120,15 +1112,25 @@ export async function getALLBasicContractTemplates() {
// 2) 등록된 templateName만 중복 없이 가져오기
export async function getExistingTemplateNames(): Promise<string[]> {
- const rows = await db
- .select({
- templateName: basicContractTemplates.templateName,
- })
- .from(basicContractTemplates)
- .where(eq(basicContractTemplates.status,"ACTIVE"))
- .groupBy(basicContractTemplates.templateName);
-
- return rows.map((r) => r.templateName);
+ try {
+ const templates = await db
+ .select({
+ templateName: basicContractTemplates.templateName
+ })
+ .from(basicContractTemplates)
+ .where(
+ and(
+ eq(basicContractTemplates.status, 'ACTIVE'),
+ // GTC가 아닌 것들만 중복 체크 (GTC는 프로젝트별로 여러 개 허용)
+ not(like(basicContractTemplates.templateName, '% GTC'))
+ )
+ );
+
+ return templates.map(t => t.templateName);
+ } catch (error) {
+ console.error('Failed to fetch existing template names:', error);
+ throw new Error('기존 템플릿 이름을 가져오는데 실패했습니다.');
+ }
}
export async function getExistingTemplateNamesById(id:number): Promise<string> {
@@ -1141,4 +1143,32 @@ export async function getExistingTemplateNamesById(id:number): Promise<string> {
.limit(1)
return rows[0].templateName;
-} \ No newline at end of file
+}
+
+export async function getVendorAttachments(vendorId: number) {
+ try {
+ const attachments = await db
+ .select()
+ .from(vendorAttachments)
+ .where(
+ and(
+ eq(vendorAttachments.vendorId, vendorId),
+ eq(vendorAttachments.attachmentType, "NDA_ATTACHMENT")
+ )
+ );
+
+ console.log(attachments,"attachments")
+
+ return {
+ success: true,
+ data: attachments
+ };
+ } catch (error) {
+ console.error("Error fetching vendor attachments:", error);
+ return {
+ success: false,
+ data: [],
+ error: "Failed to fetch vendor attachments"
+ };
+ }
+}
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 43c19e67..141cb1e3 100644
--- a/lib/basic-contract/template/add-basic-contract-template-dialog.tsx
+++ b/lib/basic-contract/template/add-basic-contract-template-dialog.tsx
@@ -16,9 +16,7 @@ import {
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,
@@ -37,21 +35,18 @@ import {
} 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 { useRouter } from "next/navigation";
-import { BUSINESS_UNITS } from "@/config/basicContractColumnsConfig";
import { getExistingTemplateNames } from "../service";
+import { getAvailableProjectsForGtc } from "@/lib/gtc-contract/service";
-// ✅ 서버 액션 import
-
-// 전체 템플릿 후보
-const TEMPLATE_NAME_OPTIONS = [
+// 고정 템플릿 옵션들 (GTC 제외)
+const FIXED_TEMPLATE_OPTIONS = [
"준법서약 (한글)",
"준법서약 (영문)",
"기술자료 요구서",
"비밀유지 계약서",
"표준하도급기본 계약서",
- "GTC",
+ "General GTC", // 기본 GTC (하나만)
"안전보건관리 약정서",
"동반성장",
"윤리규범 준수 서약서",
@@ -60,28 +55,33 @@ const TEMPLATE_NAME_OPTIONS = [
"직납자재 하도급대급등 연동제 의향서"
] as const;
+// 프로젝트 타입 정의
+type ProjectForFilter = {
+ id: number;
+ code: string;
+ name: string;
+};
+
const templateFormSchema = z.object({
- templateName: z.enum(TEMPLATE_NAME_OPTIONS, {
- required_error: "템플릿 이름을 선택해주세요.",
+ templateType: z.enum(['FIXED', 'PROJECT_GTC'], {
+ required_error: "템플릿 타입을 선택해주세요.",
}),
+ templateName: z.string().min(1, "템플릿 이름을 선택하거나 입력해주세요."),
+ selectedProjectId: z.number().optional(),
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).optional(),
+ file: z.instanceof(File, {
+ message: "파일을 업로드해주세요.",
+ }),
})
.refine((data) => {
- if (data.templateName !== "General GTC" && !data.file) return false;
+ // PROJECT_GTC 타입인 경우 프로젝트 선택 필수
+ if (data.templateType === 'PROJECT_GTC' && !data.selectedProjectId) {
+ return false;
+ }
return true;
}, {
- message: "파일을 업로드해주세요.",
- path: ["file"],
+ message: "프로젝트를 선택해주세요.",
+ path: ["selectedProjectId"],
})
.refine((data) => {
if (data.file && data.file.size > 100 * 1024 * 1024) return false;
@@ -100,16 +100,6 @@ const templateFormSchema = z.object({
}, {
message: "워드 파일(.doc, .docx)만 업로드 가능합니다.",
path: ["file"],
-})
-.refine((data) => {
- const scopeFields = [
- 'shipBuildingApplicable', 'windApplicable', 'pcApplicable', 'nbApplicable',
- 'rcApplicable', 'gyApplicable', 'sysApplicable', 'infraApplicable'
- ];
- return scopeFields.some(field => data[field as keyof typeof data] === true);
-}, {
- message: "적어도 하나의 적용 범위를 선택해야 합니다.",
- path: ["shipBuildingApplicable"],
});
type TemplateFormValues = z.infer<typeof templateFormSchema>;
@@ -120,21 +110,16 @@ 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 [availableFixedTemplates, setAvailableFixedTemplates] = React.useState<typeof FIXED_TEMPLATE_OPTIONS[number][]>([]);
+ const [availableProjects, setAvailableProjects] = React.useState<ProjectForFilter[]>([]);
const router = useRouter();
// 기본값
const defaultValues: Partial<TemplateFormValues> = {
- templateName: undefined,
+ templateType: 'FIXED',
+ templateName: '',
+ selectedProjectId: undefined,
legalReviewRequired: false,
- shipBuildingApplicable: false,
- windApplicable: false,
- pcApplicable: false,
- nbApplicable: false,
- rcApplicable: false,
- gyApplicable: false,
- sysApplicable: false,
- infraApplicable: false,
};
const form = useForm<TemplateFormValues>({
@@ -143,24 +128,38 @@ export function AddTemplateDialog() {
mode: "onChange",
});
- // 🔸 마운트 시 이미 등록된 templateName 목록 가져와서 필터링
+ // 🔸 마운트 시 사용 가능한 고정 템플릿들과 프로젝트들 가져오기
React.useEffect(() => {
let cancelled = false;
- (async () => {
+
+ const loadData = async () => {
try {
- const usedNames = await getExistingTemplateNames();
+ // 고정 템플릿 중 이미 사용된 것들 제외
+ const usedTemplateNames = await getExistingTemplateNames();
if (cancelled) return;
- // 이미 있는 이름 제외
- const filtered = TEMPLATE_NAME_OPTIONS.filter(name => !usedNames.includes(name));
- setAvailableTemplateNames(filtered);
+ const filteredFixedTemplates = FIXED_TEMPLATE_OPTIONS.filter(
+ name => !usedTemplateNames.includes(name)
+ );
+ setAvailableFixedTemplates(filteredFixedTemplates);
+
+ // GTC 생성 가능한 프로젝트들 가져오기
+ const projects = await getAvailableProjectsForGtc();
+ if (cancelled) return;
+
+ setAvailableProjects(projects);
} catch (err) {
- console.error("Failed to fetch existing template names", err);
- // 실패 시 전체 옵션 보여주거나, 오류 알려주기
+ console.error("Failed to load template data", err);
+ toast.error("템플릿 정보를 불러오는데 실패했습니다.");
}
- })();
+ };
+
+ if (open) {
+ loadData();
+ }
+
return () => { cancelled = true; };
- }, []);
+ }, [open]);
const handleFileChange = (files: File[]) => {
if (files.length > 0) {
@@ -170,10 +169,13 @@ export function AddTemplateDialog() {
}
};
- const handleSelectAllScopes = (checked: boolean) => {
- BUSINESS_UNITS.forEach(unit => {
- form.setValue(unit.key as keyof TemplateFormValues, checked);
- });
+ // 프로젝트 선택 시 템플릿 이름 자동 설정
+ const handleProjectChange = (projectId: string) => {
+ const project = availableProjects.find(p => p.id === parseInt(projectId));
+ if (project) {
+ form.setValue("selectedProjectId", project.id);
+ form.setValue("templateName", `${project.code} GTC`);
+ }
};
// 청크 업로드 설정
@@ -218,22 +220,14 @@ export function AddTemplateDialog() {
async function onSubmit(formData: TemplateFormValues) {
setIsLoading(true);
try {
- let uploadResult = null;
-
- // 📝 파일 업로드가 필요한 경우에만 업로드 진행
- if (formData.file) {
- const fileId = uuidv4();
- uploadResult = await uploadFileInChunks(formData.file, fileId);
-
- if (!uploadResult?.success) {
- throw new Error("파일 업로드에 실패했습니다.");
- }
+ // 파일 업로드 진행
+ const fileId = uuidv4();
+ const uploadResult = await uploadFileInChunks(formData.file, fileId);
+
+ if (!uploadResult?.success) {
+ throw new Error("파일 업로드에 실패했습니다.");
}
-
- // 📝 General GTC이고 파일이 없는 경우와 다른 경우 구분 처리
- const isGeneralGTC = formData.templateName === "General GTC";
- const hasFile = uploadResult && uploadResult.success;
-
+
const saveResponse = await fetch('/api/upload/basicContract/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -241,37 +235,19 @@ export function AddTemplateDialog() {
templateName: formData.templateName,
revision: 1,
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,
status: "ACTIVE",
-
- // 📝 파일이 있는 경우에만 fileName과 filePath 전송
- ...(hasFile && {
- fileName: uploadResult.fileName,
- filePath: uploadResult.filePath,
- }),
-
- // 📝 파일이 없는 경우 null 전송 (스키마가 nullable이어야 함)
- ...(!hasFile && {
- fileName: null,
- filePath: null,
- })
+ fileName: uploadResult.fileName,
+ filePath: uploadResult.filePath,
}),
next: { tags: ["basic-contract-templates"] },
});
-
+
const saveResult = await saveResponse.json();
if (!saveResult.success) {
console.log(saveResult.error);
throw new Error(saveResult.error || "템플릿 정보 저장에 실패했습니다.");
}
-
+
toast.success('템플릿이 성공적으로 추가되었습니다.');
form.reset();
setSelectedFile(null);
@@ -302,16 +278,15 @@ export function AddTemplateDialog() {
setOpen(nextOpen);
}
- const selectedScopesCount = BUSINESS_UNITS.filter(unit =>
- form.watch(unit.key as keyof TemplateFormValues)
- ).length;
-
- const templateNameIsRequired = form.watch("templateName") !== "General GTC";
+ const templateType = form.watch("templateType");
+ const selectedProjectId = form.watch("selectedProjectId");
+ const templateName = form.watch("templateName");
const isSubmitDisabled = isLoading ||
- !form.watch("templateName") ||
- (templateNameIsRequired && !form.watch("file")) ||
- !BUSINESS_UNITS.some(unit => form.watch(unit.key as keyof TemplateFormValues));
+ !templateType ||
+ !templateName ||
+ !form.watch("file") ||
+ (templateType === 'PROJECT_GTC' && !selectedProjectId);
return (
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
@@ -332,13 +307,61 @@ export function AddTemplateDialog() {
<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>
+ <FormField
+ control={form.control}
+ name="templateType"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 템플릿 종류 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Select
+ onValueChange={(value) => {
+ field.onChange(value);
+ // 타입 변경 시 관련 필드 초기화
+ form.setValue("templateName", "");
+ form.setValue("selectedProjectId", undefined);
+ }}
+ value={field.value}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="템플릿 종류를 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="FIXED">표준 템플릿</SelectItem>
+ <SelectItem value="PROJECT_GTC">프로젝트 GTC</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormDescription>
+ {templateType === 'FIXED' && "미리 정의된 표준 템플릿 중에서 선택합니다."}
+ {templateType === 'PROJECT_GTC' && "특정 프로젝트용 GTC 템플릿을 생성합니다."}
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </CardContent>
+ </Card>
+
{/* 기본 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-lg">기본 정보</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
- <div className="grid grid-cols-1 gap-4">
+ {/* 표준 템플릿 선택 */}
+ {templateType === 'FIXED' && (
<FormField
control={form.control}
name="templateName"
@@ -349,16 +372,16 @@ export function AddTemplateDialog() {
</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
- <SelectTrigger disabled={availableTemplateNames.length === 0}>
+ <SelectTrigger disabled={availableFixedTemplates.length === 0}>
<SelectValue placeholder={
- availableTemplateNames.length === 0
+ availableFixedTemplates.length === 0
? "사용 가능한 템플릿이 없습니다"
: "템플릿 이름을 선택하세요"
} />
</SelectTrigger>
</FormControl>
<SelectContent>
- {availableTemplateNames.map((option) => (
+ {availableFixedTemplates.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
@@ -372,7 +395,57 @@ export function AddTemplateDialog() {
</FormItem>
)}
/>
- </div>
+ )}
+
+ {/* 프로젝트 GTC */}
+ {templateType === 'PROJECT_GTC' && (
+ <>
+ <FormField
+ control={form.control}
+ name="selectedProjectId"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 프로젝트 선택 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Select
+ onValueChange={handleProjectChange}
+ value={field.value?.toString()}
+ >
+ <FormControl>
+ <SelectTrigger disabled={availableProjects.length === 0}>
+ <SelectValue placeholder={
+ availableProjects.length === 0
+ ? "GTC 생성 가능한 프로젝트가 없습니다"
+ : "프로젝트를 선택하세요"
+ } />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {availableProjects.map((project) => (
+ <SelectItem key={project.id} value={project.id.toString()}>
+ {project.code} - {project.name}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormDescription>
+ 아직 GTC가 생성되지 않은 프로젝트만 표시됩니다.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 생성될 템플릿 이름 미리보기 */}
+ {templateName && (
+ <div className="rounded-lg border p-3 bg-muted/50">
+ <div className="text-sm font-medium">생성될 템플릿 이름</div>
+ <div className="text-lg font-semibold text-primary">{templateName}</div>
+ </div>
+ )}
+ </>
+ )}
<FormField
control={form.control}
@@ -397,71 +470,12 @@ export function AddTemplateDialog() {
</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 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>
-
- <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 TemplateFormValues}
- 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>
- {form.watch("templateName") === "General GTC"
- ? "General GTC는 파일 업로드가 선택사항입니다"
- : "템플릿 파일을 업로드하세요"}
+ 템플릿 파일을 업로드하세요
</CardDescription>
</CardHeader>
<CardContent>
@@ -471,13 +485,7 @@ export function AddTemplateDialog() {
render={() => (
<FormItem>
<FormLabel>
- 템플릿 파일
- {form.watch("templateName") !== "General GTC" && (
- <span className="text-red-500"> *</span>
- )}
- {form.watch("templateName") === "General GTC" && (
- <span className="text-muted-foreground"> (선택사항)</span>
- )}
+ 템플릿 파일 <span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Dropzone
@@ -495,9 +503,7 @@ export function AddTemplateDialog() {
<DropzoneDescription>
{selectedFile
? `파일 크기: ${(selectedFile.size / (1024 * 1024)).toFixed(2)} MB`
- : form.watch("templateName") === "General GTC"
- ? "또는 클릭하여 워드 파일(.doc, .docx)을 선택하세요 (선택사항, 최대 100MB)"
- : "또는 클릭하여 워드 파일(.doc, .docx)을 선택하세요 (최대 100MB)"}
+ : "또는 클릭하여 워드 파일(.doc, .docx)을 선택하세요 (최대 100MB)"}
</DropzoneDescription>
<DropzoneInput />
</DropzoneZone>
@@ -543,4 +549,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 446112db..a0bef7bf 100644
--- a/lib/basic-contract/template/basic-contract-template-columns.tsx
+++ b/lib/basic-contract/template/basic-contract-template-columns.tsx
@@ -119,13 +119,7 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
const template = row.original;
const handleViewDetails = () => {
- // templateName이 "General GTC"인 경우 특별한 라우팅
- if (template.templateName === "GTC") {
- router.push(`/evcp/basic-contract-template/gtc`);
- } else {
- // 일반적인 경우는 기존과 동일
router.push(`/evcp/basic-contract-template/${template.id}`);
- }
};
return (
@@ -221,12 +215,10 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
const template = row.original;
const handleClick = () => {
- if (template.templateName === "GTC") {
- router.push(`/evcp/basic-contract-template/gtc`);
- } else {
+
// 일반적인 경우는 기존과 동일
router.push(`/evcp/basic-contract-template/${template.id}`);
- }
+
};
return (
@@ -277,152 +269,152 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
];
// 적용 범위 그룹
- const scopeColumns: ColumnDef<BasicContractTemplate>[] = [
- {
- accessorKey: "shipBuildingApplicable",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="조선해양" />,
- cell: ({ row }) => {
- const applicable = row.getValue("shipBuildingApplicable") as boolean;
- return (
- <div className="flex justify-center">
- {applicable ? (
- <CheckCircle className="h-4 w-4 text-green-500" />
- ) : (
- <XCircle className="h-4 w-4 text-gray-300" />
- )}
- </div>
- );
- },
- size: 80,
- enableResizing: true,
- },
- {
- accessorKey: "windApplicable",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="풍력" />,
- cell: ({ row }) => {
- const applicable = row.getValue("windApplicable") as boolean;
- return (
- <div className="flex justify-center">
- {applicable ? (
- <CheckCircle className="h-4 w-4 text-green-500" />
- ) : (
- <XCircle className="h-4 w-4 text-gray-300" />
- )}
- </div>
- );
- },
- size: 60,
- enableResizing: true,
- },
- {
- accessorKey: "pcApplicable",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="PC" />,
- cell: ({ row }) => {
- const applicable = row.getValue("pcApplicable") as boolean;
- return (
- <div className="flex justify-center">
- {applicable ? (
- <CheckCircle className="h-4 w-4 text-green-500" />
- ) : (
- <XCircle className="h-4 w-4 text-gray-300" />
- )}
- </div>
- );
- },
- size: 50,
- enableResizing: true,
- },
- {
- accessorKey: "nbApplicable",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="NB" />,
- cell: ({ row }) => {
- const applicable = row.getValue("nbApplicable") as boolean;
- return (
- <div className="flex justify-center">
- {applicable ? (
- <CheckCircle className="h-4 w-4 text-green-500" />
- ) : (
- <XCircle className="h-4 w-4 text-gray-300" />
- )}
- </div>
- );
- },
- size: 50,
- enableResizing: true,
- },
- {
- accessorKey: "rcApplicable",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="RC" />,
- cell: ({ row }) => {
- const applicable = row.getValue("rcApplicable") as boolean;
- return (
- <div className="flex justify-center">
- {applicable ? (
- <CheckCircle className="h-4 w-4 text-green-500" />
- ) : (
- <XCircle className="h-4 w-4 text-gray-300" />
- )}
- </div>
- );
- },
- size: 50,
- enableResizing: true,
- },
- {
- accessorKey: "gyApplicable",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="GY" />,
- cell: ({ row }) => {
- const applicable = row.getValue("gyApplicable") as boolean;
- return (
- <div className="flex justify-center">
- {applicable ? (
- <CheckCircle className="h-4 w-4 text-green-500" />
- ) : (
- <XCircle className="h-4 w-4 text-gray-300" />
- )}
- </div>
- );
- },
- size: 50,
- enableResizing: true,
- },
- {
- accessorKey: "sysApplicable",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="S&Sys" />,
- cell: ({ row }) => {
- const applicable = row.getValue("sysApplicable") as boolean;
- return (
- <div className="flex justify-center">
- {applicable ? (
- <CheckCircle className="h-4 w-4 text-green-500" />
- ) : (
- <XCircle className="h-4 w-4 text-gray-300" />
- )}
- </div>
- );
- },
- size: 60,
- enableResizing: true,
- },
- {
- accessorKey: "infraApplicable",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="Infra" />,
- cell: ({ row }) => {
- const applicable = row.getValue("infraApplicable") as boolean;
- return (
- <div className="flex justify-center">
- {applicable ? (
- <CheckCircle className="h-4 w-4 text-green-500" />
- ) : (
- <XCircle className="h-4 w-4 text-gray-300" />
- )}
- </div>
- );
- },
- size: 60,
- enableResizing: true,
- },
- ];
+ // const scopeColumns: ColumnDef<BasicContractTemplate>[] = [
+ // {
+ // accessorKey: "shipBuildingApplicable",
+ // header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="조선해양" />,
+ // cell: ({ row }) => {
+ // const applicable = row.getValue("shipBuildingApplicable") as boolean;
+ // return (
+ // <div className="flex justify-center">
+ // {applicable ? (
+ // <CheckCircle className="h-4 w-4 text-green-500" />
+ // ) : (
+ // <XCircle className="h-4 w-4 text-gray-300" />
+ // )}
+ // </div>
+ // );
+ // },
+ // size: 80,
+ // enableResizing: true,
+ // },
+ // {
+ // accessorKey: "windApplicable",
+ // header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="풍력" />,
+ // cell: ({ row }) => {
+ // const applicable = row.getValue("windApplicable") as boolean;
+ // return (
+ // <div className="flex justify-center">
+ // {applicable ? (
+ // <CheckCircle className="h-4 w-4 text-green-500" />
+ // ) : (
+ // <XCircle className="h-4 w-4 text-gray-300" />
+ // )}
+ // </div>
+ // );
+ // },
+ // size: 60,
+ // enableResizing: true,
+ // },
+ // {
+ // accessorKey: "pcApplicable",
+ // header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="PC" />,
+ // cell: ({ row }) => {
+ // const applicable = row.getValue("pcApplicable") as boolean;
+ // return (
+ // <div className="flex justify-center">
+ // {applicable ? (
+ // <CheckCircle className="h-4 w-4 text-green-500" />
+ // ) : (
+ // <XCircle className="h-4 w-4 text-gray-300" />
+ // )}
+ // </div>
+ // );
+ // },
+ // size: 50,
+ // enableResizing: true,
+ // },
+ // {
+ // accessorKey: "nbApplicable",
+ // header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="NB" />,
+ // cell: ({ row }) => {
+ // const applicable = row.getValue("nbApplicable") as boolean;
+ // return (
+ // <div className="flex justify-center">
+ // {applicable ? (
+ // <CheckCircle className="h-4 w-4 text-green-500" />
+ // ) : (
+ // <XCircle className="h-4 w-4 text-gray-300" />
+ // )}
+ // </div>
+ // );
+ // },
+ // size: 50,
+ // enableResizing: true,
+ // },
+ // {
+ // accessorKey: "rcApplicable",
+ // header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="RC" />,
+ // cell: ({ row }) => {
+ // const applicable = row.getValue("rcApplicable") as boolean;
+ // return (
+ // <div className="flex justify-center">
+ // {applicable ? (
+ // <CheckCircle className="h-4 w-4 text-green-500" />
+ // ) : (
+ // <XCircle className="h-4 w-4 text-gray-300" />
+ // )}
+ // </div>
+ // );
+ // },
+ // size: 50,
+ // enableResizing: true,
+ // },
+ // {
+ // accessorKey: "gyApplicable",
+ // header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="GY" />,
+ // cell: ({ row }) => {
+ // const applicable = row.getValue("gyApplicable") as boolean;
+ // return (
+ // <div className="flex justify-center">
+ // {applicable ? (
+ // <CheckCircle className="h-4 w-4 text-green-500" />
+ // ) : (
+ // <XCircle className="h-4 w-4 text-gray-300" />
+ // )}
+ // </div>
+ // );
+ // },
+ // size: 50,
+ // enableResizing: true,
+ // },
+ // {
+ // accessorKey: "sysApplicable",
+ // header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="S&Sys" />,
+ // cell: ({ row }) => {
+ // const applicable = row.getValue("sysApplicable") as boolean;
+ // return (
+ // <div className="flex justify-center">
+ // {applicable ? (
+ // <CheckCircle className="h-4 w-4 text-green-500" />
+ // ) : (
+ // <XCircle className="h-4 w-4 text-gray-300" />
+ // )}
+ // </div>
+ // );
+ // },
+ // size: 60,
+ // enableResizing: true,
+ // },
+ // {
+ // accessorKey: "infraApplicable",
+ // header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="Infra" />,
+ // cell: ({ row }) => {
+ // const applicable = row.getValue("infraApplicable") as boolean;
+ // return (
+ // <div className="flex justify-center">
+ // {applicable ? (
+ // <CheckCircle className="h-4 w-4 text-green-500" />
+ // ) : (
+ // <XCircle className="h-4 w-4 text-gray-300" />
+ // )}
+ // </div>
+ // );
+ // },
+ // size: 60,
+ // enableResizing: true,
+ // },
+ // ];
// 파일 정보 그룹
const fileInfoColumns: ColumnDef<BasicContractTemplate>[] = [
@@ -495,11 +487,11 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
header: "기본 정보",
columns: basicInfoColumns,
},
- {
- id: "적용 범위",
- header: "적용 범위",
- columns: scopeColumns,
- },
+ // {
+ // id: "적용 범위",
+ // header: "적용 범위",
+ // columns: scopeColumns,
+ // },
{
id: "파일 정보",
header: "파일 정보",
diff --git a/lib/basic-contract/template/create-revision-dialog.tsx b/lib/basic-contract/template/create-revision-dialog.tsx
index 262df6ba..6ae03cc2 100644
--- a/lib/basic-contract/template/create-revision-dialog.tsx
+++ b/lib/basic-contract/template/create-revision-dialog.tsx
@@ -65,15 +65,6 @@ 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: "파일을 업로드해주세요." })
@@ -86,18 +77,6 @@ const createRevisionSchema = z.object({
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>;
diff --git a/lib/basic-contract/template/template-editor-wrapper.tsx b/lib/basic-contract/template/template-editor-wrapper.tsx
index 96e2330f..af5d42a8 100644
--- a/lib/basic-contract/template/template-editor-wrapper.tsx
+++ b/lib/basic-contract/template/template-editor-wrapper.tsx
@@ -6,6 +6,7 @@ import { toast } from "sonner";
import { Save, RefreshCw, Type, FileText, AlertCircle } from "lucide-react";
import type { WebViewerInstance } from "@pdftron/webviewer";
import { Badge } from "@/components/ui/badge";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { BasicContractTemplateViewer } from "./basic-contract-template-viewer";
import { getExistingTemplateNamesById, saveTemplateFile } from "../service";
@@ -16,20 +17,57 @@ interface TemplateEditorWrapperProps {
refreshAction?: () => Promise<void>;
}
-// 템플릿 이름별 변수 매핑 (영문 변수명 사용)
+const getVariablesForTemplate = (templateName: string): string[] => {
+ // 정확한 매치 먼저 확인
+ if (TEMPLATE_VARIABLES_MAP[templateName as keyof typeof TEMPLATE_VARIABLES_MAP]) {
+ return [...TEMPLATE_VARIABLES_MAP[templateName as keyof typeof TEMPLATE_VARIABLES_MAP]];
+ }
+
+ // GTC가 포함된 경우 확인
+ if (templateName.includes("GTC")) {
+ return [...TEMPLATE_VARIABLES_MAP["GTC"]];
+ }
+
+ // 다른 키워드들도 포함 관계로 확인
+ for (const [key, variables] of Object.entries(TEMPLATE_VARIABLES_MAP)) {
+ if (templateName.includes(key)) {
+ return [...variables];
+ }
+ }
+
+ // 기본값 반환
+ return ["company_name", "company_address", "representative_name", "signature_date"];
+};
+
+// 템플릿 이름별 변수 매핑
const TEMPLATE_VARIABLES_MAP = {
- "준법서약 (한글)": ["vendor_name", "address", "representative_name", "today_date"],
- "준법서약 (영문)": ["vendor_name", "address", "representative_name", "today_date"],
- "기술자료 요구서": ["vendor_name", "address", "representative_name", "today_date"],
- "비밀유지 계약서": ["vendor_name", "address", "representative_name", "today_date"],
- "표준하도급기본 계약서": ["vendor_name", "address", "representative_name", "today_date"],
- "GTC": ["vendor_name", "address", "representative_name", "today_date"],
- "안전보건관리 약정서": ["vendor_name", "address", "representative_name", "today_date"],
- "동반성장": ["vendor_name", "address", "representative_name", "today_date"],
- "윤리규범 준수 서약서": ["vendor_name", "address", "representative_name", "today_date"],
- "기술자료 동의서": ["vendor_name", "address", "representative_name", "today_date"],
- "내국신용장 미개설 합의서": ["vendor_name", "address", "representative_name", "today_date"],
- "직납자재 하도급대급등 연동제 의향서": ["vendor_name", "address", "representative_name", "today_date"]
+ "준법서약 (한글)": ["company_name", "company_address", "representative_name", "signature_date"],
+ "준법서약 (영문)": ["company_name", "company_address", "representative_name", "signature_date"],
+ "기술자료 요구서": ["company_name", "company_address", "representative_name", "signature_date", 'tax_id', 'phone_number'],
+ "비밀유지 계약서": ["company_name", "company_address", "representative_name", "signature_date"],
+ "표준하도급기본 계약서": ["company_name", "company_address", "representative_name", "signature_date"],
+ "GTC": ["company_name", "company_address", "representative_name", "signature_date"],
+ "안전보건관리 약정서": ["company_name", "company_address", "representative_name", "signature_date"],
+ "동반성장": ["company_name", "company_address", "representative_name", "signature_date"],
+ "윤리규범 준수 서약서": ["company_name", "company_address", "representative_name", "signature_date"],
+ "기술자료 동의서": ["company_name", "company_address", "representative_name", "signature_date", 'tax_id', 'phone_number'],
+ "내국신용장 미개설 합의서": ["company_name", "company_address", "representative_name", "signature_date"],
+ "직납자재 하도급대급등 연동제 의향서": ["company_name", "company_address", "representative_name", "signature_date"]
+} as const;
+
+// 변수별 한글 설명 매핑
+const VARIABLE_DESCRIPTION_MAP = {
+ "company_name": "협력회사명",
+ "vendor_name": "협력회사명",
+ "company_address": "회사주소",
+ "address": "회사주소",
+ "representative_name": "대표자명",
+ "signature_date": "서명날짜",
+ "today_date": "오늘날짜",
+ "tax_id": "사업자등록번호",
+ "phone_number": "전화번호",
+ "phone": "전화번호",
+ "email": "이메일"
} as const;
// 변수 패턴 감지를 위한 정규식
@@ -49,8 +87,6 @@ export function TemplateEditorWrapper({
const [templateName, setTemplateName] = React.useState<string>("");
const [predefinedVariables, setPredefinedVariables] = React.useState<string[]>([]);
- console.log(templateId, "templateId");
-
// 템플릿 이름 로드 및 변수 설정
React.useEffect(() => {
const loadTemplateInfo = async () => {
@@ -59,15 +95,15 @@ export function TemplateEditorWrapper({
setTemplateName(name);
// 템플릿 이름에 따른 변수 설정
- const variables = TEMPLATE_VARIABLES_MAP[name as keyof typeof TEMPLATE_VARIABLES_MAP] || [];
- setPredefinedVariables(variables);
+ const variables = getVariablesForTemplate(name);
+ setPredefinedVariables([...variables]);
console.log("🏷️ 템플릿 이름:", name);
console.log("📝 할당된 변수들:", variables);
} catch (error) {
console.error("템플릿 정보 로드 오류:", error);
// 기본 변수 설정
- setPredefinedVariables(["회사명", "주소", "대표자명", "오늘날짜"]);
+ setPredefinedVariables(["company_name", "company_address", "representative_name", "signature_date"]);
}
};
@@ -358,19 +394,27 @@ export function TemplateEditorWrapper({
<p className="text-xs text-muted-foreground mb-2">
{templateName ? `${templateName}에 권장되는 변수` : "자주 사용하는 변수"} (클릭하여 복사):
</p>
- <div className="flex flex-wrap gap-1">
- {predefinedVariables.map((variable, index) => (
- <Button
- key={index}
- variant="ghost"
- size="sm"
- className="h-6 px-2 text-xs hover:bg-blue-50"
- onClick={() => insertVariable(variable)}
- >
- {`{{${variable}}}`}
- </Button>
- ))}
- </div>
+ <TooltipProvider>
+ <div className="flex flex-wrap gap-1">
+ {predefinedVariables.map((variable, index) => (
+ <Tooltip key={index}>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-6 px-2 text-xs hover:bg-blue-50"
+ onClick={() => insertVariable(variable)}
+ >
+ {`{{${variable}}}`}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>{VARIABLE_DESCRIPTION_MAP[variable as keyof typeof VARIABLE_DESCRIPTION_MAP] || variable}</p>
+ </TooltipContent>
+ </Tooltip>
+ ))}
+ </div>
+ </TooltipProvider>
</div>
)}
</div>
diff --git a/lib/basic-contract/template/update-basicContract-sheet.tsx b/lib/basic-contract/template/update-basicContract-sheet.tsx
index 07bac31b..0236fda5 100644
--- a/lib/basic-contract/template/update-basicContract-sheet.tsx
+++ b/lib/basic-contract/template/update-basicContract-sheet.tsx
@@ -8,7 +8,6 @@ import { toast } from "sonner"
import * as z from "zod"
import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
import { Switch } from "@/components/ui/switch"
import {
Form,
@@ -20,14 +19,6 @@ import {
FormDescription,
} from "@/components/ui/form"
import {
- Select,
- SelectContent,
- SelectGroup,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-import {
Sheet,
SheetClose,
SheetContent,
@@ -45,45 +36,14 @@ import {
DropzoneInput
} 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 = [
- "준법서약 (한글)",
- "준법서약 (영문)",
- "기술자료 요구서",
- "비밀유지 계약서",
- "표준하도급기본 계약서",
- "GTC",
- "안전보건관리 약정서",
- "동반성장",
- "윤리규범 준수 서약서",
- "기술자료 동의서",
- "내국신용장 미개설 합의서",
- "직납자재 하도급대급등 연동제 의향서"
-] as const;
+import { scopeHelpers } from "@/config/basicContractColumnsConfig"
-// 업데이트 템플릿 스키마 정의 (리비전 필드 제거, 워드파일만 허용)
+// 업데이트 템플릿 스키마 정의 (파일 업데이트 중심)
export const updateTemplateSchema = z.object({
- templateName: z.enum(TEMPLATE_NAME_OPTIONS, {
- required_error: "템플릿 이름을 선택해주세요.",
- }),
legalReviewRequired: z.boolean(),
-
- // 적용 범위
- shipBuildingApplicable: z.boolean(),
- windApplicable: z.boolean(),
- pcApplicable: z.boolean(),
- nbApplicable: z.boolean(),
- rcApplicable: z.boolean(),
- gyApplicable: z.boolean(),
- sysApplicable: z.boolean(),
- infraApplicable: z.boolean(),
-
file: z
.instanceof(File, { message: "파일을 업로드해주세요." })
.refine((file) => file.size <= 100 * 1024 * 1024, {
@@ -96,15 +56,6 @@ export const updateTemplateSchema = z.object({
{ message: "워드 파일(.doc, .docx)만 업로드 가능합니다." }
)
.optional(),
-}).refine((data) => {
- // 적어도 하나의 적용 범위는 선택되어야 함
- const hasAnyScope = BUSINESS_UNITS.some(unit =>
- data[unit.key as keyof typeof data] as boolean
- );
- return hasAnyScope;
-}, {
- message: "적어도 하나의 적용 범위를 선택해야 합니다.",
- path: ["shipBuildingApplicable"],
});
export type UpdateTemplateSchema = z.infer<typeof updateTemplateSchema>
@@ -122,16 +73,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] ?? "준법서약 (한글)",
legalReviewRequired: template?.legalReviewRequired ?? false,
- shipBuildingApplicable: template?.shipBuildingApplicable ?? false,
- windApplicable: template?.windApplicable ?? false,
- pcApplicable: template?.pcApplicable ?? false,
- nbApplicable: template?.nbApplicable ?? false,
- rcApplicable: template?.rcApplicable ?? false,
- gyApplicable: template?.gyApplicable ?? false,
- sysApplicable: template?.sysApplicable ?? false,
- infraApplicable: template?.infraApplicable ?? false,
},
mode: "onChange"
})
@@ -145,52 +87,23 @@ export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTem
}
};
- // 모든 적용 범위 선택/해제
- const handleSelectAllScopes = (checked: boolean | "indeterminate") => {
- const value = checked === true;
- BUSINESS_UNITS.forEach(unit => {
- form.setValue(unit.key as keyof UpdateTemplateSchema, value);
- });
- };
-
// 템플릿 변경 시 폼 값 업데이트
React.useEffect(() => {
if (template) {
form.reset({
- templateName: template.templateName as typeof TEMPLATE_NAME_OPTIONS[number],
legalReviewRequired: template.legalReviewRequired ?? false,
- shipBuildingApplicable: template.shipBuildingApplicable ?? false,
- windApplicable: template.windApplicable ?? false,
- pcApplicable: template.pcApplicable ?? false,
- nbApplicable: template.nbApplicable ?? false,
- rcApplicable: template.rcApplicable ?? false,
- gyApplicable: template.gyApplicable ?? false,
- sysApplicable: template.sysApplicable ?? false,
- infraApplicable: template.infraApplicable ?? false,
});
}
}, [template, form]);
- // 현재 선택된 적용 범위 수
- const selectedScopesCount = BUSINESS_UNITS.filter(unit =>
- form.watch(unit.key as keyof UpdateTemplateSchema)
- ).length;
-
function onSubmit(input: UpdateTemplateSchema) {
startUpdateTransition(async () => {
if (!template) return
// FormData 객체 생성하여 파일과 데이터를 함께 전송
const formData = new FormData();
- formData.append("templateName", input.templateName);
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);
}
@@ -221,24 +134,14 @@ 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">
+ <SheetContent className="sm:max-w-[500px] h-[100vh] flex flex-col p-0">
{/* 고정된 헤더 */}
<SheetHeader className="p-6 pb-4 border-b">
<SheetTitle>템플릿 업데이트</SheetTitle>
<SheetDescription>
- 템플릿 정보를 수정하고 변경사항을 저장하세요
- <span className="text-red-500 mt-1 block text-sm">* 표시된 항목은 필수 입력사항입니다.</span>
+ 템플릿 파일을 업데이트하고 설정을 변경하세요
</SheetDescription>
</SheetHeader>
@@ -249,51 +152,49 @@ export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTem
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6 py-4"
>
- {/* 기본 정보 */}
+ {/* 템플릿 정보 표시 */}
<Card>
<CardHeader>
- <CardTitle className="text-lg">기본 정보</CardTitle>
+ <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 gap-4">
- <FormField
- control={form.control}
- name="templateName"
- render={({ field }) => (
- <FormItem>
- <FormLabel>
- 템플릿 이름 <span className="text-red-500">*</span>
- </FormLabel>
- <Select onValueChange={field.onChange} defaultValue={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="템플릿 이름을 선택하세요" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- <SelectGroup>
- {TEMPLATE_NAME_OPTIONS.map((option) => (
- <SelectItem key={option} value={option}>
- {option}
- </SelectItem>
- ))}
- </SelectGroup>
- </SelectContent>
- </Select>
- <FormDescription>
- 미리 정의된 템플릿 중에서 선택
- </FormDescription>
- <FormMessage />
- </FormItem>
- )}
- />
+ <div className="space-y-2">
+ <label className="text-sm font-medium">템플릿 이름</label>
+ <div className="px-3 py-2 border rounded-md bg-gray-50">
+ {template.templateName}
+ </div>
+ </div>
+
+ <div className="space-y-2">
+ <label className="text-sm font-medium">현재 리비전</label>
+ <div className="px-3 py-2 border rounded-md bg-gray-50">
+ <Badge variant="outline">v{template.revision}</Badge>
+ </div>
+ </div>
+
+ <div className="space-y-2">
+ <label className="text-sm font-medium">현재 파일</label>
+ <div className="px-3 py-2 border rounded-md bg-gray-50">
+ {template.fileName}
+ </div>
+ </div>
</div>
+ </CardContent>
+ </Card>
+ {/* 설정 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg">설정</CardTitle>
+ <CardDescription>
+ 템플릿 관련 설정을 변경할 수 있습니다
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
<FormField
control={form.control}
name="legalReviewRequired"
@@ -317,69 +218,12 @@ export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTem
</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 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>
-
- <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 UpdateTemplateSchema}
- 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>
- 현재 파일: {template.fileName}
+ 새로운 템플릿 파일을 업로드하세요
</CardDescription>
</CardHeader>
<CardContent>
@@ -388,7 +232,7 @@ export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTem
name="file"
render={() => (
<FormItem>
- <FormLabel>템플릿 파일 (선택사항)</FormLabel>
+ <FormLabel>새 템플릿 파일 (선택사항)</FormLabel>
<FormControl>
<Dropzone
onDrop={handleFileChange}
@@ -402,7 +246,7 @@ export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTem
<DropzoneTitle>
{selectedFile
? selectedFile.name
- : "새 워드 파일을 드래그하세요 (선택사항)"}
+ : "새 워드 파일을 드래그하세요"}
</DropzoneTitle>
<DropzoneDescription>
{selectedFile
@@ -413,6 +257,9 @@ export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTem
</DropzoneZone>
</Dropzone>
</FormControl>
+ <FormDescription>
+ 파일을 업로드하지 않으면 기존 파일이 유지됩니다
+ </FormDescription>
<FormMessage />
</FormItem>
)}
@@ -433,12 +280,12 @@ export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTem
<Button
type="button"
onClick={form.handleSubmit(onSubmit)}
- disabled={isDisabled}
+ disabled={isUpdatePending}
>
{isUpdatePending && (
<Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
)}
- 저장
+ 업데이트
</Button>
</SheetFooter>
</SheetContent>
diff --git a/lib/basic-contract/validations.ts b/lib/basic-contract/validations.ts
index e8b28e73..bb9e3b8d 100644
--- a/lib/basic-contract/validations.ts
+++ b/lib/basic-contract/validations.ts
@@ -65,16 +65,7 @@ export const BUSINESS_UNIT_KEYS = [
export const createBasicContractTemplateSchema = z.object({
templateName: z.string().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),
-
+
status: z.enum(["ACTIVE", "DISPOSED"]).default("ACTIVE"),
fileName: z.string().nullable().optional(),
filePath: z.string().nullable().optional(),
@@ -82,12 +73,6 @@ export const createBasicContractTemplateSchema = z.object({
// 기존에 쓰시던 validityPeriod 를 계속 쓰실 거라면 남기고, 아니라면 지우세요.
// 예: 문자열(YYYY-MM-DD ~ YYYY-MM-DD) 또는 number(개월 수) 등 구체화 필요
validityPeriod: z.string().optional(),
-}).refine((data) => {
- // 최소 1개 이상 사업부 선택
- return BUSINESS_UNIT_KEYS.some((k) => data[k] === true);
-}, {
- message: "적어도 하나의 적용 범위를 선택해야 합니다.",
- path: ["shipBuildingApplicable"], // 첫 체크박스에 에러 표시 유도
});
export type CreateBasicContractTemplateSchema = z.infer<typeof createBasicContractTemplateSchema>;
diff --git a/lib/basic-contract/vendor-table/basic-contract-columns.tsx b/lib/basic-contract/vendor-table/basic-contract-columns.tsx
index c9e8da53..1b11285c 100644
--- a/lib/basic-contract/vendor-table/basic-contract-columns.tsx
+++ b/lib/basic-contract/vendor-table/basic-contract-columns.tsx
@@ -32,14 +32,65 @@ import { BasicContractView } from "@/db/schema"
interface GetColumnsProps {
setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<BasicContractView> | null>>
+ locale?: string
+ t: (key: string) => string // 번역 함수
}
+// 기본 번역값들 (fallback)
+const fallbackTranslations = {
+ ko: {
+ download: "다운로드",
+ selectAll: "전체 선택",
+ selectRow: "행 선택",
+ fileInfoMissing: "파일 정보가 없습니다.",
+ fileDownloadError: "파일 다운로드 중 오류가 발생했습니다.",
+ statusValues: {
+ PENDING: "서명대기",
+ COMPLETED: "서명완료"
+ }
+ },
+ en: {
+ download: "Download",
+ selectAll: "Select all",
+ selectRow: "Select row",
+ fileInfoMissing: "File information is missing.",
+ fileDownloadError: "An error occurred while downloading the file.",
+ statusValues: {
+ PENDING: "Pending",
+ COMPLETED: "Completed"
+ }
+ }
+};
+
+// 안전한 번역 함수
+const safeTranslate = (t: (key: string) => string, key: string, locale: string = 'ko', fallback?: string): string => {
+ try {
+ const translated = t(key);
+ // 번역 키가 그대로 반환되는 경우 (번역 실패) fallback 사용
+ if (translated === key && fallback) {
+ return fallback;
+ }
+ return translated || fallback || key;
+ } catch (error) {
+ console.warn(`Translation failed for key: ${key}`, error);
+ return fallback || key;
+ }
+};
+
/**
* 파일 다운로드 함수
*/
-const handleFileDownload = async (filePath: string | null, fileName: string | null) => {
+const handleFileDownload = async (
+ filePath: string | null,
+ fileName: string | null,
+ t: (key: string) => string,
+ locale: string = 'ko'
+) => {
+ const fallback = fallbackTranslations[locale as keyof typeof fallbackTranslations] || fallbackTranslations.ko;
+
if (!filePath || !fileName) {
- toast.error("파일 정보가 없습니다.");
+ const message = safeTranslate(t, "basicContracts.fileInfoMissing", locale, fallback.fileInfoMissing);
+ toast.error(message);
return;
}
@@ -57,14 +108,17 @@ const handleFileDownload = async (filePath: string | null, fileName: string | nu
}
} catch (error) {
console.error("파일 다운로드 오류:", error);
- toast.error("파일 다운로드 중 오류가 발생했습니다.");
+ const message = safeTranslate(t, "basicContracts.fileDownloadError", locale, fallback.fileDownloadError);
+ toast.error(message);
}
};
/**
* tanstack table 컬럼 정의 (중첩 헤더 버전)
*/
-export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<BasicContractView>[] {
+export function getColumns({ setRowAction, locale = 'ko', t }: GetColumnsProps): ColumnDef<BasicContractView>[] {
+ const fallback = fallbackTranslations[locale as keyof typeof fallbackTranslations] || fallbackTranslations.ko;
+
// ----------------------------------------------------------------
// 1) select 컬럼 (체크박스)
// ----------------------------------------------------------------
@@ -77,7 +131,7 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<BasicCo
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
- aria-label="Select all"
+ aria-label={safeTranslate(t, "basicContracts.selectAll", locale, fallback.selectAll)}
className="translate-y-0.5"
/>
),
@@ -85,7 +139,7 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<BasicCo
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
- aria-label="Select row"
+ aria-label={safeTranslate(t, "basicContracts.selectRow", locale, fallback.selectRow)}
className="translate-y-0.5"
/>
),
@@ -105,18 +159,19 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<BasicCo
// PENDING 상태일 때는 원본 PDF 파일 (signedFilePath), COMPLETED일 때는 서명된 파일 (signedFilePath)
const filePath = contract.signedFilePath;
const fileName = contract.signedFileName;
+ const downloadText = safeTranslate(t, "basicContracts.download", locale, fallback.download);
return (
<Button
variant="ghost"
size="icon"
- onClick={() => handleFileDownload(filePath, fileName)}
- title={`${fileName} 다운로드`}
+ onClick={() => handleFileDownload(filePath, fileName, t, locale)}
+ title={`${fileName} ${downloadText}`}
className="hover:bg-muted"
disabled={!filePath || !fileName}
>
<Paperclip className="h-4 w-4" />
- <span className="sr-only">다운로드</span>
+ <span className="sr-only">{downloadText}</span>
</Button>
);
},
@@ -124,7 +179,6 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<BasicCo
enableSorting: false,
}
-
// ----------------------------------------------------------------
// 4) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
// ----------------------------------------------------------------
@@ -152,22 +206,28 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<BasicCo
type: cfg.type,
},
cell: ({ row, cell }) => {
- // 날짜 형식 처리
+ // 날짜 형식 처리 - 로케일 적용
if (cfg.id === "createdAt" || cfg.id === "updatedAt" || cfg.id === "completedAt") {
const dateVal = cell.getValue() as Date
- return formatDateTime(dateVal)
+ return formatDateTime(dateVal, locale)
}
- // Status 컬럼에 Badge 적용
+ // Status 컬럼에 Badge 적용 - 다국어 적용
if (cfg.id === "status") {
const status = row.getValue(cfg.id) as string
const isPending = status === "PENDING"
+ const statusText = safeTranslate(
+ t,
+ `basicContracts.statusValues.${status}`,
+ locale,
+ fallback.statusValues[status as keyof typeof fallback.statusValues] || status
+ );
return (
<Badge
variant={!isPending ? "default" : "secondary"}
>
- {status}
+ {statusText}
</Badge>
)
}
@@ -175,8 +235,7 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<BasicCo
// 나머지 컬럼은 그대로 값 표시
return row.getValue(cfg.id) ?? ""
},
- minSize: 80,
-
+ minSize: 80,
}
groupMap[groupName].push(childCol)
@@ -194,10 +253,11 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<BasicCo
// 그룹 없음 → 그냥 최상위 레벨 컬럼
nestedColumns.push(...colDefs)
} else {
- // 상위 컬럼
+ // 상위 컬럼 - 그룹명 다국어 적용
+ const translatedGroupName = t(`basicContracts.groups.${groupName}`) || groupName;
nestedColumns.push({
id: groupName,
- header: groupName, // "Basic Info", "Metadata" 등
+ header: translatedGroupName,
columns: colDefs,
})
}
diff --git a/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx b/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx
index 7bffdac9..7d828a7e 100644
--- a/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx
+++ b/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx
@@ -7,7 +7,6 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { formatDate } from "@/lib/utils";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
-import { BasicContractSignViewer } from "@/lib/basic-contract/viewer/basic-contract-sign-viewer";
import type { WebViewerInstance } from "@pdftron/webviewer";
import type { BasicContractView } from "@/db/schema";
import {
@@ -19,45 +18,82 @@ import {
FileText,
User,
AlertCircle,
- Calendar
+ Calendar,
+ Loader2
} from "lucide-react";
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { useRouter } from "next/navigation"
+import { BasicContractSignViewer } from "../viewer/basic-contract-sign-viewer";
+import { getVendorAttachments } from "../service";
-// 수정된 props 인터페이스
interface BasicContractSignDialogProps {
contracts: BasicContractView[];
onSuccess?: () => void;
+ hasSelectedRows?: boolean;
+ t: (key: string) => string;
}
-export function BasicContractSignDialog({ contracts, onSuccess }: BasicContractSignDialogProps) {
+export function BasicContractSignDialog({
+ contracts,
+ onSuccess,
+ hasSelectedRows = false,
+ t
+}: BasicContractSignDialogProps) {
const [open, setOpen] = React.useState(false);
const [selectedContract, setSelectedContract] = React.useState<BasicContractView | null>(null);
const [instance, setInstance] = React.useState<null | WebViewerInstance>(null);
const [searchTerm, setSearchTerm] = React.useState("");
const [isSubmitting, setIsSubmitting] = React.useState(false);
+
+ // 추가된 state들
+ const [additionalFiles, setAdditionalFiles] = React.useState<any[]>([]);
+ const [isLoadingAttachments, setIsLoadingAttachments] = React.useState(false);
+
const router = useRouter()
+ console.log(selectedContract,"selectedContract")
+ console.log(additionalFiles,"additionalFiles")
+
+ // 버튼 비활성화 조건
+ const isButtonDisabled = !hasSelectedRows || contracts.length === 0;
+
+ // 비활성화 이유 텍스트
+ const getDisabledReason = () => {
+ if (!hasSelectedRows) {
+ return t("basicContracts.toolbar.selectRows");
+ }
+ if (contracts.length === 0) {
+ return t("basicContracts.toolbar.noPendingContracts");
+ }
+ return "";
+ };
+
// 다이얼로그 열기/닫기 핸들러
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen);
- // 다이얼로그가 열릴 때 첫 번째 계약서 자동 선택
- if (isOpen && contracts.length > 0 && !selectedContract) {
- setSelectedContract(contracts[0]);
- }
-
if (!isOpen) {
+ // 다이얼로그 닫을 때 상태 초기화
setSelectedContract(null);
setSearchTerm("");
+ setAdditionalFiles([]); // 추가 파일 상태 초기화
+ // WebViewer 인스턴스 정리
+ if (instance) {
+ try {
+ instance.UI.dispose();
+ } catch (error) {
+ console.log("WebViewer dispose error:", error);
+ }
+ setInstance(null);
+ }
}
};
// 계약서 선택 핸들러
const handleSelectContract = (contract: BasicContractView) => {
+ console.log("계약서 선택:", contract.id, contract.templateName);
setSelectedContract(contract);
};
@@ -79,6 +115,40 @@ export function BasicContractSignDialog({ contracts, onSuccess }: BasicContractS
}
}, [open, contracts, selectedContract]);
+ // 추가 파일 가져오기 useEffect
+ React.useEffect(() => {
+ const fetchAdditionalFiles = async () => {
+ if (!selectedContract) {
+ setAdditionalFiles([]);
+ return;
+ }
+
+ // "비밀유지 계약서"인 경우에만 추가 파일 가져오기
+ if (selectedContract.templateName === "비밀유지 계약서") {
+ setIsLoadingAttachments(true);
+ try {
+ const result = await getVendorAttachments(selectedContract.vendorId);
+ if (result.success) {
+ setAdditionalFiles(result.data);
+ console.log("추가 파일 로드됨:", result.data);
+ } else {
+ console.error("Failed to fetch attachments:", result.error);
+ setAdditionalFiles([]);
+ }
+ } catch (error) {
+ console.error("Error fetching attachments:", error);
+ setAdditionalFiles([]);
+ } finally {
+ setIsLoadingAttachments(false);
+ }
+ } else {
+ setAdditionalFiles([]);
+ }
+ };
+
+ fetchAdditionalFiles();
+ }, [selectedContract]);
+
// 서명 완료 핸들러
const completeSign = async () => {
if (!instance || !selectedContract) return;
@@ -89,29 +159,57 @@ export function BasicContractSignDialog({ contracts, onSuccess }: BasicContractS
const doc = documentViewer.getDocument();
const xfdfString = await annotationManager.exportAnnotations();
+ // 폼 필드 데이터 수집
+ const fieldManager = annotationManager.getFieldManager();
+ const fields = fieldManager.getFields();
+ const formData: any = {};
+ fields.forEach((field: any) => {
+ formData[field.name] = field.value;
+ });
+
const data = await doc.getFileData({
xfdfString,
downloadType: "pdf",
});
// FormData 생성 및 파일 추가
- const formData = new FormData();
- formData.append('file', new Blob([data], { type: 'application/pdf' }));
- formData.append('tableRowId', selectedContract.id.toString());
- formData.append('templateName', selectedContract.signedFileName || '');
+ const submitFormData = new FormData();
+ submitFormData.append('file', new Blob([data], { type: 'application/pdf' }));
+ submitFormData.append('tableRowId', selectedContract.id.toString());
+ submitFormData.append('templateName', selectedContract.signedFileName || '');
+
+ // 폼 필드 데이터 추가
+ if (Object.keys(formData).length > 0) {
+ submitFormData.append('formData', JSON.stringify(formData));
+ }
+
+ // 준법 템플릿인 경우 필수 필드 검증
+ if (selectedContract.templateName?.includes('준법')) {
+ const requiredFields = ['compliance_agreement', 'legal_review', 'risk_assessment'];
+ const missingFields = requiredFields.filter(field => !formData[field]);
+
+ if (missingFields.length > 0) {
+ toast.error("필수 준법 항목이 누락되었습니다.", {
+ description: `다음 항목을 완료해주세요: ${missingFields.join(', ')}`,
+ icon: <AlertCircle className="h-5 w-5 text-red-500" />
+ });
+ setIsSubmitting(false);
+ return;
+ }
+ }
// API 호출
const response = await fetch('/api/upload/signed-contract', {
method: 'POST',
- body: formData,
+ body: submitFormData,
next: { tags: ["basicContractView-vendor"] },
});
const result = await response.json();
if (result.result) {
- toast.success("서명이 성공적으로 완료되었습니다.", {
- description: "문서가 성공적으로 처리되었습니다.",
+ toast.success(t("basicContracts.messages.signSuccess"), {
+ description: t("basicContracts.messages.documentProcessed"),
icon: <CheckCircle2 className="h-5 w-5 text-green-500" />
});
router.refresh();
@@ -120,22 +218,19 @@ export function BasicContractSignDialog({ contracts, onSuccess }: BasicContractS
onSuccess();
}
} else {
- toast.error("서명 처리 중 오류가 발생했습니다.", {
+ toast.error(t("basicContracts.messages.signError"), {
description: result.error,
icon: <AlertCircle className="h-5 w-5 text-red-500" />
});
}
} catch (error) {
console.error("서명 완료 중 오류:", error);
- toast.error("서명 처리 중 오류가 발생했습니다.");
+ toast.error(t("basicContracts.messages.signError"));
} finally {
setIsSubmitting(false);
}
};
- // 서명 대기중(PENDING) 계약서가 있는지 확인
- const hasPendingContracts = contracts.length > 0;
-
return (
<>
{/* 서명 버튼 */}
@@ -143,62 +238,67 @@ export function BasicContractSignDialog({ contracts, onSuccess }: BasicContractS
variant="outline"
size="sm"
onClick={() => setOpen(true)}
- disabled={!hasPendingContracts}
- className="gap-2 transition-all hover:bg-blue-50 hover:text-blue-600 hover:border-blue-200"
+ disabled={isButtonDisabled}
+ className="gap-2 transition-all hover:bg-blue-50 hover:text-blue-600 hover:border-blue-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
- <Upload className="size-4 text-blue-500" aria-hidden="true" />
+ <Upload
+ className={`size-4 ${isButtonDisabled ? 'text-gray-400' : 'text-blue-500'}`}
+ aria-hidden="true"
+ />
<span className="hidden sm:inline flex items-center">
- 서명하기
- {contracts.length > 0 && (
+ {t("basicContracts.toolbar.sign")}
+ {contracts.length > 0 && !isButtonDisabled && (
<Badge variant="secondary" className="ml-2 bg-blue-100 text-blue-700 hover:bg-blue-200">
{contracts.length}
</Badge>
)}
+ {isButtonDisabled && (
+ <span className="ml-2 text-xs text-gray-400">
+ ({getDisabledReason()})
+ </span>
+ )}
</span>
</Button>
- {/* 서명 다이얼로그 - 고정 높이 유지 */}
+ {/* 서명 다이얼로그 - 레이아웃 개선 */}
<Dialog open={open} onOpenChange={handleOpenChange}>
- <DialogContent className="max-w-5xl h-[650px] w-[90vw] p-0 overflow-hidden rounded-lg shadow-lg border border-gray-200">
- <DialogHeader className="p-6 bg-gradient-to-r from-blue-50 to-purple-50 border-b">
+ <DialogContent className="max-w-7xl w-[95vw] h-[90vh] p-0 flex flex-col overflow-hidden" style={{width:'95vw', maxWidth:'95vw'}}>
+ {/* 고정 헤더 */}
+ <DialogHeader className="px-6 py-4 bg-gradient-to-r from-blue-50 to-purple-50 border-b flex-shrink-0">
<DialogTitle className="text-xl font-bold flex items-center text-gray-800">
<FileSignature className="mr-2 h-5 w-5 text-blue-500" />
- 기본계약서 및 관련문서 서명
+ {t("basicContracts.dialog.title")}
+ {/* 추가 파일 로딩 표시 */}
+ {isLoadingAttachments && (
+ <Loader2 className="ml-2 h-4 w-4 animate-spin text-blue-500" />
+ )}
</DialogTitle>
</DialogHeader>
- <div className="grid grid-cols-2 h-[calc(100%-4rem)] overflow-hidden">
- {/* 왼쪽 영역 - 계약서 목록 */}
- <div className="col-span-1 border-r border-gray-200 bg-gray-50">
- <div className="p-4 border-b">
- <div className="relative mb-10">
- <div className="absolute inset-y-0 left-3.5 flex items-center pointer-events-none">
- <Search className="h-4 w-8 text-gray-400" />
+ {/* 메인 컨텐츠 영역 - Flexbox 사용 */}
+ <div className="flex flex-1 min-h-0 overflow-hidden">
+ {/* 왼쪽 영역 - 계약서 목록 (고정 너비) */}
+ <div className="w-80 border-r border-gray-200 bg-gray-50 flex flex-col flex-shrink-0">
+ <div className="p-3 border-b flex-shrink-0">
+ <div className="relative">
+ <div className="absolute inset-y-0 left-2 flex items-center pointer-events-none">
+ <Search className="h-4 w-4 text-gray-400" />
</div>
<Input
- placeholder="문서명 또는 요청자 검색"
- className="bg-white"
- style={{paddingLeft:25}}
+ placeholder={t("basicContracts.dialog.searchPlaceholder")}
+ className="bg-white pl-8 text-sm"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
- <Tabs defaultValue="all" className="w-full">
- <TabsList className="w-full">
- <TabsTrigger value="all" className="flex-1">전체 ({contracts.length})</TabsTrigger>
- <TabsTrigger value="contracts" className="flex-1">계약서</TabsTrigger>
- <TabsTrigger value="docs" className="flex-1">관련문서</TabsTrigger>
- </TabsList>
- </Tabs>
</div>
- <ScrollArea className="h-[calc(100%-6rem)]">
- <div className="p-3">
+ <ScrollArea className="flex-1">
+ <div className="p-2">
{filteredContracts.length === 0 ? (
- <div className="flex flex-col items-center justify-center h-40 text-center">
- <FileText className="h-12 w-12 text-gray-300 mb-2" />
- <p className="text-gray-500 font-medium">서명 요청된 문서가 없습니다.</p>
- <p className="text-gray-400 text-sm mt-1">나중에 다시 확인해주세요.</p>
+ <div className="flex flex-col items-center justify-center h-32 text-center">
+ <FileText className="h-8 w-8 text-gray-300 mb-2" />
+ <p className="text-gray-500 text-sm font-medium">{t("basicContracts.dialog.noDocuments")}</p>
</div>
) : (
<div className="space-y-2">
@@ -207,30 +307,38 @@ export function BasicContractSignDialog({ contracts, onSuccess }: BasicContractS
key={contract.id}
variant="outline"
className={cn(
- "w-full justify-start text-left h-auto p-3 bg-white hover:bg-blue-50 transition-colors",
+ "w-full justify-start text-left h-auto p-2 bg-white hover:bg-blue-50 transition-colors",
"border border-gray-200 hover:border-blue-200 rounded-md",
selectedContract?.id === contract.id && "border-blue-500 bg-blue-50 shadow-sm"
)}
onClick={() => handleSelectContract(contract)}
>
- <div className="flex flex-col w-full">
+ <div className="flex flex-col w-full space-y-1">
+ {/* 첫 번째 줄: 제목 + 상태 */}
<div className="flex items-center justify-between w-full">
- <span className="font-semibold truncate text-gray-800 flex items-center">
- <FileText className="h-4 w-4 mr-2 text-blue-500" />
- {contract.templateName || '문서'}
+ <span className="font-medium text-xs truncate text-gray-800 flex items-center min-w-0">
+ <FileText className="h-3 w-3 mr-1 text-blue-500 flex-shrink-0" />
+ <span className="truncate">{contract.templateName || t("basicContracts.dialog.document")}</span>
+ {/* 비밀유지 계약서인 경우 표시 */}
+ {contract.templateName === "비밀유지 계약서" && (
+ <Badge variant="outline" className="ml-1 bg-green-50 text-green-700 border-green-200 text-xs">
+ NDA
+ </Badge>
+ )}
</span>
- <Badge variant="outline" className="bg-yellow-50 text-yellow-700 border-yellow-200">
- 대기중
+ <Badge variant="outline" className="bg-yellow-50 text-yellow-700 border-yellow-200 text-xs ml-2 flex-shrink-0">
+ {t("basicContracts.statusValues.PENDING")}
</Badge>
</div>
- <Separator className="my-2 bg-gray-100" />
- <div className="grid grid-cols-2 gap-1 mt-1 text-xs text-gray-500">
- <div className="flex items-center">
- <User className="h-3 w-3 mr-1" />
- <span className="truncate">{contract.requestedByName || '알 수 없음'}</span>
+
+ {/* 두 번째 줄: 사용자 + 날짜 */}
+ <div className="flex items-center justify-between text-xs text-gray-500">
+ <div className="flex items-center min-w-0">
+ <User className="h-3 w-3 mr-1 flex-shrink-0" />
+ <span className="truncate">{contract.requestedByName || t("basicContracts.dialog.unknown")}</span>
</div>
- <div className="flex items-center justify-end">
- <Calendar className="h-3 w-3 mr-1" />
+ <div className="flex items-center ml-2 flex-shrink-0">
+ <Calendar className="h-3 w-3 mr-1 flex-shrink-0" />
<span>{formatDate(contract.createdAt)}</span>
</div>
</div>
@@ -243,19 +351,32 @@ export function BasicContractSignDialog({ contracts, onSuccess }: BasicContractS
</ScrollArea>
</div>
- {/* 오른쪽 영역 - 문서 뷰어 */}
- <div className="col-span-1 bg-white flex flex-col h-full">
+ {/* 오른쪽 영역 - 문서 뷰어 (확장 가능) */}
+ <div className="flex-1 bg-white flex flex-col min-w-0">
{selectedContract ? (
<>
- <div className="p-3 border-b bg-gray-50">
+ {/* 뷰어 헤더 */}
+ <div className="p-4 border-b bg-gray-50 flex-shrink-0">
<h3 className="font-semibold text-gray-800 flex items-center">
<FileText className="h-4 w-4 mr-2 text-blue-500" />
- {selectedContract.templateName || '문서'}
+ {selectedContract.templateName || t("basicContracts.dialog.document")}
+ {/* 준법 템플릿 표시 */}
+ {selectedContract.templateName?.includes('준법') && (
+ <Badge variant="outline" className="ml-2 bg-amber-50 text-amber-700 border-amber-200">
+ 준법 서류
+ </Badge>
+ )}
+ {/* 비밀유지 계약서인 경우 추가 파일 수 표시 */}
+ {selectedContract.templateName === "비밀유지 계약서" && additionalFiles.length > 0 && (
+ <Badge variant="outline" className="ml-2 bg-blue-50 text-blue-700 border-blue-200">
+ 첨부파일 {additionalFiles.length}개
+ </Badge>
+ )}
</h3>
- <div className="flex justify-between items-center mt-1 text-xs text-gray-500">
+ <div className="flex justify-between items-center mt-2 text-sm text-gray-500">
<span className="flex items-center">
<User className="h-3 w-3 mr-1" />
- 요청자: {selectedContract.requestedByName || '알 수 없음'}
+ {t("basicContracts.dialog.requester")}: {selectedContract.requestedByName || t("basicContracts.dialog.unknown")}
</span>
<span className="flex items-center">
<Clock className="h-3 w-3 mr-1" />
@@ -263,19 +384,43 @@ export function BasicContractSignDialog({ contracts, onSuccess }: BasicContractS
</span>
</div>
</div>
- <div className="flex-grow overflow-hidden border-b">
+
+ {/* 뷰어 영역 - 남은 공간 모두 사용 */}
+ <div className="flex-1 min-h-0 overflow-hidden">
<BasicContractSignViewer
+ key={selectedContract.id} // key 추가로 컴포넌트 재생성 강제
contractId={selectedContract.id}
filePath={selectedContract.signedFilePath || undefined}
+ templateName={selectedContract.templateName || ""}
+ additionalFiles={additionalFiles} // 추가 파일 전달
instance={instance}
setInstance={setInstance}
+ t={t}
/>
</div>
- <div className="p-3 flex justify-between items-center bg-gray-50">
- <p className="text-sm text-gray-600">
- <AlertCircle className="h-4 w-4 text-yellow-500 inline mr-1" />
- 서명 후에는 변경할 수 없습니다.
- </p>
+
+ {/* 고정 푸터 */}
+ <div className="p-4 flex justify-between items-center bg-gray-50 border-t flex-shrink-0">
+ <div className="flex items-center space-x-4">
+ <p className="text-sm text-gray-600 flex items-center">
+ <AlertCircle className="h-4 w-4 text-yellow-500 mr-1" />
+ {t("basicContracts.dialog.signWarning")}
+ </p>
+ {/* 준법 템플릿인 경우 추가 안내 */}
+ {selectedContract.templateName?.includes('준법') && (
+ <p className="text-xs text-amber-600 flex items-center">
+ <AlertCircle className="h-3 w-3 text-amber-500 mr-1" />
+ 모든 준법 항목을 체크해주세요
+ </p>
+ )}
+ {/* 비밀유지 계약서인 경우 추가 안내 */}
+ {selectedContract.templateName === "비밀유지 계약서" && additionalFiles.length > 0 && (
+ <p className="text-xs text-blue-600 flex items-center">
+ <FileText className="h-3 w-3 text-blue-500 mr-1" />
+ 첨부 서류도 확인해주세요
+ </p>
+ )}
+ </div>
<Button
className="gap-2 bg-blue-600 hover:bg-blue-700 transition-colors"
onClick={completeSign}
@@ -287,12 +432,12 @@ export function BasicContractSignDialog({ contracts, onSuccess }: BasicContractS
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
- 처리 중...
+ {t("basicContracts.dialog.processing")}
</>
) : (
<>
<FileSignature className="h-4 w-4" />
- 서명 완료
+ {t("basicContracts.dialog.completeSign")}
</>
)}
</Button>
@@ -303,9 +448,9 @@ export function BasicContractSignDialog({ contracts, onSuccess }: BasicContractS
<div className="bg-blue-50 p-6 rounded-full mb-4">
<FileSignature className="h-12 w-12 text-blue-500" />
</div>
- <h3 className="text-xl font-medium text-gray-800 mb-2">문서를 선택해주세요</h3>
+ <h3 className="text-xl font-medium text-gray-800 mb-2">{t("basicContracts.dialog.selectDocument")}</h3>
<p className="text-gray-500 max-w-md">
- 왼쪽 목록에서 서명할 문서를 선택하면 여기에 문서 내용이 표시됩니다.
+ {t("basicContracts.dialog.selectDocumentDescription")}
</p>
</div>
)}
diff --git a/lib/basic-contract/vendor-table/basic-contract-table.tsx b/lib/basic-contract/vendor-table/basic-contract-table.tsx
index 34e15ae3..f2575024 100644
--- a/lib/basic-contract/vendor-table/basic-contract-table.tsx
+++ b/lib/basic-contract/vendor-table/basic-contract-table.tsx
@@ -1,6 +1,8 @@
"use client";
import * as React from "react";
+import { useParams } from "next/navigation";
+import { useTranslation } from "react-i18next";
import { DataTable } from "@/components/data-table/data-table";
import { Button } from "@/components/ui/button";
import { Plus, Loader2 } from "lucide-react";
@@ -17,7 +19,6 @@ import { getBasicContracts, getBasicContractsByVendorId } from "../service";
import { BasicContractView } from "@/db/schema";
import { BasicContractTableToolbarActions } from "./basicContract-table-toolbar-actions";
-
interface BasicTemplateTableProps {
promises: Promise<
[
@@ -26,44 +27,85 @@ interface BasicTemplateTableProps {
>
}
-
export function BasicContractsVendorTable({ promises }: BasicTemplateTableProps) {
-
-
+ const params = useParams();
+ const lng = (params?.lng as string) || "ko";
+ const { t, ready } = useTranslation(lng, "procurement");
+
const [rowAction, setRowAction] =
React.useState<DataTableRowAction<BasicContractView> | null>(null)
-
-
+
const [{ data, pageCount }] =
React.use(promises)
- // console.log(data)
-
- // 컬럼 설정 - 외부 파일에서 가져옴
+ console.log(data,"data")
+
+ // 안전한 번역 함수 (fallback 포함)
+ const safeT = React.useCallback((key: string, fallback: string) => {
+ if (!ready) return fallback;
+ const translated = t(key);
+ return translated === key ? fallback : translated;
+ }, [t, ready]);
+
+ // 디버깅용 로그 (개발환경에서만)
+ React.useEffect(() => {
+ if (process.env.NODE_ENV === 'development') {
+ console.log('Translation ready:', ready);
+ console.log('Current language:', lng);
+ console.log('Template name translation:', t("basicContracts.templateName"));
+ console.log('Status PENDING translation:', t("basicContracts.statusValues.PENDING"));
+ }
+ }, [ready, lng, t]);
+
+ // 컬럼 설정 - 번역이 준비된 후에만 생성
const columns = React.useMemo(
- () => getColumns({ setRowAction }),
- [setRowAction]
+ () => {
+ if (!ready) return []; // 번역이 준비되지 않으면 빈 배열 반환
+ return getColumns({ setRowAction, locale: lng, t });
+ },
+ [setRowAction, lng, t, ready]
)
- // config 기반으로 필터 필드 설정
- const advancedFilterFields: DataTableAdvancedFilterField<BasicContractView>[] = [
- { id: "templateName", label: "템플릿명", type: "text" },
- {
- id: "status", label: "상태", type: "select", options: [
- { label: "서명대기", value: "PENDING" },
- { label: "서명완료", value: "COMPLETED" },
- ]
- },
- { id: "userName", label: "요청자", type: "text" },
- { id: "createdAt", label: "생성일", type: "date" },
- { id: "updatedAt", label: "수정일", type: "date" },
- ];
+ // config 기반으로 필터 필드 설정 - 안전한 번역 함수 사용
+ const advancedFilterFields: DataTableAdvancedFilterField<BasicContractView>[] = React.useMemo(() => {
+ return [
+ {
+ id: "templateName",
+ label: safeT("basicContracts.templateName", lng === 'ko' ? "템플릿명" : "Template Name"),
+ type: "text"
+ },
+ {
+ id: "status",
+ label: safeT("basicContracts.status", lng === 'ko' ? "상태" : "Status"),
+ type: "select",
+ options: [
+ {
+ label: safeT("basicContracts.statusValues.PENDING", lng === 'ko' ? "서명대기" : "Pending"),
+ value: "PENDING"
+ },
+ {
+ label: safeT("basicContracts.statusValues.COMPLETED", lng === 'ko' ? "서명완료" : "Completed"),
+ value: "COMPLETED"
+ },
+ ]
+ },
+ {
+ id: "createdAt",
+ label: safeT("basicContracts.createdAt", lng === 'ko' ? "생성일" : "Created Date"),
+ type: "date"
+ },
+ {
+ id: "updatedAt",
+ label: safeT("basicContracts.updatedAt", lng === 'ko' ? "수정일" : "Updated Date"),
+ type: "date"
+ },
+ ];
+ }, [safeT, lng]);
const { table } = useDataTable({
data,
columns,
pageCount,
- // filterFields,
enablePinning: true,
enableAdvancedFilter: true,
initialState: {
@@ -77,18 +119,14 @@ export function BasicContractsVendorTable({ promises }: BasicTemplateTableProps)
return (
<>
-
<DataTable table={table}>
<DataTableAdvancedToolbar
table={table}
filterFields={advancedFilterFields}
>
<BasicContractTableToolbarActions table={table} />
-
</DataTableAdvancedToolbar>
</DataTable>
-
</>
-
);
-} \ No newline at end of file
+} \ No newline at end of file
diff --git a/lib/basic-contract/vendor-table/basicContract-table-toolbar-actions.tsx b/lib/basic-contract/vendor-table/basicContract-table-toolbar-actions.tsx
index 2e5e4471..1fc6fe6b 100644
--- a/lib/basic-contract/vendor-table/basicContract-table-toolbar-actions.tsx
+++ b/lib/basic-contract/vendor-table/basicContract-table-toolbar-actions.tsx
@@ -1,9 +1,10 @@
"use client"
import * as React from "react"
-import { type Task } from "@/db/schema/tasks"
import { type Table } from "@tanstack/react-table"
-import { Download, Upload } from "lucide-react"
+import { Download } from "lucide-react"
+import { useParams } from "next/navigation"
+import { useTranslation } from "@/i18n/client"
import { exportTableToExcel } from "@/lib/export"
import { Button } from "@/components/ui/button"
@@ -15,9 +16,19 @@ interface TemplateTableToolbarActionsProps {
}
export function BasicContractTableToolbarActions({ table }: TemplateTableToolbarActionsProps) {
- // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
+ const params = useParams()
+ const lng = (params?.lng as string) || "ko"
+ const { t, ready } = useTranslation(lng, "procurement")
- const inPendingContracts = React.useMemo(() => {
+ // 안전한 번역 함수
+ const safeT = React.useCallback((key: string, fallback: string) => {
+ if (!ready) return fallback;
+ const translated = t(key);
+ return translated === key ? fallback : translated;
+ }, [t, ready]);
+
+ // PENDING 상태인 선택된 계약서들
+ const pendingContracts = React.useMemo(() => {
return table
.getFilteredSelectedRowModel()
.rows
@@ -25,31 +36,35 @@ export function BasicContractTableToolbarActions({ table }: TemplateTableToolbar
.filter(contract => contract.status === "PENDING");
}, [table.getFilteredSelectedRowModel().rows]);
+ // 선택된 행이 있는지 확인
+ const hasSelectedRows = table.getFilteredSelectedRowModel().rows.length > 0;
return (
<div className="flex items-center gap-2">
+ {/* 서명 버튼 - 항상 표시하되 내부에서 조건 체크 */}
+ <BasicContractSignDialog
+ contracts={pendingContracts}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ hasSelectedRows={hasSelectedRows}
+ t={safeT}
+ />
- {table.getFilteredSelectedRowModel().rows.length > 0 ? (
- <BasicContractSignDialog
- contracts={inPendingContracts}
- onSuccess={() => table.toggleAllRowsSelected(false)}
- />
- ) : null}
-
- {/** 4) Export 버튼 */}
+ {/* Export 버튼 */}
<Button
variant="outline"
size="sm"
onClick={() =>
exportTableToExcel(table, {
- filename: "basci-contract-requested-list",
+ filename: "basic-contract-requested-list",
excludeColumns: ["select", "actions"],
})
}
className="gap-2"
>
<Download className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">Export</span>
+ <span className="hidden sm:inline">
+ {safeT("basicContracts.toolbar.export", lng === 'ko' ? "내보내기" : "Export")}
+ </span>
</Button>
</div>
)
diff --git a/lib/basic-contract/viewer/AutoSignatureFieldDetector.tsx b/lib/basic-contract/viewer/AutoSignatureFieldDetector.tsx
new file mode 100644
index 00000000..7de8062c
--- /dev/null
+++ b/lib/basic-contract/viewer/AutoSignatureFieldDetector.tsx
@@ -0,0 +1,493 @@
+// PDF 텍스트 패턴 기반 자동 서명 필드 생성 시스템
+
+interface SignaturePattern {
+ regex: RegExp;
+ name: string;
+ priority: number;
+ offsetX?: number; // 텍스트로부터 X축 오프셋
+ offsetY?: number; // 텍스트로부터 Y축 오프셋
+ width?: number; // 서명 필드 너비
+ height?: number; // 서명 필드 높이
+ }
+
+ interface DetectedSignatureLocation {
+ pageIndex: number;
+ text: string;
+ rect: {
+ x1: number;
+ y1: number;
+ x2: number;
+ y2: number;
+ };
+ pattern: SignaturePattern;
+ confidence: number;
+ }
+
+ class AutoSignatureFieldDetector {
+ private instance: WebViewerInstance;
+ private signaturePatterns: SignaturePattern[];
+
+ constructor(instance: WebViewerInstance) {
+ this.instance = instance;
+ this.signaturePatterns = this.initializePatterns();
+ }
+
+ private initializePatterns(): SignaturePattern[] {
+ return [
+ // 한국어 패턴들 (우선순위 높음)
+ {
+ regex: /서명\s*[::]\s*[_\-\s]{3,}/gi,
+ name: "한국어_서명_콜론",
+ priority: 10,
+ offsetX: 80, // "서명:" 텍스트 오른쪽으로 80px
+ offsetY: -5, // 약간 위로
+ width: 150,
+ height: 40
+ },
+ {
+ regex: /서명란\s*[_\-\s]{0,}/gi,
+ name: "한국어_서명란",
+ priority: 9,
+ offsetX: 60,
+ offsetY: -5,
+ width: 150,
+ height: 40
+ },
+ {
+ regex: /서명\s*[_\-\s]{5,}/gi,
+ name: "한국어_서명_라인",
+ priority: 8,
+ offsetX: 50,
+ offsetY: -5,
+ width: 150,
+ height: 40
+ },
+ {
+ regex: /(계약자|갑|을)\s*서명\s*[::]?\s*[_\-\s]{0,}/gi,
+ name: "한국어_계약자_서명",
+ priority: 9,
+ offsetX: 100,
+ offsetY: -5,
+ width: 150,
+ height: 40
+ },
+
+ // 영어 패턴들
+ {
+ regex: /signature\s*[::]\s*[_\-\s]{3,}/gi,
+ name: "영어_signature_콜론",
+ priority: 8,
+ offsetX: 120,
+ offsetY: -5,
+ width: 150,
+ height: 40
+ },
+ {
+ regex: /sign\s+here\s*[::]?\s*[_\-\s]{0,}/gi,
+ name: "영어_sign_here",
+ priority: 9,
+ offsetX: 100,
+ offsetY: -5,
+ width: 150,
+ height: 40
+ },
+ {
+ regex: /sign\s*[::]\s*[_\-\s]{3,}/gi,
+ name: "영어_sign_콜론",
+ priority: 7,
+ offsetX: 60,
+ offsetY: -5,
+ width: 150,
+ height: 40
+ },
+
+ // 날짜와 함께 나오는 패턴들
+ {
+ regex: /날짜\s*[::]\s*[_\-\s]{3,}.*?서명\s*[::]\s*[_\-\s]{3,}/gi,
+ name: "날짜_서명_조합",
+ priority: 10,
+ offsetX: 0,
+ offsetY: -5,
+ width: 150,
+ height: 40
+ },
+ {
+ regex: /date\s*[::]\s*[_\-\s]{3,}.*?signature\s*[::]\s*[_\-\s]{3,}/gi,
+ name: "date_signature_조합",
+ priority: 10,
+ offsetX: 0,
+ offsetY: -5,
+ width: 150,
+ height: 40
+ },
+
+ // 일반적인 양식 패턴들
+ {
+ regex: /이름\s*[::]\s*[_\-\s]{5,}.*?서명\s*[::]\s*[_\-\s]{5,}/gi,
+ name: "이름_서명_조합",
+ priority: 8,
+ offsetX: 0,
+ offsetY: -5,
+ width: 150,
+ height: 40
+ },
+ {
+ regex: /name\s*[::]\s*[_\-\s]{5,}.*?signature\s*[::]\s*[_\-\s]{5,}/gi,
+ name: "name_signature_조합",
+ priority: 8,
+ offsetX: 0,
+ offsetY: -5,
+ width: 150,
+ height: 40
+ }
+ ];
+ }
+
+ // 📄 메인 함수: 문서에서 서명 패턴 감지 및 필드 생성
+ async detectAndCreateSignatureFields(): Promise<string[]> {
+ console.log("🔍 자동 서명 필드 감지 시작...");
+
+ try {
+ const { Core } = this.instance;
+ const { documentViewer } = Core;
+
+ await Core.PDFNet.initialize();
+ const doc = await documentViewer.getDocument().getPDFDoc();
+
+ // 1. 기존 서명 필드 확인
+ const existingFields = await this.getExistingSignatureFields(doc);
+ console.log(`📊 기존 서명 필드: ${existingFields.length}개`);
+
+ if (existingFields.length > 0) {
+ console.log("✅ 기존 서명 필드가 있으므로 자동 생성 스킵");
+ return existingFields.map(f => f.name);
+ }
+
+ // 2. 텍스트 패턴 기반 서명 위치 감지
+ const detectedLocations = await this.detectSignatureLocations(doc);
+ console.log(`🎯 감지된 서명 위치: ${detectedLocations.length}개`);
+
+ // 3. 감지된 위치에 서명 필드 생성
+ const createdFields: string[] = [];
+ for (const location of detectedLocations) {
+ try {
+ const fieldName = await this.createSignatureFieldAtLocation(doc, location);
+ createdFields.push(fieldName);
+ console.log(`✅ 서명 필드 생성: ${fieldName}`);
+ } catch (error) {
+ console.error(`📛 서명 필드 생성 실패:`, error);
+ }
+ }
+
+ // 4. 문서 업데이트
+ if (createdFields.length > 0) {
+ await documentViewer.refreshAll();
+ await documentViewer.updateView();
+ console.log(`🎉 총 ${createdFields.length}개 서명 필드 자동 생성 완료`);
+ } else {
+ console.warn("⚠️ 서명 패턴을 찾지 못했습니다. 기본 서명 필드 생성...");
+ const defaultField = await this.createDefaultSignatureField(doc);
+ createdFields.push(defaultField);
+ }
+
+ return createdFields;
+
+ } catch (error) {
+ console.error("📛 자동 서명 필드 생성 실패:", error);
+ return [];
+ }
+ }
+
+ // 기존 서명 필드 확인
+ private async getExistingSignatureFields(doc: any): Promise<any[]> {
+ const { Core } = this.instance;
+ const fields = [];
+
+ try {
+ const pageCount = await doc.getPageCount();
+
+ for (let i = 1; i <= pageCount; i++) {
+ const page = await doc.getPage(i);
+ const numAnnots = await page.getNumAnnots();
+
+ for (let j = 0; j < numAnnots; j++) {
+ const annot = await page.getAnnot(j);
+ const annotType = await annot.getType();
+
+ if (annotType === Core.PDFNet.Annot.Type.e_Widget) {
+ const widget = await Core.PDFNet.WidgetAnnot.cast(annot);
+ const field = await widget.getField();
+ const fieldType = await field.getType();
+
+ if (fieldType === Core.PDFNet.Field.Type.e_signature) {
+ const fieldName = await field.getName();
+ fields.push({ name: fieldName, widget, page: i });
+ }
+ }
+ }
+ }
+ } catch (error) {
+ console.warn("기존 필드 확인 중 에러:", error);
+ }
+
+ return fields;
+ }
+
+ // 텍스트 패턴 기반 서명 위치 감지
+ private async detectSignatureLocations(doc: any): Promise<DetectedSignatureLocation[]> {
+ const { Core } = this.instance;
+ const detectedLocations: DetectedSignatureLocation[] = [];
+
+ try {
+ const pageCount = await doc.getPageCount();
+
+ for (let pageNum = 1; pageNum <= pageCount; pageNum++) {
+ const page = await doc.getPage(pageNum);
+
+ // 텍스트 추출
+ const textExtractor = await Core.PDFNet.TextExtractor.create();
+ await textExtractor.begin(page);
+
+ // 텍스트와 위치 정보 추출
+ const wordList = [];
+ let line = await textExtractor.getFirstLine();
+
+ while (line) {
+ let word = await line.getFirstWord();
+ while (word) {
+ const wordText = await word.getString();
+ const wordBox = await word.getBBox();
+
+ wordList.push({
+ text: wordText,
+ x1: await wordBox.getX1(),
+ y1: await wordBox.getY1(),
+ x2: await wordBox.getX2(),
+ y2: await wordBox.getY2()
+ });
+
+ word = await word.getNext();
+ }
+ line = await line.getNext();
+ }
+
+ // 전체 페이지 텍스트 조합
+ const fullText = wordList.map(w => w.text).join(' ');
+
+ // 패턴 매칭
+ for (const pattern of this.signaturePatterns) {
+ const matches = Array.from(fullText.matchAll(pattern.regex));
+
+ for (const match of matches) {
+ // 매치된 텍스트의 위치 찾기
+ const matchText = match[0];
+ const matchStart = match.index || 0;
+
+ // 대략적인 위치 계산 (개선 가능)
+ const location = this.calculateTextLocation(wordList, matchStart, matchText.length);
+
+ if (location) {
+ detectedLocations.push({
+ pageIndex: pageNum - 1,
+ text: matchText,
+ rect: {
+ x1: location.x1 + (pattern.offsetX || 0),
+ y1: location.y1 + (pattern.offsetY || 0),
+ x2: location.x1 + (pattern.offsetX || 0) + (pattern.width || 150),
+ y2: location.y1 + (pattern.offsetY || 0) + (pattern.height || 40)
+ },
+ pattern: pattern,
+ confidence: pattern.priority
+ });
+
+ console.log(`🎯 패턴 매치: "${matchText}" (${pattern.name}) 페이지 ${pageNum}`);
+ }
+ }
+ }
+ }
+
+ // 신뢰도 순으로 정렬 (중복 제거 포함)
+ return this.deduplicateAndSort(detectedLocations);
+
+ } catch (error) {
+ console.error("텍스트 패턴 감지 실패:", error);
+ return [];
+ }
+ }
+
+ // 텍스트 위치 계산 (개선된 버전)
+ private calculateTextLocation(wordList: any[], startIndex: number, length: number): any {
+ if (wordList.length === 0) return null;
+
+ // 간단한 구현: 첫 번째 단어의 위치 사용
+ // 실제로는 더 정교한 텍스트 매칭 필요
+ const totalChars = wordList.map(w => w.text).join(' ').length;
+ const ratio = startIndex / totalChars;
+ const targetWordIndex = Math.floor(ratio * wordList.length);
+
+ const targetWord = wordList[Math.min(targetWordIndex, wordList.length - 1)];
+ return targetWord;
+ }
+
+ // 중복 제거 및 정렬
+ private deduplicateAndSort(locations: DetectedSignatureLocation[]): DetectedSignatureLocation[] {
+ // 같은 페이지의 너무 가까운 위치들 제거
+ const filtered = locations.filter((loc, index) => {
+ return !locations.slice(0, index).some(prevLoc =>
+ prevLoc.pageIndex === loc.pageIndex &&
+ Math.abs(prevLoc.rect.x1 - loc.rect.x1) < 100 &&
+ Math.abs(prevLoc.rect.y1 - loc.rect.y1) < 50
+ );
+ });
+
+ // 신뢰도(우선순위) 순으로 정렬
+ return filtered.sort((a, b) => b.confidence - a.confidence);
+ }
+
+ // 감지된 위치에 서명 필드 생성
+ private async createSignatureFieldAtLocation(doc: any, location: DetectedSignatureLocation): Promise<string> {
+ const { Core } = this.instance;
+
+ const fieldName = `auto_signature_${location.pageIndex + 1}_${Date.now()}`;
+ const page = await doc.getPage(location.pageIndex + 1);
+
+ // 디지털 서명 필드 생성
+ const sigField = await doc.createDigitalSignatureField(fieldName);
+
+ // 서명 위젯 생성
+ const rect = await Core.PDFNet.Rect.init(
+ location.rect.x1,
+ location.rect.y1,
+ location.rect.x2,
+ location.rect.y2
+ );
+
+ const widget = await Core.PDFNet.SignatureWidget.createWithDigitalSignatureField(
+ doc, rect, sigField
+ );
+
+ // 위젯 스타일 설정
+ await widget.setBackgroundColor(
+ await Core.PDFNet.ColorPt.init(0.95, 0.95, 1.0), // 연한 파란색
+ 3 // RGB
+ );
+
+ await widget.setBorderColor(
+ await Core.PDFNet.ColorPt.init(0.2, 0.4, 0.8), // 파란색 테두리
+ 3 // RGB
+ );
+
+ // 페이지에 위젯 추가
+ await page.annotPushBack(widget);
+
+ console.log(`✅ 자동 서명 필드 생성: ${fieldName} (패턴: ${location.pattern.name})`);
+ return fieldName;
+ }
+
+ // 기본 서명 필드 생성 (패턴을 찾지 못한 경우)
+ private async createDefaultSignatureField(doc: any): Promise<string> {
+ const { Core } = this.instance;
+
+ console.log("⚠️ 서명 패턴 미발견, 기본 위치에 서명 필드 생성");
+
+ const pageCount = await doc.getPageCount();
+ const lastPage = await doc.getPage(pageCount);
+ const pageInfo = await lastPage.getPageInfo();
+ const pageWidth = await pageInfo.getWidth();
+ const pageHeight = await pageInfo.getHeight();
+
+ const fieldName = `default_signature_${Date.now()}`;
+ const sigField = await doc.createDigitalSignatureField(fieldName);
+
+ // 마지막 페이지 하단 중앙에 배치
+ const rect = await Core.PDFNet.Rect.init(
+ pageWidth * 0.3, // 페이지 너비 30% 지점
+ pageHeight * 0.1, // 페이지 하단 10% 지점
+ pageWidth * 0.7, // 페이지 너비 70% 지점
+ pageHeight * 0.2 // 페이지 하단 20% 지점
+ );
+
+ const widget = await Core.PDFNet.SignatureWidget.createWithDigitalSignatureField(
+ doc, rect, sigField
+ );
+
+ await widget.setBackgroundColor(
+ await Core.PDFNet.ColorPt.init(1.0, 0.95, 0.95), // 연한 핑크색 (주의 표시)
+ 3
+ );
+
+ await widget.setBorderColor(
+ await Core.PDFNet.ColorPt.init(0.8, 0.2, 0.2), // 빨간색 테두리
+ 3
+ );
+
+ await lastPage.annotPushBack(widget);
+
+ return fieldName;
+ }
+ }
+
+ // ✅ BasicContractSignViewer에 통합할 수 있는 함수
+ export async function addAutoSignatureFieldsToDocument(instance: WebViewerInstance): Promise<string[]> {
+ if (!instance) {
+ console.warn("⚠️ WebViewer 인스턴스가 없습니다.");
+ return [];
+ }
+
+ try {
+ const detector = new AutoSignatureFieldDetector(instance);
+ const createdFields = await detector.detectAndCreateSignatureFields();
+
+ if (createdFields.length > 0) {
+ console.log(`🎉 자동 서명 필드 생성 완료: ${createdFields.join(', ')}`);
+ }
+
+ return createdFields;
+
+ } catch (error) {
+ console.error("📛 자동 서명 필드 추가 실패:", error);
+ return [];
+ }
+ }
+
+ // ✅ 문서 로드 후 자동 호출되는 Hook
+ export function useAutoSignatureFields(instance: WebViewerInstance | null) {
+ const [signatureFields, setSignatureFields] = React.useState<string[]>([]);
+ const [isProcessing, setIsProcessing] = React.useState(false);
+
+ React.useEffect(() => {
+ if (!instance) return;
+
+ const { documentViewer } = instance.Core;
+
+ const handleDocumentLoaded = async () => {
+ try {
+ setIsProcessing(true);
+ console.log("📄 문서 로드 완료, 자동 서명 필드 생성 시작...");
+
+ // 문서 로드 후 잠시 대기 (안정성을 위해)
+ await new Promise(resolve => setTimeout(resolve, 1000));
+
+ const fields = await addAutoSignatureFieldsToDocument(instance);
+ setSignatureFields(fields);
+
+ } catch (error) {
+ console.error("📛 자동 서명 필드 처리 실패:", error);
+ } finally {
+ setIsProcessing(false);
+ }
+ };
+
+ documentViewer.addEventListener('documentLoaded', handleDocumentLoaded);
+
+ return () => {
+ documentViewer.removeEventListener('documentLoaded', handleDocumentLoaded);
+ };
+ }, [instance]);
+
+ return {
+ signatureFields,
+ isProcessing,
+ hasSignatureFields: signatureFields.length > 0
+ };
+ } \ No newline at end of file
diff --git a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx
index 8995c560..49efb551 100644
--- a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx
+++ b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx
@@ -1,233 +1,1566 @@
"use client";
import React, {
- useState,
- useEffect,
- useRef,
- SetStateAction,
- Dispatch,
+useState,
+useEffect,
+useRef,
+SetStateAction,
+Dispatch,
} from "react";
import { WebViewerInstance } from "@pdftron/webviewer";
-import { Loader2 } from "lucide-react";
+import { Loader2, FileText, ClipboardList, AlertTriangle, FileSignature, Target, CheckCircle2 } from "lucide-react";
import { toast } from "sonner";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogDescription,
- DialogFooter,
+Dialog,
+DialogContent,
+DialogHeader,
+DialogTitle,
+DialogDescription,
+DialogFooter,
} from "@/components/ui/dialog";
-import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { Upload } from "lucide-react";
+
+
+
+interface FileInfo {
+path: string;
+name: string;
+type: 'main' | 'attachment' | 'survey';
+}
interface BasicContractSignViewerProps {
- contractId?: number;
- filePath?: string;
- isOpen?: boolean;
- onClose?: () => void;
- onSign?: (documentData: ArrayBuffer) => Promise<void>;
- instance: WebViewerInstance | null;
- setInstance: Dispatch<SetStateAction<WebViewerInstance | null>>;
+contractId?: number;
+filePath?: string;
+additionalFiles?: FileInfo[];
+templateName?: string;
+isOpen?: boolean;
+onClose?: () => void;
+onSign?: (documentData: ArrayBuffer, surveyData?: any) => Promise<void>;
+instance: WebViewerInstance | null;
+setInstance: Dispatch<SetStateAction<WebViewerInstance | null>>;
+t?: (key: string) => string;
}
-export function BasicContractSignViewer({
- contractId,
- filePath,
- isOpen = false,
- onClose,
- onSign,
- instance,
- setInstance,
-}: BasicContractSignViewerProps) {
- const [fileLoading, setFileLoading] = useState<boolean>(true);
- const viewer = useRef<HTMLDivElement>(null);
- const initialized = useRef(false);
- const isCancelled = useRef(false);
- const [showDialog, setShowDialog] = useState(isOpen);
+// ✅ 자동 서명 필드 생성을 위한 타입 정의
+interface SignaturePattern {
+ regex: RegExp;
+ name: string;
+ priority: number;
+ offsetX?: number;
+ offsetY?: number;
+ width?: number;
+ height?: number;
+}
- // 다이얼로그 상태 동기화
- useEffect(() => {
- setShowDialog(isOpen);
- }, [isOpen]);
+interface DetectedSignatureLocation {
+ pageIndex: number;
+ text: string;
+ rect: {
+ x1: number;
+ y1: number;
+ x2: number;
+ y2: number;
+ };
+ pattern: SignaturePattern;
+ confidence: number;
+}
- // WebViewer 초기화
- useEffect(() => {
- if (!initialized.current && viewer.current) {
- initialized.current = true;
- isCancelled.current = false;
-
- requestAnimationFrame(() => {
- if (viewer.current) {
- import("@pdftron/webviewer").then(({ default: WebViewer }) => {
- if (isCancelled.current) {
- console.log("📛 WebViewer 초기화 취소됨");
- return;
- }
+// ✅ 개선된 자동 서명 필드 감지 클래스
- // viewerElement이 확실히 존재함을 확인
- const viewerElement = viewer.current;
- if (!viewerElement) return;
-
- WebViewer(
- {
- path: "/pdftronWeb",
- licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
- fullAPI: true,
- },
- viewerElement
- ).then((instance: WebViewerInstance) => {
- setInstance(instance);
- setFileLoading(false);
-
- const { disableElements, setToolbarGroup } = instance.UI;
-
- disableElements([
- "toolbarGroup-Annotate",
- "toolbarGroup-Shapes",
- "toolbarGroup-Insert",
- "toolbarGroup-Edit",
- // "toolbarGroup-FillAndSign",
- "toolbarGroup-Forms",
- ]);
- setToolbarGroup("toolbarGroup-View");
- });
- });
+// ✅ 초간단 안전한 서명 필드 감지 클래스 (새로고침 제거)
+class AutoSignatureFieldDetector {
+ private instance: WebViewerInstance;
+ private signaturePatterns: SignaturePattern[];
+
+ constructor(instance: WebViewerInstance) {
+ this.instance = instance;
+ this.signaturePatterns = this.initializePatterns();
+ }
+
+ private initializePatterns(): SignaturePattern[] {
+ return [
+ // 한국어 패턴들
+ {
+ regex: /서명\s*[::]\s*[_\-\s]{3,}/gi,
+ name: "한국어_서명_콜론",
+ priority: 10,
+ offsetX: 80,
+ offsetY: -5,
+ width: 150,
+ height: 40
+ },
+ {
+ regex: /서명란\s*[_\-\s]{0,}/gi,
+ name: "한국어_서명란",
+ priority: 9,
+ offsetX: 60,
+ offsetY: -5,
+ width: 150,
+ height: 40
+ },
+ // 영어 패턴들
+ {
+ regex: /signature\s*[::]\s*[_\-\s]{3,}/gi,
+ name: "영어_signature_콜론",
+ priority: 8,
+ offsetX: 120,
+ offsetY: -5,
+ width: 150,
+ height: 40
+ },
+ {
+ regex: /sign\s+here\s*[::]?\s*[_\-\s]{0,}/gi,
+ name: "영어_sign_here",
+ priority: 9,
+ offsetX: 100,
+ offsetY: -5,
+ width: 150,
+ height: 40
+ }
+ ];
+ }
+
+ async detectAndCreateSignatureFields(): Promise<string[]> {
+ console.log("🔍 안전한 서명 필드 감지 시작...");
+
+ try {
+ // ✅ 1단계: 기본 유효성 검사만
+ if (!this.instance?.Core?.documentViewer) {
+ throw new Error("WebViewer 인스턴스가 유효하지 않습니다.");
+ }
+
+ const { Core } = this.instance;
+ const { documentViewer } = Core;
+
+ // ✅ 2단계: 문서 존재 확인만 (getPDFDoc 호출 안함)
+ const document = documentViewer.getDocument();
+ if (!document) {
+ throw new Error("PDF 문서가 로드되지 않았습니다.");
+ }
+
+ console.log("📄 문서 확인 완료, 기존 필드 검사...");
+
+ // ✅ 3단계: 기존 서명 필드 확인 (안전한 방법)
+ const existingFields = await this.checkExistingFieldsSafely();
+ if (existingFields.length > 0) {
+ console.log(`✅ 기존 서명 필드 발견: ${existingFields.length}개`);
+ return existingFields;
+ }
+
+ // ✅ 4단계: 단순 기본 서명 필드 생성 (텍스트 분석 스킵)
+ console.log("📝 기본 서명 필드 생성...");
+ const defaultField = await this.createSimpleSignatureField();
+
+ // ✅ 5단계: 새로고침 없이 완료
+ console.log("✅ 서명 필드 생성 완료 (새로고침 스킵)");
+ return [defaultField];
+
+ } catch (error) {
+ console.error("📛 안전한 서명 필드 생성 실패:", error);
+
+ // 에러 타입별 메시지
+ let errorMessage = "서명 필드 생성에 실패했습니다.";
+ if (error instanceof Error) {
+ if (error.message.includes("인스턴스")) {
+ errorMessage = "뷰어가 준비되지 않았습니다.";
+ } else if (error.message.includes("문서")) {
+ errorMessage = "문서를 불러오는 중입니다.";
}
+ }
+
+ throw new Error(errorMessage);
+ }
+ }
+
+ // ✅ 안전한 기존 필드 확인 (PDFDoc 접근 안함)
+ private async checkExistingFieldsSafely(): Promise<string[]> {
+ try {
+ const { annotationManager } = this.instance.Core;
+ const annotations = annotationManager.getAnnotationsList();
+
+ const signatureFields: string[] = [];
+
+ for (const annotation of annotations) {
+ try {
+ if (annotation.getCustomData && annotation.getCustomData('fieldName')) {
+ const fieldName = annotation.getCustomData('fieldName');
+ if (fieldName.includes('signature') || fieldName.includes('서명')) {
+ signatureFields.push(fieldName);
+ }
+ }
+ } catch (annotError) {
+ // 개별 어노테이션 에러 무시
+ continue;
+ }
+ }
+
+ return signatureFields;
+ } catch (error) {
+ console.warn("기존 필드 확인 실패 (무시):", error);
+ return [];
+ }
+ }
+
+ // ✅ 초간단 서명 필드 생성 (복잡한 텍스트 분석 없이)
+ private async createSimpleSignatureField(): Promise<string> {
+ try {
+ const { Core, UI } = this.instance;
+ const { documentViewer, annotationManager, Annotations } = Core;
+
+ // 페이지 정보 안전하게 가져오기
+ const pageCount = documentViewer.getPageCount();
+ const lastPageIndex = Math.max(0, pageCount - 1);
+
+ // 페이지 크기 안전하게 가져오기
+ const pageWidth = documentViewer.getPageWidth(pageCount) || 612;
+ const pageHeight = documentViewer.getPageHeight(pageCount) || 792;
+
+ console.log(`📏 페이지 정보: ${pageCount}페이지, 크기 ${pageWidth}x${pageHeight}`);
+
+ // ✅ 간단한 서명 어노테이션 생성 (PDFDoc 접근 없이)
+ const fieldName = `simple_signature_${Date.now()}`;
+
+ // 서명 위젯 어노테이션 생성
+ const signatureWidget = new Annotations.SignatureWidgetAnnotation({
+ appearance: Annotations.SignatureWidgetAnnotation.DefaultAppearance.MATERIAL_OUTLINE,
+ Width: 150,
+ Height: 50
});
+
+ // 위치 설정 (마지막 페이지 하단)
+ signatureWidget.setPageNumber(pageCount);
+ signatureWidget.setX(pageWidth * 0.3);
+ signatureWidget.setY(pageHeight * 0.15);
+ signatureWidget.setWidth(150);
+ signatureWidget.setHeight(50);
+
+ // 필드명 설정
+ signatureWidget.setFieldName(fieldName);
+ signatureWidget.setCustomData('fieldName', fieldName);
+
+ // 스타일 설정
+ signatureWidget.StrokeColor = new Annotations.Color(0, 100, 200); // 파란색
+ signatureWidget.StrokeThickness = 2;
+
+ // 어노테이션 추가
+ annotationManager.addAnnotation(signatureWidget);
+ annotationManager.redrawAnnotation(signatureWidget);
+
+ console.log(`✅ 간단 서명 필드 생성: ${fieldName}`);
+ return fieldName;
+
+ } catch (error) {
+ console.error("📛 간단 서명 필드 생성 실패:", error);
+
+ // ✅ 최후의 수단: 텍스트 어노테이션으로 안내
+ return await this.createTextGuidance();
+ }
+ }
+
+ // ✅ 최후의 수단: 텍스트 안내 생성
+ private async createTextGuidance(): Promise<string> {
+ try {
+ const { Core } = this.instance;
+ const { documentViewer, annotationManager, Annotations } = Core;
+
+ const pageCount = documentViewer.getPageCount();
+ const pageWidth = documentViewer.getPageWidth(pageCount) || 612;
+ const pageHeight = documentViewer.getPageHeight(pageCount) || 792;
+
+ // 텍스트 어노테이션으로 서명 안내
+ const textAnnot = new Annotations.FreeTextAnnotation();
+ textAnnot.setPageNumber(pageCount);
+ textAnnot.setX(pageWidth * 0.25);
+ textAnnot.setY(pageHeight * 0.1);
+ textAnnot.setWidth(pageWidth * 0.5);
+ textAnnot.setHeight(60);
+ textAnnot.setContents("👆 여기를 클릭하여 서명해주세요");
+ textAnnot.FontSize = '14pt';
+ textAnnot.TextColor = new Annotations.Color(255, 0, 0); // 빨간색
+ textAnnot.StrokeColor = new Annotations.Color(255, 200, 200);
+ textAnnot.FillColor = new Annotations.Color(255, 240, 240);
+
+ const fieldName = `text_guidance_${Date.now()}`;
+ textAnnot.setCustomData('fieldName', fieldName);
+
+ annotationManager.addAnnotation(textAnnot);
+ annotationManager.redrawAnnotation(textAnnot);
+
+ console.log(`✅ 텍스트 안내 생성: ${fieldName}`);
+ return fieldName;
+
+ } catch (error) {
+ console.error("📛 텍스트 안내 생성도 실패:", error);
+ return "manual_signature_required";
}
+ }
+}
+
+function useAutoSignatureFields(instance: WebViewerInstance | null) {
+ const [signatureFields, setSignatureFields] = useState<string[]>([]);
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+
+ // 중복 실행 방지
+ const processingRef = useRef(false);
+ const timeoutRef = useRef<NodeJS.Timeout | null>(null);
+
+ useEffect(() => {
+ if (!instance) return;
+
+ const { documentViewer } = instance.Core;
+
+ const handleDocumentLoaded = () => {
+ // ✅ 중복 실행 방지
+ if (processingRef.current) {
+ console.log("📛 이미 처리 중이므로 스킵");
+ return;
+ }
+
+ // ✅ 기존 타이머 정리
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ timeoutRef.current = null;
+ }
+
+ // ✅ 짧은 지연 후 실행 (3초)
+ timeoutRef.current = setTimeout(async () => {
+ if (processingRef.current) return;
+
+ processingRef.current = true;
+ setIsProcessing(true);
+ setError(null);
+
+ try {
+ console.log("📄 문서 로드 완료, 안전한 서명 필드 처리 시작...");
+
+ // ✅ 최종 유효성 검사
+ if (!instance?.Core?.documentViewer?.getDocument()) {
+ throw new Error("문서가 준비되지 않았습니다.");
+ }
+
+ const detector = new AutoSignatureFieldDetector(instance);
+ const fields = await detector.detectAndCreateSignatureFields();
+
+ setSignatureFields(fields);
+
+ // ✅ 결과에 따른 토스트 메시지
+ if (fields.length > 0) {
+ const hasSimpleField = fields.some(field => field.startsWith('simple_signature_'));
+ const hasTextGuidance = fields.some(field => field.startsWith('text_guidance_'));
+ const hasManualRequired = fields.includes('manual_signature_required');
+
+ if (hasSimpleField) {
+ toast.success("📝 서명 필드가 생성되었습니다.", {
+ description: "마지막 페이지 하단의 파란색 영역에서 서명해주세요.",
+ icon: <FileSignature className="h-4 w-4 text-blue-500" />,
+ duration: 5000
+ });
+ } else if (hasTextGuidance) {
+ toast.success("📍 서명 안내가 표시되었습니다.", {
+ description: "빨간색 텍스트 영역을 클릭하여 서명해주세요.",
+ icon: <Target className="h-4 w-4 text-red-500" />,
+ duration: 6000
+ });
+ } else if (hasManualRequired) {
+ toast.info("수동 서명이 필요합니다.", {
+ description: "문서에서 서명할 위치를 직접 클릭해주세요.",
+ icon: <AlertTriangle className="h-4 w-4 text-amber-500" />,
+ duration: 5000
+ });
+ } else {
+ toast.success(`📋 ${fields.length}개의 서명 필드를 확인했습니다.`, {
+ description: "기존 서명 필드가 발견되었습니다.",
+ icon: <CheckCircle2 className="h-4 w-4 text-green-500" />,
+ duration: 4000
+ });
+ }
+ } else {
+ toast.info("서명 필드 준비 중", {
+ description: "문서에서 서명할 위치를 클릭해주세요.",
+ icon: <FileSignature className="h-4 w-4 text-blue-500" />,
+ duration: 4000
+ });
+ }
+
+ } catch (error) {
+ console.error("📛 안전한 서명 필드 처리 실패:", error);
+
+ const errorMessage = error instanceof Error ? error.message : "서명 필드 처리에 실패했습니다.";
+ setError(errorMessage);
+
+ // ✅ 부드러운 에러 처리
+ if (errorMessage.includes("준비")) {
+ toast.info("문서 로딩 중", {
+ description: "잠시 후 다시 시도하거나 수동으로 서명해주세요.",
+ icon: <Loader2 className="h-4 w-4 text-blue-500" />
+ });
+ } else {
+ toast.info("수동 서명 모드", {
+ description: "문서에서 서명할 위치를 직접 클릭해주세요.",
+ icon: <FileSignature className="h-4 w-4 text-blue-500" />
+ });
+ }
+ } finally {
+ setIsProcessing(false);
+ processingRef.current = false;
+ }
+ }, 3000); // 3초 지연
+ };
+
+ // ✅ 이벤트 리스너 등록
+ documentViewer.removeEventListener('documentLoaded', handleDocumentLoaded);
+ documentViewer.addEventListener('documentLoaded', handleDocumentLoaded);
return () => {
- if (instance) {
- instance.UI.dispose();
+ documentViewer.removeEventListener('documentLoaded', handleDocumentLoaded);
+
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ timeoutRef.current = null;
}
- isCancelled.current = true;
- setTimeout(() => cleanupHtmlStyle(), 500);
+
+ processingRef.current = false;
};
- }, []);
+ }, [instance]);
- // 문서 로드
+ // ✅ 컴포넌트 언마운트 시 정리
useEffect(() => {
- if (!instance || !filePath) return;
- console.log("📄 파일 로드 시도:", { filePath });
-
+ return () => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+ processingRef.current = false;
+ };
+ }, []);
+
+ return {
+ signatureFields,
+ isProcessing,
+ hasSignatureFields: signatureFields.length > 0,
+ error
+ };
+}
+
+export function BasicContractSignViewer({
+contractId,
+filePath,
+additionalFiles = [],
+templateName = "",
+isOpen = false,
+onClose,
+onSign,
+instance,
+setInstance,
+t = (key: string) => key,
+}: BasicContractSignViewerProps) {
+
+ console.log("🔍 BasicContractSignViewer props:", {
+ contractId,
+ filePath,
+ additionalFiles,
+ templateName,
+ isNDATemplate: templateName.includes('비밀유지') || templateName.includes('NDA')
+ });
+
+const [fileLoading, setFileLoading] = useState<boolean>(true);
+const [activeTab, setActiveTab] = useState<string>("main");
+const [surveyData, setSurveyData] = useState<any>({});
+const [surveyAnswers, setSurveyAnswers] = useState<Record<number, any>>({});
+const [surveyTemplate, setSurveyTemplate] = useState<any>(null);
+const [surveyLoading, setSurveyLoading] = useState<boolean>(false);
+const [uploadedFiles, setUploadedFiles] = useState<Record<number, File[]>>({});
+const [isInitialLoaded, setIsInitialLoaded] = useState<boolean>(false);
+
+const viewer = useRef<HTMLDivElement>(null);
+const initialized = useRef(false);
+const isCancelled = useRef(false);
+const currentDocumentPath = useRef<string>("");
+const [showDialog, setShowDialog] = useState(isOpen);
+const webViewerInstance = useRef<WebViewerInstance | null>(null);
+
+// ✅ 자동 서명 필드 생성 훅 사용
+const { signatureFields, isProcessing: isAutoSignProcessing, hasSignatureFields, error: autoSignError } = useAutoSignatureFields(webViewerInstance.current || instance);
+
+// 템플릿 타입 판단
+const isComplianceTemplate = templateName.includes('준법');
+const isNDATemplate = templateName.includes('비밀유지') || templateName.includes('NDA');
+
+// 파일 목록 생성
+const allFiles: FileInfo[] = React.useMemo(() => {
+ const files: FileInfo[] = [];
+
+ if (filePath) {
+ files.push({
+ path: filePath,
+ name: templateName || "기본 계약서",
+ type: "main",
+ });
+ }
+
+ const normalizedAttachments: FileInfo[] = (additionalFiles || [])
+ .map((f: any, idx: number) => ({
+ path: f.path ?? f.filePath ?? "",
+ name: `첨부파일 ${idx + 1}`,
+ type: "attachment" as const,
+ }))
+ .filter(f => !!f.path);
+
+ files.push(...normalizedAttachments);
+
+ if (isComplianceTemplate) {
+ files.push({
+ path: "",
+ name: "준법 설문조사",
+ type: "survey",
+ });
+ }
+
+ console.log("📂 생성된 allFiles:", files, { isNDATemplate, isComplianceTemplate });
+ return files;
+}, [filePath, additionalFiles, templateName, isComplianceTemplate, isNDATemplate]);
+
+// WebViewer 정리 함수
+const cleanupWebViewer = () => {
+ console.log("🧹 WebViewer 정리 시작");
+
+ if (webViewerInstance.current) {
+ try {
+ const { documentViewer } = webViewerInstance.current.Core;
+ if (documentViewer && documentViewer.getDocument()) {
+ documentViewer.closeDocument();
+ }
+
+ if (webViewerInstance.current.UI && typeof webViewerInstance.current.UI.dispose === 'function') {
+ webViewerInstance.current.UI.dispose();
+ }
+ } catch (error) {
+ console.warn("WebViewer 정리 중 에러 (무시됨):", error);
+ }
- // filePath를 /api/files/ 엔드포인트를 통해 접근하도록 변환
- // 한글 파일명의 경우 URL 인코딩 처리
+ webViewerInstance.current = null;
+ }
+
+ if (instance && setInstance) {
+ setInstance(null);
+ }
+
+ setTimeout(() => cleanupHtmlStyle(), 100);
+};
+
+// 다이얼로그 및 파일 상태 변경 시 리셋
+useEffect(() => {
+ setShowDialog(isOpen);
+
+ if (isOpen && isComplianceTemplate && !surveyTemplate) {
+ loadSurveyTemplate();
+ }
+
+ if (isOpen) {
+ setIsInitialLoaded(false);
+ currentDocumentPath.current = "";
+ console.log("🔄 새로운 계약서 열림, 상태 리셋");
+ }
+}, [isOpen, isComplianceTemplate]);
+
+// filePath 변경 시 상태 리셋 및 즉시 문서 로드
+useEffect(() => {
+ if (!filePath) return;
+
+ console.log("🔄 filePath 변경으로 상태 리셋 및 문서 로드:", filePath);
+
+ setIsInitialLoaded(false);
+ currentDocumentPath.current = "";
+ setActiveTab("main");
+
+ const currentInstance = webViewerInstance.current || instance;
+
+ if (currentInstance) {
const normalizedPath = filePath.startsWith('/') ? filePath.substring(1) : filePath;
const encodedPath = normalizedPath.split('/').map(part => encodeURIComponent(part)).join('/');
const apiFilePath = `/api/files/${encodedPath}`;
- console.log("📄 파일 로드 시도:", { originalPath: filePath, encodedPath: apiFilePath });
- loadDocument(instance, apiFilePath);
- }, [instance, filePath]);
+ console.log("📄 filePath 변경으로 즉시 문서 로드:", apiFilePath);
+
+ loadDocument(currentInstance, apiFilePath, true).then(() => {
+ setIsInitialLoaded(true);
+ console.log("✅ filePath 변경 문서 로드 완료");
+ }).catch((error) => {
+ console.error("📛 filePath 변경 문서 로드 실패:", error);
+ });
+ }
+}, [filePath, instance]);
- // 간소화된 문서 로드 함수
- const loadDocument = async (instance: WebViewerInstance, documentPath: string) => {
- setFileLoading(true);
- try {
- const { documentViewer } = instance.Core;
+const loadSurveyTemplate = async () => {
+ setSurveyLoading(true);
+
+ const mockTemplate = {
+ id: 1,
+ name: '기본 준법 설문조사',
+ description: '모든 계약업체 대상 기본 준법 설문조사',
+ questions: [
+ {
+ id: 4,
+ questionNumber: '4',
+ questionText: '귀사의 법률적 조직형태는?',
+ questionType: 'DROPDOWN',
+ isRequired: true,
+ hasDetailText: false,
+ hasFileUpload: false,
+ options: [
+ { id: 1, optionValue: 'COMPANY_CORP', optionText: '주식회사/유한회사' },
+ { id: 2, optionValue: 'INDIVIDUAL', optionText: '개인회사' },
+ { id: 3, optionValue: 'PARTNERSHIP', optionText: '조합' },
+ { id: 4, optionValue: 'JOINT_VENTURE', optionText: '조인트벤처' },
+ { id: 5, optionValue: 'OTHER', optionText: '기타', allowsOtherInput: true },
+ ]
+ },
+ {
+ id: 6,
+ questionNumber: '6',
+ questionText: '부패방지와 관련한 귀사의 준법정책이 있습니까? 있다면 첨부파일로 제공하여 주시기 바랍니다.',
+ questionType: 'RADIO',
+ isRequired: true,
+ hasDetailText: false,
+ hasFileUpload: true,
+ options: [
+ { id: 6, optionValue: 'YES', optionText: '네' },
+ { id: 7, optionValue: 'NO', optionText: '아니오' },
+ ]
+ },
+ {
+ id: 11,
+ questionNumber: '11',
+ questionText: '귀사의 사주, 임원 중에서 전(최근 3년내)·현직 공직자인 사람이 있습니까? 만약 있다면 상세하게 기술해 주십시오.',
+ questionType: 'RADIO',
+ isRequired: true,
+ hasDetailText: true,
+ hasFileUpload: false,
+ options: [
+ { id: 11, optionValue: 'YES', optionText: '네' },
+ { id: 12, optionValue: 'NO', optionText: '아니오' },
+ ]
+ },
+ ]
+ };
+
+ setSurveyTemplate(mockTemplate);
+ setSurveyLoading(false);
+};
+
+// WebViewer 초기화 개선
+useEffect(() => {
+ if (!initialized.current && viewer.current) {
+ initialized.current = true;
+ isCancelled.current = false;
+
+ const initializeWebViewer = () => {
+ if (!viewer.current || isCancelled.current) {
+ console.log("📛 WebViewer 초기화 취소됨 (DOM 없음)");
+ return;
+ }
+
+ const viewerElement = viewer.current;
- await documentViewer.loadDocument(documentPath, { extension: 'pdf' });
+ if (!viewerElement.isConnected) {
+ console.log("📛 WebViewer DOM이 연결되지 않음, 재시도...");
+ setTimeout(initializeWebViewer, 100);
+ return;
+ }
+
+ cleanupWebViewer();
+
+ console.log("📄 WebViewer 초기화 시작...");
- } catch (err) {
- console.error("문서 로딩 중 오류 발생:", err);
- toast.error("문서를 불러오는데 실패했습니다.");
- } finally {
- setFileLoading(false);
- }
+ import("@pdftron/webviewer").then(({ default: WebViewer }) => {
+ if (isCancelled.current || !viewer.current) {
+ console.log("📛 WebViewer 초기화 취소됨 (import 후)");
+ return;
+ }
+
+ WebViewer(
+ {
+ path: "/pdftronWeb",
+ licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
+ fullAPI: true
+ },
+ viewerElement
+ ).then((newInstance) => {
+ if (isCancelled.current) {
+ console.log("📛 WebViewer 인스턴스 생성 후 취소됨");
+ return;
+ }
+
+ console.log("📄 WebViewer 초기화 완료");
+
+ webViewerInstance.current = newInstance;
+ setInstance(newInstance);
+ setFileLoading(false);
+
+ const { documentViewer } = newInstance.Core;
+ const FitMode = newInstance.UI.FitMode;
+
+ // 문서 로드 완료 시 처리
+ const handleDocumentLoaded = () => {
+ setFileLoading(false);
+ newInstance.UI.setFitMode(FitMode.FitWidth);
+
+ requestAnimationFrame(() => {
+ try {
+ documentViewer.refreshAll();
+ documentViewer.updateView();
+ window.dispatchEvent(new Event("resize"));
+ setTimeout(() => window.dispatchEvent(new Event("resize")), 100);
+ } catch (e) {
+ console.warn("layout refresh skipped", e);
+ }
+ });
+ };
+
+ documentViewer.addEventListener('documentLoaded', handleDocumentLoaded);
+
+ documentViewer.addEventListener('layoutChanged', () => {
+ if (newInstance.UI.getFitMode && newInstance.UI.getFitMode() !== FitMode.Zoom) {
+ newInstance.UI.setFitMode(FitMode.Zoom);
+ }
+ });
+
+ newInstance.UI.setMinZoomLevel('25%');
+ newInstance.UI.setMaxZoomLevel('400%');
+
+ documentViewer.addEventListener('documentLoadingError', (error) => {
+ console.error("📛 WebViewer 문서 로딩 에러:", error);
+
+ let showToast = true;
+ let errorMessage = "문서를 불러오는데 실패했습니다.";
+
+ if (error && typeof error === 'object') {
+ const errorStr = JSON.stringify(error).toLowerCase();
+
+ if (errorStr.includes('linearized') || errorStr.includes('getreference')) {
+ console.warn("⚠️ PDF 구조 경고 (문서 로드는 진행됨)");
+ showToast = false;
+ } else if (errorStr.includes('network')) {
+ errorMessage = "네트워크 연결을 확인해주세요.";
+ } else if (errorStr.includes('permission')) {
+ errorMessage = "문서에 접근할 권한이 없습니다.";
+ }
+ }
+
+ if (showToast) {
+ setFileLoading(false);
+ toast.error(errorMessage);
+ }
+ });
+
+ }).catch((error) => {
+ console.error("📛 WebViewer 초기화 실패:", error);
+ setFileLoading(false);
+ toast.error("뷰어 초기화에 실패했습니다.");
+ });
+ }).catch((error) => {
+ console.error("📛 WebViewer 모듈 로드 실패:", error);
+ setFileLoading(false);
+ toast.error("뷰어 모듈을 불러오는데 실패했습니다.");
+ });
+ };
+
+ requestAnimationFrame(() => {
+ setTimeout(initializeWebViewer, 50);
+ });
+ }
+
+ return () => {
+ isCancelled.current = true;
+ cleanupWebViewer();
};
+}, [setInstance]);
- // 서명 저장 핸들러
- const handleSave = async () => {
- if (!instance) return;
+// 확장자 추출 유틸
+const getExtFromPath = (p: string) => {
+ const m = p.toLowerCase().match(/\.([a-z0-9]+)(?:\?.*)?$/);
+ return m ? m[1] : undefined;
+};
+
+// 문서 로드 함수 개선
+const loadDocument = async (
+ instance: WebViewerInstance,
+ documentPath: string,
+ forceReload = false
+) => {
+ if (!forceReload && currentDocumentPath.current === documentPath) {
+ console.log("📄 동일한 문서이므로 스킵:", documentPath);
+ return;
+ }
+
+ setFileLoading(true);
+ try {
+ console.log("📄 문서 로드 시작(UI):", documentPath, forceReload ? "(강제 리로드)" : "");
+
+ if (!instance || !instance.UI || !instance.Core) {
+ throw new Error("WebViewer 인스턴스가 유효하지 않습니다.");
+ }
+
+ const ext = getExtFromPath(documentPath);
+ await instance.UI.loadDocument(documentPath, {
+ ...(ext ? { extension: ext } : {}),
+ filename: documentPath.split("/").pop(),
+ });
+
+ currentDocumentPath.current = documentPath;
+ console.log("📄 문서 로드 완료(UI):", documentPath);
+
+ const { documentViewer } = instance.Core;
+ requestAnimationFrame(() => {
+ try {
+ documentViewer.refreshAll();
+ documentViewer.updateView();
+ window.dispatchEvent(new Event("resize"));
+ setTimeout(() => window.dispatchEvent(new Event("resize")), 100);
+ } catch (e) {
+ console.warn("레이아웃 새로고침 스킵:", e);
+ }
+ });
+ } catch (error) {
+ console.error("📛 문서 로딩 실패(UI):", error);
+ currentDocumentPath.current = "";
- try {
- const { documentViewer } = instance.Core;
- const doc = documentViewer.getDocument();
-
- // 서명된 문서 데이터 가져오기
- const documentData = await doc.getFileData({
- includeAnnotations: true,
- });
-
- // 외부에서 제공된 onSign 핸들러가 있으면 호출
- if (onSign) {
- await onSign(documentData);
- } else {
- // 기본 동작 - 서명 성공 메시지 표시
- toast.success("계약서가 성공적으로 서명되었습니다.");
+ let msg = "문서를 불러오는데 실패했습니다.";
+ if (error instanceof Error) {
+ const s = error.message.toLowerCase();
+ if (s.includes("network") || s.includes("fetch")) {
+ msg = "네트워크 연결을 확인해주세요.";
+ } else if (s.includes("permission") || s.includes("access")) {
+ msg = "문서에 접근할 권한이 없습니다.";
+ } else if (s.includes("corrupt") || s.includes("invalid")) {
+ msg = "파일이 손상되었거나 형식이 올바르지 않습니다.";
+ } else if (s.includes("linearized") || s.includes("getreference")) {
+ msg = "";
}
-
- handleClose();
- } catch (err) {
- console.error("서명 저장 중 오류 발생:", err);
- toast.error("서명을 저장하는데 실패했습니다.");
}
- };
+ if (msg) toast.error(msg);
+ } finally {
+ setFileLoading(false);
+ }
+};
- // 다이얼로그 닫기 핸들러
- const handleClose = () => {
- if (onClose) {
- onClose();
- } else {
- setShowDialog(false);
+// 폼 데이터 수집 함수
+const collectFormData = async (instance: WebViewerInstance) => {
+ try {
+ const { documentViewer, annotationManager } = instance.Core;
+ const fieldManager = annotationManager.getFieldManager();
+ const fields = fieldManager.getFields();
+
+ const formData: any = {};
+ fields.forEach((field: any) => {
+ formData[field.name] = field.value;
+ });
+
+ console.log('📝 폼 데이터 수집:', formData);
+ return formData;
+ } catch (error) {
+ console.error('📛 폼 데이터 수집 실패:', error);
+ return {};
+ }
+};
+
+// 탭 변경 핸들러
+const handleTabChange = async (newTab: string) => {
+ setActiveTab(newTab);
+ if (newTab === "survey") return;
+
+ const currentInstance = webViewerInstance.current || instance;
+ if (!currentInstance || fileLoading) return;
+
+ let targetFile: FileInfo | undefined;
+ if (newTab === "main") {
+ targetFile = allFiles.find(f => f.type === "main");
+ } else if (newTab.startsWith("file-")) {
+ const fileIndex = parseInt(newTab.replace("file-", ""), 10);
+ targetFile = allFiles.filter(f => f.type !== 'survey')[fileIndex];
+ }
+
+ if (!targetFile?.path) {
+ console.warn("📛 대상 파일을 찾을 수 없음:", newTab, allFiles);
+ return;
+ }
+
+ const normalizedPath = targetFile.path.startsWith("/")
+ ? targetFile.path.substring(1)
+ : targetFile.path;
+ const encodedPath = normalizedPath.split("/").map(encodeURIComponent).join("/");
+ const apiFilePath = `/api/files/${encodedPath}`;
+
+ console.log("📄 탭 변경으로 문서 로드:", { newTab, targetFile, apiFilePath });
+
+ try {
+ currentDocumentPath.current = "";
+ await loadDocument(currentInstance, apiFilePath, true);
+ setIsInitialLoaded(true);
+
+ const { documentViewer } = currentInstance.Core;
+ requestAnimationFrame(() => {
+ try {
+ documentViewer.refreshAll();
+ documentViewer.updateView();
+ window.dispatchEvent(new Event("resize"));
+ } catch (e) {
+ console.warn("탭 변경 후 레이아웃 새로고침 스킵:", e);
+ }
+ });
+ } catch (e) {
+ console.error("📛 탭 변경 실패:", e);
+ }
+};
+
+// 초기 메인 문서 로드 개선
+useEffect(() => {
+ console.log("🔍 초기 로드 체크:", {
+ hasInstance: !!(webViewerInstance.current || instance),
+ hasFilePath: !!filePath,
+ activeTab,
+ isInitialLoaded,
+ allFilesLength: allFiles.length,
+ isNDATemplate
+ });
+
+ const currentInstance = webViewerInstance.current || instance;
+
+ if (!currentInstance || !filePath || isInitialLoaded) {
+ return;
+ }
+
+ const isMainTab = activeTab === 'main';
+ const shouldLoadInitial = allFiles.length === 1 || isMainTab;
+
+ if (!shouldLoadInitial || currentDocumentPath.current !== "") {
+ return;
+ }
+
+ const normalizedPath = filePath.startsWith('/') ? filePath.substring(1) : filePath;
+ const encodedPath = normalizedPath.split('/').map(part => encodeURIComponent(part)).join('/');
+ const apiFilePath = `/api/files/${encodedPath}`;
+
+ console.log("📄 초기 마운트 문서 로드:", { apiFilePath, isNDATemplate, activeTab });
+
+ currentDocumentPath.current = "";
+
+ loadDocument(currentInstance, apiFilePath, true).then(() => {
+ setIsInitialLoaded(true);
+ console.log("✅ 초기 마운트 로드 완료");
+ }).catch((error) => {
+ console.error("📛 초기 마운트 로드 실패:", error);
+ });
+}, [webViewerInstance.current, instance, filePath, activeTab, isInitialLoaded, allFiles.length, isNDATemplate]);
+
+// 설문조사 답변 업데이트 함수
+const updateSurveyAnswer = (questionId: number, field: string, value: any) => {
+ setSurveyAnswers(prev => ({
+ ...prev,
+ [questionId]: {
+ ...prev[questionId],
+ questionId,
+ [field]: value
}
- };
+ }));
+};
+
+// 파일 업로드 핸들러
+const handleSurveyFileUpload = (questionId: number, files: FileList | null) => {
+ if (!files) return;
+
+ const fileArray = Array.from(files);
+ setUploadedFiles(prev => ({
+ ...prev,
+ [questionId]: fileArray
+ }));
+
+ updateSurveyAnswer(questionId, 'files', fileArray);
+};
+
+// 질문 완료 여부 체크
+const isSurveyQuestionComplete = (question: any): boolean => {
+ const answer = surveyAnswers[question.id];
- // 인라인 뷰어 렌더링 (다이얼로그 모드가 아닐 때)
- if (!isOpen && !onClose) {
+ if (!question.isRequired) return true;
+ if (!answer?.answerValue) return false;
+
+ if (question.hasDetailText && answer.answerValue === 'YES' && !answer.detailText) {
+ return false;
+ }
+
+ if (question.hasFileUpload && answer.answerValue === 'YES' && (!answer.files || answer.files.length === 0)) {
+ return false;
+ }
+
+ return true;
+};
+
+// 전체 설문조사 완료 여부 체크
+const isSurveyComplete = (): boolean => {
+ if (!surveyTemplate?.questions) return false;
+ return surveyTemplate.questions.every((question: any) => isSurveyQuestionComplete(question));
+};
+
+// 설문조사 데이터 처리
+const handleSurveyComplete = async () => {
+ if (!isSurveyComplete()) {
+ toast.error('모든 필수 항목을 완료해주세요.', {
+ description: '미완성된 질문이 있습니다.',
+ icon: <AlertTriangle className="h-5 w-5 text-red-500" />
+ });
+ return;
+ }
+
+ try {
+ console.log('설문조사 답변:', surveyAnswers);
+
+ setSurveyData({
+ completed: true,
+ answers: Object.values(surveyAnswers),
+ timestamp: new Date().toISOString()
+ });
+
+ toast.success("설문조사가 완료되었습니다!", {
+ icon: <CheckCircle2 className="h-5 w-5 text-green-500" />
+ });
+ } catch (error) {
+ console.error('설문조사 저장 실패:', error);
+ toast.error('설문조사 저장에 실패했습니다.');
+ }
+};
+
+// 서명 저장 핸들러
+const handleSave = async () => {
+ const currentInstance = webViewerInstance.current || instance;
+ if (!currentInstance) return;
+
+ try {
+ const { documentViewer, annotationManager } = currentInstance.Core;
+ const doc = documentViewer.getDocument();
+
+ if (!doc) {
+ toast.error("문서가 로드되지 않았습니다.");
+ return;
+ }
+
+ const formData = await collectFormData(currentInstance);
+
+ const xfdfString = await annotationManager.exportAnnotations();
+ const documentData = await doc.getFileData({
+ xfdfString,
+ downloadType: "pdf",
+ });
+
+ if (isComplianceTemplate && !surveyData.completed) {
+ toast.error("준법 설문조사를 먼저 완료해주세요.");
+ setActiveTab('survey');
+ return;
+ }
+
+ if (onSign) {
+ await onSign(documentData, { formData, surveyData, signatureFields });
+ } else {
+ toast.success("계약서가 성공적으로 서명되었습니다.");
+ }
+
+ handleClose();
+ } catch (error) {
+ console.error("📛 서명 저장 실패:", error);
+ toast.error("서명을 저장하는데 실패했습니다.");
+ }
+};
+
+// 다이얼로그 닫기 핸들러
+const handleClose = () => {
+ if (onClose) {
+ onClose();
+ } else {
+ setShowDialog(false);
+ }
+};
+
+// 동적 설문조사 컴포넌트
+const SurveyComponent = () => {
+ if (surveyLoading) {
return (
- <div className="border rounded-md overflow-hidden" style={{ height: '600px' }}>
- <div ref={viewer} className="h-[100%]">
- {fileLoading && (
- <div className="flex flex-col items-center justify-center py-12">
- <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" />
- <p className="text-sm text-muted-foreground">문서 로딩 중...</p>
+ <div className="h-full w-full">
+ <Card className="h-full">
+ <CardContent className="flex flex-col items-center justify-center h-full py-12">
+ <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" />
+ <p className="text-sm text-muted-foreground">설문조사를 불러오는 중...</p>
+ </CardContent>
+ </Card>
+ </div>
+ );
+ }
+
+ if (!surveyTemplate) {
+ return (
+ <div className="h-full w-full">
+ <Card className="h-full">
+ <CardContent className="flex flex-col items-center justify-center h-full py-12">
+ <AlertTriangle className="h-8 w-8 text-red-500 mb-4" />
+ <p className="text-sm text-muted-foreground">설문조사 템플릿을 불러올 수 없습니다.</p>
+ <Button
+ variant="outline"
+ onClick={loadSurveyTemplate}
+ className="mt-2"
+ >
+ 다시 시도
+ </Button>
+ </CardContent>
+ </Card>
+ </div>
+ );
+ }
+
+ const completedCount = surveyTemplate.questions.filter((q: any) => isSurveyQuestionComplete(q)).length;
+ const progressPercentage = surveyTemplate.questions.length > 0 ? (completedCount / surveyTemplate.questions.length) * 100 : 0;
+
+const renderSurveyQuestion = (question: any) => {
+ const answer = surveyAnswers[question.id];
+ const isComplete = isSurveyQuestionComplete(question);
+
+ return (
+ <div key={question.id} className="mb-6 p-4 border rounded-lg bg-gray-50">
+ <div className="flex items-start justify-between mb-3">
+ <div className="flex-1">
+ <Label className="text-sm font-medium text-gray-900 flex items-center">
+ <span className="mr-2 px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs">
+ {question.questionNumber}
+ </span>
+ {question.questionText}
+ {question.isRequired && <span className="text-red-500 ml-1">*</span>}
+ </Label>
+ </div>
+ {isComplete && (
+ <CheckCircle2 className="h-5 w-5 text-green-500 ml-2" />
+ )}
+ </div>
+
+ {question.questionType === 'RADIO' && (
+ <RadioGroup
+ value={answer?.answerValue || ''}
+ onValueChange={(value) => updateSurveyAnswer(question.id, 'answerValue', value)}
+ className="space-y-2"
+ >
+ {question.options?.map((option: any) => (
+ <div key={option.id} className="flex items-center space-x-2">
+ <RadioGroupItem value={option.optionValue} id={`${question.id}-${option.id}`} />
+ <Label htmlFor={`${question.id}-${option.id}`} className="text-sm">
+ {option.optionText}
+ </Label>
</div>
+ ))}
+ </RadioGroup>
+ )}
+
+ {question.questionType === 'DROPDOWN' && (
+ <div className="space-y-2">
+ <Select
+ value={answer?.answerValue || ''}
+ onValueChange={(value) => updateSurveyAnswer(question.id, 'answerValue', value)}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="선택해주세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {question.options?.map((option: any) => (
+ <SelectItem key={option.id} value={option.optionValue}>
+ {option.optionText}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+
+ {answer?.answerValue === 'OTHER' && (
+ <Input
+ placeholder="기타 내용을 입력해주세요"
+ value={answer?.otherText || ''}
+ onChange={(e) => updateSurveyAnswer(question.id, 'otherText', e.target.value)}
+ className="mt-2"
+ />
)}
</div>
- </div>
- );
+ )}
+
+ {question.questionType === 'TEXTAREA' && (
+ <Textarea
+ placeholder="상세한 내용을 입력해주세요"
+ value={answer?.detailText || ''}
+ onChange={(e) => updateSurveyAnswer(question.id, 'detailText', e.target.value)}
+ rows={4}
+ />
+ )}
+
+ {question.hasDetailText && answer?.answerValue === 'YES' && (
+ <div className="mt-3">
+ <Label className="text-sm text-gray-700 mb-2 block">상세 내용을 기술해주세요:</Label>
+ <Textarea
+ placeholder="상세한 내용을 입력해주세요"
+ value={answer?.detailText || ''}
+ onChange={(e) => updateSurveyAnswer(question.id, 'detailText', e.target.value)}
+ rows={3}
+ className="w-full"
+ />
+ </div>
+ )}
+
+ {question.hasFileUpload && answer?.answerValue === 'YES' && (
+ <div className="mt-3">
+ <Label className="text-sm text-gray-700 mb-2 block">첨부파일:</Label>
+ <div className="border-2 border-dashed border-gray-300 rounded-lg p-4">
+ <input
+ type="file"
+ multiple
+ onChange={(e) => handleSurveyFileUpload(question.id, e.target.files)}
+ className="hidden"
+ id={`file-${question.id}`}
+ />
+ <label htmlFor={`file-${question.id}`} className="cursor-pointer">
+ <div className="flex flex-col items-center">
+ <Upload className="h-8 w-8 text-gray-400 mb-2" />
+ <span className="text-sm text-gray-500">파일을 선택하거나 여기에 드래그하세요</span>
+ </div>
+ </label>
+
+ {uploadedFiles[question.id] && uploadedFiles[question.id].length > 0 && (
+ <div className="mt-3 space-y-1">
+ {uploadedFiles[question.id].map((file, index) => (
+ <div key={index} className="flex items-center space-x-2 text-sm">
+ <FileText className="h-4 w-4 text-blue-500" />
+ <span>{file.name}</span>
+ <span className="text-gray-500">({(file.size / 1024).toFixed(1)} KB)</span>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ </div>
+ )}
+ </div>
+ );
+};
+
+ return (
+ <div className="h-full w-full flex flex-col">
+ <Card className="h-full flex flex-col">
+ <CardHeader className="flex-shrink-0">
+ <CardTitle className="flex items-center justify-between">
+ <div className="flex items-center">
+ <ClipboardList className="h-5 w-5 mr-2 text-amber-500" />
+ {surveyTemplate.name}
+ </div>
+ <div className="text-sm text-gray-500">
+ {completedCount}/{surveyTemplate.questions.length} 완료
+ </div>
+ </CardTitle>
+ <CardDescription>
+ {surveyTemplate.description}
+ </CardDescription>
+
+ <div className="w-full bg-gray-200 rounded-full h-2">
+ <div
+ className="bg-blue-600 h-2 rounded-full transition-all duration-300"
+ style={{ width: `${progressPercentage}%` }}
+ />
+ </div>
+ </CardHeader>
+
+ <CardContent className="flex-1 min-h-0 overflow-y-auto">
+ <div className="space-y-6">
+ <div className="p-4 border rounded-lg bg-yellow-50">
+ <div className="flex items-start">
+ <AlertTriangle className="h-5 w-5 text-yellow-600 mt-0.5 mr-2" />
+ <div>
+ <p className="font-medium text-yellow-800">중요 안내</p>
+ <p className="text-sm text-yellow-700 mt-1">
+ 본 설문조사는 준법 의무 확인을 위한 필수 절차입니다. 모든 항목을 정확히 작성해주세요.
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <div className="space-y-4">
+ {surveyTemplate.questions.map((question: any) => renderSurveyQuestion(question))}
+ </div>
+
+ <div className="flex justify-end pt-6 border-t">
+ <Button
+ onClick={handleSurveyComplete}
+ disabled={!isSurveyComplete()}
+ className="bg-blue-600 hover:bg-blue-700"
+ >
+ <CheckCircle2 className="h-4 w-4 mr-2" />
+ 설문조사 완료
+ </Button>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+ );
+};
+
+// 디버깅을 위한 useEffect
+useEffect(() => {
+ if (isNDATemplate) {
+ console.log("🔍 NDA 템플릿 디버깅:", {
+ filePath,
+ additionalFiles,
+ allFiles,
+ activeTab,
+ isInitialLoaded,
+ currentDocumentPath: currentDocumentPath.current,
+ hasWebViewerInstance: !!webViewerInstance.current,
+ hasParentInstance: !!instance,
+ signatureFields,
+ hasSignatureFields,
+ isAutoSignProcessing,
+ autoSignError
+ });
}
+}, [isNDATemplate, filePath, additionalFiles, allFiles, activeTab, isInitialLoaded, signatureFields, hasSignatureFields, isAutoSignProcessing, autoSignError]);
+
+// ✅ 서명 필드 상태 표시 컴포넌트
+const SignatureFieldsStatus = () => {
+ if (!hasSignatureFields && !isAutoSignProcessing && !autoSignError) return null;
- // 다이얼로그 뷰어 렌더링
return (
- <Dialog open={showDialog} onOpenChange={handleClose}>
- <DialogContent className="w-[70vw]" style={{ maxWidth: "none" }}>
- <DialogHeader>
- <DialogTitle>기본계약서 서명</DialogTitle>
- <DialogDescription>
- 계약서를 확인하고 서명을 진행해주세요.
- </DialogDescription>
- </DialogHeader>
- <div className="h-[calc(70vh-60px)]">
- <div ref={viewer} className="h-[100%]">
+ <div className="mb-2">
+ {isAutoSignProcessing ? (
+ <Badge variant="secondary" className="text-xs">
+ <Loader2 className="h-3 w-3 mr-1 animate-spin" />
+ 서명 필드 생성 중...
+ </Badge>
+ ) : autoSignError ? (
+ <Badge variant="destructive" className="text-xs bg-red-50 text-red-700 border-red-200">
+ <AlertTriangle className="h-3 w-3 mr-1" />
+ 자동 생성 실패
+ </Badge>
+ ) : hasSignatureFields ? (
+ <Badge variant="outline" className="text-xs bg-blue-50 text-blue-700 border-blue-200">
+ <Target className="h-3 w-3 mr-1" />
+ {signatureFields.length}개 서명 필드 자동 생성됨
+ </Badge>
+ ) : null}
+ </div>
+ );
+};
+
+// 인라인 뷰어 렌더링
+if (!isOpen && !onClose) {
+ return (
+ <div className="h-full w-full flex flex-col overflow-hidden">
+ {allFiles.length > 1 ? (
+ <Tabs value={activeTab} onValueChange={handleTabChange} className="h-full flex flex-col">
+ <div className="border-b bg-gray-50 px-3 py-2 flex-shrink-0">
+ <SignatureFieldsStatus />
+ <TabsList className="grid w-full h-8" style={{ gridTemplateColumns: `repeat(${allFiles.length}, 1fr)` }}>
+ {allFiles.map((file, index) => {
+ let tabId: string;
+ if (index === 0) {
+ tabId = 'main';
+ } else if (file.type === 'survey') {
+ tabId = 'survey';
+ } else {
+ const fileOnlyIndex = allFiles.slice(0, index).filter(f => f.type !== 'survey').length;
+ tabId = `file-${fileOnlyIndex}`;
+ }
+
+ return (
+ <TabsTrigger key={tabId} value={tabId} className="text-xs">
+ <div className="flex items-center space-x-1">
+ {file.type === 'survey' ? (
+ <ClipboardList className="h-3 w-3" />
+ ) : (
+ <FileText className="h-3 w-3" />
+ )}
+ <span className="truncate">{file.name}</span>
+ {file.type === 'survey' && surveyData.completed && (
+ <Badge variant="secondary" className="ml-1 h-4 px-1 text-xs">완료</Badge>
+ )}
+ </div>
+ </TabsTrigger>
+ );
+})}
+ </TabsList>
+ </div>
+
+ <div className="flex-1 min-h-0 overflow-hidden relative">
+ <div
+ className={`absolute inset-0 p-3 ${activeTab === 'survey' ? 'block' : 'hidden'}`}
+ >
+ <SurveyComponent />
+ </div>
+
+ <div
+ className={`absolute inset-0 ${activeTab !== 'survey' ? 'block' : 'hidden'}`}
+ >
+ <div
+ ref={viewer}
+ className="w-full h-full"
+ style={{ position: 'relative', minHeight: '400px' }}
+ >
+ {fileLoading && (
+ <div className="absolute inset-0 flex flex-col items-center justify-center bg-white z-10">
+ <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" />
+ <p className="text-sm text-muted-foreground">문서 로딩 중...</p>
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+ </Tabs>
+ ) : (
+ <div className="h-full w-full relative">
+ <div className="absolute top-2 left-2 z-10">
+ <SignatureFieldsStatus />
+ </div>
+ <div
+ ref={viewer}
+ className="absolute inset-0"
+ style={{ position: 'relative', minHeight: '400px' }}
+ >
{fileLoading && (
- <div className="flex flex-col items-center justify-center py-12">
+ <div className="absolute inset-0 flex flex-col items-center justify-center bg-white z-10">
<Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" />
<p className="text-sm text-muted-foreground">문서 로딩 중...</p>
</div>
)}
</div>
</div>
- <DialogFooter>
- <Button variant="outline" onClick={handleClose} disabled={fileLoading}>
- 취소
- </Button>
- <Button onClick={handleSave} disabled={fileLoading}>
- 서명 완료
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
+ )}
+ </div>
);
}
+// 다이얼로그 뷰어 렌더링
+return (
+ <Dialog open={showDialog} onOpenChange={handleClose}>
+ <DialogContent className="w-[90vw] max-w-6xl h-[90vh] flex flex-col p-0">
+ <DialogHeader className="px-6 py-4 border-b flex-shrink-0">
+ <DialogTitle className="flex items-center justify-between">
+ <span>기본계약서 서명</span>
+ <SignatureFieldsStatus />
+ </DialogTitle>
+ <DialogDescription>
+ 계약서를 확인하고 서명을 진행해주세요.
+ {isComplianceTemplate && (
+ <span className="block mt-1 text-amber-600">📋 준법 설문조사를 먼저 완료해주세요.</span>
+ )}
+ {isNDATemplate && additionalFiles.length > 0 && (
+ <span className="block mt-1 text-blue-600">📎 첨부서류 {additionalFiles.length}개를 각 탭에서 확인해주세요.</span>
+ )}
+ {hasSignatureFields && (
+ <span className="block mt-1 text-green-600">
+ 🎯 서명 위치가 자동으로 감지되었습니다.
+ {signatureFields.some(f => f.includes('_text')) && (
+ <span className="block text-sm text-amber-600">
+ 💡 빨간색 텍스트로 표시된 영역을 찾아 서명해주세요.
+ </span>
+ )}
+ {signatureFields.some(f => f.startsWith('default_signature_')) && !signatureFields.some(f => f.includes('_text')) && (
+ <span className="block text-sm text-amber-600">
+ 💡 마지막 페이지 하단의 핑크색 영역에서 서명해주세요.
+ </span>
+ )}
+ </span>
+ )}
+ {autoSignError && (
+ <span className="block mt-1 text-red-600">⚠️ 자동 서명 필드 생성 실패 - 수동으로 서명 위치를 클릭해주세요.</span>
+ )}
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="flex-1 min-h-0 overflow-hidden">
+ {allFiles.length > 1 ? (
+ <Tabs value={activeTab} onValueChange={handleTabChange} className="h-full flex flex-col">
+ <div className="border-b bg-gray-50 px-3 py-2 flex-shrink-0">
+ <TabsList className="grid w-full h-8" style={{ gridTemplateColumns: `repeat(${allFiles.length}, 1fr)` }}>
+ {allFiles.map((file, index) => {
+ const tabId = index === 0 ? 'main' : file.type === 'survey' ? 'survey' : `file-${index}`;
+ return (
+ <TabsTrigger key={tabId} value={tabId} className="text-xs">
+ <div className="flex items-center space-x-1">
+ {file.type === 'survey' ? <ClipboardList className="h-3 w-3" /> : <FileText className="h-3 w-3" />}
+ <span className="truncate">{file.name}</span>
+ {file.type === 'survey' && surveyData.completed && (
+ <Badge variant="secondary" className="ml-1 h-4 px-1 text-xs">완료</Badge>
+ )}
+ </div>
+ </TabsTrigger>
+ );
+ })}
+ </TabsList>
+ </div>
+
+ <div className="flex-1 min-h-0 overflow-hidden relative">
+ <div className={`absolute inset-0 p-3 ${activeTab === 'survey' ? 'block' : 'hidden'}`}>
+ <SurveyComponent />
+ </div>
+
+ <div className={`absolute inset-0 ${activeTab !== 'survey' ? 'block' : 'hidden'}`}>
+ <div
+ ref={viewer}
+ className="w-full h-full"
+ style={{ position: 'relative', minHeight: '400px' }}
+ >
+ {fileLoading && (
+ <div className="absolute inset-0 flex flex-col items-center justify-center bg-white z-10">
+ <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" />
+ <p className="text-sm text-muted-foreground">문서 로딩 중...</p>
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+ </Tabs>
+ ) : (
+ <div className="h-full relative">
+ <div
+ ref={viewer}
+ className="absolute inset-0"
+ style={{ position: 'relative', minHeight: '400px' }}
+ >
+ {fileLoading && (
+ <div className="absolute inset-0 flex flex-col items-center justify-center bg-white z-10">
+ <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" />
+ <p className="text-sm text-muted-foreground">문서 로딩 중...</p>
+ </div>
+ )}
+ </div>
+ </div>
+ )}
+ </div>
+
+ <DialogFooter className="px-6 py-4 border-t bg-white flex-shrink-0">
+ <Button variant="outline" onClick={handleClose} disabled={fileLoading}>취소</Button>
+ <Button onClick={handleSave} disabled={fileLoading || isAutoSignProcessing}>
+ <FileSignature className="h-4 w-4 mr-2" />
+ 서명 완료
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+);
+}
+
// WebViewer 정리 함수
const cleanupHtmlStyle = () => {
- // iframe 스타일 정리 (WebViewer가 추가한 스타일)
- const elements = document.querySelectorAll('.Document_container');
- elements.forEach((elem) => {
- elem.remove();
- });
+const elements = document.querySelectorAll('.Document_container');
+elements.forEach((elem) => {
+ elem.remove();
+});
}; \ No newline at end of file