diff --git a/backend-node/src/services/menuService.ts b/backend-node/src/services/menuService.ts index 86df579c..57bddabd 100644 --- a/backend-node/src/services/menuService.ts +++ b/backend-node/src/services/menuService.ts @@ -102,6 +102,72 @@ export async function getSiblingMenuObjids(menuObjid: number): Promise } } +/** + * 선택한 메뉴와 그 하위 메뉴들의 OBJID 조회 + * + * 형제 메뉴는 포함하지 않고, 선택한 메뉴와 그 자식 메뉴들만 반환합니다. + * 채번 규칙 필터링 등 특정 메뉴 계층만 필요할 때 사용합니다. + * + * @param menuObjid 메뉴 OBJID + * @returns 선택한 메뉴 + 모든 하위 메뉴 OBJID 배열 (재귀적) + * + * @example + * // 메뉴 구조: + * // └── 구매관리 (100) + * // ├── 공급업체관리 (101) + * // ├── 발주관리 (102) + * // └── 입고관리 (103) + * // └── 입고상세 (104) + * + * await getMenuAndChildObjids(100); + * // 결과: [100, 101, 102, 103, 104] + */ +export async function getMenuAndChildObjids(menuObjid: number): Promise { + const pool = getPool(); + + try { + logger.debug("메뉴 및 하위 메뉴 조회 시작", { menuObjid }); + + // 재귀 CTE를 사용하여 선택한 메뉴와 모든 하위 메뉴 조회 + const query = ` + WITH RECURSIVE menu_tree AS ( + -- 시작점: 선택한 메뉴 + SELECT objid, parent_obj_id, 1 AS depth + FROM menu_info + WHERE objid = $1 + + UNION ALL + + -- 재귀: 하위 메뉴들 + SELECT m.objid, m.parent_obj_id, mt.depth + 1 + FROM menu_info m + INNER JOIN menu_tree mt ON m.parent_obj_id = mt.objid + WHERE mt.depth < 10 -- 무한 루프 방지 + ) + SELECT objid FROM menu_tree ORDER BY depth, objid + `; + + const result = await pool.query(query, [menuObjid]); + const objids = result.rows.map((row) => Number(row.objid)); + + logger.debug("메뉴 및 하위 메뉴 조회 완료", { + menuObjid, + totalCount: objids.length, + objids + }); + + return objids; + } catch (error: any) { + logger.error("메뉴 및 하위 메뉴 조회 실패", { + menuObjid, + error: error.message, + stack: error.stack + }); + // 에러 발생 시 안전하게 자기 자신만 반환 + return [menuObjid]; + } +} + /** * 여러 메뉴의 형제 메뉴 OBJID 합집합 조회 * diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index cb405b33..83b4f63b 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -4,7 +4,7 @@ import { getPool } from "../database/db"; import { logger } from "../utils/logger"; -import { getSiblingMenuObjids } from "./menuService"; +import { getMenuAndChildObjids } from "./menuService"; interface NumberingRulePart { id?: number; @@ -161,7 +161,7 @@ class NumberingRuleService { companyCode: string, menuObjid?: number ): Promise { - let siblingObjids: number[] = []; // catch 블록에서 접근 가능하도록 함수 최상단에 선언 + let menuAndChildObjids: number[] = []; // catch 블록에서 접근 가능하도록 함수 최상단에 선언 try { logger.info("메뉴별 사용 가능한 채번 규칙 조회 시작 (메뉴 스코프)", { @@ -171,14 +171,14 @@ class NumberingRuleService { const pool = getPool(); - // 1. 형제 메뉴 OBJID 조회 + // 1. 선택한 메뉴와 하위 메뉴 OBJID 조회 (형제 메뉴 제외) if (menuObjid) { - siblingObjids = await getSiblingMenuObjids(menuObjid); - logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids }); + menuAndChildObjids = await getMenuAndChildObjids(menuObjid); + logger.info("선택한 메뉴 및 하위 메뉴 OBJID 목록", { menuObjid, menuAndChildObjids }); } // menuObjid가 없으면 global 규칙만 반환 - if (!menuObjid || siblingObjids.length === 0) { + if (!menuObjid || menuAndChildObjids.length === 0) { let query: string; let params: any[]; @@ -280,7 +280,7 @@ class NumberingRuleService { let params: any[]; if (companyCode === "*") { - // 최고 관리자: 모든 규칙 조회 (형제 메뉴 포함) + // 최고 관리자: 모든 규칙 조회 (선택한 메뉴 + 하위 메뉴) query = ` SELECT rule_id AS "ruleId", @@ -301,8 +301,7 @@ class NumberingRuleService { WHERE scope_type = 'global' OR (scope_type = 'menu' AND menu_objid = ANY($1)) - OR (scope_type = 'table' AND menu_objid = ANY($1)) -- ✅ 메뉴별로 필터링 - OR (scope_type = 'table' AND menu_objid IS NULL) -- ✅ 기존 규칙(menu_objid NULL) 포함 (하위 호환성) + OR (scope_type = 'table' AND menu_objid = ANY($1)) ORDER BY CASE WHEN scope_type = 'menu' OR (scope_type = 'table' AND menu_objid = ANY($1)) THEN 1 @@ -311,10 +310,10 @@ class NumberingRuleService { END, created_at DESC `; - params = [siblingObjids]; - logger.info("최고 관리자: 형제 메뉴 기반 채번 규칙 조회 (메뉴별 필터링)", { siblingObjids }); + params = [menuAndChildObjids]; + logger.info("최고 관리자: 메뉴 및 하위 메뉴 기반 채번 규칙 조회", { menuAndChildObjids }); } else { - // 일반 회사: 자신의 규칙만 조회 (형제 메뉴 포함, 메뉴별 필터링) + // 일반 회사: 자신의 규칙만 조회 (선택한 메뉴 + 하위 메뉴) query = ` SELECT rule_id AS "ruleId", @@ -336,8 +335,7 @@ class NumberingRuleService { AND ( scope_type = 'global' OR (scope_type = 'menu' AND menu_objid = ANY($2)) - OR (scope_type = 'table' AND menu_objid = ANY($2)) -- ✅ 메뉴별로 필터링 - OR (scope_type = 'table' AND menu_objid IS NULL) -- ✅ 기존 규칙(menu_objid NULL) 포함 (하위 호환성) + OR (scope_type = 'table' AND menu_objid = ANY($2)) ) ORDER BY CASE @@ -347,8 +345,8 @@ class NumberingRuleService { END, created_at DESC `; - params = [companyCode, siblingObjids]; - logger.info("회사별: 형제 메뉴 기반 채번 규칙 조회 (메뉴별 필터링)", { companyCode, siblingObjids }); + params = [companyCode, menuAndChildObjids]; + logger.info("회사별: 메뉴 및 하위 메뉴 기반 채번 규칙 조회", { companyCode, menuAndChildObjids }); } logger.info("🔍 채번 규칙 쿼리 실행", { @@ -420,7 +418,7 @@ class NumberingRuleService { logger.info("메뉴별 사용 가능한 채번 규칙 조회 완료", { companyCode, menuObjid, - siblingCount: siblingObjids.length, + menuAndChildCount: menuAndChildObjids.length, count: result.rowCount, }); @@ -432,7 +430,7 @@ class NumberingRuleService { errorStack: error.stack, companyCode, menuObjid, - siblingObjids: siblingObjids || [], + menuAndChildObjids: menuAndChildObjids || [], }); throw error; } diff --git a/frontend/components/screen-embedding/EmbeddedScreen.tsx b/frontend/components/screen-embedding/EmbeddedScreen.tsx index 5cbea9d7..93803318 100644 --- a/frontend/components/screen-embedding/EmbeddedScreen.tsx +++ b/frontend/components/screen-embedding/EmbeddedScreen.tsx @@ -18,24 +18,41 @@ import { logger } from "@/lib/utils/logger"; import { applyMappingRules, filterDataByCondition } from "@/lib/utils/dataMapping"; import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; import { screenApi } from "@/lib/api/screen"; +import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext"; +import { ScreenContextProvider } from "@/contexts/ScreenContext"; interface EmbeddedScreenProps { embedding: ScreenEmbedding; onSelectionChanged?: (selectedRows: any[]) => void; + position?: SplitPanelPosition; // 분할 패널 내 위치 (left/right) } /** * 임베드된 화면 컴포넌트 */ export const EmbeddedScreen = forwardRef( - ({ embedding, onSelectionChanged }, ref) => { + ({ embedding, onSelectionChanged, position }, ref) => { const [layout, setLayout] = useState([]); const [selectedRows, setSelectedRows] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [screenInfo, setScreenInfo] = useState(null); + const [formData, setFormData] = useState>({}); // 폼 데이터 상태 추가 // 컴포넌트 참조 맵 const componentRefs = useRef>(new Map()); + + // 분할 패널 컨텍스트 (분할 패널 내부에 있을 때만 사용) + const splitPanelContext = useSplitPanelContext(); + + // 필드 값 변경 핸들러 + const handleFieldChange = useCallback((fieldName: string, value: any) => { + console.log("📝 [EmbeddedScreen] 필드 값 변경:", { fieldName, value }); + setFormData((prev) => ({ + ...prev, + [fieldName]: value, + })); + }, []); // 화면 데이터 로드 useEffect(() => { @@ -55,6 +72,12 @@ export const EmbeddedScreen = forwardRef - {layout.length === 0 ? ( -
-

화면에 컴포넌트가 없습니다.

-
- ) : ( -
- {layout.map((component) => ( - - ))} -
- )} - + +
+ {layout.length === 0 ? ( +
+

화면에 컴포넌트가 없습니다.

+
+ ) : ( +
+ {layout.map((component) => { + const { position: compPosition = { x: 0, y: 0, z: 1 }, size = { width: 200, height: 40 } } = component; + + return ( +
+ +
+ ); + })} +
+ )} +
+
); }, ); diff --git a/frontend/components/screen-embedding/ScreenSplitPanel.tsx b/frontend/components/screen-embedding/ScreenSplitPanel.tsx index a7b2cc54..88901191 100644 --- a/frontend/components/screen-embedding/ScreenSplitPanel.tsx +++ b/frontend/components/screen-embedding/ScreenSplitPanel.tsx @@ -8,9 +8,10 @@ "use client"; -import React, { useState, useCallback } from "react"; +import React, { useState, useCallback, useMemo } from "react"; import { EmbeddedScreen } from "./EmbeddedScreen"; import { Columns2 } from "lucide-react"; +import { SplitPanelProvider } from "@/contexts/SplitPanelContext"; interface ScreenSplitPanelProps { screenId?: number; @@ -22,7 +23,26 @@ interface ScreenSplitPanelProps { * 순수하게 화면 분할 기능만 제공합니다. */ export function ScreenSplitPanel({ screenId, config }: ScreenSplitPanelProps) { - const [splitRatio, setSplitRatio] = useState(config?.splitRatio || 50); + // config에서 splitRatio 추출 (기본값 50) + const configSplitRatio = config?.splitRatio ?? 50; + + console.log("🎯 [ScreenSplitPanel] 렌더링됨!", { + screenId, + config, + leftScreenId: config?.leftScreenId, + rightScreenId: config?.rightScreenId, + configSplitRatio, + configKeys: config ? Object.keys(config) : [], + }); + + // 드래그로 조절 가능한 splitRatio 상태 + const [splitRatio, setSplitRatio] = useState(configSplitRatio); + + // config.splitRatio가 변경되면 동기화 (설정 패널에서 변경 시) + React.useEffect(() => { + console.log("📐 [ScreenSplitPanel] splitRatio 동기화:", { configSplitRatio, currentSplitRatio: splitRatio }); + setSplitRatio(configSplitRatio); + }, [configSplitRatio]); // 설정 패널에서 오는 간단한 config를 임베딩 설정으로 변환 const leftEmbedding = config?.leftScreenId @@ -60,8 +80,8 @@ export function ScreenSplitPanel({ screenId, config }: ScreenSplitPanelProps) { setSplitRatio(Math.max(20, Math.min(80, newRatio))); }, []); - // config가 없거나 화면 설정이 안 된 경우 (디자이너 모드) - if (!config || !leftEmbedding || !rightEmbedding) { + // config가 없는 경우 (디자이너 모드 또는 초기 상태) + if (!config) { return (
@@ -85,46 +105,71 @@ export function ScreenSplitPanel({ screenId, config }: ScreenSplitPanelProps) { ); } + // 좌측 또는 우측 화면이 설정되지 않은 경우 안내 메시지 표시 + const hasLeftScreen = !!leftEmbedding; + const hasRightScreen = !!rightEmbedding; + + // 분할 패널 고유 ID 생성 + const splitPanelId = useMemo(() => `split-panel-${screenId || "unknown"}-${Date.now()}`, [screenId]); + return ( -
- {/* 좌측 패널 */} -
- -
- - {/* 리사이저 */} - {config?.resizable !== false && ( -
{ - e.preventDefault(); - const startX = e.clientX; - const startRatio = splitRatio; - const containerWidth = e.currentTarget.parentElement!.offsetWidth; - - const handleMouseMove = (moveEvent: MouseEvent) => { - const deltaX = moveEvent.clientX - startX; - const deltaRatio = (deltaX / containerWidth) * 100; - handleResize(startRatio + deltaRatio); - }; - - const handleMouseUp = () => { - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - }; - - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - }} - > -
+ +
+ {/* 좌측 패널 */} +
+ {hasLeftScreen ? ( + + ) : ( +
+

좌측 화면을 선택하세요

+
+ )}
- )} - {/* 우측 패널 */} -
- + {/* 리사이저 */} + {config?.resizable !== false && ( +
{ + e.preventDefault(); + const startX = e.clientX; + const startRatio = splitRatio; + const containerWidth = e.currentTarget.parentElement!.offsetWidth; + + const handleMouseMove = (moveEvent: MouseEvent) => { + const deltaX = moveEvent.clientX - startX; + const deltaRatio = (deltaX / containerWidth) * 100; + handleResize(startRatio + deltaRatio); + }; + + const handleMouseUp = () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + }} + > +
+
+ )} + + {/* 우측 패널 */} +
+ {hasRightScreen ? ( + + ) : ( +
+

우측 화면을 선택하세요

+
+ )} +
-
+ ); } diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 46d6ab37..92ca659c 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -527,9 +527,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD // 🆕 size 변경 시 style도 함께 업데이트 (파란 테두리와 실제 크기 동기화) if (path === "size.width" || path === "size.height" || path === "size") { - if (!newComp.style) { - newComp.style = {}; - } + // 🔧 style 객체를 새로 복사하여 불변성 유지 + newComp.style = { ...(newComp.style || {}) }; if (path === "size.width") { newComp.style.width = `${value}px`; diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index 58f7124c..567987e4 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -83,6 +83,14 @@ export const ButtonConfigPanel: React.FC = ({ const [blockTablePopoverOpen, setBlockTablePopoverOpen] = useState>({}); // 블록별 테이블 Popover 열림 상태 const [blockColumnPopoverOpen, setBlockColumnPopoverOpen] = useState>({}); // 블록별 컬럼 Popover 열림 상태 + // 🆕 데이터 전달 필드 매핑용 상태 + const [mappingSourceColumns, setMappingSourceColumns] = useState>([]); + const [mappingTargetColumns, setMappingTargetColumns] = useState>([]); + const [mappingSourcePopoverOpen, setMappingSourcePopoverOpen] = useState>({}); + const [mappingTargetPopoverOpen, setMappingTargetPopoverOpen] = useState>({}); + const [mappingSourceSearch, setMappingSourceSearch] = useState>({}); + const [mappingTargetSearch, setMappingTargetSearch] = useState>({}); + // 🎯 플로우 위젯이 화면에 있는지 확인 const hasFlowWidget = useMemo(() => { const found = allComponents.some((comp: any) => { @@ -258,6 +266,58 @@ export const ButtonConfigPanel: React.FC = ({ } }; + // 🆕 데이터 전달 소스/타겟 테이블 컬럼 로드 + useEffect(() => { + const sourceTable = config.action?.dataTransfer?.sourceTable; + const targetTable = config.action?.dataTransfer?.targetTable; + + const loadColumns = async () => { + if (sourceTable) { + try { + const response = await apiClient.get(`/table-management/tables/${sourceTable}/columns`); + if (response.data.success) { + let columnData = response.data.data; + if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns; + if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data; + + if (Array.isArray(columnData)) { + const columns = columnData.map((col: any) => ({ + name: col.name || col.columnName, + label: col.displayName || col.label || col.columnLabel || col.name || col.columnName, + })); + setMappingSourceColumns(columns); + } + } + } catch (error) { + console.error("소스 테이블 컬럼 로드 실패:", error); + } + } + + if (targetTable) { + try { + const response = await apiClient.get(`/table-management/tables/${targetTable}/columns`); + if (response.data.success) { + let columnData = response.data.data; + if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns; + if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data; + + if (Array.isArray(columnData)) { + const columns = columnData.map((col: any) => ({ + name: col.name || col.columnName, + label: col.displayName || col.label || col.columnLabel || col.name || col.columnName, + })); + setMappingTargetColumns(columns); + } + } + } catch (error) { + console.error("타겟 테이블 컬럼 로드 실패:", error); + } + } + }; + + loadColumns(); + }, [config.action?.dataTransfer?.sourceTable, config.action?.dataTransfer?.targetTable]); + // 화면 목록 가져오기 (현재 편집 중인 화면의 회사 코드 기준) useEffect(() => { const fetchScreens = async () => { @@ -1607,19 +1667,52 @@ export const ButtonConfigPanel: React.FC = ({

📦 데이터 전달 설정

+ {/* 소스 컴포넌트 선택 (Combobox) */}
-
@@ -1636,25 +1729,85 @@ export const ButtonConfigPanel: React.FC = ({ 같은 화면의 컴포넌트 - 다른 화면 (구현 예정) + 분할 패널 반대편 화면 + 다른 화면 (구현 예정) + {config.action?.dataTransfer?.targetType === "splitPanel" && ( +

+ 이 버튼이 분할 패널 내부에 있어야 합니다. 좌측 화면에서 우측으로, 또는 우측에서 좌측으로 데이터가 전달됩니다. +

+ )}
- {config.action?.dataTransfer?.targetType !== "screen" && ( + {/* 타겟 컴포넌트 선택 (같은 화면의 컴포넌트일 때만) */} + {config.action?.dataTransfer?.targetType === "component" && (
-
+ )} + + {/* 분할 패널 반대편 타겟 설정 */} + {config.action?.dataTransfer?.targetType === "splitPanel" && ( +
+ onUpdateProperty("componentConfig.action.dataTransfer.targetComponentId", e.target.value)} + placeholder="비워두면 첫 번째 수신 가능 컴포넌트로 전달" className="h-8 text-xs" />

- 데이터를 받을 컴포넌트의 ID + 반대편 화면의 특정 컴포넌트 ID를 지정하거나, 비워두면 자동으로 첫 번째 수신 가능 컴포넌트로 전달됩니다.

)} @@ -1748,15 +1901,382 @@ export const ButtonConfigPanel: React.FC = ({
+
+ +

+ 조건부 컨테이너의 카테고리 값 등 추가 데이터를 함께 전달할 수 있습니다 +

+
+
+ + +

+ 조건부 컨테이너, 셀렉트박스 등 (카테고리 값 전달용) +

+
+
+ + { + const currentSources = config.action?.dataTransfer?.additionalSources || []; + const newSources = [...currentSources]; + if (newSources.length === 0) { + newSources.push({ componentId: "", fieldName: e.target.value }); + } else { + newSources[0] = { ...newSources[0], fieldName: e.target.value }; + } + onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources); + }} + className="h-8 text-xs" + /> +

+ 타겟 테이블에 저장될 필드명 +

+
+
+
+ + {/* 필드 매핑 규칙 */} +
+ + + {/* 소스/타겟 테이블 선택 */} +
+
+ + + + + + + + + + 테이블을 찾을 수 없습니다 + + {availableTables.map((table) => ( + { + onUpdateProperty("componentConfig.action.dataTransfer.sourceTable", table.name); + }} + className="text-xs" + > + + {table.label} + ({table.name}) + + ))} + + + + + +
+ +
+ + + + + + + + + + 테이블을 찾을 수 없습니다 + + {availableTables.map((table) => ( + { + onUpdateProperty("componentConfig.action.dataTransfer.targetTable", table.name); + }} + className="text-xs" + > + + {table.label} + ({table.name}) + + ))} + + + + + +
+
+ + {/* 필드 매핑 규칙 */} +
+
+ + +
+

+ 소스 필드를 타겟 필드에 매핑합니다. 비워두면 같은 이름의 필드로 자동 매핑됩니다. +

+ + {(!config.action?.dataTransfer?.sourceTable || !config.action?.dataTransfer?.targetTable) ? ( +
+

+ 먼저 소스 테이블과 타겟 테이블을 선택하세요. +

+
+ ) : (config.action?.dataTransfer?.mappingRules || []).length === 0 ? ( +
+

+ 매핑 규칙이 없습니다. 같은 이름의 필드로 자동 매핑됩니다. +

+
+ ) : ( +
+ {(config.action?.dataTransfer?.mappingRules || []).map((rule: any, index: number) => ( +
+ {/* 소스 필드 선택 (Combobox) */} +
+ setMappingSourcePopoverOpen((prev) => ({ ...prev, [index]: open }))} + > + + + + + + setMappingSourceSearch((prev) => ({ ...prev, [index]: value }))} + /> + + 컬럼을 찾을 수 없습니다 + + {mappingSourceColumns.map((col) => ( + { + const rules = [...(config.action?.dataTransfer?.mappingRules || [])]; + rules[index] = { ...rules[index], sourceField: col.name }; + onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", rules); + setMappingSourcePopoverOpen((prev) => ({ ...prev, [index]: false })); + }} + className="text-xs" + > + + {col.label} + {col.label !== col.name && ( + ({col.name}) + )} + + ))} + + + + + +
+ + + + {/* 타겟 필드 선택 (Combobox) */} +
+ setMappingTargetPopoverOpen((prev) => ({ ...prev, [index]: open }))} + > + + + + + + setMappingTargetSearch((prev) => ({ ...prev, [index]: value }))} + /> + + 컬럼을 찾을 수 없습니다 + + {mappingTargetColumns.map((col) => ( + { + const rules = [...(config.action?.dataTransfer?.mappingRules || [])]; + rules[index] = { ...rules[index], targetField: col.name }; + onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", rules); + setMappingTargetPopoverOpen((prev) => ({ ...prev, [index]: false })); + }} + className="text-xs" + > + + {col.label} + {col.label !== col.name && ( + ({col.name}) + )} + + ))} + + + + + +
+ + +
+ ))} +
+ )} +
+
+

사용 방법:
1. 소스 컴포넌트에서 데이터를 선택합니다
- 2. 이 버튼을 클릭하면 선택된 데이터가 타겟으로 전달됩니다 + 2. 필드 매핑 규칙을 설정합니다 (예: 품번 → 품목코드)
- 3. 매핑 규칙은 추후 고급 설정에서 추가 예정입니다 + 3. 이 버튼을 클릭하면 매핑된 데이터가 타겟으로 전달됩니다

diff --git a/frontend/components/screen/panels/DetailSettingsPanel.tsx b/frontend/components/screen/panels/DetailSettingsPanel.tsx index e3e8cbb3..243f02ef 100644 --- a/frontend/components/screen/panels/DetailSettingsPanel.tsx +++ b/frontend/components/screen/panels/DetailSettingsPanel.tsx @@ -740,6 +740,12 @@ export const DetailSettingsPanel: React.FC = ({ const handleConfigChange = (newConfig: WebTypeConfig) => { // 강제 새 객체 생성으로 React 변경 감지 보장 const freshConfig = { ...newConfig }; + console.log("🔧 [DetailSettingsPanel] handleConfigChange 호출:", { + widgetId: widget.id, + widgetLabel: widget.label, + widgetType: widget.widgetType, + newConfig: freshConfig, + }); onUpdateProperty(widget.id, "webTypeConfig", freshConfig); // TextTypeConfig의 자동입력 설정을 autoGeneration으로도 매핑 diff --git a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx index 6e27fb93..2f402845 100644 --- a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx +++ b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx @@ -325,41 +325,46 @@ export const UnifiedPropertiesPanel: React.FC = ({ currentConfig, }); - // 래퍼 컴포넌트: 새 ConfigPanel 인터페이스를 기존 패턴에 맞춤 - const ConfigPanelWrapper = () => { - // Section Card, Section Paper 등 신규 컴포넌트는 componentConfig 바로 아래에 설정 저장 - const config = currentConfig || definition.defaultProps?.componentConfig || {}; - - const handleConfigChange = (newConfig: any) => { - // componentConfig 전체를 업데이트 - onUpdateProperty(selectedComponent.id, "componentConfig", newConfig); + // 🔧 ConfigPanelWrapper를 인라인 함수 대신 직접 JSX 반환 (리마운트 방지) + const config = currentConfig || definition.defaultProps?.componentConfig || {}; + + const handlePanelConfigChange = (newConfig: any) => { + // 🔧 Partial 업데이트: 기존 componentConfig를 유지하면서 새 설정만 병합 + const mergedConfig = { + ...currentConfig, // 기존 설정 유지 + ...newConfig, // 새 설정 병합 }; - - return ( -
-
- -

{definition.name} 설정

-
- -
설정 패널 로딩 중...
-
- }> - - -
- ); + console.log("🔧 [ConfigPanel] handleConfigChange:", { + componentId: selectedComponent.id, + currentConfig, + newConfig, + mergedConfig, + }); + onUpdateProperty(selectedComponent.id, "componentConfig", mergedConfig); }; - return ; + return ( +
+
+ +

{definition.name} 설정

+
+ +
설정 패널 로딩 중...
+
+ }> + + +
+ ); } else { console.warn("⚠️ ComponentRegistry에서 ConfigPanel을 찾을 수 없음 - switch case로 이동:", { componentId, diff --git a/frontend/components/webtypes/RepeaterInput.tsx b/frontend/components/webtypes/RepeaterInput.tsx index f81e8c9c..ca6de2d0 100644 --- a/frontend/components/webtypes/RepeaterInput.tsx +++ b/frontend/components/webtypes/RepeaterInput.tsx @@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Separator } from "@/components/ui/separator"; +import { Badge } from "@/components/ui/badge"; import { Plus, X, GripVertical, ChevronDown, ChevronUp } from "lucide-react"; import { RepeaterFieldGroupConfig, RepeaterData, RepeaterItemData, RepeaterFieldDefinition } from "@/types/repeater"; import { cn } from "@/lib/utils"; @@ -21,6 +22,7 @@ export interface RepeaterInputProps { disabled?: boolean; readonly?: boolean; className?: string; + menuObjid?: number; // 카테고리 조회용 메뉴 ID } /** @@ -34,6 +36,7 @@ export const RepeaterInput: React.FC = ({ disabled = false, readonly = false, className, + menuObjid, }) => { // 현재 브레이크포인트 감지 const globalBreakpoint = useBreakpoint(); @@ -42,6 +45,9 @@ export const RepeaterInput: React.FC = ({ // 미리보기 모달 내에서는 previewBreakpoint 우선 사용 const breakpoint = previewBreakpoint || globalBreakpoint; + // 카테고리 매핑 데이터 (값 -> {label, color}) + const [categoryMappings, setCategoryMappings] = useState>>({}); + // 설정 기본값 const { fields = [], @@ -194,20 +200,77 @@ export const RepeaterInput: React.FC = ({ // 개별 필드 렌더링 const renderField = (field: RepeaterFieldDefinition, itemIndex: number, value: any) => { + const isReadonly = disabled || readonly || field.readonly; + const commonProps = { value: value || "", - disabled: disabled || readonly, + disabled: isReadonly, placeholder: field.placeholder, required: field.required, }; + // 카테고리 타입은 항상 배지로 표시 (카테고리 관리에서 설정한 색상 적용) + if (field.type === "category") { + if (!value) return -; + + // field.name을 키로 사용 (테이블 리스트와 동일) + const mapping = categoryMappings[field.name]; + const valueStr = String(value); // 값을 문자열로 변환 + const categoryData = mapping?.[valueStr]; + const displayLabel = categoryData?.label || valueStr; + const displayColor = categoryData?.color || "#64748b"; // 기본 색상 (slate) + + console.log(`🏷️ [RepeaterInput] 카테고리 배지 렌더링:`, { + fieldName: field.name, + value: valueStr, + mapping, + categoryData, + displayLabel, + displayColor, + }); + + // 색상이 "none"이면 일반 텍스트로 표시 + if (displayColor === "none") { + return {displayLabel}; + } + + return ( + + {displayLabel} + + ); + } + + // 읽기 전용 모드: 텍스트로 표시 + // displayMode가 "readonly"이면 isReadonly 여부와 관계없이 텍스트로 표시 + if (field.displayMode === "readonly") { + // select 타입인 경우 옵션에서 라벨 찾기 + if (field.type === "select" && value && field.options) { + const option = field.options.find(opt => opt.value === value); + return {option?.label || value}; + } + + // 일반 텍스트 + return ( + + {value || "-"} + + ); + } + switch (field.type) { case "select": return (
@@ -316,16 +335,69 @@ export const RepeaterConfigPanel: React.FC = ({ -
- updateField(index, { required: checked as boolean })} - /> - -
+ {/* 카테고리 타입일 때 카테고리 코드 입력 */} + {field.type === "category" && ( +
+ + updateField(index, { categoryCode: e.target.value })} + placeholder="카테고리 코드 (예: INBOUND_TYPE)" + className="h-8 w-full text-xs" + /> +

+ 카테고리 관리에서 설정한 색상으로 배지가 표시됩니다 +

+
+ )} + + {/* 카테고리 타입이 아닐 때만 표시 모드 선택 */} + {field.type !== "category" && ( +
+
+ + +
+ +
+
+ updateField(index, { required: checked as boolean })} + /> + +
+
+
+ )} + + {/* 카테고리 타입일 때는 필수만 표시 */} + {field.type === "category" && ( +
+ updateField(index, { required: checked as boolean })} + /> + +
+ )} ))} diff --git a/frontend/contexts/ScreenContext.tsx b/frontend/contexts/ScreenContext.tsx index ca6b34b3..f8c703dd 100644 --- a/frontend/contexts/ScreenContext.tsx +++ b/frontend/contexts/ScreenContext.tsx @@ -8,10 +8,12 @@ import React, { createContext, useContext, useCallback, useRef } from "react"; import type { DataProvidable, DataReceivable } from "@/types/data-transfer"; import { logger } from "@/lib/utils/logger"; +import type { SplitPanelPosition } from "@/contexts/SplitPanelContext"; interface ScreenContextValue { screenId?: number; tableName?: string; + splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치 (left/right) // 컴포넌트 등록 registerDataProvider: (componentId: string, provider: DataProvidable) => void; @@ -33,13 +35,14 @@ const ScreenContext = createContext(null); interface ScreenContextProviderProps { screenId?: number; tableName?: string; + splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치 children: React.ReactNode; } /** * 화면 컨텍스트 프로바이더 */ -export function ScreenContextProvider({ screenId, tableName, children }: ScreenContextProviderProps) { +export function ScreenContextProvider({ screenId, tableName, splitPanelPosition, children }: ScreenContextProviderProps) { const dataProvidersRef = useRef>(new Map()); const dataReceiversRef = useRef>(new Map()); @@ -79,9 +82,11 @@ export function ScreenContextProvider({ screenId, tableName, children }: ScreenC return new Map(dataReceiversRef.current); }, []); - const value: ScreenContextValue = { + // 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지) + const value = React.useMemo(() => ({ screenId, tableName, + splitPanelPosition, registerDataProvider, unregisterDataProvider, registerDataReceiver, @@ -90,7 +95,19 @@ export function ScreenContextProvider({ screenId, tableName, children }: ScreenC getDataReceiver, getAllDataProviders, getAllDataReceivers, - }; + }), [ + screenId, + tableName, + splitPanelPosition, + registerDataProvider, + unregisterDataProvider, + registerDataReceiver, + unregisterDataReceiver, + getDataProvider, + getDataReceiver, + getAllDataProviders, + getAllDataReceivers, + ]); return {children}; } diff --git a/frontend/contexts/SplitPanelContext.tsx b/frontend/contexts/SplitPanelContext.tsx new file mode 100644 index 00000000..e5052295 --- /dev/null +++ b/frontend/contexts/SplitPanelContext.tsx @@ -0,0 +1,237 @@ +"use client"; + +import React, { createContext, useContext, useCallback, useRef, useState } from "react"; +import { logger } from "@/lib/utils/logger"; + +/** + * 분할 패널 내 화면 위치 + */ +export type SplitPanelPosition = "left" | "right"; + +/** + * 데이터 수신자 인터페이스 + */ +export interface SplitPanelDataReceiver { + componentId: string; + componentType: string; + receiveData: (data: any[], mode: "append" | "replace" | "merge") => Promise; +} + +/** + * 분할 패널 컨텍스트 값 + */ +interface SplitPanelContextValue { + // 분할 패널 ID + splitPanelId: string; + + // 좌측/우측 화면 ID + leftScreenId: number | null; + rightScreenId: number | null; + + // 데이터 수신자 등록/해제 + registerReceiver: (position: SplitPanelPosition, componentId: string, receiver: SplitPanelDataReceiver) => void; + unregisterReceiver: (position: SplitPanelPosition, componentId: string) => void; + + // 반대편 화면으로 데이터 전달 + transferToOtherSide: ( + fromPosition: SplitPanelPosition, + data: any[], + targetComponentId?: string, // 특정 컴포넌트 지정 (없으면 첫 번째 수신자) + mode?: "append" | "replace" | "merge" + ) => Promise<{ success: boolean; message: string }>; + + // 반대편 화면의 수신자 목록 가져오기 + getOtherSideReceivers: (fromPosition: SplitPanelPosition) => SplitPanelDataReceiver[]; + + // 현재 위치 확인 + isInSplitPanel: boolean; + + // screenId로 위치 찾기 + getPositionByScreenId: (screenId: number) => SplitPanelPosition | null; +} + +const SplitPanelContext = createContext(null); + +interface SplitPanelProviderProps { + splitPanelId: string; + leftScreenId: number | null; + rightScreenId: number | null; + children: React.ReactNode; +} + +/** + * 분할 패널 컨텍스트 프로바이더 + */ +export function SplitPanelProvider({ + splitPanelId, + leftScreenId, + rightScreenId, + children, +}: SplitPanelProviderProps) { + // 좌측/우측 화면의 데이터 수신자 맵 + const leftReceiversRef = useRef>(new Map()); + const rightReceiversRef = useRef>(new Map()); + + // 강제 리렌더링용 상태 + const [, forceUpdate] = useState(0); + + /** + * 데이터 수신자 등록 + */ + const registerReceiver = useCallback( + (position: SplitPanelPosition, componentId: string, receiver: SplitPanelDataReceiver) => { + const receiversRef = position === "left" ? leftReceiversRef : rightReceiversRef; + receiversRef.current.set(componentId, receiver); + + logger.debug(`[SplitPanelContext] 수신자 등록: ${position} - ${componentId}`, { + componentType: receiver.componentType, + }); + + forceUpdate((n) => n + 1); + }, + [] + ); + + /** + * 데이터 수신자 해제 + */ + const unregisterReceiver = useCallback( + (position: SplitPanelPosition, componentId: string) => { + const receiversRef = position === "left" ? leftReceiversRef : rightReceiversRef; + receiversRef.current.delete(componentId); + + logger.debug(`[SplitPanelContext] 수신자 해제: ${position} - ${componentId}`); + + forceUpdate((n) => n + 1); + }, + [] + ); + + /** + * 반대편 화면의 수신자 목록 가져오기 + */ + const getOtherSideReceivers = useCallback( + (fromPosition: SplitPanelPosition): SplitPanelDataReceiver[] => { + const receiversRef = fromPosition === "left" ? rightReceiversRef : leftReceiversRef; + return Array.from(receiversRef.current.values()); + }, + [] + ); + + /** + * 반대편 화면으로 데이터 전달 + */ + const transferToOtherSide = useCallback( + async ( + fromPosition: SplitPanelPosition, + data: any[], + targetComponentId?: string, + mode: "append" | "replace" | "merge" = "append" + ): Promise<{ success: boolean; message: string }> => { + const toPosition = fromPosition === "left" ? "right" : "left"; + const receiversRef = fromPosition === "left" ? rightReceiversRef : leftReceiversRef; + + logger.info(`[SplitPanelContext] 데이터 전달 시작: ${fromPosition} → ${toPosition}`, { + dataCount: data.length, + targetComponentId, + mode, + availableReceivers: Array.from(receiversRef.current.keys()), + }); + + if (receiversRef.current.size === 0) { + const message = `${toPosition === "left" ? "좌측" : "우측"} 화면에 데이터를 받을 수 있는 컴포넌트가 없습니다.`; + logger.warn(`[SplitPanelContext] ${message}`); + return { success: false, message }; + } + + try { + let targetReceiver: SplitPanelDataReceiver | undefined; + + if (targetComponentId) { + // 특정 컴포넌트 지정 + targetReceiver = receiversRef.current.get(targetComponentId); + if (!targetReceiver) { + const message = `타겟 컴포넌트 '${targetComponentId}'를 찾을 수 없습니다.`; + logger.warn(`[SplitPanelContext] ${message}`); + return { success: false, message }; + } + } else { + // 첫 번째 수신자 사용 + targetReceiver = receiversRef.current.values().next().value; + } + + if (!targetReceiver) { + return { success: false, message: "데이터 수신자를 찾을 수 없습니다." }; + } + + await targetReceiver.receiveData(data, mode); + + const message = `${data.length}개 항목이 ${toPosition === "left" ? "좌측" : "우측"} 화면으로 전달되었습니다.`; + logger.info(`[SplitPanelContext] ${message}`); + + return { success: true, message }; + } catch (error: any) { + const message = error.message || "데이터 전달 중 오류가 발생했습니다."; + logger.error(`[SplitPanelContext] 데이터 전달 실패`, error); + return { success: false, message }; + } + }, + [] + ); + + /** + * screenId로 위치 찾기 + */ + const getPositionByScreenId = useCallback( + (screenId: number): SplitPanelPosition | null => { + if (leftScreenId === screenId) return "left"; + if (rightScreenId === screenId) return "right"; + return null; + }, + [leftScreenId, rightScreenId] + ); + + // 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지) + const value = React.useMemo(() => ({ + splitPanelId, + leftScreenId, + rightScreenId, + registerReceiver, + unregisterReceiver, + transferToOtherSide, + getOtherSideReceivers, + isInSplitPanel: true, + getPositionByScreenId, + }), [ + splitPanelId, + leftScreenId, + rightScreenId, + registerReceiver, + unregisterReceiver, + transferToOtherSide, + getOtherSideReceivers, + getPositionByScreenId, + ]); + + return ( + + {children} + + ); +} + +/** + * 분할 패널 컨텍스트 훅 + */ +export function useSplitPanelContext() { + return useContext(SplitPanelContext); +} + +/** + * 분할 패널 내부인지 확인하는 훅 + */ +export function useIsInSplitPanel(): boolean { + const context = useContext(SplitPanelContext); + return context?.isInSplitPanel ?? false; +} + diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 245e2527..b6e34588 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -234,6 +234,20 @@ export const DynamicComponentRenderer: React.FC = }); } + // 🔍 디버깅: text-input 컴포넌트 조회 결과 확인 + if (componentType === "text-input" || component.id?.includes("text") || (component as any).webType === "text") { + console.log("🔍 [DynamicComponentRenderer] text-input 조회:", { + componentType, + componentId: component.id, + componentLabel: component.label, + componentConfig: component.componentConfig, + webTypeConfig: (component as any).webTypeConfig, + autoGeneration: (component as any).autoGeneration, + found: !!newComponent, + registeredComponents: ComponentRegistry.getAllComponents().map(c => c.id), + }); + } + if (newComponent) { // 새 컴포넌트 시스템으로 렌더링 try { @@ -422,8 +436,14 @@ export const DynamicComponentRenderer: React.FC = if (!renderer) { console.error(`⚠️ 등록되지 않은 컴포넌트 타입: ${componentType}`, { component: component, + componentId: component.id, + componentLabel: component.label, componentType: componentType, + originalType: component.type, + originalComponentType: (component as any).componentType, componentConfig: component.componentConfig, + webTypeConfig: (component as any).webTypeConfig, + autoGeneration: (component as any).autoGeneration, availableNewComponents: ComponentRegistry.getAllComponents().map((c) => c.id), availableLegacyComponents: legacyComponentRegistry.getRegisteredTypes(), }); diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 8cfec543..67b253e1 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -24,6 +24,7 @@ import { filterDOMProps } from "@/lib/utils/domPropsFilter"; import { useCurrentFlowStep } from "@/stores/flowStepStore"; import { useScreenPreview } from "@/contexts/ScreenPreviewContext"; import { useScreenContextOptional } from "@/contexts/ScreenContext"; +import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext"; import { applyMappingRules } from "@/lib/utils/dataMapping"; export interface ButtonPrimaryComponentProps extends ComponentRendererProps { @@ -100,6 +101,9 @@ export const ButtonPrimaryComponent: React.FC = ({ }) => { const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 const screenContext = useScreenContextOptional(); // 화면 컨텍스트 + const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트 + // 🆕 ScreenContext에서 splitPanelPosition 가져오기 (중첩 화면에서도 작동) + const splitPanelPosition = screenContext?.splitPanelPosition; // 🆕 props에서 onSave 추출 (명시적으로 선언되지 않은 경우 ...props에서 추출) const propsOnSave = (props as any).onSave as (() => Promise) | undefined; @@ -395,20 +399,128 @@ export const ButtonPrimaryComponent: React.FC = ({ try { // 1. 소스 컴포넌트에서 데이터 가져오기 - const sourceProvider = screenContext.getDataProvider(dataTransferConfig.sourceComponentId); + let sourceProvider = screenContext.getDataProvider(dataTransferConfig.sourceComponentId); + // 🆕 소스 컴포넌트를 찾을 수 없으면, 현재 화면에서 테이블 리스트 자동 탐색 + // (조건부 컨테이너의 다른 섹션으로 전환했을 때 이전 컴포넌트 ID가 남아있는 경우 대응) if (!sourceProvider) { - toast.error(`소스 컴포넌트를 찾을 수 없습니다: ${dataTransferConfig.sourceComponentId}`); - return; + console.log(`⚠️ [ButtonPrimary] 지정된 소스 컴포넌트를 찾을 수 없음: ${dataTransferConfig.sourceComponentId}`); + console.log(`🔍 [ButtonPrimary] 현재 화면에서 DataProvider 자동 탐색...`); + + const allProviders = screenContext.getAllDataProviders(); + + // 테이블 리스트 우선 탐색 + for (const [id, provider] of allProviders) { + if (provider.componentType === "table-list") { + sourceProvider = provider; + console.log(`✅ [ButtonPrimary] 테이블 리스트 자동 발견: ${id}`); + break; + } + } + + // 테이블 리스트가 없으면 첫 번째 DataProvider 사용 + if (!sourceProvider && allProviders.size > 0) { + const firstEntry = allProviders.entries().next().value; + if (firstEntry) { + sourceProvider = firstEntry[1]; + console.log(`✅ [ButtonPrimary] 첫 번째 DataProvider 사용: ${firstEntry[0]} (${sourceProvider.componentType})`); + } + } + + if (!sourceProvider) { + toast.error("데이터를 제공할 수 있는 컴포넌트를 찾을 수 없습니다."); + return; + } } - const sourceData = sourceProvider.getSelectedData(); + const rawSourceData = sourceProvider.getSelectedData(); + + // 🆕 배열이 아닌 경우 배열로 변환 + const sourceData = Array.isArray(rawSourceData) ? rawSourceData : (rawSourceData ? [rawSourceData] : []); + + console.log("📦 소스 데이터:", { rawSourceData, sourceData, isArray: Array.isArray(rawSourceData) }); if (!sourceData || sourceData.length === 0) { toast.warning("선택된 데이터가 없습니다."); return; } + // 1.5. 추가 데이터 소스 처리 (예: 조건부 컨테이너의 카테고리 값) + let additionalData: Record = {}; + + // 방법 1: additionalSources 설정에서 가져오기 + if (dataTransferConfig.additionalSources && Array.isArray(dataTransferConfig.additionalSources)) { + for (const additionalSource of dataTransferConfig.additionalSources) { + const additionalProvider = screenContext.getDataProvider(additionalSource.componentId); + + if (additionalProvider) { + const additionalValues = additionalProvider.getSelectedData(); + + if (additionalValues && additionalValues.length > 0) { + // 첫 번째 값 사용 (조건부 컨테이너는 항상 1개) + const firstValue = additionalValues[0]; + + // fieldName이 지정되어 있으면 그 필드만 추출 + if (additionalSource.fieldName) { + additionalData[additionalSource.fieldName] = firstValue[additionalSource.fieldName] || firstValue.condition || firstValue; + } else { + // fieldName이 없으면 전체 객체 병합 + additionalData = { ...additionalData, ...firstValue }; + } + + console.log("📦 추가 데이터 수집 (additionalSources):", { + sourceId: additionalSource.componentId, + fieldName: additionalSource.fieldName, + value: additionalData[additionalSource.fieldName || 'all'], + }); + } + } + } + } + + // 방법 2: formData에서 조건부 컨테이너 값 가져오기 (자동) + // ConditionalSectionViewer가 __conditionalContainerValue, __conditionalContainerControlField를 formData에 포함시킴 + if (formData && formData.__conditionalContainerValue) { + // includeConditionalValue 설정이 true이거나 설정이 없으면 자동 포함 + if (dataTransferConfig.includeConditionalValue !== false) { + const conditionalValue = formData.__conditionalContainerValue; + const conditionalLabel = formData.__conditionalContainerLabel; + const controlField = formData.__conditionalContainerControlField; // 🆕 제어 필드명 직접 사용 + + // 🆕 controlField가 있으면 그것을 필드명으로 사용 (자동 매핑!) + if (controlField) { + additionalData[controlField] = conditionalValue; + console.log("📦 조건부 컨테이너 값 자동 매핑:", { + controlField, + value: conditionalValue, + label: conditionalLabel, + }); + } else { + // controlField가 없으면 기존 방식: formData에서 같은 값을 가진 키 찾기 + for (const [key, value] of Object.entries(formData)) { + if (value === conditionalValue && !key.startsWith('__')) { + additionalData[key] = conditionalValue; + console.log("📦 조건부 컨테이너 값 자동 포함:", { + fieldName: key, + value: conditionalValue, + label: conditionalLabel, + }); + break; + } + } + + // 못 찾았으면 기본 필드명 사용 + if (!Object.keys(additionalData).some(k => !k.startsWith('__'))) { + additionalData['condition_type'] = conditionalValue; + console.log("📦 조건부 컨테이너 값 (기본 필드명):", { + fieldName: 'condition_type', + value: conditionalValue, + }); + } + } + } + } + // 2. 검증 const validation = dataTransferConfig.validation; if (validation) { @@ -430,9 +542,15 @@ export const ButtonPrimaryComponent: React.FC = ({ } } - // 4. 매핑 규칙 적용 + // 4. 매핑 규칙 적용 + 추가 데이터 병합 const mappedData = sourceData.map((row) => { - return applyMappingRules(row, dataTransferConfig.mappingRules || []); + const mappedRow = applyMappingRules(row, dataTransferConfig.mappingRules || []); + + // 추가 데이터를 모든 행에 포함 + return { + ...mappedRow, + ...additionalData, + }; }); console.log("📦 데이터 전달:", { @@ -459,13 +577,54 @@ export const ButtonPrimaryComponent: React.FC = ({ mode: dataTransferConfig.mode || "append", mappingRules: dataTransferConfig.mappingRules || [], }); + + toast.success(`${sourceData.length}개 항목이 전달되었습니다.`); + } else if (dataTransferConfig.targetType === "splitPanel") { + // 🆕 분할 패널의 반대편 화면으로 전달 + if (!splitPanelContext) { + toast.error("분할 패널 컨텍스트를 찾을 수 없습니다. 이 버튼이 분할 패널 내부에 있는지 확인하세요."); + return; + } + + // 🆕 useSplitPanelPosition 훅으로 위치 가져오기 (중첩된 화면에서도 작동) + // screenId로 찾는 것은 직접 임베드된 화면에서만 작동하므로, + // SplitPanelPositionProvider로 전달된 위치를 우선 사용 + const currentPosition = splitPanelPosition || (screenId ? splitPanelContext.getPositionByScreenId(screenId) : null); + + if (!currentPosition) { + toast.error("분할 패널 내 위치를 확인할 수 없습니다. screenId: " + screenId); + return; + } + + console.log("📦 분할 패널 데이터 전달:", { + currentPosition, + splitPanelPositionFromHook: splitPanelPosition, + screenId, + leftScreenId: splitPanelContext.leftScreenId, + rightScreenId: splitPanelContext.rightScreenId, + }); + + const result = await splitPanelContext.transferToOtherSide( + currentPosition, + mappedData, + dataTransferConfig.targetComponentId, // 특정 컴포넌트 지정 (선택사항) + dataTransferConfig.mode || "append" + ); + + if (result.success) { + toast.success(result.message); + } else { + toast.error(result.message); + return; + } } else if (dataTransferConfig.targetType === "screen") { // 다른 화면으로 전달 (구현 예정) toast.info("다른 화면으로의 데이터 전달은 추후 구현 예정입니다."); + return; + } else { + toast.success(`${sourceData.length}개 항목이 전달되었습니다.`); } - toast.success(`${sourceData.length}개 항목이 전달되었습니다.`); - // 6. 전달 후 정리 if (dataTransferConfig.clearAfterTransfer) { sourceProvider.clearSelection(); diff --git a/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx b/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx index 6f2ab183..db3fde4c 100644 --- a/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx +++ b/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx @@ -12,6 +12,8 @@ import { import { ConditionalContainerProps, ConditionalSection } from "./types"; import { ConditionalSectionViewer } from "./ConditionalSectionViewer"; import { cn } from "@/lib/utils"; +import { useScreenContextOptional } from "@/contexts/ScreenContext"; +import type { DataProvidable } from "@/types/data-transfer"; /** * 조건부 컨테이너 컴포넌트 @@ -42,6 +44,9 @@ export function ConditionalContainerComponent({ onSave, // 🆕 EditModal의 handleSave 콜백 }: ConditionalContainerProps) { + // 화면 컨텍스트 (데이터 제공자로 등록) + const screenContext = useScreenContextOptional(); + // config prop 우선, 없으면 개별 prop 사용 const controlField = config?.controlField || propControlField || "condition"; const controlLabel = config?.controlLabel || propControlLabel || "조건 선택"; @@ -50,30 +55,86 @@ export function ConditionalContainerComponent({ const showBorder = config?.showBorder ?? propShowBorder ?? true; const spacing = config?.spacing || propSpacing || "normal"; + // 초기값 계산 (한 번만) + const initialValue = React.useMemo(() => { + return value || formData?.[controlField] || defaultValue || ""; + }, []); // 의존성 없음 - 마운트 시 한 번만 계산 + // 현재 선택된 값 - const [selectedValue, setSelectedValue] = useState( - value || formData?.[controlField] || defaultValue || "" - ); + const [selectedValue, setSelectedValue] = useState(initialValue); + + // 최신 값을 ref로 유지 (클로저 문제 방지) + const selectedValueRef = React.useRef(selectedValue); + selectedValueRef.current = selectedValue; // 렌더링마다 업데이트 (useEffect 대신) - // formData 변경 시 동기화 - useEffect(() => { - if (formData?.[controlField]) { - setSelectedValue(formData[controlField]); - } - }, [formData, controlField]); - - // 값 변경 핸들러 - const handleValueChange = (newValue: string) => { + // 콜백 refs (의존성 제거) + const onChangeRef = React.useRef(onChange); + const onFormDataChangeRef = React.useRef(onFormDataChange); + onChangeRef.current = onChange; + onFormDataChangeRef.current = onFormDataChange; + + // 값 변경 핸들러 - 의존성 없음 + const handleValueChange = React.useCallback((newValue: string) => { + // 같은 값이면 무시 + if (newValue === selectedValueRef.current) return; + setSelectedValue(newValue); - if (onChange) { - onChange(newValue); + if (onChangeRef.current) { + onChangeRef.current(newValue); } - if (onFormDataChange) { - onFormDataChange(controlField, newValue); + if (onFormDataChangeRef.current) { + onFormDataChangeRef.current(controlField, newValue); } - }; + }, [controlField]); + + // sectionsRef 추가 (dataProvider에서 사용) + const sectionsRef = React.useRef(sections); + React.useEffect(() => { + sectionsRef.current = sections; + }, [sections]); + + // dataProvider를 useMemo로 감싸서 불필요한 재생성 방지 + const dataProvider = React.useMemo(() => ({ + componentId: componentId || "conditional-container", + componentType: "conditional-container", + + getSelectedData: () => { + // ref를 통해 최신 값 참조 (클로저 문제 방지) + const currentValue = selectedValueRef.current; + const currentSections = sectionsRef.current; + return [{ + [controlField]: currentValue, + condition: currentValue, + label: currentSections.find(s => s.condition === currentValue)?.label || currentValue, + }]; + }, + + getAllData: () => { + const currentSections = sectionsRef.current; + return currentSections.map(section => ({ + condition: section.condition, + label: section.label, + })); + }, + + clearSelection: () => { + // 조건부 컨테이너는 초기화하지 않음 + console.log("조건부 컨테이너는 선택 초기화를 지원하지 않습니다."); + }, + }), [componentId, controlField]); // selectedValue, sections는 ref로 참조 + + // 화면 컨텍스트에 데이터 제공자로 등록 + useEffect(() => { + if (screenContext && componentId) { + screenContext.registerDataProvider(componentId, dataProvider); + + return () => { + screenContext.unregisterDataProvider(componentId); + }; + } + }, [screenContext, componentId, dataProvider]); // 컨테이너 높이 측정용 ref const containerRef = useRef(null); @@ -158,6 +219,8 @@ export function ConditionalContainerComponent({ onFormDataChange={onFormDataChange} groupedData={groupedData} onSave={onSave} + controlField={controlField} + selectedCondition={selectedValue} /> ))} @@ -179,6 +242,8 @@ export function ConditionalContainerComponent({ onFormDataChange={onFormDataChange} groupedData={groupedData} onSave={onSave} + controlField={controlField} + selectedCondition={selectedValue} /> ) : null ) diff --git a/frontend/lib/registry/components/conditional-container/ConditionalContainerConfigPanel.tsx b/frontend/lib/registry/components/conditional-container/ConditionalContainerConfigPanel.tsx index 173bebc6..ff850346 100644 --- a/frontend/lib/registry/components/conditional-container/ConditionalContainerConfigPanel.tsx +++ b/frontend/lib/registry/components/conditional-container/ConditionalContainerConfigPanel.tsx @@ -12,19 +12,38 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Plus, Trash2, GripVertical, Loader2 } from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Plus, Trash2, GripVertical, Loader2, Check, ChevronsUpDown, Database } from "lucide-react"; import { ConditionalContainerConfig, ConditionalSection } from "./types"; import { screenApi } from "@/lib/api/screen"; +import { cn } from "@/lib/utils"; +import { getCategoryColumnsByMenu, getCategoryValues, getSecondLevelMenus } from "@/lib/api/tableCategoryValue"; interface ConditionalContainerConfigPanelProps { config: ConditionalContainerConfig; - onConfigChange: (config: ConditionalContainerConfig) => void; + onChange?: (config: ConditionalContainerConfig) => void; + onConfigChange?: (config: ConditionalContainerConfig) => void; } export function ConditionalContainerConfigPanel({ config, + onChange, onConfigChange, }: ConditionalContainerConfigPanelProps) { + // onChange 또는 onConfigChange 둘 다 지원 + const handleConfigChange = onChange || onConfigChange; const [localConfig, setLocalConfig] = useState({ controlField: config.controlField || "condition", controlLabel: config.controlLabel || "조건 선택", @@ -38,6 +57,21 @@ export function ConditionalContainerConfigPanel({ const [screens, setScreens] = useState([]); const [screensLoading, setScreensLoading] = useState(false); + // 🆕 메뉴 기반 카테고리 관련 상태 + const [availableMenus, setAvailableMenus] = useState>([]); + const [menusLoading, setMenusLoading] = useState(false); + const [selectedMenuObjid, setSelectedMenuObjid] = useState(null); + const [menuPopoverOpen, setMenuPopoverOpen] = useState(false); + + const [categoryColumns, setCategoryColumns] = useState>([]); + const [categoryColumnsLoading, setCategoryColumnsLoading] = useState(false); + const [selectedCategoryColumn, setSelectedCategoryColumn] = useState(""); + const [selectedCategoryTableName, setSelectedCategoryTableName] = useState(""); + const [columnPopoverOpen, setColumnPopoverOpen] = useState(false); + + const [categoryValues, setCategoryValues] = useState>([]); + const [categoryValuesLoading, setCategoryValuesLoading] = useState(false); + // 화면 목록 로드 useEffect(() => { const loadScreens = async () => { @@ -56,11 +90,122 @@ export function ConditionalContainerConfigPanel({ loadScreens(); }, []); + // 🆕 2레벨 메뉴 목록 로드 + useEffect(() => { + const loadMenus = async () => { + setMenusLoading(true); + try { + const response = await getSecondLevelMenus(); + console.log("🔍 [ConditionalContainer] 메뉴 목록 응답:", response); + if (response.success && response.data) { + setAvailableMenus(response.data); + } + } catch (error) { + console.error("메뉴 목록 로드 실패:", error); + } finally { + setMenusLoading(false); + } + }; + loadMenus(); + }, []); + + // 🆕 선택된 메뉴의 카테고리 컬럼 목록 로드 + useEffect(() => { + if (!selectedMenuObjid) { + setCategoryColumns([]); + setSelectedCategoryColumn(""); + setSelectedCategoryTableName(""); + setCategoryValues([]); + return; + } + + const loadCategoryColumns = async () => { + setCategoryColumnsLoading(true); + try { + console.log("🔍 [ConditionalContainer] 메뉴별 카테고리 컬럼 로드:", selectedMenuObjid); + const response = await getCategoryColumnsByMenu(selectedMenuObjid); + console.log("✅ [ConditionalContainer] 카테고리 컬럼 응답:", response); + + if (response.success && response.data) { + setCategoryColumns(response.data.map((col: any) => ({ + columnName: col.columnName || col.column_name, + columnLabel: col.columnLabel || col.column_label || col.columnName || col.column_name, + tableName: col.tableName || col.table_name, + }))); + } else { + setCategoryColumns([]); + } + } catch (error) { + console.error("카테고리 컬럼 로드 실패:", error); + setCategoryColumns([]); + } finally { + setCategoryColumnsLoading(false); + } + }; + loadCategoryColumns(); + }, [selectedMenuObjid]); + + // 🆕 선택된 카테고리 컬럼의 값 목록 로드 + useEffect(() => { + if (!selectedCategoryTableName || !selectedCategoryColumn || !selectedMenuObjid) { + setCategoryValues([]); + return; + } + + const loadCategoryValues = async () => { + setCategoryValuesLoading(true); + try { + console.log("🔍 [ConditionalContainer] 카테고리 값 로드:", selectedCategoryTableName, selectedCategoryColumn, selectedMenuObjid); + const response = await getCategoryValues(selectedCategoryTableName, selectedCategoryColumn, false, selectedMenuObjid); + console.log("✅ [ConditionalContainer] 카테고리 값 응답:", response); + + if (response.success && response.data) { + const values = response.data.map((v: any) => ({ + value: v.valueCode || v.value_code, + label: v.valueLabel || v.value_label || v.valueCode || v.value_code, + })); + setCategoryValues(values); + } else { + setCategoryValues([]); + } + } catch (error) { + console.error("카테고리 값 로드 실패:", error); + setCategoryValues([]); + } finally { + setCategoryValuesLoading(false); + } + }; + loadCategoryValues(); + }, [selectedCategoryTableName, selectedCategoryColumn, selectedMenuObjid]); + + // 🆕 테이블 카테고리에서 섹션 자동 생성 + const generateSectionsFromCategory = () => { + if (categoryValues.length === 0) { + alert("먼저 테이블과 카테고리 컬럼을 선택하고 값을 로드해주세요."); + return; + } + + const newSections: ConditionalSection[] = categoryValues.map((option, index) => ({ + id: `section_${Date.now()}_${index}`, + condition: option.value, + label: option.label, + screenId: null, + screenName: undefined, + })); + + updateConfig({ + sections: newSections, + controlField: selectedCategoryColumn, // 카테고리 컬럼명을 제어 필드로 사용 + }); + + alert(`${newSections.length}개의 섹션이 생성되었습니다.`); + }; + // 설정 업데이트 헬퍼 const updateConfig = (updates: Partial) => { const newConfig = { ...localConfig, ...updates }; setLocalConfig(newConfig); - onConfigChange(newConfig); + handleConfigChange?.(newConfig); }; // 새 섹션 추가 @@ -134,6 +279,207 @@ export function ConditionalContainerConfigPanel({ + {/* 🆕 메뉴별 카테고리에서 섹션 자동 생성 */} +
+
+ + +
+ + {/* 1. 메뉴 선택 */} +
+ + + + + + + + + + 메뉴를 찾을 수 없습니다 + + {availableMenus.map((menu) => ( + { + setSelectedMenuObjid(menu.menuObjid); + setSelectedCategoryColumn(""); + setSelectedCategoryTableName(""); + setMenuPopoverOpen(false); + }} + className="text-xs" + > + +
+ {menu.parentMenuName} > {menu.menuName} + {menu.screenCode && ( + + {menu.screenCode} + + )} +
+
+ ))} +
+
+
+
+
+
+ + {/* 2. 카테고리 컬럼 선택 */} + {selectedMenuObjid && ( +
+ + {categoryColumnsLoading ? ( +
+ + 로딩 중... +
+ ) : categoryColumns.length > 0 ? ( + + + + + + + + + 카테고리 컬럼이 없습니다 + + {categoryColumns.map((col) => ( + { + setSelectedCategoryColumn(col.columnName); + setSelectedCategoryTableName(col.tableName); + setColumnPopoverOpen(false); + }} + className="text-xs" + > + +
+ {col.columnLabel} + + {col.tableName}.{col.columnName} + +
+
+ ))} +
+
+
+
+
+ ) : ( +

+ 이 메뉴에 설정된 카테고리 컬럼이 없습니다. + 카테고리 관리에서 먼저 설정해주세요. +

+ )} +
+ )} + + {/* 3. 카테고리 값 미리보기 */} + {selectedCategoryColumn && ( +
+ + {categoryValuesLoading ? ( +
+ + 로딩 중... +
+ ) : categoryValues.length > 0 ? ( +
+ {categoryValues.map((option) => ( + + {option.label} + + ))} +
+ ) : ( +

+ 이 컬럼에 등록된 카테고리 값이 없습니다. + 카테고리 관리에서 값을 먼저 등록해주세요. +

+ )} +
+ )} + + + +

+ 선택한 메뉴의 카테고리 값들로 조건별 섹션을 자동으로 생성합니다. + 각 섹션에 표시할 화면은 아래에서 개별 설정하세요. +

+
+ {/* 조건별 섹션 설정 */}
diff --git a/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx b/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx index 735fac6d..d5686f6c 100644 --- a/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx +++ b/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx @@ -27,6 +27,8 @@ export function ConditionalSectionViewer({ onFormDataChange, groupedData, // 🆕 그룹 데이터 onSave, // 🆕 EditModal의 handleSave 콜백 + controlField, // 🆕 조건부 컨테이너의 제어 필드명 + selectedCondition, // 🆕 현재 선택된 조건 값 }: ConditionalSectionViewerProps) { const { userId, userName, user } = useAuth(); const [isLoading, setIsLoading] = useState(false); @@ -34,6 +36,24 @@ export function ConditionalSectionViewer({ const [screenInfo, setScreenInfo] = useState<{ id: number; tableName?: string } | null>(null); const [screenResolution, setScreenResolution] = useState<{ width: number; height: number } | null>(null); + // 🆕 조건 값을 포함한 formData 생성 + const enhancedFormData = React.useMemo(() => { + const base = formData || {}; + + // 조건부 컨테이너의 현재 선택 값을 formData에 포함 + if (controlField && selectedCondition) { + return { + ...base, + [controlField]: selectedCondition, + __conditionalContainerValue: selectedCondition, + __conditionalContainerLabel: label, + __conditionalContainerControlField: controlField, // 🆕 제어 필드명도 포함 + }; + } + + return base; + }, [formData, controlField, selectedCondition, label]); + // 화면 로드 useEffect(() => { if (!screenId) { @@ -154,18 +174,18 @@ export function ConditionalSectionViewer({ }} > + />
); })} diff --git a/frontend/lib/registry/components/conditional-container/types.ts b/frontend/lib/registry/components/conditional-container/types.ts index bcd701ef..284e0855 100644 --- a/frontend/lib/registry/components/conditional-container/types.ts +++ b/frontend/lib/registry/components/conditional-container/types.ts @@ -79,5 +79,8 @@ export interface ConditionalSectionViewerProps { onFormDataChange?: (fieldName: string, value: any) => void; groupedData?: Record[]; // 🆕 그룹 데이터 onSave?: () => Promise; // 🆕 EditModal의 handleSave 콜백 + // 🆕 조건부 컨테이너 정보 (자식 화면에 전달) + controlField?: string; // 제어 필드명 (예: "inbound_type") + selectedCondition?: string; // 현재 선택된 조건 값 (예: "PURCHASE_IN") } diff --git a/frontend/lib/registry/components/repeater-field-group/RepeaterFieldGroupRenderer.tsx b/frontend/lib/registry/components/repeater-field-group/RepeaterFieldGroupRenderer.tsx index 645cca8b..52853746 100644 --- a/frontend/lib/registry/components/repeater-field-group/RepeaterFieldGroupRenderer.tsx +++ b/frontend/lib/registry/components/repeater-field-group/RepeaterFieldGroupRenderer.tsx @@ -1,33 +1,169 @@ "use client"; -import React from "react"; +import React, { useEffect, useRef, useCallback, useMemo } from "react"; import { Layers } from "lucide-react"; import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; import { ComponentDefinition, ComponentCategory, ComponentRendererProps } from "@/types/component"; import { RepeaterInput } from "@/components/webtypes/RepeaterInput"; import { RepeaterConfigPanel } from "@/components/webtypes/config/RepeaterConfigPanel"; +import { useScreenContextOptional, DataReceivable } from "@/contexts/ScreenContext"; +import { useSplitPanelContext } from "@/contexts/SplitPanelContext"; +import { applyMappingRules } from "@/lib/utils/dataMapping"; +import { toast } from "sonner"; /** * Repeater Field Group 컴포넌트 */ const RepeaterFieldGroupComponent: React.FC = (props) => { - const { component, value, onChange, readonly, disabled } = props; + const { component, value, onChange, readonly, disabled, formData, onFormDataChange, menuObjid } = props; + const screenContext = useScreenContextOptional(); + const splitPanelContext = useSplitPanelContext(); + const receiverRef = useRef(null); + + // 컴포넌트의 필드명 (formData 키) + const fieldName = (component as any).columnName || component.id; // repeaterConfig 또는 componentConfig에서 설정 가져오기 const config = (component as any).repeaterConfig || component.componentConfig || { fields: [] }; + // formData에서 값 가져오기 (value prop보다 우선) + const rawValue = formData?.[fieldName] ?? value; + + console.log("🔄 [RepeaterFieldGroup] 렌더링:", { + fieldName, + hasFormData: !!formData, + formDataValue: formData?.[fieldName], + propsValue: value, + rawValue, + }); + // 값이 JSON 문자열인 경우 파싱 let parsedValue: any[] = []; - if (typeof value === "string") { + if (typeof rawValue === "string") { try { - parsedValue = JSON.parse(value); + parsedValue = JSON.parse(rawValue); } catch { parsedValue = []; } - } else if (Array.isArray(value)) { - parsedValue = value; + } else if (Array.isArray(rawValue)) { + parsedValue = rawValue; } + // parsedValue를 ref로 관리하여 최신 값 유지 + const parsedValueRef = useRef(parsedValue); + parsedValueRef.current = parsedValue; + + // onChange를 ref로 관리 + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + + // onFormDataChange를 ref로 관리 + const onFormDataChangeRef = useRef(onFormDataChange); + onFormDataChangeRef.current = onFormDataChange; + + // fieldName을 ref로 관리 + const fieldNameRef = useRef(fieldName); + fieldNameRef.current = fieldName; + + // 데이터 수신 핸들러 + const handleReceiveData = useCallback((data: any[], mappingRulesOrMode?: any[] | string) => { + console.log("📥 [RepeaterFieldGroup] 데이터 수신:", { data, mappingRulesOrMode }); + + if (!data || data.length === 0) { + toast.warning("전달할 데이터가 없습니다"); + return; + } + + // 매핑 규칙이 배열인 경우에만 적용 + let processedData = data; + if (Array.isArray(mappingRulesOrMode) && mappingRulesOrMode.length > 0) { + processedData = applyMappingRules(data, mappingRulesOrMode); + } + + // 데이터 정규화: 각 항목에서 실제 데이터 추출 + // 데이터가 {0: {...}, inbound_type: "..."} 형태인 경우 처리 + const normalizedData = processedData.map((item: any) => { + // item이 {0: {...실제데이터...}, 추가필드: 값} 형태인 경우 + if (item && typeof item === "object" && item[0] && typeof item[0] === "object") { + // 0번 인덱스의 데이터와 나머지 필드를 병합 + const { 0: originalData, ...additionalFields } = item; + return { ...originalData, ...additionalFields }; + } + return item; + }); + + console.log("📥 [RepeaterFieldGroup] 정규화된 데이터:", normalizedData); + + // 기존 데이터에 새 데이터 추가 (기본 모드: append) + const currentValue = parsedValueRef.current; + + // mode가 "replace"인 경우 기존 데이터 대체, 그 외에는 추가 + const mode = typeof mappingRulesOrMode === "string" ? mappingRulesOrMode : "append"; + const newItems = mode === "replace" ? normalizedData : [...currentValue, ...normalizedData]; + + console.log("📥 [RepeaterFieldGroup] 최종 데이터:", { currentValue, newItems, mode }); + + // JSON 문자열로 변환하여 저장 + const jsonValue = JSON.stringify(newItems); + console.log("📥 [RepeaterFieldGroup] onChange/onFormDataChange 호출:", { + jsonValue, + hasOnChange: !!onChangeRef.current, + hasOnFormDataChange: !!onFormDataChangeRef.current, + fieldName: fieldNameRef.current, + }); + + // onFormDataChange가 있으면 우선 사용 (EmbeddedScreen의 formData 상태 업데이트) + if (onFormDataChangeRef.current) { + onFormDataChangeRef.current(fieldNameRef.current, jsonValue); + } + // 그렇지 않으면 onChange 사용 + else if (onChangeRef.current) { + onChangeRef.current(jsonValue); + } + + toast.success(`${normalizedData.length}개 항목이 추가되었습니다`); + }, []); + + // DataReceivable 인터페이스 구현 + const dataReceiver = useMemo(() => ({ + componentId: component.id, + componentType: "repeater-field-group", + receiveData: handleReceiveData, + }), [component.id, handleReceiveData]); + + // ScreenContext에 데이터 수신자로 등록 + useEffect(() => { + if (screenContext && component.id) { + console.log("📋 [RepeaterFieldGroup] ScreenContext에 데이터 수신자 등록:", component.id); + screenContext.registerDataReceiver(component.id, dataReceiver); + + return () => { + screenContext.unregisterDataReceiver(component.id); + }; + } + }, [screenContext, component.id, dataReceiver]); + + // SplitPanelContext에 데이터 수신자로 등록 (분할 패널 내에서만) + useEffect(() => { + const splitPanelPosition = screenContext?.splitPanelPosition; + + if (splitPanelContext?.isInSplitPanel && splitPanelPosition && component.id) { + console.log("🔗 [RepeaterFieldGroup] SplitPanelContext에 데이터 수신자 등록:", { + componentId: component.id, + position: splitPanelPosition, + }); + + splitPanelContext.registerReceiver(splitPanelPosition, component.id, dataReceiver); + receiverRef.current = dataReceiver; + + return () => { + console.log("🔗 [RepeaterFieldGroup] SplitPanelContext에서 데이터 수신자 해제:", component.id); + splitPanelContext.unregisterReceiver(splitPanelPosition, component.id); + receiverRef.current = null; + }; + } + }, [splitPanelContext, screenContext?.splitPanelPosition, component.id, dataReceiver]); + return ( = (props) => config={config} disabled={disabled} readonly={readonly} + menuObjid={menuObjid} className="w-full" /> ); diff --git a/frontend/lib/registry/components/screen-split-panel/ScreenSplitPanelConfigPanel.tsx b/frontend/lib/registry/components/screen-split-panel/ScreenSplitPanelConfigPanel.tsx index 26d55dcf..1baab85c 100644 --- a/frontend/lib/registry/components/screen-split-panel/ScreenSplitPanelConfigPanel.tsx +++ b/frontend/lib/registry/components/screen-split-panel/ScreenSplitPanelConfigPanel.tsx @@ -40,6 +40,21 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl ...config, }); + // config prop이 변경되면 localConfig 동기화 + useEffect(() => { + console.log("🔄 [ScreenSplitPanelConfigPanel] config prop 변경 감지:", config); + setLocalConfig({ + screenId: config.screenId || 0, + leftScreenId: config.leftScreenId || 0, + rightScreenId: config.rightScreenId || 0, + splitRatio: config.splitRatio || 50, + resizable: config.resizable ?? true, + buttonLabel: config.buttonLabel || "데이터 전달", + buttonPosition: config.buttonPosition || "center", + ...config, + }); + }, [config]); + // 화면 목록 로드 useEffect(() => { const loadScreens = async () => { @@ -66,6 +81,13 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl }; setLocalConfig(newConfig); + console.log("📝 [ScreenSplitPanelConfigPanel] 설정 변경:", { + key, + value, + newConfig, + hasOnChange: !!onChange, + }); + // 변경 즉시 부모에게 전달 if (onChange) { onChange(newConfig); diff --git a/frontend/lib/registry/components/screen-split-panel/ScreenSplitPanelRenderer.tsx b/frontend/lib/registry/components/screen-split-panel/ScreenSplitPanelRenderer.tsx index 7247c5c2..4397dc29 100644 --- a/frontend/lib/registry/components/screen-split-panel/ScreenSplitPanelRenderer.tsx +++ b/frontend/lib/registry/components/screen-split-panel/ScreenSplitPanelRenderer.tsx @@ -66,11 +66,28 @@ class ScreenSplitPanelRenderer extends AutoRegisteringComponentRenderer { }; render() { - const { config = {}, style = {} } = this.props; + console.log("🚀 [ScreenSplitPanelRenderer] render() 호출됨!", this.props); + + const { component, style = {}, componentConfig, config, screenId } = this.props as any; + + // componentConfig 또는 config 또는 component.componentConfig 사용 + const finalConfig = componentConfig || config || component?.componentConfig || {}; + + console.log("🔍 [ScreenSplitPanelRenderer] 설정 분석:", { + hasComponentConfig: !!componentConfig, + hasConfig: !!config, + hasComponentComponentConfig: !!component?.componentConfig, + finalConfig, + splitRatio: finalConfig.splitRatio, // 🆕 splitRatio 확인 + leftScreenId: finalConfig.leftScreenId, + rightScreenId: finalConfig.rightScreenId, + componentType: component?.componentType, + componentId: component?.id, + }); return (
- +
); } diff --git a/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx b/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx index 0e618b6e..d4ad416e 100644 --- a/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx +++ b/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx @@ -2,6 +2,8 @@ import React, { useState, useEffect, useRef, useMemo } from "react"; import { filterDOMProps } from "@/lib/utils/domPropsFilter"; import { useCodeOptions, useTableCodeCategory } from "@/hooks/queries/useCodes"; import { cn } from "@/lib/registry/components/common/inputStyles"; +import { useScreenContextOptional } from "@/contexts/ScreenContext"; +import type { DataProvidable } from "@/types/data-transfer"; interface Option { value: string; @@ -50,6 +52,9 @@ const SelectBasicComponent: React.FC = ({ menuObjid, // 🆕 메뉴 OBJID ...props }) => { + // 화면 컨텍스트 (데이터 제공자로 등록) + const screenContext = useScreenContextOptional(); + // 🚨 최초 렌더링 확인용 (테스트 후 제거) console.log("🚨🚨🚨 [SelectBasicComponent] 렌더링됨!!!!", { componentId: component.id, @@ -249,6 +254,47 @@ const SelectBasicComponent: React.FC = ({ // - 중복 요청 방지: 동일한 queryKey에 대해 자동 중복 제거 // - 상태 동기화: 모든 컴포넌트가 같은 캐시 공유 + // 📦 DataProvidable 인터페이스 구현 (데이터 전달 시 셀렉트 값 제공) + const dataProvider: DataProvidable = { + componentId: component.id, + componentType: "select", + + getSelectedData: () => { + // 현재 선택된 값을 배열로 반환 + const fieldName = component.columnName || "selectedValue"; + return [{ + [fieldName]: selectedValue, + value: selectedValue, + label: selectedLabel, + }]; + }, + + getAllData: () => { + // 모든 옵션 반환 + const configOptions = config.options || []; + return [...codeOptions, ...categoryOptions, ...configOptions]; + }, + + clearSelection: () => { + setSelectedValue(""); + setSelectedLabel(""); + if (isMultiple) { + setSelectedValues([]); + } + }, + }; + + // 화면 컨텍스트에 데이터 제공자로 등록 + useEffect(() => { + if (screenContext && component.id) { + screenContext.registerDataProvider(component.id, dataProvider); + + return () => { + screenContext.unregisterDataProvider(component.id); + }; + } + }, [screenContext, component.id, selectedValue, selectedLabel, selectedValues]); + // 선택된 값에 따른 라벨 업데이트 useEffect(() => { const getAllOptions = () => { diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 1dc8f127..2fbf9c88 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -49,6 +49,7 @@ import { useTableOptions } from "@/contexts/TableOptionsContext"; import { TableFilter, ColumnVisibility } from "@/types/table-options"; import { useAuth } from "@/hooks/useAuth"; import { useScreenContextOptional } from "@/contexts/ScreenContext"; +import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext"; import type { DataProvidable, DataReceivable, DataReceiverConfig } from "@/types/data-transfer"; // ======================================== @@ -255,6 +256,14 @@ export const TableListComponent: React.FC = ({ // 화면 컨텍스트 (데이터 제공자로 등록) const screenContext = useScreenContextOptional(); + + // 분할 패널 컨텍스트 (분할 패널 내부에서 데이터 수신자로 등록) + const splitPanelContext = useSplitPanelContext(); + // 🆕 ScreenContext에서 splitPanelPosition 가져오기 (중첩 화면에서도 작동) + const splitPanelPosition = screenContext?.splitPanelPosition; + + // 🆕 연결된 필터 상태 (다른 컴포넌트 값으로 필터링) + const [linkedFilterValues, setLinkedFilterValues] = useState>({}); // TableOptions Context const { registerTable, unregisterTable, updateTableDataCount } = useTableOptions(); @@ -364,6 +373,65 @@ export const TableListComponent: React.FC = ({ const [viewMode, setViewMode] = useState<"table" | "card" | "grouped-card">("table"); const [frozenColumns, setFrozenColumns] = useState([]); + // 🆕 연결된 필터 처리 (셀렉트박스 등 다른 컴포넌트 값으로 필터링) + useEffect(() => { + const linkedFilters = tableConfig.linkedFilters; + + if (!linkedFilters || linkedFilters.length === 0 || !screenContext) { + return; + } + + // 연결된 소스 컴포넌트들의 값을 주기적으로 확인 + const checkLinkedFilters = () => { + const newFilterValues: Record = {}; + let hasChanges = false; + + linkedFilters.forEach((filter) => { + if (filter.enabled === false) return; + + const sourceProvider = screenContext.getDataProvider(filter.sourceComponentId); + if (sourceProvider) { + const selectedData = sourceProvider.getSelectedData(); + if (selectedData && selectedData.length > 0) { + const sourceField = filter.sourceField || "value"; + const value = selectedData[0][sourceField]; + + if (value !== linkedFilterValues[filter.targetColumn]) { + newFilterValues[filter.targetColumn] = value; + hasChanges = true; + } else { + newFilterValues[filter.targetColumn] = linkedFilterValues[filter.targetColumn]; + } + } + } + }); + + if (hasChanges) { + console.log("🔗 [TableList] 연결된 필터 값 변경:", newFilterValues); + setLinkedFilterValues(newFilterValues); + + // searchValues에 연결된 필터 값 병합 + setSearchValues(prev => ({ + ...prev, + ...newFilterValues + })); + + // 첫 페이지로 이동 + setCurrentPage(1); + } + }; + + // 초기 체크 + checkLinkedFilters(); + + // 주기적으로 체크 (500ms마다) + const intervalId = setInterval(checkLinkedFilters, 500); + + return () => { + clearInterval(intervalId); + }; + }, [screenContext, tableConfig.linkedFilters, linkedFilterValues]); + // DataProvidable 인터페이스 구현 const dataProvider: DataProvidable = { componentId: component.id, @@ -464,6 +532,39 @@ export const TableListComponent: React.FC = ({ }; } }, [screenContext, component.id, data, selectedRows]); + + // 분할 패널 컨텍스트에 데이터 수신자로 등록 + // useSplitPanelPosition 훅으로 위치 가져오기 (중첩된 화면에서도 작동) + const currentSplitPosition = splitPanelPosition || splitPanelContext?.getPositionByScreenId(screenId as number) || null; + + useEffect(() => { + if (splitPanelContext && component.id && currentSplitPosition) { + const splitPanelReceiver = { + componentId: component.id, + componentType: "table-list", + receiveData: async (incomingData: any[], mode: "append" | "replace" | "merge") => { + console.log("📥 [TableListComponent] 분할 패널에서 데이터 수신:", { + count: incomingData.length, + mode, + position: currentSplitPosition, + }); + + await dataReceiver.receiveData(incomingData, { + targetComponentId: component.id, + targetComponentType: "table-list", + mode, + mappingRules: [], + }); + }, + }; + + splitPanelContext.registerReceiver(currentSplitPosition, component.id, splitPanelReceiver); + + return () => { + splitPanelContext.unregisterReceiver(currentSplitPosition, component.id); + }; + } + }, [splitPanelContext, component.id, currentSplitPosition, dataReceiver]); // 테이블 등록 (Context에 등록) const tableId = `table-list-${component.id}`; diff --git a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx index 0f13abf8..9de2f6d8 100644 --- a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx +++ b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx @@ -1214,6 +1214,114 @@ export const TableListConfigPanel: React.FC = ({ onConfigChange={(dataFilter) => handleChange("dataFilter", dataFilter)} />
+ + {/* 🆕 연결된 필터 설정 (셀렉트박스 등 다른 컴포넌트 값으로 필터링) */} +
+
+

연결된 필터

+

+ 셀렉트박스 등 다른 컴포넌트의 값으로 테이블 데이터를 실시간 필터링합니다 +

+
+
+ + {/* 연결된 필터 목록 */} +
+ {(config.linkedFilters || []).map((filter, index) => ( +
+
+
+ { + const newFilters = [...(config.linkedFilters || [])]; + newFilters[index] = { ...filter, sourceComponentId: e.target.value }; + handleChange("linkedFilters", newFilters); + }} + className="h-7 text-xs flex-1" + /> + + + + + + + + + + 컬럼을 찾을 수 없습니다 + + {availableColumns.map((col) => ( + { + const newFilters = [...(config.linkedFilters || [])]; + newFilters[index] = { ...filter, targetColumn: col.columnName }; + handleChange("linkedFilters", newFilters); + }} + className="text-xs" + > + + {col.label || col.columnName} + + ))} + + + + + +
+
+ +
+ ))} + + {/* 연결된 필터 추가 버튼 */} + + +

+ 예: 셀렉트박스(ID: select-basic-123)의 값으로 테이블의 inbound_type 컬럼을 필터링 +

+
+
); diff --git a/frontend/lib/registry/components/table-list/types.ts b/frontend/lib/registry/components/table-list/types.ts index 04cbfae2..0322926b 100644 --- a/frontend/lib/registry/components/table-list/types.ts +++ b/frontend/lib/registry/components/table-list/types.ts @@ -170,6 +170,18 @@ export interface CheckboxConfig { selectAll: boolean; // 전체 선택/해제 버튼 표시 여부 } +/** + * 연결된 필터 설정 + * 다른 컴포넌트(셀렉트박스 등)의 값으로 테이블 데이터를 필터링 + */ +export interface LinkedFilterConfig { + sourceComponentId: string; // 소스 컴포넌트 ID (셀렉트박스 등) + sourceField?: string; // 소스 컴포넌트에서 가져올 필드명 (기본: value) + targetColumn: string; // 필터링할 테이블 컬럼명 + operator?: "equals" | "contains" | "in"; // 필터 연산자 (기본: equals) + enabled?: boolean; // 활성화 여부 (기본: true) +} + /** * TableList 컴포넌트 설정 타입 */ @@ -231,6 +243,9 @@ export interface TableListConfig extends ComponentConfig { // 🆕 컬럼 값 기반 데이터 필터링 dataFilter?: DataFilterConfig; + // 🆕 연결된 필터 (다른 컴포넌트 값으로 필터링) + linkedFilters?: LinkedFilterConfig[]; + // 이벤트 핸들러 onRowClick?: (row: any) => void; onRowDoubleClick?: (row: any) => void; diff --git a/frontend/lib/utils/dataMapping.ts b/frontend/lib/utils/dataMapping.ts index de7e2377..92aa2243 100644 --- a/frontend/lib/utils/dataMapping.ts +++ b/frontend/lib/utils/dataMapping.ts @@ -12,28 +12,56 @@ import { logger } from "./logger"; /** * 매핑 규칙 적용 + * @param data 배열 또는 단일 객체 + * @param rules 매핑 규칙 배열 + * @returns 매핑된 배열 */ -export function applyMappingRules(data: any[], rules: MappingRule[]): any[] { - if (!data || data.length === 0) { +export function applyMappingRules(data: any[] | any, rules: MappingRule[]): any[] { + // 빈 데이터 처리 + if (!data) { return []; } + + // 🆕 배열이 아닌 경우 배열로 변환 + const dataArray = Array.isArray(data) ? data : [data]; + + if (dataArray.length === 0) { + return []; + } + + // 규칙이 없으면 원본 데이터 반환 + if (!rules || rules.length === 0) { + return dataArray; + } // 변환 함수가 있는 규칙 확인 const hasTransform = rules.some((rule) => rule.transform && rule.transform !== "none"); if (hasTransform) { // 변환 함수가 있으면 단일 값 또는 집계 결과 반환 - return [applyTransformRules(data, rules)]; + return [applyTransformRules(dataArray, rules)]; } // 일반 매핑 (각 행에 대해 매핑) - return data.map((row) => { - const mappedRow: any = {}; + // 🆕 원본 데이터를 복사한 후 매핑 규칙 적용 (매핑되지 않은 필드도 유지) + return dataArray.map((row) => { + // 원본 데이터 복사 + const mappedRow: any = { ...row }; for (const rule of rules) { + // sourceField와 targetField가 모두 있어야 매핑 적용 + if (!rule.sourceField || !rule.targetField) { + continue; + } + const sourceValue = getNestedValue(row, rule.sourceField); const targetValue = sourceValue ?? rule.defaultValue; + // 소스 필드와 타겟 필드가 다르면 소스 필드 제거 후 타겟 필드에 설정 + if (rule.sourceField !== rule.targetField) { + delete mappedRow[rule.sourceField]; + } + setNestedValue(mappedRow, rule.targetField, targetValue); } diff --git a/frontend/types/repeater.ts b/frontend/types/repeater.ts index e67ebaeb..00e06b7f 100644 --- a/frontend/types/repeater.ts +++ b/frontend/types/repeater.ts @@ -2,7 +2,31 @@ * 반복 필드 그룹(Repeater) 타입 정의 */ -export type RepeaterFieldType = "text" | "number" | "email" | "tel" | "date" | "select" | "textarea"; +/** + * 테이블 타입 관리(table_type_columns)에서 사용하는 input_type 값들 + */ +export type RepeaterFieldType = + | "text" // 텍스트 + | "number" // 숫자 + | "textarea" // 텍스트영역 + | "date" // 날짜 + | "select" // 선택박스 + | "checkbox" // 체크박스 + | "radio" // 라디오 + | "category" // 카테고리 + | "entity" // 엔티티 참조 + | "code" // 공통코드 + | "image" // 이미지 + | "direct" // 직접입력 + | string; // 기타 커스텀 타입 허용 + +/** + * 필드 표시 모드 + * - input: 입력 필드로 표시 (편집 가능) + * - readonly: 읽기 전용 텍스트로 표시 + * - (카테고리 타입은 자동으로 배지로 표시됨) + */ +export type RepeaterFieldDisplayMode = "input" | "readonly"; /** * 반복 그룹 내 개별 필드 정의 @@ -13,8 +37,11 @@ export interface RepeaterFieldDefinition { type: RepeaterFieldType; // 입력 타입 placeholder?: string; required?: boolean; + readonly?: boolean; // 읽기 전용 여부 options?: Array<{ label: string; value: string }>; // select용 width?: string; // 필드 너비 (예: "200px", "50%") + displayMode?: RepeaterFieldDisplayMode; // 표시 모드: input(입력), readonly(읽기전용) + categoryCode?: string; // category 타입일 때 사용할 카테고리 코드 validation?: { minLength?: number; maxLength?: number;