2927 lines
139 KiB
TypeScript
2927 lines
139 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";
|
|
|
|
// 🆕 제목 블록 타입
|
|
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>>({});
|
|
|
|
// 🎯 플로우 위젯이 화면에 있는지 확인
|
|
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]);
|
|
|
|
// 화면 목록 가져오기 (현재 편집 중인 화면의 회사 코드 기준)
|
|
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="modal">모달 열기</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="geolocation">위치정보 가져오기</SelectItem>
|
|
<SelectItem value="update_field">필드 값 변경</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>
|
|
)}
|
|
|
|
{/* 수정 액션 설정 */}
|
|
{(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") === "geolocation" && (
|
|
<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="geolocation-table">
|
|
저장할 테이블 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Select
|
|
value={config.action?.geolocationTableName || currentTableName || ""}
|
|
onValueChange={(value) => {
|
|
onUpdateProperty("componentConfig.action.geolocationTableName", value);
|
|
onUpdateProperty("componentConfig.action.geolocationLatField", "");
|
|
onUpdateProperty("componentConfig.action.geolocationLngField", "");
|
|
onUpdateProperty("componentConfig.action.geolocationAccuracyField", "");
|
|
onUpdateProperty("componentConfig.action.geolocationTimestampField", "");
|
|
}}
|
|
>
|
|
<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="geolocation-lat-field">
|
|
위도 저장 필드 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Input
|
|
id="geolocation-lat-field"
|
|
placeholder="예: latitude"
|
|
value={config.action?.geolocationLatField || ""}
|
|
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationLatField", e.target.value)}
|
|
className="h-8 text-xs"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="geolocation-lng-field">
|
|
경도 저장 필드 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Input
|
|
id="geolocation-lng-field"
|
|
placeholder="예: longitude"
|
|
value={config.action?.geolocationLngField || ""}
|
|
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationLngField", e.target.value)}
|
|
className="h-8 text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<Label htmlFor="geolocation-accuracy-field">정확도 저장 필드 (선택)</Label>
|
|
<Input
|
|
id="geolocation-accuracy-field"
|
|
placeholder="예: accuracy"
|
|
value={config.action?.geolocationAccuracyField || ""}
|
|
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationAccuracyField", e.target.value)}
|
|
className="h-8 text-xs"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="geolocation-timestamp-field">타임스탬프 저장 필드 (선택)</Label>
|
|
<Input
|
|
id="geolocation-timestamp-field"
|
|
placeholder="예: location_time"
|
|
value={config.action?.geolocationTimestampField || ""}
|
|
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationTimestampField", e.target.value)}
|
|
className="h-8 text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-0.5">
|
|
<Label htmlFor="geolocation-high-accuracy">고정밀 모드</Label>
|
|
<p className="text-xs text-muted-foreground">GPS를 사용하여 더 정확한 위치 (배터리 소모 증가)</p>
|
|
</div>
|
|
<Switch
|
|
id="geolocation-high-accuracy"
|
|
checked={config.action?.geolocationHighAccuracy !== false}
|
|
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.geolocationHighAccuracy", checked)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-0.5">
|
|
<Label htmlFor="geolocation-auto-save">위치 가져온 후 자동 저장</Label>
|
|
<p className="text-xs text-muted-foreground">위치 정보를 가져온 후 자동으로 폼을 저장합니다</p>
|
|
</div>
|
|
<Switch
|
|
id="geolocation-auto-save"
|
|
checked={config.action?.geolocationAutoSave === true}
|
|
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.geolocationAutoSave", checked)}
|
|
/>
|
|
</div>
|
|
|
|
{/* 첫 번째 추가 테이블 설정 (위치정보와 함께 상태 변경) */}
|
|
<div className="mt-4 border-t pt-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-0.5">
|
|
<Label htmlFor="geolocation-update-field">추가 필드 변경 (테이블 1)</Label>
|
|
<p className="text-xs text-muted-foreground">위치정보와 함께 다른 테이블의 필드 값을 변경합니다</p>
|
|
</div>
|
|
<Switch
|
|
id="geolocation-update-field"
|
|
checked={config.action?.geolocationUpdateField === true}
|
|
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.geolocationUpdateField", checked)}
|
|
/>
|
|
</div>
|
|
|
|
{config.action?.geolocationUpdateField && (
|
|
<div className="mt-3 space-y-3 rounded-md bg-amber-50 p-3 dark:bg-amber-950">
|
|
<div>
|
|
<Label>대상 테이블</Label>
|
|
<Select
|
|
value={config.action?.geolocationExtraTableName || ""}
|
|
onValueChange={(value) => onUpdateProperty("componentConfig.action.geolocationExtraTableName", value)}
|
|
>
|
|
<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>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<Label>변경할 필드</Label>
|
|
<Input
|
|
placeholder="예: status"
|
|
value={config.action?.geolocationExtraField || ""}
|
|
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationExtraField", e.target.value)}
|
|
className="h-8 text-xs"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>변경할 값</Label>
|
|
<Input
|
|
placeholder="예: active"
|
|
value={config.action?.geolocationExtraValue || ""}
|
|
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationExtraValue", e.target.value)}
|
|
className="h-8 text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<Label>키 필드 (대상 테이블)</Label>
|
|
<Input
|
|
placeholder="예: id"
|
|
value={config.action?.geolocationExtraKeyField || ""}
|
|
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationExtraKeyField", e.target.value)}
|
|
className="h-8 text-xs"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>키 값 소스</Label>
|
|
<Select
|
|
value={config.action?.geolocationExtraKeySourceField || ""}
|
|
onValueChange={(value) => onUpdateProperty("componentConfig.action.geolocationExtraKeySourceField", value)}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue placeholder="소스 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__userId__" className="text-xs font-medium text-blue-600">
|
|
🔑 로그인 사용자 ID
|
|
</SelectItem>
|
|
<SelectItem value="__companyCode__" className="text-xs font-medium text-blue-600">
|
|
🏢 회사 코드
|
|
</SelectItem>
|
|
<SelectItem value="__userName__" className="text-xs font-medium text-blue-600">
|
|
👤 사용자 이름
|
|
</SelectItem>
|
|
{tableColumns.length > 0 && (
|
|
<>
|
|
<SelectItem value="__divider__" disabled className="text-xs text-muted-foreground">
|
|
── 폼 필드 ──
|
|
</SelectItem>
|
|
{tableColumns.map((col) => (
|
|
<SelectItem key={col} value={col} className="text-xs">
|
|
{col}
|
|
</SelectItem>
|
|
))}
|
|
</>
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</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="geolocation-second-table">추가 필드 변경 (테이블 2)</Label>
|
|
<p className="text-xs text-muted-foreground">두 번째 테이블의 필드 값도 함께 변경합니다</p>
|
|
</div>
|
|
<Switch
|
|
id="geolocation-second-table"
|
|
checked={config.action?.geolocationSecondTableEnabled === true}
|
|
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.geolocationSecondTableEnabled", checked)}
|
|
/>
|
|
</div>
|
|
|
|
{config.action?.geolocationSecondTableEnabled && (
|
|
<div className="mt-3 space-y-3 rounded-md bg-green-50 p-3 dark:bg-green-950">
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<Label>대상 테이블</Label>
|
|
<Select
|
|
value={config.action?.geolocationSecondTableName || ""}
|
|
onValueChange={(value) => onUpdateProperty("componentConfig.action.geolocationSecondTableName", value)}
|
|
>
|
|
<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>
|
|
</div>
|
|
<div>
|
|
<Label>작업 모드</Label>
|
|
<Select
|
|
value={config.action?.geolocationSecondMode || "update"}
|
|
onValueChange={(value) => onUpdateProperty("componentConfig.action.geolocationSecondMode", value)}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="update" className="text-xs">UPDATE (기존 레코드 수정)</SelectItem>
|
|
<SelectItem value="insert" className="text-xs">INSERT (새 레코드 생성)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<Label>{config.action?.geolocationSecondMode === "insert" ? "저장할 필드" : "변경할 필드"}</Label>
|
|
<Input
|
|
placeholder="예: status"
|
|
value={config.action?.geolocationSecondField || ""}
|
|
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationSecondField", e.target.value)}
|
|
className="h-8 text-xs"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>{config.action?.geolocationSecondMode === "insert" ? "저장할 값" : "변경할 값"}</Label>
|
|
<Input
|
|
placeholder="예: inactive"
|
|
value={config.action?.geolocationSecondValue || ""}
|
|
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationSecondValue", e.target.value)}
|
|
className="h-8 text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<Label>
|
|
{config.action?.geolocationSecondMode === "insert"
|
|
? "연결 필드 (대상 테이블)"
|
|
: "키 필드 (대상 테이블)"}
|
|
</Label>
|
|
<Input
|
|
placeholder={config.action?.geolocationSecondMode === "insert" ? "예: vehicle_id" : "예: id"}
|
|
value={config.action?.geolocationSecondKeyField || ""}
|
|
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationSecondKeyField", e.target.value)}
|
|
className="h-8 text-xs"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>키 값 소스</Label>
|
|
<Select
|
|
value={config.action?.geolocationSecondKeySourceField || ""}
|
|
onValueChange={(value) => onUpdateProperty("componentConfig.action.geolocationSecondKeySourceField", value)}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue placeholder="소스 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__userId__" className="text-xs font-medium text-blue-600">
|
|
🔑 로그인 사용자 ID
|
|
</SelectItem>
|
|
<SelectItem value="__companyCode__" className="text-xs font-medium text-blue-600">
|
|
🏢 회사 코드
|
|
</SelectItem>
|
|
<SelectItem value="__userName__" className="text-xs font-medium text-blue-600">
|
|
👤 사용자 이름
|
|
</SelectItem>
|
|
{tableColumns.length > 0 && (
|
|
<>
|
|
<SelectItem value="__divider__" disabled className="text-xs text-muted-foreground">
|
|
── 폼 필드 ──
|
|
</SelectItem>
|
|
{tableColumns.map((col) => (
|
|
<SelectItem key={col} value={col} className="text-xs">
|
|
{col}
|
|
</SelectItem>
|
|
))}
|
|
</>
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{config.action?.geolocationSecondMode === "insert" && (
|
|
<div className="flex items-center justify-between rounded bg-green-100 p-2 dark:bg-green-900">
|
|
<div className="space-y-0.5">
|
|
<Label className="text-xs">위치정보도 함께 저장</Label>
|
|
<p className="text-[10px] text-muted-foreground">위도/경도를 이 테이블에도 저장</p>
|
|
</div>
|
|
<Switch
|
|
checked={config.action?.geolocationSecondInsertFields?.includeLocation === true}
|
|
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.geolocationSecondInsertFields", {
|
|
...config.action?.geolocationSecondInsertFields,
|
|
includeLocation: checked
|
|
})}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<p className="text-[10px] text-green-700 dark:text-green-300">
|
|
{config.action?.geolocationSecondMode === "insert"
|
|
? "새 레코드를 생성합니다. 연결 필드로 현재 폼 데이터와 연결됩니다."
|
|
: "기존 레코드를 수정합니다. 키 필드로 레코드를 찾아 값을 변경합니다."}
|
|
</p>
|
|
</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. 사용자가 허용하면 현재 GPS 좌표를 가져옵니다
|
|
<br />
|
|
3. 위도/경도가 지정된 필드에 자동으로 입력됩니다
|
|
<br />
|
|
4. 추가 테이블 설정이 있으면 해당 테이블의 필드도 함께 변경됩니다
|
|
<br />
|
|
<br />
|
|
<strong>예시:</strong> 위치정보 저장 + vehicles.status를 inactive로 변경
|
|
<br />
|
|
<br />
|
|
<strong>참고:</strong> HTTPS 환경에서만 위치정보가 작동합니다.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 필드 값 변경 설정 */}
|
|
{(component.componentConfig?.action?.type || "save") === "update_field" && (
|
|
<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="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
|
|
<p className="text-xs text-blue-900 dark:text-blue-100">
|
|
<strong>사용 예시:</strong>
|
|
<br />
|
|
- 운행알림 버튼: status를 "active"로 + 위치정보 수집
|
|
<br />
|
|
- 출발 버튼: status를 "inactive"로 + 위치정보 수집
|
|
<br />
|
|
- 완료 버튼: is_completed를 "Y"로 변경
|
|
</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>
|
|
)}
|
|
|
|
{/* 제어 기능 섹션 */}
|
|
<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>
|
|
);
|
|
};
|