From 02b1cf005cf3e1df64183d20ba42930eb2767a9f Mon Sep 17 00:00:00 2001 From: dujinkim Date: Thu, 21 Aug 2025 06:57:36 +0000 Subject: (대표님, 최겸) 설계메뉴추가, 작업사항 업데이트 설계메뉴 - 문서관리 설계메뉴 - 벤더 데이터 gtc 메뉴 업데이트 정보시스템 - 메뉴리스트 및 정보 업데이트 파일 라우트 업데이트 엑셀임포트 개선 기본계약 개선 벤더 가입과정 변경 및 개선 벤더 기본정보 - pq 돌체 오류 수정 및 개선 벤더 로그인 과정 이메일 오류 수정 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/additional-info/join-form.tsx | 40 +- components/data-table/data-table-view-options.tsx | 25 +- components/form-data/export-excel-form.tsx | 156 +- components/form-data/form-data-table-columns.tsx | 2 +- components/form-data/form-data-table.tsx | 3 +- components/form-data/import-excel-form.tsx | 76 +- components/form-data/spreadJS-dialog copy 4.tsx | 1491 ++++++++++++++++++++ components/form-data/spreadJS-dialog.tsx | 500 ++++--- components/form-data/spreadJS-dialog_designer.tsx | 1404 ++++++++++++++++++ components/form-data/update-form-sheet.tsx | 8 +- components/information/information-client.tsx | 212 ++- components/notice/notice-client.tsx | 37 +- components/notice/notice-create-dialog.tsx | 22 +- components/notice/notice-edit-sheet.tsx | 23 +- .../user-vendor-document-table-container.tsx | 898 ++++++++++++ .../user-vendor-document-table-container.tsx | 5 + components/signup/join-form.tsx | 143 +- components/vendor-data/vendor-data-container.tsx | 333 +++-- components/vendor-info/pq-simple-dialog.tsx | 417 ++++++ .../additional-info-dialog.tsx | 119 +- 20 files changed, 5490 insertions(+), 424 deletions(-) create mode 100644 components/form-data/spreadJS-dialog copy 4.tsx create mode 100644 components/form-data/spreadJS-dialog_designer.tsx create mode 100644 components/ship-vendor-document-all/user-vendor-document-table-container.tsx create mode 100644 components/vendor-info/pq-simple-dialog.tsx (limited to 'components') diff --git a/components/additional-info/join-form.tsx b/components/additional-info/join-form.tsx index d9f5052e..ca0c60d5 100644 --- a/components/additional-info/join-form.tsx +++ b/components/additional-info/join-form.tsx @@ -192,6 +192,8 @@ export function InfoForm() { vendorName: "", taxId: "", address: "", + addressDetail: "", + postalCode: "", email: "", phone: "", country: "", @@ -286,6 +288,8 @@ export function InfoForm() { vendorName: vendorData.vendorName || "", taxId: vendorData.taxId || "", address: vendorData.address || "", + addressDetail: vendorData.addressDetail || "", + postalCode: vendorData.postalCode || "", email: vendorData.email || "", phone: vendorData.phone || "", country: vendorData.country || "", @@ -639,6 +643,8 @@ export function InfoForm() { vendorName: values.vendorName, website: values.website, address: values.address, + addressDetail: values.addressDetail, + postalCode: values.postalCode, email: values.email, phone: values.phone, country: values.country, @@ -1105,7 +1111,9 @@ export function InfoForm() { name="address" render={({ field }) => ( - 주소 + + 주소 + @@ -1114,6 +1122,36 @@ export function InfoForm() { )} /> + {/* Address Detail */} + ( + + 상세주소 + + + + + + )} + /> + + {/* Postal Code */} + ( + + 우편번호 + + + + + + )} + /> + ({ const hideableCols = React.useMemo(() => { return table .getAllLeafColumns() - .filter((col) => col.getCanHide()) - }, [table]) + .filter((col) => col.getCanHide()) + }, [table.getAllLeafColumns().map(c => c.id).join(',')]) + // 2) local state for "columnOrder" (just the ID of hideable columns) // We'll reorder these with drag & drop + const isInitialized = React.useRef(false) const [columnOrder, setColumnOrder] = React.useState(() => hideableCols.map((c) => c.id) ) @@ -107,21 +109,24 @@ export function DataTableViewOptions({ // 4) After local state changes, reflect in tanstack table // - We do this in useEffect to avoid "update a different component" error React.useEffect(() => { - // Also consider "non-hideable" columns, if any, to keep them in original positions + if (!isInitialized.current) { + isInitialized.current = true + return + } + const nonHideable = table .getAllColumns() .filter((col) => !hideableCols.some((hc) => hc.id === col.id)) .map((c) => c.id) - // e.g. place nonHideable at the front, then our local hideable order const finalOrder = [...nonHideable, ...columnOrder] + const currentOrder = table.getState().columnOrder - // Now we set the table's official column order - if (!deepEqual(table.getState().columnOrder, finalOrder)) { - table.setColumnOrder(finalOrder) - resetAutoSize?.() - } - }, [columnOrder, hideableCols.join("|"), table, resetAutoSize]) + if (!deepEqual(currentOrder, finalOrder)) { + table.setColumnOrder(finalOrder) + resetAutoSize?.() + } + }, [columnOrder,hideableCols.map(c => c.id).join(','),resetAutoSize]) return ( diff --git a/components/form-data/export-excel-form.tsx b/components/form-data/export-excel-form.tsx index 64e9ea3d..1efa5819 100644 --- a/components/form-data/export-excel-form.tsx +++ b/components/form-data/export-excel-form.tsx @@ -11,7 +11,7 @@ export interface DataTableColumnJSON { label: string; type: ColumnType; options?: string[]; - shi?: boolean; // SHI-only field indicator + shi?: string | null; // Updated to support both string and boolean for backward compatibility required?: boolean; // Required field indicator // Add any other properties that might be in columnsJSON } @@ -39,6 +39,7 @@ export interface ExportExcelOptions { tableData: GenericData[]; columnsJSON: DataTableColumnJSON[]; formCode: string; + editableFieldsMap?: Map; // 새로 추가 onPendingChange?: (isPending: boolean) => void; validateData?: boolean; // Option to enable/disable data validation } @@ -51,6 +52,63 @@ export interface ExportExcelResult { hasErrors?: boolean; } +/** + * Check if a field is editable for a specific TAG_NO + */ +function isFieldEditable( + column: DataTableColumnJSON, + tagNo: string, + editableFieldsMap: Map +): boolean { + // SHI-only fields (shi === "OUT" or shi === null) are never editable + if (column.shi === "OUT" || column.shi === null) return false; + + // System fields are never editable + if (column.key === "TAG_NO" || column.key === "TAG_DESC" || column.key === "status") return false; + + // If no editableFieldsMap provided, assume all non-SHI fields are editable + if (!editableFieldsMap || editableFieldsMap.size === 0) return true; + + // If TAG_NO not in map, no fields are editable + if (!editableFieldsMap.has(tagNo)) return false; + + // Check if this field is in the editable fields list for this TAG_NO + const editableFields = editableFieldsMap.get(tagNo) || []; + return editableFields.includes(column.key); +} + +/** + * Get the read-only reason for a field + */ +function getReadOnlyReason( + column: DataTableColumnJSON, + tagNo: string, + editableFieldsMap: Map +): string { + if (column.shi === "OUT" || column.shi === null) { + return "SHI-only field"; + } + + if (column.key === "TAG_NO" || column.key === "TAG_DESC" || column.key === "status") { + return "System field"; + } + + if (!editableFieldsMap || editableFieldsMap.size === 0) { + return "No restrictions"; + } + + if (!editableFieldsMap.has(tagNo)) { + return "No editable fields for this TAG"; + } + + const editableFields = editableFieldsMap.get(tagNo) || []; + if (!editableFields.includes(column.key)) { + return "Not editable for this TAG"; + } + + return "Editable"; +} + /** * Validate data and collect errors */ @@ -276,6 +334,7 @@ export async function exportExcelData({ tableData, columnsJSON, formCode, + editableFieldsMap = new Map(), // 새로 추가 onPendingChange, validateData = true }: ExportExcelOptions): Promise { @@ -346,7 +405,7 @@ export async function exportExcelData({ const columnIndex = colNumber - 1; const column = columnsJSON[columnIndex]; - if (column?.shi === true) { + if (column?.shi === "OUT" || column?.shi === null ) { // SHI-only 필드는 더 진한 음영으로 표시 cell.fill = { type: "pattern", @@ -384,24 +443,53 @@ export async function exportExcelData({ const rowErrors = errors.filter(err => err.rowIndex === rowIndex + 2); const hasErrors = rowErrors.length > 0; - // SHI-only 컬럼의 데이터 셀에도 음영 적용 + // 각 데이터 셀에 적절한 스타일 적용 dataRow.eachCell((cell, colNumber) => { const columnIndex = colNumber - 1; const column = columnsJSON[columnIndex]; + const tagNo = rowData.TAG_NO || ""; // Check if this cell has errors const cellHasError = rowErrors.some(err => err.columnKey === column.key); - if (column?.shi === true) { - // SHI-only 필드의 데이터 셀에 연한 음영 적용 + // Check if this field is editable for this specific TAG_NO + const fieldEditable = isFieldEditable(column, tagNo, editableFieldsMap); + const readOnlyReason = getReadOnlyReason(column, tagNo, editableFieldsMap); + + if (!fieldEditable) { + // Read-only field styling + let bgColor = "FFFFCCCC"; // Default light red for read-only + let fontColor = "FF666666"; // Gray text + + if (column?.shi === "OUT" || column?.shi === null ) { + // SHI-only fields get a more distinct styling + bgColor = cellHasError ? "FFFF6666" : "FFFFCCCC"; // Darker red if error + fontColor = "FF800000"; // Dark red text + } else { + // Other read-only fields (editableFieldsMap restrictions) + bgColor = cellHasError ? "FFFFAA99" : "FFFFDDCC"; // Orange-ish tint + fontColor = "FF996633"; // Brown text + } + cell.fill = { type: "pattern", pattern: "solid", - fgColor: { argb: cellHasError ? "FFFF6666" : "FFFFCCCC" }, // 에러가 있으면 더 진한 빨간색 + fgColor: { argb: bgColor }, }; - cell.font = { italic: true, color: { argb: "FF666666" } }; + cell.font = { italic: true, color: { argb: fontColor } }; + + // Add comment to explain why it's read-only + if (readOnlyReason !== "Editable") { + cell.note = { + texts: [{ text: `Read-only: ${readOnlyReason}` }], + margins: { + insetmode: "custom", + inset: [0.13, 0.13, 0.25, 0.25] + } + }; + } } else if (cellHasError) { - // 에러가 있는 셀은 연한 빨간색 배경 + // Editable field with validation error cell.fill = { type: "pattern", pattern: "solid", @@ -409,6 +497,7 @@ export async function exportExcelData({ }; cell.font = { color: { argb: "FFCC0000" } }; } + // If field is editable and has no errors, no special styling needed }); }); @@ -418,8 +507,8 @@ export async function exportExcelData({ columnsJSON.forEach((col, idx) => { const colLetter = worksheet.getColumn(idx + 1).letter; - // SHI-only 필드가 아닌 LIST 타입에만 유효성 검사 적용 - if (col.type === "LIST" && validationRanges.has(col.key) && col.shi !== true) { + // LIST 타입이고 유효성 검사 범위가 있는 경우에만 적용 + if (col.type === "LIST" && validationRanges.has(col.key)) { const validationRange = validationRanges.get(col.key)!; // 유효성 검사 정의 @@ -439,25 +528,34 @@ export async function exportExcelData({ rowIdx <= Math.min(tableData.length + 1, maxRows); rowIdx++ ) { - worksheet.getCell(`${colLetter}${rowIdx}`).dataValidation = - validation; + const cell = worksheet.getCell(`${colLetter}${rowIdx}`); + + // Only apply validation to editable cells + const rowData = tableData[rowIdx - 2]; // rowIdx is 1-based, data array is 0-based + if (rowData) { + const tagNo = rowData.TAG_NO || ""; + const fieldEditable = isFieldEditable(col, tagNo, editableFieldsMap); + + if (fieldEditable) { + cell.dataValidation = validation; + } + } } - // 빈 행에도 적용 (최대 maxRows까지) + // 빈 행에도 적용 (최대 maxRows까지) - 기본적으로 편집 가능하다고 가정 if (tableData.length + 1 < maxRows) { for ( let rowIdx = tableData.length + 2; rowIdx <= maxRows; rowIdx++ ) { - worksheet.getCell(`${colLetter}${rowIdx}`).dataValidation = - validation; + worksheet.getCell(`${colLetter}${rowIdx}`).dataValidation = validation; } } } - // SHI-only 필드의 빈 행들에도 음영 처리 적용 - if (col.shi === true) { + // Read-only 필드의 빈 행들에도 음영 처리 적용 (기본적으로 SHI-only 필드에만) + if (col.shi === "OUT" || col.shi === null ) { for (let rowIdx = tableData.length + 2; rowIdx <= maxRows; rowIdx++) { const cell = worksheet.getCell(`${colLetter}${rowIdx}`); cell.fill = { @@ -503,8 +601,9 @@ export async function exportExcelData({ legendSheet.addRow(["Red background header", "SHI-only fields that cannot be edited"]); legendSheet.addRow(["Blue background header", "Required fields (marked with *)"]); legendSheet.addRow(["Gray background header", "Regular optional fields"]); - legendSheet.addRow(["Light red background cells", "Cells with validation errors"]); - legendSheet.addRow(["Light red data cells", "Data in SHI-only fields (read-only)"]); + legendSheet.addRow(["Light red background cells", "Cells with validation errors OR SHI-only fields"]); + legendSheet.addRow(["Light orange background cells", "Fields not editable for specific TAG (based on editableFieldsMap)"]); + legendSheet.addRow(["Cell comments", "Hover over read-only cells to see the reason why they cannot be edited"]); if (errors.length > 0) { legendSheet.addRow([]); @@ -512,6 +611,25 @@ export async function exportExcelData({ const errorNoteRow = legendSheet.getRow(legendSheet.rowCount); errorNoteRow.font = { bold: true, color: { argb: "FFCC0000" } }; } + + // Add editableFieldsMap summary if available + if (editableFieldsMap.size > 0) { + legendSheet.addRow([]); + legendSheet.addRow([`Editable Fields Map Summary (${editableFieldsMap.size} TAGs):`]); + const summaryHeaderRow = legendSheet.getRow(legendSheet.rowCount); + summaryHeaderRow.font = { bold: true, color: { argb: "FF000080" } }; + + // Show first few examples + let count = 0; + for (const [tagNo, editableFields] of editableFieldsMap) { + if (count >= 5) { // Show only first 5 examples + legendSheet.addRow([`... and ${editableFieldsMap.size - 5} more TAGs`]); + break; + } + legendSheet.addRow([`${tagNo}:`, editableFields.join(", ")]); + count++; + } + } // 범례 스타일 적용 const legendHeaderRow = legendSheet.getRow(1); diff --git a/components/form-data/form-data-table-columns.tsx b/components/form-data/form-data-table-columns.tsx index 2f623bdb..598b66c6 100644 --- a/components/form-data/form-data-table-columns.tsx +++ b/components/form-data/form-data-table-columns.tsx @@ -41,7 +41,7 @@ export interface DataTableColumnJSON { options?: string[]; uom?: string; uomId?: string; - shi?: boolean; + shi?: string; /** 템플릿에서 가져온 추가 정보 */ hidden?: boolean; // true이면 컬럼 숨김 diff --git a/components/form-data/form-data-table.tsx b/components/form-data/form-data-table.tsx index 4f101b45..c9632c8c 100644 --- a/components/form-data/form-data-table.tsx +++ b/components/form-data/form-data-table.tsx @@ -509,7 +509,7 @@ export default function DynamicTable({ columnsJSON, formCode, contractItemId, - // editableFieldsMap, // 추가: 편집 가능 필드 정보 전달 + editableFieldsMap, // 추가: 편집 가능 필드 정보 전달 onPendingChange: setIsImporting, // Let importExcelData handle loading state onDataUpdate: (newData) => { setTableData(Array.isArray(newData) ? newData : newData(tableData)); @@ -648,6 +648,7 @@ export default function DynamicTable({ tableData, columnsJSON, formCode, + editableFieldsMap, onPendingChange: setIsExporting }); } finally { diff --git a/components/form-data/import-excel-form.tsx b/components/form-data/import-excel-form.tsx index e3ac9e0e..637b0ccf 100644 --- a/components/form-data/import-excel-form.tsx +++ b/components/form-data/import-excel-form.tsx @@ -17,13 +17,14 @@ export interface ImportError { expectedFormat?: string; } -// Simplified options interface without editableFieldsMap +// Updated options interface with editableFieldsMap export interface ImportExcelOptions { file: File; tableData: GenericData[]; columnsJSON: DataTableColumnJSON[]; formCode?: string; contractItemId?: number; + editableFieldsMap?: Map; // 새로 추가 onPendingChange?: (isPending: boolean) => void; onDataUpdate?: (updater: ((prev: GenericData[]) => GenericData[]) | GenericData[]) => void; } @@ -42,6 +43,7 @@ export interface ExportExcelOptions { tableData: GenericData[]; columnsJSON: DataTableColumnJSON[]; formCode: string; + editableFieldsMap?: Map; // 새로 추가 onPendingChange?: (isPending: boolean) => void; } @@ -49,6 +51,31 @@ interface GenericData { [key: string]: any; } +/** + * Check if a field is editable for a specific TAG_NO + */ +function isFieldEditable( + column: DataTableColumnJSON, + tagNo: string, + editableFieldsMap: Map +): boolean { + // SHI-only fields (shi === "OUT" or shi === null) are never editable + if (column.shi === "OUT" || column.shi === null) return false; + + // System fields are never editable + if (column.key === "TAG_NO" || column.key === "TAG_DESC" || column.key === "status") return false; + + // If no editableFieldsMap provided, assume all non-SHI fields are editable + if (!editableFieldsMap || editableFieldsMap.size === 0) return true; + + // If TAG_NO not in map, no fields are editable + if (!editableFieldsMap.has(tagNo)) return false; + + // Check if this field is in the editable fields list for this TAG_NO + const editableFields = editableFieldsMap.get(tagNo) || []; + return editableFields.includes(column.key); +} + /** * Create error sheet with import validation results */ @@ -156,6 +183,9 @@ function createImportErrorSheet(workbook: ExcelJS.Workbook, errors: ImportError[ case "HEADER_MISMATCH": bgColor = "FFFFE0E0"; // Very light red break; + case "READ_ONLY_FIELD": + bgColor = "FFF0F0F0"; // Light gray + break; } cell.fill = { @@ -188,6 +218,7 @@ export async function importExcelData({ columnsJSON, formCode, contractItemId, + editableFieldsMap = new Map(), // 새로 추가 onPendingChange, onDataUpdate }: ImportExcelOptions): Promise { @@ -321,8 +352,11 @@ export async function importExcelData({ const colIndex = keyToIndexMap.get(col.key); if (colIndex === undefined) return; - // Check if this is a SHI-only field (skip processing but preserve existing value) - if (col.shi === true) { + // Check if this field is editable for this TAG_NO + const fieldEditable = isFieldEditable(col, tagNo, editableFieldsMap); + + if (!fieldEditable) { + // If field is not editable, preserve existing value if (existingRowData && existingRowData[col.key] !== undefined) { rowObj[col.key] = existingRowData[col.key]; } else { @@ -339,8 +373,36 @@ export async function importExcelData({ } } + // Determine skip reason + let skipReason = ""; + if (col.shi === "OUT" || col.shi === null) { + skipReason = "SHI-only field"; + } else if (col.key === "TAG_NO" || col.key === "TAG_DESC" || col.key === "status") { + skipReason = "System field"; + } else { + skipReason = "Not editable for this TAG"; + } + // Log skipped field - skippedFields.push(`${col.label} (SHI-only field)`); + skippedFields.push(`${col.label} (${skipReason})`); + + // Check if Excel contains a value for a read-only field and warn + const cellValue = rowValues[colIndex] ?? ""; + const stringVal = String(cellValue).trim(); + if (stringVal && existingRowData && String(existingRowData[col.key] || "").trim() !== stringVal) { + validationErrors.push({ + tagNo: tagNo || `Row-${rowNum}`, + rowIndex: rowNum, + columnKey: col.key, + columnLabel: col.label, + errorType: "READ_ONLY_FIELD", + errorMessage: `Attempting to modify read-only field. ${skipReason}.`, + currentValue: stringVal, + expectedFormat: `Field is read-only. Current value: ${existingRowData[col.key] || "empty"}`, + }); + hasErrors = true; + } + return; // Skip processing Excel value for this column } @@ -419,7 +481,7 @@ export async function importExcelData({ const totalSkippedFields = skippedFieldsLog.reduce((sum, log) => sum + log.fields.length, 0); console.log("Skipped fields summary:", skippedFieldsLog); toast.info( - `${totalSkippedFields} SHI-only fields were skipped across ${skippedFieldsLog.length} rows. Check console for details.` + `${totalSkippedFields} read-only fields were skipped across ${skippedFieldsLog.length} rows. Check console for details.` ); } @@ -537,7 +599,7 @@ export async function importExcelData({ } const successMessage = skippedFieldsLog.length > 0 - ? `Successfully updated ${successCount} rows (SHI-only fields were preserved)` + ? `Successfully updated ${successCount} rows (read-only fields were preserved)` : `Successfully updated ${successCount} rows`; toast.success(successMessage); @@ -567,7 +629,7 @@ export async function importExcelData({ } const successMessage = skippedFieldsLog.length > 0 - ? `Imported ${importedData.length} rows successfully (SHI-only fields preserved)` + ? `Imported ${importedData.length} rows successfully (read-only fields preserved)` : `Imported ${importedData.length} rows successfully`; toast.success(`${successMessage} (local only)`); diff --git a/components/form-data/spreadJS-dialog copy 4.tsx b/components/form-data/spreadJS-dialog copy 4.tsx new file mode 100644 index 00000000..14f4d3ea --- /dev/null +++ b/components/form-data/spreadJS-dialog copy 4.tsx @@ -0,0 +1,1491 @@ +"use client"; + +import * as React from "react"; +import dynamic from "next/dynamic"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { GenericData } from "./export-excel-form"; +import * as GC from "@mescius/spread-sheets"; +import { toast } from "sonner"; +import { updateFormDataInDB } from "@/lib/forms/services"; +import { Loader, Save, AlertTriangle } from "lucide-react"; +import '@mescius/spread-sheets/styles/gc.spread.sheets.excel2016colorful.css'; +import { DataTableColumnJSON, ColumnType } from "./form-data-table-columns"; + +const SpreadSheets = dynamic( + () => import("@mescius/spread-sheets-react").then(mod => mod.SpreadSheets), + { + ssr: false, + loading: () => ( +
+ + Loading SpreadSheets... +
+ ) + } +); + +if (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_SPREAD_LICENSE) { + GC.Spread.Sheets.LicenseKey = process.env.NEXT_PUBLIC_SPREAD_LICENSE; +} + +interface TemplateItem { + TMPL_ID: string; + NAME: string; + TMPL_TYPE: string; + SPR_LST_SETUP: { + ACT_SHEET: string; + HIDN_SHEETS: Array; + CONTENT?: string; + DATA_SHEETS: Array<{ + SHEET_NAME: string; + REG_TYPE_ID: string; + MAP_CELL_ATT: Array<{ + ATT_ID: string; + IN: string; + }>; + }>; + }; + GRD_LST_SETUP: { + REG_TYPE_ID: string; + SPR_ITM_IDS: Array; + ATTS: Array<{}>; + }; + SPR_ITM_LST_SETUP: { + ACT_SHEET: string; + HIDN_SHEETS: Array; + CONTENT?: string; + DATA_SHEETS: Array<{ + SHEET_NAME: string; + REG_TYPE_ID: string; + MAP_CELL_ATT: Array<{ + ATT_ID: string; + IN: string; + }>; + }>; + }; +} + +interface ValidationError { + cellAddress: string; + attId: string; + value: any; + expectedType: ColumnType; + message: string; +} + +interface CellMapping { + attId: string; + cellAddress: string; + isEditable: boolean; + dataRowIndex?: number; +} + +interface TemplateViewDialogProps { + isOpen: boolean; + onClose: () => void; + templateData: TemplateItem[] | any; + selectedRow?: GenericData; + tableData?: GenericData[]; + formCode: string; + columnsJSON: DataTableColumnJSON[] + contractItemId: number; + editableFieldsMap?: Map; + onUpdateSuccess?: (updatedValues: Record | GenericData[]) => void; +} + +// 🚀 로딩 프로그레스 컴포넌트 +interface LoadingProgressProps { + phase: string; + progress: number; + total: number; + isVisible: boolean; +} + +const LoadingProgress: React.FC = ({ phase, progress, total, isVisible }) => { + const percentage = total > 0 ? Math.round((progress / total) * 100) : 0; + + if (!isVisible) return null; + + return ( +
+
+
+ + Loading Template +
+ +
+
{phase}
+
+
+
+
+ {progress.toLocaleString()} / {total.toLocaleString()} ({percentage}%) +
+
+
+
+ ); +}; + +export function TemplateViewDialog({ + isOpen, + onClose, + templateData, + selectedRow, + tableData = [], + formCode, + contractItemId, + columnsJSON, + editableFieldsMap = new Map(), + onUpdateSuccess +}: TemplateViewDialogProps) { + const [hostStyle, setHostStyle] = React.useState({ + width: '100%', + height: '100%' + }); + + const [isPending, setIsPending] = React.useState(false); + const [hasChanges, setHasChanges] = React.useState(false); + const [currentSpread, setCurrentSpread] = React.useState(null); + const [cellMappings, setCellMappings] = React.useState([]); + const [isClient, setIsClient] = React.useState(false); + const [templateType, setTemplateType] = React.useState<'SPREAD_LIST' | 'SPREAD_ITEM' | 'GRD_LIST' | null>(null); + const [validationErrors, setValidationErrors] = React.useState([]); + const [selectedTemplateId, setSelectedTemplateId] = React.useState(""); + const [availableTemplates, setAvailableTemplates] = React.useState([]); + + // 🆕 로딩 상태 추가 + const [loadingProgress, setLoadingProgress] = React.useState<{ + phase: string; + progress: number; + total: number; + } | null>(null); + const [isInitializing, setIsInitializing] = React.useState(false); + + // 🔄 진행상황 업데이트 함수 + const updateProgress = React.useCallback((phase: string, progress: number, total: number) => { + setLoadingProgress({ phase, progress, total }); + }, []); + + const determineTemplateType = React.useCallback((template: TemplateItem): 'SPREAD_LIST' | 'SPREAD_ITEM' | 'GRD_LIST' | null => { + if (template.TMPL_TYPE === "SPREAD_LIST" && (template.SPR_LST_SETUP?.CONTENT || template.SPR_ITM_LST_SETUP?.CONTENT)) { + return 'SPREAD_LIST'; + } + if (template.TMPL_TYPE === "SPREAD_ITEM" && (template.SPR_LST_SETUP?.CONTENT || template.SPR_ITM_LST_SETUP?.CONTENT)) { + return 'SPREAD_ITEM'; + } + if (template.GRD_LST_SETUP && columnsJSON.length > 0) { + return 'GRD_LIST'; + } + return null; + }, [columnsJSON]); + + const isValidTemplate = React.useCallback((template: TemplateItem): boolean => { + return determineTemplateType(template) !== null; + }, [determineTemplateType]); + + React.useEffect(() => { + setIsClient(true); + }, []); + + React.useEffect(() => { + if (!templateData) return; + + let templates: TemplateItem[]; + if (Array.isArray(templateData)) { + templates = templateData as TemplateItem[]; + } else { + templates = [templateData as TemplateItem]; + } + + const validTemplates = templates.filter(isValidTemplate); + setAvailableTemplates(validTemplates); + + if (validTemplates.length > 0 && !selectedTemplateId) { + const firstTemplate = validTemplates[0]; + const templateTypeToSet = determineTemplateType(firstTemplate); + setSelectedTemplateId(firstTemplate.TMPL_ID); + setTemplateType(templateTypeToSet); + } + }, [templateData, selectedTemplateId, isValidTemplate, determineTemplateType]); + + const handleTemplateChange = (templateId: string) => { + const template = availableTemplates.find(t => t.TMPL_ID === templateId); + if (template) { + const templateTypeToSet = determineTemplateType(template); + setSelectedTemplateId(templateId); + setTemplateType(templateTypeToSet); + setHasChanges(false); + setValidationErrors([]); + + if (currentSpread && template) { + initSpread(currentSpread, template); + } + } + }; + + const selectedTemplate = React.useMemo(() => { + return availableTemplates.find(t => t.TMPL_ID === selectedTemplateId); + }, [availableTemplates, selectedTemplateId]); + + const editableFields = React.useMemo(() => { + // SPREAD_ITEM의 경우에만 전역 editableFields 사용 + if (templateType === 'SPREAD_ITEM' && selectedRow?.TAG_NO) { + if (!editableFieldsMap.has(selectedRow.TAG_NO)) { + return []; + } + return editableFieldsMap.get(selectedRow.TAG_NO) || []; + } + + // SPREAD_LIST나 GRD_LIST의 경우 전역 editableFields는 사용하지 않음 + return []; + }, [templateType, selectedRow?.TAG_NO, editableFieldsMap]); + + +const isFieldEditable = React.useCallback((attId: string, rowData?: GenericData) => { + const columnConfig = columnsJSON.find(col => col.key === attId); + if (columnConfig?.shi === "OUT" || columnConfig?.shi === null) { + return false; + } + + if (attId === "TAG_NO" || attId === "TAG_DESC" || attId === "status") { + return false; + } + + if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') { + // 각 행의 TAG_NO를 기준으로 편집 가능 여부 판단 + if (!rowData?.TAG_NO || !editableFieldsMap.has(rowData.TAG_NO)) { + return false; + } + + const rowEditableFields = editableFieldsMap.get(rowData.TAG_NO) || []; + if (!rowEditableFields.includes(attId)) { + return false; + } + + if (rowData && (rowData.shi === "OUT" || rowData.shi === null)) { + return false; + } + return true; + } + + // SPREAD_ITEM의 경우 기존 로직 유지 + if (templateType === 'SPREAD_ITEM') { + return editableFields.includes(attId); + } + + return true; +}, [templateType, columnsJSON, editableFieldsMap]); // editableFields 의존성 제거 + +const editableFieldsCount = React.useMemo(() => { + if (templateType === 'SPREAD_ITEM') { + // SPREAD_ITEM의 경우 기존 로직 유지 + return cellMappings.filter(m => m.isEditable).length; + } + + if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') { + // 각 행별로 편집 가능한 필드 수를 계산 + let totalEditableCount = 0; + + tableData.forEach((rowData, rowIndex) => { + cellMappings.forEach(mapping => { + if (mapping.dataRowIndex === rowIndex) { + if (isFieldEditable(mapping.attId, rowData)) { + totalEditableCount++; + } + } + }); + }); + + return totalEditableCount; + } + + return cellMappings.filter(m => m.isEditable).length; +}, [cellMappings, templateType, tableData, isFieldEditable]); + + // 🚀 배치 처리 함수들 + const setBatchValues = React.useCallback(( + activeSheet: any, + valuesToSet: Array<{row: number, col: number, value: any}> + ) => { + console.log(`🚀 Setting ${valuesToSet.length} values in batch`); + + const columnGroups = new Map>(); + + valuesToSet.forEach(({row, col, value}) => { + if (!columnGroups.has(col)) { + columnGroups.set(col, []); + } + columnGroups.get(col)!.push({row, value}); + }); + + columnGroups.forEach((values, col) => { + values.sort((a, b) => a.row - b.row); + + let start = 0; + while (start < values.length) { + let end = start; + while (end + 1 < values.length && values[end + 1].row === values[end].row + 1) { + end++; + } + + const rangeValues = values.slice(start, end + 1).map(v => v.value); + const startRow = values[start].row; + + try { + if (rangeValues.length === 1) { + activeSheet.setValue(startRow, col, rangeValues[0]); + } else { + const dataArray = rangeValues.map(v => [v]); + activeSheet.setArray(startRow, col, dataArray); + } + } catch (error) { + for (let i = start; i <= end; i++) { + try { + activeSheet.setValue(values[i].row, col, values[i].value); + } catch (cellError) { + console.warn(`⚠️ Individual value setting failed [${values[i].row}, ${col}]:`, cellError); + } + } + } + + start = end + 1; + } + }); + }, []); + + const createCellStyle = React.useCallback((isEditable: boolean) => { + const style = new GC.Spread.Sheets.Style(); + if (isEditable) { + style.backColor = "#bbf7d0"; + } else { + style.backColor = "#e5e7eb"; + style.foreColor = "#4b5563"; + } + return style; + }, []); + + const setBatchStyles = React.useCallback(( + activeSheet: any, + stylesToSet: Array<{row: number, col: number, isEditable: boolean}> + ) => { + console.log(`🎨 Setting ${stylesToSet.length} styles in batch`); + + const editableStyle = createCellStyle(true); + const readonlyStyle = createCellStyle(false); + + // 🔧 개별 셀별로 스타일과 잠금 상태 설정 (편집 권한 보장) + stylesToSet.forEach(({row, col, isEditable}) => { + try { + const cell = activeSheet.getCell(row, col); + const style = isEditable ? editableStyle : readonlyStyle; + + activeSheet.setStyle(row, col, style); + cell.locked(!isEditable); // 편집 가능하면 잠금 해제 + + // 🆕 편집 가능한 셀에 기본 텍스트 에디터 설정 + if (isEditable) { + const textCellType = new GC.Spread.Sheets.CellTypes.Text(); + activeSheet.setCellType(row, col, textCellType); + } + } catch (error) { + console.warn(`⚠️ Failed to set style for cell [${row}, ${col}]:`, error); + } + }); + }, [createCellStyle]); + + const parseCellAddress = (address: string): { row: number, col: number } | null => { + if (!address || address.trim() === "") return null; + + const match = address.match(/^([A-Z]+)(\d+)$/); + if (!match) return null; + + const [, colStr, rowStr] = match; + + let col = 0; + for (let i = 0; i < colStr.length; i++) { + col = col * 26 + (colStr.charCodeAt(i) - 65 + 1); + } + col -= 1; + + const row = parseInt(rowStr) - 1; + return { row, col }; + }; + + const getCellAddress = (row: number, col: number): string => { + let colStr = ''; + let colNum = col; + while (colNum >= 0) { + colStr = String.fromCharCode((colNum % 26) + 65) + colStr; + colNum = Math.floor(colNum / 26) - 1; + } + return colStr + (row + 1); + }; + + const validateCellValue = (value: any, columnType: ColumnType, options?: string[]): string | null => { + if (value === undefined || value === null || value === "") { + return null; + } + + switch (columnType) { + case "NUMBER": + if (isNaN(Number(value))) { + return "Value must be a valid number"; + } + break; + case "LIST": + if (options && !options.includes(String(value))) { + return `Value must be one of: ${options.join(", ")}`; + } + break; + case "STRING": + break; + default: + break; + } + + return null; + }; + + const validateAllData = React.useCallback(() => { + if (!currentSpread || !selectedTemplate) return []; + + const activeSheet = currentSpread.getActiveSheet(); + const errors: ValidationError[] = []; + + cellMappings.forEach(mapping => { + const columnConfig = columnsJSON.find(col => col.key === mapping.attId); + if (!columnConfig) return; + + const cellPos = parseCellAddress(mapping.cellAddress); + if (!cellPos) return; + + const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); + const errorMessage = validateCellValue(cellValue, columnConfig.type, columnConfig.options); + + if (errorMessage) { + errors.push({ + cellAddress: mapping.cellAddress, + attId: mapping.attId, + value: cellValue, + expectedType: columnConfig.type, + message: errorMessage + }); + } + }); + + setValidationErrors(errors); + return errors; + }, [currentSpread, selectedTemplate, cellMappings, columnsJSON]); + + + + const setupOptimizedListValidation = React.useCallback((activeSheet: any, cellPos: { row: number, col: number }, options: string[], rowCount: number) => { + try { + console.log(`🎯 Setting up dropdown for ${rowCount} rows with options:`, options); + + const safeOptions = options + .filter(opt => opt !== null && opt !== undefined && opt !== '') + .map(opt => String(opt).trim()) + .filter(opt => opt.length > 0) + .filter((opt, index, arr) => arr.indexOf(opt) === index) + .slice(0, 20); + + if (safeOptions.length === 0) { + console.warn(`⚠️ No valid options found, skipping`); + return; + } + + const optionsString = safeOptions.join(','); + + for (let i = 0; i < rowCount; i++) { + try { + const targetRow = cellPos.row + i; + + // 🔧 각 셀마다 새로운 ComboBox 인스턴스 생성 + const comboBoxCellType = new GC.Spread.Sheets.CellTypes.ComboBox(); + comboBoxCellType.items(safeOptions); + comboBoxCellType.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); + + // 🔧 DataValidation 설정 + const cellValidator = GC.Spread.Sheets.DataValidation.createListValidator(optionsString); + cellValidator.showInputMessage(false); + cellValidator.showErrorMessage(false); + + // ComboBox와 Validator 적용 + activeSheet.setCellType(targetRow, cellPos.col, comboBoxCellType); + activeSheet.setDataValidator(targetRow, cellPos.col, cellValidator); + + // 🚀 중요: 셀 잠금 해제 및 편집 가능 설정 + const cell = activeSheet.getCell(targetRow, cellPos.col); + cell.locked(false); + + console.log(`✅ Dropdown applied to [${targetRow}, ${cellPos.col}] with ${safeOptions.length} options`); + + } catch (cellError) { + console.warn(`⚠️ Failed to apply dropdown to row ${cellPos.row + i}:`, cellError); + } + } + + console.log(`✅ Dropdown setup completed for ${rowCount} cells`); + + } catch (error) { + console.error('❌ Dropdown setup failed:', error); + } + }, []); + + const getSafeActiveSheet = React.useCallback((spread: any, functionName: string = 'unknown') => { + if (!spread) return null; + + try { + let activeSheet = spread.getActiveSheet(); + if (!activeSheet) { + const sheetCount = spread.getSheetCount(); + if (sheetCount > 0) { + activeSheet = spread.getSheet(0); + if (activeSheet) { + spread.setActiveSheetIndex(0); + } + } + } + return activeSheet; + } catch (error) { + console.error(`❌ Error getting activeSheet in ${functionName}:`, error); + return null; + } + }, []); + + const ensureRowCapacity = React.useCallback((activeSheet: any, requiredRowCount: number) => { + try { + if (!activeSheet) return false; + + const currentRowCount = activeSheet.getRowCount(); + if (requiredRowCount > currentRowCount) { + const newRowCount = requiredRowCount + 10; + activeSheet.setRowCount(newRowCount); + } + return true; + } catch (error) { + console.error('❌ Error in ensureRowCapacity:', error); + return false; + } + }, []); + + const ensureColumnCapacity = React.useCallback((activeSheet: any, requiredColumnCount: number) => { + try { + if (!activeSheet) return false; + + const currentColumnCount = activeSheet.getColumnCount(); + if (requiredColumnCount > currentColumnCount) { + const newColumnCount = requiredColumnCount + 10; + activeSheet.setColumnCount(newColumnCount); + } + return true; + } catch (error) { + console.error('❌ Error in ensureColumnCapacity:', error); + return false; + } + }, []); + + const setOptimalColumnWidths = React.useCallback((activeSheet: any, columns: any[], startCol: number, tableData: any[]) => { + columns.forEach((column, colIndex) => { + const targetCol = startCol + colIndex; + const optimalWidth = column.type === 'NUMBER' ? 100 : column.type === 'STRING' ? 150 : 120; + activeSheet.setColumnWidth(targetCol, optimalWidth); + }); + }, []); + + // 🚀 최적화된 GRD_LIST 생성 + // 🚀 최적화된 GRD_LIST 생성 (TAG_DESC 컬럼 틀고정 포함) +const createGrdListTableOptimized = React.useCallback((activeSheet: any, template: TemplateItem) => { + console.log('🚀 Creating optimized GRD_LIST table with TAG_DESC freeze'); + + const visibleColumns = columnsJSON + .filter(col => col.hidden !== true) + .sort((a, b) => (a.seq ?? 999999) - (b.seq ?? 999999)); + + if (visibleColumns.length === 0) return []; + + const startCol = 1; + const dataStartRow = 1; + const mappings: CellMapping[] = []; + + ensureColumnCapacity(activeSheet, startCol + visibleColumns.length); + ensureRowCapacity(activeSheet, dataStartRow + tableData.length); + + // 🧊 TAG_DESC 컬럼 위치 찾기 (틀고정용) + const tagDescColumnIndex = visibleColumns.findIndex(col => col.key === 'TAG_DESC'); + let freezeColumnCount = 0; + + if (tagDescColumnIndex !== -1) { + // TAG_DESC 컬럼까지 포함해서 고정 (startCol + tagDescColumnIndex + 1) + freezeColumnCount = startCol + tagDescColumnIndex + 1; + console.log(`🧊 TAG_DESC found at column index ${tagDescColumnIndex}, freezing ${freezeColumnCount} columns`); + } else { + // TAG_DESC가 없으면 TAG_NO까지만 고정 (일반적으로 첫 번째 컬럼) + const tagNoColumnIndex = visibleColumns.findIndex(col => col.key === 'TAG_NO'); + if (tagNoColumnIndex !== -1) { + freezeColumnCount = startCol + tagNoColumnIndex + 1; + console.log(`🧊 TAG_NO found at column index ${tagNoColumnIndex}, freezing ${freezeColumnCount} columns`); + } + } + + // 헤더 생성 + const headerStyle = new GC.Spread.Sheets.Style(); + headerStyle.backColor = "#3b82f6"; + headerStyle.foreColor = "#ffffff"; + headerStyle.font = "bold 12px Arial"; + headerStyle.hAlign = GC.Spread.Sheets.HorizontalAlign.center; + + visibleColumns.forEach((column, colIndex) => { + const targetCol = startCol + colIndex; + const cell = activeSheet.getCell(0, targetCol); + cell.value(column.label); + cell.locked(true); + activeSheet.setStyle(0, targetCol, headerStyle); + }); + + // 🚀 데이터 배치 처리 준비 + const allValues: Array<{row: number, col: number, value: any}> = []; + const allStyles: Array<{row: number, col: number, isEditable: boolean}> = []; + + // 🔧 편집 가능한 셀 정보 수집 (드롭다운용) + const dropdownConfigs: Array<{ + startRow: number; + col: number; + rowCount: number; + options: string[]; + editableRows: number[]; // 편집 가능한 행만 추적 + }> = []; + + visibleColumns.forEach((column, colIndex) => { + const targetCol = startCol + colIndex; + + // 드롭다운 설정을 위한 편집 가능한 행 찾기 + if (column.type === "LIST" && column.options) { + const editableRows: number[] = []; + tableData.forEach((rowData, rowIndex) => { + if (isFieldEditable(column.key, rowData)) { // rowData 전달 + editableRows.push(dataStartRow + rowIndex); + } + }); + + if (editableRows.length > 0) { + dropdownConfigs.push({ + startRow: dataStartRow, + col: targetCol, + rowCount: tableData.length, + options: column.options, + editableRows: editableRows + }); + } + } + + tableData.forEach((rowData, rowIndex) => { + const targetRow = dataStartRow + rowIndex; + const cellEditable = isFieldEditable(column.key, rowData); // rowData 전달 + const value = rowData[column.key]; + + mappings.push({ + attId: column.key, + cellAddress: getCellAddress(targetRow, targetCol), + isEditable: cellEditable, + dataRowIndex: rowIndex + }); + + allValues.push({ + row: targetRow, + col: targetCol, + value: value ?? null + }); + + allStyles.push({ + row: targetRow, + col: targetCol, + isEditable: cellEditable + }); + }); + }); + + // 🚀 배치로 값과 스타일 설정 + setBatchValues(activeSheet, allValues); + setBatchStyles(activeSheet, allStyles); + + // 🎯 개선된 드롭다운 설정 (편집 가능한 셀에만) + dropdownConfigs.forEach(({ col, options, editableRows }) => { + try { + console.log(`🎯 Setting dropdown for column ${col}, editable rows: ${editableRows.length}`); + + const safeOptions = options + .filter(opt => opt !== null && opt !== undefined && opt !== '') + .map(opt => String(opt).trim()) + .filter(opt => opt.length > 0) + .slice(0, 20); + + if (safeOptions.length === 0) return; + + // 편집 가능한 행에만 드롭다운 적용 + editableRows.forEach(targetRow => { + try { + const comboBoxCellType = new GC.Spread.Sheets.CellTypes.ComboBox(); + comboBoxCellType.items(safeOptions); + comboBoxCellType.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); + + const cellValidator = GC.Spread.Sheets.DataValidation.createListValidator(safeOptions.join(',')); + cellValidator.showInputMessage(false); + cellValidator.showErrorMessage(false); + + activeSheet.setCellType(targetRow, col, comboBoxCellType); + activeSheet.setDataValidator(targetRow, col, cellValidator); + + // 🚀 편집 권한 명시적 설정 + const cell = activeSheet.getCell(targetRow, col); + cell.locked(false); + + console.log(`✅ Dropdown applied to editable cell [${targetRow}, ${col}]`); + } catch (cellError) { + console.warn(`⚠️ Failed to apply dropdown to [${targetRow}, ${col}]:`, cellError); + } + }); + } catch (error) { + console.error(`❌ Dropdown config failed for column ${col}:`, error); + } + }); + + // 🧊 틀고정 설정 + if (freezeColumnCount > 0) { + try { + activeSheet.frozenColumnCount(freezeColumnCount); + activeSheet.frozenRowCount(1); // 헤더 행도 고정 + + console.log(`🧊 Freeze applied: ${freezeColumnCount} columns, 1 row (header)`); + + // 🎨 고정된 컬럼에 특별한 스타일 추가 (선택사항) + for (let col = 0; col < freezeColumnCount; col++) { + for (let row = 0; row <= tableData.length; row++) { + try { + const currentStyle = activeSheet.getStyle(row, col) || new GC.Spread.Sheets.Style(); + + if (row === 0) { + // 헤더는 기존 스타일 유지 + continue; + } else { + // 데이터 셀에 고정 구분선 추가 + if (col === freezeColumnCount - 1) { + currentStyle.borderRight = new GC.Spread.Sheets.LineBorder("#2563eb", GC.Spread.Sheets.LineStyle.medium); + activeSheet.setStyle(row, col, currentStyle); + } + } + } catch (styleError) { + console.warn(`⚠️ Failed to apply freeze border style to [${row}, ${col}]:`, styleError); + } + } + } + } catch (freezeError) { + console.error('❌ Failed to apply freeze:', freezeError); + } + } + + setOptimalColumnWidths(activeSheet, visibleColumns, startCol, tableData); + + console.log(`✅ Optimized GRD_LIST created with freeze:`); + console.log(` - Total mappings: ${mappings.length}`); + console.log(` - Editable cells: ${mappings.filter(m => m.isEditable).length}`); + console.log(` - Dropdown configs: ${dropdownConfigs.length}`); + console.log(` - Frozen columns: ${freezeColumnCount}`); + + return mappings; +}, [tableData, columnsJSON, isFieldEditable, setBatchValues, setBatchStyles, ensureColumnCapacity, ensureRowCapacity, getCellAddress, setOptimalColumnWidths]); + + const setupSheetProtectionAndEvents = React.useCallback((activeSheet: any, mappings: CellMapping[]) => { + console.log(`🛡️ Setting up protection and events for ${mappings.length} mappings`); + + // 🔧 시트 보호 완전 해제 후 편집 권한 설정 + activeSheet.options.isProtected = false; + + // 🔧 편집 가능한 셀들을 위한 강화된 설정 + mappings.forEach((mapping) => { + const cellPos = parseCellAddress(mapping.cellAddress); + if (!cellPos) return; + + try { + const cell = activeSheet.getCell(cellPos.row, cellPos.col); + const columnConfig = columnsJSON.find(col => col.key === mapping.attId); + + if (mapping.isEditable) { + // 🚀 편집 가능한 셀 설정 강화 + cell.locked(false); + + if (columnConfig?.type === "LIST" && columnConfig.options) { + // LIST 타입: 새 ComboBox 인스턴스 생성 + const comboBox = new GC.Spread.Sheets.CellTypes.ComboBox(); + comboBox.items(columnConfig.options); + comboBox.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); + activeSheet.setCellType(cellPos.row, cellPos.col, comboBox); + + // DataValidation도 추가 + const validator = GC.Spread.Sheets.DataValidation.createListValidator(columnConfig.options.join(',')); + activeSheet.setDataValidator(cellPos.row, cellPos.col, validator); + } else if (columnConfig?.type === "NUMBER") { + // NUMBER 타입: 숫자 입력 허용 + const textCellType = new GC.Spread.Sheets.CellTypes.Text(); + activeSheet.setCellType(cellPos.row, cellPos.col, textCellType); + + // 숫자 validation 추가 (에러 메시지 없이) + const numberValidator = GC.Spread.Sheets.DataValidation.createNumberValidator( + GC.Spread.Sheets.ConditionalFormatting.ComparisonOperators.between, + -999999999, 999999999, true + ); + numberValidator.showInputMessage(false); + numberValidator.showErrorMessage(false); + activeSheet.setDataValidator(cellPos.row, cellPos.col, numberValidator); + } else { + // 기본 TEXT 타입: 자유 텍스트 입력 + const textCellType = new GC.Spread.Sheets.CellTypes.Text(); + activeSheet.setCellType(cellPos.row, cellPos.col, textCellType); + } + + // 편집 가능 스타일 재적용 + const editableStyle = createCellStyle(true); + activeSheet.setStyle(cellPos.row, cellPos.col, editableStyle); + + console.log(`🔓 Cell [${cellPos.row}, ${cellPos.col}] ${mapping.attId} set as EDITABLE`); + } else { + // 읽기 전용 셀 + cell.locked(true); + const readonlyStyle = createCellStyle(false); + activeSheet.setStyle(cellPos.row, cellPos.col, readonlyStyle); + } + } catch (error) { + console.error(`❌ Error processing cell ${mapping.cellAddress}:`, error); + } + }); + + // 🛡️ 시트 보호 재설정 (편집 허용 모드로) + activeSheet.options.isProtected = false; + activeSheet.options.protectionOptions = { + allowSelectLockedCells: true, + allowSelectUnlockedCells: true, + allowSort: false, + allowFilter: false, + allowEditObjects: true, // ✅ 편집 객체 허용 + allowResizeRows: false, + allowResizeColumns: false, + allowFormatCells: false, + allowInsertRows: false, + allowInsertColumns: false, + allowDeleteRows: false, + allowDeleteColumns: false + }; + + // 🎯 변경 감지 이벤트 + const changeEvents = [ + GC.Spread.Sheets.Events.CellChanged, + GC.Spread.Sheets.Events.ValueChanged, + GC.Spread.Sheets.Events.ClipboardPasted + ]; + + changeEvents.forEach(eventType => { + activeSheet.bind(eventType, () => { + console.log(`📝 ${eventType} detected`); + setHasChanges(true); + }); + }); + + // 🚫 편집 시작 권한 확인 + activeSheet.bind(GC.Spread.Sheets.Events.EditStarting, (event: any, info: any) => { + console.log(`🎯 EditStarting: Row ${info.row}, Col ${info.col}`); + + const exactMapping = mappings.find(m => { + const cellPos = parseCellAddress(m.cellAddress); + return cellPos && cellPos.row === info.row && cellPos.col === info.col; + }); + + if (!exactMapping) { + console.log(`ℹ️ No mapping found for [${info.row}, ${info.col}] - allowing edit`); + return; // 매핑이 없으면 허용 + } + + console.log(`📋 Found mapping: ${exactMapping.attId}, isEditable: ${exactMapping.isEditable}`); + + if (!exactMapping.isEditable) { + console.log(`🚫 Field ${exactMapping.attId} is not editable`); + toast.warning(`${exactMapping.attId} field is read-only`); + info.cancel = true; + return; + } + + // SPREAD_LIST/GRD_LIST 개별 행 SHI 확인 + if ((templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && exactMapping.dataRowIndex !== undefined) { + const dataRowIndex = exactMapping.dataRowIndex; + if (dataRowIndex >= 0 && dataRowIndex < tableData.length) { + const rowData = tableData[dataRowIndex]; + if (rowData?.shi === "OUT" || rowData?.shi === null ) { + console.log(`🚫 Row ${dataRowIndex} is in SHI mode`); + toast.warning(`Row ${dataRowIndex + 1}: ${exactMapping.attId} field is read-only (SHI mode)`); + info.cancel = true; + return; + } + } + } + + console.log(`✅ Edit allowed for ${exactMapping.attId}`); + }); + + // ✅ 편집 완료 검증 + activeSheet.bind(GC.Spread.Sheets.Events.EditEnded, (event: any, info: any) => { + console.log(`🏁 EditEnded: Row ${info.row}, Col ${info.col}, New value: ${activeSheet.getValue(info.row, info.col)}`); + + const exactMapping = mappings.find(m => { + const cellPos = parseCellAddress(m.cellAddress); + return cellPos && cellPos.row === info.row && cellPos.col === info.col; + }); + + if (!exactMapping) return; + + const columnConfig = columnsJSON.find(col => col.key === exactMapping.attId); + if (columnConfig) { + const cellValue = activeSheet.getValue(info.row, info.col); + const errorMessage = validateCellValue(cellValue, columnConfig.type, columnConfig.options); + const cell = activeSheet.getCell(info.row, info.col); + + if (errorMessage) { + // 🚨 에러 스타일 적용 + const errorStyle = new GC.Spread.Sheets.Style(); + errorStyle.backColor = "#fef2f2"; + errorStyle.foreColor = "#dc2626"; + errorStyle.borderLeft = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); + errorStyle.borderRight = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); + errorStyle.borderTop = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); + errorStyle.borderBottom = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); + + activeSheet.setStyle(info.row, info.col, errorStyle); + cell.locked(!exactMapping.isEditable); // 편집 가능 상태 유지 + toast.warning(`Invalid value in ${exactMapping.attId}: ${errorMessage}`, { duration: 5000 }); + } else { + // ✅ 정상 스타일 복원 + const normalStyle = createCellStyle(exactMapping.isEditable); + activeSheet.setStyle(info.row, info.col, normalStyle); + cell.locked(!exactMapping.isEditable); + } + } + + setHasChanges(true); + }); + + console.log(`🛡️ Protection configured. Editable cells: ${mappings.filter(m => m.isEditable).length}`); + }, [templateType, tableData, createCellStyle, validateCellValue, columnsJSON]); + + // 🚀 최적화된 initSpread + const initSpread = React.useCallback(async (spread: any, template?: TemplateItem) => { + const workingTemplate = template || selectedTemplate; + if (!spread || !workingTemplate) { + console.error('❌ Invalid spread or template'); + return; + } + + try { + console.log('🚀 Starting optimized spread initialization...'); + setIsInitializing(true); + updateProgress('Initializing...', 0, 100); + + setCurrentSpread(spread); + setHasChanges(false); + setValidationErrors([]); + + // 🚀 핵심 최적화: 모든 렌더링과 이벤트 중단 + spread.suspendPaint(); + spread.suspendEvent(); + spread.suspendCalcService(); + + updateProgress('Setting up workspace...', 10, 100); + + try { + let activeSheet = getSafeActiveSheet(spread, 'initSpread'); + if (!activeSheet) { + throw new Error('Failed to get initial activeSheet'); + } + + activeSheet.options.isProtected = false; + let mappings: CellMapping[] = []; + + if (templateType === 'GRD_LIST') { + updateProgress('Creating dynamic table...', 20, 100); + + spread.clearSheets(); + spread.addSheet(0); + const sheet = spread.getSheet(0); + sheet.name('Data'); + spread.setActiveSheet('Data'); + + updateProgress('Processing table data...', 50, 100); + mappings = createGrdListTableOptimized(sheet, workingTemplate); + + } else { + updateProgress('Loading template structure...', 20, 100); + + let contentJson = workingTemplate.SPR_LST_SETUP?.CONTENT || workingTemplate.SPR_ITM_LST_SETUP?.CONTENT; + let dataSheets = workingTemplate.SPR_LST_SETUP?.DATA_SHEETS || workingTemplate.SPR_ITM_LST_SETUP?.DATA_SHEETS; + + if (!contentJson || !dataSheets) { + throw new Error(`No template content found for ${workingTemplate.NAME}`); + } + + const jsonData = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson; + + updateProgress('Loading template layout...', 40, 100); + spread.fromJSON(jsonData); + + activeSheet = getSafeActiveSheet(spread, 'after-fromJSON'); + if (!activeSheet) { + throw new Error('ActiveSheet became null after loading template'); + } + + activeSheet.options.isProtected = false; + + if (templateType === 'SPREAD_LIST' && tableData.length > 0) { + updateProgress('Processing data rows...', 60, 100); + + dataSheets.forEach(dataSheet => { + if (dataSheet.MAP_CELL_ATT && dataSheet.MAP_CELL_ATT.length > 0) { + dataSheet.MAP_CELL_ATT.forEach((mapping: any) => { + const { ATT_ID, IN } = mapping; + if (!ATT_ID || !IN || IN.trim() === "") return; + + const cellPos = parseCellAddress(IN); + if (!cellPos) return; + + const requiredRows = cellPos.row + tableData.length; + if (!ensureRowCapacity(activeSheet, requiredRows)) return; + + // 🚀 배치 데이터 준비 + const valuesToSet: Array<{row: number, col: number, value: any}> = []; + const stylesToSet: Array<{row: number, col: number, isEditable: boolean}> = []; + + tableData.forEach((rowData, index) => { + const targetRow = cellPos.row + index; + const cellEditable = isFieldEditable(ATT_ID, rowData); + const value = rowData[ATT_ID]; + + mappings.push({ + attId: ATT_ID, + cellAddress: getCellAddress(targetRow, cellPos.col), + isEditable: cellEditable, + dataRowIndex: index + }); + + valuesToSet.push({ + row: targetRow, + col: cellPos.col, + value: value ?? null + }); + + stylesToSet.push({ + row: targetRow, + col: cellPos.col, + isEditable: cellEditable + }); + }); + + // 🚀 배치 처리 + setBatchValues(activeSheet, valuesToSet); + setBatchStyles(activeSheet, stylesToSet); + + // 드롭다운 설정 + const columnConfig = columnsJSON.find(col => col.key === ATT_ID); + if (columnConfig?.type === "LIST" && columnConfig.options) { + const hasEditableRows = tableData.some((rowData) => isFieldEditable(ATT_ID, rowData)); + if (hasEditableRows) { + setupOptimizedListValidation(activeSheet, cellPos, columnConfig.options, tableData.length); + } + } + }); + } + }); + + } else if (templateType === 'SPREAD_ITEM' && selectedRow) { + updateProgress('Setting up form fields...', 60, 100); + + dataSheets.forEach(dataSheet => { + dataSheet.MAP_CELL_ATT?.forEach((mapping: any) => { + const { ATT_ID, IN } = mapping; + const cellPos = parseCellAddress(IN); + if (cellPos) { + const isEditable = isFieldEditable(ATT_ID); + const value = selectedRow[ATT_ID]; + + mappings.push({ + attId: ATT_ID, + cellAddress: IN, + isEditable: isEditable, + dataRowIndex: 0 + }); + + const cell = activeSheet.getCell(cellPos.row, cellPos.col); + cell.value(value ?? null); + + const style = createCellStyle(isEditable); + activeSheet.setStyle(cellPos.row, cellPos.col, style); + + const columnConfig = columnsJSON.find(col => col.key === ATT_ID); + if (columnConfig?.type === "LIST" && columnConfig.options && isEditable) { + setupOptimizedListValidation(activeSheet, cellPos, columnConfig.options, 1); + } + } + }); + }); + } + } + + updateProgress('Configuring interactions...', 90, 100); + setCellMappings(mappings); + + const finalActiveSheet = getSafeActiveSheet(spread, 'setupEvents'); + if (finalActiveSheet) { + setupSheetProtectionAndEvents(finalActiveSheet, mappings); + } + + updateProgress('Finalizing...', 100, 100); + console.log(`✅ Optimized initialization completed with ${mappings.length} mappings`); + + } finally { + // 🚀 올바른 순서로 재개 + spread.resumeCalcService(); + spread.resumeEvent(); + spread.resumePaint(); + } + + } catch (error) { + console.error('❌ Error in optimized spread initialization:', error); + if (spread?.resumeCalcService) spread.resumeCalcService(); + if (spread?.resumeEvent) spread.resumeEvent(); + if (spread?.resumePaint) spread.resumePaint(); + toast.error(`Template loading failed: ${error.message}`); + } finally { + setIsInitializing(false); + setLoadingProgress(null); + } + }, [selectedTemplate, templateType, selectedRow, tableData, updateProgress, getSafeActiveSheet, createGrdListTableOptimized, setBatchValues, setBatchStyles, setupSheetProtectionAndEvents, setCellMappings]); + + const handleSaveChanges = React.useCallback(async () => { + if (!currentSpread || !hasChanges) { + toast.info("No changes to save"); + return; + } + + const errors = validateAllData(); + if (errors.length > 0) { + toast.error(`Cannot save: ${errors.length} validation errors found. Please fix them first.`); + return; + } + + try { + setIsPending(true); + const activeSheet = currentSpread.getActiveSheet(); + + if (templateType === 'SPREAD_ITEM' && selectedRow) { + const dataToSave = { ...selectedRow }; + + cellMappings.forEach(mapping => { + if (mapping.isEditable) { + const cellPos = parseCellAddress(mapping.cellAddress); + if (cellPos) { + const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); + dataToSave[mapping.attId] = cellValue; + } + } + }); + + dataToSave.TAG_NO = selectedRow.TAG_NO; + + const { success, message } = await updateFormDataInDB( + formCode, + contractItemId, + dataToSave + ); + + if (!success) { + toast.error(message); + return; + } + + toast.success("Changes saved successfully!"); + onUpdateSuccess?.(dataToSave); + + } else if ((templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && tableData.length > 0) { + console.log('🔍 Starting batch save process...'); + + const updatedRows: GenericData[] = []; + let saveCount = 0; + let checkedCount = 0; + + for (let i = 0; i < tableData.length; i++) { + const originalRow = tableData[i]; + const dataToSave = { ...originalRow }; + let hasRowChanges = false; + + console.log(`🔍 Processing row ${i} (TAG_NO: ${originalRow.TAG_NO})`); + + cellMappings.forEach(mapping => { + if (mapping.dataRowIndex === i && mapping.isEditable) { + checkedCount++; + + // 🔧 isFieldEditable과 동일한 로직 사용 + const rowData = tableData[i]; + const fieldEditable = isFieldEditable(mapping.attId, rowData); + + console.log(` 📝 Field ${mapping.attId}: fieldEditable=${fieldEditable}, mapping.isEditable=${mapping.isEditable}`); + + if (fieldEditable) { + const cellPos = parseCellAddress(mapping.cellAddress); + if (cellPos) { + const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); + const originalValue = originalRow[mapping.attId]; + + // 🔧 개선된 값 비교 (타입 변환 및 null/undefined 처리) + const normalizedCellValue = cellValue === null || cellValue === undefined ? "" : String(cellValue).trim(); + const normalizedOriginalValue = originalValue === null || originalValue === undefined ? "" : String(originalValue).trim(); + + console.log(` 🔍 ${mapping.attId}: "${normalizedOriginalValue}" -> "${normalizedCellValue}"`); + + if (normalizedCellValue !== normalizedOriginalValue) { + dataToSave[mapping.attId] = cellValue; + hasRowChanges = true; + console.log(` ✅ Change detected for ${mapping.attId}`); + } + } + } + } + }); + + if (hasRowChanges) { + console.log(`💾 Saving row ${i} with changes`); + dataToSave.TAG_NO = originalRow.TAG_NO; + + try { + const { success, message } = await updateFormDataInDB( + formCode, + contractItemId, + dataToSave + ); + + if (success) { + updatedRows.push(dataToSave); + saveCount++; + console.log(`✅ Row ${i} saved successfully`); + } else { + console.error(`❌ Failed to save row ${i}: ${message}`); + toast.error(`Failed to save row ${i + 1}: ${message}`); + updatedRows.push(originalRow); // 원본 데이터 유지 + } + } catch (error) { + console.error(`❌ Error saving row ${i}:`, error); + toast.error(`Error saving row ${i + 1}`); + updatedRows.push(originalRow); // 원본 데이터 유지 + } + } else { + updatedRows.push(originalRow); + console.log(`ℹ️ No changes in row ${i}`); + } + } + + console.log(`📊 Save summary: ${saveCount} saved, ${checkedCount} fields checked`); + + if (saveCount > 0) { + toast.success(`${saveCount} rows saved successfully!`); + onUpdateSuccess?.(updatedRows); + } else { + console.warn(`⚠️ No changes detected despite hasChanges=${hasChanges}`); + toast.warning("No actual changes were found to save. Please check if the values were properly edited."); + } + } + + setHasChanges(false); + setValidationErrors([]); + + } catch (error) { + console.error("Error saving changes:", error); + toast.error("An unexpected error occurred while saving"); + } finally { + setIsPending(false); + } + }, [ + currentSpread, + hasChanges, + templateType, + selectedRow, + tableData, + formCode, + contractItemId, + onUpdateSuccess, + cellMappings, + columnsJSON, + validateAllData, + isFieldEditable // 🔧 의존성 추가 + ]); + + if (!isOpen) return null; + + const isDataValid = templateType === 'SPREAD_ITEM' ? !!selectedRow : tableData.length > 0; + const dataCount = templateType === 'SPREAD_ITEM' ? 1 : tableData.length; + + return ( + + + + SEDP Template - {formCode} + +
+ {availableTemplates.length > 1 && ( +
+ Template: + +
+ )} + + {selectedTemplate && ( +
+ + Template Type: { + templateType === 'SPREAD_LIST' ? 'List View (SPREAD_LIST)' : + templateType === 'SPREAD_ITEM' ? 'Item View (SPREAD_ITEM)' : + 'Grid List View (GRD_LIST)' + } + + {templateType === 'SPREAD_ITEM' && selectedRow && ( + • Selected TAG_NO: {selectedRow.TAG_NO || 'N/A'} + )} + {(templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && ( + • {dataCount} rows + )} + {hasChanges && ( + + • Unsaved changes + + )} + {validationErrors.length > 0 && ( + + + {validationErrors.length} validation errors + + )} +
+ )} + +
+ + + Editable fields + + + + Read-only fields + + + + Validation errors + + {cellMappings.length > 0 && ( + + {editableFieldsCount} of {cellMappings.length} fields editable + + )} +
+
+
+
+ +
+ {/* 🆕 로딩 프로그레스 오버레이 */} + + + {selectedTemplate && isClient && isDataValid ? ( + + ) : ( +
+ {!isClient ? ( + <> + + Loading... + + ) : !selectedTemplate ? ( + "No template available" + ) : !isDataValid ? ( + `No ${templateType === 'SPREAD_ITEM' ? 'selected row' : 'data'} available` + ) : ( + "Template not ready" + )} +
+ )} +
+ + +
+ + + {hasChanges && ( + + )} + + {validationErrors.length > 0 && ( + + )} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/components/form-data/spreadJS-dialog.tsx b/components/form-data/spreadJS-dialog.tsx index a223a849..14f4d3ea 100644 --- a/components/form-data/spreadJS-dialog.tsx +++ b/components/form-data/spreadJS-dialog.tsx @@ -235,46 +235,79 @@ export function TemplateViewDialog({ }, [availableTemplates, selectedTemplateId]); const editableFields = React.useMemo(() => { + // SPREAD_ITEM의 경우에만 전역 editableFields 사용 if (templateType === 'SPREAD_ITEM' && selectedRow?.TAG_NO) { if (!editableFieldsMap.has(selectedRow.TAG_NO)) { return []; } return editableFieldsMap.get(selectedRow.TAG_NO) || []; } + + // SPREAD_LIST나 GRD_LIST의 경우 전역 editableFields는 사용하지 않음 + return []; + }, [templateType, selectedRow?.TAG_NO, editableFieldsMap]); - if ((templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && tableData.length > 0) { - const firstRowTagNo = tableData[0]?.TAG_NO; - if (firstRowTagNo && editableFieldsMap.has(firstRowTagNo)) { - return editableFieldsMap.get(firstRowTagNo) || []; - } - } + +const isFieldEditable = React.useCallback((attId: string, rowData?: GenericData) => { + const columnConfig = columnsJSON.find(col => col.key === attId); + if (columnConfig?.shi === "OUT" || columnConfig?.shi === null) { + return false; + } - return []; - }, [templateType, selectedRow?.TAG_NO, tableData, editableFieldsMap]); + if (attId === "TAG_NO" || attId === "TAG_DESC" || attId === "status") { + return false; + } - const isFieldEditable = React.useCallback((attId: string, rowData?: GenericData) => { - const columnConfig = columnsJSON.find(col => col.key === attId); - if (columnConfig?.shi === true) { + if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') { + // 각 행의 TAG_NO를 기준으로 편집 가능 여부 판단 + if (!rowData?.TAG_NO || !editableFieldsMap.has(rowData.TAG_NO)) { return false; } - - if (attId === "TAG_NO" || attId === "TAG_DESC" || attId === "status") { + + const rowEditableFields = editableFieldsMap.get(rowData.TAG_NO) || []; + if (!rowEditableFields.includes(attId)) { return false; } - - if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') { - if (rowData && rowData.shi === true) { - return false; - } - return true; + + if (rowData && (rowData.shi === "OUT" || rowData.shi === null)) { + return false; } - return true; - }, [templateType, columnsJSON]); + } + + // SPREAD_ITEM의 경우 기존 로직 유지 + if (templateType === 'SPREAD_ITEM') { + return editableFields.includes(attId); + } - const editableFieldsCount = React.useMemo(() => { + return true; +}, [templateType, columnsJSON, editableFieldsMap]); // editableFields 의존성 제거 + +const editableFieldsCount = React.useMemo(() => { + if (templateType === 'SPREAD_ITEM') { + // SPREAD_ITEM의 경우 기존 로직 유지 return cellMappings.filter(m => m.isEditable).length; - }, [cellMappings]); + } + + if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') { + // 각 행별로 편집 가능한 필드 수를 계산 + let totalEditableCount = 0; + + tableData.forEach((rowData, rowIndex) => { + cellMappings.forEach(mapping => { + if (mapping.dataRowIndex === rowIndex) { + if (isFieldEditable(mapping.attId, rowData)) { + totalEditableCount++; + } + } + }); + }); + + return totalEditableCount; + } + + return cellMappings.filter(m => m.isEditable).length; +}, [cellMappings, templateType, tableData, isFieldEditable]); // 🚀 배치 처리 함수들 const setBatchValues = React.useCallback(( @@ -330,10 +363,10 @@ export function TemplateViewDialog({ const createCellStyle = React.useCallback((isEditable: boolean) => { const style = new GC.Spread.Sheets.Style(); if (isEditable) { - style.backColor = "#f0fdf4"; + style.backColor = "#bbf7d0"; } else { - style.backColor = "#f9fafb"; - style.foreColor = "#6b7280"; + style.backColor = "#e5e7eb"; + style.foreColor = "#4b5563"; } return style; }, []); @@ -569,153 +602,206 @@ export function TemplateViewDialog({ }, []); // 🚀 최적화된 GRD_LIST 생성 - const createGrdListTableOptimized = React.useCallback((activeSheet: any, template: TemplateItem) => { - console.log('🚀 Creating optimized GRD_LIST table'); - - const visibleColumns = columnsJSON - .filter(col => col.hidden !== true) - .sort((a, b) => (a.seq ?? 999999) - (b.seq ?? 999999)); - - if (visibleColumns.length === 0) return []; + // 🚀 최적화된 GRD_LIST 생성 (TAG_DESC 컬럼 틀고정 포함) +const createGrdListTableOptimized = React.useCallback((activeSheet: any, template: TemplateItem) => { + console.log('🚀 Creating optimized GRD_LIST table with TAG_DESC freeze'); - const startCol = 1; - const dataStartRow = 1; - const mappings: CellMapping[] = []; + const visibleColumns = columnsJSON + .filter(col => col.hidden !== true) + .sort((a, b) => (a.seq ?? 999999) - (b.seq ?? 999999)); - ensureColumnCapacity(activeSheet, startCol + visibleColumns.length); - ensureRowCapacity(activeSheet, dataStartRow + tableData.length); + if (visibleColumns.length === 0) return []; - // 헤더 생성 - const headerStyle = new GC.Spread.Sheets.Style(); - headerStyle.backColor = "#3b82f6"; - headerStyle.foreColor = "#ffffff"; - headerStyle.font = "bold 12px Arial"; - headerStyle.hAlign = GC.Spread.Sheets.HorizontalAlign.center; + const startCol = 1; + const dataStartRow = 1; + const mappings: CellMapping[] = []; - visibleColumns.forEach((column, colIndex) => { - const targetCol = startCol + colIndex; - const cell = activeSheet.getCell(0, targetCol); - cell.value(column.label); - cell.locked(true); - activeSheet.setStyle(0, targetCol, headerStyle); - }); + ensureColumnCapacity(activeSheet, startCol + visibleColumns.length); + ensureRowCapacity(activeSheet, dataStartRow + tableData.length); - // 🚀 데이터 배치 처리 준비 - const allValues: Array<{row: number, col: number, value: any}> = []; - const allStyles: Array<{row: number, col: number, isEditable: boolean}> = []; + // 🧊 TAG_DESC 컬럼 위치 찾기 (틀고정용) + const tagDescColumnIndex = visibleColumns.findIndex(col => col.key === 'TAG_DESC'); + let freezeColumnCount = 0; + + if (tagDescColumnIndex !== -1) { + // TAG_DESC 컬럼까지 포함해서 고정 (startCol + tagDescColumnIndex + 1) + freezeColumnCount = startCol + tagDescColumnIndex + 1; + console.log(`🧊 TAG_DESC found at column index ${tagDescColumnIndex}, freezing ${freezeColumnCount} columns`); + } else { + // TAG_DESC가 없으면 TAG_NO까지만 고정 (일반적으로 첫 번째 컬럼) + const tagNoColumnIndex = visibleColumns.findIndex(col => col.key === 'TAG_NO'); + if (tagNoColumnIndex !== -1) { + freezeColumnCount = startCol + tagNoColumnIndex + 1; + console.log(`🧊 TAG_NO found at column index ${tagNoColumnIndex}, freezing ${freezeColumnCount} columns`); + } + } - // 🔧 편집 가능한 셀 정보 수집 (드롭다운용) - const dropdownConfigs: Array<{ - startRow: number; - col: number; - rowCount: number; - options: string[]; - editableRows: number[]; // 편집 가능한 행만 추적 - }> = []; + // 헤더 생성 + const headerStyle = new GC.Spread.Sheets.Style(); + headerStyle.backColor = "#3b82f6"; + headerStyle.foreColor = "#ffffff"; + headerStyle.font = "bold 12px Arial"; + headerStyle.hAlign = GC.Spread.Sheets.HorizontalAlign.center; + + visibleColumns.forEach((column, colIndex) => { + const targetCol = startCol + colIndex; + const cell = activeSheet.getCell(0, targetCol); + cell.value(column.label); + cell.locked(true); + activeSheet.setStyle(0, targetCol, headerStyle); + }); - visibleColumns.forEach((column, colIndex) => { - const targetCol = startCol + colIndex; - - // 드롭다운 설정을 위한 편집 가능한 행 찾기 - if (column.type === "LIST" && column.options) { - const editableRows: number[] = []; - tableData.forEach((rowData, rowIndex) => { - if (isFieldEditable(column.key, rowData)) { - editableRows.push(dataStartRow + rowIndex); - } - }); - - if (editableRows.length > 0) { - dropdownConfigs.push({ - startRow: dataStartRow, - col: targetCol, - rowCount: tableData.length, - options: column.options, - editableRows: editableRows - }); + // 🚀 데이터 배치 처리 준비 + const allValues: Array<{row: number, col: number, value: any}> = []; + const allStyles: Array<{row: number, col: number, isEditable: boolean}> = []; + + // 🔧 편집 가능한 셀 정보 수집 (드롭다운용) + const dropdownConfigs: Array<{ + startRow: number; + col: number; + rowCount: number; + options: string[]; + editableRows: number[]; // 편집 가능한 행만 추적 + }> = []; + + visibleColumns.forEach((column, colIndex) => { + const targetCol = startCol + colIndex; + + // 드롭다운 설정을 위한 편집 가능한 행 찾기 + if (column.type === "LIST" && column.options) { + const editableRows: number[] = []; + tableData.forEach((rowData, rowIndex) => { + if (isFieldEditable(column.key, rowData)) { // rowData 전달 + editableRows.push(dataStartRow + rowIndex); } - } + }); - tableData.forEach((rowData, rowIndex) => { - const targetRow = dataStartRow + rowIndex; - const cellEditable = isFieldEditable(column.key, rowData); - const value = rowData[column.key]; - - mappings.push({ - attId: column.key, - cellAddress: getCellAddress(targetRow, targetCol), - isEditable: cellEditable, - dataRowIndex: rowIndex - }); - - allValues.push({ - row: targetRow, + if (editableRows.length > 0) { + dropdownConfigs.push({ + startRow: dataStartRow, col: targetCol, - value: value ?? null - }); - - allStyles.push({ - row: targetRow, - col: targetCol, - isEditable: cellEditable + rowCount: tableData.length, + options: column.options, + editableRows: editableRows }); + } + } + + tableData.forEach((rowData, rowIndex) => { + const targetRow = dataStartRow + rowIndex; + const cellEditable = isFieldEditable(column.key, rowData); // rowData 전달 + const value = rowData[column.key]; + + mappings.push({ + attId: column.key, + cellAddress: getCellAddress(targetRow, targetCol), + isEditable: cellEditable, + dataRowIndex: rowIndex + }); + + allValues.push({ + row: targetRow, + col: targetCol, + value: value ?? null + }); + + allStyles.push({ + row: targetRow, + col: targetCol, + isEditable: cellEditable }); }); + }); - // 🚀 배치로 값과 스타일 설정 - setBatchValues(activeSheet, allValues); - setBatchStyles(activeSheet, allStyles); + // 🚀 배치로 값과 스타일 설정 + setBatchValues(activeSheet, allValues); + setBatchStyles(activeSheet, allStyles); - // 🎯 개선된 드롭다운 설정 (편집 가능한 셀에만) - dropdownConfigs.forEach(({ col, options, editableRows }) => { - try { - console.log(`🎯 Setting dropdown for column ${col}, editable rows: ${editableRows.length}`); - - const safeOptions = options - .filter(opt => opt !== null && opt !== undefined && opt !== '') - .map(opt => String(opt).trim()) - .filter(opt => opt.length > 0) - .slice(0, 20); + // 🎯 개선된 드롭다운 설정 (편집 가능한 셀에만) + dropdownConfigs.forEach(({ col, options, editableRows }) => { + try { + console.log(`🎯 Setting dropdown for column ${col}, editable rows: ${editableRows.length}`); + + const safeOptions = options + .filter(opt => opt !== null && opt !== undefined && opt !== '') + .map(opt => String(opt).trim()) + .filter(opt => opt.length > 0) + .slice(0, 20); - if (safeOptions.length === 0) return; + if (safeOptions.length === 0) return; - // 편집 가능한 행에만 드롭다운 적용 - editableRows.forEach(targetRow => { - try { - const comboBoxCellType = new GC.Spread.Sheets.CellTypes.ComboBox(); - comboBoxCellType.items(safeOptions); - comboBoxCellType.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); + // 편집 가능한 행에만 드롭다운 적용 + editableRows.forEach(targetRow => { + try { + const comboBoxCellType = new GC.Spread.Sheets.CellTypes.ComboBox(); + comboBoxCellType.items(safeOptions); + comboBoxCellType.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); - const cellValidator = GC.Spread.Sheets.DataValidation.createListValidator(safeOptions.join(',')); - cellValidator.showInputMessage(false); - cellValidator.showErrorMessage(false); + const cellValidator = GC.Spread.Sheets.DataValidation.createListValidator(safeOptions.join(',')); + cellValidator.showInputMessage(false); + cellValidator.showErrorMessage(false); - activeSheet.setCellType(targetRow, col, comboBoxCellType); - activeSheet.setDataValidator(targetRow, col, cellValidator); - - // 🚀 편집 권한 명시적 설정 - const cell = activeSheet.getCell(targetRow, col); - cell.locked(false); + activeSheet.setCellType(targetRow, col, comboBoxCellType); + activeSheet.setDataValidator(targetRow, col, cellValidator); + + // 🚀 편집 권한 명시적 설정 + const cell = activeSheet.getCell(targetRow, col); + cell.locked(false); - console.log(`✅ Dropdown applied to editable cell [${targetRow}, ${col}]`); - } catch (cellError) { - console.warn(`⚠️ Failed to apply dropdown to [${targetRow}, ${col}]:`, cellError); + console.log(`✅ Dropdown applied to editable cell [${targetRow}, ${col}]`); + } catch (cellError) { + console.warn(`⚠️ Failed to apply dropdown to [${targetRow}, ${col}]:`, cellError); + } + }); + } catch (error) { + console.error(`❌ Dropdown config failed for column ${col}:`, error); + } + }); + + // 🧊 틀고정 설정 + if (freezeColumnCount > 0) { + try { + activeSheet.frozenColumnCount(freezeColumnCount); + activeSheet.frozenRowCount(1); // 헤더 행도 고정 + + console.log(`🧊 Freeze applied: ${freezeColumnCount} columns, 1 row (header)`); + + // 🎨 고정된 컬럼에 특별한 스타일 추가 (선택사항) + for (let col = 0; col < freezeColumnCount; col++) { + for (let row = 0; row <= tableData.length; row++) { + try { + const currentStyle = activeSheet.getStyle(row, col) || new GC.Spread.Sheets.Style(); + + if (row === 0) { + // 헤더는 기존 스타일 유지 + continue; + } else { + // 데이터 셀에 고정 구분선 추가 + if (col === freezeColumnCount - 1) { + currentStyle.borderRight = new GC.Spread.Sheets.LineBorder("#2563eb", GC.Spread.Sheets.LineStyle.medium); + activeSheet.setStyle(row, col, currentStyle); + } + } + } catch (styleError) { + console.warn(`⚠️ Failed to apply freeze border style to [${row}, ${col}]:`, styleError); } - }); - } catch (error) { - console.error(`❌ Dropdown config failed for column ${col}:`, error); + } } - }); + } catch (freezeError) { + console.error('❌ Failed to apply freeze:', freezeError); + } + } - setOptimalColumnWidths(activeSheet, visibleColumns, startCol, tableData); - - console.log(`✅ Optimized GRD_LIST created:`); - console.log(` - Total mappings: ${mappings.length}`); - console.log(` - Editable cells: ${mappings.filter(m => m.isEditable).length}`); - console.log(` - Dropdown configs: ${dropdownConfigs.length}`); - - return mappings; - }, [tableData, columnsJSON, isFieldEditable, setBatchValues, setBatchStyles, ensureColumnCapacity, ensureRowCapacity, getCellAddress, setOptimalColumnWidths]); + setOptimalColumnWidths(activeSheet, visibleColumns, startCol, tableData); + + console.log(`✅ Optimized GRD_LIST created with freeze:`); + console.log(` - Total mappings: ${mappings.length}`); + console.log(` - Editable cells: ${mappings.filter(m => m.isEditable).length}`); + console.log(` - Dropdown configs: ${dropdownConfigs.length}`); + console.log(` - Frozen columns: ${freezeColumnCount}`); + + return mappings; +}, [tableData, columnsJSON, isFieldEditable, setBatchValues, setBatchStyles, ensureColumnCapacity, ensureRowCapacity, getCellAddress, setOptimalColumnWidths]); const setupSheetProtectionAndEvents = React.useCallback((activeSheet: any, mappings: CellMapping[]) => { console.log(`🛡️ Setting up protection and events for ${mappings.length} mappings`); @@ -840,7 +926,7 @@ export function TemplateViewDialog({ const dataRowIndex = exactMapping.dataRowIndex; if (dataRowIndex >= 0 && dataRowIndex < tableData.length) { const rowData = tableData[dataRowIndex]; - if (rowData?.shi === true) { + if (rowData?.shi === "OUT" || rowData?.shi === null ) { console.log(`🚫 Row ${dataRowIndex} is in SHI mode`); toast.warning(`Row ${dataRowIndex + 1}: ${exactMapping.attId} field is read-only (SHI mode)`); info.cancel = true; @@ -1092,20 +1178,20 @@ export function TemplateViewDialog({ toast.info("No changes to save"); return; } - + const errors = validateAllData(); if (errors.length > 0) { toast.error(`Cannot save: ${errors.length} validation errors found. Please fix them first.`); return; } - + try { setIsPending(true); const activeSheet = currentSpread.getActiveSheet(); - + if (templateType === 'SPREAD_ITEM' && selectedRow) { const dataToSave = { ...selectedRow }; - + cellMappings.forEach(mapping => { if (mapping.isEditable) { const cellPos = parseCellAddress(mapping.cellAddress); @@ -1115,86 +1201,134 @@ export function TemplateViewDialog({ } } }); - + dataToSave.TAG_NO = selectedRow.TAG_NO; - + const { success, message } = await updateFormDataInDB( formCode, contractItemId, dataToSave ); - + if (!success) { toast.error(message); return; } - + toast.success("Changes saved successfully!"); onUpdateSuccess?.(dataToSave); - + } else if ((templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && tableData.length > 0) { + console.log('🔍 Starting batch save process...'); + const updatedRows: GenericData[] = []; let saveCount = 0; - + let checkedCount = 0; + for (let i = 0; i < tableData.length; i++) { const originalRow = tableData[i]; const dataToSave = { ...originalRow }; let hasRowChanges = false; - + + console.log(`🔍 Processing row ${i} (TAG_NO: ${originalRow.TAG_NO})`); + cellMappings.forEach(mapping => { if (mapping.dataRowIndex === i && mapping.isEditable) { - const columnConfig = columnsJSON.find(col => col.key === mapping.attId); - const isColumnEditable = columnConfig?.shi !== true; - const isRowEditable = originalRow.shi !== true; - - if (isColumnEditable && isRowEditable) { + checkedCount++; + + // 🔧 isFieldEditable과 동일한 로직 사용 + const rowData = tableData[i]; + const fieldEditable = isFieldEditable(mapping.attId, rowData); + + console.log(` 📝 Field ${mapping.attId}: fieldEditable=${fieldEditable}, mapping.isEditable=${mapping.isEditable}`); + + if (fieldEditable) { const cellPos = parseCellAddress(mapping.cellAddress); if (cellPos) { const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); - if (cellValue !== originalRow[mapping.attId]) { + const originalValue = originalRow[mapping.attId]; + + // 🔧 개선된 값 비교 (타입 변환 및 null/undefined 처리) + const normalizedCellValue = cellValue === null || cellValue === undefined ? "" : String(cellValue).trim(); + const normalizedOriginalValue = originalValue === null || originalValue === undefined ? "" : String(originalValue).trim(); + + console.log(` 🔍 ${mapping.attId}: "${normalizedOriginalValue}" -> "${normalizedCellValue}"`); + + if (normalizedCellValue !== normalizedOriginalValue) { dataToSave[mapping.attId] = cellValue; hasRowChanges = true; + console.log(` ✅ Change detected for ${mapping.attId}`); } } } } }); - + if (hasRowChanges) { + console.log(`💾 Saving row ${i} with changes`); dataToSave.TAG_NO = originalRow.TAG_NO; - const { success } = await updateFormDataInDB( - formCode, - contractItemId, - dataToSave - ); - - if (success) { - updatedRows.push(dataToSave); - saveCount++; + + try { + const { success, message } = await updateFormDataInDB( + formCode, + contractItemId, + dataToSave + ); + + if (success) { + updatedRows.push(dataToSave); + saveCount++; + console.log(`✅ Row ${i} saved successfully`); + } else { + console.error(`❌ Failed to save row ${i}: ${message}`); + toast.error(`Failed to save row ${i + 1}: ${message}`); + updatedRows.push(originalRow); // 원본 데이터 유지 + } + } catch (error) { + console.error(`❌ Error saving row ${i}:`, error); + toast.error(`Error saving row ${i + 1}`); + updatedRows.push(originalRow); // 원본 데이터 유지 } } else { updatedRows.push(originalRow); + console.log(`ℹ️ No changes in row ${i}`); } } - + + console.log(`📊 Save summary: ${saveCount} saved, ${checkedCount} fields checked`); + if (saveCount > 0) { toast.success(`${saveCount} rows saved successfully!`); onUpdateSuccess?.(updatedRows); } else { - toast.info("No changes to save"); + console.warn(`⚠️ No changes detected despite hasChanges=${hasChanges}`); + toast.warning("No actual changes were found to save. Please check if the values were properly edited."); } } - + setHasChanges(false); setValidationErrors([]); - + } catch (error) { console.error("Error saving changes:", error); toast.error("An unexpected error occurred while saving"); } finally { setIsPending(false); } - }, [currentSpread, hasChanges, templateType, selectedRow, tableData, formCode, contractItemId, onUpdateSuccess, cellMappings, columnsJSON, validateAllData]); + }, [ + currentSpread, + hasChanges, + templateType, + selectedRow, + tableData, + formCode, + contractItemId, + onUpdateSuccess, + cellMappings, + columnsJSON, + validateAllData, + isFieldEditable // 🔧 의존성 추가 + ]); if (!isOpen) return null; diff --git a/components/form-data/spreadJS-dialog_designer.tsx b/components/form-data/spreadJS-dialog_designer.tsx new file mode 100644 index 00000000..71d8ec08 --- /dev/null +++ b/components/form-data/spreadJS-dialog_designer.tsx @@ -0,0 +1,1404 @@ +"use client"; + +import * as React from "react"; +import dynamic from "next/dynamic"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { GenericData } from "./export-excel-form"; +import * as GC from "@mescius/spread-sheets"; +import { toast } from "sonner"; +import { updateFormDataInDB } from "@/lib/forms/services"; +import { Loader, Save, AlertTriangle } from "lucide-react"; +import '@mescius/spread-sheets/styles/gc.spread.sheets.excel2016colorful.css'; +import { DataTableColumnJSON, ColumnType } from "./form-data-table-columns"; + +const Designer = dynamic( + () => import("@mescius/spread-sheets-designer-react").then(mod => mod.Designer), + { + ssr: false, + loading: () => ( +
+ + Loading Designer... +
+ ) + } +); + +// 라이센스 키 설정 (두 개의 환경변수 사용) +if (typeof window !== 'undefined') { + if (process.env.NEXT_PUBLIC_SPREAD_LICENSE) { + GC.Spread.Sheets.LicenseKey = process.env.NEXT_PUBLIC_SPREAD_LICENSE; + // ExcelIO가 사용 가능한 경우에만 설정 + if (typeof (window as any).ExcelIO !== 'undefined') { + (window as any).ExcelIO.LicenseKey = process.env.NEXT_PUBLIC_SPREAD_LICENSE; + } + } + + if (process.env.NEXT_PUBLIC_DESIGNER_LICENSE) { + // Designer 라이센스 키 설정 + if (GC.Spread.Sheets.Designer) { + GC.Spread.Sheets.Designer.LicenseKey = process.env.NEXT_PUBLIC_DESIGNER_LICENSE; + } + } +} + +interface TemplateItem { + TMPL_ID: string; + NAME: string; + TMPL_TYPE: string; + SPR_LST_SETUP: { + ACT_SHEET: string; + HIDN_SHEETS: Array; + CONTENT?: string; + DATA_SHEETS: Array<{ + SHEET_NAME: string; + REG_TYPE_ID: string; + MAP_CELL_ATT: Array<{ + ATT_ID: string; + IN: string; + }>; + }>; + }; + GRD_LST_SETUP: { + REG_TYPE_ID: string; + SPR_ITM_IDS: Array; + ATTS: Array<{}>; + }; + SPR_ITM_LST_SETUP: { + ACT_SHEET: string; + HIDN_SHEETS: Array; + CONTENT?: string; + DATA_SHEETS: Array<{ + SHEET_NAME: string; + REG_TYPE_ID: string; + MAP_CELL_ATT: Array<{ + ATT_ID: string; + IN: string; + }>; + }>; + }; +} + +interface ValidationError { + cellAddress: string; + attId: string; + value: any; + expectedType: ColumnType; + message: string; +} + +interface CellMapping { + attId: string; + cellAddress: string; + isEditable: boolean; + dataRowIndex?: number; +} + +interface TemplateViewDialogProps { + isOpen: boolean; + onClose: () => void; + templateData: TemplateItem[] | any; + selectedRow?: GenericData; + tableData?: GenericData[]; + formCode: string; + columnsJSON: DataTableColumnJSON[] + contractItemId: number; + editableFieldsMap?: Map; + onUpdateSuccess?: (updatedValues: Record | GenericData[]) => void; +} + +// 🚀 로딩 프로그레스 컴포넌트 +interface LoadingProgressProps { + phase: string; + progress: number; + total: number; + isVisible: boolean; +} + +const LoadingProgress: React.FC = ({ phase, progress, total, isVisible }) => { + const percentage = total > 0 ? Math.round((progress / total) * 100) : 0; + + if (!isVisible) return null; + + return ( +
+
+
+ + Loading Template +
+ +
+
{phase}
+
+
+
+
+ {progress.toLocaleString()} / {total.toLocaleString()} ({percentage}%) +
+
+
+
+ ); +}; + +export function TemplateViewDialog({ + isOpen, + onClose, + templateData, + selectedRow, + tableData = [], + formCode, + contractItemId, + columnsJSON, + editableFieldsMap = new Map(), + onUpdateSuccess +}: TemplateViewDialogProps) { + const [hostStyle, setHostStyle] = React.useState({ + width: '100%', + height: '100%' + }); + + const [isPending, setIsPending] = React.useState(false); + const [hasChanges, setHasChanges] = React.useState(false); + const [currentSpread, setCurrentSpread] = React.useState(null); + const [cellMappings, setCellMappings] = React.useState([]); + const [isClient, setIsClient] = React.useState(false); + const [templateType, setTemplateType] = React.useState<'SPREAD_LIST' | 'SPREAD_ITEM' | 'GRD_LIST' | null>(null); + const [validationErrors, setValidationErrors] = React.useState([]); + const [selectedTemplateId, setSelectedTemplateId] = React.useState(""); + const [availableTemplates, setAvailableTemplates] = React.useState([]); + + // 🆕 로딩 상태 추가 + const [loadingProgress, setLoadingProgress] = React.useState<{ + phase: string; + progress: number; + total: number; + } | null>(null); + const [isInitializing, setIsInitializing] = React.useState(false); + + // 🔄 진행상황 업데이트 함수 + const updateProgress = React.useCallback((phase: string, progress: number, total: number) => { + setLoadingProgress({ phase, progress, total }); + }, []); + + const determineTemplateType = React.useCallback((template: TemplateItem): 'SPREAD_LIST' | 'SPREAD_ITEM' | 'GRD_LIST' | null => { + if (template.TMPL_TYPE === "SPREAD_LIST" && (template.SPR_LST_SETUP?.CONTENT || template.SPR_ITM_LST_SETUP?.CONTENT)) { + return 'SPREAD_LIST'; + } + if (template.TMPL_TYPE === "SPREAD_ITEM" && (template.SPR_LST_SETUP?.CONTENT || template.SPR_ITM_LST_SETUP?.CONTENT)) { + return 'SPREAD_ITEM'; + } + if (template.GRD_LST_SETUP && columnsJSON.length > 0) { + return 'GRD_LIST'; + } + return null; + }, [columnsJSON]); + + const isValidTemplate = React.useCallback((template: TemplateItem): boolean => { + return determineTemplateType(template) !== null; + }, [determineTemplateType]); + + React.useEffect(() => { + setIsClient(true); + }, []); + + React.useEffect(() => { + if (!templateData) return; + + let templates: TemplateItem[]; + if (Array.isArray(templateData)) { + templates = templateData as TemplateItem[]; + } else { + templates = [templateData as TemplateItem]; + } + + const validTemplates = templates.filter(isValidTemplate); + setAvailableTemplates(validTemplates); + + if (validTemplates.length > 0 && !selectedTemplateId) { + const firstTemplate = validTemplates[0]; + const templateTypeToSet = determineTemplateType(firstTemplate); + setSelectedTemplateId(firstTemplate.TMPL_ID); + setTemplateType(templateTypeToSet); + } + }, [templateData, selectedTemplateId, isValidTemplate, determineTemplateType]); + + const handleTemplateChange = (templateId: string) => { + const template = availableTemplates.find(t => t.TMPL_ID === templateId); + if (template) { + const templateTypeToSet = determineTemplateType(template); + setSelectedTemplateId(templateId); + setTemplateType(templateTypeToSet); + setHasChanges(false); + setValidationErrors([]); + + if (currentSpread && template) { + initSpread(currentSpread, template); + } + } + }; + + const selectedTemplate = React.useMemo(() => { + return availableTemplates.find(t => t.TMPL_ID === selectedTemplateId); + }, [availableTemplates, selectedTemplateId]); + + const editableFields = React.useMemo(() => { + // SPREAD_ITEM의 경우에만 전역 editableFields 사용 + if (templateType === 'SPREAD_ITEM' && selectedRow?.TAG_NO) { + if (!editableFieldsMap.has(selectedRow.TAG_NO)) { + return []; + } + return editableFieldsMap.get(selectedRow.TAG_NO) || []; + } + + // SPREAD_LIST나 GRD_LIST의 경우 전역 editableFields는 사용하지 않음 + return []; + }, [templateType, selectedRow?.TAG_NO, editableFieldsMap]); + + +const isFieldEditable = React.useCallback((attId: string, rowData?: GenericData) => { + const columnConfig = columnsJSON.find(col => col.key === attId); + if (columnConfig?.shi === "OUT" || columnConfig?.shi === null) { + return false; + } + + if (attId === "TAG_NO" || attId === "TAG_DESC" || attId === "status") { + return false; + } + + if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') { + // 각 행의 TAG_NO를 기준으로 편집 가능 여부 판단 + if (!rowData?.TAG_NO || !editableFieldsMap.has(rowData.TAG_NO)) { + return false; + } + + const rowEditableFields = editableFieldsMap.get(rowData.TAG_NO) || []; + if (!rowEditableFields.includes(attId)) { + return false; + } + + if (rowData && (rowData.shi === "OUT" || rowData.shi === null)) { + return false; + } + return true; + } + + // SPREAD_ITEM의 경우 기존 로직 유지 + if (templateType === 'SPREAD_ITEM') { + return editableFields.includes(attId); + } + + return true; +}, [templateType, columnsJSON, editableFieldsMap]); // editableFields 의존성 제거 + +const editableFieldsCount = React.useMemo(() => { + if (templateType === 'SPREAD_ITEM') { + // SPREAD_ITEM의 경우 기존 로직 유지 + return cellMappings.filter(m => m.isEditable).length; + } + + if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') { + // 각 행별로 편집 가능한 필드 수를 계산 + let totalEditableCount = 0; + + tableData.forEach((rowData, rowIndex) => { + cellMappings.forEach(mapping => { + if (mapping.dataRowIndex === rowIndex) { + if (isFieldEditable(mapping.attId, rowData)) { + totalEditableCount++; + } + } + }); + }); + + return totalEditableCount; + } + + return cellMappings.filter(m => m.isEditable).length; +}, [cellMappings, templateType, tableData, isFieldEditable]); + + // 🚀 배치 처리 함수들 + const setBatchValues = React.useCallback(( + activeSheet: any, + valuesToSet: Array<{row: number, col: number, value: any}> + ) => { + console.log(`🚀 Setting ${valuesToSet.length} values in batch`); + + const columnGroups = new Map>(); + + valuesToSet.forEach(({row, col, value}) => { + if (!columnGroups.has(col)) { + columnGroups.set(col, []); + } + columnGroups.get(col)!.push({row, value}); + }); + + columnGroups.forEach((values, col) => { + values.sort((a, b) => a.row - b.row); + + let start = 0; + while (start < values.length) { + let end = start; + while (end + 1 < values.length && values[end + 1].row === values[end].row + 1) { + end++; + } + + const rangeValues = values.slice(start, end + 1).map(v => v.value); + const startRow = values[start].row; + + try { + if (rangeValues.length === 1) { + activeSheet.setValue(startRow, col, rangeValues[0]); + } else { + const dataArray = rangeValues.map(v => [v]); + activeSheet.setArray(startRow, col, dataArray); + } + } catch (error) { + for (let i = start; i <= end; i++) { + try { + activeSheet.setValue(values[i].row, col, values[i].value); + } catch (cellError) { + console.warn(`⚠️ Individual value setting failed [${values[i].row}, ${col}]:`, cellError); + } + } + } + + start = end + 1; + } + }); + }, []); + + const createCellStyle = React.useCallback((isEditable: boolean) => { + const style = new GC.Spread.Sheets.Style(); + if (isEditable) { + style.backColor = "#bbf7d0"; + } else { + style.backColor = "#e5e7eb"; + style.foreColor = "#4b5563"; + } + return style; + }, []); + + const setBatchStyles = React.useCallback(( + activeSheet: any, + stylesToSet: Array<{row: number, col: number, isEditable: boolean}> + ) => { + console.log(`🎨 Setting ${stylesToSet.length} styles in batch`); + + const editableStyle = createCellStyle(true); + const readonlyStyle = createCellStyle(false); + + // 🔧 개별 셀별로 스타일과 잠금 상태 설정 (편집 권한 보장) + stylesToSet.forEach(({row, col, isEditable}) => { + try { + const cell = activeSheet.getCell(row, col); + const style = isEditable ? editableStyle : readonlyStyle; + + activeSheet.setStyle(row, col, style); + cell.locked(!isEditable); // 편집 가능하면 잠금 해제 + + // 🆕 편집 가능한 셀에 기본 텍스트 에디터 설정 + if (isEditable) { + const textCellType = new GC.Spread.Sheets.CellTypes.Text(); + activeSheet.setCellType(row, col, textCellType); + } + } catch (error) { + console.warn(`⚠️ Failed to set style for cell [${row}, ${col}]:`, error); + } + }); + }, [createCellStyle]); + + const parseCellAddress = (address: string): { row: number, col: number } | null => { + if (!address || address.trim() === "") return null; + + const match = address.match(/^([A-Z]+)(\d+)$/); + if (!match) return null; + + const [, colStr, rowStr] = match; + + let col = 0; + for (let i = 0; i < colStr.length; i++) { + col = col * 26 + (colStr.charCodeAt(i) - 65 + 1); + } + col -= 1; + + const row = parseInt(rowStr) - 1; + return { row, col }; + }; + + const getCellAddress = (row: number, col: number): string => { + let colStr = ''; + let colNum = col; + while (colNum >= 0) { + colStr = String.fromCharCode((colNum % 26) + 65) + colStr; + colNum = Math.floor(colNum / 26) - 1; + } + return colStr + (row + 1); + }; + + const validateCellValue = (value: any, columnType: ColumnType, options?: string[]): string | null => { + if (value === undefined || value === null || value === "") { + return null; + } + + switch (columnType) { + case "NUMBER": + if (isNaN(Number(value))) { + return "Value must be a valid number"; + } + break; + case "LIST": + if (options && !options.includes(String(value))) { + return `Value must be one of: ${options.join(", ")}`; + } + break; + case "STRING": + break; + default: + break; + } + + return null; + }; + + const validateAllData = React.useCallback(() => { + if (!currentSpread || !selectedTemplate) return []; + + const activeSheet = currentSpread.getActiveSheet(); + const errors: ValidationError[] = []; + + cellMappings.forEach(mapping => { + const columnConfig = columnsJSON.find(col => col.key === mapping.attId); + if (!columnConfig) return; + + const cellPos = parseCellAddress(mapping.cellAddress); + if (!cellPos) return; + + const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); + const errorMessage = validateCellValue(cellValue, columnConfig.type, columnConfig.options); + + if (errorMessage) { + errors.push({ + cellAddress: mapping.cellAddress, + attId: mapping.attId, + value: cellValue, + expectedType: columnConfig.type, + message: errorMessage + }); + } + }); + + setValidationErrors(errors); + return errors; + }, [currentSpread, selectedTemplate, cellMappings, columnsJSON]); + + + + const setupOptimizedListValidation = React.useCallback((activeSheet: any, cellPos: { row: number, col: number }, options: string[], rowCount: number) => { + try { + console.log(`🎯 Setting up dropdown for ${rowCount} rows with options:`, options); + + const safeOptions = options + .filter(opt => opt !== null && opt !== undefined && opt !== '') + .map(opt => String(opt).trim()) + .filter(opt => opt.length > 0) + .filter((opt, index, arr) => arr.indexOf(opt) === index) + .slice(0, 20); + + if (safeOptions.length === 0) { + console.warn(`⚠️ No valid options found, skipping`); + return; + } + + const optionsString = safeOptions.join(','); + + for (let i = 0; i < rowCount; i++) { + try { + const targetRow = cellPos.row + i; + + // 🔧 각 셀마다 새로운 ComboBox 인스턴스 생성 + const comboBoxCellType = new GC.Spread.Sheets.CellTypes.ComboBox(); + comboBoxCellType.items(safeOptions); + comboBoxCellType.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); + + // 🔧 DataValidation 설정 + const cellValidator = GC.Spread.Sheets.DataValidation.createListValidator(optionsString); + cellValidator.showInputMessage(false); + cellValidator.showErrorMessage(false); + + // ComboBox와 Validator 적용 + activeSheet.setCellType(targetRow, cellPos.col, comboBoxCellType); + activeSheet.setDataValidator(targetRow, cellPos.col, cellValidator); + + // 🚀 중요: 셀 잠금 해제 및 편집 가능 설정 + const cell = activeSheet.getCell(targetRow, cellPos.col); + cell.locked(false); + + console.log(`✅ Dropdown applied to [${targetRow}, ${cellPos.col}] with ${safeOptions.length} options`); + + } catch (cellError) { + console.warn(`⚠️ Failed to apply dropdown to row ${cellPos.row + i}:`, cellError); + } + } + + console.log(`✅ Dropdown setup completed for ${rowCount} cells`); + + } catch (error) { + console.error('❌ Dropdown setup failed:', error); + } + }, []); + + const getSafeActiveSheet = React.useCallback((spread: any, functionName: string = 'unknown') => { + if (!spread) return null; + + try { + let activeSheet = spread.getActiveSheet(); + if (!activeSheet) { + const sheetCount = spread.getSheetCount(); + if (sheetCount > 0) { + activeSheet = spread.getSheet(0); + if (activeSheet) { + spread.setActiveSheetIndex(0); + } + } + } + return activeSheet; + } catch (error) { + console.error(`❌ Error getting activeSheet in ${functionName}:`, error); + return null; + } + }, []); + + const ensureRowCapacity = React.useCallback((activeSheet: any, requiredRowCount: number) => { + try { + if (!activeSheet) return false; + + const currentRowCount = activeSheet.getRowCount(); + if (requiredRowCount > currentRowCount) { + const newRowCount = requiredRowCount + 10; + activeSheet.setRowCount(newRowCount); + } + return true; + } catch (error) { + console.error('❌ Error in ensureRowCapacity:', error); + return false; + } + }, []); + + const ensureColumnCapacity = React.useCallback((activeSheet: any, requiredColumnCount: number) => { + try { + if (!activeSheet) return false; + + const currentColumnCount = activeSheet.getColumnCount(); + if (requiredColumnCount > currentColumnCount) { + const newColumnCount = requiredColumnCount + 10; + activeSheet.setColumnCount(newColumnCount); + } + return true; + } catch (error) { + console.error('❌ Error in ensureColumnCapacity:', error); + return false; + } + }, []); + + const setOptimalColumnWidths = React.useCallback((activeSheet: any, columns: any[], startCol: number, tableData: any[]) => { + columns.forEach((column, colIndex) => { + const targetCol = startCol + colIndex; + const optimalWidth = column.type === 'NUMBER' ? 100 : column.type === 'STRING' ? 150 : 120; + activeSheet.setColumnWidth(targetCol, optimalWidth); + }); + }, []); + + // 🚀 최적화된 GRD_LIST 생성 + const createGrdListTableOptimized = React.useCallback((activeSheet: any, template: TemplateItem) => { + console.log('🚀 Creating optimized GRD_LIST table'); + + const visibleColumns = columnsJSON + .filter(col => col.hidden !== true) + .sort((a, b) => (a.seq ?? 999999) - (b.seq ?? 999999)); + + if (visibleColumns.length === 0) return []; + + const startCol = 1; + const dataStartRow = 1; + const mappings: CellMapping[] = []; + + ensureColumnCapacity(activeSheet, startCol + visibleColumns.length); + ensureRowCapacity(activeSheet, dataStartRow + tableData.length); + + // 헤더 생성 + const headerStyle = new GC.Spread.Sheets.Style(); + headerStyle.backColor = "#3b82f6"; + headerStyle.foreColor = "#ffffff"; + headerStyle.font = "bold 12px Arial"; + headerStyle.hAlign = GC.Spread.Sheets.HorizontalAlign.center; + + visibleColumns.forEach((column, colIndex) => { + const targetCol = startCol + colIndex; + const cell = activeSheet.getCell(0, targetCol); + cell.value(column.label); + cell.locked(true); + activeSheet.setStyle(0, targetCol, headerStyle); + }); + + // 🚀 데이터 배치 처리 준비 + const allValues: Array<{row: number, col: number, value: any}> = []; + const allStyles: Array<{row: number, col: number, isEditable: boolean}> = []; + + // 🔧 편집 가능한 셀 정보 수집 (드롭다운용) + const dropdownConfigs: Array<{ + startRow: number; + col: number; + rowCount: number; + options: string[]; + editableRows: number[]; // 편집 가능한 행만 추적 + }> = []; + + visibleColumns.forEach((column, colIndex) => { + const targetCol = startCol + colIndex; + + // 드롭다운 설정을 위한 편집 가능한 행 찾기 + if (column.type === "LIST" && column.options) { + const editableRows: number[] = []; + tableData.forEach((rowData, rowIndex) => { + if (isFieldEditable(column.key, rowData)) { // rowData 전달 + editableRows.push(dataStartRow + rowIndex); + } + }); + + if (editableRows.length > 0) { + dropdownConfigs.push({ + startRow: dataStartRow, + col: targetCol, + rowCount: tableData.length, + options: column.options, + editableRows: editableRows + }); + } + } + + tableData.forEach((rowData, rowIndex) => { + const targetRow = dataStartRow + rowIndex; + const cellEditable = isFieldEditable(column.key, rowData); // rowData 전달 + const value = rowData[column.key]; + + mappings.push({ + attId: column.key, + cellAddress: getCellAddress(targetRow, targetCol), + isEditable: cellEditable, + dataRowIndex: rowIndex + }); + + allValues.push({ + row: targetRow, + col: targetCol, + value: value ?? null + }); + + allStyles.push({ + row: targetRow, + col: targetCol, + isEditable: cellEditable + }); + }); + }); + + // 🚀 배치로 값과 스타일 설정 + setBatchValues(activeSheet, allValues); + setBatchStyles(activeSheet, allStyles); + + // 🎯 개선된 드롭다운 설정 (편집 가능한 셀에만) + dropdownConfigs.forEach(({ col, options, editableRows }) => { + try { + console.log(`🎯 Setting dropdown for column ${col}, editable rows: ${editableRows.length}`); + + const safeOptions = options + .filter(opt => opt !== null && opt !== undefined && opt !== '') + .map(opt => String(opt).trim()) + .filter(opt => opt.length > 0) + .slice(0, 20); + + if (safeOptions.length === 0) return; + + // 편집 가능한 행에만 드롭다운 적용 + editableRows.forEach(targetRow => { + try { + const comboBoxCellType = new GC.Spread.Sheets.CellTypes.ComboBox(); + comboBoxCellType.items(safeOptions); + comboBoxCellType.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); + + const cellValidator = GC.Spread.Sheets.DataValidation.createListValidator(safeOptions.join(',')); + cellValidator.showInputMessage(false); + cellValidator.showErrorMessage(false); + + activeSheet.setCellType(targetRow, col, comboBoxCellType); + activeSheet.setDataValidator(targetRow, col, cellValidator); + + // 🚀 편집 권한 명시적 설정 + const cell = activeSheet.getCell(targetRow, col); + cell.locked(false); + + console.log(`✅ Dropdown applied to editable cell [${targetRow}, ${col}]`); + } catch (cellError) { + console.warn(`⚠️ Failed to apply dropdown to [${targetRow}, ${col}]:`, cellError); + } + }); + } catch (error) { + console.error(`❌ Dropdown config failed for column ${col}:`, error); + } + }); + + setOptimalColumnWidths(activeSheet, visibleColumns, startCol, tableData); + + console.log(`✅ Optimized GRD_LIST created:`); + console.log(` - Total mappings: ${mappings.length}`); + console.log(` - Editable cells: ${mappings.filter(m => m.isEditable).length}`); + console.log(` - Dropdown configs: ${dropdownConfigs.length}`); + + return mappings; + }, [tableData, columnsJSON, isFieldEditable, setBatchValues, setBatchStyles, ensureColumnCapacity, ensureRowCapacity, getCellAddress, setOptimalColumnWidths]); + + const setupSheetProtectionAndEvents = React.useCallback((activeSheet: any, mappings: CellMapping[]) => { + console.log(`🛡️ Setting up protection and events for ${mappings.length} mappings`); + + // 🔧 시트 보호 완전 해제 후 편집 권한 설정 + activeSheet.options.isProtected = false; + + // 🔧 편집 가능한 셀들을 위한 강화된 설정 + mappings.forEach((mapping) => { + const cellPos = parseCellAddress(mapping.cellAddress); + if (!cellPos) return; + + try { + const cell = activeSheet.getCell(cellPos.row, cellPos.col); + const columnConfig = columnsJSON.find(col => col.key === mapping.attId); + + if (mapping.isEditable) { + // 🚀 편집 가능한 셀 설정 강화 + cell.locked(false); + + if (columnConfig?.type === "LIST" && columnConfig.options) { + // LIST 타입: 새 ComboBox 인스턴스 생성 + const comboBox = new GC.Spread.Sheets.CellTypes.ComboBox(); + comboBox.items(columnConfig.options); + comboBox.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); + activeSheet.setCellType(cellPos.row, cellPos.col, comboBox); + + // DataValidation도 추가 + const validator = GC.Spread.Sheets.DataValidation.createListValidator(columnConfig.options.join(',')); + activeSheet.setDataValidator(cellPos.row, cellPos.col, validator); + } else if (columnConfig?.type === "NUMBER") { + // NUMBER 타입: 숫자 입력 허용 + const textCellType = new GC.Spread.Sheets.CellTypes.Text(); + activeSheet.setCellType(cellPos.row, cellPos.col, textCellType); + + // 숫자 validation 추가 (에러 메시지 없이) + const numberValidator = GC.Spread.Sheets.DataValidation.createNumberValidator( + GC.Spread.Sheets.ConditionalFormatting.ComparisonOperators.between, + -999999999, 999999999, true + ); + numberValidator.showInputMessage(false); + numberValidator.showErrorMessage(false); + activeSheet.setDataValidator(cellPos.row, cellPos.col, numberValidator); + } else { + // 기본 TEXT 타입: 자유 텍스트 입력 + const textCellType = new GC.Spread.Sheets.CellTypes.Text(); + activeSheet.setCellType(cellPos.row, cellPos.col, textCellType); + } + + // 편집 가능 스타일 재적용 + const editableStyle = createCellStyle(true); + activeSheet.setStyle(cellPos.row, cellPos.col, editableStyle); + + console.log(`🔓 Cell [${cellPos.row}, ${cellPos.col}] ${mapping.attId} set as EDITABLE`); + } else { + // 읽기 전용 셀 + cell.locked(true); + const readonlyStyle = createCellStyle(false); + activeSheet.setStyle(cellPos.row, cellPos.col, readonlyStyle); + } + } catch (error) { + console.error(`❌ Error processing cell ${mapping.cellAddress}:`, error); + } + }); + + // 🛡️ 시트 보호 재설정 (편집 허용 모드로) + activeSheet.options.isProtected = false; + activeSheet.options.protectionOptions = { + allowSelectLockedCells: true, + allowSelectUnlockedCells: true, + allowSort: false, + allowFilter: false, + allowEditObjects: true, // ✅ 편집 객체 허용 + allowResizeRows: false, + allowResizeColumns: false, + allowFormatCells: false, + allowInsertRows: false, + allowInsertColumns: false, + allowDeleteRows: false, + allowDeleteColumns: false + }; + + // 🎯 변경 감지 이벤트 + const changeEvents = [ + GC.Spread.Sheets.Events.CellChanged, + GC.Spread.Sheets.Events.ValueChanged, + GC.Spread.Sheets.Events.ClipboardPasted + ]; + + changeEvents.forEach(eventType => { + activeSheet.bind(eventType, () => { + console.log(`📝 ${eventType} detected`); + setHasChanges(true); + }); + }); + + // 🚫 편집 시작 권한 확인 + activeSheet.bind(GC.Spread.Sheets.Events.EditStarting, (event: any, info: any) => { + console.log(`🎯 EditStarting: Row ${info.row}, Col ${info.col}`); + + const exactMapping = mappings.find(m => { + const cellPos = parseCellAddress(m.cellAddress); + return cellPos && cellPos.row === info.row && cellPos.col === info.col; + }); + + if (!exactMapping) { + console.log(`ℹ️ No mapping found for [${info.row}, ${info.col}] - allowing edit`); + return; // 매핑이 없으면 허용 + } + + console.log(`📋 Found mapping: ${exactMapping.attId}, isEditable: ${exactMapping.isEditable}`); + + if (!exactMapping.isEditable) { + console.log(`🚫 Field ${exactMapping.attId} is not editable`); + toast.warning(`${exactMapping.attId} field is read-only`); + info.cancel = true; + return; + } + + // SPREAD_LIST/GRD_LIST 개별 행 SHI 확인 + if ((templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && exactMapping.dataRowIndex !== undefined) { + const dataRowIndex = exactMapping.dataRowIndex; + if (dataRowIndex >= 0 && dataRowIndex < tableData.length) { + const rowData = tableData[dataRowIndex]; + if (rowData?.shi === "OUT" || rowData?.shi === null ) { + console.log(`🚫 Row ${dataRowIndex} is in SHI mode`); + toast.warning(`Row ${dataRowIndex + 1}: ${exactMapping.attId} field is read-only (SHI mode)`); + info.cancel = true; + return; + } + } + } + + console.log(`✅ Edit allowed for ${exactMapping.attId}`); + }); + + // ✅ 편집 완료 검증 + activeSheet.bind(GC.Spread.Sheets.Events.EditEnded, (event: any, info: any) => { + console.log(`🏁 EditEnded: Row ${info.row}, Col ${info.col}, New value: ${activeSheet.getValue(info.row, info.col)}`); + + const exactMapping = mappings.find(m => { + const cellPos = parseCellAddress(m.cellAddress); + return cellPos && cellPos.row === info.row && cellPos.col === info.col; + }); + + if (!exactMapping) return; + + const columnConfig = columnsJSON.find(col => col.key === exactMapping.attId); + if (columnConfig) { + const cellValue = activeSheet.getValue(info.row, info.col); + const errorMessage = validateCellValue(cellValue, columnConfig.type, columnConfig.options); + const cell = activeSheet.getCell(info.row, info.col); + + if (errorMessage) { + // 🚨 에러 스타일 적용 + const errorStyle = new GC.Spread.Sheets.Style(); + errorStyle.backColor = "#fef2f2"; + errorStyle.foreColor = "#dc2626"; + errorStyle.borderLeft = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); + errorStyle.borderRight = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); + errorStyle.borderTop = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); + errorStyle.borderBottom = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); + + activeSheet.setStyle(info.row, info.col, errorStyle); + cell.locked(!exactMapping.isEditable); // 편집 가능 상태 유지 + toast.warning(`Invalid value in ${exactMapping.attId}: ${errorMessage}`, { duration: 5000 }); + } else { + // ✅ 정상 스타일 복원 + const normalStyle = createCellStyle(exactMapping.isEditable); + activeSheet.setStyle(info.row, info.col, normalStyle); + cell.locked(!exactMapping.isEditable); + } + } + + setHasChanges(true); + }); + + console.log(`🛡️ Protection configured. Editable cells: ${mappings.filter(m => m.isEditable).length}`); + }, [templateType, tableData, createCellStyle, validateCellValue, columnsJSON]); + + // 🚀 최적화된 initSpread - Designer용으로 수정 + const initSpread = React.useCallback(async (spread: any, template?: TemplateItem) => { + const workingTemplate = template || selectedTemplate; + if (!spread || !workingTemplate) { + console.error('❌ Invalid spread or template'); + return; + } + + try { + console.log('🚀 Starting optimized Designer initialization...'); + setIsInitializing(true); + updateProgress('Initializing...', 0, 100); + + setCurrentSpread(spread); + setHasChanges(false); + setValidationErrors([]); + + // 🚀 핵심 최적화: 모든 렌더링과 이벤트 중단 + spread.suspendPaint(); + spread.suspendEvent(); + spread.suspendCalcService(); + + updateProgress('Setting up workspace...', 10, 100); + + try { + let activeSheet = getSafeActiveSheet(spread, 'initSpread'); + if (!activeSheet) { + throw new Error('Failed to get initial activeSheet'); + } + + activeSheet.options.isProtected = false; + let mappings: CellMapping[] = []; + + if (templateType === 'GRD_LIST') { + updateProgress('Creating dynamic table...', 20, 100); + + spread.clearSheets(); + spread.addSheet(0); + const sheet = spread.getSheet(0); + sheet.name('Data'); + spread.setActiveSheet('Data'); + + updateProgress('Processing table data...', 50, 100); + mappings = createGrdListTableOptimized(sheet, workingTemplate); + + } else { + updateProgress('Loading template structure...', 20, 100); + + let contentJson = workingTemplate.SPR_LST_SETUP?.CONTENT || workingTemplate.SPR_ITM_LST_SETUP?.CONTENT; + let dataSheets = workingTemplate.SPR_LST_SETUP?.DATA_SHEETS || workingTemplate.SPR_ITM_LST_SETUP?.DATA_SHEETS; + + if (!contentJson || !dataSheets) { + throw new Error(`No template content found for ${workingTemplate.NAME}`); + } + + const jsonData = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson; + + updateProgress('Loading template layout...', 40, 100); + spread.fromJSON(jsonData); + + activeSheet = getSafeActiveSheet(spread, 'after-fromJSON'); + if (!activeSheet) { + throw new Error('ActiveSheet became null after loading template'); + } + + activeSheet.options.isProtected = false; + + if (templateType === 'SPREAD_LIST' && tableData.length > 0) { + updateProgress('Processing data rows...', 60, 100); + + dataSheets.forEach(dataSheet => { + if (dataSheet.MAP_CELL_ATT && dataSheet.MAP_CELL_ATT.length > 0) { + dataSheet.MAP_CELL_ATT.forEach((mapping: any) => { + const { ATT_ID, IN } = mapping; + if (!ATT_ID || !IN || IN.trim() === "") return; + + const cellPos = parseCellAddress(IN); + if (!cellPos) return; + + const requiredRows = cellPos.row + tableData.length; + if (!ensureRowCapacity(activeSheet, requiredRows)) return; + + // 🚀 배치 데이터 준비 + const valuesToSet: Array<{row: number, col: number, value: any}> = []; + const stylesToSet: Array<{row: number, col: number, isEditable: boolean}> = []; + + tableData.forEach((rowData, index) => { + const targetRow = cellPos.row + index; + const cellEditable = isFieldEditable(ATT_ID, rowData); + const value = rowData[ATT_ID]; + + mappings.push({ + attId: ATT_ID, + cellAddress: getCellAddress(targetRow, cellPos.col), + isEditable: cellEditable, + dataRowIndex: index + }); + + valuesToSet.push({ + row: targetRow, + col: cellPos.col, + value: value ?? null + }); + + stylesToSet.push({ + row: targetRow, + col: cellPos.col, + isEditable: cellEditable + }); + }); + + // 🚀 배치 처리 + setBatchValues(activeSheet, valuesToSet); + setBatchStyles(activeSheet, stylesToSet); + + // 드롭다운 설정 + const columnConfig = columnsJSON.find(col => col.key === ATT_ID); + if (columnConfig?.type === "LIST" && columnConfig.options) { + const hasEditableRows = tableData.some((rowData) => isFieldEditable(ATT_ID, rowData)); + if (hasEditableRows) { + setupOptimizedListValidation(activeSheet, cellPos, columnConfig.options, tableData.length); + } + } + }); + } + }); + + } else if (templateType === 'SPREAD_ITEM' && selectedRow) { + updateProgress('Setting up form fields...', 60, 100); + + dataSheets.forEach(dataSheet => { + dataSheet.MAP_CELL_ATT?.forEach((mapping: any) => { + const { ATT_ID, IN } = mapping; + const cellPos = parseCellAddress(IN); + if (cellPos) { + const isEditable = isFieldEditable(ATT_ID); + const value = selectedRow[ATT_ID]; + + mappings.push({ + attId: ATT_ID, + cellAddress: IN, + isEditable: isEditable, + dataRowIndex: 0 + }); + + const cell = activeSheet.getCell(cellPos.row, cellPos.col); + cell.value(value ?? null); + + const style = createCellStyle(isEditable); + activeSheet.setStyle(cellPos.row, cellPos.col, style); + + const columnConfig = columnsJSON.find(col => col.key === ATT_ID); + if (columnConfig?.type === "LIST" && columnConfig.options && isEditable) { + setupOptimizedListValidation(activeSheet, cellPos, columnConfig.options, 1); + } + } + }); + }); + } + } + + updateProgress('Configuring interactions...', 90, 100); + setCellMappings(mappings); + + const finalActiveSheet = getSafeActiveSheet(spread, 'setupEvents'); + if (finalActiveSheet) { + setupSheetProtectionAndEvents(finalActiveSheet, mappings); + } + + updateProgress('Finalizing...', 100, 100); + console.log(`✅ Optimized Designer initialization completed with ${mappings.length} mappings`); + + } finally { + // 🚀 올바른 순서로 재개 + spread.resumeCalcService(); + spread.resumeEvent(); + spread.resumePaint(); + } + + } catch (error) { + console.error('❌ Error in optimized Designer initialization:', error); + if (spread?.resumeCalcService) spread.resumeCalcService(); + if (spread?.resumeEvent) spread.resumeEvent(); + if (spread?.resumePaint) spread.resumePaint(); + toast.error(`Template loading failed: ${error.message}`); + } finally { + setIsInitializing(false); + setLoadingProgress(null); + } + }, [selectedTemplate, templateType, selectedRow, tableData, updateProgress, getSafeActiveSheet, createGrdListTableOptimized, setBatchValues, setBatchStyles, setupSheetProtectionAndEvents, setCellMappings]); + + const handleSaveChanges = React.useCallback(async () => { + if (!currentSpread || !hasChanges) { + toast.info("No changes to save"); + return; + } + + const errors = validateAllData(); + if (errors.length > 0) { + toast.error(`Cannot save: ${errors.length} validation errors found. Please fix them first.`); + return; + } + + try { + setIsPending(true); + const activeSheet = currentSpread.getActiveSheet(); + + if (templateType === 'SPREAD_ITEM' && selectedRow) { + const dataToSave = { ...selectedRow }; + + cellMappings.forEach(mapping => { + if (mapping.isEditable) { + const cellPos = parseCellAddress(mapping.cellAddress); + if (cellPos) { + const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); + dataToSave[mapping.attId] = cellValue; + } + } + }); + + dataToSave.TAG_NO = selectedRow.TAG_NO; + + const { success, message } = await updateFormDataInDB( + formCode, + contractItemId, + dataToSave + ); + + if (!success) { + toast.error(message); + return; + } + + toast.success("Changes saved successfully!"); + onUpdateSuccess?.(dataToSave); + + } else if ((templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && tableData.length > 0) { + const updatedRows: GenericData[] = []; + let saveCount = 0; + + for (let i = 0; i < tableData.length; i++) { + const originalRow = tableData[i]; + const dataToSave = { ...originalRow }; + let hasRowChanges = false; + + cellMappings.forEach(mapping => { + if (mapping.dataRowIndex === i && mapping.isEditable) { + const columnConfig = columnsJSON.find(col => col.key === mapping.attId); + const isColumnEditable = columnConfig?.shi === "IN" ||columnConfig?.shi === "BOTH"; + const isRowEditable = originalRow.shi === "IN" ||originalRow.shi === "BOTH" ; + + if (isColumnEditable && isRowEditable) { + const cellPos = parseCellAddress(mapping.cellAddress); + if (cellPos) { + const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); + if (cellValue !== originalRow[mapping.attId]) { + dataToSave[mapping.attId] = cellValue; + hasRowChanges = true; + } + } + } + } + }); + + if (hasRowChanges) { + dataToSave.TAG_NO = originalRow.TAG_NO; + const { success } = await updateFormDataInDB( + formCode, + contractItemId, + dataToSave + ); + + if (success) { + updatedRows.push(dataToSave); + saveCount++; + } + } else { + updatedRows.push(originalRow); + } + } + + if (saveCount > 0) { + toast.success(`${saveCount} rows saved successfully!`); + onUpdateSuccess?.(updatedRows); + } else { + toast.info("No changes to save"); + } + } + + setHasChanges(false); + setValidationErrors([]); + + } catch (error) { + console.error("Error saving changes:", error); + toast.error("An unexpected error occurred while saving"); + } finally { + setIsPending(false); + } + }, [currentSpread, hasChanges, templateType, selectedRow, tableData, formCode, contractItemId, onUpdateSuccess, cellMappings, columnsJSON, validateAllData]); + + if (!isOpen) return null; + + const isDataValid = templateType === 'SPREAD_ITEM' ? !!selectedRow : tableData.length > 0; + const dataCount = templateType === 'SPREAD_ITEM' ? 1 : tableData.length; + + return ( + + + + SEDP Template Designer - {formCode} + +
+ {availableTemplates.length > 1 && ( +
+ Template: + +
+ )} + + {selectedTemplate && ( +
+ + Template Type: { + templateType === 'SPREAD_LIST' ? 'List View (SPREAD_LIST)' : + templateType === 'SPREAD_ITEM' ? 'Item View (SPREAD_ITEM)' : + 'Grid List View (GRD_LIST)' + } + + {templateType === 'SPREAD_ITEM' && selectedRow && ( + • Selected TAG_NO: {selectedRow.TAG_NO || 'N/A'} + )} + {(templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && ( + • {dataCount} rows + )} + {hasChanges && ( + + • Unsaved changes + + )} + {validationErrors.length > 0 && ( + + + {validationErrors.length} validation errors + + )} +
+ )} + +
+ + + Editable fields + + + + Read-only fields + + + + Validation errors + + {cellMappings.length > 0 && ( + + {editableFieldsCount} of {cellMappings.length} fields editable + + )} +
+
+
+
+ +
+ {/* 🆕 로딩 프로그레스 오버레이 */} + + + {selectedTemplate && isClient && isDataValid ? ( + + ) : ( +
+ {!isClient ? ( + <> + + Loading... + + ) : !selectedTemplate ? ( + "No template available" + ) : !isDataValid ? ( + `No ${templateType === 'SPREAD_ITEM' ? 'selected row' : 'data'} available` + ) : ( + "Template not ready" + )} +
+ )} +
+ + +
+ + + {hasChanges && ( + + )} + + {validationErrors.length > 0 && ( + + )} +
+
+
+
+ ); +} \ 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 6de68a1a..c7ab83b5 100644 --- a/components/form-data/update-form-sheet.tsx +++ b/components/form-data/update-form-sheet.tsx @@ -103,17 +103,17 @@ export function UpdateTagSheet({ }, [rowData?.TAG_NO, editableFieldsMap]); const isFieldEditable = React.useCallback((column: DataTableColumnJSON) => { - if (column.shi === true) return false; // SHI‑only + if (column.shi === "OUT" || column.shi === null) return false; // SHI‑only if (column.key === "TAG_NO" || column.key === "TAG_DESC") return false; if (column.key === "status") return false; - // return editableFields.includes(column.key); - return true + return editableFields.includes(column.key); + // return true }, [editableFields]); const isFieldReadOnly = React.useCallback((column: DataTableColumnJSON) => !isFieldEditable(column), [isFieldEditable]); const getReadOnlyReason = React.useCallback((column: DataTableColumnJSON) => { - if (column.shi) return t("updateTagSheet.readOnlyReasons.shiOnly"); + if (column.shi === "OUT" || column.shi === null) return t("updateTagSheet.readOnlyReasons.shiOnly"); if (column.key !== "TAG_NO" && column.key !== "TAG_DESC") { if (!rowData?.TAG_NO || !editableFieldsMap.has(rowData.TAG_NO)) { return t("updateTagSheet.readOnlyReasons.noEditableFields"); diff --git a/components/information/information-client.tsx b/components/information/information-client.tsx index d863175f..50bc6a39 100644 --- a/components/information/information-client.tsx +++ b/components/information/information-client.tsx @@ -1,7 +1,8 @@ "use client" -import { useState, useEffect, useTransition } from "react" -import { useRouter } from "next/navigation" +import React, { useState, useEffect, useTransition } from "react" +import { useRouter, useParams } from "next/navigation" +import { useTranslation } from "@/i18n/client" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { @@ -19,11 +20,13 @@ import { FileText, ChevronUp, ChevronDown, - Download + Download, + Database, + RefreshCw } from "lucide-react" import { toast } from "sonner" import { formatDate } from "@/lib/utils" -import { getInformationLists } from "@/lib/information/service" +import { getInformationLists, syncInformationFromMenuAssignments, getInformationDetail } from "@/lib/information/service" import type { PageInformation } from "@/db/schema/information" import { UpdateInformationDialog } from "@/lib/information/table/update-information-dialog" @@ -36,6 +39,25 @@ type SortDirection = "asc" | "desc" export function InformationClient({ initialData = [] }: InformationClientProps) { const router = useRouter() + const params = useParams() + const lng = (params?.lng as string) || 'ko' + const { t } = useTranslation(lng, 'menu') + + // 안전한 번역 함수 (키가 없을 때 원본 키 반환) + const safeTranslate = (key: string): string => { + try { + const translated = t(key) + // 번역 키가 그대로 반환되는 경우 원본 키 사용 + if (translated === key) { + return key + } + return translated || key + } catch (error) { + console.warn(`Translation failed for key: ${key}`, error) + return key + } + } + const [informations, setInformations] = useState(initialData) const [loading, setLoading] = useState(false) const [searchQuery, setSearchQuery] = useState("") @@ -43,28 +65,16 @@ export function InformationClient({ initialData = [] }: InformationClientProps) const [sortDirection, setSortDirection] = useState("desc") const [editingInformation, setEditingInformation] = useState(null) const [isEditDialogOpen, setIsEditDialogOpen] = useState(false) + const [isSyncing, setIsSyncing] = useState(false) const [, startTransition] = useTransition() // 정보 목록 조회 const fetchInformations = async () => { try { setLoading(true) - const search = searchQuery || undefined startTransition(async () => { - const result = await getInformationLists({ - page: 1, - perPage: 50, - search: search, - sort: [{ id: sortField, desc: sortDirection === "desc" }], - flags: [], - filters: [], - joinOperator: "and", - pagePath: "", - pageName: "", - informationContent: "", - isActive: null, - }) + const result = await getInformationLists() if (result?.data) { setInformations(result.data) @@ -80,9 +90,51 @@ export function InformationClient({ initialData = [] }: InformationClientProps) } } - // 검색 핸들러 + // 클라이언트 사이드 필터링 및 정렬 + const filteredAndSortedInformations = React.useMemo(() => { + let filtered = informations + + // 검색 필터 (페이지명으로 검색) + if (searchQuery) { + filtered = filtered.filter(info => + safeTranslate(info.pageName).toLowerCase().includes(searchQuery.toLowerCase()) || + info.pagePath.toLowerCase().includes(searchQuery.toLowerCase()) + ) + } + + // 정렬 + filtered = filtered.sort((a, b) => { + let aValue: string | Date + let bValue: string | Date + + switch (sortField) { + case "pageName": + aValue = safeTranslate(a.pageName) + bValue = safeTranslate(b.pageName) + break + case "pagePath": + aValue = a.pagePath + bValue = b.pagePath + break + case "createdAt": + aValue = new Date(a.createdAt) + bValue = new Date(b.createdAt) + break + default: + return 0 + } + + if (aValue < bValue) return sortDirection === "asc" ? -1 : 1 + if (aValue > bValue) return sortDirection === "asc" ? 1 : -1 + return 0 + }) + + return filtered + }, [informations, searchQuery, sortField, sortDirection, safeTranslate]) + + // 검색 핸들러 (클라이언트 사이드에서 필터링하므로 별도 동작 불필요) const handleSearch = () => { - fetchInformations() + // 클라이언트 사이드 필터링이므로 별도 서버 요청 불필요 } // 정렬 함수 @@ -92,8 +144,8 @@ export function InformationClient({ initialData = [] }: InformationClientProps) let bValue: string | Date if (sortField === "pageName") { - aValue = a.pageName - bValue = b.pageName + aValue = safeTranslate(a.pageName) + bValue = safeTranslate(b.pageName) } else if (sortField === "pagePath") { aValue = a.pagePath bValue = b.pagePath @@ -123,9 +175,23 @@ export function InformationClient({ initialData = [] }: InformationClientProps) } // 편집 핸들러 - const handleEdit = (information: PageInformation) => { - setEditingInformation(information) - setIsEditDialogOpen(true) + const handleEdit = async (information: PageInformation) => { + try { + // 첨부파일 정보까지 포함해서 가져오기 + const detailData = await getInformationDetail(information.id) + if (detailData) { + setEditingInformation(detailData) + } else { + // 실패시 기본 정보라도 사용 + setEditingInformation(information) + } + setIsEditDialogOpen(true) + } catch (error) { + console.error("Failed to load information detail:", error) + // 에러시 기본 정보라도 사용 + setEditingInformation(information) + setIsEditDialogOpen(true) + } } // 편집 완료 핸들러 @@ -136,20 +202,48 @@ export function InformationClient({ initialData = [] }: InformationClientProps) fetchInformations() } - // 다운로드 핸들러 - const handleDownload = (information: PageInformation) => { - if (information.attachmentFilePath && information.attachmentFileName) { - const link = document.createElement('a') - link.href = information.attachmentFilePath - link.download = information.attachmentFileName - document.body.appendChild(link) - link.click() - document.body.removeChild(link) + // 다운로드 핸들러 (다중 첨부파일은 dialog에서 처리) + const handleDownload = async (information: PageInformation) => { + try { + // 첨부파일 정보까지 포함해서 가져오기 + const detailData = await getInformationDetail(information.id) + if (detailData) { + setEditingInformation(detailData) + } else { + // 실패시 기본 정보라도 사용 + setEditingInformation(information) + } + setIsEditDialogOpen(true) + } catch (error) { + console.error("Failed to load information detail:", error) + // 에러시 기본 정보라도 사용 + setEditingInformation(information) + setIsEditDialogOpen(true) + } + } + + // 메뉴 동기화 핸들러 + const handleSync = async () => { + setIsSyncing(true) + try { + const result = await syncInformationFromMenuAssignments() + + if (result.success) { + toast.success(result.message) + // 동기화 후 데이터 새로고침 + fetchInformations() + } else { + toast.error(result.message) + } + } catch (error) { + console.error("동기화 오류:", error) + toast.error("메뉴 동기화 중 오류가 발생했습니다.") + } finally { + setIsSyncing(false) } } - // 정렬된 정보 목록 - const sortedInformations = sortInformations(informations) + useEffect(() => { if (initialData.length > 0) { @@ -159,13 +253,7 @@ export function InformationClient({ initialData = [] }: InformationClientProps) } }, []) - useEffect(() => { - if (searchQuery !== "") { - fetchInformations() - } else if (initialData.length > 0) { - setInformations(initialData) - } - }, [searchQuery]) + // searchQuery 변경 시 클라이언트 사이드 필터링으로 처리되므로 useEffect 제거 return (
@@ -179,11 +267,19 @@ export function InformationClient({ initialData = [] }: InformationClientProps) value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="pl-10" - onKeyPress={(e) => e.key === "Enter" && handleSearch()} + onKeyPress={(e) => e.key === "Enter"} />
- - ) : ( - 없음 - )} - {information.isActive ? "활성" : "비활성"} diff --git a/components/notice/notice-client.tsx b/components/notice/notice-client.tsx index e32a40c9..e5c05d84 100644 --- a/components/notice/notice-client.tsx +++ b/components/notice/notice-client.tsx @@ -1,6 +1,8 @@ "use client" import { useState, useEffect, useTransition } from "react" +import { useParams } from "next/navigation" +import { useTranslation } from "@/i18n/client" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { @@ -55,6 +57,25 @@ type SortField = "title" | "pagePath" | "createdAt" type SortDirection = "asc" | "desc" export function NoticeClient({ initialData = [], currentUserId }: NoticeClientProps) { + const params = useParams() + const lng = (params?.lng as string) || 'ko' + const { t } = useTranslation(lng, 'menu') + + // 안전한 번역 함수 (키가 없을 때 원본 키 반환) + const safeTranslate = (key: string): string => { + try { + const translated = t(key) + // 번역 키가 그대로 반환되는 경우 원본 키 사용 + if (translated === key) { + return key + } + return translated || key + } catch (error) { + console.warn(`Translation failed for key: ${key}`, error) + return key + } + } + const [notices, setNotices] = useState(initialData) const [loading, setLoading] = useState(false) const [searchQuery, setSearchQuery] = useState("") @@ -172,7 +193,7 @@ export function NoticeClient({ initialData = [], currentUserId }: NoticeClientPr const paths = await getPagePathList() const options = paths.map(path => ({ value: path.pagePath, - label: `${path.pageName} (${path.pagePath})` + label: path.pageName // i18n 키를 그대로 저장 (화면에서 번역) })) setPagePathOptions(options) } catch (error) { @@ -325,9 +346,17 @@ export function NoticeClient({ initialData = [], currentUserId }: NoticeClientPr
- - {notice.pagePath} - +
+
+ {notice.pagePath} +
+
+ {(() => { + const pageOption = pagePathOptions.find(opt => opt.value === notice.pagePath) + return pageOption ? safeTranslate(pageOption.label) : notice.pagePath + })()} +
+
diff --git a/components/notice/notice-create-dialog.tsx b/components/notice/notice-create-dialog.tsx index e3ce16a1..21cd46f6 100644 --- a/components/notice/notice-create-dialog.tsx +++ b/components/notice/notice-create-dialog.tsx @@ -4,6 +4,8 @@ import * as React from "react" import { useState } from "react" import { zodResolver } from "@hookform/resolvers/zod" import { useForm } from "react-hook-form" +import { useParams } from "next/navigation" +import { useTranslation } from "@/i18n/client" import { toast } from "sonner" import { Loader } from "lucide-react" import { Button } from "@/components/ui/button" @@ -49,6 +51,24 @@ export function NoticeCreateDialog({ currentUserId, onSuccess, }: NoticeCreateDialogProps) { + const params = useParams() + const lng = (params?.lng as string) || 'ko' + const { t } = useTranslation(lng, 'menu') + + // 안전한 번역 함수 (키가 없을 때 원본 키 반환) + const safeTranslate = (key: string): string => { + try { + const translated = t(key) + // 번역 키가 그대로 반환되는 경우 원본 키 사용 + if (translated === key) { + return key + } + return translated || key + } catch (error) { + console.warn(`Translation failed for key: ${key}`, error) + return key + } + } const [isLoading, setIsLoading] = useState(false) const form = useForm({ @@ -127,7 +147,7 @@ export function NoticeCreateDialog({ {pagePathOptions.map((option) => ( - {option.label} + {safeTranslate(option.label)} ))} diff --git a/components/notice/notice-edit-sheet.tsx b/components/notice/notice-edit-sheet.tsx index 91bcae3b..dc83d23a 100644 --- a/components/notice/notice-edit-sheet.tsx +++ b/components/notice/notice-edit-sheet.tsx @@ -3,6 +3,8 @@ import * as React from "react" import { zodResolver } from "@hookform/resolvers/zod" import { useForm } from "react-hook-form" +import { useParams } from "next/navigation" +import { useTranslation } from "@/i18n/client" import { toast } from "sonner" import { Button } from "@/components/ui/button" @@ -57,6 +59,25 @@ export function UpdateNoticeSheet({ pagePathOptions, onSuccess }: UpdateNoticeSheetProps) { + const params = useParams() + const lng = (params?.lng as string) || 'ko' + const { t } = useTranslation(lng, 'menu') + + // 안전한 번역 함수 (키가 없을 때 원본 키 반환) + const safeTranslate = (key: string): string => { + try { + const translated = t(key) + // 번역 키가 그대로 반환되는 경우 원본 키 사용 + if (translated === key) { + return key + } + return translated || key + } catch (error) { + console.warn(`Translation failed for key: ${key}`, error) + return key + } + } + const [isUpdatePending, startUpdateTransition] = React.useTransition() const form = useForm({ @@ -149,7 +170,7 @@ export function UpdateNoticeSheet({ {pagePathOptions.map((option) => ( - {option.label} + {safeTranslate(option.label)} ))} diff --git a/components/ship-vendor-document-all/user-vendor-document-table-container.tsx b/components/ship-vendor-document-all/user-vendor-document-table-container.tsx new file mode 100644 index 00000000..157bdb03 --- /dev/null +++ b/components/ship-vendor-document-all/user-vendor-document-table-container.tsx @@ -0,0 +1,898 @@ +// user-vendor-document-display.tsx - 수정된 버전 +"use client" + +import React from "react" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Table, + TableBody, + TableCaption, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Building, FileText, AlertCircle, Eye, Download, Loader2, Plus, Trash2, Edit } from "lucide-react" +import { SimplifiedDocumentsTable } from "@/lib/vendor-document-list/ship-all/enhanced-documents-table" +import { + getUserVendorDocumentsAll, + getUserVendorDocumentStatsAll, +} from "@/lib/vendor-document-list/enhanced-document-service" +import { SimplifiedDocumentsView } from "@/db/schema" +import { WebViewerInstance } from "@pdftron/webviewer" +import { useRouter } from 'next/navigation' + +/* ------------------------------------------------------------------------------------------------- + * Types & Constants + * -----------------------------------------------------------------------------------------------*/ +interface UserVendorDocumentDisplayProps { + allPromises: Promise<[ + Awaited>, // 문서 목록 + Awaited>, // 통계 데이터 + ]> +} + +interface StageInfo { + id: number + stageName: string + stageStatus: string + stageOrder: number + planDate: string | null + actualDate: string | null + assigneeName: string | null + priority: string + revisions: RevisionInfo[] +} + +interface RevisionInfo { + id: number + serialNo: string | null + issueStageId: number + revision: string + uploaderType: string + uploaderId: number | null + uploaderName: string | null + comment: string | null + usage: string | null + usageType: string | null + revisionStatus: string + submittedDate: string | null + approvedDate: string | null + uploadedAt: string | null + reviewStartDate: string | null + rejectedDate: string | null + reviewerId: number | null + reviewerName: string | null + reviewComments: string | null + createdAt: Date + updatedAt: Date + stageName?: string + attachments: AttachmentInfo[] +} + +interface AttachmentInfo { + id: number + revisionId: number + fileName: string + filePath: string + dolceFilePath: string | null + fileSize: number | null + fileType: string | null + createdAt: Date + updatedAt: Date +} + +interface DocumentSelectionContextType { + selectedDocumentId: number | null + selectedStageId: number | null + selectedRevisionId: number | null + setSelectedDocumentId: (id: number | null) => void + setSelectedStageId: (id: number | null) => void + setSelectedRevisionId: (id: number | null) => void + allData: SimplifiedDocumentsView[] | null + setAllData: (data: SimplifiedDocumentsView[]) => void +} + +export const DocumentSelectionContextAll = React.createContext( + { + selectedDocumentId: null, + selectedStageId: null, + selectedRevisionId: null, + setSelectedDocumentId: (_id: number | null) => { }, + setSelectedStageId: (_id: number | null) => { }, + setSelectedRevisionId: (_id: number | null) => { }, + allData: null, + setAllData: (_data: SimplifiedDocumentsView[]) => { }, + }, +) + +/* ------------------------------------------------------------------------------------------------- + * Revision & Attachment Tables - 너비 최적화 + * -----------------------------------------------------------------------------------------------*/ + +function getUsageTypeDisplay(usageType: string | null): string { + if (!usageType) return '-' + + // B3 용도 타입 축약 표시 + const abbreviations: Record = { + 'Approval Submission Full': 'AS-F', + 'Approval Submission Partial': 'AS-P', + 'Approval Completion Full': 'AC-F', + 'Approval Completion Partial': 'AC-P', + 'Working Full': 'W-F', + 'Working Partial': 'W-P', + 'Reference Full': 'R-F', + 'Reference Partial': 'R-P', + 'Reference Series Full': 'RS-F', + 'Reference Series Partial': 'RS-P', + } + + return abbreviations[usageType] || usageType +} + +function RevisionTable({ + revisions, + onViewRevision, +}: { + revisions: RevisionInfo[] + onViewRevision: (revision: RevisionInfo) => void +}) { + const { selectedRevisionId, setSelectedRevisionId } = + React.useContext(DocumentSelectionContextAll) + + const toggleSelect = (revisionId: number) => { + setSelectedRevisionId(revisionId === selectedRevisionId ? null : revisionId) + } + + const canEditRevision = React.useCallback((revision: RevisionInfo) => { + if ((!revision.attachments || revision.attachments.length === 0) && revision.uploaderType === "vendor") { + return true + } + + return revision.attachments.every(attachment => + !attachment.dolceFilePath || attachment.dolceFilePath.trim() === '' + ) + }, []) + + const getRevisionProcessStatus = React.useCallback((revision: RevisionInfo) => { + if (!revision.attachments || revision.attachments.length === 0) { + return 'no-files' + } + + const processedCount = revision.attachments.filter(attachment => + attachment.dolceFilePath && attachment.dolceFilePath.trim() !== '' + ).length + + if (processedCount === 0) { + return 'not-processed' + } else if (processedCount === revision.attachments.length) { + return 'fully-processed' + } else { + return 'partially-processed' + } + }, []) + + return ( + + + Revisions + + +
+ + + + Sel + Serial No + Rev + Category + Usage + Type + Status + Uploader + Comment + Date + Files + Actions + + + + {revisions.map((revision) => { + const canEdit = canEditRevision(revision) + const processStatus = getRevisionProcessStatus(revision) + + return ( + + + toggleSelect(revision.id)} + className="h-3 w-3 cursor-pointer" + /> + + + {revision.serialNo || ''} + + +
+ {revision.revision} + {processStatus === 'fully-processed' && ( +
+ )} + {processStatus === 'partially-processed' && ( +
+ )} +
+ + + {revision.uploaderType === "vendor" ? "To" : "From"} + + + + {revision.usage || '-'} + + + + + {revision.usageType ? ( + getUsageTypeDisplay(revision.usageType) + ) : ( + - + )} + + + + + {revision.revisionStatus.slice(0, 8)} + + + + {revision.uploaderName || '-'} + + + {revision.comment ? ( +
+

+ {revision.comment} +

+
+ ) : ( + - + )} +
+ + + {revision.uploadedAt + ? new Date(revision.uploadedAt).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric' + }) + : '-'} + + + +
+ {revision.attachments.length} +
+
+ +
+ {revision.attachments.length > 0 && ( + + )} +
+
+ + ) + })} + +
+
+
+
+ ) +} + +function AttachmentTable({ + attachments, + onDownloadFile, +}: { + attachments: AttachmentInfo[] + onDownloadFile: (attachment: AttachmentInfo) => void +}) { + const { selectedRevisionId, allData, setAllData } = React.useContext(DocumentSelectionContextAll) + const [addAttachmentDialogOpen, setAddAttachmentDialogOpen] = React.useState(false) + const router = useRouter() + + const selectedRevisionInfo = React.useMemo(() => { + if (!selectedRevisionId || !allData) return null + + for (const doc of allData) { + if (doc.allStages) { + for (const stage of doc.allStages as StageInfo[]) { + const revision = stage.revisions.find(r => r.id === selectedRevisionId) + if (revision) return revision + } + } + } + return null + }, [selectedRevisionId, allData]) + + const handleAddAttachment = React.useCallback(() => { + if (selectedRevisionInfo) { + setAddAttachmentDialogOpen(true) + } + }, [selectedRevisionInfo]) + + const canDeleteFile = React.useCallback((attachment: AttachmentInfo) => { + return !attachment.dolceFilePath || attachment.dolceFilePath.trim() === '' + }, []) + + const handleAttachmentUploadSuccess = React.useCallback((uploadResult?: any) => { + if (!selectedRevisionId || !allData || !uploadResult?.data) { + console.log('🔄 Full refresh') + router.refresh() + return + } + + try { + const newAttachments: AttachmentInfo[] = uploadResult.data.uploadedFiles?.map((file: any) => ({ + id: file.id, + revisionId: selectedRevisionId, + fileName: file.fileName, + filePath: file.filePath, + dolceFilePath: null, + fileSize: file.fileSize, + fileType: file.fileType || null, + createdAt: new Date(), + updatedAt: new Date(), + })) || [] + + const updatedData = allData.map(doc => { + const updatedDoc = { ...doc } + + if (updatedDoc.allStages) { + const stages = [...updatedDoc.allStages as StageInfo[]] + + for (const stage of stages) { + const revisionIndex = stage.revisions.findIndex(r => r.id === selectedRevisionId) + if (revisionIndex !== -1) { + stage.revisions[revisionIndex] = { + ...stage.revisions[revisionIndex], + attachments: [...stage.revisions[revisionIndex].attachments, ...newAttachments] + } + updatedDoc.allStages = stages + break + } + } + } + + return updatedDoc + }) + + setAllData(updatedData) + console.log('✅ AttachmentTable update complete') + + setTimeout(() => { + router.refresh() + }, 1500) + + } catch (error) { + console.error('❌ AttachmentTable update failed:', error) + router.refresh() + } + }, [selectedRevisionId, allData, setAllData, router]) + + return ( + + +
+ Attachments + {selectedRevisionId && selectedRevisionInfo && ( + + )} +
+
+ +
+ + + + File Name + Actions + + + + {!selectedRevisionId || attachments.length === 0 ? ( + + +
+ + + {!selectedRevisionId + ? 'Please select a revision' + : 'No attached files'} + + {selectedRevisionId && selectedRevisionInfo && ( + + )} +
+
+
+ ) : ( + attachments.map((file) => ( + + +
+
+ {file.fileName} +
+
+ + {file.fileSize + ? file.fileSize >= 1024 * 1024 + ? `${(file.fileSize / 1024 / 1024).toFixed(1)}MB` + : `${(file.fileSize / 1024).toFixed(1)}KB` + : '-'} + + {file.dolceFilePath && file.dolceFilePath.trim() !== '' && ( + Processed + )} +
+
+
+ +
+ +
+
+
+ )) + )} +
+
+
+
+
+ ) +} + +// SubTables 컴포넌트 - 컨테이너 너비 제한 강화 +function SubTables() { + const router = useRouter() + const { selectedDocumentId, selectedRevisionId, setSelectedRevisionId, allData, setAllData } = + React.useContext(DocumentSelectionContextAll) + + // PDF 뷰어 상태 관리 + const [viewerOpen, setViewerOpen] = React.useState(false) + const [selectedRevision, setSelectedRevision] = React.useState(null) + const [instance, setInstance] = React.useState(null) + const [viewerLoading, setViewerLoading] = React.useState(true) + const [fileSetLoading, setFileSetLoading] = React.useState(true) + const viewer = React.useRef(null) + const initialized = React.useRef(false) + const isCancelled = React.useRef(false) + + const selectedDocument = React.useMemo(() => { + if (!selectedDocumentId || !allData) return null + return allData.find((d) => d.documentId === selectedDocumentId) || null + }, [selectedDocumentId, allData]) + + const allRevisions = React.useMemo(() => { + if (!selectedDocument?.allStages) return [] + + const revisions: RevisionInfo[] = [] + for (const stage of selectedDocument.allStages as StageInfo[]) { + const stageRevisions = stage.revisions.map(revision => ({ + ...revision, + stageName: stage.stageName + })) + revisions.push(...stageRevisions) + } + + return revisions.sort((a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ) + }, [selectedDocument]) + + const selectedRevisionData = React.useMemo(() => { + if (!selectedRevisionId) return null + return allRevisions.find(r => r.id === selectedRevisionId) || null + }, [selectedRevisionId, allRevisions]) + + const cleanupHtmlStyle = React.useCallback(() => { + const htmlElement = window.document.documentElement + const originalStyle = htmlElement.getAttribute("style") || "" + const colorSchemeStyle = originalStyle + .split(";") + .map((s) => s.trim()) + .find((s) => s.startsWith("color-scheme:")) + + if (colorSchemeStyle) { + htmlElement.setAttribute("style", colorSchemeStyle + ";") + } else { + htmlElement.removeAttribute("style") + } + }, []) + + const handleViewRevision = React.useCallback((revision: RevisionInfo) => { + setSelectedRevision(revision) + setViewerOpen(true) + setViewerLoading(true) + setFileSetLoading(true) + initialized.current = false + }, []) + + const handleDownloadFile = React.useCallback(async (attachment: AttachmentInfo) => { + try { + const queryParam = attachment.id + ? `id=${encodeURIComponent(attachment.id)}` + : `path=${encodeURIComponent(attachment.filePath)}` + + const response = await fetch(`/api/document-download?${queryParam}`) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || 'Failed to download file.') + } + + const blob = await response.blob() + const url = window.URL.createObjectURL(blob) + const link = window.document.createElement('a') + link.href = url + link.download = attachment.fileName + window.document.body.appendChild(link) + link.click() + window.document.body.removeChild(link) + window.URL.revokeObjectURL(url) + } catch (error) { + console.error('File download error:', error) + alert(`File download failed: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + }, []) + + // WebViewer 초기화 + React.useEffect(() => { + if (viewerOpen && !initialized.current) { + initialized.current = true + isCancelled.current = false + + requestAnimationFrame(() => { + if (viewer.current && !isCancelled.current) { + import("@pdftron/webviewer").then(({ default: WebViewer }) => { + if (isCancelled.current) { + console.log("WebViewer initialization cancelled (Dialog closed)") + return + } + + WebViewer( + { + path: "/pdftronWeb", + licenseKey: "demo:1739264618684:616161d7030000000091db1c97c6f386d41d3506ab5b507381ef2ee2bd", + fullAPI: true, + css: "/globals.css", + }, + viewer.current as HTMLDivElement + ).then(async (instance: WebViewerInstance) => { + if (!isCancelled.current) { + setInstance(instance) + instance.UI.enableFeatures([instance.UI.Feature.MultiTab]) + instance.UI.disableElements(["addTabButton", "multiTabsEmptyPage"]) + setViewerLoading(false) + } + }) + }) + } + }) + } + + return () => { + if (instance) { + instance.UI.dispose() + } + setTimeout(() => cleanupHtmlStyle(), 500) + } + }, [viewerOpen, cleanupHtmlStyle, instance]) + + // 문서 로드 + React.useEffect(() => { + const loadDocument = async () => { + if (instance && selectedRevision?.attachments?.length) { + const { UI } = instance + + const tabIds = [] + for (const attachment of selectedRevision.attachments) { + try { + const response = await fetch(attachment.filePath) + const blob = await response.blob() + const options = { + filename: attachment.fileName, + ...(attachment.fileType?.includes("xlsx") && { + officeOptions: { + formatOptions: { + applyPageBreaksToSheet: true, + }, + }, + }), + } + const tab = await UI.TabManager.addTab(blob, options) + tabIds.push(tab) + } catch (error) { + console.error("File load failed:", attachment.filePath, error) + } + } + + if (tabIds.length > 0) { + await UI.TabManager.setActiveTab(tabIds[0]) + } + + setFileSetLoading(false) + } + } + loadDocument() + }, [instance, selectedRevision]) + + const handleCloseViewer = React.useCallback(async () => { + if (!fileSetLoading) { + isCancelled.current = true + + if (instance) { + try { + await instance.UI.dispose() + setInstance(null) + } catch (e) { + console.warn("dispose error", e) + } + } + + setViewerLoading(false) + setViewerOpen(false) + setTimeout(() => cleanupHtmlStyle(), 1000) + } + }, [fileSetLoading, instance, cleanupHtmlStyle]) + + if (!selectedDocument) return null + + return ( + <> + {/* 컨테이너 너비 제한 강화 */} +
+
+ + +
+
+ + {/* 문서 뷰어 다이얼로그 */} + + + + Document Preview + + Revision {selectedRevision?.revision} attachments + + +
+ {viewerLoading && ( +
+ +

+ Loading document viewer... +

+
+ )} +
+
+
+ + ) +} + +/* ------------------------------------------------------------------------------------------------- + * High‑level Selected Document Summary + * -----------------------------------------------------------------------------------------------*/ +function SelectedDocumentInfo() { + const { selectedDocumentId, selectedRevisionId, allData } = + React.useContext(DocumentSelectionContextAll) + + if (!selectedDocumentId || !allData) return null + + const doc = allData.find((d) => d.documentId === selectedDocumentId) + if (!doc) return null + + const totalRevisions = doc.allStages + ? (doc.allStages as StageInfo[]).reduce( + (acc, s) => acc + s.revisions.length, + 0, + ) + : 0 + + let selectedRevision: RevisionInfo | null = null + if (selectedRevisionId && doc.allStages) { + for (const stage of doc.allStages as StageInfo[]) { + const rev = stage.revisions.find((r) => r.id === selectedRevisionId) + if (rev) { + selectedRevision = rev + break + } + } + } + + return ( +
+
+
+ + Document: {doc.docNumber} + + + {doc.title} + +
+
+ + Total {totalRevisions} revisions + {selectedRevision && ( + <> + + + Selected revision: {selectedRevision.revision} + + ({selectedRevision.attachments.length} files) + + )} +
+
+
+ ) +} + +/* ------------------------------------------------------------------------------------------------- + * Main Exported Component + * -----------------------------------------------------------------------------------------------*/ +export function UserVendorALLDocumentDisplay({ + allPromises, +}: UserVendorDocumentDisplayProps) { + const [selectedDocumentId, setSelectedDocumentId] = + React.useState(null) + const [selectedStageId, setSelectedStageId] = React.useState( + null, + ) + const [selectedRevisionId, setSelectedRevisionId] = + React.useState(null) + const [allData, setAllData] = + React.useState(null) + + const handleDocumentSelect = React.useCallback((id: number | null) => { + setSelectedDocumentId(id) + setSelectedStageId(null) + setSelectedRevisionId(null) + }, []) + + const ctx = React.useMemo( + () => ({ + selectedDocumentId, + selectedStageId, + selectedRevisionId, + setSelectedDocumentId: handleDocumentSelect, + setSelectedStageId, + setSelectedRevisionId, + allData, + setAllData, + }), + [ + selectedDocumentId, + selectedStageId, + selectedRevisionId, + handleDocumentSelect, + allData, + setAllData, + ], + ) + + if (!allPromises) { + return ( + + +
+ +

Unable to load data.

+
+
+
+ ) + } + + return ( + +
+ + +
+ +
+
+
+ + +
+
+ ) +} \ No newline at end of file diff --git a/components/ship-vendor-document/user-vendor-document-table-container.tsx b/components/ship-vendor-document/user-vendor-document-table-container.tsx index 61d52c28..9e45df6b 100644 --- a/components/ship-vendor-document/user-vendor-document-table-container.tsx +++ b/components/ship-vendor-document/user-vendor-document-table-container.tsx @@ -81,6 +81,7 @@ interface RevisionInfo { reviewerId: number | null reviewerName: string | null reviewComments: string | null + serialNo: string | null createdAt: Date updatedAt: Date stageName?: string @@ -222,6 +223,7 @@ function RevisionTable({ Select + Serial No Revision Category Usage @@ -253,6 +255,9 @@ function RevisionTable({ className="h-4 w-4 cursor-pointer" /> + + {revision.serialNo || ''} +
{revision.revision} diff --git a/components/signup/join-form.tsx b/components/signup/join-form.tsx index 71ecbd1c..999b87dc 100644 --- a/components/signup/join-form.tsx +++ b/components/signup/join-form.tsx @@ -114,6 +114,8 @@ interface VendorData { items: string; taxId: string; address: string; + addressDetail: string; + postalCode: string; email: string; phone: string; country: string; @@ -165,6 +167,46 @@ const getCountryData = (lng: string): CountryOption[] => { const MAX_FILE_SIZE = 3e9; +// ========== 전화번호 정규화 함수 ========== + +/** + * 전화번호를 E.164 형식으로 정규화 (저장용) + */ +function normalizePhoneForStorage(phoneNumber: string, countryCode: string): string | null { + try { + if (!phoneNumber || !countryCode) return null; + + const parsed = parsePhoneNumberFromString(phoneNumber, countryCode); + + if (!parsed || !parsed.isValid()) { + return null; + } + + // E.164 형식으로 반환 (+821012345678) + return parsed.format('E.164'); + } catch (error) { + console.error('Phone normalization error:', error); + return null; + } +} + +/** + * E.164 형식의 전화번호를 표시용으로 포맷팅 + */ +function formatPhoneForDisplay(phoneNumber: string): string { + try { + if (!phoneNumber) return ''; + + const parsed = parsePhoneNumberFromString(phoneNumber); + if (parsed) { + return parsed.formatNational(); // 국내 형식으로 표시 + } + return phoneNumber; + } catch { + return phoneNumber; + } +} + // ========== 전화번호 처리 유틸리티 함수들 ========== /** @@ -338,8 +380,10 @@ function PhoneInput({ const formatPhone = usePhoneFormatter(countryCode); useEffect(() => { - setLocalValue(value || ''); - }, [value]); + // E.164 형식으로 저장된 번호를 표시용으로 변환 + const displayValue = value ? formatPhoneForDisplay(value) : ''; + setLocalValue(displayValue); + }, [value, countryCode]); const validation = validatePhoneNumber(localValue, countryCode, t); @@ -432,6 +476,8 @@ export default function JoinForm() { items: "", taxId: defaultTaxId, address: "", + addressDetail: "", + postalCode: "", email: "", phone: "", country: "", @@ -686,12 +732,23 @@ function AccountStep({ setIsLoading(true); setEmailCheckError(''); + try { + // 전화번호 정규화 + const normalizedPhone = normalizePhoneForStorage(data.phone, data.country); + if (!normalizedPhone) { + setEmailCheckError('전화번호 형식이 올바르지 않습니다'); + return; + } + const isUsed = await checkEmailExists(data.email); if (isUsed) { setEmailCheckError(t('emailAlreadyInUse')); return; } + + // 정규화된 전화번호로 데이터 업데이트 + onChange(prev => ({ ...prev, phone: normalizedPhone })); onNext(); } catch (error) { setEmailCheckError(t('emailCheckError')); @@ -914,6 +971,7 @@ function CompleteVendorForm({ const creditReportHandler = createFileUploadHandler(setCreditReportFiles, creditReportFiles); const bankAccountHandler = createFileUploadHandler(setBankAccountFiles, bankAccountFiles); + // 유효성 검사 const validateRequiredFiles = (): string[] => { const errors: string[] = []; @@ -945,10 +1003,10 @@ function CompleteVendorForm({ contact.contactPhone ? validatePhoneNumber(contact.contactPhone, data.country, t).isValid : true ); - const isFormValid = data.vendorName && data.vendorTypeId && data.items && + const isFormValid = data.vendorName && data.vendorTypeId && data.items && data.country && data.phone && vendorPhoneValidation.isValid && data.email && contactsValid && - validateRequiredFiles().length === 0; + validateRequiredFiles().length === 0 // 최종 제출 const handleSubmit = async () => { @@ -964,12 +1022,51 @@ function CompleteVendorForm({ setIsSubmitting(true); try { + // 업체 전화번호 정규화 + const normalizedVendorPhone = normalizePhoneForStorage(data.phone, data.country); + if (!normalizedVendorPhone) { + toast({ + variant: "destructive", + title: t('error'), + description: '업체 전화번호 형식이 올바르지 않습니다', + }); + return; + } + + // 담당자 전화번호들 정규화 + const normalizedContacts = data.contacts.map(contact => { + if (contact.contactPhone) { + const normalizedContactPhone = normalizePhoneForStorage(contact.contactPhone, data.country); + if (!normalizedContactPhone) { + throw new Error(`담당자 ${contact.contactName}의 전화번호 형식이 올바르지 않습니다`); + } + return { ...contact, contactPhone: normalizedContactPhone }; + } + return contact; + }); + + // 대표자 전화번호 정규화 (한국 업체인 경우) + let normalizedRepresentativePhone = data.representativePhone; + if (data.country === "KR" && data.representativePhone) { + const normalized = normalizePhoneForStorage(data.representativePhone, "KR"); + if (!normalized) { + throw new Error('대표자 전화번호 형식이 올바르지 않습니다'); + } + normalizedRepresentativePhone = normalized; + } + const formData = new FormData(); const completeData = { - account: accountData, + account: { + ...accountData, + // accountData.phone은 이미 AccountStep에서 정규화됨 + }, vendor: { ...data, + phone: normalizedVendorPhone, + representativePhone: normalizedRepresentativePhone, + contacts: normalizedContacts, email: data.email || accountData.email, }, consents: { @@ -1004,7 +1101,7 @@ function CompleteVendorForm({ if (data.country !== "KR") { bankAccountFiles.forEach(file => { - formData.append('bankAccountCopy', file); + formData.append('bankAccount', file); }); } @@ -1032,8 +1129,8 @@ function CompleteVendorForm({ console.error(error); toast({ variant: "destructive", - title: t('serverError'), - description: t('errorOccurred'), + title: t('error'), + description: error instanceof Error ? error.message : t('errorOccurred'), }); } finally { setIsSubmitting(false); @@ -1152,7 +1249,9 @@ function CompleteVendorForm({ {/* 주소 */}
- + handleInputChange('address', e.target.value)} @@ -1160,6 +1259,32 @@ function CompleteVendorForm({ />
+ {/* 상세주소 */} +
+ + handleInputChange('addressDetail', e.target.value)} + disabled={isSubmitting} + placeholder="상세주소를 입력해주세요" + /> +
+ + {/* 우편번호 */} +
+ + handleInputChange('postalCode', e.target.value)} + disabled={isSubmitting} + placeholder="우편번호를 입력해주세요" + /> +
+ {/* 국가 */}
{children} diff --git a/components/vendor-info/pq-simple-dialog.tsx b/components/vendor-info/pq-simple-dialog.tsx new file mode 100644 index 00000000..bb26685d --- /dev/null +++ b/components/vendor-info/pq-simple-dialog.tsx @@ -0,0 +1,417 @@ +"use client" + +import { useState, useEffect } from "react" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Download, FileText, ChevronDown, ChevronUp, Search } from "lucide-react" +import { Input } from "@/components/ui/input" +import { toast } from "sonner" +import { getPQProjectsByVendorId, ProjectPQ, getPQDataByVendorId, PQGroupData } from "@/lib/pq/service" +import { downloadFile } from "@/lib/file-download" + +interface PQSimpleDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + vendorId: string +} + +interface PQItemData { + groupName: string + code: string + checkPoint: string + description: string + answer: string | null + inputFormat: string + fileName?: string | null + filePath?: string | null +} + +export function PQSimpleDialog({ + open, + onOpenChange, + vendorId, +}: PQSimpleDialogProps) { + const [projects, setProjects] = useState([]) + const [selectedProject, setSelectedProject] = useState(null) + const [pqData, setPqData] = useState([]) + const [loading, setLoading] = useState(false) + const [expandedGroups, setExpandedGroups] = useState>(new Set()) + const [searchTerm, setSearchTerm] = useState("") + + // vendorId를 숫자로 변환 + const numericVendorId = parseInt(vendorId) + + useEffect(() => { + if (open && !isNaN(numericVendorId)) { + loadProjects() + } + }, [open, numericVendorId]) + + const loadProjects = async () => { + try { + setLoading(true) + const projectList = await getPQProjectsByVendorId(numericVendorId) + setProjects(projectList) + + if (projectList.length > 0) { + setSelectedProject(projectList[0]) + await loadPQData(projectList[0].projectId) + } + } catch (error) { + console.error("프로젝트 목록 로드 실패:", error) + toast.error("PQ 프로젝트 목록을 불러오는데 실패했습니다.") + } finally { + setLoading(false) + } + } + + const loadPQData = async (projectId: number | null) => { + if (projectId === null) return + + try { + setLoading(true) + const data = await getPQDataByVendorId(numericVendorId, projectId) + setPqData(data) + } catch (error) { + console.error("PQ 데이터 로드 실패:", error) + toast.error("PQ 데이터를 불러오는데 실패했습니다.") + } finally { + setLoading(false) + } + } + + const handleProjectChange = async (project: ProjectPQ) => { + setSelectedProject(project) + await loadPQData(project.projectId) + } + + const handleFileDownload = async (filePath: string, fileName: string) => { + try { + const result = await downloadFile(filePath, fileName) + if (result.success) { + toast.success(`${fileName} 파일이 다운로드되었습니다.`) + } else { + toast.error(result.error || "파일 다운로드에 실패했습니다.") + } + } catch (error) { + console.error("파일 다운로드 오류:", error) + toast.error("파일 다운로드에 실패했습니다.") + } + } + + // 코드 순서로 정렬하는 함수 (1-1-1, 1-1-2, 1-2-1 순서) + const sortByCode = (items: any[]) => { + return [...items].sort((a, b) => { + const parseCode = (code: string) => { + return code.split('-').map(part => parseInt(part, 10)) + } + + const aCode = parseCode(a.code) + const bCode = parseCode(b.code) + + for (let i = 0; i < Math.max(aCode.length, bCode.length); i++) { + const aPart = aCode[i] || 0 + const bPart = bCode[i] || 0 + if (aPart !== bPart) { + return aPart - bPart + } + } + return 0 + }) + } + + // 검색 필터링 함수 + const filterItems = (items: any[], searchTerm: string) => { + if (!searchTerm.trim()) return items + + const search = searchTerm.toLowerCase() + return items.filter(item => + item.checkPoint?.toLowerCase().includes(search) || + item.description?.toLowerCase().includes(search) || + item.code?.toLowerCase().includes(search) + ) + } + + // 그룹별로 정렬 및 필터링된 데이터 계산 + const processedPQData = pqData.map(group => ({ + ...group, + items: filterItems(sortByCode(group.items), searchTerm) + })).filter(group => group.items.length > 0) // 검색 결과가 없는 그룹은 제외 + + const toggleGroup = (groupName: string) => { + setExpandedGroups(prev => { + const newSet = new Set(prev) + if (newSet.has(groupName)) { + newSet.delete(groupName) + } else { + newSet.add(groupName) + } + return newSet + }) + } + + const renderPQContent = (groupData: PQGroupData) => { + const isExpanded = expandedGroups.has(groupData.groupName) + const itemCount = groupData.items.length + + return ( + + toggleGroup(groupData.groupName)} + > +
+
+ + {groupData.groupName} + + + {itemCount}개 항목 + +
+ {isExpanded ? ( + + ) : ( + + )} +
+
+ + {isExpanded && ( + +
+ {groupData.items.map((item, index) => ( +
+
+
+
+ + {item.code} + + + {item.inputFormat} + +
+

+ {item.checkPoint} +

+ {item.description && ( +

+ {item.description} +

+ )} +
+ +
+
+ +

+ {item.answer || "답변 없음"} +

+
+ + {item.attachments && item.attachments.length > 0 && ( +
+ +
+ {item.attachments.map((attachment, idx) => ( + + ))} +
+
+ )} +
+
+
+ ))} +
+
+ )} +
+ ) + } + + if (projects.length === 0 && !loading) { + return ( + + + + PQ 조회 + +
+

제출된 PQ가 없습니다.

+
+
+
+ ) + } + + return ( + + + + PQ 조회 + + + {loading ? ( +
+

로딩 중...

+
+ ) : selectedProject ? ( +
+ {/* 프로젝트 선택 */} + {projects.length > 1 && ( +
+ + +
+ )} + + {/* 프로젝트 정보 카드 */} + + +
+
+ {selectedProject.projectName} + + {selectedProject.status} + +
+
+
+ 프로젝트 코드: {selectedProject.projectCode} • + 제출일: {selectedProject.submittedAt ? new Date(selectedProject.submittedAt).toLocaleDateString('ko-KR') : '-'} +
+
+
+ + {/* 검색 및 PQ 그룹 데이터 */} +
+
+

PQ 항목

+
+ + +
+
+ + {/* 검색 박스 */} +
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> + {searchTerm && ( + + )} +
+ + {/* 검색 결과 카운트 */} + {searchTerm && ( +
+ 검색 결과: {processedPQData.reduce((total, group) => total + group.items.length, 0)}개 항목 + ({processedPQData.length}개 그룹) +
+ )} + + {/* PQ 그룹 목록 */} + {processedPQData.length > 0 ? ( + processedPQData.map((groupData) => renderPQContent(groupData)) + ) : ( +
+

+ {searchTerm ? "검색 결과가 없습니다." : "PQ 데이터가 없습니다."} +

+
+ )} +
+
+ ) : null} +
+
+ ) +} diff --git a/components/vendor-regular-registrations/additional-info-dialog.tsx b/components/vendor-regular-registrations/additional-info-dialog.tsx index 84475877..303c6d7e 100644 --- a/components/vendor-regular-registrations/additional-info-dialog.tsx +++ b/components/vendor-regular-registrations/additional-info-dialog.tsx @@ -69,6 +69,7 @@ interface AdditionalInfoDialogProps { onOpenChange: (open: boolean) => void; vendorId: number; onSave?: () => void; + readonly?: boolean; } const contactTypes = [ @@ -86,6 +87,7 @@ export function AdditionalInfoDialog({ onOpenChange, vendorId, onSave, + readonly = false, }: AdditionalInfoDialogProps) { const [saving, setSaving] = useState(false); const [loading, setLoading] = useState(false); @@ -204,9 +206,12 @@ export function AdditionalInfoDialog({ - 추가정보 입력 + {readonly ? "추가정보 조회" : "추가정보 입력"}

- 정규업체 등록을 위한 추가정보를 입력해주세요. * 표시는 필수 입력 항목입니다. + {readonly + ? "정규업체 등록을 위한 추가 정보를 조회합니다." + : "정규업체 등록을 위한 추가정보를 입력해주세요. * 표시는 필수 입력 항목입니다." + }

@@ -240,9 +245,13 @@ export function AdditionalInfoDialog({ name={`businessContacts.${index}.contactName`} render={({ field }) => ( - 담당자명 * + 담당자명 {!readonly && "*"} - + @@ -253,9 +262,13 @@ export function AdditionalInfoDialog({ name={`businessContacts.${index}.position`} render={({ field }) => ( - 직급 * + 직급 {!readonly && "*"} - + @@ -268,9 +281,13 @@ export function AdditionalInfoDialog({ name={`businessContacts.${index}.department`} render={({ field }) => ( - 부서 * + 부서 {!readonly && "*"} - + @@ -281,9 +298,14 @@ export function AdditionalInfoDialog({ name={`businessContacts.${index}.email`} render={({ field }) => ( - Email * + Email {!readonly && "*"} - + @@ -295,11 +317,12 @@ export function AdditionalInfoDialog({ name={`businessContacts.${index}.responsibility`} render={({ field }) => ( - 담당업무 * + 담당업무 {!readonly && "*"}