1037 lines
51 KiB
TypeScript
1037 lines
51 KiB
TypeScript
"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";
|
|
import { Plus, Trash2, Database, Layers, Info } from "lucide-react";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { cn } from "@/lib/utils";
|
|
import { SaveConfig, SubTableSaveConfig, SubTableFieldMapping, FormSectionConfig, FormFieldConfig, SectionSaveMode } from "../types";
|
|
|
|
// 도움말 텍스트 컴포넌트
|
|
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"
|
|
);
|
|
|
|
// open이 변경될 때마다 데이터 동기화
|
|
useEffect(() => {
|
|
if (open) {
|
|
setLocalSaveConfig(saveConfig);
|
|
setSaveMode(saveConfig.customApiSave?.enabled && saveConfig.customApiSave?.multiTable?.enabled ? "multi" : "single");
|
|
}
|
|
}, [open, saveConfig]);
|
|
|
|
// 저장 설정 업데이트 함수
|
|
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);
|
|
|
|
// 모든 필드 목록 (반복 섹션 포함)
|
|
const getAllFields = (): { columnName: string; label: string; sectionTitle: string }[] => {
|
|
const fields: { columnName: string; label: string; sectionTitle: string }[] = [];
|
|
sections.forEach((section) => {
|
|
// 필드 타입 섹션만 처리 (테이블 타입은 fields가 undefined)
|
|
if (section.fields && Array.isArray(section.fields)) {
|
|
section.fields.forEach((field) => {
|
|
fields.push({
|
|
columnName: field.columnName,
|
|
label: field.label,
|
|
sectionTitle: section.title,
|
|
});
|
|
});
|
|
}
|
|
});
|
|
return fields;
|
|
};
|
|
|
|
const allFields = getAllFields();
|
|
|
|
// 섹션별 저장 방식 조회 (없으면 기본값 반환)
|
|
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 [];
|
|
};
|
|
|
|
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>
|
|
<HelpText>모든 필드를 하나의 테이블에 저장합니다 (기본 방식)</HelpText>
|
|
|
|
<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>
|
|
메인 테이블 + 서브 테이블에 트랜잭션으로 저장합니다
|
|
<br />
|
|
예: 주문(orders) + 주문상세(order_items), 사원(user_info) + 부서(user_dept)
|
|
</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>
|
|
<Select
|
|
value={localSaveConfig.tableName || ""}
|
|
onValueChange={(value) => {
|
|
updateSaveConfig({ tableName: value });
|
|
onLoadTableColumns(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]">키 컬럼 (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>
|
|
<Select
|
|
value={localSaveConfig.customApiSave?.multiTable?.mainTable?.tableName || ""}
|
|
onValueChange={(value) => {
|
|
updateSaveConfig({
|
|
customApiSave: {
|
|
...localSaveConfig.customApiSave,
|
|
apiType: "multi-table",
|
|
multiTable: {
|
|
...localSaveConfig.customApiSave?.multiTable,
|
|
enabled: true,
|
|
mainTable: {
|
|
...localSaveConfig.customApiSave?.multiTable?.mainTable,
|
|
tableName: value,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
onLoadTableColumns(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>주요 데이터를 저장할 메인 테이블 (예: orders, user_info)</HelpText>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-[10px]">메인 테이블 키 컬럼</Label>
|
|
{mainTableColumns.length > 0 ? (
|
|
<Select
|
|
value={localSaveConfig.customApiSave?.multiTable?.mainTable?.primaryKeyColumn || ""}
|
|
onValueChange={(value) =>
|
|
updateSaveConfig({
|
|
customApiSave: {
|
|
...localSaveConfig.customApiSave,
|
|
multiTable: {
|
|
...localSaveConfig.customApiSave?.multiTable,
|
|
mainTable: {
|
|
...localSaveConfig.customApiSave?.multiTable?.mainTable,
|
|
primaryKeyColumn: value,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger className="h-7 text-xs mt-1">
|
|
<SelectValue placeholder="컬럼 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{mainTableColumns.map((col) => (
|
|
<SelectItem key={col.name} value={col.name}>
|
|
{col.name}
|
|
{col.label !== col.name && ` (${col.label})`}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
) : (
|
|
<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>
|
|
반복 섹션 데이터를 별도 테이블에 저장합니다.
|
|
<br />
|
|
예: 주문상세(order_items), 겸직부서(user_dept)
|
|
</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">
|
|
<AccordionTrigger className="px-3 py-2 text-xs hover:no-underline">
|
|
<div className="flex items-center justify-between flex-1">
|
|
<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>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
removeSubTable(subIndex);
|
|
}}
|
|
className="h-5 w-5 p-0 text-destructive hover:text-destructive mr-2"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
</AccordionTrigger>
|
|
<AccordionContent className="px-3 pb-3 space-y-3">
|
|
<div>
|
|
<Label className="text-[10px]">서브 테이블명</Label>
|
|
<Select
|
|
value={subTable.tableName || ""}
|
|
onValueChange={(value) => {
|
|
updateSubTable(subIndex, { tableName: value });
|
|
onLoadTableColumns(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>
|
|
<Select
|
|
value={subTable.repeatSectionId || ""}
|
|
onValueChange={(value) => updateSubTable(subIndex, { repeatSectionId: value })}
|
|
>
|
|
<SelectTrigger className="h-7 text-xs mt-1">
|
|
<SelectValue placeholder="섹션 선택" />
|
|
</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>
|
|
<HelpText>이 서브 테이블에 저장할 반복 섹션을 선택하세요</HelpText>
|
|
</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 ? (
|
|
<Select
|
|
value={subTable.linkColumn?.mainField || ""}
|
|
onValueChange={(value) =>
|
|
updateSubTable(subIndex, {
|
|
linkColumn: { ...subTable.linkColumn, mainField: value },
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger className="h-6 text-[9px] mt-0.5">
|
|
<SelectValue placeholder="필드 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{mainTableColumns.map((col) => (
|
|
<SelectItem key={col.name} value={col.name}>
|
|
{col.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
) : (
|
|
<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 ? (
|
|
<Select
|
|
value={subTable.linkColumn?.subColumn || ""}
|
|
onValueChange={(value) =>
|
|
updateSubTable(subIndex, {
|
|
linkColumn: { ...subTable.linkColumn, subColumn: value },
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger className="h-6 text-[9px] mt-0.5">
|
|
<SelectValue placeholder="컬럼 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{subTableColumns.map((col) => (
|
|
<SelectItem key={col.name} value={col.name}>
|
|
{col.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
) : (
|
|
<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>
|
|
{allFields.map((field) => (
|
|
<SelectItem key={field.columnName} value={field.columnName}>
|
|
{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 ? (
|
|
<Select
|
|
value={mapping.targetColumn || ""}
|
|
onValueChange={(value) =>
|
|
updateFieldMapping(subIndex, mapIndex, { targetColumn: value })
|
|
}
|
|
>
|
|
<SelectTrigger className="h-5 text-[8px] mt-0.5">
|
|
<SelectValue placeholder="컬럼 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{subTableColumns.map((col) => (
|
|
<SelectItem key={col.name} value={col.name}>
|
|
{col.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
) : (
|
|
<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>
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
</Accordion>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 섹션별 저장 방식 */}
|
|
<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>
|
|
|
|
{/* 저장 후 동작 */}
|
|
<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>
|
|
);
|
|
}
|
|
|
|
|
|
|