ERP-node/frontend/components/v2/config-panels/V2SelectedItemsDetailInputC...

1543 lines
58 KiB
TypeScript
Raw Normal View History

"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">&rarr;</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="max-h-[180px] space-y-0.5 overflow-y-auto">
{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="max-h-[300px] space-y-1 overflow-y-auto">
{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;