"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";
import { setupSpreadJSLicense } from "@/lib/spread-js/license-utils";
const SpreadSheets = dynamic(
() => import("@mescius/spread-sheets-react").then(mod => mod.SpreadSheets),
{
ssr: false,
loading: () => (
Loading SpreadSheets...
)
}
);
// 도메인별 라이선스 설정
if (typeof window !== 'undefined') {
setupSpreadJSLicense(GC);
}
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((activeSheet: any, row: number, col: number, isEditable: boolean) => {
// 기존 스타일 가져오기 (없으면 새로 생성)
const existingStyle = activeSheet.getStyle(row, col) || new GC.Spread.Sheets.Style();
// backColor만 수정
if (isEditable) {
existingStyle.backColor = "#bbf7d0";
} else {
existingStyle.backColor = "#e5e7eb";
// 읽기 전용일 때만 텍스트 색상 변경 (선택사항)
existingStyle.foreColor = "#4b5563";
}
return existingStyle;
}, []);
const setBatchStyles = React.useCallback((
activeSheet: any,
stylesToSet: Array<{row: number, col: number, isEditable: boolean}>
) => {
console.log(`🎨 Setting ${stylesToSet.length} styles in batch`);
// 🔧 개별 셀별로 스타일과 잠금 상태 설정 (편집 권한 보장)
stylesToSet.forEach(({row, col, isEditable}) => {
try {
const cell = activeSheet.getCell(row, col);
const style = createCellStyle(activeSheet, row, col, isEditable);
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(activeSheet, cellPos.row, cellPos.col, 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(activeSheet, cellPos.row, cellPos.col, 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(activeSheet, info.row, info.col, 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);
const activeSheetName = workingTemplate.SPR_LST_SETUP?.ACT_SHEET;
const matchingDataSheets = dataSheets.filter(ds =>
ds.SHEET_NAME === activeSheetName
);
if (activeSheetName && spread.getSheetFromName(activeSheetName)) {
spread.setActiveSheet(activeSheetName);
}
matchingDataSheets.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);
const activeSheetName = workingTemplate.SPR_ITM_LST_SETUP?.ACT_SHEET;
const matchingDataSheets = dataSheets.filter(ds =>
ds.SHEET_NAME === activeSheetName
);
if (activeSheetName && spread.getSheetFromName(activeSheetName)) {
spread.setActiveSheet(activeSheetName);
}
matchingDataSheets.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(activeSheet, cellPos.row, cellPos.col, 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, createCellStyle, isFieldEditable, columnsJSON, setupOptimizedListValidation, parseCellAddress, ensureRowCapacity, getCellAddress]);
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 (
);
}