"use client"; import React, { useState, useEffect, useMemo, useCallback } from "react"; import { useSearchParams } from "next/navigation"; import { ComponentRendererProps } from "@/types/component"; 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"; export interface SelectedItemsDetailInputComponentProps extends ComponentRendererProps { config?: SelectedItemsDetailInputConfig; } /** * SelectedItemsDetailInput 컴포넌트 * 선택된 항목들의 상세 정보를 입력하는 컴포넌트 */ export const SelectedItemsDetailInputComponent: React.FC = ({ component, isDesignMode = false, isSelected = false, isInteractive = false, onClick, onDragStart, onDragEnd, config, className, style, formData, onFormDataChange, screenId, ...props }) => { // 🆕 URL 파라미터에서 dataSourceId 읽기 const searchParams = useSearchParams(); const urlDataSourceId = searchParams?.get("dataSourceId") || undefined; // 컴포넌트 설정 const componentConfig = useMemo( () => ({ dataSourceId: component.id || "default", displayColumns: [], additionalFields: [], layout: "grid", inputMode: "inline", // 🆕 기본값 showIndex: true, allowRemove: false, emptyMessage: "전달받은 데이터가 없습니다.", targetTable: "", ...config, ...component.config, }) as SelectedItemsDetailInputConfig, [config, component.config, component.id], ); // 🆕 dataSourceId 우선순위: URL 파라미터 > 컴포넌트 설정 > component.id const dataSourceId = useMemo( () => urlDataSourceId || componentConfig.dataSourceId || component.id || "default", [urlDataSourceId, componentConfig.dataSourceId, component.id], ); // 전체 레지스트리를 가져와서 컴포넌트 내부에서 필터링 (캐싱 문제 회피) const dataRegistry = useModalDataStore((state) => state.dataRegistry); const modalData = useMemo(() => dataRegistry[dataSourceId] || [], [dataRegistry, dataSourceId]); // 전체 dataRegistry를 사용 (모든 누적 데이터에 접근 가능) console.log("📦 [SelectedItemsDetailInput] 사용 가능한 모든 데이터:", { keys: Object.keys(dataRegistry), counts: Object.entries(dataRegistry).map(([key, data]: [string, any]) => ({ table: key, count: data.length, })), }); const updateItemData = useModalDataStore((state) => state.updateItemData); // 🆕 새로운 데이터 구조: 품목별로 여러 개의 상세 데이터 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 () => { console.log("🔄 [loadCodeOptions] 시작:", { additionalFields: componentConfig.additionalFields, targetTable: componentConfig.targetTable, }); // 🆕 code/category 타입 필드 + codeCategory가 있는 필드 모두 처리 const codeFields = componentConfig.additionalFields?.filter( (field) => field.inputType === "code" || field.inputType === "category", ); console.log("🔍 [loadCodeOptions] code/category 필드:", codeFields); if (!codeFields || codeFields.length === 0) { console.log("⚠️ [loadCodeOptions] code/category 타입 필드가 없습니다"); return; } const newOptions: Record> = { ...codeOptions }; // 🆕 대상 테이블의 컬럼 메타데이터에서 codeCategory 가져오기 const targetTable = componentConfig.targetTable; let targetTableColumns: any[] = []; if (targetTable) { try { const { tableTypeApi } = await import("@/lib/api/screen"); const columnsResponse = await tableTypeApi.getColumns(targetTable); targetTableColumns = columnsResponse || []; } catch (error) { console.error("❌ 대상 테이블 컬럼 조회 실패:", error); } } for (const field of codeFields) { // 이미 로드된 옵션이면 스킵 if (newOptions[field.name]) { console.log(`⏭️ 이미 로드된 옵션 (${field.name})`); continue; } try { // 🆕 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(`❌ 옵션 로드 실패 (${field.name}):`, error); } } setCodeOptions(newOptions); }; loadCodeOptions(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [componentConfig.additionalFields, componentConfig.targetTable]); // 🆕 모달 데이터를 ItemData 구조로 변환 (그룹별 구조) useEffect(() => { // 🆕 수정 모드: formData에서 데이터 로드 (URL에 mode=edit이 있으면) const urlParams = new URLSearchParams(window.location.search); const mode = urlParams.get("mode"); if (mode === "edit" && formData) { // 배열인지 단일 객체인지 확인 const isArray = Array.isArray(formData); const dataArray = isArray ? formData : [formData]; if (dataArray.length === 0 || (dataArray.length === 1 && Object.keys(dataArray[0]).length === 0)) { console.warn("⚠️ [SelectedItemsDetailInput] formData가 비어있음"); return; } console.log( `📝 [SelectedItemsDetailInput] 수정 모드 - ${isArray ? "그룹 레코드" : "단일 레코드"} (${dataArray.length}개)`, ); console.log("📝 [SelectedItemsDetailInput] formData (JSON):", JSON.stringify(dataArray, null, 2)); const groups = componentConfig.fieldGroups || []; const additionalFields = componentConfig.additionalFields || []; // 🆕 첫 번째 레코드의 originalData를 기본 항목으로 설정 const firstRecord = dataArray[0]; const mainFieldGroups: Record = {}; // 🔧 각 그룹별로 고유한 엔트리만 수집 (중복 제거) groups.forEach((group) => { const groupFields = additionalFields.filter((field: any) => field.groupId === group.id); if (groupFields.length === 0) { mainFieldGroups[group.id] = []; return; } // 🆕 각 레코드에서 그룹 데이터 추출 const entriesMap = new Map(); dataArray.forEach((record) => { const entryData: Record = {}; groupFields.forEach((field: any) => { let fieldValue = record[field.name]; // 🆕 값이 없으면 autoFillFrom 로직 적용 if ((fieldValue === undefined || fieldValue === null) && field.autoFillFrom) { 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 { // 🆕 dataRegistry에 없으면 record에서 직접 찾기 (Entity Join된 경우) sourceData = record; console.log( `⚠️ [수정모드 autoFill] dataRegistry에 ${field.autoFillFromTable} 없음, record에서 직접 찾기`, ); } } else { // record 자체에서 가져오기 sourceData = record; console.log( `✅ [수정모드 autoFill] ${field.name} ← ${field.autoFillFrom} (레코드):`, sourceData?.[field.autoFillFrom], ); } if (sourceData && sourceData[field.autoFillFrom] !== undefined) { fieldValue = sourceData[field.autoFillFrom]; console.log(`✅ [수정모드 autoFill] ${field.name} 값 설정:`, fieldValue); } else { // 🆕 Entity Join의 경우 sourceColumn_fieldName 형식으로도 찾기 // 예: item_id_standard_price, customer_id_customer_name // autoFillFromTable에서 어떤 sourceColumn인지 추론 const possibleKeys = Object.keys(sourceData || {}).filter((key) => key.endsWith(`_${field.autoFillFrom}`), ); if (possibleKeys.length > 0) { fieldValue = sourceData[possibleKeys[0]]; console.log( `✅ [수정모드 autoFill] ${field.name} Entity Join 키로 찾음 (${possibleKeys[0]}):`, fieldValue, ); } else { console.warn( `⚠️ [수정모드 autoFill] ${field.name} ← ${field.autoFillFrom} 실패 (시도한 키들: ${field.autoFillFrom}, *_${field.autoFillFrom})`, ); } } } // 🔧 값이 없으면 기본값 사용 (false, 0, "" 등 falsy 값도 유효한 값으로 처리) if (fieldValue === undefined || fieldValue === null) { // 기본값이 있으면 사용, 없으면 필드 타입에 따라 기본값 설정 if (field.defaultValue !== undefined) { fieldValue = field.defaultValue; } else if (field.type === "checkbox") { fieldValue = false; // checkbox는 기본값 false } else { // 다른 타입은 null로 유지 (필수 필드가 아니면 표시 안 됨) return; } } // 🔧 날짜 타입이면 YYYY-MM-DD 형식으로 변환 (타임존 제거) if (field.type === "date" || field.type === "datetime") { const dateStr = String(fieldValue); const match = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})/); if (match) { const [, year, month, day] = match; fieldValue = `${year}-${month}-${day}`; // ISO 형식 유지 (시간 제거) } } entryData[field.name] = fieldValue; }); // 🔑 모든 필드 값을 합쳐서 고유 키 생성 (중복 제거 기준) const entryKey = JSON.stringify(entryData); if (!entriesMap.has(entryKey)) { entriesMap.set(entryKey, { id: `${group.id}_entry_${entriesMap.size + 1}`, ...entryData, }); } }); mainFieldGroups[group.id] = Array.from(entriesMap.values()); }); // 그룹이 없으면 기본 그룹 생성 if (groups.length === 0) { mainFieldGroups["default"] = []; } const newItem: ItemData = { id: String(firstRecord.id || firstRecord.item_id || "edit"), originalData: firstRecord, // 첫 번째 레코드를 대표 데이터로 사용 fieldGroups: mainFieldGroups, }; setItems([newItem]); console.log("✅ [SelectedItemsDetailInput] 수정 모드 데이터 로드 완료:", { recordCount: dataArray.length, item: newItem, fieldGroupsKeys: Object.keys(mainFieldGroups), firstGroupEntries: mainFieldGroups[groups[0]?.id]?.length || 0, }); return; } // 생성 모드: modalData에서 데이터 로드 if (modalData && modalData.length > 0) { console.log("📦 [SelectedItemsDetailInput] 데이터 수신:", 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, componentConfig.fieldGroups, formData]); // formData 의존성 추가 // 🆕 Cartesian Product 생성 함수 (items에서 모든 그룹의 조합을 생성) const generateCartesianProduct = useCallback( (itemsList: ItemData[]): Record[] => { const allRecords: Record[] = []; const groups = componentConfig.fieldGroups || []; const additionalFields = componentConfig.additionalFields || []; itemsList.forEach((item, itemIndex) => { // 각 그룹의 엔트리 배열들을 준비 const groupEntriesArrays: GroupEntry[][] = groups.map((group) => item.fieldGroups[group.id] || []); // 🆕 모든 그룹이 비어있는지 확인 const allGroupsEmpty = groupEntriesArrays.every((arr) => arr.length === 0); if (allGroupsEmpty) { // 🆕 모든 그룹이 비어있으면 품목 기본 정보만으로 레코드 생성 // (거래처 품번/품명, 기간별 단가 없이도 저장 가능) console.log("📝 [generateCartesianProduct] 모든 그룹이 비어있음 - 품목 기본 레코드 생성", { itemIndex, itemId: item.id, }); // 빈 객체를 추가하면 parentKeys와 합쳐져서 기본 레코드가 됨 allRecords.push({}); return; } // Cartesian Product 재귀 함수 const cartesian = (arrays: GroupEntry[][], currentIndex: number, currentCombination: Record) => { if (currentIndex === arrays.length) { // 모든 그룹을 순회했으면 조합 완성 allRecords.push({ ...currentCombination }); return; } const currentGroupEntries = arrays[currentIndex]; if (currentGroupEntries.length === 0) { // 🆕 현재 그룹에 데이터가 없으면 빈 조합으로 다음 그룹 진행 // (그룹이 비어있어도 다른 그룹의 데이터로 레코드 생성) cartesian(arrays, currentIndex + 1, currentCombination); return; } // 현재 그룹의 각 엔트리마다 재귀 currentGroupEntries.forEach((entry) => { const newCombination = { ...currentCombination }; // 현재 그룹의 필드들을 조합에 추가 const groupFields = additionalFields.filter((f) => f.groupId === groups[currentIndex].id); groupFields.forEach((field) => { if (entry[field.name] !== undefined) { newCombination[field.name] = entry[field.name]; } }); cartesian(arrays, currentIndex + 1, newCombination); }); }; // 재귀 시작 cartesian(groupEntriesArrays, 0, {}); }); console.log("🔀 [generateCartesianProduct] 생성된 레코드:", { count: allRecords.length, records: allRecords, }); return allRecords; }, [componentConfig.fieldGroups, componentConfig.additionalFields], ); // 🆕 저장 요청 시에만 데이터 전달 (이벤트 리스너 방식) useEffect(() => { const handleSaveRequest = async (event: Event) => { // component.id를 문자열로 안전하게 변환 const componentKey = String(component.id || "selected_items"); console.log("🔔 [SelectedItemsDetailInput] beforeFormSave 이벤트 수신!", { itemsCount: items.length, hasOnFormDataChange: !!onFormDataChange, componentId: component.id, componentIdType: typeof component.id, componentKey, }); if (items.length === 0) { console.warn("⚠️ [SelectedItemsDetailInput] 저장할 데이터 없음"); return; } // 🆕 수정 모드인지 확인 (URL에 mode=edit이 있으면) const urlParams = new URLSearchParams(window.location.search); const mode = urlParams.get("mode"); const isEditMode = mode === "edit"; console.log("📝 [SelectedItemsDetailInput] 저장 모드:", { mode, isEditMode }); if (isEditMode && componentConfig.parentDataMapping && componentConfig.parentDataMapping.length > 0) { // 🔄 수정 모드: UPSERT API 사용 try { console.log("🔄 [SelectedItemsDetailInput] UPSERT 모드로 저장 시작"); console.log("📋 [SelectedItemsDetailInput] componentConfig:", { targetTable: componentConfig.targetTable, parentDataMapping: componentConfig.parentDataMapping, fieldGroups: componentConfig.fieldGroups, additionalFields: componentConfig.additionalFields, }); // 부모 키 추출 (parentDataMapping에서) const parentKeys: Record = {}; // formData 또는 items[0].originalData에서 부모 데이터 가져오기 // formData가 배열이면 첫 번째 항목 사용 let sourceData: any = formData; if (Array.isArray(formData) && formData.length > 0) { sourceData = formData[0]; } else if (!formData) { sourceData = items[0]?.originalData || {}; } console.log("📦 [SelectedItemsDetailInput] 부모 데이터 소스:", { formDataType: Array.isArray(formData) ? "배열" : typeof formData, sourceData, sourceDataKeys: Object.keys(sourceData), parentDataMapping: componentConfig.parentDataMapping, }); console.log( "🔍 [SelectedItemsDetailInput] sourceData 전체 내용 (JSON):", JSON.stringify(sourceData, null, 2), ); componentConfig.parentDataMapping.forEach((mapping) => { // 🆕 Entity Join 필드도 처리 (예: customer_code -> customer_id_customer_code) const value = getFieldValue(sourceData, mapping.sourceField); if (value !== undefined && value !== null) { parentKeys[mapping.targetField] = value; console.log(`✅ [parentKeys] ${mapping.sourceField} → ${mapping.targetField}:`, value); } else { console.warn( `⚠️ [SelectedItemsDetailInput] 부모 키 누락: ${mapping.sourceField} → ${mapping.targetField}`, ); } }); console.log("🔑 [SelectedItemsDetailInput] 부모 키:", parentKeys); // items를 Cartesian Product로 변환 const records = generateCartesianProduct(items); console.log("📦 [SelectedItemsDetailInput] UPSERT 레코드:", { parentKeys, recordCount: records.length, records, }); // targetTable 검증 if (!componentConfig.targetTable) { console.error("❌ [SelectedItemsDetailInput] targetTable이 설정되지 않았습니다!"); window.dispatchEvent( new CustomEvent("formSaveError", { detail: { message: "대상 테이블이 설정되지 않았습니다." }, }), ); // 🆕 기본 저장 건너뛰기 if (event instanceof CustomEvent && event.detail) { event.detail.skipDefaultSave = true; } return; } // 🆕 먼저 기본 저장 로직 건너뛰기 설정 (UPSERT 전에!) if (event instanceof CustomEvent) { if (!event.detail) { console.warn("⚠️ [SelectedItemsDetailInput] event.detail이 없습니다! 새로 생성합니다."); // @ts-ignore - detail 재정의 event.detail = {}; } event.detail.skipDefaultSave = true; console.log("🚫 [SelectedItemsDetailInput] skipDefaultSave = true (UPSERT 전)", event.detail); } else { console.error("❌ [SelectedItemsDetailInput] event가 CustomEvent가 아닙니다!", event); } console.log("🎯 [SelectedItemsDetailInput] targetTable:", componentConfig.targetTable); console.log("📡 [SelectedItemsDetailInput] UPSERT API 호출 직전:", { tableName: componentConfig.targetTable, tableNameType: typeof componentConfig.targetTable, tableNameLength: componentConfig.targetTable?.length, parentKeys, recordsCount: records.length, }); // UPSERT API 호출 const { dataApi } = await import("@/lib/api/data"); const result = await dataApi.upsertGroupedRecords(componentConfig.targetTable, parentKeys, records); if (result.success) { console.log("✅ [SelectedItemsDetailInput] UPSERT 성공:", { inserted: result.inserted, updated: result.updated, deleted: result.deleted, }); // 저장 성공 이벤트 발생 window.dispatchEvent( new CustomEvent("formSaveSuccess", { detail: { message: "데이터가 저장되었습니다." }, }), ); } else { console.error("❌ [SelectedItemsDetailInput] UPSERT 실패:", result.error); window.dispatchEvent( new CustomEvent("formSaveError", { detail: { message: result.error || "데이터 저장 실패" }, }), ); } } catch (error) { console.error("❌ [SelectedItemsDetailInput] UPSERT 오류:", error); window.dispatchEvent( new CustomEvent("formSaveError", { detail: { message: "데이터 저장 중 오류가 발생했습니다." }, }), ); // 🆕 오류 발생 시에도 기본 저장 건너뛰기 (중복 저장 방지) if (event instanceof CustomEvent && event.detail) { event.detail.skipDefaultSave = true; } } } else { // 📝 생성 모드: 기존 로직 (Cartesian Product 생성 후 formData에 추가) console.log("📝 [SelectedItemsDetailInput] 생성 모드: 기존 저장 로직 사용"); console.log("📝 [SelectedItemsDetailInput] 저장 데이터 준비:", { key: componentKey, itemsCount: items.length, firstItem: items[0], }); // ✅ CustomEvent의 detail에 데이터 첨부 if (event instanceof CustomEvent && event.detail) { // context.formData에 직접 추가 event.detail.formData[componentKey] = items; console.log("✅ [SelectedItemsDetailInput] context.formData에 데이터 직접 추가 완료"); } // 기존 onFormDataChange도 호출 (호환성) if (onFormDataChange) { onFormDataChange(componentKey, items); } } }; // 저장 버튼 클릭 시 데이터 수집 window.addEventListener("beforeFormSave", handleSaveRequest as EventListener); return () => { window.removeEventListener("beforeFormSave", handleSaveRequest as EventListener); }; }, [items, component.id, onFormDataChange, componentConfig, formData, generateCartesianProduct]); // 스타일 계산 const componentStyle: React.CSSProperties = { width: "100%", height: "100%", ...component.style, ...style, }; // 디자인 모드 스타일 if (isDesignMode) { componentStyle.border = "1px dashed #cbd5e1"; componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1"; componentStyle.padding = "16px"; componentStyle.borderRadius = "8px"; } // 이벤트 핸들러 const handleClick = (e: React.MouseEvent) => { e.stopPropagation(); onClick?.(); }; // 🆕 실시간 단가 계산 함수 (설정 기반 + 카테고리 매핑) const calculatePrice = useCallback( (entry: GroupEntry): number => { // 자동 계산 설정이 없으면 계산하지 않음 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) => { console.log("📝 [handleFieldChange] 필드 값 변경:", { itemId, groupId, entryId, fieldName, value, }); setItems((prevItems) => { 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 업데이트 (항상 이 경로로만 진입) const updatedEntries = [...groupEntries]; const updatedEntry = { ...updatedEntries[existingEntryIndex], [fieldName]: value, }; console.log("✅ [handleFieldChange] Entry 업데이트:", { beforeKeys: Object.keys(updatedEntries[existingEntryIndex]), afterKeys: Object.keys(updatedEntry), updatedEntry, }); // 🆕 가격 관련 필드가 변경되면 자동 계산 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: { ...item.fieldGroups, [groupId]: updatedEntries, }, }; } else { // 이 경로는 발생하면 안 됨 (handleAddGroupEntry에서 미리 추가함) console.warn("⚠️ entry가 없는데 handleFieldChange 호출됨:", { itemId, groupId, entryId }); return item; } }); }); }, [calculatePrice], ); // 🆕 품목 제거 핸들러 const handleRemoveItem = (itemId: string) => { setItems((prevItems) => prevItems.filter((item) => item.id !== itemId)); }; // 🆕 그룹 항목 추가 핸들러 (특정 그룹에 새 항목 추가) const handleAddGroupEntry = (itemId: string, groupId: string) => { const newEntryId = `entry-${Date.now()}`; // 🔧 미리 빈 entry를 추가하여 리렌더링 방지 (autoFillFrom 처리) setItems((prevItems) => { return prevItems.map((item) => { if (item.id !== itemId) return item; const groupEntries = item.fieldGroups[groupId] || []; const newEntry: GroupEntry = { id: newEntryId }; // 🆕 autoFillFrom 필드 자동 채우기 (tableName으로 직접 접근) const groupFields = (componentConfig.additionalFields || []).filter((f) => 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 { // 🆕 dataRegistry에 없으면 item.originalData에서 찾기 (수정 모드) sourceData = item.originalData; console.log(`⚠️ [autoFill 추가] dataRegistry에 ${field.autoFillFromTable} 없음, originalData에서 찾기`); } } else { // 주 데이터 소스 (item.originalData) 사용 sourceData = item.originalData; console.log( `✅ [autoFill 추가] ${field.name} ← ${field.autoFillFrom} (주 소스):`, sourceData?.[field.autoFillFrom], ); } // 🆕 getFieldValue 사용하여 Entity Join 필드도 찾기 if (sourceData) { const fieldValue = getFieldValue(sourceData, field.autoFillFrom); if (fieldValue !== undefined && fieldValue !== null) { newEntry[field.name] = fieldValue; console.log(`✅ [autoFill 추가] ${field.name} 값 설정:`, fieldValue); } else { console.warn(`⚠️ [autoFill 추가] ${field.name} ← ${field.autoFillFrom} 실패`); } } }); return { ...item, fieldGroups: { ...item.fieldGroups, [groupId]: [...groupEntries, newEntry], }, }; }); }); 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 isCalculatedField = componentConfig.autoCalculation?.targetField === field.name; const commonProps = { value: value || "", disabled: componentConfig.disabled || componentConfig.readonly, placeholder: field.placeholder, required: field.required, }; // 🆕 inputType이 있으면 우선 사용, 없으면 field.type 사용 const renderType = field.inputType || field.type; // 🆕 inputType에 따라 적절한 컴포넌트 렌더링 switch (renderType) { // 기본 타입들 case "text": case "varchar": case "char": return ( handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)} maxLength={field.validation?.maxLength} className="h-10 text-sm" /> ); case "number": case "int": case "integer": case "bigint": case "decimal": case "numeric": // 🆕 계산된 단가는 천 단위 구분 및 강조 표시 if (isCalculatedField) { const numericValue = parseFloat(value) || 0; const formattedValue = new Intl.NumberFormat("ko-KR").format(numericValue); return (
자동 계산
); } return ( handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)} min={field.validation?.min} max={field.validation?.max} className="h-10 text-sm" /> ); case "date": case "timestamp": case "datetime": return ( handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)} onClick={(e) => { // 날짜 선택기 강제 열기 const target = e.target as HTMLInputElement; if (target && target.showPicker) { target.showPicker(); } }} className="h-10 cursor-pointer text-sm" /> ); case "checkbox": case "boolean": case "bool": return ( handleFieldChange(itemId, groupId, entryId, field.name, checked)} disabled={componentConfig.disabled || componentConfig.readonly} /> ); case "textarea": return (