ERP-node/frontend/components/screen/config-panels/ButtonConfigPanel.tsx

3102 lines
148 KiB
TypeScript

"use client";
import React, { useState, useEffect, useMemo } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Check, ChevronsUpDown, Search, Plus, X, ChevronUp, ChevronDown, Type, Database } from "lucide-react";
import { cn } from "@/lib/utils";
import { ComponentData } from "@/types/screen";
import { apiClient } from "@/lib/api/client";
import { ButtonDataflowConfigPanel } from "./ButtonDataflowConfigPanel";
import { ImprovedButtonControlConfigPanel } from "./ImprovedButtonControlConfigPanel";
import { FlowVisibilityConfigPanel } from "./FlowVisibilityConfigPanel";
import { QuickInsertConfigSection } from "./QuickInsertConfigSection";
// 🆕 제목 블록 타입
interface TitleBlock {
id: string;
type: "text" | "field";
value: string; // text: 텍스트 내용, field: 컬럼명
tableName?: string; // field일 때 테이블명
label?: string; // field일 때 표시용 라벨
}
interface ButtonConfigPanelProps {
component: ComponentData;
onUpdateProperty: (path: string, value: any) => void;
allComponents?: ComponentData[]; // 🆕 플로우 위젯 감지용
currentTableName?: string; // 현재 화면의 테이블명 (자동 감지용)
currentScreenCompanyCode?: string; // 현재 편집 중인 화면의 회사 코드
}
interface ScreenOption {
id: number;
name: string;
description?: string;
}
export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
component,
onUpdateProperty,
allComponents = [], // 🆕 기본값 빈 배열
currentTableName, // 현재 화면의 테이블명
currentScreenCompanyCode, // 현재 편집 중인 화면의 회사 코드
}) => {
// 🔧 component에서 직접 읽기 (useMemo 제거)
const config = component.componentConfig || {};
const currentAction = component.componentConfig?.action || {};
// 로컬 상태 관리 (실시간 입력 반영)
const [localInputs, setLocalInputs] = useState({
text: config.text !== undefined ? config.text : "버튼",
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 || ""),
});
const [screens, setScreens] = useState<ScreenOption[]>([]);
const [screensLoading, setScreensLoading] = useState(false);
const [modalScreenOpen, setModalScreenOpen] = useState(false);
const [navScreenOpen, setNavScreenOpen] = useState(false);
const [modalSearchTerm, setModalSearchTerm] = useState("");
const [navSearchTerm, setNavSearchTerm] = useState("");
// 테이블 컬럼 목록 상태
const [tableColumns, setTableColumns] = useState<string[]>([]);
const [columnsLoading, setColumnsLoading] = useState(false);
const [displayColumnOpen, setDisplayColumnOpen] = useState(false);
const [displayColumnSearch, setDisplayColumnSearch] = useState("");
// 🆕 제목 블록 빌더 상태
const [titleBlocks, setTitleBlocks] = useState<TitleBlock[]>([]);
const [availableTables, setAvailableTables] = useState<Array<{ name: string; label: string }>>([]); // 시스템의 모든 테이블 목록
const [tableColumnsMap, setTableColumnsMap] = useState<Record<string, Array<{ name: string; label: string }>>>({});
const [blockTableSearches, setBlockTableSearches] = useState<Record<string, string>>({}); // 블록별 테이블 검색어
const [blockColumnSearches, setBlockColumnSearches] = useState<Record<string, string>>({}); // 블록별 컬럼 검색어
const [blockTablePopoverOpen, setBlockTablePopoverOpen] = useState<Record<string, boolean>>({}); // 블록별 테이블 Popover 열림 상태
const [blockColumnPopoverOpen, setBlockColumnPopoverOpen] = useState<Record<string, boolean>>({}); // 블록별 컬럼 Popover 열림 상태
// 🆕 데이터 전달 필드 매핑용 상태
const [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>>({});
// 🆕 openModalWithData 전용 필드 매핑 상태
const [modalSourceColumns, setModalSourceColumns] = useState<Array<{ name: string; label: string }>>([]);
const [modalTargetColumns, setModalTargetColumns] = useState<Array<{ name: string; label: string }>>([]);
const [modalSourcePopoverOpen, setModalSourcePopoverOpen] = useState<Record<number, boolean>>({});
const [modalTargetPopoverOpen, setModalTargetPopoverOpen] = useState<Record<number, boolean>>({});
const [modalSourceSearch, setModalSourceSearch] = useState<Record<number, string>>({});
const [modalTargetSearch, setModalTargetSearch] = useState<Record<number, string>>({});
// 🎯 플로우 위젯이 화면에 있는지 확인
const hasFlowWidget = useMemo(() => {
const found = allComponents.some((comp: any) => {
// ScreenDesigner에서 저장하는 componentType 속성 확인!
const compType = comp.componentType || comp.widgetType || "";
// "flow-widget" 체크
const isFlow = compType === "flow-widget" || compType?.toLowerCase().includes("flow");
if (isFlow) {
console.log("✅ 플로우 위젯 발견!", { id: comp.id, componentType: comp.componentType });
}
return isFlow;
});
console.log("🎯 플로우 위젯 존재 여부:", found);
return found;
}, [allComponents]);
// 컴포넌트 prop 변경 시 로컬 상태 동기화 (Input만)
useEffect(() => {
const latestConfig = component.componentConfig || {};
const latestAction = latestConfig.action || {};
setLocalInputs({
text: latestConfig.text !== undefined ? latestConfig.text : "버튼",
modalTitle: String(latestAction.modalTitle || ""),
modalDescription: String(latestAction.modalDescription || ""),
editModalTitle: String(latestAction.editModalTitle || ""),
editModalDescription: String(latestAction.editModalDescription || ""),
targetUrl: String(latestAction.targetUrl || ""),
});
// 🆕 제목 블록 초기화
if (latestAction.modalTitleBlocks && latestAction.modalTitleBlocks.length > 0) {
setTitleBlocks(latestAction.modalTitleBlocks);
} else {
// 기본값: 빈 배열
setTitleBlocks([]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [component.id]);
// 🆕 제목 블록 핸들러
const addTextBlock = () => {
const newBlock: TitleBlock = {
id: `text-${Date.now()}`,
type: "text",
value: "",
};
const updatedBlocks = [...titleBlocks, newBlock];
setTitleBlocks(updatedBlocks);
onUpdateProperty("componentConfig.action.modalTitleBlocks", updatedBlocks);
};
const addFieldBlock = () => {
const newBlock: TitleBlock = {
id: `field-${Date.now()}`,
type: "field",
value: "",
tableName: "",
label: "",
};
const updatedBlocks = [...titleBlocks, newBlock];
setTitleBlocks(updatedBlocks);
onUpdateProperty("componentConfig.action.modalTitleBlocks", updatedBlocks);
};
const updateBlock = (id: string, updates: Partial<TitleBlock>) => {
const updatedBlocks = titleBlocks.map((block) =>
block.id === id ? { ...block, ...updates } : block
);
setTitleBlocks(updatedBlocks);
onUpdateProperty("componentConfig.action.modalTitleBlocks", updatedBlocks);
};
const removeBlock = (id: string) => {
const updatedBlocks = titleBlocks.filter((block) => block.id !== id);
setTitleBlocks(updatedBlocks);
onUpdateProperty("componentConfig.action.modalTitleBlocks", updatedBlocks);
};
const moveBlockUp = (id: string) => {
const index = titleBlocks.findIndex((b) => b.id === id);
if (index <= 0) return;
const newBlocks = [...titleBlocks];
[newBlocks[index - 1], newBlocks[index]] = [newBlocks[index], newBlocks[index - 1]];
setTitleBlocks(newBlocks);
onUpdateProperty("componentConfig.action.modalTitleBlocks", newBlocks);
};
const moveBlockDown = (id: string) => {
const index = titleBlocks.findIndex((b) => b.id === id);
if (index < 0 || index >= titleBlocks.length - 1) return;
const newBlocks = [...titleBlocks];
[newBlocks[index], newBlocks[index + 1]] = [newBlocks[index + 1], newBlocks[index]];
setTitleBlocks(newBlocks);
onUpdateProperty("componentConfig.action.modalTitleBlocks", newBlocks);
};
// 🆕 제목 미리보기 생성
const generateTitlePreview = (): string => {
if (titleBlocks.length === 0) return "(제목 없음)";
return titleBlocks
.map((block) => {
if (block.type === "text") {
return block.value || "(텍스트)";
} else {
return block.label || block.value || "(필드)";
}
})
.join("");
};
// 🆕 시스템의 모든 테이블 목록 로드
useEffect(() => {
const fetchAllTables = async () => {
try {
const response = await apiClient.get("/table-management/tables");
if (response.data.success && response.data.data) {
const tables = response.data.data.map((table: any) => ({
name: table.tableName,
label: table.displayName || table.tableName,
}));
setAvailableTables(tables);
console.log(`✅ 전체 테이블 목록 로드 성공:`, tables.length);
}
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
}
};
fetchAllTables();
}, []);
// 🆕 특정 테이블의 컬럼 로드
const loadTableColumns = async (tableName: string) => {
if (!tableName || tableColumnsMap[tableName]) return;
try {
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
console.log(`📥 테이블 ${tableName} 컬럼 응답:`, response.data);
if (response.data.success) {
// data가 배열인지 확인
let columnData = response.data.data;
// data.columns 형태일 수도 있음
if (!Array.isArray(columnData) && columnData?.columns) {
columnData = columnData.columns;
}
// data.data 형태일 수도 있음
if (!Array.isArray(columnData) && columnData?.data) {
columnData = columnData.data;
}
if (Array.isArray(columnData)) {
const columns = columnData.map((col: any) => {
const name = col.name || col.columnName;
const label = col.displayName || col.label || col.columnLabel || name;
console.log(` - 컬럼: ${name} → "${label}"`);
return { name, label };
});
setTableColumnsMap((prev) => ({ ...prev, [tableName]: columns }));
console.log(`✅ 테이블 ${tableName} 컬럼 로드 성공:`, columns.length, "개");
} else {
console.error("❌ 컬럼 데이터가 배열이 아닙니다:", columnData);
}
}
} catch (error) {
console.error("컬럼 로드 실패:", error);
}
};
// 🆕 데이터 전달 소스/타겟 테이블 컬럼 로드
useEffect(() => {
const sourceTable = config.action?.dataTransfer?.sourceTable;
const targetTable = config.action?.dataTransfer?.targetTable;
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;
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);
}
}
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;
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);
}
}
};
loadColumns();
}, [config.action?.dataTransfer?.sourceTable, config.action?.dataTransfer?.targetTable]);
// 🆕 openModalWithData 소스/타겟 테이블 컬럼 로드
useEffect(() => {
const actionType = config.action?.type;
if (actionType !== "openModalWithData") return;
const loadModalMappingColumns = async () => {
// 소스 테이블: 현재 화면의 분할 패널 또는 테이블에서 감지
let sourceTableName: string | null = null;
console.log("[openModalWithData] 컬럼 로드 시작:", {
allComponentsCount: allComponents.length,
currentTableName,
targetScreenId: config.action?.targetScreenId,
});
// 모든 컴포넌트 타입 로그
allComponents.forEach((comp, idx) => {
const compType = comp.componentType || (comp as any).componentConfig?.type;
console.log(` [${idx}] componentType: ${compType}, tableName: ${(comp as any).componentConfig?.tableName || (comp as any).componentConfig?.leftPanel?.tableName || 'N/A'}`);
});
for (const comp of allComponents) {
const compType = comp.componentType || (comp as any).componentConfig?.type;
const compConfig = (comp as any).componentConfig || {};
// 분할 패널 타입들 (다양한 경로에서 테이블명 추출)
if (compType === "split-panel-layout" || compType === "screen-split-panel") {
sourceTableName = compConfig?.leftPanel?.tableName ||
compConfig?.leftTableName ||
compConfig?.tableName;
if (sourceTableName) {
console.log(`✅ [openModalWithData] split-panel-layout에서 소스 테이블 감지: ${sourceTableName}`);
break;
}
}
// split-panel-layout2 타입 (새로운 분할 패널)
if (compType === "split-panel-layout2") {
sourceTableName = compConfig?.leftPanel?.tableName ||
compConfig?.tableName ||
compConfig?.leftTableName;
if (sourceTableName) {
console.log(`✅ [openModalWithData] split-panel-layout2에서 소스 테이블 감지: ${sourceTableName}`);
break;
}
}
// 테이블 리스트 타입
if (compType === "table-list") {
sourceTableName = compConfig?.tableName;
if (sourceTableName) {
console.log(`✅ [openModalWithData] table-list에서 소스 테이블 감지: ${sourceTableName}`);
break;
}
}
// 🆕 모든 컴포넌트에서 tableName 찾기 (폴백)
if (!sourceTableName && compConfig?.tableName) {
sourceTableName = compConfig.tableName;
console.log(`✅ [openModalWithData] ${compType}에서 소스 테이블 감지 (폴백): ${sourceTableName}`);
break;
}
}
// 여전히 없으면 currentTableName 사용 (화면 레벨 테이블명)
if (!sourceTableName && currentTableName) {
sourceTableName = currentTableName;
console.log(`✅ [openModalWithData] currentTableName에서 소스 테이블 사용: ${sourceTableName}`);
}
if (!sourceTableName) {
console.warn("[openModalWithData] 소스 테이블을 찾을 수 없습니다.");
}
// 소스 테이블 컬럼 로드
if (sourceTableName) {
try {
const response = await apiClient.get(`/table-management/tables/${sourceTableName}/columns`);
if (response.data.success) {
let columnData = response.data.data;
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
if (Array.isArray(columnData)) {
const columns = columnData.map((col: any) => ({
name: col.name || col.columnName || col.column_name,
label: col.displayName || col.label || col.columnLabel || col.display_name || col.name || col.columnName || col.column_name,
}));
setModalSourceColumns(columns);
console.log(`✅ [openModalWithData] 소스 테이블(${sourceTableName}) 컬럼 로드 완료:`, columns.length);
}
}
} catch (error) {
console.error("소스 테이블 컬럼 로드 실패:", error);
}
}
// 타겟 화면의 테이블 컬럼 로드
const targetScreenId = config.action?.targetScreenId;
if (targetScreenId) {
try {
// 타겟 화면 정보 가져오기
const screenResponse = await apiClient.get(`/screen-management/screens/${targetScreenId}`);
console.log("[openModalWithData] 타겟 화면 응답:", screenResponse.data);
if (screenResponse.data.success && screenResponse.data.data) {
const targetTableName = screenResponse.data.data.tableName;
console.log("[openModalWithData] 타겟 화면 테이블명:", targetTableName);
if (targetTableName) {
const columnResponse = await apiClient.get(`/table-management/tables/${targetTableName}/columns`);
if (columnResponse.data.success) {
let columnData = columnResponse.data.data;
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
if (Array.isArray(columnData)) {
const columns = columnData.map((col: any) => ({
name: col.name || col.columnName || col.column_name,
label: col.displayName || col.label || col.columnLabel || col.display_name || col.name || col.columnName || col.column_name,
}));
setModalTargetColumns(columns);
console.log(`✅ [openModalWithData] 타겟 테이블(${targetTableName}) 컬럼 로드 완료:`, columns.length);
}
}
} else {
console.warn("[openModalWithData] 타겟 화면에 테이블명이 없습니다.");
}
}
} catch (error) {
console.error("타겟 화면 테이블 컬럼 로드 실패:", error);
}
} else {
console.warn("[openModalWithData] 타겟 화면 ID가 없습니다.");
}
};
loadModalMappingColumns();
}, [config.action?.type, config.action?.targetScreenId, allComponents, currentTableName]);
// 화면 목록 가져오기 (현재 편집 중인 화면의 회사 코드 기준)
useEffect(() => {
const fetchScreens = async () => {
try {
setScreensLoading(true);
// 현재 편집 중인 화면의 회사 코드 기준으로 화면 목록 조회
const params: any = {
page: 1,
size: 9999, // 매우 큰 값으로 설정하여 전체 목록 가져오기
};
// 현재 화면의 회사 코드가 있으면 필터링 파라미터로 전달
if (currentScreenCompanyCode) {
params.companyCode = currentScreenCompanyCode;
}
const response = await apiClient.get("/screen-management/screens", {
params,
});
if (response.data.success && Array.isArray(response.data.data)) {
const screenList = response.data.data.map((screen: any) => ({
id: screen.screenId,
name: screen.screenName,
description: screen.description,
}));
setScreens(screenList);
}
} catch (error) {
// console.error("❌ 화면 목록 로딩 실패:", error);
} finally {
setScreensLoading(false);
}
};
fetchScreens();
}, [currentScreenCompanyCode]);
// 테이블 컬럼 목록 가져오기 (테이블 이력 보기 액션일 때)
useEffect(() => {
const fetchTableColumns = async () => {
// 테이블 이력 보기 액션이 아니면 스킵
if (config.action?.type !== "view_table_history") {
return;
}
// 1. 수동 입력된 테이블명 우선
// 2. 없으면 현재 화면의 테이블명 사용
const tableName = config.action?.historyTableName || currentTableName;
// 테이블명이 없으면 스킵
if (!tableName) {
return;
}
try {
setColumnsLoading(true);
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`, {
params: {
page: 1,
size: 9999, // 전체 컬럼 가져오기
},
});
// API 응답 구조: { success, data: { columns: [...], total, page, totalPages } }
const columnData = response.data.data?.columns;
if (!columnData || !Array.isArray(columnData)) {
console.error("❌ 컬럼 데이터가 배열이 아닙니다:", columnData);
setTableColumns([]);
return;
}
if (response.data.success) {
// ID 컬럼과 날짜 관련 컬럼 제외
const filteredColumns = columnData
.filter((col: any) => {
const colName = col.columnName.toLowerCase();
const dataType = col.dataType?.toLowerCase() || "";
// ID 컬럼 제외 (id, _id로 끝나는 컬럼)
if (colName === "id" || colName.endsWith("_id")) {
return false;
}
// 날짜/시간 타입 제외 (데이터 타입 기준)
if (dataType.includes("date") || dataType.includes("time") || dataType.includes("timestamp")) {
return false;
}
// 날짜/시간 관련 컬럼명 제외 (컬럼명에 date, time, at 포함)
if (
colName.includes("date") ||
colName.includes("time") ||
colName.endsWith("_at") ||
colName.startsWith("created") ||
colName.startsWith("updated")
) {
return false;
}
return true;
})
.map((col: any) => col.columnName);
setTableColumns(filteredColumns);
}
} catch (error) {
console.error("❌ 테이블 컬럼 로딩 실패:", error);
} finally {
setColumnsLoading(false);
}
};
fetchTableColumns();
}, [config.action?.type, config.action?.historyTableName, currentTableName]);
// 검색 필터링 함수
const filterScreens = (searchTerm: string) => {
if (!searchTerm.trim()) return screens;
return screens.filter(
(screen) =>
screen.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(screen.description && screen.description.toLowerCase().includes(searchTerm.toLowerCase())),
);
};
// console.log("🔧 config-panels/ButtonConfigPanel 렌더링:", {
// component,
// config,
// action: config.action,
// actionType: config.action?.type,
// screensCount: screens.length,
// });
return (
<div className="space-y-4">
<div>
<Label htmlFor="button-text"> </Label>
<Input
id="button-text"
value={localInputs.text}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, text: newValue }));
onUpdateProperty("componentConfig.text", newValue);
}}
placeholder="버튼 텍스트를 입력하세요"
/>
</div>
<div>
<Label htmlFor="button-action"> </Label>
<Select
key={`action-${component.id}-${component.componentConfig?.action?.type || "save"}`}
value={component.componentConfig?.action?.type || "save"}
onValueChange={(value) => {
// 🔥 action.type 업데이트
onUpdateProperty("componentConfig.action.type", value);
// 🔥 색상 업데이트는 충분히 지연 (React 리렌더링 완료 후)
setTimeout(() => {
const newColor = value === "delete" ? "#ef4444" : "#212121";
onUpdateProperty("style.labelColor", newColor);
}, 100); // 0 → 100ms로 증가
}}
>
<SelectTrigger>
<SelectValue placeholder="버튼 액션 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="save"></SelectItem>
<SelectItem value="delete"></SelectItem>
<SelectItem value="edit"></SelectItem>
<SelectItem value="copy"> ( )</SelectItem>
<SelectItem value="navigate"> </SelectItem>
<SelectItem value="transferData"> </SelectItem>
<SelectItem value="openModalWithData"> + </SelectItem>
<SelectItem value="openRelatedModal"> </SelectItem>
<SelectItem value="modal"> </SelectItem>
<SelectItem value="quickInsert"> </SelectItem>
<SelectItem value="control"> </SelectItem>
<SelectItem value="view_table_history"> </SelectItem>
<SelectItem value="excel_download"> </SelectItem>
<SelectItem value="excel_upload"> </SelectItem>
<SelectItem value="barcode_scan"> </SelectItem>
<SelectItem value="code_merge"> </SelectItem>
{/* <SelectItem value="empty_vehicle">공차등록</SelectItem> */}
<SelectItem value="operation_control"> </SelectItem>
</SelectContent>
</Select>
</div>
{/* 모달 열기 액션 설정 */}
{(component.componentConfig?.action?.type || "save") === "modal" && (
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
<h4 className="text-sm font-medium text-foreground"> </h4>
<div>
<Label htmlFor="modal-title"> </Label>
<Input
id="modal-title"
placeholder="모달 제목을 입력하세요"
value={localInputs.modalTitle}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, modalTitle: newValue }));
onUpdateProperty("componentConfig.action.modalTitle", newValue);
}}
/>
</div>
<div>
<Label htmlFor="modal-description"> </Label>
<Input
id="modal-description"
placeholder="모달 설명을 입력하세요 (선택사항)"
value={localInputs.modalDescription}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, modalDescription: newValue }));
onUpdateProperty("componentConfig.action.modalDescription", newValue);
}}
/>
<p className="mt-1 text-xs text-muted-foreground"> </p>
</div>
<div>
<Label htmlFor="modal-size"> </Label>
<Select
value={component.componentConfig?.action?.modalSize || "md"}
onValueChange={(value) => {
onUpdateProperty("componentConfig.action.modalSize", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="모달 크기 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="sm"> (Small)</SelectItem>
<SelectItem value="md"> (Medium)</SelectItem>
<SelectItem value="lg"> (Large)</SelectItem>
<SelectItem value="xl"> (Extra Large)</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="target-screen-modal"> </Label>
<Popover open={modalScreenOpen} onOpenChange={setModalScreenOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={modalScreenOpen}
className="h-6 w-full justify-between px-2 py-0"
className="text-xs"
disabled={screensLoading}
>
{config.action?.targetScreenId
? screens.find((screen) => screen.id === parseInt(config.action?.targetScreenId))?.name ||
"화면을 선택하세요..."
: "화면을 선택하세요..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<div className="flex flex-col">
<div className="flex items-center border-b px-3 py-2">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<Input
placeholder="화면 검색..."
value={modalSearchTerm}
onChange={(e) => setModalSearchTerm(e.target.value)}
className="border-0 p-0 focus-visible:ring-0"
/>
</div>
<div className="max-h-[200px] overflow-auto">
{(() => {
const filteredScreens = filterScreens(modalSearchTerm);
if (screensLoading) {
return <div className="p-3 text-sm text-muted-foreground"> ...</div>;
}
if (filteredScreens.length === 0) {
return <div className="p-3 text-sm text-muted-foreground"> .</div>;
}
return filteredScreens.map((screen, index) => (
<div
key={`modal-screen-${screen.id}-${index}`}
className="flex cursor-pointer items-center px-3 py-2 hover:bg-muted"
onClick={() => {
onUpdateProperty("componentConfig.action.targetScreenId", screen.id);
setModalScreenOpen(false);
setModalSearchTerm("");
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
parseInt(config.action?.targetScreenId) === screen.id ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{screen.name}</span>
{screen.description && <span className="text-xs text-muted-foreground">{screen.description}</span>}
</div>
</div>
));
})()}
</div>
</div>
</PopoverContent>
</Popover>
</div>
</div>
)}
{/* 🆕 데이터 전달 + 모달 열기 액션 설정 */}
{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">
<h4 className="text-sm font-medium text-foreground"> + </h4>
<p className="text-xs text-muted-foreground">
TableList에서
</p>
<div>
<Label htmlFor="data-source-id">
ID <span className="text-primary">()</span>
</Label>
<Input
id="data-source-id"
placeholder="비워두면 자동으로 감지됩니다"
value={component.componentConfig?.action?.dataSourceId || ""}
onChange={(e) => {
onUpdateProperty("componentConfig.action.dataSourceId", e.target.value);
}}
/>
<p className="mt-1 text-xs text-primary font-medium">
TableList를
</p>
<p className="mt-1 text-xs text-muted-foreground">
감지: 현재 TableList <br/>
전달: 이전 <br/>
tableName으로 <br/>
설정: 필요시 (: item_info)
</p>
</div>
{/* 🆕 블록 기반 제목 빌더 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label> </Label>
<div className="flex gap-1">
<Button
type="button"
variant="outline"
size="sm"
onClick={addTextBlock}
className="h-6 text-xs"
>
<Type className="mr-1 h-3 w-3" />
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={addFieldBlock}
className="h-6 text-xs"
>
<Database className="mr-1 h-3 w-3" />
</Button>
</div>
</div>
{/* 블록 목록 */}
<div className="space-y-2">
{titleBlocks.length === 0 ? (
<div className="text-center py-4 text-xs text-muted-foreground border-2 border-dashed rounded">
</div>
) : (
titleBlocks.map((block, index) => (
<Card key={block.id} className="p-2">
<div className="flex items-start gap-2">
{/* 순서 변경 버튼 */}
<div className="flex flex-col gap-0.5">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => moveBlockUp(block.id)}
disabled={index === 0}
className="h-5 w-5 p-0"
>
<ChevronUp className="h-3 w-3" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => moveBlockDown(block.id)}
disabled={index === titleBlocks.length - 1}
className="h-5 w-5 p-0"
>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
{/* 블록 타입 표시 */}
<div className="flex-shrink-0 mt-1">
{block.type === "text" ? (
<Type className="h-4 w-4 text-blue-500" />
) : (
<Database className="h-4 w-4 text-green-500" />
)}
</div>
{/* 블록 설정 */}
<div className="flex-1 space-y-2">
{block.type === "text" ? (
// 텍스트 블록
<Input
placeholder="텍스트 입력 (예: 품목 상세정보 - )"
value={block.value}
onChange={(e) => updateBlock(block.id, { value: e.target.value })}
className="h-7 text-xs"
/>
) : (
// 필드 블록
<>
{/* 테이블 선택 - Combobox */}
<Popover
open={blockTablePopoverOpen[block.id] || false}
onOpenChange={(open) => {
setBlockTablePopoverOpen((prev) => ({ ...prev, [block.id]: open }));
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-7 w-full justify-between text-xs"
>
{block.tableName
? (availableTables.find((t) => t.name === block.tableName)?.label || block.tableName)
: "테이블 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command shouldFilter={false}>
<CommandInput
placeholder="테이블 검색 (라벨 또는 이름)..."
className="h-7 text-xs"
value={blockTableSearches[block.id] || ""}
onValueChange={(value) => {
setBlockTableSearches((prev) => ({ ...prev, [block.id]: value }));
}}
/>
<CommandList>
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup>
{availableTables
.filter((table) => {
const search = (blockTableSearches[block.id] || "").toLowerCase();
if (!search) return true;
return (
table.label.toLowerCase().includes(search) ||
table.name.toLowerCase().includes(search)
);
})
.map((table) => (
<CommandItem
key={table.name}
value={table.name}
onSelect={() => {
updateBlock(block.id, { tableName: table.name, value: "" });
loadTableColumns(table.name);
setBlockTableSearches((prev) => ({ ...prev, [block.id]: "" }));
setBlockTablePopoverOpen((prev) => ({ ...prev, [block.id]: false }));
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
block.tableName === table.name ? "opacity-100" : "opacity-0"
)}
/>
<span className="font-medium">{table.label}</span>
<span className="ml-2 text-[10px] text-muted-foreground">({table.name})</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{block.tableName && (
<>
{/* 컬럼 선택 - Combobox (라벨명 표시) */}
<Popover
open={blockColumnPopoverOpen[block.id] || false}
onOpenChange={(open) => {
setBlockColumnPopoverOpen((prev) => ({ ...prev, [block.id]: open }));
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-7 w-full justify-between text-xs"
>
{block.value
? (tableColumnsMap[block.tableName]?.find((c) => c.name === block.value)?.label || block.value)
: "컬럼 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command shouldFilter={false}>
<CommandInput
placeholder="컬럼 검색 (라벨 또는 이름)..."
className="h-7 text-xs"
value={blockColumnSearches[block.id] || ""}
onValueChange={(value) => {
setBlockColumnSearches((prev) => ({ ...prev, [block.id]: value }));
}}
/>
<CommandList>
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup>
{(tableColumnsMap[block.tableName] || [])
.filter((col) => {
const search = (blockColumnSearches[block.id] || "").toLowerCase();
if (!search) return true;
return (
col.label.toLowerCase().includes(search) ||
col.name.toLowerCase().includes(search)
);
})
.map((col) => (
<CommandItem
key={col.name}
value={col.name}
onSelect={() => {
updateBlock(block.id, {
value: col.name,
label: col.label,
});
setBlockColumnSearches((prev) => ({ ...prev, [block.id]: "" }));
setBlockColumnPopoverOpen((prev) => ({ ...prev, [block.id]: false }));
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
block.value === col.name ? "opacity-100" : "opacity-0"
)}
/>
<span className="font-medium">{col.label}</span>
<span className="ml-2 text-[10px] text-muted-foreground">({col.name})</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Input
placeholder="표시 라벨 (예: 품목명)"
value={block.label || ""}
onChange={(e) => updateBlock(block.id, { label: e.target.value })}
className="h-7 text-xs"
/>
</>
)}
</>
)}
</div>
{/* 삭제 버튼 */}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeBlock(block.id)}
className="h-7 w-7 p-0 text-red-500"
>
<X className="h-4 w-4" />
</Button>
</div>
</Card>
))
)}
</div>
{/* 미리보기 */}
{titleBlocks.length > 0 && (
<div className="mt-2 p-2 bg-muted rounded text-xs">
<span className="text-muted-foreground">: </span>
<span className="font-medium">{generateTitlePreview()}</span>
</div>
)}
<p className="text-[10px] text-muted-foreground">
텍스트: 고정 (: "품목 상세정보 - ")<br/>
필드: 이전 (: 품목명, )<br/>
: <br/>
"표시 라벨"
</p>
</div>
<div>
<Label htmlFor="modal-size-with-data"> </Label>
<Select
value={component.componentConfig?.action?.modalSize || "lg"}
onValueChange={(value) => {
onUpdateProperty("componentConfig.action.modalSize", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="모달 크기 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="sm"> (Small)</SelectItem>
<SelectItem value="md"> (Medium)</SelectItem>
<SelectItem value="lg"> (Large) - </SelectItem>
<SelectItem value="xl"> (Extra Large)</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="target-screen-with-data"> </Label>
<Popover open={modalScreenOpen} onOpenChange={setModalScreenOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={modalScreenOpen}
className="h-6 w-full justify-between px-2 py-0"
className="text-xs"
disabled={screensLoading}
>
{config.action?.targetScreenId
? screens.find((screen) => screen.id === parseInt(config.action?.targetScreenId))?.name ||
"화면을 선택하세요..."
: "화면을 선택하세요..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<div className="flex flex-col">
<div className="flex items-center border-b px-3 py-2">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<Input
placeholder="화면 검색..."
value={modalSearchTerm}
onChange={(e) => setModalSearchTerm(e.target.value)}
className="border-0 p-0 focus-visible:ring-0"
/>
</div>
<div className="max-h-[200px] overflow-auto">
{(() => {
const filteredScreens = filterScreens(modalSearchTerm);
if (screensLoading) {
return <div className="p-3 text-sm text-muted-foreground"> ...</div>;
}
if (filteredScreens.length === 0) {
return <div className="p-3 text-sm text-muted-foreground"> .</div>;
}
return filteredScreens.map((screen, index) => (
<div
key={`modal-data-screen-${screen.id}-${index}`}
className="flex cursor-pointer items-center px-3 py-2 hover:bg-muted"
onClick={() => {
onUpdateProperty("componentConfig.action.targetScreenId", screen.id);
setModalScreenOpen(false);
setModalSearchTerm("");
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
parseInt(config.action?.targetScreenId) === screen.id ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{screen.name}</span>
{screen.description && <span className="text-xs text-muted-foreground">{screen.description}</span>}
</div>
</div>
));
})()}
</div>
</div>
</PopoverContent>
</Popover>
<p className="mt-1 text-xs text-muted-foreground">
SelectedItemsDetailInput
</p>
</div>
{/* 🆕 필드 매핑 설정 (소스 컬럼 → 타겟 컬럼) */}
<div className="space-y-2 border-t pt-4">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium"> ()</Label>
<Button
type="button"
variant="outline"
size="sm"
className="h-6 text-[10px]"
onClick={() => {
const currentMappings = config.action?.fieldMappings || [];
const newMapping = { sourceField: "", targetField: "" };
onUpdateProperty("componentConfig.action.fieldMappings", [...currentMappings, newMapping]);
}}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<p className="text-[10px] text-muted-foreground">
.
<br />
: warehouse_code warehouse_id ( ID에 )
</p>
{/* 컬럼 로드 상태 표시 */}
{modalSourceColumns.length > 0 || modalTargetColumns.length > 0 ? (
<div className="text-[10px] text-muted-foreground bg-muted/50 p-2 rounded">
: {modalSourceColumns.length} / : {modalTargetColumns.length}
</div>
) : (
<div className="text-[10px] text-amber-600 bg-amber-50 p-2 rounded dark:bg-amber-950/20">
.
</div>
)}
{(config.action?.fieldMappings || []).length === 0 ? (
<div className="rounded-md border border-dashed p-3 text-center">
<p className="text-xs text-muted-foreground">
.
</p>
</div>
) : (
<div className="space-y-3">
{(config.action?.fieldMappings || []).map((mapping: any, index: number) => (
<div key={index} className="rounded-md border bg-background p-3 space-y-2">
{/* 소스 필드 선택 (Combobox) - 세로 배치 */}
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Popover
open={modalSourcePopoverOpen[index] || false}
onOpenChange={(open) => setModalSourcePopoverOpen((prev) => ({ ...prev, [index]: open }))}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-8 w-full justify-between text-xs"
>
<span className="truncate">
{mapping.sourceField
? modalSourceColumns.find((c) => c.name === mapping.sourceField)?.label || mapping.sourceField
: "소스 컬럼 선택"}
</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] max-w-[280px] p-0" align="start">
<Command>
<CommandInput
placeholder="컬럼 검색..."
className="h-8 text-xs"
value={modalSourceSearch[index] || ""}
onValueChange={(value) => setModalSourceSearch((prev) => ({ ...prev, [index]: value }))}
/>
<CommandList className="max-h-[200px]">
<CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty>
<CommandGroup>
{modalSourceColumns.map((col) => (
<CommandItem
key={col.name}
value={`${col.label} ${col.name}`}
onSelect={() => {
const mappings = [...(config.action?.fieldMappings || [])];
mappings[index] = { ...mappings[index], sourceField: col.name };
onUpdateProperty("componentConfig.action.fieldMappings", mappings);
setModalSourcePopoverOpen((prev) => ({ ...prev, [index]: false }));
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
mapping.sourceField === col.name ? "opacity-100" : "opacity-0"
)}
/>
<span className="truncate">{col.label}</span>
{col.label !== col.name && (
<span className="ml-1 text-muted-foreground truncate">({col.name})</span>
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 화살표 표시 */}
<div className="flex justify-center">
<span className="text-xs text-muted-foreground"></span>
</div>
{/* 타겟 필드 선택 (Combobox) - 세로 배치 */}
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Popover
open={modalTargetPopoverOpen[index] || false}
onOpenChange={(open) => setModalTargetPopoverOpen((prev) => ({ ...prev, [index]: open }))}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-8 w-full justify-between text-xs"
>
<span className="truncate">
{mapping.targetField
? modalTargetColumns.find((c) => c.name === mapping.targetField)?.label || mapping.targetField
: "타겟 컬럼 선택"}
</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] max-w-[280px] p-0" align="start">
<Command>
<CommandInput
placeholder="컬럼 검색..."
className="h-8 text-xs"
value={modalTargetSearch[index] || ""}
onValueChange={(value) => setModalTargetSearch((prev) => ({ ...prev, [index]: value }))}
/>
<CommandList className="max-h-[200px]">
<CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty>
<CommandGroup>
{modalTargetColumns.map((col) => (
<CommandItem
key={col.name}
value={`${col.label} ${col.name}`}
onSelect={() => {
const mappings = [...(config.action?.fieldMappings || [])];
mappings[index] = { ...mappings[index], targetField: col.name };
onUpdateProperty("componentConfig.action.fieldMappings", mappings);
setModalTargetPopoverOpen((prev) => ({ ...prev, [index]: false }));
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
mapping.targetField === col.name ? "opacity-100" : "opacity-0"
)}
/>
<span className="truncate">{col.label}</span>
{col.label !== col.name && (
<span className="ml-1 text-muted-foreground truncate">({col.name})</span>
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 삭제 버튼 */}
<div className="flex justify-end pt-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 text-[10px] text-destructive hover:bg-destructive/10"
onClick={() => {
const mappings = [...(config.action?.fieldMappings || [])];
mappings.splice(index, 1);
onUpdateProperty("componentConfig.action.fieldMappings", mappings);
}}
>
<X className="h-3 w-3 mr-1" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
{/* 수정 액션 설정 */}
{(component.componentConfig?.action?.type || "save") === "edit" && (
<div className="mt-4 space-y-4 rounded-lg border bg-success/10 p-4">
<h4 className="text-sm font-medium text-foreground"> </h4>
<div>
<Label htmlFor="edit-screen"> </Label>
<Popover open={modalScreenOpen} onOpenChange={setModalScreenOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={modalScreenOpen}
className="h-6 w-full justify-between px-2 py-0"
className="text-xs"
disabled={screensLoading}
>
{config.action?.targetScreenId
? screens.find((screen) => screen.id === parseInt(config.action?.targetScreenId))?.name ||
"수정 폼 화면을 선택하세요..."
: "수정 폼 화면을 선택하세요..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<div className="flex flex-col">
<div className="flex items-center border-b px-3 py-2">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<Input
placeholder="화면 검색..."
value={modalSearchTerm}
onChange={(e) => setModalSearchTerm(e.target.value)}
className="border-0 p-0 focus-visible:ring-0"
/>
</div>
<div className="max-h-[200px] overflow-auto">
{(() => {
const filteredScreens = filterScreens(modalSearchTerm);
if (screensLoading) {
return <div className="p-3 text-sm text-muted-foreground"> ...</div>;
}
if (filteredScreens.length === 0) {
return <div className="p-3 text-sm text-muted-foreground"> .</div>;
}
return filteredScreens.map((screen, index) => (
<div
key={`edit-screen-${screen.id}-${index}`}
className="flex cursor-pointer items-center px-3 py-2 hover:bg-muted"
onClick={() => {
onUpdateProperty("componentConfig.action.targetScreenId", screen.id);
setModalScreenOpen(false);
setModalSearchTerm("");
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
parseInt(config.action?.targetScreenId) === screen.id ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{screen.name}</span>
{screen.description && <span className="text-xs text-muted-foreground">{screen.description}</span>}
</div>
</div>
));
})()}
</div>
</div>
</PopoverContent>
</Popover>
<p className="mt-1 text-xs text-muted-foreground">
</p>
</div>
<div>
<Label htmlFor="edit-mode"> </Label>
<Select
value={component.componentConfig?.action?.editMode || "modal"}
onValueChange={(value) => {
onUpdateProperty("componentConfig.action.editMode", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="수정 모드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="modal"> </SelectItem>
<SelectItem value="navigate"> </SelectItem>
<SelectItem value="inline"> </SelectItem>
</SelectContent>
</Select>
</div>
{(component.componentConfig?.action?.editMode || "modal") === "modal" && (
<>
<div>
<Label htmlFor="edit-modal-title"> </Label>
<Input
id="edit-modal-title"
placeholder="모달 제목을 입력하세요 (예: 데이터 수정)"
value={localInputs.editModalTitle}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, editModalTitle: newValue }));
onUpdateProperty("componentConfig.action.editModalTitle", newValue);
onUpdateProperty("webTypeConfig.editModalTitle", newValue);
}}
/>
<p className="mt-1 text-xs text-muted-foreground"> </p>
</div>
<div>
<Label htmlFor="edit-modal-description"> </Label>
<Input
id="edit-modal-description"
placeholder="모달 설명을 입력하세요 (예: 선택한 데이터를 수정합니다)"
value={localInputs.editModalDescription}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, editModalDescription: newValue }));
onUpdateProperty("componentConfig.action.editModalDescription", newValue);
onUpdateProperty("webTypeConfig.editModalDescription", newValue);
}}
/>
<p className="mt-1 text-xs text-muted-foreground"> </p>
</div>
<div>
<Label htmlFor="edit-modal-size"> </Label>
<Select
value={component.componentConfig?.action?.modalSize || "md"}
onValueChange={(value) => {
onUpdateProperty("componentConfig.action.modalSize", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="모달 크기 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="sm"> (Small)</SelectItem>
<SelectItem value="md"> (Medium)</SelectItem>
<SelectItem value="lg"> (Large)</SelectItem>
<SelectItem value="xl"> (Extra Large)</SelectItem>
<SelectItem value="full"> (Full)</SelectItem>
</SelectContent>
</Select>
</div>
</>
)}
</div>
)}
{/* 복사 액션 설정 */}
{(component.componentConfig?.action?.type || "save") === "copy" && (
<div className="mt-4 space-y-4 rounded-lg border bg-blue-50 p-4">
<h4 className="text-sm font-medium text-foreground"> ( )</h4>
<div>
<Label htmlFor="copy-screen"> </Label>
<Popover open={modalScreenOpen} onOpenChange={setModalScreenOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={modalScreenOpen}
className="h-6 w-full justify-between px-2 py-0"
className="text-xs"
disabled={screensLoading}
>
{config.action?.targetScreenId
? screens.find((screen) => screen.id === parseInt(config.action?.targetScreenId))?.name ||
"복사 폼 화면을 선택하세요..."
: "복사 폼 화면을 선택하세요..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<div className="flex flex-col">
<div className="flex items-center border-b px-3 py-2">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<Input
placeholder="화면 검색..."
value={modalSearchTerm}
onChange={(e) => setModalSearchTerm(e.target.value)}
className="border-0 p-0 focus-visible:ring-0"
/>
</div>
<div className="max-h-[200px] overflow-auto">
{(() => {
const filteredScreens = filterScreens(modalSearchTerm);
if (screensLoading) {
return <div className="p-3 text-sm text-muted-foreground"> ...</div>;
}
if (filteredScreens.length === 0) {
return <div className="p-3 text-sm text-muted-foreground"> .</div>;
}
return filteredScreens.map((screen, index) => (
<div
key={`copy-screen-${screen.id}-${index}`}
className="flex cursor-pointer items-center px-3 py-2 hover:bg-muted"
onClick={() => {
onUpdateProperty("componentConfig.action.targetScreenId", screen.id);
setModalScreenOpen(false);
setModalSearchTerm("");
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
parseInt(config.action?.targetScreenId) === screen.id ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{screen.name}</span>
{screen.description && <span className="text-xs text-muted-foreground">{screen.description}</span>}
</div>
</div>
));
})()}
</div>
</div>
</PopoverContent>
</Popover>
<p className="mt-1 text-xs text-muted-foreground">
,
</p>
</div>
<div>
<Label htmlFor="copy-mode"> </Label>
<Select
value={component.componentConfig?.action?.editMode || "modal"}
onValueChange={(value) => {
onUpdateProperty("componentConfig.action.editMode", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="복사 모드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="modal"> </SelectItem>
<SelectItem value="navigate"> </SelectItem>
</SelectContent>
</Select>
</div>
{(component.componentConfig?.action?.editMode || "modal") === "modal" && (
<>
<div>
<Label htmlFor="copy-modal-title"> </Label>
<Input
id="copy-modal-title"
placeholder="모달 제목을 입력하세요 (예: 데이터 복사)"
value={localInputs.editModalTitle}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, editModalTitle: newValue }));
onUpdateProperty("componentConfig.action.editModalTitle", newValue);
onUpdateProperty("webTypeConfig.editModalTitle", newValue);
}}
/>
<p className="mt-1 text-xs text-muted-foreground"> </p>
</div>
<div>
<Label htmlFor="copy-modal-description"> </Label>
<Input
id="copy-modal-description"
placeholder="모달 설명을 입력하세요 (예: 선택한 데이터를 복사합니다)"
value={localInputs.editModalDescription}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, editModalDescription: newValue }));
onUpdateProperty("componentConfig.action.editModalDescription", newValue);
onUpdateProperty("webTypeConfig.editModalDescription", newValue);
}}
/>
<p className="mt-1 text-xs text-muted-foreground"> </p>
</div>
<div>
<Label htmlFor="copy-modal-size"> </Label>
<Select
value={component.componentConfig?.action?.modalSize || "md"}
onValueChange={(value) => {
onUpdateProperty("componentConfig.action.modalSize", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="모달 크기 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="sm"> (Small)</SelectItem>
<SelectItem value="md"> (Medium)</SelectItem>
<SelectItem value="lg"> (Large)</SelectItem>
<SelectItem value="xl"> (Extra Large)</SelectItem>
<SelectItem value="full"> (Full)</SelectItem>
</SelectContent>
</Select>
</div>
</>
)}
</div>
)}
{/* 테이블 이력 보기 액션 설정 */}
{(component.componentConfig?.action?.type || "save") === "view_table_history" && (
<div className="mt-4 space-y-4">
<div>
<Label>
() <span className="text-destructive">*</span>
</Label>
<Popover open={displayColumnOpen} onOpenChange={setDisplayColumnOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={displayColumnOpen}
className="mt-2 h-8 w-full justify-between text-xs"
disabled={columnsLoading || tableColumns.length === 0}
>
{columnsLoading
? "로딩 중..."
: config.action?.historyDisplayColumn
? config.action.historyDisplayColumn
: tableColumns.length === 0
? "사용 가능한 컬럼이 없습니다"
: "컬럼을 선택하세요"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs">
.
</CommandEmpty>
<CommandGroup>
{tableColumns.map((column) => (
<CommandItem
key={column}
value={column}
onSelect={(currentValue) => {
onUpdateProperty("componentConfig.action.historyDisplayColumn", currentValue);
setDisplayColumnOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-4 w-4",
config.action?.historyDisplayColumn === column ? "opacity-100" : "opacity-0",
)}
/>
{column}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
)}
{/* 페이지 이동 액션 설정 */}
{(component.componentConfig?.action?.type || "save") === "navigate" && (
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
<h4 className="text-sm font-medium text-foreground"> </h4>
<div>
<Label htmlFor="target-screen-nav"> </Label>
<Popover open={navScreenOpen} onOpenChange={setNavScreenOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={navScreenOpen}
className="h-6 w-full justify-between px-2 py-0"
className="text-xs"
disabled={screensLoading}
>
{config.action?.targetScreenId
? screens.find((screen) => screen.id === parseInt(config.action?.targetScreenId))?.name ||
"화면을 선택하세요..."
: "화면을 선택하세요..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<div className="flex flex-col">
<div className="flex items-center border-b px-3 py-2">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<Input
placeholder="화면 검색..."
value={navSearchTerm}
onChange={(e) => setNavSearchTerm(e.target.value)}
className="border-0 p-0 focus-visible:ring-0"
/>
</div>
<div className="max-h-[200px] overflow-auto">
{(() => {
const filteredScreens = filterScreens(navSearchTerm);
if (screensLoading) {
return <div className="p-3 text-sm text-muted-foreground"> ...</div>;
}
if (filteredScreens.length === 0) {
return <div className="p-3 text-sm text-muted-foreground"> .</div>;
}
return filteredScreens.map((screen, index) => (
<div
key={`navigate-screen-${screen.id}-${index}`}
className="flex cursor-pointer items-center px-3 py-2 hover:bg-muted"
onClick={() => {
onUpdateProperty("componentConfig.action.targetScreenId", screen.id);
setNavScreenOpen(false);
setNavSearchTerm("");
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
parseInt(config.action?.targetScreenId) === screen.id ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{screen.name}</span>
{screen.description && <span className="text-xs text-muted-foreground">{screen.description}</span>}
</div>
</div>
));
})()}
</div>
</div>
</PopoverContent>
</Popover>
<p className="mt-1 text-xs text-muted-foreground">
/screens/{"{"}ID{"}"}
</p>
</div>
<div>
<Label htmlFor="target-url"> URL ()</Label>
<Input
id="target-url"
placeholder="예: /admin/users 또는 https://example.com"
value={localInputs.targetUrl}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, targetUrl: newValue }));
onUpdateProperty("componentConfig.action.targetUrl", newValue);
}}
className="h-6 w-full px-2 py-0 text-xs"
/>
<p className="mt-1 text-xs text-muted-foreground">URL을 </p>
</div>
</div>
)}
{/* 엑셀 다운로드 액션 설정 */}
{(component.componentConfig?.action?.type || "save") === "excel_download" && (
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
<h4 className="text-sm font-medium text-foreground"> </h4>
<div>
<Label htmlFor="excel-filename"> ()</Label>
<Input
id="excel-filename"
placeholder="예: 데이터목록 (기본값: export)"
value={config.action?.excelFileName || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.excelFileName", e.target.value)}
className="h-8 text-xs"
/>
<p className="mt-1 text-xs text-muted-foreground">(.xlsx) </p>
</div>
<div>
<Label htmlFor="excel-sheetname"> ()</Label>
<Input
id="excel-sheetname"
placeholder="예: Sheet1 (기본값)"
value={config.action?.excelSheetName || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.excelSheetName", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="excel-include-headers"> </Label>
<Switch
id="excel-include-headers"
checked={config.action?.excelIncludeHeaders !== false}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.excelIncludeHeaders", checked)}
/>
</div>
</div>
)}
{/* 엑셀 업로드 액션 설정 */}
{(component.componentConfig?.action?.type || "save") === "excel_upload" && (
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
<h4 className="text-sm font-medium text-foreground">📤 </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="mt-1 text-xs text-muted-foreground">UPDATE/UPSERT </p>
</div>
)}
</div>
)}
{/* 바코드 스캔 액션 설정 */}
{(component.componentConfig?.action?.type || "save") === "barcode_scan" && (
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
<h4 className="text-sm font-medium text-foreground">📷 </h4>
<div>
<Label htmlFor="barcode-target-field">
<span className="text-destructive">*</span>
</Label>
<Input
id="barcode-target-field"
placeholder="예: barcode, qr_code"
value={config.action?.barcodeTargetField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.barcodeTargetField", e.target.value)}
className="h-8 text-xs"
/>
<p className="mt-1 text-xs text-muted-foreground"> </p>
</div>
<div>
<Label htmlFor="barcode-format"> </Label>
<Select
value={config.action?.barcodeFormat || "all"}
onValueChange={(value) => onUpdateProperty("componentConfig.action.barcodeFormat", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> </SelectItem>
<SelectItem value="1d">1D (CODE128, EAN13 )</SelectItem>
<SelectItem value="2d">2D (QR코드, DataMatrix )</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="barcode-auto-submit"> </Label>
<Switch
id="barcode-auto-submit"
checked={config.action?.barcodeAutoSubmit === true}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.barcodeAutoSubmit", checked)}
/>
</div>
</div>
)}
{/* 코드 병합 액션 설정 */}
{(component.componentConfig?.action?.type || "save") === "code_merge" && (
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
<h4 className="text-sm font-medium text-foreground">🔀 </h4>
<div>
<Label htmlFor="merge-column-name">
<span className="text-destructive">*</span>
</Label>
<Input
id="merge-column-name"
placeholder="예: item_code, product_id"
value={config.action?.mergeColumnName || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.mergeColumnName", e.target.value)}
className="h-8 text-xs"
/>
<p className="mt-1 text-xs text-muted-foreground">
(: item_code). .
</p>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="merge-show-preview"> </Label>
<p className="text-xs text-muted-foreground"> </p>
</div>
<Switch
id="merge-show-preview"
checked={config.action?.mergeShowPreview !== false}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.mergeShowPreview", checked)}
/>
</div>
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
<p className="text-xs text-blue-900 dark:text-blue-100">
<strong> :</strong>
<br />
1.
<br />
2.
<br />
3. ,
</p>
</div>
</div>
)}
{/* 공차등록 설정 - 운행알림으로 통합되어 주석 처리 */}
{/* {(component.componentConfig?.action?.type || "save") === "empty_vehicle" && (
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
... 공차등록 설정 UI 생략 ...
</div>
)} */}
{/* 운행알림 및 종료 설정 */}
{(component.componentConfig?.action?.type || "save") === "operation_control" && (
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
<h4 className="text-sm font-medium text-foreground">🚗 </h4>
<div>
<Label htmlFor="update-table">
<span className="text-destructive">*</span>
</Label>
<Select
value={config.action?.updateTableName || currentTableName || ""}
onValueChange={(value) => {
onUpdateProperty("componentConfig.action.updateTableName", value);
onUpdateProperty("componentConfig.action.updateTargetField", "");
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{availableTables.map((table) => (
<SelectItem key={table.name} value={table.name} className="text-xs">
{table.label || table.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-muted-foreground">
(기본: 현재 )
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="update-target-field">
<span className="text-destructive">*</span>
</Label>
<Input
id="update-target-field"
placeholder="예: status"
value={config.action?.updateTargetField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.updateTargetField", e.target.value)}
className="h-8 text-xs"
/>
<p className="mt-1 text-xs text-muted-foreground"> DB </p>
</div>
<div>
<Label htmlFor="update-target-value">
<span className="text-destructive">*</span>
</Label>
<Input
id="update-target-value"
placeholder="예: active"
value={config.action?.updateTargetValue || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.updateTargetValue", e.target.value)}
className="h-8 text-xs"
/>
<p className="mt-1 text-xs text-muted-foreground"> (, )</p>
</div>
</div>
{/* 🆕 키 필드 설정 (레코드 식별용) */}
<div className="mt-4 border-t pt-4">
<h5 className="mb-3 text-xs font-medium text-muted-foreground"> </h5>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="update-key-field">
(DB ) <span className="text-destructive">*</span>
</Label>
<Input
id="update-key-field"
placeholder="예: user_id"
value={config.action?.updateKeyField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.updateKeyField", e.target.value)}
className="h-8 text-xs"
/>
<p className="mt-1 text-xs text-muted-foreground"> DB </p>
</div>
<div>
<Label htmlFor="update-key-source">
<span className="text-destructive">*</span>
</Label>
<Select
value={config.action?.updateKeySourceField || ""}
onValueChange={(value) => onUpdateProperty("componentConfig.action.updateKeySourceField", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="키 값 소스 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__userId__" className="text-xs">
<span className="flex items-center gap-1">
<span className="text-amber-500">🔑</span> ID
</span>
</SelectItem>
<SelectItem value="__userName__" className="text-xs">
<span className="flex items-center gap-1">
<span className="text-amber-500">🔑</span>
</span>
</SelectItem>
<SelectItem value="__companyCode__" className="text-xs">
<span className="flex items-center gap-1">
<span className="text-amber-500">🔑</span>
</span>
</SelectItem>
{tableColumns.map((column) => (
<SelectItem key={column} value={column} className="text-xs">
{column}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-muted-foreground"> </p>
</div>
</div>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="update-auto-save"> </Label>
<p className="text-xs text-muted-foreground"> DB에 </p>
</div>
<Switch
id="update-auto-save"
checked={config.action?.updateAutoSave !== false}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.updateAutoSave", checked)}
/>
</div>
<div>
<Label htmlFor="update-confirm-message"> ()</Label>
<Input
id="update-confirm-message"
placeholder="예: 운행을 시작하시겠습니까?"
value={config.action?.confirmMessage || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.confirmMessage", e.target.value)}
className="h-8 text-xs"
/>
<p className="mt-1 text-xs text-muted-foreground"> </p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="update-success-message"> ()</Label>
<Input
id="update-success-message"
placeholder="예: 운행이 시작되었습니다."
value={config.action?.successMessage || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.successMessage", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div>
<Label htmlFor="update-error-message"> ()</Label>
<Input
id="update-error-message"
placeholder="예: 운행 시작에 실패했습니다."
value={config.action?.errorMessage || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.errorMessage", e.target.value)}
className="h-8 text-xs"
/>
</div>
</div>
{/* 위치정보 수집 옵션 */}
<div className="mt-4 border-t pt-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="update-with-geolocation"> </Label>
<p className="text-xs text-muted-foreground"> GPS </p>
</div>
<Switch
id="update-with-geolocation"
checked={config.action?.updateWithGeolocation === true}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.updateWithGeolocation", checked)}
/>
</div>
{config.action?.updateWithGeolocation && (
<div className="mt-3 space-y-3 rounded-md bg-amber-50 p-3 dark:bg-amber-950">
<div className="grid grid-cols-2 gap-2">
<div>
<Label> <span className="text-destructive">*</span></Label>
<Input
placeholder="예: latitude"
value={config.action?.updateGeolocationLatField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.updateGeolocationLatField", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div>
<Label> <span className="text-destructive">*</span></Label>
<Input
placeholder="예: longitude"
value={config.action?.updateGeolocationLngField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.updateGeolocationLngField", e.target.value)}
className="h-8 text-xs"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label> ()</Label>
<Input
placeholder="예: accuracy"
value={config.action?.updateGeolocationAccuracyField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.updateGeolocationAccuracyField", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div>
<Label> ()</Label>
<Input
placeholder="예: location_time"
value={config.action?.updateGeolocationTimestampField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.updateGeolocationTimestampField", e.target.value)}
className="h-8 text-xs"
/>
</div>
</div>
<p className="text-[10px] text-amber-700 dark:text-amber-300">
GPS .
</p>
</div>
)}
</div>
{/* 🆕 연속 위치 추적 설정 */}
<div className="flex items-center justify-between">
<div>
<Label htmlFor="update-with-tracking"> </Label>
<p className="text-xs text-muted-foreground">10 </p>
</div>
<Switch
id="update-with-tracking"
checked={config.action?.updateWithTracking === true}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.updateWithTracking", checked)}
/>
</div>
{config.action?.updateWithTracking && (
<div className="mt-3 space-y-3 rounded-md bg-green-50 p-3 dark:bg-green-950">
<div>
<Label> <span className="text-destructive">*</span></Label>
<Select
value={config.action?.updateTrackingMode || "start"}
onValueChange={(value) => onUpdateProperty("componentConfig.action.updateTrackingMode", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="모드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="start"> ( )</SelectItem>
<SelectItem value="stop"> ( )</SelectItem>
</SelectContent>
</Select>
</div>
{config.action?.updateTrackingMode === "start" && (
<div>
<Label> ()</Label>
<Input
type="number"
placeholder="10"
value={(config.action?.updateTrackingInterval || 10000) / 1000}
onChange={(e) => onUpdateProperty("componentConfig.action.updateTrackingInterval", parseInt(e.target.value) * 1000 || 10000)}
className="h-8 text-xs"
min={5}
max={300}
/>
<p className="mt-1 text-[10px] text-muted-foreground">5 ~ 300 (기본: 10초)</p>
</div>
)}
<p className="text-[10px] text-green-700 dark:text-green-300">
{config.action?.updateTrackingMode === "start"
? "버튼 클릭 시 연속 위치 추적이 시작되고, vehicle_location_history 테이블에 경로가 저장됩니다."
: "버튼 클릭 시 진행 중인 위치 추적이 종료됩니다."}
</p>
</div>
)}
{/* 🆕 버튼 활성화 조건 설정 */}
<div className="mt-4 border-t pt-4">
<h5 className="mb-3 text-xs font-medium text-muted-foreground"> </h5>
{/* 출발지/도착지 필수 체크 */}
<div className="flex items-center justify-between">
<div>
<Label htmlFor="require-location">/ </Label>
<p className="text-xs text-muted-foreground"> </p>
</div>
<Switch
id="require-location"
checked={config.action?.requireLocationFields === true}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.requireLocationFields", checked)}
/>
</div>
{config.action?.requireLocationFields && (
<div className="mt-3 space-y-2 rounded-md bg-orange-50 p-3 dark:bg-orange-950">
<div className="grid grid-cols-2 gap-2">
<div>
<Label> </Label>
<Input
placeholder="departure"
value={config.action?.trackingDepartureField || "departure"}
onChange={(e) => onUpdateProperty("componentConfig.action.trackingDepartureField", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div>
<Label> </Label>
<Input
placeholder="destination"
value={config.action?.trackingArrivalField || "destination"}
onChange={(e) => onUpdateProperty("componentConfig.action.trackingArrivalField", e.target.value)}
className="h-8 text-xs"
/>
</div>
</div>
</div>
)}
{/* 상태 기반 활성화 조건 */}
<div className="mt-4 flex items-center justify-between">
<div>
<Label htmlFor="enable-on-status"> </Label>
<p className="text-xs text-muted-foreground"> </p>
</div>
<Switch
id="enable-on-status"
checked={config.action?.enableOnStatusCheck === true}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.enableOnStatusCheck", checked)}
/>
</div>
{config.action?.enableOnStatusCheck && (
<div className="mt-3 space-y-2 rounded-md bg-purple-50 p-3 dark:bg-purple-950">
<div>
<Label> </Label>
<Select
value={config.action?.statusCheckTableName || "vehicles"}
onValueChange={(value) => onUpdateProperty("componentConfig.action.statusCheckTableName", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableTables.map((table) => (
<SelectItem key={table.name} value={table.name} className="text-xs">
{table.label || table.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-[10px] text-muted-foreground">
(기본: vehicles)
</p>
</div>
<div>
<Label> </Label>
<Input
placeholder="user_id"
value={config.action?.statusCheckKeyField || "user_id"}
onChange={(e) => onUpdateProperty("componentConfig.action.statusCheckKeyField", e.target.value)}
className="h-8 text-xs"
/>
<p className="mt-1 text-[10px] text-muted-foreground">
ID로 (기본: user_id)
</p>
</div>
<div>
<Label> </Label>
<Input
placeholder="status"
value={config.action?.statusCheckField || "status"}
onChange={(e) => onUpdateProperty("componentConfig.action.statusCheckField", e.target.value)}
className="h-8 text-xs"
/>
<p className="mt-1 text-[10px] text-muted-foreground">
(기본: status)
</p>
</div>
<div>
<Label> </Label>
<Select
value={config.action?.statusConditionType || "enableOn"}
onValueChange={(value) => onUpdateProperty("componentConfig.action.statusConditionType", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="enableOn"> </SelectItem>
<SelectItem value="disableOn"> </SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label> ( )</Label>
<Input
placeholder="예: active, inactive"
value={config.action?.statusConditionValues || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.statusConditionValues", e.target.value)}
className="h-8 text-xs"
/>
<p className="mt-1 text-[10px] text-muted-foreground">
(,)
</p>
</div>
</div>
)}
</div>
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
<p className="text-xs text-blue-900 dark:text-blue-100">
<strong> :</strong>
<br />
- 시작: status를 &quot;active&quot; +
<br />
- 종료: status를 &quot;completed&quot; +
<br />
- 공차등록: status를 &quot;inactive&quot; + 1
</p>
</div>
</div>
)}
{/* 데이터 전달 액션 설정 */}
{(component.componentConfig?.action?.type || "save") === "transferData" && (
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
<h4 className="text-sm font-medium text-foreground">📦 </h4>
{/* 소스 컴포넌트 선택 (Combobox) */}
<div>
<Label>
<span className="text-destructive">*</span>
</Label>
<Select
value={config.action?.dataTransfer?.sourceComponentId || ""}
onValueChange={(value) => onUpdateProperty("componentConfig.action.dataTransfer.sourceComponentId", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="데이터를 가져올 컴포넌트 선택" />
</SelectTrigger>
<SelectContent>
{/* 데이터 제공 가능한 컴포넌트 필터링 */}
{allComponents
.filter((comp: any) => {
const type = comp.componentType || comp.type || "";
// 데이터를 제공할 수 있는 컴포넌트 타입들
return ["table-list", "repeater-field-group", "form-group", "data-table"].some(
(t) => type.includes(t)
);
})
.map((comp: any) => {
const compType = comp.componentType || comp.type || "unknown";
const compLabel = comp.label || comp.componentConfig?.title || comp.id;
return (
<SelectItem key={comp.id} value={comp.id}>
<div className="flex items-center gap-2">
<span className="text-xs font-medium">{compLabel}</span>
<span className="text-[10px] text-muted-foreground">({compType})</span>
</div>
</SelectItem>
);
})}
{allComponents.filter((comp: any) => {
const type = comp.componentType || comp.type || "";
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) => type.includes(t));
}).length === 0 && (
<SelectItem value="__none__" disabled>
</SelectItem>
)}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-muted-foreground">
,
</p>
</div>
<div>
<Label htmlFor="target-type">
<span className="text-destructive">*</span>
</Label>
<Select
value={config.action?.dataTransfer?.targetType || "component"}
onValueChange={(value) => onUpdateProperty("componentConfig.action.dataTransfer.targetType", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="component"> </SelectItem>
<SelectItem value="splitPanel"> </SelectItem>
<SelectItem value="screen" disabled> ( )</SelectItem>
</SelectContent>
</Select>
{config.action?.dataTransfer?.targetType === "splitPanel" && (
<p className="text-[10px] text-muted-foreground mt-1">
. , .
</p>
)}
</div>
{/* 타겟 컴포넌트 선택 (같은 화면의 컴포넌트일 때만) */}
{config.action?.dataTransfer?.targetType === "component" && (
<div>
<Label>
<span className="text-destructive">*</span>
</Label>
<Select
value={config.action?.dataTransfer?.targetComponentId || ""}
onValueChange={(value) => onUpdateProperty("componentConfig.action.dataTransfer.targetComponentId", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="데이터를 받을 컴포넌트 선택" />
</SelectTrigger>
<SelectContent>
{/* 데이터 수신 가능한 컴포넌트 필터링 (소스와 다른 컴포넌트만) */}
{allComponents
.filter((comp: any) => {
const type = comp.componentType || comp.type || "";
// 데이터를 받을 수 있는 컴포넌트 타입들
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some(
(t) => type.includes(t)
);
// 소스와 다른 컴포넌트만
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
})
.map((comp: any) => {
const compType = comp.componentType || comp.type || "unknown";
const compLabel = comp.label || comp.componentConfig?.title || comp.id;
return (
<SelectItem key={comp.id} value={comp.id}>
<div className="flex items-center gap-2">
<span className="text-xs font-medium">{compLabel}</span>
<span className="text-[10px] text-muted-foreground">({compType})</span>
</div>
</SelectItem>
);
})}
{allComponents.filter((comp: any) => {
const type = comp.componentType || comp.type || "";
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) => type.includes(t));
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
}).length === 0 && (
<SelectItem value="__none__" disabled>
</SelectItem>
)}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-muted-foreground">
,
</p>
</div>
)}
{/* 분할 패널 반대편 타겟 설정 */}
{config.action?.dataTransfer?.targetType === "splitPanel" && (
<div>
<Label>
ID ()
</Label>
<Input
value={config.action?.dataTransfer?.targetComponentId || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.dataTransfer.targetComponentId", e.target.value)}
placeholder="비워두면 첫 번째 수신 가능 컴포넌트로 전달"
className="h-8 text-xs"
/>
<p className="mt-1 text-xs text-muted-foreground">
ID를 , .
</p>
</div>
)}
<div>
<Label htmlFor="transfer-mode"> </Label>
<Select
value={config.action?.dataTransfer?.mode || "append"}
onValueChange={(value) => onUpdateProperty("componentConfig.action.dataTransfer.mode", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="append"> (Append)</SelectItem>
<SelectItem value="replace"> (Replace)</SelectItem>
<SelectItem value="merge"> (Merge)</SelectItem>
</SelectContent>
</Select>
<p className="mt-1 text-xs text-muted-foreground">
</p>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="clear-after-transfer"> </Label>
<p className="text-xs text-muted-foreground"> </p>
</div>
<Switch
id="clear-after-transfer"
checked={config.action?.dataTransfer?.clearAfterTransfer === true}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.dataTransfer.clearAfterTransfer", checked)}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="confirm-before-transfer"> </Label>
<p className="text-xs text-muted-foreground"> </p>
</div>
<Switch
id="confirm-before-transfer"
checked={config.action?.dataTransfer?.confirmBeforeTransfer === true}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.dataTransfer.confirmBeforeTransfer", checked)}
/>
</div>
{config.action?.dataTransfer?.confirmBeforeTransfer && (
<div>
<Label htmlFor="confirm-message"> </Label>
<Input
id="confirm-message"
placeholder="선택한 항목을 전달하시겠습니까?"
value={config.action?.dataTransfer?.confirmMessage || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.dataTransfer.confirmMessage", e.target.value)}
className="h-8 text-xs"
/>
</div>
)}
<div className="space-y-2">
<Label> </Label>
<div className="space-y-2 rounded-md border p-3">
<div className="flex items-center gap-2">
<Label htmlFor="min-selection" className="text-xs">
</Label>
<Input
id="min-selection"
type="number"
placeholder="0"
value={config.action?.dataTransfer?.validation?.minSelection || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.dataTransfer.validation.minSelection", parseInt(e.target.value) || 0)}
className="h-8 w-20 text-xs"
/>
</div>
<div className="flex items-center gap-2">
<Label htmlFor="max-selection" className="text-xs">
</Label>
<Input
id="max-selection"
type="number"
placeholder="제한없음"
value={config.action?.dataTransfer?.validation?.maxSelection || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.dataTransfer.validation.maxSelection", parseInt(e.target.value) || undefined)}
className="h-8 w-20 text-xs"
/>
</div>
</div>
</div>
<div className="space-y-2">
<Label> ()</Label>
<p className="text-xs text-muted-foreground">
</p>
<div className="space-y-2 rounded-md border p-3">
<div>
<Label className="text-xs"> </Label>
<Select
value={config.action?.dataTransfer?.additionalSources?.[0]?.componentId || ""}
onValueChange={(value) => {
const currentSources = config.action?.dataTransfer?.additionalSources || [];
const newSources = [...currentSources];
if (newSources.length === 0) {
newSources.push({ componentId: value, fieldName: "" });
} else {
newSources[0] = { ...newSources[0], componentId: value };
}
onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources);
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="추가 데이터 컴포넌트 선택 (선택사항)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__clear__">
<span className="text-muted-foreground"> </span>
</SelectItem>
{/* 추가 데이터 제공 가능한 컴포넌트 (조건부 컨테이너, 셀렉트박스 등) */}
{allComponents
.filter((comp: any) => {
const type = comp.componentType || comp.type || "";
// 소스/타겟과 다른 컴포넌트 중 값을 제공할 수 있는 타입
return ["conditional-container", "select-basic", "select", "combobox"].some(
(t) => type.includes(t)
);
})
.map((comp: any) => {
const compType = comp.componentType || comp.type || "unknown";
const compLabel = comp.label || comp.componentConfig?.controlLabel || comp.id;
return (
<SelectItem key={comp.id} value={comp.id}>
<div className="flex items-center gap-2">
<span className="text-xs font-medium">{compLabel}</span>
<span className="text-[10px] text-muted-foreground">({compType})</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-muted-foreground">
, ( )
</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"
/>
<p className="mt-1 text-xs text-muted-foreground">
</p>
</div>
</div>
</div>
{/* 필드 매핑 규칙 */}
<div className="space-y-3">
<Label> </Label>
{/* 소스/타겟 테이블 선택 */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-8 w-full justify-between text-xs"
>
{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",
config.action?.dataTransfer?.sourceTable === table.name ? "opacity-100" : "opacity-0"
)}
/>
<span className="font-medium">{table.label}</span>
<span className="ml-1 text-muted-foreground">({table.name})</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-8 w-full justify-between text-xs"
>
{config.action?.dataTransfer?.targetTable
? availableTables.find((t) => t.name === config.action?.dataTransfer?.targetTable)?.label ||
config.action?.dataTransfer?.targetTable
: "테이블 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[250px] p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty>
<CommandGroup>
{availableTables.map((table) => (
<CommandItem
key={table.name}
value={`${table.label} ${table.name}`}
onSelect={() => {
onUpdateProperty("componentConfig.action.dataTransfer.targetTable", table.name);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.action?.dataTransfer?.targetTable === table.name ? "opacity-100" : "opacity-0"
)}
/>
<span className="font-medium">{table.label}</span>
<span className="ml-1 text-muted-foreground">({table.name})</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
{/* 필드 매핑 규칙 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Button
type="button"
variant="outline"
size="sm"
className="h-6 text-[10px]"
onClick={() => {
const 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>
<p className="text-[10px] text-muted-foreground">
. .
</p>
{(!config.action?.dataTransfer?.sourceTable || !config.action?.dataTransfer?.targetTable) ? (
<div className="rounded-md border border-dashed p-3 text-center">
<p className="text-xs text-muted-foreground">
.
</p>
</div>
) : (config.action?.dataTransfer?.mappingRules || []).length === 0 ? (
<div className="rounded-md border border-dashed p-3 text-center">
<p className="text-xs text-muted-foreground">
. .
</p>
</div>
) : (
<div className="space-y-2">
{(config.action?.dataTransfer?.mappingRules || []).map((rule: any, index: number) => (
<div key={index} className="flex items-center gap-2 rounded-md border bg-background p-2">
{/* 소스 필드 선택 (Combobox) */}
<div className="flex-1">
<Popover
open={mappingSourcePopoverOpen[index] || false}
onOpenChange={(open) => setMappingSourcePopoverOpen((prev) => ({ ...prev, [index]: open }))}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-7 w-full justify-between text-xs"
>
{rule.sourceField
? mappingSourceColumns.find((c) => c.name === rule.sourceField)?.label || rule.sourceField
: "소스 필드"}
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput
placeholder="컬럼 검색..."
className="h-8 text-xs"
value={mappingSourceSearch[index] || ""}
onValueChange={(value) => setMappingSourceSearch((prev) => ({ ...prev, [index]: value }))}
/>
<CommandList>
<CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty>
<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",
rule.sourceField === col.name ? "opacity-100" : "opacity-0"
)}
/>
<span>{col.label}</span>
{col.label !== col.name && (
<span className="ml-1 text-muted-foreground">({col.name})</span>
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<span className="text-xs text-muted-foreground"></span>
{/* 타겟 필드 선택 (Combobox) */}
<div className="flex-1">
<Popover
open={mappingTargetPopoverOpen[index] || false}
onOpenChange={(open) => setMappingTargetPopoverOpen((prev) => ({ ...prev, [index]: open }))}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-7 w-full justify-between text-xs"
>
{rule.targetField
? mappingTargetColumns.find((c) => c.name === rule.targetField)?.label || rule.targetField
: "타겟 필드"}
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput
placeholder="컬럼 검색..."
className="h-8 text-xs"
value={mappingTargetSearch[index] || ""}
onValueChange={(value) => setMappingTargetSearch((prev) => ({ ...prev, [index]: value }))}
/>
<CommandList>
<CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty>
<CommandGroup>
{mappingTargetColumns.map((col) => (
<CommandItem
key={col.name}
value={`${col.label} ${col.name}`}
onSelect={() => {
const 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",
rule.targetField === col.name ? "opacity-100" : "opacity-0"
)}
/>
<span>{col.label}</span>
{col.label !== col.name && (
<span className="ml-1 text-muted-foreground">({col.name})</span>
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:bg-destructive/10"
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>
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
<p className="text-xs text-blue-900 dark:text-blue-100">
<strong> :</strong>
<br />
1.
<br />
2. (: 품번 )
<br />
3.
</p>
</div>
</div>
)}
{/* 🆕 즉시 저장(quickInsert) 액션 설정 */}
{component.componentConfig?.action?.type === "quickInsert" && (
<QuickInsertConfigSection
component={component}
onUpdateProperty={onUpdateProperty}
allComponents={allComponents}
currentTableName={currentTableName}
/>
)}
{/* 제어 기능 섹션 */}
<div className="mt-8 border-t border-border pt-6">
<ImprovedButtonControlConfigPanel component={component} onUpdateProperty={onUpdateProperty} />
</div>
{/* 🆕 플로우 단계별 표시 제어 섹션 (플로우 위젯이 있을 때만 표시) */}
{hasFlowWidget && (
<div className="mt-8 border-t border-border pt-6">
<FlowVisibilityConfigPanel
component={component}
allComponents={allComponents}
onUpdateProperty={onUpdateProperty}
/>
</div>
)}
</div>
);
};