"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; onChange: (config: Record) => 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 ( 테이블을 찾을 수 없습니다. {filtered.map((t) => ( { onSelect(v); setOpen(false); setSearch(""); }} className="text-xs sm:text-sm" >
{t.displayName || t.tableName} {t.displayName && ( {t.tableName} )}
))}
); }; /** * 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 ( 컬럼을 찾을 수 없습니다. {columns.map((c) => ( { onSelect(c.columnName, c); setOpen(false); }} className="text-xs" >
{c.columnLabel || c.columnName} {c.columnName} {c.dataType ? ` (${c.dataType})` : ""}
))}
); }; /** * 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([]); const [targetColumns, setTargetColumns] = useState([]); const [autoDetectedFks, setAutoDetectedFks] = useState([]); const fkAutoAppliedRef = useRef(false); // ─── 테이블 컬럼 로드 ─── const loadColumns = useCallback(async (tableName: string): Promise => { 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(() => { 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( () => config.additionalFields || [], [config.additionalFields], ); const displayColumns = useMemo>( () => config.displayColumns || [], [config.displayColumns], ); const localFieldGroups = useMemo( () => 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) => { 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) => { 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( () => config.parentDataMapping || [], [config.parentDataMapping], ); // 부모 매핑 소스 컬럼 캐시 const [mappingSourceColumns, setMappingSourceColumns] = useState< Record >({}); const addParentMapping = useCallback(() => { handleChange("parentDataMapping", [ ...parentMappings, { sourceTable: "", sourceField: "", targetField: "" }, ]); }, [parentMappings, handleChange]); const updateParentMapping = useCallback( (index: number, updates: Partial) => { 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>({}); const toggleSection = useCallback((key: string) => { setOpenSections((prev) => ({ ...prev, [key]: !prev[key] })); }, []); // ─── screenTableName 자동 설정 ─── useEffect(() => { if (screenTableName && !config.targetTable) { handleChange("targetTable", screenTableName); } }, [screenTableName]); // ─── 렌더링 ─── return (
{/* ════════ 1단계: 테이블 설정 ════════ */}
테이블 설정

데이터의 원본과 저장 대상을 설정해요

{/* 데이터 소스 ID */}
handleChange("dataSourceId", e.target.value)} placeholder="비워두면 URL 파라미터에서 자동 설정" className="h-8 text-xs" />

비워두면 Button에서 자동 전달

{/* 원본 테이블 */}
handleChange("sourceTable", v)} />

이전 화면에서 전달받은 데이터의 원본 테이블

{/* 저장 대상 테이블 */}
handleChange("targetTable", v)} />

최종 데이터를 저장할 테이블

{/* FK 자동 감지 결과 */} {autoDetectedFks.length > 0 && (

FK 자동 감지 ({autoDetectedFks.length}건)

{autoDetectedFks.map((fk) => (
{fk.mappingType === "source" ? "원본" : fk.mappingType === "parent" ? "부모" : "미분류"} {fk.columnName} {fk.referenceTable}
))}

엔티티 설정 기반 자동 매핑. sourceKeyField와 parentDataMapping이 자동 설정됩니다.

)} {/* ════════ 2단계: 레이아웃 & 입력 모드 선택 ════════ */}
레이아웃
{/* 레이아웃 카드 선택 */}
{LAYOUT_CARDS.map((card) => { const isSelected = (config.layout || "grid") === card.value; const Icon = card.icon; return ( ); })}
{/* 입력 모드 카드 선택 */}
입력 모드
{INPUT_MODE_CARDS.map((card) => { const isSelected = (config.inputMode || "inline") === card.value; const Icon = card.icon; return ( ); })}
{/* ════════ 3단계: 표시 컬럼 (원본 데이터) ════════ */}
표시 컬럼 (원본 데이터) {displayColumns.length > 0 && ( {displayColumns.length}개 )}

전달받은 원본 데이터 중 화면에 표시할 컬럼

{displayColumns.length > 0 && (
{displayColumns.map((col) => (
{col.label} {col.name}
))}
)} {sourceColumns.length > 0 ? ( 사용 가능한 컬럼이 없습니다. {availableSourceColumns.map((c) => ( addDisplayColumn( c.columnName, c.columnLabel || c.columnName, ) } className="text-xs" >
{c.columnLabel || c.columnName}
{c.dataType && (
{c.dataType}
)}
))}
) : (

{config.sourceTable ? "컬럼 로딩 중..." : "원본 테이블을 먼저 선택하세요"}

)} {/* ════════ 4단계: 추가 입력 필드 (Collapsible) ════════ */} 0)} onOpenChange={(open) => setOpenSections((prev) => ({ ...prev, inputFields: open }))} >

저장 대상 테이블의 컬럼을 입력 필드로 추가

{localFields.length > 0 && (
{localFields.map((field, index) => (
updateField(index, { name, label: col.columnLabel || name, inputType: col.inputType || "text", codeCategory: col.codeCategory, }) } />
updateField(index, { label: e.target.value }) } placeholder="필드 라벨" className="h-7 text-xs" />
updateField(index, { placeholder: e.target.value }) } placeholder="입력 안내" className="h-7 text-xs" />
{localFieldGroups.length > 0 && (
)}
updateField(index, { required: checked }) } />
{field.autoFillFrom && ( 자동: {field.autoFillFrom} )}
))}
)}
{/* ════════ 5단계: 고급 설정 (서브 Collapsible 통합) ════════ */} toggleSection("advanced")} >
{/* ─── 기본 고급 설정 ─── */}
handleChange("sourceKeyField", name)} />

대상 테이블에서 원본을 참조하는 FK 컬럼

handleChange("showIndex", v)} />
handleChange("allowRemove", v)} />
handleChange("disabled", v)} />
handleChange("readonly", v)} />
handleChange("emptyMessage", e.target.value)} placeholder="전달받은 데이터가 없습니다." className="h-8 text-xs" />
{/* ─── 필드 그룹 관리 (서브 Collapsible) ─── */} toggleSection("fieldGroups")} >

추가 입력 필드를 여러 카드로 나눠서 표시 (예: 거래처 정보, 단가 정보)

{localFieldGroups.map((group, index) => (
그룹 {index + 1}: {group.title}
updateFieldGroup(group.id, { id: e.target.value })} className="h-7 text-xs" />
updateFieldGroup(group.id, { title: e.target.value })} className="h-7 text-xs" />
updateFieldGroup(group.id, { description: e.target.value })} placeholder="그룹 설명" className="h-7 text-xs" />
updateFieldGroup(group.id, { order: parseInt(e.target.value) || 0 })} className="h-7 text-xs" min="0" />
updateFieldGroup(group.id, { sourceTable: v })} />
updateFieldGroup(group.id, { maxEntries: parseInt(e.target.value) || undefined })} placeholder="무제한" className="h-7 w-20 text-xs" min="1" />
))}
{/* ─── 부모 데이터 매핑 (서브 Collapsible) ─── */} toggleSection("parentMapping")} >

이전 화면(거래처 선택 등)에서 넘어온 데이터를 자동으로 매핑

{parentMappings.map((mapping, index) => { const isAutoDetected = autoDetectedFks.some( (fk) => fk.mappingType === "parent" && fk.columnName === mapping.targetField, ); return (
{isAutoDetected && ( FK 자동 감지 )}
{ updateParentMapping(index, { sourceTable: v, sourceField: "" }); loadMappingColumns(v, index); }} />
updateParentMapping(index, { sourceField: name })} />
updateParentMapping(index, { targetField: name })} />
updateParentMapping(index, { defaultValue: e.target.value || undefined })} placeholder="기본값 (선택)" className="h-7 flex-1 text-xs" />
); })}
{/* ─── 자동 계산 (서브 Collapsible) ─── */} toggleSection("autoCalc")} >
{ if (checked) { handleChange("autoCalculation", { targetField: "", mode: "template", inputFields: { basePrice: "", discountType: "", discountValue: "", roundingType: "", roundingUnit: "", }, calculationType: "price", valueMapping: {}, calculationSteps: [], }); } else { handleChange("autoCalculation", undefined); } }} />
{config.autoCalculation && (
{config.autoCalculation.mode === "template" && (
필드 매핑 {( [ ["basePrice", "기준 단가"], ["discountType", "할인 방식"], ["discountValue", "할인값"], ["roundingType", "반올림 방식"], ["roundingUnit", "반올림 단위"], ] as const ).map(([key, label]) => (
{label}
))}
)}
)}
); }; V2SelectedItemsDetailInputConfigPanel.displayName = "V2SelectedItemsDetailInputConfigPanel"; export default V2SelectedItemsDetailInputConfigPanel;