ERP-node/frontend/lib/registry/pop-components/pop-field/PopFieldConfig.tsx

2221 lines
76 KiB
TypeScript
Raw Normal View History

"use client";
/**
* pop-field
*
* :
* - [ ] (//) +
* - [ ] / - /
*/
import { useState, useEffect, useCallback, useMemo } from "react";
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,
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 } 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<TableInfo[]>([]);
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<PopFieldConfig>) => {
onConfigChange({ ...cfg, ...partial });
},
[cfg, onConfigChange]
);
const updateSection = useCallback(
(sectionId: string, partial: Partial<PopFieldSection>) => {
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 (
<Tabs defaultValue="layout" className="w-full">
<TabsList className="h-8 w-full">
<TabsTrigger value="layout" className="flex-1 text-xs">
</TabsTrigger>
<TabsTrigger value="save" className="flex-1 text-xs">
</TabsTrigger>
</TabsList>
<TabsContent value="layout" className="mt-3 space-y-4">
{cfg.sections.map((section, idx) => (
<SectionEditor
key={section.id}
section={section}
index={idx}
canDelete={cfg.sections.length > 1}
onUpdate={(partial) => updateSection(section.id, partial)}
onRemove={() => removeSection(section.id)}
onMoveUp={() => moveSectionUp(idx)}
/>
))}
<Button
variant="outline"
size="sm"
className="w-full text-xs"
onClick={addSection}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</TabsContent>
<TabsContent value="save" className="mt-3 space-y-4">
<SaveTabContent
cfg={cfg}
tables={tables}
saveTableName={saveTableName}
saveTableOpen={saveTableOpen}
setSaveTableOpen={setSaveTableOpen}
readTableOpen={readTableOpen}
setReadTableOpen={setReadTableOpen}
allFields={allFields}
displayFields={displayFields}
inputFields={inputFields}
hasDisplayFields={hasDisplayFields}
hasInputFields={hasInputFields}
onSaveTableChange={handleSaveTableChange}
onUpdateConfig={updateConfig}
/>
</TabsContent>
</Tabs>
);
}
// ========================================
// 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<PopFieldConfig>) => void;
}
function SaveTabContent({
cfg,
tables,
saveTableName,
saveTableOpen,
setSaveTableOpen,
readTableOpen,
setReadTableOpen,
allFields,
displayFields,
inputFields,
hasDisplayFields,
hasInputFields,
onSaveTableChange,
onUpdateConfig,
}: SaveTabContentProps) {
const [saveColumns, setSaveColumns] = useState<ColumnInfo[]>([]);
const [readColumns, setReadColumns] = useState<ColumnInfo[]>([]);
const [jsonKeysMap, setJsonKeysMap] = useState<Record<string, string[]>>({});
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<PopFieldSaveMapping>) => {
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<PopFieldHiddenMapping>) => {
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 }[]>([]);
// 레이아웃 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: true,
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]);
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<PopFieldAutoGenMapping>) => {
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<PopFieldReadMapping>) => {
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 [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
const toggleSection = useCallback((key: string) => {
setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }));
}, []);
return (
<div className="space-y-4">
{noFields && (
<p className="rounded-md border bg-muted/30 px-3 py-4 text-center text-xs text-muted-foreground">
.
</p>
)}
{/* ── 1. 테이블 설정 ── */}
{!noFields && (
<div className="rounded-md border bg-card">
<div
className="flex cursor-pointer items-center gap-1.5 px-3 py-2"
onClick={() => toggleSection("table")}
>
{collapsed["table"] ? (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronDown className="h-3 w-3 text-muted-foreground" />
)}
<span className="text-xs font-medium"> </span>
</div>
{!collapsed["table"] && <div className="space-y-3 border-t p-3">
{/* 읽기 테이블 (display 섹션이 있을 때만) */}
{hasDisplayFields && (
<>
<div>
<Label className="text-[10px]"> </Label>
<div className="mt-1 flex items-center gap-2">
<div className="flex items-center gap-1.5">
<Switch
checked={readSameAsSave}
onCheckedChange={handleReadSameAsSaveChange}
/>
<span className="text-[10px] text-muted-foreground">
</span>
</div>
</div>
{!readSameAsSave && (
<Popover open={readTableOpen} onOpenChange={setReadTableOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="mt-1 h-7 w-full justify-between text-xs"
>
{readTableName || "테이블 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs">
.
</CommandEmpty>
<CommandGroup>
{tables.map((t) => (
<CommandItem
key={t.tableName}
value={t.tableName}
onSelect={(v) => {
handleReadTableChange(v);
setReadTableOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
readTableName === t.tableName ? "opacity-100" : "opacity-0"
)}
/>
{t.tableName}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
</div>
<div>
<Label className="text-[10px]">PK </Label>
<Select
value={cfg.readSource?.pkColumn ?? ""}
onValueChange={(v) =>
onUpdateConfig({
readSource: {
...cfg.readSource,
tableName: readTableName || saveTableName,
pkColumn: v,
fieldMappings: cfg.readSource?.fieldMappings ?? [],
},
})
}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="PK 선택" />
</SelectTrigger>
<SelectContent>
{readColumns.map((c) => (
<SelectItem key={colName(c)} value={colName(c)} className="text-xs">
{colName(c)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
{/* 저장 테이블 */}
{hasInputFields && (
<div>
<Label className="text-[10px]"> </Label>
<Popover open={saveTableOpen} onOpenChange={setSaveTableOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="mt-1 h-7 w-full justify-between text-xs"
>
{saveTableName || "테이블 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs">
.
</CommandEmpty>
<CommandGroup>
{tables.map((t) => (
<CommandItem
key={t.tableName}
value={t.tableName}
onSelect={(v) => {
onSaveTableChange(v === saveTableName ? "" : v);
setSaveTableOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
saveTableName === t.tableName ? "opacity-100" : "opacity-0"
)}
/>
{t.tableName}
{t.displayName && t.displayName !== t.tableName && (
<span className="ml-1 text-muted-foreground">
({t.displayName})
</span>
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
</div>}
</div>
)}
{/* ── 2. 읽기 필드 매핑 (display) ── */}
{hasDisplayFields && (readTableForFetch || readSameAsSave) && (
<div className="rounded-md border bg-card">
<div
className="flex cursor-pointer items-center gap-1.5 px-3 py-2"
onClick={() => toggleSection("read")}
>
{collapsed["read"] ? (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronDown className="h-3 w-3 text-muted-foreground" />
)}
<span className="text-xs font-medium"> </span>
<span className="text-[10px] text-muted-foreground">
( )
</span>
</div>
{!collapsed["read"] && <div className="space-y-2 border-t p-3">
{readColumns.length === 0 ? (
<p className="py-2 text-xs text-muted-foreground">
...
</p>
) : (
displayFields.map(({ field }) => {
const m = getReadMappingForField(field.id);
const sm = getSaveMappingForField(field.id);
const isJson = m.valueSource === "json_extract";
return (
<div key={field.id} className="space-y-1">
<div className="flex items-center gap-2">
<span className="w-20 shrink-0 truncate text-xs font-medium">
{field.labelText || "(미설정)"}
</span>
<Select
value={m.valueSource === "direct" ? "db_column" : m.valueSource}
onValueChange={(v) =>
updateReadMapping(field.id, {
valueSource: v as FieldValueSource,
columnName: "",
jsonKey: undefined,
})
}
>
<SelectTrigger className="h-7 w-24 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="db_column" className="text-xs">
DB
</SelectItem>
<SelectItem value="json_extract" className="text-xs">
JSON
</SelectItem>
</SelectContent>
</Select>
{!isJson && (
<>
<span className="text-muted-foreground"></span>
<Select
value={m.columnName || ""}
onValueChange={(v) =>
updateReadMapping(field.id, { columnName: v })
}
>
<SelectTrigger className="h-7 flex-1 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{readColumns.map((c) => (
<SelectItem key={colName(c)} value={colName(c)} className="text-xs">
{colName(c)}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)}
</div>
{isJson && (
<div className="ml-[88px] flex items-center gap-1">
<span className="text-muted-foreground"></span>
<Select
value={m.columnName || ""}
onValueChange={(v) => {
updateReadMapping(field.id, { columnName: v, jsonKey: undefined });
if (readTableForFetch && v) {
fetchJsonKeysForColumn(readTableForFetch, v);
}
}}
>
<SelectTrigger className="h-7 flex-1 text-xs">
<SelectValue placeholder="JSON 컬럼" />
</SelectTrigger>
<SelectContent>
{readColumns.map((c) => (
<SelectItem key={colName(c)} value={colName(c)} className="text-xs">
{colName(c)}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-[10px] text-muted-foreground">.</span>
<JsonKeySelect
value={m.jsonKey ?? ""}
keys={readTableForFetch && m.columnName ? getJsonKeys(readTableForFetch, m.columnName) : []}
onValueChange={(v) => updateReadMapping(field.id, { jsonKey: v })}
onOpen={() => {
if (readTableForFetch && m.columnName) {
fetchJsonKeysForColumn(readTableForFetch, m.columnName);
}
}}
/>
</div>
)}
{saveTableName && saveColumns.length > 0 && (
<div className="ml-[88px] flex items-center gap-1">
<span className="text-muted-foreground"></span>
<Select
value={sm.targetColumn || "__none__"}
onValueChange={(v) =>
updateSaveMapping(field.id, {
targetColumn: v === "__none__" ? "" : v,
})
}
>
<SelectTrigger className="h-7 flex-1 text-xs">
<SelectValue placeholder="저장 컬럼" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-xs">
</SelectItem>
{saveColumns.map((c) => (
<SelectItem key={colName(c)} value={colName(c)} className="text-xs">
{colName(c)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
);
})
)}
</div>}
</div>
)}
{/* ── 3. 입력 필드 매핑 (input, auto 타입 제외) ── */}
{regularInputFields.length > 0 && saveTableName && (
<div className="rounded-md border bg-card">
<div
className="flex cursor-pointer items-center gap-1.5 px-3 py-2"
onClick={() => toggleSection("input")}
>
{collapsed["input"] ? (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronDown className="h-3 w-3 text-muted-foreground" />
)}
<span className="text-xs font-medium"> </span>
<span className="text-[10px] text-muted-foreground">
( )
</span>
</div>
{!collapsed["input"] && <div className="space-y-2 border-t p-3">
{saveColumns.length === 0 ? (
<p className="py-2 text-xs text-muted-foreground">
...
</p>
) : (
regularInputFields.map(({ field }) => {
const m = getSaveMappingForField(field.id);
return (
<div key={field.id} className="flex items-center gap-2">
<span className="w-20 shrink-0 truncate text-xs font-medium">
{field.labelText || "(미설정)"}
</span>
<span className="text-muted-foreground"></span>
<Select
value={m.targetColumn || "__none__"}
onValueChange={(v) =>
updateSaveMapping(field.id, {
targetColumn: v === "__none__" ? "" : v,
valueSource: "direct",
})
}
>
<SelectTrigger className="h-7 flex-1 text-xs">
<SelectValue placeholder="저장 컬럼" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-xs">
</SelectItem>
{saveColumns.map((c) => (
<SelectItem key={colName(c)} value={colName(c)} className="text-xs">
{colName(c)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
})
)}
</div>}
</div>
)}
{/* ── 4. 숨은 필드 매핑 (읽기 필드와 동일한 소스 구조) ── */}
{saveTableName && (
<div className="rounded-md border bg-card">
<div
className="flex cursor-pointer items-center gap-1.5 px-3 py-2"
onClick={() => toggleSection("hidden")}
>
{collapsed["hidden"] ? (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronDown className="h-3 w-3 text-muted-foreground" />
)}
<span className="text-xs font-medium"> </span>
<span className="text-[10px] text-muted-foreground">
(UI , )
</span>
</div>
{!collapsed["hidden"] && <div className="space-y-3 border-t p-3">
{hiddenMappings.map((m) => {
const isJson = m.valueSource === "json_extract";
return (
<div key={m.id} className="space-y-1.5 rounded border bg-background p-2">
<div className="flex items-center gap-2">
<Input
value={m.label || ""}
onChange={(e) => updateHiddenMapping(m.id, { label: e.target.value })}
placeholder="라벨 (관리용)"
className="h-7 flex-1 text-xs"
/>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-destructive"
onClick={() => removeHiddenMapping(m.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<div className="flex items-center gap-2">
<Select
value={m.valueSource || "db_column"}
onValueChange={(v) =>
updateHiddenMapping(m.id, {
valueSource: v as HiddenValueSource,
sourceDbColumn: undefined,
sourceJsonColumn: undefined,
sourceJsonKey: undefined,
})
}
>
<SelectTrigger className="h-7 w-24 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="db_column" className="text-xs">DB </SelectItem>
<SelectItem value="json_extract" className="text-xs">JSON</SelectItem>
</SelectContent>
</Select>
{!isJson && (
<>
<Select
value={m.sourceDbColumn || "__none__"}
onValueChange={(v) =>
updateHiddenMapping(m.id, { sourceDbColumn: v === "__none__" ? "" : v })
}
>
<SelectTrigger className="h-7 flex-1 text-xs">
<SelectValue placeholder="소스 컬럼" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-xs"></SelectItem>
{readColumns.map((c) => (
<SelectItem key={colName(c)} value={colName(c)} className="text-xs">
{colName(c)}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)}
</div>
{isJson && (
<div className="flex items-center gap-1">
<Select
value={m.sourceJsonColumn || "__none__"}
onValueChange={(v) => {
const col = v === "__none__" ? "" : v;
updateHiddenMapping(m.id, { sourceJsonColumn: col, sourceJsonKey: "" });
if (readTableForFetch && col) {
fetchJsonKeysForColumn(readTableForFetch, col);
}
}}
>
<SelectTrigger className="h-7 flex-1 text-xs">
<SelectValue placeholder="JSON 컬럼" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-xs"></SelectItem>
{readColumns.map((c) => (
<SelectItem key={colName(c)} value={colName(c)} className="text-xs">
{colName(c)}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-[10px] text-muted-foreground">.</span>
<JsonKeySelect
value={m.sourceJsonKey ?? ""}
keys={readTableForFetch && m.sourceJsonColumn ? getJsonKeys(readTableForFetch, m.sourceJsonColumn) : []}
onValueChange={(v) => updateHiddenMapping(m.id, { sourceJsonKey: v })}
onOpen={() => {
if (readTableForFetch && m.sourceJsonColumn) {
fetchJsonKeysForColumn(readTableForFetch, m.sourceJsonColumn);
}
}}
/>
</div>
)}
<div className="flex items-center gap-2">
<span className="text-muted-foreground"></span>
<Select
value={m.targetColumn || "__none__"}
onValueChange={(v) =>
updateHiddenMapping(m.id, { targetColumn: v === "__none__" ? "" : v })
}
>
<SelectTrigger className="h-7 flex-1 text-xs">
<SelectValue placeholder="저장 컬럼" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-xs"></SelectItem>
{saveColumns.map((c) => (
<SelectItem key={colName(c)} value={colName(c)} className="text-xs">
{colName(c)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
})}
<Button
variant="outline"
size="sm"
className="w-full text-xs"
onClick={addHiddenMapping}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>}
</div>
)}
{/* ── 5. 자동생성 필드 ── */}
{saveTableName && (
<div className="rounded-md border bg-card">
<div
className="flex cursor-pointer items-center gap-1.5 px-3 py-2"
onClick={() => toggleSection("autogen")}
>
{collapsed["autogen"] ? (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronDown className="h-3 w-3 text-muted-foreground" />
)}
<span className="text-xs font-medium"> </span>
<span className="text-[10px] text-muted-foreground">
( )
</span>
</div>
{!collapsed["autogen"] && <div className="space-y-3 border-t p-3">
{autoGenMappings.map((m) => {
const isLinked = !!m.linkedFieldId;
return (
<div key={m.id} className="space-y-2 rounded border bg-background p-2">
<div className="flex items-center gap-2">
{isLinked && (
<span className="shrink-0 rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
</span>
)}
<Input
value={m.label}
onChange={(e) => updateAutoGenMapping(m.id, { label: e.target.value })}
placeholder="라벨 (예: 입고번호)"
className="h-7 flex-1 text-xs"
readOnly={isLinked}
/>
{!isLinked && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-destructive"
onClick={() => removeAutoGenMapping(m.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground"></span>
<Select
value={m.targetColumn || "__none__"}
onValueChange={(v) =>
updateAutoGenMapping(m.id, { targetColumn: v === "__none__" ? "" : v })
}
>
<SelectTrigger className="h-7 flex-1 text-xs">
<SelectValue placeholder="저장 컬럼" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-xs">
</SelectItem>
{saveColumns.map((c) => (
<SelectItem key={colName(c)} value={colName(c)} className="text-xs">
{colName(c)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Select
value={m.numberingRuleId || "__none__"}
onValueChange={(v) =>
updateAutoGenMapping(m.id, { numberingRuleId: v === "__none__" ? "" : v })
}
>
<SelectTrigger className="mt-0.5 h-7 text-xs">
<SelectValue placeholder="채번 규칙 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-xs">
</SelectItem>
{numberingRules.map((r) => (
<SelectItem key={r.ruleId} value={r.ruleId} className="text-xs">
{r.ruleName || r.ruleId}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-4">
{!isLinked && (
<div className="flex items-center gap-1.5">
<Switch
checked={m.showInForm}
onCheckedChange={(v) => updateAutoGenMapping(m.id, { showInForm: v })}
/>
<Label className="text-[10px]"> </Label>
</div>
)}
<div className="flex items-center gap-1.5">
<Switch
checked={m.showResultModal}
onCheckedChange={(v) => updateAutoGenMapping(m.id, { showResultModal: v })}
/>
<Label className="text-[10px]"> </Label>
</div>
</div>
</div>
);
})}
<Button
variant="outline"
size="sm"
className="w-full text-xs"
onClick={addAutoGenMapping}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>}
</div>
)}
{/* 저장 테이블 미선택 안내 */}
{(regularInputFields.length > 0 || autoInputFields.length > 0) && !saveTableName && !noFields && (
<p className="rounded-md border bg-muted/30 px-3 py-3 text-center text-xs text-muted-foreground">
.
</p>
)}
</div>
);
}
// ========================================
// SectionEditor: 섹션 단위 편집
// ========================================
interface SectionEditorProps {
section: PopFieldSection;
index: number;
canDelete: boolean;
onUpdate: (partial: Partial<PopFieldSection>) => void;
onRemove: () => void;
onMoveUp: () => void;
}
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,
}: SectionEditorProps) {
const [collapsed, setCollapsed] = useState(false);
const resolvedStyle = migrateStyle(section.style);
const sectionFields = section.fields || [];
const updateField = useCallback(
(fieldId: string, partial: Partial<PopFieldItem>) => {
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 (
<div className="rounded-md border bg-card">
{/* 섹션 헤더 */}
<div
className="flex cursor-pointer items-center gap-2 px-3 py-2"
onClick={() => setCollapsed(!collapsed)}
>
{collapsed ? (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronDown className="h-3 w-3 text-muted-foreground" />
)}
<span className="flex-1 text-xs font-medium">
{index + 1}
{section.label && ` - ${section.label}`}
</span>
{index > 0 && (
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={(e) => {
e.stopPropagation();
onMoveUp();
}}
>
<GripVertical className="h-3 w-3" />
</Button>
)}
{canDelete && (
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-destructive"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
{!collapsed && (
<div className="space-y-3 border-t px-3 py-3">
{/* 섹션 라벨 */}
<div>
<Label className="text-xs"> </Label>
<Input
value={section.label || ""}
onChange={(e) => onUpdate({ label: e.target.value })}
placeholder="선택사항"
className="mt-1 h-7 text-xs"
/>
</div>
{/* 스타일 + 열 수 (가로 배치) */}
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs"></Label>
<Select
value={resolvedStyle}
onValueChange={(v) =>
onUpdate({ style: v as FieldSectionStyle })
}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(
Object.entries(FIELD_SECTION_STYLE_LABELS) as [
FieldSectionStyle,
string,
][]
).map(([val, label]) => (
<SelectItem key={val} value={val} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"> </Label>
<Select
value={String(section.columns)}
onValueChange={(v) =>
onUpdate({
columns: v === "auto" ? "auto" : (Number(v) as 1 | 2 | 3 | 4),
})
}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto" className="text-xs">
</SelectItem>
<SelectItem value="1" className="text-xs">
1
</SelectItem>
<SelectItem value="2" className="text-xs">
2
</SelectItem>
<SelectItem value="3" className="text-xs">
3
</SelectItem>
<SelectItem value="4" className="text-xs">
4
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 라벨 표시 토글 */}
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={section.showLabels}
onCheckedChange={(v) => onUpdate({ showLabels: v })}
/>
</div>
{/* 커스텀 색상 */}
<AppearanceEditor
style={resolvedStyle}
appearance={section.appearance}
onUpdate={(appearance) => onUpdate({ appearance })}
/>
{/* 필드 목록 */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
({sectionFields.length})
</Label>
{sectionFields.map((field) => (
<FieldItemEditor
key={field.id}
field={field}
sectionStyle={resolvedStyle}
onUpdate={(partial) => updateField(field.id, partial)}
onRemove={() => removeField(field.id)}
/>
))}
<Button
variant="outline"
size="sm"
className="w-full text-xs"
onClick={addField}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
</div>
)}
</div>
);
}
// ========================================
// FieldItemEditor: 필드 단위 편집
// ========================================
interface FieldItemEditorProps {
field: PopFieldItem;
sectionStyle?: FieldSectionStyle;
onUpdate: (partial: Partial<PopFieldItem>) => void;
onRemove: () => void;
}
function FieldItemEditor({
field,
sectionStyle,
onUpdate,
onRemove,
}: FieldItemEditorProps) {
const isDisplay = sectionStyle === "display";
const [expanded, setExpanded] = useState(false);
return (
<div className="rounded border bg-background">
{/* 필드 헤더 */}
<div
className="flex cursor-pointer items-center gap-2 px-2 py-1.5"
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
<ChevronDown className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
)}
<span className="flex-1 truncate text-xs">
{field.labelText || "(미설정)"}
<span className="ml-1 text-muted-foreground">
[{FIELD_INPUT_TYPE_LABELS[field.inputType]}]
</span>
{field.readOnly && (
<span className="ml-1 text-orange-500">()</span>
)}
</span>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-destructive"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{expanded && (
<div className="space-y-2 border-t px-2 py-2">
{/* 라벨 + 타입 */}
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-[10px]"></Label>
<Input
value={field.labelText || ""}
onChange={(e) => onUpdate({ labelText: e.target.value })}
placeholder="표시 라벨"
className="mt-0.5 h-7 text-xs"
/>
</div>
<div>
<Label className="text-[10px]"></Label>
<Select
value={field.inputType}
onValueChange={(v) =>
onUpdate({ inputType: v as FieldInputType })
}
>
<SelectTrigger className="mt-0.5 h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(
Object.entries(FIELD_INPUT_TYPE_LABELS) as [
FieldInputType,
string,
][]
).map(([val, label]) => (
<SelectItem key={val} value={val} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 플레이스홀더 */}
<div>
<Label className="text-[10px]"></Label>
<Input
value={field.placeholder || ""}
onChange={(e) => onUpdate({ placeholder: e.target.value })}
placeholder="힌트 텍스트"
className="mt-0.5 h-7 text-xs"
/>
</div>
{/* 읽기전용 + 필수 (입력 폼에서만 표시) */}
{!isDisplay && (
<div className="flex items-center gap-4">
<div className="flex items-center gap-1.5">
<Switch
checked={field.readOnly || false}
onCheckedChange={(v) => onUpdate({ readOnly: v })}
/>
<Label className="text-[10px]"></Label>
</div>
<div className="flex items-center gap-1.5">
<Switch
checked={field.validation?.required || false}
onCheckedChange={(v) =>
onUpdate({
validation: { ...field.validation, required: v },
})
}
/>
<Label className="text-[10px]"></Label>
</div>
</div>
)}
{/* 단위 (number, numpad) */}
{(field.inputType === "number" || field.inputType === "numpad") && (
<div>
<Label className="text-[10px]"></Label>
<Input
value={field.unit || ""}
onChange={(e) => onUpdate({ unit: e.target.value })}
placeholder="EA, KG 등"
className="mt-0.5 h-7 text-xs"
/>
</div>
)}
{/* select 전용: 옵션 소스 */}
{field.inputType === "select" && (
<SelectSourceEditor
source={field.selectSource}
onUpdate={(source) => onUpdate({ selectSource: source })}
/>
)}
{/* auto 전용: 채번 설정 */}
{field.inputType === "auto" && (
<AutoNumberEditor
config={field.autoNumber}
onUpdate={(autoNumber) => onUpdate({ autoNumber })}
/>
)}
</div>
)}
</div>
);
}
// ========================================
// SelectSourceEditor: select 옵션 소스 편집
// ========================================
function SelectSourceEditor({
source,
onUpdate,
}: {
source?: FieldSelectSource;
onUpdate: (source: FieldSelectSource) => void;
}) {
const current: FieldSelectSource = source || {
type: "static",
staticOptions: [],
};
return (
<div className="space-y-2 rounded border bg-muted/30 p-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={current.type}
onValueChange={(v) =>
onUpdate({ ...current, type: v as "static" | "table" })
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="static" className="text-xs">
</SelectItem>
<SelectItem value="table" className="text-xs">
</SelectItem>
</SelectContent>
</Select>
{current.type === "static" && (
<StaticOptionsEditor
options={current.staticOptions || []}
onUpdate={(opts) => onUpdate({ ...current, staticOptions: opts })}
/>
)}
{current.type === "table" && (
<TableSourceEditor
source={current}
onUpdate={(partial) => onUpdate({ ...current, ...partial })}
/>
)}
</div>
);
}
// ========================================
// StaticOptionsEditor: 정적 옵션 CRUD
// ========================================
function StaticOptionsEditor({
options,
onUpdate,
}: {
options: { value: string; label: string }[];
onUpdate: (options: { value: string; label: string }[]) => void;
}) {
return (
<div className="space-y-1">
{options.map((opt, idx) => (
<div key={idx} className="flex items-center gap-1">
<Input
value={opt.value}
onChange={(e) => {
const next = [...options];
next[idx] = { ...opt, value: e.target.value };
onUpdate(next);
}}
placeholder="값"
className="h-6 flex-1 text-[10px]"
/>
<Input
value={opt.label}
onChange={(e) => {
const next = [...options];
next[idx] = { ...opt, label: e.target.value };
onUpdate(next);
}}
placeholder="표시"
className="h-6 flex-1 text-[10px]"
/>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-destructive"
onClick={() => onUpdate(options.filter((_, i) => i !== idx))}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
className="h-6 w-full text-[10px]"
onClick={() => onUpdate([...options, { value: "", label: "" }])}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
);
}
// ========================================
// TableSourceEditor: 테이블 소스 설정
// ========================================
function TableSourceEditor({
source,
onUpdate,
}: {
source: FieldSelectSource;
onUpdate: (partial: Partial<FieldSelectSource>) => void;
}) {
const [tables, setTables] = useState<TableInfo[]>([]);
const [columns, setColumns] = useState<ColumnInfo[]>([]);
const [tblOpen, setTblOpen] = useState(false);
useEffect(() => {
fetchTableList().then(setTables);
}, []);
useEffect(() => {
if (source.tableName) {
fetchTableColumns(source.tableName).then(setColumns);
} else {
setColumns([]);
}
}, [source.tableName]);
return (
<div className="space-y-2">
{/* 테이블 Combobox */}
<Popover open={tblOpen} onOpenChange={setTblOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-7 w-full justify-between text-xs"
>
{source.tableName || "테이블 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs">
.
</CommandEmpty>
<CommandGroup>
{tables.map((t) => (
<CommandItem
key={t.tableName}
value={t.tableName}
onSelect={(v) => {
onUpdate({ tableName: v });
setTblOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
source.tableName === t.tableName
? "opacity-100"
: "opacity-0"
)}
/>
{t.tableName}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* 값 컬럼 / 라벨 컬럼 */}
<div className="grid grid-cols-2 gap-1">
<div>
<Label className="text-[10px]"> </Label>
<Select
value={source.valueColumn || ""}
onValueChange={(v) => onUpdate({ valueColumn: v })}
>
<SelectTrigger className="mt-0.5 h-7 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{columns.map((c) => (
<SelectItem
key={c.name}
value={c.name}
className="text-xs"
>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Select
value={source.labelColumn || ""}
onValueChange={(v) => onUpdate({ labelColumn: v })}
>
<SelectTrigger className="mt-0.5 h-7 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{columns.map((c) => (
<SelectItem
key={c.name}
value={c.name}
className="text-xs"
>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
);
}
// ========================================
// AutoNumberEditor: 자동 채번 설정
// ========================================
function AutoNumberEditor({
config,
onUpdate,
}: {
config?: AutoNumberConfig;
onUpdate: (config: AutoNumberConfig) => void;
}) {
const current: AutoNumberConfig = config || {
prefix: "",
dateFormat: "YYYYMMDD",
separator: "-",
sequenceDigits: 3,
};
return (
<div className="space-y-2 rounded border bg-muted/30 p-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-[10px]"></Label>
<Input
value={current.prefix || ""}
onChange={(e) => onUpdate({ ...current, prefix: e.target.value })}
placeholder="IN-"
className="mt-0.5 h-7 text-xs"
/>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Select
value={current.dateFormat || "YYYYMMDD"}
onValueChange={(v) => onUpdate({ ...current, dateFormat: v })}
>
<SelectTrigger className="mt-0.5 h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="YYYYMMDD" className="text-xs">
YYYYMMDD
</SelectItem>
<SelectItem value="YYMMDD" className="text-xs">
YYMMDD
</SelectItem>
<SelectItem value="YYMM" className="text-xs">
YYMM
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-[10px]"></Label>
<Input
value={current.separator || ""}
onChange={(e) => onUpdate({ ...current, separator: e.target.value })}
placeholder="-"
className="mt-0.5 h-7 text-xs"
/>
</div>
<div>
<Label className="text-[10px]">퀀 릿</Label>
<Input
type="number"
value={current.sequenceDigits || 3}
onChange={(e) =>
onUpdate({
...current,
sequenceDigits: Number(e.target.value) || 3,
})
}
min={1}
max={10}
className="mt-0.5 h-7 text-xs"
/>
</div>
</div>
{/* 미리보기 */}
<div className="text-[10px] text-muted-foreground">
:{" "}
<span className="font-mono">
{current.prefix || ""}
{current.separator || ""}
{current.dateFormat === "YYMM"
? "2602"
: current.dateFormat === "YYMMDD"
? "260226"
: "20260226"}
{current.separator || ""}
{"0".repeat(current.sequenceDigits || 3).slice(0, -1)}1
</span>
</div>
</div>
);
}
// ========================================
// 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 (
<Input
placeholder="키"
value={value}
onChange={(e) => onValueChange(e.target.value)}
onFocus={() => onOpen?.()}
className="h-7 w-24 text-xs"
/>
);
}
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-7 w-24 justify-between text-xs"
>
<span className="truncate">{value || "키 선택"}</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-48 p-0" align="start">
<Command>
<CommandInput placeholder="키 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs">
{keys.length === 0 ? "데이터를 불러오는 중..." : "일치하는 키가 없습니다."}
</CommandEmpty>
<CommandGroup>
{keys.map((k) => (
<CommandItem
key={k}
value={k}
onSelect={(v) => {
onValueChange(v === value ? "" : v);
setOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
value === k ? "opacity-100" : "opacity-0"
)}
/>
{k}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
// ========================================
// 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 (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground"> </Label>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-[10px]"></Label>
<Select
value={current.bgColor || "__default__"}
onValueChange={(v) => onUpdate({ ...current, bgColor: v === "__default__" ? undefined : v })}
>
<SelectTrigger className="mt-0.5 h-7 text-xs">
<SelectValue placeholder={defaults.bgColor} />
</SelectTrigger>
<SelectContent>
{BG_COLOR_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
<span className="flex items-center gap-1.5">
{opt.value !== "__default__" && <span className={cn("inline-block h-3 w-3 rounded-sm border", opt.value)} />}
{opt.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[10px]"></Label>
<Select
value={current.borderColor || "__default__"}
onValueChange={(v) => onUpdate({ ...current, borderColor: v === "__default__" ? undefined : v })}
>
<SelectTrigger className="mt-0.5 h-7 text-xs">
<SelectValue placeholder={defaults.borderColor} />
</SelectTrigger>
<SelectContent>
{BORDER_COLOR_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
<span className="flex items-center gap-1.5">
{opt.value !== "__default__" && <span className={cn("inline-block h-3 w-3 rounded-sm border-2", opt.value)} />}
{opt.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
);
}