diff --git a/backend-node/src/controllers/entityJoinController.ts b/backend-node/src/controllers/entityJoinController.ts index f4f89d25..15e05473 100644 --- a/backend-node/src/controllers/entityJoinController.ts +++ b/backend-node/src/controllers/entityJoinController.ts @@ -193,10 +193,11 @@ export class EntityJoinController { async getEntityJoinConfigs(req: Request, res: Response): Promise { try { const { tableName } = req.params; + const companyCode = (req as any).user?.companyCode; - logger.info(`Entity 조인 설정 조회: ${tableName}`); + logger.info(`Entity 조인 설정 조회: ${tableName} (companyCode: ${companyCode})`); - const joinConfigs = await entityJoinService.detectEntityJoins(tableName); + const joinConfigs = await entityJoinService.detectEntityJoins(tableName, undefined, companyCode); res.status(200).json({ success: true, @@ -224,11 +225,12 @@ export class EntityJoinController { async getReferenceTableColumns(req: Request, res: Response): Promise { try { const { tableName } = req.params; + const companyCode = (req as any).user?.companyCode; - logger.info(`참조 테이블 컬럼 조회: ${tableName}`); + logger.info(`참조 테이블 컬럼 조회: ${tableName} (companyCode: ${companyCode})`); const columns = - await tableManagementService.getReferenceTableColumns(tableName); + await tableManagementService.getReferenceTableColumns(tableName, companyCode); res.status(200).json({ success: true, @@ -408,11 +410,12 @@ export class EntityJoinController { async getEntityJoinColumns(req: Request, res: Response): Promise { try { const { tableName } = req.params; + const companyCode = (req as any).user?.companyCode; - logger.info(`Entity 조인 컬럼 조회: ${tableName}`); + logger.info(`Entity 조인 컬럼 조회: ${tableName} (companyCode: ${companyCode})`); // 1. 현재 테이블의 Entity 조인 설정 조회 - const allJoinConfigs = await entityJoinService.detectEntityJoins(tableName); + const allJoinConfigs = await entityJoinService.detectEntityJoins(tableName, undefined, companyCode); // 🆕 화면 디자이너용: table_column_category_values는 카테고리 드롭다운용이므로 제외 // 카테고리 값은 엔티티 조인 컬럼이 아니라 셀렉트박스 옵션으로 사용됨 @@ -439,7 +442,7 @@ export class EntityJoinController { try { const columns = await tableManagementService.getReferenceTableColumns( - config.referenceTable + config.referenceTable, companyCode ); // 현재 display_column 정보 (참고용으로만 사용, 필터링하지 않음) diff --git a/backend-node/src/routes/dataRoutes.ts b/backend-node/src/routes/dataRoutes.ts index 0abc6793..02dfc1e8 100644 --- a/backend-node/src/routes/dataRoutes.ts +++ b/backend-node/src/routes/dataRoutes.ts @@ -742,6 +742,7 @@ router.post( inserted: result.data?.inserted || 0, updated: result.data?.updated || 0, deleted: result.data?.deleted || 0, + savedIds: result.data?.savedIds || [], }); } catch (error) { console.error("그룹화된 데이터 UPSERT 오류:", error); diff --git a/backend-node/src/services/dataService.ts b/backend-node/src/services/dataService.ts index 8c837697..2150a4af 100644 --- a/backend-node/src/services/dataService.ts +++ b/backend-node/src/services/dataService.ts @@ -1519,11 +1519,12 @@ class DataService { } } - console.log(`✅ UPSERT 완료:`, { inserted, updated, deleted }); + const savedIds = Array.from(processedIds); + console.log(`✅ UPSERT 완료:`, { inserted, updated, deleted, savedIds }); return { success: true, - data: { inserted, updated, deleted }, + data: { inserted, updated, deleted, savedIds }, }; } catch (error) { console.error(`UPSERT 오류 (${tableName}):`, error); diff --git a/backend-node/src/services/entityJoinService.ts b/backend-node/src/services/entityJoinService.ts index 4441a636..13f757fd 100644 --- a/backend-node/src/services/entityJoinService.ts +++ b/backend-node/src/services/entityJoinService.ts @@ -16,16 +16,18 @@ export class EntityJoinService { * 테이블의 Entity 컬럼들을 감지하여 조인 설정 생성 * @param tableName 테이블명 * @param screenEntityConfigs 화면별 엔티티 설정 (선택사항) + * @param companyCode 회사코드 (회사별 설정 우선, 없으면 전체 조회) */ async detectEntityJoins( tableName: string, - screenEntityConfigs?: Record + screenEntityConfigs?: Record, + companyCode?: string ): Promise { try { - logger.info(`Entity 컬럼 감지 시작: ${tableName}`); + logger.info(`Entity 컬럼 감지 시작: ${tableName} (companyCode: ${companyCode || 'all'})`); // table_type_columns에서 entity 및 category 타입인 컬럼들 조회 - // company_code = '*' (공통 설정) 우선 조회 + // 회사코드가 있으면 해당 회사 + '*' 만 조회, 회사별 우선 const entityColumns = await query<{ column_name: string; input_type: string; @@ -33,14 +35,17 @@ export class EntityJoinService { reference_column: string; display_column: string | null; }>( - `SELECT column_name, input_type, reference_table, reference_column, display_column + `SELECT DISTINCT ON (column_name) + column_name, input_type, reference_table, reference_column, display_column FROM table_type_columns WHERE table_name = $1 AND input_type IN ('entity', 'category') - AND company_code = '*' AND reference_table IS NOT NULL - AND reference_table != ''`, - [tableName] + AND reference_table != '' + ${companyCode ? `AND company_code IN ($2, '*')` : ''} + ORDER BY column_name, + CASE WHEN company_code = '*' THEN 1 ELSE 0 END`, + companyCode ? [tableName, companyCode] : [tableName] ); logger.info(`🔍 Entity 컬럼 조회 결과: ${entityColumns.length}개 발견`); @@ -272,7 +277,8 @@ export class EntityJoinService { orderBy: string = "", limit?: number, offset?: number, - columnTypes?: Map // 컬럼명 → 데이터 타입 매핑 + columnTypes?: Map, // 컬럼명 → 데이터 타입 매핑 + referenceTableColumns?: Map // 🆕 참조 테이블별 전체 컬럼 목록 ): { query: string; aliasMap: Map } { try { // 기본 SELECT 컬럼들 (날짜는 YYYY-MM-DD 형식, 나머지는 TEXT 캐스팅) @@ -338,115 +344,100 @@ export class EntityJoinService { ); }); - // 🔧 _label 별칭 중복 방지를 위한 Set - // 같은 sourceColumn에서 여러 조인 설정이 있을 때 _label은 첫 번째만 생성 - const generatedLabelAliases = new Set(); + // 🔧 생성된 별칭 중복 방지를 위한 Set + const generatedAliases = new Set(); - const joinColumns = joinConfigs + const joinColumns = uniqueReferenceTableConfigs .map((config) => { const aliasKey = `${config.referenceTable}:${config.sourceColumn}`; const alias = aliasMap.get(aliasKey); - const displayColumns = config.displayColumns || [ - config.displayColumn, - ]; - const separator = config.separator || " - "; - - // 결과 컬럼 배열 (aliasColumn + _label 필드) const resultColumns: string[] = []; - if (displayColumns.length === 0 || !displayColumns[0]) { - // displayColumns가 빈 배열이거나 첫 번째 값이 null/undefined인 경우 - // 조인 테이블의 referenceColumn을 기본값으로 사용 - resultColumns.push( - `COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}` - ); - } else if (displayColumns.length === 1) { - // 단일 컬럼인 경우 - const col = displayColumns[0]; + // 🆕 참조 테이블의 전체 컬럼 목록이 있으면 모든 컬럼을 SELECT + const refTableCols = referenceTableColumns?.get( + `${config.referenceTable}:${config.sourceColumn}` + ) || referenceTableColumns?.get(config.referenceTable); - // ✅ 개선: referenceTable이 설정되어 있으면 조인 테이블에서 가져옴 - // 이렇게 하면 item_info.size, item_info.material 등 모든 조인 테이블 컬럼 지원 - const isJoinTableColumn = - config.referenceTable && config.referenceTable !== tableName; + if (refTableCols && refTableCols.length > 0) { + // 메타 컬럼은 제외 (메인 테이블과 중복되거나 불필요) + const skipColumns = new Set(["company_code", "created_date", "updated_date", "writer"]); + + for (const col of refTableCols) { + if (skipColumns.has(col)) continue; + + const colAlias = `${config.sourceColumn}_${col}`; + if (generatedAliases.has(colAlias)) continue; - if (isJoinTableColumn) { resultColumns.push( - `COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}` + `COALESCE(${alias}."${col}"::TEXT, '') AS "${colAlias}"` ); + generatedAliases.add(colAlias); + } - // _label 필드도 함께 SELECT (프론트엔드 getColumnUniqueValues용) - // sourceColumn_label 형식으로 추가 - // 🔧 중복 방지: 같은 sourceColumn에서 _label은 첫 번째만 생성 - const labelAlias = `${config.sourceColumn}_label`; - if (!generatedLabelAliases.has(labelAlias)) { - resultColumns.push( - `COALESCE(${alias}.${col}::TEXT, '') AS ${labelAlias}` - ); - generatedLabelAliases.add(labelAlias); - } - - // 🆕 referenceColumn (PK)도 항상 SELECT (parentDataMapping용) - // 예: customer_code, item_number 등 - // col과 동일해도 별도의 alias로 추가 (customer_code as customer_code) - // 🔧 중복 방지: referenceColumn도 한 번만 추가 - const refColAlias = config.referenceColumn; - if (!generatedLabelAliases.has(refColAlias)) { - resultColumns.push( - `COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${refColAlias}` - ); - generatedLabelAliases.add(refColAlias); - } - } else { + // _label 필드도 추가 (기존 호환성) + const labelAlias = `${config.sourceColumn}_label`; + if (!generatedAliases.has(labelAlias)) { + // 표시용 컬럼 자동 감지: *_name > name > label > referenceColumn + const nameCol = refTableCols.find((c) => c.endsWith("_name") && c !== "company_name"); + const displayCol = nameCol || refTableCols.find((c) => c === "name") || config.referenceColumn; resultColumns.push( - `COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}` + `COALESCE(${alias}."${displayCol}"::TEXT, '') AS "${labelAlias}"` ); + generatedAliases.add(labelAlias); } } else { - // 🆕 여러 컬럼인 경우 각 컬럼을 개별 alias로 반환 (합치지 않음) - // 예: item_info.standard_price → sourceColumn_standard_price (item_id_standard_price) - displayColumns.forEach((col) => { + // 🔄 기존 로직 (참조 테이블 컬럼 목록이 없는 경우 - fallback) + const displayColumns = config.displayColumns || [config.displayColumn]; + + if (displayColumns.length === 0 || !displayColumns[0]) { + resultColumns.push( + `COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}` + ); + } else if (displayColumns.length === 1) { + const col = displayColumns[0]; const isJoinTableColumn = config.referenceTable && config.referenceTable !== tableName; - const individualAlias = `${config.sourceColumn}_${col}`; - - // 🔧 중복 방지: 같은 alias가 이미 생성되었으면 스킵 - if (generatedLabelAliases.has(individualAlias)) { - return; - } - if (isJoinTableColumn) { - // 조인 테이블 컬럼은 조인 별칭 사용 resultColumns.push( - `COALESCE(${alias}.${col}::TEXT, '') AS ${individualAlias}` + `COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}` ); + const labelAlias = `${config.sourceColumn}_label`; + if (!generatedAliases.has(labelAlias)) { + resultColumns.push( + `COALESCE(${alias}.${col}::TEXT, '') AS ${labelAlias}` + ); + generatedAliases.add(labelAlias); + } } else { - // 기본 테이블 컬럼은 main 별칭 사용 resultColumns.push( - `COALESCE(main.${col}::TEXT, '') AS ${individualAlias}` + `COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}` ); } - generatedLabelAliases.add(individualAlias); - }); + } else { + displayColumns.forEach((col) => { + const isJoinTableColumn = + config.referenceTable && config.referenceTable !== tableName; + const individualAlias = `${config.sourceColumn}_${col}`; + if (generatedAliases.has(individualAlias)) return; - // 🆕 referenceColumn (PK)도 함께 SELECT (parentDataMapping용) - const isJoinTableColumn = - config.referenceTable && config.referenceTable !== tableName; - if ( - isJoinTableColumn && - !displayColumns.includes(config.referenceColumn) && - !generatedLabelAliases.has(config.referenceColumn) // 🔧 중복 방지 - ) { - resultColumns.push( - `COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.referenceColumn}` - ); - generatedLabelAliases.add(config.referenceColumn); + if (isJoinTableColumn) { + resultColumns.push( + `COALESCE(${alias}.${col}::TEXT, '') AS ${individualAlias}` + ); + } else { + resultColumns.push( + `COALESCE(main.${col}::TEXT, '') AS ${individualAlias}` + ); + } + generatedAliases.add(individualAlias); + }); } } - // 모든 resultColumns를 반환 return resultColumns.join(", "); }) + .filter(Boolean) .join(", "); // SELECT 절 구성 @@ -725,7 +716,7 @@ export class EntityJoinService { /** * 참조 테이블의 컬럼 목록 조회 (UI용) */ - async getReferenceTableColumns(tableName: string): Promise< + async getReferenceTableColumns(tableName: string, companyCode?: string): Promise< Array<{ columnName: string; displayName: string; @@ -750,16 +741,19 @@ export class EntityJoinService { ); // 2. table_type_columns 테이블에서 라벨과 input_type 정보 조회 + // 회사코드가 있으면 해당 회사 + '*' 만, 회사별 우선 const columnLabels = await query<{ column_name: string; column_label: string | null; input_type: string | null; }>( - `SELECT column_name, column_label, input_type + `SELECT DISTINCT ON (column_name) column_name, column_label, input_type FROM table_type_columns WHERE table_name = $1 - AND company_code = '*'`, - [tableName] + ${companyCode ? `AND company_code IN ($2, '*')` : ''} + ORDER BY column_name, + CASE WHEN company_code = '*' THEN 1 ELSE 0 END`, + companyCode ? [tableName, companyCode] : [tableName] ); // 3. 라벨 및 inputType 정보를 맵으로 변환 diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 7dc3b2a6..77476917 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -5379,9 +5379,11 @@ export class ScreenManagementService { [screenId], ); } else { - // 일반 회사: 자사 Zone만 조회 (company_code = '*' 제외) + // 일반 회사: 자사 Zone + 공통(*) Zone 조회 zones = await query( - `SELECT * FROM screen_conditional_zones WHERE screen_id = $1 AND company_code = $2 ORDER BY zone_id`, + `SELECT * FROM screen_conditional_zones + WHERE screen_id = $1 AND (company_code = $2 OR company_code = '*') + ORDER BY zone_id`, [screenId, companyCode], ); } diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index db5f32ed..27f713fc 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -2875,10 +2875,11 @@ export class TableManagementService { }; } - // Entity 조인 설정 감지 (화면별 엔티티 설정 전달) + // Entity 조인 설정 감지 (화면별 엔티티 설정 + 회사코드 전달) let joinConfigs = await entityJoinService.detectEntityJoins( tableName, - options.screenEntityConfigs + options.screenEntityConfigs, + options.companyCode ); logger.info( @@ -3258,6 +3259,28 @@ export class TableManagementService { startTime: number ): Promise { try { + // 🆕 참조 테이블별 전체 컬럼 목록 미리 조회 + const referenceTableColumns = new Map(); + const uniqueRefTables = new Set( + joinConfigs + .filter((c) => c.referenceTable !== "table_column_category_values") // 카테고리는 제외 + .map((c) => `${c.referenceTable}:${c.sourceColumn}`) + ); + + for (const key of uniqueRefTables) { + const refTable = key.split(":")[0]; + if (!referenceTableColumns.has(key)) { + const cols = await query<{ column_name: string }>( + `SELECT column_name FROM information_schema.columns + WHERE table_name = $1 AND table_schema = 'public' + ORDER BY ordinal_position`, + [refTable] + ); + referenceTableColumns.set(key, cols.map((c) => c.column_name)); + logger.info(`🔍 참조 테이블 컬럼 조회: ${refTable} → ${cols.length}개`); + } + } + // 데이터 조회 쿼리 const dataQuery = entityJoinService.buildJoinQuery( tableName, @@ -3266,7 +3289,9 @@ export class TableManagementService { whereClause, orderBy, limit, - offset + offset, + undefined, + referenceTableColumns // 🆕 참조 테이블 전체 컬럼 전달 ).query; // 카운트 쿼리 @@ -3767,12 +3792,12 @@ export class TableManagementService { reference_table: string; reference_column: string; }>( - `SELECT column_name, reference_table, reference_column + `SELECT DISTINCT ON (column_name) column_name, reference_table, reference_column FROM table_type_columns WHERE table_name = $1 AND input_type = 'entity' AND reference_table = $2 - AND company_code = '*' + ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END LIMIT 1`, [tableName, refTable] ); @@ -3883,7 +3908,7 @@ export class TableManagementService { /** * 참조 테이블의 표시 컬럼 목록 조회 */ - async getReferenceTableColumns(tableName: string): Promise< + async getReferenceTableColumns(tableName: string, companyCode?: string): Promise< Array<{ columnName: string; displayName: string; @@ -3891,7 +3916,7 @@ export class TableManagementService { inputType?: string; }> > { - return await entityJoinService.getReferenceTableColumns(tableName); + return await entityJoinService.getReferenceTableColumns(tableName, companyCode); } /** @@ -5005,14 +5030,14 @@ export class TableManagementService { input_type: string; display_column: string | null; }>( - `SELECT column_name, reference_column, input_type, display_column + `SELECT DISTINCT ON (column_name) column_name, reference_column, input_type, display_column FROM table_type_columns WHERE table_name = $1 AND input_type IN ('entity', 'category') AND reference_table = $2 AND reference_column IS NOT NULL AND reference_column != '' - AND company_code = '*'`, + ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`, [rightTable, leftTable] ); @@ -5034,14 +5059,14 @@ export class TableManagementService { input_type: string; display_column: string | null; }>( - `SELECT column_name, reference_column, input_type, display_column + `SELECT DISTINCT ON (column_name) column_name, reference_column, input_type, display_column FROM table_type_columns WHERE table_name = $1 AND input_type IN ('entity', 'category') AND reference_table = $2 AND reference_column IS NOT NULL AND reference_column != '' - AND company_code = '*'`, + ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`, [leftTable, rightTable] ); diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 92904e73..95305aaf 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -294,6 +294,16 @@ function ScreenViewPage() { console.log("🔄 조건부 레이어 로드 완료:", layerDefinitions.length, "개", layerDefinitions.map(l => ({ id: l.id, name: l.name, zoneId: l.zoneId, conditionValue: l.conditionValue, componentCount: l.components.length, + condition: l.condition ? { + targetComponentId: l.condition.targetComponentId, + operator: l.condition.operator, + value: l.condition.value, + } : "없음", + }))); + console.log("🗺️ Zone 정보:", loadedZones.map(z => ({ + zone_id: z.zone_id, + trigger_component_id: z.trigger_component_id, + trigger_operator: z.trigger_operator, }))); setConditionalLayers(layerDefinitions); } catch (error) { @@ -315,6 +325,9 @@ function ScreenViewPage() { if (layer.condition) { const { targetComponentId, operator, value } = layer.condition; + // 빈 targetComponentId는 무시 + if (!targetComponentId) return; + // 트리거 컴포넌트 찾기 (기본 레이어에서) const targetComponent = allComponents.find((c) => c.id === targetComponentId); @@ -329,16 +342,36 @@ function ScreenViewPage() { let isMatch = false; switch (operator) { case "eq": - isMatch = targetValue == value; + // 문자열로 변환하여 비교 (타입 불일치 방지) + isMatch = String(targetValue ?? "") === String(value ?? ""); break; case "neq": - isMatch = targetValue != value; + isMatch = String(targetValue ?? "") !== String(value ?? ""); break; case "in": - isMatch = Array.isArray(value) && value.includes(targetValue); + if (Array.isArray(value)) { + isMatch = value.some(v => String(v) === String(targetValue ?? "")); + } else if (typeof value === "string" && value.includes(",")) { + // 쉼표로 구분된 문자열도 지원 + isMatch = value.split(",").map(v => v.trim()).includes(String(targetValue ?? "")); + } break; } + // 디버그 로깅 (값이 존재할 때만) + if (targetValue !== undefined && targetValue !== "") { + console.log("🔍 [레이어 조건 평가]", { + layerId: layer.id, + layerName: layer.name, + targetComponentId, + fieldKey, + targetValue: String(targetValue), + conditionValue: String(value), + operator, + isMatch, + }); + } + if (isMatch) { newActiveIds.push(layer.id); } diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index c2d8bcbc..227eae70 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useRef, useCallback } from "react"; +import React, { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; import { AlertDialog, @@ -24,6 +24,7 @@ import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHei import { useSplitPanelContext } from "@/contexts/SplitPanelContext"; import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; +import { ConditionalZone, LayerDefinition } from "@/types/screen-management"; interface ScreenModalState { isOpen: boolean; @@ -71,6 +72,9 @@ export const ScreenModal: React.FC = ({ className }) => { // 🆕 선택된 데이터 상태 (RepeatScreenModal 등에서 사용) const [selectedData, setSelectedData] = useState[]>([]); + // 🆕 조건부 레이어 상태 (Zone 기반) + const [conditionalLayers, setConditionalLayers] = useState<(LayerDefinition & { components: ComponentData[]; zone?: ConditionalZone })[]>([]); + // 연속 등록 모드 상태 (state로 변경 - 체크박스 UI 업데이트를 위해) const [continuousMode, setContinuousMode] = useState(false); @@ -80,6 +84,9 @@ export const ScreenModal: React.FC = ({ className }) => { // 모달 닫기 확인 다이얼로그 표시 상태 const [showCloseConfirm, setShowCloseConfirm] = useState(false); + // 사용자가 폼 데이터를 실제로 변경했는지 추적 (변경 없으면 경고 없이 바로 닫기) + const formDataChangedRef = useRef(false); + // localStorage에서 연속 모드 상태 복원 useEffect(() => { const savedMode = localStorage.getItem("screenModal_continuousMode"); @@ -122,9 +129,9 @@ export const ScreenModal: React.FC = ({ className }) => { const contentWidth = maxX - minX; const contentHeight = maxY - minY; - // 적절한 여백 추가 - const paddingX = 40; - const paddingY = 40; + // 여백 없이 컨텐츠 크기 그대로 사용 + const paddingX = 0; + const paddingY = 0; const finalWidth = Math.max(contentWidth + paddingX, 400); const finalHeight = Math.max(contentHeight + paddingY, 300); @@ -132,8 +139,8 @@ export const ScreenModal: React.FC = ({ className }) => { return { width: Math.min(finalWidth, window.innerWidth * 0.95), height: Math.min(finalHeight, window.innerHeight * 0.9), - offsetX: Math.max(0, minX - paddingX / 2), // 좌측 여백 고려 - offsetY: Math.max(0, minY - paddingY / 2), // 상단 여백 고려 + offsetX: Math.max(0, minX), // 여백 없이 컨텐츠 시작점 기준 + offsetY: Math.max(0, minY), // 여백 없이 컨텐츠 시작점 기준 }; }; @@ -178,6 +185,9 @@ export const ScreenModal: React.FC = ({ className }) => { // 🆕 모달 열린 시간 기록 modalOpenedAtRef.current = Date.now(); + // 폼 변경 추적 초기화 + formDataChangedRef.current = false; + // 🆕 선택된 데이터 저장 (RepeatScreenModal 등에서 사용) if (eventSelectedData && Array.isArray(eventSelectedData)) { setSelectedData(eventSelectedData); @@ -397,6 +407,7 @@ export const ScreenModal: React.FC = ({ className }) => { if (isContinuousMode) { // 연속 모드: 폼만 초기화하고 모달은 유지 + formDataChangedRef.current = false; setFormData({}); setResetKey((prev) => prev + 1); @@ -563,6 +574,9 @@ export const ScreenModal: React.FC = ({ className }) => { components, screenInfo: screenInfo, }); + + // 🆕 조건부 레이어/존 로드 + loadConditionalLayersAndZones(screenId); } else { throw new Error("화면 데이터가 없습니다"); } @@ -575,9 +589,155 @@ export const ScreenModal: React.FC = ({ className }) => { } }; - // 사용자가 바깥 클릭/ESC/X 버튼으로 닫으려 할 때 확인 다이얼로그 표시 + // 🆕 조건부 레이어 & 존 로드 함수 + const loadConditionalLayersAndZones = async (screenId: number) => { + try { + const [layersRes, zonesRes] = await Promise.all([ + screenApi.getScreenLayers(screenId), + screenApi.getScreenZones(screenId), + ]); + + const loadedLayers = layersRes || []; + const loadedZones: ConditionalZone[] = zonesRes || []; + + // 기본 레이어(layer_id=1) 제외 + const nonBaseLayers = loadedLayers.filter((l: any) => l.layer_id !== 1); + + if (nonBaseLayers.length === 0) { + setConditionalLayers([]); + return; + } + + const layerDefs: (LayerDefinition & { components: ComponentData[] })[] = []; + + for (const layer of nonBaseLayers) { + try { + const layerLayout = await screenApi.getLayerLayout(screenId, layer.layer_id); + + let layerComponents: ComponentData[] = []; + if (layerLayout && isValidV2Layout(layerLayout)) { + const legacyLayout = convertV2ToLegacy(layerLayout); + layerComponents = (legacyLayout?.components || []) as unknown as ComponentData[]; + } else if (layerLayout?.components) { + layerComponents = layerLayout.components; + } + + // condition_config에서 zone_id, condition_value 추출 + const cc = layer.condition_config || {}; + const zone = loadedZones.find((z) => z.zone_id === cc.zone_id); + + layerDefs.push({ + id: `layer-${layer.layer_id}`, + name: layer.layer_name || `레이어 ${layer.layer_id}`, + type: "conditional", + zIndex: layer.layer_id, + isVisible: false, + isLocked: false, + zoneId: cc.zone_id, + conditionValue: cc.condition_value, + condition: zone + ? { + targetComponentId: zone.trigger_component_id || "", + operator: (zone.trigger_operator || "eq") as any, + value: cc.condition_value || "", + } + : undefined, + components: layerComponents, + zone: zone || undefined, // 🆕 Zone 위치 정보 포함 (오프셋 계산용) + } as any); + } catch (err) { + console.warn(`[ScreenModal] 레이어 ${layer.layer_id} 로드 실패:`, err); + } + } + + console.log("[ScreenModal] 조건부 레이어 로드 완료:", layerDefs.length, "개", + layerDefs.map((l) => ({ + id: l.id, name: l.name, conditionValue: l.conditionValue, + componentCount: l.components.length, + condition: l.condition, + })) + ); + + setConditionalLayers(layerDefs); + } catch (error) { + console.error("[ScreenModal] 조건부 레이어 로드 실패:", error); + } + }; + + // 🆕 조건부 레이어 활성화 평가 (formData 변경 시) + const activeConditionalComponents = useMemo(() => { + if (conditionalLayers.length === 0) return []; + + const allComponents = screenData?.components || []; + const activeComps: ComponentData[] = []; + + conditionalLayers.forEach((layer) => { + if (!layer.condition) return; + const { targetComponentId, operator, value } = layer.condition; + if (!targetComponentId) return; + + // V2 레이아웃: overrides.columnName 우선 + const comp = allComponents.find((c: any) => c.id === targetComponentId); + const fieldKey = + (comp as any)?.overrides?.columnName || + (comp as any)?.columnName || + (comp as any)?.componentConfig?.columnName || + targetComponentId; + + const targetValue = formData[fieldKey]; + + let isMatch = false; + switch (operator) { + case "eq": + isMatch = String(targetValue ?? "") === String(value ?? ""); + break; + case "neq": + isMatch = String(targetValue ?? "") !== String(value ?? ""); + break; + case "in": + if (Array.isArray(value)) { + isMatch = value.some((v) => String(v) === String(targetValue ?? "")); + } else if (typeof value === "string" && value.includes(",")) { + isMatch = value.split(",").map((v) => v.trim()).includes(String(targetValue ?? "")); + } + break; + } + + console.log("[ScreenModal] 레이어 조건 평가:", { + layerName: layer.name, fieldKey, + targetValue: String(targetValue ?? "(없음)"), + conditionValue: String(value), operator, isMatch, + }); + + if (isMatch) { + // Zone 오프셋 적용 (레이어 2 컴포넌트는 Zone 상대 좌표로 저장됨) + const zoneX = layer.zone?.x || 0; + const zoneY = layer.zone?.y || 0; + + const offsetComponents = layer.components.map((c: any) => ({ + ...c, + position: { + ...c.position, + x: parseFloat(c.position?.x?.toString() || "0") + zoneX, + y: parseFloat(c.position?.y?.toString() || "0") + zoneY, + }, + })); + + activeComps.push(...offsetComponents); + } + }); + + return activeComps; + }, [formData, conditionalLayers, screenData?.components]); + + // 사용자가 바깥 클릭/ESC/X 버튼으로 닫으려 할 때 + // 폼 데이터 변경이 있으면 확인 다이얼로그, 없으면 바로 닫기 const handleCloseAttempt = useCallback(() => { - setShowCloseConfirm(true); + if (formDataChangedRef.current) { + setShowCloseConfirm(true); + } else { + handleCloseInternal(); + } }, []); // 확인 후 실제로 모달을 닫는 함수 @@ -613,6 +773,7 @@ export const ScreenModal: React.FC = ({ className }) => { setFormData({}); // 폼 데이터 초기화 setOriginalData(null); // 원본 데이터 초기화 setSelectedData([]); // 선택된 데이터 초기화 + setConditionalLayers([]); // 🆕 조건부 레이어 초기화 setContinuousMode(false); localStorage.setItem("screenModal_continuousMode", "false"); }; @@ -624,36 +785,21 @@ export const ScreenModal: React.FC = ({ className }) => { const getModalStyle = () => { if (!screenDimensions) { return { - className: "w-fit min-w-[400px] max-w-4xl max-h-[90vh] overflow-hidden p-0", - style: undefined, // undefined로 변경 - defaultWidth/defaultHeight 사용 - needsScroll: false, + className: "w-fit min-w-[400px] max-w-4xl overflow-hidden", + style: { padding: 0, gap: 0, maxHeight: "calc(100dvh - 8px)" }, }; } - // 화면관리에서 설정한 크기 = 컨텐츠 영역 크기 - // 실제 모달 크기 = 컨텐츠 + 헤더 + 연속등록 체크박스 + gap + 패딩 - // 🔧 DialogContent의 gap-4 (16px × 2) + 컨텐츠 pt-6 (24px) 포함 - const headerHeight = 48; // DialogHeader (타이틀 + border-b + py-3) - const footerHeight = 44; // 연속 등록 모드 체크박스 영역 - const dialogGap = 32; // gap-4 × 2 (header-content, content-footer 사이) - const contentTopPadding = 24; // pt-6 (컨텐츠 영역 상단 패딩) - const horizontalPadding = 16; // 좌우 패딩 최소화 - - const totalHeight = screenDimensions.height + headerHeight + footerHeight + dialogGap + contentTopPadding; - const maxAvailableHeight = window.innerHeight * 0.95; - - // 콘텐츠가 화면 높이를 초과할 때만 스크롤 필요 - const needsScroll = totalHeight > maxAvailableHeight; - return { - className: "overflow-hidden p-0", + className: "overflow-hidden", style: { - width: `${Math.min(screenDimensions.width + horizontalPadding, window.innerWidth * 0.98)}px`, - // 🔧 height 대신 max-height만 설정 - 콘텐츠가 작으면 자동으로 줄어듦 - maxHeight: `${maxAvailableHeight}px`, + width: `${Math.min(screenDimensions.width, window.innerWidth * 0.98)}px`, + // CSS가 알아서 처리: 뷰포트 안에 들어가면 auto-height, 넘치면 max-height로 제한 + maxHeight: "calc(100dvh - 8px)", maxWidth: "98vw", + padding: 0, + gap: 0, }, - needsScroll, }; }; @@ -730,7 +876,7 @@ export const ScreenModal: React.FC = ({ className }) => { > { e.preventDefault(); @@ -755,7 +901,7 @@ export const ScreenModal: React.FC = ({ className }) => {
{loading ? (
@@ -771,8 +917,22 @@ export const ScreenModal: React.FC = ({ className }) => { className="relative bg-white" style={{ width: `${screenDimensions?.width || 800}px`, - height: `${screenDimensions?.height || 600}px`, - // 🆕 라벨이 위로 튀어나갈 수 있도록 overflow visible 설정 + // 🆕 조건부 레이어 활성화 시 높이 자동 확장 + minHeight: `${screenDimensions?.height || 600}px`, + height: (() => { + const baseHeight = screenDimensions?.height || 600; + if (activeConditionalComponents.length > 0) { + const offsetY = screenDimensions?.offsetY || 0; + let maxBottom = 0; + activeConditionalComponents.forEach((comp: any) => { + const y = parseFloat(comp.position?.y?.toString() || "0") - offsetY; + const h = parseFloat(comp.size?.height?.toString() || "40"); + maxBottom = Math.max(maxBottom, y + h); + }); + return `${Math.max(baseHeight, maxBottom + 20)}px`; + } + return `${baseHeight}px`; + })(), overflow: "visible", }} > @@ -908,6 +1068,8 @@ export const ScreenModal: React.FC = ({ className }) => { formData={formData} originalData={originalData} // 🆕 원본 데이터 전달 (UPDATE 판단용) onFormDataChange={(fieldName, value) => { + // 사용자가 실제로 데이터를 변경한 것으로 표시 + formDataChangedRef.current = true; setFormData((prev) => { const newFormData = { ...prev, @@ -932,6 +1094,48 @@ export const ScreenModal: React.FC = ({ className }) => { ); }); })()} + + {/* 🆕 조건부 레이어 컴포넌트 렌더링 */} + {activeConditionalComponents.map((component: any) => { + const offsetX = screenDimensions?.offsetX || 0; + const offsetY = screenDimensions?.offsetY || 0; + + const adjustedComponent = { + ...component, + position: { + ...component.position, + x: parseFloat(component.position?.x?.toString() || "0") - offsetX, + y: parseFloat(component.position?.y?.toString() || "0") - offsetY, + }, + }; + + return ( + { + formDataChangedRef.current = true; + setFormData((prev) => ({ + ...prev, + [fieldName]: value, + })); + }} + onRefresh={() => { + window.dispatchEvent(new CustomEvent("refreshTable")); + }} + screenInfo={{ + id: modalState.screenId!, + tableName: screenData?.screenInfo?.tableName, + }} + userId={userId} + userName={userName} + companyCode={user?.companyCode} + /> + ); + })}
diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index b95ee973..38b6da5a 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo } from "react"; import { Dialog, DialogContent, @@ -15,6 +15,8 @@ import { ComponentData } from "@/types/screen"; import { toast } from "sonner"; import { dynamicFormApi } from "@/lib/api/dynamicForm"; import { useAuth } from "@/hooks/useAuth"; +import { ConditionalZone, LayerDefinition } from "@/types/screen-management"; +import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; interface EditModalState { isOpen: boolean; @@ -116,6 +118,10 @@ export const EditModal: React.FC = ({ className }) => { const [groupData, setGroupData] = useState[]>([]); const [originalGroupData, setOriginalGroupData] = useState[]>([]); + // 🆕 조건부 레이어 상태 (Zone 기반) + const [zones, setZones] = useState([]); + const [conditionalLayers, setConditionalLayers] = useState([]); + // 화면의 실제 크기 계산 함수 (ScreenModal과 동일) const calculateScreenDimensions = (components: ComponentData[]) => { if (components.length === 0) { @@ -360,16 +366,12 @@ export const EditModal: React.FC = ({ className }) => { try { setLoading(true); - // console.log("화면 데이터 로딩 시작:", screenId); - // 화면 정보와 레이아웃 데이터 로딩 const [screenInfo, layoutData] = await Promise.all([ screenApi.getScreen(screenId), screenApi.getLayout(screenId), ]); - // console.log("API 응답:", { screenInfo, layoutData }); - if (screenInfo && layoutData) { const components = layoutData.components || []; @@ -381,11 +383,14 @@ export const EditModal: React.FC = ({ className }) => { components, screenInfo: screenInfo, }); - // console.log("화면 데이터 설정 완료:", { - // componentsCount: components.length, - // dimensions, - // screenInfo, - // }); + + // 🆕 조건부 레이어/존 로드 (await으로 에러 포착) + console.log("[EditModal] 화면 데이터 로드 완료, 조건부 레이어 로드 시작:", screenId); + try { + await loadConditionalLayersAndZones(screenId, components); + } catch (layerErr) { + console.error("[EditModal] 조건부 레이어 로드 에러:", layerErr); + } } else { throw new Error("화면 데이터가 없습니다"); } @@ -398,6 +403,165 @@ export const EditModal: React.FC = ({ className }) => { } }; + // 🆕 조건부 레이어 & 존 로드 함수 + const loadConditionalLayersAndZones = async (screenId: number, baseComponents: ComponentData[]) => { + console.log("[EditModal] loadConditionalLayersAndZones 호출됨:", screenId); + try { + // 레이어 목록 & 존 목록 병렬 로드 + console.log("[EditModal] API 호출 시작: getScreenLayers, getScreenZones"); + const [layersRes, zonesRes] = await Promise.all([ + screenApi.getScreenLayers(screenId), + screenApi.getScreenZones(screenId), + ]); + console.log("[EditModal] API 응답:", { layers: layersRes?.length, zones: zonesRes?.length }); + + const loadedLayers = layersRes || []; + const loadedZones: ConditionalZone[] = zonesRes || []; + + setZones(loadedZones); + + // 기본 레이어(layer_id=1) 제외한 조건부 레이어 처리 + const nonBaseLayers = loadedLayers.filter((l: any) => l.layer_id !== 1); + + if (nonBaseLayers.length === 0) { + setConditionalLayers([]); + return; + } + + // 각 조건부 레이어의 컴포넌트 로드 + const layerDefinitions: LayerDefinition[] = []; + + for (const layer of nonBaseLayers) { + try { + const layerLayout = await screenApi.getLayerLayout(screenId, layer.layer_id); + + let layerComponents: ComponentData[] = []; + if (layerLayout && isValidV2Layout(layerLayout)) { + const legacyLayout = convertV2ToLegacy(layerLayout); + layerComponents = (legacyLayout?.components || []) as unknown as ComponentData[]; + } else if (layerLayout?.components) { + layerComponents = layerLayout.components; + } + + // condition_config에서 zone_id, condition_value 추출 + const conditionConfig = layer.condition_config || {}; + const layerZoneId = conditionConfig.zone_id; + const layerConditionValue = conditionConfig.condition_value; + + // 이 레이어가 속한 Zone 찾기 + const associatedZone = loadedZones.find( + (z) => z.zone_id === layerZoneId + ); + + layerDefinitions.push({ + id: `layer-${layer.layer_id}`, + name: layer.layer_name || `레이어 ${layer.layer_id}`, + type: "conditional", + zIndex: layer.layer_id, + isVisible: false, + isLocked: false, + zoneId: layerZoneId, + conditionValue: layerConditionValue, + condition: associatedZone + ? { + targetComponentId: associatedZone.trigger_component_id || "", + operator: (associatedZone.trigger_operator || "eq") as any, + value: layerConditionValue || "", + } + : undefined, + components: layerComponents, + } as LayerDefinition & { components: ComponentData[] }); + } catch (layerError) { + console.warn(`[EditModal] 레이어 ${layer.layer_id} 로드 실패:`, layerError); + } + } + + console.log("[EditModal] 조건부 레이어 로드 완료:", layerDefinitions.length, "개", + layerDefinitions.map((l) => ({ + id: l.id, + name: l.name, + conditionValue: l.conditionValue, + condition: l.condition, + })) + ); + + setConditionalLayers(layerDefinitions); + } catch (error) { + console.warn("[EditModal] 조건부 레이어 로드 실패:", error); + } + }; + + // 🆕 조건부 레이어 활성화 평가 (formData 변경 시) + const activeConditionalLayerIds = useMemo(() => { + if (conditionalLayers.length === 0) return []; + + const newActiveIds: string[] = []; + const allComponents = screenData?.components || []; + + conditionalLayers.forEach((layer) => { + const layerWithComponents = layer as LayerDefinition & { components: ComponentData[] }; + if (layerWithComponents.condition) { + const { targetComponentId, operator, value } = layerWithComponents.condition; + if (!targetComponentId) return; + + // 트리거 컴포넌트의 columnName 찾기 + // V2 레이아웃: overrides.columnName, 레거시: componentConfig.columnName + const targetComponent = allComponents.find((c) => c.id === targetComponentId); + const fieldKey = + (targetComponent as any)?.overrides?.columnName || + (targetComponent as any)?.columnName || + (targetComponent as any)?.componentConfig?.columnName || + targetComponentId; + + const currentFormData = groupData.length > 0 ? groupData[0] : formData; + const targetValue = currentFormData[fieldKey]; + + let isMatch = false; + switch (operator) { + case "eq": + isMatch = String(targetValue ?? "") === String(value ?? ""); + break; + case "neq": + isMatch = String(targetValue ?? "") !== String(value ?? ""); + break; + case "in": + if (Array.isArray(value)) { + isMatch = value.some((v) => String(v) === String(targetValue ?? "")); + } else if (typeof value === "string" && value.includes(",")) { + isMatch = value.split(",").map((v) => v.trim()).includes(String(targetValue ?? "")); + } + break; + } + + // 디버그 로깅 + console.log("[EditModal] 레이어 조건 평가:", { + layerId: layer.id, + layerName: layer.name, + targetComponentId, + fieldKey, + targetValue: targetValue !== undefined ? String(targetValue) : "(없음)", + conditionValue: String(value), + operator, + isMatch, + componentFound: !!targetComponent, + }); + + if (isMatch) { + newActiveIds.push(layer.id); + } + } + }); + + return newActiveIds; + }, [formData, groupData, conditionalLayers, screenData?.components]); + + // 🆕 활성화된 조건부 레이어의 컴포넌트 가져오기 + const activeConditionalComponents = useMemo(() => { + return conditionalLayers + .filter((layer) => activeConditionalLayerIds.includes(layer.id)) + .flatMap((layer) => (layer as LayerDefinition & { components: ComponentData[] }).components || []); + }, [conditionalLayers, activeConditionalLayerIds]); + const handleClose = () => { setModalState({ isOpen: false, @@ -412,6 +576,8 @@ export const EditModal: React.FC = ({ className }) => { }); setScreenData(null); setFormData({}); + setZones([]); + setConditionalLayers([]); setOriginalData({}); setGroupData([]); // 🆕 setOriginalGroupData([]); // 🆕 @@ -1151,12 +1317,27 @@ export const EditModal: React.FC = ({ className }) => { className="relative bg-white" style={{ width: screenDimensions?.width || 800, - height: (screenDimensions?.height || 600) + 30, // 라벨 공간 추가 + // 🆕 조건부 레이어가 활성화되면 높이 자동 확장 + height: (() => { + const baseHeight = (screenDimensions?.height || 600) + 30; + if (activeConditionalComponents.length > 0) { + // 조건부 레이어 컴포넌트 중 가장 아래 위치 계산 + const offsetY = screenDimensions?.offsetY || 0; + let maxBottom = 0; + activeConditionalComponents.forEach((comp) => { + const y = parseFloat(comp.position?.y?.toString() || "0") - offsetY + 30; + const h = parseFloat(comp.size?.height?.toString() || "40"); + maxBottom = Math.max(maxBottom, y + h); + }); + return Math.max(baseHeight, maxBottom + 20); // 20px 여백 + } + return baseHeight; + })(), transformOrigin: "center center", maxWidth: "100%", - maxHeight: "100%", }} > + {/* 기본 레이어 컴포넌트 렌더링 */} {screenData.components.map((component) => { // 컴포넌트 위치를 offset만큼 조정 const offsetX = screenDimensions?.offsetX || 0; @@ -1174,49 +1355,37 @@ export const EditModal: React.FC = ({ className }) => { const groupedDataProp = groupData.length > 0 ? groupData : undefined; - // 🆕 UniversalFormModal이 있는지 확인 (자체 저장 로직 사용) - // 최상위 컴포넌트에 universal-form-modal이 있는지 확인 - // ⚠️ 수정: conditional-container는 제외 (groupData가 있으면 EditModal.handleSave 사용) const hasUniversalFormModal = screenData.components.some( (c) => { - // 최상위에 universal-form-modal이 있는 경우만 자체 저장 로직 사용 if (c.componentType === "universal-form-modal") return true; return false; } ); - // 🆕 _tableSection_ 데이터가 있는지 확인 (TableSectionRenderer 사용 시) - // _tableSection_ 데이터가 있으면 buttonActions.ts의 handleUniversalFormModalTableSectionSave가 처리 const hasTableSectionData = Object.keys(formData).some(k => k.startsWith("_tableSection_") || k.startsWith("__tableSection_") ); - // 🆕 그룹 데이터가 있으면 EditModal.handleSave 사용 (일괄 저장) - // 단, _tableSection_ 데이터가 있으면 EditModal.handleSave 사용하지 않음 (buttonActions.ts가 처리) const shouldUseEditModalSave = !hasTableSectionData && (groupData.length > 0 || !hasUniversalFormModal); - // 🔑 첨부파일 컴포넌트가 행(레코드) 단위로 파일을 저장할 수 있도록 tableName 추가 const enrichedFormData = { ...(groupData.length > 0 ? groupData[0] : formData), - tableName: screenData.screenInfo?.tableName, // 테이블명 추가 - screenId: modalState.screenId, // 화면 ID 추가 + tableName: screenData.screenInfo?.tableName, + screenId: modalState.screenId, }; return ( { - // 🆕 그룹 데이터가 있으면 처리 if (groupData.length > 0) { - // ModalRepeaterTable의 경우 배열 전체를 받음 if (Array.isArray(value)) { setGroupData(value); } else { - // 일반 필드는 모든 항목에 동일하게 적용 setGroupData((prev) => prev.map((item) => ({ ...item, @@ -1235,19 +1404,74 @@ export const EditModal: React.FC = ({ className }) => { id: modalState.screenId!, tableName: screenData.screenInfo?.tableName, }} - // 🆕 메뉴 OBJID 전달 (카테고리 스코프용) menuObjid={modalState.menuObjid} - // 🆕 그룹 데이터가 있거나 UniversalFormModal이 없으면 EditModal.handleSave 사용 - // groupData가 있으면 일괄 저장을 위해 반드시 EditModal.handleSave 사용 onSave={shouldUseEditModalSave ? handleSave : undefined} isInModal={true} - // 🆕 그룹 데이터를 ModalRepeaterTable에 전달 groupedData={groupedDataProp} - // 🆕 수정 모달에서 읽기 전용 필드 지정 (수주번호, 거래처) disabledFields={["order_no", "partner_id"]} /> ); })} + + {/* 🆕 조건부 레이어 컴포넌트 렌더링 */} + {activeConditionalComponents.map((component) => { + const offsetX = screenDimensions?.offsetX || 0; + const offsetY = screenDimensions?.offsetY || 0; + const labelSpace = 30; + + const adjustedComponent = { + ...component, + position: { + ...component.position, + x: parseFloat(component.position?.x?.toString() || "0") - offsetX, + y: parseFloat(component.position?.y?.toString() || "0") - offsetY + labelSpace, + }, + }; + + const enrichedFormData = { + ...(groupData.length > 0 ? groupData[0] : formData), + tableName: screenData.screenInfo?.tableName, + screenId: modalState.screenId, + }; + + const groupedDataProp = groupData.length > 0 ? groupData : undefined; + + return ( + { + if (groupData.length > 0) { + if (Array.isArray(value)) { + setGroupData(value); + } else { + setGroupData((prev) => + prev.map((item) => ({ + ...item, + [fieldName]: value, + })), + ); + } + } else { + setFormData((prev) => ({ + ...prev, + [fieldName]: value, + })); + } + }} + screenInfo={{ + id: modalState.screenId!, + tableName: screenData.screenInfo?.tableName, + }} + menuObjid={modalState.menuObjid} + isInModal={true} + groupedData={groupedDataProp} + /> + ); + })}
) : (
diff --git a/frontend/components/screen/LayerManagerPanel.tsx b/frontend/components/screen/LayerManagerPanel.tsx index 8d7e6dc0..a8a96862 100644 --- a/frontend/components/screen/LayerManagerPanel.tsx +++ b/frontend/components/screen/LayerManagerPanel.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback, useMemo } from "react"; +import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -19,6 +19,7 @@ import { } from "lucide-react"; import { cn } from "@/lib/utils"; import { screenApi } from "@/lib/api/screen"; +import { apiClient } from "@/lib/api/client"; import { toast } from "sonner"; import { ComponentData, ConditionalZone } from "@/types/screen-management"; @@ -167,6 +168,99 @@ export const LayerManagerPanel: React.FC = ({ } }, [screenId, loadLayers, onLayerChange, onZonesChange]); + // 동적 소스 옵션 캐시 (trigger_component_id → 옵션 배열) + const [dynamicOptionsCache, setDynamicOptionsCache] = useState>({}); + const [loadingDynamicOptions, setLoadingDynamicOptions] = useState>(new Set()); + // 이미 로드 시도한 키를 추적 (중복 요청 방지) + const loadedKeysRef = useRef>(new Set()); + + // 동적 소스 옵션 로드 함수 + const loadDynamicOptions = useCallback(async (triggerCompId: string, comp: ComponentData) => { + const cacheKey = triggerCompId; + // 이미 로드 완료 또는 로드 중이면 스킵 + if (loadedKeysRef.current.has(cacheKey)) return; + loadedKeysRef.current.add(cacheKey); + + setLoadingDynamicOptions(prev => new Set(prev).add(cacheKey)); + try { + const config = comp.componentConfig || {}; + const isCategory = (comp as any).inputType === "category" || (comp as any).webType === "category"; + const source = isCategory ? "category" : config.source; + const compTableName = (comp as any).tableName || config.tableName; + const compColumnName = (comp as any).columnName || config.columnName; + let fetchedOptions: { value: string; label: string }[] = []; + + if (source === "category" || isCategory) { + // 카테고리 소스: /table-categories/:tableName/:columnName/values + const catTable = config.categoryTable || compTableName; + const catColumn = config.categoryColumn || compColumnName; + if (catTable && catColumn) { + const response = await apiClient.get(`/table-categories/${catTable}/${catColumn}/values`); + const data = response.data; + if (data.success && data.data) { + // 트리 구조를 평탄화 (valueCode/valueLabel 사용) + const flattenTree = (items: any[]): { value: string; label: string }[] => { + const result: { value: string; label: string }[] = []; + for (const item of items) { + result.push({ value: item.valueCode, label: item.valueLabel }); + if (item.children && item.children.length > 0) { + result.push(...flattenTree(item.children)); + } + } + return result; + }; + fetchedOptions = flattenTree(data.data); + } + } + } else if (source === "code" && config.codeGroup) { + // 공통코드 소스 + const response = await apiClient.get(`/common-codes/categories/${config.codeGroup}/options`); + const data = response.data; + if (data.success && data.data) { + fetchedOptions = data.data.map((item: { value: string; label: string }) => ({ + value: item.value, + label: item.label, + })); + } + } else if (source === "entity" && config.entityTable) { + // 엔티티 소스 + const valueCol = config.entityValueColumn || "id"; + const labelCol = config.entityLabelColumn || "name"; + const response = await apiClient.get(`/entity/${config.entityTable}/options`, { + params: { value: valueCol, label: labelCol }, + }); + const data = response.data; + if (data.success && data.data) { + fetchedOptions = data.data; + } + } else if ((source === "distinct" || source === "select") && compTableName && compColumnName) { + // DISTINCT 소스 + const isValidCol = compColumnName && !compColumnName.startsWith("comp_"); + if (isValidCol) { + const response = await apiClient.get(`/entity/${compTableName}/distinct/${compColumnName}`); + const data = response.data; + if (data.success && data.data) { + fetchedOptions = data.data.map((item: { value: string; label: string }) => ({ + value: String(item.value), + label: String(item.label), + })); + } + } + } + + setDynamicOptionsCache(prev => ({ ...prev, [cacheKey]: fetchedOptions })); + } catch (error) { + console.error("트리거 옵션 동적 로드 실패:", error); + setDynamicOptionsCache(prev => ({ ...prev, [cacheKey]: [] })); + } finally { + setLoadingDynamicOptions(prev => { + const next = new Set(prev); + next.delete(cacheKey); + return next; + }); + } + }, []); + // Zone 트리거 컴포넌트 업데이트 const handleUpdateZoneTrigger = useCallback(async (zoneId: number, triggerComponentId: string, operator: string = "eq") => { try { @@ -176,12 +270,20 @@ export const LayerManagerPanel: React.FC = ({ }); const loadedZones = await screenApi.getScreenZones(screenId!); onZonesChange?.(loadedZones); + + // 트리거 변경 시 해당 컴포넌트의 동적 옵션 캐시 초기화 → 새로 로드 + loadedKeysRef.current.delete(triggerComponentId); + const triggerComp = baseLayerComponents.find(c => c.id === triggerComponentId); + if (triggerComp) { + loadDynamicOptions(triggerComponentId, triggerComp); + } + toast.success("트리거가 설정되었습니다."); } catch (error) { console.error("Zone 트리거 업데이트 실패:", error); toast.error("트리거 설정에 실패했습니다."); } - }, [screenId, onZonesChange]); + }, [screenId, onZonesChange, baseLayerComponents, loadDynamicOptions]); // Zone 접힘/펼침 토글 const toggleZone = (zoneId: number) => { @@ -197,21 +299,48 @@ export const LayerManagerPanel: React.FC = ({ ["select", "combobox", "radio-group"].some(t => c.componentType?.includes(t)) ); - // Zone의 트리거 컴포넌트에서 옵션 목록 가져오기 + // Zone 트리거가 변경되면 동적 옵션 로드 + useEffect(() => { + for (const zone of zones) { + if (!zone.trigger_component_id) continue; + const triggerComp = baseLayerComponents.find(c => c.id === zone.trigger_component_id); + if (!triggerComp) continue; + + const config = triggerComp.componentConfig || {}; + const source = config.source; + const isCategory = (triggerComp as any).inputType === "category" || (triggerComp as any).webType === "category"; + + // 정적 옵션이 아닌 경우에만 동적 로드 + const hasStaticOptions = config.options && Array.isArray(config.options) && config.options.length > 0; + if (!hasStaticOptions && (source === "category" || source === "code" || source === "entity" || source === "distinct" || source === "select" || isCategory)) { + loadDynamicOptions(zone.trigger_component_id, triggerComp); + } + } + }, [zones, baseLayerComponents, loadDynamicOptions]); + + // Zone의 트리거 컴포넌트에서 옵션 목록 가져오기 (정적 + 동적 지원) const getTriggerOptions = useCallback((zone: ConditionalZone): { value: string; label: string }[] => { if (!zone.trigger_component_id) return []; const triggerComp = baseLayerComponents.find(c => c.id === zone.trigger_component_id); if (!triggerComp) return []; const config = triggerComp.componentConfig || {}; - // 정적 옵션 (v2-select static source) - if (config.options && Array.isArray(config.options)) { + + // 1. 정적 옵션 우선 확인 + if (config.options && Array.isArray(config.options) && config.options.length > 0) { return config.options .filter((opt: any) => opt.value) .map((opt: any) => ({ value: opt.value, label: opt.label || opt.value })); } + + // 2. 동적 소스 옵션 (캐시에서 가져오기) + const cached = dynamicOptionsCache[zone.trigger_component_id]; + if (cached && cached.length > 0) { + return cached; + } + return []; - }, [baseLayerComponents]); + }, [baseLayerComponents, dynamicOptionsCache]); return (
@@ -435,6 +564,17 @@ export const LayerManagerPanel: React.FC = ({ {addingToZoneId === zone.zone_id ? (
{(() => { + // 동적 옵션 로딩 중 표시 + const isLoadingOpts = zone.trigger_component_id ? loadingDynamicOptions.has(zone.trigger_component_id) : false; + if (isLoadingOpts) { + return ( +
+ + 옵션 로딩 중... +
+ ); + } + const triggerOpts = getTriggerOptions(zone); // 이미 사용된 조건값 제외 const usedValues = new Set( diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 977715a6..76bd8973 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -2179,7 +2179,12 @@ export default function ScreenDesigner({ if (USE_POP_API) { await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout); } else if (USE_V2_API) { - await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout); + // 현재 활성 레이어 ID 포함 (레이어별 저장) + const currentLayerId = activeLayerIdRef.current || 1; + await screenApi.saveLayoutV2(selectedScreen.screenId, { + ...v2Layout, + layerId: currentLayerId, + }); } else { await screenApi.saveLayout(selectedScreen.screenId, updatedLayout); } @@ -5594,7 +5599,12 @@ export default function ScreenDesigner({ if (USE_POP_API) { await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout); } else if (USE_V2_API) { - await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout); + // 현재 활성 레이어 ID 포함 (레이어별 저장) + const currentLayerId = activeLayerIdRef.current || 1; + await screenApi.saveLayoutV2(selectedScreen.screenId, { + ...v2Layout, + layerId: currentLayerId, + }); } else { await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution); } diff --git a/frontend/lib/api/data.ts b/frontend/lib/api/data.ts index 270c59f9..14aad709 100644 --- a/frontend/lib/api/data.ts +++ b/frontend/lib/api/data.ts @@ -238,7 +238,7 @@ export const dataApi = { parentKeys: Record, records: Array>, options?: { deleteOrphans?: boolean } - ): Promise<{ success: boolean; inserted?: number; updated?: number; deleted?: number; message?: string; error?: string }> => { + ): Promise<{ success: boolean; inserted?: number; updated?: number; deleted?: number; savedIds?: string[]; message?: string; error?: string }> => { try { console.log("📡 [dataApi.upsertGroupedRecords] 요청 데이터:", { tableName, diff --git a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx index e73a78c6..ffe2875d 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx @@ -689,6 +689,8 @@ export const SelectedItemsDetailInputComponent: React.FC !!r.id); + // 저장된 매핑 ID를 추적 (디테일 테이블에 mapping_id 주입용) + let savedMappingIds: string[] = []; try { const mappingResult = await dataApi.upsertGroupedRecords( mainTable, @@ -696,6 +698,11 @@ export const SelectedItemsDetailInputComponent: React.FC 0) { + const mappingId = savedMappingIds[0]; // 일반적으로 1:N (매핑 1개 : 단가 N개) + priceRecords.forEach((record) => { + if (!record.mapping_id) { + record.mapping_id = mappingId; + } + }); + console.log(`🔗 디테일 레코드에 mapping_id 주입: ${mappingId}`); + } + const priceHasDbIds = priceRecords.some((r) => !!r.id); try { const detailResult = await dataApi.upsertGroupedRecords( diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index 3d9cd552..74b4add0 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -199,6 +199,11 @@ export const SplitPanelLayoutComponent: React.FC const [isLoadingRight, setIsLoadingRight] = useState(false); const [rightTableColumns, setRightTableColumns] = useState([]); // 우측 테이블 컬럼 정보 const [expandedItems, setExpandedItems] = useState>(new Set()); // 펼쳐진 항목들 + + // 추가 탭 관련 상태 + const [activeTabIndex, setActiveTabIndex] = useState(0); // 0 = 기본 탭, 1+ = 추가 탭 + const [tabsData, setTabsData] = useState>({}); // 탭별 데이터 + const [tabsLoading, setTabsLoading] = useState>({}); // 탭별 로딩 상태 const [leftColumnLabels, setLeftColumnLabels] = useState>({}); // 좌측 컬럼 라벨 const [rightColumnLabels, setRightColumnLabels] = useState>({}); // 우측 컬럼 라벨 const [leftCategoryMappings, setLeftCategoryMappings] = useState< @@ -952,6 +957,67 @@ export const SplitPanelLayoutComponent: React.FC [formatDateValue, formatNumberValue], ); + // 🆕 패널 config의 columns에서 additionalJoinColumns 추출하는 헬퍼 + const extractAdditionalJoinColumns = useCallback((columns: any[] | undefined, tableName: string) => { + if (!columns || columns.length === 0) return undefined; + + const joinColumns: Array<{ + sourceTable: string; + sourceColumn: string; + referenceTable: string; + joinAlias: string; + }> = []; + + columns.forEach((col: any) => { + // 방법 1: isEntityJoin 플래그가 있는 경우 (설정 패널에서 Entity 조인 컬럼으로 추가한 경우) + if (col.isEntityJoin && col.joinInfo) { + const existing = joinColumns.find( + (j) => j.referenceTable === col.joinInfo.referenceTable && j.joinAlias === col.joinInfo.joinAlias + ); + if (!existing) { + joinColumns.push({ + sourceTable: col.joinInfo.sourceTable || tableName, + sourceColumn: col.joinInfo.sourceColumn, + referenceTable: col.joinInfo.referenceTable, + joinAlias: col.joinInfo.joinAlias, + }); + } + return; + } + + // 방법 2: "테이블명.컬럼명" 형식 (기존 좌측 패널 방식) + const colName = typeof col === "string" ? col : col.name || col.columnName; + if (colName && colName.includes(".")) { + const [refTable, refColumn] = colName.split("."); + const inferredSourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id"); + const existing = joinColumns.find( + (j) => j.referenceTable === refTable && j.sourceColumn === inferredSourceColumn + ); + if (!existing) { + joinColumns.push({ + sourceTable: tableName, + sourceColumn: inferredSourceColumn, + referenceTable: refTable, + joinAlias: `${inferredSourceColumn}_${refColumn}`, + }); + } else { + // 이미 추가된 테이블이면 별칭만 추가 + const newAlias = `${inferredSourceColumn}_${refColumn}`; + if (!joinColumns.find((j) => j.joinAlias === newAlias)) { + joinColumns.push({ + sourceTable: tableName, + sourceColumn: inferredSourceColumn, + referenceTable: refTable, + joinAlias: newAlias, + }); + } + } + } + }); + + return joinColumns.length > 0 ? joinColumns : undefined; + }, []); + // 좌측 데이터 로드 const loadLeftData = useCallback(async () => { const leftTableName = componentConfig.leftPanel?.tableName; @@ -962,74 +1028,22 @@ export const SplitPanelLayoutComponent: React.FC // 🎯 필터 조건을 API에 전달 (entityJoinApi 사용) const filters = Object.keys(searchValues).length > 0 ? searchValues : undefined; - // 🆕 "테이블명.컬럼명" 형식의 조인 컬럼들을 additionalJoinColumns로 변환 - const configuredColumns = componentConfig.leftPanel?.columns || []; - const additionalJoinColumns: Array<{ - sourceTable: string; - sourceColumn: string; - referenceTable: string; - joinAlias: string; - }> = []; + // 🆕 좌측 패널 config의 Entity 조인 컬럼 추출 (헬퍼 함수 사용) + const leftJoinColumns = extractAdditionalJoinColumns( + componentConfig.leftPanel?.columns, + leftTableName, + ); - // 소스 컬럼 매핑 (item_info → item_code, warehouse_info → warehouse_id 등) - const sourceColumnMap: Record = {}; - - configuredColumns.forEach((col: any) => { - const colName = typeof col === "string" ? col : col.name || col.columnName; - if (colName && colName.includes(".")) { - const [refTable, refColumn] = colName.split("."); - // 소스 컬럼 추론 (item_info → item_code 또는 warehouse_info → warehouse_id) - // 기본: _info → _code, 백업: _info → _id - const primarySourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id"); - const secondarySourceColumn = refTable.replace("_info", "_id").replace("_mng", "_id"); - // 실제 존재하는 소스 컬럼은 백엔드에서 결정 (프론트엔드는 두 패턴 모두 전달) - const inferredSourceColumn = primarySourceColumn; - - // 이미 추가된 조인인지 확인 (동일 테이블, 동일 소스컬럼) - const existingJoin = additionalJoinColumns.find( - (j) => j.referenceTable === refTable && j.sourceColumn === inferredSourceColumn, - ); - - if (!existingJoin) { - // 새로운 조인 추가 (첫 번째 컬럼) - additionalJoinColumns.push({ - sourceTable: leftTableName, - sourceColumn: inferredSourceColumn, - referenceTable: refTable, - joinAlias: `${inferredSourceColumn}_${refColumn}`, - }); - sourceColumnMap[refTable] = inferredSourceColumn; - } - - // 추가 컬럼도 별도로 요청 (item_code_standard, item_code_unit 등) - // 단, 첫 번째 컬럼과 다른 경우만 - const existingAliases = additionalJoinColumns - .filter((j) => j.referenceTable === refTable) - .map((j) => j.joinAlias); - const newAlias = `${sourceColumnMap[refTable] || inferredSourceColumn}_${refColumn}`; - - if (!existingAliases.includes(newAlias)) { - additionalJoinColumns.push({ - sourceTable: leftTableName, - sourceColumn: sourceColumnMap[refTable] || inferredSourceColumn, - referenceTable: refTable, - joinAlias: newAlias, - }); - } - } - }); - - console.log("🔗 [분할패널] additionalJoinColumns:", additionalJoinColumns); - console.log("🔗 [분할패널] configuredColumns:", configuredColumns); + console.log("🔗 [분할패널] 좌측 additionalJoinColumns:", leftJoinColumns); const result = await entityJoinApi.getTableDataWithJoins(leftTableName, { page: 1, size: 100, - search: filters, // 필터 조건 전달 - enableEntityJoin: true, // 엔티티 조인 활성화 - dataFilter: componentConfig.leftPanel?.dataFilter, // 🆕 데이터 필터 전달 - additionalJoinColumns: additionalJoinColumns.length > 0 ? additionalJoinColumns : undefined, // 🆕 추가 조인 컬럼 - companyCodeOverride: companyCode, // 🆕 프리뷰용 회사 코드 오버라이드 + search: filters, + enableEntityJoin: true, + dataFilter: componentConfig.leftPanel?.dataFilter, + additionalJoinColumns: leftJoinColumns, + companyCodeOverride: companyCode, }); // 🔍 디버깅: API 응답 데이터의 키 확인 @@ -1088,11 +1102,16 @@ export const SplitPanelLayoutComponent: React.FC // 🆕 엔티티 조인 API 사용 const { entityJoinApi } = await import("@/lib/api/entityJoin"); + const rightDetailJoinColumns = extractAdditionalJoinColumns( + componentConfig.rightPanel?.columns, + rightTableName, + ); const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { search: { id: primaryKey }, - enableEntityJoin: true, // 엔티티 조인 활성화 + enableEntityJoin: true, size: 1, - companyCodeOverride: companyCode, // 🆕 프리뷰용 회사 코드 오버라이드 + companyCodeOverride: companyCode, + additionalJoinColumns: rightDetailJoinColumns, // 🆕 Entity 조인 컬럼 전달 }); const detail = result.items && result.items.length > 0 ? result.items[0] : null; @@ -1136,6 +1155,12 @@ export const SplitPanelLayoutComponent: React.FC const { entityJoinApi } = await import("@/lib/api/entityJoin"); const allResults: any[] = []; + // 🆕 우측 패널 Entity 조인 컬럼 추출 (그룹 합산용) + const rightJoinColumnsForGroup = extractAdditionalJoinColumns( + componentConfig.rightPanel?.columns, + rightTableName, + ); + // 각 원본 항목에 대해 조회 for (const originalItem of leftItem._originalItems) { const searchConditions: Record = {}; @@ -1150,7 +1175,8 @@ export const SplitPanelLayoutComponent: React.FC search: searchConditions, enableEntityJoin: true, size: 1000, - companyCodeOverride: companyCode, // 🆕 프리뷰용 회사 코드 오버라이드 + companyCodeOverride: companyCode, + additionalJoinColumns: rightJoinColumnsForGroup, // 🆕 Entity 조인 컬럼 전달 }); if (result.data) { allResults.push(...result.data); @@ -1180,12 +1206,22 @@ export const SplitPanelLayoutComponent: React.FC console.log("🔗 [분할패널] 복합키 조건:", searchConditions); + // 🆕 우측 패널 config의 Entity 조인 컬럼 추출 + const rightJoinColumns = extractAdditionalJoinColumns( + componentConfig.rightPanel?.columns, + rightTableName, + ); + if (rightJoinColumns) { + console.log("🔗 [분할패널] 우측 패널 additionalJoinColumns:", rightJoinColumns); + } + // 엔티티 조인 API로 데이터 조회 const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { search: searchConditions, enableEntityJoin: true, size: 1000, - companyCodeOverride: companyCode, // 🆕 프리뷰용 회사 코드 오버라이드 + companyCodeOverride: companyCode, + additionalJoinColumns: rightJoinColumns, // 🆕 Entity 조인 컬럼 전달 }); console.log("🔗 [분할패널] 복합키 조회 결과:", result); @@ -1255,14 +1291,117 @@ export const SplitPanelLayoutComponent: React.FC ], ); + // 추가 탭 데이터 로딩 함수 + const loadTabData = useCallback( + async (tabIndex: number, leftItem: any) => { + const tabConfig = componentConfig.rightPanel?.additionalTabs?.[tabIndex - 1]; + if (!tabConfig || !leftItem || isDesignMode) return; + + const tabTableName = tabConfig.tableName; + if (!tabTableName) return; + + setTabsLoading((prev) => ({ ...prev, [tabIndex]: true })); + try { + const keys = tabConfig.relation?.keys; + const leftColumn = tabConfig.relation?.leftColumn || keys?.[0]?.leftColumn; + const rightColumn = tabConfig.relation?.foreignKey || keys?.[0]?.rightColumn; + + // 🆕 탭 config의 Entity 조인 컬럼 추출 + const tabJoinColumns = extractAdditionalJoinColumns(tabConfig.columns, tabTableName); + if (tabJoinColumns) { + console.log(`🔗 [분할패널] 탭 ${tabIndex} additionalJoinColumns:`, tabJoinColumns); + } + + let resultData: any[] = []; + + if (leftColumn && rightColumn) { + const searchConditions: Record = {}; + + if (keys && keys.length > 0) { + keys.forEach((key: any) => { + if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) { + searchConditions[key.rightColumn] = { + value: leftItem[key.leftColumn], + operator: "equals", + }; + } + }); + } else { + const leftValue = leftItem[leftColumn]; + if (leftValue !== undefined) { + searchConditions[rightColumn] = { + value: leftValue, + operator: "equals", + }; + } + } + + const result = await entityJoinApi.getTableDataWithJoins(tabTableName, { + search: searchConditions, + enableEntityJoin: true, + size: 1000, + additionalJoinColumns: tabJoinColumns, // 🆕 Entity 조인 컬럼 전달 + }); + resultData = result.data || []; + } else { + const result = await entityJoinApi.getTableDataWithJoins(tabTableName, { + enableEntityJoin: true, + size: 1000, + additionalJoinColumns: tabJoinColumns, // 🆕 Entity 조인 컬럼 전달 + }); + resultData = result.data || []; + } + + setTabsData((prev) => ({ ...prev, [tabIndex]: resultData })); + } catch (error) { + console.error(`추가탭 ${tabIndex} 데이터 로드 실패:`, error); + toast({ + title: "데이터 로드 실패", + description: `탭 데이터를 불러올 수 없습니다.`, + variant: "destructive", + }); + } finally { + setTabsLoading((prev) => ({ ...prev, [tabIndex]: false })); + } + }, + [componentConfig.rightPanel?.additionalTabs, isDesignMode, toast], + ); + + // 탭 변경 핸들러 + const handleTabChange = useCallback( + (newTabIndex: number) => { + setActiveTabIndex(newTabIndex); + + if (selectedLeftItem) { + if (newTabIndex === 0) { + if (!rightData || (Array.isArray(rightData) && rightData.length === 0)) { + loadRightData(selectedLeftItem); + } + } else { + if (!tabsData[newTabIndex]) { + loadTabData(newTabIndex, selectedLeftItem); + } + } + } + }, + [selectedLeftItem, rightData, tabsData, loadRightData, loadTabData], + ); + // 좌측 항목 선택 핸들러 const handleLeftItemSelect = useCallback( (item: any) => { setSelectedLeftItem(item); setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화 - loadRightData(item); + setTabsData({}); // 모든 탭 데이터 초기화 - // 🆕 modalDataStore에 선택된 좌측 항목 저장 (단일 선택) + // 현재 활성 탭에 따라 데이터 로드 + if (activeTabIndex === 0) { + loadRightData(item); + } else { + loadTabData(activeTabIndex, item); + } + + // modalDataStore에 선택된 좌측 항목 저장 (단일 선택) const leftTableName = componentConfig.leftPanel?.tableName; if (leftTableName && !isDesignMode) { import("@/stores/modalDataStore").then(({ useModalDataStore }) => { @@ -1271,7 +1410,7 @@ export const SplitPanelLayoutComponent: React.FC }); } }, - [loadRightData, componentConfig.leftPanel?.tableName, isDesignMode], + [loadRightData, loadTabData, activeTabIndex, componentConfig.leftPanel?.tableName, isDesignMode], ); // 우측 항목 확장/축소 토글 @@ -1574,6 +1713,22 @@ export const SplitPanelLayoutComponent: React.FC } }); + // 🆕 추가 탭의 테이블도 카테고리 로드 대상에 포함 + const additionalTabs = componentConfig.rightPanel?.additionalTabs || []; + additionalTabs.forEach((tab: any) => { + if (tab.tableName) { + tablesToLoad.add(tab.tableName); + } + // 추가 탭 컬럼에서 조인된 테이블 추출 + (tab.columns || []).forEach((col: any) => { + const colName = col.name || col.columnName; + if (colName && colName.includes(".")) { + const joinTableName = colName.split(".")[0]; + tablesToLoad.add(joinTableName); + } + }); + }); + console.log("🔍 우측 패널 카테고리 로드 대상 테이블:", Array.from(tablesToLoad)); // 각 테이블에 대해 카테고리 매핑 로드 @@ -1625,7 +1780,7 @@ export const SplitPanelLayoutComponent: React.FC }; loadRightCategoryMappings(); - }, [componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.columns, isDesignMode]); + }, [componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.columns, componentConfig.rightPanel?.additionalTabs, isDesignMode]); // 항목 펼치기/접기 토글 const toggleExpand = useCallback((itemId: any) => { @@ -1668,13 +1823,24 @@ export const SplitPanelLayoutComponent: React.FC // 수정 버튼 핸들러 const handleEditClick = useCallback( (panel: "left" | "right", item: any) => { - // 🆕 우측 패널 수정 버튼 설정 확인 - if (panel === "right" && componentConfig.rightPanel?.editButton?.mode === "modal") { - const modalScreenId = componentConfig.rightPanel?.editButton?.modalScreenId; + // 우측 패널 수정 버튼 설정 확인 (탭별 설정 지원) + if (panel === "right") { + const editButtonConfig = + activeTabIndex === 0 + ? componentConfig.rightPanel?.editButton + : (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.editButton; - if (modalScreenId) { - // 커스텀 모달 화면 열기 - const rightTableName = componentConfig.rightPanel?.tableName || ""; + const currentTableName = + activeTabIndex === 0 + ? componentConfig.rightPanel?.tableName || "" + : (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.tableName || ""; + + if (editButtonConfig?.mode === "modal") { + const modalScreenId = editButtonConfig?.modalScreenId; + + if (modalScreenId) { + // 커스텀 모달 화면 열기 + const rightTableName = currentTableName; // Primary Key 찾기 (우선순위: id > ID > user_id > {table}_id > 첫 번째 필드) let primaryKeyName = "id"; @@ -1750,7 +1916,8 @@ export const SplitPanelLayoutComponent: React.FC groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음", }); - return; + return; + } } } @@ -1760,7 +1927,7 @@ export const SplitPanelLayoutComponent: React.FC setEditModalFormData({ ...item }); setShowEditModal(true); }, - [componentConfig], + [componentConfig, activeTabIndex], ); // 수정 모달 저장 @@ -2220,9 +2387,13 @@ export const SplitPanelLayoutComponent: React.FC if (!isDesignMode) { console.log("🔄 [SplitPanel] refreshTable 이벤트 수신 - 데이터 새로고침"); loadLeftData(); - // 선택된 항목이 있으면 우측 패널도 새로고침 + // 선택된 항목이 있으면 현재 활성 탭 데이터 새로고침 if (selectedLeftItem) { - loadRightData(selectedLeftItem); + if (activeTabIndex === 0) { + loadRightData(selectedLeftItem); + } else { + loadTabData(activeTabIndex, selectedLeftItem); + } } } }; @@ -2232,7 +2403,7 @@ export const SplitPanelLayoutComponent: React.FC return () => { window.removeEventListener("refreshTable", handleRefreshTable); }; - }, [isDesignMode, loadLeftData, loadRightData, selectedLeftItem]); + }, [isDesignMode, loadLeftData, loadRightData, loadTabData, activeTabIndex, selectedLeftItem]); // 리사이저 드래그 핸들러 const handleMouseDown = (e: React.MouseEvent) => { @@ -3021,24 +3192,63 @@ export const SplitPanelLayoutComponent: React.FC style={{ height: componentConfig.rightPanel?.panelHeaderHeight || 48, minHeight: componentConfig.rightPanel?.panelHeaderHeight || 48, - padding: "0 1rem", + padding: "0 0.75rem", display: "flex", alignItems: "center", }} >
- - {componentConfig.rightPanel?.title || "우측 패널"} - +
+ {/* 탭이 없으면 제목만, 있으면 탭으로 전환 */} + {(componentConfig.rightPanel?.additionalTabs?.length || 0) > 0 ? ( +
+ + {componentConfig.rightPanel?.additionalTabs?.map((tab: any, index: number) => ( + + ))} +
+ ) : ( + + {componentConfig.rightPanel?.title || "우측 패널"} + + )} +
{!isDesignMode && (
- {componentConfig.rightPanel?.showAdd && ( - - )} - {/* 우측 패널 수정/삭제는 각 카드에서 처리 */} + {activeTabIndex === 0 + ? componentConfig.rightPanel?.showAdd && ( + + ) + : (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.showAdd && ( + + )}
)}
@@ -3057,8 +3267,139 @@ export const SplitPanelLayoutComponent: React.FC
)} - {/* 우측 데이터/커스텀 */} - {componentConfig.rightPanel?.displayMode === "custom" ? ( + {/* 추가 탭 컨텐츠 */} + {activeTabIndex > 0 ? ( + (() => { + const currentTabConfig = componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any; + const currentTabData = tabsData[activeTabIndex] || []; + const isTabLoading = tabsLoading[activeTabIndex]; + + if (isTabLoading) { + return ( +
+ +
+ ); + } + + if (!selectedLeftItem) { + return ( +
+

좌측에서 항목을 선택하세요

+
+ ); + } + + if (currentTabData.length === 0) { + return ( +
+

관련 데이터가 없습니다.

+
+ ); + } + + // 탭 컬럼 설정 + const tabColumns = currentTabConfig?.columns || []; + + // 테이블 모드로 표시 + if (currentTabConfig?.displayMode === "table") { + return ( +
+ + + + {tabColumns.map((col: any) => ( + + ))} + {(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && ( + + )} + + + + {currentTabData.map((item: any, idx: number) => ( + + {tabColumns.map((col: any) => ( + + ))} + {(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && ( + + )} + + ))} + +
+ {col.label || col.name} + 작업
+ {formatCellValue( + col.name, + getEntityJoinValue(item, col.name), + rightCategoryMappings, + col.format, + )} + +
+ {currentTabConfig?.showEdit && ( + + )} + {currentTabConfig?.showDelete && ( + + )} +
+
+
+ ); + } + + // 리스트(카드) 모드로 표시 + return ( +
+ {currentTabData.map((item: any, idx: number) => ( +
+
+ {tabColumns.map((col: any) => ( + + {formatCellValue( + col.name, + getEntityJoinValue(item, col.name), + rightCategoryMappings, + col.format, + )} + + ))} +
+ {(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && ( +
+ {currentTabConfig?.showEdit && ( + + )} + {currentTabConfig?.showDelete && ( + + )} +
+ )} +
+ ))} +
+ ); + })() + ) : componentConfig.rightPanel?.displayMode === "custom" ? ( // 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치
void; + onWidthChange: (value: number) => void; + onFormatChange: (checked: boolean) => void; + onRemove: () => void; +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); + const style = { transform: CSS.Transform.toString(transform), transition }; + + return ( +
+
+ +
+ {isEntityJoin ? ( + + ) : ( + #{index + 1} + )} + onLabelChange(e.target.value)} + placeholder="라벨" + className="h-6 min-w-0 flex-1 text-xs" + /> + onWidthChange(parseInt(e.target.value) || 100)} + placeholder="너비" + className="h-6 w-14 shrink-0 text-xs" + /> + {isNumeric && ( + + )} + +
+ ); +} interface SplitPanelLayoutConfigPanelProps { config: SplitPanelLayoutConfig; @@ -206,6 +277,11 @@ interface AdditionalTabConfigPanelProps { loadedTableColumns: Record; loadTableColumns: (tableName: string) => Promise; loadingColumns: Record; + // Entity 조인 컬럼 (테이블별) + entityJoinColumns?: Record; + joinTables: Array<{ tableName: string; currentDisplayColumn: string; joinConfig?: any; availableColumns: Array<{ columnName: string; columnLabel: string; dataType: string; inputType?: string; description?: string }> }>; + }>; } const AdditionalTabConfigPanel: React.FC = ({ @@ -219,6 +295,7 @@ const AdditionalTabConfigPanel: React.FC = ({ loadedTableColumns, loadTableColumns, loadingColumns, + entityJoinColumns: entityJoinColumnsMap, }) => { // 탭 테이블 변경 시 컬럼 로드 useEffect(() => { @@ -768,6 +845,73 @@ const AdditionalTabConfigPanel: React.FC = ({
)} + {/* ===== 7.5 Entity 조인 컬럼 ===== */} + {(() => { + const joinData = tab.tableName ? entityJoinColumnsMap?.[tab.tableName] : null; + if (!joinData || joinData.joinTables.length === 0) return null; + + return ( +
+ +

연관 테이블의 컬럼을 추가합니다

+ {joinData.joinTables.map((joinTable, tableIndex) => ( +
+
+ + {joinTable.tableName} + {joinTable.currentDisplayColumn} +
+
+ {joinTable.availableColumns.map((column, colIndex) => { + const matchingJoinColumn = joinData.availableColumns.find( + (jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName, + ); + if (!matchingJoinColumn) return null; + const tabColumns2 = tab.columns || []; + const isAdded = tabColumns2.some((c) => c.name === matchingJoinColumn.joinAlias); + + return ( +
{ + if (isAdded) { + updateTab({ columns: tabColumns2.filter((c) => c.name !== matchingJoinColumn.joinAlias) }); + } else { + updateTab({ + columns: [...tabColumns2, { + name: matchingJoinColumn.joinAlias, + label: matchingJoinColumn.suggestedLabel || matchingJoinColumn.columnLabel, + width: 100, + isEntityJoin: true, + joinInfo: { + sourceTable: tab.tableName!, + sourceColumn: (joinTable as any).joinConfig?.sourceColumn || "", + referenceTable: matchingJoinColumn.tableName, + joinAlias: matchingJoinColumn.joinAlias, + }, + }], + }); + } + }} + > + + + {column.columnLabel} + {column.dataType} +
+ ); + })} +
+
+ ))} +
+ ); + })()} + {/* ===== 8. 데이터 필터링 ===== */}
@@ -1068,6 +1212,7 @@ export const SplitPanelLayoutConfigPanel: React.FC { + const [activeModal, setActiveModal] = useState(null); // 설정 모달 상태 const [leftTableOpen, setLeftTableOpen] = useState(false); // 🆕 좌측 테이블 Combobox 상태 const [rightTableOpen, setRightTableOpen] = useState(false); const [loadedTableColumns, setLoadedTableColumns] = useState>({}); @@ -1077,6 +1222,36 @@ export const SplitPanelLayoutConfigPanel: React.FC>({}); + // Entity 조인 컬럼 (테이블별) + const [entityJoinColumns, setEntityJoinColumns] = useState< + Record< + string, + { + availableColumns: Array<{ + tableName: string; + columnName: string; + columnLabel: string; + dataType: string; + joinAlias: string; + suggestedLabel: string; + }>; + joinTables: Array<{ + tableName: string; + currentDisplayColumn: string; + joinConfig?: any; + availableColumns: Array<{ + columnName: string; + columnLabel: string; + dataType: string; + inputType?: string; + description?: string; + }>; + }>; + } + > + >({}); + const [loadingEntityJoins, setLoadingEntityJoins] = useState>({}); + // 🆕 입력 필드용 로컬 상태 const [isUserEditing, setIsUserEditing] = useState(false); const [localTitles, setLocalTitles] = useState({ @@ -1159,6 +1334,9 @@ export const SplitPanelLayoutConfigPanel: React.FC ({ ...prev, [tableName]: [] })); @@ -1167,6 +1345,32 @@ export const SplitPanelLayoutConfigPanel: React.FC { + if (entityJoinColumns[tableName] || loadingEntityJoins[tableName]) return; + + setLoadingEntityJoins((prev) => ({ ...prev, [tableName]: true })); + try { + const result = await entityJoinApi.getEntityJoinColumns(tableName); + console.log(`🔗 Entity 조인 컬럼 (${tableName}):`, result); + setEntityJoinColumns((prev) => ({ + ...prev, + [tableName]: { + availableColumns: result.availableColumns || [], + joinTables: result.joinTables || [], + }, + })); + } catch (error) { + console.error(`❌ Entity 조인 컬럼 조회 실패 (${tableName}):`, error); + setEntityJoinColumns((prev) => ({ + ...prev, + [tableName]: { availableColumns: [], joinTables: [] }, + })); + } finally { + setLoadingEntityJoins((prev) => ({ ...prev, [tableName]: false })); + } + }; + // 🆕 엔티티 참조 테이블의 컬럼 로드 const loadEntityReferenceColumns = async (sourceTableName: string, columns: ColumnInfo[]) => { const entityColumns = columns.filter( @@ -1373,10 +1577,64 @@ export const SplitPanelLayoutConfigPanel: React.FC +
+ {/* ===== 간소화된 설정 메뉴 카드 ===== */} +

분할 패널 설정

+
+ {[ + { + id: "basic", + title: "기본 설정", + desc: `${relationshipType === "detail" ? "상세" : "조건 필터"} | 비율 ${config.splitRatio || 30}%`, + icon: Settings2, + }, + { + id: "left", + title: "좌측 패널", + desc: config.leftPanel?.tableName || screenTableName || "미설정", + icon: PanelLeft, + }, + { + id: "right", + title: "우측 패널", + desc: config.rightPanel?.tableName || "미설정", + icon: PanelRight, + }, + { + id: "tabs", + title: "추가 탭", + desc: `${config.rightPanel?.additionalTabs?.length || 0}개 탭`, + icon: Layers, + }, + ].map((item) => ( + + ))} +
+ + {/* ===== 기본 설정 모달 ===== */} + !open && setActiveModal(null)}> + + + 기본 설정 + 패널 관계 타입 및 레이아웃을 설정합니다 + +
{/* 관계 타입 선택 */} -
-

패널 관계 타입

+
+

패널 관계 타입

{ - const newColumns = [...(config.leftPanel?.columns || [])]; - newColumns[index] = { ...newColumns[index], label: e.target.value }; - updateLeftPanel({ columns: newColumns }); - }} - placeholder="제목" - className="h-6 flex-1 text-xs" - /> - { - const newColumns = [...(config.leftPanel?.columns || [])]; - newColumns[index] = { ...newColumns[index], width: parseInt(e.target.value) || 100 }; - updateLeftPanel({ columns: newColumns }); - }} - placeholder="너비" - className="h-6 w-14 text-xs" - /> - {/* 숫자 타입: 천단위 구분자 체크박스 */} - {isNumeric && ( -
- ); - })} + + {/* 좌측 패널 - Entity 조인 컬럼 아코디언 */} + {(() => { + const leftTable = config.leftPanel?.tableName || screenTableName; + const joinData = leftTable ? entityJoinColumns[leftTable] : null; + if (!joinData || joinData.joinTables.length === 0) return null; + + return joinData.joinTables.map((joinTable, tableIndex) => { + const joinColumnsToShow = joinTable.availableColumns.filter((column) => { + const matchingJoinColumn = joinData.availableColumns.find( + (jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName, + ); + if (!matchingJoinColumn) return false; + return !selectedColumns.some((c) => c.name === matchingJoinColumn.joinAlias); + }); + const addedCount = joinTable.availableColumns.length - joinColumnsToShow.length; + + if (joinColumnsToShow.length === 0 && addedCount === 0) return null; + + return ( +
+ + + + {joinTable.tableName} + {addedCount > 0 && ( + {addedCount}개 선택 + )} + {joinColumnsToShow.length}개 남음 + +
+ {joinColumnsToShow.map((column, colIndex) => { + const matchingJoinColumn = joinData.availableColumns.find( + (jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName, + ); + if (!matchingJoinColumn) return null; + + return ( +
{ + updateLeftPanel({ + columns: [...selectedColumns, { + name: matchingJoinColumn.joinAlias, + label: matchingJoinColumn.suggestedLabel || matchingJoinColumn.columnLabel, + width: 100, + isEntityJoin: true, + joinInfo: { + sourceTable: leftTable!, + sourceColumn: (joinTable as any).joinConfig?.sourceColumn || "", + referenceTable: matchingJoinColumn.tableName, + joinAlias: matchingJoinColumn.joinAlias, + }, + }], + }); + }} + > + + + {column.columnLabel || column.columnName} +
+ ); + })} + {joinColumnsToShow.length === 0 && ( +

모든 컬럼이 이미 추가되었습니다

+ )} +
+
+ ); + }); + })()} + + )}
- )} -
- )} + ); + })()}
{/* 좌측 패널 데이터 필터링 */} -
-

좌측 패널 데이터 필터링

+
+

좌측 패널 데이터 필터링

특정 컬럼 값으로 좌측 패널 데이터를 필터링합니다

+
+ + + {/* ===== 우측 패널 모달 ===== */} + !open && setActiveModal(null)}> + + + 우측 패널 설정 + 상세/필터 데이터 표시, 버튼, 중복 제거를 설정합니다 + +
{/* 우측 패널 설정 */} -
-

우측 패널 설정 ({relationshipType === "detail" ? "상세" : "조건 필터"})

+
+

우측 패널 설정 ({relationshipType === "detail" ? "상세" : "조건 필터"})

@@ -1919,25 +2239,27 @@ export const SplitPanelLayoutConfigPanel: React.FC updateRightPanel({ displayMode: value })} > - - + + + {(config.rightPanel?.displayMode || "list") === "list" ? "목록 (LIST)" : (config.rightPanel?.displayMode || "list") === "table" ? "테이블 (TABLE)" : "커스텀 (CUSTOM)"} + -
- 목록 (LIST) +
+ 목록 (LIST) 클릭 가능한 항목 목록 (기본)
-
- 테이블 (TABLE) +
+ 테이블 (TABLE) 컬럼 헤더가 있는 테이블 형식
-
- 커스텀 (CUSTOM) +
+ 커스텀 (CUSTOM) 패널 안에 컴포넌트 자유 배치
@@ -2038,229 +2360,183 @@ export const SplitPanelLayoutConfigPanel: React.FC )} - {/* 엔티티 설정 선택 - 조건 필터 모드에서만 표시 */} - {relationshipType !== "detail" && ( -
- -

- 우측 테이블에서 좌측 테이블을 참조하는 컬럼을 선택하세요 -

- - {config.rightPanel?.relation?.foreignKey && ( -

선택된 컬럼의 엔티티 설정이 자동으로 적용됩니다.

- )} -
- )} + {/* 필터 연결 컬럼 제거됨 - Entity 조인이 자동으로 관계를 처리 */} - {/* 우측 패널 표시 컬럼 설정 - 체크박스 방식 */} -
- + {/* 우측 패널 표시 컬럼 설정 - 드래그앤드롭 */} + {(() => { + const selectedColumns = config.rightPanel?.columns || []; + const filteredTableColumns = rightTableColumns.filter((c) => !["company_code", "company_name"].includes(c.columnName)); + const unselectedColumns = filteredTableColumns.filter((c) => !selectedColumns.some((sc) => sc.name === c.columnName)); + const dbNumericTypes = ["numeric", "decimal", "integer", "bigint", "double precision", "real", "smallint", "int4", "int8", "float4", "float8"]; + const inputNumericTypes = ["number", "decimal", "currency", "integer"]; - {/* 컬럼 체크박스 목록 */} -
- {rightTableColumns.length === 0 ? ( -

테이블을 선택해주세요

- ) : ( - rightTableColumns - .filter((column) => !["company_code", "company_name"].includes(column.columnName)) - .map((column) => { - const isSelected = (config.rightPanel?.columns || []).some((c) => c.name === column.columnName); - return ( -
{ - const currentColumns = config.rightPanel?.columns || []; - if (isSelected) { - const newColumns = currentColumns.filter((c) => c.name !== column.columnName); - updateRightPanel({ columns: newColumns }); - } else { - const newColumn = { - name: column.columnName, - label: column.columnLabel || column.columnName, - width: 100, - }; - updateRightPanel({ columns: [...currentColumns, newColumn] }); - } - }} - > - { - const currentColumns = config.rightPanel?.columns || []; - if (isSelected) { - const newColumns = currentColumns.filter((c) => c.name !== column.columnName); - updateRightPanel({ columns: newColumns }); - } else { - const newColumn = { - name: column.columnName, - label: column.columnLabel || column.columnName, - width: 100, - }; - updateRightPanel({ columns: [...currentColumns, newColumn] }); - } - }} - className="pointer-events-none h-3.5 w-3.5 shrink-0" - /> - - {column.columnLabel || column.columnName} -
- ); - }) - )} -
+ const handleRightDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (over && active.id !== over.id) { + const oldIndex = selectedColumns.findIndex((c) => c.name === active.id); + const newIndex = selectedColumns.findIndex((c) => c.name === over.id); + if (oldIndex !== -1 && newIndex !== -1) { + updateRightPanel({ columns: arrayMove([...selectedColumns], oldIndex, newIndex) }); + } + } + }; - {/* 선택된 컬럼 상세 설정 */} - {(config.rightPanel?.columns || []).length > 0 && ( + return (
- -
- {(config.rightPanel?.columns || []).map((col, index) => { - const column = rightTableColumns.find((c) => c.columnName === col.name); + +
+ {rightTableColumns.length === 0 ? ( +

테이블을 선택해주세요

+ ) : ( + <> + {selectedColumns.length > 0 && ( + + c.name)} strategy={verticalListSortingStrategy}> +
+ {selectedColumns.map((col, index) => { + const colInfo = rightTableColumns.find((c) => c.columnName === col.name); + const isNumeric = colInfo && ( + dbNumericTypes.includes(colInfo.dataType?.toLowerCase() || "") || + inputNumericTypes.includes(colInfo.input_type?.toLowerCase() || "") || + inputNumericTypes.includes(colInfo.webType?.toLowerCase() || "") + ); + return ( + { + const newColumns = [...selectedColumns]; + newColumns[index] = { ...newColumns[index], label: value }; + updateRightPanel({ columns: newColumns }); + }} + onWidthChange={(value) => { + const newColumns = [...selectedColumns]; + newColumns[index] = { ...newColumns[index], width: value }; + updateRightPanel({ columns: newColumns }); + }} + onFormatChange={(checked) => { + const newColumns = [...selectedColumns]; + newColumns[index] = { ...newColumns[index], format: { ...newColumns[index].format, type: "number", thousandSeparator: checked } }; + updateRightPanel({ columns: newColumns }); + }} + onRemove={() => updateRightPanel({ columns: selectedColumns.filter((_, i) => i !== index) })} + /> + ); + })} +
+
+
+ )} - // 숫자 타입 판별 - const dbNumericTypes = [ - "numeric", - "decimal", - "integer", - "bigint", - "double precision", - "real", - "smallint", - "int4", - "int8", - "float4", - "float8", - ]; - const inputNumericTypes = ["number", "decimal", "currency", "integer"]; - const isNumeric = - column && - (dbNumericTypes.includes(column.dataType?.toLowerCase() || "") || - inputNumericTypes.includes(column.input_type?.toLowerCase() || "") || - inputNumericTypes.includes(column.webType?.toLowerCase() || "")); + {selectedColumns.length > 0 && unselectedColumns.length > 0 && ( +
+ 미선택 컬럼 +
+ )} - return ( -
- - - { - const newColumns = [...(config.rightPanel?.columns || [])]; - newColumns[index] = { ...newColumns[index], label: e.target.value }; - updateRightPanel({ columns: newColumns }); - }} - placeholder="제목" - className="h-6 flex-1 text-xs" - /> - { - const newColumns = [...(config.rightPanel?.columns || [])]; - newColumns[index] = { ...newColumns[index], width: parseInt(e.target.value) || 100 }; - updateRightPanel({ columns: newColumns }); - }} - placeholder="너비" - className="h-6 w-14 text-xs" - /> - {/* 숫자 타입: 천단위 구분자 체크박스 */} - {isNumeric && ( -
- ); - })} + + {/* Entity 조인 컬럼 - 아코디언 (접기/펼치기) */} + {(() => { + const rightTable = config.rightPanel?.tableName; + const joinData = rightTable ? entityJoinColumns[rightTable] : null; + if (!joinData || joinData.joinTables.length === 0) return null; + + return joinData.joinTables.map((joinTable, tableIndex) => { + const joinColumnsToShow = joinTable.availableColumns.filter((column) => { + const matchingJoinColumn = joinData.availableColumns.find( + (jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName, + ); + if (!matchingJoinColumn) return false; + return !selectedColumns.some((c) => c.name === matchingJoinColumn.joinAlias); + }); + const addedCount = joinTable.availableColumns.length - joinColumnsToShow.length; + + if (joinColumnsToShow.length === 0 && addedCount === 0) return null; + + return ( +
+ + + + {joinTable.tableName} + {addedCount > 0 && ( + {addedCount}개 선택 + )} + {joinColumnsToShow.length}개 남음 + +
+ {joinColumnsToShow.map((column, colIndex) => { + const matchingJoinColumn = joinData.availableColumns.find( + (jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName, + ); + if (!matchingJoinColumn) return null; + + return ( +
{ + updateRightPanel({ + columns: [...selectedColumns, { + name: matchingJoinColumn.joinAlias, + label: matchingJoinColumn.suggestedLabel || matchingJoinColumn.columnLabel, + width: 100, + isEntityJoin: true, + joinInfo: { + sourceTable: rightTable!, + sourceColumn: (joinTable as any).joinConfig?.sourceColumn || "", + referenceTable: matchingJoinColumn.tableName, + joinAlias: matchingJoinColumn.joinAlias, + }, + }], + }); + }} + > + + + {column.columnLabel || column.columnName} +
+ ); + })} + {joinColumnsToShow.length === 0 && ( +

모든 컬럼이 이미 추가되었습니다

+ )} +
+
+ ); + }); + })()} + + )}
- )} -
+ ); + })()}
+ {/* 우측 패널 Entity 조인 컬럼은 표시 컬럼 목록에 통합됨 */} + {/* 우측 패널 데이터 필터링 */} -
-

우측 패널 데이터 필터링

+
+

우측 패널 데이터 필터링

특정 컬럼 값으로 우측 패널 데이터를 필터링합니다

{/* 우측 패널 중복 제거 */} -
+
-

중복 데이터 제거

+

중복 데이터 제거

같은 값을 가진 데이터를 하나로 통합하여 표시

{/* 🆕 우측 패널 수정 버튼 설정 */} -
+
-

수정 버튼 설정

+

수정 버튼 설정

우측 리스트의 수정 버튼 동작 방식 설정

{/* 🆕 우측 패널 삭제 버튼 설정 */} -
+
-

삭제 버튼 설정

+

삭제 버튼 설정

)}
+
+ +
- {/* ======================================== */} - {/* 추가 탭 설정 (우측 패널과 동일한 구조) */} - {/* ======================================== */} -
+ {/* ===== 추가 탭 모달 ===== */} + !open && setActiveModal(null)}> + + + 추가 탭 설정 + 우측 패널에 다른 테이블 데이터를 탭으로 추가합니다 + +
+
-

추가 탭

+

추가 탭

우측 패널에 다른 테이블 데이터를 탭으로 추가합니다

@@ -2692,6 +2976,7 @@ export const SplitPanelLayoutConfigPanel: React.FC ))} @@ -2703,36 +2988,9 @@ export const SplitPanelLayoutConfigPanel: React.FC )}
- - {/* 레이아웃 설정 */} -
-
- - updateConfig({ splitRatio: value[0] })} - min={20} - max={80} - step={5} - /> -
- -
- - updateConfig({ resizable: checked })} - /> -
- -
- - updateConfig({ autoLoad: checked })} - /> -
-
+
+ +
); }; diff --git a/frontend/lib/registry/components/v2-split-panel-layout/types.ts b/frontend/lib/registry/components/v2-split-panel-layout/types.ts index 7ab0dbcb..9a3672bb 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/types.ts +++ b/frontend/lib/registry/components/v2-split-panel-layout/types.ts @@ -50,6 +50,14 @@ export interface AdditionalTabConfig { suffix?: string; dateFormat?: string; }; + // Entity 조인 컬럼 정보 + isEntityJoin?: boolean; + joinInfo?: { + sourceTable: string; + sourceColumn: string; + referenceTable: string; + joinAlias: string; + }; }>; addModalColumns?: Array<{ @@ -145,6 +153,14 @@ export interface SplitPanelLayoutConfig { suffix?: string; // 접미사 (예: "원", "개") dateFormat?: string; // 날짜 포맷 (type: "date") }; + // Entity 조인 컬럼 정보 + isEntityJoin?: boolean; + joinInfo?: { + sourceTable: string; + sourceColumn: string; + referenceTable: string; + joinAlias: string; + }; }>; // 추가 모달에서 입력받을 컬럼 설정 addModalColumns?: Array<{ @@ -217,6 +233,14 @@ export interface SplitPanelLayoutConfig { suffix?: string; // 접미사 (예: "원", "개") dateFormat?: string; // 날짜 포맷 (type: "date") }; + // Entity 조인 컬럼 정보 + isEntityJoin?: boolean; + joinInfo?: { + sourceTable: string; + sourceColumn: string; + referenceTable: string; + joinAlias: string; + }; }>; // 추가 모달에서 입력받을 컬럼 설정 addModalColumns?: Array<{