"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"; // SpreadSheets를 동적으로 import (SSR 비활성화) 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 TemplateViewDialogProps { isOpen: boolean; onClose: () => void; templateData: TemplateItem[] | any; selectedRow?: GenericData; // SPREAD_ITEM용 tableData?: GenericData[]; // SPREAD_LIST용 formCode: string; columnsJSON: DataTableColumnJSON[] contractItemId: number; editableFieldsMap?: Map; onUpdateSuccess?: (updatedValues: Record | GenericData[]) => void; } 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' | null>(null); const [validationErrors, setValidationErrors] = React.useState([]); const [selectedTemplateId, setSelectedTemplateId] = React.useState(""); const [availableTemplates, setAvailableTemplates] = React.useState([]); // 클라이언트 사이드에서만 렌더링되도록 보장 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]; } // CONTENT가 있는 템플릿들 필터링 const validTemplates = templates.filter(template => { const hasSpreadListContent = template.SPR_LST_SETUP?.CONTENT; const hasSpreadItemContent = template.SPR_ITM_LST_SETUP?.CONTENT; const isValidType = template.TMPL_TYPE === "SPREAD_LIST" || template.TMPL_TYPE === "SPREAD_ITEM"; return isValidType && (hasSpreadListContent || hasSpreadItemContent); }); setAvailableTemplates(validTemplates); // 첫 번째 유효한 템플릿을 기본으로 선택 if (validTemplates.length > 0 && !selectedTemplateId) { setSelectedTemplateId(validTemplates[0].TMPL_ID); setTemplateType(validTemplates[0].TMPL_TYPE as 'SPREAD_LIST' | 'SPREAD_ITEM'); } }, [templateData, selectedTemplateId]); // 선택된 템플릿 변경 처리 const handleTemplateChange = (templateId: string) => { const template = availableTemplates.find(t => t.TMPL_ID === templateId); if (template) { setSelectedTemplateId(templateId); setTemplateType(template.TMPL_TYPE as 'SPREAD_LIST' | 'SPREAD_ITEM'); setHasChanges(false); setValidationErrors([]); // SpreadSheets 재초기화 if (currentSpread) { const template = availableTemplates.find(t => t.TMPL_ID === templateId); if (template) { initSpread(currentSpread, template); } } } }; // 현재 선택된 템플릿 가져오기 const selectedTemplate = React.useMemo(() => { return availableTemplates.find(t => t.TMPL_ID === selectedTemplateId); }, [availableTemplates, selectedTemplateId]); const editableFields = React.useMemo(() => { if (!selectedRow?.TAG_NO || !editableFieldsMap.has(selectedRow.TAG_NO)) { return []; } return editableFieldsMap.get(selectedRow.TAG_NO) || []; }, [selectedRow?.TAG_NO, editableFieldsMap]); // 필드가 편집 가능한지 판별하는 함수 const isFieldEditable = React.useCallback((attId: string, rowData?: GenericData) => { // columnsJSON에서 해당 attId의 shi 값 확인 const columnConfig = columnsJSON.find(col => col.key === attId); if (columnConfig?.shi === true) { return false; // columnsJSON에서 shi가 true이면 편집 불가 } // TAG_NO와 TAG_DESC는 기본적으로 편집 가능 (columnsJSON의 shi가 false인 경우) if (attId === "TAG_NO" || attId === "TAG_DESC") { return true; } // SPREAD_LIST인 경우는 기본적으로 편집 가능 (개별 행의 shi 상태는 저장시 확인) return true; }, [templateType, selectedRow, columnsJSON, editableFieldsMap]); // 편집 가능한 필드 개수 계산 const editableFieldsCount = React.useMemo(() => { return cellMappings.filter(m => m.isEditable).length; }, [cellMappings]); // 셀 주소를 행과 열로 변환하는 함수 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 validateCellValue = (value: any, columnType: ColumnType, options?: string[]): string | null => { if (value === undefined || value === null || value === "") { return null; // 빈 값은 별도 required 검증에서 처리 } 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": // 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; if (templateType === 'SPREAD_ITEM') { // 단일 행 검증 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 }); } } else if (templateType === 'SPREAD_LIST') { // 복수 행 검증 for (let i = 0; i < tableData.length; i++) { const targetRow = cellPos.row + i; const cellValue = activeSheet.getValue(targetRow, cellPos.col); const errorMessage = validateCellValue(cellValue, columnConfig.type, columnConfig.options); if (errorMessage) { errors.push({ cellAddress: `${String.fromCharCode(65 + cellPos.col)}${targetRow + 1}`, attId: mapping.attId, value: cellValue, expectedType: columnConfig.type, message: errorMessage }); } } } }); setValidationErrors(errors); return errors; }, [currentSpread, selectedTemplate, cellMappings, columnsJSON, templateType, tableData]); // LIST 타입 컬럼에 드롭다운 설정 const setupListValidation = React.useCallback((activeSheet: any, cellPos: {row: number, col: number}, options: string[], rowCount: number = 1) => { // ComboBox 셀 타입 생성 const comboBoxCellType = new GC.Spread.Sheets.CellTypes.ComboBox(); comboBoxCellType.items(options); comboBoxCellType.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); // 단일 셀 또는 범위에 적용 for (let i = 0; i < rowCount; i++) { const targetRow = cellPos.row + i; activeSheet.setCellType(targetRow, cellPos.col, comboBoxCellType); // 추가로 데이터 검증도 설정 const validator = GC.Spread.Sheets.DataValidation.createListValidator(options); activeSheet.setDataValidator(targetRow, cellPos.col, validator); } }, []); const initSpread = React.useCallback((spread: any, template?: TemplateItem) => { const workingTemplate = template || selectedTemplate; if (!spread || !workingTemplate) return; try { setCurrentSpread(spread); setHasChanges(false); setValidationErrors([]); // SPR_LST_SETUP.CONTENT와 SPR_ITM_LST_SETUP.CONTENT 중에서 값이 있는 것을 찾아서 사용 let contentJson = null; let dataSheets = null; // SPR_LST_SETUP.CONTENT가 있으면 우선 사용 if (workingTemplate.SPR_LST_SETUP?.CONTENT) { contentJson = workingTemplate.SPR_LST_SETUP.CONTENT; dataSheets = workingTemplate.SPR_LST_SETUP.DATA_SHEETS; console.log('Using SPR_LST_SETUP.CONTENT for template:', workingTemplate.NAME, '(TMPL_TYPE:', workingTemplate.TMPL_TYPE, ')'); } // SPR_ITM_LST_SETUP.CONTENT가 있으면 사용 else if (workingTemplate.SPR_ITM_LST_SETUP?.CONTENT) { contentJson = workingTemplate.SPR_ITM_LST_SETUP.CONTENT; dataSheets = workingTemplate.SPR_ITM_LST_SETUP.DATA_SHEETS; console.log('Using SPR_ITM_LST_SETUP.CONTENT for template:', workingTemplate.NAME, '(TMPL_TYPE:', workingTemplate.TMPL_TYPE, ')'); } if (!contentJson) { console.warn('No CONTENT found in template:', workingTemplate.NAME); return; } console.log(`Loading template content for: ${workingTemplate.NAME} (Type: ${workingTemplate.TMPL_TYPE})`); const jsonData = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson; // 렌더링 일시 중단 spread.suspendPaint(); try { // fromJSON으로 템플릿 구조 로드 spread.fromJSON(jsonData); // 활성 시트 가져오기 const activeSheet = spread.getActiveSheet(); // 시트 보호 먼저 해제 activeSheet.options.isProtected = false; // MAP_CELL_ATT 정보를 사용해서 데이터 매핑 if (dataSheets && dataSheets.length > 0) { const mappings: Array<{attId: string, cellAddress: string, isEditable: boolean}> = []; dataSheets.forEach(dataSheet => { if (dataSheet.MAP_CELL_ATT) { dataSheet.MAP_CELL_ATT.forEach(mapping => { const { ATT_ID, IN } = mapping; if (IN && IN.trim() !== "") { const cellPos = parseCellAddress(IN); if (cellPos) { const isEditable = isFieldEditable(ATT_ID); const columnConfig = columnsJSON.find(col => col.key === ATT_ID); mappings.push({ attId: ATT_ID, cellAddress: IN, isEditable: isEditable }); // 템플릿 타입에 따라 다른 데이터 처리 if (templateType === 'SPREAD_ITEM' && selectedRow) { // 단일 행 처리 (기존 로직) const cell = activeSheet.getCell(cellPos.row, cellPos.col); const value = selectedRow[ATT_ID]; if (value !== undefined && value !== null) { cell.value(value); } if (value === undefined || value === null) { cell.value(null); } // LIST 타입 컬럼에 드롭다운 설정 if (columnConfig?.type === "LIST" && columnConfig.options && isEditable) { setupListValidation(activeSheet, cellPos, columnConfig.options, 1); } // 스타일 적용 cell.locked(!isEditable); const existingStyle = activeSheet.getStyle(cellPos.row, cellPos.col); const newStyle = existingStyle ? Object.assign(new GC.Spread.Sheets.Style(), existingStyle) : new GC.Spread.Sheets.Style(); if (isEditable) { newStyle.backColor = "#f0fdf4"; } else { newStyle.backColor = "#f9fafb"; newStyle.foreColor = "#6b7280"; } activeSheet.setStyle(cellPos.row, cellPos.col, newStyle); } else if (templateType === 'SPREAD_LIST' && tableData.length > 0) { // 복수 행 처리 - 첫 번째 행부터 시작해서 아래로 채움 // LIST 타입 컬럼에 드롭다운 설정 (모든 행에 대해) if (columnConfig?.type === "LIST" && columnConfig.options && isEditable) { setupListValidation(activeSheet, cellPos, columnConfig.options, tableData.length); } // 필요한 경우 행 추가 (tableData 길이만큼 충분히 확보) const currentRowCount = activeSheet.getRowCount(); const requiredRowCount = cellPos.row + tableData.length; if (requiredRowCount > currentRowCount) { activeSheet.setRowCount(requiredRowCount + 10); // 여유분 추가 } tableData.forEach((rowData, index) => { const targetRow = cellPos.row + index; const cell = activeSheet.getCell(targetRow, cellPos.col); const value = rowData[ATT_ID]; if (value !== undefined && value !== null) { cell.value(value); } if (value === undefined || value === null) { cell.value(null); } const cellEditable = isFieldEditable(ATT_ID, rowData); cell.locked(!cellEditable); // 스타일 적용 const existingStyle = activeSheet.getStyle(targetRow, cellPos.col); const newStyle = existingStyle ? Object.assign(new GC.Spread.Sheets.Style(), existingStyle) : new GC.Spread.Sheets.Style(); if (cellEditable) { newStyle.backColor = "#f0fdf4"; } else { newStyle.backColor = "#f9fafb"; newStyle.foreColor = "#6b7280"; } activeSheet.setStyle(targetRow, cellPos.col, newStyle); }); } console.log(`Mapped ${ATT_ID} to cell ${IN} - ${isEditable ? 'Editable' : 'Read-only'}`); } } }); } }); setCellMappings(mappings); // 시트 보호 설정 activeSheet.options.isProtected = true; activeSheet.options.protectionOptions = { allowSelectLockedCells: true, allowSelectUnlockedCells: true, allowSort: false, allowFilter: false, allowEditObjects: false, allowResizeRows: false, allowResizeColumns: false }; // 이벤트 리스너 추가 activeSheet.bind(GC.Spread.Sheets.Events.CellChanged, (event: any, info: any) => { console.log('Cell changed:', info); setHasChanges(true); }); activeSheet.bind(GC.Spread.Sheets.Events.ValueChanged, (event: any, info: any) => { console.log('Value changed:', info); setHasChanges(true); }); // 복사 붙여넣기 이벤트 추가 activeSheet.bind(GC.Spread.Sheets.Events.ClipboardPasted, (event: any, info: any) => { console.log('Clipboard pasted:', info); setHasChanges(true); }); // 편집 시작 시 읽기 전용 셀 확인 activeSheet.bind(GC.Spread.Sheets.Events.EditStarting, (event: any, info: any) => { const mapping = mappings.find(m => { const cellPos = parseCellAddress(m.cellAddress); return cellPos && cellPos.row <= info.row && cellPos.col === info.col; }); if (mapping) { // columnsJSON에서 해당 필드의 shi 확인 const columnConfig = columnsJSON.find(col => col.key === mapping.attId); if (columnConfig?.shi === true) { toast.warning(`${mapping.attId} field is read-only (Column configuration)`); info.cancel = true; return; } // SPREAD_LIST인 경우 해당 행의 데이터에서 shi 확인 if (templateType === 'SPREAD_LIST') { const dataRowIndex = info.row - parseCellAddress(mapping.cellAddress)!.row; const rowData = tableData[dataRowIndex]; if (rowData && rowData.shi === true) { toast.warning(`Row ${dataRowIndex + 1}: ${mapping.attId} field is read-only (SHI mode)`); info.cancel = true; return; } } if (!mapping.isEditable) { toast.warning(`${mapping.attId} field is read-only`); info.cancel = true; } } }); // 편집 종료 시 데이터 검증 activeSheet.bind(GC.Spread.Sheets.Events.EditEnded, (event: any, info: any) => { const mapping = mappings.find(m => { const cellPos = parseCellAddress(m.cellAddress); return cellPos && cellPos.row <= info.row && cellPos.col === info.col; }); if (mapping) { const columnConfig = columnsJSON.find(col => col.key === mapping.attId); if (columnConfig) { const cellValue = activeSheet.getValue(info.row, info.col); const errorMessage = validateCellValue(cellValue, columnConfig.type, columnConfig.options); if (errorMessage) { toast.warning(`Invalid value in ${mapping.attId}: ${errorMessage}`); // 스타일을 오류 상태로 변경 const errorStyle = new GC.Spread.Sheets.Style(); errorStyle.backColor = "#fef2f2"; errorStyle.foreColor = "#dc2626"; activeSheet.setStyle(info.row, info.col, errorStyle); } else { // 정상 스타일로 복원 const cellEditable = isFieldEditable(mapping.attId); const normalStyle = new GC.Spread.Sheets.Style(); normalStyle.backColor = cellEditable ? "#f0fdf4" : "#f9fafb"; normalStyle.foreColor = cellEditable ? "#000000" : "#6b7280"; activeSheet.setStyle(info.row, info.col, normalStyle); } } } }); } } finally { spread.resumePaint(); } } catch (error) { console.error('Error initializing spread:', error); toast.error('Failed to load template'); if (spread && spread.resumePaint) { spread.resumePaint(); } } }, [selectedTemplate, templateType, selectedRow, tableData, isFieldEditable, columnsJSON, setupListValidation, validateCellValue]); // 변경사항 저장 함수 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' && 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 => { // columnsJSON에서 해당 필드의 shi 확인 const columnConfig = columnsJSON.find(col => col.key === mapping.attId); const isColumnEditable = columnConfig?.shi !== true; const isRowEditable = originalRow.shi !== true; if (mapping.isEditable && isColumnEditable && isRowEditable) { const cellPos = parseCellAddress(mapping.cellAddress); if (cellPos) { const targetRow = cellPos.row + i; const cellValue = activeSheet.getValue(targetRow, cellPos.col); // 값이 변경되었는지 확인 if (cellValue !== originalRow[mapping.attId]) { dataToSave[mapping.attId] = cellValue; hasRowChanges = true; } } } }); // 변경사항이 있는 행만 저장 if (hasRowChanges) { dataToSave.TAG_NO = originalRow.TAG_NO; // 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 - {formCode}
{/* 템플릿 선택 */} {availableTemplates.length > 1 && (
Template:
)} {/* 템플릿 정보 */} {selectedTemplate && (
Template Type: {selectedTemplate.TMPL_TYPE === 'SPREAD_LIST' ? 'List View (SPREAD_LIST)' : 'Item View (SPREAD_ITEM)'} {templateType === 'SPREAD_ITEM' && selectedRow && ( • Selected TAG_NO: {selectedRow.TAG_NO || 'N/A'} )} {templateType === 'SPREAD_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 )}
{/* SpreadSheets 컴포넌트 영역 */}
{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 && ( )}
); }