1543 lines
58 KiB
TypeScript
1543 lines
58 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* V2 SelectedItemsDetailInput 설정 패널
|
|
* 토스식 단계별 UX: 테이블 설정 -> 컬럼/필드 관리 -> 고급 설정(접힘)
|
|
*/
|
|
|
|
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Collapsible,
|
|
CollapsibleContent,
|
|
CollapsibleTrigger,
|
|
} from "@/components/ui/collapsible";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
} from "@/components/ui/command";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
Database,
|
|
Table2,
|
|
Columns3,
|
|
Plus,
|
|
Trash2,
|
|
Settings,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Check,
|
|
ChevronsUpDown,
|
|
Link2,
|
|
Layers,
|
|
LayoutGrid,
|
|
LayoutList,
|
|
FolderPlus,
|
|
Calculator,
|
|
} from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import type { ComponentData } from "@/types/screen";
|
|
import type {
|
|
SelectedItemsDetailInputConfig,
|
|
AdditionalFieldDefinition,
|
|
FieldGroup,
|
|
AutoDetectedFk,
|
|
ParentDataMapping,
|
|
} from "@/lib/registry/components/selected-items-detail-input/types";
|
|
|
|
// ─── 테이블 컬럼 타입 ───
|
|
interface ColumnInfo {
|
|
columnName: string;
|
|
columnLabel?: string;
|
|
dataType?: string;
|
|
inputType?: string;
|
|
codeCategory?: string;
|
|
referenceTable?: string;
|
|
referenceColumn?: string;
|
|
}
|
|
|
|
// ─── 레이아웃 카드 정의 ───
|
|
const LAYOUT_CARDS = [
|
|
{
|
|
value: "grid",
|
|
icon: LayoutGrid,
|
|
title: "테이블 형식",
|
|
description: "행 단위 데이터 표시",
|
|
},
|
|
{
|
|
value: "card",
|
|
icon: LayoutList,
|
|
title: "카드 형식",
|
|
description: "각 항목을 카드로 표시",
|
|
},
|
|
] as const;
|
|
|
|
// ─── 입력 모드 카드 정의 ───
|
|
const INPUT_MODE_CARDS = [
|
|
{
|
|
value: "inline",
|
|
icon: Columns3,
|
|
title: "항상 표시",
|
|
description: "입력창을 항상 표시",
|
|
},
|
|
{
|
|
value: "modal",
|
|
icon: Layers,
|
|
title: "추가 버튼",
|
|
description: "클릭 시 입력, 완료 후 카드",
|
|
},
|
|
] as const;
|
|
|
|
// ─── Props ───
|
|
export interface V2SelectedItemsDetailInputConfigPanelProps {
|
|
config: Record<string, any>;
|
|
onChange: (config: Record<string, any>) => void;
|
|
allComponents?: ComponentData[];
|
|
currentComponent?: ComponentData;
|
|
screenTableName?: string;
|
|
allTables?: Array<{ tableName: string; displayName?: string }>;
|
|
onUpdateProperty?: (id: string, key: string, value: any) => void;
|
|
}
|
|
|
|
/**
|
|
* Combobox 서브컴포넌트 - 테이블 선택용
|
|
*/
|
|
const TableCombobox: React.FC<{
|
|
value: string;
|
|
tables: Array<{ tableName: string; displayName?: string }>;
|
|
placeholder: string;
|
|
onSelect: (tableName: string) => void;
|
|
}> = ({ value, tables, placeholder, onSelect }) => {
|
|
const [open, setOpen] = useState(false);
|
|
const [search, setSearch] = useState("");
|
|
|
|
const filtered = useMemo(() => {
|
|
if (!search) return tables;
|
|
const s = search.toLowerCase();
|
|
return tables.filter(
|
|
(t) =>
|
|
t.tableName.toLowerCase().includes(s) ||
|
|
t.displayName?.toLowerCase().includes(s),
|
|
);
|
|
}, [tables, search]);
|
|
|
|
const selectedLabel = useMemo(() => {
|
|
if (!value) return placeholder;
|
|
const t = tables.find((t) => t.tableName === value);
|
|
return t ? t.displayName || t.tableName : value;
|
|
}, [value, tables, placeholder]);
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={open}
|
|
className="h-8 w-full justify-between text-xs sm:text-sm"
|
|
>
|
|
{selectedLabel}
|
|
<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="테이블 검색..."
|
|
value={search}
|
|
onValueChange={setSearch}
|
|
className="text-xs sm:text-sm"
|
|
/>
|
|
<CommandList>
|
|
<CommandEmpty className="text-xs">
|
|
테이블을 찾을 수 없습니다.
|
|
</CommandEmpty>
|
|
<CommandGroup className="max-h-48 overflow-auto">
|
|
{filtered.map((t) => (
|
|
<CommandItem
|
|
key={t.tableName}
|
|
value={t.tableName}
|
|
onSelect={(v) => {
|
|
onSelect(v);
|
|
setOpen(false);
|
|
setSearch("");
|
|
}}
|
|
className="text-xs sm:text-sm"
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-3 w-3",
|
|
value === t.tableName ? "opacity-100" : "opacity-0",
|
|
)}
|
|
/>
|
|
<div className="flex flex-col">
|
|
<span>{t.displayName || t.tableName}</span>
|
|
{t.displayName && (
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{t.tableName}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Combobox 서브컴포넌트 - 컬럼 선택용
|
|
*/
|
|
const ColumnCombobox: React.FC<{
|
|
value: string;
|
|
columns: ColumnInfo[];
|
|
placeholder: string;
|
|
disabled?: boolean;
|
|
onSelect: (columnName: string, column: ColumnInfo) => void;
|
|
}> = ({ value, columns, placeholder, disabled, onSelect }) => {
|
|
const [open, setOpen] = useState(false);
|
|
|
|
const selectedLabel = useMemo(() => {
|
|
if (!value) return placeholder;
|
|
const c = columns.find((c) => c.columnName === value);
|
|
return c ? c.columnLabel || c.columnName : value;
|
|
}, [value, columns, placeholder]);
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={open}
|
|
disabled={disabled}
|
|
className="h-7 w-full justify-between text-xs"
|
|
>
|
|
{selectedLabel}
|
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-[250px] p-0" align="start">
|
|
<Command>
|
|
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
|
<CommandList>
|
|
<CommandEmpty className="text-xs">
|
|
컬럼을 찾을 수 없습니다.
|
|
</CommandEmpty>
|
|
<CommandGroup className="max-h-48 overflow-auto">
|
|
{columns.map((c) => (
|
|
<CommandItem
|
|
key={c.columnName}
|
|
value={`${c.columnLabel || c.columnName} ${c.columnName}`}
|
|
onSelect={() => {
|
|
onSelect(c.columnName, c);
|
|
setOpen(false);
|
|
}}
|
|
className="text-xs"
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-3 w-3",
|
|
value === c.columnName ? "opacity-100" : "opacity-0",
|
|
)}
|
|
/>
|
|
<div className="flex flex-col">
|
|
<span className="font-medium">
|
|
{c.columnLabel || c.columnName}
|
|
</span>
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{c.columnName}
|
|
{c.dataType ? ` (${c.dataType})` : ""}
|
|
</span>
|
|
</div>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* V2 SelectedItemsDetailInput 설정 패널 (토스식 UX)
|
|
*/
|
|
export const V2SelectedItemsDetailInputConfigPanel: React.FC<
|
|
V2SelectedItemsDetailInputConfigPanelProps
|
|
> = ({
|
|
config: rawConfig,
|
|
onChange,
|
|
allComponents = [],
|
|
currentComponent,
|
|
screenTableName,
|
|
allTables = [],
|
|
onUpdateProperty,
|
|
}) => {
|
|
const config = rawConfig as SelectedItemsDetailInputConfig;
|
|
|
|
// ─── 핸들러 ───
|
|
const handleChange = useCallback(
|
|
(key: string, value: any) => {
|
|
onChange({ ...config, [key]: value });
|
|
},
|
|
[config, onChange],
|
|
);
|
|
|
|
// ─── 테이블 컬럼 로드 상태 ───
|
|
const [sourceColumns, setSourceColumns] = useState<ColumnInfo[]>([]);
|
|
const [targetColumns, setTargetColumns] = useState<ColumnInfo[]>([]);
|
|
const [autoDetectedFks, setAutoDetectedFks] = useState<AutoDetectedFk[]>([]);
|
|
const fkAutoAppliedRef = useRef(false);
|
|
|
|
// ─── 테이블 컬럼 로드 ───
|
|
const loadColumns = useCallback(async (tableName: string): Promise<ColumnInfo[]> => {
|
|
if (!tableName) return [];
|
|
try {
|
|
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
|
const response = await tableManagementApi.getColumnList(tableName);
|
|
if (response.success && response.data) {
|
|
return (response.data.columns || []).map((col: any) => ({
|
|
columnName: col.columnName,
|
|
columnLabel: col.displayName || col.columnLabel || col.columnName,
|
|
dataType: col.dataType,
|
|
inputType: col.inputType,
|
|
codeCategory: col.codeCategory,
|
|
referenceTable: col.referenceTable,
|
|
referenceColumn: col.referenceColumn,
|
|
}));
|
|
}
|
|
} catch (error) {
|
|
console.error("컬럼 로드 오류:", tableName, error);
|
|
}
|
|
return [];
|
|
}, []);
|
|
|
|
// 원본 테이블 컬럼 로드
|
|
useEffect(() => {
|
|
if (!config.sourceTable) {
|
|
setSourceColumns([]);
|
|
return;
|
|
}
|
|
loadColumns(config.sourceTable).then(setSourceColumns);
|
|
}, [config.sourceTable, loadColumns]);
|
|
|
|
// 대상 테이블 컬럼 로드
|
|
useEffect(() => {
|
|
if (!config.targetTable) {
|
|
setTargetColumns([]);
|
|
setAutoDetectedFks([]);
|
|
return;
|
|
}
|
|
loadColumns(config.targetTable).then(setTargetColumns);
|
|
}, [config.targetTable, loadColumns]);
|
|
|
|
// FK 자동 감지
|
|
const detectedFks = useMemo<AutoDetectedFk[]>(() => {
|
|
if (!config.targetTable || targetColumns.length === 0) return [];
|
|
const entityFkColumns = targetColumns.filter(
|
|
(col) => col.inputType === "entity" && col.referenceTable,
|
|
);
|
|
if (entityFkColumns.length === 0) return [];
|
|
|
|
return entityFkColumns.map((col) => {
|
|
let mappingType: "source" | "parent" | "unknown" = "unknown";
|
|
if (config.sourceTable && col.referenceTable === config.sourceTable) {
|
|
mappingType = "source";
|
|
} else if (config.sourceTable && col.referenceTable !== config.sourceTable) {
|
|
mappingType = "parent";
|
|
}
|
|
return {
|
|
columnName: col.columnName,
|
|
columnLabel: col.columnLabel,
|
|
referenceTable: col.referenceTable!,
|
|
referenceColumn: col.referenceColumn || "id",
|
|
mappingType,
|
|
};
|
|
});
|
|
}, [config.targetTable, config.sourceTable, targetColumns]);
|
|
|
|
useEffect(() => {
|
|
setAutoDetectedFks(detectedFks);
|
|
}, [detectedFks]);
|
|
|
|
// targetTable 변경 시 FK 자동 적용 리셋
|
|
useEffect(() => {
|
|
fkAutoAppliedRef.current = false;
|
|
}, [config.targetTable]);
|
|
|
|
// FK 자동 매핑 적용 (최초 1회)
|
|
useEffect(() => {
|
|
if (fkAutoAppliedRef.current || detectedFks.length === 0) return;
|
|
|
|
const sourceFk = detectedFks.find((fk) => fk.mappingType === "source");
|
|
const parentFks = detectedFks.filter((fk) => fk.mappingType === "parent");
|
|
let changed = false;
|
|
|
|
if (sourceFk && !config.sourceKeyField) {
|
|
handleChange("sourceKeyField", sourceFk.columnName);
|
|
changed = true;
|
|
}
|
|
|
|
if (
|
|
parentFks.length > 0 &&
|
|
(!config.parentDataMapping || config.parentDataMapping.length === 0)
|
|
) {
|
|
const autoMappings: ParentDataMapping[] = parentFks.map((fk) => ({
|
|
sourceTable: fk.referenceTable,
|
|
sourceField: "id",
|
|
targetField: fk.columnName,
|
|
}));
|
|
handleChange("parentDataMapping", autoMappings);
|
|
changed = true;
|
|
}
|
|
|
|
if (changed) {
|
|
fkAutoAppliedRef.current = true;
|
|
}
|
|
}, [detectedFks, config.sourceKeyField, config.parentDataMapping, handleChange]);
|
|
|
|
// ─── 필드 관련 로컬 상태 ───
|
|
const localFields = useMemo<AdditionalFieldDefinition[]>(
|
|
() => config.additionalFields || [],
|
|
[config.additionalFields],
|
|
);
|
|
|
|
const displayColumns = useMemo<Array<{ name: string; label: string; width?: string }>>(
|
|
() => config.displayColumns || [],
|
|
[config.displayColumns],
|
|
);
|
|
|
|
const localFieldGroups = useMemo<FieldGroup[]>(
|
|
() => config.fieldGroups || [],
|
|
[config.fieldGroups],
|
|
);
|
|
|
|
// 사용 가능한 원본 컬럼 (표시용)
|
|
const availableSourceColumns = useMemo(() => {
|
|
const used = new Set(displayColumns.map((c) => c.name));
|
|
return sourceColumns.filter((c) => !used.has(c.columnName));
|
|
}, [sourceColumns, displayColumns]);
|
|
|
|
// 사용 가능한 대상 컬럼 (입력 필드용)
|
|
const availableTargetColumns = useMemo(() => {
|
|
const used = new Set(localFields.map((f) => f.name));
|
|
return targetColumns.filter((c) => !used.has(c.columnName));
|
|
}, [targetColumns, localFields]);
|
|
|
|
// ─── 표시 컬럼 관리 ───
|
|
const addDisplayColumn = useCallback(
|
|
(columnName: string, columnLabel: string) => {
|
|
if (!displayColumns.some((c) => c.name === columnName)) {
|
|
handleChange("displayColumns", [
|
|
...displayColumns,
|
|
{ name: columnName, label: columnLabel },
|
|
]);
|
|
}
|
|
},
|
|
[displayColumns, handleChange],
|
|
);
|
|
|
|
const removeDisplayColumn = useCallback(
|
|
(columnName: string) => {
|
|
handleChange(
|
|
"displayColumns",
|
|
displayColumns.filter((c) => c.name !== columnName),
|
|
);
|
|
},
|
|
[displayColumns, handleChange],
|
|
);
|
|
|
|
// ─── 추가 입력 필드 관리 ───
|
|
const addField = useCallback(() => {
|
|
const newField: AdditionalFieldDefinition = {
|
|
name: `field_${localFields.length + 1}`,
|
|
label: `필드 ${localFields.length + 1}`,
|
|
type: "text",
|
|
};
|
|
handleChange("additionalFields", [...localFields, newField]);
|
|
}, [localFields, handleChange]);
|
|
|
|
const updateField = useCallback(
|
|
(index: number, updates: Partial<AdditionalFieldDefinition>) => {
|
|
const newFields = [...localFields];
|
|
newFields[index] = { ...newFields[index], ...updates };
|
|
handleChange("additionalFields", newFields);
|
|
},
|
|
[localFields, handleChange],
|
|
);
|
|
|
|
const removeField = useCallback(
|
|
(index: number) => {
|
|
handleChange(
|
|
"additionalFields",
|
|
localFields.filter((_, i) => i !== index),
|
|
);
|
|
},
|
|
[localFields, handleChange],
|
|
);
|
|
|
|
// ─── 필드 그룹 관리 ───
|
|
const addFieldGroup = useCallback(() => {
|
|
const newGroup: FieldGroup = {
|
|
id: `group_${localFieldGroups.length + 1}`,
|
|
title: `그룹 ${localFieldGroups.length + 1}`,
|
|
order: localFieldGroups.length,
|
|
};
|
|
handleChange("fieldGroups", [...localFieldGroups, newGroup]);
|
|
}, [localFieldGroups, handleChange]);
|
|
|
|
const updateFieldGroup = useCallback(
|
|
(groupId: string, updates: Partial<FieldGroup>) => {
|
|
const newGroups = localFieldGroups.map((g) =>
|
|
g.id === groupId ? { ...g, ...updates } : g,
|
|
);
|
|
handleChange("fieldGroups", newGroups);
|
|
},
|
|
[localFieldGroups, handleChange],
|
|
);
|
|
|
|
const removeFieldGroup = useCallback(
|
|
(groupId: string) => {
|
|
const updatedFields = localFields.map((f) =>
|
|
f.groupId === groupId ? { ...f, groupId: undefined } : f,
|
|
);
|
|
handleChange("additionalFields", updatedFields);
|
|
handleChange(
|
|
"fieldGroups",
|
|
localFieldGroups.filter((g) => g.id !== groupId),
|
|
);
|
|
},
|
|
[localFields, localFieldGroups, handleChange],
|
|
);
|
|
|
|
// ─── 부모 매핑 관리 ───
|
|
const parentMappings = useMemo<ParentDataMapping[]>(
|
|
() => config.parentDataMapping || [],
|
|
[config.parentDataMapping],
|
|
);
|
|
|
|
// 부모 매핑 소스 컬럼 캐시
|
|
const [mappingSourceColumns, setMappingSourceColumns] = useState<
|
|
Record<number, ColumnInfo[]>
|
|
>({});
|
|
|
|
const addParentMapping = useCallback(() => {
|
|
handleChange("parentDataMapping", [
|
|
...parentMappings,
|
|
{ sourceTable: "", sourceField: "", targetField: "" },
|
|
]);
|
|
}, [parentMappings, handleChange]);
|
|
|
|
const updateParentMapping = useCallback(
|
|
(index: number, updates: Partial<ParentDataMapping>) => {
|
|
const updated = [...parentMappings];
|
|
updated[index] = { ...updated[index], ...updates };
|
|
handleChange("parentDataMapping", updated);
|
|
},
|
|
[parentMappings, handleChange],
|
|
);
|
|
|
|
const removeParentMapping = useCallback(
|
|
(index: number) => {
|
|
handleChange(
|
|
"parentDataMapping",
|
|
parentMappings.filter((_, i) => i !== index),
|
|
);
|
|
},
|
|
[parentMappings, handleChange],
|
|
);
|
|
|
|
// 부모 매핑 소스 컬럼 로드
|
|
const loadMappingColumns = useCallback(
|
|
async (tableName: string, index: number) => {
|
|
const cols = await loadColumns(tableName);
|
|
setMappingSourceColumns((prev) => ({ ...prev, [index]: cols }));
|
|
},
|
|
[loadColumns],
|
|
);
|
|
|
|
// 기존 매핑의 소스 컬럼 초기 로드
|
|
useEffect(() => {
|
|
parentMappings.forEach((mapping, index) => {
|
|
if (mapping.sourceTable && !mappingSourceColumns[index]) {
|
|
loadMappingColumns(mapping.sourceTable, index);
|
|
}
|
|
});
|
|
}, [parentMappings, mappingSourceColumns, loadMappingColumns]);
|
|
|
|
// ─── Collapsible 상태 ───
|
|
const [openSections, setOpenSections] = useState<Record<string, boolean>>({});
|
|
const toggleSection = useCallback((key: string) => {
|
|
setOpenSections((prev) => ({ ...prev, [key]: !prev[key] }));
|
|
}, []);
|
|
|
|
// ─── screenTableName 자동 설정 ───
|
|
useEffect(() => {
|
|
if (screenTableName && !config.targetTable) {
|
|
handleChange("targetTable", screenTableName);
|
|
}
|
|
}, [screenTableName]);
|
|
|
|
// ─── 렌더링 ───
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* ════════ 1단계: 테이블 설정 ════════ */}
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center gap-1.5">
|
|
<Database className="h-3.5 w-3.5 text-primary" />
|
|
<span className="text-xs font-semibold">테이블 설정</span>
|
|
</div>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
데이터의 원본과 저장 대상을 설정해요
|
|
</p>
|
|
</div>
|
|
|
|
{/* 데이터 소스 ID */}
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">데이터 소스 ID</Label>
|
|
<Input
|
|
value={config.dataSourceId || ""}
|
|
onChange={(e) => handleChange("dataSourceId", e.target.value)}
|
|
placeholder="비워두면 URL 파라미터에서 자동 설정"
|
|
className="h-8 text-xs"
|
|
/>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
비워두면 Button에서 자동 전달
|
|
</p>
|
|
</div>
|
|
|
|
{/* 원본 테이블 */}
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">
|
|
원본 데이터 테이블 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<TableCombobox
|
|
value={config.sourceTable || ""}
|
|
tables={allTables}
|
|
placeholder="원본 테이블 선택"
|
|
onSelect={(v) => handleChange("sourceTable", v)}
|
|
/>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
이전 화면에서 전달받은 데이터의 원본 테이블
|
|
</p>
|
|
</div>
|
|
|
|
{/* 저장 대상 테이블 */}
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">
|
|
저장 대상 테이블 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<TableCombobox
|
|
value={config.targetTable || ""}
|
|
tables={allTables}
|
|
placeholder="저장 대상 테이블 선택"
|
|
onSelect={(v) => handleChange("targetTable", v)}
|
|
/>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
최종 데이터를 저장할 테이블
|
|
</p>
|
|
</div>
|
|
|
|
{/* FK 자동 감지 결과 */}
|
|
{autoDetectedFks.length > 0 && (
|
|
<div className="rounded-md border border-primary/20 bg-primary/5 p-3">
|
|
<p className="mb-2 text-xs font-medium text-primary">
|
|
FK 자동 감지 ({autoDetectedFks.length}건)
|
|
</p>
|
|
<div className="space-y-1">
|
|
{autoDetectedFks.map((fk) => (
|
|
<div
|
|
key={fk.columnName}
|
|
className="flex items-center gap-2 text-[10px]"
|
|
>
|
|
<span
|
|
className={cn(
|
|
"rounded px-1.5 py-0.5 font-mono text-[9px]",
|
|
fk.mappingType === "source"
|
|
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300"
|
|
: fk.mappingType === "parent"
|
|
? "bg-amber-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300"
|
|
: "bg-muted text-muted-foreground",
|
|
)}
|
|
>
|
|
{fk.mappingType === "source"
|
|
? "원본"
|
|
: fk.mappingType === "parent"
|
|
? "부모"
|
|
: "미분류"}
|
|
</span>
|
|
<span className="font-mono text-muted-foreground">
|
|
{fk.columnName}
|
|
</span>
|
|
<span className="text-muted-foreground">→</span>
|
|
<span className="font-mono">{fk.referenceTable}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<p className="mt-1.5 text-[9px] text-muted-foreground">
|
|
엔티티 설정 기반 자동 매핑. sourceKeyField와 parentDataMapping이
|
|
자동 설정됩니다.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<Separator />
|
|
|
|
{/* ════════ 2단계: 레이아웃 & 입력 모드 선택 ════════ */}
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center gap-1.5">
|
|
<LayoutGrid className="h-3.5 w-3.5 text-primary" />
|
|
<span className="text-xs font-semibold">레이아웃</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 레이아웃 카드 선택 */}
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{LAYOUT_CARDS.map((card) => {
|
|
const isSelected = (config.layout || "grid") === card.value;
|
|
const Icon = card.icon;
|
|
return (
|
|
<button
|
|
key={card.value}
|
|
type="button"
|
|
onClick={() => handleChange("layout", card.value)}
|
|
className={cn(
|
|
"flex flex-col items-center gap-1 rounded-lg border-2 p-3 text-center transition-all",
|
|
isSelected
|
|
? "border-primary bg-primary/5"
|
|
: "border-border hover:border-primary/50",
|
|
)}
|
|
>
|
|
<Icon
|
|
className={cn(
|
|
"h-5 w-5",
|
|
isSelected ? "text-primary" : "text-muted-foreground",
|
|
)}
|
|
/>
|
|
<span className="text-xs font-medium">{card.title}</span>
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{card.description}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* 입력 모드 카드 선택 */}
|
|
<div className="space-y-1.5">
|
|
<span className="text-xs font-medium text-muted-foreground">
|
|
입력 모드
|
|
</span>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{INPUT_MODE_CARDS.map((card) => {
|
|
const isSelected = (config.inputMode || "inline") === card.value;
|
|
const Icon = card.icon;
|
|
return (
|
|
<button
|
|
key={card.value}
|
|
type="button"
|
|
onClick={() => handleChange("inputMode", card.value)}
|
|
className={cn(
|
|
"flex flex-col items-center gap-1 rounded-lg border-2 p-3 text-center transition-all",
|
|
isSelected
|
|
? "border-primary bg-primary/5"
|
|
: "border-border hover:border-primary/50",
|
|
)}
|
|
>
|
|
<Icon
|
|
className={cn(
|
|
"h-5 w-5",
|
|
isSelected ? "text-primary" : "text-muted-foreground",
|
|
)}
|
|
/>
|
|
<span className="text-xs font-medium">{card.title}</span>
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{card.description}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* ════════ 3단계: 표시 컬럼 (원본 데이터) ════════ */}
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center gap-1.5">
|
|
<Table2 className="h-3.5 w-3.5 text-primary" />
|
|
<span className="text-xs font-semibold">표시 컬럼 (원본 데이터)</span>
|
|
{displayColumns.length > 0 && (
|
|
<Badge variant="secondary" className="text-[10px] h-5">
|
|
{displayColumns.length}개
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
전달받은 원본 데이터 중 화면에 표시할 컬럼
|
|
</p>
|
|
</div>
|
|
|
|
{displayColumns.length > 0 && (
|
|
<div className="space-y-0.5">
|
|
{displayColumns.map((col) => (
|
|
<div
|
|
key={col.name}
|
|
className="flex items-center gap-2 rounded-md border px-3 py-1"
|
|
>
|
|
<span className="text-xs font-medium truncate flex-1">{col.label}</span>
|
|
<Badge variant="outline" className="text-[9px] h-4 shrink-0">
|
|
{col.name}
|
|
</Badge>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => removeDisplayColumn(col.name)}
|
|
className="h-5 w-5 p-0 text-muted-foreground hover:text-destructive shrink-0"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{sourceColumns.length > 0 ? (
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 w-full border-dashed text-xs"
|
|
>
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
컬럼 추가
|
|
</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 className="max-h-48 overflow-auto">
|
|
{availableSourceColumns.map((c) => (
|
|
<CommandItem
|
|
key={c.columnName}
|
|
value={`${c.columnLabel || c.columnName} ${c.columnName}`}
|
|
onSelect={() =>
|
|
addDisplayColumn(
|
|
c.columnName,
|
|
c.columnLabel || c.columnName,
|
|
)
|
|
}
|
|
className="text-xs"
|
|
>
|
|
<div>
|
|
<div className="font-medium">
|
|
{c.columnLabel || c.columnName}
|
|
</div>
|
|
{c.dataType && (
|
|
<div className="text-[10px] text-muted-foreground">
|
|
{c.dataType}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
) : (
|
|
<p className="text-center text-[10px] text-muted-foreground">
|
|
{config.sourceTable
|
|
? "컬럼 로딩 중..."
|
|
: "원본 테이블을 먼저 선택하세요"}
|
|
</p>
|
|
)}
|
|
|
|
<Separator />
|
|
|
|
{/* ════════ 4단계: 추가 입력 필드 (Collapsible) ════════ */}
|
|
<Collapsible
|
|
open={openSections["inputFields"] ?? (localFields.length > 0)}
|
|
onOpenChange={(open) => setOpenSections((prev) => ({ ...prev, inputFields: open }))}
|
|
>
|
|
<CollapsibleTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Columns3 className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-sm font-medium">입력 필드</span>
|
|
<Badge variant="secondary" className="text-[10px] h-5">
|
|
{localFields.length}개
|
|
</Badge>
|
|
</div>
|
|
<ChevronDown
|
|
className={cn(
|
|
"h-4 w-4 text-muted-foreground transition-transform duration-200",
|
|
(openSections["inputFields"] ?? (localFields.length > 0)) && "rotate-180",
|
|
)}
|
|
/>
|
|
</button>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<div className="rounded-b-lg border border-t-0 p-3 space-y-2">
|
|
<p className="text-[10px] text-muted-foreground">
|
|
저장 대상 테이블의 컬럼을 입력 필드로 추가
|
|
</p>
|
|
|
|
{localFields.length > 0 && (
|
|
<div className="space-y-1">
|
|
{localFields.map((field, index) => (
|
|
<Collapsible key={index}>
|
|
<div className="rounded-md border">
|
|
<CollapsibleTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-center gap-1.5 px-2.5 py-1.5 text-left hover:bg-muted/30 transition-colors"
|
|
>
|
|
<ChevronRight className="h-3 w-3 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90 shrink-0" />
|
|
<span className="text-xs font-medium flex-1 truncate min-w-0">
|
|
{field.label || field.name}
|
|
</span>
|
|
<Badge variant="outline" className="text-[9px] h-4 shrink-0">
|
|
{field.inputType || field.type || "text"}
|
|
</Badge>
|
|
{field.required && (
|
|
<span className="text-[10px] text-destructive font-bold shrink-0">*</span>
|
|
)}
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={(e) => { e.stopPropagation(); removeField(index); }}
|
|
className="h-5 w-5 p-0 text-muted-foreground hover:text-destructive shrink-0"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</button>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<div className="space-y-2 border-t px-2.5 py-2">
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">필드명 (컬럼)</Label>
|
|
<ColumnCombobox
|
|
value={field.name}
|
|
columns={availableTargetColumns}
|
|
placeholder="컬럼 선택"
|
|
onSelect={(name, col) =>
|
|
updateField(index, {
|
|
name,
|
|
label: col.columnLabel || name,
|
|
inputType: col.inputType || "text",
|
|
codeCategory: col.codeCategory,
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">라벨</Label>
|
|
<Input
|
|
value={field.label}
|
|
onChange={(e) =>
|
|
updateField(index, { label: e.target.value })
|
|
}
|
|
placeholder="필드 라벨"
|
|
className="h-7 text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">Placeholder</Label>
|
|
<Input
|
|
value={field.placeholder || ""}
|
|
onChange={(e) =>
|
|
updateField(index, { placeholder: e.target.value })
|
|
}
|
|
placeholder="입력 안내"
|
|
className="h-7 text-xs"
|
|
/>
|
|
</div>
|
|
|
|
{localFieldGroups.length > 0 && (
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">필드 그룹</Label>
|
|
<Select
|
|
value={field.groupId || "none"}
|
|
onValueChange={(v) =>
|
|
updateField(index, {
|
|
groupId: v === "none" ? undefined : v,
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger className="h-7 text-xs">
|
|
<SelectValue placeholder="그룹 없음" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none" className="text-xs">
|
|
그룹 없음
|
|
</SelectItem>
|
|
{localFieldGroups.map((g) => (
|
|
<SelectItem key={g.id} value={g.id} className="text-xs">
|
|
{g.title} ({g.id})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center justify-between pt-1">
|
|
<div className="flex items-center gap-2">
|
|
<Switch
|
|
id={`required-${index}`}
|
|
checked={field.required ?? false}
|
|
onCheckedChange={(checked) =>
|
|
updateField(index, { required: checked })
|
|
}
|
|
/>
|
|
<Label
|
|
htmlFor={`required-${index}`}
|
|
className="text-[10px] font-normal"
|
|
>
|
|
필수 입력
|
|
</Label>
|
|
</div>
|
|
{field.autoFillFrom && (
|
|
<span className="text-[9px] text-primary truncate max-w-[120px]">
|
|
자동: {field.autoFillFrom}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CollapsibleContent>
|
|
</div>
|
|
</Collapsible>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={addField}
|
|
className="h-7 w-full border-dashed text-xs"
|
|
>
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
필드 추가
|
|
</Button>
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
|
|
{/* ════════ 5단계: 고급 설정 (서브 Collapsible 통합) ════════ */}
|
|
<Collapsible
|
|
open={openSections["advanced"] ?? false}
|
|
onOpenChange={() => toggleSection("advanced")}
|
|
>
|
|
<CollapsibleTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Settings className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-sm font-medium">고급 설정</span>
|
|
</div>
|
|
<ChevronDown
|
|
className={cn(
|
|
"h-4 w-4 text-muted-foreground transition-transform duration-200",
|
|
openSections["advanced"] && "rotate-180",
|
|
)}
|
|
/>
|
|
</button>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<div className="rounded-b-lg border border-t-0 p-3 space-y-3">
|
|
|
|
{/* ─── 기본 고급 설정 ─── */}
|
|
<div className="space-y-2">
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">원본 키 필드명</Label>
|
|
<ColumnCombobox
|
|
value={config.sourceKeyField || ""}
|
|
columns={targetColumns}
|
|
placeholder="자동 감지 (entity FK)"
|
|
onSelect={(name) => handleChange("sourceKeyField", name)}
|
|
/>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
대상 테이블에서 원본을 참조하는 FK 컬럼
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between py-1">
|
|
<Label className="text-xs">항목 번호 표시</Label>
|
|
<Switch
|
|
checked={config.showIndex ?? true}
|
|
onCheckedChange={(v) => handleChange("showIndex", v)}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-between py-1">
|
|
<Label className="text-xs">항목 삭제 허용</Label>
|
|
<Switch
|
|
checked={config.allowRemove ?? false}
|
|
onCheckedChange={(v) => handleChange("allowRemove", v)}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-between py-1">
|
|
<Label className="text-xs">비활성화</Label>
|
|
<Switch
|
|
checked={config.disabled ?? false}
|
|
onCheckedChange={(v) => handleChange("disabled", v)}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-between py-1">
|
|
<Label className="text-xs">읽기 전용</Label>
|
|
<Switch
|
|
checked={config.readonly ?? false}
|
|
onCheckedChange={(v) => handleChange("readonly", v)}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">빈 상태 메시지</Label>
|
|
<Input
|
|
value={config.emptyMessage || ""}
|
|
onChange={(e) => handleChange("emptyMessage", e.target.value)}
|
|
placeholder="전달받은 데이터가 없습니다."
|
|
className="h-8 text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* ─── 필드 그룹 관리 (서브 Collapsible) ─── */}
|
|
<Collapsible
|
|
open={openSections["fieldGroups"] ?? false}
|
|
onOpenChange={() => toggleSection("fieldGroups")}
|
|
>
|
|
<CollapsibleTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-center justify-between rounded-md border px-3 py-2 transition-colors hover:bg-muted/30"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<FolderPlus className="h-3.5 w-3.5 text-muted-foreground" />
|
|
<span className="text-xs font-medium">필드 그룹 관리</span>
|
|
{localFieldGroups.length > 0 && (
|
|
<Badge variant="secondary" className="text-[10px] h-5">
|
|
{localFieldGroups.length}개
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<ChevronDown
|
|
className={cn(
|
|
"h-3.5 w-3.5 text-muted-foreground transition-transform",
|
|
openSections["fieldGroups"] && "rotate-180",
|
|
)}
|
|
/>
|
|
</button>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="space-y-2 pt-2">
|
|
<p className="text-[10px] text-muted-foreground">
|
|
추가 입력 필드를 여러 카드로 나눠서 표시 (예: 거래처 정보, 단가 정보)
|
|
</p>
|
|
|
|
{localFieldGroups.map((group, index) => (
|
|
<div key={group.id} className="space-y-2 rounded-md border p-2.5">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-medium truncate">
|
|
그룹 {index + 1}: {group.title}
|
|
</span>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => removeFieldGroup(group.id)}
|
|
className="h-5 w-5 p-0 text-destructive shrink-0"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">그룹 ID</Label>
|
|
<Input
|
|
value={group.id}
|
|
onChange={(e) => updateFieldGroup(group.id, { id: e.target.value })}
|
|
className="h-7 text-xs"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">그룹 제목</Label>
|
|
<Input
|
|
value={group.title}
|
|
onChange={(e) => updateFieldGroup(group.id, { title: e.target.value })}
|
|
className="h-7 text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">설명</Label>
|
|
<Input
|
|
value={group.description || ""}
|
|
onChange={(e) => updateFieldGroup(group.id, { description: e.target.value })}
|
|
placeholder="그룹 설명"
|
|
className="h-7 text-xs"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">표시 순서</Label>
|
|
<Input
|
|
type="number"
|
|
value={group.order || 0}
|
|
onChange={(e) => updateFieldGroup(group.id, { order: parseInt(e.target.value) || 0 })}
|
|
className="h-7 text-xs"
|
|
min="0"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">소스 테이블 (선택)</Label>
|
|
<TableCombobox
|
|
value={group.sourceTable || ""}
|
|
tables={allTables}
|
|
placeholder="기본 대상 테이블 사용"
|
|
onSelect={(v) => updateFieldGroup(group.id, { sourceTable: v })}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-[10px]">최대 항목 수</Label>
|
|
<Input
|
|
type="number"
|
|
value={group.maxEntries || ""}
|
|
onChange={(e) => updateFieldGroup(group.id, { maxEntries: parseInt(e.target.value) || undefined })}
|
|
placeholder="무제한"
|
|
className="h-7 w-20 text-xs"
|
|
min="1"
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={addFieldGroup}
|
|
className="h-7 w-full border-dashed text-xs"
|
|
>
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
그룹 추가
|
|
</Button>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
|
|
{/* ─── 부모 데이터 매핑 (서브 Collapsible) ─── */}
|
|
<Collapsible
|
|
open={openSections["parentMapping"] ?? false}
|
|
onOpenChange={() => toggleSection("parentMapping")}
|
|
>
|
|
<CollapsibleTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-center justify-between rounded-md border px-3 py-2 transition-colors hover:bg-muted/30"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
|
<span className="text-xs font-medium">부모 데이터 매핑</span>
|
|
{parentMappings.length > 0 && (
|
|
<Badge variant="secondary" className="text-[10px] h-5">
|
|
{parentMappings.length}개
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<ChevronDown
|
|
className={cn(
|
|
"h-3.5 w-3.5 text-muted-foreground transition-transform",
|
|
openSections["parentMapping"] && "rotate-180",
|
|
)}
|
|
/>
|
|
</button>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="space-y-2 pt-2">
|
|
<p className="text-[10px] text-muted-foreground">
|
|
이전 화면(거래처 선택 등)에서 넘어온 데이터를 자동으로 매핑
|
|
</p>
|
|
|
|
{parentMappings.map((mapping, index) => {
|
|
const isAutoDetected = autoDetectedFks.some(
|
|
(fk) => fk.mappingType === "parent" && fk.columnName === mapping.targetField,
|
|
);
|
|
return (
|
|
<div
|
|
key={index}
|
|
className={cn(
|
|
"space-y-2 rounded-md border p-2.5",
|
|
isAutoDetected && "border-amber-200 bg-amber-50/30 dark:border-orange-800 dark:bg-orange-950/30",
|
|
)}
|
|
>
|
|
{isAutoDetected && (
|
|
<span className="inline-block rounded bg-amber-100 px-1.5 py-0.5 text-[9px] font-medium text-orange-700 dark:bg-orange-900 dark:text-orange-300">
|
|
FK 자동 감지
|
|
</span>
|
|
)}
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">소스 테이블</Label>
|
|
<TableCombobox
|
|
value={mapping.sourceTable}
|
|
tables={allTables}
|
|
placeholder="테이블 선택"
|
|
onSelect={(v) => {
|
|
updateParentMapping(index, { sourceTable: v, sourceField: "" });
|
|
loadMappingColumns(v, index);
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">원본 필드</Label>
|
|
<ColumnCombobox
|
|
value={mapping.sourceField}
|
|
columns={mappingSourceColumns[index] || []}
|
|
placeholder="컬럼 선택"
|
|
disabled={!mapping.sourceTable}
|
|
onSelect={(name) => updateParentMapping(index, { sourceField: name })}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">저장 필드 (대상 테이블)</Label>
|
|
<ColumnCombobox
|
|
value={mapping.targetField}
|
|
columns={targetColumns}
|
|
placeholder="저장 컬럼 선택"
|
|
disabled={!config.targetTable}
|
|
onSelect={(name) => updateParentMapping(index, { targetField: name })}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
value={mapping.defaultValue || ""}
|
|
onChange={(e) => updateParentMapping(index, { defaultValue: e.target.value || undefined })}
|
|
placeholder="기본값 (선택)"
|
|
className="h-7 flex-1 text-xs"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => removeParentMapping(index)}
|
|
className="h-7 px-2 text-destructive hover:bg-destructive/10"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={addParentMapping}
|
|
className="h-7 w-full border-dashed text-xs"
|
|
>
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
매핑 추가
|
|
</Button>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
|
|
{/* ─── 자동 계산 (서브 Collapsible) ─── */}
|
|
<Collapsible
|
|
open={openSections["autoCalc"] ?? false}
|
|
onOpenChange={() => toggleSection("autoCalc")}
|
|
>
|
|
<CollapsibleTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-center justify-between rounded-md border px-3 py-2 transition-colors hover:bg-muted/30"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Calculator className="h-3.5 w-3.5 text-muted-foreground" />
|
|
<span className="text-xs font-medium">자동 계산</span>
|
|
{config.autoCalculation && (
|
|
<Badge variant="secondary" className="text-[10px] h-5">활성</Badge>
|
|
)}
|
|
</div>
|
|
<ChevronDown
|
|
className={cn(
|
|
"h-3.5 w-3.5 text-muted-foreground transition-transform",
|
|
openSections["autoCalc"] && "rotate-180",
|
|
)}
|
|
/>
|
|
</button>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="space-y-2 pt-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs">자동 계산 활성화</Label>
|
|
<Switch
|
|
checked={!!config.autoCalculation}
|
|
onCheckedChange={(checked) => {
|
|
if (checked) {
|
|
handleChange("autoCalculation", {
|
|
targetField: "",
|
|
mode: "template",
|
|
inputFields: {
|
|
basePrice: "",
|
|
discountType: "",
|
|
discountValue: "",
|
|
roundingType: "",
|
|
roundingUnit: "",
|
|
},
|
|
calculationType: "price",
|
|
valueMapping: {},
|
|
calculationSteps: [],
|
|
});
|
|
} else {
|
|
handleChange("autoCalculation", undefined);
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{config.autoCalculation && (
|
|
<div className="space-y-2">
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">계산 방식</Label>
|
|
<Select
|
|
value={config.autoCalculation.mode || "template"}
|
|
onValueChange={(v: "template" | "custom") =>
|
|
handleChange("autoCalculation", { ...config.autoCalculation, mode: v })
|
|
}
|
|
>
|
|
<SelectTrigger className="h-7 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="template" className="text-xs">템플릿 (가격 계산)</SelectItem>
|
|
<SelectItem value="custom" className="text-xs">커스텀 (계산식 빌더)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">계산 결과 필드</Label>
|
|
<Select
|
|
value={config.autoCalculation.targetField || ""}
|
|
onValueChange={(v) =>
|
|
handleChange("autoCalculation", { ...config.autoCalculation, targetField: v })
|
|
}
|
|
>
|
|
<SelectTrigger className="h-7 text-xs">
|
|
<SelectValue placeholder="필드 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{localFields.map((f) => (
|
|
<SelectItem key={f.name} value={f.name} className="text-xs">
|
|
{f.label || f.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{config.autoCalculation.mode === "template" && (
|
|
<div className="space-y-2 rounded-md border p-2">
|
|
<span className="text-[10px] font-medium">필드 매핑</span>
|
|
{(
|
|
[
|
|
["basePrice", "기준 단가"],
|
|
["discountType", "할인 방식"],
|
|
["discountValue", "할인값"],
|
|
["roundingType", "반올림 방식"],
|
|
["roundingUnit", "반올림 단위"],
|
|
] as const
|
|
).map(([key, label]) => (
|
|
<div key={key} className="flex items-center gap-2">
|
|
<span className="w-20 text-[10px] text-muted-foreground truncate">{label}</span>
|
|
<Select
|
|
value={config.autoCalculation?.inputFields?.[key] || ""}
|
|
onValueChange={(v) =>
|
|
handleChange("autoCalculation", {
|
|
...config.autoCalculation,
|
|
inputFields: { ...config.autoCalculation?.inputFields, [key]: v },
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger className="h-6 flex-1 text-[10px]">
|
|
<SelectValue placeholder="선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{localFields.map((f) => (
|
|
<SelectItem key={f.name} value={f.name} className="text-[10px]">
|
|
{f.label || f.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
V2SelectedItemsDetailInputConfigPanel.displayName =
|
|
"V2SelectedItemsDetailInputConfigPanel";
|
|
|
|
export default V2SelectedItemsDetailInputConfigPanel;
|