2025-12-11 15:29:37 +09:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import React, { useState, useEffect } 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 { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
|
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
|
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
|
|
|
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
|
|
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
|
|
|
import { Separator } from "@/components/ui/separator";
|
2025-12-23 09:24:59 +09:00
|
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
|
|
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
|
|
|
|
import { Plus, Trash2, Database, Layers, Info, Check, ChevronsUpDown } from "lucide-react";
|
2025-12-19 14:53:16 +09:00
|
|
|
import { Badge } from "@/components/ui/badge";
|
2025-12-11 15:29:37 +09:00
|
|
|
import { cn } from "@/lib/utils";
|
2025-12-19 14:53:16 +09:00
|
|
|
import { SaveConfig, SubTableSaveConfig, SubTableFieldMapping, FormSectionConfig, FormFieldConfig, SectionSaveMode } from "../types";
|
2025-12-11 15:29:37 +09:00
|
|
|
|
|
|
|
|
// 도움말 텍스트 컴포넌트
|
|
|
|
|
const HelpText = ({ children }: { children: React.ReactNode }) => (
|
|
|
|
|
<p className="text-[10px] text-muted-foreground mt-0.5">{children}</p>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
interface SaveSettingsModalProps {
|
|
|
|
|
open: boolean;
|
|
|
|
|
onOpenChange: (open: boolean) => void;
|
|
|
|
|
saveConfig: SaveConfig;
|
|
|
|
|
sections: FormSectionConfig[];
|
|
|
|
|
onSave: (updates: SaveConfig) => void;
|
|
|
|
|
tables: { name: string; label: string }[];
|
|
|
|
|
tableColumns: { [tableName: string]: { name: string; type: string; label: string }[] };
|
|
|
|
|
onLoadTableColumns: (tableName: string) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function SaveSettingsModal({
|
|
|
|
|
open,
|
|
|
|
|
onOpenChange,
|
|
|
|
|
saveConfig,
|
|
|
|
|
sections,
|
|
|
|
|
onSave,
|
|
|
|
|
tables,
|
|
|
|
|
tableColumns,
|
|
|
|
|
onLoadTableColumns,
|
|
|
|
|
}: SaveSettingsModalProps) {
|
|
|
|
|
// 로컬 상태로 저장 설정 관리
|
|
|
|
|
const [localSaveConfig, setLocalSaveConfig] = useState<SaveConfig>(saveConfig);
|
|
|
|
|
|
|
|
|
|
// 저장 모드 (단일 테이블 vs 다중 테이블)
|
|
|
|
|
const [saveMode, setSaveMode] = useState<"single" | "multi">(
|
|
|
|
|
saveConfig.customApiSave?.enabled && saveConfig.customApiSave?.multiTable?.enabled ? "multi" : "single"
|
|
|
|
|
);
|
|
|
|
|
|
2025-12-23 09:24:59 +09:00
|
|
|
// 테이블 검색 Popover 상태
|
|
|
|
|
const [singleTableSearchOpen, setSingleTableSearchOpen] = useState(false);
|
|
|
|
|
const [mainTableSearchOpen, setMainTableSearchOpen] = useState(false);
|
|
|
|
|
const [subTableSearchOpen, setSubTableSearchOpen] = useState<Record<number, boolean>>({});
|
|
|
|
|
|
2025-12-28 19:32:13 +09:00
|
|
|
// 컬럼 검색 Popover 상태
|
|
|
|
|
const [mainKeyColumnSearchOpen, setMainKeyColumnSearchOpen] = useState(false);
|
|
|
|
|
const [mainFieldSearchOpen, setMainFieldSearchOpen] = useState<Record<number, boolean>>({});
|
|
|
|
|
const [subColumnSearchOpen, setSubColumnSearchOpen] = useState<Record<number, boolean>>({});
|
|
|
|
|
const [subTableColumnSearchOpen, setSubTableColumnSearchOpen] = useState<Record<string, boolean>>({});
|
|
|
|
|
const [markerColumnSearchOpen, setMarkerColumnSearchOpen] = useState<Record<number, boolean>>({});
|
|
|
|
|
|
2025-12-11 15:29:37 +09:00
|
|
|
// open이 변경될 때마다 데이터 동기화
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (open) {
|
|
|
|
|
setLocalSaveConfig(saveConfig);
|
|
|
|
|
setSaveMode(saveConfig.customApiSave?.enabled && saveConfig.customApiSave?.multiTable?.enabled ? "multi" : "single");
|
2025-12-28 19:32:13 +09:00
|
|
|
|
|
|
|
|
// 모달이 열릴 때 기존에 설정된 테이블들의 컬럼 정보 로드
|
|
|
|
|
const mainTableName = saveConfig.customApiSave?.multiTable?.mainTable?.tableName;
|
|
|
|
|
if (mainTableName && !tableColumns[mainTableName]) {
|
|
|
|
|
onLoadTableColumns(mainTableName);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 서브 테이블들의 컬럼 정보도 로드
|
|
|
|
|
const subTables = saveConfig.customApiSave?.multiTable?.subTables || [];
|
|
|
|
|
subTables.forEach((subTable) => {
|
|
|
|
|
if (subTable.tableName && !tableColumns[subTable.tableName]) {
|
|
|
|
|
onLoadTableColumns(subTable.tableName);
|
|
|
|
|
}
|
|
|
|
|
});
|
2025-12-11 15:29:37 +09:00
|
|
|
}
|
2025-12-28 19:32:13 +09:00
|
|
|
}, [open, saveConfig, tableColumns, onLoadTableColumns]);
|
2025-12-11 15:29:37 +09:00
|
|
|
|
|
|
|
|
// 저장 설정 업데이트 함수
|
|
|
|
|
const updateSaveConfig = (updates: Partial<SaveConfig>) => {
|
|
|
|
|
setLocalSaveConfig((prev) => ({ ...prev, ...updates }));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 저장 함수
|
|
|
|
|
const handleSave = () => {
|
|
|
|
|
// 저장 모드에 따라 설정 조정
|
|
|
|
|
let finalConfig = { ...localSaveConfig };
|
|
|
|
|
|
|
|
|
|
if (saveMode === "single") {
|
|
|
|
|
// 단일 테이블 모드: customApiSave 비활성화
|
|
|
|
|
finalConfig = {
|
|
|
|
|
...finalConfig,
|
|
|
|
|
customApiSave: {
|
|
|
|
|
enabled: false,
|
|
|
|
|
apiType: "custom",
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
} else {
|
|
|
|
|
// 다중 테이블 모드: customApiSave 활성화
|
|
|
|
|
finalConfig = {
|
|
|
|
|
...finalConfig,
|
|
|
|
|
customApiSave: {
|
|
|
|
|
...finalConfig.customApiSave,
|
|
|
|
|
enabled: true,
|
|
|
|
|
apiType: "multi-table",
|
|
|
|
|
multiTable: {
|
|
|
|
|
...finalConfig.customApiSave?.multiTable,
|
|
|
|
|
enabled: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onSave(finalConfig);
|
|
|
|
|
onOpenChange(false);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 서브 테이블 추가
|
|
|
|
|
const addSubTable = () => {
|
|
|
|
|
const newSubTable: SubTableSaveConfig = {
|
|
|
|
|
enabled: true,
|
|
|
|
|
tableName: "",
|
|
|
|
|
repeatSectionId: "",
|
|
|
|
|
linkColumn: {
|
|
|
|
|
mainField: "",
|
|
|
|
|
subColumn: "",
|
|
|
|
|
},
|
|
|
|
|
fieldMappings: [],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const subTables = [...(localSaveConfig.customApiSave?.multiTable?.subTables || []), newSubTable];
|
|
|
|
|
updateSaveConfig({
|
|
|
|
|
customApiSave: {
|
|
|
|
|
...localSaveConfig.customApiSave,
|
|
|
|
|
apiType: "multi-table",
|
|
|
|
|
multiTable: {
|
|
|
|
|
...localSaveConfig.customApiSave?.multiTable,
|
|
|
|
|
enabled: true,
|
|
|
|
|
subTables,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 서브 테이블 삭제
|
|
|
|
|
const removeSubTable = (index: number) => {
|
|
|
|
|
const subTables = [...(localSaveConfig.customApiSave?.multiTable?.subTables || [])];
|
|
|
|
|
subTables.splice(index, 1);
|
|
|
|
|
updateSaveConfig({
|
|
|
|
|
customApiSave: {
|
|
|
|
|
...localSaveConfig.customApiSave,
|
|
|
|
|
multiTable: {
|
|
|
|
|
...localSaveConfig.customApiSave?.multiTable,
|
|
|
|
|
subTables,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 서브 테이블 업데이트
|
|
|
|
|
const updateSubTable = (index: number, updates: Partial<SubTableSaveConfig>) => {
|
|
|
|
|
const subTables = [...(localSaveConfig.customApiSave?.multiTable?.subTables || [])];
|
|
|
|
|
subTables[index] = { ...subTables[index], ...updates };
|
|
|
|
|
updateSaveConfig({
|
|
|
|
|
customApiSave: {
|
|
|
|
|
...localSaveConfig.customApiSave,
|
|
|
|
|
multiTable: {
|
|
|
|
|
...localSaveConfig.customApiSave?.multiTable,
|
|
|
|
|
subTables,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 필드 매핑 추가
|
|
|
|
|
const addFieldMapping = (subTableIndex: number) => {
|
|
|
|
|
const newMapping: SubTableFieldMapping = {
|
|
|
|
|
formField: "",
|
|
|
|
|
targetColumn: "",
|
|
|
|
|
};
|
|
|
|
|
const subTables = [...(localSaveConfig.customApiSave?.multiTable?.subTables || [])];
|
|
|
|
|
const fieldMappings = [...(subTables[subTableIndex].fieldMappings || []), newMapping];
|
|
|
|
|
subTables[subTableIndex] = { ...subTables[subTableIndex], fieldMappings };
|
|
|
|
|
updateSaveConfig({
|
|
|
|
|
customApiSave: {
|
|
|
|
|
...localSaveConfig.customApiSave,
|
|
|
|
|
multiTable: {
|
|
|
|
|
...localSaveConfig.customApiSave?.multiTable,
|
|
|
|
|
subTables,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 필드 매핑 삭제
|
|
|
|
|
const removeFieldMapping = (subTableIndex: number, mappingIndex: number) => {
|
|
|
|
|
const subTables = [...(localSaveConfig.customApiSave?.multiTable?.subTables || [])];
|
|
|
|
|
const fieldMappings = [...(subTables[subTableIndex].fieldMappings || [])];
|
|
|
|
|
fieldMappings.splice(mappingIndex, 1);
|
|
|
|
|
subTables[subTableIndex] = { ...subTables[subTableIndex], fieldMappings };
|
|
|
|
|
updateSaveConfig({
|
|
|
|
|
customApiSave: {
|
|
|
|
|
...localSaveConfig.customApiSave,
|
|
|
|
|
multiTable: {
|
|
|
|
|
...localSaveConfig.customApiSave?.multiTable,
|
|
|
|
|
subTables,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 필드 매핑 업데이트
|
|
|
|
|
const updateFieldMapping = (subTableIndex: number, mappingIndex: number, updates: Partial<SubTableFieldMapping>) => {
|
|
|
|
|
const subTables = [...(localSaveConfig.customApiSave?.multiTable?.subTables || [])];
|
|
|
|
|
const fieldMappings = [...(subTables[subTableIndex].fieldMappings || [])];
|
|
|
|
|
fieldMappings[mappingIndex] = { ...fieldMappings[mappingIndex], ...updates };
|
|
|
|
|
subTables[subTableIndex] = { ...subTables[subTableIndex], fieldMappings };
|
|
|
|
|
updateSaveConfig({
|
|
|
|
|
customApiSave: {
|
|
|
|
|
...localSaveConfig.customApiSave,
|
|
|
|
|
multiTable: {
|
|
|
|
|
...localSaveConfig.customApiSave?.multiTable,
|
|
|
|
|
subTables,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 메인 테이블 컬럼 목록
|
|
|
|
|
const mainTableColumns = localSaveConfig.customApiSave?.multiTable?.mainTable?.tableName
|
|
|
|
|
? tableColumns[localSaveConfig.customApiSave.multiTable.mainTable.tableName] || []
|
|
|
|
|
: [];
|
|
|
|
|
|
|
|
|
|
// 반복 섹션 목록
|
|
|
|
|
const repeatSections = sections.filter((s) => s.repeatable);
|
|
|
|
|
|
|
|
|
|
// 모든 필드 목록 (반복 섹션 포함)
|
2025-12-22 14:36:13 +09:00
|
|
|
const getAllFields = (): { columnName: string; label: string; sectionTitle: string; sectionId: string }[] => {
|
|
|
|
|
const fields: { columnName: string; label: string; sectionTitle: string; sectionId: string }[] = [];
|
2025-12-11 15:29:37 +09:00
|
|
|
sections.forEach((section) => {
|
2025-12-18 15:19:59 +09:00
|
|
|
// 필드 타입 섹션만 처리 (테이블 타입은 fields가 undefined)
|
|
|
|
|
if (section.fields && Array.isArray(section.fields)) {
|
|
|
|
|
section.fields.forEach((field) => {
|
|
|
|
|
fields.push({
|
|
|
|
|
columnName: field.columnName,
|
|
|
|
|
label: field.label,
|
|
|
|
|
sectionTitle: section.title,
|
2025-12-22 14:36:13 +09:00
|
|
|
sectionId: section.id,
|
2025-12-18 15:19:59 +09:00
|
|
|
});
|
2025-12-11 15:29:37 +09:00
|
|
|
});
|
2025-12-18 15:19:59 +09:00
|
|
|
}
|
2025-12-11 15:29:37 +09:00
|
|
|
});
|
|
|
|
|
return fields;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const allFields = getAllFields();
|
|
|
|
|
|
2025-12-19 14:53:16 +09:00
|
|
|
// 섹션별 저장 방식 조회 (없으면 기본값 반환)
|
|
|
|
|
const getSectionSaveMode = (sectionId: string, sectionType: "fields" | "table"): "common" | "individual" => {
|
|
|
|
|
const sectionMode = localSaveConfig.sectionSaveModes?.find((s) => s.sectionId === sectionId);
|
|
|
|
|
if (sectionMode) {
|
|
|
|
|
return sectionMode.saveMode;
|
|
|
|
|
}
|
|
|
|
|
// 기본값: fields 타입은 공통 저장, table 타입은 개별 저장
|
|
|
|
|
return sectionType === "fields" ? "common" : "individual";
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 필드별 저장 방식 조회 (오버라이드 확인)
|
|
|
|
|
const getFieldSaveMode = (sectionId: string, fieldName: string, sectionType: "fields" | "table"): "common" | "individual" => {
|
|
|
|
|
const sectionMode = localSaveConfig.sectionSaveModes?.find((s) => s.sectionId === sectionId);
|
|
|
|
|
if (sectionMode) {
|
|
|
|
|
// 필드별 오버라이드 확인
|
|
|
|
|
const fieldOverride = sectionMode.fieldOverrides?.find((f) => f.fieldName === fieldName);
|
|
|
|
|
if (fieldOverride) {
|
|
|
|
|
return fieldOverride.saveMode;
|
|
|
|
|
}
|
|
|
|
|
return sectionMode.saveMode;
|
|
|
|
|
}
|
|
|
|
|
// 기본값
|
|
|
|
|
return sectionType === "fields" ? "common" : "individual";
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 섹션별 저장 방식 업데이트
|
|
|
|
|
const updateSectionSaveMode = (sectionId: string, mode: "common" | "individual") => {
|
|
|
|
|
const currentModes = localSaveConfig.sectionSaveModes || [];
|
|
|
|
|
const existingIndex = currentModes.findIndex((s) => s.sectionId === sectionId);
|
|
|
|
|
|
|
|
|
|
let newModes: SectionSaveMode[];
|
|
|
|
|
if (existingIndex >= 0) {
|
|
|
|
|
newModes = [...currentModes];
|
|
|
|
|
newModes[existingIndex] = { ...newModes[existingIndex], saveMode: mode };
|
|
|
|
|
} else {
|
|
|
|
|
newModes = [...currentModes, { sectionId, saveMode: mode }];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateSaveConfig({ sectionSaveModes: newModes });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 필드별 오버라이드 토글
|
|
|
|
|
const toggleFieldOverride = (sectionId: string, fieldName: string, sectionType: "fields" | "table") => {
|
|
|
|
|
const currentModes = localSaveConfig.sectionSaveModes || [];
|
|
|
|
|
const sectionIndex = currentModes.findIndex((s) => s.sectionId === sectionId);
|
|
|
|
|
|
|
|
|
|
// 섹션 설정이 없으면 먼저 생성
|
|
|
|
|
let newModes = [...currentModes];
|
|
|
|
|
if (sectionIndex < 0) {
|
|
|
|
|
const defaultMode = sectionType === "fields" ? "common" : "individual";
|
|
|
|
|
newModes.push({ sectionId, saveMode: defaultMode, fieldOverrides: [] });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const targetIndex = newModes.findIndex((s) => s.sectionId === sectionId);
|
|
|
|
|
const sectionMode = newModes[targetIndex];
|
|
|
|
|
const currentFieldOverrides = sectionMode.fieldOverrides || [];
|
|
|
|
|
const fieldOverrideIndex = currentFieldOverrides.findIndex((f) => f.fieldName === fieldName);
|
|
|
|
|
|
|
|
|
|
let newFieldOverrides;
|
|
|
|
|
if (fieldOverrideIndex >= 0) {
|
|
|
|
|
// 이미 오버라이드가 있으면 제거 (섹션 기본값으로 돌아감)
|
|
|
|
|
newFieldOverrides = currentFieldOverrides.filter((f) => f.fieldName !== fieldName);
|
|
|
|
|
} else {
|
|
|
|
|
// 오버라이드 추가 (섹션 기본값의 반대)
|
|
|
|
|
const oppositeMode = sectionMode.saveMode === "common" ? "individual" : "common";
|
|
|
|
|
newFieldOverrides = [...currentFieldOverrides, { fieldName, saveMode: oppositeMode }];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
newModes[targetIndex] = { ...sectionMode, fieldOverrides: newFieldOverrides };
|
|
|
|
|
updateSaveConfig({ sectionSaveModes: newModes });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 섹션의 필드 목록 가져오기
|
|
|
|
|
const getSectionFields = (section: FormSectionConfig): { fieldName: string; label: string }[] => {
|
|
|
|
|
if (section.type === "table" && section.tableConfig) {
|
|
|
|
|
// 테이블 타입: tableConfig.columns에서 필드 목록 가져오기
|
|
|
|
|
return (section.tableConfig.columns || []).map((col) => ({
|
|
|
|
|
fieldName: col.field,
|
|
|
|
|
label: col.label,
|
|
|
|
|
}));
|
|
|
|
|
} else if (section.fields) {
|
|
|
|
|
// 필드 타입: fields에서 목록 가져오기
|
|
|
|
|
return section.fields.map((field) => ({
|
|
|
|
|
fieldName: field.columnName,
|
|
|
|
|
label: field.label,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
return [];
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-11 15:29:37 +09:00
|
|
|
return (
|
|
|
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
|
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] flex flex-col p-0">
|
|
|
|
|
<DialogHeader className="px-4 pt-4 pb-2 border-b shrink-0">
|
|
|
|
|
<DialogTitle className="text-base">저장 설정</DialogTitle>
|
|
|
|
|
<DialogDescription className="text-xs">
|
|
|
|
|
폼 데이터를 데이터베이스에 저장하는 방식을 설정합니다.
|
|
|
|
|
</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
<div className="flex-1 overflow-hidden px-4">
|
|
|
|
|
<ScrollArea className="h-[calc(90vh-180px)]">
|
|
|
|
|
<div className="space-y-4 py-3 pr-3">
|
|
|
|
|
{/* 저장 모드 선택 */}
|
|
|
|
|
<div className="space-y-3 border rounded-lg p-3 bg-card">
|
|
|
|
|
<Label className="text-xs font-semibold">저장 모드</Label>
|
|
|
|
|
<RadioGroup value={saveMode} onValueChange={(value) => setSaveMode(value as "single" | "multi")}>
|
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
|
<RadioGroupItem value="single" id="mode-single" />
|
|
|
|
|
<Label htmlFor="mode-single" className="text-[10px] cursor-pointer">
|
|
|
|
|
단일 테이블 저장
|
|
|
|
|
</Label>
|
|
|
|
|
</div>
|
2026-01-07 17:42:40 +09:00
|
|
|
<HelpText>
|
|
|
|
|
폼 데이터를 하나의 테이블에 1개 행으로 저장합니다.
|
|
|
|
|
<br />
|
|
|
|
|
예: 사원 등록, 부서 등록, 거래처 등록 등 단순 등록 화면
|
|
|
|
|
</HelpText>
|
2025-12-11 15:29:37 +09:00
|
|
|
|
|
|
|
|
<div className="flex items-center space-x-2 pt-2">
|
|
|
|
|
<RadioGroupItem value="multi" id="mode-multi" />
|
|
|
|
|
<Label htmlFor="mode-multi" className="text-[10px] cursor-pointer">
|
|
|
|
|
다중 테이블 저장
|
|
|
|
|
</Label>
|
|
|
|
|
</div>
|
|
|
|
|
<HelpText>
|
2026-01-07 17:42:40 +09:00
|
|
|
하나의 폼으로 여러 테이블에 동시 저장합니다. (트랜잭션으로 묶임)
|
2025-12-11 15:29:37 +09:00
|
|
|
<br />
|
2026-01-07 17:42:40 +09:00
|
|
|
메인 테이블: 폼의 모든 필드 중 해당 테이블 컬럼과 일치하는 것 자동 저장
|
|
|
|
|
<br />
|
|
|
|
|
서브 테이블: 필드 매핑에서 지정한 필드만 저장 (메인 테이블의 키 값이 자동 연결됨)
|
|
|
|
|
<br />
|
|
|
|
|
예: 사원+부서배정(user_info+user_dept), 주문+주문상세(orders+order_items)
|
2025-12-11 15:29:37 +09:00
|
|
|
</HelpText>
|
|
|
|
|
</RadioGroup>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 단일 테이블 저장 설정 */}
|
|
|
|
|
{saveMode === "single" && (
|
|
|
|
|
<div className="space-y-3 border rounded-lg p-3 bg-card">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Database className="h-4 w-4 text-blue-600" />
|
|
|
|
|
<h3 className="text-xs font-semibold">단일 테이블 설정</h3>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-[10px]">저장 테이블</Label>
|
2025-12-23 09:24:59 +09:00
|
|
|
<Popover open={singleTableSearchOpen} onOpenChange={setSingleTableSearchOpen}>
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
role="combobox"
|
|
|
|
|
aria-expanded={singleTableSearchOpen}
|
|
|
|
|
className="h-8 w-full justify-between text-xs mt-1 font-normal"
|
|
|
|
|
>
|
|
|
|
|
{localSaveConfig.tableName ? (
|
|
|
|
|
<div className="flex flex-col items-start text-left">
|
|
|
|
|
<span className="font-medium">{localSaveConfig.tableName}</span>
|
|
|
|
|
{(() => {
|
|
|
|
|
const tableLabel = tables.find(t => t.name === localSaveConfig.tableName)?.label;
|
|
|
|
|
return tableLabel && tableLabel !== localSaveConfig.tableName ? (
|
|
|
|
|
<span className="text-[10px] text-muted-foreground">{tableLabel}</span>
|
|
|
|
|
) : null;
|
|
|
|
|
})()}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="text-muted-foreground">테이블 선택...</span>
|
|
|
|
|
)}
|
|
|
|
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
|
|
|
|
</Button>
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
<PopoverContent className="p-0 w-[300px]" align="start">
|
|
|
|
|
<Command>
|
|
|
|
|
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
|
|
|
|
<CommandList className="max-h-[250px]">
|
|
|
|
|
<CommandEmpty className="text-xs py-4 text-center">
|
|
|
|
|
테이블을 찾을 수 없습니다.
|
|
|
|
|
</CommandEmpty>
|
|
|
|
|
<CommandGroup>
|
|
|
|
|
{tables.map((t) => (
|
|
|
|
|
<CommandItem
|
|
|
|
|
key={t.name}
|
|
|
|
|
value={`${t.name} ${t.label}`}
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
updateSaveConfig({ tableName: t.name });
|
|
|
|
|
onLoadTableColumns(t.name);
|
|
|
|
|
setSingleTableSearchOpen(false);
|
|
|
|
|
}}
|
|
|
|
|
className="text-xs"
|
|
|
|
|
>
|
|
|
|
|
<Check
|
|
|
|
|
className={cn(
|
|
|
|
|
"mr-2 h-3 w-3",
|
|
|
|
|
localSaveConfig.tableName === t.name ? "opacity-100" : "opacity-0"
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
<span className="font-medium">{t.name}</span>
|
|
|
|
|
{t.label && (
|
|
|
|
|
<span className="text-[10px] text-muted-foreground">{t.label}</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</CommandItem>
|
|
|
|
|
))}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
</CommandList>
|
|
|
|
|
</Command>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
2025-12-11 15:29:37 +09:00
|
|
|
<HelpText>폼 데이터를 저장할 테이블을 선택하세요</HelpText>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-[10px]">키 컬럼 (Primary Key)</Label>
|
|
|
|
|
<Input
|
|
|
|
|
value={localSaveConfig.primaryKeyColumn || ""}
|
|
|
|
|
onChange={(e) => updateSaveConfig({ primaryKeyColumn: e.target.value })}
|
|
|
|
|
placeholder="id"
|
|
|
|
|
className="h-7 text-xs mt-1"
|
|
|
|
|
/>
|
|
|
|
|
<HelpText>
|
|
|
|
|
수정 모드에서 사용할 기본키 컬럼명
|
|
|
|
|
<br />
|
|
|
|
|
예: id, user_id, order_id
|
|
|
|
|
</HelpText>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 다중 테이블 저장 설정 */}
|
|
|
|
|
{saveMode === "multi" && (
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
{/* 메인 테이블 설정 */}
|
|
|
|
|
<div className="border rounded-lg p-3 bg-card space-y-3">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Database className="h-4 w-4 text-blue-600" />
|
|
|
|
|
<h3 className="text-xs font-semibold">메인 테이블 설정</h3>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-[10px]">메인 테이블명</Label>
|
2025-12-23 09:24:59 +09:00
|
|
|
<Popover open={mainTableSearchOpen} onOpenChange={setMainTableSearchOpen}>
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
role="combobox"
|
|
|
|
|
aria-expanded={mainTableSearchOpen}
|
|
|
|
|
className="h-8 w-full justify-between text-xs mt-1 font-normal"
|
|
|
|
|
>
|
|
|
|
|
{localSaveConfig.customApiSave?.multiTable?.mainTable?.tableName ? (
|
|
|
|
|
<div className="flex flex-col items-start text-left">
|
|
|
|
|
<span className="font-medium">{localSaveConfig.customApiSave.multiTable.mainTable.tableName}</span>
|
|
|
|
|
{(() => {
|
|
|
|
|
const tableLabel = tables.find(t => t.name === localSaveConfig.customApiSave?.multiTable?.mainTable?.tableName)?.label;
|
|
|
|
|
return tableLabel && tableLabel !== localSaveConfig.customApiSave?.multiTable?.mainTable?.tableName ? (
|
|
|
|
|
<span className="text-[10px] text-muted-foreground">{tableLabel}</span>
|
|
|
|
|
) : null;
|
|
|
|
|
})()}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="text-muted-foreground">테이블 선택...</span>
|
|
|
|
|
)}
|
|
|
|
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
|
|
|
|
</Button>
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
<PopoverContent className="p-0 w-[300px]" align="start">
|
|
|
|
|
<Command>
|
|
|
|
|
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
|
|
|
|
<CommandList className="max-h-[250px]">
|
|
|
|
|
<CommandEmpty className="text-xs py-4 text-center">
|
|
|
|
|
테이블을 찾을 수 없습니다.
|
|
|
|
|
</CommandEmpty>
|
|
|
|
|
<CommandGroup>
|
|
|
|
|
{tables.map((t) => (
|
|
|
|
|
<CommandItem
|
|
|
|
|
key={t.name}
|
|
|
|
|
value={`${t.name} ${t.label}`}
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
updateSaveConfig({
|
|
|
|
|
customApiSave: {
|
|
|
|
|
...localSaveConfig.customApiSave,
|
|
|
|
|
apiType: "multi-table",
|
|
|
|
|
multiTable: {
|
|
|
|
|
...localSaveConfig.customApiSave?.multiTable,
|
|
|
|
|
enabled: true,
|
|
|
|
|
mainTable: {
|
|
|
|
|
...localSaveConfig.customApiSave?.multiTable?.mainTable,
|
|
|
|
|
tableName: t.name,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
onLoadTableColumns(t.name);
|
|
|
|
|
setMainTableSearchOpen(false);
|
|
|
|
|
}}
|
|
|
|
|
className="text-xs"
|
|
|
|
|
>
|
|
|
|
|
<Check
|
|
|
|
|
className={cn(
|
|
|
|
|
"mr-2 h-3 w-3",
|
|
|
|
|
localSaveConfig.customApiSave?.multiTable?.mainTable?.tableName === t.name ? "opacity-100" : "opacity-0"
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
<span className="font-medium">{t.name}</span>
|
|
|
|
|
{t.label && (
|
|
|
|
|
<span className="text-[10px] text-muted-foreground">{t.label}</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</CommandItem>
|
|
|
|
|
))}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
</CommandList>
|
|
|
|
|
</Command>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
2025-12-11 15:29:37 +09:00
|
|
|
<HelpText>주요 데이터를 저장할 메인 테이블 (예: orders, user_info)</HelpText>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-[10px]">메인 테이블 키 컬럼</Label>
|
|
|
|
|
{mainTableColumns.length > 0 ? (
|
2025-12-28 19:32:13 +09:00
|
|
|
<Popover open={mainKeyColumnSearchOpen} onOpenChange={setMainKeyColumnSearchOpen}>
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
role="combobox"
|
|
|
|
|
aria-expanded={mainKeyColumnSearchOpen}
|
|
|
|
|
className="h-7 w-full justify-between text-xs mt-1 font-normal"
|
|
|
|
|
>
|
|
|
|
|
{localSaveConfig.customApiSave?.multiTable?.mainTable?.primaryKeyColumn ? (
|
|
|
|
|
<>
|
|
|
|
|
{localSaveConfig.customApiSave.multiTable.mainTable.primaryKeyColumn}
|
|
|
|
|
{(() => {
|
|
|
|
|
const col = mainTableColumns.find(c => c.name === localSaveConfig.customApiSave?.multiTable?.mainTable?.primaryKeyColumn);
|
|
|
|
|
return col?.label && col.label !== col.name ? ` (${col.label})` : "";
|
|
|
|
|
})()}
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="text-muted-foreground">컬럼 선택</span>
|
|
|
|
|
)}
|
|
|
|
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
|
|
|
|
</Button>
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
<PopoverContent className="w-[280px] p-0" align="start">
|
|
|
|
|
<Command>
|
|
|
|
|
<CommandInput placeholder="컬럼명 또는 라벨로 검색..." className="text-xs h-8" />
|
|
|
|
|
<CommandList className="max-h-[200px]">
|
|
|
|
|
<CommandEmpty className="py-3 text-center text-xs">
|
|
|
|
|
컬럼을 찾을 수 없습니다.
|
|
|
|
|
</CommandEmpty>
|
|
|
|
|
<CommandGroup>
|
|
|
|
|
{mainTableColumns.map((col) => (
|
|
|
|
|
<CommandItem
|
|
|
|
|
key={col.name}
|
|
|
|
|
value={`${col.name} ${col.label || ""}`}
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
updateSaveConfig({
|
|
|
|
|
customApiSave: {
|
|
|
|
|
...localSaveConfig.customApiSave,
|
|
|
|
|
multiTable: {
|
|
|
|
|
...localSaveConfig.customApiSave?.multiTable,
|
|
|
|
|
mainTable: {
|
|
|
|
|
...localSaveConfig.customApiSave?.multiTable?.mainTable,
|
|
|
|
|
primaryKeyColumn: col.name,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
setMainKeyColumnSearchOpen(false);
|
|
|
|
|
}}
|
|
|
|
|
className="text-xs"
|
|
|
|
|
>
|
|
|
|
|
<Check
|
|
|
|
|
className={cn(
|
|
|
|
|
"mr-2 h-3 w-3",
|
|
|
|
|
localSaveConfig.customApiSave?.multiTable?.mainTable?.primaryKeyColumn === col.name ? "opacity-100" : "opacity-0"
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
<span className="font-medium">{col.name}</span>
|
|
|
|
|
{col.label && col.label !== col.name && (
|
|
|
|
|
<span className="text-[10px] text-muted-foreground">{col.label}</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</CommandItem>
|
|
|
|
|
))}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
</CommandList>
|
|
|
|
|
</Command>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
2025-12-11 15:29:37 +09:00
|
|
|
) : (
|
|
|
|
|
<Input
|
|
|
|
|
value={localSaveConfig.customApiSave?.multiTable?.mainTable?.primaryKeyColumn || ""}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
updateSaveConfig({
|
|
|
|
|
customApiSave: {
|
|
|
|
|
...localSaveConfig.customApiSave,
|
|
|
|
|
multiTable: {
|
|
|
|
|
...localSaveConfig.customApiSave?.multiTable,
|
|
|
|
|
mainTable: {
|
|
|
|
|
...localSaveConfig.customApiSave?.multiTable?.mainTable,
|
|
|
|
|
primaryKeyColumn: e.target.value,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
placeholder="id"
|
|
|
|
|
className="h-7 text-xs mt-1"
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
<HelpText>메인 테이블의 기본키 컬럼 (예: order_id, user_id)</HelpText>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 서브 테이블 목록 */}
|
|
|
|
|
<div className="border rounded-lg p-3 bg-card space-y-3">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Layers className="h-4 w-4 text-orange-600" />
|
|
|
|
|
<h3 className="text-xs font-semibold">서브 테이블 설정</h3>
|
|
|
|
|
<span className="text-[9px] text-muted-foreground">
|
|
|
|
|
({(localSaveConfig.customApiSave?.multiTable?.subTables || []).length}개)
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<Button size="sm" variant="outline" onClick={addSubTable} className="h-6 text-[9px] px-2">
|
|
|
|
|
<Plus className="h-3 w-3 mr-1" />
|
|
|
|
|
서브 테이블 추가
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<HelpText>
|
2026-01-07 17:42:40 +09:00
|
|
|
폼에서 입력한 필드를 서브 테이블에 나눠서 저장합니다.
|
|
|
|
|
<br />
|
|
|
|
|
메인 테이블의 키 값(예: user_id)이 서브 테이블에 자동으로 연결됩니다.
|
2025-12-11 15:29:37 +09:00
|
|
|
<br />
|
2026-01-07 17:42:40 +09:00
|
|
|
필드 매핑에서 지정한 필드만 서브 테이블에 저장됩니다.
|
2025-12-11 15:29:37 +09:00
|
|
|
</HelpText>
|
|
|
|
|
|
|
|
|
|
{(localSaveConfig.customApiSave?.multiTable?.subTables || []).length === 0 ? (
|
|
|
|
|
<div className="text-center py-6 border border-dashed rounded-lg">
|
|
|
|
|
<p className="text-[10px] text-muted-foreground mb-1">서브 테이블이 없습니다</p>
|
|
|
|
|
<p className="text-[9px] text-muted-foreground">위의 "서브 테이블 추가" 버튼을 클릭하세요</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="space-y-3 pt-2">
|
|
|
|
|
{(localSaveConfig.customApiSave?.multiTable?.subTables || []).map((subTable, subIndex) => {
|
|
|
|
|
const subTableColumns = subTable.tableName ? tableColumns[subTable.tableName] || [] : [];
|
|
|
|
|
return (
|
|
|
|
|
<Accordion key={subIndex} type="single" collapsible>
|
|
|
|
|
<AccordionItem value={`sub-${subIndex}`} className="border rounded-lg bg-orange-50/30">
|
2025-12-22 14:36:13 +09:00
|
|
|
<div className="flex items-center justify-between px-3 py-2">
|
|
|
|
|
<AccordionTrigger className="flex-1 text-xs hover:no-underline p-0">
|
2025-12-11 15:29:37 +09:00
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="font-medium">
|
|
|
|
|
서브 테이블 {subIndex + 1}: {subTable.tableName || "(미설정)"}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="text-[9px] text-muted-foreground">
|
|
|
|
|
({subTable.fieldMappings?.length || 0}개 매핑)
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
2025-12-22 14:36:13 +09:00
|
|
|
</AccordionTrigger>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
removeSubTable(subIndex);
|
|
|
|
|
}}
|
|
|
|
|
className="h-5 w-5 p-0 text-destructive hover:text-destructive/80 ml-2 inline-flex items-center justify-center"
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="h-3 w-3" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2025-12-11 15:29:37 +09:00
|
|
|
<AccordionContent className="px-3 pb-3 space-y-3">
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-[10px]">서브 테이블명</Label>
|
2025-12-23 09:24:59 +09:00
|
|
|
<Popover
|
|
|
|
|
open={subTableSearchOpen[subIndex] || false}
|
|
|
|
|
onOpenChange={(open) => setSubTableSearchOpen(prev => ({ ...prev, [subIndex]: open }))}
|
2025-12-11 15:29:37 +09:00
|
|
|
>
|
2025-12-23 09:24:59 +09:00
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
role="combobox"
|
|
|
|
|
aria-expanded={subTableSearchOpen[subIndex] || false}
|
|
|
|
|
className="h-8 w-full justify-between text-xs mt-1 font-normal"
|
|
|
|
|
>
|
|
|
|
|
{subTable.tableName ? (
|
|
|
|
|
<div className="flex flex-col items-start text-left">
|
|
|
|
|
<span className="font-medium">{subTable.tableName}</span>
|
|
|
|
|
{(() => {
|
|
|
|
|
const tableLabel = tables.find(t => t.name === subTable.tableName)?.label;
|
|
|
|
|
return tableLabel && tableLabel !== subTable.tableName ? (
|
|
|
|
|
<span className="text-[10px] text-muted-foreground">{tableLabel}</span>
|
|
|
|
|
) : null;
|
|
|
|
|
})()}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="text-muted-foreground">테이블 선택...</span>
|
|
|
|
|
)}
|
|
|
|
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
|
|
|
|
</Button>
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
<PopoverContent className="p-0 w-[280px]" align="start">
|
|
|
|
|
<Command>
|
|
|
|
|
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
|
|
|
|
<CommandList className="max-h-[200px]">
|
|
|
|
|
<CommandEmpty className="text-xs py-4 text-center">
|
|
|
|
|
테이블을 찾을 수 없습니다.
|
|
|
|
|
</CommandEmpty>
|
|
|
|
|
<CommandGroup>
|
|
|
|
|
{tables.map((t) => (
|
|
|
|
|
<CommandItem
|
|
|
|
|
key={t.name}
|
|
|
|
|
value={`${t.name} ${t.label}`}
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
updateSubTable(subIndex, { tableName: t.name });
|
|
|
|
|
onLoadTableColumns(t.name);
|
|
|
|
|
setSubTableSearchOpen(prev => ({ ...prev, [subIndex]: false }));
|
|
|
|
|
}}
|
|
|
|
|
className="text-xs"
|
|
|
|
|
>
|
|
|
|
|
<Check
|
|
|
|
|
className={cn(
|
|
|
|
|
"mr-2 h-3 w-3",
|
|
|
|
|
subTable.tableName === t.name ? "opacity-100" : "opacity-0"
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
<span className="font-medium">{t.name}</span>
|
|
|
|
|
{t.label && (
|
|
|
|
|
<span className="text-[10px] text-muted-foreground">{t.label}</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</CommandItem>
|
|
|
|
|
))}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
</CommandList>
|
|
|
|
|
</Command>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
2025-12-11 15:29:37 +09:00
|
|
|
<HelpText>반복 데이터를 저장할 서브 테이블</HelpText>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
2026-01-07 17:42:40 +09:00
|
|
|
<Label className="text-[10px]">연결할 반복 섹션 (선택사항)</Label>
|
2025-12-11 15:29:37 +09:00
|
|
|
<Select
|
|
|
|
|
value={subTable.repeatSectionId || ""}
|
|
|
|
|
onValueChange={(value) => updateSubTable(subIndex, { repeatSectionId: value })}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-7 text-xs mt-1">
|
2026-01-07 17:42:40 +09:00
|
|
|
<SelectValue placeholder="섹션 선택 (없으면 필드 매핑만 사용)" />
|
2025-12-11 15:29:37 +09:00
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{repeatSections.length === 0 ? (
|
|
|
|
|
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
|
|
|
|
반복 섹션이 없습니다
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
repeatSections.map((section) => (
|
|
|
|
|
<SelectItem key={section.id} value={section.id}>
|
|
|
|
|
{section.title}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))
|
|
|
|
|
)}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
2026-01-07 17:42:40 +09:00
|
|
|
<HelpText>
|
|
|
|
|
반복 섹션: 폼 안에서 동적으로 항목을 추가/삭제할 수 있는 섹션 (예: 주문 품목 목록)
|
|
|
|
|
<br />
|
|
|
|
|
반복 섹션이 있으면 해당 섹션의 각 항목이 서브 테이블에 여러 행으로 저장됩니다.
|
|
|
|
|
<br />
|
|
|
|
|
반복 섹션 없이 필드 매핑만 사용하면 1개 행만 저장됩니다.
|
|
|
|
|
</HelpText>
|
2025-12-11 15:29:37 +09:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Separator />
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label className="text-[10px] font-medium">테이블 연결 설정</Label>
|
|
|
|
|
<HelpText>메인 테이블과 서브 테이블을 연결하는 키 컬럼</HelpText>
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-[9px]">메인 필드</Label>
|
|
|
|
|
{mainTableColumns.length > 0 ? (
|
2025-12-28 19:32:13 +09:00
|
|
|
<Popover
|
|
|
|
|
open={mainFieldSearchOpen[subIndex] || false}
|
|
|
|
|
onOpenChange={(open) => setMainFieldSearchOpen(prev => ({ ...prev, [subIndex]: open }))}
|
2025-12-11 15:29:37 +09:00
|
|
|
>
|
2025-12-28 19:32:13 +09:00
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
role="combobox"
|
|
|
|
|
aria-expanded={mainFieldSearchOpen[subIndex] || false}
|
|
|
|
|
className="h-6 w-full justify-between text-[9px] mt-0.5 font-normal"
|
|
|
|
|
>
|
|
|
|
|
{subTable.linkColumn?.mainField ? (
|
|
|
|
|
<>
|
|
|
|
|
{subTable.linkColumn.mainField}
|
|
|
|
|
{(() => {
|
|
|
|
|
const col = mainTableColumns.find(c => c.name === subTable.linkColumn?.mainField);
|
|
|
|
|
return col?.label && col.label !== col.name ? ` (${col.label})` : "";
|
|
|
|
|
})()}
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="text-muted-foreground">필드 선택</span>
|
|
|
|
|
)}
|
|
|
|
|
<ChevronsUpDown className="ml-1 h-2.5 w-2.5 shrink-0 opacity-50" />
|
|
|
|
|
</Button>
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
<PopoverContent className="w-[220px] p-0" align="start">
|
|
|
|
|
<Command>
|
|
|
|
|
<CommandInput placeholder="컬럼명/라벨 검색..." className="text-xs h-7" />
|
|
|
|
|
<CommandList className="max-h-[180px]">
|
|
|
|
|
<CommandEmpty className="py-2 text-center text-[10px]">
|
|
|
|
|
컬럼을 찾을 수 없습니다.
|
|
|
|
|
</CommandEmpty>
|
|
|
|
|
<CommandGroup>
|
|
|
|
|
{mainTableColumns.map((col) => (
|
|
|
|
|
<CommandItem
|
|
|
|
|
key={col.name}
|
|
|
|
|
value={`${col.name} ${col.label || ""}`}
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
updateSubTable(subIndex, {
|
|
|
|
|
linkColumn: { ...subTable.linkColumn, mainField: col.name },
|
|
|
|
|
});
|
|
|
|
|
setMainFieldSearchOpen(prev => ({ ...prev, [subIndex]: false }));
|
|
|
|
|
}}
|
|
|
|
|
className="text-[10px]"
|
|
|
|
|
>
|
|
|
|
|
<Check
|
|
|
|
|
className={cn(
|
|
|
|
|
"mr-2 h-3 w-3",
|
|
|
|
|
subTable.linkColumn?.mainField === col.name ? "opacity-100" : "opacity-0"
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
<span className="font-medium">{col.name}</span>
|
|
|
|
|
{col.label && col.label !== col.name && (
|
|
|
|
|
<span className="text-[9px] text-muted-foreground">{col.label}</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</CommandItem>
|
|
|
|
|
))}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
</CommandList>
|
|
|
|
|
</Command>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
2025-12-11 15:29:37 +09:00
|
|
|
) : (
|
|
|
|
|
<Input
|
|
|
|
|
value={subTable.linkColumn?.mainField || ""}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
updateSubTable(subIndex, {
|
|
|
|
|
linkColumn: { ...subTable.linkColumn, mainField: e.target.value },
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
placeholder="order_id"
|
|
|
|
|
className="h-6 text-[9px] mt-0.5"
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-[9px]">서브 컬럼</Label>
|
|
|
|
|
{subTableColumns.length > 0 ? (
|
2025-12-28 19:32:13 +09:00
|
|
|
<Popover
|
|
|
|
|
open={subColumnSearchOpen[subIndex] || false}
|
|
|
|
|
onOpenChange={(open) => setSubColumnSearchOpen(prev => ({ ...prev, [subIndex]: open }))}
|
2025-12-11 15:29:37 +09:00
|
|
|
>
|
2025-12-28 19:32:13 +09:00
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
role="combobox"
|
|
|
|
|
aria-expanded={subColumnSearchOpen[subIndex] || false}
|
|
|
|
|
className="h-6 w-full justify-between text-[9px] mt-0.5 font-normal"
|
|
|
|
|
>
|
|
|
|
|
{subTable.linkColumn?.subColumn ? (
|
|
|
|
|
<>
|
|
|
|
|
{subTable.linkColumn.subColumn}
|
|
|
|
|
{(() => {
|
|
|
|
|
const col = subTableColumns.find(c => c.name === subTable.linkColumn?.subColumn);
|
|
|
|
|
return col?.label && col.label !== col.name ? ` (${col.label})` : "";
|
|
|
|
|
})()}
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="text-muted-foreground">컬럼 선택</span>
|
|
|
|
|
)}
|
|
|
|
|
<ChevronsUpDown className="ml-1 h-2.5 w-2.5 shrink-0 opacity-50" />
|
|
|
|
|
</Button>
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
<PopoverContent className="w-[220px] p-0" align="start">
|
|
|
|
|
<Command>
|
|
|
|
|
<CommandInput placeholder="컬럼명/라벨 검색..." className="text-xs h-7" />
|
|
|
|
|
<CommandList className="max-h-[180px]">
|
|
|
|
|
<CommandEmpty className="py-2 text-center text-[10px]">
|
|
|
|
|
컬럼을 찾을 수 없습니다.
|
|
|
|
|
</CommandEmpty>
|
|
|
|
|
<CommandGroup>
|
|
|
|
|
{subTableColumns.map((col) => (
|
|
|
|
|
<CommandItem
|
|
|
|
|
key={col.name}
|
|
|
|
|
value={`${col.name} ${col.label || ""}`}
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
updateSubTable(subIndex, {
|
|
|
|
|
linkColumn: { ...subTable.linkColumn, subColumn: col.name },
|
|
|
|
|
});
|
|
|
|
|
setSubColumnSearchOpen(prev => ({ ...prev, [subIndex]: false }));
|
|
|
|
|
}}
|
|
|
|
|
className="text-[10px]"
|
|
|
|
|
>
|
|
|
|
|
<Check
|
|
|
|
|
className={cn(
|
|
|
|
|
"mr-2 h-3 w-3",
|
|
|
|
|
subTable.linkColumn?.subColumn === col.name ? "opacity-100" : "opacity-0"
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
<span className="font-medium">{col.name}</span>
|
|
|
|
|
{col.label && col.label !== col.name && (
|
|
|
|
|
<span className="text-[9px] text-muted-foreground">{col.label}</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</CommandItem>
|
|
|
|
|
))}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
</CommandList>
|
|
|
|
|
</Command>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
2025-12-11 15:29:37 +09:00
|
|
|
) : (
|
|
|
|
|
<Input
|
|
|
|
|
value={subTable.linkColumn?.subColumn || ""}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
updateSubTable(subIndex, {
|
|
|
|
|
linkColumn: { ...subTable.linkColumn, subColumn: e.target.value },
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
placeholder="order_id"
|
|
|
|
|
className="h-6 text-[9px] mt-0.5"
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Separator />
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<Label className="text-[10px] font-medium">필드 매핑</Label>
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => addFieldMapping(subIndex)}
|
|
|
|
|
className="h-5 text-[8px] px-1.5"
|
|
|
|
|
>
|
|
|
|
|
<Plus className="h-2.5 w-2.5 mr-0.5" />
|
|
|
|
|
추가
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
<HelpText>폼 필드를 서브 테이블 컬럼에 매핑합니다</HelpText>
|
|
|
|
|
|
|
|
|
|
{(subTable.fieldMappings || []).length === 0 ? (
|
|
|
|
|
<div className="text-center py-3 border border-dashed rounded-lg">
|
|
|
|
|
<p className="text-[9px] text-muted-foreground">매핑이 없습니다</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
{(subTable.fieldMappings || []).map((mapping, mapIndex) => (
|
|
|
|
|
<div key={mapIndex} className="border rounded-lg p-2 bg-white space-y-1.5">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<span className="text-[8px] font-medium text-muted-foreground">
|
|
|
|
|
매핑 {mapIndex + 1}
|
|
|
|
|
</span>
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
onClick={() => removeFieldMapping(subIndex, mapIndex)}
|
|
|
|
|
className="h-4 w-4 p-0 text-destructive"
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="h-2.5 w-2.5" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-[8px]">폼 필드</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={mapping.formField || ""}
|
|
|
|
|
onValueChange={(value) =>
|
|
|
|
|
updateFieldMapping(subIndex, mapIndex, { formField: value })
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-5 text-[8px] mt-0.5">
|
|
|
|
|
<SelectValue placeholder="필드 선택" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
2025-12-22 14:36:13 +09:00
|
|
|
{allFields.map((field, fieldIndex) => (
|
|
|
|
|
<SelectItem key={`${field.sectionId}-${field.columnName}-${fieldIndex}`} value={field.columnName}>
|
2025-12-11 15:29:37 +09:00
|
|
|
{field.label} ({field.sectionTitle})
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="text-center text-[8px] text-muted-foreground">↓</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-[8px]">서브 테이블 컬럼</Label>
|
|
|
|
|
{subTableColumns.length > 0 ? (
|
2025-12-28 19:32:13 +09:00
|
|
|
<Popover
|
|
|
|
|
open={subTableColumnSearchOpen[`${subIndex}-${mapIndex}`] || false}
|
|
|
|
|
onOpenChange={(open) => setSubTableColumnSearchOpen(prev => ({ ...prev, [`${subIndex}-${mapIndex}`]: open }))}
|
2025-12-11 15:29:37 +09:00
|
|
|
>
|
2025-12-28 19:32:13 +09:00
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
role="combobox"
|
|
|
|
|
aria-expanded={subTableColumnSearchOpen[`${subIndex}-${mapIndex}`] || false}
|
|
|
|
|
className="h-5 w-full justify-between text-[8px] mt-0.5 font-normal"
|
|
|
|
|
>
|
|
|
|
|
{mapping.targetColumn ? (
|
|
|
|
|
<>
|
|
|
|
|
{mapping.targetColumn}
|
|
|
|
|
{(() => {
|
|
|
|
|
const col = subTableColumns.find(c => c.name === mapping.targetColumn);
|
|
|
|
|
return col?.label && col.label !== col.name ? ` (${col.label})` : "";
|
|
|
|
|
})()}
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="text-muted-foreground">컬럼 선택</span>
|
|
|
|
|
)}
|
|
|
|
|
<ChevronsUpDown className="ml-1 h-2 w-2 shrink-0 opacity-50" />
|
|
|
|
|
</Button>
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
<PopoverContent className="w-[200px] p-0" align="start">
|
|
|
|
|
<Command>
|
|
|
|
|
<CommandInput placeholder="컬럼명/라벨 검색..." className="text-xs h-7" />
|
|
|
|
|
<CommandList className="max-h-[180px]">
|
|
|
|
|
<CommandEmpty className="py-2 text-center text-[10px]">
|
|
|
|
|
컬럼을 찾을 수 없습니다.
|
|
|
|
|
</CommandEmpty>
|
|
|
|
|
<CommandGroup>
|
|
|
|
|
{subTableColumns.map((col) => (
|
|
|
|
|
<CommandItem
|
|
|
|
|
key={col.name}
|
|
|
|
|
value={`${col.name} ${col.label || ""}`}
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
updateFieldMapping(subIndex, mapIndex, { targetColumn: col.name });
|
|
|
|
|
setSubTableColumnSearchOpen(prev => ({ ...prev, [`${subIndex}-${mapIndex}`]: false }));
|
|
|
|
|
}}
|
|
|
|
|
className="text-[10px]"
|
|
|
|
|
>
|
|
|
|
|
<Check
|
|
|
|
|
className={cn(
|
|
|
|
|
"mr-2 h-3 w-3",
|
|
|
|
|
mapping.targetColumn === col.name ? "opacity-100" : "opacity-0"
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
<span className="font-medium">{col.name}</span>
|
|
|
|
|
{col.label && col.label !== col.name && (
|
|
|
|
|
<span className="text-[9px] text-muted-foreground">{col.label}</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</CommandItem>
|
|
|
|
|
))}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
</CommandList>
|
|
|
|
|
</Command>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
2025-12-11 15:29:37 +09:00
|
|
|
) : (
|
|
|
|
|
<Input
|
|
|
|
|
value={mapping.targetColumn || ""}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
updateFieldMapping(subIndex, mapIndex, {
|
|
|
|
|
targetColumn: e.target.value,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
placeholder="item_name"
|
|
|
|
|
className="h-5 text-[8px] mt-0.5"
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2025-12-28 19:32:13 +09:00
|
|
|
|
|
|
|
|
<Separator />
|
|
|
|
|
|
|
|
|
|
{/* 대표 데이터 구분 저장 옵션 */}
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
{!subTable.options?.saveMainAsFirst ? (
|
|
|
|
|
// 비활성화 상태: 추가 버튼 표시
|
|
|
|
|
<div className="border-2 border-dashed border-muted rounded-lg p-3 hover:border-primary/50 hover:bg-muted/30 transition-colors">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-[10px] font-medium text-muted-foreground">대표/일반 구분 저장</p>
|
|
|
|
|
<p className="text-[9px] text-muted-foreground/70 mt-0.5">
|
|
|
|
|
저장되는 데이터를 대표와 일반으로 구분합니다
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => updateSubTable(subIndex, {
|
|
|
|
|
options: {
|
|
|
|
|
...subTable.options,
|
|
|
|
|
saveMainAsFirst: true,
|
|
|
|
|
mainMarkerColumn: "",
|
|
|
|
|
mainMarkerValue: true,
|
|
|
|
|
subMarkerValue: false,
|
|
|
|
|
}
|
|
|
|
|
})}
|
|
|
|
|
className="h-6 text-[9px] px-2 shrink-0"
|
|
|
|
|
>
|
|
|
|
|
<Plus className="h-3 w-3 mr-1" />
|
|
|
|
|
추가
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
// 활성화 상태: 설정 필드 표시
|
|
|
|
|
<div className="border rounded-lg p-3 bg-amber-50/50 space-y-3">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-[10px] font-medium">대표/일반 구분 저장</p>
|
|
|
|
|
<p className="text-[9px] text-muted-foreground">
|
|
|
|
|
저장되는 데이터를 대표와 일반으로 구분합니다
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => updateSubTable(subIndex, {
|
|
|
|
|
options: {
|
|
|
|
|
...subTable.options,
|
|
|
|
|
saveMainAsFirst: false,
|
|
|
|
|
mainMarkerColumn: undefined,
|
|
|
|
|
mainMarkerValue: undefined,
|
|
|
|
|
subMarkerValue: undefined,
|
|
|
|
|
}
|
|
|
|
|
})}
|
|
|
|
|
className="h-6 text-[9px] px-2 text-destructive hover:text-destructive"
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="h-3 w-3 mr-1" />
|
|
|
|
|
제거
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-[9px]">구분 컬럼</Label>
|
|
|
|
|
{subTableColumns.length > 0 ? (
|
|
|
|
|
<Popover
|
|
|
|
|
open={markerColumnSearchOpen[subIndex] || false}
|
|
|
|
|
onOpenChange={(open) => setMarkerColumnSearchOpen(prev => ({ ...prev, [subIndex]: open }))}
|
|
|
|
|
>
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
role="combobox"
|
|
|
|
|
aria-expanded={markerColumnSearchOpen[subIndex] || false}
|
|
|
|
|
className="h-6 w-full justify-between text-[9px] mt-0.5 font-normal"
|
|
|
|
|
>
|
|
|
|
|
{subTable.options?.mainMarkerColumn ? (
|
|
|
|
|
<>
|
|
|
|
|
{subTable.options.mainMarkerColumn}
|
|
|
|
|
{(() => {
|
|
|
|
|
const col = subTableColumns.find(c => c.name === subTable.options?.mainMarkerColumn);
|
|
|
|
|
return col?.label && col.label !== col.name ? ` (${col.label})` : "";
|
|
|
|
|
})()}
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="text-muted-foreground">컬럼 선택</span>
|
|
|
|
|
)}
|
|
|
|
|
<ChevronsUpDown className="ml-1 h-2.5 w-2.5 shrink-0 opacity-50" />
|
|
|
|
|
</Button>
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
<PopoverContent className="w-[220px] p-0" align="start">
|
|
|
|
|
<Command>
|
|
|
|
|
<CommandInput placeholder="컬럼명/라벨 검색..." className="text-xs h-7" />
|
|
|
|
|
<CommandList className="max-h-[180px]">
|
|
|
|
|
<CommandEmpty className="py-2 text-center text-[10px]">
|
|
|
|
|
컬럼을 찾을 수 없습니다.
|
|
|
|
|
</CommandEmpty>
|
|
|
|
|
<CommandGroup>
|
|
|
|
|
{subTableColumns.map((col) => (
|
|
|
|
|
<CommandItem
|
|
|
|
|
key={col.name}
|
|
|
|
|
value={`${col.name} ${col.label || ""}`}
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
updateSubTable(subIndex, {
|
|
|
|
|
options: {
|
|
|
|
|
...subTable.options,
|
|
|
|
|
mainMarkerColumn: col.name,
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
setMarkerColumnSearchOpen(prev => ({ ...prev, [subIndex]: false }));
|
|
|
|
|
}}
|
|
|
|
|
className="text-[10px]"
|
|
|
|
|
>
|
|
|
|
|
<Check
|
|
|
|
|
className={cn(
|
|
|
|
|
"mr-2 h-3 w-3",
|
|
|
|
|
subTable.options?.mainMarkerColumn === col.name ? "opacity-100" : "opacity-0"
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
<span className="font-medium">{col.name}</span>
|
|
|
|
|
{col.label && col.label !== col.name && (
|
|
|
|
|
<span className="text-[9px] text-muted-foreground">{col.label}</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</CommandItem>
|
|
|
|
|
))}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
</CommandList>
|
|
|
|
|
</Command>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
|
|
|
|
) : (
|
|
|
|
|
<Input
|
|
|
|
|
value={subTable.options?.mainMarkerColumn || ""}
|
|
|
|
|
onChange={(e) => updateSubTable(subIndex, {
|
|
|
|
|
options: {
|
|
|
|
|
...subTable.options,
|
|
|
|
|
mainMarkerColumn: e.target.value,
|
|
|
|
|
}
|
|
|
|
|
})}
|
|
|
|
|
placeholder="is_primary"
|
|
|
|
|
className="h-6 text-[9px] mt-0.5"
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
<HelpText>대표/일반을 구분하는 컬럼</HelpText>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-[9px]">함께 저장 (대표)</Label>
|
|
|
|
|
<Input
|
|
|
|
|
value={String(subTable.options?.mainMarkerValue ?? "true")}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
const val = e.target.value;
|
|
|
|
|
// true/false 문자열은 boolean으로 변환
|
|
|
|
|
let parsedValue: any = val;
|
|
|
|
|
if (val === "true") parsedValue = true;
|
|
|
|
|
else if (val === "false") parsedValue = false;
|
|
|
|
|
else if (!isNaN(Number(val)) && val !== "") parsedValue = Number(val);
|
|
|
|
|
|
|
|
|
|
updateSubTable(subIndex, {
|
|
|
|
|
options: {
|
|
|
|
|
...subTable.options,
|
|
|
|
|
mainMarkerValue: parsedValue,
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}}
|
|
|
|
|
placeholder="true, Y, 1 등"
|
|
|
|
|
className="h-6 text-[9px] mt-0.5"
|
|
|
|
|
/>
|
|
|
|
|
<HelpText>기본 정보와 함께 저장될 때 값</HelpText>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-[9px]">액션 활성화시 저장 (일반)</Label>
|
|
|
|
|
<Input
|
|
|
|
|
value={String(subTable.options?.subMarkerValue ?? "false")}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
const val = e.target.value;
|
|
|
|
|
let parsedValue: any = val;
|
|
|
|
|
if (val === "true") parsedValue = true;
|
|
|
|
|
else if (val === "false") parsedValue = false;
|
|
|
|
|
else if (!isNaN(Number(val)) && val !== "") parsedValue = Number(val);
|
|
|
|
|
|
|
|
|
|
updateSubTable(subIndex, {
|
|
|
|
|
options: {
|
|
|
|
|
...subTable.options,
|
|
|
|
|
subMarkerValue: parsedValue,
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}}
|
|
|
|
|
placeholder="false, N, 0 등"
|
|
|
|
|
className="h-6 text-[9px] mt-0.5"
|
|
|
|
|
/>
|
|
|
|
|
<HelpText>겸직 추가 시 저장될 때 값</HelpText>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Separator />
|
|
|
|
|
|
|
|
|
|
{/* 수정 시 데이터 로드 옵션 */}
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
{!subTable.options?.loadOnEdit ? (
|
|
|
|
|
// 비활성화 상태: 추가 버튼 표시
|
|
|
|
|
<div className="border-2 border-dashed border-muted rounded-lg p-3 hover:border-primary/50 hover:bg-muted/30 transition-colors">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-[10px] font-medium text-muted-foreground">수정 시 데이터 로드</p>
|
|
|
|
|
<p className="text-[9px] text-muted-foreground/70 mt-0.5">
|
|
|
|
|
수정 모드에서 서브 테이블 데이터를 불러옵니다
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => updateSubTable(subIndex, {
|
|
|
|
|
options: {
|
|
|
|
|
...subTable.options,
|
|
|
|
|
loadOnEdit: true,
|
|
|
|
|
loadOnlySubItems: true,
|
|
|
|
|
}
|
|
|
|
|
})}
|
|
|
|
|
className="h-6 text-[9px] px-2 shrink-0"
|
|
|
|
|
>
|
|
|
|
|
<Plus className="h-3 w-3 mr-1" />
|
|
|
|
|
추가
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
// 활성화 상태: 설정 필드 표시
|
|
|
|
|
<div className="border rounded-lg p-3 bg-blue-50/50 space-y-3">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-[10px] font-medium">수정 시 데이터 로드</p>
|
|
|
|
|
<p className="text-[9px] text-muted-foreground">
|
|
|
|
|
수정 모드에서 서브 테이블 데이터를 불러옵니다
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => updateSubTable(subIndex, {
|
|
|
|
|
options: {
|
|
|
|
|
...subTable.options,
|
|
|
|
|
loadOnEdit: false,
|
|
|
|
|
loadOnlySubItems: undefined,
|
|
|
|
|
}
|
|
|
|
|
})}
|
|
|
|
|
className="h-6 text-[9px] px-2 text-destructive hover:text-destructive"
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="h-3 w-3 mr-1" />
|
|
|
|
|
제거
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Switch
|
|
|
|
|
id={`loadOnlySubItems-${subIndex}`}
|
|
|
|
|
checked={subTable.options?.loadOnlySubItems ?? true}
|
|
|
|
|
onCheckedChange={(checked) => updateSubTable(subIndex, {
|
|
|
|
|
options: {
|
|
|
|
|
...subTable.options,
|
|
|
|
|
loadOnlySubItems: checked,
|
|
|
|
|
}
|
|
|
|
|
})}
|
|
|
|
|
/>
|
|
|
|
|
<Label htmlFor={`loadOnlySubItems-${subIndex}`} className="text-[9px]">
|
|
|
|
|
일반 항목만 로드 (대표 항목 제외)
|
|
|
|
|
</Label>
|
|
|
|
|
</div>
|
|
|
|
|
<HelpText>
|
|
|
|
|
활성화하면 겸직 데이터만 불러오고, 비활성화하면 모든 데이터를 불러옵니다
|
|
|
|
|
</HelpText>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2025-12-11 15:29:37 +09:00
|
|
|
</AccordionContent>
|
|
|
|
|
</AccordionItem>
|
|
|
|
|
</Accordion>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-12-19 14:53:16 +09:00
|
|
|
{/* 섹션별 저장 방식 */}
|
|
|
|
|
<div className="space-y-3 border rounded-lg p-3 bg-card">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Layers className="h-4 w-4 text-green-600" />
|
|
|
|
|
<h3 className="text-xs font-semibold">섹션별 저장 방식</h3>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 설명 */}
|
|
|
|
|
<div className="bg-muted/50 rounded-lg p-2.5 space-y-1.5">
|
|
|
|
|
<div className="flex items-start gap-2">
|
|
|
|
|
<Info className="h-3.5 w-3.5 text-blue-500 mt-0.5 shrink-0" />
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
<p className="text-[10px] text-muted-foreground">
|
|
|
|
|
<span className="font-medium text-foreground">공통 저장:</span> 이 섹션의 필드 값이 모든 품목 행에 <span className="font-medium">동일하게</span> 저장됩니다
|
|
|
|
|
<br />
|
|
|
|
|
<span className="text-[9px] text-muted-foreground/80">예: 수주번호, 거래처, 수주일 - 품목이 3개면 3개 행 모두 같은 값</span>
|
|
|
|
|
</p>
|
|
|
|
|
<p className="text-[10px] text-muted-foreground">
|
|
|
|
|
<span className="font-medium text-foreground">개별 저장:</span> 이 섹션의 필드 값이 각 품목마다 <span className="font-medium">다르게</span> 저장됩니다
|
|
|
|
|
<br />
|
|
|
|
|
<span className="text-[9px] text-muted-foreground/80">예: 품목코드, 수량, 단가 - 품목마다 다른 값</span>
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 섹션 목록 */}
|
|
|
|
|
{sections.length === 0 ? (
|
|
|
|
|
<div className="text-center py-4 border border-dashed rounded-lg">
|
|
|
|
|
<p className="text-[10px] text-muted-foreground">섹션이 없습니다</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<Accordion type="multiple" className="space-y-2">
|
|
|
|
|
{sections.map((section) => {
|
|
|
|
|
const sectionType = section.type || "fields";
|
|
|
|
|
const currentMode = getSectionSaveMode(section.id, sectionType);
|
|
|
|
|
const sectionFields = getSectionFields(section);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<AccordionItem
|
|
|
|
|
key={section.id}
|
|
|
|
|
value={section.id}
|
|
|
|
|
className={cn(
|
|
|
|
|
"border rounded-lg",
|
|
|
|
|
currentMode === "common" ? "bg-blue-50/30" : "bg-orange-50/30"
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<AccordionTrigger className="px-3 py-2 text-xs hover:no-underline">
|
|
|
|
|
<div className="flex items-center justify-between flex-1 mr-2">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="font-medium">{section.title}</span>
|
|
|
|
|
<Badge
|
|
|
|
|
variant="outline"
|
|
|
|
|
className={cn(
|
|
|
|
|
"text-[8px] h-4",
|
|
|
|
|
sectionType === "table" ? "border-orange-300 text-orange-600" : "border-blue-300 text-blue-600"
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{sectionType === "table" ? "테이블" : "필드"}
|
|
|
|
|
</Badge>
|
|
|
|
|
</div>
|
|
|
|
|
<Badge
|
|
|
|
|
variant={currentMode === "common" ? "default" : "secondary"}
|
|
|
|
|
className="text-[8px] h-4"
|
|
|
|
|
>
|
|
|
|
|
{currentMode === "common" ? "공통 저장" : "개별 저장"}
|
|
|
|
|
</Badge>
|
|
|
|
|
</div>
|
|
|
|
|
</AccordionTrigger>
|
|
|
|
|
<AccordionContent className="px-3 pb-3 space-y-3">
|
|
|
|
|
{/* 저장 방식 선택 */}
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label className="text-[10px] font-medium">저장 방식</Label>
|
|
|
|
|
<RadioGroup
|
|
|
|
|
value={currentMode}
|
|
|
|
|
onValueChange={(value) => updateSectionSaveMode(section.id, value as "common" | "individual")}
|
|
|
|
|
className="flex gap-4"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center space-x-1.5">
|
|
|
|
|
<RadioGroupItem value="common" id={`${section.id}-common`} className="h-3 w-3" />
|
|
|
|
|
<Label htmlFor={`${section.id}-common`} className="text-[10px] cursor-pointer">
|
|
|
|
|
공통 저장
|
|
|
|
|
</Label>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center space-x-1.5">
|
|
|
|
|
<RadioGroupItem value="individual" id={`${section.id}-individual`} className="h-3 w-3" />
|
|
|
|
|
<Label htmlFor={`${section.id}-individual`} className="text-[10px] cursor-pointer">
|
|
|
|
|
개별 저장
|
|
|
|
|
</Label>
|
|
|
|
|
</div>
|
|
|
|
|
</RadioGroup>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 필드 목록 */}
|
|
|
|
|
{sectionFields.length > 0 && (
|
|
|
|
|
<>
|
|
|
|
|
<Separator />
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label className="text-[10px] font-medium">필드 목록 ({sectionFields.length}개)</Label>
|
|
|
|
|
<HelpText>필드를 클릭하면 섹션 기본값과 다르게 설정할 수 있습니다</HelpText>
|
|
|
|
|
<div className="grid grid-cols-2 gap-1.5">
|
|
|
|
|
{sectionFields.map((field) => {
|
|
|
|
|
const fieldMode = getFieldSaveMode(section.id, field.fieldName, sectionType);
|
|
|
|
|
const isOverridden = fieldMode !== currentMode;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={field.fieldName}
|
|
|
|
|
onClick={() => toggleFieldOverride(section.id, field.fieldName, sectionType)}
|
|
|
|
|
className={cn(
|
|
|
|
|
"flex items-center justify-between px-2 py-1.5 rounded border text-left transition-colors",
|
|
|
|
|
isOverridden
|
|
|
|
|
? "border-amber-300 bg-amber-50"
|
|
|
|
|
: "border-gray-200 bg-white hover:bg-gray-50"
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<span className="text-[9px] truncate flex-1">
|
|
|
|
|
{field.label}
|
|
|
|
|
<span className="text-muted-foreground ml-1">({field.fieldName})</span>
|
|
|
|
|
</span>
|
|
|
|
|
<Badge
|
|
|
|
|
variant={fieldMode === "common" ? "default" : "secondary"}
|
|
|
|
|
className={cn(
|
|
|
|
|
"text-[7px] h-3.5 ml-1 shrink-0",
|
|
|
|
|
isOverridden && "ring-1 ring-amber-400"
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{fieldMode === "common" ? "공통" : "개별"}
|
|
|
|
|
</Badge>
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</AccordionContent>
|
|
|
|
|
</AccordionItem>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</Accordion>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-12-11 15:29:37 +09:00
|
|
|
{/* 저장 후 동작 */}
|
|
|
|
|
<div className="space-y-2 border rounded-lg p-3 bg-card">
|
|
|
|
|
<h3 className="text-xs font-semibold">저장 후 동작</h3>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<span className="text-[10px]">토스트 메시지 표시</span>
|
|
|
|
|
<Switch
|
|
|
|
|
checked={localSaveConfig.afterSave?.showToast !== false}
|
|
|
|
|
onCheckedChange={(checked) =>
|
|
|
|
|
updateSaveConfig({
|
|
|
|
|
afterSave: {
|
|
|
|
|
...localSaveConfig.afterSave,
|
|
|
|
|
showToast: checked,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<HelpText>저장 성공 시 "저장되었습니다" 메시지를 표시합니다</HelpText>
|
|
|
|
|
|
|
|
|
|
<Separator className="my-2" />
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<span className="text-[10px]">모달 자동 닫기</span>
|
|
|
|
|
<Switch
|
|
|
|
|
checked={localSaveConfig.afterSave?.closeModal !== false}
|
|
|
|
|
onCheckedChange={(checked) =>
|
|
|
|
|
updateSaveConfig({
|
|
|
|
|
afterSave: {
|
|
|
|
|
...localSaveConfig.afterSave,
|
|
|
|
|
closeModal: checked,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<HelpText>저장 성공 시 모달을 자동으로 닫습니다</HelpText>
|
|
|
|
|
|
|
|
|
|
<Separator className="my-2" />
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<span className="text-[10px]">부모 화면 새로고침</span>
|
|
|
|
|
<Switch
|
|
|
|
|
checked={localSaveConfig.afterSave?.refreshParent !== false}
|
|
|
|
|
onCheckedChange={(checked) =>
|
|
|
|
|
updateSaveConfig({
|
|
|
|
|
afterSave: {
|
|
|
|
|
...localSaveConfig.afterSave,
|
|
|
|
|
refreshParent: checked,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<HelpText>저장 후 부모 화면의 데이터를 새로고침합니다</HelpText>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</ScrollArea>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<DialogFooter className="px-4 py-3 border-t shrink-0">
|
|
|
|
|
<Button variant="outline" onClick={() => onOpenChange(false)} className="h-9 text-sm">
|
|
|
|
|
취소
|
|
|
|
|
</Button>
|
|
|
|
|
<Button onClick={handleSave} className="h-9 text-sm">
|
|
|
|
|
저장
|
|
|
|
|
</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-15 09:25:14 +09:00
|
|
|
|
2025-12-17 14:30:29 +09:00
|
|
|
|