2306 lines
119 KiB
TypeScript
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>
|
|
);
|
|
}
|
|
|