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:
kjs 2025-11-18 09:56:49 +09:00
parent 6839deac97
commit e9268b3f00
12 changed files with 1366 additions and 147 deletions

View File

@ -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>
);
}

View File

@ -77,3 +77,4 @@ export const numberingRuleTemplate = {

View File

@ -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();
}

View File

@ -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();
}

View File

@ -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";
// 🆕 조건부 컨테이너 컴포넌트

View File

@ -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();
}

View File

@ -129,7 +129,7 @@
```tsx
{
type: "button-primary",
config: {
config: {
text: "저장",
action: {
type: "save",

View File

@ -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>

View File

@ -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
*/

View File

@ -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;
}
}
/**
*
*/

View File

@ -377,3 +377,4 @@ interface TablePermission {