diff options
| author | joonhoekim <26rote@gmail.com> | 2025-03-27 16:33:09 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-03-27 16:33:09 +0900 |
| commit | 34bbeb86c1a8d24b5f526710889b5e54d699cfd0 (patch) | |
| tree | 19eaa9b8c266a5f6bc7f7d8fb9d9f949448c6b46 | |
| parent | e6e98a1bed7a23d148ab97b3a7414ade4b1d236b (diff) | |
| parent | a2bb2de8aa7534b7b89993c395808b4b2b0b9f5d (diff) | |
merge
| -rw-r--r-- | .env.development | 5 | ||||
| -rw-r--r-- | .env.production | 5 | ||||
| -rw-r--r-- | .gitignore | 4 | ||||
| -rw-r--r-- | app/[lng]/partners/(partners)/vendor-data/form/[packageId]/[formId]/page.tsx | 4 | ||||
| -rw-r--r-- | components/documents/view-document-dialog.tsx | 213 | ||||
| -rw-r--r-- | components/form-data/form-data-report-batch-dialog.tsx | 307 | ||||
| -rw-r--r-- | components/form-data/form-data-report-dialog.tsx | 2 | ||||
| -rw-r--r-- | components/form-data/form-data-table-columns.tsx | 37 | ||||
| -rw-r--r-- | components/form-data/form-data-table.tsx | 564 | ||||
| -rw-r--r-- | components/form-data/update-form-sheet.tsx | 137 | ||||
| -rw-r--r-- | db/migrations/meta/_journal.json | 72 | ||||
| -rw-r--r-- | lib/docuSign/docuSignFns.ts | 3 | ||||
| -rw-r--r-- | lib/forms/services.ts | 467 | ||||
| -rw-r--r-- | lib/po/service.ts | 42 | ||||
| -rw-r--r-- | package-lock.json | 445 | ||||
| -rw-r--r-- | package.json | 3 | ||||
| -rw-r--r-- | pages/api/pdftron/createVendorDataReports.ts | 36 |
17 files changed, 1778 insertions, 568 deletions
diff --git a/.env.development b/.env.development index 68ee314f..183c6f93 100644 --- a/.env.development +++ b/.env.development @@ -9,7 +9,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 - NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY=demo:1739264618684:616161d7030000000091db1c97c6f386d41d3506ab5b507381ef2ee2bd + +# 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 diff --git a/.env.production b/.env.production index b36728cb..1ada78ec 100644 --- a/.env.production +++ b/.env.production @@ -9,7 +9,10 @@ NEXT_PUBLIC_MUI_KEY=da30586e1f20b93856a9783012fc9258Tz04ODI0MyxFPTE3NDQ0NTM2Nzgw NEXT_PUBLIC_URL=https://evcp.dtsolution.io NEXT_PUBLIC_BASE_URL=https://evcp.dtsolution.io - NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY=demo:1739264618684:616161d7030000000091db1c97c6f386d41d3506ab5b507381ef2ee2bd + +# 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 @@ -33,9 +33,6 @@ yarn-error.log* # env files (can opt-in for committing if needed) # .env* -# docusign key -# *.key - # vercel .vercel @@ -55,7 +52,6 @@ next-env.d.ts /public/vendors /public/profiles /public/vendorFormData -/tmp # 직접 참조가 불가능해 복사가 필요했던 라이브러리 (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 58337016..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,7 +1,5 @@ import DynamicTable from "@/components/form-data/form-data-table"; -// 김준회프로입니다. 현재 이 부분에서 컴파일 에러가 발생하여 잠시 주석처리 하겠습니다. -// import { getFormData, getFormId } from "@/lib/forms/services"; -import { getFormData } from "@/lib/forms/services"; +import { getFormData, getFormId } from "@/lib/forms/services"; interface IndexPageProps { params: { diff --git a/components/documents/view-document-dialog.tsx b/components/documents/view-document-dialog.tsx index eaa09fad..7603fdc0 100644 --- a/components/documents/view-document-dialog.tsx +++ b/components/documents/view-document-dialog.tsx @@ -1,3 +1,4 @@ +<<<<<<< HEAD "use client"; import * as React from "react"; @@ -73,22 +74,95 @@ const DocumentViewer: React.FC<{ ); const [viwerLoading, setViewerLoading] = React.useState<boolean>(true); const [fileSetLoading, setFileSetLoading] = React.useState<boolean>(true); +======= +"use client" + +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" + +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 +} + +type ViewDocumentDialogProps = { + versions: Version[] +} + +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} + /> + } + </> + ); +} + +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) +>>>>>>> cac978d5c77e9b30165e4fbe6930eeac9862204d const viewer = React.useRef<HTMLDivElement>(null); const initialized = React.useRef(false); const isCancelled = React.useRef(false); // 초기화 중단용 flag const cleanupHtmlStyle = () => { const htmlElement = document.documentElement; +<<<<<<< HEAD // 기존 style 속성 가져오기 const originalStyle = htmlElement.getAttribute("style") || ""; +======= + + // 기존 style 속성 가져오기 + const originalStyle = htmlElement.getAttribute("style") || ""; + +>>>>>>> cac978d5c77e9b30165e4fbe6930eeac9862204d // "color-scheme: light" 또는 "color-scheme: dark" 찾기 const colorSchemeStyle = originalStyle .split(";") .map((s) => s.trim()) .find((s) => s.startsWith("color-scheme:")); +<<<<<<< HEAD +======= + +>>>>>>> cac978d5c77e9b30165e4fbe6930eeac9862204d // 새로운 스타일 적용 (color-scheme만 유지) if (colorSchemeStyle) { htmlElement.setAttribute("style", colorSchemeStyle + ";"); @@ -96,13 +170,18 @@ const DocumentViewer: React.FC<{ htmlElement.removeAttribute("style"); // color-scheme도 없으면 style 속성 자체 삭제 } +<<<<<<< HEAD console.log("html style 삭제"); +======= + console.log("html style 삭제") +>>>>>>> cac978d5c77e9b30165e4fbe6930eeac9862204d }; React.useEffect(() => { if (open && !initialized.current) { initialized.current = true; isCancelled.current = false; // 다시 열릴 때는 false로 리셋 +<<<<<<< HEAD requestAnimationFrame(() => { if (viewer.current) { @@ -130,11 +209,41 @@ const DocumentViewer: React.FC<{ "multiTabsEmptyPage", ]); setViewerLoading(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: "demo:1739264618684:616161d7030000000091db1c97c6f386d41d3506ab5b507381ef2ee2bd", + fullAPI: true, + 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"]); + setViewerLoading(false); + +>>>>>>> cac978d5c77e9b30165e4fbe6930eeac9862204d }); }); } }); } +<<<<<<< HEAD return () => { // cleanup 시에는 중단 flag 세움 @@ -142,10 +251,20 @@ const DocumentViewer: React.FC<{ instance.UI.dispose(); } setTimeout(() => cleanupHtmlStyle(), 500); +======= + + return async () => { + // cleanup 시에는 중단 flag 세움 + if(instance){ + await instance.UI.dispose() + } + await setTimeout(() => cleanupHtmlStyle(), 500) +>>>>>>> cac978d5c77e9b30165e4fbe6930eeac9862204d }; }, [open]); React.useEffect(() => { +<<<<<<< HEAD const loadDocument = async () => { if (instance && versions.length > 0) { const { UI } = instance; @@ -211,10 +330,77 @@ const DocumentViewer: React.FC<{ try { await instance.UI.dispose(); setInstance(null); // 상태도 초기화 +======= + 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, + }, + }, + }), + }; + + optionsArray.push({ + filePath, + options + }) + }) + }) + + const tabIds = []; + + 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 저장 + } + + if (tabIds.length > 0) { + await UI.TabManager.setActiveTab(tabIds[0]); + } + + setFileSetLoading(false) + } + } + loadDocument(); + }, [instance, versions]) + + + return ( + <Dialog open={open} onOpenChange={async (val) => { + console.log({val, fileSetLoading}) + if(!val && fileSetLoading){ + return; + } + + if (instance) { + try { + await instance.UI.dispose(); + setInstance(null); // 상태도 초기화 + +>>>>>>> cac978d5c77e9b30165e4fbe6930eeac9862204d } catch (e) { console.warn("dispose error", e); } } +<<<<<<< HEAD // cleanupHtmlStyle() setViewerLoading(false); @@ -244,3 +430,30 @@ const DocumentViewer: React.FC<{ </Dialog> ); }; +======= + + // 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> + </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> + </DialogContent> + </Dialog> + ); +} +>>>>>>> cac978d5c77e9b30165e4fbe6930eeac9862204d 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..e3fd7ea2 --- /dev/null +++ b/components/form-data/form-data-report-batch-dialog.tsx @@ -0,0 +1,307 @@ +"use client"; + +import React, { + FC, + Dispatch, + SetStateAction, + useState, + useEffect, + useRef, +} 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, + SelectGroup, + 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 } 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 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); + + // await uploadReportTemp(packageId, formId, formData); + + successCount++; + } + } catch (err) { + console.error(err); + toast({ + title: "Error", + description: "파일 업로드 중 오류가 발생했습니다.", + variant: "destructive", + }); + } finally { + setIsUploading(false); + 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} + 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> + ); +}; + +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-dialog.tsx b/components/form-data/form-data-report-dialog.tsx index 5ddc5e0c..deb0873b 100644 --- a/components/form-data/form-data-report-dialog.tsx +++ b/components/form-data/form-data-report-dialog.tsx @@ -348,7 +348,7 @@ const importReportData: ImportReportData = async ( const objKey = label.split(" ").join("_"); - reportValueMapping[objKey] = reportValue[key]; + reportValueMapping[objKey] = reportValue?.[key] ?? ""; }); const doc = await createDocument(reportFileBlob, { diff --git a/components/form-data/form-data-table-columns.tsx b/components/form-data/form-data-table-columns.tsx index 38f5cad8..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,24 +15,23 @@ 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; /** 실제 Excel 등에서 구분용으로 쓰이는 label (고정) */ - label: string + label: string; /** UI 표시용 label (예: 단위를 함께 표시) */ - displayLabel?: string + displayLabel?: string; type: ColumnType; options?: string[]; @@ -78,7 +77,7 @@ export function getColumns<TData extends object>({ }, // (2) 실제 셀(cell) 렌더링: type에 따라 분기 가능 cell: ({ row }) => { - const cellValue = row.getValue(col.key) + const cellValue = row.getValue(col.key); // 데이터 타입별 처리 switch (col.type) { @@ -98,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> = { @@ -141,11 +140,11 @@ export function getColumns<TData extends object>({ ), size: 40, meta: { - // maxWidth: 40, + 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..9feaf3b2 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 Batch + </Button> + <Button + variant="default" + size="sm" + onClick={() => setTempUpDialog(true)} + > + Temp 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 26a15dcf..f2725ef7 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -659,76 +659,6 @@ "when": 1742834014376, "tag": "0093_young_the_hunter", "breakpoints": true - }, - { - "idx": 94, - "version": "7", - "when": 1742890498534, - "tag": "0094_yellow_living_tribunal", - "breakpoints": true - }, - { - "idx": 95, - "version": "7", - "when": 1742891731932, - "tag": "0095_mute_lizard", - "breakpoints": true - }, - { - "idx": 96, - "version": "7", - "when": 1742893022564, - "tag": "0096_far_lord_tyger", - "breakpoints": true - }, - { - "idx": 97, - "version": "7", - "when": 1742955154051, - "tag": "0097_worried_cobalt_man", - "breakpoints": true - }, - { - "idx": 98, - "version": "7", - "when": 1742955431955, - "tag": "0098_cooing_reptil", - "breakpoints": true - }, - { - "idx": 99, - "version": "7", - "when": 1742964468968, - "tag": "0099_parallel_ink", - "breakpoints": true - }, - { - "idx": 100, - "version": "7", - "when": 1742964742230, - "tag": "0100_abandoned_moonstone", - "breakpoints": true - }, - { - "idx": 101, - "version": "7", - "when": 1742964857927, - "tag": "0101_past_killraven", - "breakpoints": true - }, - { - "idx": 102, - "version": "7", - "when": 1742964981809, - "tag": "0102_melodic_blob", - "breakpoints": true - }, - { - "idx": 103, - "version": "7", - "when": 1743043253528, - "tag": "0103_huge_wallflower", - "breakpoints": true - } + } ] }
\ No newline at end of file diff --git a/lib/docuSign/docuSignFns.ts b/lib/docuSign/docuSignFns.ts index 75263b19..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,7 +363,7 @@ export async function getRecipients( result: false, message: "해당 Recipient id를 가진 서명자를 찾을 수 없습니다.", }; - } + } const { autoRespondedReason } = signer; diff --git a/lib/forms/services.ts b/lib/forms/services.ts index a8b21815..ff21626c 100644 --- a/lib/forms/services.ts +++ b/lib/forms/services.ts @@ -1,19 +1,29 @@ // lib/forms/services.ts -"use server" +"use server"; +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 } @@ -30,7 +40,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 { // 데이터베이스에서 폼 조회 @@ -39,38 +51,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({ @@ -84,7 +106,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: [] }; } } @@ -114,7 +138,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로 전체 로직을 감싼다 @@ -127,24 +151,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이 있으면 그대로 두고, @@ -152,40 +181,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 @@ -193,24 +226,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이 있으면 그대로 두고, @@ -218,33 +256,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() @@ -321,50 +360,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 @@ -376,7 +420,7 @@ export async function syncMissingTags(contractItemId: number, formCode: string) eq(formEntries.formCode, formCode) ) ) - .limit(1) + .limit(1); if (!entry) { const [inserted] = await db @@ -386,64 +430,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++; } } } @@ -453,13 +497,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 }; } /** @@ -469,10 +513,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, @@ -481,12 +525,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 찾기 (단 하나) @@ -499,52 +543,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 { @@ -552,67 +596,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[]; } /** @@ -621,26 +668,140 @@ 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) { - console.error("Error in fetchFormMetadata:", err) - return null + } finally { + return result; } -}
\ No newline at end of file +}; + +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) { + } 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(); + }); + } +} diff --git a/lib/po/service.ts b/lib/po/service.ts index 358f23d9..f697bd58 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,14 +324,27 @@ 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 발송에 실패하였습니다.", }; } - // Create a single envelope for all signers + // 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) .values({ @@ -338,7 +352,8 @@ Remarks:${contract.remarks}`, envelopeId: envelopeId, envelopeStatus: "sent", fileName: `${contractNo}-signature.pdf`, // Required field - filePath: `/contracts/${validatedData.contractId}/signatures/${contractNo}-signature.pdf`, // Required field + + filePath: `/contracts/${validatedData.contractId}/signatures/${uniqueName}`, // Required field // Add any other required fields based on your schema }) .returning(); @@ -368,25 +383,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..47f6055d --- /dev/null +++ b/pages/api/pdftron/createVendorDataReports.ts @@ -0,0 +1,36 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import formidable from "formidable"; + +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(); + } + + const form = formidable({ multiples: true }); + + form.parse(req, async (err, fields, files) => { + if (err) { + console.error(err); + return res.status(500).json({ error: "Error parsing form" }); + } + + try { + const additionalData = JSON.parse((fields?.additionalData ?? "") as string); + console.log("📦 additionalData:", additionalData); + console.log("📎 files:", files.files); // files.files는 array or single file + + return res.status(200).json({ success: true }); + } catch (e) { + return res.status(400).json({ error: "Invalid additionalData" }); + } + }); +} |
