diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-27 01:16:20 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-27 01:16:20 +0000 |
| commit | e9897d416b3e7327bbd4d4aef887eee37751ae82 (patch) | |
| tree | bd20ce6eadf9b21755bd7425492d2d31c7700a0e /components/form-data | |
| parent | 3bf1952c1dad9d479bb8b22031b06a7434d37c37 (diff) | |
(대표님) 20250627 오전 10시 작업사항
Diffstat (limited to 'components/form-data')
| -rw-r--r-- | components/form-data/form-data-table.tsx | 1 | ||||
| -rw-r--r-- | components/form-data/spreadJS-dialog.tsx | 317 |
2 files changed, 259 insertions, 59 deletions
diff --git a/components/form-data/form-data-table.tsx b/components/form-data/form-data-table.tsx index 92ec3c56..57913192 100644 --- a/components/form-data/form-data-table.tsx +++ b/components/form-data/form-data-table.tsx @@ -923,6 +923,7 @@ export default function DynamicTable({ selectedRow={selectedRowsData[0]} formCode={formCode} contractItemId={contractItemId} + editableFieldsMap={editableFieldsMap} onUpdateSuccess={(updatedValues) => { // SpreadSheets에서 업데이트된 값을 테이블에 반영 const tagNo = updatedValues.TAG_NO; 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> |
