From f272f0c4c751f2a2efef8027bfa0099750a8bb6b Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 11 Dec 2025 10:41:28 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A0=9C=EC=96=B4=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=ED=9A=8C=EC=82=AC=EC=BD=94=EB=93=9C=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EC=95=88=EB=90=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/routes/dataflow/node-flows.ts | 88 +- .../src/services/dynamicFormService.ts | 29 +- .../src/services/nodeFlowExecutionService.ts | 57 + .../SplitPanelLayout2Component.tsx | 1061 +++++++++-------- .../SplitPanelLayout2ConfigPanel.tsx | 457 +++---- .../components/split-panel-layout2/types.ts | 21 +- 6 files changed, 962 insertions(+), 751 deletions(-) diff --git a/backend-node/src/routes/dataflow/node-flows.ts b/backend-node/src/routes/dataflow/node-flows.ts index f13d65cf..6de84866 100644 --- a/backend-node/src/routes/dataflow/node-flows.ts +++ b/backend-node/src/routes/dataflow/node-flows.ts @@ -218,46 +218,62 @@ router.delete("/:flowId", async (req: Request, res: Response) => { * 플로우 실행 * POST /api/dataflow/node-flows/:flowId/execute */ -router.post("/:flowId/execute", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { - try { - const { flowId } = req.params; - const contextData = req.body; +router.post( + "/:flowId/execute", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + try { + const { flowId } = req.params; + const contextData = req.body; - logger.info(`🚀 플로우 실행 요청: flowId=${flowId}`, { - contextDataKeys: Object.keys(contextData), - userId: req.user?.userId, - companyCode: req.user?.companyCode, - }); + logger.info(`🚀 플로우 실행 요청: flowId=${flowId}`, { + contextDataKeys: Object.keys(contextData), + userId: req.user?.userId, + companyCode: req.user?.companyCode, + }); - // 사용자 정보를 contextData에 추가 - const enrichedContextData = { - ...contextData, - userId: req.user?.userId, - userName: req.user?.userName, - companyCode: req.user?.companyCode, - }; + // 🔍 디버깅: req.user 전체 확인 + logger.info(`🔍 req.user 전체 정보:`, { + user: req.user, + hasUser: !!req.user, + }); - // 플로우 실행 - const result = await NodeFlowExecutionService.executeFlow( - parseInt(flowId, 10), - enrichedContextData - ); + // 사용자 정보를 contextData에 추가 + const enrichedContextData = { + ...contextData, + userId: req.user?.userId, + userName: req.user?.userName, + companyCode: req.user?.companyCode, + }; - return res.json({ - success: result.success, - message: result.message, - data: result, - }); - } catch (error) { - logger.error("플로우 실행 실패:", error); - return res.status(500).json({ - success: false, - message: - error instanceof Error - ? error.message - : "플로우 실행 중 오류가 발생했습니다.", - }); + // 🔍 디버깅: enrichedContextData 확인 + logger.info(`🔍 enrichedContextData:`, { + userId: enrichedContextData.userId, + companyCode: enrichedContextData.companyCode, + }); + + // 플로우 실행 + const result = await NodeFlowExecutionService.executeFlow( + parseInt(flowId, 10), + enrichedContextData + ); + + return res.json({ + success: result.success, + message: result.message, + data: result, + }); + } catch (error) { + logger.error("플로우 실행 실패:", error); + return res.status(500).json({ + success: false, + message: + error instanceof Error + ? error.message + : "플로우 실행 중 오류가 발생했습니다.", + }); + } } -}); +); export default router; diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index be87f930..77593fa1 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -754,12 +754,19 @@ export class DynamicFormService { // 🎯 제어관리 실행 (새로 추가) try { + // savedData 또는 insertedRecord에서 company_code 추출 + const recordCompanyCode = + (insertedRecord as Record)?.company_code || + dataToInsert.company_code || + "*"; + await this.executeDataflowControlIfConfigured( screenId, tableName, insertedRecord as Record, "insert", - created_by || "system" + created_by || "system", + recordCompanyCode ); } catch (controlError) { console.error("⚠️ 제어관리 실행 오류:", controlError); @@ -1107,12 +1114,19 @@ export class DynamicFormService { // 🎯 제어관리 실행 (UPDATE 트리거) try { + // updatedRecord에서 company_code 추출 + const recordCompanyCode = + (updatedRecord as Record)?.company_code || + company_code || + "*"; + await this.executeDataflowControlIfConfigured( 0, // UPDATE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요) tableName, updatedRecord as Record, "update", - updated_by || "system" + updated_by || "system", + recordCompanyCode ); } catch (controlError) { console.error("⚠️ 제어관리 실행 오류:", controlError); @@ -1251,12 +1265,17 @@ export class DynamicFormService { try { if (result && Array.isArray(result) && result.length > 0) { const deletedRecord = result[0] as Record; + // deletedRecord에서 company_code 추출 + const recordCompanyCode = + deletedRecord?.company_code || companyCode || "*"; + await this.executeDataflowControlIfConfigured( 0, // DELETE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요) tableName, deletedRecord, "delete", - userId || "system" + userId || "system", + recordCompanyCode ); } } catch (controlError) { @@ -1562,7 +1581,8 @@ export class DynamicFormService { tableName: string, savedData: Record, triggerType: "insert" | "update" | "delete", - userId: string = "system" + userId: string = "system", + companyCode: string = "*" ): Promise { try { console.log(`🎯 제어관리 설정 확인 중... (screenId: ${screenId})`); @@ -1636,6 +1656,7 @@ export class DynamicFormService { buttonId: "save-button", screenId: screenId, userId: userId, + companyCode: companyCode, formData: savedData, } ); diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts index fc38406a..7901702a 100644 --- a/backend-node/src/services/nodeFlowExecutionService.ts +++ b/backend-node/src/services/nodeFlowExecutionService.ts @@ -117,6 +117,18 @@ export class NodeFlowExecutionService { try { logger.info(`🚀 플로우 실행 시작: flowId=${flowId}`); + // 🔍 디버깅: contextData 상세 로그 + logger.info(`🔍 contextData 상세:`, { + directCompanyCode: contextData.companyCode, + nestedCompanyCode: contextData.context?.companyCode, + directUserId: contextData.userId, + nestedUserId: contextData.context?.userId, + contextKeys: Object.keys(contextData), + nestedContextKeys: contextData.context + ? Object.keys(contextData.context) + : "no nested context", + }); + // 1. 플로우 데이터 조회 const flow = await queryOne<{ flow_id: number; @@ -979,12 +991,25 @@ export class NodeFlowExecutionService { const userId = context.buttonContext?.userId; const companyCode = context.buttonContext?.companyCode; + // 🔍 디버깅: 자동 추가 조건 확인 + console.log(` 🔍 INSERT 자동 추가 조건 확인:`, { + hasWriterMapping, + hasCompanyCodeMapping, + userId, + companyCode, + buttonContext: context.buttonContext, + }); + // writer 자동 추가 (매핑에 없고, 컨텍스트에 userId가 있는 경우) if (!hasWriterMapping && userId) { fields.push("writer"); values.push(userId); insertedData.writer = userId; console.log(` 🔧 자동 추가: writer = ${userId}`); + } else { + console.log( + ` ⚠️ writer 자동 추가 스킵: hasWriterMapping=${hasWriterMapping}, userId=${userId}` + ); } // company_code 자동 추가 (매핑에 없고, 컨텍스트에 companyCode가 있는 경우) @@ -993,6 +1018,10 @@ export class NodeFlowExecutionService { values.push(companyCode); insertedData.company_code = companyCode; console.log(` 🔧 자동 추가: company_code = ${companyCode}`); + } else { + console.log( + ` ⚠️ company_code 자동 추가 스킵: hasCompanyCodeMapping=${hasCompanyCodeMapping}, companyCode=${companyCode}` + ); } const sql = ` @@ -2251,6 +2280,34 @@ export class NodeFlowExecutionService { values.push(value); }); + // 🆕 writer와 company_code 자동 추가 (필드 매핑에 없는 경우) + const hasWriterMapping = fieldMappings.some( + (m: any) => m.targetField === "writer" + ); + const hasCompanyCodeMapping = fieldMappings.some( + (m: any) => m.targetField === "company_code" + ); + + // 컨텍스트에서 사용자 정보 추출 + const userId = context.buttonContext?.userId; + const companyCode = context.buttonContext?.companyCode; + + // writer 자동 추가 (매핑에 없고, 컨텍스트에 userId가 있는 경우) + if (!hasWriterMapping && userId) { + columns.push("writer"); + values.push(userId); + logger.info(` 🔧 UPSERT INSERT - 자동 추가: writer = ${userId}`); + } + + // company_code 자동 추가 (매핑에 없고, 컨텍스트에 companyCode가 있는 경우) + if (!hasCompanyCodeMapping && companyCode && companyCode !== "*") { + columns.push("company_code"); + values.push(companyCode); + logger.info( + ` 🔧 UPSERT INSERT - 자동 추가: company_code = ${companyCode}` + ); + } + const placeholders = values.map((_, i) => `$${i + 1}`).join(", "); const insertSql = ` INSERT INTO ${targetTable} (${columns.join(", ")}) diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx index 3bdd2015..1ee1218a 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx @@ -2,16 +2,21 @@ import React, { useState, useEffect, useCallback, useMemo } from "react"; import { ComponentRendererProps } from "@/types/component"; -import { - SplitPanelLayout2Config, - ColumnConfig, - DataTransferField, - ActionButtonConfig, - JoinTableConfig, -} from "./types"; +import { SplitPanelLayout2Config, ColumnConfig, DataTransferField, ActionButtonConfig, JoinTableConfig } from "./types"; import { defaultConfig } from "./config"; import { cn } from "@/lib/utils"; -import { Search, Plus, ChevronRight, ChevronDown, Edit, Trash2, Users, Building2, Check, MoreHorizontal } from "lucide-react"; +import { + Search, + Plus, + ChevronRight, + ChevronDown, + Edit, + Trash2, + Users, + Building2, + Check, + MoreHorizontal, +} from "lucide-react"; import { Checkbox } from "@/components/ui/checkbox"; import { AlertDialog, @@ -23,14 +28,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; @@ -82,13 +80,12 @@ export const SplitPanelLayout2Component: React.FC>(new Set()); - + // 삭제 확인 다이얼로그 상태 const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [itemToDelete, setItemToDelete] = useState(null); const [isBulkDelete, setIsBulkDelete] = useState(false); - // 좌측 데이터 로드 const loadLeftData = useCallback(async () => { if (!config.leftPanel?.tableName || isDesignMode) return; @@ -114,7 +111,7 @@ export const SplitPanelLayout2Component: React.FC> => { - const resultMap = new Map(); - if (!joinConfig.joinTable || !joinConfig.mainColumn || !joinConfig.joinColumn || mainData.length === 0) { - return resultMap; - } - - // 메인 데이터에서 조인할 키 값들 추출 - const joinKeys = [...new Set(mainData.map((item) => item[joinConfig.mainColumn]).filter(Boolean))]; - if (joinKeys.length === 0) return resultMap; - - try { - console.log(`[SplitPanelLayout2] 조인 테이블 로드: ${joinConfig.joinTable}, 키: ${joinKeys.length}개`); - - const response = await apiClient.post(`/table-management/tables/${joinConfig.joinTable}/data`, { - page: 1, - size: 1000, - // 조인 키 값들로 필터링 - dataFilter: { - enabled: true, - matchType: "any", // OR 조건으로 여러 키 매칭 - filters: joinKeys.map((key, idx) => ({ - id: `join_key_${idx}`, - columnName: joinConfig.joinColumn, - operator: "equals", - value: String(key), - valueType: "static", - })), - }, - autoFilter: { - enabled: true, - filterColumn: "company_code", - filterType: "company", - }, - }); - - if (response.data.success) { - const joinData = response.data.data?.data || []; - // 조인 컬럼 값을 키로 하는 Map 생성 - joinData.forEach((item: any) => { - const key = item[joinConfig.joinColumn]; - if (key) { - resultMap.set(String(key), item); - } - }); - console.log(`[SplitPanelLayout2] 조인 테이블 로드 완료: ${joinData.length}건`); + const loadJoinTableData = useCallback( + async (joinConfig: JoinTableConfig, mainData: any[]): Promise> => { + const resultMap = new Map(); + if (!joinConfig.joinTable || !joinConfig.mainColumn || !joinConfig.joinColumn || mainData.length === 0) { + return resultMap; } - } catch (error) { - console.error(`[SplitPanelLayout2] 조인 테이블 로드 실패 (${joinConfig.joinTable}):`, error); - } - return resultMap; - }, []); + // 메인 데이터에서 조인할 키 값들 추출 + const joinKeys = [...new Set(mainData.map((item) => item[joinConfig.mainColumn]).filter(Boolean))]; + if (joinKeys.length === 0) return resultMap; + + try { + console.log(`[SplitPanelLayout2] 조인 테이블 로드: ${joinConfig.joinTable}, 키: ${joinKeys.length}개`); + + const response = await apiClient.post(`/table-management/tables/${joinConfig.joinTable}/data`, { + page: 1, + size: 1000, + // 조인 키 값들로 필터링 + dataFilter: { + enabled: true, + matchType: "any", // OR 조건으로 여러 키 매칭 + filters: joinKeys.map((key, idx) => ({ + id: `join_key_${idx}`, + columnName: joinConfig.joinColumn, + operator: "equals", + value: String(key), + valueType: "static", + })), + }, + autoFilter: { + enabled: true, + filterColumn: "company_code", + filterType: "company", + }, + }); + + if (response.data.success) { + const joinData = response.data.data?.data || []; + // 조인 컬럼 값을 키로 하는 Map 생성 + joinData.forEach((item: any) => { + const key = item[joinConfig.joinColumn]; + if (key) { + resultMap.set(String(key), item); + } + }); + console.log(`[SplitPanelLayout2] 조인 테이블 로드 완료: ${joinData.length}건`); + } + } catch (error) { + console.error(`[SplitPanelLayout2] 조인 테이블 로드 실패 (${joinConfig.joinTable}):`, error); + } + + return resultMap; + }, + [], + ); // 메인 데이터에 조인 테이블 데이터 병합 - const mergeJoinData = useCallback(( - mainData: any[], - joinConfig: JoinTableConfig, - joinDataMap: Map - ): any[] => { - return mainData.map((item) => { - const joinKey = item[joinConfig.mainColumn]; - const joinRow = joinDataMap.get(String(joinKey)); + const mergeJoinData = useCallback( + (mainData: any[], joinConfig: JoinTableConfig, joinDataMap: Map): any[] => { + return mainData.map((item) => { + const joinKey = item[joinConfig.mainColumn]; + const joinRow = joinDataMap.get(String(joinKey)); - if (joinRow && joinConfig.selectColumns) { - // 선택된 컬럼만 병합 - const mergedItem = { ...item }; - joinConfig.selectColumns.forEach((col) => { - // 조인 테이블명.컬럼명 형식으로 저장 (sourceTable 참조용) - const tableColumnKey = `${joinConfig.joinTable}.${col}`; - mergedItem[tableColumnKey] = joinRow[col]; - - // alias가 있으면 alias_컬럼명, 없으면 그냥 컬럼명으로도 저장 (하위 호환성) - const targetKey = joinConfig.alias ? `${joinConfig.alias}_${col}` : col; - // 메인 테이블에 같은 컬럼이 없으면 추가 - if (!(col in mergedItem)) { - mergedItem[col] = joinRow[col]; - } else if (joinConfig.alias) { - // 메인 테이블에 같은 컬럼이 있으면 alias로 추가 - mergedItem[targetKey] = joinRow[col]; - } - }); - console.log(`[SplitPanelLayout2] 조인 데이터 병합:`, { mainKey: joinKey, mergedKeys: Object.keys(mergedItem) }); - return mergedItem; - } + if (joinRow && joinConfig.selectColumns) { + // 선택된 컬럼만 병합 + const mergedItem = { ...item }; + joinConfig.selectColumns.forEach((col) => { + // 조인 테이블명.컬럼명 형식으로 저장 (sourceTable 참조용) + const tableColumnKey = `${joinConfig.joinTable}.${col}`; + mergedItem[tableColumnKey] = joinRow[col]; - return item; - }); - }, []); + // alias가 있으면 alias_컬럼명, 없으면 그냥 컬럼명으로도 저장 (하위 호환성) + const targetKey = joinConfig.alias ? `${joinConfig.alias}_${col}` : col; + // 메인 테이블에 같은 컬럼이 없으면 추가 + if (!(col in mergedItem)) { + mergedItem[col] = joinRow[col]; + } else if (joinConfig.alias) { + // 메인 테이블에 같은 컬럼이 있으면 alias로 추가 + mergedItem[targetKey] = joinRow[col]; + } + }); + console.log(`[SplitPanelLayout2] 조인 데이터 병합:`, { + mainKey: joinKey, + mergedKeys: Object.keys(mergedItem), + }); + return mergedItem; + } + + return item; + }); + }, + [], + ); // 우측 데이터 로드 (좌측 선택 항목 기반) - const loadRightData = useCallback(async (selectedItem: any) => { - if (!config.rightPanel?.tableName || !config.joinConfig?.leftColumn || !config.joinConfig?.rightColumn || !selectedItem) { - setRightData([]); - return; - } - - const joinValue = selectedItem[config.joinConfig.leftColumn]; - if (joinValue === undefined || joinValue === null) { - console.log(`[SplitPanelLayout2] 조인 값이 없음: ${config.joinConfig.leftColumn}`); - setRightData([]); - return; - } - - setRightLoading(true); - try { - console.log(`[SplitPanelLayout2] 우측 데이터 로드 시작: ${config.rightPanel.tableName}, ${config.joinConfig.rightColumn}=${joinValue}`); - - const response = await apiClient.post(`/table-management/tables/${config.rightPanel.tableName}/data`, { - page: 1, - size: 1000, // 전체 데이터 로드 - // dataFilter를 사용하여 정확한 값 매칭 (Entity 타입 검색 문제 회피) - dataFilter: { - enabled: true, - matchType: "all", - filters: [ - { - id: "join_filter", - columnName: config.joinConfig.rightColumn, - operator: "equals", - value: String(joinValue), - valueType: "static", - } - ], - }, - // 멀티테넌시: 자동으로 company_code 필터링 적용 - autoFilter: { - enabled: true, - filterColumn: "company_code", - filterType: "company", - }, - }); - - if (response.data.success) { - // API 응답 구조: { success: true, data: { data: [...], total, page, ... } } - let data = response.data.data?.data || []; - console.log(`[SplitPanelLayout2] 메인 데이터 로드 완료: ${data.length}건`); - - // 추가 조인 테이블 처리 - const joinTables = config.rightPanel?.joinTables || []; - if (joinTables.length > 0 && data.length > 0) { - console.log(`[SplitPanelLayout2] 조인 테이블 처리 시작: ${joinTables.length}개`); - - for (const joinTableConfig of joinTables) { - const joinDataMap = await loadJoinTableData(joinTableConfig, data); - if (joinDataMap.size > 0) { - data = mergeJoinData(data, joinTableConfig, joinDataMap); - } - } - - console.log(`[SplitPanelLayout2] 조인 데이터 병합 완료`); - } - - setRightData(data); - console.log(`[SplitPanelLayout2] 우측 데이터 로드 완료: ${data.length}건`); - } else { - console.error("[SplitPanelLayout2] 우측 데이터 로드 실패:", response.data.message); + const loadRightData = useCallback( + async (selectedItem: any) => { + if (!config.rightPanel?.tableName || !selectedItem) { setRightData([]); + return; } - } catch (error: any) { - console.error("[SplitPanelLayout2] 우측 데이터 로드 에러:", { - message: error?.message, - status: error?.response?.status, - statusText: error?.response?.statusText, - data: error?.response?.data, - config: { - url: error?.config?.url, - method: error?.config?.method, - data: error?.config?.data, + + // 복합키 또는 단일키 처리 + const joinKeys = config.joinConfig?.keys || []; + const hasCompositeKeys = joinKeys.length > 0; + const hasSingleKey = config.joinConfig?.leftColumn && config.joinConfig?.rightColumn; + + if (!hasCompositeKeys && !hasSingleKey) { + console.log(`[SplitPanelLayout2] 조인 설정이 없음`); + setRightData([]); + return; + } + + // 필터 배열 생성 + const filters: any[] = []; + + if (hasCompositeKeys) { + // 복합키 처리 + for (let i = 0; i < joinKeys.length; i++) { + const key = joinKeys[i]; + const joinValue = selectedItem[key.leftColumn]; + if (joinValue === undefined || joinValue === null) { + console.log(`[SplitPanelLayout2] 복합키 조인 값이 없음: ${key.leftColumn}`); + setRightData([]); + return; + } + filters.push({ + id: `join_filter_${i}`, + columnName: key.rightColumn, + operator: "equals", + value: String(joinValue), + valueType: "static", + }); } - }); - setRightData([]); - } finally { - setRightLoading(false); - } - }, [config.rightPanel?.tableName, config.rightPanel?.joinTables, config.joinConfig, loadJoinTableData, mergeJoinData]); + console.log( + `[SplitPanelLayout2] 복합키 조인: ${joinKeys.map((k) => `${k.leftColumn}→${k.rightColumn}`).join(", ")}`, + ); + } else { + // 단일키 처리 (하위 호환성) + const joinValue = selectedItem[config.joinConfig!.leftColumn!]; + if (joinValue === undefined || joinValue === null) { + console.log(`[SplitPanelLayout2] 조인 값이 없음: ${config.joinConfig!.leftColumn}`); + setRightData([]); + return; + } + filters.push({ + id: "join_filter", + columnName: config.joinConfig!.rightColumn, + operator: "equals", + value: String(joinValue), + valueType: "static", + }); + } + + setRightLoading(true); + try { + console.log( + `[SplitPanelLayout2] 우측 데이터 로드 시작: ${config.rightPanel.tableName}, 필터 ${filters.length}개`, + ); + + const response = await apiClient.post(`/table-management/tables/${config.rightPanel.tableName}/data`, { + page: 1, + size: 1000, // 전체 데이터 로드 + // dataFilter를 사용하여 정확한 값 매칭 (Entity 타입 검색 문제 회피) + dataFilter: { + enabled: true, + matchType: "all", + filters, + }, + // 멀티테넌시: 자동으로 company_code 필터링 적용 + autoFilter: { + enabled: true, + filterColumn: "company_code", + filterType: "company", + }, + }); + + if (response.data.success) { + // API 응답 구조: { success: true, data: { data: [...], total, page, ... } } + let data = response.data.data?.data || []; + console.log(`[SplitPanelLayout2] 메인 데이터 로드 완료: ${data.length}건`); + + // 추가 조인 테이블 처리 + const joinTables = config.rightPanel?.joinTables || []; + if (joinTables.length > 0 && data.length > 0) { + console.log(`[SplitPanelLayout2] 조인 테이블 처리 시작: ${joinTables.length}개`); + + for (const joinTableConfig of joinTables) { + const joinDataMap = await loadJoinTableData(joinTableConfig, data); + if (joinDataMap.size > 0) { + data = mergeJoinData(data, joinTableConfig, joinDataMap); + } + } + + console.log(`[SplitPanelLayout2] 조인 데이터 병합 완료`); + } + + setRightData(data); + console.log(`[SplitPanelLayout2] 우측 데이터 로드 완료: ${data.length}건`); + } else { + console.error("[SplitPanelLayout2] 우측 데이터 로드 실패:", response.data.message); + setRightData([]); + } + } catch (error: any) { + console.error("[SplitPanelLayout2] 우측 데이터 로드 에러:", { + message: error?.message, + status: error?.response?.status, + statusText: error?.response?.statusText, + data: error?.response?.data, + config: { + url: error?.config?.url, + method: error?.config?.method, + data: error?.config?.data, + }, + }); + setRightData([]); + } finally { + setRightLoading(false); + } + }, + [config.rightPanel?.tableName, config.rightPanel?.joinTables, config.joinConfig, loadJoinTableData, mergeJoinData], + ); // 좌측 패널 추가 버튼 클릭 const handleLeftAddClick = useCallback(() => { @@ -315,7 +356,7 @@ export const SplitPanelLayout2Component: React.FC { @@ -378,33 +425,36 @@ export const SplitPanelLayout2Component: React.FC { - // 수정용 모달 화면 ID 결정 (editModalScreenId 우선, 없으면 addModalScreenId 사용) - const modalScreenId = config.rightPanel?.editModalScreenId || config.rightPanel?.addModalScreenId; - - if (!modalScreenId) { - toast.error("연결된 모달 화면이 없습니다."); - return; - } + const handleEditItem = useCallback( + (item: any) => { + // 수정용 모달 화면 ID 결정 (editModalScreenId 우선, 없으면 addModalScreenId 사용) + const modalScreenId = config.rightPanel?.editModalScreenId || config.rightPanel?.addModalScreenId; - // EditModal 열기 이벤트 발생 (수정 모드) - const event = new CustomEvent("openEditModal", { - detail: { - screenId: modalScreenId, - title: "수정", - modalSize: "lg", - editData: item, // 기존 데이터 전달 - isCreateMode: false, // 수정 모드 - onSave: () => { - if (selectedLeftItem) { - loadRightData(selectedLeftItem); - } + if (!modalScreenId) { + toast.error("연결된 모달 화면이 없습니다."); + return; + } + + // EditModal 열기 이벤트 발생 (수정 모드) + const event = new CustomEvent("openEditModal", { + detail: { + screenId: modalScreenId, + title: "수정", + modalSize: "lg", + editData: item, // 기존 데이터 전달 + isCreateMode: false, // 수정 모드 + onSave: () => { + if (selectedLeftItem) { + loadRightData(selectedLeftItem); + } + }, }, - }, - }); - window.dispatchEvent(event); - console.log("[SplitPanelLayout2] 수정 모달 열기:", item); - }, [config.rightPanel?.editModalScreenId, config.rightPanel?.addModalScreenId, selectedLeftItem, loadRightData]); + }); + window.dispatchEvent(event); + console.log("[SplitPanelLayout2] 수정 모달 열기:", item); + }, + [config.rightPanel?.editModalScreenId, config.rightPanel?.addModalScreenId, selectedLeftItem, loadRightData], + ); // 우측 패널 삭제 버튼 클릭 (확인 다이얼로그 표시) const handleDeleteClick = useCallback((item: any) => { @@ -465,7 +515,15 @@ export const SplitPanelLayout2Component: React.FC { @@ -481,92 +539,107 @@ export const SplitPanelLayout2Component: React.FC { - switch (btn.action) { - case "add": - if (btn.modalScreenId) { - // 데이터 전달 필드 설정 - const initialData: Record = {}; - if (selectedLeftItem && config.dataTransferFields) { - for (const field of config.dataTransferFields) { - if (field.sourceColumn && field.targetColumn) { - initialData[field.targetColumn] = selectedLeftItem[field.sourceColumn]; + const handleActionButton = useCallback( + (btn: ActionButtonConfig) => { + switch (btn.action) { + case "add": + if (btn.modalScreenId) { + // 데이터 전달 필드 설정 + const initialData: Record = {}; + if (selectedLeftItem && config.dataTransferFields) { + for (const field of config.dataTransferFields) { + if (field.sourceColumn && field.targetColumn) { + initialData[field.targetColumn] = selectedLeftItem[field.sourceColumn]; + } } } - } - const event = new CustomEvent("openEditModal", { - detail: { - screenId: btn.modalScreenId, - title: btn.label || "추가", - modalSize: "lg", - editData: initialData, - isCreateMode: true, - onSave: () => { - if (selectedLeftItem) { - loadRightData(selectedLeftItem); - } + const event = new CustomEvent("openEditModal", { + detail: { + screenId: btn.modalScreenId, + title: btn.label || "추가", + modalSize: "lg", + editData: initialData, + isCreateMode: true, + onSave: () => { + if (selectedLeftItem) { + loadRightData(selectedLeftItem); + } + }, }, - }, - }); - window.dispatchEvent(event); - } - break; - - case "edit": - // 선택된 항목이 1개일 때만 수정 - if (selectedRightItems.size === 1) { - const pkColumn = getPrimaryKeyColumn(); - const selectedId = Array.from(selectedRightItems)[0]; - const item = rightData.find((d) => d[pkColumn] === selectedId); - if (item) { - handleEditItem(item); + }); + window.dispatchEvent(event); } - } else if (selectedRightItems.size > 1) { - toast.error("수정할 항목을 1개만 선택해주세요."); - } else { - toast.error("수정할 항목을 선택해주세요."); - } - break; + break; - case "delete": - case "bulk-delete": - handleBulkDeleteClick(); - break; + case "edit": + // 선택된 항목이 1개일 때만 수정 + if (selectedRightItems.size === 1) { + const pkColumn = getPrimaryKeyColumn(); + const selectedId = Array.from(selectedRightItems)[0]; + const item = rightData.find((d) => d[pkColumn] === selectedId); + if (item) { + handleEditItem(item); + } + } else if (selectedRightItems.size > 1) { + toast.error("수정할 항목을 1개만 선택해주세요."); + } else { + toast.error("수정할 항목을 선택해주세요."); + } + break; - case "custom": - // 커스텀 액션 (추후 확장) - console.log("[SplitPanelLayout2] 커스텀 액션:", btn); - break; + case "delete": + case "bulk-delete": + handleBulkDeleteClick(); + break; - default: - break; - } - }, [selectedLeftItem, config.dataTransferFields, loadRightData, selectedRightItems, getPrimaryKeyColumn, rightData, handleEditItem, handleBulkDeleteClick]); + case "custom": + // 커스텀 액션 (추후 확장) + console.log("[SplitPanelLayout2] 커스텀 액션:", btn); + break; + + default: + break; + } + }, + [ + selectedLeftItem, + config.dataTransferFields, + loadRightData, + selectedRightItems, + getPrimaryKeyColumn, + rightData, + handleEditItem, + handleBulkDeleteClick, + ], + ); // 컬럼 라벨 로드 - const loadColumnLabels = useCallback(async (tableName: string, setLabels: (labels: Record) => void) => { - if (!tableName) return; + const loadColumnLabels = useCallback( + async (tableName: string, setLabels: (labels: Record) => void) => { + if (!tableName) return; - try { - const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); - if (response.data.success) { - const labels: Record = {}; - // API 응답 구조: { success: true, data: { columns: [...] } } - const columns = response.data.data?.columns || []; - columns.forEach((col: any) => { - const colName = col.column_name || col.columnName; - const colLabel = col.column_label || col.columnLabel || colName; - if (colName) { - labels[colName] = colLabel; - } - }); - setLabels(labels); + try { + const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); + if (response.data.success) { + const labels: Record = {}; + // API 응답 구조: { success: true, data: { columns: [...] } } + const columns = response.data.data?.columns || []; + columns.forEach((col: any) => { + const colName = col.column_name || col.columnName; + const colLabel = col.column_label || col.columnLabel || colName; + if (colName) { + labels[colName] = colLabel; + } + }); + setLabels(labels); + } + } catch (error) { + console.error("[SplitPanelLayout2] 컬럼 라벨 로드 실패:", error); } - } catch (error) { - console.error("[SplitPanelLayout2] 컬럼 라벨 로드 실패:", error); - } - }, []); + }, + [], + ); // 계층 구조 빌드 const buildHierarchy = (data: any[], idColumn: string, parentColumn: string): any[] => { @@ -594,22 +667,25 @@ export const SplitPanelLayout2Component: React.FC { - setSelectedLeftItem(item); - loadRightData(item); + const handleLeftItemSelect = useCallback( + (item: any) => { + setSelectedLeftItem(item); + loadRightData(item); - // ScreenContext DataProvider 등록 (버튼에서 접근 가능하도록) - if (screenContext && !isDesignMode) { - screenContext.registerDataProvider(component.id, { - componentId: component.id, - componentType: "split-panel-layout2", - getSelectedData: () => [item], - getAllData: () => leftData, - clearSelection: () => setSelectedLeftItem(null), - }); - console.log(`[SplitPanelLayout2] DataProvider 등록: ${component.id}`); - } - }, [isDesignMode, screenContext, component.id, leftData, loadRightData]); + // ScreenContext DataProvider 등록 (버튼에서 접근 가능하도록) + if (screenContext && !isDesignMode) { + screenContext.registerDataProvider(component.id, { + componentId: component.id, + componentType: "split-panel-layout2", + getSelectedData: () => [item], + getAllData: () => leftData, + clearSelection: () => setSelectedLeftItem(null), + }); + console.log(`[SplitPanelLayout2] DataProvider 등록: ${component.id}`); + } + }, + [isDesignMode, screenContext, component.id, leftData, loadRightData], + ); // 항목 확장/축소 토글 const toggleExpand = useCallback((itemId: string) => { @@ -678,36 +754,45 @@ export const SplitPanelLayout2Component: React.FC { - if (checked) { - const pkColumn = getPrimaryKeyColumn(); - const allIds = new Set(filteredRightData.map((item) => item[pkColumn])); - setSelectedRightItems(allIds); - } else { - setSelectedRightItems(new Set()); - } - }, [filteredRightData, getPrimaryKeyColumn]); + const handleSelectAll = useCallback( + (checked: boolean) => { + if (checked) { + const pkColumn = getPrimaryKeyColumn(); + const allIds = new Set(filteredRightData.map((item) => item[pkColumn])); + setSelectedRightItems(allIds); + } else { + setSelectedRightItems(new Set()); + } + }, + [filteredRightData, getPrimaryKeyColumn], + ); // 리사이즈 핸들러 - const handleResizeStart = useCallback((e: React.MouseEvent) => { - if (!config.resizable) return; - e.preventDefault(); - setIsResizing(true); - }, [config.resizable]); + const handleResizeStart = useCallback( + (e: React.MouseEvent) => { + if (!config.resizable) return; + e.preventDefault(); + setIsResizing(true); + }, + [config.resizable], + ); - const handleResizeMove = useCallback((e: MouseEvent) => { - if (!isResizing) return; + const handleResizeMove = useCallback( + (e: MouseEvent) => { + if (!isResizing) return; - const container = document.getElementById(`split-panel-${component.id}`); - if (!container) return; + const container = document.getElementById(`split-panel-${component.id}`); + if (!container) return; - const rect = container.getBoundingClientRect(); - const newPosition = ((e.clientX - rect.left) / rect.width) * 100; - const minLeft = (config.minLeftWidth || 200) / rect.width * 100; - const minRight = (config.minRightWidth || 300) / rect.width * 100; + const rect = container.getBoundingClientRect(); + const newPosition = ((e.clientX - rect.left) / rect.width) * 100; + const minLeft = ((config.minLeftWidth || 200) / rect.width) * 100; + const minRight = ((config.minRightWidth || 300) / rect.width) * 100; - setSplitPosition(Math.max(minLeft, Math.min(100 - minRight, newPosition))); - }, [isResizing, component.id, config.minLeftWidth, config.minRightWidth]); + setSplitPosition(Math.max(minLeft, Math.min(100 - minRight, newPosition))); + }, + [isResizing, component.id, config.minLeftWidth, config.minRightWidth], + ); const handleResizeEnd = useCallback(() => { setIsResizing(false); @@ -732,7 +817,14 @@ export const SplitPanelLayout2Component: React.FC { @@ -744,35 +836,38 @@ export const SplitPanelLayout2Component: React.FC { - // col.name이 "테이블명.컬럼명" 형식인 경우 처리 - const actualColName = col.name.includes(".") ? col.name.split(".")[1] : col.name; - const tableFromName = col.name.includes(".") ? col.name.split(".")[0] : null; - const effectiveSourceTable = col.sourceTable || tableFromName; - - // sourceTable이 설정되어 있고, 메인 테이블이 아닌 경우 - if (effectiveSourceTable && effectiveSourceTable !== config.rightPanel?.tableName) { - // 1. 테이블명.컬럼명 형식으로 먼저 시도 (mergeJoinData에서 저장한 형식) - const tableColumnKey = `${effectiveSourceTable}.${actualColName}`; - if (item[tableColumnKey] !== undefined) { - return item[tableColumnKey]; - } - // 2. 조인 테이블의 alias가 설정된 경우 alias_컬럼명으로 시도 - const joinTable = config.rightPanel?.joinTables?.find(jt => jt.joinTable === effectiveSourceTable); - if (joinTable?.alias) { - const aliasKey = `${joinTable.alias}_${actualColName}`; - if (item[aliasKey] !== undefined) { - return item[aliasKey]; + const getColumnValue = useCallback( + (item: any, col: ColumnConfig): any => { + // col.name이 "테이블명.컬럼명" 형식인 경우 처리 + const actualColName = col.name.includes(".") ? col.name.split(".")[1] : col.name; + const tableFromName = col.name.includes(".") ? col.name.split(".")[0] : null; + const effectiveSourceTable = col.sourceTable || tableFromName; + + // sourceTable이 설정되어 있고, 메인 테이블이 아닌 경우 + if (effectiveSourceTable && effectiveSourceTable !== config.rightPanel?.tableName) { + // 1. 테이블명.컬럼명 형식으로 먼저 시도 (mergeJoinData에서 저장한 형식) + const tableColumnKey = `${effectiveSourceTable}.${actualColName}`; + if (item[tableColumnKey] !== undefined) { + return item[tableColumnKey]; + } + // 2. 조인 테이블의 alias가 설정된 경우 alias_컬럼명으로 시도 + const joinTable = config.rightPanel?.joinTables?.find((jt) => jt.joinTable === effectiveSourceTable); + if (joinTable?.alias) { + const aliasKey = `${joinTable.alias}_${actualColName}`; + if (item[aliasKey] !== undefined) { + return item[aliasKey]; + } + } + // 3. 그냥 컬럼명으로 시도 (메인 테이블에 없는 경우 조인 데이터가 직접 들어감) + if (item[actualColName] !== undefined) { + return item[actualColName]; } } - // 3. 그냥 컬럼명으로 시도 (메인 테이블에 없는 경우 조인 데이터가 직접 들어감) - if (item[actualColName] !== undefined) { - return item[actualColName]; - } - } - // 4. 기본: 컬럼명으로 직접 접근 - return item[actualColName]; - }, [config.rightPanel?.tableName, config.rightPanel?.joinTables]); + // 4. 기본: 컬럼명으로 직접 접근 + return item[actualColName]; + }, + [config.rightPanel?.tableName, config.rightPanel?.joinTables], + ); // 값 포맷팅 const formatValue = (value: any, format?: ColumnConfig["format"]): string => { @@ -783,9 +878,7 @@ export const SplitPanelLayout2Component: React.FC - col.displayRow === "name" || (!col.displayRow && idx === 0) + const nameRowColumns = displayColumns.filter( + (col, idx) => col.displayRow === "name" || (!col.displayRow && idx === 0), ); - const infoRowColumns = displayColumns.filter((col, idx) => - col.displayRow === "info" || (!col.displayRow && idx > 0) + const infoRowColumns = displayColumns.filter( + (col, idx) => col.displayRow === "info" || (!col.displayRow && idx > 0), ); // 이름 행의 첫 번째 값 (주요 표시 값) @@ -836,9 +929,9 @@ export const SplitPanelLayout2Component: React.FC
handleLeftItemSelect(item)} @@ -846,16 +939,16 @@ export const SplitPanelLayout2Component: React.FC { e.stopPropagation(); toggleExpand(String(itemId)); }} > {isExpanded ? ( - + ) : ( - + )} ) : ( @@ -863,21 +956,19 @@ export const SplitPanelLayout2Component: React.FC + {/* 내용 */} -
+
{/* 이름 행 (Name Row) */}
- - {primaryValue || "이름 없음"} - + {primaryValue || "이름 없음"} {/* 이름 행의 추가 컬럼들 (배지 스타일) */} {nameRowColumns.slice(1).map((col, idx) => { const value = item[col.name]; if (!value) return null; return ( - + {formatValue(value, col.format)} ); @@ -885,20 +976,24 @@ export const SplitPanelLayout2Component: React.FC {/* 정보 행 (Info Row) */} {infoRowColumns.length > 0 && ( -
- {infoRowColumns.map((col, idx) => { - const value = item[col.name]; - if (!value) return null; - return ( - - {formatValue(value, col.format)} - - ); - }).filter(Boolean).reduce((acc: React.ReactNode[], curr, idx) => { - if (idx > 0) acc.push(|); - acc.push(curr); - return acc; - }, [])} +
+ {infoRowColumns + .map((col, idx) => { + const value = item[col.name]; + if (!value) return null; + return {formatValue(value, col.format)}; + }) + .filter(Boolean) + .reduce((acc: React.ReactNode[], curr, idx) => { + if (idx > 0) + acc.push( + + | + , + ); + acc.push(curr); + return acc; + }, [])}
)}
@@ -924,15 +1019,15 @@ export const SplitPanelLayout2Component: React.FC - col.displayRow === "name" || (!col.displayRow && idx === 0) + const nameRowColumns = displayColumns.filter( + (col, idx) => col.displayRow === "name" || (!col.displayRow && idx === 0), ); - const infoRowColumns = displayColumns.filter((col, idx) => - col.displayRow === "info" || (!col.displayRow && idx > 0) + const infoRowColumns = displayColumns.filter( + (col, idx) => col.displayRow === "info" || (!col.displayRow && idx > 0), ); return ( - +
{/* 체크박스 */} @@ -956,7 +1051,7 @@ export const SplitPanelLayout2Component: React.FC - {col.label || col.name}: + {col.label || col.name}: {formatValue(value, col.format)} ); @@ -965,7 +1060,7 @@ export const SplitPanelLayout2Component: React.FC 0 && ( -
+
{infoRowColumns.map((col, idx) => { const value = getColumnValue(item, col); if (value === null || value === undefined) return null; @@ -990,13 +1085,13 @@ export const SplitPanelLayout2Component: React.FC + {formatValue(value, col.format)} ); } return ( - + {formatValue(value, col.format)} ); @@ -1005,7 +1100,7 @@ export const SplitPanelLayout2Component: React.FC 0 && ( -
+
{infoRowColumns.map((col, idx) => { const value = getColumnValue(item, col); if (value === null || value === undefined) return null; @@ -1024,20 +1119,15 @@ export const SplitPanelLayout2Component: React.FC {config.rightPanel?.showEditButton && ( - )} {config.rightPanel?.showDeleteButton && ( -