"use client"; import React, { useState, useEffect, useMemo, useCallback } from "react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { Checkbox } from "@/components/ui/checkbox"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Check, ChevronsUpDown, Search, Plus, X, ChevronUp, ChevronDown, Type, Database, Info, } from "lucide-react"; import { cn } from "@/lib/utils"; import { ComponentData } from "@/types/screen"; import { apiClient } from "@/lib/api/client"; import { QuickInsertConfigSection } from "../QuickInsertConfigSection"; import { getApprovalDefinitions, type ApprovalDefinition } from "@/lib/api/approval"; import type { ButtonTabProps, TitleBlock, ScreenOption } from "./types"; /** 액션 탭: 액션 유형별 상세 설정 (모달/이동/엑셀/결재/이벤트 등) */ export const ActionTab: React.FC = ({ component, onUpdateProperty, allComponents, currentTableName, currentScreenCompanyCode, }) => { const config = component.componentConfig || {}; const [localInputs, setLocalInputs] = useState({ actionType: String(config.action?.type || "save"), modalTitle: String(config.action?.modalTitle || ""), modalDescription: String(config.action?.modalDescription || ""), editModalTitle: String(config.action?.editModalTitle || ""), editModalDescription: String(config.action?.editModalDescription || ""), targetUrl: String(config.action?.targetUrl || ""), groupByColumn: String(config.action?.groupByColumns?.[0] || ""), }); const [screens, setScreens] = useState([]); const [screensLoading, setScreensLoading] = useState(false); const [modalScreenOpen, setModalScreenOpen] = useState(false); const [navScreenOpen, setNavScreenOpen] = useState(false); const [modalSearchTerm, setModalSearchTerm] = useState(""); const [navSearchTerm, setNavSearchTerm] = useState(""); // 테이블 컬럼 목록 상태 const [tableColumns, setTableColumns] = useState([]); const [columnsLoading, setColumnsLoading] = useState(false); const [displayColumnOpen, setDisplayColumnOpen] = useState(false); const [displayColumnSearch, setDisplayColumnSearch] = useState(""); // 🆕 제목 블록 빌더 상태 const [titleBlocks, setTitleBlocks] = useState([]); const [availableTables, setAvailableTables] = useState>([]); // 시스템의 모든 테이블 목록 const [tableColumnsMap, setTableColumnsMap] = useState>>({}); const [blockTableSearches, setBlockTableSearches] = useState>({}); // 블록별 테이블 검색어 const [blockColumnSearches, setBlockColumnSearches] = useState>({}); // 블록별 컬럼 검색어 const [blockTablePopoverOpen, setBlockTablePopoverOpen] = useState>({}); // 블록별 테이블 Popover 열림 상태 const [blockColumnPopoverOpen, setBlockColumnPopoverOpen] = useState>({}); // 블록별 컬럼 Popover 열림 상태 // 🆕 데이터 전달 필드 매핑용 상태 (멀티 테이블 매핑 지원) const [mappingSourceColumnsMap, setMappingSourceColumnsMap] = useState< Record> >({}); const [mappingTargetColumns, setMappingTargetColumns] = useState>([]); const [mappingSourcePopoverOpen, setMappingSourcePopoverOpen] = useState>({}); const [mappingTargetPopoverOpen, setMappingTargetPopoverOpen] = useState>({}); const [mappingSourceSearch, setMappingSourceSearch] = useState>({}); const [mappingTargetSearch, setMappingTargetSearch] = useState>({}); const [activeMappingGroupIndex, setActiveMappingGroupIndex] = useState(0); // 🆕 openModalWithData 전용 필드 매핑 상태 const [modalSourceColumns, setModalSourceColumns] = useState>([]); const [modalTargetColumns, setModalTargetColumns] = useState>([]); const [modalSourcePopoverOpen, setModalSourcePopoverOpen] = useState>({}); const [modalTargetPopoverOpen, setModalTargetPopoverOpen] = useState>({}); // 결재 유형 목록 상태 const [approvalDefinitions, setApprovalDefinitions] = useState([]); const [approvalDefinitionsLoading, setApprovalDefinitionsLoading] = useState(false); // 🆕 그룹화 컬럼 선택용 상태 const [currentTableColumns, setCurrentTableColumns] = useState>([]); const [groupByColumnOpen, setGroupByColumnOpen] = useState(false); const [groupByColumnSearch, setGroupByColumnSearch] = useState(""); const [modalSourceSearch, setModalSourceSearch] = useState>({}); const [modalTargetSearch, setModalTargetSearch] = useState>({}); // 🆕 modal 액션용 필드 매핑 상태 const [modalActionSourceTable, setModalActionSourceTable] = useState(null); const [modalActionTargetTable, setModalActionTargetTable] = useState(null); const [modalActionSourceColumns, setModalActionSourceColumns] = useState>([]); const [modalActionTargetColumns, setModalActionTargetColumns] = useState>([]); const [modalActionFieldMappings, setModalActionFieldMappings] = useState< Array<{ sourceField: string; targetField: string }> >([]); const [modalFieldMappingSourceOpen, setModalFieldMappingSourceOpen] = useState>({}); const [modalFieldMappingTargetOpen, setModalFieldMappingTargetOpen] = useState>({}); const [modalFieldMappingSourceSearch, setModalFieldMappingSourceSearch] = useState>({}); const [modalFieldMappingTargetSearch, setModalFieldMappingTargetSearch] = useState>({}); // 컴포넌트 prop 변경 시 로컬 상태 동기화 (Input만) useEffect(() => { const latestConfig = component.componentConfig || {}; const latestAction = latestConfig.action || {}; setLocalInputs({ actionType: String(latestAction.type || "save"), modalTitle: String(latestAction.modalTitle || ""), modalDescription: String(latestAction.modalDescription || ""), editModalTitle: String(latestAction.editModalTitle || ""), editModalDescription: String(latestAction.editModalDescription || ""), targetUrl: String(latestAction.targetUrl || ""), groupByColumn: String(latestAction.groupByColumns?.[0] || ""), }); // 🆕 제목 블록 초기화 if (latestAction.modalTitleBlocks && latestAction.modalTitleBlocks.length > 0) { setTitleBlocks(latestAction.modalTitleBlocks); } else { // 기본값: 빈 배열 setTitleBlocks([]); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [component.id, component.componentConfig?.action?.type]); // 🆕 제목 블록 핸들러 const addTextBlock = () => { const newBlock: TitleBlock = { id: `text-${Date.now()}`, type: "text", value: "", }; const updatedBlocks = [...titleBlocks, newBlock]; setTitleBlocks(updatedBlocks); onUpdateProperty("componentConfig.action.modalTitleBlocks", updatedBlocks); }; const addFieldBlock = () => { const newBlock: TitleBlock = { id: `field-${Date.now()}`, type: "field", value: "", tableName: "", label: "", }; const updatedBlocks = [...titleBlocks, newBlock]; setTitleBlocks(updatedBlocks); onUpdateProperty("componentConfig.action.modalTitleBlocks", updatedBlocks); }; const updateBlock = (id: string, updates: Partial) => { const updatedBlocks = titleBlocks.map((block) => (block.id === id ? { ...block, ...updates } : block)); setTitleBlocks(updatedBlocks); onUpdateProperty("componentConfig.action.modalTitleBlocks", updatedBlocks); }; const removeBlock = (id: string) => { const updatedBlocks = titleBlocks.filter((block) => block.id !== id); setTitleBlocks(updatedBlocks); onUpdateProperty("componentConfig.action.modalTitleBlocks", updatedBlocks); }; const moveBlockUp = (id: string) => { const index = titleBlocks.findIndex((b) => b.id === id); if (index <= 0) return; const newBlocks = [...titleBlocks]; [newBlocks[index - 1], newBlocks[index]] = [newBlocks[index], newBlocks[index - 1]]; setTitleBlocks(newBlocks); onUpdateProperty("componentConfig.action.modalTitleBlocks", newBlocks); }; const moveBlockDown = (id: string) => { const index = titleBlocks.findIndex((b) => b.id === id); if (index < 0 || index >= titleBlocks.length - 1) return; const newBlocks = [...titleBlocks]; [newBlocks[index], newBlocks[index + 1]] = [newBlocks[index + 1], newBlocks[index]]; setTitleBlocks(newBlocks); onUpdateProperty("componentConfig.action.modalTitleBlocks", newBlocks); }; // 🆕 제목 미리보기 생성 const generateTitlePreview = (): string => { if (titleBlocks.length === 0) return "(제목 없음)"; return titleBlocks .map((block) => { if (block.type === "text") { return block.value || "(텍스트)"; } else { return block.label || block.value || "(필드)"; } }) .join(""); }; // 🆕 시스템의 모든 테이블 목록 로드 useEffect(() => { const fetchAllTables = async () => { try { const response = await apiClient.get("/table-management/tables"); if (response.data.success && response.data.data) { const tables = response.data.data.map((table: any) => ({ name: table.tableName, label: table.displayName || table.tableName, })); setAvailableTables(tables); } } catch (error) { console.error("테이블 목록 로드 실패:", error); } }; fetchAllTables(); }, []); // 🆕 특정 테이블의 컬럼 로드 const loadTableColumns = async (tableName: string) => { if (!tableName || tableColumnsMap[tableName]) return; try { const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); console.log(`📥 테이블 ${tableName} 컬럼 응답:`, response.data); if (response.data.success) { // data가 배열인지 확인 let columnData = response.data.data; // data.columns 형태일 수도 있음 if (!Array.isArray(columnData) && columnData?.columns) { columnData = columnData.columns; } // data.data 형태일 수도 있음 if (!Array.isArray(columnData) && columnData?.data) { columnData = columnData.data; } if (Array.isArray(columnData)) { const columns = columnData.map((col: any) => { const name = col.name || col.columnName; const label = col.displayName || col.label || col.columnLabel || name; console.log(` - 컬럼: ${name} → "${label}"`); return { name, label }; }); setTableColumnsMap((prev) => ({ ...prev, [tableName]: columns })); console.log(`✅ 테이블 ${tableName} 컬럼 로드 성공:`, columns.length, "개"); } else { console.error("❌ 컬럼 데이터가 배열이 아닙니다:", columnData); } } } catch (error) { console.error("컬럼 로드 실패:", error); } }; // 멀티 테이블 매핑: 소스/타겟 테이블 컬럼 로드 const loadMappingColumns = useCallback(async (tableName: string): Promise> => { try { const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); if (response.data.success) { let columnData = response.data.data; if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns; if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data; if (Array.isArray(columnData)) { return columnData.map((col: any) => ({ name: col.name || col.columnName, label: col.displayName || col.label || col.columnLabel || col.name || col.columnName, })); } } } catch (error) { console.error(`테이블 ${tableName} 컬럼 로드 실패:`, error); } return []; }, []); useEffect(() => { const multiTableMappings: Array<{ sourceTable: string }> = config.action?.dataTransfer?.multiTableMappings || []; const legacySourceTable = config.action?.dataTransfer?.sourceTable; const targetTable = config.action?.dataTransfer?.targetTable; const loadAll = async () => { const sourceTableNames = multiTableMappings.map((m) => m.sourceTable).filter(Boolean); if (legacySourceTable && !sourceTableNames.includes(legacySourceTable)) { sourceTableNames.push(legacySourceTable); } const newMap: Record> = {}; for (const tbl of sourceTableNames) { if (!mappingSourceColumnsMap[tbl]) { newMap[tbl] = await loadMappingColumns(tbl); } } if (Object.keys(newMap).length > 0) { setMappingSourceColumnsMap((prev) => ({ ...prev, ...newMap })); } if (targetTable && mappingTargetColumns.length === 0) { const cols = await loadMappingColumns(targetTable); setMappingTargetColumns(cols); } }; loadAll(); }, [ config.action?.dataTransfer?.multiTableMappings, config.action?.dataTransfer?.sourceTable, config.action?.dataTransfer?.targetTable, loadMappingColumns, ]); // 🆕 modal 액션: 대상 화면 테이블 조회 및 필드 매핑 로드 useEffect(() => { const actionType = config.action?.type; if (actionType !== "modal") return; const autoDetect = config.action?.autoDetectDataSource; if (!autoDetect) { // 데이터 전달이 비활성화되면 상태 초기화 setModalActionSourceTable(null); setModalActionTargetTable(null); setModalActionSourceColumns([]); setModalActionTargetColumns([]); return; } const targetScreenId = config.action?.targetScreenId; if (!targetScreenId) return; const loadModalActionMappingData = async () => { // 1. 소스 테이블 감지 (현재 화면) let sourceTableName: string | null = currentTableName || null; // allComponents에서 분할패널/테이블리스트/통합목록 감지 for (const comp of allComponents) { const compType = comp.componentType || (comp as any).componentConfig?.type; const compConfig = (comp as any).componentConfig || {}; if (compType === "split-panel-layout" || compType === "screen-split-panel") { sourceTableName = compConfig.leftPanel?.tableName || compConfig.tableName || null; if (sourceTableName) break; } if (compType === "table-list") { sourceTableName = compConfig.tableName || compConfig.selectedTable || null; if (sourceTableName) break; } if (compType === "v2-list") { sourceTableName = compConfig.dataSource?.table || compConfig.tableName || null; if (sourceTableName) break; } } setModalActionSourceTable(sourceTableName); // 2. 대상 화면의 테이블 조회 let targetTableName: string | null = null; try { const screenResponse = await apiClient.get(`/screen-management/screens/${targetScreenId}`); if (screenResponse.data.success && screenResponse.data.data) { targetTableName = screenResponse.data.data.tableName || null; } else if (screenResponse.data?.tableName) { // 직접 데이터 반환 형식인 경우 targetTableName = screenResponse.data.tableName || null; } } catch (error) { console.error("대상 화면 정보 로드 실패:", error); } setModalActionTargetTable(targetTableName); // 3. 소스 테이블 컬럼 로드 if (sourceTableName) { try { const response = await apiClient.get(`/table-management/tables/${sourceTableName}/columns`); if (response.data.success) { let columnData = response.data.data; if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns; if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data; if (Array.isArray(columnData)) { const columns = columnData.map((col: any) => ({ name: col.name || col.columnName, label: col.displayName || col.label || col.columnLabel || col.name || col.columnName, })); setModalActionSourceColumns(columns); } } } catch (error) { console.error("소스 테이블 컬럼 로드 실패:", error); } } // 4. 대상 테이블 컬럼 로드 if (targetTableName) { try { const response = await apiClient.get(`/table-management/tables/${targetTableName}/columns`); if (response.data.success) { let columnData = response.data.data; if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns; if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data; if (Array.isArray(columnData)) { const columns = columnData.map((col: any) => ({ name: col.name || col.columnName, label: col.displayName || col.label || col.columnLabel || col.name || col.columnName, })); setModalActionTargetColumns(columns); } } } catch (error) { console.error("대상 테이블 컬럼 로드 실패:", error); } } // 5. 기존 필드 매핑 로드 또는 자동 매핑 생성 const existingMappings = config.action?.fieldMappings || []; if (existingMappings.length > 0) { setModalActionFieldMappings(existingMappings); } else if (sourceTableName && targetTableName && sourceTableName === targetTableName) { // 테이블이 같으면 자동 매핑 (동일 컬럼명) setModalActionFieldMappings([]); // 빈 배열 = 자동 매핑 } }; loadModalActionMappingData(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [ config.action?.type, config.action?.autoDetectDataSource, config.action?.targetScreenId, currentTableName, allComponents, ]); // 🆕 현재 테이블 컬럼 로드 (그룹화 컬럼 선택용) useEffect(() => { if (!currentTableName) return; const loadCurrentTableColumns = async () => { try { const response = await apiClient.get(`/table-management/tables/${currentTableName}/columns`); if (response.data.success) { let columnData = response.data.data; if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns; if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data; if (Array.isArray(columnData)) { const columns = columnData.map((col: any) => ({ name: col.name || col.columnName, label: col.displayName || col.label || col.columnLabel || col.name || col.columnName, })); setCurrentTableColumns(columns); } } } catch (error) { console.error("현재 테이블 컬럼 로드 실패:", error); } }; loadCurrentTableColumns(); }, [currentTableName]); // 🆕 openModalWithData 소스/타겟 테이블 컬럼 로드 useEffect(() => { const actionType = config.action?.type; if (actionType !== "openModalWithData") return; const loadModalMappingColumns = async () => { // 소스 테이블: 현재 화면의 분할 패널 또는 테이블에서 감지 let sourceTableName: string | null = null; console.log("[openModalWithData] 컬럼 로드 시작:", { allComponentsCount: allComponents.length, currentTableName, targetScreenId: config.action?.targetScreenId, }); // 모든 컴포넌트 타입 로그 allComponents.forEach((comp, idx) => { const compType = comp.componentType || (comp as any).componentConfig?.type; console.log( ` [${idx}] componentType: ${compType}, tableName: ${(comp as any).componentConfig?.tableName || (comp as any).componentConfig?.leftPanel?.tableName || "N/A"}`, ); }); for (const comp of allComponents) { const compType = comp.componentType || (comp as any).componentConfig?.type; const compConfig = (comp as any).componentConfig || {}; // 분할 패널 타입들 (다양한 경로에서 테이블명 추출) if (compType === "split-panel-layout" || compType === "screen-split-panel") { sourceTableName = compConfig?.leftPanel?.tableName || compConfig?.leftTableName || compConfig?.tableName; if (sourceTableName) { console.log(`✅ [openModalWithData] split-panel-layout에서 소스 테이블 감지: ${sourceTableName}`); break; } } // split-panel-layout2 타입 (새로운 분할 패널) if (compType === "split-panel-layout2") { sourceTableName = compConfig?.leftPanel?.tableName || compConfig?.tableName || compConfig?.leftTableName; if (sourceTableName) { console.log(`✅ [openModalWithData] split-panel-layout2에서 소스 테이블 감지: ${sourceTableName}`); break; } } // 테이블 리스트 타입 if (compType === "table-list") { sourceTableName = compConfig?.tableName; if (sourceTableName) { console.log(`✅ [openModalWithData] table-list에서 소스 테이블 감지: ${sourceTableName}`); break; } } // 🆕 모든 컴포넌트에서 tableName 찾기 (폴백) if (!sourceTableName && compConfig?.tableName) { sourceTableName = compConfig.tableName; console.log(`✅ [openModalWithData] ${compType}에서 소스 테이블 감지 (폴백): ${sourceTableName}`); break; } } // 여전히 없으면 currentTableName 사용 (화면 레벨 테이블명) if (!sourceTableName && currentTableName) { sourceTableName = currentTableName; console.log(`✅ [openModalWithData] currentTableName에서 소스 테이블 사용: ${sourceTableName}`); } if (!sourceTableName) { console.warn("[openModalWithData] 소스 테이블을 찾을 수 없습니다."); } // 소스 테이블 컬럼 로드 if (sourceTableName) { try { const response = await apiClient.get(`/table-management/tables/${sourceTableName}/columns`); if (response.data.success) { let columnData = response.data.data; if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns; if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data; if (Array.isArray(columnData)) { const columns = columnData.map((col: any) => ({ name: col.name || col.columnName || col.column_name, label: col.displayName || col.label || col.columnLabel || col.display_name || col.name || col.columnName || col.column_name, })); setModalSourceColumns(columns); console.log(`✅ [openModalWithData] 소스 테이블(${sourceTableName}) 컬럼 로드 완료:`, columns.length); } } } catch (error) { console.error("소스 테이블 컬럼 로드 실패:", error); } } // 타겟 화면의 테이블 컬럼 로드 const targetScreenId = config.action?.targetScreenId; if (targetScreenId) { try { // 타겟 화면 정보 가져오기 const screenResponse = await apiClient.get(`/screen-management/screens/${targetScreenId}`); console.log("[openModalWithData] 타겟 화면 응답:", screenResponse.data); if (screenResponse.data.success && screenResponse.data.data) { const targetTableName = screenResponse.data.data.tableName; console.log("[openModalWithData] 타겟 화면 테이블명:", targetTableName); if (targetTableName) { const columnResponse = await apiClient.get(`/table-management/tables/${targetTableName}/columns`); if (columnResponse.data.success) { let columnData = columnResponse.data.data; if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns; if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data; if (Array.isArray(columnData)) { const columns = columnData.map((col: any) => ({ name: col.name || col.columnName || col.column_name, label: col.displayName || col.label || col.columnLabel || col.display_name || col.name || col.columnName || col.column_name, })); setModalTargetColumns(columns); console.log(`✅ [openModalWithData] 타겟 테이블(${targetTableName}) 컬럼 로드 완료:`, columns.length); } } } else { console.warn("[openModalWithData] 타겟 화면에 테이블명이 없습니다."); } } } catch (error) { console.error("타겟 화면 테이블 컬럼 로드 실패:", error); } } else { console.warn("[openModalWithData] 타겟 화면 ID가 없습니다."); } }; loadModalMappingColumns(); }, [config.action?.type, config.action?.targetScreenId, allComponents, currentTableName]); // 화면 목록 가져오기 (현재 편집 중인 화면의 회사 코드 기준) useEffect(() => { const fetchScreens = async () => { try { setScreensLoading(true); // 현재 편집 중인 화면의 회사 코드 기준으로 화면 목록 조회 const params: any = { page: 1, size: 9999, // 매우 큰 값으로 설정하여 전체 목록 가져오기 }; // 현재 화면의 회사 코드가 있으면 필터링 파라미터로 전달 if (currentScreenCompanyCode) { params.companyCode = currentScreenCompanyCode; } const response = await apiClient.get("/screen-management/screens", { params, }); if (response.data.success && Array.isArray(response.data.data)) { const screenList = response.data.data.map((screen: any) => ({ id: screen.screenId, name: screen.screenName, description: screen.description, })); setScreens(screenList); } } catch (error) { // console.error("❌ 화면 목록 로딩 실패:", error); } finally { setScreensLoading(false); } }; fetchScreens(); }, [currentScreenCompanyCode]); // 결재 유형 목록 가져오기 (approval 액션일 때) useEffect(() => { if (localInputs.actionType !== "approval") return; const fetchApprovalDefinitions = async () => { setApprovalDefinitionsLoading(true); try { const res = await getApprovalDefinitions({ is_active: "Y" }); if (res.success && res.data) { setApprovalDefinitions(res.data); } } catch { // 조용히 실패 } finally { setApprovalDefinitionsLoading(false); } }; fetchApprovalDefinitions(); }, [localInputs.actionType]); // 테이블 컬럼 목록 가져오기 (테이블 이력 보기 액션일 때) useEffect(() => { const fetchTableColumns = async () => { // 테이블 이력 보기 액션이 아니면 스킵 if (config.action?.type !== "view_table_history") { return; } // 1. 수동 입력된 테이블명 우선 // 2. 없으면 현재 화면의 테이블명 사용 const tableName = config.action?.historyTableName || currentTableName; // 테이블명이 없으면 스킵 if (!tableName) { return; } try { setColumnsLoading(true); const response = await apiClient.get(`/table-management/tables/${tableName}/columns`, { params: { page: 1, size: 9999, // 전체 컬럼 가져오기 }, }); // API 응답 구조: { success, data: { columns: [...], total, page, totalPages } } const columnData = response.data.data?.columns; if (!columnData || !Array.isArray(columnData)) { console.error("❌ 컬럼 데이터가 배열이 아닙니다:", columnData); setTableColumns([]); return; } if (response.data.success) { // ID 컬럼과 날짜 관련 컬럼 제외 const filteredColumns = columnData .filter((col: any) => { const colName = col.columnName.toLowerCase(); const dataType = col.dataType?.toLowerCase() || ""; // ID 컬럼 제외 (id, _id로 끝나는 컬럼) if (colName === "id" || colName.endsWith("_id")) { return false; } // 날짜/시간 타입 제외 (데이터 타입 기준) if (dataType.includes("date") || dataType.includes("time") || dataType.includes("timestamp")) { return false; } // 날짜/시간 관련 컬럼명 제외 (컬럼명에 date, time, at 포함) if ( colName.includes("date") || colName.includes("time") || colName.endsWith("_at") || colName.startsWith("created") || colName.startsWith("updated") ) { return false; } return true; }) .map((col: any) => col.columnName); setTableColumns(filteredColumns); } } catch (error) { console.error("❌ 테이블 컬럼 로딩 실패:", error); } finally { setColumnsLoading(false); } }; fetchTableColumns(); }, [config.action?.type, config.action?.historyTableName, currentTableName]); // 검색 필터링 함수 const filterScreens = (searchTerm: string) => { if (!searchTerm.trim()) return screens; return screens.filter( (screen) => screen.name.toLowerCase().includes(searchTerm.toLowerCase()) || (screen.description && screen.description.toLowerCase().includes(searchTerm.toLowerCase())), ); }; return (
{/* 모달 열기 액션 설정 */} {localInputs.actionType === "modal" && (

모달 설정

{ const newValue = e.target.value; setLocalInputs((prev) => ({ ...prev, modalTitle: newValue })); onUpdateProperty("componentConfig.action.modalTitle", newValue); }} />
{ const newValue = e.target.value; setLocalInputs((prev) => ({ ...prev, modalDescription: newValue })); onUpdateProperty("componentConfig.action.modalDescription", newValue); }} />

모달 제목 아래에 표시됩니다

setModalSearchTerm(e.target.value)} className="border-0 p-0 focus-visible:ring-0" />
{(() => { const filteredScreens = filterScreens(modalSearchTerm); if (screensLoading) { return
화면 목록을 불러오는 중...
; } if (filteredScreens.length === 0) { return
검색 결과가 없습니다.
; } return filteredScreens.map((screen, index) => (
{ onUpdateProperty("componentConfig.action.targetScreenId", screen.id); setModalScreenOpen(false); setModalSearchTerm(""); }} >
{screen.name} {screen.description && ( {screen.description} )}
)); })()}
{/* 선택된 데이터 전달 옵션 */}
{ onUpdateProperty("componentConfig.action.autoDetectDataSource", checked); if (!checked) { // 체크 해제 시 필드 매핑도 초기화 onUpdateProperty("componentConfig.action.fieldMappings", []); } }} />

TableList/SplitPanel에서 선택된 데이터를 모달에 자동으로 전달합니다

{/* 🆕 필드 매핑 UI (데이터 전달 활성화 + 테이블이 다른 경우) */} {component.componentConfig?.action?.autoDetectDataSource === true && (
{/* 테이블 정보 표시 */}
소스: {modalActionSourceTable || "감지 중..."}
대상: {modalActionTargetTable || "감지 중..."}
{/* 테이블이 같으면 자동 매핑 안내 */} {modalActionSourceTable && modalActionTargetTable && modalActionSourceTable === modalActionTargetTable && (
동일한 테이블입니다. 컬럼명이 같은 필드는 자동으로 매핑됩니다.
)} {/* 테이블이 다르면 필드 매핑 UI 표시 */} {modalActionSourceTable && modalActionTargetTable && modalActionSourceTable !== modalActionTargetTable && (
{(component.componentConfig?.action?.fieldMappings || []).length === 0 && (

컬럼명이 다른 경우 매핑을 추가하세요. 매핑이 없으면 동일 컬럼명만 전달됩니다.

)} {(component.componentConfig?.action?.fieldMappings || []).map((mapping: any, index: number) => (
{/* 소스 필드 선택 */} setModalFieldMappingSourceOpen((prev) => ({ ...prev, [index]: open })) } > setModalFieldMappingSourceSearch((prev) => ({ ...prev, [index]: val })) } /> 컬럼을 찾을 수 없습니다. {modalActionSourceColumns .filter( (col) => col.name .toLowerCase() .includes((modalFieldMappingSourceSearch[index] || "").toLowerCase()) || col.label .toLowerCase() .includes((modalFieldMappingSourceSearch[index] || "").toLowerCase()), ) .map((col) => ( { const newMappings = [ ...(component.componentConfig?.action?.fieldMappings || []), ]; newMappings[index] = { ...newMappings[index], sourceField: col.name }; setModalActionFieldMappings(newMappings); onUpdateProperty("componentConfig.action.fieldMappings", newMappings); setModalFieldMappingSourceOpen((prev) => ({ ...prev, [index]: false })); }} >
{col.label} {col.name}
))}
{/* 대상 필드 선택 */} setModalFieldMappingTargetOpen((prev) => ({ ...prev, [index]: open })) } > setModalFieldMappingTargetSearch((prev) => ({ ...prev, [index]: val })) } /> 컬럼을 찾을 수 없습니다. {modalActionTargetColumns .filter( (col) => col.name .toLowerCase() .includes((modalFieldMappingTargetSearch[index] || "").toLowerCase()) || col.label .toLowerCase() .includes((modalFieldMappingTargetSearch[index] || "").toLowerCase()), ) .map((col) => ( { const newMappings = [ ...(component.componentConfig?.action?.fieldMappings || []), ]; newMappings[index] = { ...newMappings[index], targetField: col.name }; setModalActionFieldMappings(newMappings); onUpdateProperty("componentConfig.action.fieldMappings", newMappings); setModalFieldMappingTargetOpen((prev) => ({ ...prev, [index]: false })); }} >
{col.label} {col.name}
))}
{/* 삭제 버튼 */}
))}
)}
)}
)} {/* 🆕 데이터 전달 + 모달 열기 액션 설정 (deprecated - 하위 호환성 유지) */} {component.componentConfig?.action?.type === "openModalWithData" && (

데이터 전달 + 모달 설정

이 옵션은 "모달 열기" 액션으로 통합되었습니다. 새 개발에서는 "모달 열기" + "선택된 데이터 전달"을 사용하세요.

{/* 🆕 블록 기반 제목 빌더 */}
{/* 블록 목록 */}
{titleBlocks.length === 0 ? (
텍스트나 필드를 추가하여 제목을 구성하세요
) : ( titleBlocks.map((block, index) => (
{/* 순서 변경 버튼 */}
{/* 블록 타입 표시 */}
{block.type === "text" ? ( ) : ( )}
{/* 블록 설정 */}
{block.type === "text" ? ( // 텍스트 블록 updateBlock(block.id, { value: e.target.value })} className="h-7 text-xs" /> ) : ( // 필드 블록 <> {/* 테이블 선택 - Combobox */} { setBlockTablePopoverOpen((prev) => ({ ...prev, [block.id]: open })); }} > { setBlockTableSearches((prev) => ({ ...prev, [block.id]: value })); }} /> 테이블을 찾을 수 없습니다. {availableTables .filter((table) => { const search = (blockTableSearches[block.id] || "").toLowerCase(); if (!search) return true; return ( table.label.toLowerCase().includes(search) || table.name.toLowerCase().includes(search) ); }) .map((table) => ( { updateBlock(block.id, { tableName: table.name, value: "" }); loadTableColumns(table.name); setBlockTableSearches((prev) => ({ ...prev, [block.id]: "" })); setBlockTablePopoverOpen((prev) => ({ ...prev, [block.id]: false })); }} className="text-xs" > {table.label} ({table.name}) ))} {block.tableName && ( <> {/* 컬럼 선택 - Combobox (라벨명 표시) */} { setBlockColumnPopoverOpen((prev) => ({ ...prev, [block.id]: open })); }} > { setBlockColumnSearches((prev) => ({ ...prev, [block.id]: value })); }} /> 컬럼을 찾을 수 없습니다. {(tableColumnsMap[block.tableName] || []) .filter((col) => { const search = (blockColumnSearches[block.id] || "").toLowerCase(); if (!search) return true; return ( col.label.toLowerCase().includes(search) || col.name.toLowerCase().includes(search) ); }) .map((col) => ( { updateBlock(block.id, { value: col.name, label: col.label, }); setBlockColumnSearches((prev) => ({ ...prev, [block.id]: "" })); setBlockColumnPopoverOpen((prev) => ({ ...prev, [block.id]: false })); }} className="text-xs" > {col.label} ({col.name}) ))} updateBlock(block.id, { label: e.target.value })} className="h-7 text-xs" /> )} )}
{/* 삭제 버튼 */}
)) )}
{/* 미리보기 */} {titleBlocks.length > 0 && (
미리보기: {generateTitlePreview()}
)}

• 텍스트: 고정 텍스트 입력 (예: "품목 상세정보 - ")
• 필드: 이전 화면 데이터로 자동 채워짐 (예: 품목명, 규격)
• 순서 변경: ↑↓ 버튼으로 자유롭게 배치
• 데이터가 없으면 "표시 라벨"이 대신 표시됩니다

setModalSearchTerm(e.target.value)} className="border-0 p-0 focus-visible:ring-0" />
{(() => { const filteredScreens = filterScreens(modalSearchTerm); if (screensLoading) { return
화면 목록을 불러오는 중...
; } if (filteredScreens.length === 0) { return
검색 결과가 없습니다.
; } return filteredScreens.map((screen, index) => (
{ onUpdateProperty("componentConfig.action.targetScreenId", screen.id); setModalScreenOpen(false); setModalSearchTerm(""); }} >
{screen.name} {screen.description && ( {screen.description} )}
)); })()}

SelectedItemsDetailInput 컴포넌트가 있는 화면을 선택하세요

{/* 🆕 필드 매핑 설정 (소스 컬럼 → 타겟 컬럼) */}

소스 테이블의 컬럼명이 타겟 화면의 입력 필드 컬럼명과 다를 때 매핑을 설정하세요.
예: warehouse_code → warehouse_id (분할 패널의 창고코드를 모달의 창고ID에 매핑)

{/* 컬럼 로드 상태 표시 */} {modalSourceColumns.length > 0 || modalTargetColumns.length > 0 ? (
소스 컬럼: {modalSourceColumns.length}개 / 타겟 컬럼: {modalTargetColumns.length}개
) : (
분할 패널 또는 테이블 컴포넌트와 대상 화면을 설정하면 컬럼 목록이 로드됩니다.
)} {(config.action?.fieldMappings || []).length === 0 ? (

매핑이 없으면 같은 이름의 컬럼끼리 자동으로 매핑됩니다.

) : (
{(config.action?.fieldMappings || []).map((mapping: any, index: number) => (
{/* 소스 필드 선택 (Combobox) - 세로 배치 */}
setModalSourcePopoverOpen((prev) => ({ ...prev, [index]: open }))} > setModalSourceSearch((prev) => ({ ...prev, [index]: value }))} /> 컬럼을 찾을 수 없습니다 {modalSourceColumns.map((col) => ( { const mappings = [...(config.action?.fieldMappings || [])]; mappings[index] = { ...mappings[index], sourceField: col.name }; onUpdateProperty("componentConfig.action.fieldMappings", mappings); setModalSourcePopoverOpen((prev) => ({ ...prev, [index]: false })); }} className="text-xs" > {col.label} {col.label !== col.name && ( ({col.name}) )} ))}
{/* 화살표 표시 */}
{/* 타겟 필드 선택 (Combobox) - 세로 배치 */}
setModalTargetPopoverOpen((prev) => ({ ...prev, [index]: open }))} > setModalTargetSearch((prev) => ({ ...prev, [index]: value }))} /> 컬럼을 찾을 수 없습니다 {modalTargetColumns.map((col) => ( { const mappings = [...(config.action?.fieldMappings || [])]; mappings[index] = { ...mappings[index], targetField: col.name }; onUpdateProperty("componentConfig.action.fieldMappings", mappings); setModalTargetPopoverOpen((prev) => ({ ...prev, [index]: false })); }} className="text-xs" > {col.label} {col.label !== col.name && ( ({col.name}) )} ))}
{/* 삭제 버튼 */}
))}
)}
)} {/* 수정 액션 설정 */} {localInputs.actionType === "edit" && (

수정 설정

setModalSearchTerm(e.target.value)} className="border-0 p-0 focus-visible:ring-0" />
{(() => { const filteredScreens = filterScreens(modalSearchTerm); if (screensLoading) { return
화면 목록을 불러오는 중...
; } if (filteredScreens.length === 0) { return
검색 결과가 없습니다.
; } return filteredScreens.map((screen, index) => (
{ onUpdateProperty("componentConfig.action.targetScreenId", screen.id); setModalScreenOpen(false); setModalSearchTerm(""); }} >
{screen.name} {screen.description && ( {screen.description} )}
)); })()}

선택된 데이터가 이 폼 화면에 자동으로 로드되어 수정할 수 있습니다

{(component.componentConfig?.action?.editMode || "modal") === "modal" && ( <>
{ const newValue = e.target.value; setLocalInputs((prev) => ({ ...prev, editModalTitle: newValue })); onUpdateProperty("componentConfig.action.editModalTitle", newValue); onUpdateProperty("webTypeConfig.editModalTitle", newValue); }} />

비워두면 기본 제목이 표시됩니다

{ const newValue = e.target.value; setLocalInputs((prev) => ({ ...prev, editModalDescription: newValue })); onUpdateProperty("componentConfig.action.editModalDescription", newValue); onUpdateProperty("webTypeConfig.editModalDescription", newValue); }} />

비워두면 설명이 표시되지 않습니다

)}
setGroupByColumnSearch(e.target.value)} className="border-0 p-0 focus-visible:ring-0" />
{currentTableColumns.length === 0 ? (
{currentTableName ? "컬럼을 불러오는 중..." : "테이블이 설정되지 않았습니다"}
) : ( <> {/* 선택 해제 옵션 */}
{ setLocalInputs((prev) => ({ ...prev, groupByColumn: "" })); onUpdateProperty("componentConfig.action.groupByColumns", undefined); setGroupByColumnOpen(false); setGroupByColumnSearch(""); }} > 선택 안 함
{/* 컬럼 목록 */} {currentTableColumns .filter((col) => { if (!groupByColumnSearch) return true; const search = groupByColumnSearch.toLowerCase(); return col.name.toLowerCase().includes(search) || col.label.toLowerCase().includes(search); }) .map((col) => (
{ setLocalInputs((prev) => ({ ...prev, groupByColumn: col.name })); onUpdateProperty("componentConfig.action.groupByColumns", [col.name]); setGroupByColumnOpen(false); setGroupByColumnSearch(""); }} >
{col.name} {col.label !== col.name && ( {col.label} )}
))} )}

여러 행을 하나의 그룹으로 묶어서 수정할 때 사용합니다

)} {/* 복사 액션 설정 */} {localInputs.actionType === "copy" && (

복사 설정 (품목코드 자동 초기화)

setModalSearchTerm(e.target.value)} className="border-0 p-0 focus-visible:ring-0" />
{(() => { const filteredScreens = filterScreens(modalSearchTerm); if (screensLoading) { return
화면 목록을 불러오는 중...
; } if (filteredScreens.length === 0) { return
검색 결과가 없습니다.
; } return filteredScreens.map((screen, index) => (
{ onUpdateProperty("componentConfig.action.targetScreenId", screen.id); setModalScreenOpen(false); setModalSearchTerm(""); }} >
{screen.name} {screen.description && ( {screen.description} )}
)); })()}

선택된 데이터가 복사되며, 품목코드는 자동으로 초기화됩니다

{(component.componentConfig?.action?.editMode || "modal") === "modal" && ( <>
{ const newValue = e.target.value; setLocalInputs((prev) => ({ ...prev, editModalTitle: newValue })); onUpdateProperty("componentConfig.action.editModalTitle", newValue); onUpdateProperty("webTypeConfig.editModalTitle", newValue); }} />

비워두면 기본 제목이 표시됩니다

{ const newValue = e.target.value; setLocalInputs((prev) => ({ ...prev, editModalDescription: newValue })); onUpdateProperty("componentConfig.action.editModalDescription", newValue); onUpdateProperty("webTypeConfig.editModalDescription", newValue); }} />

비워두면 설명이 표시되지 않습니다

)}
)} {/* 테이블 이력 보기 액션 설정 */} {localInputs.actionType === "view_table_history" && (
컬럼을 찾을 수 없습니다. {tableColumns.map((column) => ( { onUpdateProperty("componentConfig.action.historyDisplayColumn", currentValue); setDisplayColumnOpen(false); }} className="text-xs" > {column} ))}
)} {/* 페이지 이동 액션 설정 */} {localInputs.actionType === "navigate" && (

페이지 이동 설정

setNavSearchTerm(e.target.value)} className="border-0 p-0 focus-visible:ring-0" />
{(() => { const filteredScreens = filterScreens(navSearchTerm); if (screensLoading) { return
화면 목록을 불러오는 중...
; } if (filteredScreens.length === 0) { return
검색 결과가 없습니다.
; } return filteredScreens.map((screen, index) => (
{ onUpdateProperty("componentConfig.action.targetScreenId", screen.id); setNavScreenOpen(false); setNavSearchTerm(""); }} >
{screen.name} {screen.description && ( {screen.description} )}
)); })()}

선택한 화면으로 /screens/{"{"}화면ID{"}"} 형태로 이동합니다

{ const newValue = e.target.value; setLocalInputs((prev) => ({ ...prev, targetUrl: newValue })); onUpdateProperty("componentConfig.action.targetUrl", newValue); }} className="h-6 w-full px-2 py-0 text-xs" />

URL을 입력하면 화면 선택보다 우선 적용됩니다

)} {/* 엑셀 다운로드 액션 설정 */} {localInputs.actionType === "excel_download" && (

엑셀 다운로드 설정

onUpdateProperty("componentConfig.action.excelFileName", e.target.value)} className="h-8 text-xs" />

확장자(.xlsx)는 자동으로 추가됩니다

onUpdateProperty("componentConfig.action.excelSheetName", e.target.value)} className="h-8 text-xs" />
onUpdateProperty("componentConfig.action.excelIncludeHeaders", checked)} />
)} {/* 엑셀 업로드 액션 설정 */} {localInputs.actionType === "excel_upload" && ( )} {/* 다중 테이블 엑셀 업로드: 설정 불필요 (버튼 클릭 시 화면 테이블에서 자동 감지) */} {/* 바코드 스캔 액션 설정 */} {localInputs.actionType === "barcode_scan" && (

📷 바코드 스캔 설정

onUpdateProperty("componentConfig.action.barcodeTargetField", e.target.value)} className="h-8 text-xs" />

스캔 결과가 입력될 폼 필드명

onUpdateProperty("componentConfig.action.barcodeAutoSubmit", checked)} />
)} {/* 코드 병합 액션 설정 */} {localInputs.actionType === "code_merge" && (

🔀 코드 병합 설정

onUpdateProperty("componentConfig.action.mergeColumnName", e.target.value)} className="h-8 text-xs" />

병합할 컬럼명 (예: item_code). 이 컬럼이 있는 모든 테이블에 병합이 적용됩니다.

영향받을 테이블과 행 수를 미리 확인합니다

onUpdateProperty("componentConfig.action.mergeShowPreview", checked)} />

사용 방법:
1. 테이블에서 병합할 두 개의 행을 선택합니다
2. 이 버튼을 클릭하면 병합 방향을 선택할 수 있습니다
3. 데이터는 삭제되지 않고, 컬럼 값만 변경됩니다

)} {/* 공차등록 설정 - 운행알림으로 통합되어 주석 처리 */} {/* {localInputs.actionType === "empty_vehicle" && (
... 공차등록 설정 UI 생략 ...
)} */} {/* 운행알림 및 종료 설정 */} {localInputs.actionType === "operation_control" && (

🚗 운행알림 및 종료 설정

필드 값을 변경할 테이블 (기본: 현재 화면 테이블)

onUpdateProperty("componentConfig.action.updateTargetField", e.target.value)} className="h-8 text-xs" />

변경할 DB 컬럼

onUpdateProperty("componentConfig.action.updateTargetValue", e.target.value)} className="h-8 text-xs" />

변경할 값 (문자열, 숫자)

{/* 🆕 키 필드 설정 (레코드 식별용) */}
레코드 식별 설정
onUpdateProperty("componentConfig.action.updateKeyField", e.target.value)} className="h-8 text-xs" />

레코드를 찾을 DB 컬럼명

키 값을 가져올 소스

버튼 클릭 시 즉시 DB에 저장

onUpdateProperty("componentConfig.action.updateAutoSave", checked)} />
onUpdateProperty("componentConfig.action.confirmMessage", e.target.value)} className="h-8 text-xs" />

입력하면 변경 전 확인 창이 표시됩니다

onUpdateProperty("componentConfig.action.successMessage", e.target.value)} className="h-8 text-xs" />
onUpdateProperty("componentConfig.action.errorMessage", e.target.value)} className="h-8 text-xs" />
{/* 위치정보 수집 옵션 */}

상태 변경과 함께 현재 GPS 좌표를 수집합니다

onUpdateProperty("componentConfig.action.updateWithGeolocation", checked)} />
{config.action?.updateWithGeolocation && (
onUpdateProperty("componentConfig.action.updateGeolocationLatField", e.target.value) } className="h-8 text-xs" />
onUpdateProperty("componentConfig.action.updateGeolocationLngField", e.target.value) } className="h-8 text-xs" />
onUpdateProperty("componentConfig.action.updateGeolocationAccuracyField", e.target.value) } className="h-8 text-xs" />
onUpdateProperty("componentConfig.action.updateGeolocationTimestampField", e.target.value) } className="h-8 text-xs" />

버튼 클릭 시 GPS 위치를 수집하여 위 필드에 저장합니다.

)}
{/* 🆕 연속 위치 추적 설정 */}

10초마다 위치를 경로 테이블에 저장합니다

onUpdateProperty("componentConfig.action.updateWithTracking", checked)} />
{config.action?.updateWithTracking && (
{config.action?.updateTrackingMode === "start" && (
onUpdateProperty( "componentConfig.action.updateTrackingInterval", parseInt(e.target.value) * 1000 || 10000, ) } className="h-8 text-xs" min={5} max={300} />

5초 ~ 300초 사이로 설정 (기본: 10초)

)}

{config.action?.updateTrackingMode === "start" ? "버튼 클릭 시 연속 위치 추적이 시작되고, vehicle_location_history 테이블에 경로가 저장됩니다." : "버튼 클릭 시 진행 중인 위치 추적이 종료됩니다."}

)} {/* 🆕 버튼 활성화 조건 설정 */}
버튼 활성화 조건
{/* 출발지/도착지 필수 체크 */}

선택하지 않으면 버튼 비활성화

onUpdateProperty("componentConfig.action.requireLocationFields", checked)} />
{config.action?.requireLocationFields && (
onUpdateProperty("componentConfig.action.trackingDepartureField", e.target.value) } className="h-8 text-xs" />
onUpdateProperty("componentConfig.action.trackingArrivalField", e.target.value)} className="h-8 text-xs" />
)} {/* 상태 기반 활성화 조건 */}

특정 상태일 때만 버튼 활성화

onUpdateProperty("componentConfig.action.enableOnStatusCheck", checked)} />
{config.action?.enableOnStatusCheck && (

상태를 조회할 테이블 (기본: vehicles)

onUpdateProperty("componentConfig.action.statusCheckKeyField", e.target.value)} className="h-8 text-xs" />

현재 로그인 사용자 ID로 조회할 필드 (기본: user_id)

onUpdateProperty("componentConfig.action.statusCheckField", e.target.value)} className="h-8 text-xs" />

상태 값이 저장된 컬럼명 (기본: status)

onUpdateProperty("componentConfig.action.statusConditionValues", e.target.value)} className="h-8 text-xs" />

여러 상태값은 쉼표(,)로 구분

)}

사용 예시:
- 운행 시작: status를 "active"로 + 연속 추적 시작
- 운행 종료: status를 "completed"로 + 연속 추적 종료
- 공차등록: status를 "inactive"로 + 1회성 위치정보 수집

)} {/* 데이터 전달 액션 설정 */} {localInputs.actionType === "transferData" && (

📦 데이터 전달 설정

{/* 소스 컴포넌트 선택 (Combobox) */}

레이어별로 다른 테이블이 있을 경우 "자동 탐색"을 선택하면 현재 활성화된 레이어의 테이블을 자동으로 사용합니다

{config.action?.dataTransfer?.targetType === "splitPanel" && (

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

)}
{/* 타겟 컴포넌트 선택 (같은 화면의 컴포넌트일 때만) */} {config.action?.dataTransfer?.targetType === "component" && (

테이블, 반복 필드 그룹 등 데이터를 받는 컴포넌트

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

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

)}

기존 데이터를 어떻게 처리할지 선택

데이터 전달 후 소스의 선택을 해제합니다

onUpdateProperty("componentConfig.action.dataTransfer.clearAfterTransfer", checked) } />

데이터 전달 전 확인 다이얼로그를 표시합니다

onUpdateProperty("componentConfig.action.dataTransfer.confirmBeforeTransfer", checked) } />
{config.action?.dataTransfer?.confirmBeforeTransfer && (
onUpdateProperty("componentConfig.action.dataTransfer.confirmMessage", e.target.value)} className="h-8 text-xs" />
)}
onUpdateProperty( "componentConfig.action.dataTransfer.validation.minSelection", parseInt(e.target.value) || 0, ) } className="h-8 w-20 text-xs" />
onUpdateProperty( "componentConfig.action.dataTransfer.validation.maxSelection", parseInt(e.target.value) || undefined, ) } className="h-8 w-20 text-xs" />

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

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

컬럼을 찾을 수 없습니다. { const currentSources = config.action?.dataTransfer?.additionalSources || []; const newSources = [...currentSources]; if (newSources.length === 0) { newSources.push({ componentId: "", fieldName: "" }); } else { newSources[0] = { ...newSources[0], fieldName: "" }; } onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources); }} className="text-xs" > 선택 안 함 (전체 데이터 병합) {(mappingTargetColumns.length > 0 ? mappingTargetColumns : currentTableColumns).map((col) => ( { const currentSources = config.action?.dataTransfer?.additionalSources || []; const newSources = [...currentSources]; if (newSources.length === 0) { newSources.push({ componentId: "", fieldName: col.name }); } else { newSources[0] = { ...newSources[0], fieldName: col.name }; } onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources); }} className="text-xs" > {col.label || col.name} {col.label && col.label !== col.name && ( ({col.name}) )} ))}

추가 데이터가 저장될 타겟 테이블 컬럼

{/* 멀티 테이블 필드 매핑 */}
{/* 타겟 테이블 (공통) */}
테이블을 찾을 수 없습니다 {availableTables.map((table) => ( { onUpdateProperty("componentConfig.action.dataTransfer.targetTable", table.name); }} className="text-xs" > {table.label} ({table.name}) ))}
{/* 소스 테이블 매핑 그룹 */}

여러 소스 테이블에서 데이터를 전달할 때, 각 테이블별로 매핑 규칙을 설정합니다. 런타임에 소스 테이블을 자동 감지합니다.

{!config.action?.dataTransfer?.targetTable ? (

먼저 타겟 테이블을 선택하세요.

) : !(config.action?.dataTransfer?.multiTableMappings || []).length ? (

매핑 그룹이 없습니다. 소스 테이블을 추가하세요.

) : (
{/* 소스 테이블 탭 */}
{(config.action?.dataTransfer?.multiTableMappings || []).map((group: any, gIdx: number) => (
))}
{/* 활성 그룹 편집 영역 */} {(() => { const multiMappings = config.action?.dataTransfer?.multiTableMappings || []; const activeGroup = multiMappings[activeMappingGroupIndex]; if (!activeGroup) return null; const activeSourceTable = activeGroup.sourceTable || ""; const activeSourceColumns = mappingSourceColumnsMap[activeSourceTable] || []; const activeRules: any[] = activeGroup.mappingRules || []; const updateGroupField = (field: string, value: any) => { const mappings = [...multiMappings]; mappings[activeMappingGroupIndex] = { ...mappings[activeMappingGroupIndex], [field]: value }; onUpdateProperty("componentConfig.action.dataTransfer.multiTableMappings", mappings); }; return (
{/* 소스 테이블 선택 */}
테이블을 찾을 수 없습니다 {availableTables.map((table) => ( { updateGroupField("sourceTable", table.name); if (!mappingSourceColumnsMap[table.name]) { const cols = await loadMappingColumns(table.name); setMappingSourceColumnsMap((prev) => ({ ...prev, [table.name]: cols })); } }} className="text-xs" > {table.label} ({table.name}) ))}
{/* 매핑 규칙 목록 */}
{!activeSourceTable ? (

소스 테이블을 먼저 선택하세요.

) : activeRules.length === 0 ? (

매핑 없음 (동일 필드명 자동 매핑)

) : ( activeRules.map((rule: any, rIdx: number) => { const popoverKeyS = `${activeMappingGroupIndex}-${rIdx}-s`; const popoverKeyT = `${activeMappingGroupIndex}-${rIdx}-t`; return (
setMappingSourcePopoverOpen((prev) => ({ ...prev, [popoverKeyS]: open })) } > 컬럼 없음 {activeSourceColumns.map((col) => ( { const newRules = [...activeRules]; newRules[rIdx] = { ...newRules[rIdx], sourceField: col.name }; updateGroupField("mappingRules", newRules); setMappingSourcePopoverOpen((prev) => ({ ...prev, [popoverKeyS]: false, })); }} className="text-xs" > {col.label} {col.label !== col.name && ( ({col.name}) )} ))}
setMappingTargetPopoverOpen((prev) => ({ ...prev, [popoverKeyT]: open })) } > 컬럼 없음 {mappingTargetColumns.map((col) => ( { const newRules = [...activeRules]; newRules[rIdx] = { ...newRules[rIdx], targetField: col.name }; updateGroupField("mappingRules", newRules); setMappingTargetPopoverOpen((prev) => ({ ...prev, [popoverKeyT]: false, })); }} className="text-xs" > {col.label} {col.label !== col.name && ( ({col.name}) )} ))}
); }) )}
); })()}
)}

사용 방법:
1. 소스 컴포넌트에서 데이터를 선택합니다
2. 소스 테이블별로 필드 매핑 규칙을 설정합니다
3. 이 버튼을 클릭하면 소스 테이블을 자동 감지하여 매핑된 데이터가 타겟으로 전달됩니다

)} {/* 🆕 즉시 저장(quickInsert) 액션 설정 */} {component.componentConfig?.action?.type === "quickInsert" && ( )} {/* 결재 요청(approval) 액션 설정 */} {localInputs.actionType === "approval" && (

결재 요청 설정

버튼 클릭 시 결재 요청 모달이 열립니다. 결재 유형을 선택하면 기본 결재선이 자동으로 세팅됩니다.

결재 유형을 선택하면 기본 결재선 템플릿이 자동 적용됩니다

onUpdateProperty("componentConfig.action.approvalTargetTable", e.target.value)} className="h-8 text-xs sm:h-10 sm:text-sm" readOnly={!!currentTableName && !component.componentConfig?.action?.approvalTargetTable} />

{currentTableName ? `현재 화면 테이블 "${currentTableName}" 자동 적용됨` : "결재 대상 레코드가 저장된 테이블명"}

onUpdateProperty("componentConfig.action.approvalRecordIdField", e.target.value)} className="h-8 text-xs sm:h-10 sm:text-sm" />

현재 선택된 레코드의 PK 컬럼명

)} {/* 🆕 이벤트 발송 액션 설정 */} {localInputs.actionType === "event" && (

이벤트 발송 설정

버튼 클릭 시 V2 이벤트 버스를 통해 이벤트를 발송합니다. 다른 컴포넌트나 서비스에서 이 이벤트를 수신하여 처리할 수 있습니다.

{component.componentConfig?.action?.eventConfig?.eventName === "SCHEDULE_GENERATE_REQUEST" && (
{ onUpdateProperty( "componentConfig.action.eventConfig.eventPayload.config.scheduling.leadTimeDays", parseInt(e.target.value) || 3, ); }} />
{ onUpdateProperty( "componentConfig.action.eventConfig.eventPayload.config.scheduling.maxDailyCapacity", parseInt(e.target.value) || 100, ); }} />

동작 방식: 테이블에서 선택된 데이터를 기반으로 스케줄을 자동 생성합니다. 생성 전 미리보기 확인 다이얼로그가 표시됩니다.

)}
)}
); }; /** * 마스터-디테일 엑셀 업로드 설정 컴포넌트 * 분할 패널 + column_labels에서 관계를 자동 감지 (채번은 테이블 타입 관리에서 자동 감지) */ const MasterDetailExcelUploadConfig: React.FC<{ config: any; onUpdateProperty: (path: string, value: any) => void; allComponents: ComponentData[]; }> = ({ config, onUpdateProperty, allComponents }) => { const [relationInfo, setRelationInfo] = useState<{ masterTable: string; detailTable: string; masterKeyColumn: string; detailFkColumn: string; } | null>(null); const [loading, setLoading] = useState(false); const [masterColumns, setMasterColumns] = useState< Array<{ columnName: string; columnLabel: string; inputType: string; referenceTable?: string; referenceColumn?: string; displayColumn?: string; }> >([]); // 참조 테이블별 컬럼 목록 캐시 (컬럼명 + 라벨) const [refTableColumns, setRefTableColumns] = useState>>({}); // 마스터-디테일 설정 const masterDetailConfig = config.action?.masterDetailExcel || {}; // 분할 패널에서 마스터/디테일 테이블명 자동 감지 const splitPanelInfo = useMemo(() => { const findSplitPanel = (components: any[]): any => { for (const comp of components) { const compId = comp.componentId || comp.componentType; if (compId === "split-panel-layout") { return comp.componentConfig; } if (comp.children && comp.children.length > 0) { const found = findSplitPanel(comp.children); if (found) return found; } } return null; }; return findSplitPanel(allComponents as any[]); }, [allComponents]); const masterTable = splitPanelInfo?.leftPanel?.tableName || ""; const detailTable = splitPanelInfo?.rightPanel?.tableName || ""; // 마스터 테이블 컬럼 로드 useEffect(() => { if (!masterTable) { setMasterColumns([]); return; } const loadMasterColumns = async () => { try { const { apiClient } = await import("@/lib/api/client"); const response = await apiClient.get(`/table-management/tables/${masterTable}/columns`); if (response.data?.success && response.data?.data?.columns) { const cols = response.data.data.columns.map((col: any) => ({ columnName: col.columnName || col.column_name, columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name, inputType: col.inputType || col.input_type || "text", referenceTable: col.referenceTable || col.reference_table, referenceColumn: col.referenceColumn || col.reference_column, displayColumn: col.displayColumn || col.display_column, })); setMasterColumns(cols); } } catch (error) { console.error("마스터 테이블 컬럼 로드 실패:", error); } }; loadMasterColumns(); }, [masterTable]); // 선택된 엔티티 필드들의 참조 테이블 컬럼 로드 useEffect(() => { const entityFields = (masterDetailConfig.masterSelectFields || []).filter( (f: any) => f.inputType === "entity" && f.referenceTable, ); const loadRefTableColumns = async () => { const { apiClient } = await import("@/lib/api/client"); for (const field of entityFields) { // 이미 로드된 테이블은 스킵 if (refTableColumns[field.referenceTable]) continue; try { const response = await apiClient.get(`/table-management/tables/${field.referenceTable}/columns`); if (response.data?.success && response.data?.data?.columns) { const cols = response.data.data.columns.map((c: any) => ({ name: c.columnName || c.column_name, label: c.displayName || c.columnLabel || c.column_label || c.columnName || c.column_name, })); setRefTableColumns((prev) => ({ ...prev, [field.referenceTable]: cols, })); } } catch (error) { console.error("참조 테이블 컬럼 로드 실패:", field.referenceTable, error); } } }; if (entityFields.length > 0) { loadRefTableColumns(); } }, [masterDetailConfig.masterSelectFields, refTableColumns]); // column_labels에서 FK 관계 자동 감지 useEffect(() => { if (!masterTable || !detailTable) { setRelationInfo(null); return; } const loadRelationInfo = async () => { setLoading(true); try { const { apiClient } = await import("@/lib/api/client"); // 디테일 테이블의 컬럼 정보 조회 (referenceTable, referenceColumn 포함) const response = await apiClient.get(`/table-management/tables/${detailTable}/columns`); if (response.data?.success && response.data?.data?.columns) { const columns = response.data.data.columns; // referenceTable이 마스터 테이블인 컬럼 찾기 const fkColumn = columns.find((col: any) => col.referenceTable === masterTable); if (fkColumn) { const detailFk = fkColumn.columnName || fkColumn.column_name; const masterKey = fkColumn.referenceColumn || fkColumn.reference_column; setRelationInfo({ masterTable, detailTable, masterKeyColumn: masterKey, detailFkColumn: detailFk, }); // 설정에 자동으로 저장 onUpdateProperty("componentConfig.action.masterDetailExcel", { ...masterDetailConfig, masterTable, detailTable, masterKeyColumn: masterKey, detailFkColumn: detailFk, }); } else { setRelationInfo(null); } } } catch (error) { console.error("FK 관계 로드 실패:", error); setRelationInfo(null); } finally { setLoading(false); } }; loadRelationInfo(); }, [masterTable, detailTable]); const updateMasterDetailConfig = (updates: Record) => { onUpdateProperty("componentConfig.action.masterDetailExcel", { ...masterDetailConfig, ...updates, }); }; // 분할 패널이 없으면 표시하지 않음 if (!splitPanelInfo) { return (

이 화면에 분할 패널이 없습니다. 마스터-디테일 업로드는 분할 패널 화면에서만 사용할 수 있습니다.

); } return (
마스터-디테일 설정 (자동 감지)
{/* 자동 감지된 정보 표시 */}

분할 패널에서 감지된 정보:

마스터:{" "} {masterTable || "-"}
디테일:{" "} {detailTable || "-"}
{loading ? (

FK 관계 조회 중...

) : relationInfo ? (
마스터 키:{" "} {relationInfo.masterKeyColumn}
디테일 FK:{" "} {relationInfo.detailFkColumn}
) : (

FK 관계를 찾을 수 없습니다. 테이블 타입관리에서 reference_table을 설정해주세요.

)}
{/* 마스터 키 자동 생성 안내 */} {relationInfo && (

마스터 테이블의 {relationInfo.masterKeyColumn} 값은 테이블 타입 관리에서 설정된 채번 규칙으로 자동 생성됩니다.

)} {/* 마스터 필드 선택 - 사용자가 엑셀 업로드 시 입력할 필드 */} {relationInfo && masterColumns.length > 0 && (

엑셀 업로드 시 사용자가 직접 선택/입력할 마스터 테이블 필드를 선택하세요.

{masterColumns .filter((col) => col.columnName !== relationInfo.masterKeyColumn) // 채번으로 자동 생성되는 키는 제외 .map((col) => { const selectedFields = masterDetailConfig.masterSelectFields || []; const isSelected = selectedFields.some((f: any) => f.columnName === col.columnName); return (
{ const checked = e.target.checked; let newFields = [...selectedFields]; if (checked) { newFields.push({ columnName: col.columnName, columnLabel: col.columnLabel, inputType: col.inputType, referenceTable: col.referenceTable, referenceColumn: col.referenceColumn, displayColumn: col.displayColumn, required: true, }); } else { newFields = newFields.filter((f: any) => f.columnName !== col.columnName); } updateMasterDetailConfig({ masterSelectFields: newFields }); }} className="h-4 w-4 rounded border-input" />
); })}
{(masterDetailConfig.masterSelectFields?.length || 0) > 0 && (

선택된 필드: {masterDetailConfig.masterSelectFields.length}개

)} {/* 엔티티 필드의 표시컬럼 설정 */} {masterDetailConfig.masterSelectFields?.filter((f: any) => f.inputType === "entity").length > 0 && (
{masterDetailConfig.masterSelectFields .filter((f: any) => f.inputType === "entity") .map((field: any) => { const availableColumns = refTableColumns[field.referenceTable] || []; return (
{field.columnLabel}:
); })}

참조 테이블에서 사용자에게 표시할 컬럼을 선택하세요.

)}
)}
); }; /** * 엑셀 업로드 채번 규칙 안내 (테이블 타입 관리에서 자동 감지) */ const ExcelNumberingRuleInfo: React.FC = () => { return (

테이블 타입 관리에서 "채번" 타입으로 설정된 컬럼의 채번 규칙이 업로드 시 자동으로 적용됩니다.

); }; /** * 엑셀 업로드 후 제어 실행 설정 (단일 테이블/마스터-디테일 모두 사용 가능) */ const ExcelAfterUploadControlConfig: React.FC<{ config: { afterUploadFlows?: Array<{ flowId: string; order: number }> }; updateConfig: (updates: { afterUploadFlows?: Array<{ flowId: string; order: number }> }) => void; }> = ({ config, updateConfig }) => { const [nodeFlows, setNodeFlows] = useState>([]); const [flowSelectOpen, setFlowSelectOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); const selectedFlows = config.afterUploadFlows || []; // 노드 플로우 목록 로드 useEffect(() => { const loadNodeFlows = async () => { setIsLoading(true); try { const { apiClient } = await import("@/lib/api/client"); const response = await apiClient.get("/dataflow/node-flows"); if (response.data?.success && response.data?.data) { setNodeFlows(response.data.data); } } catch (error) { console.error("노드 플로우 목록 로드 실패:", error); } finally { setIsLoading(false); } }; loadNodeFlows(); }, []); const addFlow = (flowId: string) => { if (selectedFlows.some((f) => f.flowId === flowId)) return; const newFlows = [...selectedFlows, { flowId, order: selectedFlows.length + 1 }]; updateConfig({ afterUploadFlows: newFlows }); setFlowSelectOpen(false); }; const removeFlow = (flowId: string) => { const newFlows = selectedFlows.filter((f) => f.flowId !== flowId).map((f, idx) => ({ ...f, order: idx + 1 })); updateConfig({ afterUploadFlows: newFlows }); }; const moveUp = (index: number) => { if (index === 0) return; const newFlows = [...selectedFlows]; [newFlows[index - 1], newFlows[index]] = [newFlows[index], newFlows[index - 1]]; updateConfig({ afterUploadFlows: newFlows.map((f, idx) => ({ ...f, order: idx + 1 })) }); }; const moveDown = (index: number) => { if (index === selectedFlows.length - 1) return; const newFlows = [...selectedFlows]; [newFlows[index], newFlows[index + 1]] = [newFlows[index + 1], newFlows[index]]; updateConfig({ afterUploadFlows: newFlows.map((f, idx) => ({ ...f, order: idx + 1 })) }); }; const availableFlows = nodeFlows.filter((f) => !selectedFlows.some((s) => s.flowId === String(f.flowId))); return (

엑셀 업로드 완료 후 순서대로 실행할 제어를 추가하세요.

{selectedFlows.length > 0 && (
{selectedFlows.map((selected, index) => { const flow = nodeFlows.find((f) => String(f.flowId) === selected.flowId); return (
{index + 1} {flow?.flowName || `Flow ${selected.flowId}`}
); })}
)} 검색 결과 없음 {availableFlows.map((flow) => ( addFlow(String(flow.flowId))} className="text-xs" >
{flow.flowName} {flow.flowDescription && ( {flow.flowDescription} )}
))}
{selectedFlows.length > 0 && (

업로드 완료 후 위 순서대로 {selectedFlows.length}개의 제어가 실행됩니다.

)}
); }; /** * 엑셀 업로드 설정 섹션 컴포넌트 * 마스터-디테일 설정은 분할 패널 자동 감지 */ const ExcelUploadConfigSection: React.FC<{ config: any; onUpdateProperty: (path: string, value: any) => void; allComponents: ComponentData[]; currentTableName?: string; // 현재 화면의 테이블명 (ButtonConfigPanel에서 전달) }> = ({ config, onUpdateProperty, allComponents, currentTableName: propTableName }) => { // 엑셀 업로드 설정 상태 관리 (채번은 테이블 타입 관리에서 자동 감지) const [excelUploadConfig, setExcelUploadConfig] = useState<{ afterUploadFlows?: Array<{ flowId: string; order: number }>; }>({ afterUploadFlows: config.action?.excelAfterUploadFlows || [], }); // 분할 패널 감지 const splitPanelInfo = useMemo(() => { const findSplitPanel = (components: any[]): any => { for (const comp of components) { const compId = comp.componentId || comp.componentType; if (compId === "split-panel-layout") { return comp.componentConfig; } if (comp.children && comp.children.length > 0) { const found = findSplitPanel(comp.children); if (found) return found; } } return null; }; return findSplitPanel(allComponents as any[]); }, [allComponents]); const hasSplitPanel = !!splitPanelInfo; // 단일 테이블 감지 (props 우선, 없으면 컴포넌트에서 탐색) const singleTableName = useMemo(() => { if (hasSplitPanel) return undefined; // props로 전달된 테이블명 우선 사용 if (propTableName) return propTableName; // 컴포넌트에서 테이블명 탐색 const findTableName = (components: any[]): string | undefined => { for (const comp of components) { const compId = comp.componentId || comp.componentType; const compConfig = comp.componentConfig || comp.config || comp; // 테이블 패널이나 데이터 테이블에서 테이블명 찾기 if ( compId === "table-panel" || compId === "data-table" || compId === "table-list" || compId === "simple-table" ) { const tableName = compConfig?.tableName || compConfig?.table; if (tableName) return tableName; } // 폼 컴포넌트에서 테이블명 찾기 if (compId === "form-panel" || compId === "input-form" || compId === "form" || compId === "detail-form") { const tableName = compConfig?.tableName || compConfig?.table; if (tableName) return tableName; } // 범용적으로 tableName 속성이 있는 컴포넌트 찾기 if (compConfig?.tableName) { return compConfig.tableName; } if (comp.children && comp.children.length > 0) { const found = findTableName(comp.children); if (found) return found; } } return undefined; }; return findTableName(allComponents as any[]); }, [allComponents, hasSplitPanel, propTableName]); // 디버깅: 감지된 테이블명 로그 useEffect(() => { console.log( "[ExcelUploadConfigSection] 분할 패널:", hasSplitPanel, "단일 테이블:", singleTableName, "(props:", propTableName, ")", ); }, [hasSplitPanel, singleTableName, propTableName]); // 설정 업데이트 함수 (채번은 테이블 타입 관리에서 자동 감지되므로 제어 실행만 관리) const updateExcelUploadConfig = (updates: Partial) => { const newConfig = { ...excelUploadConfig, ...updates }; setExcelUploadConfig(newConfig); if (updates.afterUploadFlows !== undefined) { onUpdateProperty("componentConfig.action.excelAfterUploadFlows", updates.afterUploadFlows); } }; // config 변경 시 로컬 상태 동기화 useEffect(() => { setExcelUploadConfig({ afterUploadFlows: config.action?.excelAfterUploadFlows || [], }); }, [config.action?.excelAfterUploadFlows]); return (

엑셀 업로드 설정

{(config.action?.excelUploadMode === "update" || config.action?.excelUploadMode === "upsert") && (
onUpdateProperty("componentConfig.action.excelKeyColumn", e.target.value)} className="h-8 text-xs" />

UPDATE/UPSERT 시 기준이 되는 컬럼명

)} {/* 채번 규칙 안내 (테이블 타입 관리에서 자동 감지) */} {/* 업로드 후 제어 실행 (항상 표시) */} {/* 마스터-디테일 설정 (분할 패널 자동 감지) */}
); };