"use client"; /** * pop-field 설정 패널 * * 구조: * - [레이아웃 탭] 섹션 목록 (추가/삭제/이동) + 필드 편집 * - [저장 탭] 저장 테이블 / 필드-컬럼 매핑 / 읽기 데이터 소스 */ import { useState, useEffect, useCallback, useMemo } from "react"; import { useCollapsibleSections } from "@/hooks/pop/useCollapsibleSections"; import { ChevronDown, ChevronRight, Plus, Trash2, GripVertical, Check, ChevronsUpDown, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@/components/ui/command"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { cn } from "@/lib/utils"; import type { PopFieldConfig, PopFieldSection, PopFieldItem, FieldInputType, FieldSectionStyle, FieldSectionAppearance, FieldSelectSource, SelectLinkedFilter, AutoNumberConfig, FieldValueSource, PopFieldSaveMapping, PopFieldSaveConfig, PopFieldReadMapping, PopFieldHiddenMapping, PopFieldAutoGenMapping, HiddenValueSource, } from "./types"; import { DEFAULT_FIELD_CONFIG, DEFAULT_SECTION_APPEARANCES, FIELD_INPUT_TYPE_LABELS, FIELD_SECTION_STYLE_LABELS, } from "./types"; import { fetchTableList, fetchTableColumns, type TableInfo, type ColumnInfo, } from "../pop-dashboard/utils/dataFetcher"; import { dataApi } from "@/lib/api/data"; import { getAvailableNumberingRulesForScreen, getNumberingRules } from "@/lib/api/numberingRule"; // ======================================== // Props // ======================================== interface PopFieldConfigPanelProps { config: PopFieldConfig | undefined; onUpdate: (config: PopFieldConfig) => void; } // ======================================== // 메인 설정 패널 // ======================================== export function PopFieldConfigPanel({ config, onUpdate: onConfigChange, }: PopFieldConfigPanelProps) { const cfg: PopFieldConfig = { ...DEFAULT_FIELD_CONFIG, ...config, sections: config?.sections?.length ? config.sections : DEFAULT_FIELD_CONFIG.sections, }; const [tables, setTables] = useState([]); const [saveTableOpen, setSaveTableOpen] = useState(false); const [readTableOpen, setReadTableOpen] = useState(false); const saveTableName = cfg.saveConfig?.tableName ?? cfg.targetTable ?? ""; useEffect(() => { fetchTableList().then(setTables); }, []); const updateConfig = useCallback( (partial: Partial) => { onConfigChange({ ...cfg, ...partial }); }, [cfg, onConfigChange] ); const updateSection = useCallback( (sectionId: string, partial: Partial) => { const sections = cfg.sections.map((s) => s.id === sectionId ? { ...s, ...partial } : s ); updateConfig({ sections }); }, [cfg.sections, updateConfig] ); const addSection = useCallback(() => { const newId = `section_${Date.now()}`; const newSection: PopFieldSection = { id: newId, style: "input", columns: "auto", showLabels: true, fields: [], }; updateConfig({ sections: [...cfg.sections, newSection] }); }, [cfg.sections, updateConfig]); const removeSection = useCallback( (sectionId: string) => { if (cfg.sections.length <= 1) return; updateConfig({ sections: cfg.sections.filter((s) => s.id !== sectionId) }); }, [cfg.sections, updateConfig] ); const moveSectionUp = useCallback( (index: number) => { if (index <= 0) return; const sections = [...cfg.sections]; [sections[index - 1], sections[index]] = [ sections[index], sections[index - 1], ]; updateConfig({ sections }); }, [cfg.sections, updateConfig] ); const handleSaveTableChange = useCallback( (tableName: string) => { const next = tableName === saveTableName ? "" : tableName; const saveConfig: PopFieldSaveConfig = { ...cfg.saveConfig, tableName: next, fieldMappings: cfg.saveConfig?.fieldMappings ?? [], }; updateConfig({ saveConfig, targetTable: next }); }, [cfg.saveConfig, saveTableName, updateConfig] ); const allFields = useMemo(() => { return cfg.sections.flatMap((s) => (s.fields ?? []).map((f) => ({ field: f, section: s })) ); }, [cfg.sections]); const displayFields = useMemo(() => { return cfg.sections .filter((s) => migrateStyle(s.style) === "display") .flatMap((s) => (s.fields ?? []).map((f) => ({ field: f, section: s }))); }, [cfg.sections]); const inputFields = useMemo(() => { return cfg.sections .filter((s) => migrateStyle(s.style) === "input") .flatMap((s) => (s.fields ?? []).map((f) => ({ field: f, section: s }))); }, [cfg.sections]); const hasDisplayFields = displayFields.length > 0; const hasInputFields = inputFields.length > 0; return ( 레이아웃 저장 {cfg.sections.map((section, idx) => ( 1} onUpdate={(partial) => updateSection(section.id, partial)} onRemove={() => removeSection(section.id)} onMoveUp={() => moveSectionUp(idx)} allSections={cfg.sections} /> ))} ); } // ======================================== // SaveTabContent: 저장 탭 (필드 중심 순차 설정) // 1. 테이블 설정 (읽기/저장 테이블 + PK) // 2. 읽기 필드 매핑 (display 섹션) // 3. 입력 필드 매핑 (input 섹션) // ======================================== interface SaveTabContentProps { cfg: PopFieldConfig; tables: TableInfo[]; saveTableName: string; saveTableOpen: boolean; setSaveTableOpen: (v: boolean) => void; readTableOpen: boolean; setReadTableOpen: (v: boolean) => void; allFields: { field: PopFieldItem; section: PopFieldSection }[]; displayFields: { field: PopFieldItem; section: PopFieldSection }[]; inputFields: { field: PopFieldItem; section: PopFieldSection }[]; hasDisplayFields: boolean; hasInputFields: boolean; onSaveTableChange: (tableName: string) => void; onUpdateConfig: (partial: Partial) => void; } function SaveTabContent({ cfg, tables, saveTableName, saveTableOpen, setSaveTableOpen, readTableOpen, setReadTableOpen, allFields, displayFields, inputFields, hasDisplayFields, hasInputFields, onSaveTableChange, onUpdateConfig, }: SaveTabContentProps) { const [saveColumns, setSaveColumns] = useState([]); const [readColumns, setReadColumns] = useState([]); const [jsonKeysMap, setJsonKeysMap] = useState>({}); useEffect(() => { if (saveTableName) { fetchTableColumns(saveTableName).then(setSaveColumns); } else { setSaveColumns([]); } }, [saveTableName]); const readTableName = cfg.readSource?.tableName ?? ""; const readSameAsSave = readTableName === saveTableName && !!saveTableName; const readTableForFetch = readSameAsSave ? saveTableName : readTableName; useEffect(() => { if (readTableForFetch) { fetchTableColumns(readTableForFetch).then(setReadColumns); } else { setReadColumns([]); } }, [readTableForFetch]); const fetchJsonKeysForColumn = useCallback( async (tableName: string, columnName: string) => { const cacheKey = `${tableName}__${columnName}`; if (jsonKeysMap[cacheKey]) return; try { const result = await dataApi.getTableData(tableName, { page: 1, size: 1 }); const row = result.data?.[0]; if (!row || !row[columnName]) return; const raw = row[columnName]; const parsed = typeof raw === "string" ? JSON.parse(raw) : raw; if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { setJsonKeysMap((prev) => ({ ...prev, [cacheKey]: Object.keys(parsed).sort() })); } } catch { // JSON 파싱 실패 시 무시 } }, [jsonKeysMap] ); const getJsonKeys = useCallback( (tableName: string, columnName: string): string[] => { return jsonKeysMap[`${tableName}__${columnName}`] ?? []; }, [jsonKeysMap] ); // --- 저장 매핑 로직 --- const saveMappings = cfg.saveConfig?.fieldMappings ?? []; const getSaveMappingForField = (fieldId: string): PopFieldSaveMapping => { return ( saveMappings.find((x) => x.fieldId === fieldId) ?? { fieldId, valueSource: "direct", targetColumn: "", } ); }; const syncAndUpdateSaveMappings = useCallback( (updater?: (prev: PopFieldSaveMapping[]) => PopFieldSaveMapping[]) => { const fieldIds = new Set(allFields.map(({ field }) => field.id)); const prev = saveMappings.filter((m) => fieldIds.has(m.fieldId)); const next = updater ? updater(prev) : prev; const added = allFields.filter( ({ field }) => !next.some((m) => m.fieldId === field.id) ); const merged: PopFieldSaveMapping[] = [ ...next, ...added.map(({ field }) => ({ fieldId: field.id, valueSource: "direct" as FieldValueSource, targetColumn: "", })), ]; const structureChanged = merged.length !== saveMappings.length || merged.some((m, i) => m.fieldId !== saveMappings[i]?.fieldId); if (updater || structureChanged) { onUpdateConfig({ saveConfig: { ...cfg.saveConfig, tableName: saveTableName, fieldMappings: merged, }, }); } }, [allFields, saveMappings, cfg.saveConfig, saveTableName, onUpdateConfig] ); const fieldIdsKey = allFields.map(({ field }) => field.id).join(","); useEffect(() => { syncAndUpdateSaveMappings(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [fieldIdsKey]); const updateSaveMapping = useCallback( (fieldId: string, partial: Partial) => { syncAndUpdateSaveMappings((prev) => prev.map((m) => (m.fieldId === fieldId ? { ...m, ...partial } : m)) ); }, [syncAndUpdateSaveMappings] ); // --- 숨은 필드 매핑 로직 --- const hiddenMappings = cfg.saveConfig?.hiddenMappings ?? []; const addHiddenMapping = useCallback(() => { const newMapping: PopFieldHiddenMapping = { id: `hidden_${Date.now()}`, valueSource: "db_column", targetColumn: "", label: "", }; onUpdateConfig({ saveConfig: { ...cfg.saveConfig, tableName: saveTableName, fieldMappings: cfg.saveConfig?.fieldMappings ?? [], hiddenMappings: [...hiddenMappings, newMapping], }, }); }, [cfg.saveConfig, saveTableName, hiddenMappings, onUpdateConfig]); const updateHiddenMapping = useCallback( (id: string, partial: Partial) => { onUpdateConfig({ saveConfig: { ...cfg.saveConfig, tableName: saveTableName, fieldMappings: cfg.saveConfig?.fieldMappings ?? [], hiddenMappings: hiddenMappings.map((m) => m.id === id ? { ...m, ...partial } : m ), }, }); }, [cfg.saveConfig, saveTableName, hiddenMappings, onUpdateConfig] ); const removeHiddenMapping = useCallback( (id: string) => { onUpdateConfig({ saveConfig: { ...cfg.saveConfig, tableName: saveTableName, fieldMappings: cfg.saveConfig?.fieldMappings ?? [], hiddenMappings: hiddenMappings.filter((m) => m.id !== id), }, }); }, [cfg.saveConfig, saveTableName, hiddenMappings, onUpdateConfig] ); // --- 레이아웃 auto 필드 감지 (입력 섹션에서 inputType=auto인 필드) --- const autoInputFields = useMemo( () => inputFields.filter(({ field }) => field.inputType === "auto"), [inputFields] ); const regularInputFields = useMemo( () => inputFields.filter(({ field }) => field.inputType !== "auto"), [inputFields] ); // --- 자동생성 필드 로직 --- const autoGenMappings = cfg.saveConfig?.autoGenMappings ?? []; const [numberingRules, setNumberingRules] = useState<{ ruleId: string; ruleName: string }[]>([]); const [allNumberingRules, setAllNumberingRules] = useState<{ ruleId: string; ruleName: string; tableName: string }[]>([]); const [showAllRules, setShowAllRules] = useState(false); // 레이아웃 auto 필드 → autoGenMappings 자동 동기화 const autoFieldIdsKey = autoInputFields.map(({ field }) => field.id).join(","); useEffect(() => { if (autoInputFields.length === 0) return; const current = cfg.saveConfig?.autoGenMappings ?? []; const linkedIds = new Set(current.filter((m) => m.linkedFieldId).map((m) => m.linkedFieldId)); const toAdd: PopFieldAutoGenMapping[] = []; for (const { field } of autoInputFields) { if (!linkedIds.has(field.id)) { toAdd.push({ id: `autogen_linked_${field.id}`, linkedFieldId: field.id, label: field.labelText || "", targetColumn: "", numberingRuleId: field.autoNumber?.numberingRuleId ?? "", showInForm: false, showResultModal: true, }); } } if (toAdd.length > 0) { onUpdateConfig({ saveConfig: { ...cfg.saveConfig, tableName: saveTableName, fieldMappings: cfg.saveConfig?.fieldMappings ?? [], autoGenMappings: [...current, ...toAdd], }, }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [autoFieldIdsKey]); useEffect(() => { if (saveTableName) { getAvailableNumberingRulesForScreen(saveTableName) .then((res) => { if (res.success && Array.isArray(res.data)) { setNumberingRules( res.data.map((r: any) => ({ ruleId: String(r.ruleId ?? r.rule_id ?? ""), ruleName: String(r.ruleName ?? r.rule_name ?? ""), })) ); } }) .catch(() => setNumberingRules([])); } }, [saveTableName]); useEffect(() => { if (!showAllRules) return; if (allNumberingRules.length > 0) return; getNumberingRules() .then((res) => { if (res.success && Array.isArray(res.data)) { setAllNumberingRules( res.data.map((r: any) => ({ ruleId: String(r.ruleId ?? r.rule_id ?? ""), ruleName: String(r.ruleName ?? r.rule_name ?? ""), tableName: String(r.tableName ?? r.table_name ?? ""), })) ); } }) .catch(() => setAllNumberingRules([])); }, [showAllRules, allNumberingRules.length]); const addAutoGenMapping = useCallback(() => { const newMapping: PopFieldAutoGenMapping = { id: `autogen_${Date.now()}`, label: "", targetColumn: "", numberingRuleId: "", showInForm: false, showResultModal: true, }; onUpdateConfig({ saveConfig: { ...cfg.saveConfig, tableName: saveTableName, fieldMappings: cfg.saveConfig?.fieldMappings ?? [], autoGenMappings: [...autoGenMappings, newMapping], }, }); }, [cfg.saveConfig, saveTableName, autoGenMappings, onUpdateConfig]); const updateAutoGenMapping = useCallback( (id: string, partial: Partial) => { onUpdateConfig({ saveConfig: { ...cfg.saveConfig, tableName: saveTableName, fieldMappings: cfg.saveConfig?.fieldMappings ?? [], autoGenMappings: autoGenMappings.map((m) => m.id === id ? { ...m, ...partial } : m ), }, }); }, [cfg.saveConfig, saveTableName, autoGenMappings, onUpdateConfig] ); const removeAutoGenMapping = useCallback( (id: string) => { onUpdateConfig({ saveConfig: { ...cfg.saveConfig, tableName: saveTableName, fieldMappings: cfg.saveConfig?.fieldMappings ?? [], autoGenMappings: autoGenMappings.filter((m) => m.id !== id), }, }); }, [cfg.saveConfig, saveTableName, autoGenMappings, onUpdateConfig] ); // --- 읽기 매핑 로직 --- const getReadMappingForField = (fieldId: string): PopFieldReadMapping => { return ( cfg.readSource?.fieldMappings?.find((x) => x.fieldId === fieldId) ?? { fieldId, valueSource: "db_column", columnName: "", } ); }; const updateReadMapping = useCallback( (fieldId: string, partial: Partial) => { const prev = cfg.readSource?.fieldMappings ?? []; const next = prev.map((m) => m.fieldId === fieldId ? { ...m, ...partial } : m ); if (!next.some((m) => m.fieldId === fieldId)) { next.push({ fieldId, valueSource: "db_column", columnName: "", ...partial }); } onUpdateConfig({ readSource: { ...cfg.readSource, tableName: readTableName || saveTableName, pkColumn: cfg.readSource?.pkColumn ?? "", fieldMappings: next, }, }); }, [cfg.readSource, readTableName, saveTableName, onUpdateConfig] ); // --- 읽기 테이블 변경 --- const handleReadSameAsSaveChange = useCallback( (checked: boolean) => { onUpdateConfig({ readSource: { tableName: checked ? saveTableName : "", pkColumn: checked ? (cfg.readSource?.pkColumn ?? "") : "", fieldMappings: cfg.readSource?.fieldMappings ?? [], }, }); }, [saveTableName, cfg.readSource, onUpdateConfig] ); const handleReadTableChange = useCallback( (tableName: string) => { onUpdateConfig({ readSource: { ...cfg.readSource, tableName, pkColumn: cfg.readSource?.pkColumn ?? "", fieldMappings: cfg.readSource?.fieldMappings ?? [], }, }); }, [cfg.readSource, onUpdateConfig] ); const colName = (c: ColumnInfo) => c.name; const noFields = allFields.length === 0; const sections = useCollapsibleSections("pop-field"); return (
{noFields && (

레이아웃 탭에서 섹션과 필드를 먼저 추가해주세요.

)} {/* ── 1. 테이블 설정 ── */} {!noFields && (
sections.toggle("table")} > {sections.isOpen("table") ? ( ) : ( )} 테이블 설정
{sections.isOpen("table") &&
{/* 읽기 테이블 (display 섹션이 있을 때만) */} {hasDisplayFields && ( <>
저장과 동일
{!readSameAsSave && ( 테이블을 찾을 수 없습니다. {tables.map((t) => ( { handleReadTableChange(v); setReadTableOpen(false); }} className="text-xs" > {t.tableName} ))} )}
)} {/* 저장 테이블 */} {hasInputFields && (
테이블을 찾을 수 없습니다. {tables.map((t) => ( { onSaveTableChange(v === saveTableName ? "" : v); setSaveTableOpen(false); }} className="text-xs" > {t.tableName} {t.displayName && t.displayName !== t.tableName && ( ({t.displayName}) )} ))}
)}
}
)} {/* ── 2. 읽기 필드 매핑 (display) ── */} {hasDisplayFields && (readTableForFetch || readSameAsSave) && (
sections.toggle("read")} > {sections.isOpen("read") ? ( ) : ( )} 읽기 필드 (읽기 폼)
{sections.isOpen("read") &&
{readColumns.length === 0 ? (

읽기 테이블의 컬럼을 불러오는 중...

) : ( displayFields.map(({ field }) => { const m = getReadMappingForField(field.id); const sm = getSaveMappingForField(field.id); const isJson = m.valueSource === "json_extract"; return (
{field.labelText || "(미설정)"} {!isJson && ( <> )}
{isJson && (
. updateReadMapping(field.id, { jsonKey: v })} onOpen={() => { if (readTableForFetch && m.columnName) { fetchJsonKeysForColumn(readTableForFetch, m.columnName); } }} />
)} {saveTableName && saveColumns.length > 0 && (
)}
); }) )}
}
)} {/* ── 3. 입력 필드 매핑 (input, auto 타입 제외) ── */} {regularInputFields.length > 0 && saveTableName && (
sections.toggle("input")} > {sections.isOpen("input") ? ( ) : ( )} 입력 필드 (입력 폼 → 저장)
{sections.isOpen("input") &&
{saveColumns.length === 0 ? (

저장 테이블의 컬럼을 불러오는 중...

) : ( regularInputFields.map(({ field }) => { const m = getSaveMappingForField(field.id); return (
{field.labelText || "(미설정)"}
); }) )}
}
)} {/* ── 4. 숨은 필드 매핑 (읽기 필드와 동일한 소스 구조) ── */} {saveTableName && (
sections.toggle("hidden")} > {sections.isOpen("hidden") ? ( ) : ( )} 숨은 필드 (UI 미표시, 전달 데이터에서 추출하여 저장)
{sections.isOpen("hidden") &&
{hiddenMappings.map((m) => { const isJson = m.valueSource === "json_extract"; const isStatic = m.valueSource === "static"; const isDbColumn = m.valueSource === "db_column"; return (
updateHiddenMapping(m.id, { label: e.target.value })} placeholder="라벨 (관리용)" className="h-7 flex-1 text-xs" />
{isDbColumn && ( )} {isStatic && ( updateHiddenMapping(m.id, { staticValue: e.target.value })} placeholder="고정값 입력" className="h-7 flex-1 text-xs" /> )}
{isJson && (
. updateHiddenMapping(m.id, { sourceJsonKey: v })} onOpen={() => { if (readTableForFetch && m.sourceJsonColumn) { fetchJsonKeysForColumn(readTableForFetch, m.sourceJsonColumn); } }} />
)}
); })}
}
)} {/* ── 5. 자동생성 필드 ── */} {saveTableName && (
sections.toggle("autogen")} > {sections.isOpen("autogen") ? ( ) : ( )} 자동생성 필드 (저장 시 서버에서 채번)
{sections.isOpen("autogen") &&
{autoGenMappings.map((m) => { const isLinked = !!m.linkedFieldId; return (
{isLinked && ( 레이아웃 )} updateAutoGenMapping(m.id, { label: e.target.value })} placeholder="라벨 (예: 입고번호)" className="h-7 flex-1 text-xs" readOnly={isLinked} /> {!isLinked && ( )}
{!isLinked && (
updateAutoGenMapping(m.id, { showInForm: v })} />
)}
updateAutoGenMapping(m.id, { showResultModal: v })} />
); })}
}
)} {/* 저장 테이블 미선택 안내 */} {(regularInputFields.length > 0 || autoInputFields.length > 0) && !saveTableName && !noFields && (

저장 테이블을 선택하면 입력 필드 매핑이 표시됩니다.

)}
); } // ======================================== // SectionEditor: 섹션 단위 편집 // ======================================== interface SectionEditorProps { section: PopFieldSection; index: number; canDelete: boolean; onUpdate: (partial: Partial) => void; onRemove: () => void; onMoveUp: () => void; allSections: PopFieldSection[]; } function migrateStyle(style: string): FieldSectionStyle { if (style === "display" || style === "input") return style; if (style === "summary") return "display"; if (style === "form") return "input"; return "input"; } function SectionEditor({ section, index, canDelete, onUpdate, onRemove, onMoveUp, allSections, }: SectionEditorProps) { const [collapsed, setCollapsed] = useState(true); const resolvedStyle = migrateStyle(section.style); const sectionFields = section.fields || []; const updateField = useCallback( (fieldId: string, partial: Partial) => { const fields = sectionFields.map((f) => f.id === fieldId ? { ...f, ...partial } : f ); onUpdate({ fields }); }, [sectionFields, onUpdate] ); const addField = useCallback(() => { const fieldId = `field_${Date.now()}`; const newField: PopFieldItem = { id: fieldId, inputType: "text", fieldName: fieldId, labelText: "", readOnly: false, }; onUpdate({ fields: [...sectionFields, newField] }); }, [sectionFields, onUpdate]); const removeField = useCallback( (fieldId: string) => { onUpdate({ fields: sectionFields.filter((f) => f.id !== fieldId) }); }, [sectionFields, onUpdate] ); return (
{/* 섹션 헤더 */}
setCollapsed(!collapsed)} > {collapsed ? ( ) : ( )} 섹션 {index + 1} {section.label && ` - ${section.label}`} {index > 0 && ( )} {canDelete && ( )}
{!collapsed && (
{/* 섹션 라벨 */}
onUpdate({ label: e.target.value })} placeholder="선택사항" className="mt-1 h-7 text-xs" />
{/* 스타일 + 열 수 (가로 배치) */}
{/* 라벨 표시 토글 */}
onUpdate({ showLabels: v })} />
{/* 커스텀 색상 */} onUpdate({ appearance })} /> {/* 필드 목록 */}
{sectionFields.map((field) => ( updateField(field.id, partial)} onRemove={() => removeField(field.id)} allSections={allSections} /> ))}
)}
); } // ======================================== // FieldItemEditor: 필드 단위 편집 // ======================================== interface FieldItemEditorProps { field: PopFieldItem; sectionStyle?: FieldSectionStyle; onUpdate: (partial: Partial) => void; onRemove: () => void; allSections?: PopFieldSection[]; } function FieldItemEditor({ field, sectionStyle, onUpdate, onRemove, allSections, }: FieldItemEditorProps) { const isDisplay = sectionStyle === "display"; const [expanded, setExpanded] = useState(false); return (
{/* 필드 헤더 */}
setExpanded(!expanded)} > {expanded ? ( ) : ( )} {field.labelText || "(미설정)"} [{FIELD_INPUT_TYPE_LABELS[field.inputType]}] {field.readOnly && ( (읽기전용) )}
{expanded && (
{/* 라벨 + 타입 */}
onUpdate({ labelText: e.target.value })} placeholder="표시 라벨" className="mt-0.5 h-7 text-xs" />
{/* 플레이스홀더 */}
onUpdate({ placeholder: e.target.value })} placeholder="힌트 텍스트" className="mt-0.5 h-7 text-xs" />
{/* 읽기전용 + 필수 + 데이터 연동 (입력 폼에서만 표시) */} {!isDisplay && (
onUpdate({ readOnly: v })} />
onUpdate({ validation: { ...field.validation, required: v }, }) } />
{field.inputType === "select" && field.selectSource?.type === "table" && (
{ const src = field.selectSource ?? { type: "table" as const }; if (v) { onUpdate({ selectSource: { ...src, linkedFilters: [{ sourceFieldId: "", filterColumn: "" }], }, }); } else { onUpdate({ selectSource: { ...src, linkedFilters: undefined }, }); } }} />
)}
)} {/* 단위 (number, numpad) */} {(field.inputType === "number" || field.inputType === "numpad") && (
onUpdate({ unit: e.target.value })} placeholder="EA, KG 등" className="mt-0.5 h-7 text-xs" />
)} {/* select 전용: 옵션 소스 */} {field.inputType === "select" && ( onUpdate({ selectSource: source })} /> )} {/* select + table + 연동 필터 활성화 시 */} {field.inputType === "select" && field.selectSource?.type === "table" && field.selectSource?.linkedFilters && field.selectSource.linkedFilters.length > 0 && ( onUpdate({ selectSource: { ...field.selectSource!, linkedFilters: filters }, }) } /> )} {/* auto 전용: 저장 탭에서 채번 규칙을 연결하라는 안내 */} {field.inputType === "auto" && (

채번 규칙은 [저장] 탭 > 자동생성 필드에서 설정합니다.

)}
)}
); } // ======================================== // SelectSourceEditor: select 옵션 소스 편집 // ======================================== function SelectSourceEditor({ source, onUpdate, }: { source?: FieldSelectSource; onUpdate: (source: FieldSelectSource) => void; }) { const current: FieldSelectSource = source || { type: "static", staticOptions: [], }; return (
{current.type === "static" && ( onUpdate({ ...current, staticOptions: opts })} /> )} {current.type === "table" && ( onUpdate({ ...current, ...partial })} /> )}
); } // ======================================== // StaticOptionsEditor: 정적 옵션 CRUD // ======================================== function StaticOptionsEditor({ options, onUpdate, }: { options: { value: string; label: string }[]; onUpdate: (options: { value: string; label: string }[]) => void; }) { return (
{options.map((opt, idx) => (
{ const next = [...options]; next[idx] = { ...opt, value: e.target.value }; onUpdate(next); }} placeholder="값" className="h-6 flex-1 text-[10px]" /> { const next = [...options]; next[idx] = { ...opt, label: e.target.value }; onUpdate(next); }} placeholder="표시" className="h-6 flex-1 text-[10px]" />
))}
); } // ======================================== // TableSourceEditor: 테이블 소스 설정 // ======================================== function TableSourceEditor({ source, onUpdate, }: { source: FieldSelectSource; onUpdate: (partial: Partial) => void; }) { const [tables, setTables] = useState([]); const [columns, setColumns] = useState([]); const [tblOpen, setTblOpen] = useState(false); useEffect(() => { fetchTableList().then(setTables); }, []); useEffect(() => { if (source.tableName) { fetchTableColumns(source.tableName).then(setColumns); } else { setColumns([]); } }, [source.tableName]); return (
{/* 테이블 Combobox */} 테이블을 찾을 수 없습니다. {tables.map((t) => ( { onUpdate({ tableName: v }); setTblOpen(false); }} className="text-xs" > {t.tableName} ))} {/* 값 컬럼 / 라벨 컬럼 */}
); } // AutoNumberEditor 삭제됨: 채번 규칙은 저장 탭 > 자동생성 필드에서 관리 // ======================================== // JsonKeySelect: JSON 키 드롭다운 (자동 추출) // ======================================== function JsonKeySelect({ value, keys, onValueChange, onOpen, }: { value: string; keys: string[]; onValueChange: (v: string) => void; onOpen?: () => void; }) { const [open, setOpen] = useState(false); const handleOpenChange = (nextOpen: boolean) => { setOpen(nextOpen); if (nextOpen) onOpen?.(); }; if (keys.length === 0 && !value) { return ( onValueChange(e.target.value)} onFocus={() => onOpen?.()} className="h-7 w-24 text-xs" /> ); } return ( {keys.length === 0 ? "데이터를 불러오는 중..." : "일치하는 키가 없습니다."} {keys.map((k) => ( { onValueChange(v === value ? "" : v); setOpen(false); }} className="text-xs" > {k} ))} ); } // ======================================== // LinkedFiltersEditor: 데이터 연동 필터 설정 // ======================================== function LinkedFiltersEditor({ linkedFilters, tableName, currentFieldId, allSections, onUpdate, }: { linkedFilters: SelectLinkedFilter[]; tableName: string; currentFieldId: string; allSections: PopFieldSection[]; onUpdate: (filters: SelectLinkedFilter[]) => void; }) { const [columns, setColumns] = useState([]); useEffect(() => { if (tableName) { fetchTableColumns(tableName).then(setColumns); } else { setColumns([]); } }, [tableName]); const candidateFields = useMemo(() => { return allSections.flatMap((sec) => (sec.fields ?? []) .filter((f) => f.id !== currentFieldId) .map((f) => ({ id: f.id, label: f.labelText || f.fieldName || f.id, sectionLabel: sec.label })) ); }, [allSections, currentFieldId]); const updateFilter = (idx: number, partial: Partial) => { const next = linkedFilters.map((f, i) => (i === idx ? { ...f, ...partial } : f)); onUpdate(next); }; const removeFilter = (idx: number) => { const next = linkedFilters.filter((_, i) => i !== idx); onUpdate(next.length > 0 ? next : [{ sourceFieldId: "", filterColumn: "" }]); }; const addFilter = () => { onUpdate([...linkedFilters, { sourceFieldId: "", filterColumn: "" }]); }; return (
{linkedFilters.map((lf, idx) => (
= {linkedFilters.length > 1 && ( )}
))}
); } // ======================================== // AppearanceEditor: 섹션 외관 설정 // ======================================== const BG_COLOR_OPTIONS = [ { value: "__default__", label: "기본" }, { value: "bg-emerald-50", label: "초록" }, { value: "bg-blue-50", label: "파랑" }, { value: "bg-amber-50", label: "노랑" }, { value: "bg-rose-50", label: "빨강" }, { value: "bg-purple-50", label: "보라" }, { value: "bg-gray-50", label: "회색" }, ] as const; const BORDER_COLOR_OPTIONS = [ { value: "__default__", label: "기본" }, { value: "border-emerald-200", label: "초록" }, { value: "border-blue-200", label: "파랑" }, { value: "border-amber-200", label: "노랑" }, { value: "border-rose-200", label: "빨강" }, { value: "border-purple-200", label: "보라" }, { value: "border-gray-200", label: "회색" }, ] as const; function AppearanceEditor({ style, appearance, onUpdate, }: { style: FieldSectionStyle; appearance?: FieldSectionAppearance; onUpdate: (appearance: FieldSectionAppearance) => void; }) { const defaults = DEFAULT_SECTION_APPEARANCES[style]; const current = appearance || {}; return (
); }