"use client"; import React, { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { ComponentRendererProps } from "../../types"; import { SplitPanelLayoutConfig } from "./types"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { Plus, Search, GripVertical, Loader2, ChevronDown, ChevronUp, Save, ChevronRight, Pencil, Trash2, } from "lucide-react"; import { dataApi } from "@/lib/api/data"; import { entityJoinApi } from "@/lib/api/entityJoin"; import { useToast } from "@/hooks/use-toast"; import { tableTypeApi } from "@/lib/api/screen"; import { apiClient } from "@/lib/api/client"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, } from "@/components/ui/dialog"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Label } from "@/components/ui/label"; import { useTableOptions } from "@/contexts/TableOptionsContext"; import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-options"; import { useAuth } from "@/hooks/useAuth"; import { useSplitPanel } from "./SplitPanelContext"; export interface SplitPanelLayoutComponentProps extends ComponentRendererProps { // 추가 props } /** * SplitPanelLayout 컴포넌트 * 마스터-디테일 패턴의 좌우 분할 레이아웃 */ export const SplitPanelLayoutComponent: React.FC = ({ component, isDesignMode = false, isSelected = false, isPreview = false, onClick, ...props }) => { const componentConfig = (component.componentConfig || {}) as SplitPanelLayoutConfig; // 기본 설정값 const splitRatio = componentConfig.splitRatio || 30; const resizable = componentConfig.resizable ?? true; const minLeftWidth = componentConfig.minLeftWidth || 200; const minRightWidth = componentConfig.minRightWidth || 300; // 필드 표시 유틸리티 (하드코딩 제거, 동적으로 작동) const shouldShowField = (fieldName: string): boolean => { const lower = fieldName.toLowerCase(); // 기본 제외: id, 비밀번호, 토큰, 회사코드 if (lower === "id" || lower === "company_code" || lower === "company_name") return false; if (lower.includes("password") || lower.includes("token")) return false; // 나머지는 모두 표시! return true; }; // 🆕 엔티티 조인 컬럼명 변환 헬퍼 // "테이블명.컬럼명" 형식을 "원본컬럼_조인컬럼명" 형식으로 변환하여 데이터 접근 const getEntityJoinValue = useCallback( (item: any, columnName: string, entityColumnMap?: Record): any => { // 직접 매칭 시도 if (item[columnName] !== undefined) { return item[columnName]; } // "테이블명.컬럼명" 형식인 경우 (예: item_info.item_name) if (columnName.includes(".")) { const [tableName, fieldName] = columnName.split("."); // 🔍 엔티티 조인 컬럼 값 추출 // 예: item_info.item_name, item_info.standard, item_info.unit // 1️⃣ 소스 컬럼 추론 (item_info → item_code, warehouse_info → warehouse_id 등) const inferredSourceColumn = tableName.replace("_info", "_code").replace("_mng", "_id"); // 2️⃣ 정확한 키 매핑 시도: 소스컬럼_필드명 // 예: item_code_item_name, item_code_standard, item_code_unit const exactKey = `${inferredSourceColumn}_${fieldName}`; if (item[exactKey] !== undefined) { return item[exactKey]; } // 🆕 2-1️⃣ item_id 패턴 시도 (백엔드가 item_id_xxx 형식으로 반환하는 경우) // 예: item_info.item_name → item_id_item_name const idPatternKey = `${tableName.replace("_info", "_id").replace("_mng", "_id")}_${fieldName}`; if (item[idPatternKey] !== undefined) { return item[idPatternKey]; } // 3️⃣ 별칭 패턴: 소스컬럼_name (기본 표시 컬럼용) // 예: item_code_name (item_name의 별칭) if (fieldName === "item_name" || fieldName === "name") { const aliasKey = `${inferredSourceColumn}_name`; if (item[aliasKey] !== undefined) { return item[aliasKey]; } // 🆕 item_id_name 패턴도 시도 const idAliasKey = `${tableName.replace("_info", "_id").replace("_mng", "_id")}_name`; if (item[idAliasKey] !== undefined) { return item[idAliasKey]; } } // 4️⃣ entityColumnMap에서 매핑 찾기 (화면 설정에서 지정된 경우) if (entityColumnMap && entityColumnMap[tableName]) { const sourceColumn = entityColumnMap[tableName]; const joinedColumnName = `${sourceColumn}_${fieldName}`; if (item[joinedColumnName] !== undefined) { return item[joinedColumnName]; } } // 5️⃣ 테이블명_컬럼명 형식으로 시도 const underscoreKey = `${tableName}_${fieldName}`; if (item[underscoreKey] !== undefined) { return item[underscoreKey]; } // 6️⃣ 🆕 모든 키에서 _fieldName으로 끝나는 키 찾기 // 예: partner_id_customer_name (프론트엔드가 customer_id로 추론했지만 실제는 partner_id인 경우) const matchingKey = Object.keys(item).find((key) => key.endsWith(`_${fieldName}`)); if (matchingKey && item[matchingKey] !== undefined) { return item[matchingKey]; } } return undefined; }, [], ); // TableOptions Context const { registerTable, unregisterTable } = useTableOptions(); const [leftFilters, setLeftFilters] = useState([]); const [leftGrouping, setLeftGrouping] = useState([]); const [leftColumnVisibility, setLeftColumnVisibility] = useState([]); const [leftColumnOrder, setLeftColumnOrder] = useState([]); // 🔧 컬럼 순서 const [leftGroupSumConfig, setLeftGroupSumConfig] = useState(null); // 🆕 그룹별 합산 설정 const [rightFilters, setRightFilters] = useState([]); const [rightGrouping, setRightGrouping] = useState([]); const [rightColumnVisibility, setRightColumnVisibility] = useState([]); // 데이터 상태 const [leftData, setLeftData] = useState([]); const [rightData, setRightData] = useState(null); // 조인 모드는 배열, 상세 모드는 객체 const [selectedLeftItem, setSelectedLeftItem] = useState(null); const [expandedRightItems, setExpandedRightItems] = useState>(new Set()); // 확장된 우측 아이템 const [leftSearchQuery, setLeftSearchQuery] = useState(""); const [rightSearchQuery, setRightSearchQuery] = useState(""); const [isLoadingLeft, setIsLoadingLeft] = useState(false); const [isLoadingRight, setIsLoadingRight] = useState(false); // 🆕 추가 탭 관련 상태 const [activeTabIndex, setActiveTabIndex] = useState(0); // 0 = 기본 탭 (우측 패널), 1+ = 추가 탭 const [tabsData, setTabsData] = useState>({}); // 탭별 데이터 캐시 const [tabsLoading, setTabsLoading] = useState>({}); // 탭별 로딩 상태 const [rightTableColumns, setRightTableColumns] = useState([]); // 우측 테이블 컬럼 정보 const [expandedItems, setExpandedItems] = useState>(new Set()); // 펼쳐진 항목들 const [leftColumnLabels, setLeftColumnLabels] = useState>({}); // 좌측 컬럼 라벨 const [rightColumnLabels, setRightColumnLabels] = useState>({}); // 우측 컬럼 라벨 const [leftCategoryMappings, setLeftCategoryMappings] = useState< Record> >({}); // 좌측 카테고리 매핑 const [rightCategoryMappings, setRightCategoryMappings] = useState< Record> >({}); // 우측 카테고리 매핑 // 카테고리 코드 라벨 캐시 (CATEGORY_* 코드 -> 라벨) const [categoryCodeLabels, setCategoryCodeLabels] = useState>({}); const { toast } = useToast(); // 추가 모달 상태 const [showAddModal, setShowAddModal] = useState(false); const [addModalPanel, setAddModalPanel] = useState<"left" | "right" | "left-item" | null>(null); const [addModalFormData, setAddModalFormData] = useState>({}); // 수정 모달 상태 const [showEditModal, setShowEditModal] = useState(false); const [editModalPanel, setEditModalPanel] = useState<"left" | "right" | null>(null); const [editModalItem, setEditModalItem] = useState(null); const [editModalFormData, setEditModalFormData] = useState>({}); // 삭제 확인 모달 상태 const [showDeleteModal, setShowDeleteModal] = useState(false); const [deleteModalPanel, setDeleteModalPanel] = useState<"left" | "right" | null>(null); const [deleteModalItem, setDeleteModalItem] = useState(null); // 리사이저 드래그 상태 const [isDragging, setIsDragging] = useState(false); const [leftWidth, setLeftWidth] = useState(splitRatio); const containerRef = React.useRef(null); // 🆕 SplitPanel Resize Context 연동 (버튼 등 외부 컴포넌트와 드래그 리사이즈 상태 공유) const splitPanelContext = useSplitPanel(); const { registerSplitPanel: ctxRegisterSplitPanel, unregisterSplitPanel: ctxUnregisterSplitPanel, updateSplitPanel: ctxUpdateSplitPanel, } = splitPanelContext; const splitPanelId = `split-panel-${component.id}`; // 디버깅: Context 연결 상태 확인 // console.log("🔗 [SplitPanelLayout] Context 연결 상태:", { // componentId: component.id, // splitPanelId, // hasRegisterFunc: typeof ctxRegisterSplitPanel === "function", // splitPanelsSize: splitPanelContext.splitPanels?.size ?? "없음", // }); // Context에 분할 패널 등록 (좌표 정보 포함) - 마운트 시 1회만 실행 const ctxRegisterRef = useRef(ctxRegisterSplitPanel); const ctxUnregisterRef = useRef(ctxUnregisterSplitPanel); ctxRegisterRef.current = ctxRegisterSplitPanel; ctxUnregisterRef.current = ctxUnregisterSplitPanel; useEffect(() => { // 컴포넌트의 위치와 크기 정보 const panelX = component.position?.x || 0; const panelY = component.position?.y || 0; const panelWidth = component.size?.width || component.style?.width || 800; const panelHeight = component.size?.height || component.style?.height || 600; const panelInfo = { x: panelX, y: panelY, width: typeof panelWidth === "number" ? panelWidth : parseInt(String(panelWidth)) || 800, height: typeof panelHeight === "number" ? panelHeight : parseInt(String(panelHeight)) || 600, leftWidthPercent: splitRatio, // 초기값은 splitRatio 사용 initialLeftWidthPercent: splitRatio, isDragging: false, }; // console.log("📦 [SplitPanelLayout] Context에 분할 패널 등록:", { // splitPanelId, // panelInfo, // }); ctxRegisterRef.current(splitPanelId, panelInfo); return () => { // console.log("📦 [SplitPanelLayout] Context에서 분할 패널 해제:", splitPanelId); ctxUnregisterRef.current(splitPanelId); }; // 마운트/언마운트 시에만 실행, 위치/크기 변경은 별도 업데이트로 처리 // eslint-disable-next-line react-hooks/exhaustive-deps }, [splitPanelId]); // 위치/크기 변경 시 Context 업데이트 (등록 후) const ctxUpdateRef = useRef(ctxUpdateSplitPanel); ctxUpdateRef.current = ctxUpdateSplitPanel; useEffect(() => { const panelX = component.position?.x || 0; const panelY = component.position?.y || 0; const panelWidth = component.size?.width || component.style?.width || 800; const panelHeight = component.size?.height || component.style?.height || 600; ctxUpdateRef.current(splitPanelId, { x: panelX, y: panelY, width: typeof panelWidth === "number" ? panelWidth : parseInt(String(panelWidth)) || 800, height: typeof panelHeight === "number" ? panelHeight : parseInt(String(panelHeight)) || 600, }); }, [ splitPanelId, component.position?.x, component.position?.y, component.size?.width, component.size?.height, component.style?.width, component.style?.height, ]); // leftWidth 변경 시 Context 업데이트 useEffect(() => { ctxUpdateRef.current(splitPanelId, { leftWidthPercent: leftWidth }); }, [leftWidth, splitPanelId]); // 드래그 상태 변경 시 Context 업데이트 // 이전 드래그 상태를 추적하여 드래그 종료 시점을 감지 const prevIsDraggingRef = useRef(false); useEffect(() => { const wasJustDragging = prevIsDraggingRef.current && !isDragging; if (isDragging) { // 드래그 시작 시: 현재 비율을 초기 비율로 저장 ctxUpdateRef.current(splitPanelId, { isDragging: true, initialLeftWidthPercent: leftWidth, }); } else if (wasJustDragging) { // 드래그 종료 시: 최종 비율을 초기 비율로 업데이트 (버튼 위치 고정) ctxUpdateRef.current(splitPanelId, { isDragging: false, initialLeftWidthPercent: leftWidth, }); console.log("🛑 [SplitPanelLayout] 드래그 종료 - 버튼 위치 고정:", { splitPanelId, finalLeftWidthPercent: leftWidth, }); } prevIsDraggingRef.current = isDragging; }, [isDragging, splitPanelId, leftWidth]); // 🆕 그룹별 합산된 데이터 계산 const summedLeftData = useMemo(() => { // console.log("🔍 [그룹합산] leftGroupSumConfig:", leftGroupSumConfig); // 그룹핑이 비활성화되었거나 그룹 기준 컬럼이 없으면 원본 데이터 반환 if (!leftGroupSumConfig?.enabled || !leftGroupSumConfig?.groupByColumn) { // console.log("🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환"); return leftData; } const groupByColumn = leftGroupSumConfig.groupByColumn; const groupMap = new Map(); // 조인 컬럼인지 확인하고 실제 키 추론 const getActualKey = (columnName: string, item: any): string => { if (columnName.includes(".")) { const [refTable, fieldName] = columnName.split("."); const inferredSourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id"); const exactKey = `${inferredSourceColumn}_${fieldName}`; console.log("🔍 [그룹합산] 조인 컬럼 키 변환:", { columnName, exactKey, hasKey: item[exactKey] !== undefined }); if (item[exactKey] !== undefined) return exactKey; if (fieldName === "item_name" || fieldName === "name") { const aliasKey = `${inferredSourceColumn}_name`; if (item[aliasKey] !== undefined) return aliasKey; } } return columnName; }; // 숫자 타입인지 확인하는 함수 const isNumericValue = (value: any): boolean => { if (value === null || value === undefined || value === "") return false; const num = parseFloat(String(value)); return !isNaN(num) && isFinite(num); }; // 그룹핑 수행 leftData.forEach((item) => { const actualKey = getActualKey(groupByColumn, item); const groupValue = String(item[actualKey] || item[groupByColumn] || ""); // 원본 ID 추출 (id, ID, 또는 첫 번째 값) const originalId = item.id || item.ID || Object.values(item)[0]; if (!groupMap.has(groupValue)) { // 첫 번째 항목을 기준으로 초기화 + 원본 ID 배열 + 원본 데이터 배열 groupMap.set(groupValue, { ...item, _groupCount: 1, _originalIds: [originalId], _originalItems: [item], // 🆕 원본 데이터 전체 저장 }); } else { const existing = groupMap.get(groupValue); existing._groupCount += 1; existing._originalIds.push(originalId); existing._originalItems.push(item); // 🆕 원본 데이터 추가 // 모든 키에 대해 숫자면 합산 Object.keys(item).forEach((key) => { const value = item[key]; if (isNumericValue(value) && key !== groupByColumn && !key.endsWith("_id") && !key.includes("code")) { const numValue = parseFloat(String(value)); const existingValue = parseFloat(String(existing[key] || 0)); existing[key] = existingValue + numValue; } }); groupMap.set(groupValue, existing); } }); const result = Array.from(groupMap.values()); console.log("🔗 [분할패널] 그룹별 합산 결과:", { 원본개수: leftData.length, 그룹개수: result.length, 그룹기준: groupByColumn, }); return result; }, [leftData, leftGroupSumConfig]); // 컴포넌트 스타일 // height 처리: 이미 px 단위면 그대로, 숫자면 px 추가 const getHeightValue = () => { const height = component.style?.height; if (!height) return "600px"; if (typeof height === "string") return height; // 이미 '540px' 형태 return `${height}px`; // 숫자면 px 추가 }; const componentStyle: React.CSSProperties = isPreview ? { // 반응형 모드: position relative, 그리드 컨테이너가 제공하는 크기 사용 position: "relative", width: "100%", // 🆕 부모 컨테이너 너비에 맞춤 height: getHeightValue(), border: "1px solid #e5e7eb", } : { // 디자이너 모드: position absolute position: "absolute", left: `${component.style?.positionX || 0}px`, top: `${component.style?.positionY || 0}px`, width: "100%", // 🆕 부모 컨테이너 너비에 맞춤 (그리드 기반) height: getHeightValue(), zIndex: component.style?.positionZ || 1, cursor: isDesignMode ? "pointer" : "default", border: isSelected ? "2px solid #3b82f6" : "1px solid #e5e7eb", }; // 계층 구조 빌드 함수 (트리 구조 유지) const buildHierarchy = useCallback( (items: any[]): any[] => { if (!items || items.length === 0) return []; const itemAddConfig = componentConfig.leftPanel?.itemAddConfig; if (!itemAddConfig) return items.map((item) => ({ ...item, children: [] })); // 계층 설정이 없으면 평면 목록 const { sourceColumn, parentColumn } = itemAddConfig; if (!sourceColumn || !parentColumn) return items.map((item) => ({ ...item, children: [] })); // ID를 키로 하는 맵 생성 const itemMap = new Map(); const rootItems: any[] = []; // 모든 항목을 맵에 추가하고 children 배열 초기화 items.forEach((item) => { const id = item[sourceColumn]; itemMap.set(id, { ...item, children: [], level: 0 }); }); // 부모-자식 관계 설정 items.forEach((item) => { const id = item[sourceColumn]; const parentId = item[parentColumn]; const currentItem = itemMap.get(id); if (!currentItem) return; if (!parentId || parentId === null || parentId === "") { // 최상위 항목 rootItems.push(currentItem); } else { // 부모가 있는 항목 const parentItem = itemMap.get(parentId); if (parentItem) { currentItem.level = parentItem.level + 1; parentItem.children.push(currentItem); } else { // 부모를 찾을 수 없으면 최상위로 처리 rootItems.push(currentItem); } } }); return rootItems; }, [componentConfig.leftPanel?.itemAddConfig], ); // 🔧 사용자 ID 가져오기 const { userId: currentUserId } = useAuth(); // 🔄 필터를 searchValues 형식으로 변환 const searchValues = useMemo(() => { if (!leftFilters || leftFilters.length === 0) return {}; const values: Record = {}; leftFilters.forEach((filter) => { if (filter.value !== undefined && filter.value !== null && filter.value !== "") { values[filter.columnName] = { value: filter.value, operator: filter.operator || "contains", }; } }); return values; }, [leftFilters]); // 🔄 컬럼 가시성 및 순서 처리 const visibleLeftColumns = useMemo(() => { const displayColumns = componentConfig.leftPanel?.columns || []; if (displayColumns.length === 0) return []; let columns = displayColumns; // columnVisibility가 있으면 가시성 적용 if (leftColumnVisibility.length > 0) { const visibilityMap = new Map(leftColumnVisibility.map((cv) => [cv.columnName, cv.visible])); columns = columns.filter((col: any) => { const colName = typeof col === "string" ? col : col.name || col.columnName; return visibilityMap.get(colName) !== false; }); } // 🔧 컬럼 순서 적용 if (leftColumnOrder.length > 0) { const orderMap = new Map(leftColumnOrder.map((name, index) => [name, index])); columns = [...columns].sort((a, b) => { const aName = typeof a === "string" ? a : a.name || a.columnName; const bName = typeof b === "string" ? b : b.name || b.columnName; const aIndex = orderMap.get(aName) ?? 999; const bIndex = orderMap.get(bName) ?? 999; return aIndex - bIndex; }); } return columns; }, [componentConfig.leftPanel?.columns, leftColumnVisibility, leftColumnOrder]); // 🔄 데이터 그룹화 const groupedLeftData = useMemo(() => { if (!leftGrouping || leftGrouping.length === 0 || leftData.length === 0) return []; const grouped = new Map(); leftData.forEach((item) => { // 각 그룹 컬럼의 값을 조합하여 그룹 키 생성 const groupKey = leftGrouping .map((col) => { const value = item[col]; // null/undefined 처리 return value === null || value === undefined ? "(비어있음)" : String(value); }) .join(" > "); if (!grouped.has(groupKey)) { grouped.set(groupKey, []); } grouped.get(groupKey)!.push(item); }); return Array.from(grouped.entries()).map(([key, items]) => ({ groupKey: key, items, count: items.length, })); }, [leftData, leftGrouping]); // 날짜 포맷팅 헬퍼 함수 const formatDateValue = useCallback((value: any, dateFormat: string): string => { if (!value) return "-"; const date = new Date(value); if (isNaN(date.getTime())) return String(value); if (dateFormat === "relative") { // 상대 시간 (예: 3일 전, 2시간 전) const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffSec = Math.floor(diffMs / 1000); const diffMin = Math.floor(diffSec / 60); const diffHour = Math.floor(diffMin / 60); const diffDay = Math.floor(diffHour / 24); const diffMonth = Math.floor(diffDay / 30); const diffYear = Math.floor(diffMonth / 12); if (diffYear > 0) return `${diffYear}년 전`; if (diffMonth > 0) return `${diffMonth}개월 전`; if (diffDay > 0) return `${diffDay}일 전`; if (diffHour > 0) return `${diffHour}시간 전`; if (diffMin > 0) return `${diffMin}분 전`; return "방금 전"; } // 포맷 문자열 치환 return dateFormat .replace("YYYY", String(date.getFullYear())) .replace("MM", String(date.getMonth() + 1).padStart(2, "0")) .replace("DD", String(date.getDate()).padStart(2, "0")) .replace("HH", String(date.getHours()).padStart(2, "0")) .replace("mm", String(date.getMinutes()).padStart(2, "0")) .replace("ss", String(date.getSeconds()).padStart(2, "0")); }, []); // 숫자 포맷팅 헬퍼 함수 const formatNumberValue = useCallback((value: any, format: any): string => { if (value === null || value === undefined || value === "") return "-"; const num = typeof value === "number" ? value : parseFloat(String(value)); if (isNaN(num)) return String(value); const options: Intl.NumberFormatOptions = { minimumFractionDigits: format?.decimalPlaces ?? 0, maximumFractionDigits: format?.decimalPlaces ?? 10, useGrouping: format?.thousandSeparator ?? false, }; let result = num.toLocaleString("ko-KR", options); if (format?.prefix) result = format.prefix + result; if (format?.suffix) result = result + format.suffix; return result; }, []); // 🆕 간단한 값 포맷팅 함수 (추가 탭용) const formatValue = useCallback( ( value: any, format?: { type?: "number" | "currency" | "date" | "text"; thousandSeparator?: boolean; decimalPlaces?: number; prefix?: string; suffix?: string; dateFormat?: string; }, ): string => { if (value === null || value === undefined) return "-"; // 날짜 포맷 if (format?.type === "date" || format?.dateFormat) { return formatDateValue(value, format?.dateFormat || "YYYY-MM-DD"); } // 숫자 포맷 if ( format?.type === "number" || format?.type === "currency" || format?.thousandSeparator || format?.decimalPlaces !== undefined ) { return formatNumberValue(value, format); } return String(value); }, [formatDateValue, formatNumberValue], ); // 셀 값 포맷팅 함수 (카테고리 타입 처리 + 날짜/숫자 포맷) const formatCellValue = useCallback( ( columnName: string, value: any, categoryMappings: Record>, format?: { type?: "number" | "currency" | "date" | "text"; thousandSeparator?: boolean; decimalPlaces?: number; prefix?: string; suffix?: string; dateFormat?: string; }, ) => { if (value === null || value === undefined) return "-"; // 🆕 날짜 포맷 적용 if (format?.type === "date" || format?.dateFormat) { return formatDateValue(value, format?.dateFormat || "YYYY-MM-DD"); } // 🆕 숫자 포맷 적용 if ( format?.type === "number" || format?.type === "currency" || format?.thousandSeparator || format?.decimalPlaces !== undefined ) { return formatNumberValue(value, format); } // 🆕 카테고리 매핑 찾기 (여러 키 형태 시도) // 1. 전체 컬럼명 (예: "item_info.material") // 2. 컬럼명만 (예: "material") let mapping = categoryMappings[columnName]; if (!mapping && columnName.includes(".")) { // 조인된 컬럼의 경우 컬럼명만으로 다시 시도 const simpleColumnName = columnName.split(".").pop() || columnName; mapping = categoryMappings[simpleColumnName]; } if (mapping && mapping[String(value)]) { const categoryData = mapping[String(value)]; const displayLabel = categoryData.label || String(value); const displayColor = categoryData.color || "#64748b"; // 배지로 표시 return ( {displayLabel} ); } // 🆕 카테고리 코드 패턴 감지 (CATEGORY_로 시작하는 값) if (typeof value === "string" && value.startsWith("CATEGORY_")) { const cachedLabel = categoryCodeLabels[value]; if (cachedLabel) { return {cachedLabel}; } } // 🆕 자동 날짜 감지 (ISO 8601 형식 또는 Date 객체) if (typeof value === "string" && value.match(/^\d{4}-\d{2}-\d{2}(T|\s)/)) { return formatDateValue(value, "YYYY-MM-DD"); } // 🆕 자동 숫자 감지 (숫자 또는 숫자 문자열) - 소수점 있으면 정수로 변환 if (typeof value === "number") { // 숫자인 경우 정수로 표시 (소수점 제거) return Number.isInteger(value) ? String(value) : String(Math.round(value * 100) / 100); } if (typeof value === "string" && /^-?\d+\.?\d*$/.test(value.trim())) { // 숫자 문자열인 경우 (예: "5.00" → "5") const num = parseFloat(value); if (!isNaN(num)) { return Number.isInteger(num) ? String(num) : String(Math.round(num * 100) / 100); } } // 일반 값 return String(value); }, [formatDateValue, formatNumberValue, categoryCodeLabels], ); // 좌측 데이터 로드 const loadLeftData = useCallback(async () => { const leftTableName = componentConfig.leftPanel?.tableName; if (!leftTableName || isDesignMode) return; setIsLoadingLeft(true); try { // 🎯 필터 조건을 API에 전달 (entityJoinApi 사용) const filters = Object.keys(searchValues).length > 0 ? searchValues : undefined; // 🆕 "테이블명.컬럼명" 형식의 조인 컬럼들을 additionalJoinColumns로 변환 const configuredColumns = componentConfig.leftPanel?.columns || []; const additionalJoinColumns: Array<{ sourceTable: string; sourceColumn: string; referenceTable: string; joinAlias: string; }> = []; // 소스 컬럼 매핑 (item_info → item_code, warehouse_info → warehouse_id 등) const sourceColumnMap: Record = {}; configuredColumns.forEach((col: any) => { const colName = typeof col === "string" ? col : col.name || col.columnName; if (colName && colName.includes(".")) { const [refTable, refColumn] = colName.split("."); // 소스 컬럼 추론 (item_info → item_code 또는 warehouse_info → warehouse_id) // 기본: _info → _code, 백업: _info → _id const primarySourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id"); const secondarySourceColumn = refTable.replace("_info", "_id").replace("_mng", "_id"); // 실제 존재하는 소스 컬럼은 백엔드에서 결정 (프론트엔드는 두 패턴 모두 전달) const inferredSourceColumn = primarySourceColumn; // 이미 추가된 조인인지 확인 (동일 테이블, 동일 소스컬럼) const existingJoin = additionalJoinColumns.find( (j) => j.referenceTable === refTable && j.sourceColumn === inferredSourceColumn, ); if (!existingJoin) { // 새로운 조인 추가 (첫 번째 컬럼) additionalJoinColumns.push({ sourceTable: leftTableName, sourceColumn: inferredSourceColumn, referenceTable: refTable, joinAlias: `${inferredSourceColumn}_${refColumn}`, }); sourceColumnMap[refTable] = inferredSourceColumn; } // 추가 컬럼도 별도로 요청 (item_code_standard, item_code_unit 등) // 단, 첫 번째 컬럼과 다른 경우만 const existingAliases = additionalJoinColumns .filter((j) => j.referenceTable === refTable) .map((j) => j.joinAlias); const newAlias = `${sourceColumnMap[refTable] || inferredSourceColumn}_${refColumn}`; if (!existingAliases.includes(newAlias)) { additionalJoinColumns.push({ sourceTable: leftTableName, sourceColumn: sourceColumnMap[refTable] || inferredSourceColumn, referenceTable: refTable, joinAlias: newAlias, }); } } }); // console.log("🔗 [분할패널] additionalJoinColumns:", additionalJoinColumns); // console.log("🔗 [분할패널] configuredColumns:", configuredColumns); const result = await entityJoinApi.getTableDataWithJoins(leftTableName, { page: 1, size: 100, search: filters, // 필터 조건 전달 enableEntityJoin: true, // 엔티티 조인 활성화 dataFilter: componentConfig.leftPanel?.dataFilter, // 🆕 데이터 필터 전달 additionalJoinColumns: additionalJoinColumns.length > 0 ? additionalJoinColumns : undefined, // 🆕 추가 조인 컬럼 }); // 🔍 디버깅: API 응답 데이터의 키 확인 // if (result.data && result.data.length > 0) { // console.log("🔗 [분할패널] API 응답 첫 번째 데이터 키:", Object.keys(result.data[0])); // console.log("🔗 [분할패널] API 응답 첫 번째 데이터:", result.data[0]); // } // 가나다순 정렬 (좌측 패널의 표시 컬럼 기준) const leftColumn = componentConfig.rightPanel?.relation?.leftColumn; if (leftColumn && result.data.length > 0) { result.data.sort((a, b) => { const aValue = String(a[leftColumn] || ""); const bValue = String(b[leftColumn] || ""); return aValue.localeCompare(bValue, "ko-KR"); }); } // 계층 구조 빌드 const hierarchicalData = buildHierarchy(result.data); setLeftData(hierarchicalData); } catch (error) { console.error("좌측 데이터 로드 실패:", error); toast({ title: "데이터 로드 실패", description: "좌측 패널 데이터를 불러올 수 없습니다.", variant: "destructive", }); } finally { setIsLoadingLeft(false); } }, [ componentConfig.leftPanel?.tableName, componentConfig.leftPanel?.columns, componentConfig.leftPanel?.dataFilter, componentConfig.rightPanel?.relation?.leftColumn, isDesignMode, toast, buildHierarchy, searchValues, ]); // 우측 데이터 로드 const loadRightData = useCallback( async (leftItem: any) => { const relationshipType = componentConfig.rightPanel?.relation?.type || "detail"; const rightTableName = componentConfig.rightPanel?.tableName; if (!rightTableName || isDesignMode) return; setIsLoadingRight(true); try { if (relationshipType === "detail") { // 상세 모드: 동일 테이블의 상세 정보 (🆕 엔티티 조인 활성화) const primaryKey = leftItem.id || leftItem.ID || Object.values(leftItem)[0]; // 🆕 엔티티 조인 API 사용 const { entityJoinApi } = await import("@/lib/api/entityJoin"); const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { search: { id: primaryKey }, enableEntityJoin: true, // 엔티티 조인 활성화 size: 1, }); // result.data가 EntityJoinResponse의 실제 배열 필드 const detail = result.data && result.data.length > 0 ? result.data[0] : null; setRightData(detail); } else if (relationshipType === "join") { // 조인 모드: 다른 테이블의 관련 데이터 (여러 개) const keys = componentConfig.rightPanel?.relation?.keys; const leftTable = componentConfig.leftPanel?.tableName; // 🆕 그룹 합산된 항목인 경우: 원본 데이터들로 우측 패널 표시 if (leftItem._originalItems && leftItem._originalItems.length > 0) { console.log("🔗 [분할패널] 그룹 합산 항목 - 원본 개수:", leftItem._originalItems.length); // 정렬 기준 컬럼 (복합키의 leftColumn들) const sortColumns = keys?.map((k: any) => k.leftColumn).filter(Boolean) || []; console.log("🔗 [분할패널] 정렬 기준 컬럼:", sortColumns); // 정렬 함수 const sortByKeys = (data: any[]) => { if (sortColumns.length === 0) return data; return [...data].sort((a, b) => { for (const col of sortColumns) { const aVal = String(a[col] || ""); const bVal = String(b[col] || ""); const cmp = aVal.localeCompare(bVal, "ko-KR"); if (cmp !== 0) return cmp; } return 0; }); }; // 원본 데이터를 그대로 우측 패널에 표시 (이력 테이블과 동일 테이블인 경우) if (leftTable === rightTableName) { const sortedData = sortByKeys(leftItem._originalItems); console.log("🔗 [분할패널] 동일 테이블 - 정렬된 원본 데이터:", sortedData.length); setRightData(sortedData); return; } // 다른 테이블인 경우: 원본 ID들로 조회 const { entityJoinApi } = await import("@/lib/api/entityJoin"); const allResults: any[] = []; // 각 원본 항목에 대해 조회 for (const originalItem of leftItem._originalItems) { const searchConditions: Record = {}; keys?.forEach((key: any) => { if (key.leftColumn && key.rightColumn && originalItem[key.leftColumn] !== undefined) { searchConditions[key.rightColumn] = originalItem[key.leftColumn]; } }); if (Object.keys(searchConditions).length > 0) { const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { search: searchConditions, enableEntityJoin: true, size: 1000, }); if (result.data) { allResults.push(...result.data); } } } // 정렬 적용 const sortedResults = sortByKeys(allResults); console.log("🔗 [분할패널] 그룹 합산 - 우측 패널 정렬된 데이터:", sortedResults.length); setRightData(sortedResults); return; } // 🆕 엔티티 관계 자동 감지 로직 개선 // 1. 설정된 keys가 있으면 사용 // 2. 없으면 테이블 타입관리에서 정의된 엔티티 관계를 자동으로 조회 let effectiveKeys = keys || []; if (effectiveKeys.length === 0 && leftTable && rightTableName) { // 엔티티 관계 자동 감지 console.log("🔍 [분할패널] 엔티티 관계 자동 감지 시작:", leftTable, "->", rightTableName); const { tableManagementApi } = await import("@/lib/api/tableManagement"); const relResponse = await tableManagementApi.getTableEntityRelations(leftTable, rightTableName); if (relResponse.success && relResponse.data?.relations && relResponse.data.relations.length > 0) { effectiveKeys = relResponse.data.relations.map((rel) => ({ leftColumn: rel.leftColumn, rightColumn: rel.rightColumn, })); console.log("✅ [분할패널] 자동 감지된 관계:", effectiveKeys); } } if (effectiveKeys.length > 0 && leftTable) { // 복합키: 여러 조건으로 필터링 const { entityJoinApi } = await import("@/lib/api/entityJoin"); // 복합키 조건 생성 (다중 값 지원) // 🆕 항상 배열로 전달하여 백엔드에서 다중 값 컬럼 검색을 지원하도록 함 // 예: 좌측에서 "2"를 선택해도, 우측에서 "2,3"을 가진 행이 표시되도록 const searchConditions: Record = {}; effectiveKeys.forEach((key) => { if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) { const leftValue = leftItem[key.leftColumn]; // 다중 값 지원: 모든 값을 배열로 변환하여 다중 값 컬럼 검색 활성화 if (typeof leftValue === "string") { if (leftValue.includes(",")) { // "2,3" 형태면 분리해서 배열로 const values = leftValue.split(",").map((v: string) => v.trim()).filter((v: string) => v); searchConditions[key.rightColumn] = values; console.log("🔗 [분할패널] 다중 값 검색 (분리):", key.rightColumn, "=", values); } else { // 단일 값도 배열로 변환 (우측에 "2,3" 같은 다중 값이 있을 수 있으므로) searchConditions[key.rightColumn] = [leftValue.trim()]; console.log("🔗 [분할패널] 다중 값 검색 (단일):", key.rightColumn, "=", [leftValue.trim()]); } } else { // 숫자나 다른 타입은 배열로 감싸기 searchConditions[key.rightColumn] = [leftValue]; console.log("🔗 [분할패널] 다중 값 검색 (기타):", key.rightColumn, "=", [leftValue]); } } }); console.log("🔗 [분할패널] 복합키 조건:", searchConditions); // 엔티티 조인 API로 데이터 조회 (🆕 deduplication 전달) const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { search: searchConditions, enableEntityJoin: true, size: 1000, deduplication: componentConfig.rightPanel?.deduplication, // 🆕 중복 제거 설정 전달 }); console.log("🔗 [분할패널] 복합키 조회 결과:", result); // 추가 dataFilter 적용 let filteredData = result.data || []; const dataFilter = componentConfig.rightPanel?.dataFilter; // filters 또는 conditions 배열 지원 (DataFilterConfigPanel은 filters 사용) const filterConditions = dataFilter?.filters || dataFilter?.conditions || []; if (dataFilter?.enabled && filterConditions.length > 0) { filteredData = filteredData.filter((item: any) => { return filterConditions.every((cond: any) => { // columnName 또는 column 지원 const columnName = cond.columnName || cond.column; const value = item[columnName]; const condValue = cond.value; switch (cond.operator) { case "equals": return value === condValue; case "notEquals": return value !== condValue; case "contains": return String(value).includes(String(condValue)); case "is_null": case "NULL": return value === null || value === undefined || value === ""; case "is_not_null": case "NOT NULL": return value !== null && value !== undefined && value !== ""; default: return true; } }); }); } setRightData(filteredData); } else { // 단일키 (하위 호환성) 또는 관계를 찾지 못한 경우 const leftColumn = componentConfig.rightPanel?.relation?.leftColumn; const rightColumn = componentConfig.rightPanel?.relation?.foreignKey; if (leftColumn && rightColumn && leftTable) { const leftValue = leftItem[leftColumn]; const joinedData = await dataApi.getJoinedData( leftTable, rightTableName, leftColumn, rightColumn, leftValue, componentConfig.rightPanel?.dataFilter, // 🆕 데이터 필터 전달 true, // 🆕 Entity 조인 활성화 componentConfig.rightPanel?.columns, // 🆕 표시 컬럼 전달 (item_info.item_name 등) componentConfig.rightPanel?.deduplication, // 🆕 중복 제거 설정 전달 ); setRightData(joinedData || []); // 모든 관련 레코드 (배열) } else { console.warn("⚠️ [분할패널] 테이블 관계를 찾을 수 없습니다:", leftTable, "->", rightTableName); setRightData([]); } } } } catch (error) { console.error("우측 데이터 로드 실패:", error); toast({ title: "데이터 로드 실패", description: "우측 패널 데이터를 불러올 수 없습니다.", variant: "destructive", }); } finally { setIsLoadingRight(false); } }, [ componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.relation, componentConfig.leftPanel?.tableName, isDesignMode, toast, ], ); // 🆕 카테고리 코드 라벨 로드 (rightData 변경 시) useEffect(() => { const loadCategoryCodeLabels = async () => { if (!rightData) return; const categoryCodes = new Set(); // rightData가 배열인 경우 (조인 모드) const dataArray = Array.isArray(rightData) ? rightData : [rightData]; dataArray.forEach((row: Record) => { if (row) { Object.values(row).forEach((value) => { if (typeof value === "string" && value.startsWith("CATEGORY_")) { categoryCodes.add(value); } }); } }); // 새로운 카테고리 코드만 필터링 (이미 캐시된 것 제외) const newCodes = Array.from(categoryCodes).filter((code) => !categoryCodeLabels[code]); if (newCodes.length > 0) { try { console.log("🏷️ [SplitPanel] 카테고리 코드 라벨 조회:", newCodes); const response = await apiClient.post("/table-categories/labels-by-codes", { valueCodes: newCodes }); if (response.data.success && response.data.data) { console.log("🏷️ [SplitPanel] 카테고리 라벨 조회 결과:", response.data.data); setCategoryCodeLabels((prev) => ({ ...prev, ...response.data.data, })); } } catch (error) { console.error("카테고리 라벨 조회 실패:", error); } } }; loadCategoryCodeLabels(); }, [rightData]); // 🆕 추가 탭 데이터 로딩 함수 const loadTabData = useCallback( async (tabIndex: number, leftItem: any) => { console.log(`📥 loadTabData 호출됨: tabIndex=${tabIndex}`, { leftItem: leftItem ? Object.keys(leftItem) : null, additionalTabs: componentConfig.rightPanel?.additionalTabs?.length, isDesignMode, }); const tabConfig = componentConfig.rightPanel?.additionalTabs?.[tabIndex - 1]; console.log(`📥 tabConfig:`, { tabIndex, configIndex: tabIndex - 1, tabConfig: tabConfig ? { tableName: tabConfig.tableName, relation: tabConfig.relation, dataFilter: tabConfig.dataFilter } : null, }); if (!tabConfig || !leftItem || isDesignMode) { console.log(`⚠️ loadTabData 중단:`, { hasTabConfig: !!tabConfig, hasLeftItem: !!leftItem, isDesignMode }); return; } const tabTableName = tabConfig.tableName; if (!tabTableName) return; setTabsLoading((prev) => ({ ...prev, [tabIndex]: true })); try { // 조인 키 확인 const keys = tabConfig.relation?.keys; const leftColumn = tabConfig.relation?.leftColumn || keys?.[0]?.leftColumn; const rightColumn = tabConfig.relation?.foreignKey || keys?.[0]?.rightColumn; console.log(`🔑 [추가탭 ${tabIndex}] 조인 키 분석:`, { hasRelation: !!tabConfig.relation, keys, leftColumn, rightColumn, willUseJoin: !!(leftColumn && rightColumn), }); let resultData: any[] = []; if (leftColumn && rightColumn) { // 조인 조건이 있는 경우 const { entityJoinApi } = await import("@/lib/api/entityJoin"); const searchConditions: Record = {}; if (keys && keys.length > 0) { // 복합키 keys.forEach((key) => { if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) { // operator: "equals"를 추가하여 정확한 값 매칭 (entity 타입 컬럼에서 코드값으로 검색) searchConditions[key.rightColumn] = { value: leftItem[key.leftColumn], operator: "equals", }; } }); } else { // 단일키 const leftValue = leftItem[leftColumn]; if (leftValue !== undefined) { // operator: "equals"를 추가하여 정확한 값 매칭 (entity 타입 컬럼에서 코드값으로 검색) searchConditions[rightColumn] = { value: leftValue, operator: "equals", }; } } console.log(`🔗 [추가탭 ${tabIndex}] 조회 조건:`, searchConditions); const result = await entityJoinApi.getTableDataWithJoins(tabTableName, { search: searchConditions, enableEntityJoin: true, size: 1000, }); resultData = result.data || []; } else { // 조인 조건이 없는 경우: 전체 데이터 조회 (독립 탭) console.log(`📋 [추가탭 ${tabIndex}] 조인 없이 전체 데이터 조회: ${tabTableName}`); const { entityJoinApi } = await import("@/lib/api/entityJoin"); const result = await entityJoinApi.getTableDataWithJoins(tabTableName, { enableEntityJoin: true, size: 1000, }); resultData = result.data || []; console.log(`📋 [추가탭 ${tabIndex}] 전체 데이터 조회 결과:`, resultData.length); } // 데이터 필터 적용 const dataFilter = tabConfig.dataFilter; // filters 또는 conditions 배열 지원 (DataFilterConfigPanel은 filters 사용) const filterConditions = dataFilter?.filters || dataFilter?.conditions || []; console.log(`🔍 [추가탭 ${tabIndex}] 필터 설정:`, { enabled: dataFilter?.enabled, filterConditions, dataBeforeFilter: resultData.length, }); if (dataFilter?.enabled && filterConditions.length > 0) { const beforeCount = resultData.length; resultData = resultData.filter((item: any) => { return filterConditions.every((cond: any) => { // columnName 또는 column 지원 const columnName = cond.columnName || cond.column; const value = item[columnName]; const condValue = cond.value; let result = true; switch (cond.operator) { case "equals": result = value === condValue; break; case "notEquals": result = value !== condValue; break; case "contains": result = String(value).includes(String(condValue)); break; case "is_null": case "NULL": result = value === null || value === undefined || value === ""; break; case "is_not_null": case "NOT NULL": result = value !== null && value !== undefined && value !== ""; break; default: result = true; } // 첫 5개 항목만 로그 출력 if (resultData.indexOf(item) < 5) { console.log(` 필터 체크: ${columnName}=${value}, operator=${cond.operator}, result=${result}`); } return result; }); }); console.log(`🔍 [추가탭 ${tabIndex}] 필터 적용 후: ${beforeCount} → ${resultData.length}`); } // 중복 제거 적용 const deduplication = tabConfig.deduplication; if (deduplication?.enabled && deduplication.groupByColumn) { const groupedMap = new Map(); resultData.forEach((item) => { const key = String(item[deduplication.groupByColumn] || ""); const existing = groupedMap.get(key); if (!existing) { groupedMap.set(key, item); } else { // keepStrategy에 따라 유지할 항목 결정 const sortCol = deduplication.sortColumn || "start_date"; const existingVal = existing[sortCol]; const newVal = item[sortCol]; if (deduplication.keepStrategy === "latest" && newVal > existingVal) { groupedMap.set(key, item); } else if (deduplication.keepStrategy === "earliest" && newVal < existingVal) { groupedMap.set(key, item); } } }); resultData = Array.from(groupedMap.values()); } console.log(`🔗 [추가탭 ${tabIndex}] 결과 데이터:`, resultData.length); setTabsData((prev) => ({ ...prev, [tabIndex]: resultData })); } catch (error) { console.error(`추가탭 ${tabIndex} 데이터 로드 실패:`, error); toast({ title: "데이터 로드 실패", description: `탭 데이터를 불러올 수 없습니다.`, variant: "destructive", }); } finally { setTabsLoading((prev) => ({ ...prev, [tabIndex]: false })); } }, [componentConfig.rightPanel?.additionalTabs, isDesignMode, toast], ); // 좌측 항목 선택 핸들러 const handleLeftItemSelect = useCallback( (item: any) => { setSelectedLeftItem(item); setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화 setTabsData({}); // 모든 탭 데이터 초기화 // 현재 활성 탭에 따라 데이터 로드 if (activeTabIndex === 0) { loadRightData(item); } else { loadTabData(activeTabIndex, 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, loadTabData, activeTabIndex, componentConfig.leftPanel?.tableName, isDesignMode], ); // 🆕 탭 변경 핸들러 const handleTabChange = useCallback( (newTabIndex: number) => { console.log(`🔄 탭 변경: ${activeTabIndex} → ${newTabIndex}`, { selectedLeftItem: !!selectedLeftItem, tabsData: Object.keys(tabsData), hasTabData: !!tabsData[newTabIndex], }); setActiveTabIndex(newTabIndex); // 선택된 좌측 항목이 있으면 해당 탭의 데이터 로드 if (selectedLeftItem) { if (newTabIndex === 0) { // 기본 탭: 우측 패널 데이터가 없으면 로드 if (!rightData || (Array.isArray(rightData) && rightData.length === 0)) { loadRightData(selectedLeftItem); } } else { // 추가 탭: 항상 새로 로드 (필터 설정 변경 반영을 위해) console.log(`🔄 추가 탭 ${newTabIndex} 데이터 로드 (항상 새로고침)`); loadTabData(newTabIndex, selectedLeftItem); } } else { console.log(`⚠️ 좌측 항목이 선택되지 않아 탭 데이터를 로드하지 않음`); } }, [selectedLeftItem, rightData, tabsData, loadRightData, loadTabData, activeTabIndex], ); // 우측 항목 확장/축소 토글 const toggleRightItemExpansion = useCallback((itemId: string | number) => { setExpandedRightItems((prev) => { const newSet = new Set(prev); if (newSet.has(itemId)) { newSet.delete(itemId); } else { newSet.add(itemId); } return newSet; }); }, []); // 컬럼명을 라벨로 변환하는 함수 const getColumnLabel = useCallback( (columnName: string) => { const column = rightTableColumns.find((col) => col.columnName === columnName || col.column_name === columnName); return column?.columnLabel || column?.column_label || column?.displayName || columnName; }, [rightTableColumns], ); // 🔧 컬럼의 고유값 가져오기 함수 const getLeftColumnUniqueValues = useCallback( async (columnName: string) => { const leftTableName = componentConfig.leftPanel?.tableName; if (!leftTableName || leftData.length === 0) return []; // 현재 로드된 데이터에서 고유값 추출 const uniqueValues = new Set(); leftData.forEach((item) => { // 🆕 조인 컬럼 처리 (item_info.standard → item_code_standard 또는 item_id_standard) let value: any; if (columnName.includes(".")) { // 조인 컬럼: getEntityJoinValue와 동일한 로직 적용 const [refTable, fieldName] = columnName.split("."); const inferredSourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id"); // 정확한 키로 먼저 시도 const exactKey = `${inferredSourceColumn}_${fieldName}`; value = item[exactKey]; // 🆕 item_id 패턴 시도 if (value === undefined) { const idPatternKey = `${refTable.replace("_info", "_id").replace("_mng", "_id")}_${fieldName}`; value = item[idPatternKey]; } // 기본 별칭 패턴 시도 (item_code_name 또는 item_id_name) if (value === undefined && (fieldName === "item_name" || fieldName === "name")) { const aliasKey = `${inferredSourceColumn}_name`; value = item[aliasKey]; // item_id_name 패턴도 시도 if (value === undefined) { const idAliasKey = `${refTable.replace("_info", "_id").replace("_mng", "_id")}_name`; value = item[idAliasKey]; } } } else { // 일반 컬럼 value = item[columnName]; } if (value !== null && value !== undefined && value !== "") { // _name 필드 우선 사용 (category/entity type) const displayValue = item[`${columnName}_name`] || value; uniqueValues.add(String(displayValue)); } }); return Array.from(uniqueValues).map((value) => ({ value: value, label: value, })); }, [componentConfig.leftPanel?.tableName, leftData], ); // 좌측 테이블 등록 (Context에 등록) useEffect(() => { const leftTableName = componentConfig.leftPanel?.tableName; if (!leftTableName || isDesignMode) return; const leftTableId = `split-panel-left-${component.id}`; // 🔧 화면에 표시되는 컬럼 사용 (columns 속성) const configuredColumns = componentConfig.leftPanel?.columns || []; // 🆕 설정에서 지정한 라벨 맵 생성 const configuredLabels: Record = {}; configuredColumns.forEach((col: any) => { if (typeof col === "object" && col.name && col.label) { configuredLabels[col.name] = col.label; } }); const displayColumns = configuredColumns .map((col: any) => { if (typeof col === "string") return col; return col.columnName || col.name || col; }) .filter(Boolean); // 화면에 설정된 컬럼이 없으면 등록하지 않음 if (displayColumns.length === 0) return; // 테이블명이 있으면 등록 registerTable({ tableId: leftTableId, label: `${component.title || "분할 패널"} (좌측)`, tableName: leftTableName, columns: displayColumns.map((col: string) => ({ columnName: col, // 🆕 우선순위: 1) 설정에서 지정한 라벨 2) DB 라벨 3) 컬럼명 columnLabel: configuredLabels[col] || leftColumnLabels[col] || col, inputType: "text", visible: true, width: 150, sortable: true, filterable: true, })), onFilterChange: setLeftFilters, onGroupChange: setLeftGrouping, onColumnVisibilityChange: setLeftColumnVisibility, onColumnOrderChange: setLeftColumnOrder, // 🔧 컬럼 순서 변경 콜백 추가 getColumnUniqueValues: getLeftColumnUniqueValues, // 🔧 고유값 가져오기 함수 추가 onGroupSumChange: setLeftGroupSumConfig, // 🆕 그룹별 합산 설정 콜백 }); return () => unregisterTable(leftTableId); }, [ component.id, componentConfig.leftPanel?.tableName, componentConfig.leftPanel?.columns, leftColumnLabels, component.title, isDesignMode, getLeftColumnUniqueValues, ]); // 우측 테이블은 검색 컴포넌트 등록 제외 (좌측 마스터 테이블만 검색 가능) // useEffect(() => { // const rightTableName = componentConfig.rightPanel?.tableName; // if (!rightTableName || isDesignMode) return; // // const rightTableId = `split-panel-right-${component.id}`; // // 🔧 화면에 표시되는 컬럼만 등록 (displayColumns 또는 columns) // const displayColumns = componentConfig.rightPanel?.columns || []; // const rightColumns = displayColumns.map((col: any) => col.columnName || col.name || col).filter(Boolean); // // if (rightColumns.length > 0) { // registerTable({ // tableId: rightTableId, // label: `${component.title || "분할 패널"} (우측)`, // tableName: rightTableName, // columns: rightColumns.map((col: string) => ({ // columnName: col, // columnLabel: rightColumnLabels[col] || col, // inputType: "text", // visible: true, // width: 150, // sortable: true, // filterable: true, // })), // onFilterChange: setRightFilters, // onGroupChange: setRightGrouping, // onColumnVisibilityChange: setRightColumnVisibility, // }); // // return () => unregisterTable(rightTableId); // } // }, [component.id, componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.columns, rightColumnLabels, component.title, isDesignMode]); // 좌측 테이블 컬럼 라벨 로드 useEffect(() => { const loadLeftColumnLabels = async () => { const leftTableName = componentConfig.leftPanel?.tableName; if (!leftTableName || isDesignMode) return; try { const columnsResponse = await tableTypeApi.getColumns(leftTableName); const labels: Record = {}; columnsResponse.forEach((col: any) => { const columnName = col.columnName || col.column_name; const label = col.columnLabel || col.column_label || col.displayName || columnName; if (columnName) { labels[columnName] = label; } }); setLeftColumnLabels(labels); // console.log("✅ 좌측 컬럼 라벨 로드:", labels); } catch (error) { console.error("좌측 테이블 컬럼 라벨 로드 실패:", error); } }; loadLeftColumnLabels(); }, [componentConfig.leftPanel?.tableName, isDesignMode]); // 우측 테이블 컬럼 정보 로드 useEffect(() => { const loadRightTableColumns = async () => { const rightTableName = componentConfig.rightPanel?.tableName; if (!rightTableName || isDesignMode) return; try { const columnsResponse = await tableTypeApi.getColumns(rightTableName); setRightTableColumns(columnsResponse || []); // 우측 컬럼 라벨도 함께 로드 const labels: Record = {}; columnsResponse.forEach((col: any) => { const columnName = col.columnName || col.column_name; const label = col.columnLabel || col.column_label || col.displayName || columnName; if (columnName) { labels[columnName] = label; } }); setRightColumnLabels(labels); // console.log("✅ 우측 컬럼 라벨 로드:", labels); } catch (error) { console.error("우측 테이블 컬럼 정보 로드 실패:", error); } }; loadRightTableColumns(); }, [componentConfig.rightPanel?.tableName, isDesignMode]); // 좌측 테이블 카테고리 매핑 로드 useEffect(() => { const loadLeftCategoryMappings = async () => { const leftTableName = componentConfig.leftPanel?.tableName; if (!leftTableName || isDesignMode) return; try { // 1. 컬럼 메타 정보 조회 const columnsResponse = await tableTypeApi.getColumns(leftTableName); const categoryColumns = columnsResponse.filter((col: any) => col.inputType === "category"); if (categoryColumns.length === 0) { setLeftCategoryMappings({}); return; } // 2. 각 카테고리 컬럼에 대한 값 조회 const mappings: Record> = {}; for (const col of categoryColumns) { const columnName = col.columnName || col.column_name; try { const response = await apiClient.get(`/table-categories/${leftTableName}/${columnName}/values`); if (response.data.success && response.data.data) { const valueMap: Record = {}; response.data.data.forEach((item: any) => { valueMap[item.value_code || item.valueCode] = { label: item.value_label || item.valueLabel, color: item.color, }; }); mappings[columnName] = valueMap; // console.log(`✅ 좌측 카테고리 매핑 로드 [${columnName}]:`, valueMap); } } catch (error) { console.error(`좌측 카테고리 값 조회 실패 [${columnName}]:`, error); } } setLeftCategoryMappings(mappings); } catch (error) { console.error("좌측 카테고리 매핑 로드 실패:", error); } }; loadLeftCategoryMappings(); }, [componentConfig.leftPanel?.tableName, isDesignMode]); // 우측 테이블 카테고리 매핑 로드 (조인된 테이블 포함) useEffect(() => { const loadRightCategoryMappings = async () => { const rightTableName = componentConfig.rightPanel?.tableName; if (!rightTableName || isDesignMode) return; try { const mappings: Record> = {}; // 🆕 우측 패널 컬럼 설정에서 조인된 테이블 추출 const rightColumns = componentConfig.rightPanel?.columns || []; const tablesToLoad = new Set([rightTableName]); // 컬럼명에서 테이블명 추출 (예: "item_info.material" -> "item_info") rightColumns.forEach((col: any) => { const colName = col.name || col.columnName; if (colName && colName.includes(".")) { const joinTableName = colName.split(".")[0]; tablesToLoad.add(joinTableName); } }); // console.log("🔍 우측 패널 카테고리 로드 대상 테이블:", Array.from(tablesToLoad)); // 각 테이블에 대해 카테고리 매핑 로드 for (const tableName of tablesToLoad) { try { // 1. 컬럼 메타 정보 조회 const columnsResponse = await tableTypeApi.getColumns(tableName); const categoryColumns = columnsResponse.filter((col: any) => col.inputType === "category"); // 2. 각 카테고리 컬럼에 대한 값 조회 for (const col of categoryColumns) { const columnName = col.columnName || col.column_name; try { const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`); if (response.data.success && response.data.data) { const valueMap: Record = {}; response.data.data.forEach((item: any) => { valueMap[item.value_code || item.valueCode] = { label: item.value_label || item.valueLabel, color: item.color, }; }); // 조인된 테이블의 경우 "테이블명.컬럼명" 형태로 저장 const mappingKey = tableName === rightTableName ? columnName : `${tableName}.${columnName}`; mappings[mappingKey] = valueMap; // 🆕 컬럼명만으로도 접근할 수 있도록 추가 저장 (모든 테이블) // 기존 매핑이 있으면 병합, 없으면 새로 생성 mappings[columnName] = { ...(mappings[columnName] || {}), ...valueMap }; console.log(`✅ 우측 카테고리 매핑 로드 [${mappingKey}]:`, valueMap); console.log(`✅ 우측 카테고리 매핑 (컬럼명만) [${columnName}]:`, mappings[columnName]); } } catch (error) { console.error(`우측 카테고리 값 조회 실패 [${tableName}.${columnName}]:`, error); } } } catch (error) { console.error(`테이블 ${tableName} 컬럼 정보 조회 실패:`, error); } } setRightCategoryMappings(mappings); } catch (error) { console.error("우측 카테고리 매핑 로드 실패:", error); } }; loadRightCategoryMappings(); }, [componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.columns, isDesignMode]); // 항목 펼치기/접기 토글 const toggleExpand = useCallback((itemId: any) => { setExpandedItems((prev) => { const newSet = new Set(prev); if (newSet.has(itemId)) { newSet.delete(itemId); } else { newSet.add(itemId); } return newSet; }); }, []); // 추가 버튼 핸들러 const handleAddClick = useCallback( (panel: "left" | "right") => { setAddModalPanel(panel); // 우측 패널 추가 시, 좌측에서 선택된 항목의 조인 컬럼 값을 자동으로 채움 if ( panel === "right" && selectedLeftItem && componentConfig.leftPanel?.leftColumn && componentConfig.rightPanel?.rightColumn ) { const leftColumnValue = selectedLeftItem[componentConfig.leftPanel.leftColumn]; setAddModalFormData({ [componentConfig.rightPanel.rightColumn]: leftColumnValue, }); } else { setAddModalFormData({}); } setShowAddModal(true); }, [selectedLeftItem, componentConfig], ); // 수정 버튼 핸들러 const handleEditClick = useCallback( async (panel: "left" | "right", item: any) => { // 🆕 현재 활성 탭의 설정 가져오기 const currentTabConfig = activeTabIndex === 0 ? componentConfig.rightPanel : componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]; // 🆕 우측 패널 수정 버튼 설정 확인 if (panel === "right" && currentTabConfig?.editButton?.mode === "modal") { const modalScreenId = currentTabConfig?.editButton?.modalScreenId; if (modalScreenId) { // 커스텀 모달 화면 열기 const rightTableName = currentTabConfig?.tableName || ""; console.log("✅ 수정 모달 열기:", { tableName: rightTableName, screenId: modalScreenId, fullItem: item, }); // modalDataStore에도 저장 (호환성 유지) import("@/stores/modalDataStore").then(({ useModalDataStore }) => { useModalDataStore.getState().setData(rightTableName, [item]); }); // 🆕 groupByColumns 추출 const groupByColumns = currentTabConfig?.editButton?.groupByColumns || []; console.log("🔧 [SplitPanel] 수정 버튼 클릭 - groupByColumns 확인:", { groupByColumns, editButtonConfig: currentTabConfig?.editButton, hasGroupByColumns: groupByColumns.length > 0, }); // 🆕 groupByColumns 기준으로 모든 관련 레코드 조회 (API 직접 호출) let allRelatedRecords = [item]; // 기본값: 현재 아이템만 if (groupByColumns.length > 0) { // groupByColumns 값으로 검색 조건 생성 const matchConditions: Record = {}; groupByColumns.forEach((col: string) => { if (item[col] !== undefined && item[col] !== null) { matchConditions[col] = item[col]; } }); console.log("🔍 [SplitPanel] 그룹 레코드 조회 시작:", { 테이블: rightTableName, 조건: matchConditions, }); if (Object.keys(matchConditions).length > 0) { // 🆕 deduplication 없이 원본 데이터 다시 조회 (API 직접 호출) try { const { entityJoinApi } = await import("@/lib/api/entityJoin"); // 🔧 dataFilter로 정확 매칭 조건 생성 (search는 LIKE 검색이라 부정확) const exactMatchFilters = Object.entries(matchConditions).map(([key, value]) => ({ id: `exact-${key}`, columnName: key, operator: "equals", value: value, valueType: "text", })); console.log("🔍 [SplitPanel] 정확 매칭 필터:", exactMatchFilters); const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { // search 대신 dataFilter 사용 (정확 매칭) dataFilter: { enabled: true, matchType: "all", filters: exactMatchFilters, }, enableEntityJoin: true, size: 1000, // 🔧 명시적으로 deduplication 비활성화 (모든 레코드 가져오기) deduplication: { enabled: false, groupByColumn: "", keepStrategy: "latest" }, }); // 🔍 디버깅: API 응답 구조 확인 console.log("🔍 [SplitPanel] API 응답 전체:", result); console.log("🔍 [SplitPanel] result.data:", result.data); console.log("🔍 [SplitPanel] result 타입:", typeof result); // result 자체가 배열일 수도 있음 (entityJoinApi 응답 구조에 따라) const dataArray = Array.isArray(result) ? result : (result.data || []); if (dataArray.length > 0) { allRelatedRecords = dataArray; console.log("✅ [SplitPanel] 그룹 레코드 조회 완료:", { 조건: matchConditions, 결과수: allRelatedRecords.length, 레코드들: allRelatedRecords.map((r: any) => ({ id: r.id, supplier_item_code: r.supplier_item_code })), }); } else { console.warn("⚠️ [SplitPanel] 그룹 레코드 조회 결과 없음, 현재 아이템만 사용"); } } catch (error) { console.error("❌ [SplitPanel] 그룹 레코드 조회 실패:", error); allRelatedRecords = [item]; } } else { console.warn("⚠️ [SplitPanel] groupByColumns 값이 없음, 현재 아이템만 사용"); } } // 🔧 수정: URL 파라미터 대신 editData로 직접 전달 // 이렇게 하면 테이블의 Primary Key가 무엇이든 상관없이 데이터가 정확히 전달됨 window.dispatchEvent( new CustomEvent("openScreenModal", { detail: { screenId: modalScreenId, editData: allRelatedRecords, // 🆕 모든 관련 레코드 전달 (배열) urlParams: { mode: "edit", // 🆕 수정 모드 표시 ...(groupByColumns.length > 0 && { groupByColumns: JSON.stringify(groupByColumns), }), }, }, }), ); console.log("✅ [SplitPanel] openScreenModal 이벤트 발생 (editData 직접 전달):", { screenId: modalScreenId, editData: allRelatedRecords, recordCount: allRelatedRecords.length, groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음", }); return; } } // 기존 자동 편집 모드 (인라인 편집 모달) setEditModalPanel(panel); setEditModalItem(item); setEditModalFormData({ ...item }); setShowEditModal(true); }, [componentConfig, activeTabIndex], ); // 수정 모달 저장 const handleEditModalSave = useCallback(async () => { const tableName = editModalPanel === "left" ? componentConfig.leftPanel?.tableName : componentConfig.rightPanel?.tableName; const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id"; const primaryKey = editModalItem[sourceColumn] || editModalItem.id || editModalItem.ID; if (!tableName || !primaryKey) { toast({ title: "수정 오류", description: "테이블명 또는 Primary Key가 없습니다.", variant: "destructive", }); return; } try { console.log("📝 데이터 수정:", { tableName, primaryKey, data: editModalFormData }); // 프론트엔드 전용 필드 제거 (children, level 등) const cleanData = { ...editModalFormData }; delete cleanData.children; delete cleanData.level; // 좌측 패널 수정 시, 조인 관계 정보 포함 const updatePayload: any = cleanData; if (editModalPanel === "left" && componentConfig.rightPanel?.relation?.type === "join") { // 조인 관계가 있는 경우, 관계 정보를 페이로드에 추가 updatePayload._relationInfo = { rightTable: componentConfig.rightPanel.tableName, leftColumn: componentConfig.rightPanel.relation.leftColumn, rightColumn: componentConfig.rightPanel.relation.rightColumn, oldLeftValue: editModalItem[componentConfig.rightPanel.relation.leftColumn], }; console.log("🔗 조인 관계 정보 추가:", updatePayload._relationInfo); } const result = await dataApi.updateRecord(tableName, primaryKey, updatePayload); if (result.success) { toast({ title: "성공", description: "데이터가 성공적으로 수정되었습니다.", }); // 모달 닫기 setShowEditModal(false); setEditModalFormData({}); setEditModalItem(null); // 데이터 새로고침 if (editModalPanel === "left") { loadLeftData(); // 우측 패널도 새로고침 (FK가 변경되었을 수 있음) if (selectedLeftItem) { loadRightData(selectedLeftItem); } } else if (editModalPanel === "right" && selectedLeftItem) { loadRightData(selectedLeftItem); } } else { toast({ title: "수정 실패", description: result.message || "데이터 수정에 실패했습니다.", variant: "destructive", }); } } catch (error: any) { console.error("데이터 수정 오류:", error); toast({ title: "오류", description: error?.response?.data?.message || "데이터 수정 중 오류가 발생했습니다.", variant: "destructive", }); } }, [ editModalPanel, componentConfig, editModalItem, editModalFormData, toast, selectedLeftItem, loadLeftData, loadRightData, ]); // 삭제 버튼 핸들러 const handleDeleteClick = useCallback((panel: "left" | "right", item: any) => { setDeleteModalPanel(panel); setDeleteModalItem(item); setShowDeleteModal(true); }, []); // 삭제 확인 const handleDeleteConfirm = useCallback(async () => { // 🆕 현재 활성 탭의 설정 가져오기 const currentTabConfig = activeTabIndex === 0 ? componentConfig.rightPanel : componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]; // 우측 패널 삭제 시 중계 테이블 확인 let tableName = deleteModalPanel === "left" ? componentConfig.leftPanel?.tableName : currentTabConfig?.tableName; // 우측 패널 + 중계 테이블 모드인 경우 if (deleteModalPanel === "right" && currentTabConfig?.addConfig?.targetTable) { tableName = currentTabConfig.addConfig.targetTable; console.log("🔗 중계 테이블 모드: 삭제 대상 테이블 =", tableName); } const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id"; let primaryKey: any = deleteModalItem[sourceColumn] || deleteModalItem.id || deleteModalItem.ID; // 복합키 처리: deleteModalItem 전체를 전달 (백엔드에서 복합키 자동 처리) if (deleteModalItem && typeof deleteModalItem === "object") { primaryKey = deleteModalItem; console.log("🔑 복합키 가능성: 전체 객체 전달", primaryKey); } if (!tableName || !primaryKey) { toast({ title: "삭제 오류", description: "테이블명 또는 Primary Key가 없습니다.", variant: "destructive", }); return; } try { console.log("🗑️ 데이터 삭제:", { tableName, primaryKey }); // 🔍 그룹 삭제 설정 확인 (editButton.groupByColumns 또는 deduplication) const groupByColumns = componentConfig.rightPanel?.editButton?.groupByColumns || []; const deduplication = componentConfig.rightPanel?.dataFilter?.deduplication; console.log("🔍 삭제 설정 디버깅:", { panel: deleteModalPanel, groupByColumns, deduplication, deduplicationEnabled: deduplication?.enabled, }); let result; // 🔧 우측 패널 삭제 시 그룹 삭제 조건 확인 if (deleteModalPanel === "right") { // 1. groupByColumns가 설정된 경우 (패널 설정에서 선택된 컬럼들) if (groupByColumns.length > 0) { const filterConditions: Record = {}; // 선택된 컬럼들의 값을 필터 조건으로 추가 for (const col of groupByColumns) { if (deleteModalItem[col] !== undefined && deleteModalItem[col] !== null) { filterConditions[col] = deleteModalItem[col]; } } // 🔒 안전장치: 조인 모드에서 좌측 패널의 키 값도 필터 조건에 포함 // (다른 거래처의 같은 품목이 삭제되는 것을 방지) if (selectedLeftItem && componentConfig.rightPanel?.mode === "join") { const leftColumn = componentConfig.rightPanel.join?.leftColumn; const rightColumn = componentConfig.rightPanel.join?.rightColumn; if (leftColumn && rightColumn && selectedLeftItem[leftColumn]) { // rightColumn이 filterConditions에 없으면 추가 if (!filterConditions[rightColumn]) { filterConditions[rightColumn] = selectedLeftItem[leftColumn]; console.log(`🔒 안전장치: ${rightColumn} = ${selectedLeftItem[leftColumn]} 추가`); } } } // 필터 조건이 있으면 그룹 삭제 if (Object.keys(filterConditions).length > 0) { console.log(`🔗 그룹 삭제 (groupByColumns): ${groupByColumns.join(", ")} 기준`); console.log("🗑️ 그룹 삭제 조건:", filterConditions); result = await dataApi.deleteGroupRecords(tableName, filterConditions); } else { // 필터 조건이 없으면 단일 삭제 console.log("⚠️ groupByColumns 값이 없어 단일 삭제로 전환"); result = await dataApi.deleteRecord(tableName, primaryKey); } } // 2. 중복 제거(deduplication)가 활성화된 경우 else if (deduplication?.enabled && deduplication?.groupByColumn) { const groupByColumn = deduplication.groupByColumn; const groupValue = deleteModalItem[groupByColumn]; if (groupValue) { console.log(`🔗 중복 제거 활성화: ${groupByColumn} = ${groupValue} 기준으로 모든 레코드 삭제`); const filterConditions: Record = { [groupByColumn]: groupValue, }; // 좌측 패널의 선택된 항목 정보도 포함 (customer_id 등) if (selectedLeftItem && componentConfig.rightPanel?.mode === "join") { const leftColumn = componentConfig.rightPanel.join.leftColumn; const rightColumn = componentConfig.rightPanel.join.rightColumn; filterConditions[rightColumn] = selectedLeftItem[leftColumn]; } console.log("🗑️ 그룹 삭제 조건:", filterConditions); result = await dataApi.deleteGroupRecords(tableName, filterConditions); } else { result = await dataApi.deleteRecord(tableName, primaryKey); } } // 3. 그 외: 단일 레코드 삭제 else { result = await dataApi.deleteRecord(tableName, primaryKey); } } else { // 좌측 패널: 단일 레코드 삭제 result = await dataApi.deleteRecord(tableName, primaryKey); } if (result.success) { toast({ title: "성공", description: "데이터가 성공적으로 삭제되었습니다.", }); // 모달 닫기 setShowDeleteModal(false); setDeleteModalItem(null); // 데이터 새로고침 if (deleteModalPanel === "left") { loadLeftData(); // 삭제된 항목이 선택되어 있었으면 선택 해제 if (selectedLeftItem && selectedLeftItem[sourceColumn] === primaryKey) { setSelectedLeftItem(null); setRightData(null); } } else if (deleteModalPanel === "right" && selectedLeftItem) { // 🆕 현재 활성 탭에 따라 새로고침 if (activeTabIndex === 0) { loadRightData(selectedLeftItem); } else { loadTabData(activeTabIndex, selectedLeftItem); } } } else { toast({ title: "삭제 실패", description: result.message || "데이터 삭제에 실패했습니다.", variant: "destructive", }); } } catch (error: any) { console.error("데이터 삭제 오류:", error); // 외래키 제약조건 에러 처리 let errorMessage = "데이터 삭제 중 오류가 발생했습니다."; if (error?.response?.data?.error?.includes("foreign key")) { errorMessage = "이 데이터를 참조하는 다른 데이터가 있어 삭제할 수 없습니다."; } toast({ title: "오류", description: errorMessage, variant: "destructive", }); } }, [deleteModalPanel, componentConfig, deleteModalItem, toast, selectedLeftItem, loadLeftData, loadRightData, activeTabIndex, loadTabData]); // 항목별 추가 버튼 핸들러 (좌측 항목의 + 버튼 - 하위 항목 추가) const handleItemAddClick = useCallback( (item: any) => { const itemAddConfig = componentConfig.leftPanel?.itemAddConfig; if (!itemAddConfig) { toast({ title: "설정 오류", description: "하위 항목 추가 설정이 없습니다.", variant: "destructive", }); return; } const { sourceColumn, parentColumn } = itemAddConfig; if (!sourceColumn || !parentColumn) { toast({ title: "설정 오류", description: "현재 항목 ID 컬럼과 상위 항목 저장 컬럼을 설정해주세요.", variant: "destructive", }); return; } // 선택된 항목의 sourceColumn 값을 가져와서 parentColumn에 매핑 const sourceValue = item[sourceColumn]; if (!sourceValue) { toast({ title: "데이터 오류", description: `선택한 항목의 ${sourceColumn} 값이 없습니다.`, variant: "destructive", }); return; } // 좌측 패널 추가 모달 열기 (parentColumn 값 미리 채우기) setAddModalPanel("left-item"); setAddModalFormData({ [parentColumn]: sourceValue }); setShowAddModal(true); }, [componentConfig, toast], ); // 추가 모달 저장 const handleAddModalSave = useCallback(async () => { // 테이블명과 모달 컬럼 결정 let tableName: string | undefined; let modalColumns: Array<{ name: string; label: string; required?: boolean }> | undefined; const finalData = { ...addModalFormData }; if (addModalPanel === "left") { tableName = componentConfig.leftPanel?.tableName; modalColumns = componentConfig.leftPanel?.addModalColumns; } else if (addModalPanel === "right") { // 우측 패널: 중계 테이블 설정이 있는지 확인 const addConfig = componentConfig.rightPanel?.addConfig; if (addConfig?.targetTable) { // 중계 테이블 모드 tableName = addConfig.targetTable; modalColumns = componentConfig.rightPanel?.addModalColumns; // 좌측 패널에서 선택된 값 자동 채우기 if (addConfig.leftPanelColumn && addConfig.targetColumn && selectedLeftItem) { const leftValue = selectedLeftItem[addConfig.leftPanelColumn]; finalData[addConfig.targetColumn] = leftValue; console.log(`🔗 좌측 패널 값 자동 채움: ${addConfig.targetColumn} = ${leftValue}`); } // 자동 채움 컬럼 추가 if (addConfig.autoFillColumns) { Object.entries(addConfig.autoFillColumns).forEach(([key, value]) => { finalData[key] = value; }); console.log("🔧 자동 채움 컬럼:", addConfig.autoFillColumns); } } else { // 일반 테이블 모드 tableName = componentConfig.rightPanel?.tableName; modalColumns = componentConfig.rightPanel?.addModalColumns; } } else if (addModalPanel === "left-item") { // 하위 항목 추가 (좌측 테이블에 추가) tableName = componentConfig.leftPanel?.tableName; modalColumns = componentConfig.leftPanel?.itemAddConfig?.addModalColumns; } if (!tableName) { toast({ title: "테이블 오류", description: "테이블명이 설정되지 않았습니다.", variant: "destructive", }); return; } // 필수 필드 검증 const requiredFields = (modalColumns || []).filter((col) => col.required); for (const field of requiredFields) { if (!addModalFormData[field.name]) { toast({ title: "입력 오류", description: `${field.label}은(는) 필수 입력 항목입니다.`, variant: "destructive", }); return; } } try { console.log("📝 데이터 추가:", { tableName, data: finalData }); const result = await dataApi.createRecord(tableName, finalData); if (result.success) { toast({ title: "성공", description: "데이터가 성공적으로 추가되었습니다.", }); // 모달 닫기 setShowAddModal(false); setAddModalFormData({}); // 데이터 새로고침 if (addModalPanel === "left" || addModalPanel === "left-item") { // 좌측 패널 데이터 새로고침 (일반 추가 또는 하위 항목 추가) loadLeftData(); } else if (addModalPanel === "right" && selectedLeftItem) { // 우측 패널 데이터 새로고침 loadRightData(selectedLeftItem); } } else { toast({ title: "저장 실패", description: result.message || "데이터 추가에 실패했습니다.", variant: "destructive", }); } } catch (error: any) { console.error("데이터 추가 오류:", error); // 에러 메시지 추출 let errorMessage = "데이터 추가 중 오류가 발생했습니다."; if (error?.response?.data) { const responseData = error.response.data; // 백엔드에서 반환한 에러 메시지 확인 if (responseData.error) { // 중복 키 에러 처리 if (responseData.error.includes("duplicate key")) { errorMessage = "이미 존재하는 값입니다. 다른 값을 입력해주세요."; } // NOT NULL 제약조건 에러 else if (responseData.error.includes("null value")) { const match = responseData.error.match(/column "(\w+)"/); const columnName = match ? match[1] : "필수"; errorMessage = `${columnName} 필드는 필수 입력 항목입니다.`; } // 외래키 제약조건 에러 else if (responseData.error.includes("foreign key")) { errorMessage = "참조하는 데이터가 존재하지 않습니다."; } // 기타 에러 else { errorMessage = responseData.message || responseData.error; } } else if (responseData.message) { errorMessage = responseData.message; } } toast({ title: "오류", description: errorMessage, variant: "destructive", }); } }, [addModalPanel, componentConfig, addModalFormData, toast, selectedLeftItem, loadLeftData, loadRightData]); // 🔧 좌측 컬럼 가시성 설정 저장 및 불러오기 useEffect(() => { const leftTableName = componentConfig.leftPanel?.tableName; if (leftTableName && currentUserId) { // localStorage에서 저장된 설정 불러오기 const storageKey = `table_column_visibility_${leftTableName}_${currentUserId}`; const savedSettings = localStorage.getItem(storageKey); if (savedSettings) { try { const parsed = JSON.parse(savedSettings) as ColumnVisibility[]; setLeftColumnVisibility(parsed); } catch (error) { console.error("저장된 컬럼 설정 불러오기 실패:", error); } } } }, [componentConfig.leftPanel?.tableName, currentUserId]); // 🔧 컬럼 가시성 변경 시 localStorage에 저장 및 순서 업데이트 useEffect(() => { const leftTableName = componentConfig.leftPanel?.tableName; if (leftColumnVisibility.length > 0 && leftTableName && currentUserId) { // 순서 업데이트 const newOrder = leftColumnVisibility.map((cv) => cv.columnName).filter((name) => name !== "__checkbox__"); // 체크박스 제외 setLeftColumnOrder(newOrder); // localStorage에 저장 const storageKey = `table_column_visibility_${leftTableName}_${currentUserId}`; localStorage.setItem(storageKey, JSON.stringify(leftColumnVisibility)); } }, [leftColumnVisibility, componentConfig.leftPanel?.tableName, currentUserId]); // 초기 데이터 로드 useEffect(() => { if (!isDesignMode && componentConfig.autoLoad !== false) { loadLeftData(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isDesignMode, componentConfig.autoLoad]); // 🔄 필터 변경 시 데이터 다시 로드 useEffect(() => { if (!isDesignMode && componentConfig.autoLoad !== false) { loadLeftData(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [leftFilters]); // 🆕 전역 테이블 새로고침 이벤트 리스너 useEffect(() => { const handleRefreshTable = () => { if (!isDesignMode) { // console.log("🔄 [SplitPanel] refreshTable 이벤트 수신 - 데이터 새로고침"); loadLeftData(); // 선택된 항목이 있으면 우측 패널도 새로고침 if (selectedLeftItem) { loadRightData(selectedLeftItem); } } }; window.addEventListener("refreshTable", handleRefreshTable); return () => { window.removeEventListener("refreshTable", handleRefreshTable); }; }, [isDesignMode, loadLeftData, loadRightData, selectedLeftItem]); // 리사이저 드래그 핸들러 const handleMouseDown = (e: React.MouseEvent) => { if (!resizable) return; setIsDragging(true); e.preventDefault(); }; const handleMouseMove = useCallback( (e: MouseEvent) => { if (!isDragging || !containerRef.current) return; const containerRect = containerRef.current.getBoundingClientRect(); const containerWidth = containerRect.width; const relativeX = e.clientX - containerRect.left; const newLeftWidth = (relativeX / containerWidth) * 100; // 최소/최대 너비 제한 (20% ~ 80%) if (newLeftWidth >= 20 && newLeftWidth <= 80) { setLeftWidth(newLeftWidth); } }, [isDragging], ); const handleMouseUp = useCallback(() => { setIsDragging(false); }, []); React.useEffect(() => { if (isDragging) { // 드래그 중에는 텍스트 선택 방지 document.body.style.userSelect = "none"; document.body.style.cursor = "col-resize"; document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); return () => { document.body.style.userSelect = ""; document.body.style.cursor = ""; document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); }; } }, [isDragging, handleMouseMove, handleMouseUp]); return (
{ if (isDesignMode) { e.stopPropagation(); onClick?.(e); } }} className="w-full overflow-hidden rounded-lg bg-white shadow-sm" > {/* 좌측 패널 */}
{componentConfig.leftPanel?.title || "좌측 패널"} {!isDesignMode && componentConfig.leftPanel?.showAdd && ( )}
{componentConfig.leftPanel?.showSearch && (
setLeftSearchQuery(e.target.value)} className="pl-9" />
)} {/* 좌측 데이터 목록/테이블 */} {componentConfig.leftPanel?.displayMode === "table" ? ( // 테이블 모드
{isDesignMode ? ( // 디자인 모드: 샘플 테이블
컬럼 1 컬럼 2 컬럼 3
데이터 1-1 데이터 1-2 데이터 1-3
데이터 2-1 데이터 2-2 데이터 2-3
) : isLoadingLeft ? (
데이터를 불러오는 중...
) : ( (() => { // 🆕 그룹별 합산된 데이터 사용 const dataSource = summedLeftData; // console.log( // "🔍 [테이블모드 렌더링] dataSource 개수:", // dataSource.length, // "leftGroupSumConfig:", // leftGroupSumConfig, // ); // 🔧 로컬 검색 필터 적용 const filteredData = leftSearchQuery ? dataSource.filter((item) => { const searchLower = leftSearchQuery.toLowerCase(); return Object.entries(item).some(([key, value]) => { if (value === null || value === undefined) return false; return String(value).toLowerCase().includes(searchLower); }); }) : dataSource; // 🔧 가시성 처리된 컬럼 사용 const columnsToShow = visibleLeftColumns.length > 0 ? visibleLeftColumns.map((col: any) => { const colName = typeof col === "string" ? col : col.name || col.columnName; return { name: colName, label: leftColumnLabels[colName] || (typeof col === "object" ? col.label : null) || colName, width: typeof col === "object" ? col.width : 150, align: (typeof col === "object" ? col.align : "left") as "left" | "center" | "right", format: typeof col === "object" ? col.format : undefined, // 🆕 포맷 설정 포함 }; }) : Object.keys(filteredData[0] || {}) .filter((key) => key !== "children" && key !== "level") .slice(0, 5) .map((key) => ({ name: key, label: leftColumnLabels[key] || key, width: 150, align: "left" as const, format: undefined, // 🆕 기본값 })); // 🔧 그룹화된 데이터 렌더링 if (groupedLeftData.length > 0) { return (
{groupedLeftData.map((group, groupIdx) => (
{group.groupKey} ({group.count}개)
{columnsToShow.map((col, idx) => ( ))} {group.items.map((item, idx) => { const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id"; const itemId = item[sourceColumn] || item.id || item.ID || idx; const isSelected = selectedLeftItem && (selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item); return ( handleLeftItemSelect(item)} className={`hover:bg-accent cursor-pointer transition-colors ${ isSelected ? "bg-primary/10" : "" }`} > {columnsToShow.map((col, colIdx) => ( ))} ); })}
{col.label}
{formatCellValue( col.name, getEntityJoinValue(item, col.name), leftCategoryMappings, col.format, )}
))}
); } // 🔧 일반 테이블 렌더링 (그룹화 없음) return (
{columnsToShow.map((col, idx) => ( ))} {filteredData.map((item, idx) => { const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id"; const itemId = item[sourceColumn] || item.id || item.ID || idx; const isSelected = selectedLeftItem && (selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item); return ( handleLeftItemSelect(item)} className={`hover:bg-accent cursor-pointer transition-colors ${ isSelected ? "bg-primary/10" : "" }`} > {columnsToShow.map((col, colIdx) => ( ))} ); })}
{col.label}
{formatCellValue( col.name, getEntityJoinValue(item, col.name), leftCategoryMappings, col.format, )}
); })() )}
) : ( // 목록 모드 (기존)
{isDesignMode ? ( // 디자인 모드: 샘플 데이터 <>
handleLeftItemSelect({ id: 1, name: "항목 1" })} className={`hover:bg-accent cursor-pointer rounded-md p-3 transition-colors ${ selectedLeftItem?.id === 1 ? "bg-primary/10 text-primary" : "" }`} >
항목 1
설명 텍스트
handleLeftItemSelect({ id: 2, name: "항목 2" })} className={`hover:bg-accent cursor-pointer rounded-md p-3 transition-colors ${ selectedLeftItem?.id === 2 ? "bg-primary/10 text-primary" : "" }`} >
항목 2
설명 텍스트
handleLeftItemSelect({ id: 3, name: "항목 3" })} className={`hover:bg-accent cursor-pointer rounded-md p-3 transition-colors ${ selectedLeftItem?.id === 3 ? "bg-primary/10 text-primary" : "" }`} >
항목 3
설명 텍스트
) : isLoadingLeft ? ( // 로딩 중
데이터를 불러오는 중...
) : ( (() => { // 🆕 그룹별 합산된 데이터 사용 const dataToDisplay = summedLeftData; console.log( "🔍 [렌더링] dataToDisplay 개수:", dataToDisplay.length, "leftGroupSumConfig:", leftGroupSumConfig, ); // 검색 필터링 (클라이언트 사이드) const filteredLeftData = leftSearchQuery ? dataToDisplay.filter((item) => { const searchLower = leftSearchQuery.toLowerCase(); return Object.entries(item).some(([key, value]) => { if (value === null || value === undefined) return false; return String(value).toLowerCase().includes(searchLower); }); }) : dataToDisplay; // 재귀 렌더링 함수 const renderTreeItem = (item: any, index: number): React.ReactNode => { const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id"; const itemId = item[sourceColumn] || item.id || item.ID || index; const isSelected = selectedLeftItem && (selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item); const hasChildren = item.children && item.children.length > 0; const isExpanded = expandedItems.has(itemId); const level = item.level || 0; // 🔧 수정: "표시할 컬럼 선택"에서 설정한 컬럼을 우선 사용 const configuredColumns = componentConfig.leftPanel?.columns || []; let displayFields: { label: string; value: any }[] = []; // 디버그 로그 if (index === 0) { console.log("🔍 좌측 패널 표시 로직:"); console.log(" - 설정된 표시 컬럼:", configuredColumns); console.log(" - item keys:", Object.keys(item)); } if (configuredColumns.length > 0) { // 🔧 "표시할 컬럼 선택"에서 설정한 컬럼 사용 displayFields = configuredColumns.slice(0, 2).map((col: any) => { const colName = typeof col === "string" ? col : col.name || col.columnName; const colLabel = typeof col === "object" ? col.label : leftColumnLabels[colName] || colName; return { label: colLabel, value: item[colName], }; }); if (index === 0) { console.log(" ✅ 설정된 컬럼 기반 표시:", displayFields); } } else { // 설정된 컬럼이 없으면 자동으로 첫 2개 필드 표시 const keys = Object.keys(item).filter( (k) => k !== "id" && k !== "ID" && k !== "children" && k !== "level" && shouldShowField(k), ); displayFields = keys.slice(0, 2).map((key) => ({ label: leftColumnLabels[key] || key, value: item[key], })); if (index === 0) { console.log(" ⚠️ 설정된 컬럼 없음, 자동 선택:", displayFields); } } const displayTitle = displayFields[0]?.value || item.name || item.title || `항목 ${index + 1}`; const displaySubtitle = displayFields[1]?.value || null; return ( {/* 현재 항목 */}
{ handleLeftItemSelect(item); if (hasChildren) { toggleExpand(itemId); } }} > {/* 펼치기/접기 아이콘 */} {hasChildren ? (
{isExpanded ? ( ) : ( )}
) : (
)} {/* 항목 내용 */}
{displayTitle}
{displaySubtitle && (
{displaySubtitle}
)}
{/* 항목별 버튼들 */} {!isDesignMode && (
{/* 수정 버튼 */} {/* 삭제 버튼 */} {/* 항목별 추가 버튼 */} {componentConfig.leftPanel?.showItemAddButton && ( )}
)}
{/* 자식 항목들 (접혀있으면 표시 안함) */} {hasChildren && isExpanded && item.children.map((child: any, childIndex: number) => renderTreeItem(child, childIndex))} ); }; return filteredLeftData.length > 0 ? ( // 실제 데이터 표시 filteredLeftData.map((item, index) => renderTreeItem(item, index)) ) : ( // 검색 결과 없음
{leftSearchQuery ? ( <>

검색 결과가 없습니다.

다른 검색어를 입력해보세요.

) : ( "데이터가 없습니다." )}
); })() )}
)}
{/* 리사이저 */} {resizable && (
)} {/* 우측 패널 */}
{/* 🆕 탭 바 (추가 탭이 있을 때만 표시) */} {(componentConfig.rightPanel?.additionalTabs?.length || 0) > 0 && (
handleTabChange(Number(value))} className="w-full" > {componentConfig.rightPanel?.title || "기본"} {componentConfig.rightPanel?.additionalTabs?.map((tab, index) => ( {tab.label || `탭 ${index + 1}`} ))}
)}
{activeTabIndex === 0 ? componentConfig.rightPanel?.title || "우측 패널" : componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]?.title || componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]?.label || "우측 패널"} {!isDesignMode && (
{/* 현재 활성 탭에 따른 추가 버튼 */} {activeTabIndex === 0 ? componentConfig.rightPanel?.showAdd && ( ) : componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]?.showAdd && ( )} {/* 우측 패널 수정/삭제는 각 카드에서 처리 */}
)}
{componentConfig.rightPanel?.showSearch && (
setRightSearchQuery(e.target.value)} className="pl-9" />
)} {/* 🆕 추가 탭 데이터 렌더링 */} {activeTabIndex > 0 ? ( // 추가 탭 컨텐츠 (() => { const currentTabConfig = componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]; const currentTabData = tabsData[activeTabIndex] || []; const isTabLoading = tabsLoading[activeTabIndex]; if (isTabLoading) { return (

데이터를 불러오는 중...

); } if (!selectedLeftItem) { return (

좌측에서 항목을 선택하세요

); } if (currentTabData.length === 0) { return (

데이터가 없습니다

); } // 탭 데이터 렌더링 (목록/테이블 모드) const isTableMode = currentTabConfig?.displayMode === "table"; if (isTableMode) { // 테이블 모드 const displayColumns = currentTabConfig?.columns || []; const columnsToShow = displayColumns.length > 0 ? displayColumns.map((col) => ({ ...col, label: col.label || col.name, })) : Object.keys(currentTabData[0] || {}) .filter(shouldShowField) .slice(0, 8) .map((key) => ({ name: key, label: key })); return (
{columnsToShow.map((col: any) => ( ))} {(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && ( )} {currentTabData.map((item: any, idx: number) => ( {columnsToShow.map((col: any) => ( ))} {(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && ( )} ))}
{col.label} 작업
{formatValue(item[col.name], col.format)}
{currentTabConfig?.showEdit && ( )} {currentTabConfig?.showDelete && ( )}
); } else { // 목록 (카드) 모드 const displayColumns = currentTabConfig?.columns || []; const summaryCount = currentTabConfig?.summaryColumnCount ?? 3; const showLabel = currentTabConfig?.summaryShowLabel ?? true; return (
{currentTabData.map((item: any, idx: number) => { const itemId = item.id || idx; const isExpanded = expandedRightItems.has(itemId); // 표시할 컬럼 결정 const columnsToShow = displayColumns.length > 0 ? displayColumns : Object.keys(item) .filter(shouldShowField) .slice(0, 8) .map((key) => ({ name: key, label: key })); const summaryColumns = columnsToShow.slice(0, summaryCount); const detailColumns = columnsToShow.slice(summaryCount); return (
toggleRightItemExpansion(itemId)} >
{summaryColumns.map((col: any) => (
{showLabel && ( {col.label}: )} {formatValue(item[col.name], col.format)}
))}
{currentTabConfig?.showEdit && ( )} {currentTabConfig?.showDelete && ( )} {detailColumns.length > 0 && ( isExpanded ? ( ) : ( ) )}
{isExpanded && detailColumns.length > 0 && (
{detailColumns.map((col: any) => (
{col.label}: {formatValue(item[col.name], col.format)}
))}
)}
); })}
); } })() ) : ( /* 기본 탭 (우측 패널) 데이터 */ <> {isLoadingRight ? ( // 로딩 중

데이터를 불러오는 중...

) : rightData ? ( // 실제 데이터 표시 Array.isArray(rightData) ? ( // 조인 모드: 여러 데이터를 테이블/리스트로 표시 (() => { // 검색 필터링 const filteredData = rightSearchQuery ? rightData.filter((item) => { const searchLower = rightSearchQuery.toLowerCase(); return Object.entries(item).some(([key, value]) => { if (value === null || value === undefined) return false; return String(value).toLowerCase().includes(searchLower); }); }) : rightData; // 테이블 모드 체크 const isTableMode = componentConfig.rightPanel?.displayMode === "table"; if (isTableMode) { // 테이블 모드 렌더링 const displayColumns = componentConfig.rightPanel?.columns || []; // 🆕 그룹 합산 모드일 때: 복합키 컬럼을 우선 표시 const relationKeys = componentConfig.rightPanel?.relation?.keys || []; const keyColumns = relationKeys.map((k: any) => k.leftColumn).filter(Boolean); const isGroupedMode = selectedLeftItem?._originalItems?.length > 0; let columnsToShow: any[] = []; if (displayColumns.length > 0) { // 설정된 컬럼 사용 columnsToShow = displayColumns.map((col) => ({ ...col, label: rightColumnLabels[col.name] || col.label || col.name, format: col.format, })); // 🆕 그룹 합산 모드이고, 키 컬럼이 표시 목록에 없으면 맨 앞에 추가 if (isGroupedMode && keyColumns.length > 0) { const existingColNames = columnsToShow.map((c) => c.name); const missingKeyColumns = keyColumns.filter((k: string) => !existingColNames.includes(k)); if (missingKeyColumns.length > 0) { const keyColsToAdd = missingKeyColumns.map((colName: string) => ({ name: colName, label: rightColumnLabels[colName] || colName, width: 120, align: "left" as const, format: undefined, _isKeyColumn: true, // 구분용 플래그 })); columnsToShow = [...keyColsToAdd, ...columnsToShow]; console.log("🔗 [우측패널] 그룹모드 - 키 컬럼 추가:", missingKeyColumns); } } } else { // 기본 컬럼 자동 생성 columnsToShow = Object.keys(filteredData[0] || {}) .filter((key) => shouldShowField(key)) .slice(0, 5) .map((key) => ({ name: key, label: rightColumnLabels[key] || key, width: 150, align: "left" as const, format: undefined, })); } return (
{filteredData.length}개의 관련 데이터 {rightSearchQuery && filteredData.length !== rightData.length && ( (전체 {rightData.length}개 중) )}
{columnsToShow.map((col, idx) => ( ))} {/* 수정 또는 삭제 버튼이 하나라도 활성화되어 있을 때만 작업 컬럼 표시 */} {!isDesignMode && ((componentConfig.rightPanel?.editButton?.enabled ?? true) || (componentConfig.rightPanel?.deleteButton?.enabled ?? true)) && ( )} {filteredData.map((item, idx) => { const itemId = item.id || item.ID || idx; return ( {columnsToShow.map((col, colIdx) => ( ))} {/* 수정 또는 삭제 버튼이 하나라도 활성화되어 있을 때만 작업 셀 표시 */} {!isDesignMode && ((componentConfig.rightPanel?.editButton?.enabled ?? true) || (componentConfig.rightPanel?.deleteButton?.enabled ?? true)) && ( )} ); })}
{col.label} 작업
{formatCellValue( col.name, getEntityJoinValue(item, col.name), rightCategoryMappings, col.format, )}
{(componentConfig.rightPanel?.editButton?.enabled ?? true) && ( )} {(componentConfig.rightPanel?.deleteButton?.enabled ?? true) && ( )}
); } // 목록 모드 (기존) return filteredData.length > 0 ? (
{filteredData.length}개의 관련 데이터 {rightSearchQuery && filteredData.length !== rightData.length && ( (전체 {rightData.length}개 중) )}
{filteredData.map((item, index) => { const itemId = item.id || item.ID || index; const isExpanded = expandedRightItems.has(itemId); // 우측 패널 표시 컬럼 설정 확인 const rightColumns = componentConfig.rightPanel?.columns; let firstValues: [string, any, string][] = []; let allValues: [string, any, string][] = []; if (rightColumns && rightColumns.length > 0) { // 설정된 컬럼만 표시 (엔티티 조인 컬럼 처리) const summaryCount = componentConfig.rightPanel?.summaryColumnCount ?? 3; firstValues = rightColumns .slice(0, summaryCount) .map((col) => { // 🆕 엔티티 조인 컬럼 처리 (getEntityJoinValue 사용) const value = getEntityJoinValue(item, col.name); return [col.name, value, col.label] as [string, any, string]; }) .filter(([_, value]) => value !== null && value !== undefined && value !== ""); allValues = rightColumns .map((col) => { // 🆕 엔티티 조인 컬럼 처리 (getEntityJoinValue 사용) const value = getEntityJoinValue(item, col.name); return [col.name, value, col.label] as [string, any, string]; }) .filter(([_, value]) => value !== null && value !== undefined && value !== ""); } else { // 설정 없으면 모든 컬럼 표시 (기존 로직) const summaryCount = componentConfig.rightPanel?.summaryColumnCount ?? 3; firstValues = Object.entries(item) .filter(([key]) => !key.toLowerCase().includes("id")) .slice(0, summaryCount) .map(([key, value]) => [key, value, ""] as [string, any, string]); allValues = Object.entries(item) .filter(([key, value]) => value !== null && value !== undefined && value !== "") .map(([key, value]) => [key, value, ""] as [string, any, string]); } return (
{/* 요약 정보 */}
toggleRightItemExpansion(itemId)} >
{firstValues.map(([key, value, label], idx) => { // 포맷 설정 및 볼드 설정 찾기 const colConfig = rightColumns?.find((c) => c.name === key); const format = colConfig?.format; const boldValue = colConfig?.bold ?? false; // 🆕 포맷 적용 (날짜/숫자/카테고리) const displayValue = formatCellValue(key, value, rightCategoryMappings, format); const showLabel = componentConfig.rightPanel?.summaryShowLabel ?? true; return (
{showLabel && ( {label || getColumnLabel(key)}: )} {displayValue}
); })}
{/* 수정 버튼 */} {!isDesignMode && (componentConfig.rightPanel?.editButton?.enabled ?? true) && ( )} {/* 삭제 버튼 */} {!isDesignMode && (componentConfig.rightPanel?.deleteButton?.enabled ?? true) && ( )} {/* 확장/접기 버튼 */}
{/* 상세 정보 (확장 시 표시) */} {isExpanded && (
전체 상세 정보
{allValues.map(([key, value, label]) => { // 포맷 설정 찾기 const colConfig = rightColumns?.find((c) => c.name === key); const format = colConfig?.format; // 🆕 포맷 적용 (날짜/숫자/카테고리) const displayValue = formatCellValue(key, value, rightCategoryMappings, format); return ( ); })}
{label || getColumnLabel(key)} {displayValue}
)}
); })}
) : (
{rightSearchQuery ? ( <>

검색 결과가 없습니다.

다른 검색어를 입력해보세요.

) : ( "관련 데이터가 없습니다." )}
); })() ) : ( // 상세 모드: 단일 객체를 상세 정보로 표시 (() => { const rightColumns = componentConfig.rightPanel?.columns; let displayEntries: [string, any, string][] = []; if (rightColumns && rightColumns.length > 0) { console.log("🔍 [디버깅] 상세 모드 표시 로직:"); console.log(" 📋 rightData 전체:", rightData); console.log(" 📋 rightData keys:", Object.keys(rightData)); console.log( " ⚙️ 설정된 컬럼:", rightColumns.map((c) => `${c.name} (${c.label})`), ); // 설정된 컬럼만 표시 displayEntries = rightColumns .map((col) => { // 🆕 엔티티 조인 컬럼 처리 (예: item_info.item_name → item_name) let value = rightData[col.name]; console.log(` 🔎 컬럼 "${col.name}": 직접 접근 = ${value}`); if (value === undefined && col.name.includes(".")) { const columnName = col.name.split(".").pop(); value = rightData[columnName || ""]; console.log(` → 변환 후 "${columnName}" 접근 = ${value}`); } return [col.name, value, col.label] as [string, any, string]; }) .filter(([key, value]) => { const filtered = value === null || value === undefined || value === ""; if (filtered) { console.log(` ❌ 필터링됨: "${key}" (값: ${value})`); } return !filtered; }); console.log(" ✅ 최종 표시할 항목:", displayEntries.length, "개"); } else { // 설정 없으면 모든 컬럼 표시 displayEntries = Object.entries(rightData) .filter(([_, value]) => value !== null && value !== undefined && value !== "") .map(([key, value]) => [key, value, ""] as [string, any, string]); console.log(" ⚠️ 컬럼 설정 없음, 모든 컬럼 표시"); } return (
{displayEntries.map(([key, value, label]) => (
{label || getColumnLabel(key)}
{String(value)}
))}
); })() ) ) : selectedLeftItem && isDesignMode ? ( // 디자인 모드: 샘플 데이터

{selectedLeftItem.name} 상세 정보

항목 1: 값 1
항목 2: 값 2
항목 3: 값 3
) : ( // 선택 없음

좌측에서 항목을 선택하세요

선택한 항목의 상세 정보가 여기에 표시됩니다

)} )}
{/* 추가 모달 */} {addModalPanel === "left" ? `${componentConfig.leftPanel?.title} 추가` : addModalPanel === "right" ? `${componentConfig.rightPanel?.title} 추가` : `하위 ${componentConfig.leftPanel?.title} 추가`} {addModalPanel === "left-item" ? "선택한 항목의 하위 항목을 추가합니다. 필수 항목을 입력해주세요." : "새로운 데이터를 추가합니다. 필수 항목을 입력해주세요."}
{(() => { // 어떤 컬럼들을 표시할지 결정 let modalColumns: Array<{ name: string; label: string; required?: boolean }> | undefined; if (addModalPanel === "left") { modalColumns = componentConfig.leftPanel?.addModalColumns; } else if (addModalPanel === "right") { modalColumns = componentConfig.rightPanel?.addModalColumns; } else if (addModalPanel === "left-item") { modalColumns = componentConfig.leftPanel?.itemAddConfig?.addModalColumns; } return modalColumns?.map((col, index) => { // 항목별 추가 버튼으로 열렸을 때, parentColumn은 미리 채워져 있고 수정 불가 const isItemAddPreFilled = addModalPanel === "left-item" && componentConfig.leftPanel?.itemAddConfig?.parentColumn === col.name && addModalFormData[col.name]; // 우측 패널 추가 시, 조인 컬럼(rightColumn)은 미리 채워져 있고 수정 불가 const isRightJoinPreFilled = addModalPanel === "right" && componentConfig.rightPanel?.rightColumn === col.name && addModalFormData[col.name]; const isPreFilled = isItemAddPreFilled || isRightJoinPreFilled; return (
{ setAddModalFormData((prev) => ({ ...prev, [col.name]: e.target.value, })); }} placeholder={`${col.label} 입력`} className="h-8 text-xs sm:h-10 sm:text-sm" required={col.required} disabled={isPreFilled} />
); }); })()}
{/* 수정 모달 */} {editModalPanel === "left" ? `${componentConfig.leftPanel?.title} 수정` : `${componentConfig.rightPanel?.title} 수정`} 데이터를 수정합니다. 필요한 항목을 변경해주세요.
{editModalItem && (() => { // 좌측 패널 수정: leftColumn만 수정 가능 if (editModalPanel === "left") { const leftColumn = componentConfig.rightPanel?.relation?.leftColumn; // leftColumn만 표시 if (!leftColumn || editModalFormData[leftColumn] === undefined) { return

수정 가능한 컬럼이 없습니다.

; } return (
{ setEditModalFormData((prev) => ({ ...prev, [leftColumn]: e.target.value, })); }} placeholder={`${leftColumn} 입력`} className="h-8 text-xs sm:h-10 sm:text-sm" />
); } // 우측 패널 수정: 우측 패널에 설정된 표시 컬럼들만 if (editModalPanel === "right") { const rightColumns = componentConfig.rightPanel?.columns; if (rightColumns && rightColumns.length > 0) { // 설정된 컬럼만 표시 return rightColumns.map((col) => (
{ setEditModalFormData((prev) => ({ ...prev, [col.name]: e.target.value, })); }} placeholder={`${col.label || col.name} 입력`} className="h-8 text-xs sm:h-10 sm:text-sm" />
)); } else { // 설정이 없으면 모든 컬럼 표시 (민감한 필드 제외) return Object.entries(editModalFormData) .filter(([key]) => shouldShowField(key)) .map(([key, value]) => (
{ setEditModalFormData((prev) => ({ ...prev, [key]: e.target.value, })); }} placeholder={`${key} 입력`} className="h-8 text-xs sm:h-10 sm:text-sm" />
)); } } return null; })()}
{/* 삭제 확인 모달 */} 삭제 확인 정말로 이 데이터를 삭제하시겠습니까?
이 작업은 되돌릴 수 없습니다.
); }; /** * SplitPanelLayout 래퍼 컴포넌트 */ export const SplitPanelLayoutWrapper: React.FC = (props) => { return ; };