feat(UniversalFormModal): 섹션별 저장 방식 설정 기능 추가

SectionSaveMode 타입 추가 (공통 저장/개별 저장)
SaveSettingsModal에 섹션별/필드별 저장 방식 설정 UI 추가
saveSingleRow()에 공통 필드 + 품목 병합 저장 로직 구현
buttonActions.ts에 외부 저장 버튼용 병합 저장 처리 추가
This commit is contained in:
SeongHyun Kim 2025-12-19 14:53:16 +09:00
parent 9684a83f37
commit 9fb94da493
4 changed files with 485 additions and 5 deletions

View File

@ -837,7 +837,79 @@ export function UniversalFormModalComponent({
}
}
// 메인 데이터 저장
// 테이블 섹션이 있고 메인 테이블에 품목별로 저장하는 경우 (공통 + 개별 병합 저장)
// targetTable이 없거나 메인 테이블과 같은 경우
const tableSectionsForMainTable = config.sections.filter(
(s) => s.type === "table" &&
(!s.tableConfig?.saveConfig?.targetTable ||
s.tableConfig.saveConfig.targetTable === config.saveConfig.tableName)
);
if (tableSectionsForMainTable.length > 0) {
// 공통 저장 필드 수집 (sectionSaveModes 설정에 따라)
const commonFieldsData: Record<string, any> = {};
const { sectionSaveModes } = config.saveConfig;
// 필드 타입 섹션에서 공통 저장 필드 수집
for (const section of config.sections) {
if (section.type === "table") continue;
const sectionMode = sectionSaveModes?.find((s) => s.sectionId === section.id);
const defaultMode = "common"; // 필드 타입 섹션의 기본값은 공통 저장
const sectionSaveMode = sectionMode?.saveMode || defaultMode;
if (section.fields) {
for (const field of section.fields) {
const fieldOverride = sectionMode?.fieldOverrides?.find((f) => f.fieldName === field.columnName);
const fieldSaveMode = fieldOverride?.saveMode || sectionSaveMode;
if (fieldSaveMode === "common" && dataToSave[field.columnName] !== undefined) {
commonFieldsData[field.columnName] = dataToSave[field.columnName];
}
}
}
}
// 각 테이블 섹션의 품목 데이터에 공통 필드 병합하여 저장
for (const tableSection of tableSectionsForMainTable) {
const sectionData = tableSectionData[tableSection.id] || [];
if (sectionData.length > 0) {
// 품목별로 행 저장
for (const item of sectionData) {
const rowToSave = { ...commonFieldsData, ...item };
// _sourceData 등 내부 메타데이터 제거
Object.keys(rowToSave).forEach((key) => {
if (key.startsWith("_")) {
delete rowToSave[key];
}
});
const response = await apiClient.post(
`/table-management/tables/${config.saveConfig.tableName}/add`,
rowToSave
);
if (!response.data?.success) {
throw new Error(response.data?.message || "품목 저장 실패");
}
}
// 이미 저장했으므로 아래 로직에서 다시 저장하지 않도록 제거
delete tableSectionData[tableSection.id];
}
}
// 품목이 없으면 공통 데이터만 저장하지 않음 (품목이 필요한 화면이므로)
// 다른 테이블 섹션이 있는 경우에만 메인 데이터 저장
const hasOtherTableSections = Object.keys(tableSectionData).length > 0;
if (!hasOtherTableSections) {
return; // 메인 테이블에 저장할 품목이 없으면 종료
}
}
// 메인 데이터 저장 (테이블 섹션이 없거나 별도 테이블에 저장하는 경우)
const response = await apiClient.post(`/table-management/tables/${config.saveConfig.tableName}/add`, dataToSave);
if (!response.data?.success) {
@ -852,8 +924,39 @@ export function UniversalFormModalComponent({
// 메인 레코드 ID가 필요한 경우 (response.data에서 가져오기)
const mainRecordId = response.data?.data?.id;
// 공통 저장 필드 수집 (sectionSaveModes 설정에 따라)
const commonFieldsData: Record<string, any> = {};
const { sectionSaveModes } = config.saveConfig;
if (sectionSaveModes && sectionSaveModes.length > 0) {
// 다른 섹션에서 공통 저장으로 설정된 필드 값 수집
for (const otherSection of config.sections) {
if (otherSection.id === section.id) continue; // 현재 테이블 섹션은 건너뛰기
const sectionMode = sectionSaveModes.find((s) => s.sectionId === otherSection.id);
const defaultMode = otherSection.type === "table" ? "individual" : "common";
const sectionSaveMode = sectionMode?.saveMode || defaultMode;
// 필드 타입 섹션의 필드들 처리
if (otherSection.type !== "table" && otherSection.fields) {
for (const field of otherSection.fields) {
// 필드별 오버라이드 확인
const fieldOverride = sectionMode?.fieldOverrides?.find((f) => f.fieldName === field.columnName);
const fieldSaveMode = fieldOverride?.saveMode || sectionSaveMode;
// 공통 저장이면 formData에서 값을 가져와 모든 품목에 적용
if (fieldSaveMode === "common" && formData[field.columnName] !== undefined) {
commonFieldsData[field.columnName] = formData[field.columnName];
}
}
}
}
}
for (const item of sectionData) {
const itemToSave = { ...item };
// 공통 필드 병합 + 개별 품목 데이터
const itemToSave = { ...commonFieldsData, ...item };
// 메인 레코드와 연결이 필요한 경우
if (mainRecordId && config.saveConfig.primaryKeyColumn) {
itemToSave[config.saveConfig.primaryKeyColumn] = mainRecordId;
@ -867,7 +970,7 @@ export function UniversalFormModalComponent({
}
}
}
}, [config.sections, config.saveConfig.tableName, config.saveConfig.primaryKeyColumn, formData]);
}, [config.sections, config.saveConfig.tableName, config.saveConfig.primaryKeyColumn, config.saveConfig.sectionSaveModes, formData]);
// 다중 행 저장 (겸직 등)
const saveMultipleRows = useCallback(async () => {

View File

@ -11,9 +11,10 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Di
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 } from "lucide-react";
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 } from "../types";
import { SaveConfig, SubTableSaveConfig, SubTableFieldMapping, FormSectionConfig, FormFieldConfig, SectionSaveMode } from "../types";
// 도움말 텍스트 컴포넌트
const HelpText = ({ children }: { children: React.ReactNode }) => (
@ -235,6 +236,96 @@ export function SaveSettingsModal({
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">
@ -724,6 +815,150 @@ export function SaveSettingsModal({
</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>

View File

@ -507,6 +507,21 @@ export interface MultiRowSaveConfig {
mainSectionFields?: string[]; // 메인 행에만 저장할 필드
}
/**
*
* 저장: 해당 (: 수주번호, )
* 저장: 해당 (: 품목코드, , )
*/
export interface SectionSaveMode {
sectionId: string;
saveMode: "common" | "individual"; // 공통 저장 / 개별 저장
// 필드별 세부 설정 (선택사항 - 섹션 기본값과 다르게 설정할 필드)
fieldOverrides?: {
fieldName: string;
saveMode: "common" | "individual";
}[];
}
// 저장 설정
export interface SaveConfig {
tableName: string;
@ -518,6 +533,9 @@ export interface SaveConfig {
// 커스텀 API 저장 설정 (테이블 직접 저장 대신 전용 API 사용)
customApiSave?: CustomApiSaveConfig;
// 섹션별 저장 방식 설정
sectionSaveModes?: SectionSaveMode[];
// 저장 후 동작 (간편 설정)
showToast?: boolean; // 토스트 메시지 (기본: true)
refreshParent?: boolean; // 부모 새로고침 (기본: true)

View File

@ -675,6 +675,14 @@ export class ButtonActionExecutor {
console.log("⚠️ [handleSave] formData 전체 내용:", context.formData);
}
// 🆕 Universal Form Modal 테이블 섹션 병합 저장 처리
// 범용_폼_모달 내부에 _tableSection_ 데이터가 있는 경우 공통 필드 + 개별 품목 병합 저장
const universalFormModalResult = await this.handleUniversalFormModalTableSectionSave(config, context, formData);
if (universalFormModalResult.handled) {
console.log("✅ [handleSave] Universal Form Modal 테이블 섹션 저장 완료");
return universalFormModalResult.success;
}
// 폼 유효성 검사
if (config.validateForm) {
const validation = this.validateFormData(formData);
@ -1479,6 +1487,122 @@ export class ButtonActionExecutor {
}
}
/**
* 🆕 Universal Form Modal
* _폼_모달 + _tableSection_
*/
private static async handleUniversalFormModalTableSectionSave(
config: ButtonActionConfig,
context: ButtonActionContext,
formData: Record<string, any>,
): Promise<{ handled: boolean; success: boolean }> {
const { tableName, screenId } = context;
// 범용_폼_모달 키 찾기 (컬럼명에 따라 다를 수 있음)
const universalFormModalKey = Object.keys(formData).find((key) => {
const value = formData[key];
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
// _tableSection_ 키가 있는지 확인
return Object.keys(value).some((k) => k.startsWith("_tableSection_"));
});
if (!universalFormModalKey) {
return { handled: false, success: false };
}
console.log("🎯 [handleUniversalFormModalTableSectionSave] Universal Form Modal 감지:", universalFormModalKey);
const modalData = formData[universalFormModalKey];
// _tableSection_ 데이터 추출
const tableSectionData: Record<string, any[]> = {};
const commonFieldsData: Record<string, any> = {};
for (const [key, value] of Object.entries(modalData)) {
if (key.startsWith("_tableSection_")) {
const sectionId = key.replace("_tableSection_", "");
tableSectionData[sectionId] = value as any[];
} else if (!key.startsWith("_")) {
// _로 시작하지 않는 필드는 공통 필드로 처리
commonFieldsData[key] = value;
}
}
console.log("🎯 [handleUniversalFormModalTableSectionSave] 데이터 분리:", {
commonFields: Object.keys(commonFieldsData),
tableSections: Object.keys(tableSectionData),
tableSectionCounts: Object.entries(tableSectionData).map(([k, v]) => ({ [k]: v.length })),
});
// 테이블 섹션 데이터가 없으면 처리하지 않음
const hasTableSectionData = Object.values(tableSectionData).some((arr) => arr.length > 0);
if (!hasTableSectionData) {
console.log("⚠️ [handleUniversalFormModalTableSectionSave] 테이블 섹션 데이터 없음 - 일반 저장으로 전환");
return { handled: false, success: false };
}
try {
// 사용자 정보 추가
if (!context.userId) {
throw new Error("사용자 정보를 불러올 수 없습니다. 다시 로그인해주세요.");
}
const userInfo = {
writer: context.userId,
created_by: context.userId,
updated_by: context.userId,
company_code: context.companyCode || "",
};
let totalSaved = 0;
// 각 테이블 섹션의 품목별로 저장
for (const [sectionId, items] of Object.entries(tableSectionData)) {
console.log(`🔄 [handleUniversalFormModalTableSectionSave] 섹션 ${sectionId} 저장 시작: ${items.length}개 품목`);
for (const item of items) {
// 공통 필드 + 품목 데이터 병합
const rowToSave = { ...commonFieldsData, ...item, ...userInfo };
// 내부 메타데이터 제거
Object.keys(rowToSave).forEach((key) => {
if (key.startsWith("_")) {
delete rowToSave[key];
}
});
console.log("📝 [handleUniversalFormModalTableSectionSave] 저장할 행:", rowToSave);
// INSERT 실행
const saveResult = await DynamicFormApi.saveFormData({
screenId: screenId!,
tableName: tableName!,
data: rowToSave,
});
if (!saveResult.success) {
throw new Error(saveResult.message || "품목 저장 실패");
}
totalSaved++;
}
}
console.log(`✅ [handleUniversalFormModalTableSectionSave] 총 ${totalSaved}개 행 저장 완료`);
toast.success(`${totalSaved}개 항목이 저장되었습니다.`);
// 저장 성공 이벤트 발생
window.dispatchEvent(new CustomEvent("saveSuccess"));
window.dispatchEvent(new CustomEvent("refreshTable"));
return { handled: true, success: true };
} catch (error: any) {
console.error("❌ [handleUniversalFormModalTableSectionSave] 저장 오류:", error);
toast.error(error.message || "저장 중 오류가 발생했습니다.");
return { handled: true, success: false };
}
}
/**
* 🆕 (SelectedItemsDetailInput용 - )
* ItemData[] details