+ {/* 원본 데이터 요약 */}
+
+ {componentConfig.displayColumns?.map((col) => editingItem.originalData[col.name]).filter(Boolean).join(" | ")}
- ))}
-
- {/* 추가 입력 필드 */}
- {componentConfig.additionalFields?.map((field) => (
-
-
- {field.label}
- {field.required && * }
-
- {renderField(field, item)}
+
+ {/* 🆕 이미 입력된 상세 항목들 표시 */}
+ {editingItem.details.length > 0 && (
+
+
입력된 품번 ({editingItem.details.length}개)
+ {editingItem.details.map((detail, idx) => (
+
+ {idx + 1}. {detail[componentConfig.additionalFields?.[0]?.name] || "입력됨"}
+ handleRemoveDetail(editingItem.id, detail.id)}
+ className="h-6 w-6 p-0"
+ >
+ X
+
+
+ ))}
+
+ )}
+
+ {/* 추가 입력 필드 */}
+ {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);
+ })()}
+
+ {/* 액션 버튼들 */}
+
+ handleAddDetail(editingItem.id)}
+ size="sm"
+ variant="outline"
+ className="text-xs"
+ >
+ + 이 품목에 추가 입력
+
+
+ 다음 품목
+
- ))}
-
-
- ))}
+
+
+ );
+ })()}
+
+ {/* 저장된 항목들 (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}
+
+ )}
+
+
+ {
+ setIsEditing(true);
+ setEditingItemId(item.id);
+ }}
+ className="h-7 text-xs text-orange-600"
+ >
+ 수정
+
+ {componentConfig.allowRemove && (
+ handleRemoveItem(item.id)}
+ className="h-7 text-xs text-destructive"
+ >
+ X
+
+ )}
+
+
+
+ );
+ }
+
+ // Inline 모드: 각 품목마다 여러 상세 항목 표시
+ return (
+
+
+ {/* 제목 (품명) */}
+
+
+ {index + 1}. {item.originalData[componentConfig.displayColumns?.[0]?.name] || "항목"}
+
+
{
+ const newDetailId = `detail-${Date.now()}`;
+ handleAddDetail(item.id);
+ }}
+ size="sm"
+ variant="outline"
+ className="text-xs"
+ >
+ + 상세 입력 추가
+
+
+
+ {/* 원본 데이터 요약 (작은 텍스트, | 구분자) */}
+
+ {componentConfig.displayColumns?.map((col) => item.originalData[col.name]).filter(Boolean).join(" | ")}
+
+
+ {/* 🆕 각 상세 항목 표시 */}
+ {item.details && item.details.length > 0 ? (
+
+ {item.details.map((detail, detailIdx) => (
+
+
+
+
상세 항목 {detailIdx + 1}
+
handleRemoveDetail(item.id, detail.id)}
+ className="h-6 w-6 p-0 text-red-500"
+ >
+ X
+
+
+ {/* 입력 필드들 */}
+ {renderFieldsByGroup(item.id, detail.id, detail)}
+
+
+ ))}
+
+ ) : (
+
+ 아직 입력된 상세 항목이 없습니다.
+
+ )}
+
+
+ );
+ })}
+
+ {/* Modal 모드: 하단 추가 버튼 (항목이 있을 때) */}
+ {isModalMode && !isEditing && items.length > 0 && (
+
{
+ // 새 항목 추가 로직은 여기서 처리하지 않고, 기존 항목이 있으면 첫 항목을 편집 모드로
+ setIsEditing(true);
+ setEditingItemId(items[0]?.id || null);
+ }}
+ variant="outline"
+ size="sm"
+ className="w-full text-xs sm:text-sm border-dashed"
+ >
+ + 추가
+
+ )}
);
};
+ 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..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,18 +1,21 @@
"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";
import { Card, CardContent } from "@/components/ui/card";
-import { Plus, X } from "lucide-react";
-import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition } from "./types";
+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";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
+import { getSecondLevelMenus, getCategoryColumns, getCategoryValues } from "@/lib/api/tableCategoryValue";
export interface SelectedItemsDetailInputConfigPanelProps {
config: SelectedItemsDetailInputConfig;
@@ -43,6 +46,16 @@ export const SelectedItemsDetailInputConfigPanel: React.FC>(config.displayColumns || []);
const [fieldPopoverOpen, setFieldPopoverOpen] = useState>({});
+ // 🆕 필드 그룹 상태
+ const [localFieldGroups, setLocalFieldGroups] = useState(config.fieldGroups || []);
+
+
+ // 🆕 그룹별 펼침/접힘 상태
+ const [expandedGroups, setExpandedGroups] = useState>({});
+
+ // 🆕 그룹별 표시 항목 설정 펼침/접힘 상태
+ const [expandedDisplayItems, setExpandedDisplayItems] = useState>({});
+
// 🆕 원본 테이블 선택 상태
const [sourceTableSelectOpen, setSourceTableSelectOpen] = useState(false);
const [sourceTableSearchValue, setSourceTableSearchValue] = useState("");
@@ -51,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) {
@@ -100,6 +184,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)) {
@@ -134,6 +250,71 @@ export const SelectedItemsDetailInputConfigPanel: React.FC {
+ const newItem: DisplayItem = {
+ type,
+ id: `display-${Date.now()}`,
+ };
+
+ if (type === "field") {
+ // 해당 그룹의 필드만 선택 가능하도록
+ const groupFields = localFields.filter(f => f.groupId === groupId);
+ newItem.fieldName = groupFields[0]?.name || "";
+ newItem.format = "text";
+ newItem.emptyBehavior = "default";
+ } else if (type === "icon") {
+ newItem.icon = "Circle";
+ } else if (type === "text") {
+ newItem.value = "텍스트";
+ }
+
+ const updatedGroups = localFieldGroups.map(g => {
+ if (g.id === groupId) {
+ return {
+ ...g,
+ displayItems: [...(g.displayItems || []), newItem]
+ };
+ }
+ return g;
+ });
+
+ setLocalFieldGroups(updatedGroups);
+ handleChange("fieldGroups", updatedGroups);
+ };
+
+ const removeDisplayItemFromGroup = (groupId: string, itemIndex: number) => {
+ const updatedGroups = localFieldGroups.map(g => {
+ if (g.id === groupId) {
+ return {
+ ...g,
+ displayItems: (g.displayItems || []).filter((_, i) => i !== itemIndex)
+ };
+ }
+ return g;
+ });
+
+ setLocalFieldGroups(updatedGroups);
+ handleChange("fieldGroups", updatedGroups);
+ };
+
+ const updateDisplayItemInGroup = (groupId: string, itemIndex: number, updates: Partial) => {
+ const updatedGroups = localFieldGroups.map(g => {
+ if (g.id === groupId) {
+ const updatedItems = [...(g.displayItems || [])];
+ updatedItems[itemIndex] = { ...updatedItems[itemIndex], ...updates };
+ return {
+ ...g,
+ displayItems: updatedItems
+ };
+ }
+ return g;
+ });
+
+ setLocalFieldGroups(updatedGroups);
+ handleChange("fieldGroups", updatedGroups);
+ };
+
// 🆕 선택된 원본 테이블 표시명
const selectedSourceTableLabel = useMemo(() => {
if (!config.sourceTable) return "원본 테이블을 선택하세요";
@@ -461,6 +642,111 @@ 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"
+ />
+
+ 다른 테이블에서 가져올 경우 테이블명 입력
+
+
+ {/* 필드 선택 */}
+
+
+
+ {field.autoFillFrom
+ ? sourceTableColumns.find(c => c.columnName === field.autoFillFrom)?.columnLabel || field.autoFillFrom
+ : "필드 선택 안 함"}
+
+
+
+
+
+
+ 원본 테이블을 먼저 선택하세요.
+
+ 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 && (
+
+
필드 그룹 (선택사항)
+
updateField(index, { groupId: value === "none" ? undefined : value })}
+ >
+
+
+
+
+ 그룹 없음
+ {localFieldGroups.map((group) => (
+
+ {group.title} ({group.id})
+
+ ))}
+
+
+
+ 같은 그룹 ID를 가진 필드들은 같은 카드에 표시됩니다
+
+
+ )}
+
+ {/* 🆕 필드 그룹 관리 */}
+
+
필드 그룹 설정
+
+ 추가 입력 필드를 여러 카드로 나눠서 표시 (예: 거래처 정보, 단가 정보)
+
+
+ {localFieldGroups.map((group, index) => {
+ const isGroupExpanded = expandedGroups[group.id] ?? true;
+
+ return (
+
setExpandedGroups(prev => ({ ...prev, [group.id]: open }))}
+ >
+
+
+
+
+
+
+ {isGroupExpanded ? (
+
+ ) : (
+
+ )}
+
+ 그룹 {index + 1}: {group.title || group.id}
+
+
+
+
+
removeFieldGroup(group.id)}
+ className="h-6 w-6 text-red-500 hover:text-red-700 sm:h-7 sm:w-7"
+ >
+
+
+
+
+
+ {/* 그룹 ID */}
+
+ 그룹 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"
+ />
+
+
+ {/* 🆕 이 그룹의 항목 표시 설정 */}
+ setExpandedDisplayItems(prev => ({ ...prev, [group.id]: open }))}
+ >
+
+
+
+
+ {expandedDisplayItems[group.id] ? (
+
+ ) : (
+
+ )}
+
+ 항목 표시 설정 ({(group.displayItems || []).length}개)
+
+
+
+
+
+
+ {/* 추가 버튼들 */}
+
+
addDisplayItemToGroup(group.id, "icon")}
+ className="h-5 px-1.5 text-[8px] sm:h-6 sm:px-2 sm:text-[10px]"
+ >
+
+ 아이콘
+
+
addDisplayItemToGroup(group.id, "field")}
+ className="h-5 px-1.5 text-[8px] sm:h-6 sm:px-2 sm:text-[10px]"
+ >
+
+ 필드
+
+
addDisplayItemToGroup(group.id, "text")}
+ className="h-5 px-1.5 text-[8px] sm:h-6 sm:px-2 sm:text-[10px]"
+ >
+
+ 텍스트
+
+
+
+
+ 이 그룹의 입력 항목이 추가되면 어떻게 표시될지 설정
+
+
+ {(!group.displayItems || group.displayItems.length === 0) ? (
+
+ 미설정 (모든 필드를 " / "로 구분하여 표시)
+
+ ) : (
+
+ {group.displayItems.map((item, itemIndex) => (
+
+ {/* 헤더 */}
+
+
+ {item.type === "icon" && "🎨"}
+ {item.type === "field" && "📝"}
+ {item.type === "text" && "💬"}
+ {item.type === "badge" && "🏷️"}
+
+ removeDisplayItemFromGroup(group.id, itemIndex)}
+ className="h-4 w-4 p-0"
+ >
+
+
+
+
+ {/* 아이콘 설정 */}
+ {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, { fieldName: value })}
+ >
+
+
+
+
+ {localFields.filter(f => f.groupId === group.id).map((field) => (
+
+ {field.label || field.name}
+
+ ))}
+
+
+
+ {/* 라벨 */}
+ updateDisplayItemInGroup(group.id, itemIndex, { label: e.target.value })}
+ placeholder="라벨 (예: 거래처:)"
+ className="h-6 w-full text-[9px] sm:text-[10px]"
+ />
+
+ {/* 표시 형식 */}
+ updateDisplayItemInGroup(group.id, itemIndex, { format: value as DisplayFieldFormat })}
+ >
+
+
+
+
+ 텍스트
+ 금액
+ 숫자
+ 날짜
+ 배지
+
+
+
+ {/* 빈 값 처리 */}
+ updateDisplayItemInGroup(group.id, itemIndex, { emptyBehavior: value as EmptyBehavior })}
+ >
+
+
+
+
+ 숨김
+ 기본값
+ 빈칸
+
+
+
+ {/* 기본값 */}
+ {item.emptyBehavior === "default" && (
+ updateDisplayItemInGroup(group.id, itemIndex, { defaultValue: e.target.value })}
+ placeholder="미입력"
+ className="h-6 w-full text-[9px] sm:text-[10px]"
+ />
+ )}
+
+ )}
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+ );
+ })}
+
+
+
+ 그룹 추가
+
+
+ {localFieldGroups.length > 0 && (
+
+ 💡 추가 입력 필드의 "필드 그룹 ID"에 위에서 정의한 그룹 ID를 입력하세요
+
+ )}
+
+
+ {/* 입력 모드 설정 */}
+
+
입력 모드
+
handleChange("inputMode", value as "inline" | "modal")}
+ >
+
+
+
+
+
+ 항상 표시 (Inline)
+
+
+ 추가 버튼으로 표시 (Modal)
+
+
+
+
+ {config.inputMode === "modal"
+ ? "추가 버튼 클릭 시 입력창 표시, 완료 후 작은 카드로 표시"
+ : "모든 항목의 입력창을 항상 표시"}
+
+
+
{/* 레이아웃 설정 */}
레이아웃
@@ -511,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단계: 메뉴 선택 */}
+
+ 1단계: 메뉴 선택
+ handleMenuSelect(Number(value), "discountType")}
+ >
+
+
+
+
+ {secondLevelMenus.map((menu) => (
+
+ {menu.parentMenuName} > {menu.menuName}
+
+ ))}
+
+
+
+
+ {/* 2단계: 카테고리 선택 */}
+ {(config.autoCalculation.valueMapping as any)?._selectedMenus?.discountType && (
+
+ 2단계: 카테고리 선택
+ handleCategorySelect(
+ value,
+ (config.autoCalculation.valueMapping as any)._selectedMenus.discountType,
+ "discountType"
+ )}
+ >
+
+
+
+
+ {(categoryColumns.discountType || []).map((col: any) => (
+
+ {col.columnLabel || col.columnName}
+
+ ))}
+
+
+
+ )}
+
+ {/* 3단계: 값 매핑 */}
+ {(config.autoCalculation.valueMapping as any)?._selectedCategories?.discountType && (
+
+
3단계: 카테고리 값 → 연산 매핑
+ {["할인없음", "할인율(%)", "할인금액"].map((label, idx) => {
+ const operations = ["none", "rate", "amount"];
+ return (
+
+ {label}
+ →
+ op === operations[idx])?.[0] || ""
+ }
+ onValueChange={(value) => {
+ const newMapping = { ...config.autoCalculation.valueMapping?.discountType };
+ Object.keys(newMapping).forEach(key => {
+ if (newMapping[key] === operations[idx]) delete newMapping[key];
+ });
+ if (value) {
+ newMapping[value] = operations[idx];
+ }
+ handleChange("autoCalculation", {
+ ...config.autoCalculation,
+ valueMapping: {
+ ...config.autoCalculation.valueMapping,
+ discountType: newMapping,
+ },
+ });
+ }}
+ >
+
+
+
+
+ {(categoryValues.discountType || []).map((val: any) => (
+
+ {val.valueLabel}
+
+ ))}
+
+
+ {operations[idx]}
+
+ );
+ })}
+
+ )}
+
+
+
+ {/* 반올림 방식 매핑 */}
+
+
+
+ 반올림 방식 연산 매핑
+
+
+
+
+ {/* 1단계: 메뉴 선택 */}
+
+ 1단계: 메뉴 선택
+ handleMenuSelect(Number(value), "roundingType")}
+ >
+
+
+
+
+ {secondLevelMenus.map((menu) => (
+
+ {menu.parentMenuName} > {menu.menuName}
+
+ ))}
+
+
+
+
+ {/* 2단계: 카테고리 선택 */}
+ {(config.autoCalculation.valueMapping as any)?._selectedMenus?.roundingType && (
+
+ 2단계: 카테고리 선택
+ handleCategorySelect(
+ value,
+ (config.autoCalculation.valueMapping as any)._selectedMenus.roundingType,
+ "roundingType"
+ )}
+ >
+
+
+
+
+ {(categoryColumns.roundingType || []).map((col: any) => (
+
+ {col.columnLabel || col.columnName}
+
+ ))}
+
+
+
+ )}
+
+ {/* 3단계: 값 매핑 */}
+ {(config.autoCalculation.valueMapping as any)?._selectedCategories?.roundingType && (
+
+
3단계: 카테고리 값 → 연산 매핑
+ {["반올림없음", "반올림", "절삭", "올림"].map((label, idx) => {
+ const operations = ["none", "round", "floor", "ceil"];
+ return (
+
+ {label}
+ →
+ op === operations[idx])?.[0] || ""
+ }
+ onValueChange={(value) => {
+ const newMapping = { ...config.autoCalculation.valueMapping?.roundingType };
+ Object.keys(newMapping).forEach(key => {
+ if (newMapping[key] === operations[idx]) delete newMapping[key];
+ });
+ if (value) {
+ newMapping[value] = operations[idx];
+ }
+ handleChange("autoCalculation", {
+ ...config.autoCalculation,
+ valueMapping: {
+ ...config.autoCalculation.valueMapping,
+ roundingType: newMapping,
+ },
+ });
+ }}
+ >
+
+
+
+
+ {(categoryValues.roundingType || []).map((val: any) => (
+
+ {val.valueLabel}
+
+ ))}
+
+
+ {operations[idx]}
+
+ );
+ })}
+
+ )}
+
+
+
+ {/* 반올림 단위 매핑 */}
+
+
+
+ 반올림 단위 값 매핑
+
+
+
+
+ {/* 1단계: 메뉴 선택 */}
+
+ 1단계: 메뉴 선택
+ handleMenuSelect(Number(value), "roundingUnit")}
+ >
+
+
+
+
+ {secondLevelMenus.map((menu) => (
+
+ {menu.parentMenuName} > {menu.menuName}
+
+ ))}
+
+
+
+
+ {/* 2단계: 카테고리 선택 */}
+ {(config.autoCalculation.valueMapping as any)?._selectedMenus?.roundingUnit && (
+
+ 2단계: 카테고리 선택
+ handleCategorySelect(
+ value,
+ (config.autoCalculation.valueMapping as any)._selectedMenus.roundingUnit,
+ "roundingUnit"
+ )}
+ >
+
+
+
+
+ {(categoryColumns.roundingUnit || []).map((col: any) => (
+
+ {col.columnLabel || col.columnName}
+
+ ))}
+
+
+
+ )}
+
+ {/* 3단계: 값 매핑 */}
+ {(config.autoCalculation.valueMapping as any)?._selectedCategories?.roundingUnit && (
+
+
3단계: 카테고리 값 → 단위 값 매핑
+ {["1원", "10원", "100원", "1,000원"].map((label) => {
+ const unitValue = label === "1,000원" ? 1000 : parseInt(label);
+ return (
+
+ {label}
+ →
+ val === unitValue)?.[0] || ""
+ }
+ onValueChange={(value) => {
+ const newMapping = { ...config.autoCalculation.valueMapping?.roundingUnit };
+ Object.keys(newMapping).forEach(key => {
+ if (newMapping[key] === unitValue) delete newMapping[key];
+ });
+ if (value) {
+ newMapping[value] = unitValue;
+ }
+ handleChange("autoCalculation", {
+ ...config.autoCalculation,
+ valueMapping: {
+ ...config.autoCalculation.valueMapping,
+ roundingUnit: newMapping,
+ },
+ });
+ }}
+ >
+
+
+
+
+ {(categoryValues.roundingUnit || []).map((val: any) => (
+
+ {val.valueLabel}
+
+ ))}
+
+
+ {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 d69afd2d..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,10 +22,16 @@ export interface AdditionalFieldDefinition {
placeholder?: string;
/** 기본값 */
defaultValue?: any;
+ /** 🆕 원본 데이터에서 자동으로 값을 가져올 필드명 */
+ autoFillFrom?: string;
+ /** 🆕 자동 채우기할 데이터의 테이블명 (비워두면 주 데이터 소스 사용) */
+ autoFillFromTable?: string;
/** 선택 옵션 (type이 select일 때) */
options?: Array<{ label: string; value: string }>;
/** 필드 너비 (px 또는 %) */
width?: string;
+ /** 🆕 필드 그룹 ID (같은 그룹ID를 가진 필드들은 같은 카드에 표시) */
+ groupId?: string;
/** 검증 규칙 */
validation?: {
min?: number;
@@ -36,6 +42,55 @@ export interface AdditionalFieldDefinition {
};
}
+/**
+ * 필드 그룹 정의
+ */
+export interface FieldGroup {
+ /** 그룹 ID */
+ id: string;
+ /** 그룹 제목 */
+ title: string;
+ /** 그룹 설명 (선택사항) */
+ description?: string;
+ /** 그룹 표시 순서 */
+ order?: number;
+ /** 🆕 이 그룹의 항목 표시 설정 (그룹별로 다른 표시 형식 가능) */
+ 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 컴포넌트 설정 타입
*/
@@ -64,11 +119,23 @@ export interface SelectedItemsDetailInputConfig extends ComponentConfig {
*/
additionalFields?: AdditionalFieldDefinition[];
+ /**
+ * 🆕 필드 그룹 정의
+ * 추가 입력 필드를 여러 카드로 나눠서 표시
+ */
+ fieldGroups?: FieldGroup[];
+
/**
* 저장 대상 테이블
*/
targetTable?: string;
+ /**
+ * 🆕 자동 계산 설정
+ * 특정 필드가 변경되면 다른 필드를 자동으로 계산
+ */
+ autoCalculation?: AutoCalculationConfig;
+
/**
* 레이아웃 모드
* - grid: 테이블 형식 (기본)
@@ -86,6 +153,13 @@ export interface SelectedItemsDetailInputConfig extends ComponentConfig {
*/
allowRemove?: boolean;
+ /**
+ * 🆕 입력 모드
+ * - inline: 항상 입력창 표시 (기본)
+ * - modal: 추가 버튼 클릭 시 입력창 표시, 완료 후 작은 카드로 표시
+ */
+ inputMode?: "inline" | "modal";
+
/**
* 빈 상태 메시지
*/
@@ -96,6 +170,92 @@ export interface SelectedItemsDetailInputConfig extends ComponentConfig {
readonly?: boolean;
}
+/**
+ * 🆕 그룹별 입력 항목 (예: 그룹1의 한 줄)
+ */
+export interface GroupEntry {
+ /** 입력 항목 고유 ID */
+ id: string;
+ /** 입력된 필드 데이터 */
+ [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;
+}
+
+/**
+ * 🆕 품목 + 그룹별 여러 입력 항목
+ * 각 필드 그룹마다 독립적으로 여러 개의 입력을 추가할 수 있음
+ * 예: { "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/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 28dc2ae1..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;
@@ -198,6 +205,41 @@ 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 등에서 최신 데이터 수집)
+ window.dispatchEvent(new CustomEvent("beforeFormSave"));
+
+ // 약간의 대기 시간을 주어 이벤트 핸들러가 formData를 업데이트할 수 있도록 함
+ 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;
+ });
+
+ if (selectedItemsKeys.length > 0) {
+ console.log("🔄 [handleSave] SelectedItemsDetailInput 배치 저장 감지:", selectedItemsKeys);
+ return await this.handleBatchSave(config, context, selectedItemsKeys);
+ } else {
+ console.log("⚠️ [handleSave] SelectedItemsDetailInput 데이터 감지 실패 - 일반 저장 진행");
+ }
+
// 폼 유효성 검사
if (config.validateForm) {
const validation = this.validateFormData(formData);
@@ -446,6 +488,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;
+ }
+ }
+
/**
* 삭제 액션 처리
*/
@@ -689,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
);
@@ -704,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,
+ });
+ }
}
}
@@ -712,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);
@@ -734,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 || "";
@@ -753,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";
/**
* 기본 위치 정보
diff --git a/동적_테이블_접근_시스템_개선_완료.md b/동적_테이블_접근_시스템_개선_완료.md
index d143a6a5..da8f5e82 100644
--- a/동적_테이블_접근_시스템_개선_완료.md
+++ b/동적_테이블_접근_시스템_개선_완료.md
@@ -377,3 +377,4 @@ interface TablePermission {
+