|
|
|
|
@ -269,6 +269,13 @@ export const V2ButtonConfigPanel: React.FC<V2ButtonConfigPanelProps> = ({
|
|
|
|
|
const [modalScreenOpen, setModalScreenOpen] = useState(false);
|
|
|
|
|
const [modalSearchTerm, setModalSearchTerm] = useState("");
|
|
|
|
|
|
|
|
|
|
// 데이터 전달 필드 매핑 관련
|
|
|
|
|
const [availableTables, setAvailableTables] = useState<Array<{ name: string; label: string }>>([]);
|
|
|
|
|
const [mappingSourceColumnsMap, setMappingSourceColumnsMap] = useState<Record<string, Array<{ name: string; label: string }>>>({});
|
|
|
|
|
const [mappingTargetColumns, setMappingTargetColumns] = useState<Array<{ name: string; label: string }>>([]);
|
|
|
|
|
const [fieldMappingOpen, setFieldMappingOpen] = useState(false);
|
|
|
|
|
const [activeMappingGroupIndex, setActiveMappingGroupIndex] = useState(0);
|
|
|
|
|
|
|
|
|
|
const showIconSettings = displayMode === "icon" || displayMode === "icon-text";
|
|
|
|
|
const currentActionIcons = actionIconMap[actionType] || [];
|
|
|
|
|
const isNoIconAction = noIconActions.has(actionType);
|
|
|
|
|
@ -330,6 +337,76 @@ export const V2ButtonConfigPanel: React.FC<V2ButtonConfigPanelProps> = ({
|
|
|
|
|
setIconSize(config.icon?.size || "보통");
|
|
|
|
|
}, [config.icon?.name, config.icon?.type, config.icon?.size]);
|
|
|
|
|
|
|
|
|
|
// 테이블 목록 로드 (데이터 전달 액션용)
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (actionType !== "transferData") return;
|
|
|
|
|
if (availableTables.length > 0) return;
|
|
|
|
|
|
|
|
|
|
const loadTables = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const response = await apiClient.get("/table-management/tables");
|
|
|
|
|
if (response.data.success && response.data.data) {
|
|
|
|
|
const tables = response.data.data.map((t: any) => ({
|
|
|
|
|
name: t.tableName || t.name,
|
|
|
|
|
label: t.displayName || t.tableLabel || t.label || t.tableName || t.name,
|
|
|
|
|
}));
|
|
|
|
|
setAvailableTables(tables);
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
setAvailableTables([]);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
loadTables();
|
|
|
|
|
}, [actionType, availableTables.length]);
|
|
|
|
|
|
|
|
|
|
// 테이블 컬럼 로드 헬퍼
|
|
|
|
|
const loadTableColumns = useCallback(async (tableName: string): Promise<Array<{ name: string; label: string }>> => {
|
|
|
|
|
try {
|
|
|
|
|
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
|
|
|
|
if (response.data.success) {
|
|
|
|
|
let columnData = response.data.data;
|
|
|
|
|
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
|
|
|
|
|
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
|
|
|
|
|
if (Array.isArray(columnData)) {
|
|
|
|
|
return columnData.map((col: any) => ({
|
|
|
|
|
name: col.name || col.columnName,
|
|
|
|
|
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch { /* ignore */ }
|
|
|
|
|
return [];
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 멀티 테이블 매핑: 소스/타겟 테이블 컬럼 로드
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (actionType !== "transferData") return;
|
|
|
|
|
|
|
|
|
|
const multiTableMappings: Array<{ sourceTable: string }> = config.action?.dataTransfer?.multiTableMappings || [];
|
|
|
|
|
const targetTable = config.action?.dataTransfer?.targetTable;
|
|
|
|
|
|
|
|
|
|
const loadAll = async () => {
|
|
|
|
|
const sourceTableNames = multiTableMappings.map((m) => m.sourceTable).filter(Boolean);
|
|
|
|
|
const newMap: Record<string, Array<{ name: string; label: string }>> = {};
|
|
|
|
|
for (const tbl of sourceTableNames) {
|
|
|
|
|
if (!mappingSourceColumnsMap[tbl]) {
|
|
|
|
|
newMap[tbl] = await loadTableColumns(tbl);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (Object.keys(newMap).length > 0) {
|
|
|
|
|
setMappingSourceColumnsMap((prev) => ({ ...prev, ...newMap }));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (targetTable) {
|
|
|
|
|
const cols = await loadTableColumns(targetTable);
|
|
|
|
|
setMappingTargetColumns(cols);
|
|
|
|
|
} else {
|
|
|
|
|
setMappingTargetColumns([]);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
loadAll();
|
|
|
|
|
}, [actionType, config.action?.dataTransfer?.multiTableMappings, config.action?.dataTransfer?.targetTable, loadTableColumns]);
|
|
|
|
|
|
|
|
|
|
// 화면 목록 로드 (모달 액션용)
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (actionType !== "modal" && actionType !== "navigate") return;
|
|
|
|
|
@ -338,7 +415,7 @@ export const V2ButtonConfigPanel: React.FC<V2ButtonConfigPanelProps> = ({
|
|
|
|
|
const loadScreens = async () => {
|
|
|
|
|
setScreensLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const response = await apiClient.get("/screen-management/screens");
|
|
|
|
|
const response = await apiClient.get("/screen-management/screens?size=1000");
|
|
|
|
|
if (response.data.success && response.data.data) {
|
|
|
|
|
const screenList = response.data.data.map((s: any) => ({
|
|
|
|
|
id: s.id || s.screenId,
|
|
|
|
|
@ -521,6 +598,8 @@ export const V2ButtonConfigPanel: React.FC<V2ButtonConfigPanelProps> = ({
|
|
|
|
|
modalSearchTerm={modalSearchTerm}
|
|
|
|
|
setModalSearchTerm={setModalSearchTerm}
|
|
|
|
|
currentTableName={effectiveTableName}
|
|
|
|
|
allComponents={allComponents}
|
|
|
|
|
handleUpdateProperty={handleUpdateProperty}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{/* ─── 아이콘 설정 (접기) ─── */}
|
|
|
|
|
@ -657,6 +736,26 @@ export const V2ButtonConfigPanel: React.FC<V2ButtonConfigPanelProps> = ({
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 데이터 전달 필드 매핑 (transferData 액션 전용) */}
|
|
|
|
|
{actionType === "transferData" && (
|
|
|
|
|
<>
|
|
|
|
|
<Separator />
|
|
|
|
|
<TransferDataFieldMappingSection
|
|
|
|
|
config={config}
|
|
|
|
|
onChange={onChange}
|
|
|
|
|
availableTables={availableTables}
|
|
|
|
|
mappingSourceColumnsMap={mappingSourceColumnsMap}
|
|
|
|
|
setMappingSourceColumnsMap={setMappingSourceColumnsMap}
|
|
|
|
|
mappingTargetColumns={mappingTargetColumns}
|
|
|
|
|
fieldMappingOpen={fieldMappingOpen}
|
|
|
|
|
setFieldMappingOpen={setFieldMappingOpen}
|
|
|
|
|
activeMappingGroupIndex={activeMappingGroupIndex}
|
|
|
|
|
setActiveMappingGroupIndex={setActiveMappingGroupIndex}
|
|
|
|
|
loadTableColumns={loadTableColumns}
|
|
|
|
|
/>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 제어 기능 */}
|
|
|
|
|
{actionType !== "excel_upload" && actionType !== "multi_table_excel_upload" && (
|
|
|
|
|
<>
|
|
|
|
|
@ -699,6 +798,8 @@ const ActionDetailSection: React.FC<{
|
|
|
|
|
modalSearchTerm: string;
|
|
|
|
|
setModalSearchTerm: (term: string) => void;
|
|
|
|
|
currentTableName?: string;
|
|
|
|
|
allComponents?: ComponentData[];
|
|
|
|
|
handleUpdateProperty?: (path: string, value: any) => void;
|
|
|
|
|
}> = ({
|
|
|
|
|
actionType,
|
|
|
|
|
config,
|
|
|
|
|
@ -711,6 +812,8 @@ const ActionDetailSection: React.FC<{
|
|
|
|
|
modalSearchTerm,
|
|
|
|
|
setModalSearchTerm,
|
|
|
|
|
currentTableName,
|
|
|
|
|
allComponents = [],
|
|
|
|
|
handleUpdateProperty,
|
|
|
|
|
}) => {
|
|
|
|
|
const action = config.action || {};
|
|
|
|
|
|
|
|
|
|
@ -800,7 +903,7 @@ const ActionDetailSection: React.FC<{
|
|
|
|
|
</Button>
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
|
|
|
|
<Command>
|
|
|
|
|
<Command shouldFilter={false}>
|
|
|
|
|
<CommandInput
|
|
|
|
|
placeholder="화면 검색..."
|
|
|
|
|
value={modalSearchTerm}
|
|
|
|
|
@ -812,8 +915,10 @@ const ActionDetailSection: React.FC<{
|
|
|
|
|
<CommandGroup>
|
|
|
|
|
{screens
|
|
|
|
|
.filter((s) =>
|
|
|
|
|
!modalSearchTerm ||
|
|
|
|
|
s.name.toLowerCase().includes(modalSearchTerm.toLowerCase()) ||
|
|
|
|
|
s.description?.toLowerCase().includes(modalSearchTerm.toLowerCase())
|
|
|
|
|
s.description?.toLowerCase().includes(modalSearchTerm.toLowerCase()) ||
|
|
|
|
|
String(s.id).includes(modalSearchTerm)
|
|
|
|
|
)
|
|
|
|
|
.map((screen) => (
|
|
|
|
|
<CommandItem
|
|
|
|
|
@ -951,6 +1056,190 @@ const ActionDetailSection: React.FC<{
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "transferData":
|
|
|
|
|
return (
|
|
|
|
|
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<SendHorizontal className="h-4 w-4 text-primary" />
|
|
|
|
|
<span className="text-sm font-medium">데이터 전달 설정</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 소스 컴포넌트 선택 */}
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-xs">
|
|
|
|
|
소스 컴포넌트 <span className="text-destructive">*</span>
|
|
|
|
|
</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={action.dataTransfer?.sourceComponentId || ""}
|
|
|
|
|
onValueChange={(v) => {
|
|
|
|
|
const dt = { ...action.dataTransfer, sourceComponentId: v };
|
|
|
|
|
updateActionConfig("dataTransfer", dt);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-8 text-xs">
|
|
|
|
|
<SelectValue placeholder="데이터를 가져올 컴포넌트 선택" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
<SelectItem value="__auto__">
|
|
|
|
|
<span className="text-xs font-medium">자동 탐색 (현재 활성 테이블)</span>
|
|
|
|
|
</SelectItem>
|
|
|
|
|
{allComponents
|
|
|
|
|
.filter((comp: any) => {
|
|
|
|
|
const type = comp.componentType || comp.type || "";
|
|
|
|
|
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) =>
|
|
|
|
|
type.includes(t)
|
|
|
|
|
);
|
|
|
|
|
})
|
|
|
|
|
.map((comp: any) => {
|
|
|
|
|
const compType = comp.componentType || comp.type || "unknown";
|
|
|
|
|
const compLabel = comp.label || comp.componentConfig?.title || comp.id;
|
|
|
|
|
return (
|
|
|
|
|
<SelectItem key={comp.id} value={comp.id}>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="text-xs font-medium">{compLabel}</span>
|
|
|
|
|
<span className="text-muted-foreground text-[10px]">({compType})</span>
|
|
|
|
|
</div>
|
|
|
|
|
</SelectItem>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 타겟 타입 */}
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-xs">
|
|
|
|
|
타겟 타입 <span className="text-destructive">*</span>
|
|
|
|
|
</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={action.dataTransfer?.targetType || "component"}
|
|
|
|
|
onValueChange={(v) => {
|
|
|
|
|
const dt = { ...action.dataTransfer, targetType: v };
|
|
|
|
|
updateActionConfig("dataTransfer", dt);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-8 text-xs">
|
|
|
|
|
<SelectValue />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
<SelectItem value="component">같은 화면의 컴포넌트</SelectItem>
|
|
|
|
|
<SelectItem value="splitPanel">분할 패널 반대편 화면</SelectItem>
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 타겟 컴포넌트 선택 */}
|
|
|
|
|
{action.dataTransfer?.targetType === "component" && (
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-xs">
|
|
|
|
|
타겟 컴포넌트 <span className="text-destructive">*</span>
|
|
|
|
|
</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={action.dataTransfer?.targetComponentId || ""}
|
|
|
|
|
onValueChange={(v) => {
|
|
|
|
|
const dt = { ...action.dataTransfer, targetComponentId: v };
|
|
|
|
|
updateActionConfig("dataTransfer", dt);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<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 !== 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-muted-foreground text-[10px]">({compType})</span>
|
|
|
|
|
</div>
|
|
|
|
|
</SelectItem>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 데이터 전달 모드 */}
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-xs">데이터 전달 모드</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={action.dataTransfer?.mode || "append"}
|
|
|
|
|
onValueChange={(v) => {
|
|
|
|
|
const dt = { ...action.dataTransfer, mode: v };
|
|
|
|
|
updateActionConfig("dataTransfer", dt);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<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>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 전달 후 초기화 */}
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-xs font-medium">전달 후 소스 선택 초기화</p>
|
|
|
|
|
<p className="text-[10px] text-muted-foreground">데이터 전달 후 소스의 선택을 해제해요</p>
|
|
|
|
|
</div>
|
|
|
|
|
<Switch
|
|
|
|
|
checked={action.dataTransfer?.clearAfterTransfer || false}
|
|
|
|
|
onCheckedChange={(checked) => {
|
|
|
|
|
const dt = { ...action.dataTransfer, clearAfterTransfer: checked };
|
|
|
|
|
updateActionConfig("dataTransfer", dt);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 전달 전 확인 */}
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-xs font-medium">전달 전 확인 메시지</p>
|
|
|
|
|
<p className="text-[10px] text-muted-foreground">전달 전 확인 다이얼로그를 표시해요</p>
|
|
|
|
|
</div>
|
|
|
|
|
<Switch
|
|
|
|
|
checked={action.dataTransfer?.confirmBeforeTransfer || false}
|
|
|
|
|
onCheckedChange={(checked) => {
|
|
|
|
|
const dt = { ...action.dataTransfer, confirmBeforeTransfer: checked };
|
|
|
|
|
updateActionConfig("dataTransfer", dt);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{action.dataTransfer?.confirmBeforeTransfer && (
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-xs">확인 메시지</Label>
|
|
|
|
|
<Input
|
|
|
|
|
value={action.dataTransfer?.confirmMessage || ""}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
const dt = { ...action.dataTransfer, confirmMessage: e.target.value };
|
|
|
|
|
updateActionConfig("dataTransfer", dt);
|
|
|
|
|
}}
|
|
|
|
|
placeholder="선택한 항목을 전달하시겠습니까?"
|
|
|
|
|
className="h-7 text-xs"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{commonMessageSection}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "event":
|
|
|
|
|
return (
|
|
|
|
|
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
|
|
|
|
@ -1373,6 +1662,386 @@ const IconSettingsSection: React.FC<{
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ─── 데이터 전달 필드 매핑 서브 컴포넌트 (고급 설정 내부) ───
|
|
|
|
|
const TransferDataFieldMappingSection: React.FC<{
|
|
|
|
|
config: Record<string, any>;
|
|
|
|
|
onChange: (config: Record<string, any>) => void;
|
|
|
|
|
availableTables: Array<{ name: string; label: string }>;
|
|
|
|
|
mappingSourceColumnsMap: Record<string, Array<{ name: string; label: string }>>;
|
|
|
|
|
setMappingSourceColumnsMap: React.Dispatch<React.SetStateAction<Record<string, Array<{ name: string; label: string }>>>>;
|
|
|
|
|
mappingTargetColumns: Array<{ name: string; label: string }>;
|
|
|
|
|
fieldMappingOpen: boolean;
|
|
|
|
|
setFieldMappingOpen: (open: boolean) => void;
|
|
|
|
|
activeMappingGroupIndex: number;
|
|
|
|
|
setActiveMappingGroupIndex: (index: number) => void;
|
|
|
|
|
loadTableColumns: (tableName: string) => Promise<Array<{ name: string; label: string }>>;
|
|
|
|
|
}> = ({
|
|
|
|
|
config,
|
|
|
|
|
onChange,
|
|
|
|
|
availableTables,
|
|
|
|
|
mappingSourceColumnsMap,
|
|
|
|
|
setMappingSourceColumnsMap,
|
|
|
|
|
mappingTargetColumns,
|
|
|
|
|
activeMappingGroupIndex,
|
|
|
|
|
setActiveMappingGroupIndex,
|
|
|
|
|
loadTableColumns,
|
|
|
|
|
}) => {
|
|
|
|
|
const [sourcePopoverOpen, setSourcePopoverOpen] = useState<Record<string, boolean>>({});
|
|
|
|
|
const [targetPopoverOpen, setTargetPopoverOpen] = useState<Record<string, boolean>>({});
|
|
|
|
|
|
|
|
|
|
const dataTransfer = config.action?.dataTransfer || {};
|
|
|
|
|
const multiTableMappings: Array<{ sourceTable: string; mappingRules: Array<{ sourceField: string; targetField: string }> }> =
|
|
|
|
|
dataTransfer.multiTableMappings || [];
|
|
|
|
|
|
|
|
|
|
const updateDataTransfer = (field: string, value: any) => {
|
|
|
|
|
const currentAction = config.action || {};
|
|
|
|
|
const currentDt = currentAction.dataTransfer || {};
|
|
|
|
|
onChange({
|
|
|
|
|
...config,
|
|
|
|
|
action: {
|
|
|
|
|
...currentAction,
|
|
|
|
|
dataTransfer: { ...currentDt, [field]: value },
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const activeGroup = multiTableMappings[activeMappingGroupIndex];
|
|
|
|
|
const activeSourceTable = activeGroup?.sourceTable || "";
|
|
|
|
|
const activeSourceColumns = mappingSourceColumnsMap[activeSourceTable] || [];
|
|
|
|
|
const activeRules = activeGroup?.mappingRules || [];
|
|
|
|
|
|
|
|
|
|
const updateGroupField = (field: string, value: any) => {
|
|
|
|
|
const mappings = [...multiTableMappings];
|
|
|
|
|
mappings[activeMappingGroupIndex] = { ...mappings[activeMappingGroupIndex], [field]: value };
|
|
|
|
|
updateDataTransfer("multiTableMappings", mappings);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
<div className="space-y-0.5">
|
|
|
|
|
<p className="text-sm font-medium">필드 매핑</p>
|
|
|
|
|
<p className="text-[11px] text-muted-foreground">
|
|
|
|
|
레이어별로 소스 테이블이 다를 때 각각 매핑 규칙을 설정해요
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 타겟 테이블 (공통) */}
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-xs">타겟 테이블 (공통)</Label>
|
|
|
|
|
<Popover>
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
|
|
|
|
|
{dataTransfer.targetTable
|
|
|
|
|
? availableTables.find((t) => t.name === dataTransfer.targetTable)?.label ||
|
|
|
|
|
dataTransfer.targetTable
|
|
|
|
|
: "타겟 테이블 선택"}
|
|
|
|
|
<ChevronsUpDown className="ml-2 h-3 w-3 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 className="max-h-48">
|
|
|
|
|
<CommandEmpty className="py-3 text-xs">테이블을 찾을 수 없습니다</CommandEmpty>
|
|
|
|
|
<CommandGroup>
|
|
|
|
|
{availableTables.map((table) => (
|
|
|
|
|
<CommandItem
|
|
|
|
|
key={table.name}
|
|
|
|
|
value={`${table.label} ${table.name}`}
|
|
|
|
|
onSelect={() => updateDataTransfer("targetTable", table.name)}
|
|
|
|
|
className="text-xs"
|
|
|
|
|
>
|
|
|
|
|
<Check className={cn("mr-2 h-3 w-3", dataTransfer.targetTable === table.name ? "opacity-100" : "opacity-0")} />
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
<span className="font-medium">{table.label}</span>
|
|
|
|
|
{table.label !== table.name && <span className="text-[10px] text-muted-foreground">{table.name}</span>}
|
|
|
|
|
</div>
|
|
|
|
|
</CommandItem>
|
|
|
|
|
))}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
</CommandList>
|
|
|
|
|
</Command>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 소스 테이블 그룹 탭 + 추가 버튼 */}
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<Label className="text-xs">소스 테이블별 매핑</Label>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="h-7 text-xs"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
updateDataTransfer("multiTableMappings", [
|
|
|
|
|
...multiTableMappings,
|
|
|
|
|
{ sourceTable: "", mappingRules: [] },
|
|
|
|
|
]);
|
|
|
|
|
setActiveMappingGroupIndex(multiTableMappings.length);
|
|
|
|
|
}}
|
|
|
|
|
disabled={!dataTransfer.targetTable}
|
|
|
|
|
>
|
|
|
|
|
<Plus className="mr-1 h-3.5 w-3.5" />
|
|
|
|
|
소스 테이블 추가
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{!dataTransfer.targetTable ? (
|
|
|
|
|
<div className="rounded-lg border border-dashed p-4 text-center">
|
|
|
|
|
<p className="text-xs text-muted-foreground">타겟 테이블을 먼저 선택하세요</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : multiTableMappings.length === 0 ? (
|
|
|
|
|
<div className="rounded-lg border border-dashed p-4 text-center">
|
|
|
|
|
<p className="text-xs text-muted-foreground">소스 테이블을 추가하세요</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
{/* 그룹 탭 */}
|
|
|
|
|
<div className="flex flex-wrap gap-1.5">
|
|
|
|
|
{multiTableMappings.map((group, gIdx) => (
|
|
|
|
|
<div key={gIdx} className="flex items-center">
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant={activeMappingGroupIndex === gIdx ? "default" : "outline"}
|
|
|
|
|
size="sm"
|
|
|
|
|
className="h-7 text-xs rounded-r-none"
|
|
|
|
|
onClick={() => setActiveMappingGroupIndex(gIdx)}
|
|
|
|
|
>
|
|
|
|
|
{group.sourceTable
|
|
|
|
|
? availableTables.find((t) => t.name === group.sourceTable)?.label || group.sourceTable
|
|
|
|
|
: `그룹 ${gIdx + 1}`}
|
|
|
|
|
{group.mappingRules?.length > 0 && (
|
|
|
|
|
<span className="ml-1.5 rounded-full bg-primary-foreground/20 px-1.5 text-[10px]">
|
|
|
|
|
{group.mappingRules.length}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant={activeMappingGroupIndex === gIdx ? "default" : "outline"}
|
|
|
|
|
size="icon"
|
|
|
|
|
className="h-7 w-7 rounded-l-none border-l-0"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
const mappings = [...multiTableMappings];
|
|
|
|
|
mappings.splice(gIdx, 1);
|
|
|
|
|
updateDataTransfer("multiTableMappings", mappings);
|
|
|
|
|
if (activeMappingGroupIndex >= mappings.length) {
|
|
|
|
|
setActiveMappingGroupIndex(Math.max(0, mappings.length - 1));
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<X className="h-3 w-3" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 활성 그룹 편집 */}
|
|
|
|
|
{activeGroup && (
|
|
|
|
|
<div className="space-y-3 rounded-lg border p-3">
|
|
|
|
|
{/* 소스 테이블 선택 */}
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-xs">소스 테이블</Label>
|
|
|
|
|
<Popover>
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
|
|
|
|
|
{activeSourceTable
|
|
|
|
|
? availableTables.find((t) => t.name === activeSourceTable)?.label || activeSourceTable
|
|
|
|
|
: "소스 테이블 선택"}
|
|
|
|
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
|
|
|
|
</Button>
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
|
|
|
|
<Command>
|
|
|
|
|
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
|
|
|
|
<CommandList className="max-h-48">
|
|
|
|
|
<CommandEmpty className="py-3 text-xs">테이블을 찾을 수 없습니다</CommandEmpty>
|
|
|
|
|
<CommandGroup>
|
|
|
|
|
{availableTables.map((table) => (
|
|
|
|
|
<CommandItem
|
|
|
|
|
key={table.name}
|
|
|
|
|
value={`${table.label} ${table.name}`}
|
|
|
|
|
onSelect={async () => {
|
|
|
|
|
updateGroupField("sourceTable", table.name);
|
|
|
|
|
if (!mappingSourceColumnsMap[table.name]) {
|
|
|
|
|
const cols = await loadTableColumns(table.name);
|
|
|
|
|
setMappingSourceColumnsMap((prev) => ({ ...prev, [table.name]: cols }));
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
className="text-xs"
|
|
|
|
|
>
|
|
|
|
|
<Check className={cn("mr-2 h-3 w-3", activeSourceTable === table.name ? "opacity-100" : "opacity-0")} />
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
<span className="font-medium">{table.label}</span>
|
|
|
|
|
{table.label !== table.name && <span className="text-[10px] text-muted-foreground">{table.name}</span>}
|
|
|
|
|
</div>
|
|
|
|
|
</CommandItem>
|
|
|
|
|
))}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
</CommandList>
|
|
|
|
|
</Command>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 매핑 규칙 */}
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<Label className="text-xs">매핑 규칙</Label>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="h-7 text-xs"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
updateGroupField("mappingRules", [
|
|
|
|
|
...activeRules,
|
|
|
|
|
{ sourceField: "", targetField: "" },
|
|
|
|
|
]);
|
|
|
|
|
}}
|
|
|
|
|
disabled={!activeSourceTable}
|
|
|
|
|
>
|
|
|
|
|
<Plus className="mr-1 h-3.5 w-3.5" />
|
|
|
|
|
추가
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{!activeSourceTable ? (
|
|
|
|
|
<p className="text-xs text-muted-foreground">소스 테이블을 먼저 선택하세요</p>
|
|
|
|
|
) : activeRules.length === 0 ? (
|
|
|
|
|
<p className="text-xs text-muted-foreground">매핑 없음 (동일 필드명 자동 매핑)</p>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
{activeRules.map((rule: any, rIdx: number) => {
|
|
|
|
|
const keyS = `${activeMappingGroupIndex}-${rIdx}-s`;
|
|
|
|
|
const keyT = `${activeMappingGroupIndex}-${rIdx}-t`;
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={rIdx}
|
|
|
|
|
className="grid items-center gap-1.5"
|
|
|
|
|
style={{ gridTemplateColumns: "1fr 16px 1fr 32px" }}
|
|
|
|
|
>
|
|
|
|
|
{/* 소스 필드 */}
|
|
|
|
|
<Popover
|
|
|
|
|
open={sourcePopoverOpen[keyS] || false}
|
|
|
|
|
onOpenChange={(open) => setSourcePopoverOpen((prev) => ({ ...prev, [keyS]: open }))}
|
|
|
|
|
>
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs overflow-hidden">
|
|
|
|
|
<span className="truncate">
|
|
|
|
|
{rule.sourceField
|
|
|
|
|
? activeSourceColumns.find((c) => c.name === rule.sourceField)?.label || rule.sourceField
|
|
|
|
|
: "소스 컬럼"}
|
|
|
|
|
</span>
|
|
|
|
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
|
|
|
|
</Button>
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
<PopoverContent className="w-[220px] p-0" align="start">
|
|
|
|
|
<Command>
|
|
|
|
|
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
|
|
|
|
<CommandList className="max-h-48">
|
|
|
|
|
<CommandEmpty className="py-3 text-xs">찾을 수 없습니다</CommandEmpty>
|
|
|
|
|
<CommandGroup>
|
|
|
|
|
{activeSourceColumns.map((col) => (
|
|
|
|
|
<CommandItem
|
|
|
|
|
key={col.name}
|
|
|
|
|
value={`${col.label} ${col.name}`}
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
const newRules = [...activeRules];
|
|
|
|
|
newRules[rIdx] = { ...newRules[rIdx], sourceField: col.name };
|
|
|
|
|
updateGroupField("mappingRules", newRules);
|
|
|
|
|
setSourcePopoverOpen((prev) => ({ ...prev, [keyS]: false }));
|
|
|
|
|
}}
|
|
|
|
|
className="text-xs"
|
|
|
|
|
>
|
|
|
|
|
<Check className={cn("mr-2 h-3 w-3", rule.sourceField === col.name ? "opacity-100" : "opacity-0")} />
|
|
|
|
|
<span className="font-medium">{col.label}</span>
|
|
|
|
|
{col.label !== col.name && <span className="ml-1 text-muted-foreground">({col.name})</span>}
|
|
|
|
|
</CommandItem>
|
|
|
|
|
))}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
</CommandList>
|
|
|
|
|
</Command>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
|
|
|
|
|
|
|
|
|
<ArrowRight className="mx-auto h-4 w-4 text-muted-foreground" />
|
|
|
|
|
|
|
|
|
|
{/* 타겟 필드 */}
|
|
|
|
|
<Popover
|
|
|
|
|
open={targetPopoverOpen[keyT] || false}
|
|
|
|
|
onOpenChange={(open) => setTargetPopoverOpen((prev) => ({ ...prev, [keyT]: open }))}
|
|
|
|
|
>
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs overflow-hidden">
|
|
|
|
|
<span className="truncate">
|
|
|
|
|
{rule.targetField
|
|
|
|
|
? mappingTargetColumns.find((c) => c.name === rule.targetField)?.label || rule.targetField
|
|
|
|
|
: "타겟 컬럼"}
|
|
|
|
|
</span>
|
|
|
|
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
|
|
|
|
</Button>
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
<PopoverContent className="w-[220px] p-0" align="start">
|
|
|
|
|
<Command>
|
|
|
|
|
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
|
|
|
|
<CommandList className="max-h-48">
|
|
|
|
|
<CommandEmpty className="py-3 text-xs">찾을 수 없습니다</CommandEmpty>
|
|
|
|
|
<CommandGroup>
|
|
|
|
|
{mappingTargetColumns.map((col) => (
|
|
|
|
|
<CommandItem
|
|
|
|
|
key={col.name}
|
|
|
|
|
value={`${col.label} ${col.name}`}
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
const newRules = [...activeRules];
|
|
|
|
|
newRules[rIdx] = { ...newRules[rIdx], targetField: col.name };
|
|
|
|
|
updateGroupField("mappingRules", newRules);
|
|
|
|
|
setTargetPopoverOpen((prev) => ({ ...prev, [keyT]: false }));
|
|
|
|
|
}}
|
|
|
|
|
className="text-xs"
|
|
|
|
|
>
|
|
|
|
|
<Check className={cn("mr-2 h-3 w-3", rule.targetField === col.name ? "opacity-100" : "opacity-0")} />
|
|
|
|
|
<span className="font-medium">{col.label}</span>
|
|
|
|
|
{col.label !== col.name && <span className="ml-1 text-muted-foreground">({col.name})</span>}
|
|
|
|
|
</CommandItem>
|
|
|
|
|
))}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
</CommandList>
|
|
|
|
|
</Command>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
|
|
|
|
|
|
|
|
|
{/* 삭제 */}
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
className="h-8 w-8 text-destructive hover:bg-destructive/10"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
const newRules = [...activeRules];
|
|
|
|
|
newRules.splice(rIdx, 1);
|
|
|
|
|
updateGroupField("mappingRules", newRules);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<X className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
V2ButtonConfigPanel.displayName = "V2ButtonConfigPanel";
|
|
|
|
|
|
|
|
|
|
export default V2ButtonConfigPanel;
|
|
|
|
|
|