summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-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
-rw-r--r--lib/forms/services.ts13
-rw-r--r--lib/gtc-contract/gtc-clauses/table/preview-document-dialog.tsx4
-rw-r--r--lib/gtc-contract/status/gtc-contract-table.tsx1
-rw-r--r--lib/gtc-contract/status/gtc-documents-table-columns.tsx2
-rw-r--r--lib/information/repository.ts192
-rw-r--r--lib/information/service.ts618
-rw-r--r--lib/information/table/update-information-dialog.tsx380
-rw-r--r--lib/information/validations.ts5
-rw-r--r--lib/menu-list/servcie.ts61
-rw-r--r--lib/menu-list/table/menu-list-table.tsx54
-rw-r--r--lib/notice/service.ts45
-rw-r--r--lib/pq/pq-review-table-new/edit-investigation-dialog.tsx124
-rw-r--r--lib/pq/pq-review-table-new/site-visit-dialog.tsx16
-rw-r--r--lib/pq/service.ts57
-rw-r--r--lib/sedp/get-form-tags.ts8
-rw-r--r--lib/sedp/sync-form.ts4
-rw-r--r--lib/sedp/sync-object-class.ts69
-rw-r--r--lib/site-visit/client-site-visit-wrapper.tsx19
-rw-r--r--lib/site-visit/shi-attendees-dialog.tsx2
-rw-r--r--lib/site-visit/site-visit-detail-dialog.tsx2
-rw-r--r--lib/tags/service.ts9
-rw-r--r--lib/users/auth/partners-auth.ts10
-rw-r--r--lib/users/auth/passwordUtil.ts197
-rw-r--r--lib/vendor-document-list/dolce-upload-service.ts3
-rw-r--r--lib/vendor-document-list/enhanced-document-service.ts133
-rw-r--r--lib/vendor-document-list/import-service.ts2
-rw-r--r--lib/vendor-document-list/ship-all/enhanced-doc-table-columns.tsx540
-rw-r--r--lib/vendor-document-list/ship-all/enhanced-documents-table.tsx296
-rw-r--r--lib/vendor-document-list/ship/import-from-dolce-button.tsx38
-rw-r--r--lib/vendor-registration-status/repository.ts165
-rw-r--r--lib/vendor-registration-status/service.ts260
-rw-r--r--lib/vendors/service.ts5
-rw-r--r--lib/vendors/table/request-pq-dialog.tsx6
-rw-r--r--lib/vendors/validations.ts18
47 files changed, 5159 insertions, 2076 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
diff --git a/lib/forms/services.ts b/lib/forms/services.ts
index 34bad300..2f7caec3 100644
--- a/lib/forms/services.ts
+++ b/lib/forms/services.ts
@@ -1147,8 +1147,19 @@ async function transformDataToSEDPFormat(
const transformedItems = [];
for (const row of tableData) {
+
+ const cotractItem = await db.query.contractItems.findFirst({
+ where:
+ eq(contractItems.id, contractItemId),
+ });
+
+ const item = await db.query.items.findFirst({
+ where:
+ eq(items.id, cotractItem.itemId),
+ });
+
// Get packageCode for this specific tag
- let packageCode = formCode; // fallback to formCode
+ let packageCode = item.packageCode; // fallback to formCode
let tagClassCode = ""; // for CLS_ID
if (row.TAG_NO && contractItemId) {
diff --git a/lib/gtc-contract/gtc-clauses/table/preview-document-dialog.tsx b/lib/gtc-contract/gtc-clauses/table/preview-document-dialog.tsx
index 3639c0f3..78ddc7f7 100644
--- a/lib/gtc-contract/gtc-clauses/table/preview-document-dialog.tsx
+++ b/lib/gtc-contract/gtc-clauses/table/preview-document-dialog.tsx
@@ -174,7 +174,7 @@ export function PreviewDocumentDialog({
<RefreshCw className={`mr-2 h-3 w-3 ${isGenerating ? 'animate-spin' : ''}`} />
μž¬μƒμ„±
</Button>
- <Button
+ {/* <Button
variant="outline"
size="sm"
onClick={handleExportDocument}
@@ -182,7 +182,7 @@ export function PreviewDocumentDialog({
>
<Download className="mr-2 h-3 w-3" />
PDF λ‹€μš΄λ‘œλ“œ
- </Button>
+ </Button> */}
</>
)}
{hasError && (
diff --git a/lib/gtc-contract/status/gtc-contract-table.tsx b/lib/gtc-contract/status/gtc-contract-table.tsx
index ce3a2c7a..959497a9 100644
--- a/lib/gtc-contract/status/gtc-contract-table.tsx
+++ b/lib/gtc-contract/status/gtc-contract-table.tsx
@@ -43,7 +43,6 @@ export function GtcDocumentsTable({ promises }: GtcDocumentsTableProps) {
const [{ data, pageCount }, projects, users] = React.use(promises)
const router = useRouter()
- console.log(data)
const [rowAction, setRowAction] =
React.useState<DataTableRowAction<GtcDocumentWithRelations> | null>(null)
diff --git a/lib/gtc-contract/status/gtc-documents-table-columns.tsx b/lib/gtc-contract/status/gtc-documents-table-columns.tsx
index 89415284..6fce9b66 100644
--- a/lib/gtc-contract/status/gtc-documents-table-columns.tsx
+++ b/lib/gtc-contract/status/gtc-documents-table-columns.tsx
@@ -189,7 +189,7 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
const gtcDocument = row.original
const handleViewDetails = () => {
- router.push(`/evcp/basic-contract-template/gtc/${gtcDocument.id}`)
+ router.push(`/evcp/gtc/${gtcDocument.id}`)
}
const handleCreateNewRevision = () => {
diff --git a/lib/information/repository.ts b/lib/information/repository.ts
index f640a4c6..c7c000b1 100644
--- a/lib/information/repository.ts
+++ b/lib/information/repository.ts
@@ -1,125 +1,52 @@
-import { asc, desc, eq, ilike, and, count, sql } from "drizzle-orm"
+import { asc, desc, eq, and } from "drizzle-orm"
import db from "@/db/db"
-import { pageInformation, type PageInformation, type NewPageInformation } from "@/db/schema/information"
-import type { GetInformationSchema } from "./validations"
-import { PgTransaction } from "drizzle-orm/pg-core"
-
-// μ΅œμ‹  νŒ¨ν„΄: νŠΈλžœμž­μ…˜μ„ μ§€μ›ν•˜λŠ” μΈν¬λ©”μ΄μ…˜ 쑰회
-export async function selectInformationLists(
- tx: PgTransaction<any, any, any>,
- params: {
- where?: ReturnType<typeof and>
- orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[]
- offset?: number
- limit?: number
- }
-) {
- const { where, orderBy, offset = 0, limit = 10 } = params
-
- return tx
- .select()
- .from(pageInformation)
- .where(where)
- .orderBy(...(orderBy ?? [desc(pageInformation.createdAt)]))
- .offset(offset)
- .limit(limit)
-}
-
-// μ΅œμ‹  νŒ¨ν„΄: νŠΈλžœμž­μ…˜μ„ μ§€μ›ν•˜λŠ” 카운트 쑰회
-export async function countInformationLists(
- tx: PgTransaction<any, any, any>,
- where?: ReturnType<typeof and>
-) {
- const res = await tx
- .select({ count: count() })
- .from(pageInformation)
- .where(where)
-
- return res[0]?.count ?? 0
-}
-
-// κΈ°μ‘΄ νŒ¨ν„΄ (ν•˜μœ„ ν˜Έν™˜μ„±μ„ μœ„ν•΄ μœ μ§€)
-export async function selectInformation(input: GetInformationSchema) {
- const { page, per_page = 50, sort, pagePath, isActive, from, to } = input
-
- const conditions = []
-
- if (pagePath) {
- conditions.push(ilike(pageInformation.pagePath, `%${pagePath}%`))
- }
-
- if (isActive !== null && isActive !== undefined) {
- conditions.push(eq(pageInformation.isActive, isActive))
- }
-
- if (from) {
- conditions.push(sql`${pageInformation.createdAt} >= ${from}`)
- }
+import {
+ pageInformation,
+ informationAttachments,
+ type PageInformation,
+ type NewPageInformation,
+ type InformationAttachment,
+ type NewInformationAttachment
+} from "@/db/schema/information"
- if (to) {
- conditions.push(sql`${pageInformation.createdAt} <= ${to}`)
- }
- const offset = (page - 1) * per_page
- // μ •λ ¬ μ„€μ •
- let orderBy = desc(pageInformation.createdAt);
-
- if (sort && Array.isArray(sort) && sort.length > 0) {
- const sortItem = sort[0];
- if (sortItem.id === "createdAt") {
- orderBy = sortItem.desc ? desc(pageInformation.createdAt) : asc(pageInformation.createdAt);
- }
- }
+// μΈν¬λ©”μ΄μ…˜ μˆ˜μ •
+export async function updateInformation(id: number, data: Partial<NewPageInformation>): Promise<PageInformation | null> {
+ const result = await db
+ .update(pageInformation)
+ .set({ ...data, updatedAt: new Date() })
+ .where(eq(pageInformation.id, id))
+ .returning()
- const whereClause = conditions.length > 0 ? and(...conditions) : undefined
+ return result[0] || null
+}
- const data = await db
+// μΈν¬λ©”μ΄μ…˜κ³Ό μ²¨λΆ€νŒŒμΌ ν•¨κ»˜ 쑰회
+export async function getInformationWithAttachments(id: number) {
+ const information = await db
.select()
.from(pageInformation)
- .where(whereClause)
- .orderBy(orderBy)
- .limit(per_page)
- .offset(offset)
-
- return data
-}
-
-// κΈ°μ‘΄ νŒ¨ν„΄: μΈν¬λ©”μ΄μ…˜ 총 개수 쑰회
-export async function countInformation(input: GetInformationSchema) {
- const { pagePath, isActive, from, to } = input
-
- const conditions = []
-
- if (pagePath) {
- conditions.push(ilike(pageInformation.pagePath, `%${pagePath}%`))
- }
+ .where(eq(pageInformation.id, id))
+ .limit(1)
- if (isActive !== null && isActive !== undefined) {
- conditions.push(eq(pageInformation.isActive, isActive))
- }
+ if (!information[0]) return null
- if (from) {
- conditions.push(sql`${pageInformation.createdAt} >= ${from}`)
- }
+ const attachments = await db
+ .select()
+ .from(informationAttachments)
+ .where(eq(informationAttachments.informationId, id))
+ .orderBy(asc(informationAttachments.createdAt))
- if (to) {
- conditions.push(sql`${pageInformation.createdAt} <= ${to}`)
+ return {
+ ...information[0],
+ attachments
}
-
- const whereClause = conditions.length > 0 ? and(...conditions) : undefined
-
- const result = await db
- .select({ count: count() })
- .from(pageInformation)
- .where(whereClause)
-
- return result[0]?.count ?? 0
}
-// νŽ˜μ΄μ§€ κ²½λ‘œλ³„ μΈν¬λ©”μ΄μ…˜ 쑰회 (ν™œμ„±ν™”λœ κ²ƒλ§Œ)
-export async function getInformationByPagePath(pagePath: string): Promise<PageInformation | null> {
- const result = await db
+// νŽ˜μ΄μ§€ 경둜둜 μΈν¬λ©”μ΄μ…˜κ³Ό μ²¨λΆ€νŒŒμΌ ν•¨κ»˜ 쑰회
+export async function getInformationByPagePathWithAttachments(pagePath: string) {
+ const information = await db
.select()
.from(pageInformation)
.where(and(
@@ -128,26 +55,55 @@ export async function getInformationByPagePath(pagePath: string): Promise<PageIn
))
.limit(1)
- return result[0] || null
+ if (!information[0]) return null
+
+ const attachments = await db
+ .select()
+ .from(informationAttachments)
+ .where(eq(informationAttachments.informationId, information[0].id))
+ .orderBy(asc(informationAttachments.createdAt))
+
+ return {
+ ...information[0],
+ attachments
+ }
}
-// μΈν¬λ©”μ΄μ…˜ μˆ˜μ •
-export async function updateInformation(id: number, data: Partial<NewPageInformation>): Promise<PageInformation | null> {
+// μ²¨λΆ€νŒŒμΌ μΆ”κ°€
+export async function addInformationAttachment(data: NewInformationAttachment): Promise<InformationAttachment | null> {
const result = await db
- .update(pageInformation)
- .set({ ...data, updatedAt: new Date() })
- .where(eq(pageInformation.id, id))
+ .insert(informationAttachments)
+ .values(data)
.returning()
return result[0] || null
}
-// ID둜 μΈν¬λ©”μ΄μ…˜ 쑰회
-export async function getInformationById(id: number): Promise<PageInformation | null> {
+// μ²¨λΆ€νŒŒμΌ μ‚­μ œ
+export async function deleteInformationAttachment(id: number): Promise<boolean> {
+ const result = await db
+ .delete(informationAttachments)
+ .where(eq(informationAttachments.id, id))
+ .returning()
+
+ return result.length > 0
+}
+
+// μΈν¬λ©”μ΄μ…˜ ID둜 λͺ¨λ“  μ²¨λΆ€νŒŒμΌ 쑰회
+export async function getAttachmentsByInformationId(informationId: number): Promise<InformationAttachment[]> {
+ return await db
+ .select()
+ .from(informationAttachments)
+ .where(eq(informationAttachments.informationId, informationId))
+ .orderBy(asc(informationAttachments.createdAt))
+}
+
+// μ²¨λΆ€νŒŒμΌ ID둜 쑰회
+export async function getAttachmentById(id: number): Promise<InformationAttachment | null> {
const result = await db
.select()
- .from(pageInformation)
- .where(eq(pageInformation.id, id))
+ .from(informationAttachments)
+ .where(eq(informationAttachments.id, id))
.limit(1)
return result[0] || null
diff --git a/lib/information/service.ts b/lib/information/service.ts
index 30a651f1..2826c0e9 100644
--- a/lib/information/service.ts
+++ b/lib/information/service.ts
@@ -1,283 +1,335 @@
-"use server"
-
-import { revalidateTag, unstable_noStore } from "next/cache"
-import { getErrorMessage } from "@/lib/handle-error"
-import { unstable_cache } from "@/lib/unstable-cache"
-import { filterColumns } from "@/lib/filter-columns"
-import { asc, desc, ilike, and, or, eq } from "drizzle-orm"
-import db from "@/db/db"
-import { pageInformation, menuAssignments } from "@/db/schema"
-
-import type {
- UpdateInformationSchema,
- GetInformationSchema
-} from "./validations"
-
-import {
- selectInformation,
- countInformation,
- getInformationByPagePath,
- updateInformation,
- getInformationById,
- selectInformationLists,
- countInformationLists
-} from "./repository"
-
-import type { PageInformation } from "@/db/schema/information"
-
-// μ΅œμ‹  νŒ¨ν„΄: κ³ κΈ‰ 필터링과 캐싱을 μ§€μ›ν•˜λŠ” μΈν¬λ©”μ΄μ…˜ λͺ©λ‘ 쑰회
-export async function getInformationLists(input: GetInformationSchema) {
- return unstable_cache(
- async () => {
- try {
- // κ³ κΈ‰ 검색 둜직
- const { page, perPage, search, filters, joinOperator, pagePath, pageName, informationContent, isActive } = input
-
- // κΈ°λ³Έ 검색 쑰건듀
- const conditions = []
-
- // 검색어가 있으면 μ—¬λŸ¬ ν•„λ“œμ—μ„œ 검색
- if (search && search.trim()) {
- const searchConditions = [
- ilike(pageInformation.pagePath, `%${search}%`),
- ilike(pageInformation.pageName, `%${search}%`),
- ilike(pageInformation.informationContent, `%${search}%`)
- ]
- conditions.push(or(...searchConditions))
- }
-
- // κ°œλ³„ ν•„λ“œ 쑰건듀
- if (pagePath && pagePath.trim()) {
- conditions.push(ilike(pageInformation.pagePath, `%${pagePath}%`))
- }
-
- if (pageName && pageName.trim()) {
- conditions.push(ilike(pageInformation.pageName, `%${pageName}%`))
- }
-
- if (informationContent && informationContent.trim()) {
- conditions.push(ilike(pageInformation.informationContent, `%${informationContent}%`))
- }
-
- if (isActive !== null && isActive !== undefined) {
- conditions.push(eq(pageInformation.isActive, isActive))
- }
-
- // κ³ κΈ‰ ν•„ν„° 처리
- if (filters && filters.length > 0) {
- const advancedConditions = filters.map(() =>
- filterColumns({
- table: pageInformation,
- filters: filters,
- joinOperator: joinOperator,
- })
- )
-
- if (advancedConditions.length > 0) {
- if (joinOperator === "or") {
- conditions.push(or(...advancedConditions))
- } else {
- conditions.push(and(...advancedConditions))
- }
- }
- }
-
- // 전체 WHERE 쑰건 μ‘°ν•©
- const finalWhere = conditions.length > 0
- ? (joinOperator === "or" ? or(...conditions) : and(...conditions))
- : undefined
-
- // νŽ˜μ΄μ§€λ„€μ΄μ…˜
- const offset = (page - 1) * perPage
-
- // μ •λ ¬ 처리
- const orderBy = input.sort.length > 0
- ? input.sort.map((item) => {
- if (item.id === "createdAt") {
- return item.desc ? desc(pageInformation.createdAt) : asc(pageInformation.createdAt)
- } else if (item.id === "updatedAt") {
- return item.desc ? desc(pageInformation.updatedAt) : asc(pageInformation.updatedAt)
- } else if (item.id === "pagePath") {
- return item.desc ? desc(pageInformation.pagePath) : asc(pageInformation.pagePath)
- } else if (item.id === "pageName") {
- return item.desc ? desc(pageInformation.pageName) : asc(pageInformation.pageName)
- } else if (item.id === "informationContent") {
- return item.desc ? desc(pageInformation.informationContent) : asc(pageInformation.informationContent)
- } else if (item.id === "isActive") {
- return item.desc ? desc(pageInformation.isActive) : asc(pageInformation.isActive)
- } else {
- return desc(pageInformation.createdAt) // κΈ°λ³Έκ°’
- }
- })
- : [desc(pageInformation.createdAt)]
-
- // νŠΈλžœμž­μ…˜ λ‚΄λΆ€μ—μ„œ Repository 호좜
- const { data, total } = await db.transaction(async (tx) => {
- const data = await selectInformationLists(tx, {
- where: finalWhere,
- orderBy,
- offset,
- limit: input.perPage,
- })
-
- const total = await countInformationLists(tx, finalWhere)
- return { data, total }
- })
-
- const pageCount = Math.ceil(total / input.perPage)
-
- return { data, pageCount, total }
- } catch (err) {
- console.error("Failed to get information lists:", err)
- // μ—λŸ¬ λ°œμƒ μ‹œ κΈ°λ³Έκ°’ λ°˜ν™˜
- return { data: [], pageCount: 0, total: 0 }
- }
- },
- [JSON.stringify(input)],
- {
- revalidate: 3600,
- tags: ["information-lists"],
- }
- )()
-}
-
-// κΈ°μ‘΄ νŒ¨ν„΄ (ν•˜μœ„ ν˜Έν™˜μ„±μ„ μœ„ν•΄ μœ μ§€)
-export async function getInformationList(input: Partial<GetInformationSchema> & { page: number; per_page: number }) {
- unstable_noStore()
-
- try {
- const [data, total] = await Promise.all([
- selectInformation(input as Parameters<typeof selectInformation>[0]),
- countInformation(input as Parameters<typeof countInformation>[0])
- ])
-
- const pageCount = Math.ceil(total / input.per_page)
-
- return {
- data,
- pageCount,
- total
- }
- } catch (error) {
- console.error("Failed to get information list:", error)
- throw new Error(getErrorMessage(error))
- }
-}
-
-// νŽ˜μ΄μ§€λ³„ μΈν¬λ©”μ΄μ…˜ 쑰회 (일반 μ‚¬μš©μžμš©)
-export async function getPageInformation(pagePath: string): Promise<PageInformation | null> {
- try {
- return await getInformationByPagePath(pagePath)
- } catch (error) {
- console.error(`Failed to get information for page ${pagePath}:`, error)
- return null
- }
-}
-
-// μΊμ‹œλœ νŽ˜μ΄μ§€λ³„ μΈν¬λ©”μ΄μ…˜ 쑰회
-export const getCachedPageInformation = unstable_cache(
- async (pagePath: string) => getPageInformation(pagePath),
- ["page-information"],
- {
- tags: ["page-information"],
- revalidate: 3600, // 1μ‹œκ°„ μΊμ‹œ
- }
-)
-
-// μΈν¬λ©”μ΄μ…˜ μˆ˜μ • (λ‚΄μš©κ³Ό μ²¨λΆ€νŒŒμΌλ§Œ)
-export async function updateInformationData(input: UpdateInformationSchema) {
- try {
- const { id, ...updateData } = input
-
- // μˆ˜μ • κ°€λŠ₯ν•œ ν•„λ“œλ§Œ ν—ˆμš©
- const allowedFields = {
- informationContent: updateData.informationContent,
- attachmentFilePath: updateData.attachmentFilePath,
- attachmentFileName: updateData.attachmentFileName,
- updatedAt: new Date()
- }
-
- const result = await updateInformation(id, allowedFields)
-
- if (!result) {
- return {
- success: false,
- message: "μΈν¬λ©”μ΄μ…˜μ„ 찾을 수 μ—†κ±°λ‚˜ μˆ˜μ •μ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€."
- }
- }
-
- revalidateTag("page-information")
- revalidateTag("information-lists")
- revalidateTag("information-edit-permission") // νŽΈμ§‘ κΆŒν•œ μΊμ‹œ λ¬΄νš¨ν™”
-
- return {
- success: true,
- message: "μΈν¬λ©”μ΄μ…˜μ΄ μ„±κ³΅μ μœΌλ‘œ μˆ˜μ •λ˜μ—ˆμŠ΅λ‹ˆλ‹€."
- }
- } catch (error) {
- console.error("Failed to update information:", error)
- return {
- success: false,
- message: getErrorMessage(error)
- }
- }
-}
-
-// ID둜 μΈν¬λ©”μ΄μ…˜ 쑰회
-export async function getInformationDetail(id: number): Promise<PageInformation | null> {
- try {
- return await getInformationById(id)
- } catch (error) {
- console.error(`Failed to get information detail for id ${id}:`, error)
- return null
- }
-}
-
-// μΈν¬λ©”μ΄μ…˜ νŽΈμ§‘ κΆŒν•œ 확인
-export async function checkInformationEditPermission(pagePath: string, userId: string): Promise<boolean> {
- try {
- // pagePathλ₯Ό menuPath둜 λ³€ν™˜ (pagePathκ°€ menuPath의 λ§ˆμ§€λ§‰ 뢀뢄이라고 κ°€μ •)
- // 예: pagePath "vendor-list" -> menuPath "/evcp/vendor-list" λ˜λŠ” "/partners/vendor-list"
- const menuPathQueries = [
- `/evcp/${pagePath}`,
- `/partners/${pagePath}`,
- `/${pagePath}`, // 루트 경둜
- pagePath // μ •ν™•ν•œ λ§€μΉ­
- ]
-
- // menu_assignmentsμ—μ„œ ν•΄λ‹Ή pagePath와 λ§€μΉ­λ˜λŠ” 메뉴 μ°ΎκΈ°
- const menuAssignment = await db
- .select()
- .from(menuAssignments)
- .where(
- or(
- ...menuPathQueries.map(path => eq(menuAssignments.menuPath, path))
- )
- )
- .limit(1)
-
- if (menuAssignment.length === 0) {
- // λ§€μΉ­λ˜λŠ” 메뉴가 μ—†μœΌλ©΄ κΆŒν•œ μ—†μŒ
- return false
- }
-
- const assignment = menuAssignment[0]
- const userIdNumber = parseInt(userId)
-
- // ν˜„μž¬ μ‚¬μš©μžκ°€ manager1 λ˜λŠ” manager2인지 확인
- return assignment.manager1Id === userIdNumber || assignment.manager2Id === userIdNumber
- } catch (error) {
- console.error("Failed to check information edit permission:", error)
- return false
- }
-}
-
-// μΊμ‹œλœ κΆŒν•œ 확인
-export const getCachedEditPermission = unstable_cache(
- async (pagePath: string, userId: string) => checkInformationEditPermission(pagePath, userId),
- ["information-edit-permission"],
- {
- tags: ["information-edit-permission"],
- revalidate: 300, // 5λΆ„ μΊμ‹œ
- }
-) \ No newline at end of file
+"use server"
+
+import { revalidateTag } from "next/cache"
+import { getErrorMessage } from "@/lib/handle-error"
+import { unstable_cache } from "@/lib/unstable-cache"
+import { desc, or, eq } from "drizzle-orm"
+import db from "@/db/db"
+import { pageInformation, menuAssignments } from "@/db/schema"
+import { saveDRMFile } from "@/lib/file-stroage"
+import { decryptWithServerAction } from "@/components/drm/drmUtils"
+
+import type {
+ UpdateInformationSchema
+} from "./validations"
+
+import {
+ getInformationByPagePathWithAttachments,
+ updateInformation,
+ getInformationWithAttachments,
+ addInformationAttachment,
+ deleteInformationAttachment,
+ getAttachmentById
+} from "./repository"
+
+import type { PageInformation, InformationAttachment } from "@/db/schema/information"
+
+// κ°„λ‹¨ν•œ μΈν¬λ©”μ΄μ…˜ λͺ©λ‘ 쑰회 (νŽ˜μ΄μ§€λ„€μ΄μ…˜ 없이 전체 쑰회)
+export async function getInformationLists() {
+ try {
+ // 전체 데이터 쑰회 (ν΄λΌμ΄μ–ΈνŠΈμ—μ„œ 검색 처리)
+ const data = await db
+ .select()
+ .from(pageInformation)
+ .orderBy(desc(pageInformation.createdAt))
+
+ return { data }
+ } catch (err) {
+ console.error("Failed to get information lists:", err)
+ return { data: [] }
+ }
+}
+
+
+
+// νŽ˜μ΄μ§€λ³„ μΈν¬λ©”μ΄μ…˜ 쑰회 (μ²¨λΆ€νŒŒμΌ 포함)
+export async function getPageInformation(pagePath: string) {
+ try {
+ return await getInformationByPagePathWithAttachments(pagePath)
+ } catch (error) {
+ console.error(`Failed to get information for page ${pagePath}:`, error)
+ return null
+ }
+}
+
+// μΊμ‹œλœ νŽ˜μ΄μ§€λ³„ μΈν¬λ©”μ΄μ…˜ 쑰회
+export const getCachedPageInformation = unstable_cache(
+ async (pagePath: string) => getPageInformation(pagePath),
+ ["page-information"],
+ {
+ tags: ["page-information"],
+ revalidate: 3600, // 1μ‹œκ°„ μΊμ‹œ
+ }
+)
+
+// μΈν¬λ©”μ΄μ…˜ μˆ˜μ • (λ‚΄μš©κ³Ό μ²¨λΆ€νŒŒμΌλ§Œ)
+export async function updateInformationData(input: UpdateInformationSchema) {
+ try {
+ const { id, ...updateData } = input
+
+ // μˆ˜μ • κ°€λŠ₯ν•œ ν•„λ“œλ§Œ ν—ˆμš©
+ const allowedFields = {
+ informationContent: updateData.informationContent,
+ isActive: updateData.isActive,
+ updatedAt: new Date()
+ }
+
+ const result = await updateInformation(id, allowedFields)
+
+ if (!result) {
+ return {
+ success: false,
+ message: "μΈν¬λ©”μ΄μ…˜μ„ 찾을 수 μ—†κ±°λ‚˜ μˆ˜μ •μ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€."
+ }
+ }
+
+ revalidateTag("page-information")
+ revalidateTag("information-lists")
+ revalidateTag("information-edit-permission") // νŽΈμ§‘ κΆŒν•œ μΊμ‹œ λ¬΄νš¨ν™”
+
+ return {
+ success: true,
+ message: "μΈν¬λ©”μ΄μ…˜μ΄ μ„±κ³΅μ μœΌλ‘œ μˆ˜μ •λ˜μ—ˆμŠ΅λ‹ˆλ‹€."
+ }
+ } catch (error) {
+ console.error("Failed to update information:", error)
+ return {
+ success: false,
+ message: getErrorMessage(error)
+ }
+ }
+}
+
+// ID둜 μΈν¬λ©”μ΄μ…˜ 쑰회 (μ²¨λΆ€νŒŒμΌ 포함)
+export async function getInformationDetail(id: number) {
+ try {
+ return await getInformationWithAttachments(id)
+ } catch (error) {
+ console.error(`Failed to get information detail for id ${id}:`, error)
+ return null
+ }
+}
+
+// μΈν¬λ©”μ΄μ…˜ νŽΈμ§‘ κΆŒν•œ 확인
+export async function checkInformationEditPermission(pagePath: string, userId: string): Promise<boolean> {
+ try {
+ // pagePathλ₯Ό menuPath둜 λ³€ν™˜ (pagePathκ°€ menuPath의 λ§ˆμ§€λ§‰ 뢀뢄이라고 κ°€μ •)
+ // 예: pagePath "vendor-list" -> menuPath "/evcp/vendor-list" λ˜λŠ” "/partners/vendor-list"
+ const menuPathQueries = [
+ `/evcp/${pagePath}`,
+ `/partners/${pagePath}`,
+ `/${pagePath}`, // 루트 경둜
+ pagePath // μ •ν™•ν•œ λ§€μΉ­
+ ]
+
+ // menu_assignmentsμ—μ„œ ν•΄λ‹Ή pagePath와 λ§€μΉ­λ˜λŠ” 메뉴 μ°ΎκΈ°
+ const menuAssignment = await db
+ .select()
+ .from(menuAssignments)
+ .where(
+ or(
+ ...menuPathQueries.map(path => eq(menuAssignments.menuPath, path))
+ )
+ )
+ .limit(1)
+
+ if (menuAssignment.length === 0) {
+ // λ§€μΉ­λ˜λŠ” 메뉴가 μ—†μœΌλ©΄ κΆŒν•œ μ—†μŒ
+ return false
+ }
+
+ const assignment = menuAssignment[0]
+ const userIdNumber = parseInt(userId)
+
+ // ν˜„μž¬ μ‚¬μš©μžκ°€ manager1 λ˜λŠ” manager2인지 확인
+ return assignment.manager1Id === userIdNumber || assignment.manager2Id === userIdNumber
+ } catch (error) {
+ console.error("Failed to check information edit permission:", error)
+ return false
+ }
+}
+
+// μΊμ‹œλœ κΆŒν•œ 확인
+export const getCachedEditPermission = unstable_cache(
+ async (pagePath: string, userId: string) => checkInformationEditPermission(pagePath, userId),
+ ["information-edit-permission"],
+ {
+ tags: ["information-edit-permission"],
+ revalidate: 300, // 5λΆ„ μΊμ‹œ
+ }
+)
+
+// menu_assignments 기반으둜 page_information 동기화
+export async function syncInformationFromMenuAssignments() {
+ try {
+ // menu_assignmentsμ—μ„œ λͺ¨λ“  메뉴 κ°€μ Έμ˜€κΈ°
+ const menuItems = await db.select().from(menuAssignments);
+
+ let processedCount = 0;
+
+ // upsertλ₯Ό μ‚¬μš©ν•˜μ—¬ 각 메뉴 ν•­λͺ© 처리
+ for (const menu of menuItems) {
+ try {
+ await db.insert(pageInformation)
+ .values({
+ pagePath: menu.menuPath,
+ pageName: menu.menuTitle,
+ informationContent: "",
+ isActive: true // κΈ°λ³Έκ°’μœΌλ‘œ ν™œμ„±ν™”
+ })
+ .onConflictDoUpdate({
+ target: pageInformation.pagePath,
+ set: {
+ pageName: menu.menuTitle,
+ updatedAt: new Date()
+ }
+ });
+ processedCount++;
+ } catch (itemError: any) {
+ console.warn(`메뉴 ν•­λͺ© 처리 μ‹€νŒ¨: ${menu.menuPath}`, itemError);
+ continue;
+ }
+ }
+
+ revalidateTag("information");
+
+ return {
+ success: true,
+ message: `νŽ˜μ΄μ§€ 정보 동기화 μ™„λ£Œ: ${processedCount}개 처리됨`
+ };
+ } catch (error) {
+ console.error("Information 동기화 였λ₯˜:", error);
+ return {
+ success: false,
+ message: "νŽ˜μ΄μ§€ 정보 동기화 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€."
+ };
+ }
+}
+
+// μ²¨λΆ€νŒŒμΌ μ—…λ‘œλ“œ
+export async function uploadInformationAttachment(formData: FormData) {
+ try {
+ const informationId = parseInt(formData.get("informationId") as string)
+ const file = formData.get("file") as File
+
+ if (!informationId || !file) {
+ return {
+ success: false,
+ message: "ν•„μˆ˜ λ§€κ°œλ³€μˆ˜κ°€ λˆ„λ½λ˜μ—ˆμŠ΅λ‹ˆλ‹€."
+ }
+ }
+
+ // 파일 μ €μž₯
+ const saveResult = await saveDRMFile(
+ file,
+ decryptWithServerAction,
+ `information/${informationId}`,
+ // userIdλŠ” ν•„μš”μ‹œ μΆ”κ°€
+ )
+
+ if (!saveResult.success) {
+ return {
+ success: false,
+ message: saveResult.error || "파일 μ €μž₯에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€."
+ }
+ }
+
+ // DB에 μ²¨λΆ€νŒŒμΌ 정보 μ €μž₯
+ const attachment = await addInformationAttachment({
+ informationId,
+ fileName: file.name,
+ filePath: saveResult.publicPath || "",
+ fileSize: saveResult.fileSize ? String(saveResult.fileSize) : String(file.size)
+ })
+
+ if (!attachment) {
+ return {
+ success: false,
+ message: "μ²¨λΆ€νŒŒμΌ 정보 μ €μž₯에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€."
+ }
+ }
+
+ revalidateTag("page-information")
+ revalidateTag("information-lists")
+
+ return {
+ success: true,
+ message: "μ²¨λΆ€νŒŒμΌμ΄ μ„±κ³΅μ μœΌλ‘œ μ—…λ‘œλ“œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.",
+ data: attachment
+ }
+ } catch (error) {
+ console.error("Failed to upload attachment:", error)
+ return {
+ success: false,
+ message: getErrorMessage(error)
+ }
+ }
+}
+
+// μ²¨λΆ€νŒŒμΌ μ‚­μ œ
+export async function deleteInformationAttachmentAction(attachmentId: number) {
+ try {
+ const attachment = await getAttachmentById(attachmentId)
+
+ if (!attachment) {
+ return {
+ success: false,
+ message: "μ²¨λΆ€νŒŒμΌμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."
+ }
+ }
+
+ // DBμ—μ„œ μ‚­μ œ
+ const deleted = await deleteInformationAttachment(attachmentId)
+
+ if (!deleted) {
+ return {
+ success: false,
+ message: "μ²¨λΆ€νŒŒμΌ μ‚­μ œμ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€."
+ }
+ }
+
+ revalidateTag("page-information")
+ revalidateTag("information-lists")
+
+ return {
+ success: true,
+ message: "μ²¨λΆ€νŒŒμΌμ΄ μ„±κ³΅μ μœΌλ‘œ μ‚­μ œλ˜μ—ˆμŠ΅λ‹ˆλ‹€."
+ }
+ } catch (error) {
+ console.error("Failed to delete attachment:", error)
+ return {
+ success: false,
+ message: getErrorMessage(error)
+ }
+ }
+}
+
+// μ²¨λΆ€νŒŒμΌ λ‹€μš΄λ‘œλ“œ
+export async function downloadInformationAttachment(attachmentId: number) {
+ try {
+ const attachment = await getAttachmentById(attachmentId)
+
+ if (!attachment) {
+ return {
+ success: false,
+ message: "μ²¨λΆ€νŒŒμΌμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."
+ }
+ }
+
+ // 파일 λ‹€μš΄λ‘œλ“œ (ν΄λΌμ΄μ–ΈνŠΈμ—μ„œ μ‚¬μš©)
+ return {
+ success: true,
+ data: {
+ filePath: attachment.filePath,
+ fileName: attachment.fileName,
+ fileSize: attachment.fileSize
+ }
+ }
+ } catch (error) {
+ console.error("Failed to download attachment:", error)
+ return {
+ success: false,
+ message: getErrorMessage(error)
+ }
+ }
+} \ No newline at end of file
diff --git a/lib/information/table/update-information-dialog.tsx b/lib/information/table/update-information-dialog.tsx
index b4c11e17..a02b6eb1 100644
--- a/lib/information/table/update-information-dialog.tsx
+++ b/lib/information/table/update-information-dialog.tsx
@@ -2,10 +2,12 @@
import * as React from "react"
import { zodResolver } from "@hookform/resolvers/zod"
-import { useForm } from "react-hook-form"
+import { useForm, useFieldArray } from "react-hook-form"
import { toast } from "sonner"
-import { Loader, Upload, X } from "lucide-react"
-import { useRouter } from "next/navigation"
+import { Loader, Download, X, FileText } from "lucide-react"
+import { useRouter, useParams } from "next/navigation"
+import { useTranslation } from "@/i18n/client"
+import { z } from "zod"
import { Button } from "@/components/ui/button"
import {
@@ -24,17 +26,43 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form"
+import {
+ Dropzone,
+ DropzoneZone,
+ DropzoneInput,
+ DropzoneUploadIcon,
+ DropzoneTitle,
+ DropzoneDescription,
+} from "@/components/ui/dropzone"
import { Textarea } from "@/components/ui/textarea"
import { Switch } from "@/components/ui/switch"
-import { updateInformationData } from "@/lib/information/service"
-import { updateInformationSchema, type UpdateInformationSchema } from "@/lib/information/validations"
-import type { PageInformation } from "@/db/schema/information"
+import {
+ updateInformationData,
+ uploadInformationAttachment,
+ deleteInformationAttachmentAction,
+ downloadInformationAttachment
+} from "@/lib/information/service"
+import type { PageInformation, InformationAttachment } from "@/db/schema/information"
+import { downloadFile } from "@/lib/file-download"
+import prettyBytes from "pretty-bytes"
+
+const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50MB
+
+// 폼 μŠ€ν‚€λ§ˆ
+const updateInformationSchema = z.object({
+ id: z.number(),
+ informationContent: z.string().min(1, "μΈν¬λ©”μ΄μ…˜ λ‚΄μš©μ„ μž…λ ₯ν•΄μ£Όμ„Έμš”"),
+ isActive: z.boolean(),
+ newFiles: z.array(z.any()).optional(),
+})
+
+type UpdateInformationSchema = z.infer<typeof updateInformationSchema>
interface UpdateInformationDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
- information?: PageInformation
+ information?: PageInformation & { attachments?: InformationAttachment[] }
onSuccess?: () => void
}
@@ -45,137 +73,172 @@ export function UpdateInformationDialog({
onSuccess,
}: UpdateInformationDialogProps) {
const router = useRouter()
+ const params = useParams()
+ const lng = (params?.lng as string) || 'ko'
+ const { t } = useTranslation(lng, 'common')
const [isLoading, setIsLoading] = React.useState(false)
- const [uploadedFile, setUploadedFile] = React.useState<File | null>(null)
+ const [isUploadingFiles, setIsUploadingFiles] = React.useState(false)
+ const [existingAttachments, setExistingAttachments] = React.useState<InformationAttachment[]>([])
const form = useForm<UpdateInformationSchema>({
resolver: zodResolver(updateInformationSchema),
defaultValues: {
id: 0,
informationContent: "",
- attachmentFileName: "",
- attachmentFilePath: "",
- attachmentFileSize: "",
isActive: true,
+ newFiles: [],
},
})
+ const { fields: newFileFields, append: appendFile, remove: removeFile } = useFieldArray({
+ control: form.control,
+ name: "newFiles",
+ })
+
// μΈν¬λ©”μ΄μ…˜ 데이터가 λ³€κ²½λ˜λ©΄ 폼 μ—…λ°μ΄νŠΈ
React.useEffect(() => {
if (information && open) {
form.reset({
id: information.id,
informationContent: information.informationContent || "",
- attachmentFileName: information.attachmentFileName || "",
- attachmentFilePath: information.attachmentFilePath || "",
- attachmentFileSize: information.attachmentFileSize || "",
isActive: information.isActive,
+ newFiles: [],
})
+ setExistingAttachments(information.attachments || [])
}
}, [information, open, form])
- const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
- const file = event.target.files?.[0]
- if (file) {
- setUploadedFile(file)
- // 파일 크기λ₯Ό MB λ‹¨μœ„λ‘œ λ³€ν™˜
- const sizeInMB = (file.size / (1024 * 1024)).toFixed(2)
- form.setValue("attachmentFileName", file.name)
- form.setValue("attachmentFileSize", `${sizeInMB} MB`)
- }
- }
-
- const removeFile = () => {
- setUploadedFile(null)
- form.setValue("attachmentFileName", "")
- form.setValue("attachmentFilePath", "")
- form.setValue("attachmentFileSize", "")
- }
-
- const uploadFile = async (file: File): Promise<string> => {
- const formData = new FormData()
- formData.append("file", file)
+ // 파일 λ“œλ‘­ ν•Έλ“€λŸ¬
+ const handleDropAccepted = React.useCallback((acceptedFiles: File[]) => {
+ acceptedFiles.forEach(file => {
+ appendFile(file)
+ })
+ }, [appendFile])
- const response = await fetch("/api/upload", {
- method: "POST",
- body: formData,
+ const handleDropRejected = React.useCallback((rejectedFiles: any[]) => {
+ rejectedFiles.forEach(rejection => {
+ toast.error(`파일 μ—…λ‘œλ“œ μ‹€νŒ¨: ${rejection.file.name}`)
})
+ }, [])
- if (!response.ok) {
- throw new Error("파일 μ—…λ‘œλ“œμ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.")
+ // κΈ°μ‘΄ μ²¨λΆ€νŒŒμΌ λ‹€μš΄λ‘œλ“œ
+ const handleDownloadAttachment = async (attachment: InformationAttachment) => {
+ try {
+ const result = await downloadInformationAttachment(attachment.id)
+ if (result.success && result.data) {
+ await downloadFile(result.data.filePath, result.data.fileName)
+ toast.success("파일 λ‹€μš΄λ‘œλ“œκ°€ μ‹œμž‘λ˜μ—ˆμŠ΅λ‹ˆλ‹€.")
+ } else {
+ toast.error(result.message || "파일 λ‹€μš΄λ‘œλ“œμ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.")
+ }
+ } catch (error) {
+ console.error("파일 λ‹€μš΄λ‘œλ“œ 였λ₯˜:", error)
+ toast.error("파일 λ‹€μš΄λ‘œλ“œ 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.")
}
+ }
+
+ // κΈ°μ‘΄ μ²¨λΆ€νŒŒμΌ μ‚­μ œ
+ const handleDeleteAttachment = async (attachmentId: number) => {
+ if (!confirm("μ •λ§λ‘œ 이 μ²¨λΆ€νŒŒμΌμ„ μ‚­μ œν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?")) return
- const result = await response.json()
- return result.url
+ try {
+ const result = await deleteInformationAttachmentAction(attachmentId)
+ if (result.success) {
+ setExistingAttachments(prev => prev.filter(att => att.id !== attachmentId))
+ toast.success(result.message)
+ } else {
+ toast.error(result.message)
+ }
+ } catch (error) {
+ console.error("μ²¨λΆ€νŒŒμΌ μ‚­μ œ 였λ₯˜:", error)
+ toast.error("μ²¨λΆ€νŒŒμΌ μ‚­μ œ 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.")
+ }
}
const onSubmit = async (values: UpdateInformationSchema) => {
setIsLoading(true)
try {
- const finalValues = { ...values }
+ // 1. μΈν¬λ©”μ΄μ…˜ 정보 μ—…λ°μ΄νŠΈ
+ const updateResult = await updateInformationData({
+ id: values.id,
+ informationContent: values.informationContent,
+ isActive: values.isActive,
+ })
- // μƒˆ 파일이 있으면 μ—…λ‘œλ“œ
- if (uploadedFile) {
- const filePath = await uploadFile(uploadedFile)
- finalValues.attachmentFilePath = filePath
+ if (!updateResult.success) {
+ toast.error(updateResult.message)
+ return
}
- const result = await updateInformationData(finalValues)
-
- if (result.success) {
- toast.success(result.message)
- if (onSuccess) onSuccess()
- onOpenChange(false)
- router.refresh()
- } else {
- toast.error(result.message)
+ // 2. μƒˆ μ²¨λΆ€νŒŒμΌ μ—…λ‘œλ“œ
+ if (values.newFiles && values.newFiles.length > 0) {
+ setIsUploadingFiles(true)
+
+ for (const file of values.newFiles) {
+ const formData = new FormData()
+ formData.append("informationId", String(values.id))
+ formData.append("file", file)
+
+ const uploadResult = await uploadInformationAttachment(formData)
+ if (!uploadResult.success) {
+ toast.error(`파일 μ—…λ‘œλ“œ μ‹€νŒ¨: ${file.name} - ${uploadResult.message}`)
+ }
+ }
+ setIsUploadingFiles(false)
}
+
+ toast.success("μΈν¬λ©”μ΄μ…˜μ΄ μ„±κ³΅μ μœΌλ‘œ μˆ˜μ •λ˜μ—ˆμŠ΅λ‹ˆλ‹€.")
+ if (onSuccess) onSuccess()
+ onOpenChange(false)
+ router.refresh()
} catch (error) {
+ console.error("μΈν¬λ©”μ΄μ…˜ μˆ˜μ • 였λ₯˜:", error)
toast.error("μΈν¬λ©”μ΄μ…˜ μˆ˜μ •μ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.")
- console.error(error)
} finally {
setIsLoading(false)
+ setIsUploadingFiles(false)
}
}
const handleClose = () => {
- setUploadedFile(null)
+ form.reset()
+ setExistingAttachments([])
onOpenChange(false)
}
- const currentFileName = form.watch("attachmentFileName")
-
return (
<Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-2xl">
+ <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
- <DialogTitle>μΈν¬λ©”μ΄μ…˜ μˆ˜μ •</DialogTitle>
+ <DialogTitle>{t('information.edit.title', 'μΈν¬λ©”μ΄μ…˜ μˆ˜μ •')}</DialogTitle>
<DialogDescription>
- νŽ˜μ΄μ§€ μΈν¬λ©”μ΄μ…˜ 정보λ₯Ό μˆ˜μ •ν•©λ‹ˆλ‹€.
+ {t('information.edit.description', 'νŽ˜μ΄μ§€ μΈν¬λ©”μ΄μ…˜ 정보λ₯Ό μˆ˜μ •ν•©λ‹ˆλ‹€.')}
</DialogDescription>
</DialogHeader>
<Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
+ {/* νŽ˜μ΄μ§€ 정보 */}
<div className="bg-gray-50 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
- <span className="font-medium">νŽ˜μ΄μ§€ 정보</span>
+ <span className="font-medium">{t('information.page.info', 'νŽ˜μ΄μ§€ 정보')}</span>
</div>
- <div className="text-sm ">
- <div><strong>νŽ˜μ΄μ§€λͺ…:</strong> {information?.pageName}</div>
- <div><strong>경둜:</strong> {information?.pagePath}</div>
+ <div className="text-sm">
+ <div><strong>{t('information.page.name', 'νŽ˜μ΄μ§€λͺ…')}:</strong> {information?.pageName}</div>
+ <div><strong>{t('information.page.path', '경둜')}:</strong> {information?.pagePath}</div>
</div>
</div>
+ {/* μΈν¬λ©”μ΄μ…˜ λ‚΄μš© */}
<FormField
control={form.control}
name="informationContent"
render={({ field }) => (
<FormItem>
- <FormLabel>μΈν¬λ©”μ΄μ…˜ λ‚΄μš©</FormLabel>
+ <FormLabel>{t('information.content.label', 'μΈν¬λ©”μ΄μ…˜ λ‚΄μš©')}</FormLabel>
<FormControl>
<Textarea
- placeholder="μΈν¬λ©”μ΄μ…˜ λ‚΄μš©μ„ μž…λ ₯ν•˜μ„Έμš”"
+ placeholder={t('information.content.placeholder', 'μΈν¬λ©”μ΄μ…˜ λ‚΄μš©μ„ μž…λ ₯ν•˜μ„Έμš”')}
rows={6}
{...field}
/>
@@ -185,91 +248,112 @@ export function UpdateInformationDialog({
)}
/>
- <div>
- <FormLabel>μ²¨λΆ€νŒŒμΌ</FormLabel>
- <div className="mt-2">
- {uploadedFile ? (
- <div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
- <div className="flex items-center gap-2">
- <span className="text-sm font-medium">{uploadedFile.name}</span>
- <span className="text-xs text-gray-500">
- ({(uploadedFile.size / (1024 * 1024)).toFixed(2)} MB)
- </span>
- <span className="text-xs">(μƒˆ 파일)</span>
- </div>
- <Button
- type="button"
- variant="ghost"
- size="sm"
- onClick={removeFile}
- >
- <X className="h-4 w-4" />
- </Button>
- </div>
- ) : currentFileName ? (
- <div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
- <div className="flex items-center gap-2">
- <span className="text-sm font-medium">{currentFileName}</span>
- {form.watch("attachmentFileSize") && (
- <span className="text-xs text-gray-500">
- ({form.watch("attachmentFileSize")})
- </span>
- )}
- </div>
- <div className="flex gap-2">
- <label
- htmlFor="file-upload-update"
- className="cursor-pointer text-sm"
- >
- λ³€κ²½
- </label>
- <Button
- type="button"
- variant="ghost"
- size="sm"
- onClick={removeFile}
- >
- <X className="h-4 w-4" />
- </Button>
- </div>
- </div>
- ) : (
- <div className="border-2 border-dashed border-gray-300 rounded-lg p-4">
- <div className="text-center">
- <Upload className="mx-auto h-8 w-8 text-gray-400" />
- <div className="mt-2">
- <label
- htmlFor="file-upload-update"
- className="cursor-pointer text-sm"
+ {/* κΈ°μ‘΄ μ²¨λΆ€νŒŒμΌ */}
+ {existingAttachments.length > 0 && (
+ <div className="space-y-3">
+ <FormLabel>{t('information.attachment.existing', 'κΈ°μ‘΄ μ²¨λΆ€νŒŒμΌ')}</FormLabel>
+ <div className="grid gap-2">
+ {existingAttachments.map((attachment) => (
+ <div key={attachment.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
+ <div className="flex items-center gap-2">
+ <FileText className="h-4 w-4" />
+ <span className="text-sm font-medium">{attachment.fileName}</span>
+ <span className="text-xs text-gray-500">({attachment.fileSize})</span>
+ </div>
+ <div className="flex gap-2">
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => handleDownloadAttachment(attachment)}
>
- νŒŒμΌμ„ μ„ νƒν•˜μ„Έμš”
- </label>
+ <Download className="h-4 w-4" />
+ </Button>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => handleDeleteAttachment(attachment.id)}
+ >
+ <X className="h-4 w-4" />
+ </Button>
</div>
- <p className="text-xs text-gray-500 mt-1">
- PDF, DOC, DOCX, XLSX, PPT, PPTX, TXT, ZIP 파일만 μ—…λ‘œλ“œ κ°€λŠ₯
- </p>
</div>
- </div>
- )}
- <input
- id="file-upload-update"
- type="file"
- className="hidden"
- onChange={handleFileSelect}
- accept=".pdf,.doc,.docx,.xlsx,.ppt,.pptx,.txt,.zip"
- />
+ ))}
+ </div>
</div>
+ )}
+
+ {/* μƒˆ μ²¨λΆ€νŒŒμΌ μ—…λ‘œλ“œ */}
+ <div className="space-y-3">
+ <FormLabel>{t('information.attachment.new', 'μƒˆ μ²¨λΆ€νŒŒμΌ')}</FormLabel>
+
+ <Dropzone
+ maxSize={MAX_FILE_SIZE}
+ multiple
+ onDropAccepted={handleDropAccepted}
+ onDropRejected={handleDropRejected}
+ disabled={isLoading || isUploadingFiles}
+ >
+ {({ maxSize }) => (
+ <DropzoneZone className="flex justify-center">
+ <DropzoneInput />
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>
+ {t('information.attachment.select', 'νŒŒμΌμ„ μ„ νƒν•˜μ„Έμš”')}
+ </DropzoneTitle>
+ <DropzoneDescription>
+ λ“œλž˜κ·Έ μ•€ λ“œλ‘­ν•˜κ±°λ‚˜ ν΄λ¦­ν•˜μ—¬ νŒŒμΌμ„ μ„ νƒν•˜μ„Έμš”
+ {maxSize && ` (μ΅œλŒ€: ${prettyBytes(maxSize)})`}
+ </DropzoneDescription>
+ </div>
+ </div>
+ </DropzoneZone>
+ )}
+ </Dropzone>
+
+ {/* μƒˆλ‘œ μ„ νƒλœ 파일 λͺ©λ‘ */}
+ {newFileFields.length > 0 && (
+ <div className="grid gap-2">
+ {newFileFields.map((field, index) => {
+ const file = form.getValues(`newFiles.${index}`)
+ if (!file) return null
+
+ return (
+ <div key={field.id} className="flex items-center justify-between p-3 bg-blue-50 rounded-lg">
+ <div className="flex items-center gap-2">
+ <FileText className="h-4 w-4" />
+ <span className="text-sm font-medium">{file.name}</span>
+ <span className="text-xs text-gray-500">({prettyBytes(file.size)})</span>
+ <span className="text-xs text-blue-600">({t('information.attachment.new', 'μƒˆ 파일')})</span>
+ </div>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removeFile(index)}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
+ )
+ })}
+ </div>
+ )}
</div>
+ {/* ν™œμ„± μƒνƒœ */}
<FormField
control={form.control}
name="isActive"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
- <FormLabel className="text-base">ν™œμ„± μƒνƒœ</FormLabel>
+ <FormLabel className="text-base">{t('information.status.label', 'ν™œμ„± μƒνƒœ')}</FormLabel>
<div className="text-sm text-muted-foreground">
- ν™œμ„±ν™”ν•˜λ©΄ ν•΄λ‹Ή νŽ˜μ΄μ§€μ—μ„œ μΈν¬λ©”μ΄μ…˜ λ²„νŠΌμ΄ ν‘œμ‹œλ©λ‹ˆλ‹€.
+ {t('information.status.description', 'ν™œμ„±ν™”ν•˜λ©΄ ν•΄λ‹Ή νŽ˜μ΄μ§€μ—μ„œ μΈν¬λ©”μ΄μ…˜ λ²„νŠΌμ΄ ν‘œμ‹œλ©λ‹ˆλ‹€.')}
</div>
</div>
<FormControl>
@@ -287,13 +371,13 @@ export function UpdateInformationDialog({
type="button"
variant="outline"
onClick={handleClose}
- disabled={isLoading}
+ disabled={isLoading || isUploadingFiles}
>
- μ·¨μ†Œ
+ {t('common.cancel', 'μ·¨μ†Œ')}
</Button>
- <Button type="submit" disabled={isLoading}>
- {isLoading && <Loader className="mr-2 h-4 w-4 animate-spin" />}
- μˆ˜μ •
+ <Button type="submit" disabled={isLoading || isUploadingFiles}>
+ {(isLoading || isUploadingFiles) && <Loader className="mr-2 h-4 w-4 animate-spin" />}
+ {isUploadingFiles ? "파일 μ—…λ‘œλ“œ 쀑..." : t('common.save', 'μˆ˜μ •')}
</Button>
</DialogFooter>
</form>
@@ -301,4 +385,4 @@ export function UpdateInformationDialog({
</DialogContent>
</Dialog>
)
-} \ No newline at end of file
+} \ No newline at end of file
diff --git a/lib/information/validations.ts b/lib/information/validations.ts
index c4f5d530..3aab7c91 100644
--- a/lib/information/validations.ts
+++ b/lib/information/validations.ts
@@ -10,13 +10,10 @@ import {
import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
import { PageInformation } from "@/db/schema/information"
-// μΈν¬λ©”μ΄μ…˜ μˆ˜μ • μŠ€ν‚€λ§ˆ
+// μΈν¬λ©”μ΄μ…˜ μˆ˜μ • μŠ€ν‚€λ§ˆ (μ²¨λΆ€νŒŒμΌμ€ 별도 처리)
export const updateInformationSchema = z.object({
id: z.number(),
informationContent: z.string().min(1, "λ‚΄μš©μ„ μž…λ ₯ν•΄μ£Όμ„Έμš”"),
- attachmentFileName: z.string().optional(),
- attachmentFilePath: z.string().optional(),
- attachmentFileSize: z.string().optional(),
isActive: z.boolean().default(true),
})
diff --git a/lib/menu-list/servcie.ts b/lib/menu-list/servcie.ts
index 8686bf43..cd414ab4 100644
--- a/lib/menu-list/servcie.ts
+++ b/lib/menu-list/servcie.ts
@@ -3,7 +3,7 @@
"use server";
import db from "@/db/db";
-import { menuAssignments, users } from "@/db/schema";
+import { menuAssignments, users, pageInformation, notice } from "@/db/schema";
import { eq, and } from "drizzle-orm";
import { revalidatePath, revalidateTag } from "next/cache";
import { mainNav, mainNavVendor, additionalNav, additionalNavVendor } from "@/config/menuConfig";
@@ -27,10 +27,10 @@ function extractMenusFromConfig(): MenuData[] {
section.items.forEach(item => {
menus.push({
menuPath: item.href,
- menuTitle: item.title,
- menuDescription: item.description,
- menuGroup: item.group,
- sectionTitle: section.title,
+ menuTitle: item.titleKey,
+ menuDescription: item.descriptionKey,
+ menuGroup: item.groupKey,
+ sectionTitle: section.titleKey,
domain: "evcp"
});
});
@@ -40,8 +40,8 @@ function extractMenusFromConfig(): MenuData[] {
additionalNav.forEach(item => {
menus.push({
menuPath: item.href,
- menuTitle: item.title,
- menuDescription: item.description,
+ menuTitle: item.titleKey,
+ menuDescription: item.descriptionKey,
menuGroup: undefined,
sectionTitle: "μΆ”κ°€ 메뉴",
domain: "evcp"
@@ -53,10 +53,10 @@ function extractMenusFromConfig(): MenuData[] {
section.items.forEach(item => {
menus.push({
menuPath: item.href,
- menuTitle: item.title,
- menuDescription: item.description,
- menuGroup: item.group,
- sectionTitle: section.title,
+ menuTitle: item.titleKey,
+ menuDescription: item.descriptionKey,
+ menuGroup: item.groupKey,
+ sectionTitle: section.titleKey,
domain: "partners"
});
});
@@ -66,8 +66,8 @@ function extractMenusFromConfig(): MenuData[] {
additionalNavVendor.forEach(item => {
menus.push({
menuPath: item.href,
- menuTitle: item.title,
- menuDescription: item.description,
+ menuTitle: item.titleKey,
+ menuDescription: item.descriptionKey,
menuGroup: undefined,
sectionTitle: "μΆ”κ°€ 메뉴",
domain: "partners"
@@ -84,23 +84,34 @@ export async function initializeMenuAssignments() {
const existingMenus = await db.select().from(menuAssignments);
const existingPaths = new Set(existingMenus.map(m => m.menuPath));
- // μƒˆλ‘œμš΄ λ©”λ‰΄λ§Œ μΆ”κ°€
+ // μƒˆλ‘œμš΄ λ©”λ‰΄λ§Œ μΆ”κ°€ (νŠΉμ • 경둜 μ˜ˆμ™Έμ²˜λ¦¬)
const newMenus = configMenus.filter(menu => !existingPaths.has(menu.menuPath));
console.log(newMenus, newMenus)
if (newMenus.length > 0) {
- await db.insert(menuAssignments).values(
- newMenus.map(menu => ({
- menuPath: menu.menuPath,
- menuTitle: menu.menuTitle,
- menuDescription: menu.menuDescription || null,
- menuGroup: menu.menuGroup || null,
- sectionTitle: menu.sectionTitle,
- domain: menu.domain,
- isActive: true,
- }))
- );
+ // κ°œλ³„μ μœΌλ‘œ insertν•˜μ—¬ 쀑볡 였λ₯˜ 처리
+ for (const menu of newMenus) {
+ try {
+ await db.insert(menuAssignments).values({
+ menuPath: menu.menuPath,
+ menuTitle: menu.menuTitle,
+ menuDescription: menu.menuDescription || null,
+ menuGroup: menu.menuGroup || null,
+ sectionTitle: menu.sectionTitle,
+ domain: menu.domain,
+ isActive: true,
+ });
+ } catch (insertError: any) {
+ // /partners/vendor-data 경둜의 쀑볡 였λ₯˜λŠ” λ¬΄μ‹œ
+ if (insertError?.code === '23505' && menu.menuPath === '/partners/vendor-data') {
+ console.warn(`쀑볡 메뉴 경둜 κ±΄λ„ˆλ›°κΈ°: ${menu.menuPath}`);
+ continue;
+ }
+ // λ‹€λ₯Έ 였λ₯˜λŠ” λ‹€μ‹œ throw
+ throw insertError;
+ }
+ }
}
// κΈ°μ‘΄ 메뉴 정보 μ—…λ°μ΄νŠΈ (title, description 등이 변경될 수 있음)
diff --git a/lib/menu-list/table/menu-list-table.tsx b/lib/menu-list/table/menu-list-table.tsx
index 097be082..dedbc9bf 100644
--- a/lib/menu-list/table/menu-list-table.tsx
+++ b/lib/menu-list/table/menu-list-table.tsx
@@ -3,6 +3,8 @@
"use client";
import { useState, useMemo } from "react";
+import { useParams } from "next/navigation";
+import { useTranslation } from "@/i18n/client";
import {
Table,
TableBody,
@@ -25,6 +27,7 @@ import { Button } from "@/components/ui/button";
import { Search, Filter, ExternalLink } from "lucide-react";
import { toast } from "sonner";
import { ManagerSelect } from "./manager-select";
+import { InitializeButton } from "./initialize-button";
import { toggleMenuActive } from "../servcie";
interface MenuAssignment {
@@ -59,6 +62,26 @@ interface MenuListTableProps {
}
export function MenuListTable({ initialMenus, initialUsers }: MenuListTableProps) {
+ // URLμ—μ„œ μ–Έμ–΄ νŒŒλΌλ―Έν„° κ°€μ Έμ˜€κΈ°
+ const params = useParams();
+ const lng = (params?.lng as string) || 'ko';
+ const { t } = useTranslation(lng, 'menu');
+
+ // μ•ˆμ „ν•œ λ²ˆμ—­ ν•¨μˆ˜ (ν‚€κ°€ 없을 λ•Œ 원본 ν‚€ λ°˜ν™˜)
+ const safeTranslate = (key: string): string => {
+ try {
+ const translated = t(key);
+ // λ²ˆμ—­ ν‚€κ°€ κ·ΈλŒ€λ‘œ λ°˜ν™˜λ˜λŠ” 경우 원본 ν‚€ μ‚¬μš©
+ if (translated === key) {
+ return key;
+ }
+ return translated || key;
+ } catch (error) {
+ console.warn(`Translation failed for key: ${key}`, error);
+ return key;
+ }
+ };
+
const [searchQuery, setSearchQuery] = useState("");
const [domainFilter, setDomainFilter] = useState<string>("all");
const [sectionFilter, setSectionFilter] = useState<string>("all");
@@ -67,27 +90,31 @@ export function MenuListTable({ initialMenus, initialUsers }: MenuListTableProps
// ν•„ν„°λ§λœ 메뉴 데이터
const filteredMenus = useMemo(() => {
return initialMenus.filter((menu) => {
+ const translatedTitle = safeTranslate(menu.menuTitle);
+ const translatedSection = safeTranslate(menu.sectionTitle);
+ const translatedDescription = menu.menuDescription ? safeTranslate(menu.menuDescription) : '';
+
const matchesSearch =
- menu.menuTitle.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ translatedTitle.toLowerCase().includes(searchQuery.toLowerCase()) ||
menu.menuPath.toLowerCase().includes(searchQuery.toLowerCase()) ||
- menu.sectionTitle.toLowerCase().includes(searchQuery.toLowerCase()) ||
- (menu.menuDescription?.toLowerCase().includes(searchQuery.toLowerCase()) ?? false);
+ translatedSection.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ translatedDescription.toLowerCase().includes(searchQuery.toLowerCase());
const matchesDomain = domainFilter === "all" || menu.domain === domainFilter;
- const matchesSection = sectionFilter === "all" || menu.sectionTitle === sectionFilter;
+ const matchesSection = sectionFilter === "all" || translatedSection === sectionFilter;
const matchesStatus = statusFilter === "all" ||
(statusFilter === "active" && menu.isActive) ||
(statusFilter === "inactive" && !menu.isActive);
return matchesSearch && matchesDomain && matchesSection && matchesStatus;
});
- }, [initialMenus, searchQuery, domainFilter, sectionFilter, statusFilter]);
+ }, [initialMenus, searchQuery, domainFilter, sectionFilter, statusFilter, safeTranslate]);
- // μ„Ήμ…˜ 리슀트 μΆ”μΆœ
+ // μ„Ήμ…˜ 리슀트 μΆ”μΆœ (λ²ˆμ—­λœ μ΄λ¦„μœΌλ‘œ)
const sections = useMemo(() => {
- const sectionSet = new Set(initialMenus.map(menu => menu.sectionTitle));
+ const sectionSet = new Set(initialMenus.map(menu => safeTranslate(menu.sectionTitle)));
return Array.from(sectionSet).sort();
- }, [initialMenus]);
+ }, [initialMenus, safeTranslate]);
// 도메인별 μ‚¬μš©μž 필터링
const getFilteredUsers = (domain: string) => {
@@ -163,12 +190,13 @@ export function MenuListTable({ initialMenus, initialUsers }: MenuListTableProps
</div>
</div>
- {/* κ²°κ³Ό μš”μ•½ */}
+ {/* κ²°κ³Ό μš”μ•½ 및 μ΄ˆκΈ°ν™” λ²„νŠΌ */}
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
총 {filteredMenus.length}개의 메뉴
{searchQuery && ` (${initialMenus.length}개 쀑 검색 κ²°κ³Ό)`}
</span>
+ <InitializeButton />
</div>
{/* ν…Œμ΄λΈ” */}
@@ -207,13 +235,13 @@ export function MenuListTable({ initialMenus, initialUsers }: MenuListTableProps
<TableCell>
<div className="space-y-1">
<div className="flex items-center gap-2">
- <span className="font-medium">{menu.menuTitle}</span>
+ <span className="font-medium">{(menu as any).translatedMenuTitle || safeTranslate(menu.menuTitle)}</span>
<Badge variant="outline" className="text-xs">
- {menu.sectionTitle}
+ {(menu as any).translatedSectionTitle || safeTranslate(menu.sectionTitle)}
</Badge>
{menu.menuGroup && (
<Badge variant="secondary" className="text-xs">
- {menu.menuGroup}
+ {(menu as any).translatedMenuGroup || safeTranslate(menu.menuGroup)}
</Badge>
)}
</div>
@@ -222,7 +250,7 @@ export function MenuListTable({ initialMenus, initialUsers }: MenuListTableProps
</div>
{menu.menuDescription && (
<div className="text-xs text-muted-foreground">
- {menu.menuDescription}
+ {(menu as any).translatedMenuDescription || safeTranslate(menu.menuDescription)}
</div>
)}
</div>
diff --git a/lib/notice/service.ts b/lib/notice/service.ts
index 24b03fe9..c261cd2e 100644
--- a/lib/notice/service.ts
+++ b/lib/notice/service.ts
@@ -6,7 +6,7 @@ import { unstable_cache } from "@/lib/unstable-cache"
import { filterColumns } from "@/lib/filter-columns"
import { asc, desc, ilike, and, or, eq } from "drizzle-orm"
import db from "@/db/db"
-import { notice, pageInformation } from "@/db/schema"
+import { notice, pageInformation, menuAssignments } from "@/db/schema"
import type {
CreateNoticeSchema,
@@ -321,4 +321,47 @@ export async function getPagePathList(): Promise<Array<{ pagePath: string; pageN
console.error("Failed to get page path list:", error)
return []
}
+}
+
+// menu_assignments 기반으둜 notice νŽ˜μ΄μ§€ 경둜 동기화
+export async function syncNoticeFromMenuAssignments() {
+ try {
+ // menu_assignmentsμ—μ„œ λͺ¨λ“  메뉴 κ°€μ Έμ˜€κΈ°
+ const menuItems = await db.select().from(menuAssignments);
+
+ // κΈ°μ‘΄ notice νŽ˜μ΄μ§€ κ²½λ‘œλ“€ κ°€μ Έμ˜€κΈ° (쀑볡 제거)
+ const existingNotices = await db.select({
+ pagePath: notice.pagePath
+ }).from(notice);
+ const existingPaths = new Set(existingNotices.map(item => item.pagePath));
+
+ let processedCount = 0;
+ const missingPaths = [];
+
+ // 각 메뉴 ν•­λͺ©μ— λŒ€ν•΄ 확인
+ for (const menu of menuItems) {
+ if (!existingPaths.has(menu.menuPath)) {
+ missingPaths.push({
+ pagePath: menu.menuPath,
+ pageName: menu.menuTitle
+ });
+ }
+ processedCount++;
+ }
+
+ revalidateTag("notice");
+
+ return {
+ success: true,
+ message: `곡지사항 경둜 동기화 확인 μ™„λ£Œ: ${processedCount}개 확인, ${missingPaths.length}개 λˆ„λ½`,
+ missingPaths: missingPaths
+ };
+ } catch (error) {
+ console.error("Notice 동기화 였λ₯˜:", error);
+ return {
+ success: false,
+ message: "곡지사항 경둜 동기화 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.",
+ missingPaths: []
+ };
+ }
} \ No newline at end of file
diff --git a/lib/pq/pq-review-table-new/edit-investigation-dialog.tsx b/lib/pq/pq-review-table-new/edit-investigation-dialog.tsx
index 7fd1c3f8..c5470e47 100644
--- a/lib/pq/pq-review-table-new/edit-investigation-dialog.tsx
+++ b/lib/pq/pq-review-table-new/edit-investigation-dialog.tsx
@@ -40,6 +40,9 @@ import {
PopoverTrigger,
} from "@/components/ui/popover"
import { z } from "zod"
+import { getInvestigationAttachments, deleteInvestigationAttachment } from "../service"
+import { downloadFile } from "@/lib/file-download"
+import { Download } from "lucide-react"
// Validation schema for editing investigation
const editInvestigationSchema = z.object({
@@ -74,6 +77,8 @@ export function EditInvestigationDialog({
}: EditInvestigationDialogProps) {
const [isPending, startTransition] = React.useTransition()
const [selectedFiles, setSelectedFiles] = React.useState<File[]>([])
+ const [existingAttachments, setExistingAttachments] = React.useState<any[]>([])
+ const [loadingAttachments, setLoadingAttachments] = React.useState(false)
const fileInputRef = React.useRef<HTMLInputElement>(null)
const form = useForm<EditInvestigationSchema>({
@@ -96,9 +101,67 @@ export function EditInvestigationDialog({
attachments: [],
})
setSelectedFiles([])
+
+ // κΈ°μ‘΄ μ²¨λΆ€νŒŒμΌ λ‘œλ“œ
+ loadExistingAttachments(investigation.id)
}
}, [investigation, form])
+ // κΈ°μ‘΄ μ²¨λΆ€νŒŒμΌ λ‘œλ“œ ν•¨μˆ˜
+ const loadExistingAttachments = async (investigationId: number) => {
+ setLoadingAttachments(true)
+ try {
+ const result = await getInvestigationAttachments(investigationId)
+ if (result.success) {
+ setExistingAttachments(result.attachments || [])
+ } else {
+ toast.error("μ²¨λΆ€νŒŒμΌ λͺ©λ‘μ„ λΆˆλŸ¬μ˜€λŠ”λ° μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.")
+ }
+ } catch (error) {
+ console.error("μ²¨λΆ€νŒŒμΌ λ‘œλ“œ μ‹€νŒ¨:", error)
+ toast.error("μ²¨λΆ€νŒŒμΌ λͺ©λ‘μ„ λΆˆλŸ¬μ˜€λŠ” 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.")
+ } finally {
+ setLoadingAttachments(false)
+ }
+ }
+
+ // μ²¨λΆ€νŒŒμΌ μ‚­μ œ ν•¨μˆ˜
+ const handleDeleteAttachment = async (attachmentId: number) => {
+ if (!investigation) return
+
+ try {
+ const result = await deleteInvestigationAttachment(attachmentId)
+ if (result.success) {
+ toast.success("μ²¨λΆ€νŒŒμΌμ΄ μ‚­μ œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.")
+ // λͺ©λ‘ μƒˆλ‘œκ³ μΉ¨
+ loadExistingAttachments(investigation.id)
+ } else {
+ toast.error(result.error || "μ²¨λΆ€νŒŒμΌ μ‚­μ œμ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.")
+ }
+ } catch (error) {
+ console.error("μ²¨λΆ€νŒŒμΌ μ‚­μ œ 였λ₯˜:", error)
+ toast.error("μ²¨λΆ€νŒŒμΌ μ‚­μ œ 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.")
+ }
+ }
+
+ // μ²¨λΆ€νŒŒμΌ λ‹€μš΄λ‘œλ“œ ν•¨μˆ˜
+ const handleDownloadAttachment = async (attachment: any) => {
+ if (!attachment.filePath || !attachment.fileName) {
+ toast.error("μ²¨λΆ€νŒŒμΌ 정보가 μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.")
+ return
+ }
+
+ try {
+ await downloadFile(attachment.filePath, attachment.fileName, {
+ showToast: true,
+ action: 'download'
+ })
+ } catch (error) {
+ console.error("μ²¨λΆ€νŒŒμΌ λ‹€μš΄λ‘œλ“œ 였λ₯˜:", error)
+ toast.error("μ²¨λΆ€νŒŒμΌ λ‹€μš΄λ‘œλ“œ 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.")
+ }
+ }
+
// 파일 선택 ν•Έλ“€λŸ¬
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || [])
@@ -145,7 +208,7 @@ export function EditInvestigationDialog({
return (
<Dialog open={isOpen} onOpenChange={onClose}>
- <DialogContent className="sm:max-w-[425px]">
+ <DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>싀사 정보 μˆ˜μ •</DialogTitle>
<DialogDescription>
@@ -246,6 +309,65 @@ export function EditInvestigationDialog({
<FormLabel>μ²¨λΆ€νŒŒμΌ</FormLabel>
<FormControl>
<div className="space-y-4">
+ {/* κΈ°μ‘΄ μ²¨λΆ€νŒŒμΌ λͺ©λ‘ */}
+ {(existingAttachments.length > 0 || loadingAttachments) && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">κΈ°μ‘΄ μ²¨λΆ€νŒŒμΌ</div>
+ <div className="border rounded-md p-3 space-y-2 max-h-32 overflow-y-auto">
+ {loadingAttachments ? (
+ <div className="flex items-center justify-center py-4">
+ <Loader className="h-4 w-4 animate-spin" />
+ <span className="ml-2 text-sm text-muted-foreground">
+ μ²¨λΆ€νŒŒμΌ λ‘œλ”© 쀑...
+ </span>
+ </div>
+ ) : existingAttachments.length > 0 ? (
+ existingAttachments.map((attachment) => (
+ <div key={attachment.id} className="flex items-center justify-between text-sm">
+ <div className="flex items-center space-x-2 flex-1 min-w-0">
+ <span className="text-xs px-2 py-1 bg-muted rounded">
+ {attachment.attachmentType || 'FILE'}
+ </span>
+ <span className="truncate">{attachment.fileName}</span>
+ <span className="text-muted-foreground">
+ ({attachment.fileSize ? Math.round(attachment.fileSize / 1024) : 0}KB)
+ </span>
+ </div>
+ <div className="flex items-center gap-1">
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => handleDownloadAttachment(attachment)}
+ className="text-blue-600 hover:text-blue-700"
+ disabled={isPending}
+ title="파일 λ‹€μš΄λ‘œλ“œ"
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => handleDeleteAttachment(attachment.id)}
+ className="text-destructive hover:text-destructive"
+ disabled={isPending}
+ title="파일 μ‚­μ œ"
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ ))
+ ) : (
+ <div className="text-sm text-muted-foreground text-center py-2">
+ μ²¨λΆ€λœ 파일이 μ—†μŠ΅λ‹ˆλ‹€.
+ </div>
+ )}
+ </div>
+ </div>
+ )}
+
{/* 파일 선택 μ˜μ—­ */}
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center">
<input
diff --git a/lib/pq/pq-review-table-new/site-visit-dialog.tsx b/lib/pq/pq-review-table-new/site-visit-dialog.tsx
index 172aed98..2b65d03e 100644
--- a/lib/pq/pq-review-table-new/site-visit-dialog.tsx
+++ b/lib/pq/pq-review-table-new/site-visit-dialog.tsx
@@ -138,7 +138,7 @@ interface SiteVisitDialogProps {
vendorCode: string
projectName?: string
projectCode?: string
- pqItems?: string | null
+ pqItems?: Array<{itemCode: string, itemName: string}> | null
}
}
@@ -315,7 +315,19 @@ export function SiteVisitDialog({
<div>
<FormLabel className="text-sm font-medium">λŒ€μƒν’ˆλͺ©</FormLabel>
<div className="mt-1 p-3 bg-muted rounded-md">
- <div className="font-medium">{investigation.pqItems || "-"}</div>
+ <div className="font-medium">
+ {investigation.pqItems && investigation.pqItems.length > 0
+ ? investigation.pqItems.map((item, index) => (
+ <div key={index} className="flex items-center gap-2">
+ <span className="text-xs px-2 py-1 bg-primary/10 rounded">
+ {item.itemCode}
+ </span>
+ <span>{item.itemName}</span>
+ </div>
+ ))
+ : "-"
+ }
+ </div>
</div>
</div>
</div>
diff --git a/lib/pq/service.ts b/lib/pq/service.ts
index f15790eb..989f8d5c 100644
--- a/lib/pq/service.ts
+++ b/lib/pq/service.ts
@@ -2710,7 +2710,7 @@ export async function sendInvestigationResultsAction(input: {
await tx.insert(vendorRegularRegistrations).values({
vendorId: investigation.vendorId,
- status: "audit_pass", // 싀사 톡과 μƒνƒœλ‘œ μ‹œμž‘
+ status: "under_review", // 싀사 톡과 μƒνƒœλ‘œ μ‹œμž‘
majorItems: majorItemsJson,
registrationRequestDate: new Date().toISOString().split('T')[0], // date νƒ€μž…μœΌλ‘œ λ³€ν™˜
remarks: `PQ 싀사 ν†΅κ³Όλ‘œ μžλ™ 생성 (PQ번호: ${investigation.pqNumber || 'N/A'})`,
@@ -3772,6 +3772,61 @@ export async function updateInvestigationDetailsAction(input: {
}
}
+// κ΅¬λ§€μžμ²΄ν‰κ°€ μ²¨λΆ€νŒŒμΌ 쑰회
+export async function getInvestigationAttachments(investigationId: number) {
+ try {
+ const attachments = await db
+ .select({
+ id: vendorInvestigationAttachments.id,
+ fileName: vendorInvestigationAttachments.fileName,
+ originalFileName: vendorInvestigationAttachments.originalFileName,
+ filePath: vendorInvestigationAttachments.filePath,
+ fileSize: vendorInvestigationAttachments.fileSize,
+ mimeType: vendorInvestigationAttachments.mimeType,
+ attachmentType: vendorInvestigationAttachments.attachmentType,
+ createdAt: vendorInvestigationAttachments.createdAt,
+ })
+ .from(vendorInvestigationAttachments)
+ .where(eq(vendorInvestigationAttachments.investigationId, investigationId))
+ .orderBy(desc(vendorInvestigationAttachments.createdAt));
+
+ return {
+ success: true,
+ attachments,
+ };
+ } catch (error) {
+ console.error("μ²¨λΆ€νŒŒμΌ 쑰회 였λ₯˜:", error);
+ return {
+ success: false,
+ error: "μ²¨λΆ€νŒŒμΌ 쑰회 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.",
+ attachments: [],
+ };
+ }
+}
+
+// κ΅¬λ§€μžμ²΄ν‰κ°€ μ²¨λΆ€νŒŒμΌ μ‚­μ œ
+export async function deleteInvestigationAttachment(attachmentId: number) {
+ try {
+ await db
+ .delete(vendorInvestigationAttachments)
+ .where(eq(vendorInvestigationAttachments.id, attachmentId));
+
+ revalidateTag("pq-submissions");
+ revalidatePath("/evcp/pq_new");
+
+ return {
+ success: true,
+ message: "μ²¨λΆ€νŒŒμΌμ΄ μ„±κ³΅μ μœΌλ‘œ μ‚­μ œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.",
+ };
+ } catch (error) {
+ console.error("μ²¨λΆ€νŒŒμΌ μ‚­μ œ 였λ₯˜:", error);
+ return {
+ success: false,
+ error: "μ²¨λΆ€νŒŒμΌ μ‚­μ œ 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.",
+ };
+ }
+}
+
export async function autoDeactivateExpiredPQLists() {
try {
const now = new Date();
diff --git a/lib/sedp/get-form-tags.ts b/lib/sedp/get-form-tags.ts
index 34f990f3..ef3a390d 100644
--- a/lib/sedp/get-form-tags.ts
+++ b/lib/sedp/get-form-tags.ts
@@ -41,7 +41,7 @@ interface Column {
key: string;
label: string;
type: string;
- shi?: boolean;
+ shi?: string | null;
}
/**
@@ -491,7 +491,7 @@ export async function importTagsFromSEDP(
if (Array.isArray(tagEntry.ATTRIBUTES)) {
for (const attr of tagEntry.ATTRIBUTES) {
const columnInfo = columnsJSON.find(col => col.key === attr.ATT_ID);
- if (columnInfo && columnInfo.shi === true) {
+ if (columnInfo && (columnInfo.shi === "BOTH" ||columnInfo.shi === "OUT" ||columnInfo.shi === null )) {
if (columnInfo.type === "NUMBER") {
if (attr.VALUE !== undefined && attr.VALUE !== null) {
if (typeof attr.VALUE === 'string') {
@@ -551,7 +551,7 @@ export async function importTagsFromSEDP(
}
const columnInfo = columnsJSON.find(col => col.key === key);
- if (columnInfo && columnInfo.shi === true) {
+ if (columnInfo && (columnInfo.shi === "BOTH" ||columnInfo.shi === "OUT" ||columnInfo.shi === null )) {
if (existingTag.data[key] !== tagObject[key]) {
updates[key] = tagObject[key];
hasUpdates = true;
@@ -663,7 +663,7 @@ export async function importTagsFromSEDP(
// 각 νƒœκ·Έλ₯Ό ν™•μΈν•˜μ—¬ μ‹ κ·œ/μ—…λ°μ΄νŠΈ λΆ„λ₯˜
for (const tagRecord of upsertTagRecords) {
- const existingTagRecord = existingTagsMap.get(tagRecord.tagNo);
+ const existingTagRecord = existingTagsMap.get(tagRecord.tagIdx);
if (existingTagRecord) {
// κΈ°μ‘΄ νƒœκ·Έκ°€ 있으면 μ—…λ°μ΄νŠΈ μ€€λΉ„
diff --git a/lib/sedp/sync-form.ts b/lib/sedp/sync-form.ts
index 1f903c78..e3c3f6bb 100644
--- a/lib/sedp/sync-form.ts
+++ b/lib/sedp/sync-form.ts
@@ -248,7 +248,7 @@ interface FormColumn {
options?: string[];
uom?: string;
uomId?: string;
- shi?: Boolean;
+ shi?:string | null;
hidden?: boolean;
seq?: number;
head?: string;
@@ -983,7 +983,7 @@ export async function saveFormMappingsAndMetas(
if (!attribute) continue;
const tmplMeta = templateAttrMap.get(attId);
- const isShi = mapAtt.INOUT === null || mapAtt.INOUT === "OUT";
+ const isShi = mapAtt.INOUT;
let uomSymbol: string | undefined; let uomId: string | undefined;
if (legacy?.LNK_ATT) {
diff --git a/lib/sedp/sync-object-class.ts b/lib/sedp/sync-object-class.ts
index 1a325407..d48a8e7c 100644
--- a/lib/sedp/sync-object-class.ts
+++ b/lib/sedp/sync-object-class.ts
@@ -115,6 +115,43 @@ export async function getCodeListsByID(projectCode: string): Promise<SubClassCod
}
}
+function collectInheritedAttributes(
+ classId: string,
+ allClasses: ObjectClass[]
+): LinkAttribute[] {
+ const classMap = new Map(allClasses.map(cls => [cls.CLS_ID, cls]));
+ const collectedAttributes: LinkAttribute[] = [];
+ const seenAttributeIds = new Set<string>();
+
+ // ν˜„μž¬ ν΄λž˜μŠ€λΆ€ν„° μ‹œμž‘ν•΄μ„œ λΆ€λͺ¨λ₯Ό 따라 μ˜¬λΌκ°€λ©° 속성 μˆ˜μ§‘
+ function collectFromClass(currentClassId: string | null): void {
+ if (!currentClassId) return;
+
+ const currentClass = classMap.get(currentClassId);
+ if (!currentClass) return;
+
+ // λ¨Όμ € λΆ€λͺ¨ 클래슀의 속성을 μˆ˜μ§‘ (μƒμœ„λΆ€ν„° ν•˜μœ„ μˆœμ„œλ‘œ)
+ if (currentClass.PRT_CLS_ID) {
+ collectFromClass(currentClass.PRT_CLS_ID);
+ }
+
+ // ν˜„μž¬ 클래슀의 LNK_ATT μΆ”κ°€ (쀑볡 제거)
+ if (currentClass.LNK_ATT && Array.isArray(currentClass.LNK_ATT)) {
+ for (const attr of currentClass.LNK_ATT) {
+ if (!seenAttributeIds.has(attr.ATT_ID)) {
+ seenAttributeIds.add(attr.ATT_ID);
+ collectedAttributes.push(attr);
+ }
+ }
+ }
+ }
+
+ collectFromClass(classId);
+
+ // SEQ μˆœμ„œλ‘œ μ •λ ¬
+ return collectedAttributes.sort((a, b) => (a.SEQ || 0) - (b.SEQ || 0));
+}
+
// νƒœκ·Έ 클래슀 속성 μ €μž₯ ν•¨μˆ˜
async function saveTagClassAttributes(
tagClassId: number,
@@ -579,15 +616,18 @@ async function saveObjectClassesToDatabase(
totalChanged += toInsert.length;
console.log(`ν”„λ‘œμ νŠΈ ID ${projectId}에 ${toInsert.length}개의 μƒˆ 였브젝트 클래슀 μΆ”κ°€ μ™„λ£Œ`);
- // μƒˆλ‘œ μ‚½μž…λœ 각 클래슀의 LNK_ATT 속성 처리
+ // μƒˆλ‘œ μ‚½μž…λœ 각 클래슀의 μƒμ†λœ LNK_ATT 속성 처리
for (const insertedClass of insertedClasses) {
- const originalClass = classesToSave.find(cls => cls.CLS_ID === insertedClass.code);
- if (originalClass && originalClass.LNK_ATT && originalClass.LNK_ATT.length > 0) {
- try {
- await saveTagClassAttributes(insertedClass.id, originalClass.LNK_ATT);
- } catch (error) {
- console.error(`νƒœκ·Έ 클래슀 ${insertedClass.code}의 속성 μ €μž₯ μ‹€νŒ¨:`, error);
+ try {
+ // πŸ”₯ μˆ˜μ •λœ λΆ€λΆ„: μƒμ†λœ 속성 μˆ˜μ§‘
+ const inheritedAttributes = collectInheritedAttributes(insertedClass.code, classes);
+
+ if (inheritedAttributes.length > 0) {
+ await saveTagClassAttributes(insertedClass.id, inheritedAttributes);
+ console.log(`νƒœκ·Έ 클래슀 ${insertedClass.code}에 ${inheritedAttributes.length}개의 μƒμ†λœ 속성 μ €μž₯ μ™„λ£Œ`);
}
+ } catch (error) {
+ console.error(`νƒœκ·Έ 클래슀 ${insertedClass.code}의 속성 μ €μž₯ μ‹€νŒ¨:`, error);
}
}
}
@@ -621,13 +661,14 @@ async function saveObjectClassesToDatabase(
.limit(1);
if (updatedClass.length > 0) {
- const originalClass = classesToSave.find(cls => cls.CLS_ID === item.code);
- if (originalClass && originalClass.LNK_ATT) {
- try {
- await saveTagClassAttributes(updatedClass[0].id, originalClass.LNK_ATT);
- } catch (error) {
- console.error(`νƒœκ·Έ 클래슀 ${item.code}의 속성 μ—…λ°μ΄νŠΈ μ‹€νŒ¨:`, error);
- }
+ try {
+ // πŸ”₯ μˆ˜μ •λœ λΆ€λΆ„: μƒμ†λœ 속성 μˆ˜μ§‘
+ const inheritedAttributes = collectInheritedAttributes(item.code, classes);
+
+ await saveTagClassAttributes(updatedClass[0].id, inheritedAttributes);
+ console.log(`νƒœκ·Έ 클래슀 ${item.code}에 ${inheritedAttributes.length}개의 μƒμ†λœ 속성 μ—…λ°μ΄νŠΈ μ™„λ£Œ`);
+ } catch (error) {
+ console.error(`νƒœκ·Έ 클래슀 ${item.code}의 속성 μ—…λ°μ΄νŠΈ μ‹€νŒ¨:`, error);
}
}
diff --git a/lib/site-visit/client-site-visit-wrapper.tsx b/lib/site-visit/client-site-visit-wrapper.tsx
index ad8da632..b92eda3b 100644
--- a/lib/site-visit/client-site-visit-wrapper.tsx
+++ b/lib/site-visit/client-site-visit-wrapper.tsx
@@ -71,7 +71,7 @@ interface SiteVisitRequest {
resultNotes: string | null
// PQ 정보
- pqItems: string | null
+ pqItems: string | null | Array<{itemCode: string, itemName: string}>
// μš”μ²­μž 정보
requesterName: string | null
@@ -292,7 +292,22 @@ export function ClientSiteVisitWrapper({
</TableCell>
<TableCell>
{/* μ‹€μ‚¬ν’ˆλͺ© - PQμ—μ„œ κ°€μ Έμ˜¨ 정보 ν‘œμ‹œ */}
- {request.pqItems || "-"}
+ {request.pqItems ? (
+ typeof request.pqItems === 'string' ? (
+ request.pqItems
+ ) : Array.isArray(request.pqItems) && request.pqItems.length > 0 ? (
+ <div className="space-y-1">
+ {request.pqItems.map((item, index) => (
+ <div key={index} className="flex items-center gap-2">
+ <span className="text-xs px-2 py-1 bg-primary/10 rounded">
+ {item.itemCode}
+ </span>
+ <span className="text-sm">{item.itemName}</span>
+ </div>
+ ))}
+ </div>
+ ) : "-"
+ ) : "-"}
</TableCell>
<TableCell>
<Badge variant="outline">
diff --git a/lib/site-visit/shi-attendees-dialog.tsx b/lib/site-visit/shi-attendees-dialog.tsx
index 80681cb5..3d7d94a1 100644
--- a/lib/site-visit/shi-attendees-dialog.tsx
+++ b/lib/site-visit/shi-attendees-dialog.tsx
@@ -36,7 +36,7 @@ interface SiteVisitRequest {
resultNotes: string | null
// PQ 정보
- pqItems: string | null
+ pqItems: string | null | Array<{itemCode: string, itemName: string}>
// μš”μ²­μž 정보
requesterName: string | null
diff --git a/lib/site-visit/site-visit-detail-dialog.tsx b/lib/site-visit/site-visit-detail-dialog.tsx
index 18ab6bb5..51aeb40a 100644
--- a/lib/site-visit/site-visit-detail-dialog.tsx
+++ b/lib/site-visit/site-visit-detail-dialog.tsx
@@ -42,7 +42,7 @@ interface SiteVisitRequest {
resultNotes: string | null
// PQ 정보
- pqItems: string | null
+ pqItems: string | null | Array<{itemCode: string, itemName: string}>
// μš”μ²­μž 정보
requesterName: string | null
diff --git a/lib/tags/service.ts b/lib/tags/service.ts
index bb59287e..cef20209 100644
--- a/lib/tags/service.ts
+++ b/lib/tags/service.ts
@@ -473,6 +473,9 @@ export async function createTagInForm(
projectId,
validated.data.class
)
+
+
+
// epκ°€ "IMEP"인 κ²ƒλ§Œ 필터링
const formMappings = allFormMappings?.filter(mapping => mapping.ep === "IMEP") || []
@@ -566,12 +569,16 @@ export async function createTagInForm(
console.log(`[CREATE TAG IN FORM] Existing data count: ${existingData.length}`);
+ const tagClass = await db.query.tagClasses.findFirst({
+ where: and(eq(tagClasses.projectId, projectId),eq(tagClasses.label, validated.data.class))
+ });
+
// 8) μƒˆλ‘œμš΄ νƒœκ·Έλ₯Ό κΈ°μ‘΄ 데이터에 μΆ”κ°€ (TAG_IDX 포함)
const newTagData = {
TAG_IDX: generatedTagIdx, // πŸ†• 같은 16μ§„μˆ˜ 24자리 κ°’ μ‚¬μš©
TAG_NO: validated.data.tagNo,
TAG_DESC: validated.data.description ?? null,
- CLS_ID: validated.data.class,
+ CLS_ID: tagClass.code,
VNDRCD: vendor.vendorCode,
VNDRNM_1: vendor.vendorName,
CM3003: packageCode,
diff --git a/lib/users/auth/partners-auth.ts b/lib/users/auth/partners-auth.ts
index 5418e2a8..ac0dec08 100644
--- a/lib/users/auth/partners-auth.ts
+++ b/lib/users/auth/partners-auth.ts
@@ -8,7 +8,7 @@ import crypto from 'crypto';
import { PasswordStrength, passwordResetRequestSchema, passwordResetSchema } from './validataions-password';
import { sendEmail } from '@/lib/mail/sendEmail';
import { analyzePasswordStrength, checkPasswordHistory, validatePasswordPolicy } from '@/lib/users/auth/passwordUtil';
-
+import { headers } from 'next/headers';
export interface PasswordValidationResult {
strength: PasswordStrength;
@@ -73,7 +73,13 @@ export async function requestPasswordResetAction(
});
// μž¬μ„€μ • 링크 생성
- const resetLink = `${process.env.NEXTAUTH_URL}/auth/reset-password?token=${resetToken}`;
+ const headersList = await headers();
+ const host = headersList.get('host') || 'localhost:3000';
+ // 둜그인 λ˜λŠ” μ„œλͺ… νŽ˜μ΄μ§€ URL 생성
+ // const baseUrl = `http://${host}`
+ const baseUrl = process.env.NEXT_PUBLIC_DMZ_URL || `https://partners.sevcp.com`
+
+ const resetLink = `${baseUrl}/auth/reset-password?token=${resetToken}`;
// 이메일 전솑
await sendEmail({
diff --git a/lib/users/auth/passwordUtil.ts b/lib/users/auth/passwordUtil.ts
index 54599761..4d342a61 100644
--- a/lib/users/auth/passwordUtil.ts
+++ b/lib/users/auth/passwordUtil.ts
@@ -12,6 +12,13 @@ import {
mfaTokens
} from '@/db/schema';
+// libphonenumber-js import μΆ”κ°€
+import {
+ parsePhoneNumber,
+ parsePhoneNumberFromString,
+ isValidPhoneNumber
+} from 'libphonenumber-js';
+
export interface PasswordStrength {
score: number; // 1-5
hasUppercase: boolean;
@@ -309,7 +316,62 @@ async function cleanupPasswordHistory(userId: number, keepCount: number) {
}
}
-// MFA SMS 토큰 생성 및 전솑
+// ========== SMS κ΄€λ ¨ ν•¨μˆ˜λ“€ ==========
+
+// μ „ν™”λ²ˆν˜Έμ—μ„œ κ΅­κ°€ 정보 μΆ”μΆœ (libphonenumber-js μ‚¬μš©)
+function extractCountryInfo(phoneNumber: string): {
+ countryCode: string;
+ nationalNumber: string;
+ country?: string;
+} | null {
+ try {
+ let parsed;
+
+ // E.164 ν˜•μ‹μΈμ§€ 확인
+ if (phoneNumber.startsWith('+')) {
+ parsed = parsePhoneNumber(phoneNumber);
+ } else {
+ // κ΅­κ°€ μ½”λ“œκ°€ μ—†μœΌλ©΄ ν•œκ΅­μœΌλ‘œ κ°€μ • (κΈ°λ³Έκ°’)
+ parsed = parsePhoneNumberFromString(phoneNumber, 'KR');
+ }
+
+ if (!parsed || !isValidPhoneNumber(parsed.number)) {
+ return null;
+ }
+
+ const countryCallingCode = parsed.countryCallingCode;
+ let nationalNumber = parsed.nationalNumber;
+
+ // ꡭ가별 νŠΉλ³„ 처리
+ switch (countryCallingCode) {
+ case '82': // ν•œκ΅­
+ // ν•œκ΅­ λ²ˆν˜ΈλŠ” 0으둜 μ‹œμž‘ν•΄μ•Ό 함
+ if (!nationalNumber.startsWith('0')) {
+ nationalNumber = '0' + nationalNumber;
+ }
+ break;
+ case '1': // λ―Έκ΅­/μΊλ‚˜λ‹€
+ // 이미 μ˜¬λ°”λ₯Έ ν˜•μ‹
+ break;
+ case '81': // 일본
+ // 이미 μ˜¬λ°”λ₯Έ ν˜•μ‹
+ break;
+ case '86': // 쀑ꡭ
+ // 이미 μ˜¬λ°”λ₯Έ ν˜•μ‹
+ break;
+ }
+
+ return {
+ countryCode: countryCallingCode,
+ nationalNumber: nationalNumber.replace(/[-\s]/g, ''), // ν•˜μ΄ν”ˆκ³Ό 곡백 제거
+ country: parsed.country
+ };
+ } catch (error) {
+ console.error('Country info extraction error:', error);
+ return null;
+ }
+}
+
// Bizppurio API 토큰 λ°œκΈ‰
async function getBizppurioToken(): Promise<string> {
const account = process.env.BIZPPURIO_ACCOUNT;
@@ -337,7 +399,7 @@ async function getBizppurioToken(): Promise<string> {
return data.accesstoken;
}
-// SMS λ©”μ‹œμ§€ 전솑
+// SMS λ©”μ‹œμ§€ 전솑 (libphonenumber-js μ‚¬μš©)
async function sendSmsMessage(phoneNumber: string, message: string): Promise<boolean> {
try {
const accessToken = await getBizppurioToken();
@@ -348,48 +410,32 @@ async function sendSmsMessage(phoneNumber: string, message: string): Promise<boo
throw new Error('Bizppurio configuration missing');
}
- // phoneNumberμ—μ„œ κ΅­κ°€μ½”λ“œμ™€ 번호 뢄리
- let country = '';
- let to = phoneNumber;
-
- if (phoneNumber.startsWith('+82')) {
- country = '82';
- to = phoneNumber.substring(3);
- // ν•œκ΅­ λ²ˆν˜ΈλŠ” 0으둜 μ‹œμž‘ν•˜μ§€ μ•ŠλŠ” 경우 0 μΆ”κ°€
- if (!to.startsWith('0')) {
- to = '0' + to;
- }
- } else if (phoneNumber.startsWith('+1')) {
- country = '1';
- to = phoneNumber.substring(2);
- } else if (phoneNumber.startsWith('+81')) {
- country = '81';
- to = phoneNumber.substring(3);
- } else if (phoneNumber.startsWith('+86')) {
- country = '86';
- to = phoneNumber.substring(3);
+ // libphonenumber-jsλ₯Ό μ‚¬μš©ν•˜μ—¬ μ „ν™”λ²ˆν˜Έ νŒŒμ‹±
+ const countryInfo = extractCountryInfo(phoneNumber);
+
+ if (!countryInfo) {
+ throw new Error(`Invalid phone number format: ${phoneNumber}`);
}
- // κ΅­κ°€μ½”λ“œκ°€ μ—†λŠ” 경우 ν•œκ΅­μœΌλ‘œ κ°€μ •
- else if (!phoneNumber.startsWith('+')) {
- country = '82';
- to = phoneNumber.replace(/-/g, ''); // ν•˜μ΄ν”ˆ 제거
- if (!to.startsWith('0')) {
- to = '0' + to;
- }
+
+ console.log(`Sending SMS to ${phoneNumber}:`);
+ console.log(` Country Code: ${countryInfo.countryCode}`);
+ console.log(` National Number: ${countryInfo.nationalNumber}`);
+ if (countryInfo.country) {
+ console.log(` Country: ${countryInfo.country}`);
}
const requestBody = {
account: account,
type: 'sms',
from: fromNumber,
- to: to,
- country: country,
+ to: countryInfo.nationalNumber,
+ country: countryInfo.countryCode,
content: {
sms: {
message: message
}
},
- refkey: `sms_${Date.now()}_${Math.random().toString(36).substring(7)}` // κ³ κ°μ‚¬μ—μ„œ λΆ€μ—¬ν•œ ν‚€
+ refkey: `sms_${Date.now()}_${Math.random().toString(36).substring(7)}`
};
const response = await fetch('https://api.bizppurio.com/v3/message', {
@@ -409,7 +455,7 @@ async function sendSmsMessage(phoneNumber: string, message: string): Promise<boo
const result = await response.json();
if (result.code === 1000) {
- console.log(`SMS sent successfully. MessageKey: ${result.messagekey}`);
+ console.log(`SMS sent successfully to ${phoneNumber}. MessageKey: ${result.messagekey}`);
return true;
} else {
throw new Error(`SMS send failed: ${result.description} (Code: ${result.code})`);
@@ -420,7 +466,7 @@ async function sendSmsMessage(phoneNumber: string, message: string): Promise<boo
}
}
-
+// SMS ν…œν”Œλ¦Ώ (κΈ°μ‘΄ μœ μ§€)
const SMS_TEMPLATES = {
'82': '[인증번호] {token}', // ν•œκ΅­
'1': '[Verification Code] {token}', // λ―Έκ΅­
@@ -429,25 +475,63 @@ const SMS_TEMPLATES = {
'default': '[Verification Code] {token}' // κΈ°λ³Έκ°’ (μ˜μ–΄)
} as const;
-function getSmsMessage(country: string, token: string): string {
- const template = SMS_TEMPLATES[country as keyof typeof SMS_TEMPLATES] || SMS_TEMPLATES.default;
- return template.replace('{token}', token);
+// SMS λ©”μ‹œμ§€ 생성 (libphonenumber-js μ‚¬μš©)
+function getSmsMessage(phoneNumber: string, token: string): string {
+ try {
+ const countryInfo = extractCountryInfo(phoneNumber);
+ if (!countryInfo) {
+ return SMS_TEMPLATES.default.replace('{token}', token);
+ }
+
+ const template = SMS_TEMPLATES[countryInfo.countryCode as keyof typeof SMS_TEMPLATES] || SMS_TEMPLATES.default;
+ return template.replace('{token}', token);
+ } catch (error) {
+ return SMS_TEMPLATES.default.replace('{token}', token); // μ—λŸ¬ μ‹œ κΈ°λ³Έκ°’
+ }
+}
+
+// μ „ν™”λ²ˆν˜Έ μ •κ·œν™” (μ €μž₯용)
+export function normalizePhoneNumber(phoneNumber: string, countryCode?: string): string | null {
+ try {
+ let parsed;
+
+ if (countryCode) {
+ // κ΅­κ°€ μ½”λ“œκ°€ 제곡된 경우
+ parsed = parsePhoneNumberFromString(phoneNumber, countryCode);
+ } else {
+ // κ΅­κ°€ μ½”λ“œκ°€ μ—†λŠ” 경우 ꡭ제 ν˜•μ‹μœΌλ‘œ νŒŒμ‹± μ‹œλ„
+ parsed = parsePhoneNumber(phoneNumber);
+ }
+
+ if (!parsed || !isValidPhoneNumber(parsed.number)) {
+ return null;
+ }
+
+ // 항상 ꡭ제 ν˜•μ‹μœΌλ‘œ λ°˜ν™˜ (예: +821012345678)
+ return parsed.format('E.164');
+ } catch (error) {
+ console.error('Phone number normalization error:', error);
+ return null;
+ }
}
-// μ—…λ°μ΄νŠΈλœ 메인 ν•¨μˆ˜
+// SMS 토큰 생성 및 전솑 (μ—…λ°μ΄νŠΈλ¨)
export async function generateAndSendSmsToken(
userId: number,
phoneNumber: string
): Promise<{ success: boolean; error?: string }> {
try {
+ // μ „ν™”λ²ˆν˜Έ μœ νš¨μ„± 검사
+ if (!isValidPhoneNumber(phoneNumber)) {
+ return { success: false, error: 'μœ νš¨ν•˜μ§€ μ•Šμ€ μ „ν™”λ²ˆν˜Έμž…λ‹ˆλ‹€' };
+ }
+
// 1. 일일 SMS ν•œλ„ 체크
const settings = await db.select().from(securitySettings).limit(1);
const maxSmsPerDay = settings[0]?.maxSmsAttemptsPerDay || 10;
const today = new Date();
today.setHours(0, 0, 0, 0);
- const tomorrow = new Date(today);
- tomorrow.setDate(tomorrow.getDate() + 1);
const todayCount = await db
.select({ count: count() })
@@ -482,34 +566,24 @@ export async function generateAndSendSmsToken(
const expiresAt = new Date();
expiresAt.setMinutes(expiresAt.getMinutes() + expiryMinutes);
+ // μ „ν™”λ²ˆν˜Έ μ •κ·œν™” (μ €μž₯용)
+ const normalizedPhone = normalizePhoneNumber(phoneNumber);
+ if (!normalizedPhone) {
+ return { success: false, error: 'μ „ν™”λ²ˆν˜Έ ν˜•μ‹μ΄ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€' };
+ }
+
await db.insert(mfaTokens).values({
userId,
token,
type: 'sms',
- phoneNumber,
+ phoneNumber: normalizedPhone, // μ •κ·œν™”λœ 번호둜 μ €μž₯
expiresAt,
isActive: true,
});
-
- let country = '';
-
- if (phoneNumber.startsWith('+82')) {
- country = '82';
- } else if (phoneNumber.startsWith('+1')) {
- country = '1';
- } else if (phoneNumber.startsWith('+81')) {
- country = '81';
- } else if (phoneNumber.startsWith('+86')) {
- country = '86';
- }
- // κ΅­κ°€μ½”λ“œκ°€ μ—†λŠ” 경우 ν•œκ΅­μœΌλ‘œ κ°€μ •
- else if (!phoneNumber.startsWith('+')) {
- country = '82';
- }
- // 4. SMS 전솑 (Bizppurio API μ‚¬μš©)
- const message = getSmsMessage(country, token);
- const smsResult = await sendSmsMessage(phoneNumber, message);
+ // 4. SMS 전솑
+ const message = getSmsMessage(normalizedPhone, token);
+ const smsResult = await sendSmsMessage(normalizedPhone, message);
if (!smsResult) {
// SMS 전솑 μ‹€νŒ¨ μ‹œ 토큰 λΉ„ν™œμ„±ν™”
@@ -526,7 +600,7 @@ export async function generateAndSendSmsToken(
return { success: false, error: 'SMS 전솑에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€' };
}
- console.log(`SMS 토큰 ${token}을 ${phoneNumber}둜 μ „μ†‘ν–ˆμŠ΅λ‹ˆλ‹€`);
+ console.log(`SMS 토큰을 ${normalizedPhone}둜 μ „μ†‘ν–ˆμŠ΅λ‹ˆλ‹€`);
return { success: true };
} catch (error) {
@@ -534,6 +608,7 @@ export async function generateAndSendSmsToken(
return { success: false, error: 'SMS 전솑 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€' };
}
}
+
// SMS 토큰 검증
export async function verifySmsToken(
userId: number,
diff --git a/lib/vendor-document-list/dolce-upload-service.ts b/lib/vendor-document-list/dolce-upload-service.ts
index 84ae4525..8835c0b0 100644
--- a/lib/vendor-document-list/dolce-upload-service.ts
+++ b/lib/vendor-document-list/dolce-upload-service.ts
@@ -718,7 +718,8 @@ class DOLCEUploadService {
return {
Mode: mode,
- Status: revision.revisionStatus || "Standby",
+ // Status: revision.revisionStatus || "Standby",
+ Status: "Standby",
RegisterId: revision.registerId || 0, // registerIdκ°€ μ—†μœΌλ©΄ 0 (ADD λͺ¨λ“œ)
ProjectNo: contractInfo.projectCode,
Discipline: revision.discipline || "DL",
diff --git a/lib/vendor-document-list/enhanced-document-service.ts b/lib/vendor-document-list/enhanced-document-service.ts
index f2d9c26f..7464b13f 100644
--- a/lib/vendor-document-list/enhanced-document-service.ts
+++ b/lib/vendor-document-list/enhanced-document-service.ts
@@ -1173,6 +1173,139 @@ export async function getDocumentDetails(documentId: number) {
}
}
+ export async function getUserVendorDocumentsAll(
+ userId: number,
+ input: GetVendorShipDcoumentsSchema
+ ) {
+ try {
+
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.id) {
+ throw new Error("인증이 ν•„μš”ν•©λ‹ˆλ‹€.")
+ }
+
+
+
+ const offset = (input.page - 1) * input.perPage
+
+
+
+ // 3. κ³ κΈ‰ ν•„ν„° 처리
+ const advancedWhere = filterColumns({
+ table: simplifiedDocumentsView,
+ filters: input.filters || [],
+ joinOperator: input.joinOperator || "and",
+ })
+
+ // 4. μ „μ—­ 검색 처리
+ let globalWhere
+ if (input.search) {
+ const searchTerm = `%${input.search}%`
+ globalWhere = or(
+ ilike(simplifiedDocumentsView.title, searchTerm),
+ ilike(simplifiedDocumentsView.docNumber, searchTerm),
+ ilike(simplifiedDocumentsView.vendorDocNumber, searchTerm),
+ )
+ }
+
+ // 5. μ΅œμ’… WHERE 쑰건 (계약 IDλ“€λ‘œ 필터링)
+ const finalWhere = and(
+ advancedWhere,
+ globalWhere,
+ )
+
+ // 6. μ •λ ¬ 처리
+ const orderBy = input.sort && input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc
+ ? desc(simplifiedDocumentsView[item.id])
+ : asc(simplifiedDocumentsView[item.id])
+ )
+ : [desc(simplifiedDocumentsView.createdAt)]
+
+ // 7. νŠΈλžœμž­μ…˜ μ‹€ν–‰
+ const { data, total, drawingKind, vendorInfo } = await db.transaction(async (tx) => {
+ // 데이터 쑰회
+ const data = await tx
+ .select()
+ .from(simplifiedDocumentsView)
+ .where(finalWhere)
+ .orderBy(...orderBy)
+ .limit(input.perPage)
+ .offset(offset)
+
+ // 총 개수 쑰회
+ const [{ total }] = await tx
+ .select({
+ total: count()
+ })
+ .from(simplifiedDocumentsView)
+ .where(finalWhere)
+
+ // DrawingKind 뢄석 (첫 번째 λ¬Έμ„œμ˜ drawingKind μ‚¬μš©)
+ const drawingKind = data.length > 0 ? data[0].drawingKind : null
+
+ // 벀더 정보 쑰회
+
+
+ return { data, total, drawingKind }
+ })
+
+ const pageCount = Math.ceil(total / input.perPage)
+
+ return {
+ data,
+ pageCount,
+ total,
+ drawingKind: drawingKind as 'B3' | 'B4' | 'B5' | null,
+ }
+ } catch (err) {
+ console.error("Error fetching user vendor documents:", err)
+ return { data: [], pageCount: 0, total: 0, drawingKind: null }
+ }
+ }
+
+ /**
+ * DrawingKind별 λ¬Έμ„œ 톡계 쑰회
+ */
+ export async function getUserVendorDocumentStatsAll(userId: number) {
+ try {
+
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.id) {
+ throw new Error("인증이 ν•„μš”ν•©λ‹ˆλ‹€.")
+ }
+
+
+ // DrawingKind별 톡계 쑰회
+ const documents = await db
+ .select({
+ drawingKind: simplifiedDocumentsView.drawingKind,
+ })
+ .from(simplifiedDocumentsView)
+
+ // 톡계 계산
+ const stats = documents.reduce((acc, doc) => {
+ if (doc.drawingKind) {
+ acc[doc.drawingKind] = (acc[doc.drawingKind] || 0) + 1
+ }
+ return acc
+ }, {} as Record<string, number>)
+
+ // κ°€μž₯ λ§Žμ€ DrawingKind μ°ΎκΈ°
+ const primaryDrawingKind = Object.entries(stats)
+ .sort(([,a], [,b]) => b - a)[0]?.[0] as 'B3' | 'B4' | 'B5' | undefined
+
+ return {
+ stats,
+ totalDocuments: documents.length,
+ primaryDrawingKind: primaryDrawingKind || null
+ }
+ } catch (err) {
+ console.error("Error fetching user vendor document stats:", err)
+ return { stats: {}, totalDocuments: 0, primaryDrawingKind: null }
+ }
+ }
export interface UpdateRevisionInput {
diff --git a/lib/vendor-document-list/import-service.ts b/lib/vendor-document-list/import-service.ts
index 216e373e..61670c79 100644
--- a/lib/vendor-document-list/import-service.ts
+++ b/lib/vendor-document-list/import-service.ts
@@ -1076,6 +1076,7 @@ class ImportService {
// DOLCE 상세 데이터λ₯Ό revisions μŠ€ν‚€λ§ˆμ— 맞게 λ³€ν™˜
const revisionData = {
+ serialNo:detailDoc.RegisterSerialNo ,
issueStageId,
revision: detailDoc.DrawingRevNo,
uploaderType,
@@ -1096,6 +1097,7 @@ class ImportService {
existingRevision.revision !== revisionData.revision ||
existingRevision.revisionStatus !== revisionData.revisionStatus ||
existingRevision.uploaderName !== revisionData.uploaderName ||
+ existingRevision.serialNo !== revisionData.serialNo ||
existingRevision.registerId !== revisionData.registerId // πŸ†• registerId λ³€κ²½ 확인
if (hasChanges) {
diff --git a/lib/vendor-document-list/ship-all/enhanced-doc-table-columns.tsx b/lib/vendor-document-list/ship-all/enhanced-doc-table-columns.tsx
new file mode 100644
index 00000000..6c9a9ab6
--- /dev/null
+++ b/lib/vendor-document-list/ship-all/enhanced-doc-table-columns.tsx
@@ -0,0 +1,540 @@
+// simplified-doc-table-columns.tsx
+"use client"
+
+import * as React from "react"
+import { ColumnDef } from "@tanstack/react-table"
+import { formatDate, formatDateTime } from "@/lib/utils"
+import { Checkbox } from "@/components/ui/checkbox"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { DataTableRowAction } from "@/types/table"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { Button } from "@/components/ui/button"
+import {
+ Ellipsis,
+ FileText,
+ Eye,
+ Edit,
+ Trash2,
+} from "lucide-react"
+import { cn } from "@/lib/utils"
+import { SimplifiedDocumentsView } from "@/db/schema"
+
+// DocumentSelectionContextλ₯Ό import (μ‹€μ œ 파일 κ²½λ‘œμ— 맞게 μˆ˜μ • ν•„μš”)
+// 예: import { DocumentSelectionContextAll } from "../user-vendor-document-display"
+// λ˜λŠ”: import { DocumentSelectionContextAll } from "./user-vendor-document-display"
+import { DocumentSelectionContextAll } from "@/components/ship-vendor-document-all/user-vendor-document-table-container"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<SimplifiedDocumentsView> | null>>
+}
+
+// λ‚ μ§œ ν‘œμ‹œ μ»΄ν¬λ„ŒνŠΈ (간단 버전)
+const DateDisplay = ({ date, isSelected = false }: { date: string | null, isSelected?: boolean }) => {
+ if (!date) return <span className="text-gray-400">-</span>
+
+ return (
+ <span className={cn(
+ "text-sm",
+ isSelected && "text-blue-600 font-semibold"
+ )}>
+ {formatDate(date)}
+ </span>
+ )
+}
+
+export function getSimplifiedDocumentColumns({
+ setRowAction,
+}: GetColumnsProps): ColumnDef<SimplifiedDocumentsView>[] {
+
+ const columns: ColumnDef<SimplifiedDocumentsView>[] = [
+ // λΌλ””μ˜€ λ²„νŠΌ 같은 μ²΄ν¬λ°•μŠ€ 선택
+ {
+ id: "select",
+ header: ({ table }) => (
+ <div className="flex items-center justify-center">
+ <span className="text-xs text-gray-500">Select</span>
+ </div>
+ ),
+ cell: ({ row }) => {
+ const doc = row.original
+
+ return (
+ <SelectCell documentId={doc.documentId} />
+ )
+ },
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ },
+
+ // λ¬Έμ„œλ²ˆν˜Έ (μ„ νƒλœ ν–‰ ν•˜μ΄λΌμ΄νŠΈ 적용)
+ {
+ accessorKey: "docNumber",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Document No" />
+ ),
+ cell: ({ row }) => {
+ const doc = row.original
+
+ return (
+ <DocNumberCell doc={doc} />
+ )
+ },
+ size: 120,
+ enableResizing: true,
+ meta: {
+ excelHeader: "Document No"
+ },
+ },
+
+ // λ¬Έμ„œλͺ… (μ„ νƒλœ ν–‰ ν•˜μ΄λΌμ΄νŠΈ 적용)
+ {
+ accessorKey: "title",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Title" />
+ ),
+ cell: ({ row }) => {
+ const doc = row.original
+
+ return (
+ <TitleCell doc={doc} />
+ )
+ },
+ enableResizing: true,
+ maxSize:300,
+ meta: {
+ excelHeader: "Title"
+ },
+ },
+
+ // ν”„λ‘œμ νŠΈ μ½”λ“œ
+ {
+ accessorKey: "projectCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Project" />
+ ),
+ cell: ({ row }) => {
+ const projectCode = row.original.projectCode
+
+ return (
+ <ProjectCodeCell projectCode={projectCode} documentId={row.original.documentId} />
+ )
+ },
+ enableResizing: true,
+ maxSize:100,
+ meta: {
+ excelHeader: "Project"
+ },
+ },
+
+ // 벀더λͺ…
+ {
+ accessorKey: "vendorName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Vendor Name" />
+ ),
+ cell: ({ row }) => {
+ const vendorName = row.original.vendorName
+
+ return (
+ <VendorNameCell vendorName={vendorName} documentId={row.original.documentId} />
+ )
+ },
+ enableResizing: true,
+ maxSize: 200,
+ meta: {
+ excelHeader: "Vendor Name"
+ },
+ },
+
+ // 벀더 μ½”λ“œ
+ {
+ accessorKey: "vendorCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Vendor Code" />
+ ),
+ cell: ({ row }) => {
+ const vendorCode = row.original.vendorCode
+
+ return (
+ <VendorCodeCell vendorCode={vendorCode} documentId={row.original.documentId} />
+ )
+ },
+ enableResizing: true,
+ maxSize: 120,
+ meta: {
+ excelHeader: "Vendor Code"
+ },
+ },
+
+ // 1μ°¨ μŠ€ν…Œμ΄μ§€ κ·Έλ£Ή
+ {
+ id: "firstStageGroup",
+ header: ({ table }) => {
+ // 첫 번째 ν–‰μ˜ firstStageName을 κ·Έλ£Ή ν—€λ”λ‘œ μ‚¬μš©
+ const firstRow = table.getRowModel().rows[0]?.original
+ const stageName = firstRow?.firstStageName || "First Stage"
+ return (
+ <div className="text-center font-medium text-gray-700">
+ {stageName}
+ </div>
+ )
+ },
+ columns: [
+ {
+ accessorKey: "firstStagePlanDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Planned Date" />
+ ),
+ cell: ({ row }) => {
+ return <FirstStagePlanDateCell row={row} />
+ },
+ enableResizing: true,
+ meta: {
+ excelHeader: "First Planned Date"
+ },
+ },
+ {
+ accessorKey: "firstStageActualDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Actual Date" />
+ ),
+ cell: ({ row }) => {
+ return <FirstStageActualDateCell row={row} />
+ },
+ enableResizing: true,
+ meta: {
+ excelHeader: "First Actual Date"
+ },
+ },
+ ],
+ },
+
+ // 2μ°¨ μŠ€ν…Œμ΄μ§€ κ·Έλ£Ή
+ {
+ id: "secondStageGroup",
+ header: ({ table }) => {
+ // 첫 번째 ν–‰μ˜ secondStageName을 κ·Έλ£Ή ν—€λ”λ‘œ μ‚¬μš©
+ const firstRow = table.getRowModel().rows[0]?.original
+ const stageName = firstRow?.secondStageName || "Second Stage"
+ return (
+ <div className="text-center font-medium text-gray-700">
+ {stageName}
+ </div>
+ )
+ },
+ columns: [
+ {
+ accessorKey: "secondStagePlanDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Planned Date" />
+ ),
+ cell: ({ row }) => {
+ return <SecondStagePlanDateCell row={row} />
+ },
+ enableResizing: true,
+ meta: {
+ excelHeader: "Second Planned Date"
+ },
+ },
+ {
+ accessorKey: "secondStageActualDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Actual Date" />
+ ),
+ cell: ({ row }) => {
+ return <SecondStageActualDateCell row={row} />
+ },
+ enableResizing: true,
+ meta: {
+ excelHeader: "Second Actual Date"
+ },
+ },
+ ],
+ },
+
+ // μ²¨λΆ€νŒŒμΌ 수
+ {
+ accessorKey: "attachmentCount",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Files" />
+ ),
+ cell: ({ row }) => {
+ const count = row.original.attachmentCount || 0
+
+ return (
+ <AttachmentCountCell count={count} documentId={row.original.documentId} />
+ )
+ },
+ size: 60,
+ enableResizing: true,
+ meta: {
+ excelHeader: "Attachments"
+ },
+ },
+
+ // μ—…λ°μ΄νŠΈ μΌμ‹œ
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Updated" />
+ ),
+ cell: ({ cell, row }) => {
+ return (
+ <UpdatedAtCell updatedAt={cell.getValue() as Date} documentId={row.original.documentId} />
+ )
+ },
+ enableResizing: true,
+ meta: {
+ excelHeader: "Updated"
+ },
+ },
+
+ // μ•‘μ…˜ λ²„νŠΌ
+ // {
+ // id: "actions",
+ // header: () => <span className="sr-only">Actions</span>,
+ // cell: ({ row }) => {
+ // const doc = row.original
+ // return (
+ // <DropdownMenu>
+ // <DropdownMenuTrigger asChild>
+ // <Button variant="ghost" className="h-8 w-8 p-0">
+ // <span className="sr-only">Open menu</span>
+ // <Ellipsis className="h-4 w-4" />
+ // </Button>
+ // </DropdownMenuTrigger>
+ // <DropdownMenuContent align="end">
+ // <DropdownMenuItem
+ // onClick={() => setRowAction({ type: "view", row: doc })}
+ // >
+ // <Eye className="mr-2 h-4 w-4" />
+ // 보기
+ // </DropdownMenuItem>
+ // <DropdownMenuItem
+ // onClick={() => setRowAction({ type: "edit", row: doc })}
+ // >
+ // <Edit className="mr-2 h-4 w-4" />
+ // νŽΈμ§‘
+ // </DropdownMenuItem>
+ // <DropdownMenuSeparator />
+ // <DropdownMenuItem
+ // onClick={() => setRowAction({ type: "delete", row: doc })}
+ // className="text-red-600"
+ // >
+ // <Trash2 className="mr-2 h-4 w-4" />
+ // μ‚­μ œ
+ // <DropdownMenuShortcut>⌫</DropdownMenuShortcut>
+ // </DropdownMenuItem>
+ // </DropdownMenuContent>
+ // </DropdownMenu>
+ // )
+ // },
+ // size: 50,
+ // enableSorting: false,
+ // enableHiding: false,
+ // },
+ ]
+
+ return columns
+}
+
+// κ°œλ³„ μ…€ μ»΄ν¬λ„ŒνŠΈλ“€ (Context μ‚¬μš©)
+function SelectCell({ documentId }: { documentId: number }) {
+ const { selectedDocumentId, setSelectedDocumentId } = React.useContext(DocumentSelectionContextAll);
+ const isSelected = selectedDocumentId === documentId;
+
+ return (
+ <div className="flex items-center justify-center">
+ <input
+ type="radio"
+ checked={isSelected}
+ onChange={() => {
+ const newSelection = isSelected ? null : documentId;
+ setSelectedDocumentId(newSelection);
+ }}
+ className="cursor-pointer w-4 h-4"
+ />
+ </div>
+ );
+}
+
+function DocNumberCell({ doc }: { doc: SimplifiedDocumentsView }) {
+ const { selectedDocumentId, setSelectedDocumentId } = React.useContext(DocumentSelectionContextAll);
+ const isSelected = selectedDocumentId === doc.documentId;
+
+ return (
+ <div
+ className={cn(
+ "font-mono text-sm font-medium cursor-pointer px-2 py-1 rounded transition-colors",
+ isSelected
+ ? "text-blue-600 font-bold bg-blue-50"
+ : "hover:bg-gray-50"
+ )}
+ onClick={() => {
+ const newSelection = isSelected ? null : doc.documentId;
+ setSelectedDocumentId(newSelection);
+ }}
+ >
+ {doc.docNumber}
+ </div>
+ );
+}
+
+function TitleCell({ doc }: { doc: SimplifiedDocumentsView }) {
+ const { selectedDocumentId, setSelectedDocumentId } = React.useContext(DocumentSelectionContextAll);
+ const isSelected = selectedDocumentId === doc.documentId;
+
+ return (
+ <div
+ className={cn(
+ "font-medium text-gray-900 truncate max-w-[300px] cursor-pointer px-2 py-1 rounded transition-colors",
+ isSelected
+ ? "text-blue-600 font-bold bg-blue-50"
+ : "hover:bg-gray-50"
+ )}
+ title={doc.title}
+ onClick={() => {
+ const newSelection = isSelected ? null : doc.documentId;
+ setSelectedDocumentId(newSelection);
+ }}
+ >
+ {doc.title}
+ </div>
+ );
+}
+
+function ProjectCodeCell({ projectCode, documentId }: { projectCode: string | null, documentId: number }) {
+ const { selectedDocumentId } = React.useContext(DocumentSelectionContextAll);
+ const isSelected = selectedDocumentId === documentId;
+
+ if (!projectCode) return <span className="text-gray-400">-</span>;
+
+ return (
+ <span className={cn(
+ "text-sm font-medium",
+ isSelected && "text-blue-600 font-bold"
+ )}>
+ {projectCode}
+ </span>
+ );
+}
+
+function VendorNameCell({ vendorName, documentId }: { vendorName: string | null, documentId: number }) {
+ const { selectedDocumentId } = React.useContext(DocumentSelectionContextAll);
+ const isSelected = selectedDocumentId === documentId;
+
+ if (!vendorName) return <span className="text-gray-400">-</span>;
+
+ return (
+ <div
+ className={cn(
+ "text-sm font-medium truncate max-w-[200px]",
+ isSelected && "text-blue-600 font-bold"
+ )}
+ title={vendorName}
+ >
+ {vendorName}
+ </div>
+ );
+}
+
+function VendorCodeCell({ vendorCode, documentId }: { vendorCode: string | null, documentId: number }) {
+ const { selectedDocumentId } = React.useContext(DocumentSelectionContextAll);
+ const isSelected = selectedDocumentId === documentId;
+
+ if (!vendorCode) return <span className="text-gray-400">-</span>;
+
+ return (
+ <span className={cn(
+ "text-sm font-medium font-mono",
+ isSelected && "text-blue-600 font-bold"
+ )}>
+ {vendorCode}
+ </span>
+ );
+}
+
+function FirstStagePlanDateCell({ row }: { row: any }) {
+ const { selectedDocumentId } = React.useContext(DocumentSelectionContextAll);
+ const isSelected = selectedDocumentId === row.original.documentId;
+
+ return <DateDisplay date={row.original.firstStagePlanDate} isSelected={isSelected} />;
+}
+
+function FirstStageActualDateCell({ row }: { row: any }) {
+ const { selectedDocumentId } = React.useContext(DocumentSelectionContextAll);
+ const isSelected = selectedDocumentId === row.original.documentId;
+ const date = row.original.firstStageActualDate;
+
+ return (
+ <div className={cn(
+ date ? "text-green-600 font-medium" : "",
+ isSelected && date && "text-green-700 font-bold"
+ )}>
+ <DateDisplay date={date} isSelected={isSelected && !date} />
+ {date && <span className="text-xs block">βœ“ μ™„λ£Œ</span>}
+ </div>
+ );
+}
+
+function SecondStagePlanDateCell({ row }: { row: any }) {
+ const { selectedDocumentId } = React.useContext(DocumentSelectionContextAll);
+ const isSelected = selectedDocumentId === row.original.documentId;
+
+ return <DateDisplay date={row.original.secondStagePlanDate} isSelected={isSelected} />;
+}
+
+function SecondStageActualDateCell({ row }: { row: any }) {
+ const { selectedDocumentId } = React.useContext(DocumentSelectionContextAll);
+ const isSelected = selectedDocumentId === row.original.documentId;
+ const date = row.original.secondStageActualDate;
+
+ return (
+ <div className={cn(
+ date ? "text-green-600 font-medium" : "",
+ isSelected && date && "text-green-700 font-bold"
+ )}>
+ <DateDisplay date={date} isSelected={isSelected && !date} />
+ {date && <span className="text-xs block">βœ“ μ™„λ£Œ</span>}
+ </div>
+ );
+}
+
+function AttachmentCountCell({ count, documentId }: { count: number, documentId: number }) {
+ const { selectedDocumentId } = React.useContext(DocumentSelectionContextAll);
+ const isSelected = selectedDocumentId === documentId;
+
+ return (
+ <div className="flex items-center justify-center gap-1">
+ <FileText className="w-4 h-4 text-gray-400" />
+ <span className={cn(
+ "text-sm font-medium",
+ isSelected && "text-blue-600 font-bold"
+ )}>
+ {count}
+ </span>
+ </div>
+ );
+}
+
+function UpdatedAtCell({ updatedAt, documentId }: { updatedAt: Date, documentId: number }) {
+ const { selectedDocumentId } = React.useContext(DocumentSelectionContextAll);
+ const isSelected = selectedDocumentId === documentId;
+
+ return (
+ <span className={cn(
+ "text-sm text-gray-600",
+ isSelected && "text-blue-600 font-semibold"
+ )}>
+ {formatDateTime(updatedAt)}
+ </span>
+ );
+} \ No newline at end of file
diff --git a/lib/vendor-document-list/ship-all/enhanced-documents-table.tsx b/lib/vendor-document-list/ship-all/enhanced-documents-table.tsx
new file mode 100644
index 00000000..255aa56c
--- /dev/null
+++ b/lib/vendor-document-list/ship-all/enhanced-documents-table.tsx
@@ -0,0 +1,296 @@
+// simplified-documents-table.tsx - μ΅œμ ν™”λœ 버전
+"use client"
+
+import React from "react"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+
+import { useDataTable } from "@/hooks/use-data-table"
+import { getUserVendorDocumentsAll, getUserVendorDocumentStatsAll } from "@/lib/vendor-document-list/enhanced-document-service"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { toast } from "sonner"
+import { Badge } from "@/components/ui/badge"
+import { FileText } from "lucide-react"
+
+import { Label } from "@/components/ui/label"
+import { DataTable } from "@/components/data-table/data-table"
+import { SimplifiedDocumentsView } from "@/db/schema"
+import { getSimplifiedDocumentColumns } from "./enhanced-doc-table-columns"
+// import { EnhancedDocTableToolbarActions } from "./enhanced-doc-table-toolbar-actions"
+
+// DrawingKind별 μ„€λͺ… λ§€ν•‘
+const DRAWING_KIND_INFO = {
+ B3: {
+ title: "B3 Vendor",
+ description: "Approval-focused drawings progressing through Approval β†’ Work stages",
+ color: "bg-blue-50 text-blue-700 border-blue-200"
+ },
+ B4: {
+ title: "B4 GTT",
+ description: "DOLCE-integrated drawings progressing through Pre β†’ Work stages",
+ color: "bg-green-50 text-green-700 border-green-200"
+ },
+ B5: {
+ title: "B5 FMEA",
+ description: "Sequential drawings progressing through First β†’ Second stages",
+ color: "bg-purple-50 text-purple-700 border-purple-200"
+ }
+} as const
+
+interface SimplifiedDocumentsTableProps {
+ allPromises: Promise<[
+ Awaited<ReturnType<typeof getUserVendorDocumentsAll>>,
+ Awaited<ReturnType<typeof getUserVendorDocumentStatsAll>>
+ ]>
+ onDataLoaded?: (data: SimplifiedDocumentsView[]) => void
+}
+
+export function SimplifiedDocumentsTable({
+ allPromises,
+ onDataLoaded,
+}: SimplifiedDocumentsTableProps) {
+ // πŸ”₯ React.use() κ²°κ³Όλ₯Ό μ•ˆμ „ν•˜κ²Œ 처리
+ const promiseResults = React.use(allPromises)
+ const [documentResult, statsResult] = promiseResults
+
+ // πŸ”₯ 데이터 ꡬ쑰뢄해λ₯Ό λ©”λͺ¨μ΄μ œμ΄μ…˜
+ const { data, pageCount, total, drawingKind, vendorInfo } = React.useMemo(() => documentResult, [documentResult])
+ const { stats, totalDocuments, primaryDrawingKind } = React.useMemo(() => statsResult, [statsResult])
+
+ // πŸ”₯ 데이터 λ‘œλ“œ μ½œλ°±μ„ useCallback으둜 μ΅œμ ν™”
+ const handleDataLoaded = React.useCallback((loadedData: SimplifiedDocumentsView[]) => {
+ onDataLoaded?.(loadedData)
+ }, [onDataLoaded])
+
+ // πŸ”₯ 데이터가 λ‘œλ“œλ˜λ©΄ 콜백 호좜 (μ˜μ‘΄μ„± μ΅œμ ν™”)
+ React.useEffect(() => {
+ if (data && handleDataLoaded) {
+ handleDataLoaded(data)
+ }
+ }, [data, handleDataLoaded])
+
+ // πŸ”₯ μƒνƒœλ“€μ„ μ•ˆμ •μ μœΌλ‘œ 관리
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<SimplifiedDocumentsView> | null>(null)
+ const [expandedRows] = React.useState<Set<string>>(() => new Set())
+
+ // πŸ”₯ 컬럼 λ©”λͺ¨μ΄μ œμ΄μ…˜ μ΅œμ ν™”
+ const columns = React.useMemo(
+ () => getSimplifiedDocumentColumns({
+ setRowAction,
+ }),
+ [] // setRowAction은 항상 λ™μΌν•œ ν•¨μˆ˜μ΄λ―€λ‘œ μ˜μ‘΄μ„±μ—μ„œ μ œμ™Έ
+ )
+
+ // πŸ”₯ ν•„ν„° ν•„λ“œλ“€μ„ λ©”λͺ¨μ΄μ œμ΄μ…˜
+ const advancedFilterFields: DataTableAdvancedFilterField<SimplifiedDocumentsView>[] = React.useMemo(() => [
+ {
+ id: "docNumber",
+ label: "Document No",
+ type: "text",
+ },
+ {
+ id: "title",
+ label: "Document Title",
+ type: "text",
+ },
+ {
+ id: "drawingKind",
+ label: "Document Type",
+ type: "select",
+ options: [
+ { label: "B3", value: "B3" },
+ { label: "B4", value: "B4" },
+ { label: "B5", value: "B5" },
+ ],
+ },
+ {
+ id: "projectCode",
+ label: "Project Code",
+ type: "text",
+ },
+ {
+ id: "vendorName",
+ label: "Vendor Name",
+ type: "text",
+ },
+ {
+ id: "vendorCode",
+ label: "Vendor Code",
+ type: "text",
+ },
+ {
+ id: "pic",
+ label: "PIC",
+ type: "text",
+ },
+ {
+ id: "status",
+ label: "Document Status",
+ type: "select",
+ options: [
+ { label: "Active", value: "ACTIVE" },
+ { label: "Inactive", value: "INACTIVE" },
+ { label: "Pending", value: "PENDING" },
+ { label: "Completed", value: "COMPLETED" },
+ ],
+ },
+ {
+ id: "firstStageName",
+ label: "First Stage",
+ type: "text",
+ },
+ {
+ id: "secondStageName",
+ label: "Second Stage",
+ type: "text",
+ },
+ {
+ id: "firstStagePlanDate",
+ label: "First Planned Date",
+ type: "date",
+ },
+ {
+ id: "firstStageActualDate",
+ label: "First Actual Date",
+ type: "date",
+ },
+ {
+ id: "secondStagePlanDate",
+ label: "Second Planned Date",
+ type: "date",
+ },
+ {
+ id: "secondStageActualDate",
+ label: "Second Actual Date",
+ type: "date",
+ },
+ {
+ id: "issuedDate",
+ label: "Issue Date",
+ type: "date",
+ },
+ {
+ id: "createdAt",
+ label: "Created Date",
+ type: "date",
+ },
+ {
+ id: "updatedAt",
+ label: "Updated Date",
+ type: "date",
+ },
+ ], [])
+
+ // πŸ”₯ B4 μ „μš© ν•„λ“œλ“€ λ©”λͺ¨μ΄μ œμ΄μ…˜
+ const b4FilterFields: DataTableAdvancedFilterField<SimplifiedDocumentsView>[] = React.useMemo(() => [
+ {
+ id: "cGbn",
+ label: "C Category",
+ type: "text",
+ },
+ {
+ id: "dGbn",
+ label: "D Category",
+ type: "text",
+ },
+ {
+ id: "degreeGbn",
+ label: "Degree Category",
+ type: "text",
+ },
+ {
+ id: "deptGbn",
+ label: "Dept Category",
+ type: "text",
+ },
+ {
+ id: "jGbn",
+ label: "J Category",
+ type: "text",
+ },
+ {
+ id: "sGbn",
+ label: "S Category",
+ type: "text",
+ },
+ ], [])
+
+ // πŸ”₯ B4 λ¬Έμ„œ 쑴재 μ—¬λΆ€ 체크 λ©”λͺ¨μ΄μ œμ΄μ…˜
+ const hasB4Documents = React.useMemo(() => {
+ return data.some(doc => doc.drawingKind === 'B4')
+ }, [data])
+
+ // πŸ”₯ μ΅œμ’… ν•„ν„° ν•„λ“œ λ©”λͺ¨μ΄μ œμ΄μ…˜
+ const finalFilterFields = React.useMemo(() => {
+ return hasB4Documents ? [...advancedFilterFields, ...b4FilterFields] : advancedFilterFields
+ }, [hasB4Documents, advancedFilterFields, b4FilterFields])
+
+ // πŸ”₯ ν…Œμ΄λΈ” 초기 μƒνƒœ λ©”λͺ¨μ΄μ œμ΄μ…˜
+ const tableInitialState = React.useMemo(() => ({
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ }), [])
+
+ // πŸ”₯ getRowId ν•¨μˆ˜ λ©”λͺ¨μ΄μ œμ΄μ…˜
+ const getRowId = React.useCallback((originalRow: SimplifiedDocumentsView) => String(originalRow.documentId), [])
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: tableInitialState,
+ getRowId,
+ shallow: false,
+ clearOnDefault: true,
+ columnResizeMode: "onEnd",
+ })
+
+ // πŸ”₯ ν™œμ„± drawingKind λ©”λͺ¨μ΄μ œμ΄μ…˜
+ const activeDrawingKind = React.useMemo(() => {
+ return drawingKind || primaryDrawingKind
+ }, [drawingKind, primaryDrawingKind])
+
+ // πŸ”₯ kindInfo λ©”λͺ¨μ΄μ œμ΄μ…˜
+ const kindInfo = React.useMemo(() => {
+ return activeDrawingKind ? DRAWING_KIND_INFO[activeDrawingKind] : null
+ }, [activeDrawingKind])
+
+ return (
+ <div className="w-full space-y-4">
+ {/* DrawingKind 정보 간단 ν‘œμ‹œ */}
+ {kindInfo && (
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-4">
+ {/* 주석 처리된 뢀뢄은 κ·ΈλŒ€λ‘œ μœ μ§€ */}
+ </div>
+ <div className="flex items-center gap-2">
+ <Badge variant="outline">
+ {total} documents
+ </Badge>
+ </div>
+ </div>
+ )}
+
+ {/* ν…Œμ΄λΈ” */}
+ <div className="overflow-x-auto">
+ <DataTable table={table} compact>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={finalFilterFields}
+ shallow={false}
+ >
+ {/* <EnhancedDocTableToolbarActions
+ table={table}
+ projectType="ship"
+ /> */}
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-document-list/ship/import-from-dolce-button.tsx b/lib/vendor-document-list/ship/import-from-dolce-button.tsx
index 1ffe466d..5e720220 100644
--- a/lib/vendor-document-list/ship/import-from-dolce-button.tsx
+++ b/lib/vendor-document-list/ship/import-from-dolce-button.tsx
@@ -223,6 +223,8 @@ export function ImportFromDOLCEButton({
}
}, [debouncedProjectIds, fetchAllImportStatus])
+
+
// πŸ”₯ 전체 톡계 λ©”λͺ¨μ΄μ œμ΄μ…˜
const totalStats = React.useMemo(() => {
const statuses = Array.from(importStatusMap.values())
@@ -389,6 +391,42 @@ export function ImportFromDOLCEButton({
fetchAllImportStatus()
}, [fetchAllImportStatus])
+
+ // πŸ”₯ μžλ™ 동기화 μ‹€ν–‰ (κΈ°μ‘΄ useEffectλ“€ λ‹€μŒμ— μΆ”κ°€)
+ React.useEffect(() => {
+ // 쑰건: κ°€μ Έμ˜€κΈ° κ°€λŠ₯ν•˜κ³ , 동기화할 ν•­λͺ©μ΄ 있고, ν˜„μž¬ 진행쀑이 아닐 λ•Œ
+ if (canImport &&
+ (totalStats.newDocuments > 0 || totalStats.updatedDocuments > 0) &&
+ !isImporting &&
+ !isDialogOpen) {
+
+ // μƒνƒœ λ‘œλ”©μ΄ μ™„λ£Œλœ ν›„ 잠깐 λŒ€κΈ° (μ‚¬μš©μžκ°€ μƒνƒœλ₯Ό 확인할 수 μžˆλ„λ‘)
+ const timer = setTimeout(() => {
+ console.log(`πŸ”„ μžλ™ 동기화 μ‹œμž‘: μƒˆ λ¬Έμ„œ ${totalStats.newDocuments}개, μ—…λ°μ΄νŠΈ ${totalStats.updatedDocuments}개`)
+
+ // 동기화 μ‹œμž‘ μ•Œλ¦Ό
+ toast.info(
+ 'μƒˆλ‘œμš΄ λ¬Έμ„œκ°€ λ°œκ²¬λ˜μ–΄ μžλ™ 동기화λ₯Ό μ‹œμž‘ν•©λ‹ˆλ‹€',
+ {
+ description: `μƒˆ λ¬Έμ„œ ${totalStats.newDocuments}개, μ—…λ°μ΄νŠΈ ${totalStats.updatedDocuments}개`,
+ duration: 3000
+ }
+ )
+
+ // μžλ™μœΌλ‘œ λ‹€μ΄μ–Όλ‘œκ·Έ μ—΄κ³  동기화 μ‹€ν–‰
+ setIsDialogOpen(true)
+
+ // 잠깐 ν›„ μ‹€μ œ 동기화 μ‹œμž‘ (λ‹€μ΄μ–Όλ‘œκ·Έκ°€ μ—΄λ¦¬λŠ” μ‹œκ°„)
+ setTimeout(() => {
+ handleImport()
+ }, 500)
+ }, 1500) // 1.5초 λŒ€κΈ°
+
+ return () => clearTimeout(timer)
+ }
+ }, [canImport, totalStats.newDocuments, totalStats.updatedDocuments, isImporting, isDialogOpen, handleImport])
+
+
// λ‘œλ”© μ€‘μ΄κ±°λ‚˜ projectIdsκ°€ μ—†μœΌλ©΄ λ²„νŠΌμ„ ν‘œμ‹œν•˜μ§€ μ•ŠμŒ
if (projectIds.length === 0) {
return null
diff --git a/lib/vendor-registration-status/repository.ts b/lib/vendor-registration-status/repository.ts
deleted file mode 100644
index f9c3d63f..00000000
--- a/lib/vendor-registration-status/repository.ts
+++ /dev/null
@@ -1,165 +0,0 @@
-import { db } from "@/db"
-import {
- vendorBusinessContacts,
- vendorAdditionalInfo,
- vendors
-} from "@/db/schema"
-import { eq, and, inArray } from "drizzle-orm"
-
-// μ—…λ¬΄λ‹΄λ‹Ήμž 정보 νƒ€μž…
-export interface VendorBusinessContact {
- id: number
- vendorId: number
- contactType: "sales" | "design" | "delivery" | "quality" | "tax_invoice"
- contactName: string
- position: string
- department: string
- responsibility: string
- email: string
- createdAt: Date
- updatedAt: Date
-}
-
-// 좔가정보 νƒ€μž…
-export interface VendorAdditionalInfo {
- id: number
- vendorId: number
- businessType?: string
- industryType?: string
- companySize?: string
- revenue?: string
- factoryEstablishedDate?: Date
- preferredContractTerms?: string
- createdAt: Date
- updatedAt: Date
-}
-
-// μ—…λ¬΄λ‹΄λ‹Ήμž 정보 쑰회
-export async function getBusinessContactsByVendorId(vendorId: number): Promise<VendorBusinessContact[]> {
- try {
- return await db
- .select()
- .from(vendorBusinessContacts)
- .where(eq(vendorBusinessContacts.vendorId, vendorId))
- .orderBy(vendorBusinessContacts.contactType)
- } catch (error) {
- console.error("Error fetching business contacts:", error)
- throw new Error("μ—…λ¬΄λ‹΄λ‹Ήμž 정보λ₯Ό κ°€μ Έμ˜€λŠ” 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.")
- }
-}
-
-// μ—…λ¬΄λ‹΄λ‹Ήμž 정보 μ €μž₯/μ—…λ°μ΄νŠΈ
-export async function upsertBusinessContacts(
- vendorId: number,
- contacts: Omit<VendorBusinessContact, "id" | "vendorId" | "createdAt" | "updatedAt">[]
-): Promise<void> {
- try {
- // κΈ°μ‘΄ 데이터 μ‚­μ œ
- await db
- .delete(vendorBusinessContacts)
- .where(eq(vendorBusinessContacts.vendorId, vendorId))
-
- // μƒˆ 데이터 μ‚½μž…
- if (contacts.length > 0) {
- await db
- .insert(vendorBusinessContacts)
- .values(contacts.map(contact => ({
- ...contact,
- vendorId,
- })))
- }
- } catch (error) {
- console.error("Error upserting business contacts:", error)
- throw new Error("μ—…λ¬΄λ‹΄λ‹Ήμž 정보 μ €μž₯ 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.")
- }
-}
-
-// 좔가정보 쑰회
-export async function getAdditionalInfoByVendorId(vendorId: number): Promise<VendorAdditionalInfo | null> {
- try {
- const result = await db
- .select()
- .from(vendorAdditionalInfo)
- .where(eq(vendorAdditionalInfo.vendorId, vendorId))
- .limit(1)
-
- return result[0] || null
- } catch (error) {
- console.error("Error fetching additional info:", error)
- throw new Error("좔가정보λ₯Ό κ°€μ Έμ˜€λŠ” 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.")
- }
-}
-
-// 좔가정보 μ €μž₯/μ—…λ°μ΄νŠΈ
-export async function upsertAdditionalInfo(
- vendorId: number,
- info: Omit<VendorAdditionalInfo, "id" | "vendorId" | "createdAt" | "updatedAt">
-): Promise<void> {
- try {
- const existing = await getAdditionalInfoByVendorId(vendorId)
-
- if (existing) {
- // μ—…λ°μ΄νŠΈ
- await db
- .update(vendorAdditionalInfo)
- .set({
- ...info,
- updatedAt: new Date(),
- })
- .where(eq(vendorAdditionalInfo.vendorId, vendorId))
- } else {
- // μ‹ κ·œ μ‚½μž…
- await db
- .insert(vendorAdditionalInfo)
- .values({
- ...info,
- vendorId,
- })
- }
- } catch (error) {
- console.error("Error upserting additional info:", error)
- throw new Error("좔가정보 μ €μž₯ 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.")
- }
-}
-
-// νŠΉμ • λ²€λ”μ˜ λͺ¨λ“  좔가정보 쑰회 (μ—…λ¬΄λ‹΄λ‹Ήμž + 좔가정보)
-export async function getVendorAllAdditionalData(vendorId: number) {
- try {
- const [businessContacts, additionalInfo] = await Promise.all([
- getBusinessContactsByVendorId(vendorId),
- getAdditionalInfoByVendorId(vendorId)
- ])
-
- return {
- businessContacts,
- additionalInfo
- }
- } catch (error) {
- console.error("Error fetching vendor additional data:", error)
- throw new Error("벀더 좔가정보λ₯Ό κ°€μ Έμ˜€λŠ” 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.")
- }
-}
-
-// μ—…λ¬΄λ‹΄λ‹Ήμž 정보 μ‚­μ œ
-export async function deleteBusinessContactsByVendorId(vendorId: number): Promise<void> {
- try {
- await db
- .delete(vendorBusinessContacts)
- .where(eq(vendorBusinessContacts.vendorId, vendorId))
- } catch (error) {
- console.error("Error deleting business contacts:", error)
- throw new Error("μ—…λ¬΄λ‹΄λ‹Ήμž 정보 μ‚­μ œ 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.")
- }
-}
-
-// 좔가정보 μ‚­μ œ
-export async function deleteAdditionalInfoByVendorId(vendorId: number): Promise<void> {
- try {
- await db
- .delete(vendorAdditionalInfo)
- .where(eq(vendorAdditionalInfo.vendorId, vendorId))
- } catch (error) {
- console.error("Error deleting additional info:", error)
- throw new Error("좔가정보 μ‚­μ œ 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.")
- }
-}
diff --git a/lib/vendor-registration-status/service.ts b/lib/vendor-registration-status/service.ts
deleted file mode 100644
index 97503a13..00000000
--- a/lib/vendor-registration-status/service.ts
+++ /dev/null
@@ -1,260 +0,0 @@
-import { revalidateTag, unstable_cache } from "next/cache"
-import {
- getBusinessContactsByVendorId,
- upsertBusinessContacts,
- getAdditionalInfoByVendorId,
- upsertAdditionalInfo,
- getVendorAllAdditionalData,
- deleteBusinessContactsByVendorId,
- deleteAdditionalInfoByVendorId,
- type VendorBusinessContact,
- type VendorAdditionalInfo
-} from "./repository"
-
-// μ—…λ¬΄λ‹΄λ‹Ήμž 정보 쑰회
-export async function fetchBusinessContacts(vendorId: number) {
- return unstable_cache(
- async () => {
- try {
- const contacts = await getBusinessContactsByVendorId(vendorId)
- return {
- success: true,
- data: contacts,
- }
- } catch (error) {
- console.error("Error in fetchBusinessContacts:", error)
- return {
- success: false,
- error: error instanceof Error ? error.message : "μ—…λ¬΄λ‹΄λ‹Ήμž 정보λ₯Ό κ°€μ Έμ˜€λŠ” 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.",
- }
- }
- },
- [`business-contacts-${vendorId}`],
- {
- revalidate: 300, // 5λΆ„ μΊμ‹œ
- tags: ["business-contacts", `vendor-${vendorId}`],
- }
- )()
-}
-
-// μ—…λ¬΄λ‹΄λ‹Ήμž 정보 μ €μž₯
-export async function saveBusinessContacts(
- vendorId: number,
- contacts: Omit<VendorBusinessContact, "id" | "vendorId" | "createdAt" | "updatedAt">[]
-) {
- try {
- await upsertBusinessContacts(vendorId, contacts)
-
- // μΊμ‹œ λ¬΄νš¨ν™”
- revalidateTag("business-contacts")
- revalidateTag(`vendor-${vendorId}`)
-
- return {
- success: true,
- message: "μ—…λ¬΄λ‹΄λ‹Ήμž 정보가 μ €μž₯λ˜μ—ˆμŠ΅λ‹ˆλ‹€.",
- }
- } catch (error) {
- console.error("Error in saveBusinessContacts:", error)
- return {
- success: false,
- error: error instanceof Error ? error.message : "μ—…λ¬΄λ‹΄λ‹Ήμž 정보 μ €μž₯ 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.",
- }
- }
-}
-
-// 좔가정보 쑰회
-export async function fetchAdditionalInfo(vendorId: number) {
- return unstable_cache(
- async () => {
- try {
- const additionalInfo = await getAdditionalInfoByVendorId(vendorId)
- return {
- success: true,
- data: additionalInfo,
- }
- } catch (error) {
- console.error("Error in fetchAdditionalInfo:", error)
- return {
- success: false,
- error: error instanceof Error ? error.message : "좔가정보λ₯Ό κ°€μ Έμ˜€λŠ” 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.",
- }
- }
- },
- [`additional-info-${vendorId}`],
- {
- revalidate: 300, // 5λΆ„ μΊμ‹œ
- tags: ["additional-info", `vendor-${vendorId}`],
- }
- )()
-}
-
-// 좔가정보 μ €μž₯
-export async function saveAdditionalInfo(
- vendorId: number,
- info: Omit<VendorAdditionalInfo, "id" | "vendorId" | "createdAt" | "updatedAt">
-) {
- try {
- await upsertAdditionalInfo(vendorId, info)
-
- // μΊμ‹œ λ¬΄νš¨ν™”
- revalidateTag("additional-info")
- revalidateTag(`vendor-${vendorId}`)
-
- return {
- success: true,
- message: "좔가정보가 μ €μž₯λ˜μ—ˆμŠ΅λ‹ˆλ‹€.",
- }
- } catch (error) {
- console.error("Error in saveAdditionalInfo:", error)
- return {
- success: false,
- error: error instanceof Error ? error.message : "좔가정보 μ €μž₯ 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.",
- }
- }
-}
-
-// λͺ¨λ“  좔가정보 쑰회 (μ—…λ¬΄λ‹΄λ‹Ήμž + 좔가정보)
-export async function fetchAllAdditionalData(vendorId: number) {
- return unstable_cache(
- async () => {
- try {
- const data = await getVendorAllAdditionalData(vendorId)
- return {
- success: true,
- data,
- }
- } catch (error) {
- console.error("Error in fetchAllAdditionalData:", error)
- return {
- success: false,
- error: error instanceof Error ? error.message : "벀더 좔가정보λ₯Ό κ°€μ Έμ˜€λŠ” 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.",
- }
- }
- },
- [`all-additional-data-${vendorId}`],
- {
- revalidate: 300, // 5λΆ„ μΊμ‹œ
- tags: ["business-contacts", "additional-info", `vendor-${vendorId}`],
- }
- )()
-}
-
-// μ—…λ¬΄λ‹΄λ‹Ήμž + 좔가정보 ν•œ λ²ˆμ— μ €μž₯
-export async function saveAllAdditionalData(
- vendorId: number,
- data: {
- businessContacts: Omit<VendorBusinessContact, "id" | "vendorId" | "createdAt" | "updatedAt">[]
- additionalInfo: Omit<VendorAdditionalInfo, "id" | "vendorId" | "createdAt" | "updatedAt">
- }
-) {
- try {
- // 두 μž‘μ—…μ„ 순차적으둜 μ‹€ν–‰
- await Promise.all([
- upsertBusinessContacts(vendorId, data.businessContacts),
- upsertAdditionalInfo(vendorId, data.additionalInfo)
- ])
-
- // μΊμ‹œ λ¬΄νš¨ν™”
- revalidateTag("business-contacts")
- revalidateTag("additional-info")
- revalidateTag(`vendor-${vendorId}`)
-
- return {
- success: true,
- message: "λͺ¨λ“  좔가정보가 μ €μž₯λ˜μ—ˆμŠ΅λ‹ˆλ‹€.",
- }
- } catch (error) {
- console.error("Error in saveAllAdditionalData:", error)
- return {
- success: false,
- error: error instanceof Error ? error.message : "좔가정보 μ €μž₯ 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.",
- }
- }
-}
-
-// μ—…λ¬΄λ‹΄λ‹Ήμž 정보 μ‚­μ œ
-export async function removeBusinessContacts(vendorId: number) {
- try {
- await deleteBusinessContactsByVendorId(vendorId)
-
- // μΊμ‹œ λ¬΄νš¨ν™”
- revalidateTag("business-contacts")
- revalidateTag(`vendor-${vendorId}`)
-
- return {
- success: true,
- message: "μ—…λ¬΄λ‹΄λ‹Ήμž 정보가 μ‚­μ œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.",
- }
- } catch (error) {
- console.error("Error in removeBusinessContacts:", error)
- return {
- success: false,
- error: error instanceof Error ? error.message : "μ—…λ¬΄λ‹΄λ‹Ήμž 정보 μ‚­μ œ 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.",
- }
- }
-}
-
-// 좔가정보 μ‚­μ œ
-export async function removeAdditionalInfo(vendorId: number) {
- try {
- await deleteAdditionalInfoByVendorId(vendorId)
-
- // μΊμ‹œ λ¬΄νš¨ν™”
- revalidateTag("additional-info")
- revalidateTag(`vendor-${vendorId}`)
-
- return {
- success: true,
- message: "좔가정보가 μ‚­μ œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.",
- }
- } catch (error) {
- console.error("Error in removeAdditionalInfo:", error)
- return {
- success: false,
- error: error instanceof Error ? error.message : "좔가정보 μ‚­μ œ 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.",
- }
- }
-}
-
-// μž…λ ₯ 완성도 체크
-export async function checkAdditionalDataCompletion(vendorId: number) {
- try {
- const result = await fetchAllAdditionalData(vendorId)
-
- if (!result.success || !result.data) {
- return {
- success: false,
- error: "좔가정보λ₯Ό 확인할 수 μ—†μŠ΅λ‹ˆλ‹€.",
- }
- }
-
- const { businessContacts, additionalInfo } = result.data
-
- // ν•„μˆ˜ μ—…λ¬΄λ‹΄λ‹Ήμž 5개 νƒ€μž…μ΄ λͺ¨λ‘ μž…λ ₯λ˜μ—ˆλŠ”μ§€ 체크
- const requiredContactTypes = ["sales", "design", "delivery", "quality", "tax_invoice"]
- const existingContactTypes = businessContacts.map(contact => contact.contactType)
- const missingContactTypes = requiredContactTypes.filter(type => !existingContactTypes.includes(type))
-
- // μ—…λ¬΄λ‹΄λ‹Ήμž 완성도
- const businessContactsComplete = missingContactTypes.length === 0
-
- // 좔가정보 완성도 (μ„ νƒμ‚¬ν•­μ΄λ―€λ‘œ 쑴재 μ—¬λΆ€λ§Œ 체크)
- const additionalInfoExists = !!additionalInfo
-
- return {
- success: true,
- data: {
- businessContactsComplete,
- missingContactTypes,
- additionalInfoExists,
- totalCompletion: businessContactsComplete && additionalInfoExists
- }
- }
- } catch (error) {
- console.error("Error in checkAdditionalDataCompletion:", error)
- return {
- success: false,
- error: error instanceof Error ? error.message : "완성도 확인 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.",
- }
- }
-}
diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts
index 9af81021..9cb653ea 100644
--- a/lib/vendors/service.ts
+++ b/lib/vendors/service.ts
@@ -65,6 +65,7 @@ import { deleteFile, saveFile, saveBuffer } from "../file-stroage";
import { basicContractTemplates } from "@/db/schema/basicContractDocumnet";
import { basicContract } from "@/db/schema/basicContractDocumnet";
import { headers } from 'next/headers';
+import { Router } from "lucide-react";
/* -----------------------------------------------------
1) 쑰회 κ΄€λ ¨
----------------------------------------------------- */
@@ -2990,6 +2991,8 @@ export async function requestBasicContractInfo({
revalidatePath("/partners/basic-contract");
revalidatePath("/ko/partners/basic-contract");
revalidatePath("/en/partners/basic-contract");
+ revalidateTag("basicContractView-vendor");
+ revalidateTag("basicContractView");
return { success: true };
} catch (error) {
@@ -3027,7 +3030,7 @@ export async function saveNdaAttachments(input: {
const saveResult = await saveDRMFile(
file,
decryptWithServerAction,
- `vendor-attachments/nda/${vendorId}`,
+ `vendors/nda/${vendorId}`,
input.userId
);
diff --git a/lib/vendors/table/request-pq-dialog.tsx b/lib/vendors/table/request-pq-dialog.tsx
index 9fd7b1d8..20388f71 100644
--- a/lib/vendors/table/request-pq-dialog.tsx
+++ b/lib/vendors/table/request-pq-dialog.tsx
@@ -2,7 +2,7 @@
import * as React from "react"
import { type Row } from "@tanstack/react-table"
-import { Loader, SendHorizonal, Search, X, Plus } from "lucide-react"
+import { Loader, SendHorizonal, Search, X, Plus, Router } from "lucide-react"
import { toast } from "sonner"
import { useMediaQuery } from "@/hooks/use-media-query"
import { Button } from "@/components/ui/button"
@@ -47,6 +47,7 @@ import { getALLBasicContractTemplates } from "@/lib/basic-contract/service"
import type { BasicContractTemplate } from "@/db/schema"
import { searchItemsForPQ } from "@/lib/items/service"
import { saveNdaAttachments } from "../service"
+import { useRouter } from "next/navigation"
// import { PQContractViewer } from "../pq-contract-viewer" // 더 이상 μ‚¬μš©ν•˜μ§€ μ•ŠμŒ
interface RequestPQDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> {
@@ -76,7 +77,7 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro
const [isApprovePending, startApproveTransition] = React.useTransition()
const isDesktop = useMediaQuery("(min-width: 640px)")
const { data: session } = useSession()
-
+ const router = useRouter()
const [type, setType] = React.useState<"GENERAL" | "PROJECT" | "NON_INSPECTION" | null>(null)
const [dueDate, setDueDate] = React.useState<string | null>(null)
const [projects, setProjects] = React.useState<Project[]>([])
@@ -337,6 +338,7 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro
}
toast.success(`총 ${totalContracts}개 기본계약이 λͺ¨λ‘ μƒμ„±λ˜μ—ˆμŠ΅λ‹ˆλ‹€`)
+ router.refresh();
} catch (error) {
console.error('기본계약 처리 쀑 였λ₯˜:', error)
diff --git a/lib/vendors/validations.ts b/lib/vendors/validations.ts
index ecadd67a..2538e1c8 100644
--- a/lib/vendors/validations.ts
+++ b/lib/vendors/validations.ts
@@ -121,7 +121,9 @@ export type CreditAgencyType = z.infer<typeof creditAgencyEnum>;
export const updateVendorSchema = z.object({
vendorName: z.string().min(1, "업체λͺ…은 ν•„μˆ˜ μž…λ ₯μ‚¬ν•­μž…λ‹ˆλ‹€"),
vendorCode: z.string().optional(),
- address: z.string().optional(),
+ address: z.string().min(1, "μ£Όμ†ŒλŠ” ν•„μˆ˜ μž…λ ₯μ‚¬ν•­μž…λ‹ˆλ‹€."),
+ addressDetail: z.string().optional(),
+ postalCode: z.string().optional(),
country: z.string().optional(),
phone: z.string().optional(),
email: z.string().email("μœ νš¨ν•œ 이메일 μ£Όμ†Œλ₯Ό μž…λ ₯ν•΄μ£Όμ„Έμš”").optional(),
@@ -220,7 +222,15 @@ export const createVendorSchema = z
// Other fields remain the same
vendorCode: z.string().max(100, "Max length 100").optional(),
- address: z.string().optional(),
+ address: z.string()
+ .min(1, "μ£Όμ†ŒλŠ” ν•„μˆ˜μž…λ‹ˆλ‹€.")
+ .max(500, "μ£Όμ†ŒλŠ” μ΅œλŒ€ 500μžκΉŒμ§€ μž…λ ₯ κ°€λŠ₯ν•©λ‹ˆλ‹€."),
+ addressDetail: z.string()
+ .min(1, "μƒμ„Έμ£Όμ†ŒλŠ” ν•„μˆ˜μž…λ‹ˆλ‹€.")
+ .max(500, "μƒμ„Έμ£Όμ†ŒλŠ” μ΅œλŒ€ 500μžκΉŒμ§€ μž…λ ₯ κ°€λŠ₯ν•©λ‹ˆλ‹€."),
+ postalCode: z.string()
+ .min(1, "μš°νŽΈλ²ˆν˜ΈλŠ” ν•„μˆ˜μž…λ‹ˆλ‹€.")
+ .max(20, "μš°νŽΈλ²ˆν˜ΈλŠ” μ΅œλŒ€ 20μžκΉŒμ§€ μž…λ ₯ κ°€λŠ₯ν•©λ‹ˆλ‹€."),
country: z.string()
.min(1, "κ΅­κ°€ 선택은 ν•„μˆ˜μž…λ‹ˆλ‹€.")
.max(100, "Max length 100"),
@@ -399,7 +409,9 @@ export type GetRfqHistorySchema = Awaited<ReturnType<typeof searchParamsRfqHisto
export const updateVendorInfoSchema = z.object({
vendorName: z.string().min(1, "업체λͺ…은 ν•„μˆ˜ μž…λ ₯μ‚¬ν•­μž…λ‹ˆλ‹€."),
taxId: z.string(),
- address: z.string().optional(),
+ address: z.string().min(1, "μ£Όμ†ŒλŠ” ν•„μˆ˜ μž…λ ₯μ‚¬ν•­μž…λ‹ˆλ‹€."),
+ addressDetail: z.string().optional(),
+ postalCode: z.string().optional(),
country: z.string().min(1, "κ΅­κ°€λ₯Ό 선택해 μ£Όμ„Έμš”."),
phone: z.string().optional(),
email: z.string().email("μœ νš¨ν•œ 이메일을 μž…λ ₯ν•΄ μ£Όμ„Έμš”."),