diff options
Diffstat (limited to 'lib')
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("μ ν¨ν μ΄λ©μΌμ μ
λ ₯ν΄ μ£ΌμΈμ."), |
