summaryrefslogtreecommitdiff
path: root/components/form-data
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-08-21 06:57:36 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-08-21 06:57:36 +0000
commit02b1cf005cf3e1df64183d20ba42930eb2767a9f (patch)
treee932c54d5260b0e6fda2b46be2a6ba1c3ee30434 /components/form-data
parentd78378ecd7ceede1429359f8058c7a99ac34b1b7 (diff)
(대표님, 최겸) 설계메뉴추가, 작업사항 업데이트
설계메뉴 - 문서관리 설계메뉴 - 벤더 데이터 gtc 메뉴 업데이트 정보시스템 - 메뉴리스트 및 정보 업데이트 파일 라우트 업데이트 엑셀임포트 개선 기본계약 개선 벤더 가입과정 변경 및 개선 벤더 기본정보 - pq 돌체 오류 수정 및 개선 벤더 로그인 과정 이메일 오류 수정
Diffstat (limited to 'components/form-data')
-rw-r--r--components/form-data/export-excel-form.tsx156
-rw-r--r--components/form-data/form-data-table-columns.tsx2
-rw-r--r--components/form-data/form-data-table.tsx3
-rw-r--r--components/form-data/import-excel-form.tsx76
-rw-r--r--components/form-data/spreadJS-dialog copy 4.tsx1491
-rw-r--r--components/form-data/spreadJS-dialog.tsx500
-rw-r--r--components/form-data/spreadJS-dialog_designer.tsx1404
-rw-r--r--components/form-data/update-form-sheet.tsx8
8 files changed, 3425 insertions, 215 deletions
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<string, string[]>; // 새로 추가
onPendingChange?: (isPending: boolean) => void;
validateData?: boolean; // Option to enable/disable data validation
}
@@ -52,6 +53,63 @@ export interface ExportExcelResult {
}
/**
+ * Check if a field is editable for a specific TAG_NO
+ */
+function isFieldEditable(
+ column: DataTableColumnJSON,
+ tagNo: string,
+ editableFieldsMap: Map<string, string[]>
+): 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, string[]>
+): 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
*/
function validateTableData(
@@ -276,6 +334,7 @@ export async function exportExcelData({
tableData,
columnsJSON,
formCode,
+ editableFieldsMap = new Map(), // 새로 추가
onPendingChange,
validateData = true
}: ExportExcelOptions): Promise<ExportExcelResult> {
@@ -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<string, string[]>; // 새로 추가
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<string, string[]>; // 새로 추가
onPendingChange?: (isPending: boolean) => void;
}
@@ -50,6 +52,31 @@ interface GenericData {
}
/**
+ * Check if a field is editable for a specific TAG_NO
+ */
+function isFieldEditable(
+ column: DataTableColumnJSON,
+ tagNo: string,
+ editableFieldsMap: Map<string, string[]>
+): 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
*/
function createImportErrorSheet(workbook: ExcelJS.Workbook, errors: ImportError[], headerErrors?: string[]) {
@@ -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<ImportExcelResult> {
@@ -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: () => (
+ <div className="flex items-center justify-center h-full">
+ <Loader className="mr-2 h-4 w-4 animate-spin" />
+ Loading SpreadSheets...
+ </div>
+ )
+ }
+);
+
+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<string>;
+ 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<string>;
+ ATTS: Array<{}>;
+ };
+ SPR_ITM_LST_SETUP: {
+ ACT_SHEET: string;
+ HIDN_SHEETS: Array<string>;
+ 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<string, string[]>;
+ onUpdateSuccess?: (updatedValues: Record<string, any> | GenericData[]) => void;
+}
+
+// 🚀 로딩 프로그레스 컴포넌트
+interface LoadingProgressProps {
+ phase: string;
+ progress: number;
+ total: number;
+ isVisible: boolean;
+}
+
+const LoadingProgress: React.FC<LoadingProgressProps> = ({ phase, progress, total, isVisible }) => {
+ const percentage = total > 0 ? Math.round((progress / total) * 100) : 0;
+
+ if (!isVisible) return null;
+
+ return (
+ <div className="absolute inset-0 bg-white/90 flex items-center justify-center z-50">
+ <div className="bg-white rounded-lg shadow-lg border p-6 min-w-[300px]">
+ <div className="flex items-center space-x-3 mb-4">
+ <Loader className="h-5 w-5 animate-spin text-blue-600" />
+ <span className="font-medium text-gray-900">Loading Template</span>
+ </div>
+
+ <div className="space-y-2">
+ <div className="text-sm text-gray-600">{phase}</div>
+ <div className="w-full bg-gray-200 rounded-full h-2">
+ <div
+ className="bg-blue-600 h-2 rounded-full transition-all duration-300 ease-out"
+ style={{ width: `${percentage}%` }}
+ />
+ </div>
+ <div className="text-xs text-gray-500 text-right">
+ {progress.toLocaleString()} / {total.toLocaleString()} ({percentage}%)
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+};
+
+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<any>(null);
+ const [cellMappings, setCellMappings] = React.useState<CellMapping[]>([]);
+ const [isClient, setIsClient] = React.useState(false);
+ const [templateType, setTemplateType] = React.useState<'SPREAD_LIST' | 'SPREAD_ITEM' | 'GRD_LIST' | null>(null);
+ const [validationErrors, setValidationErrors] = React.useState<ValidationError[]>([]);
+ const [selectedTemplateId, setSelectedTemplateId] = React.useState<string>("");
+ const [availableTemplates, setAvailableTemplates] = React.useState<TemplateItem[]>([]);
+
+ // 🆕 로딩 상태 추가
+ 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<number, Array<{row: number, value: any}>>();
+
+ 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 (
+ <Dialog open={isOpen} onOpenChange={onClose}>
+ <DialogContent
+ className="w-[90vw] max-w-[1400px] h-[85vh] flex flex-col fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] z-50"
+ >
+ <DialogHeader className="flex-shrink-0">
+ <DialogTitle>SEDP Template - {formCode}</DialogTitle>
+ <DialogDescription>
+ <div className="space-y-3">
+ {availableTemplates.length > 1 && (
+ <div className="flex items-center gap-4">
+ <span className="text-sm font-medium">Template:</span>
+ <Select value={selectedTemplateId} onValueChange={handleTemplateChange}>
+ <SelectTrigger className="w-64">
+ <SelectValue placeholder="Select a template" />
+ </SelectTrigger>
+ <SelectContent>
+ {availableTemplates.map(template => (
+ <SelectItem key={template.TMPL_ID} value={template.TMPL_ID}>
+ {template.NAME} ({template.TMPL_TYPE})
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ )}
+
+ {selectedTemplate && (
+ <div className="flex items-center gap-4 text-sm">
+ <span className="font-medium text-blue-600">
+ Template Type: {
+ templateType === 'SPREAD_LIST' ? 'List View (SPREAD_LIST)' :
+ templateType === 'SPREAD_ITEM' ? 'Item View (SPREAD_ITEM)' :
+ 'Grid List View (GRD_LIST)'
+ }
+ </span>
+ {templateType === 'SPREAD_ITEM' && selectedRow && (
+ <span>• Selected TAG_NO: {selectedRow.TAG_NO || 'N/A'}</span>
+ )}
+ {(templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && (
+ <span>• {dataCount} rows</span>
+ )}
+ {hasChanges && (
+ <span className="text-orange-600 font-medium">
+ • Unsaved changes
+ </span>
+ )}
+ {validationErrors.length > 0 && (
+ <span className="text-red-600 font-medium flex items-center">
+ <AlertTriangle className="w-4 h-4 mr-1" />
+ {validationErrors.length} validation errors
+ </span>
+ )}
+ </div>
+ )}
+
+ <div className="flex items-center gap-4 text-xs">
+ <span className="text-muted-foreground">
+ <span className="inline-block w-3 h-3 bg-green-100 border border-green-400 mr-1"></span>
+ Editable fields
+ </span>
+ <span className="text-muted-foreground">
+ <span className="inline-block w-3 h-3 bg-gray-100 border border-gray-300 mr-1"></span>
+ Read-only fields
+ </span>
+ <span className="text-muted-foreground">
+ <span className="inline-block w-3 h-3 bg-red-100 border border-red-300 mr-1"></span>
+ Validation errors
+ </span>
+ {cellMappings.length > 0 && (
+ <span className="text-blue-600">
+ {editableFieldsCount} of {cellMappings.length} fields editable
+ </span>
+ )}
+ </div>
+ </div>
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="flex-1 overflow-hidden relative">
+ {/* 🆕 로딩 프로그레스 오버레이 */}
+ <LoadingProgress
+ phase={loadingProgress?.phase || ''}
+ progress={loadingProgress?.progress || 0}
+ total={loadingProgress?.total || 100}
+ isVisible={isInitializing && !!loadingProgress}
+ />
+
+ {selectedTemplate && isClient && isDataValid ? (
+ <SpreadSheets
+ key={`${templateType}-${selectedTemplate.TMPL_ID}-${selectedTemplateId}`}
+ workbookInitialized={initSpread}
+ hostStyle={hostStyle}
+ />
+ ) : (
+ <div className="flex items-center justify-center h-full text-muted-foreground">
+ {!isClient ? (
+ <>
+ <Loader className="mr-2 h-4 w-4 animate-spin" />
+ Loading...
+ </>
+ ) : !selectedTemplate ? (
+ "No template available"
+ ) : !isDataValid ? (
+ `No ${templateType === 'SPREAD_ITEM' ? 'selected row' : 'data'} available`
+ ) : (
+ "Template not ready"
+ )}
+ </div>
+ )}
+ </div>
+
+ <DialogFooter className="flex-shrink-0">
+ <div className="flex items-center gap-2">
+ <Button variant="outline" onClick={onClose}>
+ Close
+ </Button>
+
+ {hasChanges && (
+ <Button
+ variant="default"
+ onClick={handleSaveChanges}
+ disabled={isPending || validationErrors.length > 0}
+ >
+ {isPending ? (
+ <>
+ <Loader className="mr-2 h-4 w-4 animate-spin" />
+ Saving...
+ </>
+ ) : (
+ <>
+ <Save className="mr-2 h-4 w-4" />
+ Save Changes
+ </>
+ )}
+ </Button>
+ )}
+
+ {validationErrors.length > 0 && (
+ <Button
+ variant="outline"
+ onClick={validateAllData}
+ className="text-red-600 border-red-300 hover:bg-red-50"
+ >
+ <AlertTriangle className="mr-2 h-4 w-4" />
+ Check Errors ({validationErrors.length})
+ </Button>
+ )}
+ </div>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+} \ 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: () => (
+ <div className="flex items-center justify-center h-full">
+ <Loader className="mr-2 h-4 w-4 animate-spin" />
+ Loading Designer...
+ </div>
+ )
+ }
+);
+
+// 라이센스 키 설정 (두 개의 환경변수 사용)
+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<string>;
+ 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<string>;
+ ATTS: Array<{}>;
+ };
+ SPR_ITM_LST_SETUP: {
+ ACT_SHEET: string;
+ HIDN_SHEETS: Array<string>;
+ 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<string, string[]>;
+ onUpdateSuccess?: (updatedValues: Record<string, any> | GenericData[]) => void;
+}
+
+// 🚀 로딩 프로그레스 컴포넌트
+interface LoadingProgressProps {
+ phase: string;
+ progress: number;
+ total: number;
+ isVisible: boolean;
+}
+
+const LoadingProgress: React.FC<LoadingProgressProps> = ({ phase, progress, total, isVisible }) => {
+ const percentage = total > 0 ? Math.round((progress / total) * 100) : 0;
+
+ if (!isVisible) return null;
+
+ return (
+ <div className="absolute inset-0 bg-white/90 flex items-center justify-center z-50">
+ <div className="bg-white rounded-lg shadow-lg border p-6 min-w-[300px]">
+ <div className="flex items-center space-x-3 mb-4">
+ <Loader className="h-5 w-5 animate-spin text-blue-600" />
+ <span className="font-medium text-gray-900">Loading Template</span>
+ </div>
+
+ <div className="space-y-2">
+ <div className="text-sm text-gray-600">{phase}</div>
+ <div className="w-full bg-gray-200 rounded-full h-2">
+ <div
+ className="bg-blue-600 h-2 rounded-full transition-all duration-300 ease-out"
+ style={{ width: `${percentage}%` }}
+ />
+ </div>
+ <div className="text-xs text-gray-500 text-right">
+ {progress.toLocaleString()} / {total.toLocaleString()} ({percentage}%)
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+};
+
+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<any>(null);
+ const [cellMappings, setCellMappings] = React.useState<CellMapping[]>([]);
+ const [isClient, setIsClient] = React.useState(false);
+ const [templateType, setTemplateType] = React.useState<'SPREAD_LIST' | 'SPREAD_ITEM' | 'GRD_LIST' | null>(null);
+ const [validationErrors, setValidationErrors] = React.useState<ValidationError[]>([]);
+ const [selectedTemplateId, setSelectedTemplateId] = React.useState<string>("");
+ const [availableTemplates, setAvailableTemplates] = React.useState<TemplateItem[]>([]);
+
+ // 🆕 로딩 상태 추가
+ 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<number, Array<{row: number, value: any}>>();
+
+ 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 (
+ <Dialog open={isOpen} onOpenChange={onClose}>
+ <DialogContent
+ className="w-[90vw] max-w-[1400px] h-[85vh] flex flex-col fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] z-50"
+ >
+ <DialogHeader className="flex-shrink-0">
+ <DialogTitle>SEDP Template Designer - {formCode}</DialogTitle>
+ <DialogDescription>
+ <div className="space-y-3">
+ {availableTemplates.length > 1 && (
+ <div className="flex items-center gap-4">
+ <span className="text-sm font-medium">Template:</span>
+ <Select value={selectedTemplateId} onValueChange={handleTemplateChange}>
+ <SelectTrigger className="w-64">
+ <SelectValue placeholder="Select a template" />
+ </SelectTrigger>
+ <SelectContent>
+ {availableTemplates.map(template => (
+ <SelectItem key={template.TMPL_ID} value={template.TMPL_ID}>
+ {template.NAME} ({template.TMPL_TYPE})
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ )}
+
+ {selectedTemplate && (
+ <div className="flex items-center gap-4 text-sm">
+ <span className="font-medium text-blue-600">
+ Template Type: {
+ templateType === 'SPREAD_LIST' ? 'List View (SPREAD_LIST)' :
+ templateType === 'SPREAD_ITEM' ? 'Item View (SPREAD_ITEM)' :
+ 'Grid List View (GRD_LIST)'
+ }
+ </span>
+ {templateType === 'SPREAD_ITEM' && selectedRow && (
+ <span>• Selected TAG_NO: {selectedRow.TAG_NO || 'N/A'}</span>
+ )}
+ {(templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && (
+ <span>• {dataCount} rows</span>
+ )}
+ {hasChanges && (
+ <span className="text-orange-600 font-medium">
+ • Unsaved changes
+ </span>
+ )}
+ {validationErrors.length > 0 && (
+ <span className="text-red-600 font-medium flex items-center">
+ <AlertTriangle className="w-4 h-4 mr-1" />
+ {validationErrors.length} validation errors
+ </span>
+ )}
+ </div>
+ )}
+
+ <div className="flex items-center gap-4 text-xs">
+ <span className="text-muted-foreground">
+ <span className="inline-block w-3 h-3 bg-green-100 border border-green-400 mr-1"></span>
+ Editable fields
+ </span>
+ <span className="text-muted-foreground">
+ <span className="inline-block w-3 h-3 bg-gray-100 border border-gray-300 mr-1"></span>
+ Read-only fields
+ </span>
+ <span className="text-muted-foreground">
+ <span className="inline-block w-3 h-3 bg-red-100 border border-red-300 mr-1"></span>
+ Validation errors
+ </span>
+ {cellMappings.length > 0 && (
+ <span className="text-blue-600">
+ {editableFieldsCount} of {cellMappings.length} fields editable
+ </span>
+ )}
+ </div>
+ </div>
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="flex-1 overflow-hidden relative">
+ {/* 🆕 로딩 프로그레스 오버레이 */}
+ <LoadingProgress
+ phase={loadingProgress?.phase || ''}
+ progress={loadingProgress?.progress || 0}
+ total={loadingProgress?.total || 100}
+ isVisible={isInitializing && !!loadingProgress}
+ />
+
+ {selectedTemplate && isClient && isDataValid ? (
+ <Designer
+ key={`${templateType}-${selectedTemplate.TMPL_ID}-${selectedTemplateId}`}
+ workbookInitialized={initSpread}
+ hostStyle={hostStyle}
+ />
+ ) : (
+ <div className="flex items-center justify-center h-full text-muted-foreground">
+ {!isClient ? (
+ <>
+ <Loader className="mr-2 h-4 w-4 animate-spin" />
+ Loading...
+ </>
+ ) : !selectedTemplate ? (
+ "No template available"
+ ) : !isDataValid ? (
+ `No ${templateType === 'SPREAD_ITEM' ? 'selected row' : 'data'} available`
+ ) : (
+ "Template not ready"
+ )}
+ </div>
+ )}
+ </div>
+
+ <DialogFooter className="flex-shrink-0">
+ <div className="flex items-center gap-2">
+ <Button variant="outline" onClick={onClose}>
+ Close
+ </Button>
+
+ {hasChanges && (
+ <Button
+ variant="default"
+ onClick={handleSaveChanges}
+ disabled={isPending || validationErrors.length > 0}
+ >
+ {isPending ? (
+ <>
+ <Loader className="mr-2 h-4 w-4 animate-spin" />
+ Saving...
+ </>
+ ) : (
+ <>
+ <Save className="mr-2 h-4 w-4" />
+ Save Changes
+ </>
+ )}
+ </Button>
+ )}
+
+ {validationErrors.length > 0 && (
+ <Button
+ variant="outline"
+ onClick={validateAllData}
+ className="text-red-600 border-red-300 hover:bg-red-50"
+ >
+ <AlertTriangle className="mr-2 h-4 w-4" />
+ Check Errors ({validationErrors.length})
+ </Button>
+ )}
+ </div>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+} \ 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");