diff options
Diffstat (limited to 'components/form-data')
| -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 | 305 | ||||
| -rw-r--r-- | components/form-data/form-data-table-columns.tsx | 77 |
3 files changed, 807 insertions, 32 deletions
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..5ddc5e0c --- /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..b646c3e6 --- /dev/null +++ b/components/form-data/form-data-report-temp-upload-dialog.tsx @@ -0,0 +1,305 @@ +"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 { 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를 업로드 하여주시기 바랍니다. + </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> + </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..38f5cad8 100644 --- a/components/form-data/form-data-table-columns.tsx +++ b/components/form-data/form-data-table-columns.tsx @@ -27,24 +27,27 @@ export type ColumnType = "STRING" | "NUMBER" | "LIST" export interface DataTableColumnJSON { - key: string + key: string; /** 실제 Excel 등에서 구분용으로 쓰이는 label (고정) */ label: string /** UI 표시용 label (예: 단위를 함께 표시) */ 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 +58,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,7 +74,7 @@ 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 }) => { @@ -81,7 +84,9 @@ export function getColumns<TData extends object>({ switch (col.type) { case "NUMBER": // 예: number인 경우 콤마 등 표시 - return <div>{cellValue ? Number(cellValue).toLocaleString() : ""}</div> + return ( + <div>{cellValue ? Number(cellValue).toLocaleString() : ""}</div> + ); // case "date": // // 예: 날짜 포맷팅 @@ -108,30 +113,38 @@ 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] |
