"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(() => { 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]); // onFormDataChange는 의존성에서 제외 // 🆕 저장 요청 시에만 데이터 전달 (이벤트 리스너 방식) useEffect(() => { const handleSaveRequest = () => { if (items.length > 0 && onFormDataChange) { const dataToSave = { [component.id || "selected_items"]: items }; console.log("📝 [SelectedItemsDetailInput] 저장 요청 시 데이터 전달:", dataToSave); onFormDataChange(dataToSave); } }; // 저장 버튼 클릭 시 데이터 수집 window.addEventListener("beforeFormSave", handleSaveRequest); return () => { window.removeEventListener("beforeFormSave", handleSaveRequest); }; }, [items, component.id, onFormDataChange]); // 스타일 계산 const componentStyle: React.CSSProperties = { width: "100%", 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) => { 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, }; // 🆕 가격 관련 필드가 변경되면 자동 계산 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 { // 주 데이터 소스 (item.originalData) 사용 sourceData = item.originalData; console.log(`✅ [autoFill] ${field.name} ← ${field.autoFillFrom} (주 소스):`, sourceData[field.autoFillFrom]); } if (sourceData && sourceData[field.autoFillFrom] !== undefined) { newEntry[field.name] = sourceData[field.autoFillFrom]; } }); return { ...item, fieldGroups: { ...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 text-sm cursor-pointer" /> ); case "checkbox": case "boolean": case "bool": return ( handleFieldChange(itemId, groupId, entryId, field.name, checked)} disabled={componentConfig.disabled || componentConfig.readonly} /> ); case "textarea": return (