ERP-node/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPan...

2306 lines
119 KiB
TypeScript

"use client";
import React, { useState, useEffect, useCallback } from "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 { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import {
Plus,
Trash2,
GripVertical,
ChevronUp,
ChevronDown,
Settings,
Database,
Layout,
Hash,
Check,
ChevronsUpDown,
} from "lucide-react";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { apiClient } from "@/lib/api/client";
import { getNumberingRules } from "@/lib/api/numberingRule";
import {
UniversalFormModalConfig,
UniversalFormModalConfigPanelProps,
FormSectionConfig,
FormFieldConfig,
LinkedFieldMapping,
FIELD_TYPE_OPTIONS,
MODAL_SIZE_OPTIONS,
SELECT_OPTION_TYPE_OPTIONS,
LINKED_FIELD_DISPLAY_FORMAT_OPTIONS,
} from "./types";
import {
defaultFieldConfig,
defaultSectionConfig,
defaultNumberingRuleConfig,
defaultSelectOptionsConfig,
generateSectionId,
generateFieldId,
} from "./config";
// 도움말 텍스트 컴포넌트
const HelpText = ({ children }: { children: React.ReactNode }) => (
<p className="text-[10px] text-muted-foreground mt-0.5">{children}</p>
);
export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFormModalConfigPanelProps) {
// 테이블 목록
const [tables, setTables] = useState<{ name: string; label: string }[]>([]);
const [tableColumns, setTableColumns] = useState<{
[tableName: string]: { name: string; type: string; label: string }[];
}>({});
// 채번규칙 목록
const [numberingRules, setNumberingRules] = useState<{ id: string; name: string }[]>([]);
// 선택된 섹션/필드
const [selectedSectionId, setSelectedSectionId] = useState<string | null>(null);
const [selectedFieldId, setSelectedFieldId] = useState<string | null>(null);
// 테이블 선택 Combobox 상태
const [tableSelectOpen, setTableSelectOpen] = useState(false);
// 테이블 목록 로드
useEffect(() => {
console.log("[UniversalFormModal ConfigPanel] 초기화 - 테이블 및 채번규칙 로드");
loadTables();
loadNumberingRules();
}, []);
// 저장 테이블 변경 시 컬럼 로드
useEffect(() => {
if (config.saveConfig.tableName) {
loadTableColumns(config.saveConfig.tableName);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config.saveConfig.tableName]);
// 다중 컬럼 저장의 소스 테이블 컬럼 로드
useEffect(() => {
const allSourceTables = new Set<string>();
config.sections.forEach((section) => {
// 필드 레벨의 linkedFieldGroup 확인
section.fields.forEach((field) => {
if (field.linkedFieldGroup?.sourceTable) {
allSourceTables.add(field.linkedFieldGroup.sourceTable);
}
});
});
allSourceTables.forEach((tableName) => {
if (!tableColumns[tableName]) {
loadTableColumns(tableName);
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config.sections]);
// 다중 테이블 저장 설정의 메인/서브 테이블 컬럼 로드
useEffect(() => {
const customApiSave = config.saveConfig.customApiSave;
if (customApiSave?.enabled && customApiSave?.multiTable) {
// 메인 테이블 컬럼 로드
const mainTableName = customApiSave.multiTable.mainTable?.tableName;
if (mainTableName && !tableColumns[mainTableName]) {
loadTableColumns(mainTableName);
}
// 서브 테이블들 컬럼 로드
customApiSave.multiTable.subTables?.forEach((subTable) => {
if (subTable.tableName && !tableColumns[subTable.tableName]) {
loadTableColumns(subTable.tableName);
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config.saveConfig.customApiSave]);
const loadTables = async () => {
try {
const response = await apiClient.get("/table-management/tables");
const data = response.data?.data;
if (response.data?.success && Array.isArray(data)) {
setTables(
data.map((t: { tableName?: string; table_name?: string; tableLabel?: string; table_label?: string }) => ({
name: t.tableName || t.table_name || "",
label: t.tableLabel || t.table_label || t.tableName || t.table_name || "",
})),
);
}
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
}
};
const loadTableColumns = async (tableName: string) => {
console.log(`[UniversalFormModal] 테이블 컬럼 로드 시도: ${tableName}`);
if (!tableName || (tableColumns[tableName] && tableColumns[tableName].length > 0)) return;
try {
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
console.log("[UniversalFormModal] 테이블 컬럼 응답:", response.data);
// API 응답 구조: { success: true, data: { columns: [...] } }
const data = response.data?.data?.columns || response.data?.data;
if (response.data?.success && Array.isArray(data)) {
console.log(`[UniversalFormModal] 파싱된 컬럼 ${data.length}개:`, data);
setTableColumns((prev) => ({
...prev,
[tableName]: data.map(
(c: {
columnName?: string;
column_name?: string;
dataType?: string;
data_type?: string;
columnLabel?: string;
column_label?: string;
name?: string;
}) => ({
name: c.columnName || c.column_name || c.name || "",
type: c.dataType || c.data_type || "",
label: c.columnLabel || c.column_label || c.columnName || c.column_name || c.name || "",
}),
),
}));
} else {
console.warn("[UniversalFormModal] 컬럼 데이터 없음 또는 형식 오류:", response.data);
setTableColumns((prev) => ({ ...prev, [tableName]: [] }));
}
} catch (error) {
console.error(`테이블 컬럼 로드 실패 (${tableName}):`, error);
setTableColumns((prev) => ({ ...prev, [tableName]: [] }));
}
};
const loadNumberingRules = async () => {
try {
console.log("[UniversalFormModal] 채번규칙 로드 시도");
const response = await getNumberingRules();
console.log("[UniversalFormModal] 채번규칙 응답:", response);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data: any = response.data;
if (response.success && Array.isArray(data) && data.length > 0) {
const rules = data.map(
(r: {
id?: string | number;
ruleId?: string;
rule_id?: string;
name?: string;
ruleName?: string;
rule_name?: string;
}) => ({
id: String(r.id || r.ruleId || r.rule_id || ""),
name: r.name || r.ruleName || r.rule_name || "",
}),
);
console.log("[UniversalFormModal] 파싱된 채번규칙:", rules);
setNumberingRules(rules);
} else {
console.warn("[UniversalFormModal] 채번규칙 데이터 없음:", data);
}
} catch (error) {
console.error("[UniversalFormModal] 채번규칙 목록 로드 실패:", error);
}
};
// 설정 업데이트 헬퍼
const updateModalConfig = useCallback(
(updates: Partial<UniversalFormModalConfig["modal"]>) => {
onChange({
...config,
modal: { ...config.modal, ...updates },
});
},
[config, onChange],
);
const updateSaveConfig = useCallback(
(updates: Partial<UniversalFormModalConfig["saveConfig"]>) => {
onChange({
...config,
saveConfig: { ...config.saveConfig, ...updates },
});
},
[config, onChange],
);
// 섹션 관리
const addSection = useCallback(() => {
const newSection: FormSectionConfig = {
...defaultSectionConfig,
id: generateSectionId(),
title: `섹션 ${config.sections.length + 1}`,
};
onChange({
...config,
sections: [...config.sections, newSection],
});
setSelectedSectionId(newSection.id);
}, [config, onChange]);
const updateSection = useCallback(
(sectionId: string, updates: Partial<FormSectionConfig>) => {
onChange({
...config,
sections: config.sections.map((s) => (s.id === sectionId ? { ...s, ...updates } : s)),
});
},
[config, onChange],
);
const removeSection = useCallback(
(sectionId: string) => {
onChange({
...config,
sections: config.sections.filter((s) => s.id !== sectionId),
});
if (selectedSectionId === sectionId) {
setSelectedSectionId(null);
setSelectedFieldId(null);
}
},
[config, onChange, selectedSectionId],
);
const moveSectionUp = useCallback(
(index: number) => {
if (index <= 0) return;
const newSections = [...config.sections];
[newSections[index - 1], newSections[index]] = [newSections[index], newSections[index - 1]];
onChange({ ...config, sections: newSections });
},
[config, onChange],
);
const moveSectionDown = useCallback(
(index: number) => {
if (index >= config.sections.length - 1) return;
const newSections = [...config.sections];
[newSections[index], newSections[index + 1]] = [newSections[index + 1], newSections[index]];
onChange({ ...config, sections: newSections });
},
[config, onChange],
);
// 필드 관리
const addField = useCallback(
(sectionId: string) => {
const newField: FormFieldConfig = {
...defaultFieldConfig,
id: generateFieldId(),
label: "새 필드",
numberingRule: { ...defaultNumberingRuleConfig },
selectOptions: { ...defaultSelectOptionsConfig },
};
onChange({
...config,
sections: config.sections.map((s) =>
s.id === sectionId ? { ...s, fields: [...s.fields, newField] } : s,
),
});
setSelectedSectionId(sectionId);
setSelectedFieldId(newField.id);
},
[config, onChange],
);
const updateField = useCallback(
(sectionId: string, fieldId: string, updates: Partial<FormFieldConfig>) => {
onChange({
...config,
sections: config.sections.map((s) =>
s.id === sectionId
? {
...s,
fields: s.fields.map((f) => (f.id === fieldId ? { ...f, ...updates } : f)),
}
: s,
),
});
},
[config, onChange],
);
const removeField = useCallback(
(sectionId: string, fieldId: string) => {
onChange({
...config,
sections: config.sections.map((s) =>
s.id === sectionId ? { ...s, fields: s.fields.filter((f) => f.id !== fieldId) } : s,
),
});
if (selectedFieldId === fieldId) {
setSelectedFieldId(null);
}
},
[config, onChange, selectedFieldId],
);
// 선택된 섹션/필드 가져오기
const selectedSection = config.sections.find((s) => s.id === selectedSectionId);
const selectedField = selectedSection?.fields.find((f) => f.id === selectedFieldId);
// 현재 테이블의 컬럼 목록
const currentColumns = tableColumns[config.saveConfig.tableName] || [];
return (
<ScrollArea className="h-full">
<div className="p-3 space-y-4">
{/* 모달 기본 설정 */}
<Accordion type="single" collapsible defaultValue="modal-settings">
<AccordionItem value="modal-settings" className="border rounded-lg">
<AccordionTrigger className="px-3 py-2 text-xs font-medium hover:no-underline">
<div className="flex items-center gap-2">
<Layout className="h-3.5 w-3.5" />
</div>
</AccordionTrigger>
<AccordionContent className="px-3 pb-3 space-y-3">
<div>
<Label className="text-[10px]"> </Label>
<Input
value={config.modal.title || ""}
onChange={(e) => updateModalConfig({ title: e.target.value })}
placeholder="모달 제목 입력"
className="h-7 text-xs mt-1"
/>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Select
value={config.modal.size || "lg"}
onValueChange={(value) =>
updateModalConfig({ size: value as UniversalFormModalConfig["modal"]["size"] })
}
>
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MODAL_SIZE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="border rounded-md p-2 space-y-2">
<div className="flex items-center justify-between">
<span className="text-[10px] font-medium"> </span>
<Switch
checked={config.modal.showSaveButton !== false}
onCheckedChange={(checked) => updateModalConfig({ showSaveButton: checked })}
/>
</div>
<HelpText>ButtonPrimary </HelpText>
{config.modal.showSaveButton !== false && (
<div>
<Label className="text-[10px]"> </Label>
<Input
value={config.modal.saveButtonText || "저장"}
onChange={(e) => updateModalConfig({ saveButtonText: e.target.value })}
className="h-7 text-xs mt-1"
/>
</div>
)}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
{/* 저장 설정 */}
<Accordion type="single" collapsible defaultValue="save-settings">
<AccordionItem value="save-settings" className="border rounded-lg">
<AccordionTrigger className="px-3 py-2 text-xs font-medium hover:no-underline">
<div className="flex items-center gap-2">
<Database className="h-3.5 w-3.5" />
</div>
</AccordionTrigger>
<AccordionContent className="px-3 pb-3 space-y-3">
{/* 저장 테이블 - Combobox */}
<div>
<Label className="text-[10px]"> </Label>
{config.saveConfig.customApiSave?.enabled ? (
<div className="mt-1 p-2 bg-muted/50 rounded text-[10px] text-muted-foreground">
API API가 .
{config.saveConfig.customApiSave?.apiType === "user-with-dept" && (
<span className="block mt-1"> 테이블: user_info + user_dept</span>
)}
</div>
) : (
<>
<Popover open={tableSelectOpen} onOpenChange={setTableSelectOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tableSelectOpen}
className="w-full h-7 justify-between text-xs mt-1"
>
{config.saveConfig.tableName
? tables.find((t) => t.name === config.saveConfig.tableName)?.label ||
config.saveConfig.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 h-8" />
<CommandList>
<CommandEmpty className="text-xs py-2 text-center"> </CommandEmpty>
<CommandGroup>
{tables.map((t) => (
<CommandItem
key={t.name}
value={`${t.name} ${t.label}`}
onSelect={() => {
updateSaveConfig({ tableName: t.name });
setTableSelectOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.saveConfig.tableName === t.name ? "opacity-100" : "opacity-0",
)}
/>
<span className="font-medium">{t.name}</span>
{t.label !== t.name && (
<span className="ml-1 text-muted-foreground">({t.label})</span>
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{config.saveConfig.tableName && (
<p className="text-[10px] text-muted-foreground mt-1">
{currentColumns.length}
</p>
)}
</>
)}
</div>
{/* 다중 행 저장 설정 - 전용 API 모드에서는 숨김 */}
{!config.saveConfig.customApiSave?.enabled && (
<div className="border rounded-md p-2 space-y-2">
<div className="flex items-center justify-between">
<span className="text-[10px] font-medium"> </span>
<Switch
checked={config.saveConfig.multiRowSave?.enabled || false}
onCheckedChange={(checked) =>
updateSaveConfig({
multiRowSave: { ...config.saveConfig.multiRowSave, enabled: checked },
})
}
/>
</div>
<HelpText> .</HelpText>
{config.saveConfig.multiRowSave?.enabled && (
<div className="space-y-2 pt-2 border-t">
{/* 공통 필드 선택 */}
<div>
<Label className="text-[10px]"> ( )</Label>
<div className="mt-1 max-h-32 overflow-y-auto border rounded p-1 space-y-1">
{config.sections
.filter((s) => !s.repeatable)
.flatMap((s) => s.fields)
.map((field) => (
<label key={field.id} className="flex items-center gap-1 text-[10px] cursor-pointer">
<input
type="checkbox"
checked={config.saveConfig.multiRowSave?.commonFields?.includes(field.columnName) || false}
onChange={(e) => {
const currentFields = config.saveConfig.multiRowSave?.commonFields || [];
const newFields = e.target.checked
? [...currentFields, field.columnName]
: currentFields.filter((f) => f !== field.columnName);
updateSaveConfig({
multiRowSave: { ...config.saveConfig.multiRowSave, commonFields: newFields },
});
}}
className="h-3 w-3"
/>
{field.label} ({field.columnName})
</label>
))}
</div>
<HelpText> </HelpText>
</div>
{/* 메인 섹션 필드 선택 */}
<div>
<Label className="text-[10px]"> ( )</Label>
<div className="mt-1 max-h-32 overflow-y-auto border rounded p-1 space-y-1">
{config.sections
.filter((s) => !s.repeatable)
.flatMap((s) => s.fields)
.filter((field) => !config.saveConfig.multiRowSave?.commonFields?.includes(field.columnName))
.map((field) => (
<label key={field.id} className="flex items-center gap-1 text-[10px] cursor-pointer">
<input
type="checkbox"
checked={config.saveConfig.multiRowSave?.mainSectionFields?.includes(field.columnName) || false}
onChange={(e) => {
const currentFields = config.saveConfig.multiRowSave?.mainSectionFields || [];
const newFields = e.target.checked
? [...currentFields, field.columnName]
: currentFields.filter((f) => f !== field.columnName);
updateSaveConfig({
multiRowSave: { ...config.saveConfig.multiRowSave, mainSectionFields: newFields },
});
}}
className="h-3 w-3"
/>
{field.label} ({field.columnName})
</label>
))}
</div>
<HelpText> ( )</HelpText>
</div>
{/* 반복 섹션 선택 */}
<div>
<Label className="text-[10px]"> </Label>
<Select
value={config.saveConfig.multiRowSave?.repeatSectionId || ""}
onValueChange={(value) =>
updateSaveConfig({
multiRowSave: { ...config.saveConfig.multiRowSave, repeatSectionId: value },
})
}
>
<SelectTrigger className="h-6 text-[10px] mt-1">
<SelectValue placeholder="반복 섹션 선택" />
</SelectTrigger>
<SelectContent>
{config.sections
.filter((s) => s.repeatable)
.map((section) => (
<SelectItem key={section.id} value={section.id}>
{section.title}
</SelectItem>
))}
</SelectContent>
</Select>
<HelpText> </HelpText>
</div>
</div>
)}
</div>
)}
{/* 다중 테이블 저장 설정 (범용) */}
<div className="border rounded-md p-2 space-y-2 overflow-hidden min-w-0">
<div className="flex items-center justify-between min-w-0">
<span className="text-[10px] font-medium truncate"> </span>
<Switch
checked={config.saveConfig.customApiSave?.enabled || false}
onCheckedChange={(checked) =>
updateSaveConfig({
customApiSave: {
...config.saveConfig.customApiSave,
enabled: checked,
apiType: "multi-table",
multiTable: checked ? {
enabled: true,
mainTable: { tableName: config.saveConfig.tableName || "", primaryKeyColumn: "" },
subTables: [],
} : undefined,
},
})
}
/>
</div>
<HelpText>
+ ( ) .
<br />: 사원+, +, +
</HelpText>
{config.saveConfig.customApiSave?.enabled && (
<div className="space-y-3 pt-2 border-t overflow-hidden min-w-0">
{/* API 타입 선택 */}
<div>
<Label className="text-[10px]"> </Label>
<Select
value={config.saveConfig.customApiSave?.apiType || "multi-table"}
onValueChange={(value: "multi-table" | "custom") =>
updateSaveConfig({
customApiSave: { ...config.saveConfig.customApiSave, apiType: value },
})
}
>
<SelectTrigger className="h-6 text-[10px] mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="multi-table"> </SelectItem>
<SelectItem value="custom"> API</SelectItem>
</SelectContent>
</Select>
</div>
{/* 다중 테이블 저장 설정 */}
{config.saveConfig.customApiSave?.apiType === "multi-table" && (
<div className="space-y-3 overflow-hidden min-w-0">
{/* 메인 테이블 설정 */}
<div className="p-2 bg-muted/30 rounded space-y-2 overflow-hidden min-w-0">
<Label className="text-[10px] font-medium"> </Label>
<HelpText> .</HelpText>
<div className="space-y-1">
<Label className="text-[9px]"></Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-6 w-full justify-between text-[10px] font-normal"
>
{config.saveConfig.customApiSave?.multiTable?.mainTable?.tableName
? tables.find((t) => t.name === config.saveConfig.customApiSave?.multiTable?.mainTable?.tableName)?.label ||
config.saveConfig.customApiSave?.multiTable?.mainTable?.tableName
: "테이블 선택"}
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="h-7 text-[10px]" />
<CommandList>
<CommandEmpty className="text-[10px] py-2 text-center"> </CommandEmpty>
<CommandGroup>
{tables.map((table) => (
<CommandItem
key={table.name}
value={`${table.name} ${table.label || ""}`}
onSelect={() => {
updateSaveConfig({
customApiSave: {
...config.saveConfig.customApiSave,
multiTable: {
...config.saveConfig.customApiSave?.multiTable,
enabled: true,
mainTable: {
...config.saveConfig.customApiSave?.multiTable?.mainTable,
tableName: table.name,
},
subTables: config.saveConfig.customApiSave?.multiTable?.subTables || [],
},
},
});
// 테이블 컬럼 로드
if (!tableColumns[table.name]) {
loadTableColumns(table.name);
}
}}
className="text-[10px]"
>
<Check
className={cn(
"mr-1 h-3 w-3",
config.saveConfig.customApiSave?.multiTable?.mainTable?.tableName === table.name
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex flex-col">
<span>{table.label || table.name}</span>
{table.label && <span className="text-[9px] text-muted-foreground">{table.name}</span>}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-1">
<Label className="text-[9px]">PK </Label>
<Select
value={config.saveConfig.customApiSave?.multiTable?.mainTable?.primaryKeyColumn || "_none_"}
onValueChange={(value) =>
updateSaveConfig({
customApiSave: {
...config.saveConfig.customApiSave,
multiTable: {
...config.saveConfig.customApiSave?.multiTable,
enabled: true,
mainTable: {
...config.saveConfig.customApiSave?.multiTable?.mainTable,
tableName: config.saveConfig.customApiSave?.multiTable?.mainTable?.tableName || "",
primaryKeyColumn: value === "_none_" ? "" : value,
},
subTables: config.saveConfig.customApiSave?.multiTable?.subTables || [],
},
},
})
}
>
<SelectTrigger className="h-6 text-[10px]">
<SelectValue placeholder="PK 컬럼 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none_"> </SelectItem>
{(tableColumns[config.saveConfig.customApiSave?.multiTable?.mainTable?.tableName || ""] || []).map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.label || col.name}
</SelectItem>
))}
</SelectContent>
</Select>
<HelpText> PK </HelpText>
</div>
</div>
{/* 서브 테이블 설정 */}
<div className="space-y-2 overflow-hidden min-w-0">
<div className="flex items-center justify-between min-w-0">
<Label className="text-[10px] font-medium truncate"> </Label>
<Button
variant="outline"
size="sm"
className="h-5 text-[9px] px-2"
onClick={() => {
const newSubTable = {
enabled: true,
tableName: "",
repeatSectionId: "",
linkColumn: { mainField: "", subColumn: "" },
fieldMappings: [],
};
updateSaveConfig({
customApiSave: {
...config.saveConfig.customApiSave,
multiTable: {
...config.saveConfig.customApiSave?.multiTable,
enabled: true,
mainTable: config.saveConfig.customApiSave?.multiTable?.mainTable || { tableName: "", primaryKeyColumn: "" },
subTables: [...(config.saveConfig.customApiSave?.multiTable?.subTables || []), newSubTable],
},
},
});
}}
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<HelpText> .</HelpText>
{(config.saveConfig.customApiSave?.multiTable?.subTables || []).map((subTable, subIndex) => (
<div key={subIndex} className="p-2 bg-muted/30 rounded space-y-2 border overflow-hidden min-w-0">
<div className="flex items-center justify-between min-w-0">
<span className="text-[9px] font-medium truncate"> #{subIndex + 1}</span>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 text-destructive"
onClick={() => {
const newSubTables = (config.saveConfig.customApiSave?.multiTable?.subTables || []).filter((_, i) => i !== subIndex);
updateSaveConfig({
customApiSave: {
...config.saveConfig.customApiSave,
multiTable: {
...config.saveConfig.customApiSave?.multiTable,
enabled: true,
mainTable: config.saveConfig.customApiSave?.multiTable?.mainTable || { tableName: "", primaryKeyColumn: "" },
subTables: newSubTables,
},
},
});
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* 서브 테이블명 */}
<div className="space-y-1">
<Label className="text-[9px]"></Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-5 w-full justify-between text-[9px] font-normal"
>
{subTable.tableName
? tables.find((t) => t.name === subTable.tableName)?.label || subTable.tableName
: "테이블 선택"}
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="h-7 text-[10px]" />
<CommandList>
<CommandEmpty className="text-[10px] py-2 text-center"> </CommandEmpty>
<CommandGroup>
{tables.map((table) => (
<CommandItem
key={table.name}
value={`${table.name} ${table.label || ""}`}
onSelect={() => {
const newSubTables = [...(config.saveConfig.customApiSave?.multiTable?.subTables || [])];
newSubTables[subIndex] = { ...newSubTables[subIndex], tableName: table.name };
updateSaveConfig({
customApiSave: {
...config.saveConfig.customApiSave,
multiTable: {
...config.saveConfig.customApiSave?.multiTable,
enabled: true,
mainTable: config.saveConfig.customApiSave?.multiTable?.mainTable || { tableName: "", primaryKeyColumn: "" },
subTables: newSubTables,
},
},
});
// 테이블 컬럼 로드
if (!tableColumns[table.name]) {
loadTableColumns(table.name);
}
}}
className="text-[9px]"
>
<Check
className={cn(
"mr-1 h-3 w-3",
subTable.tableName === table.name ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span>{table.label || table.name}</span>
{table.label && <span className="text-[8px] text-muted-foreground">{table.name}</span>}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 반복 섹션 선택 */}
<div className="space-y-1">
<Label className="text-[9px]"> ( )</Label>
<HelpText> </HelpText>
<Select
value={subTable.repeatSectionId || "_none_"}
onValueChange={(value) => {
const newSubTables = [...(config.saveConfig.customApiSave?.multiTable?.subTables || [])];
newSubTables[subIndex] = { ...newSubTables[subIndex], repeatSectionId: value === "_none_" ? "" : value };
updateSaveConfig({
customApiSave: {
...config.saveConfig.customApiSave,
multiTable: {
...config.saveConfig.customApiSave?.multiTable,
enabled: true,
mainTable: config.saveConfig.customApiSave?.multiTable?.mainTable || { tableName: "", primaryKeyColumn: "" },
subTables: newSubTables,
},
},
});
}}
>
<SelectTrigger className="h-5 text-[9px]">
<SelectValue placeholder="반복 섹션 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none_"> </SelectItem>
{config.sections
.filter((s) => s.repeatable)
.map((section) => (
<SelectItem key={section.id} value={section.id}>
: {section.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 연결 컬럼 설정 */}
<div className="space-y-1 overflow-hidden min-w-0">
<Label className="text-[9px]"> (FK)</Label>
<HelpText> PK와 FK를 </HelpText>
<div className="space-y-1">
{/* 메인 테이블 컬럼 선택 (PK 컬럼 기준) */}
<Select
value={subTable.linkColumn?.mainField || "_none_"}
onValueChange={(value) => {
const newSubTables = [...(config.saveConfig.customApiSave?.multiTable?.subTables || [])];
newSubTables[subIndex] = {
...newSubTables[subIndex],
linkColumn: { ...newSubTables[subIndex].linkColumn, mainField: value === "_none_" ? "" : value },
};
updateSaveConfig({
customApiSave: {
...config.saveConfig.customApiSave,
multiTable: {
...config.saveConfig.customApiSave?.multiTable,
enabled: true,
mainTable: config.saveConfig.customApiSave?.multiTable?.mainTable || { tableName: "", primaryKeyColumn: "" },
subTables: newSubTables,
},
},
});
}}
>
<SelectTrigger className="h-5 text-[9px] w-full">
<SelectValue placeholder="메인 테이블 컬럼" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none_"></SelectItem>
{/* 메인 테이블의 컬럼 목록에서 선택 */}
{(tableColumns[config.saveConfig.customApiSave?.multiTable?.mainTable?.tableName || ""] || []).map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.label || col.name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="text-center text-[9px] text-muted-foreground"></div>
{/* 서브 테이블 컬럼 선택 (FK 컬럼) */}
<Select
value={subTable.linkColumn?.subColumn || "_none_"}
onValueChange={(value) => {
const newSubTables = [...(config.saveConfig.customApiSave?.multiTable?.subTables || [])];
newSubTables[subIndex] = {
...newSubTables[subIndex],
linkColumn: { ...newSubTables[subIndex].linkColumn, subColumn: value === "_none_" ? "" : value },
};
updateSaveConfig({
customApiSave: {
...config.saveConfig.customApiSave,
multiTable: {
...config.saveConfig.customApiSave?.multiTable,
enabled: true,
mainTable: config.saveConfig.customApiSave?.multiTable?.mainTable || { tableName: "", primaryKeyColumn: "" },
subTables: newSubTables,
},
},
});
}}
>
<SelectTrigger className="h-5 text-[9px] w-full">
<SelectValue placeholder="서브 테이블 컬럼" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none_"></SelectItem>
{(tableColumns[subTable.tableName] || []).map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.label || col.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<HelpText> </HelpText>
</div>
{/* 필드 매핑 */}
{subTable.repeatSectionId && subTable.tableName && (
<div className="space-y-1 overflow-hidden min-w-0">
<div className="flex items-center justify-between min-w-0">
<Label className="text-[9px] truncate"> </Label>
<Button
variant="ghost"
size="sm"
className="h-4 w-4 p-0 shrink-0"
onClick={() => {
const newSubTables = [...(config.saveConfig.customApiSave?.multiTable?.subTables || [])];
newSubTables[subIndex] = {
...newSubTables[subIndex],
fieldMappings: [...(newSubTables[subIndex].fieldMappings || []), { formField: "", targetColumn: "" }],
};
updateSaveConfig({
customApiSave: {
...config.saveConfig.customApiSave,
multiTable: {
...config.saveConfig.customApiSave?.multiTable,
enabled: true,
mainTable: config.saveConfig.customApiSave?.multiTable?.mainTable || { tableName: "", primaryKeyColumn: "" },
subTables: newSubTables,
},
},
});
}}
>
<Plus className="h-3 w-3" />
</Button>
</div>
{(subTable.fieldMappings || []).map((mapping, mapIndex) => {
const repeatSection = config.sections.find((s) => s.id === subTable.repeatSectionId);
const sectionFields = repeatSection?.fields || [];
return (
<div key={mapIndex} className="p-1 bg-background rounded border space-y-1">
<div className="flex items-center justify-between">
<span className="text-[8px] text-muted-foreground"> #{mapIndex + 1}</span>
<Button
variant="ghost"
size="sm"
className="h-4 w-4 p-0 text-destructive"
onClick={() => {
const newSubTables = [...(config.saveConfig.customApiSave?.multiTable?.subTables || [])];
newSubTables[subIndex] = {
...newSubTables[subIndex],
fieldMappings: (newSubTables[subIndex].fieldMappings || []).filter((_, i) => i !== mapIndex),
};
updateSaveConfig({
customApiSave: {
...config.saveConfig.customApiSave,
multiTable: {
...config.saveConfig.customApiSave?.multiTable,
enabled: true,
mainTable: config.saveConfig.customApiSave?.multiTable?.mainTable || { tableName: "", primaryKeyColumn: "" },
subTables: newSubTables,
},
},
});
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<Select
value={mapping.formField || "_none_"}
onValueChange={(value) => {
const newSubTables = [...(config.saveConfig.customApiSave?.multiTable?.subTables || [])];
const newMappings = [...(newSubTables[subIndex].fieldMappings || [])];
newMappings[mapIndex] = { ...newMappings[mapIndex], formField: value === "_none_" ? "" : value };
newSubTables[subIndex] = { ...newSubTables[subIndex], fieldMappings: newMappings };
updateSaveConfig({
customApiSave: {
...config.saveConfig.customApiSave,
multiTable: {
...config.saveConfig.customApiSave?.multiTable,
enabled: true,
mainTable: config.saveConfig.customApiSave?.multiTable?.mainTable || { tableName: "", primaryKeyColumn: "" },
subTables: newSubTables,
},
},
});
}}
>
<SelectTrigger className="h-5 text-[9px] w-full">
<SelectValue placeholder="폼 필드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none_"></SelectItem>
{sectionFields
.filter((f) => f.columnName && f.columnName.trim() !== "")
.map((field) => (
<SelectItem key={field.id} value={field.columnName}>
{field.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="text-center text-[9px] text-muted-foreground"></div>
<Select
value={mapping.targetColumn || "_none_"}
onValueChange={(value) => {
const newSubTables = [...(config.saveConfig.customApiSave?.multiTable?.subTables || [])];
const newMappings = [...(newSubTables[subIndex].fieldMappings || [])];
newMappings[mapIndex] = { ...newMappings[mapIndex], targetColumn: value === "_none_" ? "" : value };
newSubTables[subIndex] = { ...newSubTables[subIndex], fieldMappings: newMappings };
updateSaveConfig({
customApiSave: {
...config.saveConfig.customApiSave,
multiTable: {
...config.saveConfig.customApiSave?.multiTable,
enabled: true,
mainTable: config.saveConfig.customApiSave?.multiTable?.mainTable || { tableName: "", primaryKeyColumn: "" },
subTables: newSubTables,
},
},
});
}}
>
<SelectTrigger className="h-5 text-[9px] w-full">
<SelectValue placeholder="DB 컬럼 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none_"></SelectItem>
{(tableColumns[subTable.tableName] || []).map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.label || col.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
})}
</div>
)}
{/* 추가 옵션 */}
<div className="space-y-1 pt-1 border-t overflow-hidden min-w-0">
<Label className="text-[9px]"> </Label>
<div className="flex items-center gap-2 min-w-0">
<Checkbox
id={`saveMainAsFirst-${subIndex}`}
checked={subTable.options?.saveMainAsFirst || false}
onCheckedChange={(checked) => {
const newSubTables = [...(config.saveConfig.customApiSave?.multiTable?.subTables || [])];
newSubTables[subIndex] = {
...newSubTables[subIndex],
options: { ...newSubTables[subIndex].options, saveMainAsFirst: !!checked },
};
updateSaveConfig({
customApiSave: {
...config.saveConfig.customApiSave,
multiTable: {
...config.saveConfig.customApiSave?.multiTable,
enabled: true,
mainTable: config.saveConfig.customApiSave?.multiTable?.mainTable || { tableName: "", primaryKeyColumn: "" },
subTables: newSubTables,
},
},
});
}}
className="shrink-0"
/>
<label htmlFor={`saveMainAsFirst-${subIndex}`} className="text-[9px] truncate">
</label>
</div>
{subTable.options?.saveMainAsFirst && (
<div className="pl-4 space-y-1 overflow-hidden min-w-0">
<Label className="text-[8px] text-muted-foreground"> </Label>
<Select
value={subTable.options?.mainMarkerColumn || "_none_"}
onValueChange={(value) => {
const newSubTables = [...(config.saveConfig.customApiSave?.multiTable?.subTables || [])];
newSubTables[subIndex] = {
...newSubTables[subIndex],
options: {
...newSubTables[subIndex].options,
mainMarkerColumn: value === "_none_" ? "" : value,
mainMarkerValue: true,
subMarkerValue: false,
},
};
updateSaveConfig({
customApiSave: {
...config.saveConfig.customApiSave,
multiTable: {
...config.saveConfig.customApiSave?.multiTable,
enabled: true,
mainTable: config.saveConfig.customApiSave?.multiTable?.mainTable || { tableName: "", primaryKeyColumn: "" },
subTables: newSubTables,
},
},
});
}}
>
<SelectTrigger className="h-5 text-[9px] w-full">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none_"> </SelectItem>
{(tableColumns[subTable.tableName] || []).map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.label || col.name}
</SelectItem>
))}
</SelectContent>
</Select>
<HelpText>/ (: is_primary)</HelpText>
</div>
)}
<div className="flex items-center gap-2 min-w-0">
<Checkbox
id={`deleteExisting-${subIndex}`}
checked={subTable.options?.deleteExistingBefore || false}
onCheckedChange={(checked) => {
const newSubTables = [...(config.saveConfig.customApiSave?.multiTable?.subTables || [])];
newSubTables[subIndex] = {
...newSubTables[subIndex],
options: { ...newSubTables[subIndex].options, deleteExistingBefore: !!checked },
};
updateSaveConfig({
customApiSave: {
...config.saveConfig.customApiSave,
multiTable: {
...config.saveConfig.customApiSave?.multiTable,
enabled: true,
mainTable: config.saveConfig.customApiSave?.multiTable?.mainTable || { tableName: "", primaryKeyColumn: "" },
subTables: newSubTables,
},
},
});
}}
className="shrink-0"
/>
<label htmlFor={`deleteExisting-${subIndex}`} className="text-[9px] truncate">
</label>
</div>
</div>
</div>
))}
{(config.saveConfig.customApiSave?.multiTable?.subTables || []).length === 0 && (
<p className="text-[9px] text-muted-foreground text-center py-2">
</p>
)}
</div>
</div>
)}
{/* 커스텀 API 설정 */}
{config.saveConfig.customApiSave?.apiType === "custom" && (
<div className="space-y-2 p-2 bg-muted/30 rounded">
<div>
<Label className="text-[10px]">API </Label>
<Input
value={config.saveConfig.customApiSave?.customEndpoint || ""}
onChange={(e) =>
updateSaveConfig({
customApiSave: { ...config.saveConfig.customApiSave, customEndpoint: e.target.value },
})
}
placeholder="/api/custom/endpoint"
className="h-6 text-[10px] mt-1"
/>
</div>
<div>
<Label className="text-[10px]">HTTP </Label>
<Select
value={config.saveConfig.customApiSave?.customMethod || "POST"}
onValueChange={(value: "POST" | "PUT") =>
updateSaveConfig({
customApiSave: { ...config.saveConfig.customApiSave, customMethod: value },
})
}
>
<SelectTrigger className="h-6 text-[10px] mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
)}
</div>
{/* 저장 후 동작 */}
<div className="space-y-2">
<Label className="text-[10px] font-medium"> </Label>
<div className="flex items-center justify-between">
<span className="text-[10px]"> </span>
<Switch
checked={config.saveConfig.afterSave?.closeModal !== false}
onCheckedChange={(checked) =>
updateSaveConfig({
afterSave: { ...config.saveConfig.afterSave, closeModal: checked },
})
}
/>
</div>
<div className="flex items-center justify-between">
<span className="text-[10px]"> </span>
<Switch
checked={config.saveConfig.afterSave?.refreshParent || false}
onCheckedChange={(checked) =>
updateSaveConfig({
afterSave: { ...config.saveConfig.afterSave, refreshParent: checked },
})
}
/>
</div>
<div className="flex items-center justify-between">
<span className="text-[10px]"> </span>
<Switch
checked={config.saveConfig.afterSave?.showToast !== false}
onCheckedChange={(checked) =>
updateSaveConfig({
afterSave: { ...config.saveConfig.afterSave, showToast: checked },
})
}
/>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
{/* 섹션 관리 */}
<Accordion type="single" collapsible defaultValue="sections">
<AccordionItem value="sections" className="border rounded-lg">
<AccordionTrigger className="px-3 py-2 text-xs font-medium hover:no-underline">
<div className="flex items-center gap-2">
<Settings className="h-3.5 w-3.5" />
({config.sections.length})
</div>
</AccordionTrigger>
<AccordionContent className="px-3 pb-3 space-y-2">
<Button onClick={addSection} variant="outline" size="sm" className="w-full h-7 text-xs">
<Plus className="h-3 w-3 mr-1" />
</Button>
{config.sections.map((section, sectionIndex) => (
<Card
key={section.id}
className={cn(
"cursor-pointer transition-colors !p-0",
selectedSectionId === section.id && "ring-2 ring-primary",
)}
onClick={() => {
setSelectedSectionId(section.id);
setSelectedFieldId(null);
}}
>
<CardHeader className="p-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
<GripVertical className="h-3 w-3 text-muted-foreground" />
<CardTitle className="text-xs">{section.title}</CardTitle>
{section.repeatable && (
<span className="text-[9px] bg-blue-100 text-blue-700 px-1 rounded"></span>
)}
</div>
<div className="flex items-center gap-0.5">
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={(e) => {
e.stopPropagation();
moveSectionUp(sectionIndex);
}}
disabled={sectionIndex === 0}
>
<ChevronUp className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={(e) => {
e.stopPropagation();
moveSectionDown(sectionIndex);
}}
disabled={sectionIndex === config.sections.length - 1}
>
<ChevronDown className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-destructive"
onClick={(e) => {
e.stopPropagation();
removeSection(section.id);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
<CardDescription className="text-[10px]"> {section.fields.length}</CardDescription>
</CardHeader>
</Card>
))}
</AccordionContent>
</AccordionItem>
</Accordion>
{/* 선택된 섹션 설정 */}
{selectedSection && (
<Accordion type="single" collapsible defaultValue="section-detail">
<AccordionItem value="section-detail" className="border rounded-lg border-primary/50">
<AccordionTrigger className="px-3 py-2 text-xs font-medium hover:no-underline">
<div className="flex items-center gap-2">
<Layout className="h-3.5 w-3.5 text-primary" />
: {selectedSection.title}
</div>
</AccordionTrigger>
<AccordionContent className="px-3 pb-3 space-y-3">
<div>
<Label className="text-[10px]"> </Label>
<Input
value={selectedSection.title}
onChange={(e) => updateSection(selectedSection.id, { title: e.target.value })}
className="h-7 text-xs mt-1"
/>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Textarea
value={selectedSection.description || ""}
onChange={(e) => updateSection(selectedSection.id, { description: e.target.value })}
className="text-xs mt-1 min-h-[50px]"
placeholder="섹션에 대한 설명 (선택)"
/>
</div>
<div className="flex items-center justify-between">
<span className="text-[10px]"> </span>
<Switch
checked={selectedSection.collapsible || false}
onCheckedChange={(checked) => updateSection(selectedSection.id, { collapsible: checked })}
/>
</div>
<div>
<Label className="text-[10px]"> ()</Label>
<Select
value={String(selectedSection.columns || 2)}
onValueChange={(value) => updateSection(selectedSection.id, { columns: parseInt(value) })}
>
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 ( )</SelectItem>
<SelectItem value="2">2 ()</SelectItem>
<SelectItem value="3">3</SelectItem>
<SelectItem value="4">4</SelectItem>
</SelectContent>
</Select>
<HelpText> .</HelpText>
</div>
<div className="border rounded-md p-2 space-y-2">
<div className="flex items-center justify-between">
<span className="text-[10px] font-medium"> </span>
<Switch
checked={selectedSection.repeatable || false}
onCheckedChange={(checked) => updateSection(selectedSection.id, { repeatable: checked })}
/>
</div>
<HelpText> .</HelpText>
{selectedSection.repeatable && (
<div className="space-y-2 pt-2 border-t">
<div>
<Label className="text-[10px]"> </Label>
<Input
type="number"
value={selectedSection.repeatConfig?.minItems || 0}
onChange={(e) =>
updateSection(selectedSection.id, {
repeatConfig: {
...selectedSection.repeatConfig,
minItems: parseInt(e.target.value) || 0,
},
})
}
className="h-6 text-[10px] mt-1"
/>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Input
type="number"
value={selectedSection.repeatConfig?.maxItems || 10}
onChange={(e) =>
updateSection(selectedSection.id, {
repeatConfig: {
...selectedSection.repeatConfig,
maxItems: parseInt(e.target.value) || 10,
},
})
}
className="h-6 text-[10px] mt-1"
/>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Input
value={selectedSection.repeatConfig?.addButtonText || "+ 추가"}
onChange={(e) =>
updateSection(selectedSection.id, {
repeatConfig: {
...selectedSection.repeatConfig,
addButtonText: e.target.value,
},
})
}
className="h-6 text-[10px] mt-1"
/>
</div>
</div>
)}
</div>
<Separator />
{/* 필드 목록 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-[10px] font-medium"> </Label>
<Button
onClick={() => addField(selectedSection.id)}
variant="outline"
size="sm"
className="h-6 text-[10px]"
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
{selectedSection.fields.length === 0 ? (
<p className="text-[10px] text-muted-foreground text-center py-2"> </p>
) : (
<div className="space-y-1">
{selectedSection.fields.map((field) => (
<div
key={field.id}
className={cn(
"flex items-center justify-between p-1.5 rounded border cursor-pointer hover:bg-accent/50",
selectedFieldId === field.id && "bg-accent ring-1 ring-primary",
)}
onClick={() => setSelectedFieldId(field.id)}
>
<div className="flex items-center gap-1.5">
<GripVertical className="h-3 w-3 text-muted-foreground" />
<span className="text-[10px] font-medium">{field.label}</span>
{field.columnName && (
<span className="text-[9px] text-muted-foreground">({field.columnName})</span>
)}
{field.required && <span className="text-[9px] text-destructive">*</span>}
</div>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-destructive"
onClick={(e) => {
e.stopPropagation();
removeField(selectedSection.id, field.id);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
{/* 선택된 필드 설정 */}
{selectedSection && selectedField && (
<Accordion type="single" collapsible defaultValue="field-detail">
<AccordionItem value="field-detail" className="border rounded-lg border-blue-500/50">
<AccordionTrigger className="px-3 py-2 text-xs font-medium hover:no-underline">
<div className="flex items-center gap-2">
<Hash className="h-3.5 w-3.5 text-blue-500" />
: {selectedField.label}
</div>
</AccordionTrigger>
<AccordionContent className="px-3 pb-3 space-y-3">
{/* 기본 정보 */}
<div>
<Label className="text-[10px]"></Label>
<Input
value={selectedField.label}
onChange={(e) => updateField(selectedSection.id, selectedField.id, { label: e.target.value })}
className="h-7 text-xs mt-1"
/>
</div>
<div>
<Label className="text-[10px]"></Label>
{currentColumns.length > 0 ? (
<Select
value={selectedField.columnName}
onValueChange={(value) =>
updateField(selectedSection.id, selectedField.id, { columnName: value })
}
>
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{currentColumns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
{col.label !== col.name && ` (${col.label})`}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={selectedField.columnName}
onChange={(e) =>
updateField(selectedSection.id, selectedField.id, { columnName: e.target.value })
}
placeholder="테이블을 먼저 선택하세요"
className="h-7 text-xs mt-1"
/>
)}
</div>
<div>
<Label className="text-[10px]"> </Label>
<Select
value={selectedField.fieldType}
onValueChange={(value) =>
updateField(selectedSection.id, selectedField.id, {
fieldType: value as FormFieldConfig["fieldType"],
})
}
>
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Select
value={String(selectedField.gridSpan || 6)}
onValueChange={(value) =>
updateField(selectedSection.id, selectedField.id, { gridSpan: parseInt(value) })
}
>
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="3">1/4 </SelectItem>
<SelectItem value="4">1/3 </SelectItem>
<SelectItem value="6">1/2 </SelectItem>
<SelectItem value="8">2/3 </SelectItem>
<SelectItem value="12"> </SelectItem>
</SelectContent>
</Select>
<HelpText>12 .</HelpText>
</div>
<div>
<Label className="text-[10px]"></Label>
<Input
value={selectedField.placeholder || ""}
onChange={(e) =>
updateField(selectedSection.id, selectedField.id, { placeholder: e.target.value })
}
placeholder="입력 힌트"
className="h-7 text-xs mt-1"
/>
</div>
<Separator />
{/* 옵션 토글 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-[10px]"> </span>
<Switch
checked={selectedField.required || false}
onCheckedChange={(checked) =>
updateField(selectedSection.id, selectedField.id, { required: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<span className="text-[10px]"> ()</span>
<Switch
checked={selectedField.disabled || false}
onCheckedChange={(checked) =>
updateField(selectedSection.id, selectedField.id, { disabled: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<span className="text-[10px]"> ( )</span>
<Switch
checked={selectedField.hidden || false}
onCheckedChange={(checked) =>
updateField(selectedSection.id, selectedField.id, { hidden: checked })
}
/>
</div>
<HelpText> .</HelpText>
<div className="flex items-center justify-between">
<span className="text-[10px]"> </span>
<Switch
checked={selectedField.receiveFromParent || false}
onCheckedChange={(checked) =>
updateField(selectedSection.id, selectedField.id, { receiveFromParent: checked })
}
/>
</div>
<HelpText> .</HelpText>
</div>
<Separator />
{/* 채번규칙 설정 */}
<div className="border rounded-md p-2 space-y-2">
<div className="flex items-center justify-between">
<span className="text-[10px] font-medium"> </span>
<Switch
checked={selectedField.numberingRule?.enabled || false}
onCheckedChange={(checked) =>
updateField(selectedSection.id, selectedField.id, {
numberingRule: {
...selectedField.numberingRule,
enabled: checked,
},
})
}
/>
</div>
<HelpText> / . (: EMP-001)</HelpText>
{selectedField.numberingRule?.enabled && (
<div className="space-y-2 pt-2 border-t">
<Select
value={selectedField.numberingRule?.ruleId || ""}
onValueChange={(value) =>
updateField(selectedSection.id, selectedField.id, {
numberingRule: {
...selectedField.numberingRule,
ruleId: value,
},
})
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="규칙 선택" />
</SelectTrigger>
<SelectContent>
{numberingRules.length === 0 ? (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
</div>
) : (
numberingRules.map((rule) => (
<SelectItem key={rule.id} value={rule.id}>
{rule.name}
</SelectItem>
))
)}
</SelectContent>
</Select>
<div className="flex items-center justify-between">
<span className="text-[10px]"> </span>
<Switch
checked={selectedField.numberingRule?.editable || false}
onCheckedChange={(checked) =>
updateField(selectedSection.id, selectedField.id, {
numberingRule: {
...selectedField.numberingRule,
editable: checked,
},
})
}
/>
</div>
<div className="flex items-center justify-between">
<span className="text-[10px]"> </span>
<Switch
checked={selectedField.numberingRule?.generateOnSave || false}
onCheckedChange={(checked) =>
updateField(selectedSection.id, selectedField.id, {
numberingRule: {
...selectedField.numberingRule,
generateOnSave: checked,
generateOnOpen: !checked,
},
})
}
/>
</div>
<HelpText>OFF: 모달 / ON: 저장 </HelpText>
</div>
)}
</div>
{/* Select 옵션 설정 */}
{selectedField.fieldType === "select" && (
<div className="border rounded-md p-2 space-y-2">
<Label className="text-[10px] font-medium"> </Label>
<HelpText> .</HelpText>
<Select
value={selectedField.selectOptions?.type || "static"}
onValueChange={(value) =>
updateField(selectedSection.id, selectedField.id, {
selectOptions: {
...selectedField.selectOptions,
type: value as "static" | "table" | "code",
},
})
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SELECT_OPTION_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedField.selectOptions?.type === "static" && (
<HelpText> 입력: 옵션을 . ( - )</HelpText>
)}
{selectedField.selectOptions?.type === "table" && (
<div className="space-y-2 pt-2 border-t">
<HelpText> 참조: DB .</HelpText>
<div>
<Label className="text-[10px]"> </Label>
<Select
value={selectedField.selectOptions?.tableName || ""}
onValueChange={(value) =>
updateField(selectedSection.id, selectedField.id, {
selectOptions: {
...selectedField.selectOptions,
tableName: value,
},
})
}
>
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{tables.map((t) => (
<SelectItem key={t.name} value={t.name}>
{t.label || t.name}
</SelectItem>
))}
</SelectContent>
</Select>
<HelpText> </HelpText>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Input
value={selectedField.selectOptions?.valueColumn || ""}
onChange={(e) =>
updateField(selectedSection.id, selectedField.id, {
selectOptions: {
...selectedField.selectOptions,
valueColumn: e.target.value,
},
})
}
placeholder="customer_code"
className="h-7 text-xs mt-1"
/>
<HelpText>
<br />
: customer_code, customer_id
</HelpText>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Input
value={selectedField.selectOptions?.labelColumn || ""}
onChange={(e) =>
updateField(selectedSection.id, selectedField.id, {
selectOptions: {
...selectedField.selectOptions,
labelColumn: e.target.value,
},
})
}
placeholder="customer_name"
className="h-7 text-xs mt-1"
/>
<HelpText>
<br />
: customer_name, company_name
</HelpText>
</div>
</div>
)}
{selectedField.selectOptions?.type === "code" && (
<div className="pt-2 border-t">
<HelpText>공통코드: 공통코드 .</HelpText>
<Label className="text-[10px]"> </Label>
<Input
value={selectedField.selectOptions?.codeCategory || ""}
onChange={(e) =>
updateField(selectedSection.id, selectedField.id, {
selectOptions: {
...selectedField.selectOptions,
codeCategory: e.target.value,
},
})
}
placeholder="POSITION_CODE"
className="h-6 text-[10px] mt-1"
/>
<HelpText>: POSITION_CODE (), STATUS_CODE () </HelpText>
</div>
)}
</div>
)}
{/* 다중 컬럼 저장 (select 타입만) */}
{selectedField.fieldType === "select" && (
<div className="border rounded-md p-2 space-y-2 overflow-hidden min-w-0">
<div className="flex items-center justify-between min-w-0">
<span className="text-[10px] font-medium truncate"> </span>
<Switch
checked={selectedField.linkedFieldGroup?.enabled || false}
onCheckedChange={(checked) =>
updateField(selectedSection.id, selectedField.id, {
linkedFieldGroup: {
...selectedField.linkedFieldGroup,
enabled: checked,
},
})
}
/>
</div>
<HelpText>
.
<br />: 부서 +
</HelpText>
{selectedField.linkedFieldGroup?.enabled && (
<div className="space-y-2 pt-2 border-t overflow-hidden min-w-0">
{/* 소스 테이블 */}
<div className="overflow-hidden min-w-0">
<Label className="text-[10px] truncate block"> </Label>
<Select
value={selectedField.linkedFieldGroup?.sourceTable || ""}
onValueChange={(value) => {
updateField(selectedSection.id, selectedField.id, {
linkedFieldGroup: {
...selectedField.linkedFieldGroup,
sourceTable: value,
},
});
if (value && !tableColumns[value]) {
loadTableColumns(value);
}
}}
>
<SelectTrigger className="h-6 text-[10px] mt-1">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{tables.map((table) => (
<SelectItem key={table.name} value={table.name} className="text-xs">
{table.label || table.name}
</SelectItem>
))}
</SelectContent>
</Select>
<HelpText> </HelpText>
</div>
{/* 표시 형식 */}
<div>
<Label className="text-[10px]"> </Label>
<Select
value={selectedField.linkedFieldGroup?.displayFormat || "name_only"}
onValueChange={(value: "name_only" | "code_name" | "name_code") =>
updateField(selectedSection.id, selectedField.id, {
linkedFieldGroup: {
...selectedField.linkedFieldGroup,
displayFormat: value,
},
})
}
>
<SelectTrigger className="h-6 text-[10px] mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LINKED_FIELD_DISPLAY_FORMAT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 표시 컬럼 / 값 컬럼 */}
<div className="space-y-1">
<div>
<Label className="text-[10px]"> ( )</Label>
<Select
value={selectedField.linkedFieldGroup?.displayColumn || ""}
onValueChange={(value) =>
updateField(selectedSection.id, selectedField.id, {
linkedFieldGroup: {
...selectedField.linkedFieldGroup,
displayColumn: value,
},
})
}
>
<SelectTrigger className="h-6 text-[10px] mt-1">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{(tableColumns[selectedField.linkedFieldGroup?.sourceTable || ""] || []).map((col) => (
<SelectItem key={col.name} value={col.name} className="text-xs">
{col.label || col.name}
</SelectItem>
))}
</SelectContent>
</Select>
<HelpText> (: 영업부, )</HelpText>
</div>
</div>
{/* 저장할 컬럼 매핑 */}
<div className="space-y-1 overflow-hidden min-w-0">
<div className="flex items-center justify-between min-w-0">
<Label className="text-[10px] truncate"> </Label>
<Button
onClick={() => {
const newMapping: LinkedFieldMapping = { sourceColumn: "", targetColumn: "" };
updateField(selectedSection.id, selectedField.id, {
linkedFieldGroup: {
...selectedField.linkedFieldGroup,
mappings: [...(selectedField.linkedFieldGroup?.mappings || []), newMapping],
},
});
}}
variant="outline"
size="icon"
className="h-5 w-5"
>
<Plus className="h-3 w-3" />
</Button>
</div>
<HelpText> </HelpText>
{(selectedField.linkedFieldGroup?.mappings || []).map((mapping, mappingIndex) => {
// 사용 가능한 저장 테이블 목록 계산
const availableTargetTables: { name: string; label: string }[] = [];
// 메인 테이블
if (config.saveConfig.tableName) {
const mainTable = tables.find(t => t.name === config.saveConfig.tableName);
availableTargetTables.push({
name: config.saveConfig.tableName,
label: mainTable?.label || config.saveConfig.tableName,
});
}
// 서브 테이블들
config.saveConfig.customApiSave?.multiTable?.subTables?.forEach((st) => {
if (st.tableName && !availableTargetTables.find(t => t.name === st.tableName)) {
const subTable = tables.find(t => t.name === st.tableName);
availableTargetTables.push({
name: st.tableName,
label: subTable?.label || st.tableName,
});
}
});
// 현재 선택된 저장 테이블 결정
let currentTargetTable = mapping.targetTable;
if (!currentTargetTable) {
// 자동 결정: 반복 섹션이면 서브 테이블, 아니면 메인 테이블
if (selectedSection?.repeatable) {
const subTable = config.saveConfig.customApiSave?.multiTable?.subTables?.find(
(st) => st.repeatSectionId === selectedSection.id
);
currentTargetTable = subTable?.tableName || config.saveConfig.tableName;
} else {
currentTargetTable = config.saveConfig.tableName;
}
}
return (
<div key={mappingIndex} className="bg-muted/30 p-1.5 rounded space-y-1 border overflow-hidden min-w-0">
<div className="flex items-center justify-between min-w-0">
<span className="text-[9px] text-muted-foreground truncate"> #{mappingIndex + 1}</span>
<Button
variant="ghost"
size="icon"
className="h-4 w-4 text-destructive shrink-0"
onClick={() => {
const updatedMappings = (selectedField.linkedFieldGroup?.mappings || []).filter(
(_, i) => i !== mappingIndex
);
updateField(selectedSection.id, selectedField.id, {
linkedFieldGroup: {
...selectedField.linkedFieldGroup,
mappings: updatedMappings,
},
});
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* 가져올 컬럼 (소스 테이블) */}
<div className="overflow-hidden min-w-0">
<Label className="text-[9px] truncate block"> ()</Label>
<Select
value={mapping.sourceColumn}
onValueChange={(value) => {
const updatedMappings = (selectedField.linkedFieldGroup?.mappings || []).map((m, i) =>
i === mappingIndex ? { ...m, sourceColumn: value } : m
);
updateField(selectedSection.id, selectedField.id, {
linkedFieldGroup: {
...selectedField.linkedFieldGroup,
mappings: updatedMappings,
},
});
}}
>
<SelectTrigger className="h-5 text-[9px] mt-0.5 w-full">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{(tableColumns[selectedField.linkedFieldGroup?.sourceTable || ""] || []).map((col) => (
<SelectItem key={col.name} value={col.name} className="text-xs">
{col.label || col.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="text-center text-[9px] text-muted-foreground"></div>
{/* 저장할 테이블 선택 */}
<div className="overflow-hidden min-w-0">
<Label className="text-[9px] truncate block"> </Label>
<Select
value={currentTargetTable || "_auto_"}
onValueChange={(value) => {
const updatedMappings = (selectedField.linkedFieldGroup?.mappings || []).map((m, i) =>
i === mappingIndex ? { ...m, targetTable: value === "_auto_" ? undefined : value, targetColumn: "" } : m
);
updateField(selectedSection.id, selectedField.id, {
linkedFieldGroup: {
...selectedField.linkedFieldGroup,
mappings: updatedMappings,
},
});
// 테이블 컬럼 로드
if (value !== "_auto_" && !tableColumns[value]) {
loadTableColumns(value);
}
}}
>
<SelectTrigger className="h-5 text-[9px] mt-0.5 w-full">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{availableTargetTables.map((t) => (
<SelectItem key={t.name} value={t.name} className="text-xs">
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 저장할 컬럼 선택 */}
<div className="overflow-hidden min-w-0">
<Label className="text-[9px] truncate block"> </Label>
<Select
value={mapping.targetColumn}
onValueChange={(value) => {
const updatedMappings = (selectedField.linkedFieldGroup?.mappings || []).map((m, i) =>
i === mappingIndex ? { ...m, targetColumn: value } : m
);
updateField(selectedSection.id, selectedField.id, {
linkedFieldGroup: {
...selectedField.linkedFieldGroup,
mappings: updatedMappings,
},
});
}}
>
<SelectTrigger className="h-5 text-[9px] mt-0.5 w-full">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{(tableColumns[currentTargetTable || ""] || []).map((col) => (
<SelectItem key={col.name} value={col.name} className="text-xs">
{col.label || col.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
})}
{(selectedField.linkedFieldGroup?.mappings || []).length === 0 && (
<p className="text-[9px] text-muted-foreground text-center py-2">
+
</p>
)}
</div>
</div>
)}
</div>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
)}
</div>
</ScrollArea>
);
}