From e9897d416b3e7327bbd4d4aef887eee37751ae82 Mon Sep 17 00:00:00 2001
From: dujinkim <dujin.kim@dtsolution.co.kr>
Date: Fri, 27 Jun 2025 01:16:20 +0000
Subject: (대표님) 20250627 오전 10시 작업사항
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 components/form-data/spreadJS-dialog.tsx | 317 +++++++++++++++++++++++++------
 1 file changed, 258 insertions(+), 59 deletions(-)

(limited to 'components/form-data/spreadJS-dialog.tsx')

diff --git a/components/form-data/spreadJS-dialog.tsx b/components/form-data/spreadJS-dialog.tsx
index 4a8550cb..5a51c2b5 100644
--- a/components/form-data/spreadJS-dialog.tsx
+++ b/components/form-data/spreadJS-dialog.tsx
@@ -1,10 +1,10 @@
 "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 { GenericData } from "./export-excel-form";
-import { SpreadSheets, Worksheet, Column } from "@mescius/spread-sheets-react";
 import * as GC from "@mescius/spread-sheets";
 import { toast } from "sonner";
 import { updateFormDataInDB } from "@/lib/forms/services";
@@ -16,6 +16,26 @@ import {
   SelectTrigger,
   SelectValue,
 } from "@/components/ui/select";
+import '@mescius/spread-sheets/styles/gc.spread.sheets.excel2016colorful.css';
+
+// SpreadSheets를 동적으로 import (SSR 비활성화)
+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;
@@ -24,7 +44,7 @@ interface TemplateItem {
   SPR_LST_SETUP: {
     ACT_SHEET: string;
     HIDN_SHEETS: Array<string>;
-    CONTENT?: string; // SpreadSheets JSON
+    CONTENT?: string;
     DATA_SHEETS: Array<{
       SHEET_NAME: string;
       REG_TYPE_ID: string;
@@ -42,7 +62,7 @@ interface TemplateItem {
   SPR_ITM_LST_SETUP: {
     ACT_SHEET: string;
     HIDN_SHEETS: Array<string>;
-    CONTENT?: string; // SpreadSheets JSON
+    CONTENT?: string;
     DATA_SHEETS: Array<{
       SHEET_NAME: string;
       REG_TYPE_ID: string;
@@ -57,11 +77,11 @@ interface TemplateItem {
 interface TemplateViewDialogProps {
   isOpen: boolean;
   onClose: () => void;
-  templateData: TemplateItem[] | any; // 배열 또는 기존 형태
+  templateData: TemplateItem[] | any;
   selectedRow: GenericData;
   formCode: string;
   contractItemId: number;
-  /** 업데이트 성공 시 호출될 콜백 */
+  editableFieldsMap?: Map<string, string[]>; // 편집 가능 필드 정보
   onUpdateSuccess?: (updatedValues: Record<string, any>) => void;
 }
 
@@ -72,6 +92,7 @@ export function TemplateViewDialog({
   selectedRow,
   formCode,
   contractItemId,
+  editableFieldsMap = new Map(),
   onUpdateSuccess
 }: TemplateViewDialogProps) {
   const [hostStyle, setHostStyle] = React.useState({
@@ -83,21 +104,25 @@ export function TemplateViewDialog({
   const [hasChanges, setHasChanges] = React.useState(false);
   const [currentSpread, setCurrentSpread] = React.useState<any>(null);
   const [selectedTemplateId, setSelectedTemplateId] = React.useState<string>("");
+  const [cellMappings, setCellMappings] = React.useState<Array<{attId: string, cellAddress: string, isEditable: boolean}>>([]);
+  const [isClient, setIsClient] = React.useState(false);
+
+  // 클라이언트 사이드에서만 렌더링되도록 보장
+  React.useEffect(() => {
+    setIsClient(true);
+  }, []);
 
   // 템플릿 데이터를 배열로 정규화하고 CONTENT가 있는 것만 필터링
   const normalizedTemplates = React.useMemo((): TemplateItem[] => {
     if (!templateData) return [];
     
     let templates: TemplateItem[];
-    // 이미 배열인 경우
     if (Array.isArray(templateData)) {
       templates = templateData as TemplateItem[];
     } else {
-      // 기존 형태인 경우 (하위 호환성)
       templates = [templateData as TemplateItem];
     }
     
-    // CONTENT가 있는 템플릿만 필터링
     return templates.filter(template => {
       const sprContent = template.SPR_LST_SETUP?.CONTENT;
       const sprItmContent = template.SPR_ITM_LST_SETUP?.CONTENT;
@@ -107,10 +132,54 @@ export function TemplateViewDialog({
 
   // 선택된 템플릿 가져오기
   const selectedTemplate = React.useMemo(() => {
-    if (!selectedTemplateId) return normalizedTemplates[0]; // 기본값: 첫 번째 템플릿
+    if (!selectedTemplateId) return normalizedTemplates[0];
     return normalizedTemplates.find(t => t.TMPL_ID === selectedTemplateId) || normalizedTemplates[0];
   }, [normalizedTemplates, selectedTemplateId]);
 
+  // 현재 TAG의 편집 가능한 필드 목록 가져오기
+  const editableFields = React.useMemo(() => {
+    if (!selectedRow?.TAG_NO || !editableFieldsMap.has(selectedRow.TAG_NO)) {
+      return [];
+    }
+    return editableFieldsMap.get(selectedRow.TAG_NO) || [];
+  }, [selectedRow?.TAG_NO, editableFieldsMap]);
+
+  // 필드가 편집 가능한지 판별하는 함수
+  const isFieldEditable = React.useCallback((attId: string) => {
+    // TAG_NO와 TAG_DESC는 기본적으로 편집 가능
+    if (attId === "TAG_NO" || attId === "TAG_DESC") {
+      return true;
+    }
+    
+    // editableFieldsMap이 있으면 해당 리스트에 있는지 확인
+    if (selectedRow?.TAG_NO && editableFieldsMap.has(selectedRow.TAG_NO)) {
+      return editableFields.includes(attId);
+    }
+    
+    return false;
+  }, [selectedRow?.TAG_NO, editableFieldsMap, editableFields]);
+
+  // 셀 주소를 행과 열로 변환하는 함수 (예: "M1" -> {row: 0, col: 12})
+  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;
+    
+    // 열 문자를 숫자로 변환 (A=0, B=1, ..., Z=25, AA=26, ...)
+    let col = 0;
+    for (let i = 0; i < colStr.length; i++) {
+      col = col * 26 + (colStr.charCodeAt(i) - 65 + 1);
+    }
+    col -= 1; // 0-based index로 변환
+    
+    const row = parseInt(rowStr) - 1; // 0-based index로 변환
+    
+    return { row, col };
+  };
+
   // 템플릿 변경 시 기본 선택
   React.useEffect(() => {
     if (normalizedTemplates.length > 0 && !selectedTemplateId) {
@@ -119,63 +188,176 @@ export function TemplateViewDialog({
   }, [normalizedTemplates, selectedTemplateId]);
 
   const initSpread = React.useCallback((spread: any) => {
-    if (!spread || !selectedTemplate) return;
+    if (!spread || !selectedTemplate || !selectedRow) return;
 
     try {
       setCurrentSpread(spread);
-      setHasChanges(false); // 템플릿 로드 시 변경사항 초기화
+      setHasChanges(false);
 
-      // CONTENT 찾기 (SPR_LST_SETUP 또는 SPR_ITM_LST_SETUP 중 하나)
+      // CONTENT 찾기
       let contentJson = null;
+      let dataSheets = null;
+      
       if (selectedTemplate.SPR_LST_SETUP?.CONTENT) {
         contentJson = selectedTemplate.SPR_LST_SETUP.CONTENT;
+        dataSheets = selectedTemplate.SPR_LST_SETUP.DATA_SHEETS;
         console.log('Using SPR_LST_SETUP.CONTENT for template:', selectedTemplate.NAME);
       } else if (selectedTemplate.SPR_ITM_LST_SETUP?.CONTENT) {
         contentJson = selectedTemplate.SPR_ITM_LST_SETUP.CONTENT;
+        dataSheets = selectedTemplate.SPR_ITM_LST_SETUP.DATA_SHEETS;
         console.log('Using SPR_ITM_LST_SETUP.CONTENT for template:', selectedTemplate.NAME);
       }
 
-      if (contentJson) {
-        console.log('Loading template content for:', selectedTemplate.NAME);
-        
-        const jsonData = typeof contentJson === 'string' 
-          ? JSON.parse(contentJson) 
-          : contentJson;
-
-        // fromJSON으로 템플릿 구조 로드
-        spread.fromJSON(jsonData);
-      } else {
+      if (!contentJson) {
         console.warn('No CONTENT found in template:', selectedTemplate.NAME);
         return;
       }
 
-      // 값 변경 이벤트 리스너 추가 (간단한 변경사항 감지만)
-      const activeSheet = spread.getActiveSheet();
+      console.log('Loading template content for:', selectedTemplate.NAME);
       
-      activeSheet.bind(GC.Spread.Sheets.Events.CellChanged, (event: any, info: any) => {
-        console.log('Cell changed:', info);
-        setHasChanges(true);
-      });
+      const jsonData = typeof contentJson === 'string' 
+        ? JSON.parse(contentJson) 
+        : contentJson;
 
-      activeSheet.bind(GC.Spread.Sheets.Events.ValueChanged, (event: any, info: any) => {
-        console.log('Value changed:', info);
-        setHasChanges(true);
-      });
+      // 렌더링 일시 중단 (성능 향상)
+      spread.suspendPaint();
+
+      try {
+        // fromJSON으로 템플릿 구조 로드
+        spread.fromJSON(jsonData);
+
+        // 활성 시트 가져오기
+        const activeSheet = spread.getActiveSheet();
+        
+        // 시트 보호 먼저 해제
+        activeSheet.options.isProtected = false;
+
+        // MAP_CELL_ATT 정보를 사용해서 셀에 데이터 매핑과 스타일을 한번에 처리
+        if (dataSheets && dataSheets.length > 0) {
+          const mappings: Array<{attId: string, cellAddress: string, isEditable: boolean}> = [];
+          
+          dataSheets.forEach(dataSheet => {
+            if (dataSheet.MAP_CELL_ATT) {
+              dataSheet.MAP_CELL_ATT.forEach(mapping => {
+                const { ATT_ID, IN } = mapping;
+                
+                // 셀 주소가 비어있지 않은 경우만 처리
+                if (IN && IN.trim() !== "") {
+                  const cellPos = parseCellAddress(IN);
+                  if (cellPos) {
+                    const isEditable = isFieldEditable(ATT_ID);
+                    mappings.push({
+                      attId: ATT_ID,
+                      cellAddress: IN,
+                      isEditable: isEditable
+                    });
+                    
+                    // 셀 객체 가져오기
+                    const cell = activeSheet.getCell(cellPos.row, cellPos.col);
+                    
+                    // selectedRow에서 해당 값 가져와서 셀에 설정
+                    const value = selectedRow[ATT_ID];
+                    if (value !== undefined && value !== null) {
+                      cell.value(value);
+                    }
+                    
+                    // 편집 권한 설정
+                    cell.locked(!isEditable);
+                    
+                    // 즉시 스타일 적용 (기존 스타일 보존하면서)
+                    const existingStyle = activeSheet.getStyle(cellPos.row, cellPos.col);
+                    if (existingStyle) {
+                      // 기존 스타일 복사
+                      const newStyle = Object.assign(new GC.Spread.Sheets.Style(), existingStyle);
+                      
+                      // 편집 권한에 따라 배경색만 변경
+                      if (isEditable) {
+                        newStyle.backColor = "#f0fdf4"; // 연한 녹색
+                      } else {
+                        newStyle.backColor = "#f9fafb"; // 연한 회색
+                        newStyle.foreColor = "#6b7280"; // 회색 글자
+                      }
+                      
+                      // 스타일 적용
+                      activeSheet.setStyle(cellPos.row, cellPos.col, newStyle);
+                    } else {
+                      // 기존 스타일이 없는 경우 새로운 스타일 생성
+                      const newStyle = new GC.Spread.Sheets.Style();
+                      if (isEditable) {
+                        newStyle.backColor = "#f0fdf4";
+                      } else {
+                        newStyle.backColor = "#f9fafb";
+                        newStyle.foreColor = "#6b7280";
+                      }
+                      activeSheet.setStyle(cellPos.row, cellPos.col, newStyle);
+                    }
+                    
+                    console.log(`Mapped ${ATT_ID} (${value}) to cell ${IN} - ${isEditable ? 'Editable' : 'Read-only'}`);
+                  }
+                }
+              });
+            }
+          });
+          
+          setCellMappings(mappings);
+          
+          // 시트 보호 설정
+          activeSheet.options.isProtected = true;
+          activeSheet.options.protectionOptions = {
+            allowSelectLockedCells: true,
+            allowSelectUnlockedCells: true,
+            allowSort: false,
+            allowFilter: false,
+            allowEditObjects: false,
+            allowResizeRows: false,
+            allowResizeColumns: false
+          };
+
+          // 이벤트 리스너 추가
+          activeSheet.bind(GC.Spread.Sheets.Events.CellChanged, (event: any, info: any) => {
+            console.log('Cell changed:', info);
+            setHasChanges(true);
+          });
+
+          activeSheet.bind(GC.Spread.Sheets.Events.ValueChanged, (event: any, info: any) => {
+            console.log('Value changed:', info);
+            setHasChanges(true);
+          });
+
+          // 편집 시작 시 읽기 전용 셀 확인
+          activeSheet.bind(GC.Spread.Sheets.Events.EditStarting, (event: any, info: any) => {
+            const mapping = mappings.find(m => {
+              const cellPos = parseCellAddress(m.cellAddress);
+              return cellPos && cellPos.row === info.row && cellPos.col === info.col;
+            });
+            
+            if (mapping && !mapping.isEditable) {
+              toast.warning(`${mapping.attId} field is read-only`);
+              info.cancel = true;
+            }
+          });
+        }
+      } finally {
+        // 렌더링 재개 (모든 변경사항이 한번에 화면에 표시됨)
+        spread.resumePaint();
+      }
 
     } catch (error) {
       console.error('Error initializing spread:', error);
       toast.error('Failed to load template');
+      // 에러 발생 시에도 렌더링 재개
+      if (spread && spread.resumePaint) {
+        spread.resumePaint();
+      }
     }
-  }, [selectedTemplate]);
+  }, [selectedTemplate, selectedRow, isFieldEditable]);
 
   // 템플릿 변경 핸들러
   const handleTemplateChange = (templateId: string) => {
     setSelectedTemplateId(templateId);
-    setHasChanges(false); // 템플릿 변경 시 변경사항 초기화
+    setHasChanges(false);
     
-    // SpreadSheets 재초기화는 useCallback 의존성에 의해 자동으로 처리됨
     if (currentSpread) {
-      // 강제로 재초기화
       setTimeout(() => {
         initSpread(currentSpread);
       }, 100);
@@ -184,7 +366,7 @@ export function TemplateViewDialog({
 
   // 변경사항 저장 함수
   const handleSaveChanges = React.useCallback(async () => {
-    if (!currentSpread || !hasChanges) {
+    if (!currentSpread || !hasChanges || !selectedRow) {
       toast.info("No changes to save");
       return;
     }
@@ -192,24 +374,22 @@ export function TemplateViewDialog({
     try {
       setIsPending(true);
 
-      // SpreadSheets에서 현재 데이터를 JSON으로 추출
-      const spreadJson = currentSpread.toJSON();
-      console.log('Current spread data:', spreadJson);
-
-      // 실제 데이터 추출 방법은 SpreadSheets 구조에 따라 달라질 수 있음
-      // 여기서는 기본적인 예시만 제공
       const activeSheet = currentSpread.getActiveSheet();
-      
-      // 간단한 예시: 특정 범위의 데이터를 추출하여 selectedRow 형태로 변환
-      // 실제 구현에서는 템플릿의 구조에 맞춰 데이터를 추출해야 함
-      const dataToSave = {
-        ...selectedRow, // 기본값으로 원본 데이터 사용
-        // 여기에 SpreadSheets에서 변경된 값들을 추가
-        // 예: TAG_DESC: activeSheet.getValue(특정행, 특정열)
-      };
+      const dataToSave = { ...selectedRow };
+
+      // cellMappings를 사용해서 편집 가능한 셀의 값만 추출
+      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;
+          }
+        }
+      });
 
       // TAG_NO는 절대 변경되지 않도록 원본 값으로 강제 설정
-      dataToSave.TAG_NO = selectedRow?.TAG_NO;
+      dataToSave.TAG_NO = selectedRow.TAG_NO;
 
       console.log('Data to save (TAG_NO preserved):', dataToSave);
 
@@ -240,7 +420,7 @@ export function TemplateViewDialog({
     } finally {
       setIsPending(false);
     }
-  }, [currentSpread, hasChanges, formCode, contractItemId, selectedRow, onUpdateSuccess]);
+  }, [currentSpread, hasChanges, formCode, contractItemId, selectedRow, onUpdateSuccess, cellMappings]);
 
   if (!isOpen) return null;
 
@@ -260,9 +440,21 @@ export function TemplateViewDialog({
               </span>
             )}
             <br />
-            <span className="text-xs text-muted-foreground">
-              Template content will be loaded directly. Manual data entry may be required.
-            </span>
+            <div className="flex items-center gap-4 mt-2">
+              <span className="text-xs 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-xs 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>
+              {cellMappings.length > 0 && (
+                <span className="text-xs text-blue-600">
+                  {cellMappings.filter(m => m.isEditable).length} of {cellMappings.length} fields editable
+                </span>
+              )}
+            </div>
           </DialogDescription>
         </DialogHeader>
 
@@ -295,15 +487,22 @@ export function TemplateViewDialog({
         
         {/* SpreadSheets 컴포넌트 영역 */}
         <div className="flex-1 overflow-hidden">
-          {selectedTemplate ? (
+          {selectedTemplate && isClient ? (
             <SpreadSheets 
-              key={selectedTemplateId} // 템플릿 변경 시 컴포넌트 재생성
+              key={selectedTemplateId}
               workbookInitialized={initSpread}
               hostStyle={hostStyle}
             />
           ) : (
             <div className="flex items-center justify-center h-full text-muted-foreground">
-              No template available
+              {!isClient ? (
+                <>
+                  <Loader className="mr-2 h-4 w-4 animate-spin" />
+                  Loading...
+                </>
+              ) : (
+                "No template available"
+              )}
             </div>
           )}
         </div>
-- 
cgit v1.2.3

