Files
vexplor/frontend/components/screen/config-panels/ButtonConfigPanel.tsx
kjs 0e8c68a9ff feat: Add multi-table Excel upload functionality
- Implemented new API endpoints for multi-table Excel upload and auto-detection of table chains.
- The GET endpoint `/api/data/multi-table/auto-detect` allows automatic detection of foreign key relationships based on the provided root table.
- The POST endpoint `/api/data/multi-table/upload` handles the upload of multi-table data, including validation and logging of the upload process.
- Updated the frontend to include options for multi-table Excel upload in the button configuration panel and integrated the corresponding action handler.

This feature enhances the data management capabilities by allowing users to upload and manage data across multiple related tables efficiently.
2026-03-05 19:17:35 +09:00

4694 lines
221 KiB
TypeScript

"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 } from "lucide-react";
import { cn } from "@/lib/utils";
import { ComponentData } from "@/types/screen";
import { apiClient } from "@/lib/api/client";
import { ButtonDataflowConfigPanel } from "./ButtonDataflowConfigPanel";
import { ImprovedButtonControlConfigPanel } from "./ImprovedButtonControlConfigPanel";
import { FlowVisibilityConfigPanel } from "./FlowVisibilityConfigPanel";
import { QuickInsertConfigSection } from "./QuickInsertConfigSection";
import { getApprovalDefinitions, type ApprovalDefinition } from "@/lib/api/approval";
// 🆕 제목 블록 타입
interface TitleBlock {
id: string;
type: "text" | "field";
value: string; // text: 텍스트 내용, field: 컬럼명
tableName?: string; // field일 때 테이블명
label?: string; // field일 때 표시용 라벨
}
interface ButtonConfigPanelProps {
component: ComponentData;
onUpdateProperty: (path: string, value: any) => void;
allComponents?: ComponentData[]; // 🆕 플로우 위젯 감지용
currentTableName?: string; // 현재 화면의 테이블명 (자동 감지용)
currentScreenCompanyCode?: string; // 현재 편집 중인 화면의 회사 코드
}
interface ScreenOption {
id: number;
name: string;
description?: string;
}
export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
component,
onUpdateProperty,
allComponents = [], // 🆕 기본값 빈 배열
currentTableName, // 현재 화면의 테이블명
currentScreenCompanyCode, // 현재 편집 중인 화면의 회사 코드
}) => {
// 🔧 component가 없는 경우 방어 처리
if (!component) {
return <div className="text-muted-foreground p-4 text-sm"> .</div>;
}
// 🔧 component에서 직접 읽기 (useMemo 제거)
const config = component.componentConfig || {};
const currentAction = component.componentConfig?.action || {};
// 로컬 상태 관리 (실시간 입력 반영)
const [localInputs, setLocalInputs] = useState({
text: config.text !== undefined ? config.text : "버튼",
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<ScreenOption[]>([]);
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<string[]>([]);
const [columnsLoading, setColumnsLoading] = useState(false);
const [displayColumnOpen, setDisplayColumnOpen] = useState(false);
const [displayColumnSearch, setDisplayColumnSearch] = useState("");
// 🆕 제목 블록 빌더 상태
const [titleBlocks, setTitleBlocks] = useState<TitleBlock[]>([]);
const [availableTables, setAvailableTables] = useState<Array<{ name: string; label: string }>>([]); // 시스템의 모든 테이블 목록
const [tableColumnsMap, setTableColumnsMap] = useState<Record<string, Array<{ name: string; label: string }>>>({});
const [blockTableSearches, setBlockTableSearches] = useState<Record<string, string>>({}); // 블록별 테이블 검색어
const [blockColumnSearches, setBlockColumnSearches] = useState<Record<string, string>>({}); // 블록별 컬럼 검색어
const [blockTablePopoverOpen, setBlockTablePopoverOpen] = useState<Record<string, boolean>>({}); // 블록별 테이블 Popover 열림 상태
const [blockColumnPopoverOpen, setBlockColumnPopoverOpen] = useState<Record<string, boolean>>({}); // 블록별 컬럼 Popover 열림 상태
// 🆕 데이터 전달 필드 매핑용 상태 (멀티 테이블 매핑 지원)
const [mappingSourceColumnsMap, setMappingSourceColumnsMap] = useState<Record<string, Array<{ name: string; label: string }>>>({});
const [mappingTargetColumns, setMappingTargetColumns] = useState<Array<{ name: string; label: string }>>([]);
const [mappingSourcePopoverOpen, setMappingSourcePopoverOpen] = useState<Record<string, boolean>>({});
const [mappingTargetPopoverOpen, setMappingTargetPopoverOpen] = useState<Record<string, boolean>>({});
const [mappingSourceSearch, setMappingSourceSearch] = useState<Record<string, string>>({});
const [mappingTargetSearch, setMappingTargetSearch] = useState<Record<string, string>>({});
const [activeMappingGroupIndex, setActiveMappingGroupIndex] = useState(0);
// 🆕 openModalWithData 전용 필드 매핑 상태
const [modalSourceColumns, setModalSourceColumns] = useState<Array<{ name: string; label: string }>>([]);
const [modalTargetColumns, setModalTargetColumns] = useState<Array<{ name: string; label: string }>>([]);
const [modalSourcePopoverOpen, setModalSourcePopoverOpen] = useState<Record<number, boolean>>({});
const [modalTargetPopoverOpen, setModalTargetPopoverOpen] = useState<Record<number, boolean>>({});
// 결재 유형 목록 상태
const [approvalDefinitions, setApprovalDefinitions] = useState<ApprovalDefinition[]>([]);
const [approvalDefinitionsLoading, setApprovalDefinitionsLoading] = useState(false);
// 🆕 그룹화 컬럼 선택용 상태
const [currentTableColumns, setCurrentTableColumns] = useState<Array<{ name: string; label: string }>>([]);
const [groupByColumnOpen, setGroupByColumnOpen] = useState(false);
const [groupByColumnSearch, setGroupByColumnSearch] = useState("");
const [modalSourceSearch, setModalSourceSearch] = useState<Record<number, string>>({});
const [modalTargetSearch, setModalTargetSearch] = useState<Record<number, string>>({});
// 🆕 modal 액션용 필드 매핑 상태
const [modalActionSourceTable, setModalActionSourceTable] = useState<string | null>(null);
const [modalActionTargetTable, setModalActionTargetTable] = useState<string | null>(null);
const [modalActionSourceColumns, setModalActionSourceColumns] = useState<Array<{ name: string; label: string }>>([]);
const [modalActionTargetColumns, setModalActionTargetColumns] = useState<Array<{ name: string; label: string }>>([]);
const [modalActionFieldMappings, setModalActionFieldMappings] = useState<
Array<{ sourceField: string; targetField: string }>
>([]);
const [modalFieldMappingSourceOpen, setModalFieldMappingSourceOpen] = useState<Record<number, boolean>>({});
const [modalFieldMappingTargetOpen, setModalFieldMappingTargetOpen] = useState<Record<number, boolean>>({});
const [modalFieldMappingSourceSearch, setModalFieldMappingSourceSearch] = useState<Record<number, string>>({});
const [modalFieldMappingTargetSearch, setModalFieldMappingTargetSearch] = useState<Record<number, string>>({});
// 🎯 플로우 위젯이 화면에 있는지 확인
const hasFlowWidget = useMemo(() => {
const found = allComponents.some((comp: any) => {
// ScreenDesigner에서 저장하는 componentType 속성 확인!
const compType = comp.componentType || comp.widgetType || "";
// "flow-widget" 체크
const isFlow = compType === "flow-widget" || compType?.toLowerCase().includes("flow");
return isFlow;
});
return found;
}, [allComponents]);
// 컴포넌트 prop 변경 시 로컬 상태 동기화 (Input만)
useEffect(() => {
const latestConfig = component.componentConfig || {};
const latestAction = latestConfig.action || {};
setLocalInputs({
text: latestConfig.text !== undefined ? latestConfig.text : "버튼",
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<TitleBlock>) => {
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<Array<{ name: string; label: string }>> => {
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<string, Array<{ name: string; label: string }>> = {};
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 (
<div className="space-y-4">
<div>
<Label htmlFor="button-text"> </Label>
<Input
id="button-text"
value={localInputs.text}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, text: newValue }));
onUpdateProperty("componentConfig.text", newValue);
}}
placeholder="버튼 텍스트를 입력하세요"
/>
</div>
<div>
<Label htmlFor="button-action"> </Label>
<Select
key={`action-${component.id}`}
value={localInputs.actionType}
onValueChange={(value) => {
// 🔥 로컬 상태 먼저 업데이트
setLocalInputs((prev) => ({ ...prev, actionType: value }));
// 🔥 action.type 업데이트
onUpdateProperty("componentConfig.action.type", value);
// 🔥 색상 업데이트는 충분히 지연 (React 리렌더링 완료 후)
setTimeout(() => {
const newColor = value === "delete" ? "#ef4444" : "#212121";
onUpdateProperty("style.labelColor", newColor);
}, 100); // 0 → 100ms로 증가
}}
>
<SelectTrigger>
<SelectValue placeholder="버튼 액션 선택" />
</SelectTrigger>
<SelectContent>
{/* 핵심 액션 */}
<SelectItem value="save"></SelectItem>
<SelectItem value="delete"></SelectItem>
<SelectItem value="edit"></SelectItem>
<SelectItem value="navigate"> </SelectItem>
<SelectItem value="modal"> </SelectItem>
<SelectItem value="transferData"> </SelectItem>
{/* 엑셀 관련 */}
<SelectItem value="excel_download"> </SelectItem>
<SelectItem value="excel_upload"> </SelectItem>
<SelectItem value="multi_table_excel_upload"> </SelectItem>
{/* 고급 기능 */}
<SelectItem value="quickInsert"> </SelectItem>
<SelectItem value="control"> </SelectItem>
<SelectItem value="approval"> </SelectItem>
{/* 특수 기능 (필요 시 사용) */}
<SelectItem value="barcode_scan"> </SelectItem>
<SelectItem value="operation_control"> </SelectItem>
{/* 이벤트 버스 */}
<SelectItem value="event"> </SelectItem>
{/* 복사 */}
<SelectItem value="copy"> ( )</SelectItem>
{/* 🔒 숨김 처리 - 기존 시스템 호환성 유지, UI에서만 숨김
<SelectItem value="openRelatedModal">연관 데이터 버튼 모달 열기</SelectItem>
<SelectItem value="openModalWithData">(deprecated) 데이터 전달 + 모달 열기</SelectItem>
<SelectItem value="view_table_history">테이블 이력 보기</SelectItem>
<SelectItem value="code_merge">코드 병합</SelectItem>
<SelectItem value="empty_vehicle">공차등록</SelectItem>
*/}
</SelectContent>
</Select>
</div>
{/* 모달 열기 액션 설정 */}
{localInputs.actionType === "modal" && (
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
<h4 className="text-foreground text-sm font-medium"> </h4>
<div>
<Label htmlFor="modal-title"> </Label>
<Input
id="modal-title"
placeholder="모달 제목을 입력하세요"
value={localInputs.modalTitle}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, modalTitle: newValue }));
onUpdateProperty("componentConfig.action.modalTitle", newValue);
}}
/>
</div>
<div>
<Label htmlFor="modal-description"> </Label>
<Input
id="modal-description"
placeholder="모달 설명을 입력하세요 (선택사항)"
value={localInputs.modalDescription}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, modalDescription: newValue }));
onUpdateProperty("componentConfig.action.modalDescription", newValue);
}}
/>
<p className="text-muted-foreground mt-1 text-xs"> </p>
</div>
<div>
<Label htmlFor="modal-size"> </Label>
<Select
value={component.componentConfig?.action?.modalSize || "md"}
onValueChange={(value) => {
onUpdateProperty("componentConfig.action.modalSize", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="모달 크기 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="sm"> (Small)</SelectItem>
<SelectItem value="md"> (Medium)</SelectItem>
<SelectItem value="lg"> (Large)</SelectItem>
<SelectItem value="xl"> (Extra Large)</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="target-screen-modal"> </Label>
<Popover open={modalScreenOpen} onOpenChange={setModalScreenOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={modalScreenOpen}
className="h-6 w-full justify-between px-2 py-0 text-xs"
disabled={screensLoading}
>
{config.action?.targetScreenId
? screens.find((screen) => screen.id === parseInt(config.action?.targetScreenId))?.name ||
"화면을 선택하세요..."
: "화면을 선택하세요..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<div className="flex flex-col">
<div className="flex items-center border-b px-3 py-2">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<Input
placeholder="화면 검색..."
value={modalSearchTerm}
onChange={(e) => setModalSearchTerm(e.target.value)}
className="border-0 p-0 focus-visible:ring-0"
/>
</div>
<div className="max-h-[200px] overflow-auto">
{(() => {
const filteredScreens = filterScreens(modalSearchTerm);
if (screensLoading) {
return <div className="text-muted-foreground p-3 text-sm"> ...</div>;
}
if (filteredScreens.length === 0) {
return <div className="text-muted-foreground p-3 text-sm"> .</div>;
}
return filteredScreens.map((screen, index) => (
<div
key={`modal-screen-${screen.id}-${index}`}
className="hover:bg-muted flex cursor-pointer items-center px-3 py-2"
onClick={() => {
onUpdateProperty("componentConfig.action.targetScreenId", screen.id);
setModalScreenOpen(false);
setModalSearchTerm("");
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
parseInt(config.action?.targetScreenId) === screen.id ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{screen.name}</span>
{screen.description && (
<span className="text-muted-foreground text-xs">{screen.description}</span>
)}
</div>
</div>
));
})()}
</div>
</div>
</PopoverContent>
</Popover>
</div>
{/* 선택된 데이터 전달 옵션 */}
<div className="flex items-center space-x-2">
<Checkbox
id="auto-detect-data-source"
checked={component.componentConfig?.action?.autoDetectDataSource === true}
onCheckedChange={(checked) => {
onUpdateProperty("componentConfig.action.autoDetectDataSource", checked);
if (!checked) {
// 체크 해제 시 필드 매핑도 초기화
onUpdateProperty("componentConfig.action.fieldMappings", []);
}
}}
/>
<div className="flex flex-col">
<Label htmlFor="auto-detect-data-source" className="cursor-pointer text-sm">
</Label>
<p className="text-muted-foreground text-xs">
TableList/SplitPanel에서
</p>
</div>
</div>
{/* 🆕 필드 매핑 UI (데이터 전달 활성화 + 테이블이 다른 경우) */}
{component.componentConfig?.action?.autoDetectDataSource === true && (
<div className="bg-background mt-4 space-y-3 rounded-lg border p-3">
{/* 테이블 정보 표시 */}
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-2">
<Database className="text-muted-foreground h-3 w-3" />
<span className="text-muted-foreground">:</span>
<span className="font-medium">{modalActionSourceTable || "감지 중..."}</span>
</div>
<span className="text-muted-foreground"></span>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{modalActionTargetTable || "감지 중..."}</span>
</div>
</div>
{/* 테이블이 같으면 자동 매핑 안내 */}
{modalActionSourceTable &&
modalActionTargetTable &&
modalActionSourceTable === modalActionTargetTable && (
<div className="rounded-md bg-green-50 p-2 text-xs text-green-700 dark:bg-green-950/30 dark:text-green-400">
. .
</div>
)}
{/* 테이블이 다르면 필드 매핑 UI 표시 */}
{modalActionSourceTable &&
modalActionTargetTable &&
modalActionSourceTable !== modalActionTargetTable && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium"> </Label>
<Button
type="button"
variant="outline"
size="sm"
className="h-6 text-xs"
onClick={() => {
const newMappings = [
...(component.componentConfig?.action?.fieldMappings || []),
{ sourceField: "", targetField: "" },
];
setModalActionFieldMappings(newMappings);
onUpdateProperty("componentConfig.action.fieldMappings", newMappings);
}}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{(component.componentConfig?.action?.fieldMappings || []).length === 0 && (
<p className="text-muted-foreground text-xs">
. .
</p>
)}
{(component.componentConfig?.action?.fieldMappings || []).map((mapping: any, index: number) => (
<div key={index} className="flex items-center gap-2">
{/* 소스 필드 선택 */}
<Popover
open={modalFieldMappingSourceOpen[index] || false}
onOpenChange={(open) =>
setModalFieldMappingSourceOpen((prev) => ({ ...prev, [index]: open }))
}
>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-7 flex-1 justify-between text-xs">
{mapping.sourceField
? modalActionSourceColumns.find((c) => c.name === mapping.sourceField)?.label ||
mapping.sourceField
: "소스 컬럼 선택"}
<ChevronsUpDown className="ml-1 h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput
placeholder="컬럼 검색..."
value={modalFieldMappingSourceSearch[index] || ""}
onValueChange={(val) =>
setModalFieldMappingSourceSearch((prev) => ({ ...prev, [index]: val }))
}
/>
<CommandList>
<CommandEmpty> .</CommandEmpty>
<CommandGroup>
{modalActionSourceColumns
.filter(
(col) =>
col.name
.toLowerCase()
.includes((modalFieldMappingSourceSearch[index] || "").toLowerCase()) ||
col.label
.toLowerCase()
.includes((modalFieldMappingSourceSearch[index] || "").toLowerCase()),
)
.map((col) => (
<CommandItem
key={col.name}
value={col.name}
onSelect={() => {
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 }));
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
mapping.sourceField === col.name ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="text-xs font-medium">{col.label}</span>
<span className="text-muted-foreground text-[10px]">{col.name}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<span className="text-muted-foreground text-xs"></span>
{/* 대상 필드 선택 */}
<Popover
open={modalFieldMappingTargetOpen[index] || false}
onOpenChange={(open) =>
setModalFieldMappingTargetOpen((prev) => ({ ...prev, [index]: open }))
}
>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-7 flex-1 justify-between text-xs">
{mapping.targetField
? modalActionTargetColumns.find((c) => c.name === mapping.targetField)?.label ||
mapping.targetField
: "대상 컬럼 선택"}
<ChevronsUpDown className="ml-1 h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput
placeholder="컬럼 검색..."
value={modalFieldMappingTargetSearch[index] || ""}
onValueChange={(val) =>
setModalFieldMappingTargetSearch((prev) => ({ ...prev, [index]: val }))
}
/>
<CommandList>
<CommandEmpty> .</CommandEmpty>
<CommandGroup>
{modalActionTargetColumns
.filter(
(col) =>
col.name
.toLowerCase()
.includes((modalFieldMappingTargetSearch[index] || "").toLowerCase()) ||
col.label
.toLowerCase()
.includes((modalFieldMappingTargetSearch[index] || "").toLowerCase()),
)
.map((col) => (
<CommandItem
key={col.name}
value={col.name}
onSelect={() => {
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 }));
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
mapping.targetField === col.name ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="text-xs font-medium">{col.label}</span>
<span className="text-muted-foreground text-[10px]">{col.name}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* 삭제 버튼 */}
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive hover:bg-destructive/10 h-7 w-7 p-0"
onClick={() => {
const newMappings = (component.componentConfig?.action?.fieldMappings || []).filter(
(_: any, i: number) => i !== index,
);
setModalActionFieldMappings(newMappings);
onUpdateProperty("componentConfig.action.fieldMappings", newMappings);
}}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
</div>
)}
</div>
)}
{/* 🆕 데이터 전달 + 모달 열기 액션 설정 (deprecated - 하위 호환성 유지) */}
{component.componentConfig?.action?.type === "openModalWithData" && (
<div className="mt-4 space-y-4 rounded-lg border bg-amber-50 p-4 dark:bg-amber-950/20">
<h4 className="text-foreground text-sm font-medium"> + </h4>
<p className="text-xs text-amber-600 dark:text-amber-400">
"모달 열기" . "모달 열기" + "선택된 데이터 전달"
.
</p>
{/* 🆕 블록 기반 제목 빌더 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label> </Label>
<div className="flex gap-1">
<Button type="button" variant="outline" size="sm" onClick={addTextBlock} className="h-6 text-xs">
<Type className="mr-1 h-3 w-3" />
</Button>
<Button type="button" variant="outline" size="sm" onClick={addFieldBlock} className="h-6 text-xs">
<Database className="mr-1 h-3 w-3" />
</Button>
</div>
</div>
{/* 블록 목록 */}
<div className="space-y-2">
{titleBlocks.length === 0 ? (
<div className="text-muted-foreground rounded border-2 border-dashed py-4 text-center text-xs">
</div>
) : (
titleBlocks.map((block, index) => (
<Card key={block.id} className="p-2">
<div className="flex items-start gap-2">
{/* 순서 변경 버튼 */}
<div className="flex flex-col gap-0.5">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => moveBlockUp(block.id)}
disabled={index === 0}
className="h-5 w-5 p-0"
>
<ChevronUp className="h-3 w-3" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => moveBlockDown(block.id)}
disabled={index === titleBlocks.length - 1}
className="h-5 w-5 p-0"
>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
{/* 블록 타입 표시 */}
<div className="mt-1 flex-shrink-0">
{block.type === "text" ? (
<Type className="h-4 w-4 text-blue-500" />
) : (
<Database className="h-4 w-4 text-green-500" />
)}
</div>
{/* 블록 설정 */}
<div className="flex-1 space-y-2">
{block.type === "text" ? (
// 텍스트 블록
<Input
placeholder="텍스트 입력 (예: 품목 상세정보 - )"
value={block.value}
onChange={(e) => updateBlock(block.id, { value: e.target.value })}
className="h-7 text-xs"
/>
) : (
// 필드 블록
<>
{/* 테이블 선택 - Combobox */}
<Popover
open={blockTablePopoverOpen[block.id] || false}
onOpenChange={(open) => {
setBlockTablePopoverOpen((prev) => ({ ...prev, [block.id]: open }));
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-7 w-full justify-between text-xs"
>
{block.tableName
? availableTables.find((t) => t.name === block.tableName)?.label || block.tableName
: "테이블 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command shouldFilter={false}>
<CommandInput
placeholder="테이블 검색 (라벨 또는 이름)..."
className="h-7 text-xs"
value={blockTableSearches[block.id] || ""}
onValueChange={(value) => {
setBlockTableSearches((prev) => ({ ...prev, [block.id]: value }));
}}
/>
<CommandList>
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup>
{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) => (
<CommandItem
key={table.name}
value={table.name}
onSelect={() => {
updateBlock(block.id, { tableName: table.name, value: "" });
loadTableColumns(table.name);
setBlockTableSearches((prev) => ({ ...prev, [block.id]: "" }));
setBlockTablePopoverOpen((prev) => ({ ...prev, [block.id]: false }));
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
block.tableName === table.name ? "opacity-100" : "opacity-0",
)}
/>
<span className="font-medium">{table.label}</span>
<span className="text-muted-foreground ml-2 text-[10px]">
({table.name})
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{block.tableName && (
<>
{/* 컬럼 선택 - Combobox (라벨명 표시) */}
<Popover
open={blockColumnPopoverOpen[block.id] || false}
onOpenChange={(open) => {
setBlockColumnPopoverOpen((prev) => ({ ...prev, [block.id]: open }));
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-7 w-full justify-between text-xs"
>
{block.value
? tableColumnsMap[block.tableName]?.find((c) => c.name === block.value)
?.label || block.value
: "컬럼 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command shouldFilter={false}>
<CommandInput
placeholder="컬럼 검색 (라벨 또는 이름)..."
className="h-7 text-xs"
value={blockColumnSearches[block.id] || ""}
onValueChange={(value) => {
setBlockColumnSearches((prev) => ({ ...prev, [block.id]: value }));
}}
/>
<CommandList>
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup>
{(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) => (
<CommandItem
key={col.name}
value={col.name}
onSelect={() => {
updateBlock(block.id, {
value: col.name,
label: col.label,
});
setBlockColumnSearches((prev) => ({ ...prev, [block.id]: "" }));
setBlockColumnPopoverOpen((prev) => ({ ...prev, [block.id]: false }));
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
block.value === col.name ? "opacity-100" : "opacity-0",
)}
/>
<span className="font-medium">{col.label}</span>
<span className="text-muted-foreground ml-2 text-[10px]">
({col.name})
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Input
placeholder="표시 라벨 (예: 품목명)"
value={block.label || ""}
onChange={(e) => updateBlock(block.id, { label: e.target.value })}
className="h-7 text-xs"
/>
</>
)}
</>
)}
</div>
{/* 삭제 버튼 */}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeBlock(block.id)}
className="h-7 w-7 p-0 text-red-500"
>
<X className="h-4 w-4" />
</Button>
</div>
</Card>
))
)}
</div>
{/* 미리보기 */}
{titleBlocks.length > 0 && (
<div className="bg-muted mt-2 rounded p-2 text-xs">
<span className="text-muted-foreground">: </span>
<span className="font-medium">{generateTitlePreview()}</span>
</div>
)}
<p className="text-muted-foreground text-[10px]">
텍스트: 고정 (: "품목 상세정보 - ")
<br />
필드: 이전 (: 품목명, )
<br />
:
<br /> "표시 라벨"
</p>
</div>
<div>
<Label htmlFor="modal-size-with-data"> </Label>
<Select
value={component.componentConfig?.action?.modalSize || "lg"}
onValueChange={(value) => {
onUpdateProperty("componentConfig.action.modalSize", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="모달 크기 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="sm"> (Small)</SelectItem>
<SelectItem value="md"> (Medium)</SelectItem>
<SelectItem value="lg"> (Large) - </SelectItem>
<SelectItem value="xl"> (Extra Large)</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="target-screen-with-data"> </Label>
<Popover open={modalScreenOpen} onOpenChange={setModalScreenOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={modalScreenOpen}
className="h-6 w-full justify-between px-2 py-0"
className="text-xs"
disabled={screensLoading}
>
{config.action?.targetScreenId
? screens.find((screen) => screen.id === parseInt(config.action?.targetScreenId))?.name ||
"화면을 선택하세요..."
: "화면을 선택하세요..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<div className="flex flex-col">
<div className="flex items-center border-b px-3 py-2">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<Input
placeholder="화면 검색..."
value={modalSearchTerm}
onChange={(e) => setModalSearchTerm(e.target.value)}
className="border-0 p-0 focus-visible:ring-0"
/>
</div>
<div className="max-h-[200px] overflow-auto">
{(() => {
const filteredScreens = filterScreens(modalSearchTerm);
if (screensLoading) {
return <div className="text-muted-foreground p-3 text-sm"> ...</div>;
}
if (filteredScreens.length === 0) {
return <div className="text-muted-foreground p-3 text-sm"> .</div>;
}
return filteredScreens.map((screen, index) => (
<div
key={`modal-data-screen-${screen.id}-${index}`}
className="hover:bg-muted flex cursor-pointer items-center px-3 py-2"
onClick={() => {
onUpdateProperty("componentConfig.action.targetScreenId", screen.id);
setModalScreenOpen(false);
setModalSearchTerm("");
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
parseInt(config.action?.targetScreenId) === screen.id ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{screen.name}</span>
{screen.description && (
<span className="text-muted-foreground text-xs">{screen.description}</span>
)}
</div>
</div>
));
})()}
</div>
</div>
</PopoverContent>
</Popover>
<p className="text-muted-foreground mt-1 text-xs">
SelectedItemsDetailInput
</p>
</div>
{/* 🆕 필드 매핑 설정 (소스 컬럼 → 타겟 컬럼) */}
<div className="space-y-2 border-t pt-4">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium"> ()</Label>
<Button
type="button"
variant="outline"
size="sm"
className="h-6 text-[10px]"
onClick={() => {
const currentMappings = config.action?.fieldMappings || [];
const newMapping = { sourceField: "", targetField: "" };
onUpdateProperty("componentConfig.action.fieldMappings", [...currentMappings, newMapping]);
}}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<p className="text-muted-foreground text-[10px]">
.
<br />
: warehouse_code warehouse_id ( ID에 )
</p>
{/* 컬럼 로드 상태 표시 */}
{modalSourceColumns.length > 0 || modalTargetColumns.length > 0 ? (
<div className="text-muted-foreground bg-muted/50 rounded p-2 text-[10px]">
: {modalSourceColumns.length} / : {modalTargetColumns.length}
</div>
) : (
<div className="rounded bg-amber-50 p-2 text-[10px] text-amber-600 dark:bg-amber-950/20">
.
</div>
)}
{(config.action?.fieldMappings || []).length === 0 ? (
<div className="rounded-md border border-dashed p-3 text-center">
<p className="text-muted-foreground text-xs"> .</p>
</div>
) : (
<div className="space-y-3">
{(config.action?.fieldMappings || []).map((mapping: any, index: number) => (
<div key={index} className="bg-background space-y-2 rounded-md border p-3">
{/* 소스 필드 선택 (Combobox) - 세로 배치 */}
<div className="space-y-1">
<Label className="text-muted-foreground text-[10px]"> </Label>
<Popover
open={modalSourcePopoverOpen[index] || false}
onOpenChange={(open) => setModalSourcePopoverOpen((prev) => ({ ...prev, [index]: open }))}
>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
<span className="truncate">
{mapping.sourceField
? modalSourceColumns.find((c) => c.name === mapping.sourceField)?.label ||
mapping.sourceField
: "소스 컬럼 선택"}
</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] max-w-[280px] p-0" align="start">
<Command>
<CommandInput
placeholder="컬럼 검색..."
className="h-8 text-xs"
value={modalSourceSearch[index] || ""}
onValueChange={(value) => setModalSourceSearch((prev) => ({ ...prev, [index]: value }))}
/>
<CommandList className="max-h-[200px]">
<CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty>
<CommandGroup>
{modalSourceColumns.map((col) => (
<CommandItem
key={col.name}
value={`${col.label} ${col.name}`}
onSelect={() => {
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"
>
<Check
className={cn(
"mr-2 h-3 w-3",
mapping.sourceField === col.name ? "opacity-100" : "opacity-0",
)}
/>
<span className="truncate">{col.label}</span>
{col.label !== col.name && (
<span className="text-muted-foreground ml-1 truncate">({col.name})</span>
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 화살표 표시 */}
<div className="flex justify-center">
<span className="text-muted-foreground text-xs"></span>
</div>
{/* 타겟 필드 선택 (Combobox) - 세로 배치 */}
<div className="space-y-1">
<Label className="text-muted-foreground text-[10px]"> </Label>
<Popover
open={modalTargetPopoverOpen[index] || false}
onOpenChange={(open) => setModalTargetPopoverOpen((prev) => ({ ...prev, [index]: open }))}
>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
<span className="truncate">
{mapping.targetField
? modalTargetColumns.find((c) => c.name === mapping.targetField)?.label ||
mapping.targetField
: "타겟 컬럼 선택"}
</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] max-w-[280px] p-0" align="start">
<Command>
<CommandInput
placeholder="컬럼 검색..."
className="h-8 text-xs"
value={modalTargetSearch[index] || ""}
onValueChange={(value) => setModalTargetSearch((prev) => ({ ...prev, [index]: value }))}
/>
<CommandList className="max-h-[200px]">
<CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty>
<CommandGroup>
{modalTargetColumns.map((col) => (
<CommandItem
key={col.name}
value={`${col.label} ${col.name}`}
onSelect={() => {
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"
>
<Check
className={cn(
"mr-2 h-3 w-3",
mapping.targetField === col.name ? "opacity-100" : "opacity-0",
)}
/>
<span className="truncate">{col.label}</span>
{col.label !== col.name && (
<span className="text-muted-foreground ml-1 truncate">({col.name})</span>
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 삭제 버튼 */}
<div className="flex justify-end pt-1">
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive hover:bg-destructive/10 h-6 text-[10px]"
onClick={() => {
const mappings = [...(config.action?.fieldMappings || [])];
mappings.splice(index, 1);
onUpdateProperty("componentConfig.action.fieldMappings", mappings);
}}
>
<X className="mr-1 h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
{/* 수정 액션 설정 */}
{localInputs.actionType === "edit" && (
<div className="bg-success/10 mt-4 space-y-4 rounded-lg border p-4">
<h4 className="text-foreground text-sm font-medium"> </h4>
<div>
<Label htmlFor="edit-screen"> </Label>
<Popover open={modalScreenOpen} onOpenChange={setModalScreenOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={modalScreenOpen}
className="h-6 w-full justify-between px-2 py-0"
className="text-xs"
disabled={screensLoading}
>
{config.action?.targetScreenId
? screens.find((screen) => screen.id === parseInt(config.action?.targetScreenId))?.name ||
"수정 폼 화면을 선택하세요..."
: "수정 폼 화면을 선택하세요..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<div className="flex flex-col">
<div className="flex items-center border-b px-3 py-2">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<Input
placeholder="화면 검색..."
value={modalSearchTerm}
onChange={(e) => setModalSearchTerm(e.target.value)}
className="border-0 p-0 focus-visible:ring-0"
/>
</div>
<div className="max-h-[200px] overflow-auto">
{(() => {
const filteredScreens = filterScreens(modalSearchTerm);
if (screensLoading) {
return <div className="text-muted-foreground p-3 text-sm"> ...</div>;
}
if (filteredScreens.length === 0) {
return <div className="text-muted-foreground p-3 text-sm"> .</div>;
}
return filteredScreens.map((screen, index) => (
<div
key={`edit-screen-${screen.id}-${index}`}
className="hover:bg-muted flex cursor-pointer items-center px-3 py-2"
onClick={() => {
onUpdateProperty("componentConfig.action.targetScreenId", screen.id);
setModalScreenOpen(false);
setModalSearchTerm("");
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
parseInt(config.action?.targetScreenId) === screen.id ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{screen.name}</span>
{screen.description && (
<span className="text-muted-foreground text-xs">{screen.description}</span>
)}
</div>
</div>
));
})()}
</div>
</div>
</PopoverContent>
</Popover>
<p className="text-muted-foreground mt-1 text-xs">
</p>
</div>
<div>
<Label htmlFor="edit-mode"> </Label>
<Select
value={component.componentConfig?.action?.editMode || "modal"}
onValueChange={(value) => {
onUpdateProperty("componentConfig.action.editMode", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="수정 모드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="modal"> </SelectItem>
<SelectItem value="navigate"> </SelectItem>
<SelectItem value="inline"> </SelectItem>
</SelectContent>
</Select>
</div>
{(component.componentConfig?.action?.editMode || "modal") === "modal" && (
<>
<div>
<Label htmlFor="edit-modal-title"> </Label>
<Input
id="edit-modal-title"
placeholder="모달 제목을 입력하세요 (예: 데이터 수정)"
value={localInputs.editModalTitle}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, editModalTitle: newValue }));
onUpdateProperty("componentConfig.action.editModalTitle", newValue);
onUpdateProperty("webTypeConfig.editModalTitle", newValue);
}}
/>
<p className="text-muted-foreground mt-1 text-xs"> </p>
</div>
<div>
<Label htmlFor="edit-modal-description"> </Label>
<Input
id="edit-modal-description"
placeholder="모달 설명을 입력하세요 (예: 선택한 데이터를 수정합니다)"
value={localInputs.editModalDescription}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, editModalDescription: newValue }));
onUpdateProperty("componentConfig.action.editModalDescription", newValue);
onUpdateProperty("webTypeConfig.editModalDescription", newValue);
}}
/>
<p className="text-muted-foreground mt-1 text-xs"> </p>
</div>
<div>
<Label htmlFor="edit-modal-size"> </Label>
<Select
value={component.componentConfig?.action?.modalSize || "md"}
onValueChange={(value) => {
onUpdateProperty("componentConfig.action.modalSize", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="모달 크기 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="sm"> (Small)</SelectItem>
<SelectItem value="md"> (Medium)</SelectItem>
<SelectItem value="lg"> (Large)</SelectItem>
<SelectItem value="xl"> (Extra Large)</SelectItem>
<SelectItem value="full"> (Full)</SelectItem>
</SelectContent>
</Select>
</div>
</>
)}
<div>
<Label htmlFor="edit-group-by-column"> </Label>
<Popover open={groupByColumnOpen} onOpenChange={setGroupByColumnOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={groupByColumnOpen}
className="h-8 w-full justify-between text-xs"
>
{localInputs.groupByColumn ? (
<span>
{localInputs.groupByColumn}
{currentTableColumns.find((col) => col.name === localInputs.groupByColumn)?.label &&
currentTableColumns.find((col) => col.name === localInputs.groupByColumn)?.label !==
localInputs.groupByColumn && (
<span className="text-muted-foreground ml-1">
({currentTableColumns.find((col) => col.name === localInputs.groupByColumn)?.label})
</span>
)}
</span>
) : (
<span className="text-muted-foreground"> </span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<div className="flex flex-col">
<div className="flex items-center border-b px-3 py-2">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<Input
placeholder="컬럼명 또는 라벨 검색..."
value={groupByColumnSearch}
onChange={(e) => setGroupByColumnSearch(e.target.value)}
className="border-0 p-0 focus-visible:ring-0"
/>
</div>
<div className="max-h-[200px] overflow-auto">
{currentTableColumns.length === 0 ? (
<div className="text-muted-foreground p-3 text-sm">
{currentTableName ? "컬럼을 불러오는 중..." : "테이블이 설정되지 않았습니다"}
</div>
) : (
<>
{/* 선택 해제 옵션 */}
<div
className="hover:bg-muted flex cursor-pointer items-center px-3 py-2"
onClick={() => {
setLocalInputs((prev) => ({ ...prev, groupByColumn: "" }));
onUpdateProperty("componentConfig.action.groupByColumns", undefined);
setGroupByColumnOpen(false);
setGroupByColumnSearch("");
}}
>
<Check
className={cn("mr-2 h-4 w-4", !localInputs.groupByColumn ? "opacity-100" : "opacity-0")}
/>
<span className="text-muted-foreground"> </span>
</div>
{/* 컬럼 목록 */}
{currentTableColumns
.filter((col) => {
if (!groupByColumnSearch) return true;
const search = groupByColumnSearch.toLowerCase();
return col.name.toLowerCase().includes(search) || col.label.toLowerCase().includes(search);
})
.map((col) => (
<div
key={col.name}
className="hover:bg-muted flex cursor-pointer items-center px-3 py-2"
onClick={() => {
setLocalInputs((prev) => ({ ...prev, groupByColumn: col.name }));
onUpdateProperty("componentConfig.action.groupByColumns", [col.name]);
setGroupByColumnOpen(false);
setGroupByColumnSearch("");
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
localInputs.groupByColumn === col.name ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{col.name}</span>
{col.label !== col.name && (
<span className="text-muted-foreground text-xs">{col.label}</span>
)}
</div>
</div>
))}
</>
)}
</div>
</div>
</PopoverContent>
</Popover>
<p className="text-muted-foreground mt-1 text-xs"> </p>
</div>
</div>
)}
{/* 복사 액션 설정 */}
{localInputs.actionType === "copy" && (
<div className="mt-4 space-y-4 rounded-lg border bg-blue-50 p-4">
<h4 className="text-foreground text-sm font-medium"> ( )</h4>
<div>
<Label htmlFor="copy-screen"> </Label>
<Popover open={modalScreenOpen} onOpenChange={setModalScreenOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={modalScreenOpen}
className="h-6 w-full justify-between px-2 py-0"
className="text-xs"
disabled={screensLoading}
>
{config.action?.targetScreenId
? screens.find((screen) => screen.id === parseInt(config.action?.targetScreenId))?.name ||
"복사 폼 화면을 선택하세요..."
: "복사 폼 화면을 선택하세요..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<div className="flex flex-col">
<div className="flex items-center border-b px-3 py-2">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<Input
placeholder="화면 검색..."
value={modalSearchTerm}
onChange={(e) => setModalSearchTerm(e.target.value)}
className="border-0 p-0 focus-visible:ring-0"
/>
</div>
<div className="max-h-[200px] overflow-auto">
{(() => {
const filteredScreens = filterScreens(modalSearchTerm);
if (screensLoading) {
return <div className="text-muted-foreground p-3 text-sm"> ...</div>;
}
if (filteredScreens.length === 0) {
return <div className="text-muted-foreground p-3 text-sm"> .</div>;
}
return filteredScreens.map((screen, index) => (
<div
key={`copy-screen-${screen.id}-${index}`}
className="hover:bg-muted flex cursor-pointer items-center px-3 py-2"
onClick={() => {
onUpdateProperty("componentConfig.action.targetScreenId", screen.id);
setModalScreenOpen(false);
setModalSearchTerm("");
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
parseInt(config.action?.targetScreenId) === screen.id ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{screen.name}</span>
{screen.description && (
<span className="text-muted-foreground text-xs">{screen.description}</span>
)}
</div>
</div>
));
})()}
</div>
</div>
</PopoverContent>
</Popover>
<p className="text-muted-foreground mt-1 text-xs">
,
</p>
</div>
<div>
<Label htmlFor="copy-mode"> </Label>
<Select
value={component.componentConfig?.action?.editMode || "modal"}
onValueChange={(value) => {
onUpdateProperty("componentConfig.action.editMode", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="복사 모드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="modal"> </SelectItem>
<SelectItem value="navigate"> </SelectItem>
</SelectContent>
</Select>
</div>
{(component.componentConfig?.action?.editMode || "modal") === "modal" && (
<>
<div>
<Label htmlFor="copy-modal-title"> </Label>
<Input
id="copy-modal-title"
placeholder="모달 제목을 입력하세요 (예: 데이터 복사)"
value={localInputs.editModalTitle}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, editModalTitle: newValue }));
onUpdateProperty("componentConfig.action.editModalTitle", newValue);
onUpdateProperty("webTypeConfig.editModalTitle", newValue);
}}
/>
<p className="text-muted-foreground mt-1 text-xs"> </p>
</div>
<div>
<Label htmlFor="copy-modal-description"> </Label>
<Input
id="copy-modal-description"
placeholder="모달 설명을 입력하세요 (예: 선택한 데이터를 복사합니다)"
value={localInputs.editModalDescription}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, editModalDescription: newValue }));
onUpdateProperty("componentConfig.action.editModalDescription", newValue);
onUpdateProperty("webTypeConfig.editModalDescription", newValue);
}}
/>
<p className="text-muted-foreground mt-1 text-xs"> </p>
</div>
<div>
<Label htmlFor="copy-modal-size"> </Label>
<Select
value={component.componentConfig?.action?.modalSize || "md"}
onValueChange={(value) => {
onUpdateProperty("componentConfig.action.modalSize", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="모달 크기 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="sm"> (Small)</SelectItem>
<SelectItem value="md"> (Medium)</SelectItem>
<SelectItem value="lg"> (Large)</SelectItem>
<SelectItem value="xl"> (Extra Large)</SelectItem>
<SelectItem value="full"> (Full)</SelectItem>
</SelectContent>
</Select>
</div>
</>
)}
</div>
)}
{/* 테이블 이력 보기 액션 설정 */}
{localInputs.actionType === "view_table_history" && (
<div className="mt-4 space-y-4">
<div>
<Label>
() <span className="text-destructive">*</span>
</Label>
<Popover open={displayColumnOpen} onOpenChange={setDisplayColumnOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={displayColumnOpen}
className="mt-2 h-8 w-full justify-between text-xs"
disabled={columnsLoading || tableColumns.length === 0}
>
{columnsLoading
? "로딩 중..."
: config.action?.historyDisplayColumn
? config.action.historyDisplayColumn
: tableColumns.length === 0
? "사용 가능한 컬럼이 없습니다"
: "컬럼을 선택하세요"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup>
{tableColumns.map((column) => (
<CommandItem
key={column}
value={column}
onSelect={(currentValue) => {
onUpdateProperty("componentConfig.action.historyDisplayColumn", currentValue);
setDisplayColumnOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-4 w-4",
config.action?.historyDisplayColumn === column ? "opacity-100" : "opacity-0",
)}
/>
{column}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
)}
{/* 페이지 이동 액션 설정 */}
{localInputs.actionType === "navigate" && (
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
<h4 className="text-foreground text-sm font-medium"> </h4>
<div>
<Label htmlFor="target-screen-nav"> </Label>
<Popover open={navScreenOpen} onOpenChange={setNavScreenOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={navScreenOpen}
className="h-6 w-full justify-between px-2 py-0"
className="text-xs"
disabled={screensLoading}
>
{config.action?.targetScreenId
? screens.find((screen) => screen.id === parseInt(config.action?.targetScreenId))?.name ||
"화면을 선택하세요..."
: "화면을 선택하세요..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<div className="flex flex-col">
<div className="flex items-center border-b px-3 py-2">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<Input
placeholder="화면 검색..."
value={navSearchTerm}
onChange={(e) => setNavSearchTerm(e.target.value)}
className="border-0 p-0 focus-visible:ring-0"
/>
</div>
<div className="max-h-[200px] overflow-auto">
{(() => {
const filteredScreens = filterScreens(navSearchTerm);
if (screensLoading) {
return <div className="text-muted-foreground p-3 text-sm"> ...</div>;
}
if (filteredScreens.length === 0) {
return <div className="text-muted-foreground p-3 text-sm"> .</div>;
}
return filteredScreens.map((screen, index) => (
<div
key={`navigate-screen-${screen.id}-${index}`}
className="hover:bg-muted flex cursor-pointer items-center px-3 py-2"
onClick={() => {
onUpdateProperty("componentConfig.action.targetScreenId", screen.id);
setNavScreenOpen(false);
setNavSearchTerm("");
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
parseInt(config.action?.targetScreenId) === screen.id ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{screen.name}</span>
{screen.description && (
<span className="text-muted-foreground text-xs">{screen.description}</span>
)}
</div>
</div>
));
})()}
</div>
</div>
</PopoverContent>
</Popover>
<p className="text-muted-foreground mt-1 text-xs">
/screens/{"{"}ID{"}"}
</p>
</div>
<div>
<Label htmlFor="target-url"> URL ()</Label>
<Input
id="target-url"
placeholder="예: /admin/users 또는 https://example.com"
value={localInputs.targetUrl}
onChange={(e) => {
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"
/>
<p className="text-muted-foreground mt-1 text-xs">URL을 </p>
</div>
</div>
)}
{/* 엑셀 다운로드 액션 설정 */}
{localInputs.actionType === "excel_download" && (
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
<h4 className="text-foreground text-sm font-medium"> </h4>
<div>
<Label htmlFor="excel-filename"> ()</Label>
<Input
id="excel-filename"
placeholder="예: 데이터목록 (기본값: export)"
value={config.action?.excelFileName || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.excelFileName", e.target.value)}
className="h-8 text-xs"
/>
<p className="text-muted-foreground mt-1 text-xs">(.xlsx) </p>
</div>
<div>
<Label htmlFor="excel-sheetname"> ()</Label>
<Input
id="excel-sheetname"
placeholder="예: Sheet1 (기본값)"
value={config.action?.excelSheetName || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.excelSheetName", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="excel-include-headers"> </Label>
<Switch
id="excel-include-headers"
checked={config.action?.excelIncludeHeaders !== false}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.excelIncludeHeaders", checked)}
/>
</div>
</div>
)}
{/* 엑셀 업로드 액션 설정 */}
{localInputs.actionType === "excel_upload" && (
<ExcelUploadConfigSection
config={config}
onUpdateProperty={onUpdateProperty}
allComponents={allComponents}
currentTableName={currentTableName}
/>
)}
{/* 다중 테이블 엑셀 업로드: 설정 불필요 (버튼 클릭 시 화면 테이블에서 자동 감지) */}
{/* 바코드 스캔 액션 설정 */}
{localInputs.actionType === "barcode_scan" && (
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
<h4 className="text-foreground text-sm font-medium">📷 </h4>
<div>
<Label htmlFor="barcode-target-field">
<span className="text-destructive">*</span>
</Label>
<Input
id="barcode-target-field"
placeholder="예: barcode, qr_code"
value={config.action?.barcodeTargetField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.barcodeTargetField", e.target.value)}
className="h-8 text-xs"
/>
<p className="text-muted-foreground mt-1 text-xs"> </p>
</div>
<div>
<Label htmlFor="barcode-format"> </Label>
<Select
value={config.action?.barcodeFormat || "all"}
onValueChange={(value) => onUpdateProperty("componentConfig.action.barcodeFormat", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> </SelectItem>
<SelectItem value="1d">1D (CODE128, EAN13 )</SelectItem>
<SelectItem value="2d">2D (QR코드, DataMatrix )</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="barcode-auto-submit"> </Label>
<Switch
id="barcode-auto-submit"
checked={config.action?.barcodeAutoSubmit === true}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.barcodeAutoSubmit", checked)}
/>
</div>
</div>
)}
{/* 코드 병합 액션 설정 */}
{localInputs.actionType === "code_merge" && (
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
<h4 className="text-foreground text-sm font-medium">🔀 </h4>
<div>
<Label htmlFor="merge-column-name">
<span className="text-destructive">*</span>
</Label>
<Input
id="merge-column-name"
placeholder="예: item_code, product_id"
value={config.action?.mergeColumnName || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.mergeColumnName", e.target.value)}
className="h-8 text-xs"
/>
<p className="text-muted-foreground mt-1 text-xs">
(: item_code). .
</p>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="merge-show-preview"> </Label>
<p className="text-muted-foreground text-xs"> </p>
</div>
<Switch
id="merge-show-preview"
checked={config.action?.mergeShowPreview !== false}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.mergeShowPreview", checked)}
/>
</div>
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
<p className="text-xs text-blue-900 dark:text-blue-100">
<strong> :</strong>
<br />
1.
<br />
2.
<br />
3. ,
</p>
</div>
</div>
)}
{/* 공차등록 설정 - 운행알림으로 통합되어 주석 처리 */}
{/* {localInputs.actionType === "empty_vehicle" && (
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
... 공차등록 설정 UI 생략 ...
</div>
)} */}
{/* 운행알림 및 종료 설정 */}
{localInputs.actionType === "operation_control" && (
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
<h4 className="text-foreground text-sm font-medium">🚗 </h4>
<div>
<Label htmlFor="update-table">
<span className="text-destructive">*</span>
</Label>
<Select
value={config.action?.updateTableName || currentTableName || ""}
onValueChange={(value) => {
onUpdateProperty("componentConfig.action.updateTableName", value);
onUpdateProperty("componentConfig.action.updateTargetField", "");
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{availableTables.map((table) => (
<SelectItem key={table.name} value={table.name} className="text-xs">
{table.label || table.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-xs"> (기본: 현재 )</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="update-target-field">
<span className="text-destructive">*</span>
</Label>
<Input
id="update-target-field"
placeholder="예: status"
value={config.action?.updateTargetField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.updateTargetField", e.target.value)}
className="h-8 text-xs"
/>
<p className="text-muted-foreground mt-1 text-xs"> DB </p>
</div>
<div>
<Label htmlFor="update-target-value">
<span className="text-destructive">*</span>
</Label>
<Input
id="update-target-value"
placeholder="예: active"
value={config.action?.updateTargetValue || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.updateTargetValue", e.target.value)}
className="h-8 text-xs"
/>
<p className="text-muted-foreground mt-1 text-xs"> (, )</p>
</div>
</div>
{/* 🆕 키 필드 설정 (레코드 식별용) */}
<div className="mt-4 border-t pt-4">
<h5 className="text-muted-foreground mb-3 text-xs font-medium"> </h5>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="update-key-field">
(DB ) <span className="text-destructive">*</span>
</Label>
<Input
id="update-key-field"
placeholder="예: user_id"
value={config.action?.updateKeyField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.updateKeyField", e.target.value)}
className="h-8 text-xs"
/>
<p className="text-muted-foreground mt-1 text-xs"> DB </p>
</div>
<div>
<Label htmlFor="update-key-source">
<span className="text-destructive">*</span>
</Label>
<Select
value={config.action?.updateKeySourceField || ""}
onValueChange={(value) => onUpdateProperty("componentConfig.action.updateKeySourceField", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="키 값 소스 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__userId__" className="text-xs">
<span className="flex items-center gap-1">
<span className="text-amber-500">🔑</span> ID
</span>
</SelectItem>
<SelectItem value="__userName__" className="text-xs">
<span className="flex items-center gap-1">
<span className="text-amber-500">🔑</span>
</span>
</SelectItem>
<SelectItem value="__companyCode__" className="text-xs">
<span className="flex items-center gap-1">
<span className="text-amber-500">🔑</span>
</span>
</SelectItem>
{tableColumns.map((column) => (
<SelectItem key={column} value={column} className="text-xs">
{column}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-xs"> </p>
</div>
</div>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="update-auto-save"> </Label>
<p className="text-muted-foreground text-xs"> DB에 </p>
</div>
<Switch
id="update-auto-save"
checked={config.action?.updateAutoSave !== false}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.updateAutoSave", checked)}
/>
</div>
<div>
<Label htmlFor="update-confirm-message"> ()</Label>
<Input
id="update-confirm-message"
placeholder="예: 운행을 시작하시겠습니까?"
value={config.action?.confirmMessage || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.confirmMessage", e.target.value)}
className="h-8 text-xs"
/>
<p className="text-muted-foreground mt-1 text-xs"> </p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="update-success-message"> ()</Label>
<Input
id="update-success-message"
placeholder="예: 운행이 시작되었습니다."
value={config.action?.successMessage || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.successMessage", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div>
<Label htmlFor="update-error-message"> ()</Label>
<Input
id="update-error-message"
placeholder="예: 운행 시작에 실패했습니다."
value={config.action?.errorMessage || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.errorMessage", e.target.value)}
className="h-8 text-xs"
/>
</div>
</div>
{/* 위치정보 수집 옵션 */}
<div className="mt-4 border-t pt-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="update-with-geolocation"> </Label>
<p className="text-muted-foreground text-xs"> GPS </p>
</div>
<Switch
id="update-with-geolocation"
checked={config.action?.updateWithGeolocation === true}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.updateWithGeolocation", checked)}
/>
</div>
{config.action?.updateWithGeolocation && (
<div className="mt-3 space-y-3 rounded-md bg-amber-50 p-3 dark:bg-amber-950">
<div className="grid grid-cols-2 gap-2">
<div>
<Label>
<span className="text-destructive">*</span>
</Label>
<Input
placeholder="예: latitude"
value={config.action?.updateGeolocationLatField || ""}
onChange={(e) =>
onUpdateProperty("componentConfig.action.updateGeolocationLatField", e.target.value)
}
className="h-8 text-xs"
/>
</div>
<div>
<Label>
<span className="text-destructive">*</span>
</Label>
<Input
placeholder="예: longitude"
value={config.action?.updateGeolocationLngField || ""}
onChange={(e) =>
onUpdateProperty("componentConfig.action.updateGeolocationLngField", e.target.value)
}
className="h-8 text-xs"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label> ()</Label>
<Input
placeholder="예: accuracy"
value={config.action?.updateGeolocationAccuracyField || ""}
onChange={(e) =>
onUpdateProperty("componentConfig.action.updateGeolocationAccuracyField", e.target.value)
}
className="h-8 text-xs"
/>
</div>
<div>
<Label> ()</Label>
<Input
placeholder="예: location_time"
value={config.action?.updateGeolocationTimestampField || ""}
onChange={(e) =>
onUpdateProperty("componentConfig.action.updateGeolocationTimestampField", e.target.value)
}
className="h-8 text-xs"
/>
</div>
</div>
<p className="text-[10px] text-amber-700 dark:text-amber-300">
GPS .
</p>
</div>
)}
</div>
{/* 🆕 연속 위치 추적 설정 */}
<div className="flex items-center justify-between">
<div>
<Label htmlFor="update-with-tracking"> </Label>
<p className="text-muted-foreground text-xs">10 </p>
</div>
<Switch
id="update-with-tracking"
checked={config.action?.updateWithTracking === true}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.updateWithTracking", checked)}
/>
</div>
{config.action?.updateWithTracking && (
<div className="mt-3 space-y-3 rounded-md bg-green-50 p-3 dark:bg-green-950">
<div>
<Label>
<span className="text-destructive">*</span>
</Label>
<Select
value={config.action?.updateTrackingMode || "start"}
onValueChange={(value) => onUpdateProperty("componentConfig.action.updateTrackingMode", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="모드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="start"> ( )</SelectItem>
<SelectItem value="stop"> ( )</SelectItem>
</SelectContent>
</Select>
</div>
{config.action?.updateTrackingMode === "start" && (
<div>
<Label> ()</Label>
<Input
type="number"
placeholder="10"
value={(config.action?.updateTrackingInterval || 10000) / 1000}
onChange={(e) =>
onUpdateProperty(
"componentConfig.action.updateTrackingInterval",
parseInt(e.target.value) * 1000 || 10000,
)
}
className="h-8 text-xs"
min={5}
max={300}
/>
<p className="text-muted-foreground mt-1 text-[10px]">5 ~ 300 (기본: 10초)</p>
</div>
)}
<p className="text-[10px] text-green-700 dark:text-green-300">
{config.action?.updateTrackingMode === "start"
? "버튼 클릭 시 연속 위치 추적이 시작되고, vehicle_location_history 테이블에 경로가 저장됩니다."
: "버튼 클릭 시 진행 중인 위치 추적이 종료됩니다."}
</p>
</div>
)}
{/* 🆕 버튼 활성화 조건 설정 */}
<div className="mt-4 border-t pt-4">
<h5 className="text-muted-foreground mb-3 text-xs font-medium"> </h5>
{/* 출발지/도착지 필수 체크 */}
<div className="flex items-center justify-between">
<div>
<Label htmlFor="require-location">/ </Label>
<p className="text-muted-foreground text-xs"> </p>
</div>
<Switch
id="require-location"
checked={config.action?.requireLocationFields === true}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.requireLocationFields", checked)}
/>
</div>
{config.action?.requireLocationFields && (
<div className="mt-3 space-y-2 rounded-md bg-orange-50 p-3 dark:bg-orange-950">
<div className="grid grid-cols-2 gap-2">
<div>
<Label> </Label>
<Input
placeholder="departure"
value={config.action?.trackingDepartureField || "departure"}
onChange={(e) =>
onUpdateProperty("componentConfig.action.trackingDepartureField", e.target.value)
}
className="h-8 text-xs"
/>
</div>
<div>
<Label> </Label>
<Input
placeholder="destination"
value={config.action?.trackingArrivalField || "destination"}
onChange={(e) => onUpdateProperty("componentConfig.action.trackingArrivalField", e.target.value)}
className="h-8 text-xs"
/>
</div>
</div>
</div>
)}
{/* 상태 기반 활성화 조건 */}
<div className="mt-4 flex items-center justify-between">
<div>
<Label htmlFor="enable-on-status"> </Label>
<p className="text-muted-foreground text-xs"> </p>
</div>
<Switch
id="enable-on-status"
checked={config.action?.enableOnStatusCheck === true}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.enableOnStatusCheck", checked)}
/>
</div>
{config.action?.enableOnStatusCheck && (
<div className="mt-3 space-y-2 rounded-md bg-purple-50 p-3 dark:bg-purple-950">
<div>
<Label> </Label>
<Select
value={config.action?.statusCheckTableName || "vehicles"}
onValueChange={(value) => onUpdateProperty("componentConfig.action.statusCheckTableName", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableTables.map((table) => (
<SelectItem key={table.name} value={table.name} className="text-xs">
{table.label || table.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-[10px]"> (기본: vehicles)</p>
</div>
<div>
<Label> </Label>
<Input
placeholder="user_id"
value={config.action?.statusCheckKeyField || "user_id"}
onChange={(e) => onUpdateProperty("componentConfig.action.statusCheckKeyField", e.target.value)}
className="h-8 text-xs"
/>
<p className="text-muted-foreground mt-1 text-[10px]">
ID로 (기본: user_id)
</p>
</div>
<div>
<Label> </Label>
<Input
placeholder="status"
value={config.action?.statusCheckField || "status"}
onChange={(e) => onUpdateProperty("componentConfig.action.statusCheckField", e.target.value)}
className="h-8 text-xs"
/>
<p className="text-muted-foreground mt-1 text-[10px]"> (기본: status)</p>
</div>
<div>
<Label> </Label>
<Select
value={config.action?.statusConditionType || "enableOn"}
onValueChange={(value) => onUpdateProperty("componentConfig.action.statusConditionType", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="enableOn"> </SelectItem>
<SelectItem value="disableOn"> </SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label> ( )</Label>
<Input
placeholder="예: active, inactive"
value={config.action?.statusConditionValues || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.statusConditionValues", e.target.value)}
className="h-8 text-xs"
/>
<p className="text-muted-foreground mt-1 text-[10px]"> (,) </p>
</div>
</div>
)}
</div>
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
<p className="text-xs text-blue-900 dark:text-blue-100">
<strong> :</strong>
<br />
- 시작: status를 &quot;active&quot; +
<br />
- 종료: status를 &quot;completed&quot; +
<br />- 공차등록: status를 &quot;inactive&quot; + 1
</p>
</div>
</div>
)}
{/* 데이터 전달 액션 설정 */}
{localInputs.actionType === "transferData" && (
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
<h4 className="text-foreground text-sm font-medium">📦 </h4>
{/* 소스 컴포넌트 선택 (Combobox) */}
<div>
<Label>
<span className="text-destructive">*</span>
</Label>
<Select
value={config.action?.dataTransfer?.sourceComponentId || ""}
onValueChange={(value) =>
onUpdateProperty("componentConfig.action.dataTransfer.sourceComponentId", value)
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="데이터를 가져올 컴포넌트 선택" />
</SelectTrigger>
<SelectContent>
{/* 자동 탐색 옵션 (레이어별 테이블이 다를 때 유용) */}
<SelectItem value="__auto__">
<div className="flex items-center gap-2">
<span className="text-xs font-medium"> ( )</span>
<span className="text-muted-foreground text-[10px]">(auto)</span>
</div>
</SelectItem>
{/* 데이터 제공 가능한 컴포넌트 필터링 (모든 레이어 포함) */}
{allComponents
.filter((comp: any) => {
const type = comp.componentType || comp.type || "";
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) =>
type.includes(t),
);
})
.map((comp: any) => {
const compType = comp.componentType || comp.type || "unknown";
const compLabel = comp.label || comp.componentConfig?.title || comp.id;
const layerName = comp._layerName;
return (
<SelectItem key={comp.id} value={comp.id}>
<div className="flex items-center gap-2">
<span className="text-xs font-medium">{compLabel}</span>
<span className="text-muted-foreground text-[10px]">({compType})</span>
{layerName && (
<span className="rounded bg-amber-100 px-1 text-[9px] text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
{layerName}
</span>
)}
</div>
</SelectItem>
);
})}
{allComponents.filter((comp: any) => {
const type = comp.componentType || comp.type || "";
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) =>
type.includes(t),
);
}).length === 0 && (
<SelectItem value="__none__" disabled>
</SelectItem>
)}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-xs">
"자동 탐색"
</p>
</div>
<div>
<Label htmlFor="target-type">
<span className="text-destructive">*</span>
</Label>
<Select
value={config.action?.dataTransfer?.targetType || "component"}
onValueChange={(value) => onUpdateProperty("componentConfig.action.dataTransfer.targetType", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="component"> </SelectItem>
<SelectItem value="splitPanel"> </SelectItem>
<SelectItem value="screen" disabled>
( )
</SelectItem>
</SelectContent>
</Select>
{config.action?.dataTransfer?.targetType === "splitPanel" && (
<p className="text-muted-foreground mt-1 text-[10px]">
. ,
.
</p>
)}
</div>
{/* 타겟 컴포넌트 선택 (같은 화면의 컴포넌트일 때만) */}
{config.action?.dataTransfer?.targetType === "component" && (
<div>
<Label>
<span className="text-destructive">*</span>
</Label>
<Select
value={config.action?.dataTransfer?.targetComponentId || ""}
onValueChange={(value) => {
onUpdateProperty("componentConfig.action.dataTransfer.targetComponentId", value);
// 선택한 컴포넌트가 다른 레이어에 있으면 targetLayerId도 저장
const selectedComp = allComponents.find((c: any) => c.id === value);
if (selectedComp && (selectedComp as any)._layerId) {
onUpdateProperty(
"componentConfig.action.dataTransfer.targetLayerId",
(selectedComp as any)._layerId,
);
} else {
onUpdateProperty("componentConfig.action.dataTransfer.targetLayerId", undefined);
}
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="데이터를 받을 컴포넌트 선택" />
</SelectTrigger>
<SelectContent>
{/* 데이터 수신 가능한 컴포넌트 필터링 (모든 레이어 포함, 소스와 다른 컴포넌트만) */}
{allComponents
.filter((comp: any) => {
const type = comp.componentType || comp.type || "";
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some(
(t) => type.includes(t),
);
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
})
.map((comp: any) => {
const compType = comp.componentType || comp.type || "unknown";
const compLabel = comp.label || comp.componentConfig?.title || comp.id;
const layerName = comp._layerName;
return (
<SelectItem key={comp.id} value={comp.id}>
<div className="flex items-center gap-2">
<span className="text-xs font-medium">{compLabel}</span>
<span className="text-muted-foreground text-[10px]">({compType})</span>
{layerName && (
<span className="rounded bg-amber-100 px-1 text-[9px] text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
{layerName}
</span>
)}
</div>
</SelectItem>
);
})}
{allComponents.filter((comp: any) => {
const type = comp.componentType || comp.type || "";
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) =>
type.includes(t),
);
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
}).length === 0 && (
<SelectItem value="__none__" disabled>
</SelectItem>
)}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-xs">, </p>
</div>
)}
{/* 분할 패널 반대편 타겟 설정 */}
{config.action?.dataTransfer?.targetType === "splitPanel" && (
<div>
<Label> ID ()</Label>
<Input
value={config.action?.dataTransfer?.targetComponentId || ""}
onChange={(e) =>
onUpdateProperty("componentConfig.action.dataTransfer.targetComponentId", e.target.value)
}
placeholder="비워두면 첫 번째 수신 가능 컴포넌트로 전달"
className="h-8 text-xs"
/>
<p className="text-muted-foreground mt-1 text-xs">
ID를 , .
</p>
</div>
)}
<div>
<Label htmlFor="transfer-mode"> </Label>
<Select
value={config.action?.dataTransfer?.mode || "append"}
onValueChange={(value) => onUpdateProperty("componentConfig.action.dataTransfer.mode", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="append"> (Append)</SelectItem>
<SelectItem value="replace"> (Replace)</SelectItem>
<SelectItem value="merge"> (Merge)</SelectItem>
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-xs"> </p>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="clear-after-transfer"> </Label>
<p className="text-muted-foreground text-xs"> </p>
</div>
<Switch
id="clear-after-transfer"
checked={config.action?.dataTransfer?.clearAfterTransfer === true}
onCheckedChange={(checked) =>
onUpdateProperty("componentConfig.action.dataTransfer.clearAfterTransfer", checked)
}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="confirm-before-transfer"> </Label>
<p className="text-muted-foreground text-xs"> </p>
</div>
<Switch
id="confirm-before-transfer"
checked={config.action?.dataTransfer?.confirmBeforeTransfer === true}
onCheckedChange={(checked) =>
onUpdateProperty("componentConfig.action.dataTransfer.confirmBeforeTransfer", checked)
}
/>
</div>
{config.action?.dataTransfer?.confirmBeforeTransfer && (
<div>
<Label htmlFor="confirm-message"> </Label>
<Input
id="confirm-message"
placeholder="선택한 항목을 전달하시겠습니까?"
value={config.action?.dataTransfer?.confirmMessage || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.dataTransfer.confirmMessage", e.target.value)}
className="h-8 text-xs"
/>
</div>
)}
<div className="space-y-2">
<Label> </Label>
<div className="space-y-2 rounded-md border p-3">
<div className="flex items-center gap-2">
<Label htmlFor="min-selection" className="text-xs">
</Label>
<Input
id="min-selection"
type="number"
placeholder="0"
value={config.action?.dataTransfer?.validation?.minSelection || ""}
onChange={(e) =>
onUpdateProperty(
"componentConfig.action.dataTransfer.validation.minSelection",
parseInt(e.target.value) || 0,
)
}
className="h-8 w-20 text-xs"
/>
</div>
<div className="flex items-center gap-2">
<Label htmlFor="max-selection" className="text-xs">
</Label>
<Input
id="max-selection"
type="number"
placeholder="제한없음"
value={config.action?.dataTransfer?.validation?.maxSelection || ""}
onChange={(e) =>
onUpdateProperty(
"componentConfig.action.dataTransfer.validation.maxSelection",
parseInt(e.target.value) || undefined,
)
}
className="h-8 w-20 text-xs"
/>
</div>
</div>
</div>
<div className="space-y-2">
<Label> ()</Label>
<p className="text-muted-foreground text-xs">
</p>
<div className="space-y-2 rounded-md border p-3">
<div>
<Label className="text-xs"> </Label>
<Select
value={config.action?.dataTransfer?.additionalSources?.[0]?.componentId || ""}
onValueChange={(value) => {
const currentSources = config.action?.dataTransfer?.additionalSources || [];
const newSources = [...currentSources];
if (newSources.length === 0) {
newSources.push({ componentId: value, fieldName: "" });
} else {
newSources[0] = { ...newSources[0], componentId: value };
}
onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources);
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="추가 데이터 컴포넌트 선택 (선택사항)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__clear__">
<span className="text-muted-foreground"> </span>
</SelectItem>
{/* 추가 데이터 제공 가능한 컴포넌트 (조건부 컨테이너, 셀렉트박스 등) */}
{allComponents
.filter((comp: any) => {
const type = comp.componentType || comp.type || "";
// 소스/타겟과 다른 컴포넌트 중 값을 제공할 수 있는 타입
return ["conditional-container", "select-basic", "select", "combobox"].some((t) =>
type.includes(t),
);
})
.map((comp: any) => {
const compType = comp.componentType || comp.type || "unknown";
const compLabel = comp.label || comp.componentConfig?.controlLabel || comp.id;
return (
<SelectItem key={comp.id} value={comp.id}>
<div className="flex items-center gap-2">
<span className="text-xs font-medium">{compLabel}</span>
<span className="text-muted-foreground text-[10px]">({compType})</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-xs">
, ( )
</p>
</div>
<div>
<Label htmlFor="additional-field-name" className="text-xs">
()
</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-8 w-full justify-between text-xs"
>
{(() => {
const fieldName = config.action?.dataTransfer?.additionalSources?.[0]?.fieldName;
if (!fieldName) return "필드 선택 (비워두면 전체 데이터)";
const cols = mappingTargetColumns.length > 0 ? mappingTargetColumns : currentTableColumns;
const found = cols.find((c) => c.name === fieldName);
return found ? `${found.label || found.name}` : fieldName;
})()}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[240px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs"> .</CommandEmpty>
<CommandGroup>
<CommandItem
value="__none__"
onSelect={() => {
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"
>
<Check className={cn("mr-2 h-3 w-3", !config.action?.dataTransfer?.additionalSources?.[0]?.fieldName ? "opacity-100" : "opacity-0")} />
<span className="text-muted-foreground"> ( )</span>
</CommandItem>
{(mappingTargetColumns.length > 0 ? mappingTargetColumns : currentTableColumns).map((col) => (
<CommandItem
key={col.name}
value={`${col.label || ""} ${col.name}`}
onSelect={() => {
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"
>
<Check className={cn("mr-2 h-3 w-3", config.action?.dataTransfer?.additionalSources?.[0]?.fieldName === col.name ? "opacity-100" : "opacity-0")} />
<span className="font-medium">{col.label || col.name}</span>
{col.label && col.label !== col.name && (
<span className="text-muted-foreground ml-1 text-[10px]">({col.name})</span>
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-muted-foreground mt-1 text-xs"> </p>
</div>
</div>
</div>
{/* 멀티 테이블 필드 매핑 */}
<div className="space-y-3">
<Label> </Label>
{/* 타겟 테이블 (공통) */}
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
{config.action?.dataTransfer?.targetTable
? availableTables.find((t) => t.name === config.action?.dataTransfer?.targetTable)?.label ||
config.action?.dataTransfer?.targetTable
: "타겟 테이블 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[250px] p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty>
<CommandGroup>
{availableTables.map((table) => (
<CommandItem
key={table.name}
value={`${table.label} ${table.name}`}
onSelect={() => {
onUpdateProperty("componentConfig.action.dataTransfer.targetTable", table.name);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.action?.dataTransfer?.targetTable === table.name ? "opacity-100" : "opacity-0",
)}
/>
<span className="font-medium">{table.label}</span>
<span className="text-muted-foreground ml-1">({table.name})</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 소스 테이블 매핑 그룹 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Button
type="button"
variant="outline"
size="sm"
className="h-6 text-[10px]"
onClick={() => {
const currentMappings = config.action?.dataTransfer?.multiTableMappings || [];
onUpdateProperty("componentConfig.action.dataTransfer.multiTableMappings", [
...currentMappings,
{ sourceTable: "", mappingRules: [] },
]);
setActiveMappingGroupIndex(currentMappings.length);
}}
disabled={!config.action?.dataTransfer?.targetTable}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<p className="text-muted-foreground text-[10px]">
, . .
</p>
{!config.action?.dataTransfer?.targetTable ? (
<div className="rounded-md border border-dashed p-3 text-center">
<p className="text-muted-foreground text-xs"> .</p>
</div>
) : !(config.action?.dataTransfer?.multiTableMappings || []).length ? (
<div className="rounded-md border border-dashed p-3 text-center">
<p className="text-muted-foreground text-xs">
. .
</p>
</div>
) : (
<div className="space-y-2">
{/* 소스 테이블 탭 */}
<div className="flex flex-wrap gap-1">
{(config.action?.dataTransfer?.multiTableMappings || []).map((group: any, gIdx: number) => (
<div key={gIdx} className="flex items-center gap-0.5">
<Button
type="button"
variant={activeMappingGroupIndex === gIdx ? "default" : "outline"}
size="sm"
className="h-6 text-[10px]"
onClick={() => setActiveMappingGroupIndex(gIdx)}
>
{group.sourceTable
? availableTables.find((t) => t.name === group.sourceTable)?.label || group.sourceTable
: `그룹 ${gIdx + 1}`}
{group.mappingRules?.length > 0 && (
<span className="bg-primary/20 ml-1 rounded-full px-1 text-[9px]">
{group.mappingRules.length}
</span>
)}
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="text-destructive hover:bg-destructive/10 h-5 w-5"
onClick={() => {
const mappings = [...(config.action?.dataTransfer?.multiTableMappings || [])];
mappings.splice(gIdx, 1);
onUpdateProperty("componentConfig.action.dataTransfer.multiTableMappings", mappings);
if (activeMappingGroupIndex >= mappings.length) {
setActiveMappingGroupIndex(Math.max(0, mappings.length - 1));
}
}}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
{/* 활성 그룹 편집 영역 */}
{(() => {
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 (
<div className="space-y-2 rounded-md border p-3">
{/* 소스 테이블 선택 */}
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
{activeSourceTable
? availableTables.find((t) => t.name === activeSourceTable)?.label || activeSourceTable
: "소스 테이블 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[250px] p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty>
<CommandGroup>
{availableTables.map((table) => (
<CommandItem
key={table.name}
value={`${table.label} ${table.name}`}
onSelect={async () => {
updateGroupField("sourceTable", table.name);
if (!mappingSourceColumnsMap[table.name]) {
const cols = await loadMappingColumns(table.name);
setMappingSourceColumnsMap((prev) => ({ ...prev, [table.name]: cols }));
}
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
activeSourceTable === table.name ? "opacity-100" : "opacity-0",
)}
/>
<span className="font-medium">{table.label}</span>
<span className="text-muted-foreground ml-1">({table.name})</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 매핑 규칙 목록 */}
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-[10px]"> </Label>
<Button
type="button"
variant="outline"
size="sm"
className="h-5 text-[10px]"
onClick={() => {
updateGroupField("mappingRules", [...activeRules, { sourceField: "", targetField: "" }]);
}}
disabled={!activeSourceTable}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{!activeSourceTable ? (
<p className="text-muted-foreground text-[10px]"> .</p>
) : activeRules.length === 0 ? (
<p className="text-muted-foreground text-[10px]"> ( )</p>
) : (
activeRules.map((rule: any, rIdx: number) => {
const popoverKeyS = `${activeMappingGroupIndex}-${rIdx}-s`;
const popoverKeyT = `${activeMappingGroupIndex}-${rIdx}-t`;
return (
<div key={rIdx} className="bg-background flex items-center gap-2 rounded-md border p-2">
<div className="flex-1">
<Popover
open={mappingSourcePopoverOpen[popoverKeyS] || false}
onOpenChange={(open) =>
setMappingSourcePopoverOpen((prev) => ({ ...prev, [popoverKeyS]: open }))
}
>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-7 w-full justify-between text-xs">
{rule.sourceField
? activeSourceColumns.find((c) => c.name === rule.sourceField)?.label || rule.sourceField
: "소스 필드"}
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty>
<CommandGroup>
{activeSourceColumns.map((col) => (
<CommandItem
key={col.name}
value={`${col.label} ${col.name}`}
onSelect={() => {
const newRules = [...activeRules];
newRules[rIdx] = { ...newRules[rIdx], sourceField: col.name };
updateGroupField("mappingRules", newRules);
setMappingSourcePopoverOpen((prev) => ({ ...prev, [popoverKeyS]: false }));
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", rule.sourceField === col.name ? "opacity-100" : "opacity-0")} />
<span>{col.label}</span>
{col.label !== col.name && <span className="text-muted-foreground ml-1">({col.name})</span>}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<span className="text-muted-foreground text-xs"></span>
<div className="flex-1">
<Popover
open={mappingTargetPopoverOpen[popoverKeyT] || false}
onOpenChange={(open) =>
setMappingTargetPopoverOpen((prev) => ({ ...prev, [popoverKeyT]: open }))
}
>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-7 w-full justify-between text-xs">
{rule.targetField
? mappingTargetColumns.find((c) => c.name === rule.targetField)?.label || rule.targetField
: "타겟 필드"}
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty>
<CommandGroup>
{mappingTargetColumns.map((col) => (
<CommandItem
key={col.name}
value={`${col.label} ${col.name}`}
onSelect={() => {
const newRules = [...activeRules];
newRules[rIdx] = { ...newRules[rIdx], targetField: col.name };
updateGroupField("mappingRules", newRules);
setMappingTargetPopoverOpen((prev) => ({ ...prev, [popoverKeyT]: false }));
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", rule.targetField === col.name ? "opacity-100" : "opacity-0")} />
<span>{col.label}</span>
{col.label !== col.name && <span className="text-muted-foreground ml-1">({col.name})</span>}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="text-destructive hover:bg-destructive/10 h-7 w-7"
onClick={() => {
const newRules = [...activeRules];
newRules.splice(rIdx, 1);
updateGroupField("mappingRules", newRules);
}}
>
<X className="h-3 w-3" />
</Button>
</div>
);
})
)}
</div>
</div>
);
})()}
</div>
)}
</div>
</div>
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
<p className="text-xs text-blue-900 dark:text-blue-100">
<strong> :</strong>
<br />
1.
<br />
2.
<br />
3.
</p>
</div>
</div>
)}
{/* 🆕 즉시 저장(quickInsert) 액션 설정 */}
{component.componentConfig?.action?.type === "quickInsert" && (
<QuickInsertConfigSection
component={component}
onUpdateProperty={onUpdateProperty}
allComponents={allComponents}
currentTableName={currentTableName}
/>
)}
{/* 결재 요청(approval) 액션 설정 */}
{localInputs.actionType === "approval" && (
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
<h4 className="text-foreground text-sm font-medium"> </h4>
<p className="text-muted-foreground text-xs">
. .
</p>
<div>
<Label htmlFor="approval-definition" className="text-xs sm:text-sm">
</Label>
<Select
value={String(component.componentConfig?.action?.approvalDefinitionId || "")}
onValueChange={(value) => {
onUpdateProperty("componentConfig.action.approvalDefinitionId", value === "none" ? null : Number(value));
}}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder={approvalDefinitionsLoading ? "로딩 중..." : "결재 유형 선택 (선택사항)"} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> ( )</SelectItem>
{approvalDefinitions.map((def) => (
<SelectItem key={def.definition_id} value={String(def.definition_id)}>
{def.definition_name}
{def.description ? ` - ${def.description}` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
릿
</p>
</div>
<div>
<Label htmlFor="approval-target-table" className="text-xs sm:text-sm">
</Label>
<Input
id="approval-target-table"
placeholder={currentTableName || "예: purchase_orders"}
value={component.componentConfig?.action?.approvalTargetTable || currentTableName || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.approvalTargetTable", e.target.value)}
className="h-8 text-xs sm:h-10 sm:text-sm"
readOnly={!!currentTableName && !component.componentConfig?.action?.approvalTargetTable}
/>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
{currentTableName
? `현재 화면 테이블 "${currentTableName}" 자동 적용됨`
: "결재 대상 레코드가 저장된 테이블명"}
</p>
</div>
<div>
<Label htmlFor="approval-record-id-field" className="text-xs sm:text-sm">
ID
</Label>
<Input
id="approval-record-id-field"
placeholder="예: id, purchase_id"
value={component.componentConfig?.action?.approvalRecordIdField || "id"}
onChange={(e) => onUpdateProperty("componentConfig.action.approvalRecordIdField", e.target.value)}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
PK
</p>
</div>
</div>
)}
{/* 🆕 이벤트 발송 액션 설정 */}
{localInputs.actionType === "event" && (
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
<h4 className="text-foreground text-sm font-medium"> </h4>
<p className="text-muted-foreground text-xs">
V2 .
.
</p>
<div>
<Label htmlFor="event-name"> </Label>
<Select
value={component.componentConfig?.action?.eventConfig?.eventName || ""}
onValueChange={(value) => {
onUpdateProperty("componentConfig.action.eventConfig.eventName", value);
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="이벤트 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="SCHEDULE_GENERATE_REQUEST"> </SelectItem>
<SelectItem value="TABLE_REFRESH"> </SelectItem>
<SelectItem value="DATA_CHANGED"> </SelectItem>
</SelectContent>
</Select>
</div>
{component.componentConfig?.action?.eventConfig?.eventName === "SCHEDULE_GENERATE_REQUEST" && (
<div className="border-primary/20 space-y-3 border-l-2 pl-4">
<div>
<Label> </Label>
<Select
value={component.componentConfig?.action?.eventConfig?.eventPayload?.scheduleType || "PRODUCTION"}
onValueChange={(value) => {
onUpdateProperty("componentConfig.action.eventConfig.eventPayload.scheduleType", value);
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="스케줄 유형 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="PRODUCTION"> </SelectItem>
<SelectItem value="DELIVERY"> </SelectItem>
<SelectItem value="MAINTENANCE"> </SelectItem>
<SelectItem value="CUSTOM"></SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label> ()</Label>
<Input
type="number"
className="h-8 text-xs"
placeholder="3"
value={
component.componentConfig?.action?.eventConfig?.eventPayload?.config?.scheduling?.leadTimeDays || 3
}
onChange={(e) => {
onUpdateProperty(
"componentConfig.action.eventConfig.eventPayload.config.scheduling.leadTimeDays",
parseInt(e.target.value) || 3,
);
}}
/>
</div>
<div>
<Label> </Label>
<Input
type="number"
className="h-8 text-xs"
placeholder="100"
value={
component.componentConfig?.action?.eventConfig?.eventPayload?.config?.scheduling
?.maxDailyCapacity || 100
}
onChange={(e) => {
onUpdateProperty(
"componentConfig.action.eventConfig.eventPayload.config.scheduling.maxDailyCapacity",
parseInt(e.target.value) || 100,
);
}}
/>
</div>
<div className="rounded-md bg-blue-50 p-2 dark:bg-blue-950/20">
<p className="text-xs text-blue-800 dark:text-blue-200">
<strong> :</strong> .
.
</p>
</div>
</div>
)}
</div>
)}
{/* 🆕 행 선택 시에만 활성화 설정 */}
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
<h4 className="text-foreground text-sm font-medium"> </h4>
<p className="text-muted-foreground text-xs">
.
</p>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label> </Label>
<p className="text-muted-foreground text-xs"> .</p>
</div>
<Switch
checked={component.componentConfig?.action?.requireRowSelection || false}
onCheckedChange={(checked) => {
onUpdateProperty("componentConfig.action.requireRowSelection", checked);
}}
/>
</div>
{component.componentConfig?.action?.requireRowSelection && (
<div className="border-primary/20 space-y-3 border-l-2 pl-4">
<div>
<Label htmlFor="row-selection-source"> </Label>
<Select
value={component.componentConfig?.action?.rowSelectionSource || "auto"}
onValueChange={(value) => {
onUpdateProperty("componentConfig.action.rowSelectionSource", value);
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="데이터 소스 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto"> ()</SelectItem>
<SelectItem value="tableList"> </SelectItem>
<SelectItem value="splitPanelLeft"> </SelectItem>
<SelectItem value="flowWidget"> </SelectItem>
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-xs">
감지: 테이블, ,
</p>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label> </Label>
<p className="text-muted-foreground text-xs">
(기본: 1개 )
</p>
</div>
<Switch
checked={component.componentConfig?.action?.allowMultiRowSelection ?? true}
onCheckedChange={(checked) => {
onUpdateProperty("componentConfig.action.allowMultiRowSelection", checked);
}}
/>
</div>
{!(component.componentConfig?.action?.allowMultiRowSelection ?? true) && (
<div className="rounded-md bg-yellow-50 p-2 dark:bg-yellow-950/20">
<p className="text-xs text-yellow-800 dark:text-yellow-200">
1 .
</p>
</div>
)}
</div>
)}
</div>
{/* 제어 기능 섹션 - 엑셀 업로드 계열이 아닐 때만 표시 */}
{localInputs.actionType !== "excel_upload" && localInputs.actionType !== "multi_table_excel_upload" && (
<div className="border-border mt-8 border-t pt-6">
<ImprovedButtonControlConfigPanel component={component} onUpdateProperty={onUpdateProperty} />
</div>
)}
{/* 🆕 플로우 단계별 표시 제어 섹션 (플로우 위젯이 있을 때만 표시) */}
{hasFlowWidget && (
<div className="border-border mt-8 border-t pt-6">
<FlowVisibilityConfigPanel
component={component}
allComponents={allComponents}
onUpdateProperty={onUpdateProperty}
/>
</div>
)}
</div>
);
};
/**
* 마스터-디테일 엑셀 업로드 설정 컴포넌트
* 분할 패널 + 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<Record<string, Array<{ name: string; label: string }>>>({});
// 마스터-디테일 설정
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<string, any>) => {
onUpdateProperty("componentConfig.action.masterDetailExcel", {
...masterDetailConfig,
...updates,
});
};
// 분할 패널이 없으면 표시하지 않음
if (!splitPanelInfo) {
return (
<div className="space-y-2 border-t pt-4">
<p className="text-muted-foreground text-xs">
. - .
</p>
</div>
);
}
return (
<div className="space-y-4 border-t pt-4">
<h5 className="text-muted-foreground text-xs font-medium">- ( )</h5>
{/* 자동 감지된 정보 표시 */}
<div className="rounded-md bg-gray-50 p-3 dark:bg-gray-900">
<p className="mb-2 text-xs font-medium"> :</p>
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<span className="text-muted-foreground">:</span>{" "}
<span className="font-medium">{masterTable || "-"}</span>
</div>
<div>
<span className="text-muted-foreground">:</span>{" "}
<span className="font-medium">{detailTable || "-"}</span>
</div>
</div>
{loading ? (
<p className="text-muted-foreground mt-2 text-xs">FK ...</p>
) : relationInfo ? (
<div className="mt-2 grid grid-cols-2 gap-2 text-xs">
<div>
<span className="text-muted-foreground"> :</span>{" "}
<span className="font-medium text-green-600">{relationInfo.masterKeyColumn}</span>
</div>
<div>
<span className="text-muted-foreground"> FK:</span>{" "}
<span className="font-medium text-green-600">{relationInfo.detailFkColumn}</span>
</div>
</div>
) : (
<p className="mt-2 text-xs text-amber-600">
FK . reference_table을 .
</p>
)}
</div>
{/* 마스터 키 자동 생성 안내 */}
{relationInfo && (
<p className="text-muted-foreground border-t pt-2 text-xs">
<strong>{relationInfo.masterKeyColumn}</strong>
.
</p>
)}
{/* 마스터 필드 선택 - 사용자가 엑셀 업로드 시 입력할 필드 */}
{relationInfo && masterColumns.length > 0 && (
<div>
<Label className="text-xs"> ()</Label>
<p className="text-muted-foreground mb-2 text-xs">
/ .
</p>
<div className="max-h-40 space-y-1 overflow-y-auto rounded-md border p-2">
{masterColumns
.filter((col) => col.columnName !== relationInfo.masterKeyColumn) // 채번으로 자동 생성되는 키는 제외
.map((col) => {
const selectedFields = masterDetailConfig.masterSelectFields || [];
const isSelected = selectedFields.some((f: any) => f.columnName === col.columnName);
return (
<div key={col.columnName} className="flex items-center gap-2">
<input
type="checkbox"
id={`master-field-${col.columnName}`}
checked={isSelected}
onChange={(e) => {
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-gray-300"
/>
<label htmlFor={`master-field-${col.columnName}`} className="flex-1 cursor-pointer text-xs">
{col.columnLabel}
<span className="text-muted-foreground ml-1">({col.columnName})</span>
{col.inputType === "entity" && <span className="ml-1 text-blue-500">[]</span>}
</label>
</div>
);
})}
</div>
{(masterDetailConfig.masterSelectFields?.length || 0) > 0 && (
<p className="text-muted-foreground mt-1 text-xs">
: {masterDetailConfig.masterSelectFields.length}
</p>
)}
{/* 엔티티 필드의 표시컬럼 설정 */}
{masterDetailConfig.masterSelectFields?.filter((f: any) => f.inputType === "entity").length > 0 && (
<div className="mt-3 space-y-2 border-t pt-3">
<Label className="text-xs"> </Label>
{masterDetailConfig.masterSelectFields
.filter((f: any) => f.inputType === "entity")
.map((field: any) => {
const availableColumns = refTableColumns[field.referenceTable] || [];
return (
<div key={`display-${field.columnName}`} className="flex items-center gap-2">
<span className="w-24 truncate text-xs">{field.columnLabel}:</span>
<Select
value={field.displayColumn || ""}
onValueChange={(value) => {
const newFields = masterDetailConfig.masterSelectFields.map((f: any) =>
f.columnName === field.columnName ? { ...f, displayColumn: value } : f,
);
updateMasterDetailConfig({ masterSelectFields: newFields });
}}
>
<SelectTrigger className="h-7 flex-1 text-xs">
<SelectValue placeholder="표시컬럼 선택" />
</SelectTrigger>
<SelectContent>
{availableColumns.length === 0 ? (
<SelectItem value="__loading__" disabled>
{field.referenceTable ? "로딩 중..." : "참조 테이블 없음"}
</SelectItem>
) : (
availableColumns.map((col) => (
<SelectItem key={col.name} value={col.name} className="text-xs">
{col.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
);
})}
<p className="text-muted-foreground text-xs"> .</p>
</div>
)}
</div>
)}
</div>
);
};
/**
* 엑셀 업로드 채번 규칙 안내 (테이블 타입 관리에서 자동 감지)
*/
const ExcelNumberingRuleInfo: React.FC = () => {
return (
<div className="border-t pt-3">
<Label className="text-xs"> </Label>
<p className="text-muted-foreground mt-1 text-xs">
"채번" .
</p>
</div>
);
};
/**
* 엑셀 업로드 후 제어 실행 설정 (단일 테이블/마스터-디테일 모두 사용 가능)
*/
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<Array<{ flowId: number; flowName: string; flowDescription?: string }>>([]);
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 (
<div className="border-t pt-3">
<Label className="text-xs"> </Label>
<p className="text-muted-foreground mb-2 text-xs"> .</p>
{selectedFlows.length > 0 && (
<div className="mb-2 space-y-1">
{selectedFlows.map((selected, index) => {
const flow = nodeFlows.find((f) => String(f.flowId) === selected.flowId);
return (
<div key={selected.flowId} className="flex items-center gap-1 rounded border bg-white p-1.5">
<span className="text-muted-foreground w-5 text-center text-xs">{index + 1}</span>
<span className="flex-1 truncate text-xs">{flow?.flowName || `Flow ${selected.flowId}`}</span>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
onClick={() => moveUp(index)}
disabled={index === 0}
>
<ChevronUp className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
onClick={() => moveDown(index)}
disabled={index === selectedFlows.length - 1}
>
<ChevronDown className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 text-red-500"
onClick={() => removeFlow(selected.flowId)}
>
<X className="h-3 w-3" />
</Button>
</div>
);
})}
</div>
)}
<Popover open={flowSelectOpen} onOpenChange={setFlowSelectOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className="h-8 w-full justify-between text-xs"
disabled={isLoading || availableFlows.length === 0}
>
{isLoading ? "로딩 중..." : availableFlows.length === 0 ? "추가 가능한 제어 없음" : "제어 추가..."}
<Plus className="ml-2 h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="start">
<Command>
<CommandInput placeholder="제어 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty>
<CommandGroup>
{availableFlows.map((flow) => (
<CommandItem
key={flow.flowId}
value={flow.flowName}
onSelect={() => addFlow(String(flow.flowId))}
className="text-xs"
>
<div className="flex flex-col">
<span>{flow.flowName}</span>
{flow.flowDescription && (
<span className="text-muted-foreground text-[10px]">{flow.flowDescription}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{selectedFlows.length > 0 && (
<p className="text-muted-foreground mt-1 text-xs">
{selectedFlows.length} .
</p>
)}
</div>
);
};
/**
* 엑셀 업로드 설정 섹션 컴포넌트
* 마스터-디테일 설정은 분할 패널 자동 감지
*/
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<typeof excelUploadConfig>) => {
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 (
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
<h4 className="text-foreground text-sm font-medium"> </h4>
<div>
<Label htmlFor="excel-upload-mode"> </Label>
<Select
value={config.action?.excelUploadMode || "insert"}
onValueChange={(value) => onUpdateProperty("componentConfig.action.excelUploadMode", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="insert"> (INSERT)</SelectItem>
<SelectItem value="update"> (UPDATE)</SelectItem>
<SelectItem value="upsert">/ (UPSERT)</SelectItem>
</SelectContent>
</Select>
</div>
{(config.action?.excelUploadMode === "update" || config.action?.excelUploadMode === "upsert") && (
<div>
<Label htmlFor="excel-key-column">
<span className="text-destructive">*</span>
</Label>
<Input
id="excel-key-column"
placeholder="예: id, code"
value={config.action?.excelKeyColumn || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.excelKeyColumn", e.target.value)}
className="h-8 text-xs"
/>
<p className="text-muted-foreground mt-1 text-xs">UPDATE/UPSERT </p>
</div>
)}
{/* 채번 규칙 안내 (테이블 타입 관리에서 자동 감지) */}
<ExcelNumberingRuleInfo />
{/* 업로드 후 제어 실행 (항상 표시) */}
<ExcelAfterUploadControlConfig config={excelUploadConfig} updateConfig={updateExcelUploadConfig} />
{/* 마스터-디테일 설정 (분할 패널 자동 감지) */}
<MasterDetailExcelUploadConfig
config={config}
onUpdateProperty={onUpdateProperty}
allComponents={allComponents}
/>
</div>
);
};