summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorrlaks5757 <rlaks5757@gmail.com>2025-03-26 16:51:54 +0900
committerrlaks5757 <rlaks5757@gmail.com>2025-03-27 17:32:42 +0900
commit92ddb4f13d48cbf344dc2bf63df4457b3c713608 (patch)
tree38108e1ca08a86c1b36941d39acc47601529a14a
parent2ca4c91514feadb5edd0c9411670c7d9964d21e3 (diff)
feat: report batch download 기능 완료
-rw-r--r--.env.development4
-rw-r--r--.env.production4
-rw-r--r--.gitignore1
-rw-r--r--app/[lng]/partners/(partners)/vendor-data/form/[packageId]/[formId]/page.tsx38
-rw-r--r--components/documents/StageList.tsx2
-rw-r--r--components/documents/view-document-dialog.tsx292
-rw-r--r--components/form-data/form-data-report-batch-dialog.tsx348
-rw-r--r--components/form-data/form-data-report-dialog.tsx457
-rw-r--r--components/form-data/form-data-report-temp-upload-dialog.tsx329
-rw-r--r--components/form-data/form-data-table-columns.tsx112
-rw-r--r--components/form-data/form-data-table.tsx564
-rw-r--r--components/form-data/update-form-sheet.tsx137
-rw-r--r--db/migrations/meta/_journal.json2
-rw-r--r--db/schema/vendorData.ts325
-rw-r--r--lib/docuSign/docuSignFns.ts7
-rw-r--r--lib/forms/services.ts476
-rw-r--r--lib/pdftron/serverSDK/createReport.ts83
-rw-r--r--lib/po/service.ts39
-rw-r--r--package-lock.json445
-rw-r--r--package.json3
-rw-r--r--pages/api/pdftron/createVendorDataReports.ts81
-rw-r--r--pages/api/po/sendDocuSign.ts8
-rw-r--r--pages/api/po/webhook.ts45
23 files changed, 2975 insertions, 827 deletions
diff --git a/.env.development b/.env.development
index 203da32d..183c6f93 100644
--- a/.env.development
+++ b/.env.development
@@ -10,6 +10,10 @@ NEXT_PUBLIC_MUI_KEY=da30586e1f20b93856a9783012fc9258Tz04ODI0MyxFPTE3NDQ0NTM2Nzgw
NEXT_PUBLIC_URL=http://3.36.56.124:3000
NEXT_PUBLIC_BASE_URL=http://3.36.56.124:3001
+# PDFTRON KEYS
+NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY=demo:1739264618684:616161d7030000000091db1c97c6f386d41d3506ab5b507381ef2ee2bd
+NEXT_PUBLIC_PDFTRON_SERVER_KEY=demo:1740034881027:6175a0fc0300000000f155d153480e5ba091f17922a109cbd7cf6e40b3
+
# 기간계 시스템 연동 설정
ERP_API_URL=https://erp.example.com/api/vendors
ERP_API_KEY=your-erp-api-key
diff --git a/.env.production b/.env.production
index d2522400..1ada78ec 100644
--- a/.env.production
+++ b/.env.production
@@ -10,6 +10,10 @@ NEXT_PUBLIC_MUI_KEY=da30586e1f20b93856a9783012fc9258Tz04ODI0MyxFPTE3NDQ0NTM2Nzgw
NEXT_PUBLIC_URL=https://evcp.dtsolution.io
NEXT_PUBLIC_BASE_URL=https://evcp.dtsolution.io
+# PDFTRON KEYS
+NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY=demo:1739264618684:616161d7030000000091db1c97c6f386d41d3506ab5b507381ef2ee2bd
+NEXT_PUBLIC_PDFTRON_SERVER_KEY=demo:1740034881027:6175a0fc0300000000f155d153480e5ba091f17922a109cbd7cf6e40b3
+
# 기간계 시스템 연동 설정
ERP_API_URL=https://erp.example.com/api/vendors
ERP_API_KEY=your-erp-api-key
diff --git a/.gitignore b/.gitignore
index 7a6dfa0e..4aa665ad 100644
--- a/.gitignore
+++ b/.gitignore
@@ -51,6 +51,7 @@ next-env.d.ts
/public/uploads
/public/vendors
/public/profiles
+/public/vendorFormData
# 직접 참조가 불가능해 복사가 필요했던 라이브러리 (pdftrone)
# node_modules/@pdftron/public 경로에서 core 및 ui 경로를 복사해 사용
diff --git a/app/[lng]/partners/(partners)/vendor-data/form/[packageId]/[formId]/page.tsx b/app/[lng]/partners/(partners)/vendor-data/form/[packageId]/[formId]/page.tsx
index 248bd7fc..01f5b501 100644
--- a/app/[lng]/partners/(partners)/vendor-data/form/[packageId]/[formId]/page.tsx
+++ b/app/[lng]/partners/(partners)/vendor-data/form/[packageId]/[formId]/page.tsx
@@ -1,30 +1,35 @@
-import DynamicTable from "@/components/form-data/form-data-table"
-import { getFormData } from "@/lib/forms/services"
+import DynamicTable from "@/components/form-data/form-data-table";
+import { getFormData, getFormId } from "@/lib/forms/services";
interface IndexPageProps {
params: {
- lng: string
- packageId: string
- formId: string
- }
+ lng: string;
+ packageId: string;
+ formId: string;
+ };
}
export default async function FormPage({ params }: IndexPageProps) {
// 1) 구조 분해 할당
- const resolvedParams = await params
-
+ const resolvedParams = await params;
+
// 2) 구조 분해 할당
- const { lng, packageId, formId } = resolvedParams
+ const { lng, packageId, formId: formCode } = resolvedParams;
// 2) 변환
- const packageIdAsNumber = Number(packageId)
+ const packageIdAsNumber = Number(packageId);
// 3) DB 조회
- const { columns, data } = await getFormData(formId, packageIdAsNumber)
+ const { columns, data } = await getFormData(formCode, packageIdAsNumber);
- // 4) 예외 처리
+ // 4) formId 및 report temp file 조회
+ const { formId } = await getFormId(packageId, formCode);
+
+ // 5) 예외 처리
if (!columns) {
- return <p className="text-red-500">해당 폼의 메타 정보를 불러올 수 없습니다.</p>
+ return (
+ <p className="text-red-500">해당 폼의 메타 정보를 불러올 수 없습니다.</p>
+ );
}
// 5) 렌더링
@@ -32,10 +37,11 @@ export default async function FormPage({ params }: IndexPageProps) {
<div className="space-y-6">
<DynamicTable
contractItemId={packageIdAsNumber}
- formCode={formId}
+ formCode={formCode}
+ formId={formId}
columnsJSON={columns}
dataJSON={data}
/>
</div>
- )
-} \ No newline at end of file
+ );
+}
diff --git a/components/documents/StageList.tsx b/components/documents/StageList.tsx
index 81f8a5ca..6df448df 100644
--- a/components/documents/StageList.tsx
+++ b/components/documents/StageList.tsx
@@ -55,7 +55,7 @@ interface Version {
approvedDate: string | null
DocumentSubmitDate: Date
attachments: Attachment[]
- selected?: boolean
+ selected?: boolean;
}
export default function StageList({ document }: StageListProps) {
diff --git a/components/documents/view-document-dialog.tsx b/components/documents/view-document-dialog.tsx
index 752252ee..9711c4be 100644
--- a/components/documents/view-document-dialog.tsx
+++ b/components/documents/view-document-dialog.tsx
@@ -1,79 +1,93 @@
-"use client"
+"use client";
-import * as React from "react"
+import * as React from "react";
import { WebViewerInstance } from "@pdftron/webviewer";
import {
- Dialog, DialogTrigger, DialogContent, DialogHeader,
- DialogTitle, DialogDescription, DialogFooter
-} from "@/components/ui/dialog"
-import { Building2, FileIcon, Loader2 } from "lucide-react"
-import { Button } from "@/components/ui/button"
-import fs from "fs"
+ Dialog,
+ DialogTrigger,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@/components/ui/dialog";
+import { Building2, FileIcon, Loader2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+
+// 인터페이스
+interface Attachment {
+ id: number;
+ fileName: string;
+ filePath: string;
+ fileType?: string;
+}
interface Version {
- id: number
- stage: string
- revision: string
- uploaderType: string
- uploaderName: string | null
- comment: string | null
- status: string | null
- planDate: string | null
- actualDate: string | null
- approvedDate: string | null
- DocumentSubmitDate: Date
- attachments: Attachment[]
- selected: boolean
+ id: number;
+ stage: string;
+ revision: string;
+ uploaderType: string;
+ uploaderName: string | null;
+ comment: string | null;
+ status: string | null;
+ planDate: string | null;
+ actualDate: string | null;
+ approvedDate: string | null;
+ DocumentSubmitDate: Date;
+ attachments: Attachment[];
+ selected?: boolean;
}
type ViewDocumentDialogProps = {
- versions: Version[]
-}
+ versions: Version[];
+};
-export function ViewDocumentDialog({versions}: ViewDocumentDialogProps){
- const [open, setOpen] = React.useState(false)
-
+export function ViewDocumentDialog({ versions }: ViewDocumentDialogProps) {
+ const [open, setOpen] = React.useState(false);
return (
<>
- <Button
- size="sm"
- className="border-blue-200"
- variant="outline"
- onClick={() => setOpen(prev => !prev)}
- >
- 문서 보기
- </Button>
- {open && <DocumentViewer
- open={open}
- setOpen={setOpen}
- versions={versions}
- />
- }
- </>
+ <Button
+ size="sm"
+ className="border-blue-200"
+ variant="outline"
+ onClick={() => setOpen((prev) => !prev)}
+ >
+ 문서 보기
+ </Button>
+ {open && (
+ <DocumentViewer open={open} setOpen={setOpen} versions={versions} />
+ )}
+ </>
);
}
-function DocumentViewer({open, setOpen, versions}){
- const [instance, setInstance] = React.useState<null | WebViewerInstance>(null)
- const [viwerLoading, setViewerLoading] = React.useState<boolean>(true)
- const [fileSetLoading, setFileSetLoading] = React.useState<boolean>(true)
+const DocumentViewer: React.FC<{
+ open: boolean;
+ setOpen: React.Dispatch<React.SetStateAction<boolean>>;
+ versions: Version[];
+}> = ({ open, setOpen, versions }) => {
+ const [instance, setInstance] = React.useState<null | WebViewerInstance>(
+ null
+ );
+ const [viwerLoading, setViewerLoading] = React.useState<boolean>(true);
+ const [fileSetLoading, setFileSetLoading] = React.useState<boolean>(true);
const viewer = React.useRef<HTMLDivElement>(null);
const initialized = React.useRef(false);
const isCancelled = React.useRef(false); // 초기화 중단용 flag
const cleanupHtmlStyle = () => {
const htmlElement = document.documentElement;
-
+
// 기존 style 속성 가져오기
const originalStyle = htmlElement.getAttribute("style") || "";
-
+
// "color-scheme: light" 또는 "color-scheme: dark" 찾기
const colorSchemeStyle = originalStyle
.split(";")
.map((s) => s.trim())
.find((s) => s.startsWith("color-scheme:"));
-
+
// 새로운 스타일 적용 (color-scheme만 유지)
if (colorSchemeStyle) {
htmlElement.setAttribute("style", colorSchemeStyle + ";");
@@ -81,146 +95,152 @@ function DocumentViewer({open, setOpen, versions}){
htmlElement.removeAttribute("style"); // color-scheme도 없으면 style 속성 자체 삭제
}
- console.log("html style 삭제")
+ console.log("html style 삭제");
};
React.useEffect(() => {
if (open && !initialized.current) {
initialized.current = true;
isCancelled.current = false; // 다시 열릴 때는 false로 리셋
-
+
requestAnimationFrame(() => {
if (viewer.current) {
import("@pdftron/webviewer").then(({ default: WebViewer }) => {
- console.log(isCancelled.current)
+ console.log(isCancelled.current);
if (isCancelled.current) {
console.log("📛 WebViewer 초기화 취소됨 (Dialog 닫힘)");
-
+
return;
}
-
+
WebViewer(
{
path: "/pdftronWeb",
- licenseKey: "demo:1739264618684:616161d7030000000091db1c97c6f386d41d3506ab5b507381ef2ee2bd",
+ licenseKey:
+ "demo:1739264618684:616161d7030000000091db1c97c6f386d41d3506ab5b507381ef2ee2bd",
fullAPI: true,
- css:"/globals.css"
+ css: "/globals.css",
},
viewer.current as HTMLDivElement
).then(async (instance: WebViewerInstance) => {
-
-
setInstance(instance);
instance.UI.enableFeatures([instance.UI.Feature.MultiTab]);
- instance.UI.disableElements(["addTabButton", "multiTabsEmptyPage"]);
+ instance.UI.disableElements([
+ "addTabButton",
+ "multiTabsEmptyPage",
+ ]);
setViewerLoading(false);
-
});
});
}
});
}
-
- return async () => {
+
+ return () => {
// cleanup 시에는 중단 flag 세움
- if(instance){
- await instance.UI.dispose()
+ if (instance) {
+ instance.UI.dispose();
}
- await setTimeout(() => cleanupHtmlStyle(), 500)
+ setTimeout(() => cleanupHtmlStyle(), 500);
};
}, [open]);
React.useEffect(() => {
- const loadDocument = async () => {
-
- if(instance && versions.length > 0){
- const { UI } = instance;
-
- const optionsArray = []
-
- versions.forEach(c => {
- const {attachments} = c
- attachments.forEach(c2 => {
- const {fileName, filePath, fileType} = c2
-
- const options = {
- filename: fileName,
- ...(fileType.includes("xlsx") && {
- officeOptions: {
- formatOptions: {
- applyPageBreaksToSheet: true,
+ const loadDocument = async () => {
+ if (instance && versions.length > 0) {
+ const { UI } = instance;
+
+ const optionsArray: any[] = [];
+
+ versions.forEach((c) => {
+ const { attachments } = c;
+ attachments.forEach((c2) => {
+ const { fileName, filePath, fileType } = c2;
+
+ const fileTypeCur = fileType ?? "";
+
+ const options = {
+ filename: fileName,
+ ...(fileTypeCur.includes("xlsx") && {
+ officeOptions: {
+ formatOptions: {
+ applyPageBreaksToSheet: true,
+ },
},
- },
- }),
- };
+ }),
+ };
- optionsArray.push({
- filePath,
- options
- })
- })
- })
+ optionsArray.push({
+ filePath,
+ options,
+ });
+ });
+ });
- const tabIds = [];
+ const tabIds = [];
- for (const option of optionsArray) {
- const { filePath, options } = option;
- const response = await fetch(filePath);
- const blob = await response.blob();
+ for (const option of optionsArray) {
+ const { filePath, options } = option;
+ const response = await fetch(filePath);
+ const blob = await response.blob();
- const tab = await UI.TabManager.addTab(blob, options);
- tabIds.push(tab); // 탭 ID 저장
- }
+ const tab = await UI.TabManager.addTab(blob, options);
+ tabIds.push(tab); // 탭 ID 저장
+ }
- if (tabIds.length > 0) {
- await UI.TabManager.setActiveTab(tabIds[0]);
- }
+ if (tabIds.length > 0) {
+ await UI.TabManager.setActiveTab(tabIds[0]);
+ }
- setFileSetLoading(false)
- }
- }
- loadDocument();
- }, [instance, versions])
+ setFileSetLoading(false);
+ }
+ };
+ loadDocument();
+ }, [instance, versions]);
-
return (
- <Dialog open={open} onOpenChange={async (val) => {
- console.log({val, fileSetLoading})
- if(!val && fileSetLoading){
- return;
- }
-
+ <Dialog
+ open={open}
+ onOpenChange={async (val) => {
+ console.log({ val, fileSetLoading });
+ if (!val && fileSetLoading) {
+ return;
+ }
+
if (instance) {
try {
await instance.UI.dispose();
- setInstance(null); // 상태도 초기화
-
+ setInstance(null); // 상태도 초기화
} catch (e) {
console.warn("dispose error", e);
}
}
-
+
// cleanupHtmlStyle()
- setViewerLoading(false);
- setOpen(prev => !prev)
- await setTimeout(() => cleanupHtmlStyle(), 1000)
- }}>
- <DialogContent className="w-[90vw] h-[90vh]" style={{maxWidth: "none"}}>
- <DialogHeader className="h-[38px]">
- <DialogTitle>
- 문서 미리보기
- </DialogTitle>
- <DialogDescription>
- 첨부파일 미리보기
- </DialogDescription>
+ setViewerLoading(false);
+ setOpen((prev) => !prev);
+ await setTimeout(() => cleanupHtmlStyle(), 1000);
+ }}
+ >
+ <DialogContent className="w-[90vw] h-[90vh]" style={{ maxWidth: "none" }}>
+ <DialogHeader className="h-[38px]">
+ <DialogTitle>문서 미리보기</DialogTitle>
+ <DialogDescription>첨부파일 미리보기</DialogDescription>
</DialogHeader>
- <div ref={viewer} style={{height: "calc(90vh - 20px - 38px - 1rem - 48px)"}}>
- {viwerLoading && <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>}
+ <div
+ ref={viewer}
+ style={{ height: "calc(90vh - 20px - 38px - 1rem - 48px)" }}
+ >
+ {viwerLoading && (
+ <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>
+ )}
</div>
- </DialogContent>
+ </DialogContent>
</Dialog>
);
-} \ No newline at end of file
+};
diff --git a/components/form-data/form-data-report-batch-dialog.tsx b/components/form-data/form-data-report-batch-dialog.tsx
new file mode 100644
index 00000000..614f890e
--- /dev/null
+++ b/components/form-data/form-data-report-batch-dialog.tsx
@@ -0,0 +1,348 @@
+"use client";
+
+import React, {
+ FC,
+ Dispatch,
+ SetStateAction,
+ useState,
+ useEffect,
+} from "react";
+import { useToast } from "@/hooks/use-toast";
+import prettyBytes from "pretty-bytes";
+import { X, Loader2 } from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@/components/ui/dialog";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Dropzone,
+ DropzoneDescription,
+ DropzoneInput,
+ DropzoneTitle,
+ DropzoneUploadIcon,
+ DropzoneZone,
+} from "@/components/ui/dropzone";
+import {
+ FileList,
+ FileListAction,
+ FileListDescription,
+ FileListHeader,
+ FileListIcon,
+ FileListInfo,
+ FileListItem,
+ FileListName,
+} from "@/components/ui/file-list";
+import { Button } from "@/components/ui/button";
+import { getReportTempList, getOrigin } from "@/lib/forms/services";
+import { DataTableColumnJSON } from "./form-data-table-columns";
+
+const MAX_FILE_SIZE = 3000000;
+
+type ReportData = {
+ [key: string]: any;
+};
+
+interface tempFile {
+ fileName: string;
+ filePath: string;
+}
+
+interface FormDataReportBatchDialogProps {
+ open: boolean;
+ setOpen: Dispatch<SetStateAction<boolean>>;
+ columnsJSON: DataTableColumnJSON[];
+ reportData: ReportData[];
+ packageId: number;
+ formId: number;
+ formCode: string;
+}
+
+export const FormDataReportBatchDialog: FC<FormDataReportBatchDialogProps> = ({
+ open,
+ setOpen,
+ columnsJSON,
+ reportData,
+ packageId,
+ formId,
+ formCode,
+}) => {
+ const { toast } = useToast();
+ const [tempList, setTempList] = useState<tempFile[]>([]);
+ const [selectTemp, setSelectTemp] = useState<string>("");
+ const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
+ const [isUploading, setIsUploading] = useState(false);
+
+ useEffect(() => {
+ updateReportTempList(packageId, formId, setTempList);
+ }, [packageId, formId]);
+
+ const onClose = () => {
+ if (isUploading) {
+ return;
+ }
+ setOpen(false);
+ };
+
+ // 드롭존 - 파일 드랍 처리
+ const handleDropAccepted = (acceptedFiles: File[]) => {
+ const newFiles = [...acceptedFiles];
+ setSelectedFiles(newFiles);
+ };
+
+ // 드롭존 - 파일 거부(에러) 처리
+ const handleDropRejected = (fileRejections: any[]) => {
+ fileRejections.forEach((rejection) => {
+ toast({
+ variant: "destructive",
+ title: "File Error",
+ description: `${rejection.file.name}: ${
+ rejection.errors[0]?.message || "Upload failed"
+ }`,
+ });
+ });
+ };
+
+ // 파일 제거 핸들러
+ const removeFile = (index: number) => {
+ const updatedFiles = [...selectedFiles];
+ updatedFiles.splice(index, 1);
+ setSelectedFiles(updatedFiles);
+ };
+
+ const submitData = async () => {
+ setIsUploading(true);
+
+ try {
+ const origin = await getOrigin()
+
+ const targetFiles = selectedFiles[0];
+
+ const reportDatas = reportData.map((c) => {
+ const reportValue = stringifyAllValues(c);
+
+ const reportValueMapping: { [key: string]: any } = {};
+
+ columnsJSON.forEach((c2) => {
+ const { key, label } = c2;
+
+ const objKey = label.split(" ").join("_");
+
+ reportValueMapping[objKey] = reportValue?.[key] ?? "";
+ });
+
+ return reportValueMapping;
+ });
+ const formData = new FormData();
+ formData.append("file", targetFiles);
+ formData.append("customFileName", `${formCode}.pdf`);
+ formData.append("reportDatas", JSON.stringify(reportDatas));
+ formData.append("reportTempPath", selectTemp);
+
+ const reqeustCreateReport = await fetch(
+ `${origin}/api/pdftron/createVendorDataReports`,
+ { method: "POST", body: formData }
+ );
+
+ if (reqeustCreateReport.ok) {
+ const blob = await reqeustCreateReport.blob();
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `${formCode}.pdf`;
+ a.click();
+ window.URL.revokeObjectURL(url);
+ } else {
+ const err = await reqeustCreateReport.json();
+ console.error("에러:", err);
+ }
+ } catch (err) {
+ console.error(err);
+ toast({
+ title: "Error",
+ description: "Report 생성 중 오류가 발생했습니다.",
+ variant: "destructive",
+ });
+ } finally {
+ setIsUploading(false);
+ setSelectedFiles([])
+ setOpen(false)
+ }
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={onClose}>
+ <DialogContent className="w-[600px]" style={{ maxWidth: "none" }}>
+ <DialogHeader>
+ <DialogTitle>Batch Report Download</DialogTitle>
+ <DialogDescription>
+ Report Template을 선택하신 후 갑지를 업로드하여 주시기 바랍니다.
+ </DialogDescription>
+ </DialogHeader>
+ <div className="h-[60px]">
+ <Label>Report Template Select</Label>
+ <Select value={selectTemp} onValueChange={setSelectTemp}>
+ <SelectTrigger className="w-[100%]">
+ <SelectValue placeholder="사용하시고자하는 Report Template를 선택하여 주시기 바랍니다." />
+ </SelectTrigger>
+ <SelectContent>
+ {tempList.map((c) => {
+ const { fileName, filePath } = c;
+
+ return (
+ <SelectItem key={filePath} value={filePath}>
+ {fileName}
+ </SelectItem>
+ );
+ })}
+ </SelectContent>
+ </Select>
+ </div>
+ <div>
+ <Label>Report Cover Page Upload</Label>
+ <Dropzone
+ maxSize={MAX_FILE_SIZE}
+ multiple={false}
+ accept={{ accept: [".docx"] }}
+ onDropAccepted={handleDropAccepted}
+ onDropRejected={handleDropRejected}
+ disabled={isUploading}
+ >
+ {({ maxSize }) => (
+ <>
+ <DropzoneZone className="flex justify-center">
+ <DropzoneInput />
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>파일을 여기에 드롭하세요</DropzoneTitle>
+ <DropzoneDescription>
+ 또는 클릭하여 파일을 선택하세요. 최대 크기:{" "}
+ {maxSize ? prettyBytes(maxSize) : "무제한"}
+ </DropzoneDescription>
+ </div>
+ </div>
+ </DropzoneZone>
+ <Label className="text-xs text-muted-foreground">
+ 여러 파일을 선택할 수 있습니다.
+ </Label>
+ </>
+ )}
+ </Dropzone>
+ </div>
+
+ {selectedFiles.length > 0 && (
+ <div className="grid gap-2">
+ <div className="flex items-center justify-between">
+ <h6 className="text-sm font-semibold">
+ 선택된 파일 ({selectedFiles.length})
+ </h6>
+ <Badge variant="secondary">{selectedFiles.length}개 파일</Badge>
+ </div>
+ <ScrollArea>
+ <UploadFileItem
+ selectedFiles={selectedFiles}
+ removeFile={removeFile}
+ isUploading={isUploading}
+ />
+ </ScrollArea>
+ </div>
+ )}
+
+ <DialogFooter>
+ <Button
+ disabled={selectedFiles.length === 0 || selectTemp.length === 0 || isUploading}
+ onClick={submitData}
+ >
+ {isUploading && <Loader2 />}다운로드
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+};
+
+interface UploadFileItemProps {
+ selectedFiles: File[];
+ removeFile: (index: number) => void;
+ isUploading: boolean;
+}
+
+const UploadFileItem: FC<UploadFileItemProps> = ({
+ selectedFiles,
+ removeFile,
+ isUploading,
+}) => {
+ return (
+ <FileList className="max-h-[200px] gap-3">
+ {selectedFiles.map((file, index) => (
+ <FileListItem key={index} className="p-3">
+ <FileListHeader>
+ <FileListIcon />
+ <FileListInfo>
+ <FileListName>{file.name}</FileListName>
+ <FileListDescription>
+ {prettyBytes(file.size)}
+ </FileListDescription>
+ </FileListInfo>
+ <FileListAction
+ onClick={() => removeFile(index)}
+ disabled={isUploading}
+ >
+ <X className="h-4 w-4" />
+ <span className="sr-only">Remove</span>
+ </FileListAction>
+ </FileListHeader>
+ </FileListItem>
+ ))}
+ </FileList>
+ );
+};
+
+type UpdateReportTempList = (
+ packageId: number,
+ formId: number,
+ setPrevReportTemp: Dispatch<SetStateAction<tempFile[]>>
+) => void;
+
+const updateReportTempList: UpdateReportTempList = async (
+ packageId,
+ formId,
+ setTempList
+) => {
+ const tempList = await getReportTempList(packageId, formId);
+
+ setTempList(
+ tempList.map((c) => {
+ const { fileName, filePath } = c;
+ return { fileName, filePath };
+ })
+ );
+};
+
+const stringifyAllValues = (obj: any): any => {
+ if (Array.isArray(obj)) {
+ return obj.map((item) => stringifyAllValues(item));
+ } else if (typeof obj === "object" && obj !== null) {
+ const result: any = {};
+ for (const key in obj) {
+ result[key] = stringifyAllValues(obj[key]);
+ }
+ return result;
+ } else {
+ return obj !== null && obj !== undefined ? String(obj) : "";
+ }
+};
diff --git a/components/form-data/form-data-report-dialog.tsx b/components/form-data/form-data-report-dialog.tsx
new file mode 100644
index 00000000..deb0873b
--- /dev/null
+++ b/components/form-data/form-data-report-dialog.tsx
@@ -0,0 +1,457 @@
+"use client";
+
+import React, {
+ FC,
+ Dispatch,
+ SetStateAction,
+ useState,
+ useEffect,
+ useRef,
+} from "react";
+import { WebViewerInstance, Core } from "@pdftron/webviewer";
+import { useToast } from "@/hooks/use-toast";
+import prettyBytes from "pretty-bytes";
+import { X, Loader2 } from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@/components/ui/dialog";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Button } from "@/components/ui/button";
+import { getReportTempList } from "@/lib/forms/services";
+import { DataTableColumnJSON } from "./form-data-table-columns";
+
+type ReportData = {
+ [key: string]: any;
+};
+
+interface tempFile {
+ fileName: string;
+ filePath: string;
+}
+
+interface FormDataReportDialogProps {
+ columnsJSON: DataTableColumnJSON[];
+ reportData: ReportData[];
+ setReportData: Dispatch<SetStateAction<ReportData[]>>;
+ packageId: number;
+ formId: number;
+ formCode: string;
+}
+
+export const FormDataReportDialog: FC<FormDataReportDialogProps> = ({
+ columnsJSON,
+ reportData,
+ setReportData,
+ packageId,
+ formId,
+}) => {
+ const [tempList, setTempList] = useState<tempFile[]>([]);
+ const [selectTemp, setSelectTemp] = useState<string>("");
+ const [instance, setInstance] = useState<null | WebViewerInstance>(null);
+ const [fileLoading, setFileLoading] = useState<boolean>(true);
+
+ useEffect(() => {
+ updateReportTempList(packageId, formId, setTempList);
+ }, [packageId, formId]);
+
+ const onClose = async (value: boolean) => {
+ if (fileLoading) {
+ return;
+ }
+ if (!value) {
+ setTimeout(() => cleanupHtmlStyle(), 1000);
+ setReportData([]);
+ }
+ };
+
+ const downloadFileData = async () => {
+ if (instance) {
+ const { UI, Core } = instance;
+ const { documentViewer } = Core;
+
+ const doc = documentViewer.getDocument();
+ const fileName = doc.getFilename();
+ const fileData = await doc.getFileData({
+ includeAnnotations: true, // 사용자가 추가한 폼 필드 및 입력 포함
+ // officeOptions: {
+ // outputFormat: "docx",
+ // },
+ });
+
+ const blob = new Blob([fileData], {
+ type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ });
+
+ const link = document.createElement("a");
+ link.href = URL.createObjectURL(blob);
+ link.download = fileName;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+
+ // const allTabs = UI.TabManager.getAllTabs() as {
+ // id: number;
+ // src: Core.Document;
+ // }[];
+
+ // for (const tab of allTabs) {
+ // // await UI.TabManager.setActiveTab(tab.id);
+ // await activateTabAndWaitForLoad(instance, tab.id);
+ // const tabDoc = tab.src;
+ // const fileName = tabDoc.getFilename();
+
+ // const fileData = await tabDoc.getFileData({
+ // includeAnnotations: true,
+ // });
+
+ // console.log({ fileData });
+
+ // const blob = new Blob([fileData], {
+ // type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ // });
+
+ // // 다운로드
+ // // const link = document.createElement("a");
+ // // link.href = URL.createObjectURL(blob);
+ // // link.download = fileName;
+ // // document.body.appendChild(link);
+ // // link.click();
+ // // document.body.removeChild(link);
+ // }
+ }
+ };
+
+ return (
+ <Dialog open={reportData.length > 0} onOpenChange={onClose}>
+ <DialogContent className="w-[70vw]" style={{ maxWidth: "none" }}>
+ <DialogHeader>
+ <DialogTitle>Report</DialogTitle>
+ <DialogDescription>
+ 사용하시고자 하는 Report Template를 선택하여 주시기 바랍니다.
+ </DialogDescription>
+ </DialogHeader>
+ <div className="h-[60px]">
+ <Label>Report Template Select</Label>
+ <Select
+ value={selectTemp}
+ onValueChange={setSelectTemp}
+ disabled={instance === null}
+ >
+ <SelectTrigger className="w-[100%]">
+ <SelectValue placeholder="사용하시고자하는 Report Template를 선택하여 주시기 바랍니다." />
+ </SelectTrigger>
+ <SelectContent>
+ {tempList.map((c) => {
+ const { fileName, filePath } = c;
+
+ return (
+ <SelectItem key={filePath} value={filePath}>
+ {fileName}
+ </SelectItem>
+ );
+ })}
+ </SelectContent>
+ </Select>
+ </div>
+ <div className="h-[calc(70vh-60px)]">
+ <ReportWebViewer
+ columnsJSON={columnsJSON}
+ reportTempPath={selectTemp}
+ reportDatas={reportData}
+ instance={instance}
+ setInstance={setInstance}
+ setFileLoading={setFileLoading}
+ />
+ </div>
+
+ <DialogFooter>
+ <Button onClick={downloadFileData} disabled={selectTemp.length === 0}>
+ 다운로드
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+};
+
+interface ReportWebViewerProps {
+ columnsJSON: DataTableColumnJSON[];
+ reportTempPath: string;
+ reportDatas: ReportData[];
+ instance: null | WebViewerInstance;
+ setInstance: Dispatch<SetStateAction<WebViewerInstance | null>>;
+ setFileLoading: Dispatch<SetStateAction<boolean>>;
+}
+
+const ReportWebViewer: FC<ReportWebViewerProps> = ({
+ columnsJSON,
+ reportTempPath,
+ reportDatas,
+ instance,
+ setInstance,
+ setFileLoading,
+}) => {
+ const [viwerLoading, setViewerLoading] = useState<boolean>(true);
+ const viewer = useRef<HTMLDivElement>(null);
+ const initialized = React.useRef(false);
+ const isCancelled = React.useRef(false); // 초기화 중단용 flag
+
+ useEffect(() => {
+ if (!initialized.current) {
+ initialized.current = true;
+ isCancelled.current = false; // 다시 열릴 때는 false로 리셋
+
+ requestAnimationFrame(() => {
+ if (viewer.current) {
+ import("@pdftron/webviewer").then(({ default: WebViewer }) => {
+ console.log(isCancelled.current);
+ if (isCancelled.current) {
+ console.log("📛 WebViewer 초기화 취소됨 (Dialog 닫힘)");
+
+ return;
+ }
+
+ WebViewer(
+ {
+ path: "/pdftronWeb",
+ licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
+ fullAPI: true,
+ },
+ viewer.current as HTMLDivElement
+ ).then(async (instance: WebViewerInstance) => {
+ setInstance(instance);
+ // //Tab 메뉴 사용 필요시 활성화
+ // instance.UI.enableFeatures([instance.UI.Feature.MultiTab]);
+ // instance.UI.disableElements([
+ // "addTabButton",
+ // "multiTabsEmptyPage",
+ // ]);
+ setViewerLoading(false);
+ });
+ });
+ }
+ });
+ }
+
+ return () => {
+ // cleanup 시에는 중단 flag 세움
+ if (instance) {
+ instance.UI.dispose();
+ }
+ setTimeout(() => cleanupHtmlStyle(), 500);
+ };
+ }, []);
+
+ useEffect(() => {
+ importReportData(
+ columnsJSON,
+ instance,
+ reportDatas,
+ reportTempPath,
+ setFileLoading
+ );
+ }, [reportTempPath, reportDatas, instance, columnsJSON]);
+
+ return (
+ <div ref={viewer} className="h-[100%]">
+ {viwerLoading && (
+ <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>
+ )}
+ </div>
+ );
+};
+
+const cleanupHtmlStyle = () => {
+ const htmlElement = document.documentElement;
+
+ // 기존 style 속성 가져오기
+ const originalStyle = htmlElement.getAttribute("style") || "";
+
+ // "color-scheme: light" 또는 "color-scheme: dark" 찾기
+ const colorSchemeStyle = originalStyle
+ .split(";")
+ .map((s) => s.trim())
+ .find((s) => s.startsWith("color-scheme:"));
+
+ // 새로운 스타일 적용 (color-scheme만 유지)
+ if (colorSchemeStyle) {
+ htmlElement.setAttribute("style", colorSchemeStyle + ";");
+ } else {
+ htmlElement.removeAttribute("style"); // color-scheme도 없으면 style 속성 자체 삭제
+ }
+
+ console.log("html style 삭제");
+};
+
+const stringifyAllValues = (obj: any): any => {
+ if (Array.isArray(obj)) {
+ return obj.map((item) => stringifyAllValues(item));
+ } else if (typeof obj === "object" && obj !== null) {
+ const result: any = {};
+ for (const key in obj) {
+ result[key] = stringifyAllValues(obj[key]);
+ }
+ return result;
+ } else {
+ return obj !== null && obj !== undefined ? String(obj) : "";
+ }
+};
+
+type ImportReportData = (
+ columnsJSON: DataTableColumnJSON[],
+ instance: null | WebViewerInstance,
+ reportDatas: ReportData[],
+ reportTempPath: string,
+ setFileLoading: Dispatch<SetStateAction<boolean>>
+) => void;
+
+const importReportData: ImportReportData = async (
+ columnsJSON,
+ instance,
+ reportDatas,
+ reportTempPath,
+ setFileLoading
+) => {
+ setFileLoading(true);
+ try {
+ if (instance && reportDatas.length > 0 && reportTempPath.length > 0) {
+ const { UI, Core } = instance;
+ const { documentViewer, createDocument } = Core;
+
+ const getFileData = await fetch(reportTempPath);
+ const reportFileBlob = await getFileData.blob();
+
+ const reportData = reportDatas[0];
+ const reportValue = stringifyAllValues(reportData);
+
+ const reportValueMapping: { [key: string]: any } = {};
+
+ columnsJSON.forEach((c) => {
+ const { key, label } = c;
+
+ const objKey = label.split(" ").join("_");
+
+ reportValueMapping[objKey] = reportValue?.[key] ?? "";
+ });
+
+ const doc = await createDocument(reportFileBlob, {
+ extension: "docx",
+ });
+
+ await doc.applyTemplateValues(reportValueMapping);
+
+ documentViewer.loadDocument(doc, {
+ extension: "docx",
+ enableOfficeEditing: true,
+ officeOptions: {
+ formatOptions: {
+ applyPageBreaksToSheet: true,
+ },
+ },
+ });
+ }
+ } catch (err) {
+ } finally {
+ setFileLoading(false);
+ }
+};
+
+const importReportDataTab: ImportReportData = async (
+ columnJSON,
+ instance,
+ reportDatas,
+ reportTempPath,
+ setFileLoading
+) => {
+ setFileLoading(true);
+ try {
+ if (instance && reportDatas.length > 0 && reportTempPath.length > 0) {
+ const { UI, Core } = instance;
+ const { createDocument } = Core;
+
+ const getFileData = await fetch(reportTempPath);
+ const reportFileBlob = await getFileData.blob();
+
+ const prevTab = UI.TabManager.getAllTabs();
+
+ (prevTab as object[] as { id: number }[]).forEach((c) => {
+ const { id } = c;
+ UI.TabManager.deleteTab(id);
+ });
+
+ const fileOptions = reportDatas.map((c) => {
+ const { tagNumber } = c;
+
+ const options = {
+ filename: `${tagNumber}_report.docx`,
+ };
+
+ return { options, reportData: c };
+ });
+
+ const tabIds = [];
+
+ for (const fileOption of fileOptions) {
+ let doc = await createDocument(reportFileBlob, {
+ ...fileOption.options,
+ extension: "docx",
+ });
+
+ await doc.applyTemplateValues(
+ stringifyAllValues(fileOption.reportData)
+ );
+
+ const tab = await UI.TabManager.addTab(doc, {
+ ...fileOption.options,
+ });
+
+ tabIds.push(tab); // 탭 ID 저장
+ }
+
+ if (tabIds.length > 0) {
+ await UI.TabManager.setActiveTab(tabIds[0]);
+ }
+ }
+ } catch (err) {
+ } finally {
+ setFileLoading(false);
+ }
+};
+
+type UpdateReportTempList = (
+ packageId: number,
+ formId: number,
+ setPrevReportTemp: Dispatch<SetStateAction<tempFile[]>>
+) => void;
+
+const updateReportTempList: UpdateReportTempList = async (
+ packageId,
+ formId,
+ setTempList
+) => {
+ const tempList = await getReportTempList(packageId, formId);
+
+ setTempList(
+ tempList.map((c) => {
+ const { fileName, filePath } = c;
+ return { fileName, filePath };
+ })
+ );
+};
diff --git a/components/form-data/form-data-report-temp-upload-dialog.tsx b/components/form-data/form-data-report-temp-upload-dialog.tsx
new file mode 100644
index 00000000..413c1e51
--- /dev/null
+++ b/components/form-data/form-data-report-temp-upload-dialog.tsx
@@ -0,0 +1,329 @@
+"use client";
+
+import React, {
+ FC,
+ Dispatch,
+ SetStateAction,
+ useState,
+ useEffect,
+} from "react";
+import { useToast } from "@/hooks/use-toast";
+import prettyBytes from "pretty-bytes";
+import { X, Loader2, Download } from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@/components/ui/dialog";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Label } from "@/components/ui/label";
+import { Button } from "@/components/ui/button";
+import {
+ Dropzone,
+ DropzoneDescription,
+ DropzoneInput,
+ DropzoneTitle,
+ DropzoneUploadIcon,
+ DropzoneZone,
+} from "@/components/ui/dropzone";
+import {
+ FileList,
+ FileListAction,
+ FileListDescription,
+ FileListHeader,
+ FileListIcon,
+ FileListInfo,
+ FileListItem,
+ FileListName,
+} from "@/components/ui/file-list";
+import { getReportTempList, uploadReportTemp } from "@/lib/forms/services";
+import { VendorDataReportTemps } from "@/db/schema/vendorData";
+
+interface FormDataReportTempUploadDialogProps {
+ open: boolean;
+ setOpen: Dispatch<SetStateAction<boolean>>;
+ packageId: number;
+ formCode: string;
+ formId: number;
+ uploaderType: string;
+}
+
+// 최대 파일 크기 설정 (3000MB)
+const MAX_FILE_SIZE = 3000000;
+
+export const FormDataReportTempUploadDialog: FC<
+ FormDataReportTempUploadDialogProps
+> = ({ open, setOpen, packageId, formId, uploaderType }) => {
+ const { toast } = useToast();
+ const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
+ const [isUploading, setIsUploading] = useState(false);
+ const [uploadProgress, setUploadProgress] = useState(0);
+ const [prevReportTemp, setPrevReportTemp] = useState<VendorDataReportTemps[]>(
+ []
+ );
+
+ useEffect(() => {
+ updateReportTempList(packageId, formId, setPrevReportTemp);
+ }, [packageId, formId]);
+
+ // 드롭존 - 파일 드랍 처리
+ const handleDropAccepted = (acceptedFiles: File[]) => {
+ const newFiles = [...selectedFiles, ...acceptedFiles];
+ setSelectedFiles(newFiles);
+ };
+
+ // 드롭존 - 파일 거부(에러) 처리
+ const handleDropRejected = (fileRejections: any[]) => {
+ fileRejections.forEach((rejection) => {
+ toast({
+ variant: "destructive",
+ title: "File Error",
+ description: `${rejection.file.name}: ${
+ rejection.errors[0]?.message || "Upload failed"
+ }`,
+ });
+ });
+ };
+
+ // 파일 제거 핸들러
+ const removeFile = (index: number) => {
+ const updatedFiles = [...selectedFiles];
+ updatedFiles.splice(index, 1);
+ setSelectedFiles(updatedFiles);
+ };
+
+ const submitData = async () => {
+ setIsUploading(true);
+ setUploadProgress(0);
+ try {
+ const totalFiles = selectedFiles.length;
+ let successCount = 0;
+
+ for (let i = 0; i < totalFiles; i++) {
+ const file = selectedFiles[i];
+
+ const formData = new FormData();
+ formData.append("file", file);
+ formData.append("customFileName", file.name);
+ formData.append("uploaderType", uploaderType);
+
+ await uploadReportTemp(packageId, formId, formData);
+
+ successCount++;
+ setUploadProgress(Math.round((successCount / totalFiles) * 100));
+ }
+ } catch (err) {
+ console.error(err);
+ toast({
+ title: "Error",
+ description: "파일 업로드 중 오류가 발생했습니다.",
+ variant: "destructive",
+ });
+ } finally {
+ setIsUploading(false);
+ setUploadProgress(0);
+ updateReportTempList(packageId, formId, setPrevReportTemp);
+ setOpen(false);
+ }
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={setOpen}>
+ <DialogContent className="w-[600px]" style={{ maxWidth: "none" }}>
+ <DialogHeader>
+ <DialogTitle>Report Template Upload</DialogTitle>
+ <DialogDescription>
+ 사용하시고자 하는 Report Template(docx File)를 업로드 하여주시기
+ 바랍니다.
+ </DialogDescription>
+ </DialogHeader>
+ {/* {prevReportTemp.length > 0 && (
+ <>
+ <Label>Prev Report Template</Label>
+ <ScrollArea className="max-h-[100px]">
+ {prevReportTemp.map((c, i) => {
+ return <div key={i}>{i}</div>;
+ })}
+ </ScrollArea>
+ </>
+ )} */}
+ <div
+ // className="flex flex-col gap-2"
+ >
+ <Label>Sample Template Download</Label>
+
+ <FileList className="max-h-[200px] gap-3">
+ <FileListItem className="p-3">
+ <FileListHeader>
+ <FileListIcon />
+ <FileListInfo>
+ <FileListName>sample_template_file.docx</FileListName>
+ </FileListInfo>
+ <FileListAction
+ // onClick={() => removeFile(index)}
+ // disabled={isUploading}
+ >
+ <Download className="h-4 w-4" />
+ <span className="sr-only">Download</span>
+ </FileListAction>
+ </FileListHeader>
+ </FileListItem>
+ </FileList>
+ </div>
+
+ <Dropzone
+ maxSize={MAX_FILE_SIZE}
+ multiple={true}
+ accept={{ accept: [".docx"] }}
+ onDropAccepted={handleDropAccepted}
+ onDropRejected={handleDropRejected}
+ disabled={isUploading}
+ >
+ {({ maxSize }) => (
+ <>
+ <DropzoneZone className="flex justify-center">
+ <DropzoneInput />
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>파일을 여기에 드롭하세요</DropzoneTitle>
+ <DropzoneDescription>
+ 또는 클릭하여 파일을 선택하세요. 최대 크기:{" "}
+ {maxSize ? prettyBytes(maxSize) : "무제한"}
+ </DropzoneDescription>
+ </div>
+ </div>
+ </DropzoneZone>
+ <Label className="text-xs text-muted-foreground">
+ 여러 파일을 선택할 수 있습니다.
+ </Label>
+ </>
+ )}
+ </Dropzone>
+
+ {selectedFiles.length > 0 && (
+ <div className="grid gap-2">
+ <div className="flex items-center justify-between">
+ <h6 className="text-sm font-semibold">
+ 선택된 파일 ({selectedFiles.length})
+ </h6>
+ <Badge variant="secondary">{selectedFiles.length}개 파일</Badge>
+ </div>
+ <ScrollArea>
+ <UploadFileItem
+ selectedFiles={selectedFiles}
+ removeFile={removeFile}
+ isUploading={isUploading}
+ />
+ </ScrollArea>
+ </div>
+ )}
+
+ {isUploading && <UploadProgressBox uploadProgress={uploadProgress} />}
+ <DialogFooter>
+ <Button onClick={() => setOpen(false)}>취소</Button>
+ <Button disabled={selectedFiles.length === 0} onClick={submitData}>
+ 업로드
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+};
+
+interface UploadFileItemProps {
+ selectedFiles: File[];
+ removeFile: (index: number) => void;
+ isUploading: boolean;
+}
+
+const UploadFileItem: FC<UploadFileItemProps> = ({
+ selectedFiles,
+ removeFile,
+ isUploading,
+}) => {
+ return (
+ <FileList className="max-h-[200px] gap-3">
+ {selectedFiles.map((file, index) => (
+ <FileListItem key={index} className="p-3">
+ <FileListHeader>
+ <FileListIcon />
+ <FileListInfo>
+ <FileListName>{file.name}</FileListName>
+ <FileListDescription>
+ {prettyBytes(file.size)}
+ </FileListDescription>
+ </FileListInfo>
+ <FileListAction
+ onClick={() => removeFile(index)}
+ disabled={isUploading}
+ >
+ <X className="h-4 w-4" />
+ <span className="sr-only">Remove</span>
+ </FileListAction>
+ </FileListHeader>
+ </FileListItem>
+ ))}
+ </FileList>
+ );
+};
+
+const UploadProgressBox: FC<{ uploadProgress: number }> = ({
+ uploadProgress,
+}) => {
+ return (
+ <div className="flex flex-col gap-1 mt-2">
+ <div className="flex items-center gap-2">
+ <Loader2 className="h-4 w-4 animate-spin" />
+ <span className="text-sm">{uploadProgress}% 업로드 중...</span>
+ </div>
+ <div className="h-2 w-full bg-muted rounded-full overflow-hidden">
+ <div
+ className="h-full bg-primary rounded-full transition-all"
+ style={{ width: `${uploadProgress}%` }}
+ />
+ </div>
+ </div>
+ );
+};
+
+const generateFileName = (
+ packageId: number,
+ formId: number,
+ originalFileName: string,
+ index: number,
+ totalFiles: number
+) => {
+ // Get the file extension
+ const extension = originalFileName.split(".").pop() || "";
+
+ // Base name without extension
+ const baseName = `${packageId}_${formId}`;
+
+ // For multiple files, add a suffix
+ if (totalFiles > 1) {
+ return `${baseName}_${index + 1}.${extension}`;
+ }
+
+ // For a single file, no suffix needed
+ return `${baseName}.${extension}`;
+};
+
+type UpdateReportTempList = (
+ packageId: number,
+ formId: number,
+ setPrevReportTemp: Dispatch<SetStateAction<VendorDataReportTemps[]>>
+) => void;
+
+const updateReportTempList: UpdateReportTempList = async (
+ packageId,
+ formId,
+ setPrevReportTemp
+) => {
+ const tempList = await getReportTempList(packageId, formId);
+ setPrevReportTemp(tempList);
+};
diff --git a/components/form-data/form-data-table-columns.tsx b/components/form-data/form-data-table-columns.tsx
index d44616f8..b23b2e70 100644
--- a/components/form-data/form-data-table-columns.tsx
+++ b/components/form-data/form-data-table-columns.tsx
@@ -1,8 +1,8 @@
-import type { ColumnDef, Row } from "@tanstack/react-table"
-import { ClientDataTableColumnHeaderSimple } from "../client-data-table/data-table-column-simple-header"
-import { Button } from "@/components/ui/button"
-import { Ellipsis } from "lucide-react"
-import { formatDate } from "@/lib/utils"
+import type { ColumnDef, Row } from "@tanstack/react-table";
+import { ClientDataTableColumnHeaderSimple } from "../client-data-table/data-table-column-simple-header";
+import { Button } from "@/components/ui/button";
+import { Ellipsis } from "lucide-react";
+import { formatDate } from "@/lib/utils";
import {
DropdownMenu,
DropdownMenuContent,
@@ -15,36 +15,38 @@ import {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
+} from "@/components/ui/dropdown-menu";
/** row 액션 관련 타입 */
export interface DataTableRowAction<TData> {
- row: Row<TData>
- type: "open" | "edit" | "update"
+ row: Row<TData>;
+ type: "open" | "edit" | "update";
}
/** 컬럼 타입 (필요에 따라 확장) */
-export type ColumnType = "STRING" | "NUMBER" | "LIST"
-
+export type ColumnType = "STRING" | "NUMBER" | "LIST";
export interface DataTableColumnJSON {
- key: string
+ key: string;
/** 실제 Excel 등에서 구분용으로 쓰이는 label (고정) */
- label: string
+ label: string;
/** UI 표시용 label (예: 단위를 함께 표시) */
- displayLabel?: string
+ displayLabel?: string;
- type: ColumnType
- options?: string[]
- uom?: string
+ type: ColumnType;
+ options?: string[];
+ uom?: string;
}
-/**
- * getColumns 함수에 필요한 props
+/**
+ * getColumns 함수에 필요한 props
* - TData: 테이블에 표시할 행(Row)의 타입
*/
interface GetColumnsProps<TData> {
- columnsJSON: DataTableColumnJSON[]
- setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TData> | null>>
+ columnsJSON: DataTableColumnJSON[];
+ setRowAction: React.Dispatch<
+ React.SetStateAction<DataTableRowAction<TData> | null>
+ >;
+ setReportData: React.Dispatch<React.SetStateAction<{ [key: string]: any }[]>>;
}
/**
@@ -55,8 +57,8 @@ interface GetColumnsProps<TData> {
export function getColumns<TData extends object>({
columnsJSON,
setRowAction,
+ setReportData,
}: GetColumnsProps<TData>): ColumnDef<TData>[] {
-
// (1) 기본 컬럼들
const baseColumns: ColumnDef<TData>[] = columnsJSON.map((col) => ({
accessorKey: col.key,
@@ -71,17 +73,19 @@ export function getColumns<TData extends object>({
excelHeader: col.label,
minWidth: 80,
paddingFactor: 1.2,
- maxWidth: col.key ==="tagNumber"?120:150,
+ maxWidth: col.key === "tagNumber" ? 120 : 150,
},
// (2) 실제 셀(cell) 렌더링: type에 따라 분기 가능
cell: ({ row }) => {
- const cellValue = row.getValue(col.key)
+ const cellValue = row.getValue(col.key);
// 데이터 타입별 처리
switch (col.type) {
case "NUMBER":
// 예: number인 경우 콤마 등 표시
- return <div>{cellValue ? Number(cellValue).toLocaleString() : ""}</div>
+ return (
+ <div>{cellValue ? Number(cellValue).toLocaleString() : ""}</div>
+ );
// case "date":
// // 예: 날짜 포맷팅
@@ -93,14 +97,14 @@ export function getColumns<TData extends object>({
case "LIST":
// 예: select인 경우 label만 표시
- return <div>{String(cellValue ?? "")}</div>
+ return <div>{String(cellValue ?? "")}</div>;
case "STRING":
default:
- return <div>{String(cellValue ?? "")}</div>
+ return <div>{String(cellValue ?? "")}</div>;
}
},
- }))
+ }));
// (3) 액션 칼럼 - update 버튼 예시
const actionColumn: ColumnDef<TData> = {
@@ -108,31 +112,39 @@ export function getColumns<TData extends object>({
header: "",
cell: ({ row }) => (
<DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- aria-label="Open menu"
- variant="ghost"
- className="flex size-8 p-0 data-[state=open]:bg-muted"
- >
- <Ellipsis className="size-4" aria-hidden="true" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="w-40">
- <DropdownMenuItem
- onSelect={() => setRowAction({ row, type: "update" })}
- >
- Edit
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "update" })}
+ >
+ Edit
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onSelect={() => {
+ const { original } = row;
+ setReportData([original]);
+ }}
+ >
+ Create Report
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
),
- size:40,
- meta:{
- maxWidth:40
+ size: 40,
+ meta: {
+ maxWidth: 40,
},
enablePinning: true,
- }
+ };
// (4) 최종 반환
- return [...baseColumns, actionColumn]
-} \ No newline at end of file
+ return [...baseColumns, actionColumn];
+}
diff --git a/components/form-data/form-data-table.tsx b/components/form-data/form-data-table.tsx
index 14fff12e..50c4f267 100644
--- a/components/form-data/form-data-table.tsx
+++ b/components/form-data/form-data-table.tsx
@@ -1,36 +1,40 @@
-"use client"
+"use client";
-import * as React from "react"
-import { useParams } from "next/navigation"
-import { useTranslation } from "@/i18n/client"
+import * as React from "react";
+import { useParams } from "next/navigation";
+import { useTranslation } from "@/i18n/client";
-import { ClientDataTable } from "../client-data-table/data-table"
+import { ClientDataTable } from "../client-data-table/data-table";
import {
getColumns,
DataTableRowAction,
DataTableColumnJSON,
ColumnType,
-} from "./form-data-table-columns"
+} from "./form-data-table-columns";
-import type { DataTableAdvancedFilterField } from "@/types/table"
-import { Button } from "../ui/button"
-import { Download, Loader, Save, Upload } from "lucide-react"
-import { toast } from "sonner"
-import { syncMissingTags, updateFormDataInDB } from "@/lib/forms/services"
-import { UpdateTagSheet } from "./update-form-sheet"
+import type { DataTableAdvancedFilterField } from "@/types/table";
+import { Button } from "../ui/button";
+import { Download, Loader, Save, Upload } from "lucide-react";
+import { toast } from "sonner";
+import { syncMissingTags, updateFormDataInDB } from "@/lib/forms/services";
+import { UpdateTagSheet } from "./update-form-sheet";
-import ExcelJS from "exceljs"
-import { saveAs } from "file-saver"
+import ExcelJS from "exceljs";
+import { saveAs } from "file-saver";
+import { FormDataReportTempUploadDialog } from "./form-data-report-temp-upload-dialog";
+import { FormDataReportDialog } from "./form-data-report-dialog";
+import { FormDataReportBatchDialog } from "./form-data-report-batch-dialog";
interface GenericData {
- [key: string]: any
+ [key: string]: any;
}
export interface DynamicTableProps {
- dataJSON: GenericData[]
- columnsJSON: DataTableColumnJSON[]
- contractItemId: number
- formCode: string
+ dataJSON: GenericData[];
+ columnsJSON: DataTableColumnJSON[];
+ contractItemId: number;
+ formCode: string;
+ formId: number;
}
export default function DynamicTable({
@@ -38,437 +42,476 @@ export default function DynamicTable({
columnsJSON,
contractItemId,
formCode,
+ formId,
}: DynamicTableProps) {
- const params = useParams()
- const lng = (params?.lng as string) || "ko"
- const { t } = useTranslation(lng, "translation")
-
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<GenericData> | null>(null)
- const [tableData, setTableData] = React.useState<GenericData[]>(() => dataJSON)
- const [isPending, setIsPending] = React.useState(false)
- const [isSaving, setIsSaving] = React.useState(false)
+ console.log({ columnsJSON });
+ const params = useParams();
+ const lng = (params?.lng as string) || "ko";
+ const { t } = useTranslation(lng, "translation");
+
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<GenericData> | null>(null);
+ const [tableData, setTableData] = React.useState<GenericData[]>(
+ () => dataJSON
+ );
+ const [isPending, setIsPending] = React.useState(false);
+ const [isSaving, setIsSaving] = React.useState(false);
+ const [tempUpDialog, setTempUpDialog] = React.useState(false);
+ const [reportData, setReportData] = React.useState<GenericData[]>([]);
+ const [batchDownDialog, setBatchDownDialog] = React.useState(false);
// Reference to the table instance
- const tableRef = React.useRef(null)
+ const tableRef = React.useRef(null);
const columns = React.useMemo(
- () => getColumns<GenericData>({ columnsJSON, setRowAction }),
- [columnsJSON, setRowAction]
- )
+ () => getColumns<GenericData>({ columnsJSON, setRowAction, setReportData }),
+ [columnsJSON, setRowAction, setReportData]
+ );
function mapColumnTypeToAdvancedFilterType(
columnType: ColumnType
): DataTableAdvancedFilterField<GenericData>["type"] {
switch (columnType) {
case "STRING":
- return "text"
- case "NUMBER":
- return "number"
+ return "text";
+ case "NUMBER":
+ return "number";
case "LIST":
// "select"로 하셔도 되고 "multi-select"로 하셔도 됩니다.
- return "select"
+ return "select";
// 그 외 다른 타입들도 적절히 추가 매핑
default:
// 예: 못 매핑한 경우 기본적으로 "text" 적용
- return "text"
+ return "text";
}
}
- const advancedFilterFields = React.useMemo<DataTableAdvancedFilterField<GenericData>[]>(
- () => {
- return columnsJSON.map((col) => ({
- id: col.key,
- label: col.label,
- type: mapColumnTypeToAdvancedFilterType(col.type),
- options:
- col.type === "LIST"
- ? col.options?.map((v) => ({ label: v, value: v }))
- : undefined,
- }))
- },
- [columnsJSON]
- )
+ const advancedFilterFields = React.useMemo<
+ DataTableAdvancedFilterField<GenericData>[]
+ >(() => {
+ return columnsJSON.map((col) => ({
+ id: col.key,
+ label: col.label,
+ type: mapColumnTypeToAdvancedFilterType(col.type),
+ options:
+ col.type === "LIST"
+ ? col.options?.map((v) => ({ label: v, value: v }))
+ : undefined,
+ }));
+ }, [columnsJSON]);
// 1) 태그 불러오기 (기존)
async function handleSyncTags() {
try {
- setIsPending(true)
- const result = await syncMissingTags(contractItemId, formCode)
-
+ setIsPending(true);
+ const result = await syncMissingTags(contractItemId, formCode);
+
// Prepare the toast messages based on what changed
- const changes = []
- if (result.createdCount > 0) changes.push(`${result.createdCount}건 태그 생성`)
- if (result.updatedCount > 0) changes.push(`${result.updatedCount}건 태그 업데이트`)
- if (result.deletedCount > 0) changes.push(`${result.deletedCount}건 태그 삭제`)
-
+ const changes = [];
+ if (result.createdCount > 0)
+ changes.push(`${result.createdCount}건 태그 생성`);
+ if (result.updatedCount > 0)
+ changes.push(`${result.updatedCount}건 태그 업데이트`);
+ if (result.deletedCount > 0)
+ changes.push(`${result.deletedCount}건 태그 삭제`);
+
if (changes.length > 0) {
// If any changes were made, show success message and reload
- toast.success(`동기화 완료: ${changes.join(', ')}`)
- location.reload()
+ toast.success(`동기화 완료: ${changes.join(", ")}`);
+ location.reload();
} else {
// If no changes were made, show an info message
- toast.info("변경사항이 없습니다. 모든 태그가 최신 상태입니다.")
+ toast.info("변경사항이 없습니다. 모든 태그가 최신 상태입니다.");
}
} catch (err) {
- console.error(err)
- toast.error("태그 동기화 중 에러가 발생했습니다.")
+ console.error(err);
+ toast.error("태그 동기화 중 에러가 발생했습니다.");
} finally {
- setIsPending(false)
+ setIsPending(false);
}
}
// 2) Excel Import (새로운 기능)
async function handleImportExcel(e: React.ChangeEvent<HTMLInputElement>) {
- const file = e.target.files?.[0]
- if (!file) return
+ const file = e.target.files?.[0];
+ if (!file) return;
try {
- setIsPending(true)
+ setIsPending(true);
// 기존 테이블 데이터의 tagNumber 목록 (이미 있는 태그)
- const existingTagNumbers = new Set(tableData.map((d) => d.tagNumber))
+ const existingTagNumbers = new Set(tableData.map((d) => d.tagNumber));
- const workbook = new ExcelJS.Workbook()
- const arrayBuffer = await file.arrayBuffer()
- await workbook.xlsx.load(arrayBuffer)
+ const workbook = new ExcelJS.Workbook();
+ const arrayBuffer = await file.arrayBuffer();
+ await workbook.xlsx.load(arrayBuffer);
- const worksheet = workbook.worksheets[0]
+ const worksheet = workbook.worksheets[0];
// (A) 헤더 파싱 (Error 열 추가 전에 먼저 체크)
- const headerRow = worksheet.getRow(1)
- const headerRowValues = headerRow.values as ExcelJS.CellValue[]
+ const headerRow = worksheet.getRow(1);
+ const headerRowValues = headerRow.values as ExcelJS.CellValue[];
// 디버깅용 로그
- console.log("원본 헤더 값:", headerRowValues)
+ console.log("원본 헤더 값:", headerRowValues);
// Excel의 헤더와 columnsJSON의 label 매핑 생성
// Excel의 행은 1부터 시작하므로 headerRowValues[0]은 undefined
- const headerToIndexMap = new Map<string, number>()
+ const headerToIndexMap = new Map<string, number>();
for (let i = 1; i < headerRowValues.length; i++) {
- const headerValue = String(headerRowValues[i] || "").trim()
+ const headerValue = String(headerRowValues[i] || "").trim();
if (headerValue) {
- headerToIndexMap.set(headerValue, i)
+ headerToIndexMap.set(headerValue, i);
}
}
// (B) 헤더 검사
- let headerErrorMessage = ""
+ let headerErrorMessage = "";
// (1) "columnsJSON에 있는데 엑셀에 없는" 라벨
columnsJSON.forEach((col) => {
- const label = col.label
+ const label = col.label;
if (!headerToIndexMap.has(label)) {
- headerErrorMessage += `Column "${label}" is missing. `
+ headerErrorMessage += `Column "${label}" is missing. `;
}
- })
+ });
// (2) "엑셀에는 있는데 columnsJSON에 없는" 라벨 검사
headerToIndexMap.forEach((index, headerLabel) => {
- const found = columnsJSON.some((col) => col.label === headerLabel)
+ const found = columnsJSON.some((col) => col.label === headerLabel);
if (!found) {
- headerErrorMessage += `Unexpected column "${headerLabel}" found in Excel. `
+ headerErrorMessage += `Unexpected column "${headerLabel}" found in Excel. `;
}
- })
+ });
// (C) 이제 Error 열 추가
- const lastColIndex = worksheet.columnCount + 1
- worksheet.getRow(1).getCell(lastColIndex).value = "Error"
+ const lastColIndex = worksheet.columnCount + 1;
+ worksheet.getRow(1).getCell(lastColIndex).value = "Error";
// 헤더 에러가 있으면 기록 후 다운로드하고 중단
if (headerErrorMessage) {
- headerRow.getCell(lastColIndex).value = headerErrorMessage.trim()
+ headerRow.getCell(lastColIndex).value = headerErrorMessage.trim();
- const outBuffer = await workbook.xlsx.writeBuffer()
- saveAs(new Blob([outBuffer]), `import-check-result_${Date.now()}.xlsx`)
+ const outBuffer = await workbook.xlsx.writeBuffer();
+ saveAs(new Blob([outBuffer]), `import-check-result_${Date.now()}.xlsx`);
- toast.error(`Header mismatch found. Please check downloaded file.`)
- return
+ toast.error(`Header mismatch found. Please check downloaded file.`);
+ return;
}
// -- 여기까지 왔다면, 헤더는 문제 없음 --
// 컬럼 키-인덱스 매핑 생성 (실제 데이터 행 파싱용)
// columnsJSON의 key와 Excel 열 인덱스 간의 매핑
- const keyToIndexMap = new Map<string, number>()
+ const keyToIndexMap = new Map<string, number>();
columnsJSON.forEach((col) => {
- const index = headerToIndexMap.get(col.label)
+ const index = headerToIndexMap.get(col.label);
if (index !== undefined) {
- keyToIndexMap.set(col.key, index)
+ keyToIndexMap.set(col.key, index);
}
- })
+ });
// 데이터 파싱
- const importedData: GenericData[] = []
- const lastRowNumber = worksheet.lastRow?.number || 1
- let errorCount = 0
+ const importedData: GenericData[] = [];
+ const lastRowNumber = worksheet.lastRow?.number || 1;
+ let errorCount = 0;
// 실제 데이터 행 파싱
for (let rowNum = 2; rowNum <= lastRowNumber; rowNum++) {
- const row = worksheet.getRow(rowNum)
- const rowValues = row.values as ExcelJS.CellValue[]
- if (!rowValues || rowValues.length <= 1) continue // 빈 행 스킵
+ const row = worksheet.getRow(rowNum);
+ const rowValues = row.values as ExcelJS.CellValue[];
+ if (!rowValues || rowValues.length <= 1) continue; // 빈 행 스킵
- let errorMessage = ""
- const rowObj: Record<string, any> = {}
+ let errorMessage = "";
+ const rowObj: Record<string, any> = {};
// 각 열에 대해 처리
columnsJSON.forEach((col) => {
- const colIndex = keyToIndexMap.get(col.key)
- if (colIndex === undefined) return
+ const colIndex = keyToIndexMap.get(col.key);
+ if (colIndex === undefined) return;
- const cellValue = rowValues[colIndex] ?? ""
- let stringVal = String(cellValue).trim()
+ const cellValue = rowValues[colIndex] ?? "";
+ let stringVal = String(cellValue).trim();
// 타입별 검사
switch (col.type) {
case "STRING":
if (!stringVal && col.key === "tagNumber") {
- errorMessage += `[${col.label}] is empty. `
+ errorMessage += `[${col.label}] is empty. `;
}
- rowObj[col.key] = stringVal
- break
+ rowObj[col.key] = stringVal;
+ break;
case "NUMBER":
if (stringVal) {
- const num = parseFloat(stringVal)
+ const num = parseFloat(stringVal);
if (isNaN(num)) {
- errorMessage += `[${col.label}] '${stringVal}' is not a valid number. `
+ errorMessage += `[${col.label}] '${stringVal}' is not a valid number. `;
} else {
- rowObj[col.key] = num
+ rowObj[col.key] = num;
}
} else {
- rowObj[col.key] = null
+ rowObj[col.key] = null;
}
- break
+ break;
case "LIST":
- if (stringVal && col.options && !col.options.includes(stringVal)) {
- errorMessage += `[${col.label}] '${stringVal}' not in ${col.options.join(", ")}. `
+ if (
+ stringVal &&
+ col.options &&
+ !col.options.includes(stringVal)
+ ) {
+ errorMessage += `[${
+ col.label
+ }] '${stringVal}' not in ${col.options.join(", ")}. `;
}
- rowObj[col.key] = stringVal
- break
+ rowObj[col.key] = stringVal;
+ break;
default:
- rowObj[col.key] = stringVal
- break
+ rowObj[col.key] = stringVal;
+ break;
}
- })
+ });
// tagNumber 검사
- const tagNum = rowObj["tagNumber"]
+ const tagNum = rowObj["tagNumber"];
if (!tagNum) {
- errorMessage += `No tagNumber found. `
+ errorMessage += `No tagNumber found. `;
} else if (!existingTagNumbers.has(tagNum)) {
- errorMessage += `TagNumber '${tagNum}' is not in current data. `
+ errorMessage += `TagNumber '${tagNum}' is not in current data. `;
}
if (errorMessage) {
- row.getCell(lastColIndex).value = errorMessage.trim()
- errorCount++
+ row.getCell(lastColIndex).value = errorMessage.trim();
+ errorCount++;
} else {
- importedData.push(rowObj)
+ importedData.push(rowObj);
}
}
// 에러가 있으면 재다운로드 후 import 중단
if (errorCount > 0) {
- const outBuffer = await workbook.xlsx.writeBuffer()
- saveAs(new Blob([outBuffer]), `import-check-result_${Date.now()}.xlsx`)
- toast.error(`There are ${errorCount} error row(s). Please check downloaded file.`)
- return
+ const outBuffer = await workbook.xlsx.writeBuffer();
+ saveAs(new Blob([outBuffer]), `import-check-result_${Date.now()}.xlsx`);
+ toast.error(
+ `There are ${errorCount} error row(s). Please check downloaded file.`
+ );
+ return;
}
// 에러 없으니 tableData 병합
setTableData((prev) => {
- const newDataMap = new Map<string, GenericData>()
+ const newDataMap = new Map<string, GenericData>();
// 기존 데이터를 맵에 추가
prev.forEach((item) => {
if (item.tagNumber) {
- newDataMap.set(item.tagNumber, { ...item })
+ newDataMap.set(item.tagNumber, { ...item });
}
- })
+ });
// 임포트 데이터로 기존 데이터 업데이트
importedData.forEach((item) => {
- const tag = item.tagNumber
- if (!tag) return
- const oldItem = newDataMap.get(tag) || {}
- newDataMap.set(tag, { ...oldItem, ...item })
- })
+ const tag = item.tagNumber;
+ if (!tag) return;
+ const oldItem = newDataMap.get(tag) || {};
+ newDataMap.set(tag, { ...oldItem, ...item });
+ });
- return Array.from(newDataMap.values())
- })
+ return Array.from(newDataMap.values());
+ });
- toast.success(`Imported ${importedData.length} rows successfully.`)
+ toast.success(`Imported ${importedData.length} rows successfully.`);
} catch (err) {
- console.error("Excel import error:", err)
- toast.error("Excel import failed.")
+ console.error("Excel import error:", err);
+ toast.error("Excel import failed.");
} finally {
- setIsPending(false)
- e.target.value = ""
+ setIsPending(false);
+ e.target.value = "";
}
}
// 3) Save -> 서버에 전체 tableData를 저장
async function handleSave() {
try {
- setIsSaving(true)
-
+ setIsSaving(true);
+
// 유효성 검사
- const invalidData = tableData.filter(item => !item.tagNumber?.trim())
+ const invalidData = tableData.filter((item) => !item.tagNumber?.trim());
if (invalidData.length > 0) {
- toast.error(`태그 번호가 없는 항목이 ${invalidData.length}개 있습니다.`)
- return
+ toast.error(
+ `태그 번호가 없는 항목이 ${invalidData.length}개 있습니다.`
+ );
+ return;
}
-
+
// 서버 액션 호출
- const result = await updateFormDataInDB(formCode, contractItemId, tableData)
-
+ const result = await updateFormDataInDB(
+ formCode,
+ contractItemId,
+ tableData
+ );
+
if (result.success) {
- toast.success(result.message)
+ toast.success(result.message);
} else {
- toast.error(result.message)
+ toast.error(result.message);
}
} catch (err) {
- console.error("Save error:", err)
- toast.error("데이터 저장 중 오류가 발생했습니다.")
+ console.error("Save error:", err);
+ toast.error("데이터 저장 중 오류가 발생했습니다.");
} finally {
- setIsSaving(false)
+ setIsSaving(false);
}
}
// 4) NEW: Excel Export with data validation for select columns using a hidden validation sheet
async function handleExportExcel() {
try {
- setIsPending(true)
+ setIsPending(true);
// Create a new workbook
- const workbook = new ExcelJS.Workbook()
+ const workbook = new ExcelJS.Workbook();
// 데이터 시트 생성
- const worksheet = workbook.addWorksheet("Data")
+ const worksheet = workbook.addWorksheet("Data");
// 유효성 검사용 숨김 시트 생성
- const validationSheet = workbook.addWorksheet("ValidationData")
- validationSheet.state = 'hidden' // 시트 숨김 처리
+ const validationSheet = workbook.addWorksheet("ValidationData");
+ validationSheet.state = "hidden"; // 시트 숨김 처리
// 1. 유효성 검사 시트에 select 옵션 추가
- const selectColumns = columnsJSON.filter(col =>
- col.type === "LIST" && col.options && col.options.length > 0
- )
+ const selectColumns = columnsJSON.filter(
+ (col) => col.type === "LIST" && col.options && col.options.length > 0
+ );
// 유효성 검사 범위 저장 맵 (컬럼 키 -> 유효성 검사 범위)
- const validationRanges = new Map<string, string>()
+ const validationRanges = new Map<string, string>();
selectColumns.forEach((col, idx) => {
- const colIndex = idx + 1
- const colLetter = validationSheet.getColumn(colIndex).letter
+ const colIndex = idx + 1;
+ const colLetter = validationSheet.getColumn(colIndex).letter;
// 헤더 추가 (컬럼 레이블)
- validationSheet.getCell(`${colLetter}1`).value = col.label
+ validationSheet.getCell(`${colLetter}1`).value = col.label;
// 옵션 추가
if (col.options) {
col.options.forEach((option, optIdx) => {
- validationSheet.getCell(`${colLetter}${optIdx + 2}`).value = option
- })
+ validationSheet.getCell(`${colLetter}${optIdx + 2}`).value = option;
+ });
// 유효성 검사 범위 저장 (ValidationData!$A$2:$A$4 형식)
validationRanges.set(
col.key,
- `ValidationData!${colLetter}$2:${colLetter}${col.options.length + 1}`
- )
+ `ValidationData!${colLetter}$2:${colLetter}${
+ col.options.length + 1
+ }`
+ );
}
- })
+ });
// 2. 데이터 시트에 헤더 추가
- const headers = columnsJSON.map(col => col.label)
- worksheet.addRow(headers)
+ const headers = columnsJSON.map((col) => col.label);
+ worksheet.addRow(headers);
// 헤더 스타일 적용
- const headerRow = worksheet.getRow(1)
- headerRow.font = { bold: true }
- headerRow.alignment = { horizontal: 'center' }
+ const headerRow = worksheet.getRow(1);
+ headerRow.font = { bold: true };
+ headerRow.alignment = { horizontal: "center" };
headerRow.eachCell((cell) => {
cell.fill = {
- type: 'pattern',
- pattern: 'solid',
- fgColor: { argb: 'FFCCCCCC' }
- }
- })
+ type: "pattern",
+ pattern: "solid",
+ fgColor: { argb: "FFCCCCCC" },
+ };
+ });
// 3. 데이터 행 추가
- tableData.forEach(row => {
- const rowValues = columnsJSON.map(col => {
- const value = row[col.key]
- return value !== undefined && value !== null ? value : ''
- })
- worksheet.addRow(rowValues)
- })
+ tableData.forEach((row) => {
+ const rowValues = columnsJSON.map((col) => {
+ const value = row[col.key];
+ return value !== undefined && value !== null ? value : "";
+ });
+ worksheet.addRow(rowValues);
+ });
// 4. 데이터 유효성 검사 적용
- const maxRows = 5000 // 데이터 유효성 검사를 적용할 최대 행 수
+ const maxRows = 5000; // 데이터 유효성 검사를 적용할 최대 행 수
columnsJSON.forEach((col, idx) => {
if (col.type === "LIST" && validationRanges.has(col.key)) {
- const colLetter = worksheet.getColumn(idx + 1).letter
- const validationRange = validationRanges.get(col.key)!
+ const colLetter = worksheet.getColumn(idx + 1).letter;
+ const validationRange = validationRanges.get(col.key)!;
// 유효성 검사 정의
const validation = {
- type: 'list' as const,
+ type: "list" as const,
allowBlank: true,
formulae: [validationRange],
showErrorMessage: true,
- errorStyle: 'warning' as const,
- errorTitle: '유효하지 않은 값',
- error: '목록에서 값을 선택해주세요.'
- }
+ errorStyle: "warning" as const,
+ errorTitle: "유효하지 않은 값",
+ error: "목록에서 값을 선택해주세요.",
+ };
// 모든 데이터 행에 유효성 검사 적용 (최대 maxRows까지)
- for (let rowIdx = 2; rowIdx <= Math.min(tableData.length + 1, maxRows); rowIdx++) {
- worksheet.getCell(`${colLetter}${rowIdx}`).dataValidation = validation
+ for (
+ let rowIdx = 2;
+ rowIdx <= Math.min(tableData.length + 1, maxRows);
+ rowIdx++
+ ) {
+ worksheet.getCell(`${colLetter}${rowIdx}`).dataValidation =
+ validation;
}
// 빈 행에도 적용 (최대 maxRows까지)
if (tableData.length + 1 < maxRows) {
- for (let rowIdx = tableData.length + 2; rowIdx <= maxRows; rowIdx++) {
- worksheet.getCell(`${colLetter}${rowIdx}`).dataValidation = validation
+ for (
+ let rowIdx = tableData.length + 2;
+ rowIdx <= maxRows;
+ rowIdx++
+ ) {
+ worksheet.getCell(`${colLetter}${rowIdx}`).dataValidation =
+ validation;
}
}
}
- })
+ });
// 5. 컬럼 너비 자동 조정
columnsJSON.forEach((col, idx) => {
- const column = worksheet.getColumn(idx + 1)
+ const column = worksheet.getColumn(idx + 1);
// 최적 너비 계산
- let maxLength = col.label.length
- tableData.forEach(row => {
- const value = row[col.key]
+ let maxLength = col.label.length;
+ tableData.forEach((row) => {
+ const value = row[col.key];
if (value !== undefined && value !== null) {
- const valueLength = String(value).length
+ const valueLength = String(value).length;
if (valueLength > maxLength) {
- maxLength = valueLength
+ maxLength = valueLength;
}
}
- })
+ });
// 너비 설정 (최소 10, 최대 50)
- column.width = Math.min(Math.max(maxLength + 2, 10), 50)
- })
+ column.width = Math.min(Math.max(maxLength + 2, 10), 50);
+ });
// 6. 파일 다운로드
- const buffer = await workbook.xlsx.writeBuffer()
- saveAs(new Blob([buffer]), `${formCode}_data_${new Date().toISOString().slice(0, 10)}.xlsx`)
+ const buffer = await workbook.xlsx.writeBuffer();
+ saveAs(
+ new Blob([buffer]),
+ `${formCode}_data_${new Date().toISOString().slice(0, 10)}.xlsx`
+ );
- toast.success("Excel 내보내기 완료!")
+ toast.success("Excel 내보내기 완료!");
} catch (err) {
- console.error("Excel export error:", err)
- toast.error("Excel 내보내기 실패.")
+ console.error("Excel export error:", err);
+ toast.error("Excel 내보내기 실패.");
} finally {
- setIsPending(false)
+ setIsPending(false);
}
}
@@ -478,13 +521,34 @@ export default function DynamicTable({
data={tableData}
columns={columns}
advancedFilterFields={advancedFilterFields}
- // tableRef={tableRef}
+ // tableRef={tableRef}
>
{/* 버튼 그룹 */}
<div className="flex items-center gap-2">
{/* 태그 불러오기 버튼 */}
- <Button variant="default" size="sm" onClick={handleSyncTags} disabled={isPending}>
- {isPending && <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />}
+ <Button
+ variant="default"
+ size="sm"
+ onClick={() => setBatchDownDialog(true)}
+ >
+ Report Download
+ </Button>
+ <Button
+ variant="default"
+ size="sm"
+ onClick={() => setTempUpDialog(true)}
+ >
+ Template Upload
+ </Button>
+ <Button
+ variant="default"
+ size="sm"
+ onClick={handleSyncTags}
+ disabled={isPending}
+ >
+ {isPending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
Sync Tags
</Button>
@@ -503,7 +567,12 @@ export default function DynamicTable({
</Button>
{/* EXPORT 버튼 (새로 추가) */}
- <Button variant="outline" size="sm" onClick={handleExportExcel} disabled={isPending}>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleExportExcel}
+ disabled={isPending}
+ >
<Download className="mr-2 size-4" />
Export Template
</Button>
@@ -533,13 +602,48 @@ export default function DynamicTable({
<UpdateTagSheet
open={rowAction?.type === "update"}
onOpenChange={(open) => {
- if (!open) setRowAction(null)
+ if (!open) setRowAction(null);
}}
columns={columnsJSON}
rowData={rowAction?.row.original ?? null}
formCode={formCode}
contractItemId={contractItemId}
/>
+ {tempUpDialog && (
+ <FormDataReportTempUploadDialog
+ open={tempUpDialog}
+ setOpen={setTempUpDialog}
+ packageId={contractItemId}
+ formCode={formCode}
+ formId={formId}
+ uploaderType="vendor"
+ />
+ )}
+
+ {reportData.length > 0 && (
+ <FormDataReportDialog
+ columnsJSON={columnsJSON}
+ reportData={reportData}
+ setReportData={setReportData}
+ packageId={contractItemId}
+ formCode={formCode}
+ formId={formId}
+ />
+ )}
+
+ {batchDownDialog && (
+ <FormDataReportBatchDialog
+ open={batchDownDialog}
+ setOpen={setBatchDownDialog}
+ columnsJSON={columnsJSON}
+ reportData={tableData}
+ packageId={contractItemId}
+ formCode={formCode}
+ formId={formId}
+ />
+ )}
</>
- )
-} \ No newline at end of file
+ );
+}
+
+
diff --git a/components/form-data/update-form-sheet.tsx b/components/form-data/update-form-sheet.tsx
index d5f7d21b..c52b6833 100644
--- a/components/form-data/update-form-sheet.tsx
+++ b/components/form-data/update-form-sheet.tsx
@@ -1,11 +1,11 @@
-"use client"
+"use client";
-import * as React from "react"
-import { z } from "zod"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { useForm } from "react-hook-form"
-import { Loader } from "lucide-react"
-import { toast } from "sonner"
+import * as React from "react";
+import { z } from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import { Loader } from "lucide-react";
+import { toast } from "sonner";
import {
Sheet,
@@ -15,9 +15,9 @@ import {
SheetFooter,
SheetHeader,
SheetTitle,
-} from "@/components/ui/sheet"
-import { Button } from "@/components/ui/button"
-import { Input } from "@/components/ui/input"
+} from "@/components/ui/sheet";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
import {
Form,
FormField,
@@ -25,21 +25,28 @@ import {
FormLabel,
FormControl,
FormMessage,
-} from "@/components/ui/form"
-import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select"
-
-import { DataTableColumnJSON } from "./form-data-table-columns"
-import { updateFormDataInDB } from "@/lib/forms/services"
-
-interface UpdateTagSheetProps extends React.ComponentPropsWithoutRef<typeof Sheet> {
- open: boolean
- onOpenChange: (open: boolean) => void
- columns: DataTableColumnJSON[]
- rowData: Record<string, any> | null
- formCode: string
- contractItemId: number
+} from "@/components/ui/form";
+import {
+ Select,
+ SelectTrigger,
+ SelectContent,
+ SelectItem,
+ SelectValue,
+} from "@/components/ui/select";
+
+import { DataTableColumnJSON } from "./form-data-table-columns";
+import { updateFormDataInDB } from "@/lib/forms/services";
+
+interface UpdateTagSheetProps
+ extends React.ComponentPropsWithoutRef<typeof Sheet> {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ columns: DataTableColumnJSON[];
+ rowData: Record<string, any> | null;
+ formCode: string;
+ contractItemId: number;
/** 업데이트 성공 시 호출될 콜백 */
- onUpdateSuccess?: (updatedValues: Record<string, any>) => void
+ onUpdateSuccess?: (updatedValues: Record<string, any>) => void;
}
export function UpdateTagSheet({
@@ -52,57 +59,61 @@ export function UpdateTagSheet({
onUpdateSuccess,
...props
}: UpdateTagSheetProps) {
- const [isPending, startTransition] = React.useTransition()
+ const [isPending, startTransition] = React.useTransition();
// 1) zod 스키마
const dynamicSchema = React.useMemo(() => {
- const shape: Record<string, z.ZodType<any>> = {}
+ const shape: Record<string, z.ZodType<any>> = {};
for (const col of columns) {
if (col.type === "NUMBER") {
shape[col.key] = z
.union([z.coerce.number(), z.nan()])
.transform((val) => (isNaN(val) ? undefined : val))
- .optional()
+ .optional();
} else {
- shape[col.key] = z.string().optional()
+ shape[col.key] = z.string().optional();
}
}
- return z.object(shape)
- }, [columns])
+ return z.object(shape);
+ }, [columns]);
// 2) form init
const form = useForm({
resolver: zodResolver(dynamicSchema),
defaultValues: React.useMemo(() => {
- if (!rowData) return {}
- const defaults: Record<string, any> = {}
+ if (!rowData) return {};
+ const defaults: Record<string, any> = {};
for (const col of columns) {
- defaults[col.key] = rowData[col.key] ?? ""
+ defaults[col.key] = rowData[col.key] ?? "";
}
- return defaults
+ return defaults;
}, [rowData, columns]),
- })
+ });
React.useEffect(() => {
if (!rowData) {
- form.reset({})
- return
+ form.reset({});
+ return;
}
- const defaults: Record<string, any> = {}
+ const defaults: Record<string, any> = {};
for (const col of columns) {
- defaults[col.key] = rowData[col.key] ?? ""
+ defaults[col.key] = rowData[col.key] ?? "";
}
- form.reset(defaults)
- }, [rowData, columns, form])
+ form.reset(defaults);
+ }, [rowData, columns, form]);
async function onSubmit(values: Record<string, any>) {
startTransition(async () => {
- const { success, message } = await updateFormDataInDB(formCode, contractItemId, values)
+ const { success, message } = await updateFormDataInDB(
+ formCode,
+ contractItemId,
+ values
+ );
if (!success) {
- toast.error(message)
- return
+ toast.error(message);
+ return;
}
- toast.success("Updated successfully!")
+ toast.success("Updated successfully!");
// (A) 수정된 값(폼 데이터)을 부모 콜백에 전달
onUpdateSuccess?.({
@@ -111,10 +122,10 @@ export function UpdateTagSheet({
...rowData,
...values,
tagNumber: rowData?.tagNumber,
- })
+ });
- onOpenChange(false)
- })
+ onOpenChange(false);
+ });
}
return (
@@ -128,11 +139,15 @@ export function UpdateTagSheet({
</SheetHeader>
<Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="flex flex-col gap-4"
+ >
<div className="overflow-y-auto max-h-[80vh] flex-1 pr-4 -mr-4">
<div className="flex flex-col gap-4 pt-2">
{columns.map((col) => {
- const isTagNumberField = col.key === "tagNumber" || col.key === "tagDescription"
+ const isTagNumberField =
+ col.key === "tagNumber" || col.key === "tagDescription";
return (
<FormField
key={col.key}
@@ -149,15 +164,15 @@ export function UpdateTagSheet({
type="number"
readOnly={isTagNumberField}
onChange={(e) => {
- const num = parseFloat(e.target.value)
- field.onChange(isNaN(num) ? "" : num)
+ const num = parseFloat(e.target.value);
+ field.onChange(isNaN(num) ? "" : num);
}}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
- )
+ );
case "LIST":
return (
@@ -181,7 +196,7 @@ export function UpdateTagSheet({
</Select>
<FormMessage />
</FormItem>
- )
+ );
// case "date":
// return (
@@ -205,17 +220,19 @@ export function UpdateTagSheet({
<FormItem>
<FormLabel>{col.label}</FormLabel>
<FormControl>
- <Input readOnly={isTagNumberField} {...field} />
+ <Input
+ readOnly={isTagNumberField}
+ {...field}
+ />
</FormControl>
<FormMessage />
</FormItem>
- )
+ );
}
}}
/>
- )
+ );
})}
-
</div>
</div>
@@ -235,5 +252,5 @@ export function UpdateTagSheet({
</Form>
</SheetContent>
</Sheet>
- )
-} \ No newline at end of file
+ );
+}
diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json
index a84b0a28..f2725ef7 100644
--- a/db/migrations/meta/_journal.json
+++ b/db/migrations/meta/_journal.json
@@ -659,6 +659,6 @@
"when": 1742834014376,
"tag": "0093_young_the_hunter",
"breakpoints": true
- }
+ }
]
} \ No newline at end of file
diff --git a/db/schema/vendorData.ts b/db/schema/vendorData.ts
index f7baa883..048e5ef8 100644
--- a/db/schema/vendorData.ts
+++ b/db/schema/vendorData.ts
@@ -3,32 +3,41 @@ import {
text,
varchar,
timestamp,
- integer, numeric, date, unique, serial, jsonb, uniqueIndex
-} from "drizzle-orm/pg-core"
-import { contractItems } from "./contract"
-
-export const forms = pgTable("forms", {
- id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
- contractItemId: integer("contract_item_id")
+ integer,
+ numeric,
+ date,
+ unique,
+ serial,
+ jsonb,
+ uniqueIndex,
+ } from "drizzle-orm/pg-core";
+ import { contractItems } from "./contract";
+
+ export const forms = pgTable(
+ "forms",
+ {
+ id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
+ contractItemId: integer("contract_item_id")
.notNull()
.references(() => contractItems.id, { onDelete: "cascade" }),
- formCode: varchar("form_code", { length: 100 }).notNull(),
- formName: varchar("form_name", { length: 255 }).notNull(),
- // tagType: varchar("tag_type", { length: 50 }).notNull(),
- // class: varchar("class", { length: 100 }).notNull(),
- createdAt: timestamp("created_at").defaultNow().notNull(),
- updatedAt: timestamp("updated_at").defaultNow().notNull(),
-}, (table) => {
- return {
+ formCode: varchar("form_code", { length: 100 }).notNull(),
+ formName: varchar("form_name", { length: 255 }).notNull(),
+ // tagType: varchar("tag_type", { length: 50 }).notNull(),
+ // class: varchar("class", { length: 100 }).notNull(),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
+ },
+ (table) => {
+ return {
// contractItemId와 formCode의 조합을 유니크하게 설정
- contractItemFormCodeUnique: uniqueIndex("contract_item_form_code_unique").on(
- table.contractItemId,
- table.formCode
- ),
+ contractItemFormCodeUnique: uniqueIndex(
+ "contract_item_form_code_unique"
+ ).on(table.contractItemId, table.formCode),
+ };
}
-})
-
-export const rfqAttachments = pgTable("form_templates", {
+ );
+
+ export const rfqAttachments = pgTable("form_templates", {
id: serial("id").primaryKey(),
formId: integer("form_id").references(() => forms.id),
fileName: varchar("file_name", { length: 255 }).notNull(),
@@ -36,170 +45,220 @@ export const rfqAttachments = pgTable("form_templates", {
createdAt: timestamp("created_at").defaultNow().notNull(),
udpatedAt: timestamp("updated_at").defaultNow().notNull(),
+ });
-});
-
-
-export const formMetas = pgTable("form_metas", {
+ export const formMetas = pgTable("form_metas", {
id: serial("id").primaryKey(),
formCode: varchar("form_code", { length: 50 }).notNull(),
formName: varchar("form_name", { length: 255 }).notNull(),
columns: jsonb("columns").notNull(),
- createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
- updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
-})
-
-export const formEntries = pgTable("form_entries", {
+ createdAt: timestamp("created_at", { withTimezone: true })
+ .defaultNow()
+ .notNull(),
+ updatedAt: timestamp("updated_at", { withTimezone: true })
+ .defaultNow()
+ .notNull(),
+ });
+
+ export const formEntries = pgTable("form_entries", {
id: serial("id").primaryKey(),
formCode: varchar("form_code", { length: 50 }).notNull(),
data: jsonb("data").notNull(),
contractItemId: integer("contract_item_id")
- .notNull()
- .references(() => contractItems.id, { onDelete: "cascade" }),
- createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
- updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
-})
-
-
-// ============ tags (각 계약 아이템에 대한 Tag) ============
-// "어느 계약의 어느 아이템에 대한 태그"임을 나타내려면 contract_items를 참조
-export const tags = pgTable("tags", {
+ .notNull()
+ .references(() => contractItems.id, { onDelete: "cascade" }),
+ createdAt: timestamp("created_at", { withTimezone: true })
+ .defaultNow()
+ .notNull(),
+ updatedAt: timestamp("updated_at", { withTimezone: true })
+ .defaultNow()
+ .notNull(),
+ });
+
+ // ============ tags (각 계약 아이템에 대한 Tag) ============
+ // "어느 계약의 어느 아이템에 대한 태그"임을 나타내려면 contract_items를 참조
+ export const tags = pgTable("tags", {
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
-
+
// 이 Tag가 속한 "계약 내 아이템" (즉 contract_items.id)
contractItemId: integer("contract_item_id")
- .notNull()
- .references(() => contractItems.id, { onDelete: "cascade" }),
-
- formId: integer("form_id")
- .references(() => forms.id, { onDelete: "set null" }),
-
+ .notNull()
+ .references(() => contractItems.id, { onDelete: "cascade" }),
+
+ formId: integer("form_id").references(() => forms.id, {
+ onDelete: "set null",
+ }),
+
tagNo: varchar("tag_no", { length: 100 }).notNull(),
tagType: varchar("tag_type", { length: 50 }).notNull(),
class: varchar("class", { length: 100 }).notNull(),
description: text("description"),
-
+
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
-})
-
-export type Tag = typeof tags.$inferSelect
-export type Form = typeof forms.$inferSelect
-export type NewTag = typeof tags.$inferInsert
-
-export const tagTypes = pgTable("tag_types", {
+ });
+
+ export type Tag = typeof tags.$inferSelect;
+ export type Form = typeof forms.$inferSelect;
+ export type NewTag = typeof tags.$inferInsert;
+
+ export const tagTypes = pgTable("tag_types", {
code: varchar("code", { length: 50 }).primaryKey(),
description: text("description").notNull(),
-
- createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
- updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
-})
-
-export const tagSubfields = pgTable("tag_subfields", {
- id: serial("id").primaryKey(),
-
- // 외래키: tagTypeCode -> tagTypes.code
- tagTypeCode: varchar("tag_type_code", { length: 50 })
+
+ createdAt: timestamp("created_at", { withTimezone: true })
+ .defaultNow()
+ .notNull(),
+ updatedAt: timestamp("updated_at", { withTimezone: true })
+ .defaultNow()
+ .notNull(),
+ });
+
+ export const tagSubfields = pgTable(
+ "tag_subfields",
+ {
+ id: serial("id").primaryKey(),
+
+ // 외래키: tagTypeCode -> tagTypes.code
+ tagTypeCode: varchar("tag_type_code", { length: 50 })
.notNull()
.references(() => tagTypes.code, { onDelete: "cascade" }),
-
- /**
- * 나머지 필드
- */
- // tagTypeDescription: -> 이제 불필요. tagTypes.description로 join
- attributesId: varchar("attributes_id", { length: 50 }).notNull(),
- attributesDescription: text("attributes_description").notNull(),
-
- expression: text("expression"),
- delimiter: varchar("delimiter", { length: 10 }),
-
- sortOrder: integer("sort_order").default(0).notNull(),
-
- createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
- updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
-}, (table) => {
- return {
+
+ /**
+ * 나머지 필드
+ */
+ // tagTypeDescription: -> 이제 불필요. tagTypes.description로 join
+ attributesId: varchar("attributes_id", { length: 50 }).notNull(),
+ attributesDescription: text("attributes_description").notNull(),
+
+ expression: text("expression"),
+ delimiter: varchar("delimiter", { length: 10 }),
+
+ sortOrder: integer("sort_order").default(0).notNull(),
+
+ createdAt: timestamp("created_at", { withTimezone: true })
+ .defaultNow()
+ .notNull(),
+ updatedAt: timestamp("updated_at", { withTimezone: true })
+ .defaultNow()
+ .notNull(),
+ },
+ (table) => {
+ return {
uniqTagTypeAttribute: unique("uniq_tag_type_attribute").on(
- table.tagTypeCode,
- table.attributesId
+ table.tagTypeCode,
+ table.attributesId
),
- };
-});
-
-export const tagSubfieldOptions = pgTable("tag_subfield_options", {
+ };
+ }
+ );
+
+ export const tagSubfieldOptions = pgTable("tag_subfield_options", {
id: serial("id").primaryKey(),
-
+
// 어떤 subfield에 속하는 옵션인지
attributesId: varchar("attributes_id", { length: 50 })
- .notNull()
- .references(() => tagSubfields.attributesId, { onDelete: "cascade" }),
-
+ .notNull()
+ .references(() => tagSubfields.attributesId, { onDelete: "cascade" }),
+
/**
* 실제 코드 (예: "PM", "AA", "VB", "VAR", "01", "02" ...)
*/
code: varchar("code", { length: 50 }).notNull(),
-
+
/**
* 사용자에게 보여줄 레이블 (예: "Pump", "Pneumatic Motor", "Ball Valve", ...)
*/
label: text("label").notNull(),
-
+
/**
* 생성/수정 시각 (선택)
*/
- createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
- updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
-})
-
-export const tagClasses = pgTable("tag_classes", {
+ createdAt: timestamp("created_at", { withTimezone: true })
+ .defaultNow()
+ .notNull(),
+ updatedAt: timestamp("updated_at", { withTimezone: true })
+ .defaultNow()
+ .notNull(),
+ });
+
+ export const tagClasses = pgTable("tag_classes", {
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
-
+
// 기존 code/label
code: varchar("code", { length: 100 }).notNull(),
label: text("label").notNull(),
-
+
// 새 필드: tagTypeCode -> references tagTypes.code
tagTypeCode: varchar("tag_type_code", { length: 50 })
- .notNull()
- .references(() => tagTypes.code, { onDelete: "cascade" }),
-
+ .notNull()
+ .references(() => tagTypes.code, { onDelete: "cascade" }),
+
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
-})
-
-export const tagTypeClassFormMappings = pgTable("tag_type_class_form_mappings", {
- id: serial("id").primaryKey(),
-
- tagTypeLabel: varchar("tag_type_label", { length: 255 }).notNull(),
- classLabel: varchar("class_label", { length: 255 }).notNull(),
-
- formCode: varchar("form_code", { length: 50 }).notNull(),
- formName: varchar("form_name", { length: 255 }).notNull(),
-
- createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
- updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
-})
-
-export type TagTypeClassFormMappings = typeof tagTypeClassFormMappings.$inferSelect
-export type TagSubfields = typeof tagSubfields.$inferSelect
-export type TagSubfieldOption = typeof tagSubfieldOptions.$inferSelect
-export type TagClasses = typeof tagClasses.$inferSelect
-
-
-export const viewTagSubfields = pgTable("view_tag_subfields", {
+ });
+
+ export const tagTypeClassFormMappings = pgTable(
+ "tag_type_class_form_mappings",
+ {
+ id: serial("id").primaryKey(),
+
+ tagTypeLabel: varchar("tag_type_label", { length: 255 }).notNull(),
+ classLabel: varchar("class_label", { length: 255 }).notNull(),
+
+ formCode: varchar("form_code", { length: 50 }).notNull(),
+ formName: varchar("form_name", { length: 255 }).notNull(),
+
+ createdAt: timestamp("created_at", { withTimezone: true })
+ .defaultNow()
+ .notNull(),
+ updatedAt: timestamp("updated_at", { withTimezone: true })
+ .defaultNow()
+ .notNull(),
+ }
+ );
+
+ export type TagTypeClassFormMappings =
+ typeof tagTypeClassFormMappings.$inferSelect;
+ export type TagSubfields = typeof tagSubfields.$inferSelect;
+ export type TagSubfieldOption = typeof tagSubfieldOptions.$inferSelect;
+ export type TagClasses = typeof tagClasses.$inferSelect;
+
+ export const viewTagSubfields = pgTable("view_tag_subfields", {
id: integer("id").primaryKey(),
-
+
tagTypeCode: varchar("tag_type_code", { length: 50 }).notNull(),
tagTypeDescription: text("tag_type_description"),
attributesId: varchar("attributes_id", { length: 50 }).notNull(),
attributesDescription: text("attributes_description").notNull(),
-
+
expression: text("expression"),
delimiter: varchar("delimiter", { length: 10 }),
sortOrder: integer("sort_order").default(0).notNull(),
-
+
createdAt: timestamp("created_at", { withTimezone: true }),
updatedAt: timestamp("updated_at", { withTimezone: true }),
-})
-
-export type ViewTagSubfields = typeof viewTagSubfields.$inferSelect
+ });
+
+ export type ViewTagSubfields = typeof viewTagSubfields.$inferSelect;
+
+ export const vendorDataReportTemps = pgTable("vendor_data_report_temps", {
+ id: serial("id").primaryKey(),
+ contractItemId: integer("contract_item_id")
+ .notNull()
+ .references(() => contractItems.id, { onDelete: "cascade" }),
+ formId: integer("form_id")
+ .notNull()
+ .references(() => forms.id, { onDelete: "cascade" }),
+ fileName: varchar("file_name", { length: 255 }).notNull(),
+ filePath: varchar("file_path", { length: 1024 }).notNull(),
+ createdAt: timestamp("created_at", { withTimezone: true })
+ .defaultNow()
+ .notNull(),
+ updatedAt: timestamp("updated_at", { withTimezone: true })
+ .defaultNow()
+ .notNull(),
+ });
+
+ export type VendorDataReportTemps = typeof vendorDataReportTemps.$inferSelect;
+ \ No newline at end of file
diff --git a/lib/docuSign/docuSignFns.ts b/lib/docuSign/docuSignFns.ts
index 87977a0b..662ff23a 100644
--- a/lib/docuSign/docuSignFns.ts
+++ b/lib/docuSign/docuSignFns.ts
@@ -103,6 +103,7 @@ export async function requestContractSign(
let accountInfo = await authenticate();
if (accountInfo) {
const { accessToken, basePath, apiAccountId } = accountInfo;
+ console.log({ basePath });
const {
email: subEmail,
name: subConName,
@@ -362,11 +363,11 @@ export async function getRecipients(
result: false,
message: "해당 Recipient id를 가진 서명자를 찾을 수 없습니다.",
};
- }
+ }
- const { autoRespondedReason, status } = signer;
+ const { autoRespondedReason } = signer;
- if (autoRespondedReason || status === "status") {
+ if (autoRespondedReason) {
return {
result: false,
message: autoRespondedReason,
diff --git a/lib/forms/services.ts b/lib/forms/services.ts
index e5fc8666..a1bbf003 100644
--- a/lib/forms/services.ts
+++ b/lib/forms/services.ts
@@ -1,18 +1,30 @@
// lib/forms/services.ts
-"use server"
+"use server";
+import { headers } from "next/headers";
+import path from "path";
+import fs from "fs/promises";
+import { v4 as uuidv4 } from "uuid";
import db from "@/db/db";
-import { formEntries, formMetas, forms, tags, tagTypeClassFormMappings } from "@/db/schema/vendorData"
-import { eq, and, desc, sql, DrizzleError, or } from "drizzle-orm"
-import { unstable_cache } from "next/cache"
-import { revalidateTag } from "next/cache"
+import {
+ formEntries,
+ formMetas,
+ forms,
+ tags,
+ tagTypeClassFormMappings,
+ vendorDataReportTemps,
+ VendorDataReportTemps,
+} from "@/db/schema/vendorData";
+import { eq, and, desc, sql, DrizzleError, or } from "drizzle-orm";
+import { unstable_cache } from "next/cache";
+import { revalidateTag } from "next/cache";
import { getErrorMessage } from "../handle-error";
import { DataTableColumnJSON } from "@/components/form-data/form-data-table-columns";
export interface FormInfo {
- id: number
- formCode: string
- formName: string
+ id: number;
+ formCode: string;
+ formName: string;
// tagType: string
}
@@ -29,7 +41,9 @@ export async function getFormsByContractItemId(contractItemId: number | null) {
try {
return unstable_cache(
async () => {
- console.log(`[Forms Service] Fetching forms for contractItemId: ${contractItemId}`);
+ console.log(
+ `[Forms Service] Fetching forms for contractItemId: ${contractItemId}`
+ );
try {
// 데이터베이스에서 폼 조회
@@ -38,38 +52,48 @@ export async function getFormsByContractItemId(contractItemId: number | null) {
id: forms.id,
formCode: forms.formCode,
formName: forms.formName,
- // tagType: forms.tagType,
+ // tagType: forms.tagType,
})
.from(forms)
.where(eq(forms.contractItemId, contractItemId));
- console.log(`[Forms Service] Found ${formRecords.length} forms for contractItemId: ${contractItemId}`);
+ console.log(
+ `[Forms Service] Found ${formRecords.length} forms for contractItemId: ${contractItemId}`
+ );
// 결과가 배열인지 확인
if (!Array.isArray(formRecords)) {
- getErrorMessage(`Unexpected result format for contractItemId ${contractItemId} ${formRecords}`);
+ getErrorMessage(
+ `Unexpected result format for contractItemId ${contractItemId} ${formRecords}`
+ );
return { forms: [] };
}
return { forms: formRecords };
} catch (error) {
- getErrorMessage(`Database error for contractItemId ${contractItemId}: ${error}`);
+ getErrorMessage(
+ `Database error for contractItemId ${contractItemId}: ${error}`
+ );
throw error; // 캐시 함수에서 에러를 던져 캐싱이 발생하지 않도록 함
}
},
[cacheKey],
{
// 캐시 시간 단축
- revalidate: 60, // 1분으로 줄임
- tags: [cacheKey]
+ revalidate: 60, // 1분으로 줄임
+ tags: [cacheKey],
}
)();
} catch (error) {
- getErrorMessage(`Cache operation failed for contractItemId ${contractItemId}: ${error}`);
+ getErrorMessage(
+ `Cache operation failed for contractItemId ${contractItemId}: ${error}`
+ );
// 캐시 문제 시 직접 쿼리 시도
try {
- console.log(`[Forms Service] Fallback: Direct query for contractItemId: ${contractItemId}`);
+ console.log(
+ `[Forms Service] Fallback: Direct query for contractItemId: ${contractItemId}`
+ );
const formRecords = await db
.select({
@@ -83,7 +107,9 @@ export async function getFormsByContractItemId(contractItemId: number | null) {
return { forms: formRecords };
} catch (dbError) {
- getErrorMessage(`Fallback query failed for contractItemId ${contractItemId}:${dbError}`);
+ getErrorMessage(
+ `Fallback query failed for contractItemId ${contractItemId}:${dbError}`
+ );
return { forms: [] };
}
}
@@ -113,7 +139,7 @@ export async function revalidateForms(contractItemId: number) {
*/
export async function getFormData(formCode: string, contractItemId: number) {
// 고유 캐시 키 (formCode + contractItemId)
- const cacheKey = `form-data-${formCode}-${contractItemId}`
+ const cacheKey = `form-data-${formCode}-${contractItemId}`;
try {
// 1) unstable_cache로 전체 로직을 감싼다
@@ -126,24 +152,29 @@ export async function getFormData(formCode: string, contractItemId: number) {
.from(formMetas)
.where(eq(formMetas.formCode, formCode))
.orderBy(desc(formMetas.updatedAt))
- .limit(1)
+ .limit(1);
- const meta = metaRows[0] ?? null
+ const meta = metaRows[0] ?? null;
if (!meta) {
- return { columns: null, data: [] }
+ return { columns: null, data: [] };
}
// (2) form_entries에서 (formCode, contractItemId)에 해당하는 "가장 최신" 한 행
const entryRows = await db
.select()
.from(formEntries)
- .where(and(eq(formEntries.formCode, formCode), eq(formEntries.contractItemId, contractItemId)))
+ .where(
+ and(
+ eq(formEntries.formCode, formCode),
+ eq(formEntries.contractItemId, contractItemId)
+ )
+ )
.orderBy(desc(formEntries.updatedAt))
- .limit(1)
+ .limit(1);
- const entry = entryRows[0] ?? null
+ const entry = entryRows[0] ?? null;
// columns: DB에 저장된 JSON (DataTableColumnJSON[])
- const columns = meta.columns as DataTableColumnJSON[]
+ const columns = meta.columns as DataTableColumnJSON[];
columns.forEach((col) => {
// 이미 displayLabel이 있으면 그대로 두고,
@@ -151,40 +182,44 @@ export async function getFormData(formCode: string, contractItemId: number) {
// 둘 다 없으면 label만 쓴다.
if (!col.displayLabel) {
if (col.uom) {
- col.displayLabel = `${col.label} (${col.uom})`
+ col.displayLabel = `${col.label} (${col.uom})`;
} else {
- col.displayLabel = col.label
+ col.displayLabel = col.label;
}
}
- })
-
+ });
+
// data: 만약 entry가 없거나, data가 아닌 형태면 빈 배열
- let data: Array<Record<string, any>> = []
+ let data: Array<Record<string, any>> = [];
if (entry) {
if (Array.isArray(entry.data)) {
- data = entry.data
+ data = entry.data;
} else {
- console.warn("formEntries data was not an array. Using empty array.")
+ console.warn(
+ "formEntries data was not an array. Using empty array."
+ );
}
}
- return { columns, data }
+ return { columns, data };
// --- 기존 로직 끝 ---
},
[cacheKey], // 캐시 키 의존성
{
- revalidate: 60, // 1분 캐시
- tags: [cacheKey], // 캐시 태그
+ revalidate: 60, // 1분 캐시
+ tags: [cacheKey], // 캐시 태그
}
- )()
+ )();
- return result
+ return result;
} catch (cacheError) {
- console.error(`[getFormData] Cache operation failed:`, cacheError)
+ console.error(`[getFormData] Cache operation failed:`, cacheError);
// --- fallback: 캐시 문제 시 직접 쿼리 시도 ---
try {
- console.log(`[getFormData] Fallback DB query for (${formCode}, ${contractItemId})`)
+ console.log(
+ `[getFormData] Fallback DB query for (${formCode}, ${contractItemId})`
+ );
// (1) form_metas
const metaRows = await db
@@ -192,24 +227,29 @@ export async function getFormData(formCode: string, contractItemId: number) {
.from(formMetas)
.where(eq(formMetas.formCode, formCode))
.orderBy(desc(formMetas.updatedAt))
- .limit(1)
+ .limit(1);
- const meta = metaRows[0] ?? null
+ const meta = metaRows[0] ?? null;
if (!meta) {
- return { columns: null, data: [] }
+ return { columns: null, data: [] };
}
// (2) form_entries
const entryRows = await db
.select()
.from(formEntries)
- .where(and(eq(formEntries.formCode, formCode), eq(formEntries.contractItemId, contractItemId)))
+ .where(
+ and(
+ eq(formEntries.formCode, formCode),
+ eq(formEntries.contractItemId, contractItemId)
+ )
+ )
.orderBy(desc(formEntries.updatedAt))
- .limit(1)
+ .limit(1);
- const entry = entryRows[0] ?? null
+ const entry = entryRows[0] ?? null;
- const columns = meta.columns as DataTableColumnJSON[]
+ const columns = meta.columns as DataTableColumnJSON[];
columns.forEach((col) => {
// 이미 displayLabel이 있으면 그대로 두고,
@@ -217,33 +257,34 @@ export async function getFormData(formCode: string, contractItemId: number) {
// 둘 다 없으면 label만 쓴다.
if (!col.displayLabel) {
if (col.uom) {
- col.displayLabel = `${col.label} (${col.uom})`
+ col.displayLabel = `${col.label} (${col.uom})`;
} else {
- col.displayLabel = col.label
+ col.displayLabel = col.label;
}
}
- })
+ });
- let data: Array<Record<string, any>> = []
+ let data: Array<Record<string, any>> = [];
if (entry) {
if (Array.isArray(entry.data)) {
- data = entry.data
+ data = entry.data;
} else {
- console.warn("formEntries data was not an array. Using empty array (fallback).")
+ console.warn(
+ "formEntries data was not an array. Using empty array (fallback)."
+ );
}
}
- return { columns, data }
+ return { columns, data };
} catch (dbError) {
- console.error(`[getFormData] Fallback DB query failed:`, dbError)
- return { columns: null, data: [] }
+ console.error(`[getFormData] Fallback DB query failed:`, dbError);
+ return { columns: null, data: [] };
}
}
}
// export async function syncMissingTags(contractItemId: number, formCode: string) {
-
// // (1) forms 테이블에서 (contractItemId, formCode) 찾기
// const [formRow] = await db
// .select()
@@ -320,50 +361,55 @@ export async function getFormData(formCode: string, contractItemId: number) {
// .where(eq(formEntries.id, entry.id))
// }
-
// revalidateTag(`form-data-${formCode}-${contractItemId}`);
// return { createdCount }
// }
-export async function syncMissingTags(contractItemId: number, formCode: string) {
+export async function syncMissingTags(
+ contractItemId: number,
+ formCode: string
+) {
// (1) Ensure there's a row in `forms` matching (contractItemId, formCode).
const [formRow] = await db
.select()
.from(forms)
.where(
- and(eq(forms.contractItemId, contractItemId), eq(forms.formCode, formCode))
+ and(
+ eq(forms.contractItemId, contractItemId),
+ eq(forms.formCode, formCode)
+ )
)
- .limit(1)
+ .limit(1);
if (!formRow) {
throw new Error(
`Form not found for contractItemId=${contractItemId}, formCode=${formCode}`
- )
+ );
}
// (2) Get all mappings from `tagTypeClassFormMappings` for this formCode.
const formMappings = await db
.select()
.from(tagTypeClassFormMappings)
- .where(eq(tagTypeClassFormMappings.formCode, formCode))
+ .where(eq(tagTypeClassFormMappings.formCode, formCode));
// If no mappings are found, there's nothing to sync.
if (formMappings.length === 0) {
- console.log(`No mappings found for formCode=${formCode}`)
- return { createdCount: 0, updatedCount: 0, deletedCount: 0 }
+ console.log(`No mappings found for formCode=${formCode}`);
+ return { createdCount: 0, updatedCount: 0, deletedCount: 0 };
}
// Build a dynamic OR clause to match (tagType, class) pairs from the mappings.
const orConditions = formMappings.map((m) =>
and(eq(tags.tagType, m.tagTypeLabel), eq(tags.class, m.classLabel))
- )
+ );
// (3) Fetch all matching `tags` for the contractItemId + any of the (tagType, class) pairs.
const tagRows = await db
.select()
.from(tags)
- .where(and(eq(tags.contractItemId, contractItemId), or(...orConditions)))
+ .where(and(eq(tags.contractItemId, contractItemId), or(...orConditions)));
// (4) Fetch (or create) a single `formEntries` row for (contractItemId, formCode).
let [entry] = await db
@@ -375,7 +421,7 @@ export async function syncMissingTags(contractItemId: number, formCode: string)
eq(formEntries.formCode, formCode)
)
)
- .limit(1)
+ .limit(1);
if (!entry) {
const [inserted] = await db
@@ -385,64 +431,64 @@ export async function syncMissingTags(contractItemId: number, formCode: string)
formCode,
data: [], // Initialize with empty array
})
- .returning()
- entry = inserted
+ .returning();
+ entry = inserted;
}
// entry.data는 [{ tagNumber: string, tagDescription?: string }, ...] 형태라고 가정
const existingData = entry.data as Array<{
- tagNumber: string
- tagDescription?: string
- }>
+ tagNumber: string;
+ tagDescription?: string;
+ }>;
// Create a Set of valid tagNumbers from tagRows for efficient lookup
- const validTagNumbers = new Set(tagRows.map(tag => tag.tagNo))
+ const validTagNumbers = new Set(tagRows.map((tag) => tag.tagNo));
// Copy existing data to work with
let updatedData: Array<{
- tagNumber: string
- tagDescription?: string
- }> = []
-
- let createdCount = 0
- let updatedCount = 0
- let deletedCount = 0
+ tagNumber: string;
+ tagDescription?: string;
+ }> = [];
+
+ let createdCount = 0;
+ let updatedCount = 0;
+ let deletedCount = 0;
// First, filter out items that should be deleted (not in validTagNumbers)
for (const item of existingData) {
if (validTagNumbers.has(item.tagNumber)) {
- updatedData.push(item)
+ updatedData.push(item);
} else {
- deletedCount++
+ deletedCount++;
}
}
// (5) For each tagRow, if it's missing in updatedData, push it in.
// 이미 있는 경우에도 description이 달라지면 업데이트할 수 있음.
for (const tagRow of tagRows) {
- const { tagNo, description } = tagRow
-
+ const { tagNo, description } = tagRow;
+
// 5-1. 기존 데이터에서 tagNumber 매칭
const existingIndex = updatedData.findIndex(
(item) => item.tagNumber === tagNo
- )
-
+ );
+
// 5-2. 없다면 새로 추가
if (existingIndex === -1) {
updatedData.push({
tagNumber: tagNo,
tagDescription: description ?? "",
- })
- createdCount++
+ });
+ createdCount++;
} else {
// 5-3. 이미 있으면, description이 다를 때만 업데이트(선택 사항)
- const existingItem = updatedData[existingIndex]
+ const existingItem = updatedData[existingIndex];
if (existingItem.tagDescription !== description) {
updatedData[existingIndex] = {
...existingItem,
tagDescription: description ?? "",
- }
- updatedCount++
+ };
+ updatedCount++;
}
}
}
@@ -452,13 +498,13 @@ export async function syncMissingTags(contractItemId: number, formCode: string)
await db
.update(formEntries)
.set({ data: updatedData })
- .where(eq(formEntries.id, entry.id))
+ .where(eq(formEntries.id, entry.id));
}
// 캐시 무효화 등 후처리
- revalidateTag(`form-data-${formCode}-${contractItemId}`)
+ revalidateTag(`form-data-${formCode}-${contractItemId}`);
- return { createdCount, updatedCount, deletedCount }
+ return { createdCount, updatedCount, deletedCount };
}
/**
@@ -468,10 +514,10 @@ export async function syncMissingTags(contractItemId: number, formCode: string)
* 업데이트 후, revalidateTag()로 캐시 무효화.
*/
type UpdateResponse = {
- success: boolean
- message: string
- data?: any
-}
+ success: boolean;
+ message: string;
+ data?: any;
+};
export async function updateFormDataInDB(
formCode: string,
@@ -480,12 +526,12 @@ export async function updateFormDataInDB(
): Promise<UpdateResponse> {
try {
// 1) tagNumber로 식별
- const tagNumber = newData.tagNumber
+ const tagNumber = newData.tagNumber;
if (!tagNumber) {
return {
success: false,
- message: "tagNumber는 필수 항목입니다."
- }
+ message: "tagNumber는 필수 항목입니다.",
+ };
}
// 2) row 찾기 (단 하나)
@@ -498,52 +544,52 @@ export async function updateFormDataInDB(
eq(formEntries.contractItemId, contractItemId)
)
)
- .limit(1)
+ .limit(1);
if (!entries || entries.length === 0) {
return {
success: false,
- message: `폼 데이터를 찾을 수 없습니다. (formCode=${formCode}, contractItemId=${contractItemId})`
- }
+ message: `폼 데이터를 찾을 수 없습니다. (formCode=${formCode}, contractItemId=${contractItemId})`,
+ };
}
- const entry = entries[0]
+ const entry = entries[0];
// 3) data가 배열인지 확인
if (!entry.data) {
return {
success: false,
- message: "폼 데이터가 없습니다."
- }
+ message: "폼 데이터가 없습니다.",
+ };
}
- const dataArray = entry.data as Array<Record<string, any>>
+ const dataArray = entry.data as Array<Record<string, any>>;
if (!Array.isArray(dataArray)) {
return {
success: false,
- message: "폼 데이터가 올바른 형식이 아닙니다. 배열 형식이어야 합니다."
- }
+ message: "폼 데이터가 올바른 형식이 아닙니다. 배열 형식이어야 합니다.",
+ };
}
// 4) tagNumber = newData.tagNumber 항목 찾기
- const idx = dataArray.findIndex((item) => item.tagNumber === tagNumber)
+ const idx = dataArray.findIndex((item) => item.tagNumber === tagNumber);
if (idx < 0) {
return {
success: false,
- message: `태그 번호 "${tagNumber}"를 가진 항목을 찾을 수 없습니다.`
- }
+ message: `태그 번호 "${tagNumber}"를 가진 항목을 찾을 수 없습니다.`,
+ };
}
// 5) 병합
- const oldItem = dataArray[idx]
+ const oldItem = dataArray[idx];
const updatedItem = {
...oldItem,
...newData,
tagNumber: oldItem.tagNumber, // tagNumber 변경 불가 시 유지
- }
+ };
- const updatedArray = [...dataArray]
- updatedArray[idx] = updatedItem
+ const updatedArray = [...dataArray];
+ updatedArray[idx] = updatedItem;
// 6) DB UPDATE
try {
@@ -551,67 +597,70 @@ export async function updateFormDataInDB(
.update(formEntries)
.set({
data: updatedArray,
- updatedAt: new Date() // 업데이트 시간도 갱신
+ updatedAt: new Date(), // 업데이트 시간도 갱신
})
- .where(eq(formEntries.id, entry.id))
+ .where(eq(formEntries.id, entry.id));
} catch (dbError) {
- console.error("Database update error:", dbError)
+ console.error("Database update error:", dbError);
if (dbError instanceof DrizzleError) {
return {
success: false,
- message: `데이터베이스 업데이트 오류: ${dbError.message}`
- }
+ message: `데이터베이스 업데이트 오류: ${dbError.message}`,
+ };
}
return {
success: false,
- message: "데이터베이스 업데이트 중 오류가 발생했습니다."
- }
+ message: "데이터베이스 업데이트 중 오류가 발생했습니다.",
+ };
}
// 7) Cache 무효화
try {
// 캐시 태그를 form-data-${formCode}-${contractItemId} 형태로 가정
- const cacheTag = `form-data-${formCode}-${contractItemId}`
- revalidateTag(cacheTag)
+ const cacheTag = `form-data-${formCode}-${contractItemId}`;
+ revalidateTag(cacheTag);
} catch (cacheError) {
- console.warn("Cache revalidation warning:", cacheError)
+ console.warn("Cache revalidation warning:", cacheError);
// 캐시 무효화는 실패해도 업데이트 자체는 성공했으므로 경고만 로그로 남김
}
return {
success: true,
- message: '데이터가 성공적으로 업데이트되었습니다.',
+ message: "데이터가 성공적으로 업데이트되었습니다.",
data: {
tagNumber,
- updatedFields: Object.keys(newData).filter(key => key !== 'tagNumber')
- }
- }
+ updatedFields: Object.keys(newData).filter(
+ (key) => key !== "tagNumber"
+ ),
+ },
+ };
} catch (error) {
// 예상치 못한 오류 처리
- console.error("Unexpected error in updateFormDataInDB:", error)
+ console.error("Unexpected error in updateFormDataInDB:", error);
return {
success: false,
- message: error instanceof Error
- ? `예상치 못한 오류가 발생했습니다: ${error.message}`
- : "알 수 없는 오류가 발생했습니다."
- }
+ message:
+ error instanceof Error
+ ? `예상치 못한 오류가 발생했습니다: ${error.message}`
+ : "알 수 없는 오류가 발생했습니다.",
+ };
}
}
// FormColumn Type (동일)
export interface FormColumn {
- key: string
- type: string
- label: string
- options?: string[]
+ key: string;
+ type: string;
+ label: string;
+ options?: string[];
}
interface MetadataResult {
- formName: string
- formCode: string
- columns: FormColumn[]
+ formName: string;
+ formCode: string;
+ columns: FormColumn[];
}
/**
@@ -620,26 +669,149 @@ interface MetadataResult {
* { formName, formCode, columns } 형태로 반환.
* 없으면 null.
*/
-export async function fetchFormMetadata(formCode: string): Promise<MetadataResult | null> {
+export async function fetchFormMetadata(
+ formCode: string
+): Promise<MetadataResult | null> {
try {
// 기존 방식: select().from().where()
const rows = await db
.select()
.from(formMetas)
.where(eq(formMetas.formCode, formCode))
- .limit(1)
+ .limit(1);
// rows는 배열
- const metaData = rows[0]
- if (!metaData) return null
+ const metaData = rows[0];
+ if (!metaData) return null;
return {
formCode: metaData.formCode,
formName: metaData.formName,
- columns: metaData.columns as FormColumn[]
+ columns: metaData.columns as FormColumn[],
+ };
+ } catch (err) {
+ console.error("Error in fetchFormMetadata:", err);
+ return null;
+ }
+}
+
+type GetReportFileList = (
+ packageId: string,
+ formCode: string
+) => Promise<{
+ formId: number;
+}>;
+
+export const getFormId: GetReportFileList = async (packageId, formCode) => {
+ const result: { formId: number } = {
+ formId: 0,
+ };
+ try {
+ const [targetForm] = await db
+ .select()
+ .from(forms)
+ .where(
+ and(
+ eq(forms.formCode, formCode),
+ eq(forms.contractItemId, Number(packageId))
+ )
+ );
+
+ if (!targetForm) {
+ throw new Error("Not Found Target Form");
}
+
+ const { id: formId } = targetForm;
+
+ result.formId = formId;
+ } catch (err) {
+ } finally {
+ return result;
+ }
+};
+
+type getReportTempList = (
+ packageId: number,
+ formId: number
+) => Promise<VendorDataReportTemps[]>;
+
+export const getReportTempList: getReportTempList = async (
+ packageId,
+ formId
+) => {
+ let result: VendorDataReportTemps[] = [];
+
+ try {
+ result = await db
+ .select()
+ .from(vendorDataReportTemps)
+ .where(
+ and(
+ eq(vendorDataReportTemps.contractItemId, packageId),
+ eq(vendorDataReportTemps.formId, formId)
+ )
+ );
} catch (err) {
- console.error("Error in fetchFormMetadata:", err)
- return null
+ } finally {
+ return result;
+ }
+};
+
+export async function uploadReportTemp(
+ packageId: number,
+ formId: number,
+ formData: FormData
+) {
+ const file = formData.get("file") as File | null;
+ const customFileName = formData.get("customFileName") as string;
+ const uploaderType = (formData.get("uploaderType") as string) || "vendor";
+
+ if (!["vendor", "client", "shi"].includes(uploaderType)) {
+ throw new Error(
+ `Invalid uploaderType: ${uploaderType}. Must be one of: vendor, client, shi`
+ );
+ }
+ if (file && file.size > 0) {
+ const originalName = customFileName;
+ const ext = path.extname(originalName);
+ const uniqueName = uuidv4() + ext;
+ const baseDir = path.join(
+ process.cwd(),
+ "public",
+ "vendorFormData",
+ packageId.toString(),
+ formId.toString()
+ );
+
+ const savePath = path.join(baseDir, uniqueName);
+
+ const arrayBuffer = await file.arrayBuffer();
+ const buffer = Buffer.from(arrayBuffer);
+
+ await fs.mkdir(baseDir, { recursive: true });
+
+ await fs.writeFile(savePath, buffer);
+
+ return db.transaction(async (tx) => {
+ // 파일 정보를 테이블에 저장
+ await tx
+ .insert(vendorDataReportTemps)
+ .values({
+ contractItemId: packageId,
+ formId: formId,
+ fileName: originalName,
+ filePath: `/vendorFormData/${packageId.toString()}/${formId.toString()}/${uniqueName}`,
+ })
+ .returning();
+ });
}
-} \ No newline at end of file
+}
+
+export const getOrigin = async ():Promise<string> => {
+ const headersList = await headers();
+ const host = headersList.get("host");
+ const proto = headersList.get("x-forwarded-proto") || "http"; // 기본값은 http
+ const origin = `${proto}://${host}`;
+
+ return origin;
+};
diff --git a/lib/pdftron/serverSDK/createReport.ts b/lib/pdftron/serverSDK/createReport.ts
new file mode 100644
index 00000000..412ada87
--- /dev/null
+++ b/lib/pdftron/serverSDK/createReport.ts
@@ -0,0 +1,83 @@
+const { PDFNet } = require("@pdftron/pdfnet-node");
+
+type CreateReport = (
+ coverPage: Buffer,
+ reportTempPath: string,
+ reportDatas: {
+ [key: string]: any;
+ }[]
+) => Promise<{
+ result: boolean;
+ buffer?: ArrayBuffer;
+ error?: any;
+}>;
+
+export const createReport: CreateReport = async (
+ coverPage,
+ reportTempPath,
+ reportDatas
+) => {
+ const main = async () => {
+ await PDFNet.initialize(process.env.NEXT_PUBLIC_PDFTRON_SERVER_KEY);
+
+ const mainDoc = await PDFNet.PDFDoc.create();
+ const buf = await PDFNet.Convert.office2PDFBuffer(coverPage);
+ const coverPDFDoc = await PDFNet.PDFDoc.createFromBuffer(buf);
+
+ const options = new PDFNet.Convert.OfficeToPDFOptions();
+
+ await mainDoc.insertPages(
+ (await mainDoc.getPageCount()) + 1,
+ coverPDFDoc,
+ 1,
+ await coverPDFDoc.getPageCount(),
+ PDFNet.PDFDoc.InsertFlag.e_none
+ );
+
+ for (const reportData of reportDatas) {
+ const resportDataJson = JSON.stringify(reportData);
+
+ const templateDoc = await PDFNet.Convert.createOfficeTemplateWithPath(
+ "public" + reportTempPath,
+ options
+ );
+
+ const pdfdoc = await templateDoc.fillTemplateJson(resportDataJson);
+
+ await mainDoc.insertPages(
+ (await mainDoc.getPageCount()) + 1,
+ pdfdoc,
+ 1,
+ await pdfdoc.getPageCount(),
+ PDFNet.PDFDoc.InsertFlag.e_none
+ );
+ }
+
+ // await mainDoc.save("test1.pdf", PDFNet.SDFDoc.SaveOptions.e_linearized);
+
+ const buffer = await mainDoc.saveMemoryBuffer(
+ PDFNet.SDFDoc.SaveOptions.e_linearized
+ );
+
+ return {
+ result: true,
+ buffer,
+ };
+ };
+
+ const result = await PDFNet.runWithCleanup(
+ main,
+ process.env.NEXT_PUBLIC_PDFTRON_SERVER_KEY
+ )
+ .catch((err: any) => {
+ return {
+ result: false,
+ error: err,
+ };
+ })
+ .then(async (data: any) => {
+ return data;
+ });
+
+ return result;
+};
diff --git a/lib/po/service.ts b/lib/po/service.ts
index dc398201..0653c11a 100644
--- a/lib/po/service.ts
+++ b/lib/po/service.ts
@@ -1,5 +1,6 @@
"use server";
-
+import path from "path";
+import { v4 as uuidv4 } from "uuid";
import { headers } from "next/headers";
import db from "@/db/db";
import { GetPOSchema } from "./validations";
@@ -323,13 +324,26 @@ Remarks:${contract.remarks}`,
const { success: sendDocuSignResult, envelopeId } = sendDocuSign;
+ await tx
+ .update(contracts)
+ .set({
+ status: sendDocuSignResult ? "PENDING_SIGNATURE" : "Docu Sign Failed",
+ })
+ .where(eq(contracts.id, validatedData.contractId));
+
if (!sendDocuSignResult) {
return {
success: false,
- message: "DocuSign 전자 서명 발송에 실패하였습니다.",
+ message: "DocuSign Mail 발송에 실패하였습니다.",
};
}
+ // Update contract status to indicate pending signatures
+
+ const fileName = `${contractNo}-signature.pdf`;
+ const ext = path.extname(fileName);
+ const uniqueName = uuidv4() + ext;
+
// Create a single envelope for all signers
const [newEnvelope] = await tx
.insert(contractEnvelopes)
@@ -338,7 +352,7 @@ Remarks:${contract.remarks}`,
envelopeId: envelopeId,
envelopeStatus: "sent",
fileName: `${contractNo}-signature.pdf`, // Required field
- filePath: `/contracts/${validatedData.contractId}/signatures`, // Required field
+ filePath: `/contracts/${validatedData.contractId}/signatures/${uniqueName}`, // Required field
// Add any other required fields based on your schema
})
.returning();
@@ -368,25 +382,6 @@ Remarks:${contract.remarks}`,
});
}
- // Update contract status to indicate pending signatures
- await tx
- .update(contracts)
- .set({ status: "PENDING_SIGNATURE" })
- .where(eq(contracts.id, validatedData.contractId));
-
- // In a real implementation, you would send the envelope to DocuSign or similar service
- // For example:
- // const docusignResult = await docusignClient.createEnvelope({
- // recipients: validatedData.signers.map(signer => ({
- // email: signer.signerEmail,
- // name: signer.signerName,
- // recipientType: signer.signerType === "REQUESTER" ? "signer" : "cc",
- // routingOrder: signer.signerType === "REQUESTER" ? 1 : 2,
- // })),
- // documentId: `contract-${validatedData.contractId}`,
- // // other DocuSign-specific parameters
- // });
-
// Revalidate the path to refresh the data
revalidatePath("/po");
diff --git a/package-lock.json b/package-lock.json
index e0a4ffee..d41582f4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,6 +18,7 @@
"@mui/material": "^6.2.1",
"@mui/x-data-grid-premium": "^7.23.3",
"@mui/x-tree-view": "^7.23.6",
+ "@pdftron/pdfnet-node": "^11.3.0",
"@pdftron/webviewer": "^11.3.0",
"@radix-ui/primitive": "^1.1.1",
"@radix-ui/react-accordion": "^1.2.2",
@@ -52,6 +53,7 @@
"@t3-oss/env-nextjs": "^0.11.1",
"@tanstack/react-table": "^8.20.6",
"@types/docusign-esign": "^5.19.8",
+ "@types/formidable": "^3.4.5",
"accept-language": "^3.0.20",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -63,6 +65,7 @@
"embla-carousel-react": "^8.5.1",
"exceljs": "^4.4.0",
"file-saver": "^2.0.5",
+ "formidable": "^3.5.2",
"handlebars": "^4.7.8",
"i18n-iso-countries": "^7.13.0",
"i18next": "^24.1.2",
@@ -2122,6 +2125,60 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@mapbox/node-pre-gyp": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
+ "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==",
+ "dependencies": {
+ "detect-libc": "^2.0.0",
+ "https-proxy-agent": "^5.0.0",
+ "make-dir": "^3.1.0",
+ "node-fetch": "^2.6.7",
+ "nopt": "^5.0.0",
+ "npmlog": "^5.0.1",
+ "rimraf": "^3.0.2",
+ "semver": "^7.3.5",
+ "tar": "^6.1.11"
+ },
+ "bin": {
+ "node-pre-gyp": "bin/node-pre-gyp"
+ }
+ },
+ "node_modules/@mapbox/node-pre-gyp/node_modules/glob": {
+ "version": "7.2.3",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+ "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+ "deprecated": "Glob versions prior to v9 are no longer supported",
+ "dependencies": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.1.1",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ },
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/@mapbox/node-pre-gyp/node_modules/rimraf": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+ "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+ "deprecated": "Rimraf versions prior to v4 are no longer supported",
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/@mui/core-downloads-tracker": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.2.1.tgz",
@@ -2741,6 +2798,20 @@
"url": "https://github.com/sponsors/panva"
}
},
+ "node_modules/@pdftron/pdfnet-node": {
+ "version": "11.3.0",
+ "resolved": "https://registry.npmjs.org/@pdftron/pdfnet-node/-/pdfnet-node-11.3.0.tgz",
+ "integrity": "sha512-FhZD9Z6S/m6VUV23fFc2wtCOt50taV78rbM01+dotlb1FXu87el470EZ1SnRbdcCWegk16IfGeLIGKCuSn+oOw==",
+ "hasInstallScript": true,
+ "dependencies": {
+ "@mapbox/node-pre-gyp": "^1.0.10",
+ "underscore": "^1.13.6",
+ "xhr2": "^0.2.1"
+ },
+ "engines": {
+ "node": ">=8 <=22"
+ }
+ },
"node_modules/@pdftron/webviewer": {
"version": "11.3.0",
"resolved": "https://registry.npmjs.org/@pdftron/webviewer/-/webviewer-11.3.0.tgz",
@@ -4303,6 +4374,14 @@
"integrity": "sha512-xrCYOdHh5zA3LUrn6CvspYwlzSWxPso11Lx32WnAG6KvLCRecKZ/Rh21PLXUkzUFsQmrGcx/traJAFjR6dVS5Q==",
"license": "MIT"
},
+ "node_modules/@types/formidable": {
+ "version": "3.4.5",
+ "resolved": "https://registry.npmjs.org/@types/formidable/-/formidable-3.4.5.tgz",
+ "integrity": "sha512-s7YPsNVfnsng5L8sKnG/Gbb2tiwwJTY1conOkJzTMRvJAlLFW1nEua+ADsJQu8N1c0oTHx9+d5nqg10WuT9gHQ==",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/hoist-non-react-statics": {
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.6.tgz",
@@ -4706,6 +4785,11 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
+ "node_modules/abbrev": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
+ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="
+ },
"node_modules/accept-language": {
"version": "3.0.20",
"resolved": "https://registry.npmjs.org/accept-language/-/accept-language-3.0.20.tgz",
@@ -4751,6 +4835,17 @@
"node": ">=0.4.0"
}
},
+ "node_modules/agent-base": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+ "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+ "dependencies": {
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -4814,6 +4909,11 @@
"node": ">= 8"
}
},
+ "node_modules/aproba": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz",
+ "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ=="
+ },
"node_modules/archiver": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz",
@@ -4910,6 +5010,19 @@
"safe-buffer": "~5.1.0"
}
},
+ "node_modules/are-we-there-yet": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz",
+ "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==",
+ "deprecated": "This package is no longer supported.",
+ "dependencies": {
+ "delegates": "^1.0.0",
+ "readable-stream": "^3.6.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/arg": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
@@ -5102,6 +5215,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/asap": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
+ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="
+ },
"node_modules/ast-types-flow": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
@@ -5544,6 +5662,14 @@
"node": ">= 6"
}
},
+ "node_modules/chownr": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
+ "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
@@ -5630,6 +5756,14 @@
"simple-swizzle": "^0.2.2"
}
},
+ "node_modules/color-support": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
+ "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
+ "bin": {
+ "color-support": "bin.js"
+ }
+ },
"node_modules/colorette": {
"version": "2.0.20",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
@@ -5678,6 +5812,11 @@
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"license": "MIT"
},
+ "node_modules/console-control-strings": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
+ "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ=="
+ },
"node_modules/convert-source-map": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
@@ -6099,12 +6238,16 @@
"node": ">=0.4.0"
}
},
+ "node_modules/delegates": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
+ "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ=="
+ },
"node_modules/detect-libc": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz",
"integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==",
"license": "Apache-2.0",
- "optional": true,
"engines": {
"node": ">=8"
}
@@ -6115,6 +6258,15 @@
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
+ "node_modules/dezalgo": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
+ "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==",
+ "dependencies": {
+ "asap": "^2.0.0",
+ "wrappy": "1"
+ }
+ },
"node_modules/diacritics": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/diacritics/-/diacritics-1.3.0.tgz",
@@ -7456,12 +7608,47 @@
"node": ">= 6"
}
},
+ "node_modules/formidable": {
+ "version": "3.5.2",
+ "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.2.tgz",
+ "integrity": "sha512-Jqc1btCy3QzRbJaICGwKcBfGWuLADRerLzDqi2NwSt/UkXLsHJw2TVResiaoBufHVHy9aSgClOHCeJsSsFLTbg==",
+ "dependencies": {
+ "dezalgo": "^1.0.4",
+ "hexoid": "^2.0.0",
+ "once": "^1.4.0"
+ },
+ "funding": {
+ "url": "https://ko-fi.com/tunnckoCore/commissions"
+ }
+ },
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
+ "node_modules/fs-minipass": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
+ "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/fs-minipass/node_modules/minipass": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -7537,6 +7724,68 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/gauge": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz",
+ "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==",
+ "deprecated": "This package is no longer supported.",
+ "dependencies": {
+ "aproba": "^1.0.3 || ^2.0.0",
+ "color-support": "^1.1.2",
+ "console-control-strings": "^1.0.0",
+ "has-unicode": "^2.0.1",
+ "object-assign": "^4.1.1",
+ "signal-exit": "^3.0.0",
+ "string-width": "^4.2.3",
+ "strip-ansi": "^6.0.1",
+ "wide-align": "^1.1.2"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/gauge/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/gauge/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
+ },
+ "node_modules/gauge/node_modules/signal-exit": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="
+ },
+ "node_modules/gauge/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/gauge/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/get-intrinsic": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.6.tgz",
@@ -7809,6 +8058,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/has-unicode": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
+ "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ=="
+ },
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -7827,6 +8081,14 @@
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
"license": "MIT"
},
+ "node_modules/hexoid": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-2.0.0.tgz",
+ "integrity": "sha512-qlspKUK7IlSQv2o+5I7yhUd7TxlOG2Vr5LTa3ve2XSNVKAL/n/u/7KLvKmFNimomDIKvZFXWHv0T12mv7rT8Aw==",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
@@ -7845,6 +8107,18 @@
"void-elements": "3.1.0"
}
},
+ "node_modules/https-proxy-agent": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+ "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+ "dependencies": {
+ "agent-base": "6",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/i18n-iso-countries": {
"version": "7.13.0",
"resolved": "https://registry.npmjs.org/i18n-iso-countries/-/i18n-iso-countries-7.13.0.tgz",
@@ -9020,6 +9294,28 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
}
},
+ "node_modules/make-dir": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
+ "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
+ "dependencies": {
+ "semver": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/make-dir/node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
"node_modules/make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
@@ -9109,6 +9405,29 @@
"node": ">=16 || 14 >=14.17"
}
},
+ "node_modules/minizlib": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
+ "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
+ "dependencies": {
+ "minipass": "^3.0.0",
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/minizlib/node_modules/minipass": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
@@ -9391,6 +9710,20 @@
"node": ">=6.0.0"
}
},
+ "node_modules/nopt": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
+ "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==",
+ "dependencies": {
+ "abbrev": "1"
+ },
+ "bin": {
+ "nopt": "bin/nopt.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -9400,6 +9733,18 @@
"node": ">=0.10.0"
}
},
+ "node_modules/npmlog": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz",
+ "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==",
+ "deprecated": "This package is no longer supported.",
+ "dependencies": {
+ "are-we-there-yet": "^2.0.0",
+ "console-control-strings": "^1.1.0",
+ "gauge": "^3.0.0",
+ "set-blocking": "^2.0.0"
+ }
+ },
"node_modules/nuqs": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/nuqs/-/nuqs-2.2.3.tgz",
@@ -10970,6 +11315,11 @@
"node": ">=10"
}
},
+ "node_modules/set-blocking": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="
+ },
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -11663,6 +12013,22 @@
"node": ">=6"
}
},
+ "node_modules/tar": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
+ "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
+ "dependencies": {
+ "chownr": "^2.0.0",
+ "fs-minipass": "^2.0.0",
+ "minipass": "^5.0.0",
+ "minizlib": "^2.1.1",
+ "mkdirp": "^1.0.3",
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
@@ -11679,6 +12045,25 @@
"node": ">=6"
}
},
+ "node_modules/tar/node_modules/minipass": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
+ "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/tar/node_modules/mkdirp": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
+ "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@@ -12434,6 +12819,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/underscore": {
+ "version": "1.13.7",
+ "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz",
+ "integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g=="
+ },
"node_modules/undici-types": {
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
@@ -12742,6 +13132,51 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/wide-align": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
+ "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
+ "dependencies": {
+ "string-width": "^1.0.2 || 2 || 3 || 4"
+ }
+ },
+ "node_modules/wide-align/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wide-align/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
+ },
+ "node_modules/wide-align/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wide-align/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -12852,6 +13287,14 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
+ "node_modules/xhr2": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/xhr2/-/xhr2-0.2.1.tgz",
+ "integrity": "sha512-sID0rrVCqkVNUn8t6xuv9+6FViXjUVXq8H5rWOH2rz9fDNQEd4g0EA2XlcEdJXRz5BMEn4O1pJFdT+z4YHhoWw==",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
diff --git a/package.json b/package.json
index c5306242..af0efae7 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,7 @@
"@mui/material": "^6.2.1",
"@mui/x-data-grid-premium": "^7.23.3",
"@mui/x-tree-view": "^7.23.6",
+ "@pdftron/pdfnet-node": "^11.3.0",
"@pdftron/webviewer": "^11.3.0",
"@radix-ui/primitive": "^1.1.1",
"@radix-ui/react-accordion": "^1.2.2",
@@ -54,6 +55,7 @@
"@t3-oss/env-nextjs": "^0.11.1",
"@tanstack/react-table": "^8.20.6",
"@types/docusign-esign": "^5.19.8",
+ "@types/formidable": "^3.4.5",
"accept-language": "^3.0.20",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -65,6 +67,7 @@
"embla-carousel-react": "^8.5.1",
"exceljs": "^4.4.0",
"file-saver": "^2.0.5",
+ "formidable": "^3.5.2",
"handlebars": "^4.7.8",
"i18n-iso-countries": "^7.13.0",
"i18next": "^24.1.2",
diff --git a/pages/api/pdftron/createVendorDataReports.ts b/pages/api/pdftron/createVendorDataReports.ts
new file mode 100644
index 00000000..e6c8b8ee
--- /dev/null
+++ b/pages/api/pdftron/createVendorDataReports.ts
@@ -0,0 +1,81 @@
+import type { NextApiRequest, NextApiResponse } from "next";
+import type { File as FormidableFile } from "formidable";
+import formidable from "formidable";
+import fs from "fs/promises";
+import { createReport } from "@/lib/pdftron/serverSDK/createReport";
+
+export const config = {
+ api: {
+ bodyParser: false, // ✅ 이게 false면 안 됨!
+ },
+};
+
+export default async function handler(
+ req: NextApiRequest,
+ res: NextApiResponse
+) {
+ if (req.method !== "POST") {
+ return res.status(405).end();
+ }
+
+ try {
+ const form = formidable({ multiples: false });
+
+ form.parse(req, async (err, fields, files) => {
+ if (err) {
+ console.error(err);
+ return res.status(500).json({ error: "Error parsing form" });
+ }
+
+ try {
+ const fileName = fields?.customFileName?.[0] ?? "";
+ const reportDatas = JSON.parse(fields?.reportDatas?.[0] ?? "[]") as {
+ [key: string]: any;
+ }[];
+ const reportTempPath = fields?.reportTempPath?.[0] ?? "";
+ const reportCoverPage: FormidableFile | undefined = files?.file?.[0];
+
+ if (
+ !reportCoverPage ||
+ fileName.length === 0 ||
+ reportDatas.length === 0 ||
+ reportTempPath.length === 0
+ ) {
+ return res.status(400).json({ error: "Invalid Report Data" });
+ }
+
+ const buffer = await fs.readFile(reportCoverPage.filepath);
+
+ const {
+ result,
+ buffer: pdfBuffer,
+ error,
+ } = await createReport(buffer, reportTempPath, reportDatas);
+
+ if (result && pdfBuffer) {
+ res.setHeader("Content-Type", "application/pdf");
+ res.setHeader(
+ "Content-Disposition",
+ `attachment; filename="${fileName}"`
+ );
+
+ // ✅ 중요! PDFNet에서 나온 pdfBuffer(Uint8Array)를 Node Buffer로 감싸기
+ return res.send(Buffer.from(pdfBuffer));
+ }
+
+ return res
+ .status(200)
+ .json({
+ success: false,
+ message: "Report 생성에 실패하였습니다.",
+ error,
+ });
+ } catch (e) {
+ console.log(e);
+ return res.status(400).json({ error: "Invalid additionalData" });
+ }
+ });
+ } catch (err) {
+ return res.status(401).end();
+ }
+}
diff --git a/pages/api/po/sendDocuSign.ts b/pages/api/po/sendDocuSign.ts
index ccb83733..eb218c2c 100644
--- a/pages/api/po/sendDocuSign.ts
+++ b/pages/api/po/sendDocuSign.ts
@@ -5,7 +5,7 @@ export const config = {
};
import type { NextApiRequest, NextApiResponse } from "next";
-import { requestContractSign } from "@/lib/docuSign/docuSignFns";
+import { requestContractSign, getRecipients } from "@/lib/docuSign/docuSignFns";
export default async function handler(
req: NextApiRequest,
@@ -36,11 +36,13 @@ export default async function handler(
const { result, envelopeId, error } = docuSignStart;
- res.status(200).json({
+ const sendResult = {
success: result,
envelopeId,
message: error?.message,
- });
+ };
+
+ res.status(200).json(sendResult);
} catch (error: any) {
res
.status(500)
diff --git a/pages/api/po/webhook.ts b/pages/api/po/webhook.ts
index 50b3c1f4..4a9b6a29 100644
--- a/pages/api/po/webhook.ts
+++ b/pages/api/po/webhook.ts
@@ -3,15 +3,11 @@ export const config = {
bodyParser: true, // ✅ 이게 false면 안 됨!
},
};
-
import type { NextApiRequest, NextApiResponse } from "next";
import path from "path";
import fs from "fs";
import * as z from "zod";
import db from "@/db/db";
-import { GetPOSchema } from "@/lib/po/validations";
-import { unstable_cache } from "@/lib/unstable-cache";
-import { filterColumns } from "@/lib/filter-columns";
import {
asc,
desc,
@@ -25,18 +21,15 @@ import {
eq,
count,
} from "drizzle-orm";
-import { countPos, selectPos } from "@/lib/po/repository";
import {
contractEnvelopes,
- contractsDetailView,
contractSigners,
contracts,
} from "@/db/schema/contract";
-import { vendors, vendorContacts } from "@/db/schema/vendors";
-import dayjs from "dayjs";
-
-import { POContent } from "@/lib/docuSign/types";
-import { downloadContractFile, getRecipients } from "@/lib/docuSign/docuSignFns";
+import {
+ downloadContractFile,
+ getRecipients,
+} from "@/lib/docuSign/docuSignFns";
export default async function handler(
req: NextApiRequest,
@@ -52,8 +45,8 @@ export default async function handler(
//recipientId === "1" 첫번째 서명자 서명 완료
//recipientId === "2" 두번쨰 서명자 서명 완료
const { envelopeId, recipientId } = data;
-
- console.log(req.body)
+
+ console.log(req.body);
const contractList = [
{
@@ -110,7 +103,7 @@ export default async function handler(
await tx
.update(contracts)
- .set({ status: `$FAILED_${safeRole}_SEND_MAIL(${message})` })
+ .set({ status: `FAILED_${safeRole}_SEND_MAIL(${message})` })
.where(eq(contracts.id, contractId));
await tx
@@ -133,7 +126,7 @@ export default async function handler(
// continue;
// }
- // const fullFilePath = createFolderTree(fileName, filePath);
+ // const fullFilePath = createFolderTree(filePath);
// fs.writeFileSync(fullFilePath, buffer);
}
@@ -156,6 +149,10 @@ export default async function handler(
.where(eq(dbSchma.envelopeId, envelopeId))
.limit(1);
+ if (!targetContract) {
+ continue;
+ }
+
const { id, contractId } = targetContract;
if (contractId === null || contractId === undefined) {
@@ -212,6 +209,10 @@ export default async function handler(
.where(eq(dbSchma.envelopeId, envelopeId))
.limit(1);
+ if (!targetContract) {
+ continue;
+ }
+
const { id, contractId, fileName, filePath } = targetContract;
if (contractId === null || contractId === undefined) {
@@ -229,7 +230,7 @@ export default async function handler(
continue;
}
- const fullFilePath = createFolderTree(fileName, filePath);
+ const fullFilePath = createFolderTree(filePath);
fs.writeFileSync(fullFilePath, buffer);
@@ -279,6 +280,10 @@ export default async function handler(
.where(eq(dbSchma.envelopeId, envelopeId))
.limit(1);
+ if (!targetContract) {
+ continue;
+ }
+
const { contractId, fileName, filePath } = targetContract;
if (contractId === null || contractId === undefined) {
@@ -296,7 +301,7 @@ export default async function handler(
continue;
}
- const fullFilePath = createFolderTree(fileName, filePath);
+ const fullFilePath = createFolderTree(filePath);
fs.writeFileSync(fullFilePath, buffer);
@@ -324,8 +329,10 @@ export default async function handler(
}
}
-const createFolderTree = (fileName: string, filePath: string): string => {
- const cleanedPath = filePath.replace(/^\/+/, "");
+const createFolderTree = (filePath: string): string => {
+ const fileName = path.basename(filePath);
+ const folderPath = path.dirname(filePath);
+ const cleanedPath = folderPath.replace(/^\/+/, "");
const dirPath = path.resolve(process.cwd(), "public", cleanedPath); // 예: 'contracts/185/signatures'
const fullFilePath = path.join(dirPath, fileName); // 예: 'contracts/185/signatures/xxx.pdf'