From e9268b3f00644d69f2422a7fe278fc1befc76f88 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 18 Nov 2025 09:56:49 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20=EC=84=A0=ED=83=9D=ED=95=AD?= =?UTF-8?q?=EB=AA=A9=20=EC=83=81=EC=84=B8=EC=9E=85=EB=A0=A5=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B7=B8=EB=A3=B9=EB=B3=84=20?= =?UTF-8?q?=EB=8F=85=EB=A6=BD=20=EC=9E=85=EB=A0=A5=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=A1=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 데이터 구조 변경: 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개 항목 입력 가능 - 각 항목 클릭 → 수정 가능 - 저장 시 모든 입력 항목이 개별 레코드로 저장됨 --- frontend/components/screen/ScreenDesigner.tsx | 9 - .../screen/templates/NumberingRuleTemplate.ts | 1 + .../AutocompleteSearchInputRenderer.tsx | 36 +- .../EntitySearchInputRenderer.tsx | 36 +- frontend/lib/registry/components/index.ts | 6 +- .../ModalRepeaterTableRenderer.tsx | 36 +- .../selected-items-detail-input/README.md | 2 +- .../SelectedItemsDetailInputComponent.tsx | 1020 +++++++++++++++-- .../SelectedItemsDetailInputConfigPanel.tsx | 178 ++- .../selected-items-detail-input/types.ts | 53 + frontend/lib/utils/buttonActions.ts | 135 +++ 동적_테이블_접근_시스템_개선_완료.md | 1 + 12 files changed, 1366 insertions(+), 147 deletions(-) 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 { + From 3d74b9deb2f3fdf160d03ead5ae40747d8c96c33 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 18 Nov 2025 10:00:56 +0900 Subject: [PATCH 02/10] =?UTF-8?q?fix:=20=EC=84=A0=ED=83=9D=ED=95=AD?= =?UTF-8?q?=EB=AA=A9=20=EC=83=81=EC=84=B8=EC=9E=85=EB=A0=A5=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=9E=85=EB=A0=A5=20=EC=8B=9C=20?= =?UTF-8?q?=ED=8F=AC=EC=BB=A4=EC=8A=A4=20=EC=9C=A0=EC=A7=80=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 입력 중 onFormDataChange 호출 제거하여 불필요한 리렌더링 방지 - 저장 버튼 클릭 시에만 데이터 전달하도록 변경 (beforeFormSave 이벤트) - handleSave에서 beforeFormSave 이벤트 발생 및 100ms 대기 - 원본 데이터 표시 버그 수정 (modalData 중첩 구조 처리) - fieldGroups 구조 감지 로직 수정 (details → fieldGroups) 이제 사용자가 타이핑할 때 포커스가 유지됩니다. --- .../SelectedItemsDetailInputComponent.tsx | 29 ++++++++++++------- frontend/lib/utils/buttonActions.ts | 14 ++++++--- 2 files changed, 29 insertions(+), 14 deletions(-) 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 06c105cd..b50c32fe 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx @@ -220,6 +220,24 @@ export const SelectedItemsDetailInputComponent: React.FC { + const handleSaveRequest = () => { + if (items.length > 0 && onFormDataChange) { + const dataToSave = { [component.id || "selected_items"]: items }; + console.log("📝 [SelectedItemsDetailInput] 저장 요청 시 데이터 전달:", dataToSave); + onFormDataChange(dataToSave); + } + }; + + // 저장 버튼 클릭 시 데이터 수집 + window.addEventListener("beforeFormSave", handleSaveRequest); + + return () => { + window.removeEventListener("beforeFormSave", handleSaveRequest); + }; + }, [items, component.id, onFormDataChange]); + // 스타일 계산 const componentStyle: React.CSSProperties = { width: "100%", @@ -281,18 +299,9 @@ export const SelectedItemsDetailInputComponent: React.FC { - if (onFormDataChange) { - const dataToSave = { [component.id || "selected_items"]: updatedItems }; - console.log("📝 [SelectedItemsDetailInput] formData 업데이트:", dataToSave); - onFormDataChange(dataToSave); - } - }, 0); - return updatedItems; }); - }, [component.id, onFormDataChange]); + }, []); // 🆕 품목 제거 핸들러 const handleRemoveItem = (itemId: string) => { diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 9ac4426d..1d39ce91 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -200,10 +200,16 @@ export class ButtonActionExecutor { 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; + // 🆕 저장 전 이벤트 발생 (SelectedItemsDetailInput 등에서 최신 데이터 수집) + window.dispatchEvent(new CustomEvent("beforeFormSave")); + + // 약간의 대기 시간을 주어 이벤트 핸들러가 formData를 업데이트할 수 있도록 함 + await new Promise(resolve => setTimeout(resolve, 100)); + + // 🆕 SelectedItemsDetailInput 배치 저장 처리 (fieldGroups 구조) + const selectedItemsKeys = Object.keys(context.formData).filter(key => { + const value = context.formData[key]; + return Array.isArray(value) && value.length > 0 && value[0]?.originalData && value[0]?.fieldGroups; }); if (selectedItemsKeys.length > 0) { From cddce40f35c8fd6f6c25cdbfed6fa0866cb6b062 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 18 Nov 2025 10:02:27 +0900 Subject: [PATCH 03/10] =?UTF-8?q?fix:=20=EC=B2=AB=20=EA=B8=80=EC=9E=90=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=EC=8B=9C=20=ED=8F=AC=EC=BB=A4=EC=8A=A4=20?= =?UTF-8?q?=EC=9C=A0=EC=A7=80=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - handleAddGroupEntry: + 추가 버튼 클릭 시 미리 빈 entry를 배열에 추가 - handleFieldChange: 기존 entry 업데이트만 수행 (새로운 entry 추가 로직 제거) - 이제 첫 글자 입력 시에도 배열 길이가 변하지 않아 포커스가 유지됨 --- .../SelectedItemsDetailInputComponent.tsx | 40 +++++++++++-------- 1 file changed, 24 insertions(+), 16 deletions(-) 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 b50c32fe..a70d8e74 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx @@ -263,14 +263,14 @@ export const SelectedItemsDetailInputComponent: React.FC { setItems((prevItems) => { - const updatedItems = prevItems.map((item) => { + return 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 업데이트 + // 기존 entry 업데이트 (항상 이 경로로만 진입) const updatedEntries = [...groupEntries]; updatedEntries[existingEntryIndex] = { ...updatedEntries[existingEntryIndex], @@ -284,22 +284,11 @@ export const SelectedItemsDetailInputComponent: React.FC { const newEntryId = `entry-${Date.now()}`; + + // 🔧 미리 빈 entry를 추가하여 리렌더링 방지 + setItems((prevItems) => { + return prevItems.map((item) => { + if (item.id !== itemId) return item; + + const groupEntries = item.fieldGroups[groupId] || []; + const newEntry: GroupEntry = { id: newEntryId }; + + return { + ...item, + fieldGroups: { + ...item.fieldGroups, + [groupId]: [...groupEntries, newEntry], + }, + }; + }); + }); + setIsEditing(true); setEditingItemId(itemId); setEditingDetailId(newEntryId); From e234f885772e056de7be91e9cfddbb6f2b2562f0 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 18 Nov 2025 10:14:31 +0900 Subject: [PATCH 04/10] =?UTF-8?q?feat:=20=ED=95=AD=EB=AA=A9=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=20=EC=84=A4=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(=EA=B8=B0=EB=B3=B8=EA=B0=92,=20=EB=B9=88=20?= =?UTF-8?q?=EA=B0=92=20=EC=B2=98=EB=A6=AC=20=ED=8F=AC=ED=95=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DisplayItem 타입 추가 (icon, field, text, badge) - 필드별 표시 형식 지원 (text, currency, number, date, badge) - 빈 값 처리 옵션 추가 (hide, default, blank) - 기본값 설정 기능 추가 - 스타일 옵션 추가 (굵게, 밑줄, 기울임, 색상) - renderDisplayItems 헬퍼 함수로 유연한 표시 렌더링 - SelectedItemsDetailInputConfigPanel에 항목 표시 설정 UI 추가 - displayItems가 없으면 기존 방식(모든 필드 나열)으로 폴백 --- .../SelectedItemsDetailInputComponent.tsx | 160 +++++++++- .../SelectedItemsDetailInputConfigPanel.tsx | 286 +++++++++++++++++- .../selected-items-detail-input/types.ts | 68 +++++ 3 files changed, 510 insertions(+), 4 deletions(-) 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 a70d8e74..da16a349 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx @@ -3,16 +3,18 @@ import React, { useState, useEffect, useMemo, useCallback } from "react"; import { useSearchParams } from "next/navigation"; import { ComponentRendererProps } from "@/types/component"; -import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition, ItemData, GroupEntry } from "./types"; +import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition, ItemData, GroupEntry, DisplayItem } from "./types"; import { useModalDataStore, ModalDataItem } from "@/stores/modalDataStore"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Checkbox } from "@/components/ui/checkbox"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { X } from "lucide-react"; +import * as LucideIcons from "lucide-react"; import { commonCodeApi } from "@/lib/api/commonCode"; import { cn } from "@/lib/utils"; @@ -542,6 +544,158 @@ export const SelectedItemsDetailInputComponent: React.FC { + const displayItems = componentConfig.displayItems || []; + + if (displayItems.length === 0) { + // displayItems가 없으면 기본 방식 (모든 필드 나열) + const fields = componentConfig.additionalFields || []; + return fields.map((f) => entry[f.name] || "-").join(" / "); + } + + // displayItems 설정대로 렌더링 + return ( + <> + {displayItems.map((displayItem) => { + const styleClasses = cn( + displayItem.bold && "font-bold", + displayItem.underline && "underline", + displayItem.italic && "italic" + ); + + const inlineStyle: React.CSSProperties = { + color: displayItem.color, + backgroundColor: displayItem.backgroundColor, + }; + + switch (displayItem.type) { + case "icon": { + if (!displayItem.icon) return null; + const IconComponent = (LucideIcons as any)[displayItem.icon]; + if (!IconComponent) return null; + return ( + + ); + } + + case "text": + return ( + + {displayItem.value} + + ); + + case "field": { + const fieldValue = entry[displayItem.fieldName || ""]; + const isEmpty = fieldValue === null || fieldValue === undefined || fieldValue === ""; + + // 🆕 빈 값 처리 + if (isEmpty) { + switch (displayItem.emptyBehavior) { + case "hide": + return null; // 항목 숨김 + case "default": + // 기본값 표시 + const defaultValue = displayItem.defaultValue || "-"; + return ( + + {displayItem.label}{defaultValue} + + ); + case "blank": + default: + // 빈 칸으로 표시 + return ( + + {displayItem.label} + + ); + } + } + + // 값이 있는 경우, 형식에 맞게 표시 + let formattedValue = fieldValue; + switch (displayItem.format) { + case "currency": + // 천 단위 구분 + formattedValue = new Intl.NumberFormat("ko-KR").format(Number(fieldValue) || 0); + break; + case "number": + formattedValue = new Intl.NumberFormat("ko-KR").format(Number(fieldValue) || 0); + break; + case "date": + // YYYY.MM.DD 형식 + if (fieldValue) { + const date = new Date(fieldValue); + if (!isNaN(date.getTime())) { + formattedValue = date.toLocaleDateString("ko-KR", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }).replace(/\. /g, ".").replace(/\.$/, ""); + } + } + break; + case "badge": + // 배지로 표시 + return ( + + {displayItem.label}{formattedValue} + + ); + case "text": + default: + // 일반 텍스트 + break; + } + + return ( + + {displayItem.label}{formattedValue} + + ); + } + + case "badge": { + const fieldValue = displayItem.fieldName ? entry[displayItem.fieldName] : displayItem.value; + return ( + + {displayItem.label}{fieldValue} + + ); + } + + default: + return null; + } + })} + + ); + }, [componentConfig.displayItems, componentConfig.additionalFields]); + // 빈 상태 렌더링 if (items.length === 0) { return ( @@ -650,8 +804,8 @@ export const SelectedItemsDetailInputComponent: React.FC handleEditGroupEntry(item.id, group.id, entry.id)} > - - {idx + 1}. {groupFields.map((f) => entry[f.name] || "-").join(" / ")} + + {idx + 1}. {renderDisplayItems(entry, item)}
+ {/* 🆕 항목 표시 설정 */} +
+
+ +
+ + + +
+
+ +

+ 각 입력 항목이 추가되면 어떻게 표시될지 설정합니다 +

+ + {displayItems.length === 0 ? ( +
+ 표시 항목이 없습니다. 위의 버튼으로 추가하세요. +
+ (미설정 시 모든 필드를 " / "로 구분하여 표시) +
+ ) : ( +
+ {displayItems.map((item, index) => ( + + + {/* 헤더 */} +
+ + {item.type === "icon" && "🎨 아이콘"} + {item.type === "field" && "📝 필드"} + {item.type === "text" && "💬 텍스트"} + {item.type === "badge" && "🏷️ 배지"} + + +
+ + {/* 아이콘 설정 */} + {item.type === "icon" && ( +
+
+ + updateDisplayItem(index, { icon: e.target.value })} + placeholder="예: Building, User, Package" + className="h-7 text-xs" + /> +

+ lucide-react 아이콘 이름을 입력하세요 +

+
+
+ )} + + {/* 텍스트 설정 */} + {item.type === "text" && ( +
+ + updateDisplayItem(index, { value: e.target.value })} + placeholder="예: | , / , - " + className="h-7 text-xs" + /> +
+ )} + + {/* 필드 설정 */} + {item.type === "field" && ( +
+ {/* 필드 선택 */} +
+ + +
+ + {/* 라벨 */} +
+ + updateDisplayItem(index, { label: e.target.value })} + placeholder="예: 거래처:, 단가:" + className="h-7 text-xs" + /> +
+ + {/* 표시 형식 */} +
+ + +
+ + {/* 빈 값 처리 */} +
+ + +
+ + {/* 기본값 (emptyBehavior가 "default"일 때만) */} + {item.emptyBehavior === "default" && ( +
+ + updateDisplayItem(index, { defaultValue: e.target.value })} + placeholder="예: 미입력, 0, -" + className="h-7 text-xs" + /> +
+ )} +
+ )} + + {/* 스타일 설정 */} +
+ +
+ + + +
+ + {/* 색상 */} +
+
+ + updateDisplayItem(index, { color: e.target.value })} + className="h-7 w-full" + /> +
+
+ + updateDisplayItem(index, { backgroundColor: e.target.value })} + className="h-7 w-full" + /> +
+
+
+
+
+ ))} +
+ )} +
+ {/* 사용 예시 */}

💡 사용 예시

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 c8a67a62..6685fc50 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/types.ts +++ b/frontend/lib/registry/components/selected-items-detail-input/types.ts @@ -115,6 +115,12 @@ export interface SelectedItemsDetailInputConfig extends ComponentConfig { */ inputMode?: "inline" | "modal"; + /** + * 🆕 항목 표시 설정 (각 그룹의 입력 항목을 어떻게 표시할지) + * 예: [{ type: "icon", icon: "Building" }, { type: "field", fieldName: "customer_name", label: "거래처:" }] + */ + displayItems?: DisplayItem[]; + /** * 빈 상태 메시지 */ @@ -135,6 +141,68 @@ export interface GroupEntry { [key: string]: any; } +/** + * 🆕 표시 항목 타입 + */ +export type DisplayItemType = "icon" | "field" | "text" | "badge"; + +/** + * 🆕 빈 값 처리 방식 + */ +export type EmptyBehavior = "hide" | "default" | "blank"; + +/** + * 🆕 필드 표시 형식 + */ +export type DisplayFieldFormat = "text" | "date" | "currency" | "number" | "badge"; + +/** + * 🆕 표시 항목 정의 (아이콘, 필드, 텍스트, 배지) + */ +export interface DisplayItem { + /** 항목 타입 */ + type: DisplayItemType; + + /** 고유 ID */ + id: string; + + // === type: "field" 인 경우 === + /** 필드명 (컬럼명) */ + fieldName?: string; + /** 라벨 (예: "거래처:", "단가:") */ + label?: string; + /** 표시 형식 */ + format?: DisplayFieldFormat; + /** 빈 값일 때 동작 */ + emptyBehavior?: EmptyBehavior; + /** 기본값 (빈 값일 때 표시) */ + defaultValue?: string; + + // === type: "icon" 인 경우 === + /** 아이콘 이름 (lucide-react 아이콘명) */ + icon?: string; + + // === type: "text" 인 경우 === + /** 텍스트 내용 */ + value?: string; + + // === type: "badge" 인 경우 === + /** 배지 스타일 */ + badgeVariant?: "default" | "secondary" | "destructive" | "outline"; + + // === 공통 스타일 === + /** 굵게 표시 */ + bold?: boolean; + /** 밑줄 표시 */ + underline?: boolean; + /** 기울임 표시 */ + italic?: boolean; + /** 텍스트 색상 */ + color?: string; + /** 배경 색상 */ + backgroundColor?: string; +} + /** * 🆕 품목 + 그룹별 여러 입력 항목 * 각 필드 그룹마다 독립적으로 여러 개의 입력을 추가할 수 있음 From eef1451c5a585885780d80dfc001a9d3c18a008a Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 18 Nov 2025 10:21:36 +0900 Subject: [PATCH 05/10] =?UTF-8?q?fix:=20=ED=95=AD=EB=AA=A9=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=20=EC=84=A4=EC=A0=95=EC=9D=84=20=EA=B7=B8=EB=A3=B9?= =?UTF-8?q?=EB=B3=84=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FieldGroup에 displayItems 추가 (그룹별 독립적인 표시 설정) - SelectedItemsDetailInputConfig에서 전역 displayItems 제거 - renderDisplayItems에 groupId 파라미터 추가하여 그룹별 설정 사용 - 설정 패널에서 그룹별로 displayItems 관리 - 각 그룹마다 다른 표시 형식 가능 (예: 거래처 정보 vs 단가 정보) - 그룹의 필드만 선택 가능하도록 필터링 --- .../SelectedItemsDetailInputComponent.tsx | 20 +- .../SelectedItemsDetailInputConfigPanel.tsx | 477 ++++++++---------- .../selected-items-detail-input/types.ts | 8 +- 3 files changed, 228 insertions(+), 277 deletions(-) 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 da16a349..4bdb1a2e 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx @@ -544,13 +544,19 @@ export const SelectedItemsDetailInputComponent: React.FC { - const displayItems = componentConfig.displayItems || []; + // 🆕 displayItems를 렌더링하는 헬퍼 함수 (그룹별) + const renderDisplayItems = useCallback((entry: GroupEntry, item: ItemData, groupId: string) => { + // 🆕 해당 그룹의 displayItems 가져오기 + const group = (componentConfig.fieldGroups || []).find(g => g.id === groupId); + const displayItems = group?.displayItems || []; if (displayItems.length === 0) { - // displayItems가 없으면 기본 방식 (모든 필드 나열) - const fields = componentConfig.additionalFields || []; + // displayItems가 없으면 기본 방식 (해당 그룹의 필드만 나열) + const fields = (componentConfig.additionalFields || []).filter(f => + componentConfig.fieldGroups && componentConfig.fieldGroups.length > 0 + ? f.groupId === groupId + : true + ); return fields.map((f) => entry[f.name] || "-").join(" / "); } @@ -694,7 +700,7 @@ export const SelectedItemsDetailInputComponent: React.FC ); - }, [componentConfig.displayItems, componentConfig.additionalFields]); + }, [componentConfig.fieldGroups, componentConfig.additionalFields]); // 빈 상태 렌더링 if (items.length === 0) { @@ -805,7 +811,7 @@ export const SelectedItemsDetailInputComponent: React.FC handleEditGroupEntry(item.id, group.id, entry.id)} > - {idx + 1}. {renderDisplayItems(entry, item)} + {idx + 1}. {renderDisplayItems(entry, item, group.id)}
+ + {/* 🆕 이 그룹의 항목 표시 설정 */} +
+
+ +
+ + + +
+
+ +

+ 이 그룹의 입력 항목이 추가되면 어떻게 표시될지 설정 +

+ + {(!group.displayItems || group.displayItems.length === 0) ? ( +
+ 미설정 (모든 필드를 " / "로 구분하여 표시) +
+ ) : ( +
+ {group.displayItems.map((item, itemIndex) => ( +
+ {/* 헤더 */} +
+ + {item.type === "icon" && "🎨"} + {item.type === "field" && "📝"} + {item.type === "text" && "💬"} + {item.type === "badge" && "🏷️"} + + +
+ + {/* 아이콘 설정 */} + {item.type === "icon" && ( + updateDisplayItemInGroup(group.id, itemIndex, { icon: e.target.value })} + placeholder="Building" + className="h-6 text-[9px] sm:text-[10px]" + /> + )} + + {/* 텍스트 설정 */} + {item.type === "text" && ( + updateDisplayItemInGroup(group.id, itemIndex, { value: e.target.value })} + placeholder="| , / , -" + className="h-6 text-[9px] sm:text-[10px]" + /> + )} + + {/* 필드 설정 */} + {item.type === "field" && ( +
+ {/* 필드 선택 */} + + + {/* 라벨 */} + updateDisplayItemInGroup(group.id, itemIndex, { label: e.target.value })} + placeholder="거래처:" + className="h-6 text-[9px] sm:text-[10px]" + /> + + {/* 표시 형식 */} + + + {/* 빈 값 처리 */} +
+ + + {/* 기본값 */} + {item.emptyBehavior === "default" && ( + updateDisplayItemInGroup(group.id, itemIndex, { defaultValue: e.target.value })} + placeholder="미입력" + className="h-6 text-[9px] sm:text-[10px]" + /> + )} +
+
+ )} +
+ ))} +
+ )} +
))} @@ -761,252 +956,6 @@ export const SelectedItemsDetailInputConfigPanel: React.FC
- {/* 🆕 항목 표시 설정 */} -
-
- -
- - - -
-
- -

- 각 입력 항목이 추가되면 어떻게 표시될지 설정합니다 -

- - {displayItems.length === 0 ? ( -
- 표시 항목이 없습니다. 위의 버튼으로 추가하세요. -
- (미설정 시 모든 필드를 " / "로 구분하여 표시) -
- ) : ( -
- {displayItems.map((item, index) => ( - - - {/* 헤더 */} -
- - {item.type === "icon" && "🎨 아이콘"} - {item.type === "field" && "📝 필드"} - {item.type === "text" && "💬 텍스트"} - {item.type === "badge" && "🏷️ 배지"} - - -
- - {/* 아이콘 설정 */} - {item.type === "icon" && ( -
-
- - updateDisplayItem(index, { icon: e.target.value })} - placeholder="예: Building, User, Package" - className="h-7 text-xs" - /> -

- lucide-react 아이콘 이름을 입력하세요 -

-
-
- )} - - {/* 텍스트 설정 */} - {item.type === "text" && ( -
- - updateDisplayItem(index, { value: e.target.value })} - placeholder="예: | , / , - " - className="h-7 text-xs" - /> -
- )} - - {/* 필드 설정 */} - {item.type === "field" && ( -
- {/* 필드 선택 */} -
- - -
- - {/* 라벨 */} -
- - updateDisplayItem(index, { label: e.target.value })} - placeholder="예: 거래처:, 단가:" - className="h-7 text-xs" - /> -
- - {/* 표시 형식 */} -
- - -
- - {/* 빈 값 처리 */} -
- - -
- - {/* 기본값 (emptyBehavior가 "default"일 때만) */} - {item.emptyBehavior === "default" && ( -
- - updateDisplayItem(index, { defaultValue: e.target.value })} - placeholder="예: 미입력, 0, -" - className="h-7 text-xs" - /> -
- )} -
- )} - - {/* 스타일 설정 */} -
- -
- - - -
- - {/* 색상 */} -
-
- - updateDisplayItem(index, { color: e.target.value })} - className="h-7 w-full" - /> -
-
- - updateDisplayItem(index, { backgroundColor: e.target.value })} - className="h-7 w-full" - /> -
-
-
-
-
- ))} -
- )} -
- {/* 사용 예시 */}

💡 사용 예시

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 6685fc50..05c11e4a 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/types.ts +++ b/frontend/lib/registry/components/selected-items-detail-input/types.ts @@ -50,6 +50,8 @@ export interface FieldGroup { description?: string; /** 그룹 표시 순서 */ order?: number; + /** 🆕 이 그룹의 항목 표시 설정 (그룹별로 다른 표시 형식 가능) */ + displayItems?: DisplayItem[]; } /** @@ -115,12 +117,6 @@ export interface SelectedItemsDetailInputConfig extends ComponentConfig { */ inputMode?: "inline" | "modal"; - /** - * 🆕 항목 표시 설정 (각 그룹의 입력 항목을 어떻게 표시할지) - * 예: [{ type: "icon", icon: "Building" }, { type: "field", fieldName: "customer_name", label: "거래처:" }] - */ - displayItems?: DisplayItem[]; - /** * 빈 상태 메시지 */ From def94c41f4e450b879086c8302c633555e5d8cff Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 18 Nov 2025 10:25:28 +0900 Subject: [PATCH 06/10] =?UTF-8?q?refactor:=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=ED=8C=A8=EB=84=90=20UI=20=EA=B0=9C=EC=84=A0=20-=20Collapsible?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 필드 그룹을 Collapsible로 변경하여 펼침/접힘 가능 - 항목 표시 설정도 Collapsible로 분리하여 깔끔하게 정리 - 그룹 제목에 displayItems 개수 표시 - 기본적으로 그룹은 펼쳐진 상태, 표시 설정은 접힌 상태 - ChevronDown/ChevronRight 아이콘으로 펼침 상태 표시 - 복잡한 설정을 단계적으로 볼 수 있어 가독성 대폭 향상 --- .../SelectedItemsDetailInputConfigPanel.tsx | 104 +++++++++++++----- 1 file changed, 77 insertions(+), 27 deletions(-) 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 40ddc2c3..a55dc863 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx @@ -7,7 +7,8 @@ import { Button } from "@/components/ui/button"; 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 { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { Plus, X, ChevronDown, ChevronRight } from "lucide-react"; import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition, FieldGroup, DisplayItem, DisplayItemType, EmptyBehavior, DisplayFieldFormat } from "./types"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; @@ -46,6 +47,12 @@ export const SelectedItemsDetailInputConfigPanel: React.FC(config.fieldGroups || []); + // 🆕 그룹별 펼침/접힘 상태 + const [expandedGroups, setExpandedGroups] = useState>({}); + + // 🆕 그룹별 표시 항목 설정 펼침/접힘 상태 + const [expandedDisplayItems, setExpandedDisplayItems] = useState>({}); + // 🆕 원본 테이블 선택 상태 const [sourceTableSelectOpen, setSourceTableSelectOpen] = useState(false); const [sourceTableSearchValue, setSourceTableSearchValue] = useState(""); @@ -621,20 +628,40 @@ export const SelectedItemsDetailInputConfigPanel: React.FC {localFieldGroups.map((group, index) => ( - - -
- 그룹 {index + 1} - -
+ setExpandedGroups(prev => ({ ...prev, [group.id]: open }))} + > + + +
+ + + + +
+ + {/* 기존 그룹 설정 내용 */} {/* 그룹 ID */}
@@ -682,10 +709,27 @@ export const SelectedItemsDetailInputConfigPanel: React.FC {/* 🆕 이 그룹의 항목 표시 설정 */} -
-
- -
+ setExpandedDisplayItems(prev => ({ ...prev, [group.id]: open }))} + > +
+
+ + + +
-

- 이 그룹의 입력 항목이 추가되면 어떻게 표시될지 설정 -

+ +

+ 이 그룹의 입력 항목이 추가되면 어떻게 표시될지 설정 +

- {(!group.displayItems || group.displayItems.length === 0) ? ( + {(!group.displayItems || group.displayItems.length === 0) ? (
미설정 (모든 필드를 " / "로 구분하여 표시)
@@ -846,10 +891,15 @@ export const SelectedItemsDetailInputConfigPanel: React.FC ))}
- )} -
- - + )} + +
+ + + + + + ))}
+ From 967b76591bcd1ece0279d665fec914df8559290a Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 18 Nov 2025 11:08:05 +0900 Subject: [PATCH 09/10] =?UTF-8?q?=ED=94=8C=EB=A0=88=EC=9D=B4=EC=8A=A4?= =?UTF-8?q?=ED=99=80=EB=8D=94=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SelectedItemsDetailInputConfigPanel.tsx | 106 +++++++++--------- 1 file changed, 52 insertions(+), 54 deletions(-) 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 22f4296f..a2b2df51 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx @@ -715,31 +715,33 @@ export const SelectedItemsDetailInputConfigPanel: React.FC setExpandedDisplayItems(prev => ({ ...prev, [group.id]: open }))} > -
-
- - - -
+
+ + + + + + {/* 추가 버튼들 */} +
-
- - +

이 그룹의 입력 항목이 추가되면 어떻게 표시될지 설정

@@ -825,7 +825,7 @@ export const SelectedItemsDetailInputConfigPanel: React.FC updateDisplayItemInGroup(group.id, itemIndex, { fieldName: value })} > - + @@ -841,8 +841,8 @@ export const SelectedItemsDetailInputConfigPanel: React.FC updateDisplayItemInGroup(group.id, itemIndex, { label: e.target.value })} - placeholder="거래처:" - className="h-6 text-[9px] sm:text-[10px]" + placeholder="라벨 (예: 거래처:)" + className="h-6 w-full text-[9px] sm:text-[10px]" /> {/* 표시 형식 */} @@ -850,7 +850,7 @@ export const SelectedItemsDetailInputConfigPanel: React.FC updateDisplayItemInGroup(group.id, itemIndex, { format: value as DisplayFieldFormat })} > - + @@ -863,31 +863,29 @@ export const SelectedItemsDetailInputConfigPanel: React.FC {/* 빈 값 처리 */} -
- + - {/* 기본값 */} - {item.emptyBehavior === "default" && ( - updateDisplayItemInGroup(group.id, itemIndex, { defaultValue: e.target.value })} - placeholder="미입력" - className="h-6 text-[9px] sm:text-[10px]" - /> - )} -
+ {/* 기본값 */} + {item.emptyBehavior === "default" && ( + updateDisplayItemInGroup(group.id, itemIndex, { defaultValue: e.target.value })} + placeholder="미입력" + className="h-6 w-full text-[9px] sm:text-[10px]" + /> + )}
)}
From e1a5befdf72c5c0bfadb0267cf7564748a124313 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 18 Nov 2025 16:12:47 +0900 Subject: [PATCH 10/10] =?UTF-8?q?feat:=20=EA=B8=B0=EA=B0=84=EB=B3=84=20?= =?UTF-8?q?=EB=8B=A8=EA=B0=80=20=EC=84=A4=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20-=20=EC=9E=90=EB=8F=99=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=EC=8B=9C=EC=8A=A4=ED=85=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 선택항목 상세입력 컴포넌트 확장 - 실시간 가격 계산 기능 추가 (할인율/할인금액, 반올림 방식) - 카테고리 값 기반 연산 매핑 시스템 - 3단계 드릴다운 방식 설정 UI (메뉴 → 카테고리 → 값 매핑) - 설정 가능한 계산 로직 - autoCalculation 설정으로 계산 필드명 동적 지정 - valueMapping으로 카테고리 코드와 연산 타입 매핑 - 할인 방식: none/rate/amount - 반올림 방식: none/round/floor/ceil - 반올림 단위: 1/10/100/1000 - UI 개선 - 입력 필드 가로 배치 (반응형 Grid) - 카테고리 타입 필드 옵션 로딩 개선 - 계산 결과 필드 자동 표시 및 읽기 전용 처리 - 날짜 입력 필드 네이티브 피커 지원 - API 연동 - 2레벨 메뉴 목록 조회 - 메뉴별 카테고리 컬럼 조회 - 카테고리별 값 목록 조회 - 문서화 - 기간별 단가 설정 가이드 작성 --- .../controllers/tableManagementController.ts | 106 +-- docs/기간별_단가_설정_가이드.md | 382 +++++++++++ frontend/components/common/ScreenModal.tsx | 107 +-- .../config-panels/ButtonConfigPanel.tsx | 449 ++++++++++++- .../SelectedItemsDetailInputComponent.tsx | 283 ++++++-- .../SelectedItemsDetailInputConfigPanel.tsx | 627 +++++++++++++++++- .../selected-items-detail-input/types.ts | 43 ++ .../SplitPanelLayoutComponent.tsx | 11 +- frontend/lib/utils/buttonActions.ts | 141 +++- frontend/types/unified-core.ts | 3 +- 10 files changed, 1966 insertions(+), 186 deletions(-) create mode 100644 docs/기간별_단가_설정_가이드.md diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index beade4e6..f552124f 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -1604,10 +1604,14 @@ export async function toggleLogTable( } /** - * 메뉴의 형제 메뉴들이 사용하는 모든 테이블의 카테고리 타입 컬럼 조회 + * 메뉴의 상위 메뉴들이 설정한 모든 카테고리 타입 컬럼 조회 (계층 구조 상속) * * @route GET /api/table-management/menu/:menuObjid/category-columns - * @description 형제 메뉴들의 화면에서 사용하는 테이블의 input_type='category' 컬럼 조회 + * @description 현재 메뉴와 상위 메뉴들에서 설정한 category_column_mapping의 모든 카테고리 컬럼 조회 + * + * 예시: + * - 2레벨 메뉴 "고객사관리"에서 discount_type, rounding_type 설정 + * - 3레벨 메뉴 "고객등록", "고객조회" 등에서도 동일하게 보임 (상속) */ export async function getCategoryColumnsByMenu( req: AuthenticatedRequest, @@ -1627,40 +1631,10 @@ export async function getCategoryColumnsByMenu( return; } - // 1. 형제 메뉴 조회 - const { getSiblingMenuObjids } = await import("../services/menuService"); - const siblingObjids = await getSiblingMenuObjids(parseInt(menuObjid)); - - logger.info("✅ 형제 메뉴 조회 완료", { siblingObjids }); - - // 2. 형제 메뉴들이 사용하는 테이블 조회 const { getPool } = await import("../database/db"); const pool = getPool(); - - const tablesQuery = ` - SELECT DISTINCT sd.table_name - FROM screen_menu_assignments sma - INNER JOIN screen_definitions sd ON sma.screen_id = sd.screen_id - WHERE sma.menu_objid = ANY($1) - AND sma.company_code = $2 - AND sd.table_name IS NOT NULL - `; - - const tablesResult = await pool.query(tablesQuery, [siblingObjids, companyCode]); - const tableNames = tablesResult.rows.map((row: any) => row.table_name); - - logger.info("✅ 형제 메뉴 테이블 조회 완료", { tableNames, count: tableNames.length }); - if (tableNames.length === 0) { - res.json({ - success: true, - data: [], - message: "형제 메뉴에 연결된 테이블이 없습니다.", - }); - return; - } - - // 3. category_column_mapping 테이블 존재 여부 확인 + // 1. category_column_mapping 테이블 존재 여부 확인 const tableExistsResult = await pool.query(` SELECT EXISTS ( SELECT FROM information_schema.tables @@ -1672,33 +1646,42 @@ export async function getCategoryColumnsByMenu( let columnsResult; if (mappingTableExists) { - // 🆕 category_column_mapping을 사용한 필터링 - logger.info("🔍 category_column_mapping 기반 카테고리 컬럼 조회", { menuObjid, companyCode }); + // 🆕 category_column_mapping을 사용한 계층 구조 기반 조회 + logger.info("🔍 category_column_mapping 기반 카테고리 컬럼 조회 (계층 구조 상속)", { menuObjid, companyCode }); // 현재 메뉴와 모든 상위 메뉴의 objid 조회 (재귀) const ancestorMenuQuery = ` WITH RECURSIVE menu_hierarchy AS ( -- 현재 메뉴 - SELECT objid, parent_obj_id, menu_type + SELECT objid, parent_obj_id, menu_type, menu_name_kor FROM menu_info WHERE objid = $1 UNION ALL -- 부모 메뉴 재귀 조회 - SELECT m.objid, m.parent_obj_id, m.menu_type + SELECT m.objid, m.parent_obj_id, m.menu_type, m.menu_name_kor FROM menu_info m INNER JOIN menu_hierarchy mh ON m.objid = mh.parent_obj_id WHERE m.parent_obj_id != 0 -- 최상위 메뉴(parent_obj_id=0) 제외 ) - SELECT ARRAY_AGG(objid) as menu_objids + SELECT + ARRAY_AGG(objid) as menu_objids, + ARRAY_AGG(menu_name_kor) as menu_names FROM menu_hierarchy `; const ancestorMenuResult = await pool.query(ancestorMenuQuery, [parseInt(menuObjid)]); const ancestorMenuObjids = ancestorMenuResult.rows[0]?.menu_objids || [parseInt(menuObjid)]; + const ancestorMenuNames = ancestorMenuResult.rows[0]?.menu_names || []; + logger.info("✅ 상위 메뉴 계층 조회 완료", { + ancestorMenuObjids, + ancestorMenuNames, + hierarchyDepth: ancestorMenuObjids.length + }); + // 상위 메뉴들에 설정된 모든 카테고리 컬럼 조회 (테이블 필터링 제거) const columnsQuery = ` SELECT DISTINCT ttc.table_name AS "tableName", @@ -1711,7 +1694,8 @@ export async function getCategoryColumnsByMenu( cl.column_label, initcap(replace(ccm.logical_column_name, '_', ' ')) ) AS "columnLabel", - ttc.input_type AS "inputType" + ttc.input_type AS "inputType", + ccm.menu_objid AS "definedAtMenuObjid" FROM category_column_mapping ccm INNER JOIN table_type_columns ttc ON ccm.table_name = ttc.table_name @@ -1721,18 +1705,48 @@ export async function getCategoryColumnsByMenu( AND ttc.column_name = cl.column_name LEFT JOIN table_labels tl ON ttc.table_name = tl.table_name - WHERE ccm.table_name = ANY($1) - AND ccm.company_code = $2 - AND ccm.menu_objid = ANY($3) + WHERE ccm.company_code = $1 + AND ccm.menu_objid = ANY($2) AND ttc.input_type = 'category' ORDER BY ttc.table_name, ccm.logical_column_name `; - columnsResult = await pool.query(columnsQuery, [tableNames, companyCode, ancestorMenuObjids]); - logger.info("✅ category_column_mapping 기반 조회 완료", { rowCount: columnsResult.rows.length }); + columnsResult = await pool.query(columnsQuery, [companyCode, ancestorMenuObjids]); + logger.info("✅ category_column_mapping 기반 조회 완료 (계층 구조 상속)", { + rowCount: columnsResult.rows.length, + columns: columnsResult.rows.map((r: any) => `${r.tableName}.${r.columnName}`) + }); } else { - // 🔄 기존 방식: table_type_columns에서 모든 카테고리 컬럼 조회 - logger.info("🔍 레거시 방식: table_type_columns 기반 카테고리 컬럼 조회", { tableNames, companyCode }); + // 🔄 레거시 방식: 형제 메뉴들의 테이블에서 모든 카테고리 컬럼 조회 + logger.info("🔍 레거시 방식: 형제 메뉴 테이블 기반 카테고리 컬럼 조회", { menuObjid, companyCode }); + + // 형제 메뉴 조회 + const { getSiblingMenuObjids } = await import("../services/menuService"); + const siblingObjids = await getSiblingMenuObjids(parseInt(menuObjid)); + + // 형제 메뉴들이 사용하는 테이블 조회 + const tablesQuery = ` + SELECT DISTINCT sd.table_name + FROM screen_menu_assignments sma + INNER JOIN screen_definitions sd ON sma.screen_id = sd.screen_id + WHERE sma.menu_objid = ANY($1) + AND sma.company_code = $2 + AND sd.table_name IS NOT NULL + `; + + const tablesResult = await pool.query(tablesQuery, [siblingObjids, companyCode]); + const tableNames = tablesResult.rows.map((row: any) => row.table_name); + + logger.info("✅ 형제 메뉴 테이블 조회 완료", { tableNames, count: tableNames.length }); + + if (tableNames.length === 0) { + res.json({ + success: true, + data: [], + message: "형제 메뉴에 연결된 테이블이 없습니다.", + }); + return; + } const columnsQuery = ` SELECT diff --git a/docs/기간별_단가_설정_가이드.md b/docs/기간별_단가_설정_가이드.md new file mode 100644 index 00000000..67bed5f9 --- /dev/null +++ b/docs/기간별_단가_설정_가이드.md @@ -0,0 +1,382 @@ +# 기간별 단가 설정 시스템 구현 가이드 + +## 개요 + +**선택항목 상세입력(selected-items-detail-input)** 컴포넌트를 활용하여 기간별 단가를 설정하는 범용 시스템입니다. + +## 데이터베이스 설계 + +### 1. 마이그레이션 실행 + +```bash +# 마이그레이션 파일 위치 +db/migrations/999_add_period_price_columns_to_customer_item_mapping.sql + +# 실행 (로컬) +npm run migrate:local + +# 또는 수동 실행 +psql -U your_user -d erp_db -f db/migrations/999_add_period_price_columns_to_customer_item_mapping.sql +``` + +### 2. 추가된 컬럼들 + +| 컬럼명 | 타입 | 설명 | 사진 항목 | +|--------|------|------|-----------| +| `start_date` | DATE | 기간 시작일 | ✅ 시작일 DatePicker | +| `end_date` | DATE | 기간 종료일 | ✅ 종료일 DatePicker | +| `discount_type` | VARCHAR(50) | 할인 방식 | ✅ 할인율/할인금액 Select | +| `discount_value` | NUMERIC(15,2) | 할인율 또는 할인금액 | ✅ 숫자 입력 | +| `rounding_type` | VARCHAR(50) | 반올림 방식 | ✅ 반올림/절삭/올림 Select | +| `rounding_unit_value` | VARCHAR(50) | 반올림 단위 | ✅ 1원/10원/100원/1,000원 Select | +| `calculated_price` | NUMERIC(15,2) | 계산된 최종 단가 | ✅ 계산 결과 표시 | +| `is_base_price` | BOOLEAN | 기준단가 여부 | ✅ 기준단가 Checkbox | + +## 화면 편집기 설정 방법 + +### Step 1: 선택항목 상세입력 컴포넌트 추가 + +1. 화면 편집기에서 "선택항목 상세입력" 컴포넌트를 캔버스에 드래그앤드롭 +2. 컴포넌트 ID: `customer-item-price-periods` + +### Step 2: 데이터 소스 설정 + +- **원본 데이터 테이블**: `item_info` (품목 정보) +- **저장 대상 테이블**: `customer_item_mapping` +- **데이터 소스 ID**: URL 파라미터에서 자동 설정 (Button 컴포넌트가 전달) + +### Step 3: 표시할 원본 데이터 컬럼 설정 + +이전 화면(품목 선택 모달)에서 전달받은 품목 정보를 표시: + +``` +컬럼1: item_code (품목코드) +컬럼2: item_name (품목명) +컬럼3: spec (규격) +``` + +### Step 4: 필드 그룹 2개 생성 + +#### 그룹 1: 거래처 품목/품명 관리 (group_customer) + +| 필드명 | 라벨 | 타입 | 설명 | +|--------|------|------|------| +| `customer_item_code` | 거래처 품번 | text | 거래처에서 사용하는 품번 | +| `customer_item_name` | 거래처 품명 | text | 거래처에서 사용하는 품명 | + +#### 그룹 2: 기간별 단가 설정 (group_period_price) + +| 필드명 | 라벨 | 타입 | 자동 채우기 | 설명 | +|--------|------|------|-------------|------| +| `start_date` | 시작일 | date | - | 단가 적용 시작일 | +| `end_date` | 종료일 | date | - | 단가 적용 종료일 (NULL이면 무기한) | +| `current_unit_price` | 단가 | number | `item_info.standard_price` | 기본 단가 (품목에서 자동 채우기) | +| `currency_code` | 통화 | code/category | - | 통화 코드 (KRW, USD 등) | +| `discount_type` | 할인 방식 | code/category | - | 할인율없음/할인율(%)/할인금액 | +| `discount_value` | 할인값 | number | - | 할인율(5) 또는 할인금액 | +| `rounding_type` | 반올림 방식 | code/category | - | 반올림없음/반올림/절삭/올림 | +| `rounding_unit_value` | 반올림 단위 | code/category | - | 1원/10원/100원/1,000원 | +| `calculated_price` | 최종 단가 | number | - | 계산된 최종 단가 (읽기 전용) | +| `is_base_price` | 기준단가 | checkbox | - | 기준단가 여부 | + +### Step 5: 그룹별 표시 항목 설정 (DisplayItems) + +**그룹 2 (기간별 단가 설정)의 표시 설정:** + +``` +1. [필드] start_date | 라벨: "" | 형식: date | 빈 값: 기본값 (미설정) +2. [텍스트] " ~ " +3. [필드] end_date | 라벨: "" | 형식: date | 빈 값: 기본값 (무기한) +4. [텍스트] " | " +5. [필드] calculated_price | 라벨: "" | 형식: currency | 빈 값: 기본값 (계산 중) +6. [텍스트] " " +7. [필드] currency_code | 라벨: "" | 형식: text | 빈 값: 기본값 (KRW) +8. [조건] is_base_price가 true이면 → [배지] "기준단가" (variant: default) +``` + +**렌더링 예시:** +``` +2024-01-01 ~ 2024-06-30 | 50,000 KRW [기준단가] +2024-07-01 ~ 무기한 | 55,000 KRW +``` + +## 데이터 흐름 + +### 1. 품목 선택 모달 (이전 화면) + +```tsx +// TableList 컴포넌트에서 품목 선택 + +``` + +### 2. 기간별 단가 설정 화면 + +```tsx +// 선택항목 상세입력 컴포넌트가 자동으로 처리 +// 1. URL 파라미터에서 dataSourceId 읽기 +// 2. modalDataStore에서 item_info 데이터 가져오기 +// 3. 사용자가 그룹별로 여러 개의 기간별 단가 입력 +// 4. 저장 버튼 클릭 시 customer_item_mapping 테이블에 저장 +``` + +### 3. 저장 데이터 구조 + +**하나의 품목(item_id = "ITEM001")에 대해 3개의 기간별 단가를 입력한 경우:** + +```sql +-- customer_item_mapping 테이블에 3개의 행으로 저장 +INSERT INTO customer_item_mapping ( + customer_id, item_id, + customer_item_code, customer_item_name, + start_date, end_date, + current_unit_price, currency_code, + discount_type, discount_value, + rounding_type, rounding_unit_value, + calculated_price, is_base_price +) VALUES +-- 첫 번째 기간 (기준단가) +('CUST001', 'ITEM001', + 'CUST-A-001', '실리콘 고무 시트', + '2024-01-01', '2024-06-30', + 50000, 'KRW', + '할인율없음', 0, + '반올림', '100원', + 50000, true), + +-- 두 번째 기간 +('CUST001', 'ITEM001', + 'CUST-A-001', '실리콘 고무 시트', + '2024-07-01', '2024-12-31', + 50000, 'KRW', + '할인율(%)', 5, + '절삭', '1원', + 47500, false), + +-- 세 번째 기간 (무기한) +('CUST001', 'ITEM001', + 'CUST-A-001', '실리콘 고무 시트', + '2025-01-01', NULL, + 50000, 'KRW', + '할인금액', 3000, + '올림', '1000원', + 47000, false); +``` + +## 계산 로직 (선택사항) + +단가 계산을 자동화하려면 프론트엔드에서 `calculated_price`를 자동 계산: + +```typescript +const calculatePrice = ( + basePrice: number, + discountType: string, + discountValue: number, + roundingType: string, + roundingUnit: string +): number => { + let price = basePrice; + + // 1단계: 할인 적용 + if (discountType === "할인율(%)") { + price = price * (1 - discountValue / 100); + } else if (discountType === "할인금액") { + price = price - discountValue; + } + + // 2단계: 반올림 적용 + const unitMap: Record = { + "1원": 1, + "10원": 10, + "100원": 100, + "1,000원": 1000, + }; + + const unit = unitMap[roundingUnit] || 1; + + if (roundingType === "반올림") { + price = Math.round(price / unit) * unit; + } else if (roundingType === "절삭") { + price = Math.floor(price / unit) * unit; + } else if (roundingType === "올림") { + price = Math.ceil(price / unit) * unit; + } + + return price; +}; + +// 필드 변경 시 자동 계산 +useEffect(() => { + const calculatedPrice = calculatePrice( + basePrice, + discountType, + discountValue, + roundingType, + roundingUnit + ); + + // calculated_price 필드 업데이트 + handleFieldChange(itemId, groupId, entryId, "calculated_price", calculatedPrice); +}, [basePrice, discountType, discountValue, roundingType, roundingUnit]); +``` + +## 백엔드 API 구현 (필요시) + +### 기간별 단가 조회 + +```typescript +// GET /api/customer-item/price-periods?customer_id=CUST001&item_id=ITEM001 +router.get("/price-periods", async (req, res) => { + const { customer_id, item_id } = req.query; + const companyCode = req.user!.companyCode; + + const query = ` + SELECT * FROM customer_item_mapping + WHERE customer_id = $1 + AND item_id = $2 + AND company_code = $3 + ORDER BY start_date ASC + `; + + const result = await pool.query(query, [customer_id, item_id, companyCode]); + + return res.json({ success: true, data: result.rows }); +}); +``` + +### 기간별 단가 저장 + +```typescript +// POST /api/customer-item/price-periods +router.post("/price-periods", async (req, res) => { + const { items } = req.body; // 선택항목 상세입력 컴포넌트에서 전달 + const companyCode = req.user!.companyCode; + + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + for (const item of items) { + // item.fieldGroups.group_period_price 배열의 각 항목을 INSERT + const periodPrices = item.fieldGroups.group_period_price || []; + + for (const periodPrice of periodPrices) { + const query = ` + INSERT INTO customer_item_mapping ( + company_code, customer_id, item_id, + customer_item_code, customer_item_name, + start_date, end_date, + current_unit_price, currency_code, + discount_type, discount_value, + rounding_type, rounding_unit_value, + calculated_price, is_base_price + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + `; + + await client.query(query, [ + companyCode, + item.originalData.customer_id, + item.originalData.item_id, + periodPrice.customer_item_code, + periodPrice.customer_item_name, + periodPrice.start_date, + periodPrice.end_date || null, + periodPrice.current_unit_price, + periodPrice.currency_code, + periodPrice.discount_type, + periodPrice.discount_value, + periodPrice.rounding_type, + periodPrice.rounding_unit_value, + periodPrice.calculated_price, + periodPrice.is_base_price + ]); + } + } + + await client.query("COMMIT"); + + return res.json({ success: true, message: "기간별 단가가 저장되었습니다." }); + } catch (error) { + await client.query("ROLLBACK"); + console.error("기간별 단가 저장 실패:", error); + return res.status(500).json({ success: false, error: "저장 실패" }); + } finally { + client.release(); + } +}); +``` + +## 사용 시나리오 예시 + +### 시나리오 1: 거래처별 단가 관리 + +1. 거래처 선택 모달 → 거래처 선택 → 다음 +2. 품목 선택 모달 → 품목 여러 개 선택 → 다음 +3. **기간별 단가 설정 화면** + - 품목1 (실리콘 고무 시트) + - **그룹1 추가**: 거래처 품번: CUST-A-001, 품명: 실리콘 시트 + - **그룹2 추가**: 2024-01-01 ~ 2024-06-30, 50,000원 (기준단가) + - **그룹2 추가**: 2024-07-01 ~ 무기한, 할인율 5% → 47,500원 + - 품목2 (스테인리스 판) + - **그룹1 추가**: 거래처 품번: CUST-A-002, 품명: SUS304 판 + - **그룹2 추가**: 2024-01-01 ~ 무기한, 150,000원 (기준단가) +4. 저장 버튼 클릭 → customer_item_mapping 테이블에 4개 행 저장 + +### 시나리오 2: 단순 단가 입력 + +필드 그룹을 사용하지 않고 단일 입력도 가능: + +``` +그룹 없이 필드 정의: +- customer_item_code +- customer_item_name +- current_unit_price +- currency_code + +→ 각 품목당 1개의 행만 저장 +``` + +## 장점 + +### 1. 범용성 +- 기간별 단가뿐만 아니라 **모든 숫자 계산 시나리오**에 적용 가능 +- 견적서, 발주서, 판매 단가, 구매 단가 등 + +### 2. 유연성 +- 필드 그룹으로 자유롭게 섹션 구성 +- 표시 항목 설정으로 UI 커스터마이징 + +### 3. 데이터 무결성 +- 1:N 관계로 여러 기간별 데이터 관리 +- 기간 중복 체크는 백엔드에서 처리 + +### 4. 사용자 경험 +- 품목별로 여러 개의 기간별 단가를 손쉽게 입력 +- 입력 완료 후 작은 카드로 요약 표시 + +## 다음 단계 + +1. **마이그레이션 실행** (999_add_period_price_columns_to_customer_item_mapping.sql) +2. **화면 편집기에서 설정** (위 Step 1~5 참고) +3. **백엔드 API 구현** (저장/조회 엔드포인트) +4. **계산 로직 추가** (선택사항: 자동 계산) +5. **테스트** (품목 선택 → 기간별 단가 입력 → 저장 → 조회) + +## 참고 자료 + +- 선택항목 상세입력 컴포넌트: `frontend/lib/registry/components/selected-items-detail-input/` +- 타입 정의: `frontend/lib/registry/components/selected-items-detail-input/types.ts` +- 설정 패널: `SelectedItemsDetailInputConfigPanel.tsx` + diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 3cb55fc1..8c922da1 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -15,6 +15,7 @@ import { screenApi } from "@/lib/api/screen"; import { ComponentData } from "@/types/screen"; import { toast } from "sonner"; import { useAuth } from "@/hooks/useAuth"; +import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; interface ScreenModalState { isOpen: boolean; @@ -394,60 +395,62 @@ export const ScreenModal: React.FC = ({ className }) => {
) : screenData ? ( -
- {screenData.components.map((component) => { - // 화면 관리 해상도를 사용하는 경우 offset 조정 불필요 - const offsetX = screenDimensions?.offsetX || 0; - const offsetY = screenDimensions?.offsetY || 0; + +
+ {screenData.components.map((component) => { + // 화면 관리 해상도를 사용하는 경우 offset 조정 불필요 + const offsetX = screenDimensions?.offsetX || 0; + const offsetY = screenDimensions?.offsetY || 0; - // offset이 0이면 원본 위치 사용 (화면 관리 해상도 사용 시) - const adjustedComponent = (offsetX === 0 && offsetY === 0) ? component : { - ...component, - position: { - ...component.position, - x: parseFloat(component.position?.x?.toString() || "0") - offsetX, - y: parseFloat(component.position?.y?.toString() || "0") - offsetY, - }, - }; + // offset이 0이면 원본 위치 사용 (화면 관리 해상도 사용 시) + const adjustedComponent = (offsetX === 0 && offsetY === 0) ? component : { + ...component, + position: { + ...component.position, + x: parseFloat(component.position?.x?.toString() || "0") - offsetX, + y: parseFloat(component.position?.y?.toString() || "0") - offsetY, + }, + }; - return ( - { - // console.log(`🎯 ScreenModal onFormDataChange 호출: ${fieldName} = "${value}"`); - // console.log("📋 현재 formData:", formData); - setFormData((prev) => { - const newFormData = { - ...prev, - [fieldName]: value, - }; - // console.log("📝 ScreenModal 업데이트된 formData:", newFormData); - return newFormData; - }); - }} - onRefresh={() => { - // 부모 화면의 테이블 새로고침 이벤트 발송 - console.log("🔄 모달에서 부모 화면 테이블 새로고침 이벤트 발송"); - window.dispatchEvent(new CustomEvent("refreshTable")); - }} - screenInfo={{ - id: modalState.screenId!, - tableName: screenData.screenInfo?.tableName, - }} - /> - ); - })} -
+ return ( + { + // console.log(`🎯 ScreenModal onFormDataChange 호출: ${fieldName} = "${value}"`); + // console.log("📋 현재 formData:", formData); + setFormData((prev) => { + const newFormData = { + ...prev, + [fieldName]: value, + }; + // console.log("📝 ScreenModal 업데이트된 formData:", newFormData); + return newFormData; + }); + }} + onRefresh={() => { + // 부모 화면의 테이블 새로고침 이벤트 발송 + console.log("🔄 모달에서 부모 화면 테이블 새로고침 이벤트 발송"); + window.dispatchEvent(new CustomEvent("refreshTable")); + }} + screenInfo={{ + id: modalState.screenId!, + tableName: screenData.screenInfo?.tableName, + }} + /> + ); + })} +
+ ) : (

화면 데이터가 없습니다.

diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index 5288108f..1ef8fee4 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -8,7 +8,8 @@ import { Switch } from "@/components/ui/switch"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Button } from "@/components/ui/button"; -import { Check, ChevronsUpDown, Search } from "lucide-react"; +import { Card } from "@/components/ui/card"; +import { Check, ChevronsUpDown, Search, Plus, X, ChevronUp, ChevronDown, Type, Database } from "lucide-react"; import { cn } from "@/lib/utils"; import { ComponentData } from "@/types/screen"; import { apiClient } from "@/lib/api/client"; @@ -16,6 +17,15 @@ import { ButtonDataflowConfigPanel } from "./ButtonDataflowConfigPanel"; import { ImprovedButtonControlConfigPanel } from "./ImprovedButtonControlConfigPanel"; import { FlowVisibilityConfigPanel } from "./FlowVisibilityConfigPanel"; +// 🆕 제목 블록 타입 +interface TitleBlock { + id: string; + type: "text" | "field"; + value: string; // text: 텍스트 내용, field: 컬럼명 + tableName?: string; // field일 때 테이블명 + label?: string; // field일 때 표시용 라벨 +} + interface ButtonConfigPanelProps { component: ComponentData; onUpdateProperty: (path: string, value: any) => void; @@ -64,6 +74,15 @@ export const ButtonConfigPanel: React.FC = ({ const [displayColumnOpen, setDisplayColumnOpen] = useState(false); const [displayColumnSearch, setDisplayColumnSearch] = useState(""); + // 🆕 제목 블록 빌더 상태 + const [titleBlocks, setTitleBlocks] = useState([]); + const [availableTables, setAvailableTables] = useState>([]); // 시스템의 모든 테이블 목록 + const [tableColumnsMap, setTableColumnsMap] = useState>>({}); + const [blockTableSearches, setBlockTableSearches] = useState>({}); // 블록별 테이블 검색어 + const [blockColumnSearches, setBlockColumnSearches] = useState>({}); // 블록별 컬럼 검색어 + const [blockTablePopoverOpen, setBlockTablePopoverOpen] = useState>({}); // 블록별 테이블 Popover 열림 상태 + const [blockColumnPopoverOpen, setBlockColumnPopoverOpen] = useState>({}); // 블록별 컬럼 Popover 열림 상태 + // 🎯 플로우 위젯이 화면에 있는지 확인 const hasFlowWidget = useMemo(() => { const found = allComponents.some((comp: any) => { @@ -95,9 +114,150 @@ export const ButtonConfigPanel: React.FC = ({ editModalDescription: String(latestAction.editModalDescription || ""), targetUrl: String(latestAction.targetUrl || ""), }); + + // 🆕 제목 블록 초기화 + if (latestAction.modalTitleBlocks && latestAction.modalTitleBlocks.length > 0) { + setTitleBlocks(latestAction.modalTitleBlocks); + } else { + // 기본값: 빈 배열 + setTitleBlocks([]); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [component.id]); + // 🆕 제목 블록 핸들러 + const addTextBlock = () => { + const newBlock: TitleBlock = { + id: `text-${Date.now()}`, + type: "text", + value: "", + }; + const updatedBlocks = [...titleBlocks, newBlock]; + setTitleBlocks(updatedBlocks); + onUpdateProperty("componentConfig.action.modalTitleBlocks", updatedBlocks); + }; + + const addFieldBlock = () => { + const newBlock: TitleBlock = { + id: `field-${Date.now()}`, + type: "field", + value: "", + tableName: "", + label: "", + }; + const updatedBlocks = [...titleBlocks, newBlock]; + setTitleBlocks(updatedBlocks); + onUpdateProperty("componentConfig.action.modalTitleBlocks", updatedBlocks); + }; + + const updateBlock = (id: string, updates: Partial) => { + const updatedBlocks = titleBlocks.map((block) => + block.id === id ? { ...block, ...updates } : block + ); + setTitleBlocks(updatedBlocks); + onUpdateProperty("componentConfig.action.modalTitleBlocks", updatedBlocks); + }; + + const removeBlock = (id: string) => { + const updatedBlocks = titleBlocks.filter((block) => block.id !== id); + setTitleBlocks(updatedBlocks); + onUpdateProperty("componentConfig.action.modalTitleBlocks", updatedBlocks); + }; + + const moveBlockUp = (id: string) => { + const index = titleBlocks.findIndex((b) => b.id === id); + if (index <= 0) return; + const newBlocks = [...titleBlocks]; + [newBlocks[index - 1], newBlocks[index]] = [newBlocks[index], newBlocks[index - 1]]; + setTitleBlocks(newBlocks); + onUpdateProperty("componentConfig.action.modalTitleBlocks", newBlocks); + }; + + const moveBlockDown = (id: string) => { + const index = titleBlocks.findIndex((b) => b.id === id); + if (index < 0 || index >= titleBlocks.length - 1) return; + const newBlocks = [...titleBlocks]; + [newBlocks[index], newBlocks[index + 1]] = [newBlocks[index + 1], newBlocks[index]]; + setTitleBlocks(newBlocks); + onUpdateProperty("componentConfig.action.modalTitleBlocks", newBlocks); + }; + + // 🆕 제목 미리보기 생성 + const generateTitlePreview = (): string => { + if (titleBlocks.length === 0) return "(제목 없음)"; + return titleBlocks + .map((block) => { + if (block.type === "text") { + return block.value || "(텍스트)"; + } else { + return block.label || block.value || "(필드)"; + } + }) + .join(""); + }; + + // 🆕 시스템의 모든 테이블 목록 로드 + useEffect(() => { + const fetchAllTables = async () => { + try { + const response = await apiClient.get("/table-management/tables"); + + if (response.data.success && response.data.data) { + const tables = response.data.data.map((table: any) => ({ + name: table.tableName, + label: table.displayName || table.tableName, + })); + setAvailableTables(tables); + console.log(`✅ 전체 테이블 목록 로드 성공:`, tables.length); + } + } catch (error) { + console.error("테이블 목록 로드 실패:", error); + } + }; + + fetchAllTables(); + }, []); + + // 🆕 특정 테이블의 컬럼 로드 + const loadTableColumns = async (tableName: string) => { + if (!tableName || tableColumnsMap[tableName]) return; + + try { + const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); + console.log(`📥 테이블 ${tableName} 컬럼 응답:`, response.data); + + if (response.data.success) { + // data가 배열인지 확인 + let columnData = response.data.data; + + // data.columns 형태일 수도 있음 + if (!Array.isArray(columnData) && columnData?.columns) { + columnData = columnData.columns; + } + + // data.data 형태일 수도 있음 + if (!Array.isArray(columnData) && columnData?.data) { + columnData = columnData.data; + } + + if (Array.isArray(columnData)) { + const columns = columnData.map((col: any) => { + const name = col.name || col.columnName; + const label = col.displayName || col.label || col.columnLabel || name; + console.log(` - 컬럼: ${name} → "${label}"`); + return { name, label }; + }); + setTableColumnsMap((prev) => ({ ...prev, [tableName]: columns })); + console.log(`✅ 테이블 ${tableName} 컬럼 로드 성공:`, columns.length, "개"); + } else { + console.error("❌ 컬럼 데이터가 배열이 아닙니다:", columnData); + } + } + } catch (error) { + console.error("컬럼 로드 실패:", error); + } + }; + // 화면 목록 가져오기 (현재 편집 중인 화면의 회사 코드 기준) useEffect(() => { const fetchScreens = async () => { @@ -431,25 +591,284 @@ export const ButtonConfigPanel: React.FC = ({ }} />

- ✨ 비워두면 같은 화면의 TableList를 자동으로 감지합니다 + ✨ 비워두면 현재 화면의 TableList를 자동으로 감지합니다

- 직접 지정하려면 테이블명을 입력하세요 (예: item_info) + • 자동 감지: 현재 화면의 TableList 선택 데이터
+ • 누적 전달: 이전 모달의 모든 데이터도 자동으로 함께 전달
+ • 다음 화면에서 tableName으로 바로 사용 가능
+ • 수동 설정: 필요시 직접 테이블명 입력 (예: item_info)

-
- - { - const newValue = e.target.value; - setLocalInputs((prev) => ({ ...prev, modalTitle: newValue })); - onUpdateProperty("componentConfig.action.modalTitle", newValue); - }} - /> + {/* 🆕 블록 기반 제목 빌더 */} +
+
+ +
+ + +
+
+ + {/* 블록 목록 */} +
+ {titleBlocks.length === 0 ? ( +
+ 텍스트나 필드를 추가하여 제목을 구성하세요 +
+ ) : ( + titleBlocks.map((block, index) => ( + +
+ {/* 순서 변경 버튼 */} +
+ + +
+ + {/* 블록 타입 표시 */} +
+ {block.type === "text" ? ( + + ) : ( + + )} +
+ + {/* 블록 설정 */} +
+ {block.type === "text" ? ( + // 텍스트 블록 + updateBlock(block.id, { value: e.target.value })} + className="h-7 text-xs" + /> + ) : ( + // 필드 블록 + <> + {/* 테이블 선택 - Combobox */} + { + setBlockTablePopoverOpen((prev) => ({ ...prev, [block.id]: open })); + }} + > + + + + + + { + setBlockTableSearches((prev) => ({ ...prev, [block.id]: value })); + }} + /> + + 테이블을 찾을 수 없습니다. + + {availableTables + .filter((table) => { + const search = (blockTableSearches[block.id] || "").toLowerCase(); + if (!search) return true; + return ( + table.label.toLowerCase().includes(search) || + table.name.toLowerCase().includes(search) + ); + }) + .map((table) => ( + { + updateBlock(block.id, { tableName: table.name, value: "" }); + loadTableColumns(table.name); + setBlockTableSearches((prev) => ({ ...prev, [block.id]: "" })); + setBlockTablePopoverOpen((prev) => ({ ...prev, [block.id]: false })); + }} + className="text-xs" + > + + {table.label} + ({table.name}) + + ))} + + + + + + + {block.tableName && ( + <> + {/* 컬럼 선택 - Combobox (라벨명 표시) */} + { + setBlockColumnPopoverOpen((prev) => ({ ...prev, [block.id]: open })); + }} + > + + + + + + { + setBlockColumnSearches((prev) => ({ ...prev, [block.id]: value })); + }} + /> + + 컬럼을 찾을 수 없습니다. + + {(tableColumnsMap[block.tableName] || []) + .filter((col) => { + const search = (blockColumnSearches[block.id] || "").toLowerCase(); + if (!search) return true; + return ( + col.label.toLowerCase().includes(search) || + col.name.toLowerCase().includes(search) + ); + }) + .map((col) => ( + { + updateBlock(block.id, { + value: col.name, + label: col.label, + }); + setBlockColumnSearches((prev) => ({ ...prev, [block.id]: "" })); + setBlockColumnPopoverOpen((prev) => ({ ...prev, [block.id]: false })); + }} + className="text-xs" + > + + {col.label} + ({col.name}) + + ))} + + + + + + + updateBlock(block.id, { label: e.target.value })} + className="h-7 text-xs" + /> + + )} + + )} +
+ + {/* 삭제 버튼 */} + +
+
+ )) + )} +
+ + {/* 미리보기 */} + {titleBlocks.length > 0 && ( +
+ 미리보기: + {generateTitlePreview()} +
+ )} + +

+ • 텍스트: 고정 텍스트 입력 (예: "품목 상세정보 - ")
+ • 필드: 이전 화면 데이터로 자동 채워짐 (예: 품목명, 규격)
+ • 순서 변경: ↑↓ 버튼으로 자유롭게 배치
+ • 데이터가 없으면 "표시 라벨"이 대신 표시됩니다 +

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 4bdb1a2e..904fc4be 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx @@ -74,6 +74,15 @@ export const SelectedItemsDetailInputComponent: React.FC ({ + table: key, + count: data.length, + })), + }); + const updateItemData = useModalDataStore((state) => state.updateItemData); // 🆕 새로운 데이터 구조: 품목별로 여러 개의 상세 데이터 @@ -138,39 +147,63 @@ export const SelectedItemsDetailInputComponent: React.FC 0) { - const columnMeta = targetTableColumns.find( - (col: any) => (col.columnName || col.column_name) === field.name - ); - if (columnMeta) { - codeCategory = columnMeta.codeCategory || columnMeta.code_category; - console.log(`🔍 필드 "${field.name}"의 codeCategory를 메타데이터에서 찾음:`, codeCategory); - } - } - - if (!codeCategory) { - console.warn(`⚠️ 필드 "${field.name}"의 codeCategory를 찾을 수 없습니다`); + // 이미 로드된 옵션이면 스킵 + if (newOptions[field.name]) { + console.log(`⏭️ 이미 로드된 옵션 (${field.name})`); continue; } - // 이미 로드된 옵션이면 스킵 - if (newOptions[codeCategory]) continue; - try { - const response = await commonCodeApi.options.getOptions(codeCategory); - if (response.success && response.data) { - newOptions[codeCategory] = response.data.map((opt) => ({ - label: opt.label, - value: opt.value, - })); - console.log(`✅ 코드 옵션 로드 완료: ${codeCategory}`, newOptions[codeCategory]); + // 🆕 category 타입이면 table_column_category_values에서 로드 + if (field.inputType === "category" && targetTable) { + console.log(`🔄 카테고리 옵션 로드 시도 (${targetTable}.${field.name})`); + + const { getCategoryValues } = await import("@/lib/api/tableCategoryValue"); + const response = await getCategoryValues(targetTable, field.name, false); + + console.log(`📥 getCategoryValues 응답:`, response); + + if (response.success && response.data) { + newOptions[field.name] = response.data.map((item: any) => ({ + label: item.value_label || item.valueLabel, + value: item.value_code || item.valueCode, + })); + console.log(`✅ 카테고리 옵션 로드 완료 (${field.name}):`, newOptions[field.name]); + } else { + console.error(`❌ 카테고리 옵션 로드 실패 (${field.name}):`, response.error || "응답 없음"); + } + } else if (field.inputType === "code") { + // code 타입이면 기존대로 code_info에서 로드 + // 이미 codeCategory가 있으면 사용 + let codeCategory = field.codeCategory; + + // 🆕 codeCategory가 없으면 대상 테이블 컬럼에서 찾기 + if (!codeCategory && targetTableColumns.length > 0) { + const columnMeta = targetTableColumns.find( + (col: any) => (col.columnName || col.column_name) === field.name + ); + if (columnMeta) { + codeCategory = columnMeta.codeCategory || columnMeta.code_category; + console.log(`🔍 필드 "${field.name}"의 codeCategory를 메타데이터에서 찾음:`, codeCategory); + } + } + + if (!codeCategory) { + console.warn(`⚠️ 필드 "${field.name}"의 codeCategory를 찾을 수 없습니다`); + continue; + } + + const response = await commonCodeApi.options.getOptions(codeCategory); + if (response.success && response.data) { + newOptions[field.name] = response.data.map((opt) => ({ + label: opt.label, + value: opt.value, + })); + console.log(`✅ 코드 옵션 로드 완료 (${codeCategory}):`, newOptions[field.name]); + } } } catch (error) { - console.error(`❌ 코드 옵션 로드 실패: ${codeCategory}`, error); + console.error(`❌ 옵션 로드 실패 (${field.name}):`, error); } } @@ -262,6 +295,51 @@ export const SelectedItemsDetailInputComponent: React.FC { + // 자동 계산 설정이 없으면 계산하지 않음 + if (!componentConfig.autoCalculation) return 0; + + const { inputFields, valueMapping } = componentConfig.autoCalculation; + + // 기본 단가 + const basePrice = parseFloat(entry[inputFields.basePrice] || "0"); + if (basePrice === 0) return 0; + + let price = basePrice; + + // 1단계: 할인 적용 + const discountTypeValue = entry[inputFields.discountType]; + const discountValue = parseFloat(entry[inputFields.discountValue] || "0"); + + // 매핑을 통해 실제 연산 타입 결정 + const discountOperation = valueMapping?.discountType?.[discountTypeValue] || "none"; + + if (discountOperation === "rate") { + price = price * (1 - discountValue / 100); + } else if (discountOperation === "amount") { + price = price - discountValue; + } + + // 2단계: 반올림 적용 + const roundingTypeValue = entry[inputFields.roundingType]; + const roundingUnitValue = entry[inputFields.roundingUnit]; + + // 매핑을 통해 실제 연산 타입 결정 + const roundingOperation = valueMapping?.roundingType?.[roundingTypeValue] || "none"; + const unit = valueMapping?.roundingUnit?.[roundingUnitValue] || parseFloat(roundingUnitValue) || 1; + + if (roundingOperation === "round") { + price = Math.round(price / unit) * unit; + } else if (roundingOperation === "floor") { + price = Math.floor(price / unit) * unit; + } else if (roundingOperation === "ceil") { + price = Math.ceil(price / unit) * unit; + } + + return price; + }, [componentConfig.autoCalculation]); + // 🆕 그룹별 필드 변경 핸들러: itemId + groupId + entryId + fieldName const handleFieldChange = useCallback((itemId: string, groupId: string, entryId: string, fieldName: string, value: any) => { setItems((prevItems) => { @@ -274,10 +352,38 @@ export const SelectedItemsDetailInputComponent: React.FC= 0) { // 기존 entry 업데이트 (항상 이 경로로만 진입) const updatedEntries = [...groupEntries]; - updatedEntries[existingEntryIndex] = { + const updatedEntry = { ...updatedEntries[existingEntryIndex], [fieldName]: value, }; + + // 🆕 가격 관련 필드가 변경되면 자동 계산 + if (componentConfig.autoCalculation) { + const { inputFields, targetField } = componentConfig.autoCalculation; + const priceRelatedFields = [ + inputFields.basePrice, + inputFields.discountType, + inputFields.discountValue, + inputFields.roundingType, + inputFields.roundingUnit, + ]; + + if (priceRelatedFields.includes(fieldName)) { + const calculatedPrice = calculatePrice(updatedEntry); + updatedEntry[targetField] = calculatedPrice; + console.log("💰 [자동 계산]", { + basePrice: updatedEntry[inputFields.basePrice], + discountType: updatedEntry[inputFields.discountType], + discountValue: updatedEntry[inputFields.discountValue], + roundingType: updatedEntry[inputFields.roundingType], + roundingUnit: updatedEntry[inputFields.roundingUnit], + calculatedPrice, + targetField, + }); + } + } + + updatedEntries[existingEntryIndex] = updatedEntry; return { ...item, fieldGroups: { @@ -292,7 +398,7 @@ export const SelectedItemsDetailInputComponent: React.FC { @@ -303,7 +409,7 @@ export const SelectedItemsDetailInputComponent: React.FC { const newEntryId = `entry-${Date.now()}`; - // 🔧 미리 빈 entry를 추가하여 리렌더링 방지 + // 🔧 미리 빈 entry를 추가하여 리렌더링 방지 (autoFillFrom 처리) setItems((prevItems) => { return prevItems.map((item) => { if (item.id !== itemId) return item; @@ -311,6 +417,36 @@ export const SelectedItemsDetailInputComponent: React.FC f.groupId === groupId + ); + + groupFields.forEach((field) => { + if (!field.autoFillFrom) return; + + // 데이터 소스 결정 + let sourceData: any = null; + + if (field.autoFillFromTable) { + // 특정 테이블에서 가져오기 + const tableData = dataRegistry[field.autoFillFromTable]; + if (tableData && tableData.length > 0) { + // 첫 번째 항목 사용 (또는 매칭 로직 추가 가능) + sourceData = tableData[0].originalData || tableData[0]; + console.log(`✅ [autoFill] ${field.name} ← ${field.autoFillFrom} (테이블: ${field.autoFillFromTable}):`, sourceData[field.autoFillFrom]); + } + } else { + // 주 데이터 소스 (item.originalData) 사용 + sourceData = item.originalData; + console.log(`✅ [autoFill] ${field.name} ← ${field.autoFillFrom} (주 소스):`, sourceData[field.autoFillFrom]); + } + + if (sourceData && sourceData[field.autoFillFrom] !== undefined) { + newEntry[field.name] = sourceData[field.autoFillFrom]; + } + }); + return { ...item, fieldGroups: { @@ -377,6 +513,9 @@ export const SelectedItemsDetailInputComponent: React.FC { const value = entry[field.name] || field.defaultValue || ""; + // 🆕 계산된 필드는 읽기 전용 (자동 계산 설정 기반) + const isCalculatedField = componentConfig.autoCalculation?.targetField === field.name; + const commonProps = { value: value || "", disabled: componentConfig.disabled || componentConfig.readonly, @@ -399,7 +538,7 @@ export const SelectedItemsDetailInputComponent: React.FC handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)} maxLength={field.validation?.maxLength} - className="h-8 text-xs sm:h-10 sm:text-sm" + className="h-10 text-sm" /> ); @@ -409,6 +548,30 @@ export const SelectedItemsDetailInputComponent: React.FC + +
+ 자동 계산 +
+
+ ); + } + return ( 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" + className="h-10 text-sm" /> ); @@ -428,7 +591,14 @@ export const SelectedItemsDetailInputComponent: React.FC handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)} - className="h-8 text-xs sm:h-10 sm:text-sm" + onClick={(e) => { + // 날짜 선택기 강제 열기 + const target = e.target as HTMLInputElement; + if (target && target.showPicker) { + target.showPicker(); + } + }} + className="h-10 text-sm cursor-pointer" /> ); @@ -456,20 +626,16 @@ export const SelectedItemsDetailInputComponent: React.FC { - // 필드명과 매칭되는 카테고리 찾기 (예: currency_code → CURRENCY) - return field.name.toLowerCase().includes(cat.toLowerCase().replace('_', '')); - }); - if (matchedCategory) { - categoryOptions = codeOptions[matchedCategory]; - } } return ( @@ -478,7 +644,7 @@ export const SelectedItemsDetailInputComponent: React.FC handleFieldChange(itemId, groupId, entryId, field.name, val)} disabled={componentConfig.disabled || componentConfig.readonly} > - + @@ -769,11 +935,11 @@ export const SelectedItemsDetailInputComponent: React.FC - -
+ +
수정 중
- {groupFields.map((field) => ( -
- - {renderField(field, item.id, group.id, entry.id, entry)} -
- ))} + {/* 🆕 가로 Grid 배치 (2~3열) */} +
+ {groupFields.map((field) => ( +
+ + {renderField(field, item.id, group.id, entry.id, entry)} +
+ ))} +
); 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 a2b2df51..0f8bca42 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx @@ -1,8 +1,9 @@ "use client"; -import React, { useState, useMemo } from "react"; +import React, { useState, useMemo, useEffect } from "react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -14,6 +15,7 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from " import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Check, ChevronsUpDown } from "lucide-react"; import { cn } from "@/lib/utils"; +import { getSecondLevelMenus, getCategoryColumns, getCategoryValues } from "@/lib/api/tableCategoryValue"; export interface SelectedItemsDetailInputConfigPanelProps { config: SelectedItemsDetailInputConfig; @@ -47,6 +49,7 @@ export const SelectedItemsDetailInputConfigPanel: React.FC(config.fieldGroups || []); + // 🆕 그룹별 펼침/접힘 상태 const [expandedGroups, setExpandedGroups] = useState>({}); @@ -61,6 +64,77 @@ export const SelectedItemsDetailInputConfigPanel: React.FC>([]); + const [categoryColumns, setCategoryColumns] = useState>>({}); + const [categoryValues, setCategoryValues] = useState>>({}); + + // 2레벨 메뉴 목록 로드 + useEffect(() => { + const loadMenus = async () => { + const response = await getSecondLevelMenus(); + if (response.success && response.data) { + setSecondLevelMenus(response.data); + } + }; + loadMenus(); + }, []); + + // 메뉴 선택 시 카테고리 목록 로드 + const handleMenuSelect = async (menuObjid: number, fieldType: "discountType" | "roundingType" | "roundingUnit") => { + if (!config.targetTable) { + console.warn("⚠️ targetTable이 설정되지 않았습니다"); + return; + } + + console.log("🔍 카테고리 목록 로드 시작", { targetTable: config.targetTable, menuObjid, fieldType }); + + const response = await getCategoryColumns(config.targetTable); + + console.log("📥 getCategoryColumns 응답:", response); + + if (response.success && response.data) { + console.log("✅ 카테고리 컬럼 데이터:", response.data); + setCategoryColumns(prev => ({ ...prev, [fieldType]: response.data })); + } else { + console.error("❌ 카테고리 컬럼 로드 실패:", response); + } + + // valueMapping 업데이트 + handleChange("autoCalculation", { + ...config.autoCalculation, + valueMapping: { + ...config.autoCalculation.valueMapping, + _selectedMenus: { + ...(config.autoCalculation.valueMapping as any)?._selectedMenus, + [fieldType]: menuObjid, + }, + }, + }); + }; + + // 카테고리 선택 시 카테고리 값 목록 로드 + const handleCategorySelect = async (columnName: string, menuObjid: number, fieldType: "discountType" | "roundingType" | "roundingUnit") => { + if (!config.targetTable) return; + + const response = await getCategoryValues(config.targetTable, columnName, false, menuObjid); + if (response.success && response.data) { + setCategoryValues(prev => ({ ...prev, [fieldType]: response.data })); + } + + // valueMapping 업데이트 + handleChange("autoCalculation", { + ...config.autoCalculation, + valueMapping: { + ...config.autoCalculation.valueMapping, + _selectedCategories: { + ...(config.autoCalculation.valueMapping as any)?._selectedCategories, + [fieldType]: columnName, + }, + }, + }); + }; + // 🆕 초기 로드 시 screenTableName을 targetTable로 자동 설정 React.useEffect(() => { if (screenTableName && !config.targetTable) { @@ -568,6 +642,85 @@ export const SelectedItemsDetailInputConfigPanel: React.FC
+ {/* 🆕 원본 데이터 자동 채우기 */} +
+ + + {/* 테이블명 입력 */} + updateField(index, { autoFillFromTable: e.target.value })} + placeholder="비워두면 주 데이터 (예: item_price)" + className="h-6 w-full text-[10px] sm:h-7 sm:text-xs" + /> +

+ 다른 테이블에서 가져올 경우 테이블명 입력 +

+ + {/* 필드 선택 */} + + + + + + + + 원본 테이블을 먼저 선택하세요. + + updateField(index, { autoFillFrom: undefined })} + className="text-[10px] sm:text-xs" + > + + 선택 안 함 + + {sourceTableColumns.map((column) => ( + updateField(index, { autoFillFrom: column.columnName })} + className="text-[10px] sm:text-xs" + > + +
+
{column.columnLabel}
+
{column.columnName}
+
+
+ ))} +
+
+
+
+ +

+ {field.autoFillFromTable + ? `"${field.autoFillFromTable}" 테이블에서 자동 채우기` + : "주 데이터 소스에서 자동 채우기 (수정 가능)" + } +

+
+ {/* 🆕 필드 그룹 선택 */} {localFieldGroups.length > 0 && (
@@ -970,6 +1123,478 @@ export const SelectedItemsDetailInputConfigPanel: React.FC
+ {/* 자동 계산 설정 */} +
+
+ + { + if (checked) { + handleChange("autoCalculation", { + targetField: "calculated_price", + inputFields: { + basePrice: "current_unit_price", + discountType: "discount_type", + discountValue: "discount_value", + roundingType: "rounding_type", + roundingUnit: "rounding_unit_value", + }, + calculationType: "price", + }); + } else { + handleChange("autoCalculation", undefined); + } + }} + /> +
+ + {config.autoCalculation && ( +
+
+ + handleChange("autoCalculation", { + ...config.autoCalculation, + targetField: e.target.value, + })} + placeholder="calculated_price" + className="h-7 text-xs" + /> +
+ +
+ + handleChange("autoCalculation", { + ...config.autoCalculation, + inputFields: { + ...config.autoCalculation.inputFields, + basePrice: e.target.value, + }, + })} + placeholder="current_unit_price" + className="h-7 text-xs" + /> +
+ +
+
+ + handleChange("autoCalculation", { + ...config.autoCalculation, + inputFields: { + ...config.autoCalculation.inputFields, + discountType: e.target.value, + }, + })} + placeholder="discount_type" + className="h-7 text-xs" + /> +
+ +
+ + handleChange("autoCalculation", { + ...config.autoCalculation, + inputFields: { + ...config.autoCalculation.inputFields, + discountValue: e.target.value, + }, + })} + placeholder="discount_value" + className="h-7 text-xs" + /> +
+
+ +
+
+ + handleChange("autoCalculation", { + ...config.autoCalculation, + inputFields: { + ...config.autoCalculation.inputFields, + roundingType: e.target.value, + }, + })} + placeholder="rounding_type" + className="h-7 text-xs" + /> +
+ +
+ + handleChange("autoCalculation", { + ...config.autoCalculation, + inputFields: { + ...config.autoCalculation.inputFields, + roundingUnit: e.target.value, + }, + })} + placeholder="rounding_unit_value" + className="h-7 text-xs" + /> +
+
+ +

+ 💡 위 필드명들이 추가 입력 필드에 있어야 자동 계산이 작동합니다 +

+ + {/* 카테고리 값 매핑 */} +
+ + + {/* 할인 방식 매핑 */} + + + + + + {/* 1단계: 메뉴 선택 */} +
+ + +
+ + {/* 2단계: 카테고리 선택 */} + {(config.autoCalculation.valueMapping as any)?._selectedMenus?.discountType && ( +
+ + +
+ )} + + {/* 3단계: 값 매핑 */} + {(config.autoCalculation.valueMapping as any)?._selectedCategories?.discountType && ( +
+ + {["할인없음", "할인율(%)", "할인금액"].map((label, idx) => { + const operations = ["none", "rate", "amount"]; + return ( +
+ {label} + + + {operations[idx]} +
+ ); + })} +
+ )} +
+
+ + {/* 반올림 방식 매핑 */} + + + + + + {/* 1단계: 메뉴 선택 */} +
+ + +
+ + {/* 2단계: 카테고리 선택 */} + {(config.autoCalculation.valueMapping as any)?._selectedMenus?.roundingType && ( +
+ + +
+ )} + + {/* 3단계: 값 매핑 */} + {(config.autoCalculation.valueMapping as any)?._selectedCategories?.roundingType && ( +
+ + {["반올림없음", "반올림", "절삭", "올림"].map((label, idx) => { + const operations = ["none", "round", "floor", "ceil"]; + return ( +
+ {label} + + + {operations[idx]} +
+ ); + })} +
+ )} +
+
+ + {/* 반올림 단위 매핑 */} + + + + + + {/* 1단계: 메뉴 선택 */} +
+ + +
+ + {/* 2단계: 카테고리 선택 */} + {(config.autoCalculation.valueMapping as any)?._selectedMenus?.roundingUnit && ( +
+ + +
+ )} + + {/* 3단계: 값 매핑 */} + {(config.autoCalculation.valueMapping as any)?._selectedCategories?.roundingUnit && ( +
+ + {["1원", "10원", "100원", "1,000원"].map((label) => { + const unitValue = label === "1,000원" ? 1000 : parseInt(label); + return ( +
+ {label} + + + {unitValue} +
+ ); + })} +
+ )} +
+
+ +

+ 💡 1단계: 메뉴 선택 → 2단계: 카테고리 선택 → 3단계: 값 매핑 +

+
+
+ )} +
+ {/* 옵션 */}
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 05c11e4a..0e6120c6 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/types.ts +++ b/frontend/lib/registry/components/selected-items-detail-input/types.ts @@ -22,6 +22,10 @@ export interface AdditionalFieldDefinition { placeholder?: string; /** 기본값 */ defaultValue?: any; + /** 🆕 원본 데이터에서 자동으로 값을 가져올 필드명 */ + autoFillFrom?: string; + /** 🆕 자동 채우기할 데이터의 테이블명 (비워두면 주 데이터 소스 사용) */ + autoFillFromTable?: string; /** 선택 옵션 (type이 select일 때) */ options?: Array<{ label: string; value: string }>; /** 필드 너비 (px 또는 %) */ @@ -54,6 +58,39 @@ export interface FieldGroup { displayItems?: DisplayItem[]; } +/** + * 🆕 자동 계산 설정 + */ +export interface AutoCalculationConfig { + /** 계산 대상 필드명 (예: calculated_price) */ + targetField: string; + /** 계산에 사용할 입력 필드들 */ + inputFields: { + basePrice: string; // 기본 단가 필드명 + discountType: string; // 할인 방식 필드명 + discountValue: string; // 할인값 필드명 + roundingType: string; // 반올림 방식 필드명 + roundingUnit: string; // 반올림 단위 필드명 + }; + /** 계산 함수 타입 */ + calculationType: "price" | "custom"; + /** 🆕 카테고리 값 → 연산 매핑 */ + valueMapping?: { + /** 할인 방식 매핑 */ + discountType?: { + [valueCode: string]: "none" | "rate" | "amount"; // 예: { "CATEGORY_544740": "rate" } + }; + /** 반올림 방식 매핑 */ + roundingType?: { + [valueCode: string]: "none" | "round" | "floor" | "ceil"; + }; + /** 반올림 단위 매핑 (숫자로 변환) */ + roundingUnit?: { + [valueCode: string]: number; // 예: { "10": 10, "100": 100 } + }; + }; +} + /** * SelectedItemsDetailInput 컴포넌트 설정 타입 */ @@ -93,6 +130,12 @@ export interface SelectedItemsDetailInputConfig extends ComponentConfig { */ targetTable?: string; + /** + * 🆕 자동 계산 설정 + * 특정 필드가 변경되면 다른 필드를 자동으로 계산 + */ + autoCalculation?: AutoCalculationConfig; + /** * 레이아웃 모드 * - grid: 테이블 형식 (기본) diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index b7364a4b..5d2d621f 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -418,8 +418,17 @@ export const SplitPanelLayoutComponent: React.FC setSelectedLeftItem(item); setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화 loadRightData(item); + + // 🆕 modalDataStore에 선택된 좌측 항목 저장 (단일 선택) + const leftTableName = componentConfig.leftPanel?.tableName; + if (leftTableName && !isDesignMode) { + import("@/stores/modalDataStore").then(({ useModalDataStore }) => { + useModalDataStore.getState().setData(leftTableName, [item]); + console.log(`✅ 분할 패널 좌측 선택: ${leftTableName}`, item); + }); + } }, - [loadRightData], + [loadRightData, componentConfig.leftPanel?.tableName, isDesignMode], ); // 우측 항목 확장/축소 토글 diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 1d39ce91..615aedf9 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -41,6 +41,13 @@ export interface ButtonActionConfig { // 모달/팝업 관련 modalTitle?: string; + modalTitleBlocks?: Array<{ // 🆕 블록 기반 제목 (우선순위 높음) + id: string; + type: "text" | "field"; + value: string; // type=text: 텍스트 내용, type=field: 컬럼명 + tableName?: string; // type=field일 때 테이블명 + label?: string; // type=field일 때 표시용 라벨 + }>; modalDescription?: string; modalSize?: "sm" | "md" | "lg" | "xl"; popupWidth?: number; @@ -207,6 +214,20 @@ export class ButtonActionExecutor { await new Promise(resolve => setTimeout(resolve, 100)); // 🆕 SelectedItemsDetailInput 배치 저장 처리 (fieldGroups 구조) + console.log("🔍 [handleSave] formData 구조 확인:", { + keys: Object.keys(context.formData), + values: Object.entries(context.formData).map(([key, value]) => ({ + key, + isArray: Array.isArray(value), + length: Array.isArray(value) ? value.length : 0, + firstItem: Array.isArray(value) && value.length > 0 ? { + hasOriginalData: !!value[0]?.originalData, + hasFieldGroups: !!value[0]?.fieldGroups, + keys: Object.keys(value[0] || {}) + } : null + })) + }); + const selectedItemsKeys = Object.keys(context.formData).filter(key => { const value = context.formData[key]; return Array.isArray(value) && value.length > 0 && value[0]?.originalData && value[0]?.fieldGroups; @@ -215,6 +236,8 @@ export class ButtonActionExecutor { if (selectedItemsKeys.length > 0) { console.log("🔄 [handleSave] SelectedItemsDetailInput 배치 저장 감지:", selectedItemsKeys); return await this.handleBatchSave(config, context, selectedItemsKeys); + } else { + console.log("⚠️ [handleSave] SelectedItemsDetailInput 데이터 감지 실패 - 일반 저장 진행"); } // 폼 유효성 검사 @@ -830,11 +853,11 @@ export class ButtonActionExecutor { dataSourceId: config.dataSourceId, }); - // 🆕 1. dataSourceId 자동 결정 + // 🆕 1. 현재 화면의 TableList 또는 SplitPanelLayout 자동 감지 let dataSourceId = config.dataSourceId; - // dataSourceId가 없으면 같은 화면의 TableList 자동 감지 if (!dataSourceId && context.allComponents) { + // TableList 우선 감지 const tableListComponent = context.allComponents.find( (comp: any) => comp.componentType === "table-list" && comp.componentConfig?.tableName ); @@ -845,6 +868,19 @@ export class ButtonActionExecutor { componentId: tableListComponent.id, tableName: dataSourceId, }); + } else { + // TableList가 없으면 SplitPanelLayout의 좌측 패널 감지 + const splitPanelComponent = context.allComponents.find( + (comp: any) => comp.componentType === "split-panel-layout" && comp.componentConfig?.leftPanel?.tableName + ); + + if (splitPanelComponent) { + dataSourceId = splitPanelComponent.componentConfig.leftPanel.tableName; + console.log("✨ 분할 패널 좌측 테이블 자동 감지:", { + componentId: splitPanelComponent.id, + tableName: dataSourceId, + }); + } } } @@ -853,21 +889,30 @@ export class ButtonActionExecutor { dataSourceId = context.tableName || "default"; } - // 2. modalDataStore에서 데이터 확인 + // 🆕 2. modalDataStore에서 현재 선택된 데이터 확인 try { const { useModalDataStore } = await import("@/stores/modalDataStore"); - const modalData = useModalDataStore.getState().dataRegistry[dataSourceId] || []; + const dataRegistry = useModalDataStore.getState().dataRegistry; + + const modalData = dataRegistry[dataSourceId] || []; + + console.log("📊 현재 화면 데이터 확인:", { + dataSourceId, + count: modalData.length, + allKeys: Object.keys(dataRegistry), // 🆕 전체 데이터 키 확인 + }); if (modalData.length === 0) { - console.warn("⚠️ 전달할 데이터가 없습니다:", dataSourceId); + console.warn("⚠️ 선택된 데이터가 없습니다:", dataSourceId); toast.warning("선택된 데이터가 없습니다. 먼저 항목을 선택해주세요."); return false; } - console.log("✅ 전달할 데이터:", { - dataSourceId, - count: modalData.length, - data: modalData, + console.log("✅ 모달 데이터 준비 완료:", { + currentData: { id: dataSourceId, count: modalData.length }, + previousData: Object.entries(dataRegistry) + .filter(([key]) => key !== dataSourceId) + .map(([key, data]: [string, any]) => ({ id: key, count: data.length })), }); } catch (error) { console.error("❌ 데이터 확인 실패:", error); @@ -875,7 +920,79 @@ export class ButtonActionExecutor { return false; } - // 3. 모달 열기 + URL 파라미터로 dataSourceId 전달 + // 6. 동적 모달 제목 생성 + const { useModalDataStore } = await import("@/stores/modalDataStore"); + const dataRegistry = useModalDataStore.getState().dataRegistry; + + let finalTitle = "데이터 입력"; + + // 🆕 블록 기반 제목 (우선순위 1) + if (config.modalTitleBlocks && config.modalTitleBlocks.length > 0) { + const titleParts: string[] = []; + + config.modalTitleBlocks.forEach((block) => { + if (block.type === "text") { + // 텍스트 블록: 그대로 추가 + titleParts.push(block.value); + } else if (block.type === "field") { + // 필드 블록: 데이터에서 값 가져오기 + const tableName = block.tableName; + const columnName = block.value; + + if (tableName && columnName) { + const tableData = dataRegistry[tableName]; + if (tableData && tableData.length > 0) { + const firstItem = tableData[0].originalData || tableData[0]; + const value = firstItem[columnName]; + + if (value !== undefined && value !== null) { + titleParts.push(String(value)); + console.log(`✨ 동적 필드: ${tableName}.${columnName} → ${value}`); + } else { + // 데이터 없으면 라벨 표시 + titleParts.push(block.label || columnName); + } + } else { + // 테이블 데이터 없으면 라벨 표시 + titleParts.push(block.label || columnName); + } + } + } + }); + + finalTitle = titleParts.join(""); + console.log("📋 블록 기반 제목 생성:", finalTitle); + } + // 기존 방식: {tableName.columnName} 패턴 (우선순위 2) + else if (config.modalTitle) { + finalTitle = config.modalTitle; + + if (finalTitle.includes("{")) { + const matches = finalTitle.match(/\{([^}]+)\}/g); + + if (matches) { + matches.forEach((match) => { + const path = match.slice(1, -1); // {item_info.item_name} → item_info.item_name + const [tableName, columnName] = path.split("."); + + if (tableName && columnName) { + const tableData = dataRegistry[tableName]; + if (tableData && tableData.length > 0) { + const firstItem = tableData[0].originalData || tableData[0]; + const value = firstItem[columnName]; + + if (value !== undefined && value !== null) { + finalTitle = finalTitle.replace(match, String(value)); + console.log(`✨ 동적 제목: ${match} → ${value}`); + } + } + } + }); + } + } + } + + // 7. 모달 열기 + URL 파라미터로 dataSourceId 전달 if (config.targetScreenId) { // config에 modalDescription이 있으면 우선 사용 let description = config.modalDescription || ""; @@ -894,10 +1011,10 @@ export class ButtonActionExecutor { const modalEvent = new CustomEvent("openScreenModal", { detail: { screenId: config.targetScreenId, - title: config.modalTitle || "데이터 입력", + title: finalTitle, // 🆕 동적 제목 사용 description: description, size: config.modalSize || "lg", // 데이터 입력 화면은 기본 large - urlParams: { dataSourceId }, // 🆕 URL 파라미터로 dataSourceId 전달 + urlParams: { dataSourceId }, // 🆕 주 데이터 소스만 전달 (나머지는 modalDataStore에서 자동으로 찾음) }, }); diff --git a/frontend/types/unified-core.ts b/frontend/types/unified-core.ts index cba1c3f7..4da2280a 100644 --- a/frontend/types/unified-core.ts +++ b/frontend/types/unified-core.ts @@ -85,8 +85,7 @@ export type ComponentType = | "area" | "layout" | "flow" - | "component" - | "category-manager"; + | "component"; /** * 기본 위치 정보