- 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.
4694 lines
221 KiB
TypeScript
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를 "active"로 + 연속 추적 시작
|
|
<br />
|
|
- 운행 종료: status를 "completed"로 + 연속 추적 종료
|
|
<br />- 공차등록: status를 "inactive"로 + 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>
|
|
);
|
|
};
|
|
|