"use client"; import React, { useState, useEffect, useMemo, useCallback, useRef } 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 }) => { // 🆕 groupedData 추출 (DynamicComponentRenderer에서 전달) const groupedData = (props as any).groupedData || (props as any)._groupedData; // 🆕 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 isSavingRef = useRef(false); // 전체 레지스트리를 가져와서 컴포넌트 내부에서 필터링 (캐싱 문제 회피) const dataRegistry = useModalDataStore((state) => state.dataRegistry); const modalData = useMemo(() => dataRegistry[dataSourceId] || [], [dataRegistry, dataSourceId]); // 전체 dataRegistry를 사용 (모든 누적 데이터에 접근 가능) 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 (레거시 호환) // 🆕 그룹별 독립 편집 상태: { [groupId]: entryId } const [editingEntries, setEditingEntries] = useState>({}); // 🆕 코드 카테고리별 옵션 캐싱 const [codeOptions, setCodeOptions] = useState>>({}); // 디버깅 로그 (제거됨) // 🆕 필드에 codeCategory가 있으면 자동으로 옵션 로드 useEffect(() => { const loadCodeOptions = async () => { // code/category 타입 필드 + codeCategory가 있는 필드 모두 처리 const codeFields = componentConfig.additionalFields?.filter( (field) => field.inputType === "code" || field.inputType === "category", ); if (!codeFields || codeFields.length === 0) { return; } const newOptions: Record> = { ...codeOptions }; // 🆕 그룹별 sourceTable 매핑 구성 const groups = componentConfig.fieldGroups || []; const groupSourceTableMap: Record = {}; groups.forEach((g) => { if (g.sourceTable) { groupSourceTableMap[g.id] = g.sourceTable; } }); const defaultTargetTable = componentConfig.targetTable; // 테이블별 컬럼 메타데이터 캐시 const tableColumnsCache: Record = {}; const getTableColumns = async (tableName: string) => { if (tableColumnsCache[tableName]) return tableColumnsCache[tableName]; try { const { tableTypeApi } = await import("@/lib/api/screen"); const columnsResponse = await tableTypeApi.getColumns(tableName); tableColumnsCache[tableName] = columnsResponse || []; return tableColumnsCache[tableName]; } catch (error) { console.error(`❌ 테이블 컬럼 조회 실패 (${tableName}):`, error); return []; } }; for (const field of codeFields) { if (newOptions[field.name]) { continue; } // 🆕 필드의 그룹 sourceTable 결정 const fieldSourceTable = (field.groupId && groupSourceTableMap[field.groupId]) || defaultTargetTable; try { if (field.inputType === "category" && fieldSourceTable) { const { getCategoryValues } = await import("@/lib/api/tableCategoryValue"); const response = await getCategoryValues(fieldSourceTable, field.name, false); if (response.success && response.data && response.data.length > 0) { newOptions[field.name] = response.data.map((item: any) => ({ label: item.value_label || item.valueLabel, value: item.value_code || item.valueCode, })); } else { } } else if (field.inputType === "code") { let codeCategory = field.codeCategory; if (!codeCategory && fieldSourceTable) { const targetTableColumns = await getTableColumns(fieldSourceTable); if (targetTableColumns.length > 0) { const columnMeta = targetTableColumns.find( (col: any) => (col.columnName || col.column_name) === field.name, ); if (columnMeta) { codeCategory = columnMeta.codeCategory || columnMeta.code_category; } } } if (!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, })); } } } catch (error) { console.error(`❌ 옵션 로드 실패 (${field.name}):`, error); } } setCodeOptions(newOptions); }; loadCodeOptions(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [componentConfig.additionalFields, componentConfig.targetTable]); // 🆕 모달 데이터를 ItemData 구조로 변환 (그룹별 구조) useEffect(() => { // 🆕 수정 모드: groupedData 또는 formData에서 데이터 로드 (URL에 mode=edit이 있으면) const urlParams = new URLSearchParams(window.location.search); const mode = urlParams.get("mode"); // 🔧 데이터 소스 우선순위: groupedData > formData (배열) > formData (객체) const sourceData = groupedData && Array.isArray(groupedData) && groupedData.length > 0 ? groupedData : formData; if (mode === "edit" && sourceData) { const loadEditData = async () => { const isArray = Array.isArray(sourceData); const dataArray = isArray ? sourceData : [sourceData]; if (dataArray.length === 0 || (dataArray.length === 1 && Object.keys(dataArray[0]).length === 0)) { return; } const groups = componentConfig.fieldGroups || []; const additionalFields = componentConfig.additionalFields || []; const firstRecord = dataArray[0]; // 수정 모드: 모든 관련 테이블의 데이터를 API로 전체 로드 // sourceData는 클릭한 1개 레코드만 포함할 수 있으므로, API로 전체를 다시 가져옴 const editTableName = new URLSearchParams(window.location.search).get("tableName"); const allTableData: Record[]> = {}; if (firstRecord.customer_id && firstRecord.item_id) { try { const { dataApi } = await import("@/lib/api/data"); // 모든 sourceTable의 데이터를 API로 전체 로드 (중복 테이블 제거) const allTables = groups .map((g) => g.sourceTable || editTableName) .filter((v, i, a) => v && a.indexOf(v) === i) as string[]; for (const table of allTables) { const response = await dataApi.getTableData(table, { filters: { customer_id: firstRecord.customer_id, item_id: firstRecord.item_id, }, sortBy: "created_date", sortOrder: "desc", }); if (response.data && response.data.length > 0) { allTableData[table] = response.data; } } } catch (err) { console.error("❌ 편집 데이터 전체 로드 실패:", err); } } const mainFieldGroups: Record = {}; groups.forEach((group) => { const groupFields = additionalFields.filter((field: any) => field.groupId === group.id); if (groupFields.length === 0) { mainFieldGroups[group.id] = []; return; } // 이 그룹의 sourceTable 결정 → API에서 가져온 전체 데이터 사용 const groupTable = group.sourceTable || editTableName || ""; // 현재 테이블만 sourceData fallback 허용 (다른 테이블은 빈 배열 → id 크로스오염 방지) const isCurrentTable = !group.sourceTable || group.sourceTable === editTableName; const groupDataList = allTableData[groupTable] || (isCurrentTable ? dataArray : []); { // 모든 테이블 그룹: API에서 가져온 전체 레코드를 entry로 변환 const entriesMap = new Map(); groupDataList.forEach((record) => { const entryData: Record = {}; groupFields.forEach((field: any) => { let fieldValue = record[field.name]; // 값이 없으면 autoFillFrom 로직 적용 if ((fieldValue === undefined || fieldValue === null) && field.autoFillFrom) { let src: any = null; if (field.autoFillFromTable) { const tableData = dataRegistry[field.autoFillFromTable]; if (tableData && tableData.length > 0) { src = tableData[0].originalData || tableData[0]; } else { src = record; } } else { src = record; } if (src && src[field.autoFillFrom] !== undefined) { fieldValue = src[field.autoFillFrom]; } else { const possibleKeys = Object.keys(src || {}).filter((key) => key.endsWith(`_${field.autoFillFrom}`), ); if (possibleKeys.length > 0) { fieldValue = src[possibleKeys[0]]; } } } if (fieldValue === undefined || fieldValue === null) { if (field.defaultValue !== undefined) { fieldValue = field.defaultValue; } else if (field.type === "checkbox") { fieldValue = false; } else { 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}`; } } entryData[field.name] = fieldValue; }); const entryKey = JSON.stringify(entryData); if (!entriesMap.has(entryKey)) { entriesMap.set(entryKey, { id: `${group.id}_entry_${entriesMap.size + 1}`, // DB 레코드의 고유 id(UUID PK) 보존 → 수정 시 이 id로 UPDATE _dbRecordId: record.id || null, ...entryData, }); } }); mainFieldGroups[group.id] = Array.from(entriesMap.values()); } }); if (groups.length === 0) { mainFieldGroups["default"] = []; } const newItem: ItemData = { // 수정 모드: item_id를 우선 사용 (id는 가격레코드의 PK일 수 있음) id: String(firstRecord.item_id || firstRecord.id || "edit"), originalData: firstRecord, fieldGroups: mainFieldGroups, }; setItems([newItem]); }; loadEditData(); return; } // 생성 모드: modalData에서 데이터 로드 if (modalData && modalData.length > 0) { // 🆕 각 품목마다 빈 fieldGroups 객체를 가진 ItemData 생성 const groups = componentConfig.fieldGroups || []; const newItems: ItemData[] = modalData.map((item) => { const fieldGroups: Record = {}; // 각 그룹에 대해 초기화 (maxEntries === 1이면 자동 1개 생성) groups.forEach((group) => { if (group.maxEntries === 1) { // 1:1 관계: 빈 entry 1개 자동 생성 fieldGroups[group.id] = [{ id: `${group.id}_auto_1` }]; } else { 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); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [modalData, component.id, componentConfig.fieldGroups, formData, groupedData]); // groupedData 의존성 추가 // 🆕 Cartesian Product 생성 함수 (items에서 모든 그룹의 조합을 생성) const generateCartesianProduct = useCallback( (itemsList: ItemData[]): Record[] => { const allRecords: Record[] = []; const groups = componentConfig.fieldGroups || []; const additionalFields = componentConfig.additionalFields || []; itemsList.forEach((item, itemIndex) => { // 각 그룹의 엔트리 배열들을 준비 // 🔧 빈 엔트리 필터링: id만 있고 실제 필드 값이 없는 엔트리는 제외 const groupEntriesArrays: GroupEntry[][] = groups.map((group) => { const entries = item.fieldGroups[group.id] || []; const groupFields = additionalFields.filter((f) => f.groupId === group.id); // 실제 필드 값이 하나라도 있는 엔트리만 포함 return entries.filter((entry) => { const hasAnyFieldValue = groupFields.some((field) => { const value = entry[field.name]; return value !== undefined && value !== null && value !== ""; }); return hasAnyFieldValue; }); }); // 🆕 모든 그룹이 비어있는지 확인 const allGroupsEmpty = groupEntriesArrays.every((arr) => arr.length === 0); if (allGroupsEmpty) { // 디테일 데이터가 없어도 기본 레코드 생성 (품목-거래처 매핑 유지) // autoFillFrom 필드 (item_id 등)는 반드시 포함시켜야 나중에 식별 가능 const baseRecord: Record = {}; additionalFields.forEach((f) => { if (f.autoFillFrom && item.originalData) { const value = item.originalData[f.autoFillFrom]; if (value !== undefined && value !== null) { baseRecord[f.name] = value; } } }); allRecords.push(baseRecord); 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 }; // 🆕 기존 레코드의 id가 있으면 포함 (UPDATE를 위해) if (entry.id) { newCombination.id = entry.id; } // 현재 그룹의 필드들을 조합에 추가 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, {}); }); return allRecords; }, [componentConfig.fieldGroups, componentConfig.additionalFields], ); // 🆕 저장 요청 시에만 데이터 전달 (이벤트 리스너 방식) useEffect(() => { const handleSaveRequest = async (event: Event) => { // 중복 저장 방지 // 항상 skipDefaultSave 설정 (buttonActions.ts의 이중 저장 방지) if (event instanceof CustomEvent && event.detail) { (event.detail as any).skipDefaultSave = true; } if (isSavingRef.current) return; isSavingRef.current = true; // component.id를 문자열로 안전하게 변환 const componentKey = String(component.id || "selected_items"); if (items.length === 0) { isSavingRef.current = false; return; } // parentDataMapping이 있으면 UPSERT API로 직접 저장 const hasParentMapping = componentConfig.parentDataMapping && componentConfig.parentDataMapping.length > 0; if (hasParentMapping) { try { // 부모 키 추출 (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 || {}; } componentConfig.parentDataMapping.forEach((mapping) => { // 1차: formData(sourceData)에서 찾기 let value = getFieldValue(sourceData, mapping.sourceField); // 2차: formData에 없으면 dataRegistry[sourceTable]에서 찾기 // v2-split-panel-layout에서 좌측 항목 선택 시 dataRegistry에 저장한 데이터 활용 if ((value === undefined || value === null) && mapping.sourceTable) { const registryData = dataRegistry[mapping.sourceTable]; if (registryData && registryData.length > 0) { const registryItem = registryData[0].originalData || registryData[0]; value = registryItem[mapping.sourceField]; } } if (value !== undefined && value !== null) { parentKeys[mapping.targetField] = value; } else { console.warn(`⚠️ 부모 키 누락: ${mapping.sourceField} → ${mapping.targetField}`); } }); // 🔒 parentKeys 유효성 검증 - 빈 값이 있으면 저장 중단 const parentKeyValues = Object.values(parentKeys); const hasEmptyParentKey = parentKeyValues.length === 0 || parentKeyValues.some(v => v === null || v === undefined || v === ""); if (hasEmptyParentKey) { console.error("❌ parentKeys 비어있음:", parentKeys); window.dispatchEvent( new CustomEvent("formSaveError", { detail: { message: "부모 키 값이 비어있어 저장할 수 없습니다. 먼저 상위 데이터를 선택해주세요." }, }), ); // 🔧 기본 저장 건너뛰기 - event.detail 객체 직접 수정 if (event instanceof CustomEvent && event.detail) { (event.detail as any).skipDefaultSave = true; } isSavingRef.current = false; return; } // targetTable 검증 if (!componentConfig.targetTable) { window.dispatchEvent( new CustomEvent("formSaveError", { detail: { message: "대상 테이블이 설정되지 않았습니다." }, }), ); if (event instanceof CustomEvent && event.detail) { (event.detail as any).skipDefaultSave = true; } isSavingRef.current = false; return; } // 🔧 기본 저장 건너뛰기 설정 (UPSERT 전에!) if (event instanceof CustomEvent && event.detail) { (event.detail as any).skipDefaultSave = true; } const { dataApi } = await import("@/lib/api/data"); const groups = componentConfig.fieldGroups || []; const additionalFields = componentConfig.additionalFields || []; const mainTable = componentConfig.targetTable!; // 수정 모드 감지 (2가지 방법으로 확인) // 1. URL에 mode=edit 파라미터 확인 // 2. 로드된 데이터에 DB id(PK)가 존재하는지 확인 // 수정 모드에서는 항상 deleteOrphans=true (기존 레코드 교체, 복제 방지) const urlParams = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : null; const urlEditMode = urlParams?.get("mode") === "edit"; const dataHasDbId = items.some(item => !!item.originalData?.id); const isEditMode = urlEditMode || dataHasDbId; console.log("[SelectedItemsDetailInput] 수정 모드 감지:", { urlEditMode, dataHasDbId, isEditMode, itemCount: items.length, firstItemId: items[0]?.originalData?.id, }); // fieldGroup별 sourceTable 분류 const groupsByTable = new Map(); groups.forEach((group) => { const table = group.sourceTable || mainTable; if (!groupsByTable.has(table)) { groupsByTable.set(table, []); } groupsByTable.get(table)!.push(group); }); // 디테일 테이블이 있는지 확인 (mainTable과 다른 sourceTable) const detailTables = [...groupsByTable.keys()].filter((t) => t !== mainTable); const hasDetailTable = detailTables.length > 0; if (hasDetailTable) { // ============================================================ // 2단계 저장: 메인 테이블 + 디테일 테이블 분리 저장 // upsertGroupedRecords를 양쪽 모두 사용 (정확한 매칭 보장) // ============================================================ const mainGroups = groupsByTable.get(mainTable) || []; for (const item of items) { // item_id 추출: originalData.item_id를 최우선 사용 // (수정 모드에서 autoFillFrom:"id"가 가격 레코드 PK를 반환하는 문제 방지) let itemId: string | null = null; // 1순위: originalData에 item_id가 직접 있으면 사용 (수정 모드에서 정확한 값) if (item.originalData && item.originalData.item_id) { itemId = item.originalData.item_id; } // 2순위: autoFillFrom 로직 (신규 등록 모드에서 사용) if (!itemId) { mainGroups.forEach((group) => { const groupFields = additionalFields.filter((f) => f.groupId === group.id); groupFields.forEach((field) => { if (field.name === "item_id" && field.autoFillFrom && item.originalData) { itemId = item.originalData[field.autoFillFrom] || null; } }); }); } // 3순위: fallback (최후의 수단) if (!itemId && item.originalData) { itemId = item.originalData.id || null; } if (!itemId) { console.error("❌ [2단계 저장] item_id를 찾을 수 없음:", item); continue; } // upsert 공통 parentKeys: customer_id + item_id (정확한 매칭) const itemParentKeys = { ...parentKeys, item_id: itemId }; // === Step 1: 메인 테이블(customer_item_mapping) 저장 === // 여러 개의 매핑 레코드 지원 (거래처 품번/품명이 다중일 수 있음) const mappingRecords: Record[] = []; mainGroups.forEach((group) => { const entries = item.fieldGroups[group.id] || []; const groupFields = additionalFields.filter((f) => f.groupId === group.id); entries.forEach((entry) => { const record: Record = {}; groupFields.forEach((field) => { const val = entry[field.name]; if (val !== undefined && val !== null && val !== "") { record[field.name] = val; } }); // 기존 DB 레코드의 고유 id(PK)가 있으면 포함 → 백엔드에서 이 id로 UPDATE if (entry._dbRecordId) { record.id = entry._dbRecordId; } // item_id는 정확한 itemId 변수 사용 (autoFillFrom:"id" 오작동 방지) record.item_id = itemId; // 나머지 autoFillFrom 필드 처리 groupFields.forEach((field) => { if (field.name !== "item_id" && field.autoFillFrom && item.originalData) { const value = item.originalData[field.autoFillFrom]; if (value !== undefined && value !== null && !record[field.name]) { record[field.name] = value; } } }); mappingRecords.push(record); }); }); // 수정 모드이거나 레코드에 id(기존 DB PK)가 있으면 → 고아 삭제 (기존 레코드 교체) // 신규 등록이고 id 없으면 → 기존 레코드 건드리지 않음 const mappingHasDbIds = mappingRecords.some((r) => !!r.id); const shouldDeleteOrphans = isEditMode || mappingHasDbIds; console.log(`[SelectedItemsDetailInput] ${mainTable} 저장:`, { isEditMode, mappingHasDbIds, shouldDeleteOrphans, recordCount: mappingRecords.length, recordIds: mappingRecords.map(r => r.id || "NEW"), parentKeys: itemParentKeys, }); // 저장된 매핑 ID를 추적 (디테일 테이블에 mapping_id 주입용) let savedMappingIds: string[] = []; try { const mappingResult = await dataApi.upsertGroupedRecords( mainTable, itemParentKeys, mappingRecords, { deleteOrphans: shouldDeleteOrphans }, ); // 백엔드에서 반환된 저장된 레코드 ID 목록 if (mappingResult.success && mappingResult.savedIds) { savedMappingIds = mappingResult.savedIds; console.log(`✅ ${mainTable} 저장 완료, savedIds:`, savedMappingIds); } } catch (err) { console.error(`❌ ${mainTable} 저장 실패:`, err); } // === Step 2: 디테일 테이블(customer_item_prices) 저장 === for (const detailTable of detailTables) { const detailGroups = groupsByTable.get(detailTable) || []; const priceRecords: Record[] = []; detailGroups.forEach((group) => { const entries = item.fieldGroups[group.id] || []; const groupFields = additionalFields.filter((f) => f.groupId === group.id); entries.forEach((entry) => { // 사용자가 실제 입력한 값이 있는지 확인 // select/category 필드는 항상 기본값이 있으므로 제외하고 판별 const hasUserInput = groupFields.some((field) => { // 셀렉트/카테고리 필드는 기본값이 자동 설정되므로 무시 if (field.type === "select" || field.inputType === "code" || field.inputType === "category") { return false; } const value = entry[field.name]; if (value === undefined || value === null || value === "") return false; if (value === 0 || value === "0" || value === "0.00") return false; return true; }); if (hasUserInput) { const priceRecord: Record = {}; groupFields.forEach((field) => { const val = entry[field.name]; if (val !== undefined && val !== null) { priceRecord[field.name] = val; } }); // 기존 DB 레코드의 고유 id(PK)가 있으면 포함 → 백엔드에서 이 id로 UPDATE if (entry._dbRecordId) { priceRecord.id = entry._dbRecordId; } priceRecords.push(priceRecord); } }); }); // 빈 항목이라도 최소 레코드 생성 (우측 패널에 표시되도록) if (priceRecords.length === 0) { // select/category 필드를 명시적 null로 설정 (DB DEFAULT 'KRW' 등 방지) const emptyRecord: Record = {}; const detailGroupFields = additionalFields.filter((f) => detailGroups.some((g) => g.id === f.groupId), ); detailGroupFields.forEach((field) => { if (field.type === "select" || field.inputType === "code" || field.inputType === "category") { emptyRecord[field.name] = null; } }); priceRecords.push(emptyRecord); } // Step1에서 저장된 매핑 ID를 디테일 레코드에 주입 // (customer_item_prices.mapping_id ← customer_item_mapping.id) if (savedMappingIds.length > 0) { const mappingId = savedMappingIds[0]; // 일반적으로 1:N (매핑 1개 : 단가 N개) priceRecords.forEach((record) => { if (!record.mapping_id) { record.mapping_id = mappingId; } }); console.log(`🔗 디테일 레코드에 mapping_id 주입: ${mappingId}`); } const priceHasDbIds = priceRecords.some((r) => !!r.id); const shouldDeleteDetailOrphans = isEditMode || priceHasDbIds; console.log(`[SelectedItemsDetailInput] ${detailTable} 저장:`, { isEditMode, priceHasDbIds, shouldDeleteDetailOrphans, recordCount: priceRecords.length, recordIds: priceRecords.map(r => r.id || "NEW"), parentKeys: itemParentKeys, }); try { const detailResult = await dataApi.upsertGroupedRecords( detailTable, itemParentKeys, priceRecords, { deleteOrphans: shouldDeleteDetailOrphans }, ); if (!detailResult.success) { console.error(`❌ ${detailTable} 저장 실패:`, detailResult.error); } } catch (err) { console.error(`❌ ${detailTable} 오류:`, err); } } } // 저장 성공 이벤트 + 테이블 새로고침 (모든 아이템 저장 완료 후) window.dispatchEvent( new CustomEvent("formSaveSuccess", { detail: { message: "데이터가 저장되었습니다." }, }), ); // 분할 패널 우측 데이터 새로고침 window.dispatchEvent(new CustomEvent("refreshTable")); } else { // ============================================================ // 단일 테이블 저장 (기존 로직 - detailTable 없는 경우) // ============================================================ const records = generateCartesianProduct(items); const singleHasDbIds = records.some((r) => !!r.id); const shouldDeleteSingleOrphans = isEditMode || singleHasDbIds; const result = await dataApi.upsertGroupedRecords(mainTable, parentKeys, records, { deleteOrphans: shouldDeleteSingleOrphans }); if (result.success) { window.dispatchEvent( new CustomEvent("formSaveSuccess", { detail: { message: "데이터가 저장되었습니다." }, }), ); } else { window.dispatchEvent( new CustomEvent("formSaveError", { detail: { message: result.error || "데이터 저장 실패" }, }), ); } } } catch (error) { console.error("❌ UPSERT 오류:", error); window.dispatchEvent( new CustomEvent("formSaveError", { detail: { message: "데이터 저장 중 오류가 발생했습니다." }, }), ); // 🆕 오류 발생 시에도 기본 저장 건너뛰기 (중복 저장 방지) if (event instanceof CustomEvent && event.detail) { event.detail.skipDefaultSave = true; } } finally { // 저장 완료 후 가드 해제 isSavingRef.current = false; } } else { // 생성 모드: 기존 로직 if (event instanceof CustomEvent && event.detail) { event.detail.formData[componentKey] = items; } // 기존 onFormDataChange도 호출 (호환성) if (onFormDataChange) { onFormDataChange(componentKey, items); } isSavingRef.current = false; } }; // 저장 버튼 클릭 시 데이터 수집 window.addEventListener("beforeFormSave", handleSaveRequest as EventListener); return () => { window.removeEventListener("beforeFormSave", handleSaveRequest as EventListener); }; }, [items, component.id, onFormDataChange, componentConfig, formData, generateCartesianProduct, dataRegistry]); // 스타일 계산 const componentStyle: React.CSSProperties = { width: "100%", height: "100%", overflowY: "auto", // 항목이 많을 때 스크롤 지원 ...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 getOptionLabel = useCallback( (fieldName: string, valueCode: string): string => { const options = codeOptions[fieldName] || []; const matched = options.find((opt) => opt.value === valueCode); return matched?.label || valueCode || ""; }, [codeOptions], ); // 🆕 실시간 단가 계산 함수 (라벨명 기반 - 회사별 코드 무관) const calculatePrice = useCallback( (entry: GroupEntry): number => { if (!componentConfig.autoCalculation) return 0; const { inputFields } = componentConfig.autoCalculation; // 기본 단가 const basePrice = parseFloat(entry[inputFields.basePrice] || "0"); if (basePrice === 0) return 0; let price = basePrice; // 1단계: 할인 적용 (라벨명으로 판단) const discountTypeCode = entry[inputFields.discountType]; const discountTypeLabel = getOptionLabel("discount_type", discountTypeCode); const discountValue = parseFloat(entry[inputFields.discountValue] || "0"); if (discountTypeLabel.includes("할인율") || discountTypeLabel.includes("%")) { // 할인율(%) price = price * (1 - discountValue / 100); } else if (discountTypeLabel.includes("할인금액") || discountTypeLabel.includes("금액")) { // 할인금액 price = price - discountValue; } // "할인없음"이면 그대로 // 2단계: 반올림 적용 // rounding_type = 단위 (10원, 100원, 1000원) // rounding_unit_value = 방법 (반올림, 절삭, 올림, 반올림없음) const roundingTypeCode = entry[inputFields.roundingType]; const roundingTypeLabel = getOptionLabel("rounding_type", roundingTypeCode); const roundingUnitCode = entry[inputFields.roundingUnit]; const roundingUnitLabel = getOptionLabel("rounding_unit_value", roundingUnitCode); // roundingType 라벨에서 단위 숫자 추출 (예: "10원" → 10, "1000원" → 1000) const unitMatch = roundingTypeLabel.match(/(\d+)/); const unit = unitMatch ? parseInt(unitMatch[1]) : parseFloat(roundingTypeCode) || 1; const priceBeforeRounding = price; // roundingUnit 라벨로 반올림 방법 결정 if (roundingUnitLabel.includes("없음") || !roundingUnitCode) { // 반올림없음: 할인 적용된 원래 값 그대로 // price 변경 없음 } else if (roundingUnitLabel.includes("절삭")) { price = Math.floor(price / unit) * unit; } else if (roundingUnitLabel.includes("올림")) { price = Math.ceil(price / unit) * unit; } else if (roundingUnitLabel.includes("반올림")) { price = Math.round(price / unit) * unit; } return price; }, [componentConfig.autoCalculation, getOptionLabel], ); // 🆕 그룹별 필드 변경 핸들러: itemId + groupId + entryId + fieldName const handleFieldChange = useCallback( (itemId: string, groupId: string, entryId: string, fieldName: string, value: any) => { 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) { const currentEntry = groupEntries[existingEntryIndex]; // 날짜 검증: 종료일이 시작일보다 앞서면 차단 if (fieldName === "end_date" && value && currentEntry.start_date) { if (new Date(value) < new Date(currentEntry.start_date as string)) { alert("종료일은 시작일보다 이후여야 합니다."); return item; // 변경 취소 } } if (fieldName === "start_date" && value && currentEntry.end_date) { if (new Date(value) > new Date(currentEntry.end_date as string)) { alert("시작일은 종료일보다 이전이어야 합니다."); return item; // 변경 취소 } } // 기존 entry 업데이트 const updatedEntries = [...groupEntries]; 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; } } updatedEntries[existingEntryIndex] = updatedEntry; return { ...item, fieldGroups: { ...item.fieldGroups, [groupId]: updatedEntries, }, }; } else { // 이 경로는 발생하면 안 됨 (handleAddGroupEntry에서 미리 추가함) 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]; } else { sourceData = item.originalData; } } else { sourceData = item.originalData; } // 🆕 getFieldValue 사용하여 Entity Join 필드도 찾기 if (sourceData) { const fieldValue = getFieldValue(sourceData, field.autoFillFrom); if (fieldValue !== undefined && fieldValue !== null) { newEntry[field.name] = fieldValue; } } }); return { ...item, fieldGroups: { ...item.fieldGroups, [groupId]: [...groupEntries, newEntry], }, }; }); }); setIsEditing(true); setEditingItemId(itemId); setEditingDetailId(newEntryId); setEditingGroupId(groupId); // 그룹별 독립 편집: 해당 그룹만 열기 (다른 그룹은 유지) setEditingEntries((prev) => ({ ...prev, [groupId]: newEntryId })); }; // 🆕 그룹 항목 제거 핸들러 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), }, }; }), ); // 제거된 항목이 편집 중이었으면 해당 그룹 편집 닫기 setEditingEntries((prev) => { if (prev[groupId] === entryId) { const next = { ...prev }; delete next[groupId]; return next; } return prev; }); }; // 🆕 그룹 항목 편집 핸들러 (클릭하면 수정 가능) - 독립 편집 const handleEditGroupEntry = (itemId: string, groupId: string, entryId: string) => { setIsEditing(true); setEditingItemId(itemId); setEditingGroupId(groupId); setEditingDetailId(entryId); // 그룹별 독립 편집: 해당 그룹만 토글 (다른 그룹은 유지) setEditingEntries((prev) => ({ ...prev, [groupId]: entryId })); }; // 🆕 특정 그룹의 편집 닫기 (다른 그룹은 유지) const closeGroupEditing = (groupId: string) => { setEditingEntries((prev) => { const next = { ...prev }; delete next[groupId]; return next; }); }; // 🆕 다음 품목으로 이동 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-7 text-xs" /> ); case "number": case "int": case "integer": case "bigint": case "decimal": case "numeric": { // 숫자 포맷팅 헬퍼: 콤마 표시 + 실제 값은 숫자만 저장 const rawNum = value ? String(value).replace(/,/g, "") : ""; const displayNum = rawNum && !isNaN(Number(rawNum)) ? new Intl.NumberFormat("ko-KR").format(Number(rawNum)) : rawNum; // 계산된 단가는 읽기 전용 + 강조 표시 if (isCalculatedField) { return (
자동 계산
); } return ( { // 콤마 제거 후 숫자만 저장 const cleaned = e.target.value.replace(/,/g, "").replace(/[^0-9.\-]/g, ""); handleFieldChange(itemId, groupId, entryId, field.name, cleaned); }} className="h-7 text-xs" /> ); } 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-7 cursor-pointer text-xs" /> ); case "checkbox": case "boolean": case "bool": return ( handleFieldChange(itemId, groupId, entryId, field.name, checked)} disabled={componentConfig.disabled || componentConfig.readonly} /> ); case "textarea": return (