diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index aa87e83f..dcd80a62 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -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 )} - {/* 숨겨진 컴포넌트 렌더러들 (레지스트리 등록용) */} -
- - - -
); } diff --git a/frontend/components/screen/templates/NumberingRuleTemplate.ts b/frontend/components/screen/templates/NumberingRuleTemplate.ts index 1f5b2eb6..db61ad11 100644 --- a/frontend/components/screen/templates/NumberingRuleTemplate.ts +++ b/frontend/components/screen/templates/NumberingRuleTemplate.ts @@ -77,3 +77,4 @@ export const numberingRuleTemplate = { + diff --git a/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputRenderer.tsx b/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputRenderer.tsx index 82691247..d52aebd8 100644 --- a/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputRenderer.tsx +++ b/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputRenderer.tsx @@ -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 ; + } - return null; + /** + * 값 변경 처리 + */ + protected handleValueChange = (value: any) => { + this.updateComponent({ value }); + }; } +// 자동 등록 실행 +AutocompleteSearchInputRenderer.registerSelf(); + +// Hot Reload 지원 (개발 모드) +if (process.env.NODE_ENV === "development") { + AutocompleteSearchInputRenderer.enableHotReload(); +} diff --git a/frontend/lib/registry/components/entity-search-input/EntitySearchInputRenderer.tsx b/frontend/lib/registry/components/entity-search-input/EntitySearchInputRenderer.tsx index fca4afd2..d7eebac4 100644 --- a/frontend/lib/registry/components/entity-search-input/EntitySearchInputRenderer.tsx +++ b/frontend/lib/registry/components/entity-search-input/EntitySearchInputRenderer.tsx @@ -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 ; + } - return null; + /** + * 값 변경 처리 + */ + protected handleValueChange = (value: any) => { + this.updateComponent({ value }); + }; } +// 자동 등록 실행 +EntitySearchInputRenderer.registerSelf(); + +// Hot Reload 지원 (개발 모드) +if (process.env.NODE_ENV === "development") { + EntitySearchInputRenderer.enableHotReload(); +} diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index 8584932b..11f46ec1 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -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"; // 🆕 조건부 컨테이너 컴포넌트 diff --git a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableRenderer.tsx b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableRenderer.tsx index 6b7639f7..8b6d09f3 100644 --- a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableRenderer.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableRenderer.tsx @@ -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 ; + } - return null; + /** + * 값 변경 처리 + */ + protected handleValueChange = (value: any) => { + this.updateComponent({ value }); + }; } +// 자동 등록 실행 +ModalRepeaterTableRenderer.registerSelf(); + +// Hot Reload 지원 (개발 모드) +if (process.env.NODE_ENV === "development") { + ModalRepeaterTableRenderer.enableHotReload(); +} diff --git a/frontend/lib/registry/components/selected-items-detail-input/README.md b/frontend/lib/registry/components/selected-items-detail-input/README.md index 1b116d60..748e7207 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/README.md +++ b/frontend/lib/registry/components/selected-items-detail-input/README.md @@ -129,7 +129,7 @@ ```tsx { type: "button-primary", - config: { + config: { text: "저장", action: { type: "save", diff --git a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx index 5cf9dd23..06c105cd 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx @@ -3,7 +3,7 @@ import React, { useState, useEffect, useMemo, useCallback } from "react"; import { useSearchParams } from "next/navigation"; import { ComponentRendererProps } from "@/types/component"; -import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition } from "./types"; +import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition, ItemData, GroupEntry } from "./types"; import { useModalDataStore, ModalDataItem } from "@/stores/modalDataStore"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; @@ -50,6 +50,7 @@ export const SelectedItemsDetailInputComponent: React.FC { - console.log("📍 [SelectedItemsDetailInput] dataSourceId 결정:", { - urlDataSourceId, - configDataSourceId: componentConfig.dataSourceId, - componentId: component.id, - finalDataSourceId: dataSourceId, - }); - }, [urlDataSourceId, componentConfig.dataSourceId, component.id, dataSourceId]); - // 전체 레지스트리를 가져와서 컴포넌트 내부에서 필터링 (캐싱 문제 회피) const dataRegistry = useModalDataStore((state) => state.dataRegistry); const modalData = useMemo( @@ -83,12 +74,31 @@ export const SelectedItemsDetailInputComponent: React.FC state.updateItemData); - // 로컬 상태로 데이터 관리 - const [items, setItems] = useState([]); + // 🆕 새로운 데이터 구조: 품목별로 여러 개의 상세 데이터 + const [items, setItems] = useState([]); + + // 🆕 입력 모드 상태 (modal 모드일 때 사용) + const [isEditing, setIsEditing] = useState(false); + const [editingItemId, setEditingItemId] = useState(null); // 현재 편집 중인 품목 ID + const [editingGroupId, setEditingGroupId] = useState(null); // 현재 편집 중인 그룹 ID + const [editingDetailId, setEditingDetailId] = useState(null); // 현재 편집 중인 항목 ID // 🆕 코드 카테고리별 옵션 캐싱 const [codeOptions, setCodeOptions] = useState>>({}); + // 디버깅 로그 + useEffect(() => { + console.log("📍 [SelectedItemsDetailInput] 설정 확인:", { + inputMode: componentConfig.inputMode, + urlDataSourceId, + configDataSourceId: componentConfig.dataSourceId, + componentId: component.id, + finalDataSourceId: dataSourceId, + isEditing, + editingItemId, + }); + }, [urlDataSourceId, componentConfig.dataSourceId, component.id, dataSourceId, componentConfig.inputMode, isEditing, editingItemId]); + // 🆕 필드에 codeCategory가 있으면 자동으로 옵션 로드 useEffect(() => { const loadCodeOptions = async () => { @@ -169,19 +179,46 @@ export const SelectedItemsDetailInputComponent: React.FC { if (modalData && modalData.length > 0) { console.log("📦 [SelectedItemsDetailInput] 데이터 수신:", modalData); - setItems(modalData); - // formData에도 반영 (초기 로드 시에만) - if (onFormDataChange && items.length === 0) { - onFormDataChange({ [component.id || "selected_items"]: modalData }); - } + // 🆕 각 품목마다 빈 fieldGroups 객체를 가진 ItemData 생성 + const groups = componentConfig.fieldGroups || []; + const newItems: ItemData[] = modalData.map((item) => { + const fieldGroups: Record = {}; + + // 각 그룹에 대해 빈 배열 초기화 + groups.forEach((group) => { + fieldGroups[group.id] = []; + }); + + // 그룹이 없으면 기본 그룹 생성 + if (groups.length === 0) { + fieldGroups["default"] = []; + } + + // 🔧 modalData의 구조 확인: item.originalData가 있으면 그것을 사용, 없으면 item 자체를 사용 + const actualData = (item as any).originalData || item; + + return { + id: String(item.id), + originalData: actualData, // 🔧 실제 데이터 추출 + fieldGroups, + }; + }); + + setItems(newItems); + + console.log("✅ [SelectedItemsDetailInput] items 설정 완료:", { + itemsLength: newItems.length, + groups: groups.map(g => g.id), + firstItem: newItems[0], + }); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [modalData, component.id]); // onFormDataChange는 의존성에서 제외 + }, [modalData, component.id, componentConfig.fieldGroups]); // onFormDataChange는 의존성에서 제외 // 스타일 계산 const componentStyle: React.CSSProperties = { @@ -205,42 +242,121 @@ export const SelectedItemsDetailInputComponent: React.FC { - // 상태 업데이트 + // 🆕 그룹별 필드 변경 핸들러: itemId + groupId + entryId + fieldName + const handleFieldChange = useCallback((itemId: string, groupId: string, entryId: string, fieldName: string, value: any) => { setItems((prevItems) => { - const updatedItems = prevItems.map((item) => - item.id === itemId - ? { - ...item, - additionalData: { - ...item.additionalData, - [fieldName]: value, - }, - } - : item - ); + const updatedItems = prevItems.map((item) => { + if (item.id !== itemId) return item; + + const groupEntries = item.fieldGroups[groupId] || []; + const existingEntryIndex = groupEntries.findIndex((e) => e.id === entryId); + + if (existingEntryIndex >= 0) { + // 기존 entry 업데이트 + const updatedEntries = [...groupEntries]; + updatedEntries[existingEntryIndex] = { + ...updatedEntries[existingEntryIndex], + [fieldName]: value, + }; + return { + ...item, + fieldGroups: { + ...item.fieldGroups, + [groupId]: updatedEntries, + }, + }; + } else { + // 새로운 entry 추가 + const newEntry: GroupEntry = { + id: entryId, + [fieldName]: value, + }; + return { + ...item, + fieldGroups: { + ...item.fieldGroups, + [groupId]: [...groupEntries, newEntry], + }, + }; + } + }); - // formData에도 반영 (디바운스 없이 즉시 반영) - if (onFormDataChange) { - onFormDataChange({ [component.id || "selected_items"]: updatedItems }); - } + // 🆕 상태 업데이트 후 즉시 onFormDataChange 호출 (다음 틱에서) + setTimeout(() => { + if (onFormDataChange) { + const dataToSave = { [component.id || "selected_items"]: updatedItems }; + console.log("📝 [SelectedItemsDetailInput] formData 업데이트:", dataToSave); + onFormDataChange(dataToSave); + } + }, 0); return updatedItems; }); + }, [component.id, onFormDataChange]); - // 스토어에도 업데이트 - updateItemData(dataSourceId, itemId, { [fieldName]: value }); - }, [dataSourceId, updateItemData, onFormDataChange, component.id]); - - // 항목 제거 핸들러 - const handleRemoveItem = (itemId: string | number) => { + // 🆕 품목 제거 핸들러 + const handleRemoveItem = (itemId: string) => { setItems((prevItems) => prevItems.filter((item) => item.id !== itemId)); }; - // 개별 필드 렌더링 - const renderField = (field: AdditionalFieldDefinition, item: ModalDataItem) => { - const value = item.additionalData?.[field.name] || field.defaultValue || ""; + // 🆕 그룹 항목 추가 핸들러 (특정 그룹에 새 항목 추가) + const handleAddGroupEntry = (itemId: string, groupId: string) => { + const newEntryId = `entry-${Date.now()}`; + setIsEditing(true); + setEditingItemId(itemId); + setEditingDetailId(newEntryId); + setEditingGroupId(groupId); + }; + + // 🆕 그룹 항목 제거 핸들러 + const handleRemoveGroupEntry = (itemId: string, groupId: string, entryId: string) => { + setItems((prevItems) => + prevItems.map((item) => { + if (item.id !== itemId) return item; + return { + ...item, + fieldGroups: { + ...item.fieldGroups, + [groupId]: (item.fieldGroups[groupId] || []).filter((e) => e.id !== entryId), + }, + }; + }) + ); + }; + + // 🆕 그룹 항목 편집 핸들러 (클릭하면 수정 가능) + const handleEditGroupEntry = (itemId: string, groupId: string, entryId: string) => { + setIsEditing(true); + setEditingItemId(itemId); + setEditingGroupId(groupId); + setEditingDetailId(entryId); + }; + + // 🆕 다음 품목으로 이동 + const handleNextItem = () => { + const currentIndex = items.findIndex((item) => item.id === editingItemId); + if (currentIndex < items.length - 1) { + // 다음 품목으로 + const nextItem = items[currentIndex + 1]; + setEditingItemId(nextItem.id); + const groups = componentConfig.fieldGroups || []; + const firstGroupId = groups.length > 0 ? groups[0].id : "default"; + const newEntryId = `entry-${Date.now()}`; + setEditingDetailId(newEntryId); + setEditingGroupId(firstGroupId); + setIsEditing(true); + } else { + // 마지막 품목이면 편집 모드 종료 + setIsEditing(false); + setEditingItemId(null); + setEditingDetailId(null); + setEditingGroupId(null); + } + }; + + // 🆕 개별 필드 렌더링 (itemId, groupId, entryId, entry 데이터 전달) + const renderField = (field: AdditionalFieldDefinition, itemId: string, groupId: string, entryId: string, entry: GroupEntry) => { + const value = entry[field.name] || field.defaultValue || ""; const commonProps = { value: value || "", @@ -262,7 +378,7 @@ export const SelectedItemsDetailInputComponent: React.FC handleFieldChange(item.id, field.name, e.target.value)} + onChange={(e) => handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)} maxLength={field.validation?.maxLength} className="h-8 text-xs sm:h-10 sm:text-sm" /> @@ -278,7 +394,7 @@ export const SelectedItemsDetailInputComponent: React.FC handleFieldChange(item.id, field.name, e.target.value)} + onChange={(e) => handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)} min={field.validation?.min} max={field.validation?.max} className="h-8 text-xs sm:h-10 sm:text-sm" @@ -292,7 +408,7 @@ export const SelectedItemsDetailInputComponent: React.FC handleFieldChange(item.id, field.name, e.target.value)} + onChange={(e) => handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)} className="h-8 text-xs sm:h-10 sm:text-sm" /> ); @@ -303,7 +419,7 @@ export const SelectedItemsDetailInputComponent: React.FC handleFieldChange(item.id, field.name, checked)} + onCheckedChange={(checked) => handleFieldChange(itemId, groupId, entryId, field.name, checked)} disabled={componentConfig.disabled || componentConfig.readonly} /> ); @@ -312,7 +428,7 @@ export const SelectedItemsDetailInputComponent: React.FC handleFieldChange(item.id, field.name, e.target.value)} + onChange={(e) => handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)} rows={2} className="resize-none text-xs sm:text-sm" /> @@ -340,7 +456,7 @@ export const SelectedItemsDetailInputComponent: React.FC handleFieldChange(item.id, field.name, val)} + onValueChange={(val) => handleFieldChange(itemId, groupId, entryId, field.name, val)} disabled={componentConfig.disabled || componentConfig.readonly} > @@ -370,7 +486,7 @@ export const SelectedItemsDetailInputComponent: React.FC handleFieldChange(item.id, field.name, e.target.value)} + onChange={(e) => handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)} className="h-8 text-xs sm:h-10 sm:text-sm" /> ); @@ -379,7 +495,7 @@ export const SelectedItemsDetailInputComponent: React.FC handleFieldChange(item.id, field.name, val)} + onValueChange={(val) => handleFieldChange(itemId, groupId, entryId, field.name, val)} disabled={componentConfig.disabled || componentConfig.readonly} > @@ -401,7 +517,7 @@ export const SelectedItemsDetailInputComponent: React.FC handleFieldChange(item.id, field.name, e.target.value)} + onChange={(e) => handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)} maxLength={field.validation?.maxLength} className="h-8 text-xs sm:h-10 sm:text-sm" /> @@ -425,8 +541,491 @@ export const SelectedItemsDetailInputComponent: React.FC { + const fields = componentConfig.additionalFields || []; + const groups = componentConfig.fieldGroups || []; + + // 그룹이 정의되지 않은 경우, 기본 그룹 사용 + const effectiveGroups = groups.length > 0 + ? groups + : [{ id: "default", title: "입력 정보", order: 0 }]; + + const sortedGroups = [...effectiveGroups].sort((a, b) => (a.order || 0) - (b.order || 0)); + + return ( +
+ {sortedGroups.map((group) => { + const groupFields = fields.filter((f) => + groups.length === 0 ? true : f.groupId === group.id + ); + + if (groupFields.length === 0) return null; + + const groupEntries = item.fieldGroups[group.id] || []; + const isEditingThisGroup = isEditing && editingItemId === item.id && editingGroupId === group.id; + + return ( + + + + {group.title} + + + {group.description && ( +

{group.description}

+ )} +
+ + {/* 이미 입력된 항목들 */} + {groupEntries.length > 0 ? ( +
+ {groupEntries.map((entry, idx) => { + const isEditingThisEntry = isEditingThisGroup && editingDetailId === entry.id; + + if (isEditingThisEntry) { + // 편집 모드: 입력 필드 표시 + return ( + + +
+ 수정 중 + +
+ {groupFields.map((field) => ( +
+ + {renderField(field, item.id, group.id, entry.id, entry)} +
+ ))} +
+
+ ); + } else { + // 읽기 모드: 텍스트 표시 (클릭하면 수정) + return ( +
handleEditGroupEntry(item.id, group.id, entry.id)} + > + + {idx + 1}. {groupFields.map((f) => entry[f.name] || "-").join(" / ")} + + +
+ ); + } + })} +
+ ) : ( +

+ 아직 입력된 항목이 없습니다. +

+ )} + + {/* 새 항목 입력 중 */} + {isEditingThisGroup && editingDetailId && !groupEntries.find(e => e.id === editingDetailId) && ( + + +
+ 새 항목 + +
+ {groupFields.map((field) => ( +
+ + {renderField(field, item.id, group.id, editingDetailId, {})} +
+ ))} +
+
+ )} +
+
+ ); + })} +
+ ); + }; + + // 🆕 Grid 레이아웃 렌더링 (완전히 재작성 - 그룹별 독립 관리) const renderGridLayout = () => { + console.log("🎨 [renderGridLayout] 렌더링:", { + itemsLength: items.length, + displayColumns: componentConfig.displayColumns, + firstItemOriginalData: items[0]?.originalData, + }); + + return ( +
+ {items.map((item, index) => { + // 제목용 첫 번째 컬럼 값 + const titleValue = componentConfig.displayColumns?.[0]?.name + ? item.originalData[componentConfig.displayColumns[0].name] + : null; + + // 요약용 모든 컬럼 값들 + const summaryValues = componentConfig.displayColumns + ?.map((col) => item.originalData[col.name]) + .filter(Boolean); + + console.log("🔍 [renderGridLayout] 항목 렌더링:", { + index, + titleValue, + summaryValues, + displayColumns: componentConfig.displayColumns, + originalData: item.originalData, + "displayColumns[0]": componentConfig.displayColumns?.[0], + "originalData keys": Object.keys(item.originalData), + }); + + return ( + + + + {index + 1}. {titleValue || "항목"} + + + {/* 원본 데이터 요약 */} + {summaryValues && summaryValues.length > 0 && ( +
+ {summaryValues.join(" | ")} +
+ )} +
+ + {/* 그룹별 입력 항목들 렌더링 */} + {renderFieldsByGroup(item)} + +
+ ); + })} +
+ ); + }; + + // 🔧 기존 renderGridLayout (백업 - 사용 안 함) + const renderGridLayout_OLD = () => { + return ( +
+ {/* Modal 모드: 추가 버튼 */} + {isModalMode && !isEditing && items.length === 0 && ( +
+

항목을 추가하려면 추가 버튼을 클릭하세요

+ +
+ )} + + {/* Modal 모드: 편집 중인 항목 (입력창 표시) */} + {isModalMode && isEditing && editingItemId && (() => { + const editingItem = items.find(item => item.id === editingItemId); + console.log("🔍 [Modal Mode] 편집 항목 찾기:", { + editingItemId, + itemsLength: items.length, + itemIds: items.map(i => i.id), + editingItem: editingItem ? "찾음" : "못 찾음", + editingDetailId, + }); + if (!editingItem) { + console.warn("⚠️ [Modal Mode] 편집할 항목을 찾을 수 없습니다!"); + return null; + } + + return ( + + + + 품목: {editingItem.originalData[componentConfig.displayColumns?.[0]?.name] || "항목"} + + + + + {/* 원본 데이터 요약 */} +
+ {componentConfig.displayColumns?.map((col) => editingItem.originalData[col.name]).filter(Boolean).join(" | ")} +
+ + {/* 🆕 이미 입력된 상세 항목들 표시 */} + {editingItem.details.length > 0 && ( +
+
입력된 품번 ({editingItem.details.length}개)
+ {editingItem.details.map((detail, idx) => ( +
+ {idx + 1}. {detail[componentConfig.additionalFields?.[0]?.name] || "입력됨"} + +
+ ))} +
+ )} + + {/* 추가 입력 필드 */} + {componentConfig.additionalFields && componentConfig.additionalFields.length > 0 && editingDetailId && (() => { + // 현재 편집 중인 detail 찾기 (없으면 빈 객체) + const currentDetail = editingItem.details.find(d => d.id === editingDetailId) || { id: editingDetailId }; + return renderFieldsByGroup(editingItem.id, editingDetailId, currentDetail); + })()} + + {/* 액션 버튼들 */} +
+ + +
+
+
+ ); + })()} + + {/* 저장된 항목들 (inline 모드 또는 modal 모드에서 편집 완료된 항목) */} + {items.map((item, index) => { + // Modal 모드에서 현재 편집 중인 항목은 위에서 렌더링하므로 스킵 + if (isModalMode && isEditing && item.id === editingItemId) { + return null; + } + + // Modal 모드: 작은 요약 카드 + if (isModalMode) { + return ( + + +
+
+
+ {index + 1}. {item.originalData[componentConfig.displayColumns?.[0]?.name] || "항목"} +
+
+ {componentConfig.displayColumns?.map((col) => item.originalData[col.name]).filter(Boolean).join(" | ")} +
+
+
+ + {componentConfig.allowRemove && ( + + )} +
+
+ {/* 🆕 입력된 상세 항목들 표시 */} + {item.details && item.details.length > 0 && ( +
+ {item.details.map((detail, detailIdx) => ( +
+ {detailIdx + 1}. {detail[componentConfig.additionalFields?.[0]?.name] || "입력됨"} +
+ ))} +
+ )} +
+
+ ); + } + + // Inline 모드: 각 품목마다 여러 상세 항목 표시 + return ( + + + {/* 제목 (품명) */} +
+
+ {index + 1}. {item.originalData[componentConfig.displayColumns?.[0]?.name] || "항목"} +
+ +
+ + {/* 원본 데이터 요약 (작은 텍스트, | 구분자) */} +
+ {componentConfig.displayColumns?.map((col) => item.originalData[col.name]).filter(Boolean).join(" | ")} +
+ + {/* 🆕 각 상세 항목 표시 */} + {item.details && item.details.length > 0 ? ( +
+ {item.details.map((detail, detailIdx) => ( + + +
+
상세 항목 {detailIdx + 1}
+ +
+ {/* 입력 필드들 */} + {renderFieldsByGroup(item.id, detail.id, detail)} +
+
+ ))} +
+ ) : ( +
+ 아직 입력된 상세 항목이 없습니다. +
+ )} +
+
+ ); + })} + + {/* Modal 모드: 하단 추가 버튼 (항목이 있을 때) */} + {isModalMode && !isEditing && items.length > 0 && ( + + )} +
+ ); + }; + + // 기존 테이블 레이아웃 (사용 안 함, 삭제 예정) + const renderOldGridLayout = () => { return (
@@ -505,57 +1104,278 @@ export const SelectedItemsDetailInputComponent: React.FC { + return renderGridLayout(); + }; + + // 🔧 기존 renderCardLayout (백업 - 사용 안 함) + const renderCardLayout_OLD = () => { + const isModalMode = componentConfig.inputMode === "modal"; + console.log("🎨 [renderCardLayout] 렌더링 모드:", { + inputMode: componentConfig.inputMode, + isModalMode, + isEditing, + editingItemId, + itemsLength: items.length, + }); + return ( -
- {items.map((item, index) => ( - - - - {componentConfig.showIndex && `${index + 1}. `} - {item.originalData[componentConfig.displayColumns?.[0] || "name"] || `항목 ${index + 1}`} - - - {componentConfig.allowRemove && !componentConfig.disabled && !componentConfig.readonly && ( - - )} - - - - {/* 원본 데이터 표시 */} - {componentConfig.displayColumns?.map((col) => ( -
- {col.label || col.name}: - {item.originalData[col.name] || "-"} +
+ {/* Modal 모드: 추가 버튼 */} + {isModalMode && !isEditing && items.length === 0 && ( +
+

항목을 추가하려면 추가 버튼을 클릭하세요

+ +
+ )} + + {/* Modal 모드: 편집 중인 항목 (입력창 표시) */} + {isModalMode && isEditing && editingItemId && (() => { + const editingItem = items.find(item => item.id === editingItemId); + console.log("🔍 [Modal Mode - Card] 편집 항목 찾기:", { + editingItemId, + itemsLength: items.length, + itemIds: items.map(i => i.id), + editingItem: editingItem ? "찾음" : "못 찾음", + editingDetailId, + }); + if (!editingItem) { + console.warn("⚠️ [Modal Mode - Card] 편집할 항목을 찾을 수 없습니다!"); + return null; + } + + return ( + + + + 품목: {editingItem.originalData[componentConfig.displayColumns?.[0]?.name] || "항목"} + + + + + {/* 원본 데이터 요약 */} +
+ {componentConfig.displayColumns?.map((col) => editingItem.originalData[col.name]).filter(Boolean).join(" | ")}
- ))} - - {/* 추가 입력 필드 */} - {componentConfig.additionalFields?.map((field) => ( -
- - {renderField(field, item)} + + {/* 🆕 이미 입력된 상세 항목들 표시 */} + {editingItem.details.length > 0 && ( +
+
입력된 품번 ({editingItem.details.length}개)
+ {editingItem.details.map((detail, idx) => ( +
+ {idx + 1}. {detail[componentConfig.additionalFields?.[0]?.name] || "입력됨"} + +
+ ))} +
+ )} + + {/* 추가 입력 필드 */} + {componentConfig.additionalFields && componentConfig.additionalFields.length > 0 && editingDetailId && (() => { + // 현재 편집 중인 detail 찾기 (없으면 빈 객체) + const currentDetail = editingItem.details.find(d => d.id === editingDetailId) || { id: editingDetailId }; + return renderFieldsByGroup(editingItem.id, editingDetailId, currentDetail); + })()} + + {/* 액션 버튼들 */} +
+ +
- ))} - - - ))} + + + ); + })()} + + {/* 저장된 항목들 (inline 모드 또는 modal 모드에서 편집 완료된 항목) */} + {items.map((item, index) => { + // Modal 모드에서 현재 편집 중인 항목은 위에서 렌더링하므로 스킵 + if (isModalMode && isEditing && item.id === editingItemId) { + return null; + } + + // Modal 모드: 작은 요약 카드 + if (isModalMode) { + return ( + + +
+
+ {index + 1}. {item.originalData[componentConfig.displayColumns?.[0]?.name] || "항목"} +
+
+ {componentConfig.displayColumns?.map((col) => item.originalData[col.name]).filter(Boolean).join(" | ")} +
+ {/* 입력된 값 표시 */} + {item.additionalData && Object.keys(item.additionalData).length > 0 && ( +
+ 품번: {item.additionalData.customer_item_name} / 품명: {item.additionalData.customer_item_code} +
+ )} +
+
+ + {componentConfig.allowRemove && ( + + )} +
+
+
+ ); + } + + // Inline 모드: 각 품목마다 여러 상세 항목 표시 + return ( + + + {/* 제목 (품명) */} +
+
+ {index + 1}. {item.originalData[componentConfig.displayColumns?.[0]?.name] || "항목"} +
+ +
+ + {/* 원본 데이터 요약 (작은 텍스트, | 구분자) */} +
+ {componentConfig.displayColumns?.map((col) => item.originalData[col.name]).filter(Boolean).join(" | ")} +
+ + {/* 🆕 각 상세 항목 표시 */} + {item.details && item.details.length > 0 ? ( +
+ {item.details.map((detail, detailIdx) => ( + + +
+
상세 항목 {detailIdx + 1}
+ +
+ {/* 입력 필드들 */} + {renderFieldsByGroup(item.id, detail.id, detail)} +
+
+ ))} +
+ ) : ( +
+ 아직 입력된 상세 항목이 없습니다. +
+ )} +
+
+ ); + })} + + {/* Modal 모드: 하단 추가 버튼 (항목이 있을 때) */} + {isModalMode && !isEditing && items.length > 0 && ( + + )}
); }; + console.log("🎨 [메인 렌더] 레이아웃 결정:", { + layout: componentConfig.layout, + willUseGrid: componentConfig.layout === "grid", + inputMode: componentConfig.inputMode, + }); + return (
{/* 레이아웃에 따라 렌더링 */} diff --git a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx index 3957ba45..da972454 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx @@ -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>(config.displayColumns || []); const [fieldPopoverOpen, setFieldPopoverOpen] = useState>({}); + // 🆕 필드 그룹 상태 + const [localFieldGroups, setLocalFieldGroups] = useState(config.fieldGroups || []); + // 🆕 원본 테이블 선택 상태 const [sourceTableSelectOpen, setSourceTableSelectOpen] = useState(false); const [sourceTableSearchValue, setSourceTableSearchValue] = useState(""); @@ -100,6 +103,38 @@ export const SelectedItemsDetailInputConfigPanel: React.FC { + 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) => { + 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
+ {/* 🆕 필드 그룹 선택 */} + {localFieldGroups.length > 0 && ( +
+ + +

+ 같은 그룹 ID를 가진 필드들은 같은 카드에 표시됩니다 +

+
+ )} +
+ {/* 🆕 필드 그룹 관리 */} +
+ +

+ 추가 입력 필드를 여러 카드로 나눠서 표시 (예: 거래처 정보, 단가 정보) +

+ + {localFieldGroups.map((group, index) => ( + + +
+ 그룹 {index + 1} + +
+ + {/* 그룹 ID */} +
+ + updateFieldGroup(group.id, { id: e.target.value })} + className="h-7 text-xs sm:h-8 sm:text-sm" + placeholder="group_customer" + /> +
+ + {/* 그룹 제목 */} +
+ + updateFieldGroup(group.id, { title: e.target.value })} + className="h-7 text-xs sm:h-8 sm:text-sm" + placeholder="거래처 정보" + /> +
+ + {/* 그룹 설명 */} +
+ + updateFieldGroup(group.id, { description: e.target.value })} + className="h-7 text-xs sm:h-8 sm:text-sm" + placeholder="거래처 관련 정보를 입력합니다" + /> +
+ + {/* 표시 순서 */} +
+ + updateFieldGroup(group.id, { order: parseInt(e.target.value) || 0 })} + className="h-7 text-xs sm:h-8 sm:text-sm" + min="0" + /> +
+
+
+ ))} + + + + {localFieldGroups.length > 0 && ( +

+ 💡 추가 입력 필드의 "필드 그룹 ID"에 위에서 정의한 그룹 ID를 입력하세요 +

+ )} +
+ + {/* 입력 모드 설정 */} +
+ + +

+ {config.inputMode === "modal" + ? "추가 버튼 클릭 시 입력창 표시, 완료 후 작은 카드로 표시" + : "모든 항목의 입력창을 항상 표시"} +

+
+ {/* 레이아웃 설정 */}
diff --git a/frontend/lib/registry/components/selected-items-detail-input/types.ts b/frontend/lib/registry/components/selected-items-detail-input/types.ts index d69afd2d..c8a67a62 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/types.ts +++ b/frontend/lib/registry/components/selected-items-detail-input/types.ts @@ -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; + /** 필드 그룹별 입력 항목들 { groupId: [entry1, entry2, ...] } */ + fieldGroups: Record; +} + /** * SelectedItemsDetailInput 컴포넌트 Props 타입 */ diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 28dc2ae1..9ac4426d 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -198,6 +198,19 @@ export class ButtonActionExecutor { private static async handleSave(config: ButtonActionConfig, context: ButtonActionContext): Promise { 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 { + 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>; + }>; + + 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; + } + } + /** * 삭제 액션 처리 */ diff --git a/동적_테이블_접근_시스템_개선_완료.md b/동적_테이블_접근_시스템_개선_완료.md index d143a6a5..da8f5e82 100644 --- a/동적_테이블_접근_시스템_개선_완료.md +++ b/동적_테이블_접근_시스템_개선_완료.md @@ -377,3 +377,4 @@ interface TablePermission { +