feat: 선택항목 상세입력 컴포넌트 그룹별 독립 입력 구조로 개선
- 데이터 구조 변경: ItemData.details → ItemData.fieldGroups (그룹별 관리) - 각 필드 그룹마다 독립적으로 여러 항목 추가/수정/삭제 가능 - renderFieldsByGroup: 그룹별 입력 항목 목록 + 편집 + 추가 버튼 구현 - renderGridLayout/renderCardLayout: 품목별 그룹 카드 표시로 단순화 - handleFieldChange: groupId 파라미터 추가 (itemId, groupId, entryId, fieldName, value) - handleAddGroupEntry, handleRemoveGroupEntry, handleEditGroupEntry 핸들러 추가 - buttonActions handleBatchSave: fieldGroups 구조 처리하도록 수정 - 원본 데이터 표시 버그 수정: modalData의 중첩 구조 처리 사용 예: - 품목 1 - 그룹 1 (거래처 정보): 3개 항목 입력 가능 - 그룹 2 (단가 정보): 5개 항목 입력 가능 - 각 항목 클릭 → 수정 가능 - 저장 시 모든 입력 항목이 개별 레코드로 저장됨
This commit is contained in:
parent
6839deac97
commit
e9268b3f00
|
|
@ -70,9 +70,6 @@ import { toast } from "sonner";
|
|||
import { MenuAssignmentModal } from "./MenuAssignmentModal";
|
||||
import { FileAttachmentDetailModal } from "./FileAttachmentDetailModal";
|
||||
import { initializeComponents } from "@/lib/registry/components";
|
||||
import { AutocompleteSearchInputRenderer } from "@/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputRenderer";
|
||||
import { EntitySearchInputRenderer } from "@/lib/registry/components/entity-search-input/EntitySearchInputRenderer";
|
||||
import { ModalRepeaterTableRenderer } from "@/lib/registry/components/modal-repeater-table/ModalRepeaterTableRenderer";
|
||||
import { ScreenFileAPI } from "@/lib/api/screenFile";
|
||||
import { safeMigrateLayout, needsMigration } from "@/lib/utils/widthToColumnSpan";
|
||||
|
||||
|
|
@ -4967,12 +4964,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
)}
|
||||
</div>
|
||||
</TableOptionsProvider>
|
||||
{/* 숨겨진 컴포넌트 렌더러들 (레지스트리 등록용) */}
|
||||
<div style={{ display: "none" }}>
|
||||
<AutocompleteSearchInputRenderer />
|
||||
<EntitySearchInputRenderer />
|
||||
<ModalRepeaterTableRenderer />
|
||||
</div>
|
||||
</ScreenPreviewProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,3 +77,4 @@ export const numberingRuleTemplate = {
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +1,33 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import { ComponentRegistry } from "../../ComponentRegistry";
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { AutocompleteSearchInputDefinition } from "./index";
|
||||
import { AutocompleteSearchInputComponent } from "./AutocompleteSearchInputComponent";
|
||||
|
||||
export function AutocompleteSearchInputRenderer() {
|
||||
useEffect(() => {
|
||||
ComponentRegistry.registerComponent(AutocompleteSearchInputDefinition);
|
||||
console.log("✅ AutocompleteSearchInput 컴포넌트 등록 완료");
|
||||
/**
|
||||
* AutocompleteSearchInput 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class AutocompleteSearchInputRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = AutocompleteSearchInputDefinition;
|
||||
|
||||
return () => {
|
||||
// 컴포넌트 언마운트 시 해제하지 않음 (싱글톤 패턴)
|
||||
};
|
||||
}, []);
|
||||
render(): React.ReactElement {
|
||||
return <AutocompleteSearchInputComponent {...this.props} renderer={this} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
/**
|
||||
* 값 변경 처리
|
||||
*/
|
||||
protected handleValueChange = (value: any) => {
|
||||
this.updateComponent({ value });
|
||||
};
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
AutocompleteSearchInputRenderer.registerSelf();
|
||||
|
||||
// Hot Reload 지원 (개발 모드)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
AutocompleteSearchInputRenderer.enableHotReload();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,33 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import { ComponentRegistry } from "../../ComponentRegistry";
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { EntitySearchInputDefinition } from "./index";
|
||||
import { EntitySearchInputComponent } from "./EntitySearchInputComponent";
|
||||
|
||||
export function EntitySearchInputRenderer() {
|
||||
useEffect(() => {
|
||||
ComponentRegistry.registerComponent(EntitySearchInputDefinition);
|
||||
console.log("✅ EntitySearchInput 컴포넌트 등록 완료");
|
||||
/**
|
||||
* EntitySearchInput 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class EntitySearchInputRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = EntitySearchInputDefinition;
|
||||
|
||||
return () => {
|
||||
// 컴포넌트 언마운트 시 해제하지 않음 (싱글톤 패턴)
|
||||
};
|
||||
}, []);
|
||||
render(): React.ReactElement {
|
||||
return <EntitySearchInputComponent {...this.props} renderer={this} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
/**
|
||||
* 값 변경 처리
|
||||
*/
|
||||
protected handleValueChange = (value: any) => {
|
||||
this.updateComponent({ value });
|
||||
};
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
EntitySearchInputRenderer.registerSelf();
|
||||
|
||||
// Hot Reload 지원 (개발 모드)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
EntitySearchInputRenderer.enableHotReload();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,9 +46,9 @@ import "./table-search-widget"; // 🆕 테이블 검색 필터 위젯
|
|||
import "./customer-item-mapping/CustomerItemMappingRenderer"; // 🆕 거래처별 품목정보
|
||||
|
||||
// 🆕 수주 등록 관련 컴포넌트들
|
||||
import { AutocompleteSearchInputRenderer } from "./autocomplete-search-input/AutocompleteSearchInputRenderer";
|
||||
import { EntitySearchInputRenderer } from "./entity-search-input/EntitySearchInputRenderer";
|
||||
import { ModalRepeaterTableRenderer } from "./modal-repeater-table/ModalRepeaterTableRenderer";
|
||||
import "./autocomplete-search-input/AutocompleteSearchInputRenderer";
|
||||
import "./entity-search-input/EntitySearchInputRenderer";
|
||||
import "./modal-repeater-table/ModalRepeaterTableRenderer";
|
||||
import "./order-registration-modal/OrderRegistrationModalRenderer";
|
||||
|
||||
// 🆕 조건부 컨테이너 컴포넌트
|
||||
|
|
|
|||
|
|
@ -1,19 +1,33 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import { ComponentRegistry } from "../../ComponentRegistry";
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { ModalRepeaterTableDefinition } from "./index";
|
||||
import { ModalRepeaterTableComponent } from "./ModalRepeaterTableComponent";
|
||||
|
||||
export function ModalRepeaterTableRenderer() {
|
||||
useEffect(() => {
|
||||
ComponentRegistry.registerComponent(ModalRepeaterTableDefinition);
|
||||
console.log("✅ ModalRepeaterTable 컴포넌트 등록 완료");
|
||||
/**
|
||||
* ModalRepeaterTable 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class ModalRepeaterTableRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = ModalRepeaterTableDefinition;
|
||||
|
||||
return () => {
|
||||
// 컴포넌트 언마운트 시 해제하지 않음 (싱글톤 패턴)
|
||||
};
|
||||
}, []);
|
||||
render(): React.ReactElement {
|
||||
return <ModalRepeaterTableComponent {...this.props} renderer={this} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
/**
|
||||
* 값 변경 처리
|
||||
*/
|
||||
protected handleValueChange = (value: any) => {
|
||||
this.updateComponent({ value });
|
||||
};
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
ModalRepeaterTableRenderer.registerSelf();
|
||||
|
||||
// Hot Reload 지원 (개발 모드)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
ModalRepeaterTableRenderer.enableHotReload();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@
|
|||
```tsx
|
||||
{
|
||||
type: "button-primary",
|
||||
config: {
|
||||
config: {
|
||||
text: "저장",
|
||||
action: {
|
||||
type: "save",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -8,7 +8,7 @@ import { Checkbox } from "@/components/ui/checkbox";
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition } from "./types";
|
||||
import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition, FieldGroup } from "./types";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
|
|
@ -43,6 +43,9 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
const [displayColumns, setDisplayColumns] = useState<Array<{ name: string; label: string; width?: string }>>(config.displayColumns || []);
|
||||
const [fieldPopoverOpen, setFieldPopoverOpen] = useState<Record<number, boolean>>({});
|
||||
|
||||
// 🆕 필드 그룹 상태
|
||||
const [localFieldGroups, setLocalFieldGroups] = useState<FieldGroup[]>(config.fieldGroups || []);
|
||||
|
||||
// 🆕 원본 테이블 선택 상태
|
||||
const [sourceTableSelectOpen, setSourceTableSelectOpen] = useState(false);
|
||||
const [sourceTableSearchValue, setSourceTableSearchValue] = useState("");
|
||||
|
|
@ -100,6 +103,38 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
handleFieldsChange(newFields);
|
||||
};
|
||||
|
||||
// 🆕 필드 그룹 관리
|
||||
const handleFieldGroupsChange = (groups: FieldGroup[]) => {
|
||||
setLocalFieldGroups(groups);
|
||||
handleChange("fieldGroups", groups);
|
||||
};
|
||||
|
||||
const addFieldGroup = () => {
|
||||
const newGroup: FieldGroup = {
|
||||
id: `group_${localFieldGroups.length + 1}`,
|
||||
title: `그룹 ${localFieldGroups.length + 1}`,
|
||||
order: localFieldGroups.length,
|
||||
};
|
||||
handleFieldGroupsChange([...localFieldGroups, newGroup]);
|
||||
};
|
||||
|
||||
const removeFieldGroup = (groupId: string) => {
|
||||
// 그룹 삭제 시 해당 그룹에 속한 필드들의 groupId도 제거
|
||||
const updatedFields = localFields.map(field =>
|
||||
field.groupId === groupId ? { ...field, groupId: undefined } : field
|
||||
);
|
||||
setLocalFields(updatedFields);
|
||||
handleChange("additionalFields", updatedFields);
|
||||
handleFieldGroupsChange(localFieldGroups.filter(g => g.id !== groupId));
|
||||
};
|
||||
|
||||
const updateFieldGroup = (groupId: string, updates: Partial<FieldGroup>) => {
|
||||
const newGroups = localFieldGroups.map(g =>
|
||||
g.id === groupId ? { ...g, ...updates } : g
|
||||
);
|
||||
handleFieldGroupsChange(newGroups);
|
||||
};
|
||||
|
||||
// 표시 컬럼 추가
|
||||
const addDisplayColumn = (columnName: string, columnLabel: string) => {
|
||||
if (!displayColumns.some(col => col.name === columnName)) {
|
||||
|
|
@ -461,6 +496,32 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 🆕 필드 그룹 선택 */}
|
||||
{localFieldGroups.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] sm:text-xs">필드 그룹 (선택사항)</Label>
|
||||
<Select
|
||||
value={field.groupId || "none"}
|
||||
onValueChange={(value) => updateField(index, { groupId: value === "none" ? undefined : value })}
|
||||
>
|
||||
<SelectTrigger className="h-6 text-[10px] sm:h-7 sm:text-xs">
|
||||
<SelectValue placeholder="그룹 없음" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none" className="text-xs">그룹 없음</SelectItem>
|
||||
{localFieldGroups.map((group) => (
|
||||
<SelectItem key={group.id} value={group.id} className="text-xs">
|
||||
{group.title} ({group.id})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[9px] text-gray-500 sm:text-[10px]">
|
||||
같은 그룹 ID를 가진 필드들은 같은 카드에 표시됩니다
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`required-${index}`}
|
||||
|
|
@ -487,6 +548,121 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 🆕 필드 그룹 관리 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold sm:text-sm">필드 그룹 설정</Label>
|
||||
<p className="text-[10px] text-gray-500 sm:text-xs">
|
||||
추가 입력 필드를 여러 카드로 나눠서 표시 (예: 거래처 정보, 단가 정보)
|
||||
</p>
|
||||
|
||||
{localFieldGroups.map((group, index) => (
|
||||
<Card key={group.id} className="border-2">
|
||||
<CardContent className="space-y-2 pt-3 sm:space-y-3 sm:pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-gray-700 sm:text-sm">그룹 {index + 1}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeFieldGroup(group.id)}
|
||||
className="h-6 w-6 text-red-500 hover:text-red-700 sm:h-7 sm:w-7"
|
||||
>
|
||||
<X className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 그룹 ID */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] sm:text-xs">그룹 ID</Label>
|
||||
<Input
|
||||
value={group.id}
|
||||
onChange={(e) => updateFieldGroup(group.id, { id: e.target.value })}
|
||||
className="h-7 text-xs sm:h-8 sm:text-sm"
|
||||
placeholder="group_customer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 그룹 제목 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] sm:text-xs">그룹 제목</Label>
|
||||
<Input
|
||||
value={group.title}
|
||||
onChange={(e) => updateFieldGroup(group.id, { title: e.target.value })}
|
||||
className="h-7 text-xs sm:h-8 sm:text-sm"
|
||||
placeholder="거래처 정보"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 그룹 설명 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] sm:text-xs">그룹 설명 (선택사항)</Label>
|
||||
<Input
|
||||
value={group.description || ""}
|
||||
onChange={(e) => updateFieldGroup(group.id, { description: e.target.value })}
|
||||
className="h-7 text-xs sm:h-8 sm:text-sm"
|
||||
placeholder="거래처 관련 정보를 입력합니다"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 표시 순서 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] sm:text-xs">표시 순서</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={group.order || 0}
|
||||
onChange={(e) => updateFieldGroup(group.id, { order: parseInt(e.target.value) || 0 })}
|
||||
className="h-7 text-xs sm:h-8 sm:text-sm"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addFieldGroup}
|
||||
className="h-7 w-full border-dashed text-xs sm:text-sm"
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3 sm:mr-2 sm:h-4 sm:w-4" />
|
||||
그룹 추가
|
||||
</Button>
|
||||
|
||||
{localFieldGroups.length > 0 && (
|
||||
<p className="text-[10px] text-amber-600 sm:text-xs">
|
||||
💡 추가 입력 필드의 "필드 그룹 ID"에 위에서 정의한 그룹 ID를 입력하세요
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 입력 모드 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold sm:text-sm">입력 모드</Label>
|
||||
<Select
|
||||
value={config.inputMode || "inline"}
|
||||
onValueChange={(value) => handleChange("inputMode", value as "inline" | "modal")}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs sm:h-8 sm:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="inline" className="text-xs sm:text-sm">
|
||||
항상 표시 (Inline)
|
||||
</SelectItem>
|
||||
<SelectItem value="modal" className="text-xs sm:text-sm">
|
||||
추가 버튼으로 표시 (Modal)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[10px] text-muted-foreground sm:text-xs">
|
||||
{config.inputMode === "modal"
|
||||
? "추가 버튼 클릭 시 입력창 표시, 완료 후 작은 카드로 표시"
|
||||
: "모든 항목의 입력창을 항상 표시"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 레이아웃 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold sm:text-sm">레이아웃</Label>
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ export interface AdditionalFieldDefinition {
|
|||
options?: Array<{ label: string; value: string }>;
|
||||
/** 필드 너비 (px 또는 %) */
|
||||
width?: string;
|
||||
/** 🆕 필드 그룹 ID (같은 그룹ID를 가진 필드들은 같은 카드에 표시) */
|
||||
groupId?: string;
|
||||
/** 검증 규칙 */
|
||||
validation?: {
|
||||
min?: number;
|
||||
|
|
@ -36,6 +38,20 @@ export interface AdditionalFieldDefinition {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 필드 그룹 정의
|
||||
*/
|
||||
export interface FieldGroup {
|
||||
/** 그룹 ID */
|
||||
id: string;
|
||||
/** 그룹 제목 */
|
||||
title: string;
|
||||
/** 그룹 설명 (선택사항) */
|
||||
description?: string;
|
||||
/** 그룹 표시 순서 */
|
||||
order?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* SelectedItemsDetailInput 컴포넌트 설정 타입
|
||||
*/
|
||||
|
|
@ -64,6 +80,12 @@ export interface SelectedItemsDetailInputConfig extends ComponentConfig {
|
|||
*/
|
||||
additionalFields?: AdditionalFieldDefinition[];
|
||||
|
||||
/**
|
||||
* 🆕 필드 그룹 정의
|
||||
* 추가 입력 필드를 여러 카드로 나눠서 표시
|
||||
*/
|
||||
fieldGroups?: FieldGroup[];
|
||||
|
||||
/**
|
||||
* 저장 대상 테이블
|
||||
*/
|
||||
|
|
@ -86,6 +108,13 @@ export interface SelectedItemsDetailInputConfig extends ComponentConfig {
|
|||
*/
|
||||
allowRemove?: boolean;
|
||||
|
||||
/**
|
||||
* 🆕 입력 모드
|
||||
* - inline: 항상 입력창 표시 (기본)
|
||||
* - modal: 추가 버튼 클릭 시 입력창 표시, 완료 후 작은 카드로 표시
|
||||
*/
|
||||
inputMode?: "inline" | "modal";
|
||||
|
||||
/**
|
||||
* 빈 상태 메시지
|
||||
*/
|
||||
|
|
@ -96,6 +125,30 @@ export interface SelectedItemsDetailInputConfig extends ComponentConfig {
|
|||
readonly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 그룹별 입력 항목 (예: 그룹1의 한 줄)
|
||||
*/
|
||||
export interface GroupEntry {
|
||||
/** 입력 항목 고유 ID */
|
||||
id: string;
|
||||
/** 입력된 필드 데이터 */
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 품목 + 그룹별 여러 입력 항목
|
||||
* 각 필드 그룹마다 독립적으로 여러 개의 입력을 추가할 수 있음
|
||||
* 예: { "group1": [entry1, entry2], "group2": [entry1, entry2, entry3] }
|
||||
*/
|
||||
export interface ItemData {
|
||||
/** 품목 고유 ID */
|
||||
id: string;
|
||||
/** 원본 데이터 (품목 정보) */
|
||||
originalData: Record<string, any>;
|
||||
/** 필드 그룹별 입력 항목들 { groupId: [entry1, entry2, ...] } */
|
||||
fieldGroups: Record<string, GroupEntry[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* SelectedItemsDetailInput 컴포넌트 Props 타입
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -198,6 +198,19 @@ export class ButtonActionExecutor {
|
|||
private static async handleSave(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||
const { formData, originalData, tableName, screenId } = context;
|
||||
|
||||
console.log("💾 [handleSave] 저장 시작:", { formData, tableName, screenId });
|
||||
|
||||
// 🆕 SelectedItemsDetailInput 배치 저장 처리 (새로운 데이터 구조)
|
||||
const selectedItemsKeys = Object.keys(formData).filter(key => {
|
||||
const value = formData[key];
|
||||
return Array.isArray(value) && value.length > 0 && value[0]?.originalData && value[0]?.details;
|
||||
});
|
||||
|
||||
if (selectedItemsKeys.length > 0) {
|
||||
console.log("🔄 [handleSave] SelectedItemsDetailInput 배치 저장 감지:", selectedItemsKeys);
|
||||
return await this.handleBatchSave(config, context, selectedItemsKeys);
|
||||
}
|
||||
|
||||
// 폼 유효성 검사
|
||||
if (config.validateForm) {
|
||||
const validation = this.validateFormData(formData);
|
||||
|
|
@ -446,6 +459,128 @@ export class ButtonActionExecutor {
|
|||
return await this.handleSave(config, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 배치 저장 액션 처리 (SelectedItemsDetailInput용 - 새로운 데이터 구조)
|
||||
* ItemData[] → 각 품목의 details 배열을 개별 레코드로 저장
|
||||
*/
|
||||
private static async handleBatchSave(
|
||||
config: ButtonActionConfig,
|
||||
context: ButtonActionContext,
|
||||
selectedItemsKeys: string[]
|
||||
): Promise<boolean> {
|
||||
const { formData, tableName, screenId } = context;
|
||||
|
||||
if (!tableName || !screenId) {
|
||||
toast.error("저장에 필요한 정보가 부족합니다. (테이블명 또는 화면ID 누락)");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
// 각 SelectedItemsDetailInput 컴포넌트의 데이터 처리
|
||||
for (const key of selectedItemsKeys) {
|
||||
// 🆕 새로운 데이터 구조: ItemData[] with fieldGroups
|
||||
const items = formData[key] as Array<{
|
||||
id: string;
|
||||
originalData: any;
|
||||
fieldGroups: Record<string, Array<{ id: string; [key: string]: any }>>;
|
||||
}>;
|
||||
|
||||
console.log(`📦 [handleBatchSave] ${key} 처리 중 (${items.length}개 품목)`);
|
||||
|
||||
// 각 품목의 모든 그룹의 모든 항목을 개별 저장
|
||||
for (const item of items) {
|
||||
const allGroupEntries = Object.values(item.fieldGroups).flat();
|
||||
console.log(`🔍 [handleBatchSave] 품목 처리: ${item.id} (${allGroupEntries.length}개 입력 항목)`);
|
||||
|
||||
// 모든 그룹의 모든 항목을 개별 레코드로 저장
|
||||
for (const entry of allGroupEntries) {
|
||||
try {
|
||||
// 원본 데이터 + 입력 데이터 병합
|
||||
const mergedData = {
|
||||
...item.originalData,
|
||||
...entry,
|
||||
};
|
||||
|
||||
// id 필드 제거 (entry.id는 임시 ID이므로)
|
||||
delete mergedData.id;
|
||||
|
||||
// 사용자 정보 추가
|
||||
if (!context.userId) {
|
||||
throw new Error("사용자 정보를 불러올 수 없습니다.");
|
||||
}
|
||||
|
||||
const writerValue = context.userId;
|
||||
const companyCodeValue = context.companyCode || "";
|
||||
|
||||
const dataWithUserInfo = {
|
||||
...mergedData,
|
||||
writer: mergedData.writer || writerValue,
|
||||
created_by: writerValue,
|
||||
updated_by: writerValue,
|
||||
company_code: mergedData.company_code || companyCodeValue,
|
||||
};
|
||||
|
||||
console.log(`💾 [handleBatchSave] 입력 항목 저장:`, {
|
||||
itemId: item.id,
|
||||
entryId: entry.id,
|
||||
data: dataWithUserInfo
|
||||
});
|
||||
|
||||
// INSERT 실행
|
||||
const { DynamicFormApi } = await import("@/lib/api/dynamicForm");
|
||||
const saveResult = await DynamicFormApi.saveFormData({
|
||||
screenId,
|
||||
tableName,
|
||||
data: dataWithUserInfo,
|
||||
});
|
||||
|
||||
if (saveResult.success) {
|
||||
successCount++;
|
||||
console.log(`✅ [handleBatchSave] 입력 항목 저장 성공: ${item.id} > ${entry.id}`);
|
||||
} else {
|
||||
failCount++;
|
||||
errors.push(`품목 ${item.id} > 항목 ${entry.id}: ${saveResult.message}`);
|
||||
console.error(`❌ [handleBatchSave] 입력 항목 저장 실패: ${item.id} > ${entry.id}`, saveResult.message);
|
||||
}
|
||||
} catch (error: any) {
|
||||
failCount++;
|
||||
errors.push(`품목 ${item.id} > 항목 ${entry.id}: ${error.message}`);
|
||||
console.error(`❌ [handleBatchSave] 입력 항목 저장 오류: ${item.id} > ${entry.id}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 결과 토스트
|
||||
if (failCount === 0) {
|
||||
toast.success(`${successCount}개 항목이 저장되었습니다.`);
|
||||
} else if (successCount === 0) {
|
||||
toast.error(`저장 실패: ${errors.join(", ")}`);
|
||||
return false;
|
||||
} else {
|
||||
toast.warning(`${successCount}개 성공, ${failCount}개 실패: ${errors.join(", ")}`);
|
||||
}
|
||||
|
||||
// 테이블과 플로우 새로고침
|
||||
context.onRefresh?.();
|
||||
context.onFlowRefresh?.();
|
||||
|
||||
// 저장 성공 후 이벤트 발생
|
||||
window.dispatchEvent(new CustomEvent("closeEditModal"));
|
||||
window.dispatchEvent(new CustomEvent("saveSuccessInModal"));
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
console.error("배치 저장 오류:", error);
|
||||
toast.error(`저장 오류: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 삭제 액션 처리
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -377,3 +377,4 @@ interface TablePermission {
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue