2025-09-09 14:29:04 +09:00
|
|
|
"use client";
|
|
|
|
|
|
2025-10-21 17:32:54 +09:00
|
|
|
import React, { useState, useEffect, useMemo } from "react";
|
2025-09-10 14:09:32 +09:00
|
|
|
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";
|
2025-09-12 14:24:25 +09:00
|
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
2025-10-27 11:11:08 +09:00
|
|
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
2025-09-12 14:24:25 +09:00
|
|
|
import { Button } from "@/components/ui/button";
|
2025-11-18 16:12:47 +09:00
|
|
|
import { Card } from "@/components/ui/card";
|
|
|
|
|
import { Check, ChevronsUpDown, Search, Plus, X, ChevronUp, ChevronDown, Type, Database } from "lucide-react";
|
2025-09-12 14:24:25 +09:00
|
|
|
import { cn } from "@/lib/utils";
|
2025-09-10 14:09:32 +09:00
|
|
|
import { ComponentData } from "@/types/screen";
|
2025-09-12 14:24:25 +09:00
|
|
|
import { apiClient } from "@/lib/api/client";
|
2025-09-18 10:05:50 +09:00
|
|
|
import { ButtonDataflowConfigPanel } from "./ButtonDataflowConfigPanel";
|
2025-09-29 12:17:10 +09:00
|
|
|
import { ImprovedButtonControlConfigPanel } from "./ImprovedButtonControlConfigPanel";
|
2025-10-23 18:23:01 +09:00
|
|
|
import { FlowVisibilityConfigPanel } from "./FlowVisibilityConfigPanel";
|
2025-12-16 14:38:03 +09:00
|
|
|
import { QuickInsertConfigSection } from "./QuickInsertConfigSection";
|
2025-09-09 14:29:04 +09:00
|
|
|
|
2025-11-18 16:12:47 +09:00
|
|
|
// 🆕 제목 블록 타입
|
|
|
|
|
interface TitleBlock {
|
|
|
|
|
id: string;
|
|
|
|
|
type: "text" | "field";
|
|
|
|
|
value: string; // text: 텍스트 내용, field: 컬럼명
|
|
|
|
|
tableName?: string; // field일 때 테이블명
|
|
|
|
|
label?: string; // field일 때 표시용 라벨
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-10 14:09:32 +09:00
|
|
|
interface ButtonConfigPanelProps {
|
|
|
|
|
component: ComponentData;
|
|
|
|
|
onUpdateProperty: (path: string, value: any) => void;
|
2025-10-23 18:23:01 +09:00
|
|
|
allComponents?: ComponentData[]; // 🆕 플로우 위젯 감지용
|
2025-10-27 11:11:08 +09:00
|
|
|
currentTableName?: string; // 현재 화면의 테이블명 (자동 감지용)
|
2025-11-13 12:17:10 +09:00
|
|
|
currentScreenCompanyCode?: string; // 현재 편집 중인 화면의 회사 코드
|
2025-09-10 14:09:32 +09:00
|
|
|
}
|
2025-09-09 14:29:04 +09:00
|
|
|
|
2025-09-12 14:24:25 +09:00
|
|
|
interface ScreenOption {
|
|
|
|
|
id: number;
|
|
|
|
|
name: string;
|
|
|
|
|
description?: string;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-27 11:11:08 +09:00
|
|
|
export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|
|
|
|
component,
|
2025-10-23 18:23:01 +09:00
|
|
|
onUpdateProperty,
|
|
|
|
|
allComponents = [], // 🆕 기본값 빈 배열
|
2025-10-27 11:11:08 +09:00
|
|
|
currentTableName, // 현재 화면의 테이블명
|
2025-11-13 12:17:10 +09:00
|
|
|
currentScreenCompanyCode, // 현재 편집 중인 화면의 회사 코드
|
2025-10-23 18:23:01 +09:00
|
|
|
}) => {
|
2025-10-27 11:11:08 +09:00
|
|
|
// 🔧 component에서 직접 읽기 (useMemo 제거)
|
|
|
|
|
const config = component.componentConfig || {};
|
|
|
|
|
const currentAction = component.componentConfig?.action || {};
|
|
|
|
|
|
2025-10-21 15:11:15 +09:00
|
|
|
// 로컬 상태 관리 (실시간 입력 반영)
|
|
|
|
|
const [localInputs, setLocalInputs] = useState({
|
2025-10-21 17:32:54 +09:00
|
|
|
text: config.text !== undefined ? config.text : "버튼",
|
2025-10-30 12:03:50 +09:00
|
|
|
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 || ""),
|
2026-01-07 10:24:01 +09:00
|
|
|
groupByColumn: String(config.action?.groupByColumns?.[0] || ""),
|
2025-10-21 15:11:15 +09:00
|
|
|
});
|
|
|
|
|
|
2025-09-12 14:24:25 +09:00
|
|
|
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("");
|
|
|
|
|
|
2025-10-27 11:11:08 +09:00
|
|
|
// 테이블 컬럼 목록 상태
|
|
|
|
|
const [tableColumns, setTableColumns] = useState<string[]>([]);
|
|
|
|
|
const [columnsLoading, setColumnsLoading] = useState(false);
|
|
|
|
|
const [displayColumnOpen, setDisplayColumnOpen] = useState(false);
|
|
|
|
|
const [displayColumnSearch, setDisplayColumnSearch] = useState("");
|
|
|
|
|
|
2025-11-18 16:12:47 +09:00
|
|
|
// 🆕 제목 블록 빌더 상태
|
|
|
|
|
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 열림 상태
|
|
|
|
|
|
2025-11-28 14:56:11 +09:00
|
|
|
// 🆕 데이터 전달 필드 매핑용 상태
|
|
|
|
|
const [mappingSourceColumns, setMappingSourceColumns] = useState<Array<{ name: string; label: string }>>([]);
|
|
|
|
|
const [mappingTargetColumns, setMappingTargetColumns] = useState<Array<{ name: string; label: string }>>([]);
|
|
|
|
|
const [mappingSourcePopoverOpen, setMappingSourcePopoverOpen] = useState<Record<number, boolean>>({});
|
|
|
|
|
const [mappingTargetPopoverOpen, setMappingTargetPopoverOpen] = useState<Record<number, boolean>>({});
|
|
|
|
|
const [mappingSourceSearch, setMappingSourceSearch] = useState<Record<number, string>>({});
|
|
|
|
|
const [mappingTargetSearch, setMappingTargetSearch] = useState<Record<number, string>>({});
|
|
|
|
|
|
2025-12-08 15:50:58 +09:00
|
|
|
// 🆕 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>>({});
|
2026-01-07 10:24:01 +09:00
|
|
|
|
|
|
|
|
// 🆕 그룹화 컬럼 선택용 상태
|
|
|
|
|
const [currentTableColumns, setCurrentTableColumns] = useState<Array<{ name: string; label: string }>>([]);
|
|
|
|
|
const [groupByColumnOpen, setGroupByColumnOpen] = useState(false);
|
|
|
|
|
const [groupByColumnSearch, setGroupByColumnSearch] = useState("");
|
2025-12-08 15:50:58 +09:00
|
|
|
const [modalSourceSearch, setModalSourceSearch] = useState<Record<number, string>>({});
|
|
|
|
|
const [modalTargetSearch, setModalTargetSearch] = useState<Record<number, string>>({});
|
|
|
|
|
|
2025-10-28 18:41:45 +09:00
|
|
|
// 🎯 플로우 위젯이 화면에 있는지 확인
|
|
|
|
|
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");
|
|
|
|
|
|
|
|
|
|
if (isFlow) {
|
|
|
|
|
console.log("✅ 플로우 위젯 발견!", { id: comp.id, componentType: comp.componentType });
|
|
|
|
|
}
|
|
|
|
|
return isFlow;
|
|
|
|
|
});
|
|
|
|
|
console.log("🎯 플로우 위젯 존재 여부:", found);
|
|
|
|
|
return found;
|
|
|
|
|
}, [allComponents]);
|
|
|
|
|
|
2025-10-21 17:32:54 +09:00
|
|
|
// 컴포넌트 prop 변경 시 로컬 상태 동기화 (Input만)
|
2025-10-21 15:11:15 +09:00
|
|
|
useEffect(() => {
|
2025-10-21 17:32:54 +09:00
|
|
|
const latestConfig = component.componentConfig || {};
|
|
|
|
|
const latestAction = latestConfig.action || {};
|
2025-10-21 15:11:15 +09:00
|
|
|
|
2025-10-21 17:32:54 +09:00
|
|
|
setLocalInputs({
|
|
|
|
|
text: latestConfig.text !== undefined ? latestConfig.text : "버튼",
|
2025-10-30 12:03:50 +09:00
|
|
|
modalTitle: String(latestAction.modalTitle || ""),
|
|
|
|
|
modalDescription: String(latestAction.modalDescription || ""),
|
|
|
|
|
editModalTitle: String(latestAction.editModalTitle || ""),
|
|
|
|
|
editModalDescription: String(latestAction.editModalDescription || ""),
|
|
|
|
|
targetUrl: String(latestAction.targetUrl || ""),
|
2026-01-07 10:24:01 +09:00
|
|
|
groupByColumn: String(latestAction.groupByColumns?.[0] || ""),
|
2025-10-21 15:11:15 +09:00
|
|
|
});
|
2025-11-18 16:12:47 +09:00
|
|
|
|
|
|
|
|
// 🆕 제목 블록 초기화
|
|
|
|
|
if (latestAction.modalTitleBlocks && latestAction.modalTitleBlocks.length > 0) {
|
|
|
|
|
setTitleBlocks(latestAction.modalTitleBlocks);
|
|
|
|
|
} else {
|
|
|
|
|
// 기본값: 빈 배열
|
|
|
|
|
setTitleBlocks([]);
|
|
|
|
|
}
|
2025-10-21 17:32:54 +09:00
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [component.id]);
|
2025-10-21 15:11:15 +09:00
|
|
|
|
2025-11-18 16:12:47 +09:00
|
|
|
// 🆕 제목 블록 핸들러
|
|
|
|
|
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>) => {
|
2026-01-09 15:32:02 +09:00
|
|
|
const updatedBlocks = titleBlocks.map((block) => (block.id === id ? { ...block, ...updates } : block));
|
2025-11-18 16:12:47 +09:00
|
|
|
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");
|
2026-01-09 15:32:02 +09:00
|
|
|
|
2025-11-18 16:12:47 +09:00
|
|
|
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);
|
2026-01-09 15:32:02 +09:00
|
|
|
console.log("✅ 전체 테이블 목록 로드 성공:", tables.length);
|
2025-11-18 16:12:47 +09:00
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("테이블 목록 로드 실패:", error);
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-01-09 15:32:02 +09:00
|
|
|
|
2025-11-18 16:12:47 +09:00
|
|
|
fetchAllTables();
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 🆕 특정 테이블의 컬럼 로드
|
|
|
|
|
const loadTableColumns = async (tableName: string) => {
|
|
|
|
|
if (!tableName || tableColumnsMap[tableName]) return;
|
2026-01-09 15:32:02 +09:00
|
|
|
|
2025-11-18 16:12:47 +09:00
|
|
|
try {
|
|
|
|
|
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
|
|
|
|
console.log(`📥 테이블 ${tableName} 컬럼 응답:`, response.data);
|
2026-01-09 15:32:02 +09:00
|
|
|
|
2025-11-18 16:12:47 +09:00
|
|
|
if (response.data.success) {
|
|
|
|
|
// data가 배열인지 확인
|
|
|
|
|
let columnData = response.data.data;
|
2026-01-09 15:32:02 +09:00
|
|
|
|
2025-11-18 16:12:47 +09:00
|
|
|
// data.columns 형태일 수도 있음
|
|
|
|
|
if (!Array.isArray(columnData) && columnData?.columns) {
|
|
|
|
|
columnData = columnData.columns;
|
|
|
|
|
}
|
2026-01-09 15:32:02 +09:00
|
|
|
|
2025-11-18 16:12:47 +09:00
|
|
|
// data.data 형태일 수도 있음
|
|
|
|
|
if (!Array.isArray(columnData) && columnData?.data) {
|
|
|
|
|
columnData = columnData.data;
|
|
|
|
|
}
|
2026-01-09 15:32:02 +09:00
|
|
|
|
2025-11-18 16:12:47 +09:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-28 14:56:11 +09:00
|
|
|
// 🆕 데이터 전달 소스/타겟 테이블 컬럼 로드
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const sourceTable = config.action?.dataTransfer?.sourceTable;
|
|
|
|
|
const targetTable = config.action?.dataTransfer?.targetTable;
|
2026-01-09 15:32:02 +09:00
|
|
|
|
2025-11-28 14:56:11 +09:00
|
|
|
const loadColumns = async () => {
|
|
|
|
|
if (sourceTable) {
|
|
|
|
|
try {
|
|
|
|
|
const response = await apiClient.get(`/table-management/tables/${sourceTable}/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;
|
2026-01-09 15:32:02 +09:00
|
|
|
|
2025-11-28 14:56:11 +09:00
|
|
|
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,
|
|
|
|
|
}));
|
|
|
|
|
setMappingSourceColumns(columns);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("소스 테이블 컬럼 로드 실패:", error);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-09 15:32:02 +09:00
|
|
|
|
2025-11-28 14:56:11 +09:00
|
|
|
if (targetTable) {
|
|
|
|
|
try {
|
|
|
|
|
const response = await apiClient.get(`/table-management/tables/${targetTable}/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;
|
2026-01-09 15:32:02 +09:00
|
|
|
|
2025-11-28 14:56:11 +09:00
|
|
|
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,
|
|
|
|
|
}));
|
|
|
|
|
setMappingTargetColumns(columns);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("타겟 테이블 컬럼 로드 실패:", error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-01-09 15:32:02 +09:00
|
|
|
|
2025-11-28 14:56:11 +09:00
|
|
|
loadColumns();
|
|
|
|
|
}, [config.action?.dataTransfer?.sourceTable, config.action?.dataTransfer?.targetTable]);
|
|
|
|
|
|
2026-01-07 10:24:01 +09:00
|
|
|
// 🆕 현재 테이블 컬럼 로드 (그룹화 컬럼 선택용)
|
|
|
|
|
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);
|
|
|
|
|
console.log(`✅ 현재 테이블 ${currentTableName} 컬럼 로드 성공:`, columns.length, "개");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("현재 테이블 컬럼 로드 실패:", error);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
loadCurrentTableColumns();
|
|
|
|
|
}, [currentTableName]);
|
|
|
|
|
|
2025-12-08 15:50:58 +09:00
|
|
|
// 🆕 openModalWithData 소스/타겟 테이블 컬럼 로드
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const actionType = config.action?.type;
|
|
|
|
|
if (actionType !== "openModalWithData") return;
|
|
|
|
|
|
|
|
|
|
const loadModalMappingColumns = async () => {
|
|
|
|
|
// 소스 테이블: 현재 화면의 분할 패널 또는 테이블에서 감지
|
|
|
|
|
let sourceTableName: string | null = null;
|
2026-01-09 15:32:02 +09:00
|
|
|
|
2025-12-15 18:39:59 +09:00
|
|
|
console.log("[openModalWithData] 컬럼 로드 시작:", {
|
|
|
|
|
allComponentsCount: allComponents.length,
|
|
|
|
|
currentTableName,
|
|
|
|
|
targetScreenId: config.action?.targetScreenId,
|
|
|
|
|
});
|
2026-01-09 15:32:02 +09:00
|
|
|
|
2025-12-15 18:39:59 +09:00
|
|
|
// 모든 컴포넌트 타입 로그
|
|
|
|
|
allComponents.forEach((comp, idx) => {
|
|
|
|
|
const compType = comp.componentType || (comp as any).componentConfig?.type;
|
2026-01-09 15:32:02 +09:00
|
|
|
console.log(
|
|
|
|
|
` [${idx}] componentType: ${compType}, tableName: ${(comp as any).componentConfig?.tableName || (comp as any).componentConfig?.leftPanel?.tableName || "N/A"}`,
|
|
|
|
|
);
|
2025-12-15 18:39:59 +09:00
|
|
|
});
|
2026-01-09 15:32:02 +09:00
|
|
|
|
2025-12-08 15:50:58 +09:00
|
|
|
for (const comp of allComponents) {
|
|
|
|
|
const compType = comp.componentType || (comp as any).componentConfig?.type;
|
2025-12-15 18:39:59 +09:00
|
|
|
const compConfig = (comp as any).componentConfig || {};
|
2026-01-09 15:32:02 +09:00
|
|
|
|
2025-12-15 18:39:59 +09:00
|
|
|
// 분할 패널 타입들 (다양한 경로에서 테이블명 추출)
|
2025-12-08 15:50:58 +09:00
|
|
|
if (compType === "split-panel-layout" || compType === "screen-split-panel") {
|
2026-01-09 15:32:02 +09:00
|
|
|
sourceTableName = compConfig?.leftPanel?.tableName || compConfig?.leftTableName || compConfig?.tableName;
|
2025-12-15 18:39:59 +09:00
|
|
|
if (sourceTableName) {
|
|
|
|
|
console.log(`✅ [openModalWithData] split-panel-layout에서 소스 테이블 감지: ${sourceTableName}`);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-09 15:32:02 +09:00
|
|
|
|
2025-12-15 18:39:59 +09:00
|
|
|
// split-panel-layout2 타입 (새로운 분할 패널)
|
|
|
|
|
if (compType === "split-panel-layout2") {
|
2026-01-09 15:32:02 +09:00
|
|
|
sourceTableName = compConfig?.leftPanel?.tableName || compConfig?.tableName || compConfig?.leftTableName;
|
2025-12-15 18:39:59 +09:00
|
|
|
if (sourceTableName) {
|
|
|
|
|
console.log(`✅ [openModalWithData] split-panel-layout2에서 소스 테이블 감지: ${sourceTableName}`);
|
|
|
|
|
break;
|
|
|
|
|
}
|
2025-12-08 15:50:58 +09:00
|
|
|
}
|
2026-01-09 15:32:02 +09:00
|
|
|
|
2025-12-15 18:39:59 +09:00
|
|
|
// 테이블 리스트 타입
|
2025-12-08 15:50:58 +09:00
|
|
|
if (compType === "table-list") {
|
2025-12-15 18:39:59 +09:00
|
|
|
sourceTableName = compConfig?.tableName;
|
|
|
|
|
if (sourceTableName) {
|
|
|
|
|
console.log(`✅ [openModalWithData] table-list에서 소스 테이블 감지: ${sourceTableName}`);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-09 15:32:02 +09:00
|
|
|
|
2025-12-15 18:39:59 +09:00
|
|
|
// 🆕 모든 컴포넌트에서 tableName 찾기 (폴백)
|
|
|
|
|
if (!sourceTableName && compConfig?.tableName) {
|
|
|
|
|
sourceTableName = compConfig.tableName;
|
|
|
|
|
console.log(`✅ [openModalWithData] ${compType}에서 소스 테이블 감지 (폴백): ${sourceTableName}`);
|
2025-12-08 15:50:58 +09:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-09 15:32:02 +09:00
|
|
|
|
2025-12-15 18:39:59 +09:00
|
|
|
// 여전히 없으면 currentTableName 사용 (화면 레벨 테이블명)
|
|
|
|
|
if (!sourceTableName && currentTableName) {
|
|
|
|
|
sourceTableName = currentTableName;
|
|
|
|
|
console.log(`✅ [openModalWithData] currentTableName에서 소스 테이블 사용: ${sourceTableName}`);
|
|
|
|
|
}
|
2026-01-09 15:32:02 +09:00
|
|
|
|
2025-12-15 18:39:59 +09:00
|
|
|
if (!sourceTableName) {
|
|
|
|
|
console.warn("[openModalWithData] 소스 테이블을 찾을 수 없습니다.");
|
|
|
|
|
}
|
2025-12-08 15:50:58 +09:00
|
|
|
|
|
|
|
|
// 소스 테이블 컬럼 로드
|
|
|
|
|
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;
|
2026-01-09 15:32:02 +09:00
|
|
|
|
2025-12-08 15:50:58 +09:00
|
|
|
if (Array.isArray(columnData)) {
|
|
|
|
|
const columns = columnData.map((col: any) => ({
|
2025-12-15 18:39:59 +09:00
|
|
|
name: col.name || col.columnName || col.column_name,
|
2026-01-09 15:32:02 +09:00
|
|
|
label:
|
|
|
|
|
col.displayName ||
|
|
|
|
|
col.label ||
|
|
|
|
|
col.columnLabel ||
|
|
|
|
|
col.display_name ||
|
|
|
|
|
col.name ||
|
|
|
|
|
col.columnName ||
|
|
|
|
|
col.column_name,
|
2025-12-08 15:50:58 +09:00
|
|
|
}));
|
|
|
|
|
setModalSourceColumns(columns);
|
2025-12-15 18:39:59 +09:00
|
|
|
console.log(`✅ [openModalWithData] 소스 테이블(${sourceTableName}) 컬럼 로드 완료:`, columns.length);
|
2025-12-08 15:50:58 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("소스 테이블 컬럼 로드 실패:", error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 타겟 화면의 테이블 컬럼 로드
|
|
|
|
|
const targetScreenId = config.action?.targetScreenId;
|
|
|
|
|
if (targetScreenId) {
|
|
|
|
|
try {
|
|
|
|
|
// 타겟 화면 정보 가져오기
|
|
|
|
|
const screenResponse = await apiClient.get(`/screen-management/screens/${targetScreenId}`);
|
2025-12-15 18:39:59 +09:00
|
|
|
console.log("[openModalWithData] 타겟 화면 응답:", screenResponse.data);
|
2026-01-09 15:32:02 +09:00
|
|
|
|
2025-12-08 15:50:58 +09:00
|
|
|
if (screenResponse.data.success && screenResponse.data.data) {
|
|
|
|
|
const targetTableName = screenResponse.data.data.tableName;
|
2025-12-15 18:39:59 +09:00
|
|
|
console.log("[openModalWithData] 타겟 화면 테이블명:", targetTableName);
|
2026-01-09 15:32:02 +09:00
|
|
|
|
2025-12-08 15:50:58 +09:00
|
|
|
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;
|
2026-01-09 15:32:02 +09:00
|
|
|
|
2025-12-08 15:50:58 +09:00
|
|
|
if (Array.isArray(columnData)) {
|
|
|
|
|
const columns = columnData.map((col: any) => ({
|
2025-12-15 18:39:59 +09:00
|
|
|
name: col.name || col.columnName || col.column_name,
|
2026-01-09 15:32:02 +09:00
|
|
|
label:
|
|
|
|
|
col.displayName ||
|
|
|
|
|
col.label ||
|
|
|
|
|
col.columnLabel ||
|
|
|
|
|
col.display_name ||
|
|
|
|
|
col.name ||
|
|
|
|
|
col.columnName ||
|
|
|
|
|
col.column_name,
|
2025-12-08 15:50:58 +09:00
|
|
|
}));
|
|
|
|
|
setModalTargetColumns(columns);
|
2025-12-15 18:39:59 +09:00
|
|
|
console.log(`✅ [openModalWithData] 타겟 테이블(${targetTableName}) 컬럼 로드 완료:`, columns.length);
|
2025-12-08 15:50:58 +09:00
|
|
|
}
|
|
|
|
|
}
|
2025-12-15 18:39:59 +09:00
|
|
|
} else {
|
|
|
|
|
console.warn("[openModalWithData] 타겟 화면에 테이블명이 없습니다.");
|
2025-12-08 15:50:58 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("타겟 화면 테이블 컬럼 로드 실패:", error);
|
|
|
|
|
}
|
2025-12-15 18:39:59 +09:00
|
|
|
} else {
|
|
|
|
|
console.warn("[openModalWithData] 타겟 화면 ID가 없습니다.");
|
2025-12-08 15:50:58 +09:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
loadModalMappingColumns();
|
2025-12-15 18:39:59 +09:00
|
|
|
}, [config.action?.type, config.action?.targetScreenId, allComponents, currentTableName]);
|
2025-12-08 15:50:58 +09:00
|
|
|
|
2025-11-13 12:17:10 +09:00
|
|
|
// 화면 목록 가져오기 (현재 편집 중인 화면의 회사 코드 기준)
|
2025-09-12 14:24:25 +09:00
|
|
|
useEffect(() => {
|
|
|
|
|
const fetchScreens = async () => {
|
|
|
|
|
try {
|
|
|
|
|
setScreensLoading(true);
|
2025-11-13 12:17:10 +09:00
|
|
|
// 현재 편집 중인 화면의 회사 코드 기준으로 화면 목록 조회
|
|
|
|
|
const params: any = {
|
|
|
|
|
page: 1,
|
|
|
|
|
size: 9999, // 매우 큰 값으로 설정하여 전체 목록 가져오기
|
|
|
|
|
};
|
2026-01-09 15:32:02 +09:00
|
|
|
|
2025-11-13 12:17:10 +09:00
|
|
|
// 현재 화면의 회사 코드가 있으면 필터링 파라미터로 전달
|
|
|
|
|
if (currentScreenCompanyCode) {
|
|
|
|
|
params.companyCode = currentScreenCompanyCode;
|
|
|
|
|
}
|
2026-01-09 15:32:02 +09:00
|
|
|
|
2025-10-23 13:15:52 +09:00
|
|
|
const response = await apiClient.get("/screen-management/screens", {
|
2025-11-13 12:17:10 +09:00
|
|
|
params,
|
2025-10-23 13:15:52 +09:00
|
|
|
});
|
2025-09-12 14:24:25 +09:00
|
|
|
|
|
|
|
|
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) {
|
2025-10-01 18:17:30 +09:00
|
|
|
// console.error("❌ 화면 목록 로딩 실패:", error);
|
2025-09-12 14:24:25 +09:00
|
|
|
} finally {
|
|
|
|
|
setScreensLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
fetchScreens();
|
2025-11-13 12:17:10 +09:00
|
|
|
}, [currentScreenCompanyCode]);
|
2025-09-12 14:24:25 +09:00
|
|
|
|
2025-10-27 11:11:08 +09:00
|
|
|
// 테이블 컬럼 목록 가져오기 (테이블 이력 보기 액션일 때)
|
|
|
|
|
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]);
|
|
|
|
|
|
2025-09-12 14:24:25 +09:00
|
|
|
// 검색 필터링 함수
|
|
|
|
|
const filterScreens = (searchTerm: string) => {
|
|
|
|
|
if (!searchTerm.trim()) return screens;
|
|
|
|
|
return screens.filter(
|
|
|
|
|
(screen) =>
|
|
|
|
|
screen.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
2025-09-18 10:05:50 +09:00
|
|
|
(screen.description && screen.description.toLowerCase().includes(searchTerm.toLowerCase())),
|
2025-09-12 14:24:25 +09:00
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2025-10-21 17:32:54 +09:00
|
|
|
// console.log("🔧 config-panels/ButtonConfigPanel 렌더링:", {
|
|
|
|
|
// component,
|
|
|
|
|
// config,
|
|
|
|
|
// action: config.action,
|
|
|
|
|
// actionType: config.action?.type,
|
|
|
|
|
// screensCount: screens.length,
|
|
|
|
|
// });
|
2025-09-09 14:29:04 +09:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-4">
|
2025-09-10 14:09:32 +09:00
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="button-text">버튼 텍스트</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="button-text"
|
2025-10-21 15:11:15 +09:00
|
|
|
value={localInputs.text}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
const newValue = e.target.value;
|
|
|
|
|
setLocalInputs((prev) => ({ ...prev, text: newValue }));
|
|
|
|
|
onUpdateProperty("componentConfig.text", newValue);
|
|
|
|
|
}}
|
2025-09-10 14:09:32 +09:00
|
|
|
placeholder="버튼 텍스트를 입력하세요"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2025-09-09 14:29:04 +09:00
|
|
|
|
2025-09-10 14:09:32 +09:00
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="button-action">버튼 액션</Label>
|
|
|
|
|
<Select
|
2025-10-21 17:32:54 +09:00
|
|
|
key={`action-${component.id}-${component.componentConfig?.action?.type || "save"}`}
|
|
|
|
|
value={component.componentConfig?.action?.type || "save"}
|
2025-09-24 18:07:36 +09:00
|
|
|
onValueChange={(value) => {
|
2025-10-27 11:11:08 +09:00
|
|
|
// 🔥 action.type 업데이트
|
|
|
|
|
onUpdateProperty("componentConfig.action.type", value);
|
|
|
|
|
|
|
|
|
|
// 🔥 색상 업데이트는 충분히 지연 (React 리렌더링 완료 후)
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
const newColor = value === "delete" ? "#ef4444" : "#212121";
|
|
|
|
|
onUpdateProperty("style.labelColor", newColor);
|
|
|
|
|
}, 100); // 0 → 100ms로 증가
|
2025-09-24 18:07:36 +09:00
|
|
|
}}
|
2025-09-09 14:29:04 +09:00
|
|
|
>
|
2025-09-10 14:09:32 +09:00
|
|
|
<SelectTrigger>
|
|
|
|
|
<SelectValue placeholder="버튼 액션 선택" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
<SelectItem value="save">저장</SelectItem>
|
|
|
|
|
<SelectItem value="delete">삭제</SelectItem>
|
2025-10-23 13:15:52 +09:00
|
|
|
<SelectItem value="edit">편집</SelectItem>
|
2025-11-06 17:32:24 +09:00
|
|
|
<SelectItem value="copy">복사 (품목코드 초기화)</SelectItem>
|
2025-09-12 14:24:25 +09:00
|
|
|
<SelectItem value="navigate">페이지 이동</SelectItem>
|
2025-12-16 14:38:03 +09:00
|
|
|
<SelectItem value="transferData">데이터 전달</SelectItem>
|
|
|
|
|
<SelectItem value="openModalWithData">데이터 전달 + 모달 열기</SelectItem>
|
2025-12-16 18:02:08 +09:00
|
|
|
<SelectItem value="openRelatedModal">연관 데이터 버튼 모달 열기</SelectItem>
|
2025-10-23 13:15:52 +09:00
|
|
|
<SelectItem value="modal">모달 열기</SelectItem>
|
2025-12-16 14:38:03 +09:00
|
|
|
<SelectItem value="quickInsert">즉시 저장</SelectItem>
|
2025-10-23 13:15:52 +09:00
|
|
|
<SelectItem value="control">제어 흐름</SelectItem>
|
2025-10-27 11:11:08 +09:00
|
|
|
<SelectItem value="view_table_history">테이블 이력 보기</SelectItem>
|
2025-11-04 09:41:58 +09:00
|
|
|
<SelectItem value="excel_download">엑셀 다운로드</SelectItem>
|
|
|
|
|
<SelectItem value="excel_upload">엑셀 업로드</SelectItem>
|
|
|
|
|
<SelectItem value="barcode_scan">바코드 스캔</SelectItem>
|
2025-11-04 18:31:26 +09:00
|
|
|
<SelectItem value="code_merge">코드 병합</SelectItem>
|
2025-12-02 09:53:08 +09:00
|
|
|
{/* <SelectItem value="empty_vehicle">공차등록</SelectItem> */}
|
2025-12-01 17:04:59 +09:00
|
|
|
<SelectItem value="operation_control">운행알림 및 종료</SelectItem>
|
2025-09-10 14:09:32 +09:00
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
2025-09-09 14:29:04 +09:00
|
|
|
</div>
|
2025-09-12 14:24:25 +09:00
|
|
|
|
|
|
|
|
{/* 모달 열기 액션 설정 */}
|
2025-10-21 17:32:54 +09:00
|
|
|
{(component.componentConfig?.action?.type || "save") === "modal" && (
|
2026-01-09 15:32:02 +09:00
|
|
|
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
|
|
|
|
|
<h4 className="text-foreground text-sm font-medium">모달 설정</h4>
|
2025-09-12 14:24:25 +09:00
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="modal-title">모달 제목</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="modal-title"
|
|
|
|
|
placeholder="모달 제목을 입력하세요"
|
2025-10-21 15:11:15 +09:00
|
|
|
value={localInputs.modalTitle}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
const newValue = e.target.value;
|
|
|
|
|
setLocalInputs((prev) => ({ ...prev, modalTitle: newValue }));
|
2025-10-21 17:32:54 +09:00
|
|
|
onUpdateProperty("componentConfig.action.modalTitle", newValue);
|
2025-10-21 15:11:15 +09:00
|
|
|
}}
|
2025-09-12 14:24:25 +09:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-10-30 12:03:50 +09:00
|
|
|
<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);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground mt-1 text-xs">모달 제목 아래에 표시됩니다</p>
|
2025-10-30 12:03:50 +09:00
|
|
|
</div>
|
|
|
|
|
|
2025-09-12 14:24:25 +09:00
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="modal-size">모달 크기</Label>
|
|
|
|
|
<Select
|
2025-10-21 17:32:54 +09:00
|
|
|
value={component.componentConfig?.action?.modalSize || "md"}
|
2025-10-21 15:11:15 +09:00
|
|
|
onValueChange={(value) => {
|
2025-10-21 17:32:54 +09:00
|
|
|
onUpdateProperty("componentConfig.action.modalSize", value);
|
2025-10-21 15:11:15 +09:00
|
|
|
}}
|
2025-09-12 14:24:25 +09:00
|
|
|
>
|
|
|
|
|
<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}
|
2025-10-28 18:41:45 +09:00
|
|
|
className="h-6 w-full justify-between px-2 py-0"
|
2025-11-25 13:04:58 +09:00
|
|
|
className="text-xs"
|
2025-09-12 14:24:25 +09:00
|
|
|
disabled={screensLoading}
|
|
|
|
|
>
|
|
|
|
|
{config.action?.targetScreenId
|
2025-11-13 12:17:10 +09:00
|
|
|
? screens.find((screen) => screen.id === parseInt(config.action?.targetScreenId))?.name ||
|
2025-09-12 14:24:25 +09:00
|
|
|
"화면을 선택하세요..."
|
|
|
|
|
: "화면을 선택하세요..."}
|
|
|
|
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
|
|
|
</Button>
|
|
|
|
|
</PopoverTrigger>
|
2025-09-18 10:05:50 +09:00
|
|
|
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
|
2025-09-12 14:24:25 +09:00
|
|
|
<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) {
|
2026-01-09 15:32:02 +09:00
|
|
|
return <div className="text-muted-foreground p-3 text-sm">화면 목록을 불러오는 중...</div>;
|
2025-09-12 14:24:25 +09:00
|
|
|
}
|
|
|
|
|
if (filteredScreens.length === 0) {
|
2026-01-09 15:32:02 +09:00
|
|
|
return <div className="text-muted-foreground p-3 text-sm">검색 결과가 없습니다.</div>;
|
2025-09-12 14:24:25 +09:00
|
|
|
}
|
|
|
|
|
return filteredScreens.map((screen, index) => (
|
|
|
|
|
<div
|
|
|
|
|
key={`modal-screen-${screen.id}-${index}`}
|
2026-01-09 15:32:02 +09:00
|
|
|
className="hover:bg-muted flex cursor-pointer items-center px-3 py-2"
|
2025-09-12 14:24:25 +09:00
|
|
|
onClick={() => {
|
2025-10-21 17:32:54 +09:00
|
|
|
onUpdateProperty("componentConfig.action.targetScreenId", screen.id);
|
2025-09-12 14:24:25 +09:00
|
|
|
setModalScreenOpen(false);
|
|
|
|
|
setModalSearchTerm("");
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Check
|
|
|
|
|
className={cn(
|
|
|
|
|
"mr-2 h-4 w-4",
|
2025-11-13 12:17:10 +09:00
|
|
|
parseInt(config.action?.targetScreenId) === screen.id ? "opacity-100" : "opacity-0",
|
2025-09-12 14:24:25 +09:00
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
<span className="font-medium">{screen.name}</span>
|
2026-01-09 15:32:02 +09:00
|
|
|
{screen.description && (
|
|
|
|
|
<span className="text-muted-foreground text-xs">{screen.description}</span>
|
|
|
|
|
)}
|
2025-09-12 14:24:25 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
));
|
|
|
|
|
})()}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-11-17 12:23:45 +09:00
|
|
|
{/* 🆕 데이터 전달 + 모달 열기 액션 설정 */}
|
|
|
|
|
{component.componentConfig?.action?.type === "openModalWithData" && (
|
|
|
|
|
<div className="mt-4 space-y-4 rounded-lg border bg-blue-50 p-4 dark:bg-blue-950/20">
|
2026-01-09 15:32:02 +09:00
|
|
|
<h4 className="text-foreground text-sm font-medium">데이터 전달 + 모달 설정</h4>
|
|
|
|
|
<p className="text-muted-foreground text-xs">TableList에서 선택된 데이터를 다음 모달로 전달합니다</p>
|
2025-11-17 12:23:45 +09:00
|
|
|
|
|
|
|
|
<div>
|
2025-11-17 15:25:08 +09:00
|
|
|
<Label htmlFor="data-source-id">
|
|
|
|
|
데이터 소스 ID <span className="text-primary">(선택사항)</span>
|
|
|
|
|
</Label>
|
2025-11-17 12:23:45 +09:00
|
|
|
<Input
|
|
|
|
|
id="data-source-id"
|
2025-11-17 15:25:08 +09:00
|
|
|
placeholder="비워두면 자동으로 감지됩니다"
|
2025-11-17 12:23:45 +09:00
|
|
|
value={component.componentConfig?.action?.dataSourceId || ""}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
onUpdateProperty("componentConfig.action.dataSourceId", e.target.value);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-primary mt-1 text-xs font-medium">
|
2025-11-18 16:12:47 +09:00
|
|
|
✨ 비워두면 현재 화면의 TableList를 자동으로 감지합니다
|
2025-11-17 15:25:08 +09:00
|
|
|
</p>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground mt-1 text-xs">
|
|
|
|
|
• 자동 감지: 현재 화면의 TableList 선택 데이터
|
|
|
|
|
<br />
|
|
|
|
|
• 누적 전달: 이전 모달의 모든 데이터도 자동으로 함께 전달
|
|
|
|
|
<br />
|
|
|
|
|
• 다음 화면에서 tableName으로 바로 사용 가능
|
|
|
|
|
<br />• 수동 설정: 필요시 직접 테이블명 입력 (예: item_info)
|
2025-11-17 12:23:45 +09:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-11-18 16:12:47 +09:00
|
|
|
{/* 🆕 블록 기반 제목 빌더 */}
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<Label>모달 제목 구성</Label>
|
|
|
|
|
<div className="flex gap-1">
|
2026-01-09 15:32:02 +09:00
|
|
|
<Button type="button" variant="outline" size="sm" onClick={addTextBlock} className="h-6 text-xs">
|
2025-11-18 16:12:47 +09:00
|
|
|
<Type className="mr-1 h-3 w-3" />
|
|
|
|
|
텍스트 추가
|
|
|
|
|
</Button>
|
2026-01-09 15:32:02 +09:00
|
|
|
<Button type="button" variant="outline" size="sm" onClick={addFieldBlock} className="h-6 text-xs">
|
2025-11-18 16:12:47 +09:00
|
|
|
<Database className="mr-1 h-3 w-3" />
|
|
|
|
|
필드 추가
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 블록 목록 */}
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
{titleBlocks.length === 0 ? (
|
2026-01-09 15:32:02 +09:00
|
|
|
<div className="text-muted-foreground rounded border-2 border-dashed py-4 text-center text-xs">
|
2025-11-18 16:12:47 +09:00
|
|
|
텍스트나 필드를 추가하여 제목을 구성하세요
|
|
|
|
|
</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>
|
|
|
|
|
|
|
|
|
|
{/* 블록 타입 표시 */}
|
2026-01-09 15:32:02 +09:00
|
|
|
<div className="mt-1 flex-shrink-0">
|
2025-11-18 16:12:47 +09:00
|
|
|
{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
|
2026-01-09 15:32:02 +09:00
|
|
|
? availableTables.find((t) => t.name === block.tableName)?.label || block.tableName
|
2025-11-18 16:12:47 +09:00
|
|
|
: "테이블 선택"}
|
|
|
|
|
<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}>
|
2026-01-09 15:32:02 +09:00
|
|
|
<CommandInput
|
|
|
|
|
placeholder="테이블 검색 (라벨 또는 이름)..."
|
2025-11-18 16:12:47 +09:00
|
|
|
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",
|
2026-01-09 15:32:02 +09:00
|
|
|
block.tableName === table.name ? "opacity-100" : "opacity-0",
|
2025-11-18 16:12:47 +09:00
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
<span className="font-medium">{table.label}</span>
|
2026-01-09 15:32:02 +09:00
|
|
|
<span className="text-muted-foreground ml-2 text-[10px]">
|
|
|
|
|
({table.name})
|
|
|
|
|
</span>
|
2025-11-18 16:12:47 +09:00
|
|
|
</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
|
2026-01-09 15:32:02 +09:00
|
|
|
? tableColumnsMap[block.tableName]?.find((c) => c.name === block.value)
|
|
|
|
|
?.label || block.value
|
2025-11-18 16:12:47 +09:00
|
|
|
: "컬럼 선택"}
|
|
|
|
|
<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}>
|
2026-01-09 15:32:02 +09:00
|
|
|
<CommandInput
|
|
|
|
|
placeholder="컬럼 검색 (라벨 또는 이름)..."
|
2025-11-18 16:12:47 +09:00
|
|
|
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",
|
2026-01-09 15:32:02 +09:00
|
|
|
block.value === col.name ? "opacity-100" : "opacity-0",
|
2025-11-18 16:12:47 +09:00
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
<span className="font-medium">{col.label}</span>
|
2026-01-09 15:32:02 +09:00
|
|
|
<span className="text-muted-foreground ml-2 text-[10px]">
|
|
|
|
|
({col.name})
|
|
|
|
|
</span>
|
2025-11-18 16:12:47 +09:00
|
|
|
</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 && (
|
2026-01-09 15:32:02 +09:00
|
|
|
<div className="bg-muted mt-2 rounded p-2 text-xs">
|
2025-11-18 16:12:47 +09:00
|
|
|
<span className="text-muted-foreground">미리보기: </span>
|
|
|
|
|
<span className="font-medium">{generateTitlePreview()}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground text-[10px]">
|
|
|
|
|
• 텍스트: 고정 텍스트 입력 (예: "품목 상세정보 - ")
|
|
|
|
|
<br />
|
|
|
|
|
• 필드: 이전 화면 데이터로 자동 채워짐 (예: 품목명, 규격)
|
|
|
|
|
<br />
|
|
|
|
|
• 순서 변경: ↑↓ 버튼으로 자유롭게 배치
|
|
|
|
|
<br />• 데이터가 없으면 "표시 라벨"이 대신 표시됩니다
|
2025-11-18 16:12:47 +09:00
|
|
|
</p>
|
2025-11-17 12:23:45 +09:00
|
|
|
</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"
|
2025-11-25 13:04:58 +09:00
|
|
|
className="text-xs"
|
2025-11-17 12:23:45 +09:00
|
|
|
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) {
|
2026-01-09 15:32:02 +09:00
|
|
|
return <div className="text-muted-foreground p-3 text-sm">화면 목록을 불러오는 중...</div>;
|
2025-11-17 12:23:45 +09:00
|
|
|
}
|
|
|
|
|
if (filteredScreens.length === 0) {
|
2026-01-09 15:32:02 +09:00
|
|
|
return <div className="text-muted-foreground p-3 text-sm">검색 결과가 없습니다.</div>;
|
2025-11-17 12:23:45 +09:00
|
|
|
}
|
|
|
|
|
return filteredScreens.map((screen, index) => (
|
|
|
|
|
<div
|
|
|
|
|
key={`modal-data-screen-${screen.id}-${index}`}
|
2026-01-09 15:32:02 +09:00
|
|
|
className="hover:bg-muted flex cursor-pointer items-center px-3 py-2"
|
2025-11-17 12:23:45 +09:00
|
|
|
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>
|
2026-01-09 15:32:02 +09:00
|
|
|
{screen.description && (
|
|
|
|
|
<span className="text-muted-foreground text-xs">{screen.description}</span>
|
|
|
|
|
)}
|
2025-11-17 12:23:45 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
));
|
|
|
|
|
})()}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground mt-1 text-xs">
|
2025-11-17 12:23:45 +09:00
|
|
|
SelectedItemsDetailInput 컴포넌트가 있는 화면을 선택하세요
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
2025-12-08 15:50:58 +09:00
|
|
|
|
|
|
|
|
{/* 🆕 필드 매핑 설정 (소스 컬럼 → 타겟 컬럼) */}
|
|
|
|
|
<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>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground text-[10px]">
|
2025-12-08 15:50:58 +09:00
|
|
|
소스 테이블의 컬럼명이 타겟 화면의 입력 필드 컬럼명과 다를 때 매핑을 설정하세요.
|
|
|
|
|
<br />
|
|
|
|
|
예: warehouse_code → warehouse_id (분할 패널의 창고코드를 모달의 창고ID에 매핑)
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
{/* 컬럼 로드 상태 표시 */}
|
|
|
|
|
{modalSourceColumns.length > 0 || modalTargetColumns.length > 0 ? (
|
2026-01-09 15:32:02 +09:00
|
|
|
<div className="text-muted-foreground bg-muted/50 rounded p-2 text-[10px]">
|
2025-12-08 15:50:58 +09:00
|
|
|
소스 컬럼: {modalSourceColumns.length}개 / 타겟 컬럼: {modalTargetColumns.length}개
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
2026-01-09 15:32:02 +09:00
|
|
|
<div className="rounded bg-amber-50 p-2 text-[10px] text-amber-600 dark:bg-amber-950/20">
|
2025-12-08 15:50:58 +09:00
|
|
|
분할 패널 또는 테이블 컴포넌트와 대상 화면을 설정하면 컬럼 목록이 로드됩니다.
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{(config.action?.fieldMappings || []).length === 0 ? (
|
|
|
|
|
<div className="rounded-md border border-dashed p-3 text-center">
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground text-xs">매핑이 없으면 같은 이름의 컬럼끼리 자동으로 매핑됩니다.</p>
|
2025-12-08 15:50:58 +09:00
|
|
|
</div>
|
|
|
|
|
) : (
|
2025-12-15 18:39:59 +09:00
|
|
|
<div className="space-y-3">
|
2025-12-08 15:50:58 +09:00
|
|
|
{(config.action?.fieldMappings || []).map((mapping: any, index: number) => (
|
2026-01-09 15:32:02 +09:00
|
|
|
<div key={index} className="bg-background space-y-2 rounded-md border p-3">
|
2025-12-15 18:39:59 +09:00
|
|
|
{/* 소스 필드 선택 (Combobox) - 세로 배치 */}
|
|
|
|
|
<div className="space-y-1">
|
2026-01-09 15:32:02 +09:00
|
|
|
<Label className="text-muted-foreground text-[10px]">소스 컬럼</Label>
|
2025-12-08 15:50:58 +09:00
|
|
|
<Popover
|
|
|
|
|
open={modalSourcePopoverOpen[index] || false}
|
|
|
|
|
onOpenChange={(open) => setModalSourcePopoverOpen((prev) => ({ ...prev, [index]: open }))}
|
|
|
|
|
>
|
|
|
|
|
<PopoverTrigger asChild>
|
2026-01-09 15:32:02 +09:00
|
|
|
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
|
2025-12-15 18:39:59 +09:00
|
|
|
<span className="truncate">
|
|
|
|
|
{mapping.sourceField
|
2026-01-09 15:32:02 +09:00
|
|
|
? modalSourceColumns.find((c) => c.name === mapping.sourceField)?.label ||
|
|
|
|
|
mapping.sourceField
|
2025-12-15 18:39:59 +09:00
|
|
|
: "소스 컬럼 선택"}
|
|
|
|
|
</span>
|
2025-12-08 15:50:58 +09:00
|
|
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
|
|
|
|
</Button>
|
|
|
|
|
</PopoverTrigger>
|
2025-12-15 18:39:59 +09:00
|
|
|
<PopoverContent className="w-[--radix-popover-trigger-width] max-w-[280px] p-0" align="start">
|
2025-12-08 15:50:58 +09:00
|
|
|
<Command>
|
|
|
|
|
<CommandInput
|
|
|
|
|
placeholder="컬럼 검색..."
|
|
|
|
|
className="h-8 text-xs"
|
|
|
|
|
value={modalSourceSearch[index] || ""}
|
|
|
|
|
onValueChange={(value) => setModalSourceSearch((prev) => ({ ...prev, [index]: value }))}
|
|
|
|
|
/>
|
2025-12-15 18:39:59 +09:00
|
|
|
<CommandList className="max-h-[200px]">
|
2025-12-08 15:50:58 +09:00
|
|
|
<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",
|
2026-01-09 15:32:02 +09:00
|
|
|
mapping.sourceField === col.name ? "opacity-100" : "opacity-0",
|
2025-12-08 15:50:58 +09:00
|
|
|
)}
|
|
|
|
|
/>
|
2025-12-15 18:39:59 +09:00
|
|
|
<span className="truncate">{col.label}</span>
|
2025-12-08 15:50:58 +09:00
|
|
|
{col.label !== col.name && (
|
2026-01-09 15:32:02 +09:00
|
|
|
<span className="text-muted-foreground ml-1 truncate">({col.name})</span>
|
2025-12-08 15:50:58 +09:00
|
|
|
)}
|
|
|
|
|
</CommandItem>
|
|
|
|
|
))}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
</CommandList>
|
|
|
|
|
</Command>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-12-15 18:39:59 +09:00
|
|
|
{/* 화살표 표시 */}
|
|
|
|
|
<div className="flex justify-center">
|
2026-01-09 15:32:02 +09:00
|
|
|
<span className="text-muted-foreground text-xs">↓</span>
|
2025-12-15 18:39:59 +09:00
|
|
|
</div>
|
2025-12-08 15:50:58 +09:00
|
|
|
|
2025-12-15 18:39:59 +09:00
|
|
|
{/* 타겟 필드 선택 (Combobox) - 세로 배치 */}
|
|
|
|
|
<div className="space-y-1">
|
2026-01-09 15:32:02 +09:00
|
|
|
<Label className="text-muted-foreground text-[10px]">타겟 컬럼</Label>
|
2025-12-08 15:50:58 +09:00
|
|
|
<Popover
|
|
|
|
|
open={modalTargetPopoverOpen[index] || false}
|
|
|
|
|
onOpenChange={(open) => setModalTargetPopoverOpen((prev) => ({ ...prev, [index]: open }))}
|
|
|
|
|
>
|
|
|
|
|
<PopoverTrigger asChild>
|
2026-01-09 15:32:02 +09:00
|
|
|
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
|
2025-12-15 18:39:59 +09:00
|
|
|
<span className="truncate">
|
|
|
|
|
{mapping.targetField
|
2026-01-09 15:32:02 +09:00
|
|
|
? modalTargetColumns.find((c) => c.name === mapping.targetField)?.label ||
|
|
|
|
|
mapping.targetField
|
2025-12-15 18:39:59 +09:00
|
|
|
: "타겟 컬럼 선택"}
|
|
|
|
|
</span>
|
2025-12-08 15:50:58 +09:00
|
|
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
|
|
|
|
</Button>
|
|
|
|
|
</PopoverTrigger>
|
2025-12-15 18:39:59 +09:00
|
|
|
<PopoverContent className="w-[--radix-popover-trigger-width] max-w-[280px] p-0" align="start">
|
2025-12-08 15:50:58 +09:00
|
|
|
<Command>
|
|
|
|
|
<CommandInput
|
|
|
|
|
placeholder="컬럼 검색..."
|
|
|
|
|
className="h-8 text-xs"
|
|
|
|
|
value={modalTargetSearch[index] || ""}
|
|
|
|
|
onValueChange={(value) => setModalTargetSearch((prev) => ({ ...prev, [index]: value }))}
|
|
|
|
|
/>
|
2025-12-15 18:39:59 +09:00
|
|
|
<CommandList className="max-h-[200px]">
|
2025-12-08 15:50:58 +09:00
|
|
|
<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",
|
2026-01-09 15:32:02 +09:00
|
|
|
mapping.targetField === col.name ? "opacity-100" : "opacity-0",
|
2025-12-08 15:50:58 +09:00
|
|
|
)}
|
|
|
|
|
/>
|
2025-12-15 18:39:59 +09:00
|
|
|
<span className="truncate">{col.label}</span>
|
2025-12-08 15:50:58 +09:00
|
|
|
{col.label !== col.name && (
|
2026-01-09 15:32:02 +09:00
|
|
|
<span className="text-muted-foreground ml-1 truncate">({col.name})</span>
|
2025-12-08 15:50:58 +09:00
|
|
|
)}
|
|
|
|
|
</CommandItem>
|
|
|
|
|
))}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
</CommandList>
|
|
|
|
|
</Command>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 삭제 버튼 */}
|
2025-12-15 18:39:59 +09:00
|
|
|
<div className="flex justify-end pt-1">
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
2026-01-09 15:32:02 +09:00
|
|
|
className="text-destructive hover:bg-destructive/10 h-6 text-[10px]"
|
2025-12-15 18:39:59 +09:00
|
|
|
onClick={() => {
|
|
|
|
|
const mappings = [...(config.action?.fieldMappings || [])];
|
|
|
|
|
mappings.splice(index, 1);
|
|
|
|
|
onUpdateProperty("componentConfig.action.fieldMappings", mappings);
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-01-09 15:32:02 +09:00
|
|
|
<X className="mr-1 h-3 w-3" />
|
2025-12-15 18:39:59 +09:00
|
|
|
삭제
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
2025-12-08 15:50:58 +09:00
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2025-11-17 12:23:45 +09:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-09-18 18:49:30 +09:00
|
|
|
{/* 수정 액션 설정 */}
|
2025-10-21 17:32:54 +09:00
|
|
|
{(component.componentConfig?.action?.type || "save") === "edit" && (
|
2026-01-09 15:32:02 +09:00
|
|
|
<div className="bg-success/10 mt-4 space-y-4 rounded-lg border p-4">
|
|
|
|
|
<h4 className="text-foreground text-sm font-medium">수정 설정</h4>
|
2025-09-18 18:49:30 +09:00
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="edit-screen">수정 폼 화면 선택</Label>
|
|
|
|
|
<Popover open={modalScreenOpen} onOpenChange={setModalScreenOpen}>
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
role="combobox"
|
|
|
|
|
aria-expanded={modalScreenOpen}
|
2025-10-28 18:41:45 +09:00
|
|
|
className="h-6 w-full justify-between px-2 py-0"
|
2025-11-25 13:04:58 +09:00
|
|
|
className="text-xs"
|
2025-09-18 18:49:30 +09:00
|
|
|
disabled={screensLoading}
|
|
|
|
|
>
|
|
|
|
|
{config.action?.targetScreenId
|
2025-11-13 12:17:10 +09:00
|
|
|
? screens.find((screen) => screen.id === parseInt(config.action?.targetScreenId))?.name ||
|
2025-09-18 18:49:30 +09:00
|
|
|
"수정 폼 화면을 선택하세요..."
|
|
|
|
|
: "수정 폼 화면을 선택하세요..."}
|
|
|
|
|
<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) {
|
2026-01-09 15:32:02 +09:00
|
|
|
return <div className="text-muted-foreground p-3 text-sm">화면 목록을 불러오는 중...</div>;
|
2025-09-18 18:49:30 +09:00
|
|
|
}
|
|
|
|
|
if (filteredScreens.length === 0) {
|
2026-01-09 15:32:02 +09:00
|
|
|
return <div className="text-muted-foreground p-3 text-sm">검색 결과가 없습니다.</div>;
|
2025-09-18 18:49:30 +09:00
|
|
|
}
|
|
|
|
|
return filteredScreens.map((screen, index) => (
|
|
|
|
|
<div
|
|
|
|
|
key={`edit-screen-${screen.id}-${index}`}
|
2026-01-09 15:32:02 +09:00
|
|
|
className="hover:bg-muted flex cursor-pointer items-center px-3 py-2"
|
2025-09-18 18:49:30 +09:00
|
|
|
onClick={() => {
|
2025-10-21 17:32:54 +09:00
|
|
|
onUpdateProperty("componentConfig.action.targetScreenId", screen.id);
|
2025-09-18 18:49:30 +09:00
|
|
|
setModalScreenOpen(false);
|
|
|
|
|
setModalSearchTerm("");
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Check
|
|
|
|
|
className={cn(
|
|
|
|
|
"mr-2 h-4 w-4",
|
2025-11-13 12:17:10 +09:00
|
|
|
parseInt(config.action?.targetScreenId) === screen.id ? "opacity-100" : "opacity-0",
|
2025-09-18 18:49:30 +09:00
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
<span className="font-medium">{screen.name}</span>
|
2026-01-09 15:32:02 +09:00
|
|
|
{screen.description && (
|
|
|
|
|
<span className="text-muted-foreground text-xs">{screen.description}</span>
|
|
|
|
|
)}
|
2025-09-18 18:49:30 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
));
|
|
|
|
|
})()}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground mt-1 text-xs">
|
2025-09-18 18:49:30 +09:00
|
|
|
선택된 데이터가 이 폼 화면에 자동으로 로드되어 수정할 수 있습니다
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="edit-mode">수정 모드</Label>
|
|
|
|
|
<Select
|
2025-10-21 17:32:54 +09:00
|
|
|
value={component.componentConfig?.action?.editMode || "modal"}
|
2025-10-21 15:11:15 +09:00
|
|
|
onValueChange={(value) => {
|
2025-10-21 17:32:54 +09:00
|
|
|
onUpdateProperty("componentConfig.action.editMode", value);
|
2025-10-21 15:11:15 +09:00
|
|
|
}}
|
2025-09-18 18:49:30 +09:00
|
|
|
>
|
|
|
|
|
<SelectTrigger>
|
|
|
|
|
<SelectValue placeholder="수정 모드 선택" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
<SelectItem value="modal">모달로 열기</SelectItem>
|
|
|
|
|
<SelectItem value="navigate">새 페이지로 이동</SelectItem>
|
|
|
|
|
<SelectItem value="inline">현재 화면에서 수정</SelectItem>
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-10-21 17:32:54 +09:00
|
|
|
{(component.componentConfig?.action?.editMode || "modal") === "modal" && (
|
2025-10-01 17:41:30 +09:00
|
|
|
<>
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="edit-modal-title">모달 제목</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="edit-modal-title"
|
|
|
|
|
placeholder="모달 제목을 입력하세요 (예: 데이터 수정)"
|
2025-10-21 15:11:15 +09:00
|
|
|
value={localInputs.editModalTitle}
|
2025-10-01 17:45:29 +09:00
|
|
|
onChange={(e) => {
|
|
|
|
|
const newValue = e.target.value;
|
2025-10-21 15:11:15 +09:00
|
|
|
setLocalInputs((prev) => ({ ...prev, editModalTitle: newValue }));
|
2025-10-21 17:32:54 +09:00
|
|
|
onUpdateProperty("componentConfig.action.editModalTitle", newValue);
|
2025-10-01 17:45:29 +09:00
|
|
|
onUpdateProperty("webTypeConfig.editModalTitle", newValue);
|
|
|
|
|
}}
|
2025-10-01 17:41:30 +09:00
|
|
|
/>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground mt-1 text-xs">비워두면 기본 제목이 표시됩니다</p>
|
2025-10-01 17:41:30 +09:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="edit-modal-description">모달 설명</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="edit-modal-description"
|
|
|
|
|
placeholder="모달 설명을 입력하세요 (예: 선택한 데이터를 수정합니다)"
|
2025-10-21 15:11:15 +09:00
|
|
|
value={localInputs.editModalDescription}
|
2025-10-01 17:45:29 +09:00
|
|
|
onChange={(e) => {
|
|
|
|
|
const newValue = e.target.value;
|
2025-10-21 15:11:15 +09:00
|
|
|
setLocalInputs((prev) => ({ ...prev, editModalDescription: newValue }));
|
2025-10-21 17:32:54 +09:00
|
|
|
onUpdateProperty("componentConfig.action.editModalDescription", newValue);
|
2025-10-01 17:45:29 +09:00
|
|
|
onUpdateProperty("webTypeConfig.editModalDescription", newValue);
|
|
|
|
|
}}
|
2025-10-01 17:41:30 +09:00
|
|
|
/>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground mt-1 text-xs">비워두면 설명이 표시되지 않습니다</p>
|
2025-10-01 17:41:30 +09:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="edit-modal-size">모달 크기</Label>
|
|
|
|
|
<Select
|
2025-10-21 17:32:54 +09:00
|
|
|
value={component.componentConfig?.action?.modalSize || "md"}
|
2025-10-21 15:11:15 +09:00
|
|
|
onValueChange={(value) => {
|
2025-10-21 17:32:54 +09:00
|
|
|
onUpdateProperty("componentConfig.action.modalSize", value);
|
2025-10-21 15:11:15 +09:00
|
|
|
}}
|
2025-10-01 17:41:30 +09:00
|
|
|
>
|
|
|
|
|
<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>
|
2025-11-06 17:32:24 +09:00
|
|
|
<SelectItem value="full">전체 화면 (Full)</SelectItem>
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2026-01-07 10:24:01 +09:00
|
|
|
|
|
|
|
|
<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 &&
|
2026-01-09 15:32:02 +09:00
|
|
|
currentTableColumns.find((col) => col.name === localInputs.groupByColumn)?.label !==
|
|
|
|
|
localInputs.groupByColumn && (
|
|
|
|
|
<span className="text-muted-foreground ml-1">
|
2026-01-07 10:24:01 +09:00
|
|
|
({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 ? (
|
2026-01-09 15:32:02 +09:00
|
|
|
<div className="text-muted-foreground p-3 text-sm">
|
2026-01-07 10:24:01 +09:00
|
|
|
{currentTableName ? "컬럼을 불러오는 중..." : "테이블이 설정되지 않았습니다"}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
{/* 선택 해제 옵션 */}
|
|
|
|
|
<div
|
2026-01-09 15:32:02 +09:00
|
|
|
className="hover:bg-muted flex cursor-pointer items-center px-3 py-2"
|
2026-01-07 10:24:01 +09:00
|
|
|
onClick={() => {
|
|
|
|
|
setLocalInputs((prev) => ({ ...prev, groupByColumn: "" }));
|
|
|
|
|
onUpdateProperty("componentConfig.action.groupByColumns", undefined);
|
|
|
|
|
setGroupByColumnOpen(false);
|
|
|
|
|
setGroupByColumnSearch("");
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-01-09 15:32:02 +09:00
|
|
|
<Check
|
|
|
|
|
className={cn("mr-2 h-4 w-4", !localInputs.groupByColumn ? "opacity-100" : "opacity-0")}
|
|
|
|
|
/>
|
2026-01-07 10:24:01 +09:00
|
|
|
<span className="text-muted-foreground">선택 안 함</span>
|
|
|
|
|
</div>
|
|
|
|
|
{/* 컬럼 목록 */}
|
|
|
|
|
{currentTableColumns
|
|
|
|
|
.filter((col) => {
|
|
|
|
|
if (!groupByColumnSearch) return true;
|
|
|
|
|
const search = groupByColumnSearch.toLowerCase();
|
2026-01-09 15:32:02 +09:00
|
|
|
return col.name.toLowerCase().includes(search) || col.label.toLowerCase().includes(search);
|
2026-01-07 10:24:01 +09:00
|
|
|
})
|
|
|
|
|
.map((col) => (
|
|
|
|
|
<div
|
|
|
|
|
key={col.name}
|
2026-01-09 15:32:02 +09:00
|
|
|
className="hover:bg-muted flex cursor-pointer items-center px-3 py-2"
|
2026-01-07 10:24:01 +09:00
|
|
|
onClick={() => {
|
|
|
|
|
setLocalInputs((prev) => ({ ...prev, groupByColumn: col.name }));
|
|
|
|
|
onUpdateProperty("componentConfig.action.groupByColumns", [col.name]);
|
|
|
|
|
setGroupByColumnOpen(false);
|
|
|
|
|
setGroupByColumnSearch("");
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Check
|
2026-01-09 15:32:02 +09:00
|
|
|
className={cn(
|
|
|
|
|
"mr-2 h-4 w-4",
|
|
|
|
|
localInputs.groupByColumn === col.name ? "opacity-100" : "opacity-0",
|
|
|
|
|
)}
|
2026-01-07 10:24:01 +09:00
|
|
|
/>
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
<span className="font-medium">{col.name}</span>
|
|
|
|
|
{col.label !== col.name && (
|
2026-01-09 15:32:02 +09:00
|
|
|
<span className="text-muted-foreground text-xs">{col.label}</span>
|
2026-01-07 10:24:01 +09:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground mt-1 text-xs">여러 행을 하나의 그룹으로 묶어서 수정할 때 사용합니다</p>
|
2026-01-07 10:24:01 +09:00
|
|
|
</div>
|
2025-11-06 17:32:24 +09:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 복사 액션 설정 */}
|
|
|
|
|
{(component.componentConfig?.action?.type || "save") === "copy" && (
|
|
|
|
|
<div className="mt-4 space-y-4 rounded-lg border bg-blue-50 p-4">
|
2026-01-09 15:32:02 +09:00
|
|
|
<h4 className="text-foreground text-sm font-medium">복사 설정 (품목코드 자동 초기화)</h4>
|
2025-11-06 17:32:24 +09:00
|
|
|
|
|
|
|
|
<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"
|
2025-11-25 13:04:58 +09:00
|
|
|
className="text-xs"
|
2025-11-06 17:32:24 +09:00
|
|
|
disabled={screensLoading}
|
|
|
|
|
>
|
|
|
|
|
{config.action?.targetScreenId
|
2025-11-13 12:17:10 +09:00
|
|
|
? screens.find((screen) => screen.id === parseInt(config.action?.targetScreenId))?.name ||
|
2025-11-06 17:32:24 +09:00
|
|
|
"복사 폼 화면을 선택하세요..."
|
|
|
|
|
: "복사 폼 화면을 선택하세요..."}
|
|
|
|
|
<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) {
|
2026-01-09 15:32:02 +09:00
|
|
|
return <div className="text-muted-foreground p-3 text-sm">화면 목록을 불러오는 중...</div>;
|
2025-11-06 17:32:24 +09:00
|
|
|
}
|
|
|
|
|
if (filteredScreens.length === 0) {
|
2026-01-09 15:32:02 +09:00
|
|
|
return <div className="text-muted-foreground p-3 text-sm">검색 결과가 없습니다.</div>;
|
2025-11-06 17:32:24 +09:00
|
|
|
}
|
|
|
|
|
return filteredScreens.map((screen, index) => (
|
|
|
|
|
<div
|
|
|
|
|
key={`copy-screen-${screen.id}-${index}`}
|
2026-01-09 15:32:02 +09:00
|
|
|
className="hover:bg-muted flex cursor-pointer items-center px-3 py-2"
|
2025-11-06 17:32:24 +09:00
|
|
|
onClick={() => {
|
|
|
|
|
onUpdateProperty("componentConfig.action.targetScreenId", screen.id);
|
|
|
|
|
setModalScreenOpen(false);
|
|
|
|
|
setModalSearchTerm("");
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Check
|
|
|
|
|
className={cn(
|
|
|
|
|
"mr-2 h-4 w-4",
|
2025-11-13 12:17:10 +09:00
|
|
|
parseInt(config.action?.targetScreenId) === screen.id ? "opacity-100" : "opacity-0",
|
2025-11-06 17:32:24 +09:00
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
<span className="font-medium">{screen.name}</span>
|
2026-01-09 15:32:02 +09:00
|
|
|
{screen.description && (
|
|
|
|
|
<span className="text-muted-foreground text-xs">{screen.description}</span>
|
|
|
|
|
)}
|
2025-11-06 17:32:24 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
));
|
|
|
|
|
})()}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground mt-1 text-xs">
|
2025-11-06 17:32:24 +09:00
|
|
|
선택된 데이터가 복사되며, 품목코드는 자동으로 초기화됩니다
|
|
|
|
|
</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);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground mt-1 text-xs">비워두면 기본 제목이 표시됩니다</p>
|
2025-11-06 17:32:24 +09:00
|
|
|
</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);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground mt-1 text-xs">비워두면 설명이 표시되지 않습니다</p>
|
2025-11-06 17:32:24 +09:00
|
|
|
</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>
|
2025-10-01 17:41:30 +09:00
|
|
|
<SelectItem value="full">전체 화면 (Full)</SelectItem>
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
2025-09-18 18:49:30 +09:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-10-27 11:11:08 +09:00
|
|
|
{/* 테이블 이력 보기 액션 설정 */}
|
|
|
|
|
{(component.componentConfig?.action?.type || "save") === "view_table_history" && (
|
2025-10-27 16:40:59 +09:00
|
|
|
<div className="mt-4 space-y-4">
|
2025-10-27 11:11:08 +09:00
|
|
|
<div>
|
2025-10-27 16:40:59 +09:00
|
|
|
<Label>
|
2025-10-30 15:39:39 +09:00
|
|
|
전체 이력 표시 컬럼 (필수) <span className="text-destructive">*</span>
|
2025-10-27 11:11:08 +09:00
|
|
|
</Label>
|
|
|
|
|
|
2025-10-28 16:16:00 +09:00
|
|
|
<Popover open={displayColumnOpen} onOpenChange={setDisplayColumnOpen}>
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
role="combobox"
|
|
|
|
|
aria-expanded={displayColumnOpen}
|
2025-10-28 18:41:45 +09:00
|
|
|
className="mt-2 h-8 w-full justify-between text-xs"
|
2025-10-28 16:16:00 +09:00
|
|
|
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>
|
2025-11-25 13:04:58 +09:00
|
|
|
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
2025-10-28 16:16:00 +09:00
|
|
|
<CommandList>
|
2026-01-09 15:32:02 +09:00
|
|
|
<CommandEmpty className="text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
2025-10-28 16:16:00 +09:00
|
|
|
<CommandGroup>
|
|
|
|
|
{tableColumns.map((column) => (
|
|
|
|
|
<CommandItem
|
|
|
|
|
key={column}
|
|
|
|
|
value={column}
|
|
|
|
|
onSelect={(currentValue) => {
|
|
|
|
|
onUpdateProperty("componentConfig.action.historyDisplayColumn", currentValue);
|
|
|
|
|
setDisplayColumnOpen(false);
|
|
|
|
|
}}
|
2025-10-28 18:41:45 +09:00
|
|
|
className="text-xs"
|
2025-10-28 16:16:00 +09:00
|
|
|
>
|
|
|
|
|
<Check
|
|
|
|
|
className={cn(
|
|
|
|
|
"mr-2 h-4 w-4",
|
|
|
|
|
config.action?.historyDisplayColumn === column ? "opacity-100" : "opacity-0",
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
{column}
|
|
|
|
|
</CommandItem>
|
|
|
|
|
))}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
</CommandList>
|
|
|
|
|
</Command>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
2025-10-27 11:11:08 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-09-12 14:24:25 +09:00
|
|
|
{/* 페이지 이동 액션 설정 */}
|
2025-10-21 17:32:54 +09:00
|
|
|
{(component.componentConfig?.action?.type || "save") === "navigate" && (
|
2026-01-09 15:32:02 +09:00
|
|
|
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
|
|
|
|
|
<h4 className="text-foreground text-sm font-medium">페이지 이동 설정</h4>
|
2025-09-12 14:24:25 +09:00
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="target-screen-nav">이동할 화면 선택</Label>
|
|
|
|
|
<Popover open={navScreenOpen} onOpenChange={setNavScreenOpen}>
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
role="combobox"
|
|
|
|
|
aria-expanded={navScreenOpen}
|
2025-10-28 18:41:45 +09:00
|
|
|
className="h-6 w-full justify-between px-2 py-0"
|
2025-11-25 13:04:58 +09:00
|
|
|
className="text-xs"
|
2025-09-12 14:24:25 +09:00
|
|
|
disabled={screensLoading}
|
|
|
|
|
>
|
|
|
|
|
{config.action?.targetScreenId
|
2025-11-13 12:17:10 +09:00
|
|
|
? screens.find((screen) => screen.id === parseInt(config.action?.targetScreenId))?.name ||
|
2025-09-12 14:24:25 +09:00
|
|
|
"화면을 선택하세요..."
|
|
|
|
|
: "화면을 선택하세요..."}
|
|
|
|
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
|
|
|
</Button>
|
|
|
|
|
</PopoverTrigger>
|
2025-09-18 10:05:50 +09:00
|
|
|
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
|
2025-09-12 14:24:25 +09:00
|
|
|
<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) {
|
2026-01-09 15:32:02 +09:00
|
|
|
return <div className="text-muted-foreground p-3 text-sm">화면 목록을 불러오는 중...</div>;
|
2025-09-12 14:24:25 +09:00
|
|
|
}
|
|
|
|
|
if (filteredScreens.length === 0) {
|
2026-01-09 15:32:02 +09:00
|
|
|
return <div className="text-muted-foreground p-3 text-sm">검색 결과가 없습니다.</div>;
|
2025-09-12 14:24:25 +09:00
|
|
|
}
|
|
|
|
|
return filteredScreens.map((screen, index) => (
|
|
|
|
|
<div
|
|
|
|
|
key={`navigate-screen-${screen.id}-${index}`}
|
2026-01-09 15:32:02 +09:00
|
|
|
className="hover:bg-muted flex cursor-pointer items-center px-3 py-2"
|
2025-09-12 14:24:25 +09:00
|
|
|
onClick={() => {
|
2025-10-21 17:32:54 +09:00
|
|
|
onUpdateProperty("componentConfig.action.targetScreenId", screen.id);
|
2025-09-12 14:24:25 +09:00
|
|
|
setNavScreenOpen(false);
|
|
|
|
|
setNavSearchTerm("");
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Check
|
|
|
|
|
className={cn(
|
|
|
|
|
"mr-2 h-4 w-4",
|
2025-11-13 12:17:10 +09:00
|
|
|
parseInt(config.action?.targetScreenId) === screen.id ? "opacity-100" : "opacity-0",
|
2025-09-12 14:24:25 +09:00
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
<span className="font-medium">{screen.name}</span>
|
2026-01-09 15:32:02 +09:00
|
|
|
{screen.description && (
|
|
|
|
|
<span className="text-muted-foreground text-xs">{screen.description}</span>
|
|
|
|
|
)}
|
2025-09-12 14:24:25 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
));
|
|
|
|
|
})()}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground mt-1 text-xs">
|
2025-09-12 14:24:25 +09:00
|
|
|
선택한 화면으로 /screens/{"{"}화면ID{"}"} 형태로 이동합니다
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="target-url">또는 직접 URL 입력 (고급)</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="target-url"
|
|
|
|
|
placeholder="예: /admin/users 또는 https://example.com"
|
2025-10-21 15:11:15 +09:00
|
|
|
value={localInputs.targetUrl}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
const newValue = e.target.value;
|
|
|
|
|
setLocalInputs((prev) => ({ ...prev, targetUrl: newValue }));
|
2025-10-21 17:32:54 +09:00
|
|
|
onUpdateProperty("componentConfig.action.targetUrl", newValue);
|
2025-10-21 15:11:15 +09:00
|
|
|
}}
|
2025-10-28 18:41:45 +09:00
|
|
|
className="h-6 w-full px-2 py-0 text-xs"
|
2025-09-12 14:24:25 +09:00
|
|
|
/>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground mt-1 text-xs">URL을 입력하면 화면 선택보다 우선 적용됩니다</p>
|
2025-09-12 14:24:25 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-11-04 09:41:58 +09:00
|
|
|
{/* 엑셀 다운로드 액션 설정 */}
|
|
|
|
|
{(component.componentConfig?.action?.type || "save") === "excel_download" && (
|
2026-01-09 15:32:02 +09:00
|
|
|
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
|
|
|
|
|
<h4 className="text-foreground text-sm font-medium">엑셀 다운로드 설정</h4>
|
2025-11-04 09:41:58 +09:00
|
|
|
|
|
|
|
|
<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"
|
|
|
|
|
/>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground mt-1 text-xs">확장자(.xlsx)는 자동으로 추가됩니다</p>
|
2025-11-04 09:41:58 +09:00
|
|
|
</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>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 엑셀 업로드 액션 설정 */}
|
|
|
|
|
{(component.componentConfig?.action?.type || "save") === "excel_upload" && (
|
2026-01-09 17:56:48 +09:00
|
|
|
<ExcelUploadConfigSection
|
|
|
|
|
config={config}
|
|
|
|
|
onUpdateProperty={onUpdateProperty}
|
|
|
|
|
allComponents={allComponents}
|
|
|
|
|
currentTableName={currentTableName}
|
|
|
|
|
/>
|
2025-11-04 09:41:58 +09:00
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 바코드 스캔 액션 설정 */}
|
|
|
|
|
{(component.componentConfig?.action?.type || "save") === "barcode_scan" && (
|
2026-01-09 15:32:02 +09:00
|
|
|
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
|
|
|
|
|
<h4 className="text-foreground text-sm font-medium">📷 바코드 스캔 설정</h4>
|
2025-11-04 09:41:58 +09:00
|
|
|
|
|
|
|
|
<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"
|
|
|
|
|
/>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground mt-1 text-xs">스캔 결과가 입력될 폼 필드명</p>
|
2025-11-04 09:41:58 +09:00
|
|
|
</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>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-11-04 18:31:26 +09:00
|
|
|
{/* 코드 병합 액션 설정 */}
|
|
|
|
|
{(component.componentConfig?.action?.type || "save") === "code_merge" && (
|
2026-01-09 15:32:02 +09:00
|
|
|
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
|
|
|
|
|
<h4 className="text-foreground text-sm font-medium">🔀 코드 병합 설정</h4>
|
2025-11-04 18:31:26 +09:00
|
|
|
|
|
|
|
|
<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"
|
|
|
|
|
/>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground mt-1 text-xs">
|
2025-11-04 18:31:26 +09:00
|
|
|
병합할 컬럼명 (예: item_code). 이 컬럼이 있는 모든 테이블에 병합이 적용됩니다.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div className="space-y-0.5">
|
|
|
|
|
<Label htmlFor="merge-show-preview">병합 전 미리보기</Label>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground text-xs">영향받을 테이블과 행 수를 미리 확인합니다</p>
|
2025-11-04 18:31:26 +09:00
|
|
|
</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>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-12-02 09:53:08 +09:00
|
|
|
{/* 공차등록 설정 - 운행알림으로 통합되어 주석 처리 */}
|
|
|
|
|
{/* {(component.componentConfig?.action?.type || "save") === "empty_vehicle" && (
|
2025-11-28 18:35:07 +09:00
|
|
|
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
|
2025-12-02 09:53:08 +09:00
|
|
|
... 공차등록 설정 UI 생략 ...
|
2025-11-28 18:35:07 +09:00
|
|
|
</div>
|
2025-12-02 09:53:08 +09:00
|
|
|
)} */}
|
2025-11-28 18:35:07 +09:00
|
|
|
|
2025-12-01 17:04:59 +09:00
|
|
|
{/* 운행알림 및 종료 설정 */}
|
|
|
|
|
{(component.componentConfig?.action?.type || "save") === "operation_control" && (
|
2026-01-09 15:32:02 +09:00
|
|
|
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
|
|
|
|
|
<h4 className="text-foreground text-sm font-medium">🚗 운행알림 및 종료 설정</h4>
|
2025-11-28 18:35:07 +09:00
|
|
|
|
|
|
|
|
<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>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground mt-1 text-xs">필드 값을 변경할 테이블 (기본: 현재 화면 테이블)</p>
|
2025-11-28 18:35:07 +09:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="update-target-field">
|
|
|
|
|
변경할 필드명 <span className="text-destructive">*</span>
|
|
|
|
|
</Label>
|
2025-11-28 18:45:41 +09:00
|
|
|
<Input
|
|
|
|
|
id="update-target-field"
|
|
|
|
|
placeholder="예: status"
|
2025-11-28 18:35:07 +09:00
|
|
|
value={config.action?.updateTargetField || ""}
|
2025-11-28 18:45:41 +09:00
|
|
|
onChange={(e) => onUpdateProperty("componentConfig.action.updateTargetField", e.target.value)}
|
|
|
|
|
className="h-8 text-xs"
|
|
|
|
|
/>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground mt-1 text-xs">변경할 DB 컬럼</p>
|
2025-11-28 18:35:07 +09:00
|
|
|
</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"
|
|
|
|
|
/>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground mt-1 text-xs">변경할 값 (문자열, 숫자)</p>
|
2025-11-28 18:35:07 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-12-01 15:23:07 +09:00
|
|
|
{/* 🆕 키 필드 설정 (레코드 식별용) */}
|
|
|
|
|
<div className="mt-4 border-t pt-4">
|
2026-01-09 15:32:02 +09:00
|
|
|
<h5 className="text-muted-foreground mb-3 text-xs font-medium">레코드 식별 설정</h5>
|
2025-12-01 15:23:07 +09:00
|
|
|
<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"
|
|
|
|
|
/>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground mt-1 text-xs">레코드를 찾을 DB 컬럼명</p>
|
2025-12-01 15:23:07 +09:00
|
|
|
</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>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground mt-1 text-xs">키 값을 가져올 소스</p>
|
2025-12-01 15:23:07 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-11-28 18:35:07 +09:00
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div className="space-y-0.5">
|
|
|
|
|
<Label htmlFor="update-auto-save">변경 후 자동 저장</Label>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground text-xs">버튼 클릭 시 즉시 DB에 저장</p>
|
2025-11-28 18:35:07 +09:00
|
|
|
</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"
|
|
|
|
|
/>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground mt-1 text-xs">입력하면 변경 전 확인 창이 표시됩니다</p>
|
2025-11-28 18:35:07 +09:00
|
|
|
</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>
|
|
|
|
|
|
2025-12-01 15:23:07 +09:00
|
|
|
{/* 위치정보 수집 옵션 */}
|
|
|
|
|
<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>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground text-xs">상태 변경과 함께 현재 GPS 좌표를 수집합니다</p>
|
2025-12-01 15:23:07 +09:00
|
|
|
</div>
|
|
|
|
|
<Switch
|
|
|
|
|
id="update-with-geolocation"
|
|
|
|
|
checked={config.action?.updateWithGeolocation === true}
|
|
|
|
|
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.updateWithGeolocation", checked)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-01-09 15:32:02 +09:00
|
|
|
|
2025-12-01 15:23:07 +09:00
|
|
|
{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>
|
2026-01-09 15:32:02 +09:00
|
|
|
<Label>
|
|
|
|
|
위도 저장 필드 <span className="text-destructive">*</span>
|
|
|
|
|
</Label>
|
2025-12-01 15:23:07 +09:00
|
|
|
<Input
|
|
|
|
|
placeholder="예: latitude"
|
|
|
|
|
value={config.action?.updateGeolocationLatField || ""}
|
2026-01-09 15:32:02 +09:00
|
|
|
onChange={(e) =>
|
|
|
|
|
onUpdateProperty("componentConfig.action.updateGeolocationLatField", e.target.value)
|
|
|
|
|
}
|
2025-12-01 15:23:07 +09:00
|
|
|
className="h-8 text-xs"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
2026-01-09 15:32:02 +09:00
|
|
|
<Label>
|
|
|
|
|
경도 저장 필드 <span className="text-destructive">*</span>
|
|
|
|
|
</Label>
|
2025-12-01 15:23:07 +09:00
|
|
|
<Input
|
|
|
|
|
placeholder="예: longitude"
|
|
|
|
|
value={config.action?.updateGeolocationLngField || ""}
|
2026-01-09 15:32:02 +09:00
|
|
|
onChange={(e) =>
|
|
|
|
|
onUpdateProperty("componentConfig.action.updateGeolocationLngField", e.target.value)
|
|
|
|
|
}
|
2025-12-01 15:23:07 +09:00
|
|
|
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 || ""}
|
2026-01-09 15:32:02 +09:00
|
|
|
onChange={(e) =>
|
|
|
|
|
onUpdateProperty("componentConfig.action.updateGeolocationAccuracyField", e.target.value)
|
|
|
|
|
}
|
2025-12-01 15:23:07 +09:00
|
|
|
className="h-8 text-xs"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<Label>타임스탬프 필드 (선택)</Label>
|
|
|
|
|
<Input
|
|
|
|
|
placeholder="예: location_time"
|
|
|
|
|
value={config.action?.updateGeolocationTimestampField || ""}
|
2026-01-09 15:32:02 +09:00
|
|
|
onChange={(e) =>
|
|
|
|
|
onUpdateProperty("componentConfig.action.updateGeolocationTimestampField", e.target.value)
|
|
|
|
|
}
|
2025-12-01 15:23:07 +09:00
|
|
|
className="h-8 text-xs"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<p className="text-[10px] text-amber-700 dark:text-amber-300">
|
|
|
|
|
버튼 클릭 시 GPS 위치를 수집하여 위 필드에 저장합니다.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-12-01 16:49:02 +09:00
|
|
|
{/* 🆕 연속 위치 추적 설정 */}
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="update-with-tracking">연속 위치 추적</Label>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground text-xs">10초마다 위치를 경로 테이블에 저장합니다</p>
|
2025-12-01 16:49:02 +09:00
|
|
|
</div>
|
|
|
|
|
<Switch
|
|
|
|
|
id="update-with-tracking"
|
|
|
|
|
checked={config.action?.updateWithTracking === true}
|
|
|
|
|
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.updateWithTracking", checked)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-01-09 15:32:02 +09:00
|
|
|
|
2025-12-01 16:49:02 +09:00
|
|
|
{config.action?.updateWithTracking && (
|
|
|
|
|
<div className="mt-3 space-y-3 rounded-md bg-green-50 p-3 dark:bg-green-950">
|
|
|
|
|
<div>
|
2026-01-09 15:32:02 +09:00
|
|
|
<Label>
|
|
|
|
|
추적 모드 <span className="text-destructive">*</span>
|
|
|
|
|
</Label>
|
2025-12-01 16:49:02 +09:00
|
|
|
<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>
|
2026-01-09 15:32:02 +09:00
|
|
|
|
2025-12-01 16:49:02 +09:00
|
|
|
{config.action?.updateTrackingMode === "start" && (
|
|
|
|
|
<div>
|
|
|
|
|
<Label>위치 저장 주기 (초)</Label>
|
|
|
|
|
<Input
|
|
|
|
|
type="number"
|
|
|
|
|
placeholder="10"
|
|
|
|
|
value={(config.action?.updateTrackingInterval || 10000) / 1000}
|
2026-01-09 15:32:02 +09:00
|
|
|
onChange={(e) =>
|
|
|
|
|
onUpdateProperty(
|
|
|
|
|
"componentConfig.action.updateTrackingInterval",
|
|
|
|
|
parseInt(e.target.value) * 1000 || 10000,
|
|
|
|
|
)
|
|
|
|
|
}
|
2025-12-01 16:49:02 +09:00
|
|
|
className="h-8 text-xs"
|
|
|
|
|
min={5}
|
|
|
|
|
max={300}
|
|
|
|
|
/>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground mt-1 text-[10px]">5초 ~ 300초 사이로 설정 (기본: 10초)</p>
|
2025-12-01 16:49:02 +09:00
|
|
|
</div>
|
|
|
|
|
)}
|
2026-01-09 15:32:02 +09:00
|
|
|
|
2025-12-01 16:49:02 +09:00
|
|
|
<p className="text-[10px] text-green-700 dark:text-green-300">
|
2026-01-09 15:32:02 +09:00
|
|
|
{config.action?.updateTrackingMode === "start"
|
2025-12-01 16:49:02 +09:00
|
|
|
? "버튼 클릭 시 연속 위치 추적이 시작되고, vehicle_location_history 테이블에 경로가 저장됩니다."
|
|
|
|
|
: "버튼 클릭 시 진행 중인 위치 추적이 종료됩니다."}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-12-05 11:03:15 +09:00
|
|
|
{/* 🆕 버튼 활성화 조건 설정 */}
|
|
|
|
|
<div className="mt-4 border-t pt-4">
|
2026-01-09 15:32:02 +09:00
|
|
|
<h5 className="text-muted-foreground mb-3 text-xs font-medium">버튼 활성화 조건</h5>
|
|
|
|
|
|
2025-12-05 11:03:15 +09:00
|
|
|
{/* 출발지/도착지 필수 체크 */}
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="require-location">출발지/도착지 필수</Label>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground text-xs">선택하지 않으면 버튼 비활성화</p>
|
2025-12-05 11:03:15 +09:00
|
|
|
</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"}
|
2026-01-09 15:32:02 +09:00
|
|
|
onChange={(e) =>
|
|
|
|
|
onUpdateProperty("componentConfig.action.trackingDepartureField", e.target.value)
|
|
|
|
|
}
|
2025-12-05 11:03:15 +09:00
|
|
|
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>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground text-xs">특정 상태일 때만 버튼 활성화</p>
|
2025-12-05 11:03:15 +09:00
|
|
|
</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>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground mt-1 text-[10px]">상태를 조회할 테이블 (기본: vehicles)</p>
|
2025-12-05 11:03:15 +09:00
|
|
|
</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"
|
|
|
|
|
/>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground mt-1 text-[10px]">
|
2025-12-05 11:03:15 +09:00
|
|
|
현재 로그인 사용자 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"
|
|
|
|
|
/>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground mt-1 text-[10px]">상태 값이 저장된 컬럼명 (기본: status)</p>
|
2025-12-05 11:03:15 +09:00
|
|
|
</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"
|
|
|
|
|
/>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground mt-1 text-[10px]">여러 상태값은 쉼표(,)로 구분</p>
|
2025-12-05 11:03:15 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-11-28 18:35:07 +09:00
|
|
|
<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 />
|
2025-12-01 16:49:02 +09:00
|
|
|
- 운행 시작: status를 "active"로 + 연속 추적 시작
|
2025-11-28 18:35:07 +09:00
|
|
|
<br />
|
2025-12-01 16:49:02 +09:00
|
|
|
- 운행 종료: status를 "completed"로 + 연속 추적 종료
|
2026-01-09 15:32:02 +09:00
|
|
|
<br />- 공차등록: status를 "inactive"로 + 1회성 위치정보 수집
|
2025-11-28 18:35:07 +09:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-11-27 12:54:57 +09:00
|
|
|
{/* 데이터 전달 액션 설정 */}
|
|
|
|
|
{(component.componentConfig?.action?.type || "save") === "transferData" && (
|
2026-01-09 15:32:02 +09:00
|
|
|
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
|
|
|
|
|
<h4 className="text-foreground text-sm font-medium">📦 데이터 전달 설정</h4>
|
2025-11-27 12:54:57 +09:00
|
|
|
|
2025-11-28 14:56:11 +09:00
|
|
|
{/* 소스 컴포넌트 선택 (Combobox) */}
|
2025-11-27 12:54:57 +09:00
|
|
|
<div>
|
2025-11-28 14:56:11 +09:00
|
|
|
<Label>
|
|
|
|
|
소스 컴포넌트 <span className="text-destructive">*</span>
|
2025-11-27 12:54:57 +09:00
|
|
|
</Label>
|
2025-11-28 14:56:11 +09:00
|
|
|
<Select
|
2025-11-27 12:54:57 +09:00
|
|
|
value={config.action?.dataTransfer?.sourceComponentId || ""}
|
2026-01-09 15:32:02 +09:00
|
|
|
onValueChange={(value) =>
|
|
|
|
|
onUpdateProperty("componentConfig.action.dataTransfer.sourceComponentId", value)
|
|
|
|
|
}
|
2025-11-28 14:56:11 +09:00
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-8 text-xs">
|
|
|
|
|
<SelectValue placeholder="데이터를 가져올 컴포넌트 선택" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{/* 데이터 제공 가능한 컴포넌트 필터링 */}
|
|
|
|
|
{allComponents
|
|
|
|
|
.filter((comp: any) => {
|
|
|
|
|
const type = comp.componentType || comp.type || "";
|
|
|
|
|
// 데이터를 제공할 수 있는 컴포넌트 타입들
|
2026-01-09 15:32:02 +09:00
|
|
|
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) =>
|
|
|
|
|
type.includes(t),
|
2025-11-28 14:56:11 +09:00
|
|
|
);
|
|
|
|
|
})
|
|
|
|
|
.map((comp: any) => {
|
|
|
|
|
const compType = comp.componentType || comp.type || "unknown";
|
|
|
|
|
const compLabel = comp.label || comp.componentConfig?.title || 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>
|
2026-01-09 15:32:02 +09:00
|
|
|
<span className="text-muted-foreground text-[10px]">({compType})</span>
|
2025-11-28 14:56:11 +09:00
|
|
|
</div>
|
|
|
|
|
</SelectItem>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
{allComponents.filter((comp: any) => {
|
|
|
|
|
const type = comp.componentType || comp.type || "";
|
2026-01-09 15:32:02 +09:00
|
|
|
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) =>
|
|
|
|
|
type.includes(t),
|
|
|
|
|
);
|
2025-11-28 14:56:11 +09:00
|
|
|
}).length === 0 && (
|
|
|
|
|
<SelectItem value="__none__" disabled>
|
|
|
|
|
데이터 제공 가능한 컴포넌트가 없습니다
|
|
|
|
|
</SelectItem>
|
|
|
|
|
)}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground mt-1 text-xs">테이블, 반복 필드 그룹 등 데이터를 제공하는 컴포넌트</p>
|
2025-11-27 12:54:57 +09:00
|
|
|
</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>
|
2025-11-28 14:56:11 +09:00
|
|
|
<SelectItem value="splitPanel">분할 패널 반대편 화면</SelectItem>
|
2026-01-09 15:32:02 +09:00
|
|
|
<SelectItem value="screen" disabled>
|
|
|
|
|
다른 화면 (구현 예정)
|
|
|
|
|
</SelectItem>
|
2025-11-27 12:54:57 +09:00
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
2025-11-28 14:56:11 +09:00
|
|
|
{config.action?.dataTransfer?.targetType === "splitPanel" && (
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground mt-1 text-[10px]">
|
|
|
|
|
이 버튼이 분할 패널 내부에 있어야 합니다. 좌측 화면에서 우측으로, 또는 우측에서 좌측으로 데이터가
|
|
|
|
|
전달됩니다.
|
2025-11-28 14:56:11 +09:00
|
|
|
</p>
|
|
|
|
|
)}
|
2025-11-27 12:54:57 +09:00
|
|
|
</div>
|
|
|
|
|
|
2025-11-28 14:56:11 +09:00
|
|
|
{/* 타겟 컴포넌트 선택 (같은 화면의 컴포넌트일 때만) */}
|
|
|
|
|
{config.action?.dataTransfer?.targetType === "component" && (
|
2025-11-27 12:54:57 +09:00
|
|
|
<div>
|
2025-11-28 14:56:11 +09:00
|
|
|
<Label>
|
|
|
|
|
타겟 컴포넌트 <span className="text-destructive">*</span>
|
|
|
|
|
</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={config.action?.dataTransfer?.targetComponentId || ""}
|
2026-01-09 15:32:02 +09:00
|
|
|
onValueChange={(value) =>
|
|
|
|
|
onUpdateProperty("componentConfig.action.dataTransfer.targetComponentId", value)
|
|
|
|
|
}
|
2025-11-28 14:56:11 +09:00
|
|
|
>
|
|
|
|
|
<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(
|
2026-01-09 15:32:02 +09:00
|
|
|
(t) => type.includes(t),
|
2025-11-28 14:56:11 +09:00
|
|
|
);
|
|
|
|
|
// 소스와 다른 컴포넌트만
|
|
|
|
|
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;
|
|
|
|
|
return (
|
|
|
|
|
<SelectItem key={comp.id} value={comp.id}>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="text-xs font-medium">{compLabel}</span>
|
2026-01-09 15:32:02 +09:00
|
|
|
<span className="text-muted-foreground text-[10px]">({compType})</span>
|
2025-11-28 14:56:11 +09:00
|
|
|
</div>
|
|
|
|
|
</SelectItem>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
{allComponents.filter((comp: any) => {
|
|
|
|
|
const type = comp.componentType || comp.type || "";
|
2026-01-09 15:32:02 +09:00
|
|
|
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) =>
|
|
|
|
|
type.includes(t),
|
|
|
|
|
);
|
2025-11-28 14:56:11 +09:00
|
|
|
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
|
|
|
|
|
}).length === 0 && (
|
|
|
|
|
<SelectItem value="__none__" disabled>
|
|
|
|
|
데이터 수신 가능한 컴포넌트가 없습니다
|
|
|
|
|
</SelectItem>
|
|
|
|
|
)}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground mt-1 text-xs">테이블, 반복 필드 그룹 등 데이터를 받는 컴포넌트</p>
|
2025-11-28 14:56:11 +09:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 분할 패널 반대편 타겟 설정 */}
|
|
|
|
|
{config.action?.dataTransfer?.targetType === "splitPanel" && (
|
|
|
|
|
<div>
|
2026-01-09 15:32:02 +09:00
|
|
|
<Label>타겟 컴포넌트 ID (선택사항)</Label>
|
2025-11-27 12:54:57 +09:00
|
|
|
<Input
|
|
|
|
|
value={config.action?.dataTransfer?.targetComponentId || ""}
|
2026-01-09 15:32:02 +09:00
|
|
|
onChange={(e) =>
|
|
|
|
|
onUpdateProperty("componentConfig.action.dataTransfer.targetComponentId", e.target.value)
|
|
|
|
|
}
|
2025-11-28 14:56:11 +09:00
|
|
|
placeholder="비워두면 첫 번째 수신 가능 컴포넌트로 전달"
|
2025-11-27 12:54:57 +09:00
|
|
|
className="h-8 text-xs"
|
|
|
|
|
/>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground mt-1 text-xs">
|
2025-11-28 14:56:11 +09:00
|
|
|
반대편 화면의 특정 컴포넌트 ID를 지정하거나, 비워두면 자동으로 첫 번째 수신 가능 컴포넌트로 전달됩니다.
|
2025-11-27 12:54:57 +09:00
|
|
|
</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>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground mt-1 text-xs">기존 데이터를 어떻게 처리할지 선택</p>
|
2025-11-27 12:54:57 +09:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div className="space-y-0.5">
|
|
|
|
|
<Label htmlFor="clear-after-transfer">전달 후 소스 선택 초기화</Label>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground text-xs">데이터 전달 후 소스의 선택을 해제합니다</p>
|
2025-11-27 12:54:57 +09:00
|
|
|
</div>
|
|
|
|
|
<Switch
|
|
|
|
|
id="clear-after-transfer"
|
|
|
|
|
checked={config.action?.dataTransfer?.clearAfterTransfer === true}
|
2026-01-09 15:32:02 +09:00
|
|
|
onCheckedChange={(checked) =>
|
|
|
|
|
onUpdateProperty("componentConfig.action.dataTransfer.clearAfterTransfer", checked)
|
|
|
|
|
}
|
2025-11-27 12:54:57 +09:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div className="space-y-0.5">
|
|
|
|
|
<Label htmlFor="confirm-before-transfer">전달 전 확인 메시지</Label>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground text-xs">데이터 전달 전 확인 다이얼로그를 표시합니다</p>
|
2025-11-27 12:54:57 +09:00
|
|
|
</div>
|
|
|
|
|
<Switch
|
|
|
|
|
id="confirm-before-transfer"
|
|
|
|
|
checked={config.action?.dataTransfer?.confirmBeforeTransfer === true}
|
2026-01-09 15:32:02 +09:00
|
|
|
onCheckedChange={(checked) =>
|
|
|
|
|
onUpdateProperty("componentConfig.action.dataTransfer.confirmBeforeTransfer", checked)
|
|
|
|
|
}
|
2025-11-27 12:54:57 +09:00
|
|
|
/>
|
|
|
|
|
</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 || ""}
|
2026-01-09 15:32:02 +09:00
|
|
|
onChange={(e) =>
|
|
|
|
|
onUpdateProperty(
|
|
|
|
|
"componentConfig.action.dataTransfer.validation.minSelection",
|
|
|
|
|
parseInt(e.target.value) || 0,
|
|
|
|
|
)
|
|
|
|
|
}
|
2025-11-27 12:54:57 +09:00
|
|
|
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 || ""}
|
2026-01-09 15:32:02 +09:00
|
|
|
onChange={(e) =>
|
|
|
|
|
onUpdateProperty(
|
|
|
|
|
"componentConfig.action.dataTransfer.validation.maxSelection",
|
|
|
|
|
parseInt(e.target.value) || undefined,
|
|
|
|
|
)
|
|
|
|
|
}
|
2025-11-27 12:54:57 +09:00
|
|
|
className="h-8 w-20 text-xs"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-11-28 14:56:11 +09:00
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label>추가 데이터 소스 (선택사항)</Label>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground text-xs">
|
2025-11-28 14:56:11 +09:00
|
|
|
조건부 컨테이너의 카테고리 값 등 추가 데이터를 함께 전달할 수 있습니다
|
|
|
|
|
</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 || "";
|
|
|
|
|
// 소스/타겟과 다른 컴포넌트 중 값을 제공할 수 있는 타입
|
2026-01-09 15:32:02 +09:00
|
|
|
return ["conditional-container", "select-basic", "select", "combobox"].some((t) =>
|
|
|
|
|
type.includes(t),
|
2025-11-28 14:56:11 +09:00
|
|
|
);
|
|
|
|
|
})
|
|
|
|
|
.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>
|
2026-01-09 15:32:02 +09:00
|
|
|
<span className="text-muted-foreground text-[10px]">({compType})</span>
|
2025-11-28 14:56:11 +09:00
|
|
|
</div>
|
|
|
|
|
</SelectItem>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground mt-1 text-xs">
|
2025-11-28 14:56:11 +09:00
|
|
|
조건부 컨테이너, 셀렉트박스 등 (카테고리 값 전달용)
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="additional-field-name" className="text-xs">
|
|
|
|
|
필드명 (선택사항)
|
|
|
|
|
</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="additional-field-name"
|
|
|
|
|
placeholder="예: inbound_type (비워두면 전체 데이터)"
|
|
|
|
|
value={config.action?.dataTransfer?.additionalSources?.[0]?.fieldName || ""}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
const currentSources = config.action?.dataTransfer?.additionalSources || [];
|
|
|
|
|
const newSources = [...currentSources];
|
|
|
|
|
if (newSources.length === 0) {
|
|
|
|
|
newSources.push({ componentId: "", fieldName: e.target.value });
|
|
|
|
|
} else {
|
|
|
|
|
newSources[0] = { ...newSources[0], fieldName: e.target.value };
|
|
|
|
|
}
|
|
|
|
|
onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources);
|
|
|
|
|
}}
|
|
|
|
|
className="h-8 text-xs"
|
|
|
|
|
/>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground mt-1 text-xs">타겟 테이블에 저장될 필드명</p>
|
2025-11-28 14:56:11 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 필드 매핑 규칙 */}
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
<Label>필드 매핑 설정</Label>
|
2026-01-09 15:32:02 +09:00
|
|
|
|
2025-11-28 14:56:11 +09:00
|
|
|
{/* 소스/타겟 테이블 선택 */}
|
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
<Label className="text-xs">소스 테이블</Label>
|
|
|
|
|
<Popover>
|
|
|
|
|
<PopoverTrigger asChild>
|
2026-01-09 15:32:02 +09:00
|
|
|
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
|
2025-11-28 14:56:11 +09:00
|
|
|
{config.action?.dataTransfer?.sourceTable
|
|
|
|
|
? availableTables.find((t) => t.name === config.action?.dataTransfer?.sourceTable)?.label ||
|
|
|
|
|
config.action?.dataTransfer?.sourceTable
|
|
|
|
|
: "테이블 선택"}
|
|
|
|
|
<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.sourceTable", table.name);
|
|
|
|
|
}}
|
|
|
|
|
className="text-xs"
|
|
|
|
|
>
|
|
|
|
|
<Check
|
|
|
|
|
className={cn(
|
|
|
|
|
"mr-2 h-3 w-3",
|
2026-01-09 15:32:02 +09:00
|
|
|
config.action?.dataTransfer?.sourceTable === table.name ? "opacity-100" : "opacity-0",
|
2025-11-28 14:56:11 +09:00
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
<span className="font-medium">{table.label}</span>
|
2026-01-09 15:32:02 +09:00
|
|
|
<span className="text-muted-foreground ml-1">({table.name})</span>
|
2025-11-28 14:56:11 +09:00
|
|
|
</CommandItem>
|
|
|
|
|
))}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
</CommandList>
|
|
|
|
|
</Command>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
|
|
|
|
</div>
|
2026-01-09 15:32:02 +09:00
|
|
|
|
2025-11-28 14:56:11 +09:00
|
|
|
<div className="space-y-1">
|
|
|
|
|
<Label className="text-xs">타겟 테이블</Label>
|
|
|
|
|
<Popover>
|
|
|
|
|
<PopoverTrigger asChild>
|
2026-01-09 15:32:02 +09:00
|
|
|
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
|
2025-11-28 14:56:11 +09:00
|
|
|
{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",
|
2026-01-09 15:32:02 +09:00
|
|
|
config.action?.dataTransfer?.targetTable === table.name ? "opacity-100" : "opacity-0",
|
2025-11-28 14:56:11 +09:00
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
<span className="font-medium">{table.label}</span>
|
2026-01-09 15:32:02 +09:00
|
|
|
<span className="text-muted-foreground ml-1">({table.name})</span>
|
2025-11-28 14:56:11 +09:00
|
|
|
</CommandItem>
|
|
|
|
|
))}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
</CommandList>
|
|
|
|
|
</Command>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-01-09 15:32:02 +09:00
|
|
|
|
2025-11-28 14:56:11 +09:00
|
|
|
{/* 필드 매핑 규칙 */}
|
|
|
|
|
<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 currentRules = config.action?.dataTransfer?.mappingRules || [];
|
|
|
|
|
const newRule = { sourceField: "", targetField: "", transform: "" };
|
|
|
|
|
onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", [...currentRules, newRule]);
|
|
|
|
|
}}
|
|
|
|
|
disabled={!config.action?.dataTransfer?.sourceTable || !config.action?.dataTransfer?.targetTable}
|
|
|
|
|
>
|
|
|
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
|
|
|
매핑 추가
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground text-[10px]">
|
2025-11-28 14:56:11 +09:00
|
|
|
소스 필드를 타겟 필드에 매핑합니다. 비워두면 같은 이름의 필드로 자동 매핑됩니다.
|
|
|
|
|
</p>
|
2026-01-09 15:32:02 +09:00
|
|
|
|
|
|
|
|
{!config.action?.dataTransfer?.sourceTable || !config.action?.dataTransfer?.targetTable ? (
|
2025-11-28 14:56:11 +09:00
|
|
|
<div className="rounded-md border border-dashed p-3 text-center">
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground text-xs">먼저 소스 테이블과 타겟 테이블을 선택하세요.</p>
|
2025-11-28 14:56:11 +09:00
|
|
|
</div>
|
|
|
|
|
) : (config.action?.dataTransfer?.mappingRules || []).length === 0 ? (
|
|
|
|
|
<div className="rounded-md border border-dashed p-3 text-center">
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground text-xs">
|
2025-11-28 14:56:11 +09:00
|
|
|
매핑 규칙이 없습니다. 같은 이름의 필드로 자동 매핑됩니다.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
{(config.action?.dataTransfer?.mappingRules || []).map((rule: any, index: number) => (
|
2026-01-09 15:32:02 +09:00
|
|
|
<div key={index} className="bg-background flex items-center gap-2 rounded-md border p-2">
|
2025-11-28 14:56:11 +09:00
|
|
|
{/* 소스 필드 선택 (Combobox) */}
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
<Popover
|
|
|
|
|
open={mappingSourcePopoverOpen[index] || false}
|
|
|
|
|
onOpenChange={(open) => setMappingSourcePopoverOpen((prev) => ({ ...prev, [index]: open }))}
|
|
|
|
|
>
|
|
|
|
|
<PopoverTrigger asChild>
|
2026-01-09 15:32:02 +09:00
|
|
|
<Button variant="outline" role="combobox" className="h-7 w-full justify-between text-xs">
|
2025-11-28 14:56:11 +09:00
|
|
|
{rule.sourceField
|
2026-01-09 15:32:02 +09:00
|
|
|
? mappingSourceColumns.find((c) => c.name === rule.sourceField)?.label ||
|
|
|
|
|
rule.sourceField
|
2025-11-28 14:56:11 +09:00
|
|
|
: "소스 필드"}
|
|
|
|
|
<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"
|
|
|
|
|
value={mappingSourceSearch[index] || ""}
|
2026-01-09 15:32:02 +09:00
|
|
|
onValueChange={(value) =>
|
|
|
|
|
setMappingSourceSearch((prev) => ({ ...prev, [index]: value }))
|
|
|
|
|
}
|
2025-11-28 14:56:11 +09:00
|
|
|
/>
|
|
|
|
|
<CommandList>
|
2026-01-09 15:32:02 +09:00
|
|
|
<CommandEmpty className="py-2 text-center text-xs">
|
|
|
|
|
컬럼을 찾을 수 없습니다
|
|
|
|
|
</CommandEmpty>
|
2025-11-28 14:56:11 +09:00
|
|
|
<CommandGroup>
|
|
|
|
|
{mappingSourceColumns.map((col) => (
|
|
|
|
|
<CommandItem
|
|
|
|
|
key={col.name}
|
|
|
|
|
value={`${col.label} ${col.name}`}
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
const rules = [...(config.action?.dataTransfer?.mappingRules || [])];
|
|
|
|
|
rules[index] = { ...rules[index], sourceField: col.name };
|
|
|
|
|
onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", rules);
|
|
|
|
|
setMappingSourcePopoverOpen((prev) => ({ ...prev, [index]: false }));
|
|
|
|
|
}}
|
|
|
|
|
className="text-xs"
|
|
|
|
|
>
|
|
|
|
|
<Check
|
|
|
|
|
className={cn(
|
|
|
|
|
"mr-2 h-3 w-3",
|
2026-01-09 15:32:02 +09:00
|
|
|
rule.sourceField === col.name ? "opacity-100" : "opacity-0",
|
2025-11-28 14:56:11 +09:00
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
<span>{col.label}</span>
|
|
|
|
|
{col.label !== col.name && (
|
2026-01-09 15:32:02 +09:00
|
|
|
<span className="text-muted-foreground ml-1">({col.name})</span>
|
2025-11-28 14:56:11 +09:00
|
|
|
)}
|
|
|
|
|
</CommandItem>
|
|
|
|
|
))}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
</CommandList>
|
|
|
|
|
</Command>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
|
|
|
|
</div>
|
2026-01-09 15:32:02 +09:00
|
|
|
|
|
|
|
|
<span className="text-muted-foreground text-xs">→</span>
|
|
|
|
|
|
2025-11-28 14:56:11 +09:00
|
|
|
{/* 타겟 필드 선택 (Combobox) */}
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
<Popover
|
|
|
|
|
open={mappingTargetPopoverOpen[index] || false}
|
|
|
|
|
onOpenChange={(open) => setMappingTargetPopoverOpen((prev) => ({ ...prev, [index]: open }))}
|
|
|
|
|
>
|
|
|
|
|
<PopoverTrigger asChild>
|
2026-01-09 15:32:02 +09:00
|
|
|
<Button variant="outline" role="combobox" className="h-7 w-full justify-between text-xs">
|
2025-11-28 14:56:11 +09:00
|
|
|
{rule.targetField
|
2026-01-09 15:32:02 +09:00
|
|
|
? mappingTargetColumns.find((c) => c.name === rule.targetField)?.label ||
|
|
|
|
|
rule.targetField
|
2025-11-28 14:56:11 +09:00
|
|
|
: "타겟 필드"}
|
|
|
|
|
<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"
|
|
|
|
|
value={mappingTargetSearch[index] || ""}
|
2026-01-09 15:32:02 +09:00
|
|
|
onValueChange={(value) =>
|
|
|
|
|
setMappingTargetSearch((prev) => ({ ...prev, [index]: value }))
|
|
|
|
|
}
|
2025-11-28 14:56:11 +09:00
|
|
|
/>
|
|
|
|
|
<CommandList>
|
2026-01-09 15:32:02 +09:00
|
|
|
<CommandEmpty className="py-2 text-center text-xs">
|
|
|
|
|
컬럼을 찾을 수 없습니다
|
|
|
|
|
</CommandEmpty>
|
2025-11-28 14:56:11 +09:00
|
|
|
<CommandGroup>
|
|
|
|
|
{mappingTargetColumns.map((col) => (
|
|
|
|
|
<CommandItem
|
|
|
|
|
key={col.name}
|
|
|
|
|
value={`${col.label} ${col.name}`}
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
const rules = [...(config.action?.dataTransfer?.mappingRules || [])];
|
|
|
|
|
rules[index] = { ...rules[index], targetField: col.name };
|
|
|
|
|
onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", rules);
|
|
|
|
|
setMappingTargetPopoverOpen((prev) => ({ ...prev, [index]: false }));
|
|
|
|
|
}}
|
|
|
|
|
className="text-xs"
|
|
|
|
|
>
|
|
|
|
|
<Check
|
|
|
|
|
className={cn(
|
|
|
|
|
"mr-2 h-3 w-3",
|
2026-01-09 15:32:02 +09:00
|
|
|
rule.targetField === col.name ? "opacity-100" : "opacity-0",
|
2025-11-28 14:56:11 +09:00
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
<span>{col.label}</span>
|
|
|
|
|
{col.label !== col.name && (
|
2026-01-09 15:32:02 +09:00
|
|
|
<span className="text-muted-foreground ml-1">({col.name})</span>
|
2025-11-28 14:56:11 +09:00
|
|
|
)}
|
|
|
|
|
</CommandItem>
|
|
|
|
|
))}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
</CommandList>
|
|
|
|
|
</Command>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
|
|
|
|
</div>
|
2026-01-09 15:32:02 +09:00
|
|
|
|
2025-11-28 14:56:11 +09:00
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
2026-01-09 15:32:02 +09:00
|
|
|
className="text-destructive hover:bg-destructive/10 h-7 w-7"
|
2025-11-28 14:56:11 +09:00
|
|
|
onClick={() => {
|
|
|
|
|
const rules = [...(config.action?.dataTransfer?.mappingRules || [])];
|
|
|
|
|
rules.splice(index, 1);
|
|
|
|
|
onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", rules);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<X className="h-3 w-3" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-11-27 12:54:57 +09:00
|
|
|
<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 />
|
2025-11-28 14:56:11 +09:00
|
|
|
2. 필드 매핑 규칙을 설정합니다 (예: 품번 → 품목코드)
|
2025-11-27 12:54:57 +09:00
|
|
|
<br />
|
2025-11-28 14:56:11 +09:00
|
|
|
3. 이 버튼을 클릭하면 매핑된 데이터가 타겟으로 전달됩니다
|
2025-11-27 12:54:57 +09:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-12-16 14:38:03 +09:00
|
|
|
{/* 🆕 즉시 저장(quickInsert) 액션 설정 */}
|
|
|
|
|
{component.componentConfig?.action?.type === "quickInsert" && (
|
|
|
|
|
<QuickInsertConfigSection
|
|
|
|
|
component={component}
|
|
|
|
|
onUpdateProperty={onUpdateProperty}
|
|
|
|
|
allComponents={allComponents}
|
|
|
|
|
currentTableName={currentTableName}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-12-30 14:11:42 +09:00
|
|
|
{/* 🆕 행 선택 시에만 활성화 설정 */}
|
2026-01-09 15:32:02 +09:00
|
|
|
<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">
|
2025-12-30 14:11:42 +09:00
|
|
|
테이블 리스트나 분할 패널에서 데이터가 선택되었을 때만 버튼을 활성화합니다.
|
|
|
|
|
</p>
|
2026-01-09 15:32:02 +09:00
|
|
|
|
2025-12-30 14:11:42 +09:00
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div className="space-y-0.5">
|
|
|
|
|
<Label>행 선택 시에만 활성화</Label>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground text-xs">체크하면 테이블에서 행을 선택해야만 버튼이 활성화됩니다.</p>
|
2025-12-30 14:11:42 +09:00
|
|
|
</div>
|
|
|
|
|
<Switch
|
|
|
|
|
checked={component.componentConfig?.action?.requireRowSelection || false}
|
|
|
|
|
onCheckedChange={(checked) => {
|
|
|
|
|
onUpdateProperty("componentConfig.action.requireRowSelection", checked);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{component.componentConfig?.action?.requireRowSelection && (
|
2026-01-09 15:32:02 +09:00
|
|
|
<div className="border-primary/20 space-y-3 border-l-2 pl-4">
|
2025-12-30 14:11:42 +09:00
|
|
|
<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>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground mt-1 text-xs">
|
2025-12-30 14:11:42 +09:00
|
|
|
자동 감지: 테이블, 분할 패널, 플로우 위젯 중 선택된 항목이 있으면 활성화
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div className="space-y-0.5">
|
|
|
|
|
<Label>다중 선택 허용</Label>
|
2026-01-09 15:32:02 +09:00
|
|
|
<p className="text-muted-foreground text-xs">
|
2025-12-30 14:11:42 +09:00
|
|
|
여러 행이 선택되어도 활성화 (기본: 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>
|
|
|
|
|
|
2026-01-09 15:46:09 +09:00
|
|
|
{/* 제어 기능 섹션 - 엑셀 업로드가 아닐 때만 표시 */}
|
|
|
|
|
{(component.componentConfig?.action?.type || "save") !== "excel_upload" && (
|
|
|
|
|
<div className="border-border mt-8 border-t pt-6">
|
|
|
|
|
<ImprovedButtonControlConfigPanel component={component} onUpdateProperty={onUpdateProperty} />
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-10-23 18:23:01 +09:00
|
|
|
|
2025-10-28 18:41:45 +09:00
|
|
|
{/* 🆕 플로우 단계별 표시 제어 섹션 (플로우 위젯이 있을 때만 표시) */}
|
|
|
|
|
{hasFlowWidget && (
|
2026-01-09 15:32:02 +09:00
|
|
|
<div className="border-border mt-8 border-t pt-6">
|
2025-10-28 18:41:45 +09:00
|
|
|
<FlowVisibilityConfigPanel
|
|
|
|
|
component={component}
|
|
|
|
|
allComponents={allComponents}
|
|
|
|
|
onUpdateProperty={onUpdateProperty}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-09-09 14:29:04 +09:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
2026-01-09 15:32:02 +09:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 마스터-디테일 엑셀 업로드 설정 컴포넌트
|
|
|
|
|
* 분할 패널 + 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>
|
|
|
|
|
|
2026-01-09 17:56:48 +09:00
|
|
|
{/* 마스터 키 자동 생성 안내 */}
|
2026-01-09 15:32:02 +09:00
|
|
|
{relationInfo && (
|
2026-01-09 17:56:48 +09:00
|
|
|
<p className="text-muted-foreground border-t pt-2 text-xs">
|
|
|
|
|
마스터 테이블의 <strong>{relationInfo.masterKeyColumn}</strong> 값은 위에서 설정한 채번 규칙으로 자동
|
|
|
|
|
생성됩니다.
|
|
|
|
|
</p>
|
2026-01-09 15:32:02 +09:00
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 마스터 필드 선택 - 사용자가 엑셀 업로드 시 입력할 필드 */}
|
|
|
|
|
{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>
|
|
|
|
|
)}
|
2026-01-09 17:56:48 +09:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
2026-01-09 15:46:09 +09:00
|
|
|
|
2026-01-09 17:56:48 +09:00
|
|
|
/**
|
|
|
|
|
* 엑셀 업로드 채번 규칙 설정 (단일 테이블/마스터-디테일 모두 사용 가능)
|
|
|
|
|
*/
|
|
|
|
|
const ExcelNumberingRuleConfig: React.FC<{
|
|
|
|
|
config: { numberingRuleId?: string; numberingTargetColumn?: string };
|
|
|
|
|
updateConfig: (updates: { numberingRuleId?: string; numberingTargetColumn?: string }) => void;
|
|
|
|
|
tableName?: string; // 단일 테이블인 경우 테이블명
|
|
|
|
|
hasSplitPanel?: boolean; // 분할 패널 여부 (마스터-디테일)
|
|
|
|
|
}> = ({ config, updateConfig, tableName, hasSplitPanel }) => {
|
|
|
|
|
const [numberingRules, setNumberingRules] = useState<any[]>([]);
|
|
|
|
|
const [ruleSelectOpen, setRuleSelectOpen] = useState(false);
|
|
|
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
|
const [tableColumns, setTableColumns] = useState<Array<{ columnName: string; columnLabel: string }>>([]);
|
|
|
|
|
const [columnsLoading, setColumnsLoading] = useState(false);
|
|
|
|
|
|
|
|
|
|
// 채번 규칙 목록 로드
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const loadNumberingRules = async () => {
|
|
|
|
|
setIsLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const { apiClient } = await import("@/lib/api/client");
|
|
|
|
|
const response = await apiClient.get("/numbering-rules");
|
|
|
|
|
if (response.data?.success && response.data?.data) {
|
|
|
|
|
setNumberingRules(response.data.data);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("채번 규칙 목록 로드 실패:", error);
|
|
|
|
|
} finally {
|
|
|
|
|
setIsLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
loadNumberingRules();
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 단일 테이블인 경우 컬럼 목록 로드
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!tableName || hasSplitPanel) {
|
|
|
|
|
setTableColumns([]);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const loadColumns = async () => {
|
|
|
|
|
setColumnsLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const { apiClient } = await import("@/lib/api/client");
|
|
|
|
|
const response = await apiClient.get(`/table-management/tables/${tableName}/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,
|
|
|
|
|
}));
|
|
|
|
|
setTableColumns(cols);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("컬럼 목록 로드 실패:", error);
|
|
|
|
|
} finally {
|
|
|
|
|
setColumnsLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
loadColumns();
|
|
|
|
|
}, [tableName, hasSplitPanel]);
|
|
|
|
|
|
|
|
|
|
const selectedRule = numberingRules.find((r) => String(r.rule_id || r.ruleId) === String(config.numberingRuleId));
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="border-t pt-3">
|
|
|
|
|
<Label className="text-xs">채번 규칙</Label>
|
|
|
|
|
<p className="text-muted-foreground mb-2 text-xs">
|
|
|
|
|
업로드 시 자동으로 생성할 코드/번호의 채번 규칙을 선택하세요.
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
<Popover open={ruleSelectOpen} onOpenChange={setRuleSelectOpen}>
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
role="combobox"
|
|
|
|
|
aria-expanded={ruleSelectOpen}
|
|
|
|
|
className="h-8 w-full justify-between text-xs"
|
|
|
|
|
disabled={isLoading}
|
|
|
|
|
>
|
|
|
|
|
{isLoading ? "로딩 중..." : selectedRule?.rule_name || selectedRule?.ruleName || "채번 없음"}
|
|
|
|
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
|
|
|
</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>
|
|
|
|
|
<CommandItem
|
|
|
|
|
value="__none__"
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
updateConfig({ numberingRuleId: undefined, numberingTargetColumn: undefined });
|
|
|
|
|
setRuleSelectOpen(false);
|
|
|
|
|
}}
|
|
|
|
|
className="text-xs"
|
|
|
|
|
>
|
|
|
|
|
<Check className={cn("mr-2 h-4 w-4", !config.numberingRuleId ? "opacity-100" : "opacity-0")} />
|
|
|
|
|
채번 없음
|
|
|
|
|
</CommandItem>
|
|
|
|
|
{numberingRules.map((rule, idx) => {
|
|
|
|
|
const ruleId = String(rule.rule_id || rule.ruleId || `rule-${idx}`);
|
|
|
|
|
const ruleName = rule.rule_name || rule.ruleName || "(이름 없음)";
|
|
|
|
|
return (
|
|
|
|
|
<CommandItem
|
|
|
|
|
key={ruleId}
|
|
|
|
|
value={ruleName}
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
updateConfig({ numberingRuleId: ruleId });
|
|
|
|
|
setRuleSelectOpen(false);
|
|
|
|
|
}}
|
|
|
|
|
className="text-xs"
|
|
|
|
|
>
|
|
|
|
|
<Check
|
|
|
|
|
className={cn(
|
|
|
|
|
"mr-2 h-4 w-4",
|
|
|
|
|
String(config.numberingRuleId) === ruleId ? "opacity-100" : "opacity-0",
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
{ruleName}
|
|
|
|
|
</CommandItem>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
</CommandList>
|
|
|
|
|
</Command>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
|
|
|
|
|
|
|
|
|
{/* 단일 테이블이고 채번 규칙이 선택된 경우, 적용할 컬럼 선택 */}
|
|
|
|
|
{config.numberingRuleId && !hasSplitPanel && tableName && (
|
|
|
|
|
<div className="mt-2">
|
|
|
|
|
<Label className="text-xs">채번 적용 컬럼</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={config.numberingTargetColumn || ""}
|
|
|
|
|
onValueChange={(value) => updateConfig({ numberingTargetColumn: value || undefined })}
|
|
|
|
|
disabled={columnsLoading}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-8 text-xs">
|
|
|
|
|
<SelectValue placeholder={columnsLoading ? "로딩 중..." : "컬럼 선택"} />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{tableColumns.map((col) => (
|
|
|
|
|
<SelectItem key={col.columnName} value={col.columnName}>
|
|
|
|
|
{col.columnLabel} ({col.columnName})
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
<p className="text-muted-foreground mt-1 text-xs">채번 값이 입력될 컬럼을 선택하세요.</p>
|
2026-01-09 15:46:09 +09:00
|
|
|
</div>
|
|
|
|
|
)}
|
2026-01-09 17:56:48 +09:00
|
|
|
|
|
|
|
|
{/* 분할 패널인 경우 안내 메시지 */}
|
|
|
|
|
{config.numberingRuleId && hasSplitPanel && (
|
|
|
|
|
<p className="text-muted-foreground mt-1 text-xs">마스터-디테일 구조에서는 마스터 키 컬럼에 자동 적용됩니다.</p>
|
|
|
|
|
)}
|
2026-01-09 15:46:09 +09:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-09 17:56:48 +09:00
|
|
|
* 엑셀 업로드 후 제어 실행 설정 (단일 테이블/마스터-디테일 모두 사용 가능)
|
2026-01-09 15:46:09 +09:00
|
|
|
*/
|
2026-01-09 17:56:48 +09:00
|
|
|
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 }>>([]);
|
2026-01-09 15:46:09 +09:00
|
|
|
const [flowSelectOpen, setFlowSelectOpen] = useState(false);
|
|
|
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
|
|
2026-01-09 17:56:48 +09:00
|
|
|
const selectedFlows = config.afterUploadFlows || [];
|
2026-01-09 15:46:09 +09:00
|
|
|
|
|
|
|
|
// 노드 플로우 목록 로드
|
|
|
|
|
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 }];
|
2026-01-09 17:56:48 +09:00
|
|
|
updateConfig({ afterUploadFlows: newFlows });
|
2026-01-09 15:46:09 +09:00
|
|
|
setFlowSelectOpen(false);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const removeFlow = (flowId: string) => {
|
2026-01-09 17:56:48 +09:00
|
|
|
const newFlows = selectedFlows.filter((f) => f.flowId !== flowId).map((f, idx) => ({ ...f, order: idx + 1 }));
|
|
|
|
|
updateConfig({ afterUploadFlows: newFlows });
|
2026-01-09 15:46:09 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const moveUp = (index: number) => {
|
|
|
|
|
if (index === 0) return;
|
|
|
|
|
const newFlows = [...selectedFlows];
|
|
|
|
|
[newFlows[index - 1], newFlows[index]] = [newFlows[index], newFlows[index - 1]];
|
2026-01-09 17:56:48 +09:00
|
|
|
updateConfig({ afterUploadFlows: newFlows.map((f, idx) => ({ ...f, order: idx + 1 })) });
|
2026-01-09 15:46:09 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const moveDown = (index: number) => {
|
|
|
|
|
if (index === selectedFlows.length - 1) return;
|
|
|
|
|
const newFlows = [...selectedFlows];
|
|
|
|
|
[newFlows[index], newFlows[index + 1]] = [newFlows[index + 1], newFlows[index]];
|
2026-01-09 17:56:48 +09:00
|
|
|
updateConfig({ afterUploadFlows: newFlows.map((f, idx) => ({ ...f, order: idx + 1 })) });
|
2026-01-09 15:46:09 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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>
|
2026-01-09 17:56:48 +09:00
|
|
|
<p className="text-muted-foreground mb-2 text-xs">엑셀 업로드 완료 후 순서대로 실행할 제어를 추가하세요.</p>
|
2026-01-09 15:46:09 +09:00
|
|
|
|
|
|
|
|
{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>
|
2026-01-09 17:56:48 +09:00
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="h-5 w-5 p-0 text-red-500"
|
|
|
|
|
onClick={() => removeFlow(selected.flowId)}
|
|
|
|
|
>
|
2026-01-09 15:46:09 +09:00
|
|
|
<X className="h-3 w-3" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
2026-01-09 15:32:02 +09:00
|
|
|
</div>
|
|
|
|
|
)}
|
2026-01-09 15:46:09 +09:00
|
|
|
|
|
|
|
|
<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>
|
|
|
|
|
)}
|
2026-01-09 15:32:02 +09:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 엑셀 업로드 설정 섹션 컴포넌트
|
|
|
|
|
* 마스터-디테일 설정은 분할 패널 자동 감지
|
|
|
|
|
*/
|
|
|
|
|
const ExcelUploadConfigSection: React.FC<{
|
|
|
|
|
config: any;
|
|
|
|
|
onUpdateProperty: (path: string, value: any) => void;
|
|
|
|
|
allComponents: ComponentData[];
|
2026-01-09 17:56:48 +09:00
|
|
|
currentTableName?: string; // 현재 화면의 테이블명 (ButtonConfigPanel에서 전달)
|
|
|
|
|
}> = ({ config, onUpdateProperty, allComponents, currentTableName: propTableName }) => {
|
|
|
|
|
// 엑셀 업로드 설정 상태 관리
|
|
|
|
|
const [excelUploadConfig, setExcelUploadConfig] = useState<{
|
|
|
|
|
numberingRuleId?: string;
|
|
|
|
|
numberingTargetColumn?: string;
|
|
|
|
|
afterUploadFlows?: Array<{ flowId: string; order: number }>;
|
|
|
|
|
}>({
|
|
|
|
|
numberingRuleId: config.action?.excelNumberingRuleId,
|
|
|
|
|
numberingTargetColumn: config.action?.excelNumberingTargetColumn,
|
|
|
|
|
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.numberingRuleId !== undefined) {
|
|
|
|
|
onUpdateProperty("componentConfig.action.excelNumberingRuleId", updates.numberingRuleId);
|
|
|
|
|
}
|
|
|
|
|
if (updates.numberingTargetColumn !== undefined) {
|
|
|
|
|
onUpdateProperty("componentConfig.action.excelNumberingTargetColumn", updates.numberingTargetColumn);
|
|
|
|
|
}
|
|
|
|
|
if (updates.afterUploadFlows !== undefined) {
|
|
|
|
|
onUpdateProperty("componentConfig.action.excelAfterUploadFlows", updates.afterUploadFlows);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// config 변경 시 로컬 상태 동기화
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setExcelUploadConfig({
|
|
|
|
|
numberingRuleId: config.action?.excelNumberingRuleId,
|
|
|
|
|
numberingTargetColumn: config.action?.excelNumberingTargetColumn,
|
|
|
|
|
afterUploadFlows: config.action?.excelAfterUploadFlows || [],
|
|
|
|
|
});
|
|
|
|
|
}, [
|
|
|
|
|
config.action?.excelNumberingRuleId,
|
|
|
|
|
config.action?.excelNumberingTargetColumn,
|
|
|
|
|
config.action?.excelAfterUploadFlows,
|
|
|
|
|
]);
|
|
|
|
|
|
2026-01-09 15:32:02 +09:00
|
|
|
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>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-01-09 17:56:48 +09:00
|
|
|
{/* 채번 규칙 설정 (항상 표시) */}
|
|
|
|
|
<ExcelNumberingRuleConfig
|
|
|
|
|
config={excelUploadConfig}
|
|
|
|
|
updateConfig={updateExcelUploadConfig}
|
|
|
|
|
tableName={singleTableName}
|
|
|
|
|
hasSplitPanel={hasSplitPanel}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{/* 업로드 후 제어 실행 (항상 표시) */}
|
|
|
|
|
<ExcelAfterUploadControlConfig config={excelUploadConfig} updateConfig={updateExcelUploadConfig} />
|
|
|
|
|
|
2026-01-09 15:32:02 +09:00
|
|
|
{/* 마스터-디테일 설정 (분할 패널 자동 감지) */}
|
|
|
|
|
<MasterDetailExcelUploadConfig
|
|
|
|
|
config={config}
|
|
|
|
|
onUpdateProperty={onUpdateProperty}
|
|
|
|
|
allComponents={allComponents}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|