From da6ac92391b4b25187c52a5501408b6d65947afb Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 1 Dec 2025 15:21:03 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=EC=9D=B4=20=EC=95=88=EB=90=98=EB=8A=94=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/dynamicFormController.ts | 2 +- .../src/services/dynamicFormService.ts | 2 +- frontend/components/common/ScreenModal.tsx | 26 +- .../screen/InteractiveScreenViewerDynamic.tsx | 4 + .../screen/panels/UnifiedPropertiesPanel.tsx | 1 + frontend/lib/api/dynamicForm.ts | 2 +- .../lib/registry/DynamicComponentRenderer.tsx | 5 + .../AutocompleteSearchInputComponent.tsx | 36 +- .../AutocompleteSearchInputConfigPanel.tsx | 35 +- .../button-primary/ButtonPrimaryComponent.tsx | 2 +- frontend/lib/utils/buttonActions.ts | 17 +- ..._임베딩_및_데이터_전달_시스템_구현_계획서.md | 702 ++++++++++-------- 화면_임베딩_시스템_Phase1-4_구현_완료.md | 70 +- 화면_임베딩_시스템_충돌_분석_보고서.md | 126 +++- 14 files changed, 621 insertions(+), 409 deletions(-) diff --git a/backend-node/src/controllers/dynamicFormController.ts b/backend-node/src/controllers/dynamicFormController.ts index 738d1964..6adf8cd6 100644 --- a/backend-node/src/controllers/dynamicFormController.ts +++ b/backend-node/src/controllers/dynamicFormController.ts @@ -203,7 +203,7 @@ export const updateFormDataPartial = async ( }; const result = await dynamicFormService.updateFormDataPartial( - parseInt(id), + id, // 🔧 parseInt 제거 - UUID 문자열도 지원 tableName, originalData, newDataWithMeta diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index 11648577..ffaf8586 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -746,7 +746,7 @@ export class DynamicFormService { * 폼 데이터 부분 업데이트 (변경된 필드만 업데이트) */ async updateFormDataPartial( - id: number, + id: string | number, // 🔧 UUID 문자열도 지원 tableName: string, originalData: Record, newData: Record diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 70567c99..53fd0852 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -57,6 +57,9 @@ export const ScreenModal: React.FC = ({ className }) => { // 폼 데이터 상태 추가 const [formData, setFormData] = useState>({}); + // 🆕 원본 데이터 상태 (수정 모드에서 UPDATE 판단용) + const [originalData, setOriginalData] = useState | null>(null); + // 연속 등록 모드 상태 (state로 변경 - 체크박스 UI 업데이트를 위해) const [continuousMode, setContinuousMode] = useState(false); @@ -143,10 +146,13 @@ export const ScreenModal: React.FC = ({ className }) => { console.log("✅ URL 파라미터 추가:", urlParams); } - // 🆕 editData가 있으면 formData로 설정 (수정 모드) + // 🆕 editData가 있으면 formData와 originalData로 설정 (수정 모드) if (editData) { console.log("📝 [ScreenModal] 수정 데이터 설정:", editData); setFormData(editData); + setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용) + } else { + setOriginalData(null); // 신규 등록 모드 } setModalState({ @@ -177,6 +183,7 @@ export const ScreenModal: React.FC = ({ className }) => { }); setScreenData(null); setFormData({}); + setOriginalData(null); // 🆕 원본 데이터 초기화 setContinuousMode(false); localStorage.setItem("screenModal_continuousMode", "false"); // localStorage에 저장 console.log("🔄 연속 모드 초기화: false"); @@ -364,12 +371,15 @@ export const ScreenModal: React.FC = ({ className }) => { "⚠️ [ScreenModal] 그룹 레코드(배열)는 formData로 설정하지 않음. SelectedItemsDetailInput만 사용합니다.", ); setFormData(normalizedData); // SelectedItemsDetailInput이 직접 사용 + setOriginalData(normalizedData[0] || null); // 🆕 첫 번째 레코드를 원본으로 저장 } else { setFormData(normalizedData); + setOriginalData(normalizedData); // 🆕 원본 데이터 저장 (UPDATE 판단용) } // setFormData 직후 확인 console.log("🔄 setFormData 호출 완료 (날짜 정규화됨)"); + console.log("🔄 setOriginalData 호출 완료 (UPDATE 판단용)"); } else { console.error("❌ 수정 데이터 로드 실패:", response.error); toast.error("데이터를 불러올 수 없습니다."); @@ -618,11 +628,17 @@ export const ScreenModal: React.FC = ({ className }) => { component={adjustedComponent} allComponents={screenData.components} formData={formData} + originalData={originalData} // 🆕 원본 데이터 전달 (UPDATE 판단용) onFormDataChange={(fieldName, value) => { - setFormData((prev) => ({ - ...prev, - [fieldName]: value, - })); + console.log("🔧 [ScreenModal] onFormDataChange 호출:", { fieldName, value }); + setFormData((prev) => { + const newFormData = { + ...prev, + [fieldName]: value, + }; + console.log("🔧 [ScreenModal] formData 업데이트:", { prev, newFormData }); + return newFormData; + }); }} onRefresh={() => { // 부모 화면의 테이블 새로고침 이벤트 발송 diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index e351b68c..c9535285 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -53,6 +53,8 @@ interface InteractiveScreenViewerProps { disabledFields?: string[]; // 🆕 EditModal 내부인지 여부 (button-primary가 EditModal의 handleSave 사용하도록) isInModal?: boolean; + // 🆕 원본 데이터 (수정 모드에서 UPDATE 판단용) + originalData?: Record | null; } export const InteractiveScreenViewerDynamic: React.FC = ({ @@ -72,6 +74,7 @@ export const InteractiveScreenViewerDynamic: React.FC { const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 const { userName: authUserName, user: authUser } = useAuth(); @@ -331,6 +334,7 @@ export const InteractiveScreenViewerDynamic: React.FC = ({ , newData: Record, tableName: string, diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index fe93f4af..0ea687bf 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -337,6 +337,11 @@ export const DynamicComponentRenderer: React.FC = // onChange 핸들러 - 컴포넌트 타입에 따라 다르게 처리 const handleChange = (value: any) => { + // autocomplete-search-input, entity-search-input은 자체적으로 onFormDataChange를 호출하므로 중복 저장 방지 + if (componentType === "autocomplete-search-input" || componentType === "entity-search-input") { + return; + } + // React 이벤트 객체인 경우 값 추출 let actualValue = value; if (value && typeof value === "object" && value.nativeEvent && value.target) { diff --git a/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx b/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx index e3572e33..1c5920f0 100644 --- a/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx +++ b/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx @@ -57,20 +57,42 @@ export function AutocompleteSearchInputComponent({ filterCondition, }); + // 선택된 데이터를 ref로도 유지 (리렌더링 시 초기화 방지) + const selectedDataRef = useRef(null); + const inputValueRef = useRef(""); + // formData에서 현재 값 가져오기 (isInteractive 모드) const currentValue = isInteractive && formData && component?.columnName ? formData[component.columnName] : value; - // value가 변경되면 표시값 업데이트 + // selectedData 변경 시 ref도 업데이트 useEffect(() => { - if (currentValue && selectedData) { - setInputValue(selectedData[displayField] || ""); - } else if (!currentValue) { - setInputValue(""); - setSelectedData(null); + if (selectedData) { + selectedDataRef.current = selectedData; + inputValueRef.current = inputValue; } - }, [currentValue, displayField, selectedData]); + }, [selectedData, inputValue]); + + // 리렌더링 시 ref에서 값 복원 + useEffect(() => { + if (!selectedData && selectedDataRef.current) { + setSelectedData(selectedDataRef.current); + setInputValue(inputValueRef.current); + } + }, []); + + // value가 변경되면 표시값 업데이트 - 단, selectedData가 있으면 유지 + useEffect(() => { + // selectedData가 있으면 표시값 유지 (사용자가 방금 선택한 경우) + if (selectedData || selectedDataRef.current) { + return; + } + + if (!currentValue) { + setInputValue(""); + } + }, [currentValue, selectedData]); // 외부 클릭 감지 useEffect(() => { diff --git a/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputConfigPanel.tsx b/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputConfigPanel.tsx index e6942704..d2290c2f 100644 --- a/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputConfigPanel.tsx +++ b/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputConfigPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -21,7 +21,9 @@ export function AutocompleteSearchInputConfigPanel({ config, onConfigChange, }: AutocompleteSearchInputConfigPanelProps) { - const [localConfig, setLocalConfig] = useState(config); + // 초기화 여부 추적 (첫 마운트 시에만 config로 초기화) + const isInitialized = useRef(false); + const [localConfig, setLocalConfig] = useState(config); const [allTables, setAllTables] = useState([]); const [sourceTableColumns, setSourceTableColumns] = useState([]); const [targetTableColumns, setTargetTableColumns] = useState([]); @@ -32,12 +34,21 @@ export function AutocompleteSearchInputConfigPanel({ const [openTargetTableCombo, setOpenTargetTableCombo] = useState(false); const [openDisplayFieldCombo, setOpenDisplayFieldCombo] = useState(false); + // 첫 마운트 시에만 config로 초기화 (이후에는 localConfig 유지) useEffect(() => { - setLocalConfig(config); + if (!isInitialized.current && config) { + setLocalConfig(config); + isInitialized.current = true; + } }, [config]); const updateConfig = (updates: Partial) => { const newConfig = { ...localConfig, ...updates }; + console.log("🔧 [AutocompleteConfigPanel] updateConfig:", { + updates, + localConfig, + newConfig, + }); setLocalConfig(newConfig); onConfigChange(newConfig); }; @@ -325,10 +336,11 @@ export function AutocompleteSearchInputConfigPanel({
- updateFieldMapping(index, { targetField: value }) - } + value={mapping.targetField || undefined} + onValueChange={(value) => { + console.log("🔧 [Select] targetField 변경:", value); + updateFieldMapping(index, { targetField: value }); + }} disabled={!localConfig.targetTable || isLoadingTargetColumns} > diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 1e00442f..180dacaa 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -694,7 +694,7 @@ export const ButtonPrimaryComponent: React.FC = ({ const context: ButtonActionContext = { formData: formData || {}, - originalData: originalData || {}, // 부분 업데이트용 원본 데이터 추가 + originalData: originalData, // 🔧 빈 객체 대신 undefined 유지 (UPDATE 판단에 사용) screenId: effectiveScreenId, // 🆕 ScreenContext에서 가져온 값 사용 tableName: effectiveTableName, // 🆕 ScreenContext에서 가져온 값 사용 userId, // 🆕 사용자 ID diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index f7f1fc20..ad441754 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -414,9 +414,20 @@ export class ButtonActionExecutor { const primaryKeys = primaryKeyResult.data || []; const primaryKeyValue = this.extractPrimaryKeyValueFromDB(formData, primaryKeys); - // 단순히 기본키 값 존재 여부로 판단 (임시) - // TODO: 실제 테이블에서 기본키로 레코드 존재 여부 확인하는 API 필요 - const isUpdate = false; // 현재는 항상 INSERT로 처리 + // 🔧 수정: originalData가 있고 실제 데이터가 있으면 UPDATE 모드로 처리 + // originalData는 수정 버튼 클릭 시 editData로 전달되어 context.originalData로 설정됨 + // 빈 객체 {}도 truthy이므로 Object.keys로 실제 데이터 유무 확인 + const hasRealOriginalData = originalData && Object.keys(originalData).length > 0; + const isUpdate = hasRealOriginalData && !!primaryKeyValue; + + console.log("🔍 [handleSave] INSERT/UPDATE 판단:", { + hasOriginalData: !!originalData, + hasRealOriginalData, + originalDataKeys: originalData ? Object.keys(originalData) : [], + primaryKeyValue, + isUpdate, + primaryKeys, + }); let saveResult; diff --git a/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md b/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md index 7aed8903..716d7f98 100644 --- a/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md +++ b/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md @@ -1,6 +1,7 @@ # 화면 임베딩 및 데이터 전달 시스템 구현 계획서 ## 📋 목차 + 1. [개요](#개요) 2. [현재 문제점](#현재-문제점) 3. [목표](#목표) @@ -17,9 +18,11 @@ ## 개요 ### 배경 + 현재 화면관리 시스템은 단일 화면 단위로만 동작하며, 화면 간 데이터 전달이나 화면 임베딩이 불가능합니다. 실무에서는 "입고 등록"과 같이 **좌측에서 데이터를 선택하고 우측으로 전달하여 처리하는** 복잡한 워크플로우가 필요합니다. ### 핵심 요구사항 + - **화면 임베딩**: 기존 화면을 다른 화면 안에 재사용 - **데이터 전달**: 한 화면에서 선택한 데이터를 다른 화면의 컴포넌트로 전달 - **유연한 매핑**: 테이블뿐만 아니라 입력 필드, 셀렉트 박스, 리피터 등 모든 컴포넌트에 데이터 주입 가능 @@ -30,18 +33,22 @@ ## 현재 문제점 ### 1. 화면 재사용 불가 + - 각 화면은 독립적으로만 동작 - 동일한 기능을 여러 화면에서 중복 구현 ### 2. 화면 간 데이터 전달 불가 + - 한 화면에서 선택한 데이터를 다른 화면으로 전달할 수 없음 - 사용자가 수동으로 복사/붙여넣기 해야 함 ### 3. 복잡한 워크플로우 구현 불가 + - "발주 목록 조회 → 품목 선택 → 입고 등록"과 같은 프로세스를 단일 화면에서 처리 불가 - 여러 화면을 오가며 작업해야 하는 불편함 ### 4. 컴포넌트별 데이터 주입 불가 + - 테이블에만 데이터를 추가할 수 있음 - 입력 필드, 셀렉트 박스 등에 자동으로 값을 설정할 수 없음 @@ -50,12 +57,14 @@ ## 목표 ### 주요 목표 + 1. **화면 임베딩 시스템 구축**: 기존 화면을 컨테이너로 사용 2. **범용 데이터 전달 시스템**: 모든 컴포넌트 타입 지원 3. **시각적 매핑 설정 UI**: 드래그앤드롭으로 매핑 규칙 설정 4. **실시간 미리보기**: 데이터 전달 결과를 즉시 확인 ### 부가 목표 + - 조건부 데이터 전달 (필터링) - 데이터 변환 함수 (합계, 평균, 개수 등) - 양방향 데이터 동기화 @@ -128,19 +137,19 @@ ```sql CREATE TABLE screen_embedding ( id SERIAL PRIMARY KEY, - + -- 부모 화면 (컨테이너) parent_screen_id INTEGER NOT NULL REFERENCES screen_info(screen_id), - + -- 자식 화면 (임베드될 화면) child_screen_id INTEGER NOT NULL REFERENCES screen_info(screen_id), - + -- 임베딩 위치 position VARCHAR(20) NOT NULL, -- 'left', 'right', 'top', 'bottom', 'center' - + -- 임베딩 모드 mode VARCHAR(20) NOT NULL, -- 'view', 'select', 'form', 'edit' - + -- 추가 설정 config JSONB, -- { @@ -150,18 +159,18 @@ CREATE TABLE screen_embedding ( -- "multiSelect": true, -- "showToolbar": true -- } - + -- 멀티테넌시 company_code VARCHAR(20) NOT NULL, - + -- 메타데이터 created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), created_by VARCHAR(50), - - CONSTRAINT fk_parent_screen FOREIGN KEY (parent_screen_id) + + CONSTRAINT fk_parent_screen FOREIGN KEY (parent_screen_id) REFERENCES screen_info(screen_id) ON DELETE CASCADE, - CONSTRAINT fk_child_screen FOREIGN KEY (child_screen_id) + CONSTRAINT fk_child_screen FOREIGN KEY (child_screen_id) REFERENCES screen_info(screen_id) ON DELETE CASCADE ); @@ -175,17 +184,17 @@ CREATE INDEX idx_screen_embedding_child ON screen_embedding(child_screen_id, com ```sql CREATE TABLE screen_data_transfer ( id SERIAL PRIMARY KEY, - + -- 소스 화면 (데이터 제공) source_screen_id INTEGER NOT NULL REFERENCES screen_info(screen_id), - + -- 타겟 화면 (데이터 수신) target_screen_id INTEGER NOT NULL REFERENCES screen_info(screen_id), - + -- 소스 컴포넌트 (선택 영역) source_component_id VARCHAR(100), source_component_type VARCHAR(50), -- 'table', 'list', 'grid' - + -- 데이터 수신자 설정 (JSONB 배열) data_receivers JSONB NOT NULL, -- [ @@ -207,7 +216,7 @@ CREATE TABLE screen_data_transfer ( -- } -- } -- ] - + -- 전달 버튼 설정 button_config JSONB, -- { @@ -221,18 +230,18 @@ CREATE TABLE screen_data_transfer ( -- "customValidation": "function(rows) { return rows.length > 0; }" -- } -- } - + -- 멀티테넌시 company_code VARCHAR(20) NOT NULL, - + -- 메타데이터 created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), created_by VARCHAR(50), - - CONSTRAINT fk_source_screen FOREIGN KEY (source_screen_id) + + CONSTRAINT fk_source_screen FOREIGN KEY (source_screen_id) REFERENCES screen_info(screen_id) ON DELETE CASCADE, - CONSTRAINT fk_target_screen FOREIGN KEY (target_screen_id) + CONSTRAINT fk_target_screen FOREIGN KEY (target_screen_id) REFERENCES screen_info(screen_id) ON DELETE CASCADE ); @@ -246,19 +255,19 @@ CREATE INDEX idx_screen_data_transfer_target ON screen_data_transfer(target_scre ```sql CREATE TABLE screen_split_panel ( id SERIAL PRIMARY KEY, - + -- 부모 화면 (분할 패널 컨테이너) screen_id INTEGER NOT NULL REFERENCES screen_info(screen_id), - + -- 좌측 화면 임베딩 left_embedding_id INTEGER REFERENCES screen_embedding(id), - + -- 우측 화면 임베딩 right_embedding_id INTEGER REFERENCES screen_embedding(id), - + -- 데이터 전달 설정 data_transfer_id INTEGER REFERENCES screen_data_transfer(id), - + -- 레이아웃 설정 layout_config JSONB, -- { @@ -268,21 +277,21 @@ CREATE TABLE screen_split_panel ( -- "minRightWidth": 400, -- "orientation": "horizontal" // 'horizontal' | 'vertical' -- } - + -- 멀티테넌시 company_code VARCHAR(20) NOT NULL, - + -- 메타데이터 created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), - - CONSTRAINT fk_screen FOREIGN KEY (screen_id) + + CONSTRAINT fk_screen FOREIGN KEY (screen_id) REFERENCES screen_info(screen_id) ON DELETE CASCADE, - CONSTRAINT fk_left_embedding FOREIGN KEY (left_embedding_id) + CONSTRAINT fk_left_embedding FOREIGN KEY (left_embedding_id) REFERENCES screen_embedding(id) ON DELETE SET NULL, - CONSTRAINT fk_right_embedding FOREIGN KEY (right_embedding_id) + CONSTRAINT fk_right_embedding FOREIGN KEY (right_embedding_id) REFERENCES screen_embedding(id) ON DELETE SET NULL, - CONSTRAINT fk_data_transfer FOREIGN KEY (data_transfer_id) + CONSTRAINT fk_data_transfer FOREIGN KEY (data_transfer_id) REFERENCES screen_data_transfer(id) ON DELETE SET NULL ); @@ -298,19 +307,14 @@ CREATE INDEX idx_screen_split_panel_screen ON screen_split_panel(screen_id, comp ```typescript // 임베딩 모드 -type EmbeddingMode = - | "view" // 읽기 전용 - | "select" // 선택 모드 (체크박스) - | "form" // 폼 입력 모드 - | "edit"; // 편집 모드 +type EmbeddingMode = + | "view" // 읽기 전용 + | "select" // 선택 모드 (체크박스) + | "form" // 폼 입력 모드 + | "edit"; // 편집 모드 // 임베딩 위치 -type EmbeddingPosition = - | "left" - | "right" - | "top" - | "bottom" - | "center"; +type EmbeddingPosition = "left" | "right" | "top" | "bottom" | "center"; // 화면 임베딩 설정 interface ScreenEmbedding { @@ -320,8 +324,8 @@ interface ScreenEmbedding { position: EmbeddingPosition; mode: EmbeddingMode; config: { - width?: string; // "50%", "400px" - height?: string; // "100%", "600px" + width?: string; // "50%", "400px" + height?: string; // "100%", "600px" resizable?: boolean; multiSelect?: boolean; showToolbar?: boolean; @@ -336,40 +340,40 @@ interface ScreenEmbedding { ```typescript // 컴포넌트 타입 -type ComponentType = - | "table" // 테이블 - | "input" // 입력 필드 - | "select" // 셀렉트 박스 - | "textarea" // 텍스트 영역 - | "checkbox" // 체크박스 - | "radio" // 라디오 버튼 - | "date" // 날짜 선택 - | "repeater" // 리피터 (반복 그룹) - | "form-group" // 폼 그룹 - | "hidden"; // 히든 필드 +type ComponentType = + | "table" // 테이블 + | "input" // 입력 필드 + | "select" // 셀렉트 박스 + | "textarea" // 텍스트 영역 + | "checkbox" // 체크박스 + | "radio" // 라디오 버튼 + | "date" // 날짜 선택 + | "repeater" // 리피터 (반복 그룹) + | "form-group" // 폼 그룹 + | "hidden"; // 히든 필드 // 데이터 수신 모드 -type DataReceiveMode = - | "append" // 기존 데이터에 추가 - | "replace" // 기존 데이터 덮어쓰기 - | "merge"; // 기존 데이터와 병합 (키 기준) +type DataReceiveMode = + | "append" // 기존 데이터에 추가 + | "replace" // 기존 데이터 덮어쓰기 + | "merge"; // 기존 데이터와 병합 (키 기준) // 변환 함수 -type TransformFunction = - | "none" // 변환 없음 - | "sum" // 합계 - | "average" // 평균 - | "count" // 개수 - | "min" // 최소값 - | "max" // 최대값 - | "first" // 첫 번째 값 - | "last" // 마지막 값 - | "concat" // 문자열 결합 - | "join" // 배열 결합 - | "custom"; // 커스텀 함수 +type TransformFunction = + | "none" // 변환 없음 + | "sum" // 합계 + | "average" // 평균 + | "count" // 개수 + | "min" // 최소값 + | "max" // 최대값 + | "first" // 첫 번째 값 + | "last" // 마지막 값 + | "concat" // 문자열 결합 + | "join" // 배열 결합 + | "custom"; // 커스텀 함수 // 조건 연산자 -type ConditionOperator = +type ConditionOperator = | "equals" | "notEquals" | "contains" @@ -383,12 +387,12 @@ type ConditionOperator = // 매핑 규칙 interface MappingRule { - sourceField: string; // 소스 필드명 - targetField: string; // 타겟 필드명 + sourceField: string; // 소스 필드명 + targetField: string; // 타겟 필드명 transform?: TransformFunction; // 변환 함수 - transformConfig?: any; // 변환 함수 설정 - defaultValue?: any; // 기본값 - required?: boolean; // 필수 여부 + transformConfig?: any; // 변환 함수 설정 + defaultValue?: any; // 기본값 + required?: boolean; // 필수 여부 } // 조건 @@ -400,16 +404,16 @@ interface Condition { // 데이터 수신자 interface DataReceiver { - targetComponentId: string; // 타겟 컴포넌트 ID + targetComponentId: string; // 타겟 컴포넌트 ID targetComponentType: ComponentType; mode: DataReceiveMode; mappingRules: MappingRule[]; - condition?: Condition; // 조건부 전달 + condition?: Condition; // 조건부 전달 validation?: { required?: boolean; minRows?: number; maxRows?: number; - customValidation?: string; // JavaScript 함수 문자열 + customValidation?: string; // JavaScript 함수 문자열 }; } @@ -447,10 +451,10 @@ interface ScreenDataTransfer { ```typescript // 레이아웃 설정 interface LayoutConfig { - splitRatio: number; // 0-100 (좌측 비율) + splitRatio: number; // 0-100 (좌측 비율) resizable: boolean; - minLeftWidth?: number; // 최소 좌측 너비 (px) - minRightWidth?: number; // 최소 우측 너비 (px) + minLeftWidth?: number; // 최소 좌측 너비 (px) + minRightWidth?: number; // 최소 우측 너비 (px) orientation: "horizontal" | "vertical"; } @@ -473,22 +477,22 @@ interface ScreenSplitPanel { interface DataReceivable { // 컴포넌트 ID componentId: string; - + // 컴포넌트 타입 componentType: ComponentType; - + // 데이터 수신 receiveData(data: any[], mode: DataReceiveMode): Promise; - + // 현재 데이터 가져오기 getData(): any; - + // 데이터 초기화 clearData(): void; - + // 검증 validate(): boolean; - + // 이벤트 리스너 onDataReceived?: (data: any[]) => void; onDataCleared?: () => void; @@ -498,13 +502,13 @@ interface DataReceivable { interface Selectable { // 선택된 행/항목 가져오기 getSelectedRows(): any[]; - + // 선택 초기화 clearSelection(): void; - + // 전체 선택 selectAll(): void; - + // 선택 이벤트 onSelectionChanged?: (selectedRows: any[]) => void; } @@ -522,51 +526,62 @@ interface ScreenSplitPanelProps { onDataTransferred?: (data: any[]) => void; } -export function ScreenSplitPanel({ config, onDataTransferred }: ScreenSplitPanelProps) { +export function ScreenSplitPanel({ + config, + onDataTransferred, +}: ScreenSplitPanelProps) { const leftScreenRef = useRef(null); const rightScreenRef = useRef(null); const [splitRatio, setSplitRatio] = useState(config.layoutConfig.splitRatio); - + // 데이터 전달 핸들러 const handleTransferData = async () => { // 1. 좌측 화면에서 선택된 데이터 가져오기 const selectedRows = leftScreenRef.current?.getSelectedRows() || []; - + if (selectedRows.length === 0) { toast.error("선택된 항목이 없습니다."); return; } - + // 2. 검증 if (config.dataTransfer.buttonConfig.validation) { const validation = config.dataTransfer.buttonConfig.validation; - - if (validation.minSelection && selectedRows.length < validation.minSelection) { + + if ( + validation.minSelection && + selectedRows.length < validation.minSelection + ) { toast.error(`최소 ${validation.minSelection}개 이상 선택해야 합니다.`); return; } - - if (validation.maxSelection && selectedRows.length > validation.maxSelection) { - toast.error(`최대 ${validation.maxSelection}개까지만 선택할 수 있습니다.`); + + if ( + validation.maxSelection && + selectedRows.length > validation.maxSelection + ) { + toast.error( + `최대 ${validation.maxSelection}개까지만 선택할 수 있습니다.` + ); return; } - + if (validation.confirmMessage) { const confirmed = await confirm(validation.confirmMessage); if (!confirmed) return; } } - + // 3. 데이터 전달 try { await rightScreenRef.current?.receiveData( selectedRows, config.dataTransfer.dataReceivers ); - + toast.success("데이터가 전달되었습니다."); onDataTransferred?.(selectedRows); - + // 4. 좌측 선택 초기화 (옵션) if (config.dataTransfer.buttonConfig.clearAfterTransfer) { leftScreenRef.current?.clearSelection(); @@ -576,24 +591,19 @@ export function ScreenSplitPanel({ config, onDataTransferred }: ScreenSplitPanel console.error(error); } }; - + return (
{/* 좌측 패널 */}
- +
- + {/* 리사이저 */} {config.layoutConfig.resizable && ( - setSplitRatio(newRatio)} - /> + setSplitRatio(newRatio)} /> )} - + {/* 전달 버튼 */}
- + {/* 우측 패널 */}
( - ({ embedding }, ref) => { - const [screenData, setScreenData] = useState(null); - const [selectedRows, setSelectedRows] = useState([]); - const componentRefs = useRef>(new Map()); - - // 화면 데이터 로드 - useEffect(() => { - loadScreenData(embedding.childScreenId); - }, [embedding.childScreenId]); - - // 외부에서 호출 가능한 메서드 - useImperativeHandle(ref, () => ({ - getSelectedRows: () => selectedRows, - - clearSelection: () => { - setSelectedRows([]); - }, - - receiveData: async (data: any[], receivers: DataReceiver[]) => { - // 각 데이터 수신자에게 데이터 전달 - for (const receiver of receivers) { - const component = componentRefs.current.get(receiver.targetComponentId); - - if (!component) { - console.warn(`컴포넌트를 찾을 수 없습니다: ${receiver.targetComponentId}`); - continue; - } - - // 조건 확인 - let filteredData = data; - if (receiver.condition) { - filteredData = filterData(data, receiver.condition); - } - - // 매핑 적용 - const mappedData = applyMappingRules(filteredData, receiver.mappingRules); - - // 데이터 전달 - await component.receiveData(mappedData, receiver.mode); +export const EmbeddedScreen = forwardRef< + EmbeddedScreenHandle, + EmbeddedScreenProps +>(({ embedding }, ref) => { + const [screenData, setScreenData] = useState(null); + const [selectedRows, setSelectedRows] = useState([]); + const componentRefs = useRef>(new Map()); + + // 화면 데이터 로드 + useEffect(() => { + loadScreenData(embedding.childScreenId); + }, [embedding.childScreenId]); + + // 외부에서 호출 가능한 메서드 + useImperativeHandle(ref, () => ({ + getSelectedRows: () => selectedRows, + + clearSelection: () => { + setSelectedRows([]); + }, + + receiveData: async (data: any[], receivers: DataReceiver[]) => { + // 각 데이터 수신자에게 데이터 전달 + for (const receiver of receivers) { + const component = componentRefs.current.get(receiver.targetComponentId); + + if (!component) { + console.warn( + `컴포넌트를 찾을 수 없습니다: ${receiver.targetComponentId}` + ); + continue; } - }, - - getData: () => { - const allData: Record = {}; - componentRefs.current.forEach((component, id) => { - allData[id] = component.getData(); - }); - return allData; + + // 조건 확인 + let filteredData = data; + if (receiver.condition) { + filteredData = filterData(data, receiver.condition); + } + + // 매핑 적용 + const mappedData = applyMappingRules( + filteredData, + receiver.mappingRules + ); + + // 데이터 전달 + await component.receiveData(mappedData, receiver.mode); } - })); - - // 컴포넌트 등록 - const registerComponent = (id: string, component: DataReceivable) => { - componentRefs.current.set(id, component); - }; - - return ( -
- {screenData && ( - - )} -
- ); - } -); + }, + + getData: () => { + const allData: Record = {}; + componentRefs.current.forEach((component, id) => { + allData[id] = component.getData(); + }); + return allData; + }, + })); + + // 컴포넌트 등록 + const registerComponent = (id: string, component: DataReceivable) => { + componentRefs.current.set(id, component); + }; + + return ( +
+ {screenData && ( + + )} +
+ ); +}); ``` ### 3. DataReceivable 구현 예시 @@ -716,7 +735,7 @@ class TableComponent implements DataReceivable { componentId: string; componentType: ComponentType = "table"; private rows: any[] = []; - + async receiveData(data: any[], mode: DataReceiveMode): Promise { switch (mode) { case "append": @@ -727,30 +746,30 @@ class TableComponent implements DataReceivable { break; case "merge": // 키 기반 병합 (예: id 필드) - const existingIds = new Set(this.rows.map(r => r.id)); - const newRows = data.filter(r => !existingIds.has(r.id)); + const existingIds = new Set(this.rows.map((r) => r.id)); + const newRows = data.filter((r) => !existingIds.has(r.id)); this.rows = [...this.rows, ...newRows]; break; } - + this.render(); this.onDataReceived?.(data); } - + getData(): any { return this.rows; } - + clearData(): void { this.rows = []; this.render(); this.onDataCleared?.(); } - + validate(): boolean { return this.rows.length > 0; } - + private render() { // 테이블 리렌더링 } @@ -764,7 +783,7 @@ class InputComponent implements DataReceivable { componentId: string; componentType: ComponentType = "input"; private value: any = ""; - + async receiveData(data: any[], mode: DataReceiveMode): Promise { // 입력 필드는 단일 값이므로 첫 번째 항목만 사용 if (data.length > 0) { @@ -773,21 +792,21 @@ class InputComponent implements DataReceivable { this.onDataReceived?.(data); } } - + getData(): any { return this.value; } - + clearData(): void { this.value = ""; this.render(); this.onDataCleared?.(); } - + validate(): boolean { return this.value !== null && this.value !== undefined && this.value !== ""; } - + private render() { // 입력 필드 리렌더링 } @@ -812,7 +831,7 @@ export async function getScreenEmbeddings( AND company_code = $2 ORDER BY position `; - + const result = await pool.query(query, [parentScreenId, companyCode]); return { success: true, data: result.rows }; } @@ -828,16 +847,16 @@ export async function createScreenEmbedding( ) VALUES ($1, $2, $3, $4, $5, $6) RETURNING * `; - + const result = await pool.query(query, [ embedding.parentScreenId, embedding.childScreenId, embedding.position, embedding.mode, JSON.stringify(embedding.config), - companyCode + companyCode, ]); - + return { success: true, data: result.rows[0] }; } @@ -850,26 +869,26 @@ export async function updateScreenEmbedding( const updates: string[] = []; const values: any[] = []; let paramIndex = 1; - + if (embedding.position) { updates.push(`position = $${paramIndex++}`); values.push(embedding.position); } - + if (embedding.mode) { updates.push(`mode = $${paramIndex++}`); values.push(embedding.mode); } - + if (embedding.config) { updates.push(`config = $${paramIndex++}`); values.push(JSON.stringify(embedding.config)); } - + updates.push(`updated_at = NOW()`); - + values.push(id, companyCode); - + const query = ` UPDATE screen_embedding SET ${updates.join(", ")} @@ -877,13 +896,13 @@ export async function updateScreenEmbedding( AND company_code = $${paramIndex++} RETURNING * `; - + const result = await pool.query(query, values); - + if (result.rowCount === 0) { return { success: false, message: "임베딩 설정을 찾을 수 없습니다." }; } - + return { success: true, data: result.rows[0] }; } @@ -896,13 +915,13 @@ export async function deleteScreenEmbedding( DELETE FROM screen_embedding WHERE id = $1 AND company_code = $2 `; - + const result = await pool.query(query, [id, companyCode]); - + if (result.rowCount === 0) { return { success: false, message: "임베딩 설정을 찾을 수 없습니다." }; } - + return { success: true }; } ``` @@ -922,13 +941,17 @@ export async function getScreenDataTransfer( AND target_screen_id = $2 AND company_code = $3 `; - - const result = await pool.query(query, [sourceScreenId, targetScreenId, companyCode]); - + + const result = await pool.query(query, [ + sourceScreenId, + targetScreenId, + companyCode, + ]); + if (result.rowCount === 0) { return { success: false, message: "데이터 전달 설정을 찾을 수 없습니다." }; } - + return { success: true, data: result.rows[0] }; } @@ -944,7 +967,7 @@ export async function createScreenDataTransfer( ) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING * `; - + const result = await pool.query(query, [ transfer.sourceScreenId, transfer.targetScreenId, @@ -952,9 +975,9 @@ export async function createScreenDataTransfer( transfer.sourceComponentType, JSON.stringify(transfer.dataReceivers), JSON.stringify(transfer.buttonConfig), - companyCode + companyCode, ]); - + return { success: true, data: result.rows[0] }; } @@ -967,21 +990,21 @@ export async function updateScreenDataTransfer( const updates: string[] = []; const values: any[] = []; let paramIndex = 1; - + if (transfer.dataReceivers) { updates.push(`data_receivers = $${paramIndex++}`); values.push(JSON.stringify(transfer.dataReceivers)); } - + if (transfer.buttonConfig) { updates.push(`button_config = $${paramIndex++}`); values.push(JSON.stringify(transfer.buttonConfig)); } - + updates.push(`updated_at = NOW()`); - + values.push(id, companyCode); - + const query = ` UPDATE screen_data_transfer SET ${updates.join(", ")} @@ -989,13 +1012,13 @@ export async function updateScreenDataTransfer( AND company_code = $${paramIndex++} RETURNING * `; - + const result = await pool.query(query, values); - + if (result.rowCount === 0) { return { success: false, message: "데이터 전달 설정을 찾을 수 없습니다." }; } - + return { success: true, data: result.rows[0] }; } ``` @@ -1021,13 +1044,13 @@ export async function getScreenSplitPanel( WHERE ssp.screen_id = $1 AND ssp.company_code = $2 `; - + const result = await pool.query(query, [screenId, companyCode]); - + if (result.rowCount === 0) { return { success: false, message: "분할 패널 설정을 찾을 수 없습니다." }; } - + return { success: true, data: result.rows[0] }; } @@ -1037,19 +1060,28 @@ export async function createScreenSplitPanel( companyCode: string ): Promise> { const client = await pool.connect(); - + try { await client.query("BEGIN"); - + // 1. 좌측 임베딩 생성 - const leftEmbedding = await createScreenEmbedding(panel.leftEmbedding, companyCode); - + const leftEmbedding = await createScreenEmbedding( + panel.leftEmbedding, + companyCode + ); + // 2. 우측 임베딩 생성 - const rightEmbedding = await createScreenEmbedding(panel.rightEmbedding, companyCode); - + const rightEmbedding = await createScreenEmbedding( + panel.rightEmbedding, + companyCode + ); + // 3. 데이터 전달 설정 생성 - const dataTransfer = await createScreenDataTransfer(panel.dataTransfer, companyCode); - + const dataTransfer = await createScreenDataTransfer( + panel.dataTransfer, + companyCode + ); + // 4. 분할 패널 생성 const query = ` INSERT INTO screen_split_panel ( @@ -1058,18 +1090,18 @@ export async function createScreenSplitPanel( ) VALUES ($1, $2, $3, $4, $5, $6) RETURNING * `; - + const result = await client.query(query, [ panel.screenId, leftEmbedding.data!.id, rightEmbedding.data!.id, dataTransfer.data!.id, JSON.stringify(panel.layoutConfig), - companyCode + companyCode, ]); - + await client.query("COMMIT"); - + return { success: true, data: result.rows[0] }; } catch (error) { await client.query("ROLLBACK"); @@ -1087,6 +1119,7 @@ export async function createScreenSplitPanel( ### Phase 1: 기본 인프라 구축 (1-2주) #### 1.1 데이터베이스 마이그레이션 + - [ ] `screen_embedding` 테이블 생성 - [ ] `screen_data_transfer` 테이블 생성 - [ ] `screen_split_panel` 테이블 생성 @@ -1094,12 +1127,14 @@ export async function createScreenSplitPanel( - [ ] 샘플 데이터 삽입 #### 1.2 타입 정의 + - [ ] TypeScript 인터페이스 작성 - [ ] `types/screen-embedding.ts` - [ ] `types/data-transfer.ts` - [ ] `types/split-panel.ts` #### 1.3 백엔드 API + - [ ] 화면 임베딩 CRUD API - [ ] 데이터 전달 설정 CRUD API - [ ] 분할 패널 CRUD API @@ -1108,12 +1143,14 @@ export async function createScreenSplitPanel( ### Phase 2: 화면 임베딩 기능 (2-3주) #### 2.1 EmbeddedScreen 컴포넌트 + - [ ] 기본 임베딩 기능 - [ ] 모드별 렌더링 (view, select, form, edit) - [ ] 선택 모드 구현 (체크박스) - [ ] 이벤트 핸들링 #### 2.2 DataReceivable 인터페이스 구현 + - [ ] TableComponent - [ ] InputComponent - [ ] SelectComponent @@ -1123,6 +1160,7 @@ export async function createScreenSplitPanel( - [ ] HiddenComponent #### 2.3 컴포넌트 등록 시스템 + - [ ] 컴포넌트 마운트 시 자동 등록 - [ ] 컴포넌트 ID 관리 - [ ] 컴포넌트 참조 관리 @@ -1130,6 +1168,7 @@ export async function createScreenSplitPanel( ### Phase 3: 데이터 전달 시스템 (2-3주) #### 3.1 매핑 엔진 + - [ ] 매핑 규칙 파싱 - [ ] 필드 매핑 적용 - [ ] 변환 함수 구현 @@ -1139,11 +1178,13 @@ export async function createScreenSplitPanel( - [ ] concat, join #### 3.2 조건부 전달 + - [ ] 조건 파싱 - [ ] 필터링 로직 - [ ] 복합 조건 지원 #### 3.3 검증 시스템 + - [ ] 필수 필드 검증 - [ ] 최소/최대 행 수 검증 - [ ] 커스텀 검증 함수 실행 @@ -1151,18 +1192,21 @@ export async function createScreenSplitPanel( ### Phase 4: 분할 패널 UI (2-3주) #### 4.1 ScreenSplitPanel 컴포넌트 + - [ ] 기본 레이아웃 - [ ] 리사이저 구현 - [ ] 전달 버튼 - [ ] 반응형 디자인 #### 4.2 설정 UI + - [ ] 화면 선택 드롭다운 - [ ] 매핑 규칙 설정 UI - [ ] 드래그앤드롭 매핑 - [ ] 미리보기 기능 #### 4.3 시각적 피드백 + - [ ] 데이터 전달 애니메이션 - [ ] 로딩 상태 표시 - [ ] 성공/실패 토스트 @@ -1170,14 +1214,17 @@ export async function createScreenSplitPanel( ### Phase 5: 고급 기능 (2-3주) #### 5.1 양방향 동기화 + - [ ] 우측 → 좌측 데이터 반영 - [ ] 실시간 업데이트 #### 5.2 트랜잭션 지원 + - [ ] 전체 성공 또는 전체 실패 - [ ] 롤백 기능 #### 5.3 성능 최적화 + - [ ] 대량 데이터 처리 - [ ] 가상 스크롤링 - [ ] 메모이제이션 @@ -1185,15 +1232,18 @@ export async function createScreenSplitPanel( ### Phase 6: 테스트 및 문서화 (1-2주) #### 6.1 단위 테스트 + - [ ] 매핑 엔진 테스트 - [ ] 변환 함수 테스트 - [ ] 검증 로직 테스트 #### 6.2 통합 테스트 + - [ ] 전체 워크플로우 테스트 - [ ] 실제 시나리오 테스트 #### 6.3 문서화 + - [ ] 사용자 가이드 - [ ] 개발자 문서 - [ ] API 문서 @@ -1205,6 +1255,7 @@ export async function createScreenSplitPanel( ### 시나리오 1: 입고 등록 #### 요구사항 + - 발주 목록에서 품목을 선택하여 입고 등록 - 선택된 품목의 정보를 입고 처리 품목 테이블에 추가 - 공급자 정보를 자동으로 입력 필드에 설정 @@ -1216,23 +1267,23 @@ export async function createScreenSplitPanel( const 입고등록_설정: ScreenSplitPanel = { screenId: 100, leftEmbedding: { - childScreenId: 10, // 발주 목록 조회 화면 + childScreenId: 10, // 발주 목록 조회 화면 position: "left", mode: "select", config: { width: "50%", multiSelect: true, showSearch: true, - showPagination: true - } + showPagination: true, + }, }, rightEmbedding: { - childScreenId: 20, // 입고 등록 폼 화면 + childScreenId: 20, // 입고 등록 폼 화면 position: "right", mode: "form", config: { - width: "50%" - } + width: "50%", + }, }, dataTransfer: { sourceScreenId: 10, @@ -1248,33 +1299,33 @@ const 입고등록_설정: ScreenSplitPanel = { { sourceField: "품목코드", targetField: "품목코드" }, { sourceField: "품목명", targetField: "품목명" }, { sourceField: "발주수량", targetField: "발주수량" }, - { sourceField: "미입고수량", targetField: "입고수량" } - ] + { sourceField: "미입고수량", targetField: "입고수량" }, + ], }, { targetComponentId: "input-공급자", targetComponentType: "input", mode: "replace", mappingRules: [ - { - sourceField: "공급자", + { + sourceField: "공급자", targetField: "value", - transform: "first" - } - ] + transform: "first", + }, + ], }, { targetComponentId: "input-품목수", targetComponentType: "input", mode: "replace", mappingRules: [ - { - sourceField: "품목코드", + { + sourceField: "품목코드", targetField: "value", - transform: "count" - } - ] - } + transform: "count", + }, + ], + }, ], buttonConfig: { label: "선택 품목 추가", @@ -1282,23 +1333,24 @@ const 입고등록_설정: ScreenSplitPanel = { icon: "ArrowRight", validation: { requireSelection: true, - minSelection: 1 - } - } + minSelection: 1, + }, + }, }, layoutConfig: { splitRatio: 50, resizable: true, minLeftWidth: 400, minRightWidth: 600, - orientation: "horizontal" - } + orientation: "horizontal", + }, }; ``` ### 시나리오 2: 수주 등록 #### 요구사항 + - 견적서 목록에서 품목을 선택하여 수주 등록 - 고객 정보를 자동으로 폼에 설정 - 품목별 수량 및 금액 자동 계산 @@ -1310,21 +1362,21 @@ const 입고등록_설정: ScreenSplitPanel = { const 수주등록_설정: ScreenSplitPanel = { screenId: 101, leftEmbedding: { - childScreenId: 30, // 견적서 목록 조회 화면 + childScreenId: 30, // 견적서 목록 조회 화면 position: "left", mode: "select", config: { width: "40%", - multiSelect: true - } + multiSelect: true, + }, }, rightEmbedding: { - childScreenId: 40, // 수주 등록 폼 화면 + childScreenId: 40, // 수주 등록 폼 화면 position: "right", mode: "form", config: { - width: "60%" - } + width: "60%", + }, }, dataTransfer: { sourceScreenId: 30, @@ -1339,54 +1391,55 @@ const 수주등록_설정: ScreenSplitPanel = { { sourceField: "품목명", targetField: "품목명" }, { sourceField: "수량", targetField: "수량" }, { sourceField: "단가", targetField: "단가" }, - { - sourceField: "수량", + { + sourceField: "수량", targetField: "금액", transform: "custom", transformConfig: { - formula: "수량 * 단가" - } - } - ] + formula: "수량 * 단가", + }, + }, + ], }, { targetComponentId: "input-고객명", targetComponentType: "input", mode: "replace", mappingRules: [ - { sourceField: "고객명", targetField: "value", transform: "first" } - ] + { sourceField: "고객명", targetField: "value", transform: "first" }, + ], }, { targetComponentId: "input-총금액", targetComponentType: "input", mode: "replace", mappingRules: [ - { - sourceField: "금액", + { + sourceField: "금액", targetField: "value", - transform: "sum" - } - ] - } + transform: "sum", + }, + ], + }, ], buttonConfig: { label: "견적서 불러오기", position: "center", - icon: "Download" - } + icon: "Download", + }, }, layoutConfig: { splitRatio: 40, resizable: true, - orientation: "horizontal" - } + orientation: "horizontal", + }, }; ``` ### 시나리오 3: 출고 등록 #### 요구사항 + - 재고 목록에서 품목을 선택하여 출고 등록 - 재고 수량 확인 및 경고 - 출고 가능 수량만 필터링 @@ -1398,21 +1451,21 @@ const 수주등록_설정: ScreenSplitPanel = { const 출고등록_설정: ScreenSplitPanel = { screenId: 102, leftEmbedding: { - childScreenId: 50, // 재고 목록 조회 화면 + childScreenId: 50, // 재고 목록 조회 화면 position: "left", mode: "select", config: { width: "45%", - multiSelect: true - } + multiSelect: true, + }, }, rightEmbedding: { - childScreenId: 60, // 출고 등록 폼 화면 + childScreenId: 60, // 출고 등록 폼 화면 position: "right", mode: "form", config: { - width: "55%" - } + width: "55%", + }, }, dataTransfer: { sourceScreenId: 50, @@ -1426,26 +1479,26 @@ const 출고등록_설정: ScreenSplitPanel = { { sourceField: "품목코드", targetField: "품목코드" }, { sourceField: "품목명", targetField: "품목명" }, { sourceField: "재고수량", targetField: "가용수량" }, - { sourceField: "창고", targetField: "출고창고" } + { sourceField: "창고", targetField: "출고창고" }, ], condition: { field: "재고수량", operator: "greaterThan", - value: 0 - } + value: 0, + }, }, { targetComponentId: "input-총출고수량", targetComponentType: "input", mode: "replace", mappingRules: [ - { - sourceField: "재고수량", + { + sourceField: "재고수량", targetField: "value", - transform: "sum" - } - ] - } + transform: "sum", + }, + ], + }, ], buttonConfig: { label: "출고 품목 추가", @@ -1453,15 +1506,15 @@ const 출고등록_설정: ScreenSplitPanel = { icon: "ArrowRight", validation: { requireSelection: true, - confirmMessage: "선택한 품목을 출고 처리하시겠습니까?" - } - } + confirmMessage: "선택한 품목을 출고 처리하시겠습니까?", + }, + }, }, layoutConfig: { splitRatio: 45, resizable: true, - orientation: "horizontal" - } + orientation: "horizontal", + }, }; ``` @@ -1472,11 +1525,13 @@ const 출고등록_설정: ScreenSplitPanel = { ### 1. 성능 최적화 #### 대량 데이터 처리 + - 가상 스크롤링 적용 - 청크 단위 데이터 전달 - 백그라운드 처리 #### 메모리 관리 + - 컴포넌트 언마운트 시 참조 해제 - 이벤트 리스너 정리 - 메모이제이션 활용 @@ -1484,11 +1539,13 @@ const 출고등록_설정: ScreenSplitPanel = { ### 2. 보안 #### 권한 검증 + - 화면 접근 권한 확인 - 데이터 전달 권한 확인 - 멀티테넌시 격리 #### 데이터 검증 + - 입력값 검증 - SQL 인젝션 방지 - XSS 방지 @@ -1496,22 +1553,26 @@ const 출고등록_설정: ScreenSplitPanel = { ### 3. 에러 처리 #### 사용자 친화적 메시지 + - 명확한 오류 메시지 - 복구 방법 안내 - 로그 기록 #### 트랜잭션 롤백 + - 부분 실패 시 전체 롤백 - 데이터 일관성 유지 ### 4. 확장성 #### 플러그인 시스템 + - 커스텀 변환 함수 등록 - 커스텀 검증 함수 등록 - 커스텀 컴포넌트 타입 추가 #### 이벤트 시스템 + - 데이터 전달 전/후 이벤트 - 커스텀 이벤트 핸들러 @@ -1520,31 +1581,37 @@ const 출고등록_설정: ScreenSplitPanel = { ## 마일스톤 ### M1: 기본 인프라 (2주) + - 데이터베이스 스키마 완성 - 백엔드 API 완성 - 타입 정의 완성 ### M2: 화면 임베딩 (3주) + - EmbeddedScreen 컴포넌트 완성 - DataReceivable 인터페이스 구현 완료 - 선택 모드 동작 확인 ### M3: 데이터 전달 (3주) + - 매핑 엔진 완성 - 변환 함수 구현 완료 - 조건부 전달 동작 확인 ### M4: 분할 패널 UI (3주) + - ScreenSplitPanel 컴포넌트 완성 - 설정 UI 완성 - 입고 등록 시나리오 완성 ### M5: 고급 기능 및 최적화 (3주) + - 양방향 동기화 완성 - 성능 최적화 완료 - 전체 테스트 통과 ### M6: 문서화 및 배포 (1주) + - 사용자 가이드 작성 - 개발자 문서 작성 - 프로덕션 배포 @@ -1567,6 +1634,7 @@ const 출고등록_설정: ScreenSplitPanel = { ## 성공 지표 ### 기능적 지표 + - [ ] 입고 등록 시나리오 완벽 동작 - [ ] 수주 등록 시나리오 완벽 동작 - [ ] 출고 등록 시나리오 완벽 동작 @@ -1574,11 +1642,13 @@ const 출고등록_설정: ScreenSplitPanel = { - [ ] 모든 변환 함수 정상 동작 ### 성능 지표 + - [ ] 1000개 행 데이터 전달 < 1초 - [ ] 화면 로딩 시간 < 2초 - [ ] 메모리 사용량 < 100MB ### 사용성 지표 + - [ ] 설정 UI 직관적 - [ ] 에러 메시지 명확 - [ ] 문서 완성도 90% 이상 @@ -1588,15 +1658,18 @@ const 출고등록_설정: ScreenSplitPanel = { ## 리스크 관리 ### 기술적 리스크 + - **복잡도 증가**: 단계별 구현으로 관리 - **성능 문제**: 초기부터 최적화 고려 - **호환성 문제**: 기존 시스템과 충돌 방지 ### 일정 리스크 + - **예상 기간 초과**: 버퍼 2주 확보 - **우선순위 변경**: 핵심 기능 먼저 구현 ### 인력 리스크 + - **담당자 부재**: 문서화 철저히 - **지식 공유**: 주간 리뷰 미팅 @@ -1605,4 +1678,3 @@ const 출고등록_설정: ScreenSplitPanel = { ## 결론 화면 임베딩 및 데이터 전달 시스템은 복잡한 업무 워크플로우를 효율적으로 처리할 수 있는 강력한 기능입니다. 단계별로 체계적으로 구현하면 약 3.5개월 내에 완성할 수 있으며, 이를 통해 사용자 경험을 크게 향상시킬 수 있습니다. - diff --git a/화면_임베딩_시스템_Phase1-4_구현_완료.md b/화면_임베딩_시스템_Phase1-4_구현_완료.md index cf4879c0..c1880ef7 100644 --- a/화면_임베딩_시스템_Phase1-4_구현_완료.md +++ b/화면_임베딩_시스템_Phase1-4_구현_완료.md @@ -21,12 +21,14 @@ **생성된 테이블**: 1. **screen_embedding** (화면 임베딩 설정) + - 한 화면을 다른 화면 안에 임베드 - 위치 (left, right, top, bottom, center) - 모드 (view, select, form, edit) - 설정 (width, height, multiSelect 등) 2. **screen_data_transfer** (데이터 전달 설정) + - 소스 화면 → 타겟 화면 데이터 전달 - 데이터 수신자 배열 (JSONB) - 매핑 규칙, 조건, 검증 @@ -38,6 +40,7 @@ - 레이아웃 설정 (splitRatio, resizable 등) **샘플 데이터**: + - 입고 등록 시나리오 샘플 데이터 포함 - 발주 목록 → 입고 처리 품목 매핑 예시 @@ -46,6 +49,7 @@ **파일**: `frontend/types/screen-embedding.ts` **주요 타입**: + ```typescript // 화면 임베딩 - EmbeddingMode: "view" | "select" | "form" | "edit" @@ -67,13 +71,15 @@ #### 1.3 백엔드 API -**파일**: +**파일**: + - `backend-node/src/controllers/screenEmbeddingController.ts` - `backend-node/src/routes/screenEmbeddingRoutes.ts` **API 엔드포인트**: **화면 임베딩**: + - `GET /api/screen-embedding?parentScreenId=1` - 목록 조회 - `GET /api/screen-embedding/:id` - 상세 조회 - `POST /api/screen-embedding` - 생성 @@ -81,18 +87,21 @@ - `DELETE /api/screen-embedding/:id` - 삭제 **데이터 전달**: + - `GET /api/screen-data-transfer?sourceScreenId=1&targetScreenId=2` - 조회 - `POST /api/screen-data-transfer` - 생성 - `PUT /api/screen-data-transfer/:id` - 수정 - `DELETE /api/screen-data-transfer/:id` - 삭제 **분할 패널**: + - `GET /api/screen-split-panel/:screenId` - 조회 - `POST /api/screen-split-panel` - 생성 (트랜잭션) - `PUT /api/screen-split-panel/:id` - 수정 - `DELETE /api/screen-split-panel/:id` - 삭제 (CASCADE) **특징**: + - ✅ 멀티테넌시 지원 (company_code 필터링) - ✅ 트랜잭션 처리 (분할 패널 생성/삭제) - ✅ 외래키 CASCADE 처리 @@ -103,25 +112,24 @@ **파일**: `frontend/lib/api/screenEmbedding.ts` **함수**: + ```typescript // 화면 임베딩 -- getScreenEmbeddings(parentScreenId) -- getScreenEmbeddingById(id) -- createScreenEmbedding(data) -- updateScreenEmbedding(id, data) -- deleteScreenEmbedding(id) - -// 데이터 전달 -- getScreenDataTransfer(sourceScreenId, targetScreenId) -- createScreenDataTransfer(data) -- updateScreenDataTransfer(id, data) -- deleteScreenDataTransfer(id) - -// 분할 패널 -- getScreenSplitPanel(screenId) -- createScreenSplitPanel(data) -- updateScreenSplitPanel(id, layoutConfig) -- deleteScreenSplitPanel(id) +-getScreenEmbeddings(parentScreenId) - + getScreenEmbeddingById(id) - + createScreenEmbedding(data) - + updateScreenEmbedding(id, data) - + deleteScreenEmbedding(id) - + // 데이터 전달 + getScreenDataTransfer(sourceScreenId, targetScreenId) - + createScreenDataTransfer(data) - + updateScreenDataTransfer(id, data) - + deleteScreenDataTransfer(id) - + // 분할 패널 + getScreenSplitPanel(screenId) - + createScreenSplitPanel(data) - + updateScreenSplitPanel(id, layoutConfig) - + deleteScreenSplitPanel(id); ``` --- @@ -133,6 +141,7 @@ **파일**: `frontend/components/screen-embedding/EmbeddedScreen.tsx` **주요 기능**: + - ✅ 화면 데이터 로드 - ✅ 모드별 렌더링 (view, select, form, edit) - ✅ 선택 모드 지원 (체크박스) @@ -141,6 +150,7 @@ - ✅ 로딩/에러 상태 UI **외부 인터페이스** (useImperativeHandle): + ```typescript - getSelectedRows(): any[] - clearSelection(): void @@ -149,6 +159,7 @@ ``` **데이터 수신 프로세스**: + 1. 조건 필터링 (condition) 2. 매핑 규칙 적용 (mappingRules) 3. 검증 (validation) @@ -165,10 +176,12 @@ **주요 함수**: 1. **applyMappingRules(data, rules)** + - 일반 매핑: 각 행에 대해 필드 매핑 - 변환 매핑: 집계 함수 적용 2. **변환 함수 지원**: + - `sum`: 합계 - `average`: 평균 - `count`: 개수 @@ -177,15 +190,18 @@ - `concat`, `join`: 문자열 결합 3. **filterDataByCondition(data, condition)** + - 조건 연산자: equals, notEquals, contains, greaterThan, lessThan, in, notIn 4. **validateMappingResult(data, rules)** + - 필수 필드 검증 5. **previewMapping(sampleData, rules)** - 매핑 결과 미리보기 **특징**: + - ✅ 중첩 객체 지원 (`user.address.city`) - ✅ 타입 안전성 - ✅ 에러 처리 @@ -195,6 +211,7 @@ **파일**: `frontend/lib/utils/logger.ts` **기능**: + - debug, info, warn, error 레벨 - 개발 환경에서만 debug 출력 - 타임스탬프 포함 @@ -208,6 +225,7 @@ **파일**: `frontend/components/screen-embedding/ScreenSplitPanel.tsx` **주요 기능**: + - ✅ 좌우 화면 임베딩 - ✅ 리사이저 (드래그로 비율 조정) - ✅ 데이터 전달 버튼 @@ -218,6 +236,7 @@ - ✅ 전달 후 선택 초기화 (옵션) **UI 구조**: + ``` ┌─────────────────────────────────────────────────────────┐ │ [좌측 패널 50%] │ [버튼] │ [우측 패널 50%] │ @@ -230,6 +249,7 @@ ``` **이벤트 흐름**: + 1. 좌측에서 행 선택 → 선택 카운트 업데이트 2. 전달 버튼 클릭 → 검증 3. 우측 화면의 컴포넌트들에 데이터 전달 @@ -281,7 +301,7 @@ ERP-node/ const inboundConfig: ScreenSplitPanel = { screenId: 100, leftEmbedding: { - childScreenId: 10, // 발주 목록 조회 + childScreenId: 10, // 발주 목록 조회 position: "left", mode: "select", config: { @@ -290,7 +310,7 @@ const inboundConfig: ScreenSplitPanel = { }, }, rightEmbedding: { - childScreenId: 20, // 입고 등록 폼 + childScreenId: 20, // 입고 등록 폼 position: "right", mode: "form", config: { @@ -352,7 +372,7 @@ const inboundConfig: ScreenSplitPanel = { onDataTransferred={(data) => { console.log("전달된 데이터:", data); }} -/> +/>; ``` --- @@ -395,6 +415,7 @@ const inboundConfig: ScreenSplitPanel = { ### Phase 5: 고급 기능 (예정) 1. **DataReceivable 인터페이스 구현** + - TableComponent - InputComponent - SelectComponent @@ -402,6 +423,7 @@ const inboundConfig: ScreenSplitPanel = { - 기타 컴포넌트들 2. **양방향 동기화** + - 우측 → 좌측 데이터 반영 - 실시간 업데이트 @@ -412,6 +434,7 @@ const inboundConfig: ScreenSplitPanel = { ### Phase 6: 설정 UI (예정) 1. **시각적 매핑 설정 UI** + - 드래그앤드롭으로 필드 매핑 - 변환 함수 선택 - 조건 설정 @@ -463,7 +486,7 @@ import { getScreenSplitPanel } from "@/lib/api/screenEmbedding"; const { data: config } = await getScreenSplitPanel(screenId); // 렌더링 - +; ``` --- @@ -471,6 +494,7 @@ const { data: config } = await getScreenSplitPanel(screenId); ## ✅ 체크리스트 ### 구현 완료 + - [x] 데이터베이스 스키마 (3개 테이블) - [x] TypeScript 타입 정의 - [x] 백엔드 API (15개 엔드포인트) @@ -481,6 +505,7 @@ const { data: config } = await getScreenSplitPanel(screenId); - [x] 로거 유틸리티 ### 다음 단계 + - [ ] DataReceivable 구현 (각 컴포넌트 타입별) - [ ] 설정 UI (드래그앤드롭 매핑) - [ ] 미리보기 기능 @@ -500,4 +525,3 @@ const { data: config } = await getScreenSplitPanel(screenId); - ✅ 매핑 엔진 완성 이제 입고 등록과 같은 복잡한 워크플로우를 구현할 수 있습니다. 다음 단계는 각 컴포넌트 타입별 DataReceivable 인터페이스 구현과 설정 UI 개발입니다. - diff --git a/화면_임베딩_시스템_충돌_분석_보고서.md b/화면_임베딩_시스템_충돌_분석_보고서.md index 00e16b8e..6cebf31e 100644 --- a/화면_임베딩_시스템_충돌_분석_보고서.md +++ b/화면_임베딩_시스템_충돌_분석_보고서.md @@ -11,6 +11,7 @@ ### 1. 데이터베이스 스키마 #### 새로운 테이블 (독립적) + ```sql - screen_embedding (신규) - screen_data_transfer (신규) @@ -18,11 +19,13 @@ ``` **충돌 없는 이유**: + - ✅ 완전히 새로운 테이블명 - ✅ 기존 테이블과 이름 중복 없음 - ✅ 외래키는 기존 `screen_definitions`만 참조 (읽기 전용) #### 기존 테이블 (영향 없음) + ```sql - screen_definitions (변경 없음) - screen_layouts (변경 없음) @@ -32,6 +35,7 @@ ``` **확인 사항**: + - ✅ 기존 테이블 구조 변경 없음 - ✅ 기존 데이터 마이그레이션 불필요 - ✅ 기존 쿼리 영향 없음 @@ -41,6 +45,7 @@ ### 2. API 엔드포인트 #### 새로운 엔드포인트 (독립적) + ``` POST /api/screen-embedding GET /api/screen-embedding @@ -59,11 +64,13 @@ DELETE /api/screen-split-panel/:id ``` **충돌 없는 이유**: + - ✅ 기존 `/api/screen-management/*` 와 다른 경로 - ✅ 새로운 라우트 추가만 (기존 라우트 수정 없음) - ✅ 독립적인 컨트롤러 파일 #### 기존 엔드포인트 (영향 없음) + ``` /api/screen-management/* (변경 없음) /api/screen/* (변경 없음) @@ -75,16 +82,19 @@ DELETE /api/screen-split-panel/:id ### 3. TypeScript 타입 #### 새로운 타입 파일 (독립적) + ```typescript -frontend/types/screen-embedding.ts (신규) +frontend / types / screen - embedding.ts(신규); ``` **충돌 없는 이유**: + - ✅ 기존 `screen.ts`, `screen-management.ts` 와 별도 파일 - ✅ 타입명 중복 없음 - ✅ 독립적인 네임스페이스 #### 기존 타입 (영향 없음) + ```typescript frontend/types/screen.ts (변경 없음) frontend/types/screen-management.ts (변경 없음) @@ -96,6 +106,7 @@ backend-node/src/types/screen.ts (변경 없음) ### 4. 프론트엔드 컴포넌트 #### 새로운 컴포넌트 (독립적) + ``` frontend/components/screen-embedding/ ├── EmbeddedScreen.tsx (신규) @@ -104,11 +115,13 @@ frontend/components/screen-embedding/ ``` **충돌 없는 이유**: + - ✅ 별도 디렉토리 (`screen-embedding/`) - ✅ 기존 컴포넌트 수정 없음 - ✅ 독립적으로 import 가능 #### 기존 컴포넌트 (영향 없음) + ``` frontend/components/screen/ (변경 없음) frontend/app/(main)/screens/[screenId]/page.tsx (변경 없음) @@ -121,17 +134,20 @@ frontend/app/(main)/screens/[screenId]/page.tsx (변경 없음) ### 1. screen_definitions 테이블 참조 **현재 구조**: + ```sql -- 새 테이블들이 screen_definitions를 참조 -CONSTRAINT fk_parent_screen FOREIGN KEY (parent_screen_id) +CONSTRAINT fk_parent_screen FOREIGN KEY (parent_screen_id) REFERENCES screen_definitions(screen_id) ON DELETE CASCADE ``` **잠재적 문제**: + - ⚠️ 기존 화면 삭제 시 임베딩 설정도 함께 삭제됨 (CASCADE) - ⚠️ 화면 ID 변경 시 임베딩 설정이 깨질 수 있음 **해결 방법**: + ```sql -- 이미 구현됨: ON DELETE CASCADE -- 화면 삭제 시 자동으로 관련 임베딩도 삭제 @@ -139,6 +155,7 @@ CONSTRAINT fk_parent_screen FOREIGN KEY (parent_screen_id) ``` **권장 사항**: + - ✅ 화면 삭제 전 임베딩 사용 여부 확인 UI 추가 (Phase 6) - ✅ 삭제 시 경고 메시지 표시 @@ -147,21 +164,23 @@ CONSTRAINT fk_parent_screen FOREIGN KEY (parent_screen_id) ### 2. 화면 렌더링 로직 **현재 화면 렌더링**: + ```typescript // frontend/app/(main)/screens/[screenId]/page.tsx function ScreenViewPage() { // 기존: 단일 화면 렌더링 const screenId = parseInt(params.screenId as string); - + // 레이아웃 로드 const layout = await screenApi.getScreenLayout(screenId); - + // 컴포넌트 렌더링 - + ; } ``` **새로운 렌더링 (분할 패널)**: + ```typescript // 분할 패널 화면인 경우 if (isSplitPanelScreen) { @@ -174,10 +193,12 @@ return ; ``` **잠재적 문제**: + - ⚠️ 화면 타입 구분 로직 필요 - ⚠️ 기존 화면 렌더링 로직 수정 필요 **해결 방법**: + ```typescript // 1. screen_definitions에 screen_type 컬럼 추가 (선택사항) ALTER TABLE screen_definitions ADD COLUMN screen_type VARCHAR(20) DEFAULT 'normal'; @@ -191,40 +212,45 @@ if (splitPanelConfig.success && splitPanelConfig.data) { ``` **권장 구현**: + ```typescript // frontend/app/(main)/screens/[screenId]/page.tsx 수정 useEffect(() => { const loadScreen = async () => { // 1. 분할 패널 확인 const splitPanelResult = await getScreenSplitPanel(screenId); - + if (splitPanelResult.success && splitPanelResult.data) { // 분할 패널 화면 - setScreenType('split_panel'); + setScreenType("split_panel"); setSplitPanelConfig(splitPanelResult.data); return; } - + // 2. 일반 화면 const screenResult = await screenApi.getScreen(screenId); const layoutResult = await screenApi.getScreenLayout(screenId); - - setScreenType('normal'); + + setScreenType("normal"); setScreen(screenResult.data); setLayout(layoutResult.data); }; - + loadScreen(); }, [screenId]); // 렌더링 -{screenType === 'split_panel' && splitPanelConfig && ( - -)} +{ + screenType === "split_panel" && splitPanelConfig && ( + + ); +} -{screenType === 'normal' && layout && ( - -)} +{ + screenType === "normal" && layout && ( + + ); +} ``` --- @@ -232,6 +258,7 @@ useEffect(() => { ### 3. 컴포넌트 등록 시스템 **현재 시스템**: + ```typescript // frontend/lib/registry/components.ts const componentRegistry = new Map(); @@ -242,6 +269,7 @@ export function registerComponent(id: string, component: any) { ``` **새로운 요구사항**: + ```typescript // DataReceivable 인터페이스 구현 필요 interface DataReceivable { @@ -254,29 +282,31 @@ interface DataReceivable { ``` **잠재적 문제**: + - ⚠️ 기존 컴포넌트들이 DataReceivable 인터페이스 미구현 - ⚠️ 데이터 수신 기능 없음 **해결 방법**: + ```typescript // Phase 5에서 구현 예정 // 기존 컴포넌트를 래핑하는 어댑터 패턴 사용 class TableComponentAdapter implements DataReceivable { constructor(private tableComponent: any) {} - + async receiveData(data: any[], mode: DataReceiveMode) { - if (mode === 'append') { + if (mode === "append") { this.tableComponent.addRows(data); - } else if (mode === 'replace') { + } else if (mode === "replace") { this.tableComponent.setRows(data); } } - + getData() { return this.tableComponent.getRows(); } - + clearData() { this.tableComponent.clearRows(); } @@ -284,6 +314,7 @@ class TableComponentAdapter implements DataReceivable { ``` **권장 사항**: + - ✅ 기존 컴포넌트 수정 없이 어댑터로 래핑 - ✅ 점진적으로 DataReceivable 구현 - ✅ 하위 호환성 유지 @@ -297,38 +328,41 @@ class TableComponentAdapter implements DataReceivable { **파일**: `frontend/app/(main)/screens/[screenId]/page.tsx` **수정 내용**: + ```typescript import { getScreenSplitPanel } from "@/lib/api/screenEmbedding"; import { ScreenSplitPanel } from "@/components/screen-embedding"; function ScreenViewPage() { - const [screenType, setScreenType] = useState<'normal' | 'split_panel'>('normal'); + const [screenType, setScreenType] = useState<"normal" | "split_panel">( + "normal" + ); const [splitPanelConfig, setSplitPanelConfig] = useState(null); - + useEffect(() => { const loadScreen = async () => { // 분할 패널 확인 const splitResult = await getScreenSplitPanel(screenId); - + if (splitResult.success && splitResult.data) { - setScreenType('split_panel'); + setScreenType("split_panel"); setSplitPanelConfig(splitResult.data); setLoading(false); return; } - + // 일반 화면 로드 (기존 로직) // ... }; - + loadScreen(); }, [screenId]); - + // 렌더링 - if (screenType === 'split_panel' && splitPanelConfig) { + if (screenType === "split_panel" && splitPanelConfig) { return ; } - + // 기존 렌더링 로직 // ... } @@ -343,6 +377,7 @@ function ScreenViewPage() { **파일**: 화면 관리 페이지 **추가 기능**: + - 화면 생성 시 "분할 패널" 타입 선택 - 분할 패널 설정 UI - 임베딩 설정 UI @@ -354,15 +389,15 @@ function ScreenViewPage() { ## 📊 충돌 위험도 평가 -| 항목 | 위험도 | 설명 | 조치 필요 | -|------|--------|------|-----------| -| 데이터베이스 스키마 | 🟢 낮음 | 독립적인 새 테이블 | ❌ 불필요 | -| API 엔드포인트 | 🟢 낮음 | 새로운 경로 추가 | ❌ 불필요 | -| TypeScript 타입 | 🟢 낮음 | 별도 파일 | ❌ 불필요 | -| 프론트엔드 컴포넌트 | 🟢 낮음 | 별도 디렉토리 | ❌ 불필요 | -| 화면 렌더링 로직 | 🟡 중간 | 조건 분기 추가 필요 | ✅ 필요 | -| 컴포넌트 등록 시스템 | 🟡 중간 | 어댑터 패턴 필요 | ✅ 필요 (Phase 5) | -| 외래키 CASCADE | 🟡 중간 | 화면 삭제 시 주의 | ⚠️ 주의 | +| 항목 | 위험도 | 설명 | 조치 필요 | +| -------------------- | ------- | ------------------- | ----------------- | +| 데이터베이스 스키마 | 🟢 낮음 | 독립적인 새 테이블 | ❌ 불필요 | +| API 엔드포인트 | 🟢 낮음 | 새로운 경로 추가 | ❌ 불필요 | +| TypeScript 타입 | 🟢 낮음 | 별도 파일 | ❌ 불필요 | +| 프론트엔드 컴포넌트 | 🟢 낮음 | 별도 디렉토리 | ❌ 불필요 | +| 화면 렌더링 로직 | 🟡 중간 | 조건 분기 추가 필요 | ✅ 필요 | +| 컴포넌트 등록 시스템 | 🟡 중간 | 어댑터 패턴 필요 | ✅ 필요 (Phase 5) | +| 외래키 CASCADE | 🟡 중간 | 화면 삭제 시 주의 | ⚠️ 주의 | **전체 위험도**: 🟢 **낮음** (대부분 독립적) @@ -371,24 +406,28 @@ function ScreenViewPage() { ## ✅ 안전성 체크리스트 ### 데이터베이스 + - [x] 새 테이블명이 기존과 중복되지 않음 - [x] 기존 테이블 구조 변경 없음 - [x] 외래키 CASCADE 설정 완료 - [x] 멀티테넌시 (company_code) 지원 ### 백엔드 + - [x] 새 라우트가 기존과 충돌하지 않음 - [x] 독립적인 컨트롤러 파일 - [x] 기존 API 수정 없음 - [x] 에러 핸들링 완료 ### 프론트엔드 + - [x] 새 컴포넌트가 별도 디렉토리 - [x] 기존 컴포넌트 수정 없음 - [x] 독립적인 타입 정의 - [ ] 화면 페이지 수정 필요 (조건 분기) ### 호환성 + - [x] 기존 화면 동작 영향 없음 - [x] 하위 호환성 유지 - [ ] 컴포넌트 어댑터 구현 (Phase 5) @@ -400,6 +439,7 @@ function ScreenViewPage() { ### 즉시 조치 (필수) 1. **화면 페이지 수정** + ```typescript // frontend/app/(main)/screens/[screenId]/page.tsx // 분할 패널 확인 로직 추가 @@ -421,11 +461,13 @@ function ScreenViewPage() { ### 단계적 조치 (Phase 5-6) 1. **컴포넌트 어댑터 구현** + - TableComponent → DataReceivable - InputComponent → DataReceivable - 기타 컴포넌트들 2. **설정 UI 개발** + - 분할 패널 생성 UI - 매핑 규칙 설정 UI - 미리보기 기능 @@ -442,6 +484,7 @@ function ScreenViewPage() { ### ✅ 안전성 평가: 높음 **이유**: + 1. ✅ 대부분의 코드가 독립적으로 추가됨 2. ✅ 기존 시스템 수정 최소화 3. ✅ 하위 호환성 유지 @@ -450,10 +493,12 @@ function ScreenViewPage() { ### ⚠️ 주의 사항 1. **화면 페이지 수정 필요** + - 분할 패널 확인 로직 추가 - 조건부 렌더링 구현 2. **점진적 구현 권장** + - Phase 5: 컴포넌트 어댑터 - Phase 6: 설정 UI - 단계별 테스트 @@ -467,4 +512,3 @@ function ScreenViewPage() { **충돌 위험도: 낮음 (🟢)** 새로운 시스템은 기존 시스템과 **독립적으로 동작**하며, 최소한의 수정만으로 통합 가능합니다. 화면 페이지에 조건 분기만 추가하면 바로 사용할 수 있습니다. -