diff options
| author | kiman Kim <94714426+rlaks5757@users.noreply.github.com> | 2025-03-28 13:59:09 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-03-28 13:59:09 +0900 |
| commit | 53e136b022c0b8d6afee7bbf743bdcec49dd4e95 (patch) | |
| tree | a7cb3cfcae4291b0b76dd497ad596dbb6a328950 | |
| parent | f839e58817340f09720e477ad610d41994a2cd8c (diff) | |
| parent | 2bcbef17fadb6799cca97bf612c87fc558dd19ca (diff) | |
Merge pull request #4 from DTS-Development/feature/kiman
Report Batch, Report Temp Sample Download
24 files changed, 962 insertions, 1598 deletions
diff --git a/app/[lng]/evcp/poa/page.tsx b/app/[lng]/evcp/poa/page.tsx deleted file mode 100644 index dec5e05b..00000000 --- a/app/[lng]/evcp/poa/page.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" -import { getChangeOrders } from "@/lib/poa/service" -import { searchParamsCache } from "@/lib/poa/validations" -import { ChangeOrderListsTable } from "@/lib/poa/table/poa-table" - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getChangeOrders({ - ...search, - filters: validFilters, - }), - ]) - - return ( - <Shell className="gap-2"> - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center justify-between space-y-2"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - 변경 PO 확인 및 전자서명 - </h2> - <p className="text-muted-foreground"> - 발행된 PO의 변경 내역을 확인하고 관리할 수 있습니다. - </p> - </div> - </div> - </div> - - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - </React.Suspense> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <ChangeOrderListsTable promises={promises} /> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/components/documents/StageList.tsx b/components/documents/StageList.tsx index 81f8a5ca..6df448df 100644 --- a/components/documents/StageList.tsx +++ b/components/documents/StageList.tsx @@ -55,7 +55,7 @@ interface Version { approvedDate: string | null DocumentSubmitDate: Date attachments: Attachment[] - selected?: boolean + selected?: boolean; } export default function StageList({ document }: StageListProps) { diff --git a/components/documents/view-document-dialog.tsx b/components/documents/view-document-dialog.tsx index 6daa806b..bd802b77 100644 --- a/components/documents/view-document-dialog.tsx +++ b/components/documents/view-document-dialog.tsx @@ -9,78 +9,80 @@ import { import { Building2, FileIcon, Loader2 } from "lucide-react" import { Button } from "@/components/ui/button" +// 인터페이스 interface Attachment { id: number; fileName: string; filePath: string; fileType?: string; } - interface Version { - id: number - stage: string - revision: string - uploaderType: string - uploaderName: string | null - comment: string | null - status: string | null - planDate: string | null - actualDate: string | null - approvedDate: string | null - DocumentSubmitDate: Date - attachments: Attachment[] - selected: boolean + id: number; + stage: string; + revision: string; + uploaderType: string; + uploaderName: string | null; + comment: string | null; + status: string | null; + planDate: string | null; + actualDate: string | null; + approvedDate: string | null; + DocumentSubmitDate: Date; + attachments: Attachment[]; + selected?: boolean; } type ViewDocumentDialogProps = { - versions: Version[] -} + versions: Version[]; +}; -export function ViewDocumentDialog({versions}: ViewDocumentDialogProps){ - const [open, setOpen] = React.useState(false) - +export function ViewDocumentDialog({ versions }: ViewDocumentDialogProps) { + const [open, setOpen] = React.useState(false); return ( <> - <Button - size="sm" - className="border-blue-200" - variant="outline" - onClick={() => setOpen(prev => !prev)} - > - 문서 보기 - </Button> - {open && <DocumentViewer - open={open} - setOpen={setOpen} - versions={versions} - /> - } - </> + <Button + size="sm" + className="border-blue-200" + variant="outline" + onClick={() => setOpen((prev) => !prev)} + > + 문서 보기 + </Button> + {open && ( + <DocumentViewer open={open} setOpen={setOpen} versions={versions} /> + )} + </> ); } -function DocumentViewer({open, setOpen, versions}){ - const [instance, setInstance] = React.useState<null | WebViewerInstance>(null) - const [viwerLoading, setViewerLoading] = React.useState<boolean>(true) - const [fileSetLoading, setFileSetLoading] = React.useState<boolean>(true) +const DocumentViewer: React.FC<{ + open: boolean; + setOpen: React.Dispatch<React.SetStateAction<boolean>>; + versions: Version[]; +}> = ({ open, setOpen, versions }) => { + const [instance, setInstance] = React.useState<null | WebViewerInstance>( + null + ); + const [viwerLoading, setViewerLoading] = React.useState<boolean>(true); + const [fileSetLoading, setFileSetLoading] = React.useState<boolean>(true); const viewer = React.useRef<HTMLDivElement>(null); const initialized = React.useRef(false); const isCancelled = React.useRef(false); // 초기화 중단용 flag const cleanupHtmlStyle = () => { const htmlElement = document.documentElement; - + // 기존 style 속성 가져오기 const originalStyle = htmlElement.getAttribute("style") || ""; - + // "color-scheme: light" 또는 "color-scheme: dark" 찾기 const colorSchemeStyle = originalStyle .split(";") .map((s) => s.trim()) .find((s) => s.startsWith("color-scheme:")); - + // 새로운 스타일 적용 (color-scheme만 유지) if (colorSchemeStyle) { htmlElement.setAttribute("style", colorSchemeStyle + ";"); @@ -88,146 +90,151 @@ function DocumentViewer({open, setOpen, versions}){ htmlElement.removeAttribute("style"); // color-scheme도 없으면 style 속성 자체 삭제 } - console.log("html style 삭제") + console.log("html style 삭제"); }; React.useEffect(() => { if (open && !initialized.current) { initialized.current = true; isCancelled.current = false; // 다시 열릴 때는 false로 리셋 - + requestAnimationFrame(() => { if (viewer.current) { import("@pdftron/webviewer").then(({ default: WebViewer }) => { - console.log(isCancelled.current) + console.log(isCancelled.current); if (isCancelled.current) { console.log("📛 WebViewer 초기화 취소됨 (Dialog 닫힘)"); - + return; } - + WebViewer( { path: "/pdftronWeb", - licenseKey: "demo:1739264618684:616161d7030000000091db1c97c6f386d41d3506ab5b507381ef2ee2bd", + licenseKey: + "demo:1739264618684:616161d7030000000091db1c97c6f386d41d3506ab5b507381ef2ee2bd", fullAPI: true, - css:"/globals.css" + css: "/globals.css", }, viewer.current as HTMLDivElement ).then(async (instance: WebViewerInstance) => { - - setInstance(instance); instance.UI.enableFeatures([instance.UI.Feature.MultiTab]); - instance.UI.disableElements(["addTabButton", "multiTabsEmptyPage"]); + instance.UI.disableElements([ + "addTabButton", + "multiTabsEmptyPage", + ]); setViewerLoading(false); - }); }); } }); } - - return async () => { + + return () => { // cleanup 시에는 중단 flag 세움 - if(instance){ - await instance.UI.dispose() + if (instance) { + instance.UI.dispose(); } - await setTimeout(() => cleanupHtmlStyle(), 500) + setTimeout(() => cleanupHtmlStyle(), 500); }; }, [open]); React.useEffect(() => { - const loadDocument = async () => { - - if(instance && versions.length > 0){ - const { UI } = instance; - - const optionsArray = [] - - versions.forEach(c => { - const {attachments} = c - attachments.forEach(c2 => { - const {fileName, filePath, fileType} = c2 - - const options = { - filename: fileName, - ...(fileType.includes("xlsx") && { - officeOptions: { - formatOptions: { - applyPageBreaksToSheet: true, + const loadDocument = async () => { + if (instance && versions.length > 0) { + const { UI } = instance; + + const optionsArray: any[] = []; + + versions.forEach((c) => { + const { attachments } = c; + attachments.forEach((c2) => { + const { fileName, filePath, fileType } = c2; + + const fileTypeCur = fileType ?? ""; + + const options = { + filename: fileName, + ...(fileTypeCur.includes("xlsx") && { + officeOptions: { + formatOptions: { + applyPageBreaksToSheet: true, + }, }, - }, - }), - }; + }), + }; - optionsArray.push({ - filePath, - options - }) - }) - }) + optionsArray.push({ + filePath, + options, + }); + }); + }); - const tabIds = []; + const tabIds = []; - for (const option of optionsArray) { - const { filePath, options } = option; - const response = await fetch(filePath); - const blob = await response.blob(); + for (const option of optionsArray) { + const { filePath, options } = option; + const response = await fetch(filePath); + const blob = await response.blob(); - const tab = await UI.TabManager.addTab(blob, options); - tabIds.push(tab); // 탭 ID 저장 - } + const tab = await UI.TabManager.addTab(blob, options); + tabIds.push(tab); // 탭 ID 저장 + } - if (tabIds.length > 0) { - await UI.TabManager.setActiveTab(tabIds[0]); - } + if (tabIds.length > 0) { + await UI.TabManager.setActiveTab(tabIds[0]); + } - setFileSetLoading(false) - } - } - loadDocument(); - }, [instance, versions]) + setFileSetLoading(false); + } + }; + loadDocument(); + }, [instance, versions]); - return ( - <Dialog open={open} onOpenChange={async (val) => { - console.log({val, fileSetLoading}) - if(!val && fileSetLoading){ - return; - } - + <Dialog + open={open} + onOpenChange={async (val) => { + if (!val && fileSetLoading) { + return; + } + if (instance) { try { await instance.UI.dispose(); - setInstance(null); // 상태도 초기화 - + setInstance(null); // 상태도 초기화 } catch (e) { console.warn("dispose error", e); } } - + // cleanupHtmlStyle() - setViewerLoading(false); - setOpen(prev => !prev) - await setTimeout(() => cleanupHtmlStyle(), 1000) - }}> - <DialogContent className="w-[90vw] h-[90vh]" style={{maxWidth: "none"}}> - <DialogHeader className="h-[38px]"> - <DialogTitle> - 문서 미리보기 - </DialogTitle> - <DialogDescription> - 첨부파일 미리보기 - </DialogDescription> + setViewerLoading(false); + setOpen((prev) => !prev); + setTimeout(() => cleanupHtmlStyle(), 1000); + }} + > + <DialogContent className="w-[90vw] h-[90vh]" style={{ maxWidth: "none" }}> + <DialogHeader className="h-[38px]"> + <DialogTitle>문서 미리보기</DialogTitle> + <DialogDescription>첨부파일 미리보기</DialogDescription> </DialogHeader> - <div ref={viewer} style={{height: "calc(90vh - 20px - 38px - 1rem - 48px)"}}> - {viwerLoading && <div className="flex flex-col items-center justify-center py-12"> - <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> - <p className="text-sm text-muted-foreground">문서 뷰어 로딩 중...</p> - </div>} + <div + ref={viewer} + style={{ height: "calc(90vh - 20px - 38px - 1rem - 48px)" }} + > + {viwerLoading && ( + <div className="flex flex-col items-center justify-center py-12"> + <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> + <p className="text-sm text-muted-foreground"> + 문서 뷰어 로딩 중... + </p> + </div> + )} </div> - </DialogContent> + </DialogContent> </Dialog> ); -}
\ No newline at end of file +}; diff --git a/components/form-data/form-data-report-batch-dialog.tsx b/components/form-data/form-data-report-batch-dialog.tsx index e3fd7ea2..6c690363 100644 --- a/components/form-data/form-data-report-batch-dialog.tsx +++ b/components/form-data/form-data-report-batch-dialog.tsx @@ -6,11 +6,12 @@ import React, { SetStateAction, useState, useEffect, - useRef, } from "react"; import { useToast } from "@/hooks/use-toast"; +import { toast as toastMessage} from "sonner"; import prettyBytes from "pretty-bytes"; import { X, Loader2 } from "lucide-react"; +import { saveAs } from 'file-saver'; import { Badge } from "@/components/ui/badge"; import { Dialog, @@ -25,7 +26,6 @@ import { Label } from "@/components/ui/label"; import { Select, SelectContent, - SelectGroup, SelectItem, SelectTrigger, SelectValue, @@ -49,7 +49,7 @@ import { FileListName, } from "@/components/ui/file-list"; import { Button } from "@/components/ui/button"; -import { getReportTempList } from "@/lib/forms/services"; +import { getReportTempList, getOrigin } from "@/lib/forms/services"; import { DataTableColumnJSON } from "./form-data-table-columns"; const MAX_FILE_SIZE = 3000000; @@ -129,30 +129,58 @@ export const FormDataReportBatchDialog: FC<FormDataReportBatchDialogProps> = ({ setIsUploading(true); try { - const totalFiles = selectedFiles.length; - let successCount = 0; + const origin = await getOrigin() - for (let i = 0; i < totalFiles; i++) { - const file = selectedFiles[i]; + const targetFiles = selectedFiles[0]; - const formData = new FormData(); - formData.append("file", file); - formData.append("customFileName", file.name); + const reportDatas = reportData.map((c) => { + const reportValue = stringifyAllValues(c); - // await uploadReportTemp(packageId, formId, formData); + const reportValueMapping: { [key: string]: any } = {}; - successCount++; + columnsJSON.forEach((c2) => { + const { key, label } = c2; + + const objKey = label.split(" ").join("_"); + + reportValueMapping[objKey] = reportValue?.[key] ?? ""; + }); + + return reportValueMapping; + }); + const formData = new FormData(); + formData.append("file", targetFiles); + formData.append("customFileName", `${formCode}.pdf`); + formData.append("reportDatas", JSON.stringify(reportDatas)); + formData.append("reportTempPath", selectTemp); + + const reqeustCreateReport = await fetch( + `${origin}/api/pdftron/createVendorDataReports`, + { method: "POST", body: formData } + ); + + if (reqeustCreateReport.ok) { + const blob = await reqeustCreateReport.blob(); + + saveAs(blob, `${formCode}.pdf`); + + toastMessage.success("Report 다운로드 완료!") + } else { + const err = await reqeustCreateReport.json(); + console.error("에러:", err); + throw new Error(err.message) } } catch (err) { console.error(err); toast({ title: "Error", - description: "파일 업로드 중 오류가 발생했습니다.", + description: "Report 생성 중 오류가 발생했습니다.", variant: "destructive", }); } finally { setIsUploading(false); - setOpen(false); + setSelectedFiles([]) + setOpen(false) } }; @@ -237,10 +265,10 @@ export const FormDataReportBatchDialog: FC<FormDataReportBatchDialogProps> = ({ <DialogFooter> <Button - disabled={selectedFiles.length === 0 || selectTemp.length === 0} + disabled={selectedFiles.length === 0 || selectTemp.length === 0 || isUploading} onClick={submitData} > - 다운로드 + {isUploading && <Loader2 />}다운로드 </Button> </DialogFooter> </DialogContent> @@ -305,3 +333,17 @@ const updateReportTempList: UpdateReportTempList = async ( }) ); }; + +const stringifyAllValues = (obj: any): any => { + if (Array.isArray(obj)) { + return obj.map((item) => stringifyAllValues(item)); + } else if (typeof obj === "object" && obj !== null) { + const result: any = {}; + for (const key in obj) { + result[key] = stringifyAllValues(obj[key]); + } + return result; + } else { + return obj !== null && obj !== undefined ? String(obj) : ""; + } +}; diff --git a/components/form-data/form-data-report-dialog.tsx b/components/form-data/form-data-report-dialog.tsx index deb0873b..e28b4345 100644 --- a/components/form-data/form-data-report-dialog.tsx +++ b/components/form-data/form-data-report-dialog.tsx @@ -8,11 +8,10 @@ import React, { useEffect, useRef, } from "react"; -import { WebViewerInstance, Core } from "@pdftron/webviewer"; -import { useToast } from "@/hooks/use-toast"; -import prettyBytes from "pretty-bytes"; -import { X, Loader2 } from "lucide-react"; -import { Badge } from "@/components/ui/badge"; +import { WebViewerInstance } from "@pdftron/webviewer"; +import { Loader2 } from "lucide-react"; +import { saveAs } from "file-saver"; +import { toast } from "sonner"; import { Dialog, DialogContent, @@ -25,11 +24,11 @@ import { Label } from "@/components/ui/label"; import { Select, SelectContent, - SelectGroup, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; + import { Button } from "@/components/ui/button"; import { getReportTempList } from "@/lib/forms/services"; import { DataTableColumnJSON } from "./form-data-table-columns"; @@ -58,7 +57,9 @@ export const FormDataReportDialog: FC<FormDataReportDialogProps> = ({ setReportData, packageId, formId, + formCode, }) => { + const [tempList, setTempList] = useState<tempFile[]>([]); const [selectTemp, setSelectTemp] = useState<string>(""); const [instance, setInstance] = useState<null | WebViewerInstance>(null); @@ -92,46 +93,9 @@ export const FormDataReportDialog: FC<FormDataReportDialogProps> = ({ // }, }); - const blob = new Blob([fileData], { - type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - }); + saveAs(new Blob([fileData]), fileName); - const link = document.createElement("a"); - link.href = URL.createObjectURL(blob); - link.download = fileName; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - - // const allTabs = UI.TabManager.getAllTabs() as { - // id: number; - // src: Core.Document; - // }[]; - - // for (const tab of allTabs) { - // // await UI.TabManager.setActiveTab(tab.id); - // await activateTabAndWaitForLoad(instance, tab.id); - // const tabDoc = tab.src; - // const fileName = tabDoc.getFilename(); - - // const fileData = await tabDoc.getFileData({ - // includeAnnotations: true, - // }); - - // console.log({ fileData }); - - // const blob = new Blob([fileData], { - // type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - // }); - - // // 다운로드 - // // const link = document.createElement("a"); - // // link.href = URL.createObjectURL(blob); - // // link.download = fileName; - // // document.body.appendChild(link); - // // link.click(); - // // document.body.removeChild(link); - // } + toast.success("Report 다운로드 완료!"); } }; @@ -175,6 +139,7 @@ export const FormDataReportDialog: FC<FormDataReportDialogProps> = ({ instance={instance} setInstance={setInstance} setFileLoading={setFileLoading} + formCode={formCode} /> </div> @@ -195,6 +160,7 @@ interface ReportWebViewerProps { instance: null | WebViewerInstance; setInstance: Dispatch<SetStateAction<WebViewerInstance | null>>; setFileLoading: Dispatch<SetStateAction<boolean>>; + formCode: string; } const ReportWebViewer: FC<ReportWebViewerProps> = ({ @@ -204,6 +170,7 @@ const ReportWebViewer: FC<ReportWebViewerProps> = ({ instance, setInstance, setFileLoading, + formCode, }) => { const [viwerLoading, setViewerLoading] = useState<boolean>(true); const viewer = useRef<HTMLDivElement>(null); @@ -234,12 +201,6 @@ const ReportWebViewer: FC<ReportWebViewerProps> = ({ viewer.current as HTMLDivElement ).then(async (instance: WebViewerInstance) => { setInstance(instance); - // //Tab 메뉴 사용 필요시 활성화 - // instance.UI.enableFeatures([instance.UI.Feature.MultiTab]); - // instance.UI.disableElements([ - // "addTabButton", - // "multiTabsEmptyPage", - // ]); setViewerLoading(false); }); }); @@ -262,9 +223,10 @@ const ReportWebViewer: FC<ReportWebViewerProps> = ({ instance, reportDatas, reportTempPath, - setFileLoading + setFileLoading, + formCode ); - }, [reportTempPath, reportDatas, instance, columnsJSON]); + }, [reportTempPath, reportDatas, instance, columnsJSON, formCode]); return ( <div ref={viewer} className="h-[100%]"> @@ -319,7 +281,8 @@ type ImportReportData = ( instance: null | WebViewerInstance, reportDatas: ReportData[], reportTempPath: string, - setFileLoading: Dispatch<SetStateAction<boolean>> + setFileLoading: Dispatch<SetStateAction<boolean>>, + formCode: string ) => void; const importReportData: ImportReportData = async ( @@ -327,7 +290,8 @@ const importReportData: ImportReportData = async ( instance, reportDatas, reportTempPath, - setFileLoading + setFileLoading, + formCode ) => { setFileLoading(true); try { @@ -352,12 +316,13 @@ const importReportData: ImportReportData = async ( }); const doc = await createDocument(reportFileBlob, { + filename: `${formCode}_report.docx`, extension: "docx", }); await doc.applyTemplateValues(reportValueMapping); - documentViewer.loadDocument(doc, { + documentViewer.loadDocument(doc, { extension: "docx", enableOfficeEditing: true, officeOptions: { @@ -373,68 +338,6 @@ const importReportData: ImportReportData = async ( } }; -const importReportDataTab: ImportReportData = async ( - columnJSON, - instance, - reportDatas, - reportTempPath, - setFileLoading -) => { - setFileLoading(true); - try { - if (instance && reportDatas.length > 0 && reportTempPath.length > 0) { - const { UI, Core } = instance; - const { createDocument } = Core; - - const getFileData = await fetch(reportTempPath); - const reportFileBlob = await getFileData.blob(); - - const prevTab = UI.TabManager.getAllTabs(); - - (prevTab as object[] as { id: number }[]).forEach((c) => { - const { id } = c; - UI.TabManager.deleteTab(id); - }); - - const fileOptions = reportDatas.map((c) => { - const { tagNumber } = c; - - const options = { - filename: `${tagNumber}_report.docx`, - }; - - return { options, reportData: c }; - }); - - const tabIds = []; - - for (const fileOption of fileOptions) { - let doc = await createDocument(reportFileBlob, { - ...fileOption.options, - extension: "docx", - }); - - await doc.applyTemplateValues( - stringifyAllValues(fileOption.reportData) - ); - - const tab = await UI.TabManager.addTab(doc, { - ...fileOption.options, - }); - - tabIds.push(tab); // 탭 ID 저장 - } - - if (tabIds.length > 0) { - await UI.TabManager.setActiveTab(tabIds[0]); - } - } - } catch (err) { - } finally { - setFileLoading(false); - } -}; - type UpdateReportTempList = ( packageId: number, formId: number, diff --git a/components/form-data/form-data-report-temp-upload-dialog.tsx b/components/form-data/form-data-report-temp-upload-dialog.tsx index b646c3e6..69df704e 100644 --- a/components/form-data/form-data-report-temp-upload-dialog.tsx +++ b/components/form-data/form-data-report-temp-upload-dialog.tsx @@ -8,8 +8,11 @@ import React, { useEffect, } from "react"; import { useToast } from "@/hooks/use-toast"; +import { toast as toastMessage } from "sonner"; import prettyBytes from "pretty-bytes"; -import { X, Loader2 } from "lucide-react"; +import { X, Loader2, Download, Delete, Trash2 } from "lucide-react"; +import ExcelJS from "exceljs"; +import { saveAs } from "file-saver"; import { Badge } from "@/components/ui/badge"; import { Dialog, @@ -40,10 +43,28 @@ import { FileListItem, FileListName, } from "@/components/ui/file-list"; -import { getReportTempList, uploadReportTemp } from "@/lib/forms/services"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { + getReportTempList, + uploadReportTemp, + getReportTempFileData, + deleteReportTempFile, +} from "@/lib/forms/services"; import { VendorDataReportTemps } from "@/db/schema/vendorData"; +import { DataTableColumnJSON } from "./form-data-table-columns"; interface FormDataReportTempUploadDialogProps { + columnsJSON: DataTableColumnJSON[]; open: boolean; setOpen: Dispatch<SetStateAction<boolean>>; packageId: number; @@ -57,7 +78,15 @@ const MAX_FILE_SIZE = 3000000; export const FormDataReportTempUploadDialog: FC< FormDataReportTempUploadDialogProps -> = ({ open, setOpen, packageId, formId, uploaderType }) => { +> = ({ + columnsJSON, + open, + setOpen, + packageId, + formId, + formCode, + uploaderType, +}) => { const { toast } = useToast(); const [selectedFiles, setSelectedFiles] = useState<File[]>([]); const [isUploading, setIsUploading] = useState(false); @@ -131,55 +160,177 @@ export const FormDataReportTempUploadDialog: FC< } }; + const downloadTempFile = async () => { + try { + const { fileName, fileType, base64 } = await getReportTempFileData(); + + saveAs(`data:${fileType};base64,${base64}`, fileName); + + toastMessage.success("Report Sample File 다운로드 완료!"); + } catch (err) { + console.log(err); + toast({ + title: "Error", + description: "Sample File을 찾을 수가 없습니다.", + variant: "destructive", + }); + } + }; + + const downloadReportVarList = async () => { + try { + // Create a new workbook + const workbook = new ExcelJS.Workbook(); + + // 데이터 시트 생성 + const worksheet = workbook.addWorksheet("Data"); + + // 유효성 검사용 숨김 시트 생성 + const validationSheet = workbook.addWorksheet("ValidationData"); + validationSheet.state = "hidden"; // 시트 숨김 처리 + + // 1. 데이터 시트에 헤더 추가 + const headers = ["Table Column Label", "Report Variable"]; + worksheet.addRow(headers); + + // 헤더 스타일 적용 + 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" }, + }; + }); + + // 2. 데이터 행 추가 + columnsJSON.forEach((row) => { + const { displayLabel, label } = row; + + const labelConvert = label.replaceAll(" ", "_"); + + worksheet.addRow([displayLabel, labelConvert]); + }); + + // 3. 컬럼 너비 자동 조정 + headers.forEach((col, idx) => { + const column = worksheet.getColumn(idx + 1); + + // 최적 너비 계산 + let maxLength = col.length; + columnsJSON.forEach((row) => { + const valueKey = idx === 0 ? "displayLabel" : "label"; + + const value = row[valueKey]; + if (value !== undefined && value !== null) { + const valueLength = String(value).length; + if (valueLength > maxLength) { + maxLength = valueLength; + } + } + }); + + // 너비 설정 (최소 10, 최대 50) + column.width = Math.min(Math.max(maxLength + 2, 10), 50); + }); + + const buffer = await workbook.xlsx.writeBuffer(); + saveAs(new Blob([buffer]), `${formCode}_report_varible_list.xlsx`); + toastMessage.success("Report Varible List File 다운로드 완료!"); + } catch (err) { + console.log(err); + toast({ + title: "Error", + description: "Variable List 파일을 찾을 수가 없습니다.", + variant: "destructive", + }); + } + }; + return ( <Dialog open={open} onOpenChange={setOpen}> <DialogContent className="w-[600px]" style={{ maxWidth: "none" }}> <DialogHeader> <DialogTitle>Report Template Upload</DialogTitle> <DialogDescription> - 사용하시고자 하는 Report Template를 업로드 하여주시기 바랍니다. + 사용하시고자 하는 Report Template(.docx)를 업로드 하여주시기 + 바랍니다. </DialogDescription> </DialogHeader> - {/* {prevReportTemp.length > 0 && ( - <> - <Label>Prev Report Template</Label> - <ScrollArea className="max-h-[100px]"> - {prevReportTemp.map((c, i) => { - return <div key={i}>{i}</div>; - })} - </ScrollArea> - </> - )} */} - - <Dropzone - maxSize={MAX_FILE_SIZE} - multiple={true} - accept={{ accept: [".docx"] }} - onDropAccepted={handleDropAccepted} - onDropRejected={handleDropRejected} - disabled={isUploading} - > - {({ maxSize }) => ( - <> - <DropzoneZone className="flex justify-center"> - <DropzoneInput /> - <div className="flex items-center gap-6"> - <DropzoneUploadIcon /> - <div className="grid gap-0.5"> - <DropzoneTitle>파일을 여기에 드롭하세요</DropzoneTitle> - <DropzoneDescription> - 또는 클릭하여 파일을 선택하세요. 최대 크기:{" "} - {maxSize ? prettyBytes(maxSize) : "무제한"} - </DropzoneDescription> + <div> + <Label>Sample Template Download</Label> + + <FileList className="max-h-[200px] gap-3"> + <FileListItem className="p-3"> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>sample_template_file.docx</FileListName> + </FileListInfo> + <FileListAction onClick={downloadTempFile}> + <Download className="h-4 w-4" /> + <span className="sr-only">Download</span> + </FileListAction> + </FileListHeader> + </FileListItem> + <FileListItem className="p-3"> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>report_variable_list.xlsx</FileListName> + </FileListInfo> + <FileListAction onClick={downloadReportVarList}> + <Download className="h-4 w-4" /> + <span className="sr-only">Download</span> + </FileListAction> + </FileListHeader> + </FileListItem> + </FileList> + </div> + <div> + <Label>Uploaded Template Files</Label> + <UploadedTempFiles + prevReportTemp={prevReportTemp} + updateReportTempList={() => + updateReportTempList(packageId, formId, setPrevReportTemp) + } + /> + </div> + + <div> + <Label>Report Template File Upload(.docx)</Label> + <Dropzone + maxSize={MAX_FILE_SIZE} + multiple={true} + accept={{ accept: [".docx"] }} + onDropAccepted={handleDropAccepted} + onDropRejected={handleDropRejected} + disabled={isUploading} + > + {({ maxSize }) => ( + <> + <DropzoneZone className="flex justify-center"> + <DropzoneInput /> + <div className="flex items-center gap-6"> + <DropzoneUploadIcon /> + <div className="grid gap-0.5"> + <DropzoneTitle>파일을 여기에 드롭하세요</DropzoneTitle> + <DropzoneDescription> + 또는 클릭하여 파일을 선택하세요. 최대 크기:{" "} + {maxSize ? prettyBytes(maxSize) : "무제한"} + </DropzoneDescription> + </div> </div> - </div> - </DropzoneZone> - <Label className="text-xs text-muted-foreground"> - 여러 파일을 선택할 수 있습니다. - </Label> - </> - )} - </Dropzone> + </DropzoneZone> + <Label className="text-xs text-muted-foreground"> + 여러 파일을 선택할 수 있습니다. + </Label> + </> + )} + </Dropzone> + </div> {selectedFiles.length > 0 && ( <div className="grid gap-2"> @@ -223,7 +374,7 @@ const UploadFileItem: FC<UploadFileItemProps> = ({ isUploading, }) => { return ( - <FileList className="max-h-[200px] gap-3"> + <FileList className="max-h-[100px] gap-3"> {selectedFiles.map((file, index) => ( <FileListItem key={index} className="p-3"> <FileListHeader> @@ -303,3 +454,116 @@ const updateReportTempList: UpdateReportTempList = async ( const tempList = await getReportTempList(packageId, formId); setPrevReportTemp(tempList); }; + +interface UploadedTempFiles { + prevReportTemp: VendorDataReportTemps[]; + updateReportTempList: () => void; +} + +const UploadedTempFiles: FC<UploadedTempFiles> = ({ + prevReportTemp, + updateReportTempList, +}) => { + const { toast } = useToast(); + + const downloadTempFile = async (fileName: string, filePath: string) => { + try { + const getTempFile = await fetch(filePath); + + if (getTempFile.ok) { + const blob = await getTempFile.blob(); + + saveAs(blob, fileName); + + toastMessage.success("Report 다운로드 완료!"); + } else { + const err = await getTempFile.json(); + console.error("에러:", err); + throw new Error(err.message); + } + + toastMessage.success("Template File 다운로드 완료!"); + } catch (err) { + console.error(err) + toast({ + title: "Error", + description: "Template File 다운로드 중 오류가 발생했습니다.", + variant: "destructive", + }); + } + }; + + const deleteTempFile = async (id: number) => { + try { + const { result, error } = await deleteReportTempFile(id); + + if (result) { + updateReportTempList(); + toastMessage.success("Template File 삭제 완료!"); + } else { + throw new Error(error); + } + } catch (err) { + toast({ + title: "Error", + description: "Template File 삭제 중 오류가 발생했습니다.", + variant: "destructive", + }); + } + }; + + return ( + <ScrollArea> + <FileList className="max-h-[100px] gap-3"> + {prevReportTemp.map((c) => { + const { fileName, filePath, id } = c; + + return ( + <AlertDialog key={id}> + <FileListItem className="p-3"> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>{fileName}</FileListName> + </FileListInfo> + <FileListAction + onClick={() => { + downloadTempFile(fileName, filePath); + }} + > + <Download className="h-4 w-4" /> + <span className="sr-only">Download</span> + </FileListAction> + <AlertDialogTrigger asChild> + <FileListAction> + <Trash2 className="h-4 w-4" /> + <span className="sr-only">Delete</span> + </FileListAction> + </AlertDialogTrigger> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle> + Report Templete File({fileName})을 삭제하시겠습니까? + </AlertDialogTitle> + <AlertDialogDescription /> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>취소</AlertDialogCancel> + <AlertDialogAction + onClick={() => { + deleteTempFile(id); + }} + > + 삭제 + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </FileListHeader> + </FileListItem> + </AlertDialog> + ); + })} + </FileList> + </ScrollArea> + ); +}; diff --git a/components/form-data/form-data-table.tsx b/components/form-data/form-data-table.tsx index 9feaf3b2..823416c1 100644 --- a/components/form-data/form-data-table.tsx +++ b/components/form-data/form-data-table.tsx @@ -11,19 +11,22 @@ import { DataTableColumnJSON, ColumnType, } 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 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"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; interface GenericData { [key: string]: any; @@ -526,20 +529,29 @@ export default function DynamicTable({ {/* 버튼 그룹 */} <div className="flex items-center gap-2"> {/* 태그 불러오기 버튼 */} - <Button - variant="default" - size="sm" - onClick={() => setBatchDownDialog(true)} - > - Report Batch - </Button> - <Button - variant="default" - size="sm" - onClick={() => setTempUpDialog(true)} - > - Temp Upload - </Button> + <Popover> + <PopoverTrigger asChild> + <Button variant="default" size="sm"> + Report + </Button> + </PopoverTrigger> + <PopoverContent className="flex flex-row gap-2"> + <Button + variant="outline" + size="sm" + onClick={() => setTempUpDialog(true)} + > + Template Upload + </Button> + <Button + variant="outline" + size="sm" + onClick={() => setBatchDownDialog(true)} + > + Report Download + </Button> + </PopoverContent> + </Popover> <Button variant="default" size="sm" @@ -611,6 +623,7 @@ export default function DynamicTable({ /> {tempUpDialog && ( <FormDataReportTempUploadDialog + columnsJSON={columnsJSON} open={tempUpDialog} setOpen={setTempUpDialog} packageId={contractItemId} @@ -645,5 +658,3 @@ export default function DynamicTable({ </> ); } - - diff --git a/config/poaColumnsConfig.ts b/config/poaColumnsConfig.ts deleted file mode 100644 index 268a2259..00000000 --- a/config/poaColumnsConfig.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { POADetail } from "@/db/schema/contract" - -export interface PoaColumnConfig { - id: keyof POADetail - label: string - group?: string - excelHeader?: string - type?: string -} - -export const poaColumnsConfig: PoaColumnConfig[] = [ - { - id: "id", - label: "ID", - excelHeader: "ID", - group: "Key Info", - type: "number", - }, - { - id: "projectId", - label: "Project ID", - excelHeader: "Project ID", - group: "Key Info", - type: "number", - }, - { - id: "vendorId", - label: "Vendor ID", - excelHeader: "Vendor ID", - group: "Key Info", - type: "number", - }, - { - id: "contractNo", - label: "Form Code", - excelHeader: "Form Code", - group: "Original Info", - type: "text", - }, - { - id: "originalContractName", - label: "Contract Name", - excelHeader: "Contract Name", - group: "Original Info", - type: "text", - }, - { - id: "originalStatus", - label: "Status", - excelHeader: "Status", - group: "Original Info", - type: "text", - }, - { - id: "deliveryTerms", - label: "Delivery Terms", - excelHeader: "Delivery Terms", - group: "Change Info", - type: "text", - }, - { - id: "deliveryDate", - label: "Delivery Date", - excelHeader: "Delivery Date", - group: "Change Info", - type: "date", - }, - { - id: "deliveryLocation", - label: "Delivery Location", - excelHeader: "Delivery Location", - group: "Change Info", - type: "text", - }, - { - id: "currency", - label: "Currency", - excelHeader: "Currency", - group: "Change Info", - type: "text", - }, - { - id: "totalAmount", - label: "Total Amount", - excelHeader: "Total Amount", - group: "Change Info", - type: "number", - }, - { - id: "discount", - label: "Discount", - excelHeader: "Discount", - group: "Change Info", - type: "number", - }, - { - id: "tax", - label: "Tax", - excelHeader: "Tax", - group: "Change Info", - type: "number", - }, - { - id: "shippingFee", - label: "Shipping Fee", - excelHeader: "Shipping Fee", - group: "Change Info", - type: "number", - }, - { - id: "netTotal", - label: "Net Total", - excelHeader: "Net Total", - group: "Change Info", - type: "number", - }, - { - id: "createdAt", - label: "Created At", - excelHeader: "Created At", - group: "System Info", - type: "date", - }, - { - id: "updatedAt", - label: "Updated At", - excelHeader: "Updated At", - group: "System Info", - type: "date", - }, -]
\ No newline at end of file diff --git a/db/migrations/0097_poa_initial_setup.sql b/db/migrations/0097_poa_initial_setup.sql deleted file mode 100644 index fae3f4d1..00000000 --- a/db/migrations/0097_poa_initial_setup.sql +++ /dev/null @@ -1,95 +0,0 @@ --- Drop existing tables and views -DROP VIEW IF EXISTS change_orders_detail_view; -DROP TABLE IF EXISTS change_order_items CASCADE; -DROP TABLE IF EXISTS change_orders CASCADE; -DROP VIEW IF EXISTS poa_detail_view; -DROP TABLE IF EXISTS poa CASCADE; - --- Create POA table -CREATE TABLE poa ( - id SERIAL PRIMARY KEY, - contract_no VARCHAR(100) NOT NULL, - original_contract_no VARCHAR(100) NOT NULL, - project_id INTEGER NOT NULL, - vendor_id INTEGER NOT NULL, - original_contract_name VARCHAR(255) NOT NULL, - original_status VARCHAR(50) NOT NULL, - delivery_terms TEXT, - delivery_date DATE, - delivery_location VARCHAR(255), - currency VARCHAR(10), - total_amount NUMERIC(12,2), - discount NUMERIC(12,2), - tax NUMERIC(12,2), - shipping_fee NUMERIC(12,2), - net_total NUMERIC(12,2), - change_reason TEXT, - approval_status VARCHAR(50) DEFAULT 'PENDING', - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW(), - CONSTRAINT poa_original_contract_no_contracts_contract_no_fk - FOREIGN KEY (original_contract_no) - REFERENCES contracts(contract_no) - ON DELETE CASCADE, - CONSTRAINT poa_project_id_projects_id_fk - FOREIGN KEY (project_id) - REFERENCES projects(id) - ON DELETE CASCADE, - CONSTRAINT poa_vendor_id_vendors_id_fk - FOREIGN KEY (vendor_id) - REFERENCES vendors(id) - ON DELETE CASCADE -); - --- Create POA detail view -CREATE VIEW poa_detail_view AS -SELECT - -- POA primary information - poa.id, - poa.contract_no, - poa.change_reason, - poa.approval_status, - - -- Original PO information - poa.original_contract_no, - poa.original_contract_name, - poa.original_status, - c.start_date as original_start_date, - c.end_date as original_end_date, - - -- Project information - poa.project_id, - p.code as project_code, - p.name as project_name, - - -- Vendor information - poa.vendor_id, - v.vendor_name, - - -- Changed delivery details - poa.delivery_terms, - poa.delivery_date, - poa.delivery_location, - - -- Changed financial information - poa.currency, - poa.total_amount, - poa.discount, - poa.tax, - poa.shipping_fee, - poa.net_total, - - -- Timestamps - poa.created_at, - poa.updated_at, - - -- Electronic signature status - EXISTS ( - SELECT 1 - FROM contract_envelopes - WHERE contract_envelopes.contract_id = poa.id - ) as has_signature -FROM poa -LEFT JOIN contracts c ON poa.original_contract_no = c.contract_no -LEFT JOIN projects p ON poa.project_id = p.id -LEFT JOIN vendors v ON poa.vendor_id = v.id;
\ No newline at end of file diff --git a/db/schema/contract.ts b/db/schema/contract.ts index c14921bb..10721b4d 100644 --- a/db/schema/contract.ts +++ b/db/schema/contract.ts @@ -257,106 +257,4 @@ export const contractsDetailView = pgView("contracts_detail_view").as((qb) => { }); // Type inference for the view -export type ContractDetail = typeof contractsDetailView.$inferSelect; - - - - -// ============ poa (Purchase Order Amendment) ============ -export const poa = pgTable("poa", { - // 주 키 - id: integer("id").primaryKey().generatedAlwaysAsIdentity(), - - // Form code는 원본과 동일하게 유지 - contractNo: varchar("contract_no", { length: 100 }).notNull(), - - // 원본 PO 참조 - originalContractNo: varchar("original_contract_no", { length: 100 }) - .notNull() - .references(() => contracts.contractNo, { onDelete: "cascade" }), - - // 원본 계약 정보 - projectId: integer("project_id") - .notNull() - .references(() => projects.id, { onDelete: "cascade" }), - vendorId: integer("vendor_id") - .notNull() - .references(() => vendors.id, { onDelete: "cascade" }), - originalContractName: varchar("original_contract_name", { length: 255 }).notNull(), - originalStatus: varchar("original_status", { length: 50 }).notNull(), - - // 변경된 납품 조건 - deliveryTerms: text("delivery_terms"), // 변경된 납품 조건 - deliveryDate: date("delivery_date"), // 변경된 납품 기한 - deliveryLocation: varchar("delivery_location", { length: 255 }), // 변경된 납품 장소 - - // 변경된 가격/금액 관련 - currency: varchar("currency", { length: 10 }), // 변경된 통화 - totalAmount: numeric("total_amount", { precision: 12, scale: 2 }), // 변경된 총 금액 - discount: numeric("discount", { precision: 12, scale: 2 }), // 변경된 할인 - tax: numeric("tax", { precision: 12, scale: 2 }), // 변경된 세금 - shippingFee: numeric("shipping_fee", { precision: 12, scale: 2 }), // 변경된 배송비 - netTotal: numeric("net_total", { precision: 12, scale: 2 }), // 변경된 순 총액 - - // 변경 사유 - changeReason: text("change_reason"), - - // 승인 상태 - approvalStatus: varchar("approval_status", { length: 50 }).default("PENDING"), - - // 생성/수정 시각 - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().notNull(), -}) - -// 타입 추론 -export type POA = typeof poa.$inferSelect - -// ============ poa_detail_view ============ -export const poaDetailView = pgView("poa_detail_view").as((qb) => { - return qb - .select({ - // POA primary information - id: poa.id, - contractNo: poa.contractNo, - projectId: contracts.projectId, - vendorId: contracts.vendorId, - changeReason: poa.changeReason, - approvalStatus: poa.approvalStatus, - - // Original PO information - originalContractName: sql<string>`${contracts.contractName}`.as('original_contract_name'), - originalStatus: sql<string>`${contracts.status}`.as('original_status'), - originalStartDate: sql<Date>`${contracts.startDate}`.as('original_start_date'), - originalEndDate: sql<Date>`${contracts.endDate}`.as('original_end_date'), - - // Changed delivery details - deliveryTerms: poa.deliveryTerms, - deliveryDate: poa.deliveryDate, - deliveryLocation: poa.deliveryLocation, - - // Changed financial information - currency: poa.currency, - totalAmount: poa.totalAmount, - discount: poa.discount, - tax: poa.tax, - shippingFee: poa.shippingFee, - netTotal: poa.netTotal, - - // Timestamps - createdAt: poa.createdAt, - updatedAt: poa.updatedAt, - - // Electronic signature status - hasSignature: sql<boolean>`EXISTS ( - SELECT 1 - FROM ${contractEnvelopes} - WHERE ${contractEnvelopes.contractId} = ${poa.id} - )`.as('has_signature'), - }) - .from(poa) - .leftJoin(contracts, eq(poa.contractNo, contracts.contractNo)) -}); - -// Type inference for the view -export type POADetail = typeof poaDetailView.$inferSelect;
\ No newline at end of file +export type ContractDetail = typeof contractsDetailView.$inferSelect;
\ No newline at end of file diff --git a/db/schema/vendorData.ts b/db/schema/vendorData.ts index 92a92c8e..01a10b7e 100644 --- a/db/schema/vendorData.ts +++ b/db/schema/vendorData.ts @@ -3,32 +3,41 @@ import { text, varchar, timestamp, - integer, numeric, date, unique, serial, jsonb, uniqueIndex -} from "drizzle-orm/pg-core" -import { contractItems } from "./contract" - -export const forms = pgTable("forms", { - id: integer("id").primaryKey().generatedAlwaysAsIdentity(), - contractItemId: integer("contract_item_id") + integer, + numeric, + date, + unique, + serial, + jsonb, + uniqueIndex, + } from "drizzle-orm/pg-core"; + import { contractItems } from "./contract"; + + export const forms = pgTable( + "forms", + { + id: integer("id").primaryKey().generatedAlwaysAsIdentity(), + contractItemId: integer("contract_item_id") .notNull() .references(() => contractItems.id, { onDelete: "cascade" }), - formCode: varchar("form_code", { length: 100 }).notNull(), - formName: varchar("form_name", { length: 255 }).notNull(), - // tagType: varchar("tag_type", { length: 50 }).notNull(), - // class: varchar("class", { length: 100 }).notNull(), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().notNull(), -}, (table) => { - return { + formCode: varchar("form_code", { length: 100 }).notNull(), + formName: varchar("form_name", { length: 255 }).notNull(), + // tagType: varchar("tag_type", { length: 50 }).notNull(), + // class: varchar("class", { length: 100 }).notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + }, + (table) => { + return { // contractItemId와 formCode의 조합을 유니크하게 설정 - contractItemFormCodeUnique: uniqueIndex("contract_item_form_code_unique").on( - table.contractItemId, - table.formCode - ), + contractItemFormCodeUnique: uniqueIndex( + "contract_item_form_code_unique" + ).on(table.contractItemId, table.formCode), + }; } -}) - -export const rfqAttachments = pgTable("form_templates", { + ); + + export const rfqAttachments = pgTable("form_templates", { id: serial("id").primaryKey(), formId: integer("form_id").references(() => forms.id), fileName: varchar("file_name", { length: 255 }).notNull(), @@ -36,175 +45,204 @@ export const rfqAttachments = pgTable("form_templates", { createdAt: timestamp("created_at").defaultNow().notNull(), udpatedAt: timestamp("updated_at").defaultNow().notNull(), + }); -}); - - -export const formMetas = pgTable("form_metas", { + export const formMetas = pgTable("form_metas", { id: serial("id").primaryKey(), formCode: varchar("form_code", { length: 50 }).notNull(), formName: varchar("form_name", { length: 255 }).notNull(), columns: jsonb("columns").notNull(), - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), -}) - -export const formEntries = pgTable("form_entries", { + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }); + + export const formEntries = pgTable("form_entries", { id: serial("id").primaryKey(), formCode: varchar("form_code", { length: 50 }).notNull(), data: jsonb("data").notNull(), contractItemId: integer("contract_item_id") - .notNull() - .references(() => contractItems.id, { onDelete: "cascade" }), - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), -}) - - -// ============ tags (각 계약 아이템에 대한 Tag) ============ -// "어느 계약의 어느 아이템에 대한 태그"임을 나타내려면 contract_items를 참조 -export const tags = pgTable("tags", { + .notNull() + .references(() => contractItems.id, { onDelete: "cascade" }), + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }); + + // ============ tags (각 계약 아이템에 대한 Tag) ============ + // "어느 계약의 어느 아이템에 대한 태그"임을 나타내려면 contract_items를 참조 + export const tags = pgTable("tags", { id: integer("id").primaryKey().generatedAlwaysAsIdentity(), - + // 이 Tag가 속한 "계약 내 아이템" (즉 contract_items.id) contractItemId: integer("contract_item_id") - .notNull() - .references(() => contractItems.id, { onDelete: "cascade" }), - - formId: integer("form_id") - .references(() => forms.id, { onDelete: "set null" }), - + .notNull() + .references(() => contractItems.id, { onDelete: "cascade" }), + + formId: integer("form_id").references(() => forms.id, { + onDelete: "set null", + }), + tagNo: varchar("tag_no", { length: 100 }).notNull(), tagType: varchar("tag_type", { length: 50 }).notNull(), class: varchar("class", { length: 100 }).notNull(), description: text("description"), - + createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), -}) - -export type Tag = typeof tags.$inferSelect -export type Form = typeof forms.$inferSelect -export type NewTag = typeof tags.$inferInsert - -export const tagTypes = pgTable("tag_types", { + }); + + export type Tag = typeof tags.$inferSelect; + export type Form = typeof forms.$inferSelect; + export type NewTag = typeof tags.$inferInsert; + + export const tagTypes = pgTable("tag_types", { code: varchar("code", { length: 50 }).primaryKey(), description: text("description").notNull(), - - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), -}) - -export const tagSubfields = pgTable("tag_subfields", { - id: serial("id").primaryKey(), - - // 외래키: tagTypeCode -> tagTypes.code - tagTypeCode: varchar("tag_type_code", { length: 50 }) + + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }); + + export const tagSubfields = pgTable( + "tag_subfields", + { + id: serial("id").primaryKey(), + + // 외래키: tagTypeCode -> tagTypes.code + tagTypeCode: varchar("tag_type_code", { length: 50 }) .notNull() .references(() => tagTypes.code, { onDelete: "cascade" }), - - /** - * 나머지 필드 - */ - // tagTypeDescription: -> 이제 불필요. tagTypes.description로 join - attributesId: varchar("attributes_id", { length: 50 }).notNull(), - attributesDescription: text("attributes_description").notNull(), - - expression: text("expression"), - delimiter: varchar("delimiter", { length: 10 }), - - sortOrder: integer("sort_order").default(0).notNull(), - - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), -}, (table) => { - return { + + /** + * 나머지 필드 + */ + // tagTypeDescription: -> 이제 불필요. tagTypes.description로 join + attributesId: varchar("attributes_id", { length: 50 }).notNull(), + attributesDescription: text("attributes_description").notNull(), + + expression: text("expression"), + delimiter: varchar("delimiter", { length: 10 }), + + sortOrder: integer("sort_order").default(0).notNull(), + + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => { + return { uniqTagTypeAttribute: unique("uniq_tag_type_attribute").on( - table.tagTypeCode, - table.attributesId + table.tagTypeCode, + table.attributesId ), - }; -}); - -export const tagSubfieldOptions = pgTable("tag_subfield_options", { + }; + } + ); + + export const tagSubfieldOptions = pgTable("tag_subfield_options", { id: serial("id").primaryKey(), - + // 어떤 subfield에 속하는 옵션인지 attributesId: varchar("attributes_id", { length: 50 }) - .notNull() - .references(() => tagSubfields.attributesId, { onDelete: "cascade" }), - + .notNull() + .references(() => tagSubfields.attributesId, { onDelete: "cascade" }), + /** * 실제 코드 (예: "PM", "AA", "VB", "VAR", "01", "02" ...) */ code: varchar("code", { length: 50 }).notNull(), - + /** * 사용자에게 보여줄 레이블 (예: "Pump", "Pneumatic Motor", "Ball Valve", ...) */ label: text("label").notNull(), - + /** * 생성/수정 시각 (선택) */ - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), -}) - -export const tagClasses = pgTable("tag_classes", { + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }); + + export const tagClasses = pgTable("tag_classes", { id: integer("id").primaryKey().generatedAlwaysAsIdentity(), - + // 기존 code/label code: varchar("code", { length: 100 }).notNull(), label: text("label").notNull(), - + // 새 필드: tagTypeCode -> references tagTypes.code tagTypeCode: varchar("tag_type_code", { length: 50 }) - .notNull() - .references(() => tagTypes.code, { onDelete: "cascade" }), - + .notNull() + .references(() => tagTypes.code, { onDelete: "cascade" }), + createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), -}) - -export const tagTypeClassFormMappings = pgTable("tag_type_class_form_mappings", { - id: serial("id").primaryKey(), - - tagTypeLabel: varchar("tag_type_label", { length: 255 }).notNull(), - classLabel: varchar("class_label", { length: 255 }).notNull(), - - formCode: varchar("form_code", { length: 50 }).notNull(), - formName: varchar("form_name", { length: 255 }).notNull(), - - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), -}) - -export type TagTypeClassFormMappings = typeof tagTypeClassFormMappings.$inferSelect -export type TagSubfields = typeof tagSubfields.$inferSelect -export type TagSubfieldOption = typeof tagSubfieldOptions.$inferSelect -export type TagClasses = typeof tagClasses.$inferSelect - - -export const viewTagSubfields = pgTable("view_tag_subfields", { + }); + + export const tagTypeClassFormMappings = pgTable( + "tag_type_class_form_mappings", + { + id: serial("id").primaryKey(), + + tagTypeLabel: varchar("tag_type_label", { length: 255 }).notNull(), + classLabel: varchar("class_label", { length: 255 }).notNull(), + + formCode: varchar("form_code", { length: 50 }).notNull(), + formName: varchar("form_name", { length: 255 }).notNull(), + + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .notNull(), + } + ); + + export type TagTypeClassFormMappings = + typeof tagTypeClassFormMappings.$inferSelect; + export type TagSubfields = typeof tagSubfields.$inferSelect; + export type TagSubfieldOption = typeof tagSubfieldOptions.$inferSelect; + export type TagClasses = typeof tagClasses.$inferSelect; + + export const viewTagSubfields = pgTable("view_tag_subfields", { id: integer("id").primaryKey(), - + tagTypeCode: varchar("tag_type_code", { length: 50 }).notNull(), tagTypeDescription: text("tag_type_description"), attributesId: varchar("attributes_id", { length: 50 }).notNull(), attributesDescription: text("attributes_description").notNull(), - + expression: text("expression"), delimiter: varchar("delimiter", { length: 10 }), sortOrder: integer("sort_order").default(0).notNull(), - + createdAt: timestamp("created_at", { withTimezone: true }), updatedAt: timestamp("updated_at", { withTimezone: true }), -}) - -export type ViewTagSubfields = typeof viewTagSubfields.$inferSelect - -export const vendorDataReportTemps = pgTable("vendor_data_report_temps", { + }); + + export type ViewTagSubfields = typeof viewTagSubfields.$inferSelect; + + export const vendorDataReportTemps = pgTable("vendor_data_report_temps", { id: serial("id").primaryKey(), contractItemId: integer("contract_item_id") .notNull() @@ -215,11 +253,12 @@ export const vendorDataReportTemps = pgTable("vendor_data_report_temps", { fileName: varchar("file_name", { length: 255 }).notNull(), filePath: varchar("file_path", { length: 1024 }).notNull(), createdAt: timestamp("created_at", { withTimezone: true }) - .defaultNow() - .notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .defaultNow() - .notNull(), + .defaultNow() + .notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .notNull(), }); - export type VendorDataReportTemps = typeof vendorDataReportTemps.$inferSelect;
\ No newline at end of file + export type VendorDataReportTemps = typeof vendorDataReportTemps.$inferSelect; + diff --git a/db/seeds_2/poaSeed.ts b/db/seeds_2/poaSeed.ts deleted file mode 100644 index d93cde14..00000000 --- a/db/seeds_2/poaSeed.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { faker } from "@faker-js/faker" -import db from "../db" -import { contracts, poa } from "../schema/contract" -import { sql } from "drizzle-orm" - -export async function seedPOA({ count = 10 } = {}) { - try { - console.log(`📝 Inserting POA ${count}`) - - // 기존 POA 데이터 삭제 및 ID 시퀀스 초기화 - await db.delete(poa) - await db.execute(sql`ALTER SEQUENCE poa_id_seq RESTART WITH 1;`) - console.log("✅ 기존 POA 데이터 삭제 및 ID 초기화 완료") - - // 조선업 맥락에 맞는 예시 문구들 - const deliveryTermsExamples = [ - "FOB 부산항", - "CIF 상하이항", - "DAP 울산조선소", - "DDP 거제 옥포조선소", - ] - const deliveryLocations = [ - "부산 영도조선소", - "울산 본사 도크 #3", - "거제 옥포조선소 해양공장", - "목포신항 부두", - ] - const changeReasonExamples = [ - "납품 일정 조정 필요", - "자재 사양 변경", - "선박 설계 변경에 따른 수정", - "추가 부품 요청", - "납품 장소 변경", - "계약 조건 재협상" - ] - - // 1. 기존 계약(PO) 목록 가져오기 - const existingContracts = await db.select().from(contracts) - console.log(`Found ${existingContracts.length} existing contracts`) - - if (existingContracts.length === 0) { - throw new Error("계약(PO) 데이터가 없습니다. 먼저 계약 데이터를 생성해주세요.") - } - - // 2. POA 생성 - for (let i = 0; i < count; i++) { - try { - // 랜덤으로 원본 계약 선택 - const originalContract = faker.helpers.arrayElement(existingContracts) - console.log(`Selected original contract: ${originalContract.contractNo}`) - - // POA 생성 - const totalAmount = faker.number.float({ min: 5000000, max: 500000000 }) - const discount = faker.helpers.maybe(() => faker.number.float({ min: 0, max: 500000 }), { probability: 0.3 }) - const tax = faker.helpers.maybe(() => faker.number.float({ min: 0, max: 1000000 }), { probability: 0.8 }) - const shippingFee = faker.helpers.maybe(() => faker.number.float({ min: 0, max: 300000 }), { probability: 0.5 }) - const netTotal = totalAmount - (discount || 0) + (tax || 0) + (shippingFee || 0) - - const poaData = { - // Form code는 원본과 동일하게 유지 - contractNo: originalContract.contractNo, - originalContractNo: originalContract.contractNo, - projectId: originalContract.projectId, - vendorId: originalContract.vendorId, - originalContractName: originalContract.contractName, - originalStatus: originalContract.status, - - // 변경 가능한 정보들 - deliveryTerms: faker.helpers.arrayElement(deliveryTermsExamples), - deliveryDate: faker.helpers.maybe(() => faker.date.future().toISOString(), { probability: 0.7 }), - deliveryLocation: faker.helpers.arrayElement(deliveryLocations), - currency: "KRW", - totalAmount: totalAmount.toString(), - discount: discount?.toString(), - tax: tax?.toString(), - shippingFee: shippingFee?.toString(), - netTotal: netTotal.toString(), - changeReason: faker.helpers.arrayElement(changeReasonExamples), - approvalStatus: faker.helpers.arrayElement(["PENDING", "APPROVED", "REJECTED"]), - createdAt: new Date(), - updatedAt: new Date(), - } - - console.log("POA data:", poaData) - - await db.insert(poa).values(poaData) - console.log(`Created POA for contract: ${originalContract.contractNo}`) - } catch (error) { - console.error(`Error creating POA ${i + 1}:`, error) - throw error - } - } - - console.log(`✅ Successfully added ${count} new POAs`) - } catch (error) { - console.error("Error in seedPOA:", error) - throw error - } -} - -// 실행 -if (require.main === module) { - seedPOA({ count: 5 }) - .then(() => process.exit(0)) - .catch((error) => { - console.error(error) - process.exit(1) - }) -}
\ No newline at end of file diff --git a/lib/forms/services.ts b/lib/forms/services.ts index ff21626c..22f10466 100644 --- a/lib/forms/services.ts +++ b/lib/forms/services.ts @@ -1,6 +1,7 @@ // lib/forms/services.ts "use server"; +import { headers } from "next/headers"; import path from "path"; import fs from "fs/promises"; import { v4 as uuidv4 } from "uuid"; @@ -805,3 +806,63 @@ export async function uploadReportTemp( }); } } + +export const getOrigin = async (): Promise<string> => { + const headersList = await headers(); + const host = headersList.get("host"); + const proto = headersList.get("x-forwarded-proto") || "http"; // 기본값은 http + const origin = `${proto}://${host}`; + + return origin; +}; + +export const getReportTempFileData = async (): Promise<{ + fileName: string; + fileType: string; + base64: string; +}> => { + const fileName = "sample_template_file.docx"; + + const tempFile = await fs.readFile( + `public/vendorFormReportSample/${fileName}` + ); + + return { + fileName, + fileType: + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + base64: tempFile.toString("base64"), + }; +}; + +type deleteReportTempFile = (id: number) => Promise<{ + result: boolean; + error?: any; +}>; + +export const deleteReportTempFile: deleteReportTempFile = async (id) => { + try { + return db.transaction(async (tx) => { + const [targetTempFile] = await tx + .select() + .from(vendorDataReportTemps) + .where(eq(vendorDataReportTemps.id, id)); + + if (!targetTempFile) { + throw new Error("해당 Template File을 찾을 수 없습니다."); + } + + await tx + .delete(vendorDataReportTemps) + .where(eq(vendorDataReportTemps.id, id)); + + const { filePath } = targetTempFile; + + await fs.unlink("public" + filePath); + + return { result: true }; + }); + } catch (err) { + return { result: false, error: (err as Error).message }; + } +}; diff --git a/lib/pdftron/serverSDK/createReport.ts b/lib/pdftron/serverSDK/createReport.ts new file mode 100644 index 00000000..412ada87 --- /dev/null +++ b/lib/pdftron/serverSDK/createReport.ts @@ -0,0 +1,83 @@ +const { PDFNet } = require("@pdftron/pdfnet-node"); + +type CreateReport = ( + coverPage: Buffer, + reportTempPath: string, + reportDatas: { + [key: string]: any; + }[] +) => Promise<{ + result: boolean; + buffer?: ArrayBuffer; + error?: any; +}>; + +export const createReport: CreateReport = async ( + coverPage, + reportTempPath, + reportDatas +) => { + const main = async () => { + await PDFNet.initialize(process.env.NEXT_PUBLIC_PDFTRON_SERVER_KEY); + + const mainDoc = await PDFNet.PDFDoc.create(); + const buf = await PDFNet.Convert.office2PDFBuffer(coverPage); + const coverPDFDoc = await PDFNet.PDFDoc.createFromBuffer(buf); + + const options = new PDFNet.Convert.OfficeToPDFOptions(); + + await mainDoc.insertPages( + (await mainDoc.getPageCount()) + 1, + coverPDFDoc, + 1, + await coverPDFDoc.getPageCount(), + PDFNet.PDFDoc.InsertFlag.e_none + ); + + for (const reportData of reportDatas) { + const resportDataJson = JSON.stringify(reportData); + + const templateDoc = await PDFNet.Convert.createOfficeTemplateWithPath( + "public" + reportTempPath, + options + ); + + const pdfdoc = await templateDoc.fillTemplateJson(resportDataJson); + + await mainDoc.insertPages( + (await mainDoc.getPageCount()) + 1, + pdfdoc, + 1, + await pdfdoc.getPageCount(), + PDFNet.PDFDoc.InsertFlag.e_none + ); + } + + // await mainDoc.save("test1.pdf", PDFNet.SDFDoc.SaveOptions.e_linearized); + + const buffer = await mainDoc.saveMemoryBuffer( + PDFNet.SDFDoc.SaveOptions.e_linearized + ); + + return { + result: true, + buffer, + }; + }; + + const result = await PDFNet.runWithCleanup( + main, + process.env.NEXT_PUBLIC_PDFTRON_SERVER_KEY + ) + .catch((err: any) => { + return { + result: false, + error: err, + }; + }) + .then(async (data: any) => { + return data; + }); + + return result; +}; diff --git a/lib/po/service.ts b/lib/po/service.ts index f697bd58..5f2e4f35 100644 --- a/lib/po/service.ts +++ b/lib/po/service.ts @@ -324,7 +324,6 @@ Remarks:${contract.remarks}`, const { success: sendDocuSignResult, envelopeId } = sendDocuSign; - await tx .update(contracts) .set({ @@ -344,7 +343,8 @@ Remarks:${contract.remarks}`, const fileName = `${contractNo}-signature.pdf`; const ext = path.extname(fileName); const uniqueName = uuidv4() + ext; - // Create a single envelope for all signers + + // Create a single envelope for all signers const [newEnvelope] = await tx .insert(contractEnvelopes) .values({ diff --git a/lib/poa/service.ts b/lib/poa/service.ts deleted file mode 100644 index a11cbdd8..00000000 --- a/lib/poa/service.ts +++ /dev/null @@ -1,132 +0,0 @@ -"use server"; - -import db from "@/db/db"; -import { GetChangeOrderSchema } from "./validations"; -import { unstable_cache } from "@/lib/unstable-cache"; -import { filterColumns } from "@/lib/filter-columns"; -import { - asc, - desc, - ilike, - and, - or, - count, -} from "drizzle-orm"; - -import { - poaDetailView, -} from "@/db/schema/contract"; - -/** - * POA 목록 조회 - */ -export async function getChangeOrders(input: GetChangeOrderSchema) { - return unstable_cache( - async () => { - try { - const offset = (input.page - 1) * input.perPage; - - // 1. Build where clause - let advancedWhere; - try { - advancedWhere = filterColumns({ - table: poaDetailView, - filters: input.filters, - joinOperator: input.joinOperator, - }); - } catch (whereErr) { - console.error("Error building advanced where:", whereErr); - advancedWhere = undefined; - } - - let globalWhere; - if (input.search) { - try { - const s = `%${input.search}%`; - globalWhere = or( - ilike(poaDetailView.contractNo, s), - ilike(poaDetailView.originalContractName, s), - ilike(poaDetailView.projectCode, s), - ilike(poaDetailView.projectName, s), - ilike(poaDetailView.vendorName, s) - ); - } catch (searchErr) { - console.error("Error building search where:", searchErr); - globalWhere = undefined; - } - } - - // 2. Combine where clauses - let finalWhere; - if (advancedWhere && globalWhere) { - finalWhere = and(advancedWhere, globalWhere); - } else { - finalWhere = advancedWhere || globalWhere; - } - - // 3. Build order by - let orderBy; - try { - orderBy = - input.sort.length > 0 - ? input.sort.map((item) => - item.desc - ? desc(poaDetailView[item.id]) - : asc(poaDetailView[item.id]) - ) - : [desc(poaDetailView.createdAt)]; - } catch (orderErr) { - console.error("Error building order by:", orderErr); - orderBy = [desc(poaDetailView.createdAt)]; - } - - // 4. Execute queries - let data = []; - let total = 0; - - try { - const queryBuilder = db.select().from(poaDetailView); - - if (finalWhere) { - queryBuilder.where(finalWhere); - } - - queryBuilder.orderBy(...orderBy); - queryBuilder.offset(offset).limit(input.perPage); - - data = await queryBuilder; - - const countBuilder = db - .select({ count: count() }) - .from(poaDetailView); - - if (finalWhere) { - countBuilder.where(finalWhere); - } - - const countResult = await countBuilder; - total = countResult[0]?.count || 0; - } catch (queryErr) { - console.error("Query execution failed:", queryErr); - throw queryErr; - } - - const pageCount = Math.ceil(total / input.perPage); - - return { data, pageCount }; - } catch (err) { - console.error("Error in getChangeOrders:", err); - if (err instanceof Error) { - console.error("Error message:", err.message); - console.error("Error stack:", err.stack); - } - return { data: [], pageCount: 0 }; - } - }, - [JSON.stringify(input)], - { - revalidate: 3600, - tags: [`poa`], - } - )(); -}
\ No newline at end of file diff --git a/lib/poa/table/poa-table-columns.tsx b/lib/poa/table/poa-table-columns.tsx deleted file mode 100644 index b362e54c..00000000 --- a/lib/poa/table/poa-table-columns.tsx +++ /dev/null @@ -1,165 +0,0 @@ -"use client" - -import * as React from "react" -import { type DataTableRowAction } from "@/types/table" -import { type ColumnDef } from "@tanstack/react-table" -import { InfoIcon, PenIcon } from "lucide-react" - -import { formatDate } from "@/lib/utils" -import { Button } from "@/components/ui/button" -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip" -import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" -import { POADetail } from "@/db/schema/contract" -import { poaColumnsConfig } from "@/config/poaColumnsConfig" - -interface GetColumnsProps { - setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<POADetail> | null>> -} - -/** - * tanstack table column definitions with nested headers - */ -export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<POADetail>[] { - // ---------------------------------------------------------------- - // 1) actions column (buttons for item info) - // ---------------------------------------------------------------- - const actionsColumn: ColumnDef<POADetail> = { - id: "actions", - enableHiding: false, - cell: function Cell({ row }) { - const hasSignature = row.original.hasSignature; - - return ( - <div className="flex items-center space-x-1"> - {/* Item Info Button */} - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="ghost" - size="icon" - onClick={() => setRowAction({ row, type: "items" })} - > - <InfoIcon className="h-4 w-4" aria-hidden="true" /> - </Button> - </TooltipTrigger> - <TooltipContent> - View Item Info - </TooltipContent> - </Tooltip> - </TooltipProvider> - - {/* Signature Request Button - only show if no signature exists */} - {!hasSignature && ( - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="ghost" - size="icon" - onClick={() => setRowAction({ row, type: "signature" })} - > - <PenIcon className="h-4 w-4" aria-hidden="true" /> - </Button> - </TooltipTrigger> - <TooltipContent> - Request Electronic Signature - </TooltipContent> - </Tooltip> - </TooltipProvider> - )} - </div> - ); - }, - size: 80, - }; - - // ---------------------------------------------------------------- - // 2) Regular columns grouped by group name - // ---------------------------------------------------------------- - // 2-1) groupMap: { [groupName]: ColumnDef<POADetail>[] } - const groupMap: Record<string, ColumnDef<POADetail>[]> = {}; - - poaColumnsConfig.forEach((cfg) => { - // Use "_noGroup" if no group is specified - const groupName = cfg.group || "_noGroup"; - - if (!groupMap[groupName]) { - groupMap[groupName] = []; - } - - // Child column definition - const childCol: ColumnDef<POADetail> = { - accessorKey: cfg.id, - enableResizing: true, - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title={cfg.label} /> - ), - meta: { - excelHeader: cfg.excelHeader, - group: cfg.group, - type: cfg.type, - }, - cell: ({ cell }) => { - const value = cell.getValue(); - - if (cfg.type === "date") { - const dateVal = value as Date; - return ( - <div className="text-sm"> - {formatDate(dateVal)} - </div> - ); - } - if (cfg.type === "number") { - const numVal = value as number; - return ( - <div className="text-sm"> - {numVal ? numVal.toLocaleString() : "-"} - </div> - ); - } - return ( - <div className="text-sm"> - {value ?? "-"} - </div> - ); - }, - }; - - groupMap[groupName].push(childCol); - }); - - // ---------------------------------------------------------------- - // 2-2) Create actual parent columns (groups) from the groupMap - // ---------------------------------------------------------------- - const nestedColumns: ColumnDef<POADetail>[] = []; - - // Order can be fixed by pre-defining group order or sorting - Object.entries(groupMap).forEach(([groupName, colDefs]) => { - if (groupName === "_noGroup") { - // No group → Add as top-level columns - nestedColumns.push(...colDefs); - } else { - // Parent column - nestedColumns.push({ - id: groupName, - header: groupName, - columns: colDefs, - }); - } - }); - - // ---------------------------------------------------------------- - // 3) Final column array: nestedColumns + actionsColumn - // ---------------------------------------------------------------- - return [ - ...nestedColumns, - actionsColumn, - ]; -}
\ No newline at end of file diff --git a/lib/poa/table/poa-table-toolbar-actions.tsx b/lib/poa/table/poa-table-toolbar-actions.tsx deleted file mode 100644 index 97a9cc55..00000000 --- a/lib/poa/table/poa-table-toolbar-actions.tsx +++ /dev/null @@ -1,45 +0,0 @@ -"use client" - -import * as React from "react" -import { type Table } from "@tanstack/react-table" -import { Download, RefreshCcw } from "lucide-react" - -import { exportTableToExcel } from "@/lib/export" -import { Button } from "@/components/ui/button" -import { POADetail } from "@/db/schema/contract" - -interface ItemsTableToolbarActionsProps { - table: Table<POADetail> -} - -export function PoaTableToolbarActions({ table }: ItemsTableToolbarActionsProps) { - return ( - <div className="flex items-center gap-2"> - {/** Refresh 버튼 */} - <Button - variant="samsung" - size="sm" - className="gap-2" - > - <RefreshCcw className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">Get POAs</span> - </Button> - - {/** Export 버튼 */} - <Button - variant="outline" - size="sm" - onClick={() => - exportTableToExcel(table, { - filename: "poa-list", - excludeColumns: ["select", "actions"], - }) - } - className="gap-2" - > - <Download className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">Export</span> - </Button> - </div> - ) -}
\ No newline at end of file diff --git a/lib/poa/table/poa-table.tsx b/lib/poa/table/poa-table.tsx deleted file mode 100644 index a5cad02a..00000000 --- a/lib/poa/table/poa-table.tsx +++ /dev/null @@ -1,189 +0,0 @@ -"use client" - -import * as React from "react" -import type { - DataTableAdvancedFilterField, - DataTableFilterField, - DataTableRowAction, -} from "@/types/table" - -import { useDataTable } from "@/hooks/use-data-table" -import { DataTable } from "@/components/data-table/data-table" -import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" - -import { getChangeOrders } from "../service" -import { POADetail } from "@/db/schema/contract" -import { getColumns } from "./poa-table-columns" -import { PoaTableToolbarActions } from "./poa-table-toolbar-actions" - -interface ItemsTableProps { - promises: Promise< - [ - Awaited<ReturnType<typeof getChangeOrders>>, - ] - > -} - -export function ChangeOrderListsTable({ promises }: ItemsTableProps) { - const [result] = React.use(promises) - const { data, pageCount } = result - - const [rowAction, setRowAction] = - React.useState<DataTableRowAction<POADetail> | null>(null) - - // Handle row actions - React.useEffect(() => { - if (!rowAction) return - - if (rowAction.type === "items") { - // Handle items view action - setRowAction(null) - } - }, [rowAction]) - - const columns = React.useMemo( - () => getColumns({ setRowAction }), - [setRowAction] - ) - - const filterFields: DataTableFilterField<POADetail>[] = [ - { - id: "contractNo", - label: "계약번호", - }, - { - id: "originalContractName", - label: "계약명", - }, - { - id: "approvalStatus", - label: "승인 상태", - }, - ] - - const advancedFilterFields: DataTableAdvancedFilterField<POADetail>[] = [ - { - id: "contractNo", - label: "계약번호", - type: "text", - }, - { - id: "originalContractName", - label: "계약명", - type: "text", - }, - { - id: "projectId", - label: "프로젝트 ID", - type: "number", - }, - { - id: "vendorId", - label: "벤더 ID", - type: "number", - }, - { - id: "originalStatus", - label: "상태", - type: "text", - }, - { - id: "deliveryTerms", - label: "납품조건", - type: "text", - }, - { - id: "deliveryDate", - label: "납품기한", - type: "date", - }, - { - id: "deliveryLocation", - label: "납품장소", - type: "text", - }, - { - id: "currency", - label: "통화", - type: "text", - }, - { - id: "totalAmount", - label: "총 금액", - type: "number", - }, - { - id: "discount", - label: "할인", - type: "number", - }, - { - id: "tax", - label: "세금", - type: "number", - }, - { - id: "shippingFee", - label: "배송비", - type: "number", - }, - { - id: "netTotal", - label: "최종 금액", - type: "number", - }, - { - id: "changeReason", - label: "변경 사유", - type: "text", - }, - { - id: "approvalStatus", - label: "승인 상태", - type: "text", - }, - { - id: "createdAt", - label: "생성일", - type: "date", - }, - { - id: "updatedAt", - label: "수정일", - type: "date", - }, - ] - - const { table } = useDataTable({ - data, - columns, - pageCount, - filterFields, - enablePinning: true, - enableAdvancedFilter: true, - initialState: { - sorting: [{ id: "createdAt", desc: true }], - columnPinning: { right: ["actions"] }, - }, - getRowId: (originalRow) => String(originalRow.id), - shallow: false, - clearOnDefault: true, - }) - - return ( - <> - <DataTable - table={table} - className="h-[calc(100vh-12rem)]" - > - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - shallow={false} - > - <PoaTableToolbarActions table={table} /> - </DataTableAdvancedToolbar> - </DataTable> - </> - ) -}
\ No newline at end of file diff --git a/lib/poa/validations.ts b/lib/poa/validations.ts deleted file mode 100644 index eae1b5ab..00000000 --- a/lib/poa/validations.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { - createSearchParamsCache, - parseAsArrayOf, - parseAsInteger, - parseAsString, - parseAsStringEnum, -} from "nuqs/server" -import * as z from "zod" - -import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" -import { POADetail } from "@/db/schema/contract" - -export const searchParamsCache = createSearchParamsCache({ - // UI 모드나 플래그 관련 - flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), - - // 페이징 - page: parseAsInteger.withDefault(1), - perPage: parseAsInteger.withDefault(10), - - // 정렬 (createdAt 기준 내림차순) - sort: getSortingStateParser<POADetail>().withDefault([ - { id: "createdAt", desc: true }, - ]), - - // 원본 PO 관련 - contractNo: parseAsString.withDefault(""), - originalContractName: parseAsString.withDefault(""), - originalStatus: parseAsString.withDefault(""), - originalStartDate: parseAsString.withDefault(""), - originalEndDate: parseAsString.withDefault(""), - - // 프로젝트 정보 - projectId: parseAsString.withDefault(""), - projectCode: parseAsString.withDefault(""), - projectName: parseAsString.withDefault(""), - - // 벤더 정보 - vendorId: parseAsString.withDefault(""), - vendorName: parseAsString.withDefault(""), - - // 납품 관련 - deliveryTerms: parseAsString.withDefault(""), - deliveryDate: parseAsString.withDefault(""), - deliveryLocation: parseAsString.withDefault(""), - - // 금액 관련 - currency: parseAsString.withDefault(""), - totalAmount: parseAsString.withDefault(""), - discount: parseAsString.withDefault(""), - tax: parseAsString.withDefault(""), - shippingFee: parseAsString.withDefault(""), - netTotal: parseAsString.withDefault(""), - - // 변경 사유 및 승인 상태 - changeReason: parseAsString.withDefault(""), - approvalStatus: parseAsString.withDefault(""), - - // 고급 필터(Advanced) & 검색 - filters: getFiltersStateParser().withDefault([]), - joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - search: parseAsString.withDefault(""), -}) - -// 최종 타입 -export type GetChangeOrderSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>
\ No newline at end of file diff --git a/pages/api/pdftron/createVendorDataReports.ts b/pages/api/pdftron/createVendorDataReports.ts index 47f6055d..f461a7fb 100644 --- a/pages/api/pdftron/createVendorDataReports.ts +++ b/pages/api/pdftron/createVendorDataReports.ts @@ -1,5 +1,8 @@ import type { NextApiRequest, NextApiResponse } from "next"; +import type { File as FormidableFile } from "formidable"; import formidable from "formidable"; +import fs from "fs/promises"; +import { createReport } from "@/lib/pdftron/serverSDK/createReport"; export const config = { api: { @@ -15,22 +18,61 @@ export default async function handler( 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" }); - } - }); + try { + const form = formidable({ multiples: false }); + + form.parse(req, async (err, fields, files) => { + if (err) { + console.error(err); + return res.status(500).json({ error: "Error parsing form" }); + } + + try { + const fileName = fields?.customFileName?.[0] ?? ""; + const reportDatas = JSON.parse(fields?.reportDatas?.[0] ?? "[]") as { + [key: string]: any; + }[]; + const reportTempPath = fields?.reportTempPath?.[0] ?? ""; + const reportCoverPage: FormidableFile | undefined = files?.file?.[0]; + + if ( + !reportCoverPage || + fileName.length === 0 || + reportDatas.length === 0 || + reportTempPath.length === 0 + ) { + return res.status(400).json({ error: "Invalid Report Data" }); + } + + const buffer = await fs.readFile(reportCoverPage.filepath); + + const { + result, + buffer: pdfBuffer, + error, + } = await createReport(buffer, reportTempPath, reportDatas); + + if (result && pdfBuffer) { + res.setHeader("Content-Type", "application/pdf"); + res.setHeader( + "Content-Disposition", + `attachment; filename="${fileName}"` + ); + + return res.send(Buffer.from(pdfBuffer)); + } + + return res.status(200).json({ + success: false, + message: "Report 생성에 실패하였습니다.", + error, + }); + } catch (e) { + console.log(e); + return res.status(400).json({ error: "Invalid additionalData" }); + } + }); + } catch (err) { + return res.status(401).end(); + } } diff --git a/pages/api/po/sendDocuSign.ts b/pages/api/po/sendDocuSign.ts index ccb83733..eb218c2c 100644 --- a/pages/api/po/sendDocuSign.ts +++ b/pages/api/po/sendDocuSign.ts @@ -5,7 +5,7 @@ export const config = { }; import type { NextApiRequest, NextApiResponse } from "next"; -import { requestContractSign } from "@/lib/docuSign/docuSignFns"; +import { requestContractSign, getRecipients } from "@/lib/docuSign/docuSignFns"; export default async function handler( req: NextApiRequest, @@ -36,11 +36,13 @@ export default async function handler( const { result, envelopeId, error } = docuSignStart; - res.status(200).json({ + const sendResult = { success: result, envelopeId, message: error?.message, - }); + }; + + res.status(200).json(sendResult); } catch (error: any) { res .status(500) diff --git a/pages/api/po/webhook.ts b/pages/api/po/webhook.ts index e61246a2..39a6931e 100644 --- a/pages/api/po/webhook.ts +++ b/pages/api/po/webhook.ts @@ -3,15 +3,11 @@ export const config = { bodyParser: true, // ✅ 이게 false면 안 됨! }, }; - import type { NextApiRequest, NextApiResponse } from "next"; import path from "path"; import fs from "fs"; import * as z from "zod"; import db from "@/db/db"; -import { GetPOSchema } from "@/lib/po/validations"; -import { unstable_cache } from "@/lib/unstable-cache"; -import { filterColumns } from "@/lib/filter-columns"; import { asc, desc, @@ -25,18 +21,15 @@ import { eq, count, } from "drizzle-orm"; -import { countPos, selectPos } from "@/lib/po/repository"; import { contractEnvelopes, - contractsDetailView, contractSigners, contracts, } from "@/db/schema/contract"; -import { vendors, vendorContacts } from "@/db/schema/vendors"; -import dayjs from "dayjs"; - -import { POContent } from "@/lib/docuSign/types"; -import { downloadContractFile, getRecipients } from "@/lib/docuSign/docuSignFns"; +import { + downloadContractFile, + getRecipients, +} from "@/lib/docuSign/docuSignFns"; export default async function handler( req: NextApiRequest, @@ -108,7 +101,7 @@ export default async function handler( await tx .update(contracts) - .set({ status: `$FAILED_${safeRole}_SEND_MAIL(${message})` }) + .set({ status: `FAILED_${safeRole}_SEND_MAIL(${message})` }) .where(eq(contracts.id, contractId)); await tx @@ -154,6 +147,10 @@ export default async function handler( .where(eq(dbSchma.envelopeId, envelopeId)) .limit(1); + if (!targetContract) { + continue; + } + const { id, contractId } = targetContract; if (contractId === null || contractId === undefined) { @@ -210,6 +207,10 @@ export default async function handler( .where(eq(dbSchma.envelopeId, envelopeId)) .limit(1); + if (!targetContract) { + continue; + } + const { id, contractId, fileName, filePath } = targetContract; if (contractId === null || contractId === undefined) { @@ -277,6 +278,10 @@ export default async function handler( .where(eq(dbSchma.envelopeId, envelopeId)) .limit(1); + if (!targetContract) { + continue; + } + const { contractId, fileName, filePath } = targetContract; if (contractId === null || contractId === undefined) { diff --git a/public/vendorFormReportSample/sample_template_file.docx b/public/vendorFormReportSample/sample_template_file.docx Binary files differnew file mode 100644 index 00000000..0e338eb8 --- /dev/null +++ b/public/vendorFormReportSample/sample_template_file.docx |
