diff options
Diffstat (limited to 'components')
| -rw-r--r-- | components/documents/StageList.tsx | 2 | ||||
| -rw-r--r-- | components/documents/view-document-dialog.tsx | 292 | ||||
| -rw-r--r-- | components/form-data/form-data-report-batch-dialog.tsx | 348 | ||||
| -rw-r--r-- | components/form-data/form-data-report-dialog.tsx | 457 | ||||
| -rw-r--r-- | components/form-data/form-data-report-temp-upload-dialog.tsx | 329 | ||||
| -rw-r--r-- | components/form-data/form-data-table-columns.tsx | 112 | ||||
| -rw-r--r-- | components/form-data/form-data-table.tsx | 564 | ||||
| -rw-r--r-- | components/form-data/update-form-sheet.tsx | 137 |
8 files changed, 1764 insertions, 477 deletions
diff --git a/components/documents/StageList.tsx b/components/documents/StageList.tsx index 81f8a5ca..6df448df 100644 --- a/components/documents/StageList.tsx +++ b/components/documents/StageList.tsx @@ -55,7 +55,7 @@ interface Version { approvedDate: string | null DocumentSubmitDate: Date attachments: Attachment[] - selected?: boolean + selected?: boolean; } export default function StageList({ document }: StageListProps) { diff --git a/components/documents/view-document-dialog.tsx b/components/documents/view-document-dialog.tsx index 752252ee..9711c4be 100644 --- a/components/documents/view-document-dialog.tsx +++ b/components/documents/view-document-dialog.tsx @@ -1,79 +1,93 @@ -"use client" +"use client"; -import * as React from "react" +import * as React from "react"; import { WebViewerInstance } from "@pdftron/webviewer"; import { - Dialog, DialogTrigger, DialogContent, DialogHeader, - DialogTitle, DialogDescription, DialogFooter -} from "@/components/ui/dialog" -import { Building2, FileIcon, Loader2 } from "lucide-react" -import { Button } from "@/components/ui/button" -import fs from "fs" + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Building2, FileIcon, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +// 인터페이스 +interface Attachment { + id: number; + fileName: string; + filePath: string; + fileType?: string; +} interface Version { - id: number - stage: string - revision: string - uploaderType: string - uploaderName: string | null - comment: string | null - status: string | null - planDate: string | null - actualDate: string | null - approvedDate: string | null - DocumentSubmitDate: Date - attachments: Attachment[] - selected: boolean + id: number; + stage: string; + revision: string; + uploaderType: string; + uploaderName: string | null; + comment: string | null; + status: string | null; + planDate: string | null; + actualDate: string | null; + approvedDate: string | null; + DocumentSubmitDate: Date; + attachments: Attachment[]; + selected?: boolean; } type ViewDocumentDialogProps = { - versions: Version[] -} + versions: Version[]; +}; -export function ViewDocumentDialog({versions}: ViewDocumentDialogProps){ - const [open, setOpen] = React.useState(false) - +export function ViewDocumentDialog({ versions }: ViewDocumentDialogProps) { + const [open, setOpen] = React.useState(false); return ( <> - <Button - size="sm" - className="border-blue-200" - variant="outline" - onClick={() => setOpen(prev => !prev)} - > - 문서 보기 - </Button> - {open && <DocumentViewer - open={open} - setOpen={setOpen} - versions={versions} - /> - } - </> + <Button + size="sm" + className="border-blue-200" + variant="outline" + onClick={() => setOpen((prev) => !prev)} + > + 문서 보기 + </Button> + {open && ( + <DocumentViewer open={open} setOpen={setOpen} versions={versions} /> + )} + </> ); } -function DocumentViewer({open, setOpen, versions}){ - const [instance, setInstance] = React.useState<null | WebViewerInstance>(null) - const [viwerLoading, setViewerLoading] = React.useState<boolean>(true) - const [fileSetLoading, setFileSetLoading] = React.useState<boolean>(true) +const DocumentViewer: React.FC<{ + open: boolean; + setOpen: React.Dispatch<React.SetStateAction<boolean>>; + versions: Version[]; +}> = ({ open, setOpen, versions }) => { + const [instance, setInstance] = React.useState<null | WebViewerInstance>( + null + ); + const [viwerLoading, setViewerLoading] = React.useState<boolean>(true); + const [fileSetLoading, setFileSetLoading] = React.useState<boolean>(true); const viewer = React.useRef<HTMLDivElement>(null); const initialized = React.useRef(false); const isCancelled = React.useRef(false); // 초기화 중단용 flag const cleanupHtmlStyle = () => { const htmlElement = document.documentElement; - + // 기존 style 속성 가져오기 const originalStyle = htmlElement.getAttribute("style") || ""; - + // "color-scheme: light" 또는 "color-scheme: dark" 찾기 const colorSchemeStyle = originalStyle .split(";") .map((s) => s.trim()) .find((s) => s.startsWith("color-scheme:")); - + // 새로운 스타일 적용 (color-scheme만 유지) if (colorSchemeStyle) { htmlElement.setAttribute("style", colorSchemeStyle + ";"); @@ -81,146 +95,152 @@ function DocumentViewer({open, setOpen, versions}){ htmlElement.removeAttribute("style"); // color-scheme도 없으면 style 속성 자체 삭제 } - console.log("html style 삭제") + console.log("html style 삭제"); }; React.useEffect(() => { if (open && !initialized.current) { initialized.current = true; isCancelled.current = false; // 다시 열릴 때는 false로 리셋 - + requestAnimationFrame(() => { if (viewer.current) { import("@pdftron/webviewer").then(({ default: WebViewer }) => { - console.log(isCancelled.current) + console.log(isCancelled.current); if (isCancelled.current) { console.log("📛 WebViewer 초기화 취소됨 (Dialog 닫힘)"); - + return; } - + WebViewer( { path: "/pdftronWeb", - licenseKey: "demo:1739264618684:616161d7030000000091db1c97c6f386d41d3506ab5b507381ef2ee2bd", + licenseKey: + "demo:1739264618684:616161d7030000000091db1c97c6f386d41d3506ab5b507381ef2ee2bd", fullAPI: true, - css:"/globals.css" + css: "/globals.css", }, viewer.current as HTMLDivElement ).then(async (instance: WebViewerInstance) => { - - setInstance(instance); instance.UI.enableFeatures([instance.UI.Feature.MultiTab]); - instance.UI.disableElements(["addTabButton", "multiTabsEmptyPage"]); + instance.UI.disableElements([ + "addTabButton", + "multiTabsEmptyPage", + ]); setViewerLoading(false); - }); }); } }); } - - return async () => { + + return () => { // cleanup 시에는 중단 flag 세움 - if(instance){ - await instance.UI.dispose() + if (instance) { + instance.UI.dispose(); } - await setTimeout(() => cleanupHtmlStyle(), 500) + setTimeout(() => cleanupHtmlStyle(), 500); }; }, [open]); React.useEffect(() => { - const loadDocument = async () => { - - if(instance && versions.length > 0){ - const { UI } = instance; - - const optionsArray = [] - - versions.forEach(c => { - const {attachments} = c - attachments.forEach(c2 => { - const {fileName, filePath, fileType} = c2 - - const options = { - filename: fileName, - ...(fileType.includes("xlsx") && { - officeOptions: { - formatOptions: { - applyPageBreaksToSheet: true, + const loadDocument = async () => { + if (instance && versions.length > 0) { + const { UI } = instance; + + const optionsArray: any[] = []; + + versions.forEach((c) => { + const { attachments } = c; + attachments.forEach((c2) => { + const { fileName, filePath, fileType } = c2; + + const fileTypeCur = fileType ?? ""; + + const options = { + filename: fileName, + ...(fileTypeCur.includes("xlsx") && { + officeOptions: { + formatOptions: { + applyPageBreaksToSheet: true, + }, }, - }, - }), - }; + }), + }; - optionsArray.push({ - filePath, - options - }) - }) - }) + optionsArray.push({ + filePath, + options, + }); + }); + }); - const tabIds = []; + const tabIds = []; - for (const option of optionsArray) { - const { filePath, options } = option; - const response = await fetch(filePath); - const blob = await response.blob(); + for (const option of optionsArray) { + const { filePath, options } = option; + const response = await fetch(filePath); + const blob = await response.blob(); - const tab = await UI.TabManager.addTab(blob, options); - tabIds.push(tab); // 탭 ID 저장 - } + const tab = await UI.TabManager.addTab(blob, options); + tabIds.push(tab); // 탭 ID 저장 + } - if (tabIds.length > 0) { - await UI.TabManager.setActiveTab(tabIds[0]); - } + if (tabIds.length > 0) { + await UI.TabManager.setActiveTab(tabIds[0]); + } - setFileSetLoading(false) - } - } - loadDocument(); - }, [instance, versions]) + setFileSetLoading(false); + } + }; + loadDocument(); + }, [instance, versions]); - return ( - <Dialog open={open} onOpenChange={async (val) => { - console.log({val, fileSetLoading}) - if(!val && fileSetLoading){ - return; - } - + <Dialog + open={open} + onOpenChange={async (val) => { + console.log({ val, fileSetLoading }); + if (!val && fileSetLoading) { + return; + } + if (instance) { try { await instance.UI.dispose(); - setInstance(null); // 상태도 초기화 - + setInstance(null); // 상태도 초기화 } catch (e) { console.warn("dispose error", e); } } - + // cleanupHtmlStyle() - setViewerLoading(false); - setOpen(prev => !prev) - await setTimeout(() => cleanupHtmlStyle(), 1000) - }}> - <DialogContent className="w-[90vw] h-[90vh]" style={{maxWidth: "none"}}> - <DialogHeader className="h-[38px]"> - <DialogTitle> - 문서 미리보기 - </DialogTitle> - <DialogDescription> - 첨부파일 미리보기 - </DialogDescription> + setViewerLoading(false); + setOpen((prev) => !prev); + await setTimeout(() => cleanupHtmlStyle(), 1000); + }} + > + <DialogContent className="w-[90vw] h-[90vh]" style={{ maxWidth: "none" }}> + <DialogHeader className="h-[38px]"> + <DialogTitle>문서 미리보기</DialogTitle> + <DialogDescription>첨부파일 미리보기</DialogDescription> </DialogHeader> - <div ref={viewer} style={{height: "calc(90vh - 20px - 38px - 1rem - 48px)"}}> - {viwerLoading && <div className="flex flex-col items-center justify-center py-12"> - <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> - <p className="text-sm text-muted-foreground">문서 뷰어 로딩 중...</p> - </div>} + <div + ref={viewer} + style={{ height: "calc(90vh - 20px - 38px - 1rem - 48px)" }} + > + {viwerLoading && ( + <div className="flex flex-col items-center justify-center py-12"> + <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> + <p className="text-sm text-muted-foreground"> + 문서 뷰어 로딩 중... + </p> + </div> + )} </div> - </DialogContent> + </DialogContent> </Dialog> ); -}
\ No newline at end of file +}; diff --git a/components/form-data/form-data-report-batch-dialog.tsx b/components/form-data/form-data-report-batch-dialog.tsx new file mode 100644 index 00000000..614f890e --- /dev/null +++ b/components/form-data/form-data-report-batch-dialog.tsx @@ -0,0 +1,348 @@ +"use client"; + +import React, { + FC, + Dispatch, + SetStateAction, + useState, + useEffect, +} from "react"; +import { useToast } from "@/hooks/use-toast"; +import prettyBytes from "pretty-bytes"; +import { X, Loader2 } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dropzone, + DropzoneDescription, + DropzoneInput, + DropzoneTitle, + DropzoneUploadIcon, + DropzoneZone, +} from "@/components/ui/dropzone"; +import { + FileList, + FileListAction, + FileListDescription, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, +} from "@/components/ui/file-list"; +import { Button } from "@/components/ui/button"; +import { getReportTempList, getOrigin } from "@/lib/forms/services"; +import { DataTableColumnJSON } from "./form-data-table-columns"; + +const MAX_FILE_SIZE = 3000000; + +type ReportData = { + [key: string]: any; +}; + +interface tempFile { + fileName: string; + filePath: string; +} + +interface FormDataReportBatchDialogProps { + open: boolean; + setOpen: Dispatch<SetStateAction<boolean>>; + columnsJSON: DataTableColumnJSON[]; + reportData: ReportData[]; + packageId: number; + formId: number; + formCode: string; +} + +export const FormDataReportBatchDialog: FC<FormDataReportBatchDialogProps> = ({ + open, + setOpen, + columnsJSON, + reportData, + packageId, + formId, + formCode, +}) => { + const { toast } = useToast(); + const [tempList, setTempList] = useState<tempFile[]>([]); + const [selectTemp, setSelectTemp] = useState<string>(""); + const [selectedFiles, setSelectedFiles] = useState<File[]>([]); + const [isUploading, setIsUploading] = useState(false); + + useEffect(() => { + updateReportTempList(packageId, formId, setTempList); + }, [packageId, formId]); + + const onClose = () => { + if (isUploading) { + return; + } + setOpen(false); + }; + + // 드롭존 - 파일 드랍 처리 + const handleDropAccepted = (acceptedFiles: File[]) => { + const newFiles = [...acceptedFiles]; + setSelectedFiles(newFiles); + }; + + // 드롭존 - 파일 거부(에러) 처리 + const handleDropRejected = (fileRejections: any[]) => { + fileRejections.forEach((rejection) => { + toast({ + variant: "destructive", + title: "File Error", + description: `${rejection.file.name}: ${ + rejection.errors[0]?.message || "Upload failed" + }`, + }); + }); + }; + + // 파일 제거 핸들러 + const removeFile = (index: number) => { + const updatedFiles = [...selectedFiles]; + updatedFiles.splice(index, 1); + setSelectedFiles(updatedFiles); + }; + + const submitData = async () => { + setIsUploading(true); + + try { + const origin = await getOrigin() + + const targetFiles = selectedFiles[0]; + + const reportDatas = reportData.map((c) => { + const reportValue = stringifyAllValues(c); + + const reportValueMapping: { [key: string]: any } = {}; + + columnsJSON.forEach((c2) => { + const { key, label } = c2; + + const objKey = label.split(" ").join("_"); + + reportValueMapping[objKey] = reportValue?.[key] ?? ""; + }); + + return reportValueMapping; + }); + const formData = new FormData(); + formData.append("file", targetFiles); + formData.append("customFileName", `${formCode}.pdf`); + formData.append("reportDatas", JSON.stringify(reportDatas)); + formData.append("reportTempPath", selectTemp); + + const reqeustCreateReport = await fetch( + `${origin}/api/pdftron/createVendorDataReports`, + { method: "POST", body: formData } + ); + + if (reqeustCreateReport.ok) { + const blob = await reqeustCreateReport.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${formCode}.pdf`; + a.click(); + window.URL.revokeObjectURL(url); + } else { + const err = await reqeustCreateReport.json(); + console.error("에러:", err); + } + } catch (err) { + console.error(err); + toast({ + title: "Error", + description: "Report 생성 중 오류가 발생했습니다.", + variant: "destructive", + }); + } finally { + setIsUploading(false); + setSelectedFiles([]) + setOpen(false) + } + }; + + return ( + <Dialog open={open} onOpenChange={onClose}> + <DialogContent className="w-[600px]" style={{ maxWidth: "none" }}> + <DialogHeader> + <DialogTitle>Batch Report Download</DialogTitle> + <DialogDescription> + Report Template을 선택하신 후 갑지를 업로드하여 주시기 바랍니다. + </DialogDescription> + </DialogHeader> + <div className="h-[60px]"> + <Label>Report Template Select</Label> + <Select value={selectTemp} onValueChange={setSelectTemp}> + <SelectTrigger className="w-[100%]"> + <SelectValue placeholder="사용하시고자하는 Report Template를 선택하여 주시기 바랍니다." /> + </SelectTrigger> + <SelectContent> + {tempList.map((c) => { + const { fileName, filePath } = c; + + return ( + <SelectItem key={filePath} value={filePath}> + {fileName} + </SelectItem> + ); + })} + </SelectContent> + </Select> + </div> + <div> + <Label>Report Cover Page Upload</Label> + <Dropzone + maxSize={MAX_FILE_SIZE} + multiple={false} + accept={{ accept: [".docx"] }} + onDropAccepted={handleDropAccepted} + onDropRejected={handleDropRejected} + disabled={isUploading} + > + {({ maxSize }) => ( + <> + <DropzoneZone className="flex justify-center"> + <DropzoneInput /> + <div className="flex items-center gap-6"> + <DropzoneUploadIcon /> + <div className="grid gap-0.5"> + <DropzoneTitle>파일을 여기에 드롭하세요</DropzoneTitle> + <DropzoneDescription> + 또는 클릭하여 파일을 선택하세요. 최대 크기:{" "} + {maxSize ? prettyBytes(maxSize) : "무제한"} + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + <Label className="text-xs text-muted-foreground"> + 여러 파일을 선택할 수 있습니다. + </Label> + </> + )} + </Dropzone> + </div> + + {selectedFiles.length > 0 && ( + <div className="grid gap-2"> + <div className="flex items-center justify-between"> + <h6 className="text-sm font-semibold"> + 선택된 파일 ({selectedFiles.length}) + </h6> + <Badge variant="secondary">{selectedFiles.length}개 파일</Badge> + </div> + <ScrollArea> + <UploadFileItem + selectedFiles={selectedFiles} + removeFile={removeFile} + isUploading={isUploading} + /> + </ScrollArea> + </div> + )} + + <DialogFooter> + <Button + disabled={selectedFiles.length === 0 || selectTemp.length === 0 || isUploading} + onClick={submitData} + > + {isUploading && <Loader2 />}다운로드 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}; + +interface UploadFileItemProps { + selectedFiles: File[]; + removeFile: (index: number) => void; + isUploading: boolean; +} + +const UploadFileItem: FC<UploadFileItemProps> = ({ + selectedFiles, + removeFile, + isUploading, +}) => { + return ( + <FileList className="max-h-[200px] gap-3"> + {selectedFiles.map((file, index) => ( + <FileListItem key={index} className="p-3"> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>{file.name}</FileListName> + <FileListDescription> + {prettyBytes(file.size)} + </FileListDescription> + </FileListInfo> + <FileListAction + onClick={() => removeFile(index)} + disabled={isUploading} + > + <X className="h-4 w-4" /> + <span className="sr-only">Remove</span> + </FileListAction> + </FileListHeader> + </FileListItem> + ))} + </FileList> + ); +}; + +type UpdateReportTempList = ( + packageId: number, + formId: number, + setPrevReportTemp: Dispatch<SetStateAction<tempFile[]>> +) => void; + +const updateReportTempList: UpdateReportTempList = async ( + packageId, + formId, + setTempList +) => { + const tempList = await getReportTempList(packageId, formId); + + setTempList( + tempList.map((c) => { + const { fileName, filePath } = c; + return { fileName, filePath }; + }) + ); +}; + +const stringifyAllValues = (obj: any): any => { + if (Array.isArray(obj)) { + return obj.map((item) => stringifyAllValues(item)); + } else if (typeof obj === "object" && obj !== null) { + const result: any = {}; + for (const key in obj) { + result[key] = stringifyAllValues(obj[key]); + } + return result; + } else { + return obj !== null && obj !== undefined ? String(obj) : ""; + } +}; diff --git a/components/form-data/form-data-report-dialog.tsx b/components/form-data/form-data-report-dialog.tsx new file mode 100644 index 00000000..deb0873b --- /dev/null +++ b/components/form-data/form-data-report-dialog.tsx @@ -0,0 +1,457 @@ +"use client"; + +import React, { + FC, + Dispatch, + SetStateAction, + useState, + useEffect, + useRef, +} from "react"; +import { WebViewerInstance, Core } from "@pdftron/webviewer"; +import { useToast } from "@/hooks/use-toast"; +import prettyBytes from "pretty-bytes"; +import { X, Loader2 } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Button } from "@/components/ui/button"; +import { getReportTempList } from "@/lib/forms/services"; +import { DataTableColumnJSON } from "./form-data-table-columns"; + +type ReportData = { + [key: string]: any; +}; + +interface tempFile { + fileName: string; + filePath: string; +} + +interface FormDataReportDialogProps { + columnsJSON: DataTableColumnJSON[]; + reportData: ReportData[]; + setReportData: Dispatch<SetStateAction<ReportData[]>>; + packageId: number; + formId: number; + formCode: string; +} + +export const FormDataReportDialog: FC<FormDataReportDialogProps> = ({ + columnsJSON, + reportData, + setReportData, + packageId, + formId, +}) => { + const [tempList, setTempList] = useState<tempFile[]>([]); + const [selectTemp, setSelectTemp] = useState<string>(""); + const [instance, setInstance] = useState<null | WebViewerInstance>(null); + const [fileLoading, setFileLoading] = useState<boolean>(true); + + useEffect(() => { + updateReportTempList(packageId, formId, setTempList); + }, [packageId, formId]); + + const onClose = async (value: boolean) => { + if (fileLoading) { + return; + } + if (!value) { + setTimeout(() => cleanupHtmlStyle(), 1000); + setReportData([]); + } + }; + + const downloadFileData = async () => { + if (instance) { + const { UI, Core } = instance; + const { documentViewer } = Core; + + const doc = documentViewer.getDocument(); + const fileName = doc.getFilename(); + const fileData = await doc.getFileData({ + includeAnnotations: true, // 사용자가 추가한 폼 필드 및 입력 포함 + // officeOptions: { + // outputFormat: "docx", + // }, + }); + + const blob = new Blob([fileData], { + type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + }); + + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + link.download = fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + // const allTabs = UI.TabManager.getAllTabs() as { + // id: number; + // src: Core.Document; + // }[]; + + // for (const tab of allTabs) { + // // await UI.TabManager.setActiveTab(tab.id); + // await activateTabAndWaitForLoad(instance, tab.id); + // const tabDoc = tab.src; + // const fileName = tabDoc.getFilename(); + + // const fileData = await tabDoc.getFileData({ + // includeAnnotations: true, + // }); + + // console.log({ fileData }); + + // const blob = new Blob([fileData], { + // type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + // }); + + // // 다운로드 + // // const link = document.createElement("a"); + // // link.href = URL.createObjectURL(blob); + // // link.download = fileName; + // // document.body.appendChild(link); + // // link.click(); + // // document.body.removeChild(link); + // } + } + }; + + return ( + <Dialog open={reportData.length > 0} onOpenChange={onClose}> + <DialogContent className="w-[70vw]" style={{ maxWidth: "none" }}> + <DialogHeader> + <DialogTitle>Report</DialogTitle> + <DialogDescription> + 사용하시고자 하는 Report Template를 선택하여 주시기 바랍니다. + </DialogDescription> + </DialogHeader> + <div className="h-[60px]"> + <Label>Report Template Select</Label> + <Select + value={selectTemp} + onValueChange={setSelectTemp} + disabled={instance === null} + > + <SelectTrigger className="w-[100%]"> + <SelectValue placeholder="사용하시고자하는 Report Template를 선택하여 주시기 바랍니다." /> + </SelectTrigger> + <SelectContent> + {tempList.map((c) => { + const { fileName, filePath } = c; + + return ( + <SelectItem key={filePath} value={filePath}> + {fileName} + </SelectItem> + ); + })} + </SelectContent> + </Select> + </div> + <div className="h-[calc(70vh-60px)]"> + <ReportWebViewer + columnsJSON={columnsJSON} + reportTempPath={selectTemp} + reportDatas={reportData} + instance={instance} + setInstance={setInstance} + setFileLoading={setFileLoading} + /> + </div> + + <DialogFooter> + <Button onClick={downloadFileData} disabled={selectTemp.length === 0}> + 다운로드 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}; + +interface ReportWebViewerProps { + columnsJSON: DataTableColumnJSON[]; + reportTempPath: string; + reportDatas: ReportData[]; + instance: null | WebViewerInstance; + setInstance: Dispatch<SetStateAction<WebViewerInstance | null>>; + setFileLoading: Dispatch<SetStateAction<boolean>>; +} + +const ReportWebViewer: FC<ReportWebViewerProps> = ({ + columnsJSON, + reportTempPath, + reportDatas, + instance, + setInstance, + setFileLoading, +}) => { + const [viwerLoading, setViewerLoading] = useState<boolean>(true); + const viewer = useRef<HTMLDivElement>(null); + const initialized = React.useRef(false); + const isCancelled = React.useRef(false); // 초기화 중단용 flag + + useEffect(() => { + if (!initialized.current) { + initialized.current = true; + isCancelled.current = false; // 다시 열릴 때는 false로 리셋 + + requestAnimationFrame(() => { + if (viewer.current) { + import("@pdftron/webviewer").then(({ default: WebViewer }) => { + console.log(isCancelled.current); + if (isCancelled.current) { + console.log("📛 WebViewer 초기화 취소됨 (Dialog 닫힘)"); + + return; + } + + WebViewer( + { + path: "/pdftronWeb", + licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, + fullAPI: true, + }, + viewer.current as HTMLDivElement + ).then(async (instance: WebViewerInstance) => { + setInstance(instance); + // //Tab 메뉴 사용 필요시 활성화 + // instance.UI.enableFeatures([instance.UI.Feature.MultiTab]); + // instance.UI.disableElements([ + // "addTabButton", + // "multiTabsEmptyPage", + // ]); + setViewerLoading(false); + }); + }); + } + }); + } + + return () => { + // cleanup 시에는 중단 flag 세움 + if (instance) { + instance.UI.dispose(); + } + setTimeout(() => cleanupHtmlStyle(), 500); + }; + }, []); + + useEffect(() => { + importReportData( + columnsJSON, + instance, + reportDatas, + reportTempPath, + setFileLoading + ); + }, [reportTempPath, reportDatas, instance, columnsJSON]); + + return ( + <div ref={viewer} className="h-[100%]"> + {viwerLoading && ( + <div className="flex flex-col items-center justify-center py-12"> + <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> + <p className="text-sm text-muted-foreground">문서 뷰어 로딩 중...</p> + </div> + )} + </div> + ); +}; + +const cleanupHtmlStyle = () => { + const htmlElement = document.documentElement; + + // 기존 style 속성 가져오기 + const originalStyle = htmlElement.getAttribute("style") || ""; + + // "color-scheme: light" 또는 "color-scheme: dark" 찾기 + const colorSchemeStyle = originalStyle + .split(";") + .map((s) => s.trim()) + .find((s) => s.startsWith("color-scheme:")); + + // 새로운 스타일 적용 (color-scheme만 유지) + if (colorSchemeStyle) { + htmlElement.setAttribute("style", colorSchemeStyle + ";"); + } else { + htmlElement.removeAttribute("style"); // color-scheme도 없으면 style 속성 자체 삭제 + } + + console.log("html style 삭제"); +}; + +const stringifyAllValues = (obj: any): any => { + if (Array.isArray(obj)) { + return obj.map((item) => stringifyAllValues(item)); + } else if (typeof obj === "object" && obj !== null) { + const result: any = {}; + for (const key in obj) { + result[key] = stringifyAllValues(obj[key]); + } + return result; + } else { + return obj !== null && obj !== undefined ? String(obj) : ""; + } +}; + +type ImportReportData = ( + columnsJSON: DataTableColumnJSON[], + instance: null | WebViewerInstance, + reportDatas: ReportData[], + reportTempPath: string, + setFileLoading: Dispatch<SetStateAction<boolean>> +) => void; + +const importReportData: ImportReportData = async ( + columnsJSON, + instance, + reportDatas, + reportTempPath, + setFileLoading +) => { + setFileLoading(true); + try { + if (instance && reportDatas.length > 0 && reportTempPath.length > 0) { + const { UI, Core } = instance; + const { documentViewer, createDocument } = Core; + + const getFileData = await fetch(reportTempPath); + const reportFileBlob = await getFileData.blob(); + + const reportData = reportDatas[0]; + const reportValue = stringifyAllValues(reportData); + + const reportValueMapping: { [key: string]: any } = {}; + + columnsJSON.forEach((c) => { + const { key, label } = c; + + const objKey = label.split(" ").join("_"); + + reportValueMapping[objKey] = reportValue?.[key] ?? ""; + }); + + const doc = await createDocument(reportFileBlob, { + extension: "docx", + }); + + await doc.applyTemplateValues(reportValueMapping); + + documentViewer.loadDocument(doc, { + extension: "docx", + enableOfficeEditing: true, + officeOptions: { + formatOptions: { + applyPageBreaksToSheet: true, + }, + }, + }); + } + } catch (err) { + } finally { + setFileLoading(false); + } +}; + +const importReportDataTab: ImportReportData = async ( + columnJSON, + instance, + reportDatas, + reportTempPath, + setFileLoading +) => { + setFileLoading(true); + try { + if (instance && reportDatas.length > 0 && reportTempPath.length > 0) { + const { UI, Core } = instance; + const { createDocument } = Core; + + const getFileData = await fetch(reportTempPath); + const reportFileBlob = await getFileData.blob(); + + const prevTab = UI.TabManager.getAllTabs(); + + (prevTab as object[] as { id: number }[]).forEach((c) => { + const { id } = c; + UI.TabManager.deleteTab(id); + }); + + const fileOptions = reportDatas.map((c) => { + const { tagNumber } = c; + + const options = { + filename: `${tagNumber}_report.docx`, + }; + + return { options, reportData: c }; + }); + + const tabIds = []; + + for (const fileOption of fileOptions) { + let doc = await createDocument(reportFileBlob, { + ...fileOption.options, + extension: "docx", + }); + + await doc.applyTemplateValues( + stringifyAllValues(fileOption.reportData) + ); + + const tab = await UI.TabManager.addTab(doc, { + ...fileOption.options, + }); + + tabIds.push(tab); // 탭 ID 저장 + } + + if (tabIds.length > 0) { + await UI.TabManager.setActiveTab(tabIds[0]); + } + } + } catch (err) { + } finally { + setFileLoading(false); + } +}; + +type UpdateReportTempList = ( + packageId: number, + formId: number, + setPrevReportTemp: Dispatch<SetStateAction<tempFile[]>> +) => void; + +const updateReportTempList: UpdateReportTempList = async ( + packageId, + formId, + setTempList +) => { + const tempList = await getReportTempList(packageId, formId); + + setTempList( + tempList.map((c) => { + const { fileName, filePath } = c; + return { fileName, filePath }; + }) + ); +}; diff --git a/components/form-data/form-data-report-temp-upload-dialog.tsx b/components/form-data/form-data-report-temp-upload-dialog.tsx new file mode 100644 index 00000000..413c1e51 --- /dev/null +++ b/components/form-data/form-data-report-temp-upload-dialog.tsx @@ -0,0 +1,329 @@ +"use client"; + +import React, { + FC, + Dispatch, + SetStateAction, + useState, + useEffect, +} from "react"; +import { useToast } from "@/hooks/use-toast"; +import prettyBytes from "pretty-bytes"; +import { X, Loader2, Download } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { + Dropzone, + DropzoneDescription, + DropzoneInput, + DropzoneTitle, + DropzoneUploadIcon, + DropzoneZone, +} from "@/components/ui/dropzone"; +import { + FileList, + FileListAction, + FileListDescription, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, +} from "@/components/ui/file-list"; +import { getReportTempList, uploadReportTemp } from "@/lib/forms/services"; +import { VendorDataReportTemps } from "@/db/schema/vendorData"; + +interface FormDataReportTempUploadDialogProps { + open: boolean; + setOpen: Dispatch<SetStateAction<boolean>>; + packageId: number; + formCode: string; + formId: number; + uploaderType: string; +} + +// 최대 파일 크기 설정 (3000MB) +const MAX_FILE_SIZE = 3000000; + +export const FormDataReportTempUploadDialog: FC< + FormDataReportTempUploadDialogProps +> = ({ open, setOpen, packageId, formId, uploaderType }) => { + const { toast } = useToast(); + const [selectedFiles, setSelectedFiles] = useState<File[]>([]); + const [isUploading, setIsUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + const [prevReportTemp, setPrevReportTemp] = useState<VendorDataReportTemps[]>( + [] + ); + + useEffect(() => { + updateReportTempList(packageId, formId, setPrevReportTemp); + }, [packageId, formId]); + + // 드롭존 - 파일 드랍 처리 + const handleDropAccepted = (acceptedFiles: File[]) => { + const newFiles = [...selectedFiles, ...acceptedFiles]; + setSelectedFiles(newFiles); + }; + + // 드롭존 - 파일 거부(에러) 처리 + const handleDropRejected = (fileRejections: any[]) => { + fileRejections.forEach((rejection) => { + toast({ + variant: "destructive", + title: "File Error", + description: `${rejection.file.name}: ${ + rejection.errors[0]?.message || "Upload failed" + }`, + }); + }); + }; + + // 파일 제거 핸들러 + const removeFile = (index: number) => { + const updatedFiles = [...selectedFiles]; + updatedFiles.splice(index, 1); + setSelectedFiles(updatedFiles); + }; + + const submitData = async () => { + setIsUploading(true); + setUploadProgress(0); + try { + const totalFiles = selectedFiles.length; + let successCount = 0; + + for (let i = 0; i < totalFiles; i++) { + const file = selectedFiles[i]; + + const formData = new FormData(); + formData.append("file", file); + formData.append("customFileName", file.name); + formData.append("uploaderType", uploaderType); + + await uploadReportTemp(packageId, formId, formData); + + successCount++; + setUploadProgress(Math.round((successCount / totalFiles) * 100)); + } + } catch (err) { + console.error(err); + toast({ + title: "Error", + description: "파일 업로드 중 오류가 발생했습니다.", + variant: "destructive", + }); + } finally { + setIsUploading(false); + setUploadProgress(0); + updateReportTempList(packageId, formId, setPrevReportTemp); + setOpen(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogContent className="w-[600px]" style={{ maxWidth: "none" }}> + <DialogHeader> + <DialogTitle>Report Template Upload</DialogTitle> + <DialogDescription> + 사용하시고자 하는 Report Template(docx File)를 업로드 하여주시기 + 바랍니다. + </DialogDescription> + </DialogHeader> + {/* {prevReportTemp.length > 0 && ( + <> + <Label>Prev Report Template</Label> + <ScrollArea className="max-h-[100px]"> + {prevReportTemp.map((c, i) => { + return <div key={i}>{i}</div>; + })} + </ScrollArea> + </> + )} */} + <div + // className="flex flex-col gap-2" + > + <Label>Sample Template Download</Label> + + <FileList className="max-h-[200px] gap-3"> + <FileListItem className="p-3"> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>sample_template_file.docx</FileListName> + </FileListInfo> + <FileListAction + // onClick={() => removeFile(index)} + // disabled={isUploading} + > + <Download className="h-4 w-4" /> + <span className="sr-only">Download</span> + </FileListAction> + </FileListHeader> + </FileListItem> + </FileList> + </div> + + <Dropzone + maxSize={MAX_FILE_SIZE} + multiple={true} + accept={{ accept: [".docx"] }} + onDropAccepted={handleDropAccepted} + onDropRejected={handleDropRejected} + disabled={isUploading} + > + {({ maxSize }) => ( + <> + <DropzoneZone className="flex justify-center"> + <DropzoneInput /> + <div className="flex items-center gap-6"> + <DropzoneUploadIcon /> + <div className="grid gap-0.5"> + <DropzoneTitle>파일을 여기에 드롭하세요</DropzoneTitle> + <DropzoneDescription> + 또는 클릭하여 파일을 선택하세요. 최대 크기:{" "} + {maxSize ? prettyBytes(maxSize) : "무제한"} + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + <Label className="text-xs text-muted-foreground"> + 여러 파일을 선택할 수 있습니다. + </Label> + </> + )} + </Dropzone> + + {selectedFiles.length > 0 && ( + <div className="grid gap-2"> + <div className="flex items-center justify-between"> + <h6 className="text-sm font-semibold"> + 선택된 파일 ({selectedFiles.length}) + </h6> + <Badge variant="secondary">{selectedFiles.length}개 파일</Badge> + </div> + <ScrollArea> + <UploadFileItem + selectedFiles={selectedFiles} + removeFile={removeFile} + isUploading={isUploading} + /> + </ScrollArea> + </div> + )} + + {isUploading && <UploadProgressBox uploadProgress={uploadProgress} />} + <DialogFooter> + <Button onClick={() => setOpen(false)}>취소</Button> + <Button disabled={selectedFiles.length === 0} onClick={submitData}> + 업로드 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}; + +interface UploadFileItemProps { + selectedFiles: File[]; + removeFile: (index: number) => void; + isUploading: boolean; +} + +const UploadFileItem: FC<UploadFileItemProps> = ({ + selectedFiles, + removeFile, + isUploading, +}) => { + return ( + <FileList className="max-h-[200px] gap-3"> + {selectedFiles.map((file, index) => ( + <FileListItem key={index} className="p-3"> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>{file.name}</FileListName> + <FileListDescription> + {prettyBytes(file.size)} + </FileListDescription> + </FileListInfo> + <FileListAction + onClick={() => removeFile(index)} + disabled={isUploading} + > + <X className="h-4 w-4" /> + <span className="sr-only">Remove</span> + </FileListAction> + </FileListHeader> + </FileListItem> + ))} + </FileList> + ); +}; + +const UploadProgressBox: FC<{ uploadProgress: number }> = ({ + uploadProgress, +}) => { + return ( + <div className="flex flex-col gap-1 mt-2"> + <div className="flex items-center gap-2"> + <Loader2 className="h-4 w-4 animate-spin" /> + <span className="text-sm">{uploadProgress}% 업로드 중...</span> + </div> + <div className="h-2 w-full bg-muted rounded-full overflow-hidden"> + <div + className="h-full bg-primary rounded-full transition-all" + style={{ width: `${uploadProgress}%` }} + /> + </div> + </div> + ); +}; + +const generateFileName = ( + packageId: number, + formId: number, + originalFileName: string, + index: number, + totalFiles: number +) => { + // Get the file extension + const extension = originalFileName.split(".").pop() || ""; + + // Base name without extension + const baseName = `${packageId}_${formId}`; + + // For multiple files, add a suffix + if (totalFiles > 1) { + return `${baseName}_${index + 1}.${extension}`; + } + + // For a single file, no suffix needed + return `${baseName}.${extension}`; +}; + +type UpdateReportTempList = ( + packageId: number, + formId: number, + setPrevReportTemp: Dispatch<SetStateAction<VendorDataReportTemps[]>> +) => void; + +const updateReportTempList: UpdateReportTempList = async ( + packageId, + formId, + setPrevReportTemp +) => { + const tempList = await getReportTempList(packageId, formId); + setPrevReportTemp(tempList); +}; diff --git a/components/form-data/form-data-table-columns.tsx b/components/form-data/form-data-table-columns.tsx index d44616f8..b23b2e70 100644 --- a/components/form-data/form-data-table-columns.tsx +++ b/components/form-data/form-data-table-columns.tsx @@ -1,8 +1,8 @@ -import type { ColumnDef, Row } from "@tanstack/react-table" -import { ClientDataTableColumnHeaderSimple } from "../client-data-table/data-table-column-simple-header" -import { Button } from "@/components/ui/button" -import { Ellipsis } from "lucide-react" -import { formatDate } from "@/lib/utils" +import type { ColumnDef, Row } from "@tanstack/react-table"; +import { ClientDataTableColumnHeaderSimple } from "../client-data-table/data-table-column-simple-header"; +import { Button } from "@/components/ui/button"; +import { Ellipsis } from "lucide-react"; +import { formatDate } from "@/lib/utils"; import { DropdownMenu, DropdownMenuContent, @@ -15,36 +15,38 @@ import { DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" +} from "@/components/ui/dropdown-menu"; /** row 액션 관련 타입 */ export interface DataTableRowAction<TData> { - row: Row<TData> - type: "open" | "edit" | "update" + row: Row<TData>; + type: "open" | "edit" | "update"; } /** 컬럼 타입 (필요에 따라 확장) */ -export type ColumnType = "STRING" | "NUMBER" | "LIST" - +export type ColumnType = "STRING" | "NUMBER" | "LIST"; export interface DataTableColumnJSON { - key: string + key: string; /** 실제 Excel 등에서 구분용으로 쓰이는 label (고정) */ - label: string + label: string; /** UI 표시용 label (예: 단위를 함께 표시) */ - displayLabel?: string + displayLabel?: string; - type: ColumnType - options?: string[] - uom?: string + type: ColumnType; + options?: string[]; + uom?: string; } -/** - * getColumns 함수에 필요한 props +/** + * getColumns 함수에 필요한 props * - TData: 테이블에 표시할 행(Row)의 타입 */ interface GetColumnsProps<TData> { - columnsJSON: DataTableColumnJSON[] - setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TData> | null>> + columnsJSON: DataTableColumnJSON[]; + setRowAction: React.Dispatch< + React.SetStateAction<DataTableRowAction<TData> | null> + >; + setReportData: React.Dispatch<React.SetStateAction<{ [key: string]: any }[]>>; } /** @@ -55,8 +57,8 @@ interface GetColumnsProps<TData> { export function getColumns<TData extends object>({ columnsJSON, setRowAction, + setReportData, }: GetColumnsProps<TData>): ColumnDef<TData>[] { - // (1) 기본 컬럼들 const baseColumns: ColumnDef<TData>[] = columnsJSON.map((col) => ({ accessorKey: col.key, @@ -71,17 +73,19 @@ export function getColumns<TData extends object>({ excelHeader: col.label, minWidth: 80, paddingFactor: 1.2, - maxWidth: col.key ==="tagNumber"?120:150, + maxWidth: col.key === "tagNumber" ? 120 : 150, }, // (2) 실제 셀(cell) 렌더링: type에 따라 분기 가능 cell: ({ row }) => { - const cellValue = row.getValue(col.key) + const cellValue = row.getValue(col.key); // 데이터 타입별 처리 switch (col.type) { case "NUMBER": // 예: number인 경우 콤마 등 표시 - return <div>{cellValue ? Number(cellValue).toLocaleString() : ""}</div> + return ( + <div>{cellValue ? Number(cellValue).toLocaleString() : ""}</div> + ); // case "date": // // 예: 날짜 포맷팅 @@ -93,14 +97,14 @@ export function getColumns<TData extends object>({ case "LIST": // 예: select인 경우 label만 표시 - return <div>{String(cellValue ?? "")}</div> + return <div>{String(cellValue ?? "")}</div>; case "STRING": default: - return <div>{String(cellValue ?? "")}</div> + return <div>{String(cellValue ?? "")}</div>; } }, - })) + })); // (3) 액션 칼럼 - update 버튼 예시 const actionColumn: ColumnDef<TData> = { @@ -108,31 +112,39 @@ export function getColumns<TData extends object>({ header: "", cell: ({ row }) => ( <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - aria-label="Open menu" - variant="ghost" - className="flex size-8 p-0 data-[state=open]:bg-muted" - > - <Ellipsis className="size-4" aria-hidden="true" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end" className="w-40"> - <DropdownMenuItem - onSelect={() => setRowAction({ row, type: "update" })} - > - Edit - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "update" })} + > + Edit + </DropdownMenuItem> + <DropdownMenuItem + onSelect={() => { + const { original } = row; + setReportData([original]); + }} + > + Create Report + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> ), - size:40, - meta:{ - maxWidth:40 + size: 40, + meta: { + maxWidth: 40, }, enablePinning: true, - } + }; // (4) 최종 반환 - return [...baseColumns, actionColumn] -}
\ No newline at end of file + return [...baseColumns, actionColumn]; +} diff --git a/components/form-data/form-data-table.tsx b/components/form-data/form-data-table.tsx index 14fff12e..50c4f267 100644 --- a/components/form-data/form-data-table.tsx +++ b/components/form-data/form-data-table.tsx @@ -1,36 +1,40 @@ -"use client" +"use client"; -import * as React from "react" -import { useParams } from "next/navigation" -import { useTranslation } from "@/i18n/client" +import * as React from "react"; +import { useParams } from "next/navigation"; +import { useTranslation } from "@/i18n/client"; -import { ClientDataTable } from "../client-data-table/data-table" +import { ClientDataTable } from "../client-data-table/data-table"; import { getColumns, DataTableRowAction, DataTableColumnJSON, ColumnType, -} from "./form-data-table-columns" +} from "./form-data-table-columns"; -import type { DataTableAdvancedFilterField } from "@/types/table" -import { Button } from "../ui/button" -import { Download, Loader, Save, Upload } from "lucide-react" -import { toast } from "sonner" -import { syncMissingTags, updateFormDataInDB } from "@/lib/forms/services" -import { UpdateTagSheet } from "./update-form-sheet" +import type { DataTableAdvancedFilterField } from "@/types/table"; +import { Button } from "../ui/button"; +import { Download, Loader, Save, Upload } from "lucide-react"; +import { toast } from "sonner"; +import { syncMissingTags, updateFormDataInDB } from "@/lib/forms/services"; +import { UpdateTagSheet } from "./update-form-sheet"; -import ExcelJS from "exceljs" -import { saveAs } from "file-saver" +import ExcelJS from "exceljs"; +import { saveAs } from "file-saver"; +import { FormDataReportTempUploadDialog } from "./form-data-report-temp-upload-dialog"; +import { FormDataReportDialog } from "./form-data-report-dialog"; +import { FormDataReportBatchDialog } from "./form-data-report-batch-dialog"; interface GenericData { - [key: string]: any + [key: string]: any; } export interface DynamicTableProps { - dataJSON: GenericData[] - columnsJSON: DataTableColumnJSON[] - contractItemId: number - formCode: string + dataJSON: GenericData[]; + columnsJSON: DataTableColumnJSON[]; + contractItemId: number; + formCode: string; + formId: number; } export default function DynamicTable({ @@ -38,437 +42,476 @@ export default function DynamicTable({ columnsJSON, contractItemId, formCode, + formId, }: DynamicTableProps) { - const params = useParams() - const lng = (params?.lng as string) || "ko" - const { t } = useTranslation(lng, "translation") - - const [rowAction, setRowAction] = React.useState<DataTableRowAction<GenericData> | null>(null) - const [tableData, setTableData] = React.useState<GenericData[]>(() => dataJSON) - const [isPending, setIsPending] = React.useState(false) - const [isSaving, setIsSaving] = React.useState(false) + console.log({ columnsJSON }); + const params = useParams(); + const lng = (params?.lng as string) || "ko"; + const { t } = useTranslation(lng, "translation"); + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<GenericData> | null>(null); + const [tableData, setTableData] = React.useState<GenericData[]>( + () => dataJSON + ); + const [isPending, setIsPending] = React.useState(false); + const [isSaving, setIsSaving] = React.useState(false); + const [tempUpDialog, setTempUpDialog] = React.useState(false); + const [reportData, setReportData] = React.useState<GenericData[]>([]); + const [batchDownDialog, setBatchDownDialog] = React.useState(false); // Reference to the table instance - const tableRef = React.useRef(null) + const tableRef = React.useRef(null); const columns = React.useMemo( - () => getColumns<GenericData>({ columnsJSON, setRowAction }), - [columnsJSON, setRowAction] - ) + () => getColumns<GenericData>({ columnsJSON, setRowAction, setReportData }), + [columnsJSON, setRowAction, setReportData] + ); function mapColumnTypeToAdvancedFilterType( columnType: ColumnType ): DataTableAdvancedFilterField<GenericData>["type"] { switch (columnType) { case "STRING": - return "text" - case "NUMBER": - return "number" + return "text"; + case "NUMBER": + return "number"; case "LIST": // "select"로 하셔도 되고 "multi-select"로 하셔도 됩니다. - return "select" + return "select"; // 그 외 다른 타입들도 적절히 추가 매핑 default: // 예: 못 매핑한 경우 기본적으로 "text" 적용 - return "text" + return "text"; } } - const advancedFilterFields = React.useMemo<DataTableAdvancedFilterField<GenericData>[]>( - () => { - return columnsJSON.map((col) => ({ - id: col.key, - label: col.label, - type: mapColumnTypeToAdvancedFilterType(col.type), - options: - col.type === "LIST" - ? col.options?.map((v) => ({ label: v, value: v })) - : undefined, - })) - }, - [columnsJSON] - ) + const advancedFilterFields = React.useMemo< + DataTableAdvancedFilterField<GenericData>[] + >(() => { + return columnsJSON.map((col) => ({ + id: col.key, + label: col.label, + type: mapColumnTypeToAdvancedFilterType(col.type), + options: + col.type === "LIST" + ? col.options?.map((v) => ({ label: v, value: v })) + : undefined, + })); + }, [columnsJSON]); // 1) 태그 불러오기 (기존) async function handleSyncTags() { try { - setIsPending(true) - const result = await syncMissingTags(contractItemId, formCode) - + setIsPending(true); + const result = await syncMissingTags(contractItemId, formCode); + // Prepare the toast messages based on what changed - const changes = [] - if (result.createdCount > 0) changes.push(`${result.createdCount}건 태그 생성`) - if (result.updatedCount > 0) changes.push(`${result.updatedCount}건 태그 업데이트`) - if (result.deletedCount > 0) changes.push(`${result.deletedCount}건 태그 삭제`) - + const changes = []; + if (result.createdCount > 0) + changes.push(`${result.createdCount}건 태그 생성`); + if (result.updatedCount > 0) + changes.push(`${result.updatedCount}건 태그 업데이트`); + if (result.deletedCount > 0) + changes.push(`${result.deletedCount}건 태그 삭제`); + if (changes.length > 0) { // If any changes were made, show success message and reload - toast.success(`동기화 완료: ${changes.join(', ')}`) - location.reload() + toast.success(`동기화 완료: ${changes.join(", ")}`); + location.reload(); } else { // If no changes were made, show an info message - toast.info("변경사항이 없습니다. 모든 태그가 최신 상태입니다.") + toast.info("변경사항이 없습니다. 모든 태그가 최신 상태입니다."); } } catch (err) { - console.error(err) - toast.error("태그 동기화 중 에러가 발생했습니다.") + console.error(err); + toast.error("태그 동기화 중 에러가 발생했습니다."); } finally { - setIsPending(false) + setIsPending(false); } } // 2) Excel Import (새로운 기능) async function handleImportExcel(e: React.ChangeEvent<HTMLInputElement>) { - const file = e.target.files?.[0] - if (!file) return + const file = e.target.files?.[0]; + if (!file) return; try { - setIsPending(true) + setIsPending(true); // 기존 테이블 데이터의 tagNumber 목록 (이미 있는 태그) - const existingTagNumbers = new Set(tableData.map((d) => d.tagNumber)) + const existingTagNumbers = new Set(tableData.map((d) => d.tagNumber)); - const workbook = new ExcelJS.Workbook() - const arrayBuffer = await file.arrayBuffer() - await workbook.xlsx.load(arrayBuffer) + const workbook = new ExcelJS.Workbook(); + const arrayBuffer = await file.arrayBuffer(); + await workbook.xlsx.load(arrayBuffer); - const worksheet = workbook.worksheets[0] + const worksheet = workbook.worksheets[0]; // (A) 헤더 파싱 (Error 열 추가 전에 먼저 체크) - const headerRow = worksheet.getRow(1) - const headerRowValues = headerRow.values as ExcelJS.CellValue[] + const headerRow = worksheet.getRow(1); + const headerRowValues = headerRow.values as ExcelJS.CellValue[]; // 디버깅용 로그 - console.log("원본 헤더 값:", headerRowValues) + console.log("원본 헤더 값:", headerRowValues); // Excel의 헤더와 columnsJSON의 label 매핑 생성 // Excel의 행은 1부터 시작하므로 headerRowValues[0]은 undefined - const headerToIndexMap = new Map<string, number>() + const headerToIndexMap = new Map<string, number>(); for (let i = 1; i < headerRowValues.length; i++) { - const headerValue = String(headerRowValues[i] || "").trim() + const headerValue = String(headerRowValues[i] || "").trim(); if (headerValue) { - headerToIndexMap.set(headerValue, i) + headerToIndexMap.set(headerValue, i); } } // (B) 헤더 검사 - let headerErrorMessage = "" + let headerErrorMessage = ""; // (1) "columnsJSON에 있는데 엑셀에 없는" 라벨 columnsJSON.forEach((col) => { - const label = col.label + const label = col.label; if (!headerToIndexMap.has(label)) { - headerErrorMessage += `Column "${label}" is missing. ` + headerErrorMessage += `Column "${label}" is missing. `; } - }) + }); // (2) "엑셀에는 있는데 columnsJSON에 없는" 라벨 검사 headerToIndexMap.forEach((index, headerLabel) => { - const found = columnsJSON.some((col) => col.label === headerLabel) + const found = columnsJSON.some((col) => col.label === headerLabel); if (!found) { - headerErrorMessage += `Unexpected column "${headerLabel}" found in Excel. ` + headerErrorMessage += `Unexpected column "${headerLabel}" found in Excel. `; } - }) + }); // (C) 이제 Error 열 추가 - const lastColIndex = worksheet.columnCount + 1 - worksheet.getRow(1).getCell(lastColIndex).value = "Error" + const lastColIndex = worksheet.columnCount + 1; + worksheet.getRow(1).getCell(lastColIndex).value = "Error"; // 헤더 에러가 있으면 기록 후 다운로드하고 중단 if (headerErrorMessage) { - headerRow.getCell(lastColIndex).value = headerErrorMessage.trim() + headerRow.getCell(lastColIndex).value = headerErrorMessage.trim(); - const outBuffer = await workbook.xlsx.writeBuffer() - saveAs(new Blob([outBuffer]), `import-check-result_${Date.now()}.xlsx`) + const outBuffer = await workbook.xlsx.writeBuffer(); + saveAs(new Blob([outBuffer]), `import-check-result_${Date.now()}.xlsx`); - toast.error(`Header mismatch found. Please check downloaded file.`) - return + toast.error(`Header mismatch found. Please check downloaded file.`); + return; } // -- 여기까지 왔다면, 헤더는 문제 없음 -- // 컬럼 키-인덱스 매핑 생성 (실제 데이터 행 파싱용) // columnsJSON의 key와 Excel 열 인덱스 간의 매핑 - const keyToIndexMap = new Map<string, number>() + const keyToIndexMap = new Map<string, number>(); columnsJSON.forEach((col) => { - const index = headerToIndexMap.get(col.label) + const index = headerToIndexMap.get(col.label); if (index !== undefined) { - keyToIndexMap.set(col.key, index) + keyToIndexMap.set(col.key, index); } - }) + }); // 데이터 파싱 - const importedData: GenericData[] = [] - const lastRowNumber = worksheet.lastRow?.number || 1 - let errorCount = 0 + const importedData: GenericData[] = []; + const lastRowNumber = worksheet.lastRow?.number || 1; + let errorCount = 0; // 실제 데이터 행 파싱 for (let rowNum = 2; rowNum <= lastRowNumber; rowNum++) { - const row = worksheet.getRow(rowNum) - const rowValues = row.values as ExcelJS.CellValue[] - if (!rowValues || rowValues.length <= 1) continue // 빈 행 스킵 + const row = worksheet.getRow(rowNum); + const rowValues = row.values as ExcelJS.CellValue[]; + if (!rowValues || rowValues.length <= 1) continue; // 빈 행 스킵 - let errorMessage = "" - const rowObj: Record<string, any> = {} + let errorMessage = ""; + const rowObj: Record<string, any> = {}; // 각 열에 대해 처리 columnsJSON.forEach((col) => { - const colIndex = keyToIndexMap.get(col.key) - if (colIndex === undefined) return + const colIndex = keyToIndexMap.get(col.key); + if (colIndex === undefined) return; - const cellValue = rowValues[colIndex] ?? "" - let stringVal = String(cellValue).trim() + const cellValue = rowValues[colIndex] ?? ""; + let stringVal = String(cellValue).trim(); // 타입별 검사 switch (col.type) { case "STRING": if (!stringVal && col.key === "tagNumber") { - errorMessage += `[${col.label}] is empty. ` + errorMessage += `[${col.label}] is empty. `; } - rowObj[col.key] = stringVal - break + rowObj[col.key] = stringVal; + break; case "NUMBER": if (stringVal) { - const num = parseFloat(stringVal) + const num = parseFloat(stringVal); if (isNaN(num)) { - errorMessage += `[${col.label}] '${stringVal}' is not a valid number. ` + errorMessage += `[${col.label}] '${stringVal}' is not a valid number. `; } else { - rowObj[col.key] = num + rowObj[col.key] = num; } } else { - rowObj[col.key] = null + rowObj[col.key] = null; } - break + break; case "LIST": - if (stringVal && col.options && !col.options.includes(stringVal)) { - errorMessage += `[${col.label}] '${stringVal}' not in ${col.options.join(", ")}. ` + if ( + stringVal && + col.options && + !col.options.includes(stringVal) + ) { + errorMessage += `[${ + col.label + }] '${stringVal}' not in ${col.options.join(", ")}. `; } - rowObj[col.key] = stringVal - break + rowObj[col.key] = stringVal; + break; default: - rowObj[col.key] = stringVal - break + rowObj[col.key] = stringVal; + break; } - }) + }); // tagNumber 검사 - const tagNum = rowObj["tagNumber"] + const tagNum = rowObj["tagNumber"]; if (!tagNum) { - errorMessage += `No tagNumber found. ` + errorMessage += `No tagNumber found. `; } else if (!existingTagNumbers.has(tagNum)) { - errorMessage += `TagNumber '${tagNum}' is not in current data. ` + errorMessage += `TagNumber '${tagNum}' is not in current data. `; } if (errorMessage) { - row.getCell(lastColIndex).value = errorMessage.trim() - errorCount++ + row.getCell(lastColIndex).value = errorMessage.trim(); + errorCount++; } else { - importedData.push(rowObj) + importedData.push(rowObj); } } // 에러가 있으면 재다운로드 후 import 중단 if (errorCount > 0) { - const outBuffer = await workbook.xlsx.writeBuffer() - saveAs(new Blob([outBuffer]), `import-check-result_${Date.now()}.xlsx`) - toast.error(`There are ${errorCount} error row(s). Please check downloaded file.`) - return + const outBuffer = await workbook.xlsx.writeBuffer(); + saveAs(new Blob([outBuffer]), `import-check-result_${Date.now()}.xlsx`); + toast.error( + `There are ${errorCount} error row(s). Please check downloaded file.` + ); + return; } // 에러 없으니 tableData 병합 setTableData((prev) => { - const newDataMap = new Map<string, GenericData>() + const newDataMap = new Map<string, GenericData>(); // 기존 데이터를 맵에 추가 prev.forEach((item) => { if (item.tagNumber) { - newDataMap.set(item.tagNumber, { ...item }) + newDataMap.set(item.tagNumber, { ...item }); } - }) + }); // 임포트 데이터로 기존 데이터 업데이트 importedData.forEach((item) => { - const tag = item.tagNumber - if (!tag) return - const oldItem = newDataMap.get(tag) || {} - newDataMap.set(tag, { ...oldItem, ...item }) - }) + const tag = item.tagNumber; + if (!tag) return; + const oldItem = newDataMap.get(tag) || {}; + newDataMap.set(tag, { ...oldItem, ...item }); + }); - return Array.from(newDataMap.values()) - }) + return Array.from(newDataMap.values()); + }); - toast.success(`Imported ${importedData.length} rows successfully.`) + toast.success(`Imported ${importedData.length} rows successfully.`); } catch (err) { - console.error("Excel import error:", err) - toast.error("Excel import failed.") + console.error("Excel import error:", err); + toast.error("Excel import failed."); } finally { - setIsPending(false) - e.target.value = "" + setIsPending(false); + e.target.value = ""; } } // 3) Save -> 서버에 전체 tableData를 저장 async function handleSave() { try { - setIsSaving(true) - + setIsSaving(true); + // 유효성 검사 - const invalidData = tableData.filter(item => !item.tagNumber?.trim()) + const invalidData = tableData.filter((item) => !item.tagNumber?.trim()); if (invalidData.length > 0) { - toast.error(`태그 번호가 없는 항목이 ${invalidData.length}개 있습니다.`) - return + toast.error( + `태그 번호가 없는 항목이 ${invalidData.length}개 있습니다.` + ); + return; } - + // 서버 액션 호출 - const result = await updateFormDataInDB(formCode, contractItemId, tableData) - + const result = await updateFormDataInDB( + formCode, + contractItemId, + tableData + ); + if (result.success) { - toast.success(result.message) + toast.success(result.message); } else { - toast.error(result.message) + toast.error(result.message); } } catch (err) { - console.error("Save error:", err) - toast.error("데이터 저장 중 오류가 발생했습니다.") + console.error("Save error:", err); + toast.error("데이터 저장 중 오류가 발생했습니다."); } finally { - setIsSaving(false) + setIsSaving(false); } } // 4) NEW: Excel Export with data validation for select columns using a hidden validation sheet async function handleExportExcel() { try { - setIsPending(true) + setIsPending(true); // Create a new workbook - const workbook = new ExcelJS.Workbook() + const workbook = new ExcelJS.Workbook(); // 데이터 시트 생성 - const worksheet = workbook.addWorksheet("Data") + const worksheet = workbook.addWorksheet("Data"); // 유효성 검사용 숨김 시트 생성 - const validationSheet = workbook.addWorksheet("ValidationData") - validationSheet.state = 'hidden' // 시트 숨김 처리 + const validationSheet = workbook.addWorksheet("ValidationData"); + validationSheet.state = "hidden"; // 시트 숨김 처리 // 1. 유효성 검사 시트에 select 옵션 추가 - const selectColumns = columnsJSON.filter(col => - col.type === "LIST" && col.options && col.options.length > 0 - ) + const selectColumns = columnsJSON.filter( + (col) => col.type === "LIST" && col.options && col.options.length > 0 + ); // 유효성 검사 범위 저장 맵 (컬럼 키 -> 유효성 검사 범위) - const validationRanges = new Map<string, string>() + const validationRanges = new Map<string, string>(); selectColumns.forEach((col, idx) => { - const colIndex = idx + 1 - const colLetter = validationSheet.getColumn(colIndex).letter + const colIndex = idx + 1; + const colLetter = validationSheet.getColumn(colIndex).letter; // 헤더 추가 (컬럼 레이블) - validationSheet.getCell(`${colLetter}1`).value = col.label + validationSheet.getCell(`${colLetter}1`).value = col.label; // 옵션 추가 if (col.options) { col.options.forEach((option, optIdx) => { - validationSheet.getCell(`${colLetter}${optIdx + 2}`).value = option - }) + validationSheet.getCell(`${colLetter}${optIdx + 2}`).value = option; + }); // 유효성 검사 범위 저장 (ValidationData!$A$2:$A$4 형식) validationRanges.set( col.key, - `ValidationData!${colLetter}$2:${colLetter}${col.options.length + 1}` - ) + `ValidationData!${colLetter}$2:${colLetter}${ + col.options.length + 1 + }` + ); } - }) + }); // 2. 데이터 시트에 헤더 추가 - const headers = columnsJSON.map(col => col.label) - worksheet.addRow(headers) + const headers = columnsJSON.map((col) => col.label); + worksheet.addRow(headers); // 헤더 스타일 적용 - const headerRow = worksheet.getRow(1) - headerRow.font = { bold: true } - headerRow.alignment = { horizontal: 'center' } + const headerRow = worksheet.getRow(1); + headerRow.font = { bold: true }; + headerRow.alignment = { horizontal: "center" }; headerRow.eachCell((cell) => { cell.fill = { - type: 'pattern', - pattern: 'solid', - fgColor: { argb: 'FFCCCCCC' } - } - }) + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCCCCC" }, + }; + }); // 3. 데이터 행 추가 - tableData.forEach(row => { - const rowValues = columnsJSON.map(col => { - const value = row[col.key] - return value !== undefined && value !== null ? value : '' - }) - worksheet.addRow(rowValues) - }) + tableData.forEach((row) => { + const rowValues = columnsJSON.map((col) => { + const value = row[col.key]; + return value !== undefined && value !== null ? value : ""; + }); + worksheet.addRow(rowValues); + }); // 4. 데이터 유효성 검사 적용 - const maxRows = 5000 // 데이터 유효성 검사를 적용할 최대 행 수 + const maxRows = 5000; // 데이터 유효성 검사를 적용할 최대 행 수 columnsJSON.forEach((col, idx) => { if (col.type === "LIST" && validationRanges.has(col.key)) { - const colLetter = worksheet.getColumn(idx + 1).letter - const validationRange = validationRanges.get(col.key)! + const colLetter = worksheet.getColumn(idx + 1).letter; + const validationRange = validationRanges.get(col.key)!; // 유효성 검사 정의 const validation = { - type: 'list' as const, + type: "list" as const, allowBlank: true, formulae: [validationRange], showErrorMessage: true, - errorStyle: 'warning' as const, - errorTitle: '유효하지 않은 값', - error: '목록에서 값을 선택해주세요.' - } + errorStyle: "warning" as const, + errorTitle: "유효하지 않은 값", + error: "목록에서 값을 선택해주세요.", + }; // 모든 데이터 행에 유효성 검사 적용 (최대 maxRows까지) - for (let rowIdx = 2; rowIdx <= Math.min(tableData.length + 1, maxRows); rowIdx++) { - worksheet.getCell(`${colLetter}${rowIdx}`).dataValidation = validation + for ( + let rowIdx = 2; + rowIdx <= Math.min(tableData.length + 1, maxRows); + rowIdx++ + ) { + worksheet.getCell(`${colLetter}${rowIdx}`).dataValidation = + validation; } // 빈 행에도 적용 (최대 maxRows까지) if (tableData.length + 1 < maxRows) { - for (let rowIdx = tableData.length + 2; rowIdx <= maxRows; rowIdx++) { - worksheet.getCell(`${colLetter}${rowIdx}`).dataValidation = validation + for ( + let rowIdx = tableData.length + 2; + rowIdx <= maxRows; + rowIdx++ + ) { + worksheet.getCell(`${colLetter}${rowIdx}`).dataValidation = + validation; } } } - }) + }); // 5. 컬럼 너비 자동 조정 columnsJSON.forEach((col, idx) => { - const column = worksheet.getColumn(idx + 1) + const column = worksheet.getColumn(idx + 1); // 최적 너비 계산 - let maxLength = col.label.length - tableData.forEach(row => { - const value = row[col.key] + let maxLength = col.label.length; + tableData.forEach((row) => { + const value = row[col.key]; if (value !== undefined && value !== null) { - const valueLength = String(value).length + const valueLength = String(value).length; if (valueLength > maxLength) { - maxLength = valueLength + maxLength = valueLength; } } - }) + }); // 너비 설정 (최소 10, 최대 50) - column.width = Math.min(Math.max(maxLength + 2, 10), 50) - }) + column.width = Math.min(Math.max(maxLength + 2, 10), 50); + }); // 6. 파일 다운로드 - const buffer = await workbook.xlsx.writeBuffer() - saveAs(new Blob([buffer]), `${formCode}_data_${new Date().toISOString().slice(0, 10)}.xlsx`) + const buffer = await workbook.xlsx.writeBuffer(); + saveAs( + new Blob([buffer]), + `${formCode}_data_${new Date().toISOString().slice(0, 10)}.xlsx` + ); - toast.success("Excel 내보내기 완료!") + toast.success("Excel 내보내기 완료!"); } catch (err) { - console.error("Excel export error:", err) - toast.error("Excel 내보내기 실패.") + console.error("Excel export error:", err); + toast.error("Excel 내보내기 실패."); } finally { - setIsPending(false) + setIsPending(false); } } @@ -478,13 +521,34 @@ export default function DynamicTable({ data={tableData} columns={columns} advancedFilterFields={advancedFilterFields} - // tableRef={tableRef} + // tableRef={tableRef} > {/* 버튼 그룹 */} <div className="flex items-center gap-2"> {/* 태그 불러오기 버튼 */} - <Button variant="default" size="sm" onClick={handleSyncTags} disabled={isPending}> - {isPending && <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />} + <Button + variant="default" + size="sm" + onClick={() => setBatchDownDialog(true)} + > + Report Download + </Button> + <Button + variant="default" + size="sm" + onClick={() => setTempUpDialog(true)} + > + Template Upload + </Button> + <Button + variant="default" + size="sm" + onClick={handleSyncTags} + disabled={isPending} + > + {isPending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} Sync Tags </Button> @@ -503,7 +567,12 @@ export default function DynamicTable({ </Button> {/* EXPORT 버튼 (새로 추가) */} - <Button variant="outline" size="sm" onClick={handleExportExcel} disabled={isPending}> + <Button + variant="outline" + size="sm" + onClick={handleExportExcel} + disabled={isPending} + > <Download className="mr-2 size-4" /> Export Template </Button> @@ -533,13 +602,48 @@ export default function DynamicTable({ <UpdateTagSheet open={rowAction?.type === "update"} onOpenChange={(open) => { - if (!open) setRowAction(null) + if (!open) setRowAction(null); }} columns={columnsJSON} rowData={rowAction?.row.original ?? null} formCode={formCode} contractItemId={contractItemId} /> + {tempUpDialog && ( + <FormDataReportTempUploadDialog + open={tempUpDialog} + setOpen={setTempUpDialog} + packageId={contractItemId} + formCode={formCode} + formId={formId} + uploaderType="vendor" + /> + )} + + {reportData.length > 0 && ( + <FormDataReportDialog + columnsJSON={columnsJSON} + reportData={reportData} + setReportData={setReportData} + packageId={contractItemId} + formCode={formCode} + formId={formId} + /> + )} + + {batchDownDialog && ( + <FormDataReportBatchDialog + open={batchDownDialog} + setOpen={setBatchDownDialog} + columnsJSON={columnsJSON} + reportData={tableData} + packageId={contractItemId} + formCode={formCode} + formId={formId} + /> + )} </> - ) -}
\ No newline at end of file + ); +} + + diff --git a/components/form-data/update-form-sheet.tsx b/components/form-data/update-form-sheet.tsx index d5f7d21b..c52b6833 100644 --- a/components/form-data/update-form-sheet.tsx +++ b/components/form-data/update-form-sheet.tsx @@ -1,11 +1,11 @@ -"use client" +"use client"; -import * as React from "react" -import { z } from "zod" -import { zodResolver } from "@hookform/resolvers/zod" -import { useForm } from "react-hook-form" -import { Loader } from "lucide-react" -import { toast } from "sonner" +import * as React from "react"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { Loader } from "lucide-react"; +import { toast } from "sonner"; import { Sheet, @@ -15,9 +15,9 @@ import { SheetFooter, SheetHeader, SheetTitle, -} from "@/components/ui/sheet" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" +} from "@/components/ui/sheet"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Form, FormField, @@ -25,21 +25,28 @@ import { FormLabel, FormControl, FormMessage, -} from "@/components/ui/form" -import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select" - -import { DataTableColumnJSON } from "./form-data-table-columns" -import { updateFormDataInDB } from "@/lib/forms/services" - -interface UpdateTagSheetProps extends React.ComponentPropsWithoutRef<typeof Sheet> { - open: boolean - onOpenChange: (open: boolean) => void - columns: DataTableColumnJSON[] - rowData: Record<string, any> | null - formCode: string - contractItemId: number +} from "@/components/ui/form"; +import { + Select, + SelectTrigger, + SelectContent, + SelectItem, + SelectValue, +} from "@/components/ui/select"; + +import { DataTableColumnJSON } from "./form-data-table-columns"; +import { updateFormDataInDB } from "@/lib/forms/services"; + +interface UpdateTagSheetProps + extends React.ComponentPropsWithoutRef<typeof Sheet> { + open: boolean; + onOpenChange: (open: boolean) => void; + columns: DataTableColumnJSON[]; + rowData: Record<string, any> | null; + formCode: string; + contractItemId: number; /** 업데이트 성공 시 호출될 콜백 */ - onUpdateSuccess?: (updatedValues: Record<string, any>) => void + onUpdateSuccess?: (updatedValues: Record<string, any>) => void; } export function UpdateTagSheet({ @@ -52,57 +59,61 @@ export function UpdateTagSheet({ onUpdateSuccess, ...props }: UpdateTagSheetProps) { - const [isPending, startTransition] = React.useTransition() + const [isPending, startTransition] = React.useTransition(); // 1) zod 스키마 const dynamicSchema = React.useMemo(() => { - const shape: Record<string, z.ZodType<any>> = {} + const shape: Record<string, z.ZodType<any>> = {}; for (const col of columns) { if (col.type === "NUMBER") { shape[col.key] = z .union([z.coerce.number(), z.nan()]) .transform((val) => (isNaN(val) ? undefined : val)) - .optional() + .optional(); } else { - shape[col.key] = z.string().optional() + shape[col.key] = z.string().optional(); } } - return z.object(shape) - }, [columns]) + return z.object(shape); + }, [columns]); // 2) form init const form = useForm({ resolver: zodResolver(dynamicSchema), defaultValues: React.useMemo(() => { - if (!rowData) return {} - const defaults: Record<string, any> = {} + if (!rowData) return {}; + const defaults: Record<string, any> = {}; for (const col of columns) { - defaults[col.key] = rowData[col.key] ?? "" + defaults[col.key] = rowData[col.key] ?? ""; } - return defaults + return defaults; }, [rowData, columns]), - }) + }); React.useEffect(() => { if (!rowData) { - form.reset({}) - return + form.reset({}); + return; } - const defaults: Record<string, any> = {} + const defaults: Record<string, any> = {}; for (const col of columns) { - defaults[col.key] = rowData[col.key] ?? "" + defaults[col.key] = rowData[col.key] ?? ""; } - form.reset(defaults) - }, [rowData, columns, form]) + form.reset(defaults); + }, [rowData, columns, form]); async function onSubmit(values: Record<string, any>) { startTransition(async () => { - const { success, message } = await updateFormDataInDB(formCode, contractItemId, values) + const { success, message } = await updateFormDataInDB( + formCode, + contractItemId, + values + ); if (!success) { - toast.error(message) - return + toast.error(message); + return; } - toast.success("Updated successfully!") + toast.success("Updated successfully!"); // (A) 수정된 값(폼 데이터)을 부모 콜백에 전달 onUpdateSuccess?.({ @@ -111,10 +122,10 @@ export function UpdateTagSheet({ ...rowData, ...values, tagNumber: rowData?.tagNumber, - }) + }); - onOpenChange(false) - }) + onOpenChange(false); + }); } return ( @@ -128,11 +139,15 @@ export function UpdateTagSheet({ </SheetHeader> <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="flex flex-col gap-4" + > <div className="overflow-y-auto max-h-[80vh] flex-1 pr-4 -mr-4"> <div className="flex flex-col gap-4 pt-2"> {columns.map((col) => { - const isTagNumberField = col.key === "tagNumber" || col.key === "tagDescription" + const isTagNumberField = + col.key === "tagNumber" || col.key === "tagDescription"; return ( <FormField key={col.key} @@ -149,15 +164,15 @@ export function UpdateTagSheet({ type="number" readOnly={isTagNumberField} onChange={(e) => { - const num = parseFloat(e.target.value) - field.onChange(isNaN(num) ? "" : num) + const num = parseFloat(e.target.value); + field.onChange(isNaN(num) ? "" : num); }} value={field.value ?? ""} /> </FormControl> <FormMessage /> </FormItem> - ) + ); case "LIST": return ( @@ -181,7 +196,7 @@ export function UpdateTagSheet({ </Select> <FormMessage /> </FormItem> - ) + ); // case "date": // return ( @@ -205,17 +220,19 @@ export function UpdateTagSheet({ <FormItem> <FormLabel>{col.label}</FormLabel> <FormControl> - <Input readOnly={isTagNumberField} {...field} /> + <Input + readOnly={isTagNumberField} + {...field} + /> </FormControl> <FormMessage /> </FormItem> - ) + ); } }} /> - ) + ); })} - </div> </div> @@ -235,5 +252,5 @@ export function UpdateTagSheet({ </Form> </SheetContent> </Sheet> - ) -}
\ No newline at end of file + ); +} |
