"use client"; import React, { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { ComponentRendererProps } from "../../types"; import { SplitPanelLayoutConfig, MAX_LOAD_ALL_SIZE } 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, ChevronLeft, ChevronsLeft, ChevronsRight, Pencil, Trash2, Settings, Move, FileSpreadsheet, } from "lucide-react"; import { dataApi } from "@/lib/api/data"; import { entityJoinApi } from "@/lib/api/entityJoin"; import { formatNumber as centralFormatNumber } from "@/lib/formatting"; import { useToast } from "@/hooks/use-toast"; import { tableTypeApi } from "@/lib/api/screen"; import { apiClient, getFullImageUrl } from "@/lib/api/client"; import { getFilePreviewUrl } from "@/lib/api/file"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, } from "@/components/ui/dialog"; 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"; import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; import { PanelInlineComponent } from "./types"; import { cn } from "@/lib/utils"; import { ResponsiveGridRenderer } from "@/components/screen/ResponsiveGridRenderer"; import { BomExcelUploadModal } from "../v2-bom-tree/BomExcelUploadModal"; /** 클라이언트 사이드 데이터 필터 (페이징 OFF 전용) */ function applyClientSideFilter(data: any[], dataFilter: any): any[] { if (!dataFilter?.enabled) return data; let result = data; if (dataFilter.filters?.length > 0) { const matchFn = dataFilter.matchType === "any" ? "some" : "every"; result = result.filter((item: any) => dataFilter.filters[matchFn]((cond: any) => { const val = item[cond.columnName]; switch (cond.operator) { case "equals": return val === cond.value; case "notEquals": case "not_equals": return val !== cond.value; case "in": { const arr = Array.isArray(cond.value) ? cond.value : [cond.value]; return arr.includes(val); } case "not_in": { const arr = Array.isArray(cond.value) ? cond.value : [cond.value]; return !arr.includes(val); } case "contains": return String(val || "").includes(String(cond.value)); case "is_null": return val === null || val === undefined || val === ""; case "is_not_null": return val !== null && val !== undefined && val !== ""; default: return true; } }), ); } // legacy conditions 형식 (하위 호환성) if (dataFilter.conditions?.length > 0) { result = result.filter((item: any) => dataFilter.conditions.every((cond: any) => { const val = item[cond.column]; switch (cond.operator) { case "equals": return val === cond.value; case "notEquals": return val !== cond.value; case "contains": return String(val || "").includes(String(cond.value)); default: return true; } }), ); } return result; } export interface SplitPanelLayoutComponentProps extends ComponentRendererProps { // 추가 props onUpdateComponent?: (component: any) => void; // 🆕 패널 내부 컴포넌트 선택 콜백 (탭 컴포넌트와 동일 구조) onSelectPanelComponent?: (panelSide: "left" | "right", compId: string, comp: PanelInlineComponent) => void; selectedPanelComponentId?: string; } // 이미지 셀 렌더링 컴포넌트 (objid 또는 파일 경로 지원) const SplitPanelCellImage: React.FC<{ value: string }> = React.memo(({ value }) => { const [imgSrc, setImgSrc] = React.useState(null); React.useEffect(() => { if (!value) return; const strVal = String(value).trim(); if (!strVal || strVal === "-") return; if (strVal.startsWith("http") || strVal.startsWith("/uploads/") || strVal.startsWith("/api/")) { setImgSrc(getFullImageUrl(strVal)); } else { const previewUrl = getFilePreviewUrl(strVal); fetch(previewUrl, { credentials: "include" }) .then((res) => { if (!res.ok) throw new Error("fetch failed"); return res.blob(); }) .then((blob) => setImgSrc(URL.createObjectURL(blob))) .catch(() => setImgSrc(null)); } }, [value]); if (!imgSrc) return -; return ( setImgSrc(null)} /> ); }); SplitPanelCellImage.displayName = "SplitPanelCellImage"; /** * 커스텀 모드 런타임: 디자이너 좌표를 비례 스케일링하여 렌더링 */ const ScaledCustomPanel: React.FC<{ components: PanelInlineComponent[]; formData: Record; onFormDataChange: (fieldName: string, value: any) => void; tableName?: string; menuObjid?: number; screenId?: number; userId?: string; userName?: string; companyCode?: string; allComponents?: any; selectedRowsData?: any[]; onSelectedRowsChange?: any; }> = ({ components, formData, onFormDataChange, tableName, ...restProps }) => { const containerRef = React.useRef(null); const [containerWidth, setContainerWidth] = React.useState(0); React.useEffect(() => { const el = containerRef.current; if (!el) return; const ro = new ResizeObserver((entries) => { const w = entries[0]?.contentRect.width; if (w && w > 0) setContainerWidth(w); }); ro.observe(el); return () => ro.disconnect(); }, []); const canvasW = Math.max( ...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)), 400, ); const canvasH = Math.max( ...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)), 200, ); return (
{containerWidth > 0 && components.map((comp) => { const x = comp.position?.x || 0; const y = comp.position?.y || 0; const w = comp.size?.width || 200; const h = comp.size?.height || 36; const componentData = { id: comp.id, type: "component" as const, componentType: comp.componentType, label: comp.label, position: { x, y }, size: { width: undefined, height: h }, componentConfig: comp.componentConfig || {}, style: { ...(comp.style || {}), width: "100%", height: "100%" }, tableName: comp.componentConfig?.tableName, columnName: comp.componentConfig?.columnName, webType: comp.componentConfig?.webType, inputType: (comp as any).inputType || comp.componentConfig?.inputType, }; return (
); })}
); }; /** * SplitPanelLayout 컴포넌트 * 마스터-디테일 패턴의 좌우 분할 레이아웃 */ export const SplitPanelLayoutComponent: React.FC = ({ component, isDesignMode = false, isSelected = false, isPreview = false, onClick, onUpdateComponent, onSelectPanelComponent, selectedPanelComponentId: externalSelectedPanelComponentId, ...props }) => { const componentConfig = (component.componentConfig || {}) as SplitPanelLayoutConfig; // 🐛 디버깅: 로드 시 rightPanel.components 확인 const rightComps = componentConfig.rightPanel?.components || []; const finishedTimeline = rightComps.find((c: any) => c.id === "finished_timeline"); if (finishedTimeline) { const fm = finishedTimeline.componentConfig?.fieldMapping; console.log("🔍 [SplitPanelLayout] finished_timeline fieldMapping:", { componentId: finishedTimeline.id, fieldMapping: fm ? JSON.stringify(fm) : "undefined", fieldMappingKeys: fm ? Object.keys(fm) : [], fieldMappingId: fm?.id, fullComponentConfig: JSON.stringify(finishedTimeline.componentConfig || {}, null, 2), }); } // 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 사용 가능) const companyCode = (props as any).companyCode as string | undefined; // 기본 설정값 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 => { // 🆕 백엔드가 제공하는 _label 필드 우선 사용 // 백엔드는 "표시 컬럼"이 설정된 경우 columnName_label을 자동 생성 const labelKey = `${columnName}_label`; if (item[labelKey] !== undefined && item[labelKey] !== "" && item[labelKey] !== null) { return item[labelKey]; } // 직접 매칭 시도 (JOIN된 값이 없으면 원본 값 반환) 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]; } } 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 [customLeftSelectedData, setCustomLeftSelectedData] = useState>({}); // 커스텀 모드: 좌측 선택 데이터 // 커스텀 모드: 탭/버튼 간 공유할 selectedRowsData 자체 관리 (항상 로컬 상태 사용) const [localSelectedRowsData, setLocalSelectedRowsData] = useState([]); const handleLocalSelectedRowsChange = useCallback( (selectedRows: any[], selectedRowsDataNew: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[]) => { setLocalSelectedRowsData(selectedRowsDataNew); if ((props as any).onSelectedRowsChange) { (props as any).onSelectedRowsChange(selectedRows, selectedRowsDataNew, sortBy, sortOrder, columnOrder); } }, [(props as any).onSelectedRowsChange], ); const [leftSearchQuery, setLeftSearchQuery] = useState(""); const [rightSearchQuery, setRightSearchQuery] = useState(""); const [isLoadingLeft, setIsLoadingLeft] = useState(false); const [isLoadingRight, setIsLoadingRight] = useState(false); const [rightTableColumns, setRightTableColumns] = useState([]); // 우측 테이블 컬럼 정보 const [columnInputTypes, setColumnInputTypes] = useState>({}); const [expandedItems, setExpandedItems] = useState>(new Set()); // 펼쳐진 항목들 // 🆕 페이징 상태 const [leftCurrentPage, setLeftCurrentPage] = useState(1); const [leftTotalPages, setLeftTotalPages] = useState(1); const [leftTotal, setLeftTotal] = useState(0); const [leftPageSize, setLeftPageSize] = useState(componentConfig.leftPanel?.pagination?.pageSize ?? 20); const [rightCurrentPage, setRightCurrentPage] = useState(1); const [rightTotalPages, setRightTotalPages] = useState(1); const [rightTotal, setRightTotal] = useState(0); const [rightPageSize, setRightPageSize] = useState(componentConfig.rightPanel?.pagination?.pageSize ?? 20); const [tabsPagination, setTabsPagination] = useState>({}); const [leftPageInput, setLeftPageInput] = useState("1"); const [rightPageInput, setRightPageInput] = useState("1"); const leftPaginationEnabled = componentConfig.leftPanel?.pagination?.enabled ?? false; const rightPaginationEnabled = componentConfig.rightPanel?.pagination?.enabled ?? false; // 추가 탭 관련 상태 const [activeTabIndex, setActiveTabIndex] = useState(0); // 0 = 기본 탭, 1+ = 추가 탭 const [tabsData, setTabsData] = useState>({}); // 탭별 데이터 const [tabsLoading, setTabsLoading] = useState>({}); // 탭별 로딩 상태 const [leftColumnLabels, setLeftColumnLabels] = useState>({}); // 좌측 컬럼 라벨 const [rightColumnLabels, setRightColumnLabels] = useState>({}); // 우측 컬럼 라벨 const [leftCategoryMappings, setLeftCategoryMappings] = useState< Record> >({}); // 좌측 카테고리 매핑 const [rightCategoryMappings, setRightCategoryMappings] = useState< Record> >({}); // 우측 카테고리 매핑 // 🆕 커스텀 모드: 드래그/리사이즈 상태 const [draggingCompId, setDraggingCompId] = useState(null); const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null); const [resizingCompId, setResizingCompId] = useState(null); const [resizeSize, setResizeSize] = useState<{ width: number; height: number } | null>(null); // 내부 선택 상태 (외부 prop 없을 때 fallback) const [internalSelectedCompId, setInternalSelectedCompId] = useState(null); const selectedPanelComponentId = externalSelectedPanelComponentId ?? internalSelectedCompId; // 🆕 커스텀 모드: 분할패널 내 탭 컴포넌트의 선택 상태 관리 const [nestedTabSelectedCompId, setNestedTabSelectedCompId] = useState(undefined); const rafRef = useRef(null); // 🆕 10px 단위 스냅 함수 const snapTo10 = useCallback((value: number) => Math.round(value / 10) * 10, []); // 🆕 커스텀 모드: 컴포넌트 삭제 핸들러 const handleRemovePanelComponent = useCallback( (panelSide: "left" | "right", compId: string) => { if (!onUpdateComponent) return; const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel"; const panelConfig = componentConfig[panelKey] || {}; const updatedComponents = (panelConfig.components || []).filter( (c: PanelInlineComponent) => c.id !== compId ); onUpdateComponent({ ...component, componentConfig: { ...componentConfig, [panelKey]: { ...panelConfig, components: updatedComponents, }, }, }); }, [component, componentConfig, onUpdateComponent] ); // 🆕 중첩된 컴포넌트 업데이트 핸들러 (탭 컴포넌트 내부 위치 변경 등) const handleNestedComponentUpdate = useCallback( (panelSide: "left" | "right", compId: string, updatedNestedComponent: any) => { if (!onUpdateComponent) return; const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel"; const panelConfig = componentConfig[panelKey] || {}; const panelComponents = panelConfig.components || []; const updatedComponents = panelComponents.map((c: PanelInlineComponent) => c.id === compId ? { ...c, ...updatedNestedComponent, id: c.id } : c ); onUpdateComponent({ ...component, componentConfig: { ...componentConfig, [panelKey]: { ...panelConfig, components: updatedComponents, }, }, }); }, [component, componentConfig, onUpdateComponent] ); // 🆕 커스텀 모드: 드래그 시작 핸들러 const handlePanelDragStart = useCallback( (e: React.MouseEvent, panelSide: "left" | "right", comp: PanelInlineComponent) => { e.stopPropagation(); e.preventDefault(); const startMouseX = e.clientX; const startMouseY = e.clientY; const startLeft = comp.position?.x || 0; const startTop = comp.position?.y || 0; setDraggingCompId(comp.id); setDragPosition({ x: startLeft, y: startTop }); const handleMouseMove = (moveEvent: MouseEvent) => { if (rafRef.current) { cancelAnimationFrame(rafRef.current); } rafRef.current = requestAnimationFrame(() => { const deltaX = moveEvent.clientX - startMouseX; const deltaY = moveEvent.clientY - startMouseY; // 10px 단위 스냅 적용 const newX = snapTo10(Math.max(0, startLeft + deltaX)); const newY = snapTo10(Math.max(0, startTop + deltaY)); setDragPosition({ x: newX, y: newY }); }); }; const handleMouseUp = (upEvent: MouseEvent) => { document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = null; } const deltaX = upEvent.clientX - startMouseX; const deltaY = upEvent.clientY - startMouseY; // 10px 단위 스냅 적용 const newX = snapTo10(Math.max(0, startLeft + deltaX)); const newY = snapTo10(Math.max(0, startTop + deltaY)); setDraggingCompId(null); setDragPosition(null); if (onUpdateComponent) { const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel"; const panelConfig = componentConfig[panelKey] || {}; const updatedComponents = (panelConfig.components || []).map((c: PanelInlineComponent) => c.id === comp.id ? { ...c, position: { x: newX, y: newY } } : c ); onUpdateComponent({ ...component, componentConfig: { ...componentConfig, [panelKey]: { ...panelConfig, components: updatedComponents, }, }, }); } }; document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); }, [component, componentConfig, onUpdateComponent, snapTo10] ); // 🆕 커스텀 모드: 리사이즈 시작 핸들러 const handlePanelResizeStart = useCallback( (e: React.MouseEvent, panelSide: "left" | "right", comp: PanelInlineComponent, direction: "e" | "s" | "se") => { e.stopPropagation(); e.preventDefault(); const startMouseX = e.clientX; const startMouseY = e.clientY; const startWidth = comp.size?.width || 200; const startHeight = comp.size?.height || 100; setResizingCompId(comp.id); setResizeSize({ width: startWidth, height: startHeight }); const handleMouseMove = (moveEvent: MouseEvent) => { if (rafRef.current) { cancelAnimationFrame(rafRef.current); } rafRef.current = requestAnimationFrame(() => { const deltaX = moveEvent.clientX - startMouseX; const deltaY = moveEvent.clientY - startMouseY; let newWidth = startWidth; let newHeight = startHeight; if (direction === "e" || direction === "se") { newWidth = snapTo10(Math.max(50, startWidth + deltaX)); } if (direction === "s" || direction === "se") { newHeight = snapTo10(Math.max(30, startHeight + deltaY)); } setResizeSize({ width: newWidth, height: newHeight }); }); }; const handleMouseUp = (upEvent: MouseEvent) => { document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = null; } const deltaX = upEvent.clientX - startMouseX; const deltaY = upEvent.clientY - startMouseY; let newWidth = startWidth; let newHeight = startHeight; if (direction === "e" || direction === "se") { newWidth = snapTo10(Math.max(50, startWidth + deltaX)); } if (direction === "s" || direction === "se") { newHeight = snapTo10(Math.max(30, startHeight + deltaY)); } setResizingCompId(null); setResizeSize(null); if (onUpdateComponent) { const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel"; const panelConfig = componentConfig[panelKey] || {}; const updatedComponents = (panelConfig.components || []).map((c: PanelInlineComponent) => c.id === comp.id ? { ...c, size: { width: newWidth, height: newHeight } } : c ); onUpdateComponent({ ...component, componentConfig: { ...componentConfig, [panelKey]: { ...panelConfig, components: updatedComponents, }, }, }); } }; document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); }, [component, componentConfig, onUpdateComponent, snapTo10] ); const { toast } = useToast(); // 추가 모달 상태 const [showAddModal, setShowAddModal] = useState(false); const [addModalPanel, setAddModalPanel] = useState<"left" | "right" | "left-item" | null>(null); const [addModalFormData, setAddModalFormData] = useState>({}); const [bomExcelUploadOpen, setBomExcelUploadOpen] = useState(false); // 수정 모달 상태 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 [deleteModalTableName, setDeleteModalTableName] = 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: component.size?.height 우선, 없으면 component.style?.height, 기본 600px const getHeightValue = () => { const sizeH = component.size?.height; if (sizeH && typeof sizeH === "number" && sizeH > 0) return `${sizeH}px`; const height = component.style?.height; if (!height) return "600px"; if (typeof height === "string") return height; return `${height}px`; }; const componentStyle: React.CSSProperties = isDesignMode ? { width: "100%", height: "100%", minHeight: getHeightValue(), cursor: "pointer", border: isSelected ? "2px solid #3b82f6" : "1px solid #e5e7eb", } : { position: "relative", width: "100%", height: getHeightValue(), }; // 계층 구조 빌드 함수 (트리 구조 유지) 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]) ); columns = columns .filter((col: any) => { const colName = typeof col === "string" ? col : col.name || col.columnName; return visibilityMap.get(colName)?.visible !== false; }) .map((col: any) => { const colName = typeof col === "string" ? col : col.name || col.columnName; const cv = visibilityMap.get(colName); if (cv?.width && typeof col === "object") { return { ...col, width: cv.width }; } return col; }); } // 🔧 컬럼 순서 적용 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")); }, []); // 숫자 포맷팅 헬퍼 함수 (공통 formatNumber 기반) 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); let result: string; if (format?.thousandSeparator === false) { const dec = format?.decimalPlaces ?? 0; result = num.toFixed(dec); } else { result = centralFormatNumber(num, format?.decimalPlaces); } if (format?.prefix) result = format.prefix + result; if (format?.suffix) result = result + format.suffix; return result; }, []); // 프로그레스바 셀 렌더링 (부모 값 대비 자식 값 비율) const renderProgressCell = useCallback( (col: any, item: any, parentData: any) => { const current = Number(item[col.numerator] || 0); const max = Number(parentData?.[col.denominator] || item[col.denominator] || 0); const percentage = max > 0 ? Math.round((current / max) * 100) : 0; const barWidth = Math.min(percentage, 100); const barColor = percentage > 100 ? "bg-red-600" : percentage >= 90 ? "bg-red-500" : percentage >= 70 ? "bg-amber-500" : "bg-emerald-500"; return (
{current.toLocaleString()} / {max.toLocaleString()}
{percentage}%
); }, [], ); // 셀 값 포맷팅 함수 (카테고리 타입 처리 + 날짜/숫자 포맷) 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 "-"; // 이미지 타입 컬럼 처리 const colInputType = columnInputTypes[columnName]; if (colInputType === "image" && value) { return ; } // 🆕 날짜 포맷 적용 if (format?.type === "date" || format?.dateFormat) { return formatDateValue(value, format?.dateFormat || "YYYY-MM-DD"); } // 숫자 포맷 적용 (format 설정이 있거나 input_type이 number/decimal이면 자동 적용) const isNumericByInputType = colInputType === "number" || colInputType === "decimal"; if ( format?.type === "number" || format?.type === "currency" || format?.thousandSeparator || format?.decimalPlaces !== undefined || isNumericByInputType ) { return formatNumberValue(value, format || { thousandSeparator: true }); } // 카테고리 매핑 찾기 (여러 키 형태 시도) // 1. 전체 컬럼명 (예: "item_info.material") // 2. 컬럼명만 (예: "material") // 3. 전역 폴백: 모든 매핑에서 value 검색 let mapping = categoryMappings[columnName]; if (!mapping && columnName.includes(".")) { const simpleColumnName = columnName.split(".").pop() || columnName; mapping = categoryMappings[simpleColumnName]; } const strValue = String(value); if (mapping && mapping[strValue]) { const categoryData = mapping[strValue]; const displayLabel = categoryData.label || strValue; const displayColor = categoryData.color || "#64748b"; return ( {displayLabel} ); } // 전역 폴백: 컬럼명으로 매핑을 못 찾았을 때, 전체 매핑에서 값 검색 if (!mapping && (strValue.startsWith("CAT_") || strValue.startsWith("CATEGORY_"))) { for (const key of Object.keys(categoryMappings)) { const m = categoryMappings[key]; if (m && m[strValue]) { const categoryData = m[strValue]; const displayLabel = categoryData.label || strValue; const displayColor = categoryData.color || "#64748b"; return ( {displayLabel} ); } } } // 🆕 자동 날짜 감지 (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, columnInputTypes], ); // 🆕 패널 config의 columns에서 additionalJoinColumns 추출하는 헬퍼 const extractAdditionalJoinColumns = useCallback((columns: any[] | undefined, tableName: string) => { if (!columns || columns.length === 0) return undefined; const joinColumns: Array<{ sourceTable: string; sourceColumn: string; referenceTable: string; joinAlias: string; }> = []; columns.forEach((col: any) => { // 방법 1: isEntityJoin 플래그가 있는 경우 (설정 패널에서 Entity 조인 컬럼으로 추가한 경우) if (col.isEntityJoin && col.joinInfo) { const existing = joinColumns.find( (j) => j.referenceTable === col.joinInfo.referenceTable && j.joinAlias === col.joinInfo.joinAlias ); if (!existing) { joinColumns.push({ sourceTable: col.joinInfo.sourceTable || tableName, sourceColumn: col.joinInfo.sourceColumn, referenceTable: col.joinInfo.referenceTable, joinAlias: col.joinInfo.joinAlias, }); } return; } // 방법 2: "테이블명.컬럼명" 형식 (기존 좌측 패널 방식) const colName = typeof col === "string" ? col : col.name || col.columnName; if (colName && colName.includes(".")) { const [refTable, refColumn] = colName.split("."); const inferredSourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id"); const existing = joinColumns.find( (j) => j.referenceTable === refTable && j.sourceColumn === inferredSourceColumn ); if (!existing) { joinColumns.push({ sourceTable: tableName, sourceColumn: inferredSourceColumn, referenceTable: refTable, joinAlias: `${inferredSourceColumn}_${refColumn}`, }); } else { // 이미 추가된 테이블이면 별칭만 추가 const newAlias = `${inferredSourceColumn}_${refColumn}`; if (!joinColumns.find((j) => j.joinAlias === newAlias)) { joinColumns.push({ sourceTable: tableName, sourceColumn: inferredSourceColumn, referenceTable: refTable, joinAlias: newAlias, }); } } } }); return joinColumns.length > 0 ? joinColumns : undefined; }, []); // 좌측 데이터 로드 (페이징 ON: page 파라미터 사용, OFF: 전체 로드) const loadLeftData = useCallback(async (page?: number, pageSizeOverride?: number) => { const leftTableName = componentConfig.leftPanel?.tableName; if (!leftTableName || isDesignMode) return; setIsLoadingLeft(true); try { const filters = Object.keys(searchValues).length > 0 ? searchValues : undefined; const leftJoinColumns = extractAdditionalJoinColumns( componentConfig.leftPanel?.columns, leftTableName, ); if (leftPaginationEnabled) { const currentPageToLoad = page ?? leftCurrentPage; const effectivePageSize = pageSizeOverride ?? leftPageSize; const result = await entityJoinApi.getTableDataWithJoins(leftTableName, { page: currentPageToLoad, size: effectivePageSize, search: filters, enableEntityJoin: true, dataFilter: componentConfig.leftPanel?.dataFilter, additionalJoinColumns: leftJoinColumns, companyCodeOverride: companyCode, }); setLeftData(result.data || []); setLeftCurrentPage(result.page || currentPageToLoad); setLeftTotalPages(result.totalPages || 1); setLeftTotal(result.total || 0); setLeftPageInput(String(result.page || currentPageToLoad)); } else { const result = await entityJoinApi.getTableDataWithJoins(leftTableName, { page: 1, size: MAX_LOAD_ALL_SIZE, search: filters, enableEntityJoin: true, dataFilter: componentConfig.leftPanel?.dataFilter, additionalJoinColumns: leftJoinColumns, companyCodeOverride: companyCode, }); let filteredLeftData = applyClientSideFilter(result.data || [], componentConfig.leftPanel?.dataFilter); const leftColumn = componentConfig.rightPanel?.relation?.leftColumn; if (leftColumn && filteredLeftData.length > 0) { filteredLeftData.sort((a, b) => { const aValue = String(a[leftColumn] || ""); const bValue = String(b[leftColumn] || ""); return aValue.localeCompare(bValue, "ko-KR"); }); } const hierarchicalData = buildHierarchy(filteredLeftData); 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, leftPaginationEnabled, leftCurrentPage, leftPageSize, isDesignMode, toast, buildHierarchy, searchValues, ]); const updateRightPaginationState = useCallback((result: any, fallbackPage: number) => { setRightCurrentPage(result.page || fallbackPage); setRightTotalPages(result.totalPages || 1); setRightTotal(result.total || 0); setRightPageInput(String(result.page || fallbackPage)); }, []); // 우측 데이터 로드 (leftItem이 null이면 전체 데이터 로드, page: 서버 페이징용) const loadRightData = useCallback( async (leftItem: any, page?: number, pageSizeOverride?: number) => { const relationshipType = componentConfig.rightPanel?.relation?.type || "detail"; const rightTableName = componentConfig.rightPanel?.tableName; if (!rightTableName || isDesignMode) return; // 좌측 미선택 시: 전체 데이터 로드 (dataFilter 적용) if (!leftItem && relationshipType === "join") { setIsLoadingRight(true); try { const rightJoinColumns = extractAdditionalJoinColumns( componentConfig.rightPanel?.columns, rightTableName, ); const effectivePageSize = pageSizeOverride ?? rightPageSize; if (rightPaginationEnabled) { const currentPageToLoad = page ?? rightCurrentPage; const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { page: currentPageToLoad, size: effectivePageSize, enableEntityJoin: true, companyCodeOverride: companyCode, additionalJoinColumns: rightJoinColumns, dataFilter: componentConfig.rightPanel?.dataFilter, }); setRightData(result.data || []); updateRightPaginationState(result, currentPageToLoad); } else { const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { enableEntityJoin: true, size: MAX_LOAD_ALL_SIZE, companyCodeOverride: companyCode, additionalJoinColumns: rightJoinColumns, dataFilter: componentConfig.rightPanel?.dataFilter, }); const filteredData = applyClientSideFilter(result.data || [], componentConfig.rightPanel?.dataFilter); setRightData(filteredData); } } catch (error) { console.error("우측 전체 데이터 로드 실패:", error); } finally { setIsLoadingRight(false); } return; } // leftItem이 null이면 join 모드 이외에는 데이터 로드 불가 // detail 모드: 선택 안 하면 아무것도 안 뜸, 선택하면 필터링 // join 모드: 선택 안 하면 전체, 선택하면 필터링 if (!leftItem) return; setIsLoadingRight(true); setRightData([]); try { // detail / join 모두 동일한 필터링 로직 사용 // (차이점: 초기 로드 여부만 다름 - detail은 초기 로드 안 함) { // 조인 모드: 다른 테이블의 관련 데이터 (여러 개) 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[] = []; // 🆕 우측 패널 Entity 조인 컬럼 추출 (그룹 합산용) const rightJoinColumnsForGroup = extractAdditionalJoinColumns( componentConfig.rightPanel?.columns, rightTableName, ); // 각 원본 항목에 대해 조회 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] = { value: originalItem[key.leftColumn], operator: "equals" }; } }); if (Object.keys(searchConditions).length > 0) { const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { search: searchConditions, enableEntityJoin: true, size: MAX_LOAD_ALL_SIZE, companyCodeOverride: companyCode, additionalJoinColumns: rightJoinColumnsForGroup, }); if (result.data) { allResults.push(...result.data); } } } // 정렬 적용 const sortedResults = sortByKeys(allResults); console.log("🔗 [분할패널] 그룹 합산 - 우측 패널 정렬된 데이터:", sortedResults.length); setRightData(sortedResults); return; } // 🆕 복합키 지원 if (keys && keys.length > 0 && leftTable) { // 복합키: 여러 조건으로 필터링 const { entityJoinApi } = await import("@/lib/api/entityJoin"); // 복합키 조건 생성 (FK 필터링이므로 equals 연산자 사용) const searchConditions: Record = {}; keys.forEach((key) => { if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) { searchConditions[key.rightColumn] = { value: leftItem[key.leftColumn], operator: "equals" }; } }); console.log("🔗 [분할패널] 복합키 조건:", searchConditions); // 🆕 우측 패널 config의 Entity 조인 컬럼 추출 const rightJoinColumns = extractAdditionalJoinColumns( componentConfig.rightPanel?.columns, rightTableName, ); if (rightJoinColumns) { console.log("🔗 [분할패널] 우측 패널 additionalJoinColumns:", rightJoinColumns); } const effectivePageSize = pageSizeOverride ?? rightPageSize; const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { search: searchConditions, enableEntityJoin: true, size: rightPaginationEnabled ? effectivePageSize : MAX_LOAD_ALL_SIZE, page: rightPaginationEnabled ? (page ?? rightCurrentPage) : undefined, companyCodeOverride: companyCode, additionalJoinColumns: rightJoinColumns, }); if (rightPaginationEnabled) { updateRightPaginationState(result, page ?? rightCurrentPage); } setRightData(result.data || []); } else { // 단일키 (하위 호환성) → entityJoinApi 사용으로 전환 (entity 조인 컬럼 지원) const leftColumn = componentConfig.rightPanel?.relation?.leftColumn; const rightColumn = componentConfig.rightPanel?.relation?.foreignKey; if (leftColumn && rightColumn && leftTable) { const leftValue = leftItem[leftColumn]; const { entityJoinApi } = await import("@/lib/api/entityJoin"); console.log("🔗 [분할패널] 단일키 조건:", { leftColumn, rightColumn, leftValue, rightTableName }); // 단일키를 복합키 형식으로 변환 (entity 컬럼이므로 equals 연산자 필수) const searchConditions: Record = {}; searchConditions[rightColumn] = { value: leftValue, operator: "equals" }; // Entity 조인 컬럼 추출 const rightJoinColumnsLegacy = extractAdditionalJoinColumns( componentConfig.rightPanel?.columns, rightTableName, ); if (rightJoinColumnsLegacy) { console.log("🔗 [분할패널] 단일키 모드 additionalJoinColumns:", rightJoinColumnsLegacy); } const effectivePageSizeLegacy = pageSizeOverride ?? rightPageSize; const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { search: searchConditions, enableEntityJoin: true, size: rightPaginationEnabled ? effectivePageSizeLegacy : MAX_LOAD_ALL_SIZE, page: rightPaginationEnabled ? (page ?? rightCurrentPage) : undefined, companyCodeOverride: companyCode, additionalJoinColumns: rightJoinColumnsLegacy, }); if (rightPaginationEnabled) { updateRightPaginationState(result, page ?? rightCurrentPage); } setRightData(result.data || []); } } } } catch (error) { console.error("우측 데이터 로드 실패:", error); setRightData([]); toast({ title: "데이터 로드 실패", description: "우측 패널 데이터를 불러올 수 없습니다.", variant: "destructive", }); } finally { setIsLoadingRight(false); } }, [ componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.relation, componentConfig.leftPanel?.tableName, rightPaginationEnabled, rightCurrentPage, rightPageSize, isDesignMode, toast, updateRightPaginationState, ], ); // 추가 탭 데이터 로딩 함수 (leftItem이 null이면 전체 데이터 로드, page: 서버 페이징용) const loadTabData = useCallback( async (tabIndex: number, leftItem: any, page?: number, pageSizeOverride?: number) => { const tabConfig = componentConfig.rightPanel?.additionalTabs?.[tabIndex - 1]; if (!tabConfig || 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; const tabJoinColumns = extractAdditionalJoinColumns(tabConfig.columns, tabTableName); let resultData: any[] = []; let apiResult: any = null; const tabDataFilterForApi = (tabConfig as any).dataFilter; const tabRelationType = tabConfig.relation?.type || "join"; const tabPagState = tabsPagination[tabIndex]; const currentTabPage = page ?? tabPagState?.currentPage ?? 1; const currentTabPageSize = pageSizeOverride ?? tabPagState?.pageSize ?? rightPageSize; const apiSize = rightPaginationEnabled ? currentTabPageSize : MAX_LOAD_ALL_SIZE; const apiPage = rightPaginationEnabled ? currentTabPage : undefined; const commonApiParams = { enableEntityJoin: true, size: apiSize, page: apiPage, companyCodeOverride: companyCode, additionalJoinColumns: tabJoinColumns, dataFilter: tabDataFilterForApi, }; if (!leftItem) { if (tabRelationType !== "detail") { apiResult = await entityJoinApi.getTableDataWithJoins(tabTableName, commonApiParams); resultData = apiResult.data || []; } } else if (leftColumn && rightColumn) { const searchConditions: Record = {}; if (keys && keys.length > 0) { keys.forEach((key: any) => { if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) { searchConditions[key.rightColumn] = { value: leftItem[key.leftColumn], operator: "equals" }; } }); } else { const leftValue = leftItem[leftColumn]; if (leftValue !== undefined) { searchConditions[rightColumn] = { value: leftValue, operator: "equals" }; } } apiResult = await entityJoinApi.getTableDataWithJoins(tabTableName, { search: searchConditions, ...commonApiParams, }); resultData = apiResult.data || []; } else { apiResult = await entityJoinApi.getTableDataWithJoins(tabTableName, commonApiParams); resultData = apiResult.data || []; } // 공통 페이징 상태 업데이트 if (rightPaginationEnabled && apiResult) { setTabsPagination((prev) => ({ ...prev, [tabIndex]: { currentPage: apiResult.page || currentTabPage, totalPages: apiResult.totalPages || 1, total: apiResult.total || 0, pageSize: currentTabPageSize, }, })); } if (!rightPaginationEnabled) { resultData = applyClientSideFilter(resultData, (tabConfig as any).dataFilter); } 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, rightPaginationEnabled, rightPageSize, tabsPagination, isDesignMode, toast], ); // 🆕 좌측 페이지 변경 핸들러 const handleLeftPageChange = useCallback((newPage: number) => { if (newPage < 1 || newPage > leftTotalPages) return; setLeftCurrentPage(newPage); setLeftPageInput(String(newPage)); loadLeftData(newPage); }, [leftTotalPages, loadLeftData]); const commitLeftPageInput = useCallback(() => { const parsed = parseInt(leftPageInput, 10); if (!isNaN(parsed) && parsed >= 1 && parsed <= leftTotalPages) { handleLeftPageChange(parsed); } else { setLeftPageInput(String(leftCurrentPage)); } }, [leftPageInput, leftTotalPages, leftCurrentPage, handleLeftPageChange]); // 🆕 좌측 페이지 크기 변경 const handleLeftPageSizeChange = useCallback((newSize: number) => { setLeftPageSize(newSize); setLeftCurrentPage(1); setLeftPageInput("1"); loadLeftData(1, newSize); }, [loadLeftData]); // 🆕 우측 페이지 변경 핸들러 const handleRightPageChange = useCallback((newPage: number) => { if (newPage < 1 || newPage > rightTotalPages) return; setRightCurrentPage(newPage); setRightPageInput(String(newPage)); if (activeTabIndex === 0) { loadRightData(selectedLeftItem, newPage); } else { loadTabData(activeTabIndex, selectedLeftItem, newPage); } }, [rightTotalPages, activeTabIndex, selectedLeftItem, loadRightData, loadTabData]); const commitRightPageInput = useCallback(() => { const parsed = parseInt(rightPageInput, 10); const tp = activeTabIndex === 0 ? rightTotalPages : (tabsPagination[activeTabIndex]?.totalPages ?? 1); if (!isNaN(parsed) && parsed >= 1 && parsed <= tp) { handleRightPageChange(parsed); } else { const cp = activeTabIndex === 0 ? rightCurrentPage : (tabsPagination[activeTabIndex]?.currentPage ?? 1); setRightPageInput(String(cp)); } }, [rightPageInput, rightTotalPages, rightCurrentPage, activeTabIndex, tabsPagination, handleRightPageChange]); // 🆕 우측 페이지 크기 변경 const handleRightPageSizeChange = useCallback((newSize: number) => { setRightPageSize(newSize); setRightCurrentPage(1); setRightPageInput("1"); setTabsPagination({}); if (activeTabIndex === 0) { loadRightData(selectedLeftItem, 1, newSize); } else { loadTabData(activeTabIndex, selectedLeftItem, 1, newSize); } }, [activeTabIndex, selectedLeftItem, loadRightData, loadTabData]); // 🆕 페이징 UI 컴포넌트 (공통) const renderPaginationBar = useCallback((params: { currentPage: number; totalPages: number; total: number; pageSize: number; pageInput: string; setPageInput: (v: string) => void; onPageChange: (p: number) => void; onPageSizeChange: (s: number) => void; commitPageInput: () => void; loading: boolean; }) => { const { currentPage, totalPages, total, pageSize, pageInput, setPageInput, onPageChange, onPageSizeChange, commitPageInput: commitFn, loading } = params; return (
표시: { const v = Math.min(MAX_LOAD_ALL_SIZE, Math.max(1, Number(e.target.value) || 1)); onPageSizeChange(v); }} className="border-input bg-background focus:ring-ring h-6 w-12 rounded border px-1 text-center text-[10px] focus:ring-1 focus:outline-none" /> / {total}건
setPageInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { commitFn(); (e.target as HTMLInputElement).blur(); } }} onBlur={commitFn} onFocus={(e) => e.target.select()} disabled={loading} className="border-input bg-background focus:ring-ring h-6 w-8 rounded border px-1 text-center text-[10px] font-medium focus:ring-1 focus:outline-none" /> / {totalPages || 1}
); }, []); // 우측/탭 페이징 상태 (IIFE 대신 useMemo로 사전 계산) const rightPagState = useMemo(() => { const isTab = activeTabIndex > 0; const tabPag = isTab ? tabsPagination[activeTabIndex] : null; return { isTab, currentPage: isTab ? (tabPag?.currentPage ?? 1) : rightCurrentPage, totalPages: isTab ? (tabPag?.totalPages ?? 1) : rightTotalPages, total: isTab ? (tabPag?.total ?? 0) : rightTotal, pageSize: isTab ? (tabPag?.pageSize ?? rightPageSize) : rightPageSize, }; }, [activeTabIndex, tabsPagination, rightCurrentPage, rightTotalPages, rightTotal, rightPageSize]); // 탭 변경 핸들러 const handleTabChange = useCallback( (newTabIndex: number) => { setActiveTabIndex(newTabIndex); // 메인 패널이 "detail"(선택 시 표시)이면 좌측 미선택 시 데이터 로드하지 않음 const mainRelationType = componentConfig.rightPanel?.relation?.type || "detail"; const requireSelection = mainRelationType === "detail"; if (newTabIndex === 0) { if (!rightData || (Array.isArray(rightData) && rightData.length === 0)) { if (!requireSelection || selectedLeftItem) { loadRightData(selectedLeftItem); } } } else { if (!tabsData[newTabIndex]) { if (!requireSelection || selectedLeftItem) { loadTabData(newTabIndex, selectedLeftItem); } } } }, [selectedLeftItem, rightData, tabsData, loadRightData, loadTabData, componentConfig.rightPanel?.relation?.type], ); // 좌측 항목 선택 핸들러 (동일 항목 재클릭 시 선택 해제 → 전체 데이터 표시) const handleLeftItemSelect = useCallback( (item: any) => { // 동일 항목 클릭 시 선택 해제 (전체 보기로 복귀) const leftPk = componentConfig.rightPanel?.relation?.leftColumn || componentConfig.rightPanel?.relation?.keys?.[0]?.leftColumn; const isSameItem = selectedLeftItem && leftPk && selectedLeftItem[leftPk] === item[leftPk]; if (isSameItem) { setSelectedLeftItem(null); setCustomLeftSelectedData({}); setExpandedRightItems(new Set()); setTabsData({}); // 우측/탭 페이지 리셋 if (rightPaginationEnabled) { setRightCurrentPage(1); setRightPageInput("1"); setTabsPagination({}); } const mainRelationType = componentConfig.rightPanel?.relation?.type || "detail"; if (mainRelationType === "detail") { // "선택 시 표시" 모드: 선택 해제 시 데이터 비움 setRightData(null); } else { // "연관 목록" 모드: 선택 해제 시 전체 데이터 로드 if (activeTabIndex === 0) { loadRightData(null); } else { loadTabData(activeTabIndex, null); } const tabs = componentConfig.rightPanel?.additionalTabs; if (tabs && tabs.length > 0) { tabs.forEach((_: any, idx: number) => { if (idx + 1 !== activeTabIndex) { loadTabData(idx + 1, null); } }); } } return; } setSelectedLeftItem(item); setCustomLeftSelectedData(item); setExpandedRightItems(new Set()); setTabsData({}); // 우측/탭 페이지 리셋 if (rightPaginationEnabled) { setRightCurrentPage(1); setRightPageInput("1"); setTabsPagination({}); } if (activeTabIndex === 0) { loadRightData(item, 1); } else { loadTabData(activeTabIndex, item, 1); } // 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, componentConfig.rightPanel?.relation, componentConfig.rightPanel?.additionalTabs, isDesignMode, selectedLeftItem, rightPaginationEnabled], ); // 우측 항목 확장/축소 토글 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) return []; // 1단계: 카테고리 API 시도 (DB에서 라벨 조회) try { const { apiClient } = await import("@/lib/api/client"); const response = await apiClient.get(`/table-categories/${leftTableName}/${columnName}/values`); if (response.data.success && response.data.data && response.data.data.length > 0) { return response.data.data.map((item: any) => ({ value: item.valueCode, label: item.valueLabel, })); } } catch { // 카테고리 API 실패 시 다음 단계로 } // 2단계: DISTINCT API (백엔드 라벨 변환 포함) try { const { apiClient } = await import("@/lib/api/client"); const response = await apiClient.get(`/entity/${leftTableName}/distinct/${columnName}`); if (response.data.success && response.data.data && response.data.data.length > 0) { return response.data.data.map((item: any) => ({ value: String(item.value), label: String(item.label), })); } } catch { // DISTINCT API 실패 시 다음 단계로 } // 3단계: 로컬 데이터에서 고유값 추출 (최종 fallback) if (leftData.length === 0) return []; const uniqueValuesMap = new Map(); leftData.forEach((item) => { let value: any; if (columnName.includes(".")) { const [refTable, fieldName] = columnName.split("."); const inferredSourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id"); const exactKey = `${inferredSourceColumn}_${fieldName}`; value = item[exactKey]; if (value === undefined) { const idPatternKey = `${refTable.replace("_info", "_id").replace("_mng", "_id")}_${fieldName}`; value = item[idPatternKey]; } if (value === undefined && (fieldName === "item_name" || fieldName === "name")) { const aliasKey = `${inferredSourceColumn}_name`; value = item[aliasKey]; 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 !== "") { const strValue = String(value); const nameField = item[`${columnName}_name`]; const label = nameField || strValue; uniqueValuesMap.set(strValue, label); } }); return Array.from(uniqueValuesMap.entries()) .map(([value, label]) => ({ value, label })) .sort((a, b) => a.label.localeCompare(b.label)); }, [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); // 컬럼 inputType 로드 (이미지 등 특수 렌더링을 위해) const tablesToLoad = new Set([rightTableName]); const additionalTabs = componentConfig.rightPanel?.additionalTabs || []; additionalTabs.forEach((tab: any) => { if (tab.tableName) tablesToLoad.add(tab.tableName); }); const inputTypes: Record = {}; for (const tbl of tablesToLoad) { try { const inputTypesResponse = await tableTypeApi.getColumnInputTypes(tbl); inputTypesResponse.forEach((col: any) => { const colName = col.columnName || col.column_name; if (colName) { inputTypes[colName] = col.inputType || "text"; } }); } catch { // ignore } } setColumnInputTypes(inputTypes); } catch (error) { console.error("우측 테이블 컬럼 정보 로드 실패:", error); } }; loadRightTableColumns(); }, [componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.additionalTabs, isDesignMode]); // 좌측 테이블 카테고리 매핑 로드 (조인된 테이블 포함) useEffect(() => { const loadLeftCategoryMappings = async () => { const leftTableName = componentConfig.leftPanel?.tableName; if (!leftTableName || isDesignMode) return; try { const mappings: Record> = {}; const tablesToLoad = new Set([leftTableName]); // 좌측 패널 컬럼 설정에서 조인된 테이블 추출 const leftColumns = componentConfig.leftPanel?.columns || []; leftColumns.forEach((col: any) => { const colName = col.name || col.columnName; if (colName && colName.includes(".")) { const joinTableName = colName.split(".")[0]; tablesToLoad.add(joinTableName); } }); // 각 테이블에 대해 카테고리 매핑 로드 for (const tableName of tablesToLoad) { try { const columnsResponse = await tableTypeApi.getColumns(tableName); const categoryColumns = columnsResponse.filter((col: any) => col.inputType === "category"); for (const col of categoryColumns) { const columnName = col.columnName || col.column_name; try { const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values?includeInactive=true`); 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 === leftTableName ? columnName : `${tableName}.${columnName}`; mappings[mappingKey] = valueMap; // 컬럼명만으로도 접근 가능하도록 추가 저장 mappings[columnName] = { ...(mappings[columnName] || {}), ...valueMap }; } } catch (error) { console.error(`좌측 카테고리 값 조회 실패 [${tableName}.${columnName}]:`, error); } } } catch (error) { console.error(`좌측 카테고리 테이블 컬럼 조회 실패 [${tableName}]:`, error); } } setLeftCategoryMappings(mappings); } catch (error) { console.error("좌측 카테고리 매핑 로드 실패:", error); } }; loadLeftCategoryMappings(); }, [componentConfig.leftPanel?.tableName, componentConfig.leftPanel?.columns, 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); } }); // 🆕 추가 탭의 테이블도 카테고리 로드 대상에 포함 const additionalTabs = componentConfig.rightPanel?.additionalTabs || []; additionalTabs.forEach((tab: any) => { if (tab.tableName) { tablesToLoad.add(tab.tableName); } // 추가 탭 컬럼에서 조인된 테이블 추출 (tab.columns || []).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?includeInactive=true`); 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, componentConfig.rightPanel?.additionalTabs, 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") => { // 좌측 패널 추가 시, addButton 모달 모드 확인 if (panel === "left") { const addButtonConfig = componentConfig.leftPanel?.addButton; if (addButtonConfig?.mode === "modal" && addButtonConfig?.modalScreenId) { const leftTableName = componentConfig.leftPanel?.tableName || ""; // ScreenModal 열기 이벤트 발생 window.dispatchEvent( new CustomEvent("openScreenModal", { detail: { screenId: addButtonConfig.modalScreenId, urlParams: { mode: "add", tableName: leftTableName, }, }, }), ); console.log("✅ [SplitPanel] 좌측 추가 모달 화면 열기:", { screenId: addButtonConfig.modalScreenId, tableName: leftTableName, }); return; } } // 우측 패널 추가 시, addButton 모달 모드 확인 if (panel === "right") { const addButtonConfig = activeTabIndex === 0 ? componentConfig.rightPanel?.addButton : (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.addButton; if (addButtonConfig?.mode === "modal" && addButtonConfig?.modalScreenId) { if (!selectedLeftItem) { toast({ title: "항목을 선택해주세요", description: "좌측 패널에서 항목을 먼저 선택한 후 추가해주세요.", variant: "destructive", }); return; } const currentTableName = activeTabIndex === 0 ? componentConfig.rightPanel?.tableName || "" : (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.tableName || ""; // 좌측 선택 데이터를 modalDataStore에 저장 if (selectedLeftItem && componentConfig.leftPanel?.tableName) { import("@/stores/modalDataStore").then(({ useModalDataStore }) => { useModalDataStore.getState().setData(componentConfig.leftPanel!.tableName!, [selectedLeftItem]); }); } // relation.keys에서 FK 데이터 추출 const parentData: Record = {}; const relation = activeTabIndex === 0 ? componentConfig.rightPanel?.relation : (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.relation; if (relation?.keys && Array.isArray(relation.keys)) { for (const key of relation.keys) { if (key.leftColumn && key.rightColumn && selectedLeftItem[key.leftColumn] != null) { parentData[key.rightColumn] = selectedLeftItem[key.leftColumn]; } } } else if (relation) { const leftColumn = relation.leftColumn; const rightColumn = relation.foreignKey || relation.rightColumn; if (leftColumn && rightColumn && selectedLeftItem[leftColumn] != null) { parentData[rightColumn] = selectedLeftItem[leftColumn]; } } window.dispatchEvent( new CustomEvent("openScreenModal", { detail: { screenId: addButtonConfig.modalScreenId, urlParams: { mode: "add", tableName: currentTableName, }, splitPanelParentData: parentData, }, }), ); console.log("✅ [SplitPanel] 추가 모달 화면 열기:", { screenId: addButtonConfig.modalScreenId, tableName: currentTableName, parentData, }); return; } } // 기존 내장 추가 모달 로직 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, activeTabIndex], ); // 수정 버튼 핸들러 const handleEditClick = useCallback( (panel: "left" | "right", item: any) => { // 좌측 패널 수정 버튼 설정 확인 (모달 모드) if (panel === "left") { const editButtonConfig = componentConfig.leftPanel?.editButton; if (editButtonConfig?.mode === "modal" && editButtonConfig?.modalScreenId) { const leftTableName = componentConfig.leftPanel?.tableName || ""; // Primary Key 찾기 - 실제 DB의 id 컬럼 값을 우선 사용 let primaryKeyValue = item.id || item.ID; if (primaryKeyValue === undefined || primaryKeyValue === null) { // id가 없으면 sourceColumn 시도, 마지막으로 첫 번째 키 const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id"; primaryKeyValue = item[sourceColumn]; if (primaryKeyValue === undefined || primaryKeyValue === null) { const firstKey = Object.keys(item)[0]; primaryKeyValue = item[firstKey]; } } // modalDataStore에 저장 import("@/stores/modalDataStore").then(({ useModalDataStore }) => { useModalDataStore.getState().setData(leftTableName, [item]); }); // ScreenModal 열기 이벤트 발생 window.dispatchEvent( new CustomEvent("openScreenModal", { detail: { screenId: editButtonConfig.modalScreenId, urlParams: { mode: "edit", editId: primaryKeyValue, tableName: leftTableName, }, }, }), ); console.log("✅ [SplitPanel] 좌측 수정 모달 화면 열기:", { screenId: editButtonConfig.modalScreenId, tableName: leftTableName, primaryKeyValue, }); return; } } // 우측 패널 수정 버튼 설정 확인 (탭별 설정 지원) if (panel === "right") { const editButtonConfig = activeTabIndex === 0 ? componentConfig.rightPanel?.editButton : (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.editButton; const currentTableName = activeTabIndex === 0 ? componentConfig.rightPanel?.tableName || "" : (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.tableName || ""; if (editButtonConfig?.mode === "modal") { const modalScreenId = editButtonConfig?.modalScreenId; if (modalScreenId) { // 커스텀 모달 화면 열기 const rightTableName = currentTableName; // Primary Key 찾기 (우선순위: id > ID > user_id > {table}_id > 첫 번째 필드) let primaryKeyName = "id"; let primaryKeyValue: any; if (item.id !== undefined && item.id !== null) { primaryKeyName = "id"; primaryKeyValue = item.id; } else if (item.ID !== undefined && item.ID !== null) { primaryKeyName = "ID"; primaryKeyValue = item.ID; } else if (item.user_id !== undefined && item.user_id !== null) { // user_info 테이블 등 user_id를 Primary Key로 사용하는 경우 primaryKeyName = "user_id"; primaryKeyValue = item.user_id; } else { // 테이블명_id 패턴 확인 (예: dept_id, item_id 등) const tableIdKey = rightTableName ? `${rightTableName.replace(/_info$/, "")}_id` : ""; if (tableIdKey && item[tableIdKey] !== undefined && item[tableIdKey] !== null) { primaryKeyName = tableIdKey; primaryKeyValue = item[tableIdKey]; } else { // 마지막으로 첫 번째 필드를 Primary Key로 간주 const firstKey = Object.keys(item)[0]; primaryKeyName = firstKey; primaryKeyValue = item[firstKey]; } } console.log("✅ 수정 모달 열기:", { tableName: rightTableName, primaryKeyName, primaryKeyValue, screenId: modalScreenId, fullItem: item, }); // modalDataStore에도 저장 (호환성 유지) import("@/stores/modalDataStore").then(({ useModalDataStore }) => { useModalDataStore.getState().setData(rightTableName, [item]); }); // 🆕 groupByColumns 추출 const groupByColumns = componentConfig.rightPanel?.editButton?.groupByColumns || []; console.log("🔧 [SplitPanel] 수정 버튼 클릭 - groupByColumns 확인:", { groupByColumns, editButtonConfig: componentConfig.rightPanel?.editButton, hasGroupByColumns: groupByColumns.length > 0, }); // ScreenModal 열기 이벤트 발생 (URL 파라미터로 ID + groupByColumns 전달) window.dispatchEvent( new CustomEvent("openScreenModal", { detail: { screenId: modalScreenId, urlParams: { mode: "edit", editId: primaryKeyValue, tableName: rightTableName, ...(groupByColumns.length > 0 && { groupByColumns: JSON.stringify(groupByColumns), }), }, }, }), ); console.log("✅ [SplitPanel] openScreenModal 이벤트 발생:", { screenId: modalScreenId, editId: primaryKeyValue, tableName: rightTableName, groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음", }); return; } } } // 기존 자동 편집 모드 (인라인 편집 모달) setEditModalPanel(panel); setEditModalItem(item); setEditModalFormData({ ...item }); setShowEditModal(true); }, [componentConfig, activeTabIndex], ); // 커스텀 모드 우측 패널 저장 (인라인 편집 데이터) const handleCustomRightSave = useCallback(async () => { if (!selectedLeftItem || !customLeftSelectedData || Object.keys(customLeftSelectedData).length === 0) { toast({ title: "저장 실패", description: "저장할 데이터가 없습니다. 좌측에서 항목을 선택해주세요.", variant: "destructive", }); return; } const tableName = componentConfig.rightPanel?.tableName || componentConfig.leftPanel?.tableName; if (!tableName) { toast({ title: "저장 실패", description: "테이블 정보가 없습니다.", variant: "destructive", }); return; } // Primary Key 찾기 - 실제 DB의 id 컬럼 값을 사용 (sourceColumn은 관계 연결용이므로 PK로 사용하지 않음) const primaryKey = selectedLeftItem.id || selectedLeftItem.ID; if (!primaryKey) { toast({ title: "저장 실패", description: "Primary Key를 찾을 수 없습니다.", variant: "destructive", }); return; } try { // 프론트엔드 전용 필드 제거 const cleanData = { ...customLeftSelectedData }; delete cleanData.children; delete cleanData.level; delete cleanData._originalItems; // company_code 자동 추가 if (companyCode && !cleanData.company_code) { cleanData.company_code = companyCode; } console.log("📝 [SplitPanel] 커스텀 우측 패널 저장:", { tableName, primaryKey, data: cleanData }); const response = await dataApi.updateRecord(tableName, primaryKey, cleanData); if (response.success) { toast({ title: "저장 완료", description: "데이터가 저장되었습니다.", }); // 좌측 데이터 새로고침 (변경된 항목 반영) loadLeftData(); // selectedLeftItem도 업데이트 setSelectedLeftItem(customLeftSelectedData); } else { toast({ title: "저장 실패", description: response.error || "데이터 저장에 실패했습니다.", variant: "destructive", }); } } catch (error) { console.error("커스텀 우측 패널 저장 오류:", error); toast({ title: "저장 오류", description: "데이터 저장 중 오류가 발생했습니다.", variant: "destructive", }); } }, [selectedLeftItem, customLeftSelectedData, componentConfig, companyCode, toast, loadLeftData]); // 수정 모달 저장 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가 변경되었을 수 있음) loadRightData(selectedLeftItem); } else if (editModalPanel === "right") { 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, ]); // 삭제 버튼 핸들러 (tableName: 추가 탭 등 특정 테이블 지정 시 사용) const handleDeleteClick = useCallback((panel: "left" | "right", item: any, tableName?: string) => { setDeleteModalPanel(panel); setDeleteModalItem(item); setDeleteModalTableName(tableName || null); setShowDeleteModal(true); }, []); // 삭제 확인 const handleDeleteConfirm = useCallback(async () => { // 1. 테이블명 결정: deleteModalTableName이 있으면 우선 사용 (추가 탭 등) let tableName = deleteModalTableName; if (!tableName) { tableName = deleteModalPanel === "left" ? componentConfig.leftPanel?.tableName : componentConfig.rightPanel?.tableName; // 우측 패널 + 중계 테이블 모드인 경우 if (deleteModalPanel === "right" && componentConfig.rightPanel?.addConfig?.targetTable) { tableName = componentConfig.rightPanel.addConfig.targetTable; console.log("🔗 중계 테이블 모드: 삭제 대상 테이블 =", tableName); } } // 2. Primary Key 추출: id 필드를 우선 사용, 없으면 전체 객체 전달 (복합키) let primaryKey: any = deleteModalItem?.id || deleteModalItem?.ID; if (!primaryKey && deleteModalItem && typeof deleteModalItem === "object") { // id가 없는 경우에만 전체 객체 전달 (복합키 테이블) primaryKey = deleteModalItem; console.log("🔑 복합키: 전체 객체 전달", Object.keys(primaryKey)); } else { console.log("🔑 단일키 삭제: id =", primaryKey, "테이블 =", tableName); } if (!tableName || !primaryKey) { toast({ title: "삭제 오류", description: "테이블명 또는 Primary Key가 없습니다.", variant: "destructive", }); return; } try { console.log("🗑️ 데이터 삭제:", { tableName, primaryKey }); // 🔍 중복 제거 설정 디버깅 console.log("🔍 중복 제거 디버깅:", { panel: deleteModalPanel, dataFilter: componentConfig.rightPanel?.dataFilter, deduplication: componentConfig.rightPanel?.dataFilter?.deduplication, enabled: componentConfig.rightPanel?.dataFilter?.deduplication?.enabled, }); let result; // 🔧 중복 제거가 활성화된 경우, groupByColumn 기준으로 모든 관련 레코드 삭제 if (deleteModalPanel === "right" && componentConfig.rightPanel?.dataFilter?.deduplication?.enabled) { const deduplication = componentConfig.rightPanel.dataFilter.deduplication; const groupByColumn = deduplication.groupByColumn; if (groupByColumn && deleteModalItem[groupByColumn]) { const groupValue = deleteModalItem[groupByColumn]; console.log(`🔗 중복 제거 활성화: ${groupByColumn} = ${groupValue} 기준으로 모든 레코드 삭제`); // groupByColumn 값으로 필터링하여 삭제 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); // 그룹 삭제 API 호출 result = await dataApi.deleteGroupRecords(tableName, filterConditions); } else { // 단일 레코드 삭제 result = await dataApi.deleteRecord(tableName, primaryKey); } } else { // 단일 레코드 삭제 result = await dataApi.deleteRecord(tableName, primaryKey); } if (result.success) { toast({ title: "성공", description: "데이터가 성공적으로 삭제되었습니다.", }); // 모달 닫기 setShowDeleteModal(false); setDeleteModalItem(null); setDeleteModalTableName(null); // 데이터 새로고침 if (deleteModalPanel === "left") { loadLeftData(); // 삭제된 항목이 선택되어 있었으면 선택 해제 const deletedId = deleteModalItem?.id || deleteModalItem?.ID; if (selectedLeftItem && (selectedLeftItem.id === deletedId || selectedLeftItem.ID === deletedId)) { setSelectedLeftItem(null); setRightData(null); } } else if (deleteModalPanel === "right") { // 추가 탭에서 삭제한 경우 해당 탭 데이터 리로드 if (deleteModalTableName && activeTabIndex > 0) { loadTabData(activeTabIndex, selectedLeftItem); } else { loadRightData(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, deleteModalTableName, componentConfig, deleteModalItem, toast, selectedLeftItem, loadLeftData, loadRightData, loadTabData, activeTabIndex]); // 항목별 추가 버튼 핸들러 (좌측 항목의 + 버튼 - 하위 항목 추가) 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") { // 우측 패널 데이터 새로고침 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(); // 좌측 미선택 상태에서 우측 전체 데이터 기본 로드 // join 모드: 초기 전체 로드 / detail 모드: 초기 로드 안 함 const relationshipType = componentConfig.rightPanel?.relation?.type || "detail"; if (relationshipType === "join") { loadRightData(null); } // 추가 탭: 메인 패널이 "detail"(선택 시 표시)이면 추가 탭도 초기 로드하지 않음 if (relationshipType !== "detail") { const tabs = componentConfig.rightPanel?.additionalTabs; if (tabs && tabs.length > 0) { tabs.forEach((tab: any, idx: number) => { const tabRelType = tab.relation?.type || "join"; if (tabRelType === "join") { loadTabData(idx + 1, null); } }); } } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isDesignMode, componentConfig.autoLoad]); // config에서 pageSize 변경 시 상태 동기화 + 페이지 리셋 useEffect(() => { const configLeftPageSize = componentConfig.leftPanel?.pagination?.pageSize ?? 20; setLeftPageSize(configLeftPageSize); setLeftCurrentPage(1); setLeftPageInput("1"); }, [componentConfig.leftPanel?.pagination?.pageSize]); useEffect(() => { const configRightPageSize = componentConfig.rightPanel?.pagination?.pageSize ?? 20; setRightPageSize(configRightPageSize); setRightCurrentPage(1); setRightPageInput("1"); setTabsPagination({}); }, [componentConfig.rightPanel?.pagination?.pageSize]); // 🔄 필터 변경 시 데이터 다시 로드 (페이지 1로 리셋) useEffect(() => { if (!isDesignMode && componentConfig.autoLoad !== false) { if (leftPaginationEnabled) { setLeftCurrentPage(1); setLeftPageInput("1"); } loadLeftData(1); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [leftFilters]); // 전역 테이블 새로고침 이벤트 리스너 useEffect(() => { const handleRefreshTable = () => { if (!isDesignMode) { console.log("🔄 [SplitPanel] refreshTable 이벤트 수신 - 데이터 새로고침"); loadLeftData(); // 현재 활성 탭 데이터 새로고침 (좌측 미선택 시에도 전체 데이터 로드) if (activeTabIndex === 0) { loadRightData(selectedLeftItem); } else { loadTabData(activeTabIndex, selectedLeftItem); } } }; window.addEventListener("refreshTable", handleRefreshTable); return () => { window.removeEventListener("refreshTable", handleRefreshTable); }; }, [isDesignMode, loadLeftData, loadRightData, loadTabData, activeTabIndex, 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 as any)?.showBomExcelUpload && ( )} {!isDesignMode && componentConfig.leftPanel?.showAdd && ( )}
{componentConfig.leftPanel?.showSearch && (
setLeftSearchQuery(e.target.value)} className="pl-9" />
)} {/* 좌측 데이터 목록/테이블/커스텀 */} {componentConfig.leftPanel?.displayMode === "custom" ? ( // 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치
{/* 커스텀 모드: 디자인/실행 모드 분기 렌더링 */} {componentConfig.leftPanel?.components && componentConfig.leftPanel.components.length > 0 ? ( !isDesignMode ? ( { if (data?.selectedRowsData && data.selectedRowsData.length > 0) { setCustomLeftSelectedData(data.selectedRowsData[0]); setSelectedLeftItem(data.selectedRowsData[0]); } else if (data?.selectedRowsData && data.selectedRowsData.length === 0) { setCustomLeftSelectedData({}); setSelectedLeftItem(null); } }} tableName={componentConfig.leftPanel?.tableName} menuObjid={(props as any).menuObjid} screenId={(props as any).screenId} userId={(props as any).userId} userName={(props as any).userName} companyCode={companyCode} allComponents={(props as any).allComponents} selectedRowsData={localSelectedRowsData} onSelectedRowsChange={handleLocalSelectedRowsChange} /> ) : (
{componentConfig.leftPanel.components.map((comp: PanelInlineComponent) => { const isSelectedComp = selectedPanelComponentId === comp.id; const isDraggingComp = draggingCompId === comp.id; const isResizingComp = resizingCompId === comp.id; const displayX = isDraggingComp && dragPosition ? dragPosition.x : (comp.position?.x || 0); const displayY = isDraggingComp && dragPosition ? dragPosition.y : (comp.position?.y || 0); const displayWidth = isResizingComp && resizeSize ? resizeSize.width : (comp.size?.width || 200); const displayHeight = isResizingComp && resizeSize ? resizeSize.height : (comp.size?.height || 100); const componentData = { id: comp.id, type: "component" as const, componentType: comp.componentType, label: comp.label, position: comp.position || { x: 0, y: 0 }, size: { width: displayWidth, height: displayHeight }, componentConfig: comp.componentConfig || {}, style: comp.style || {}, tableName: comp.componentConfig?.tableName, columnName: comp.componentConfig?.columnName, webType: comp.componentConfig?.webType, inputType: (comp as any).inputType || comp.componentConfig?.inputType, }; return (
{ e.stopPropagation(); if (comp.componentType !== "v2-tabs-widget") { setNestedTabSelectedCompId(undefined); } setInternalSelectedCompId(comp.id); onSelectPanelComponent?.("left", comp.id, comp); }} > {/* 드래그 핸들 - 컴포넌트 외부 상단 */}
handlePanelDragStart(e, "left", comp)} >
{comp.label || comp.componentType}
{/* 실제 컴포넌트 렌더링 - 핸들 아래에 별도 영역 */}
{/* 🆕 컨테이너 컴포넌트(탭, 분할 패널)는 드롭 이벤트를 받을 수 있어야 함 */}
{ handleNestedComponentUpdate("left", comp.id, updatedComp); }} // 🆕 중첩된 탭 내부 컴포넌트 선택 핸들러 - 부모 분할 패널 정보 포함 onSelectTabComponent={(tabId: string, compId: string, tabComp: any) => { console.log("🔍 [SplitPanel-Left] onSelectTabComponent 호출:", { tabId, compId, tabComp, parentSplitPanelId: component.id }); // 탭 내 컴포넌트 선택 상태 업데이트 setNestedTabSelectedCompId(compId); // 부모 분할 패널 정보와 함께 전역 이벤트 발생 const event = new CustomEvent("nested-tab-component-select", { detail: { tabsComponentId: comp.id, tabId, componentId: compId, component: tabComp, parentSplitPanelId: component.id, parentPanelSide: "left", }, }); window.dispatchEvent(event); }} selectedTabComponentId={nestedTabSelectedCompId} />
{/* 리사이즈 가장자리 영역 - 선택된 컴포넌트에만 표시 */} {isSelectedComp && ( <> {/* 오른쪽 가장자리 (너비 조절) */}
handlePanelResizeStart(e, "left", comp, "e")} /> {/* 아래 가장자리 (높이 조절) */}
handlePanelResizeStart(e, "left", comp, "s")} /> {/* 오른쪽 아래 모서리 (너비+높이 조절) */}
handlePanelResizeStart(e, "left", comp, "se")} /> )}
); })}
) ) : ( // 컴포넌트가 없을 때 드롭 영역 표시

커스텀 모드

{isDesignMode ? "컴포넌트를 드래그하여 배치하세요" : "배치된 컴포넌트가 없습니다"}

)}
) : 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, // 🆕 기본값 })); // 🔧 그룹화된 데이터 렌더링 const hasGroupedLeftActions = !isDesignMode && ( (componentConfig.leftPanel?.showEdit !== false) || (componentConfig.leftPanel?.showDelete !== false) ); if (groupedLeftData.length > 0) { return (
{groupedLeftData.map((group, groupIdx) => (
{group.groupKey} ({group.count}개)
{columnsToShow.map((col, idx) => ( ))} {hasGroupedLeftActions && ( )} {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={`group hover:bg-accent cursor-pointer transition-colors ${ isSelected ? "bg-primary/10" : "" }`} > {columnsToShow.map((col, colIdx) => ( ))} {hasGroupedLeftActions && ( )} ); })}
{col.label}
{formatCellValue( col.name, getEntityJoinValue(item, col.name), leftCategoryMappings, col.format, )}
{(componentConfig.leftPanel?.showEdit !== false) && ( )} {(componentConfig.leftPanel?.showDelete !== false) && ( )}
))}
); } // 🔧 일반 테이블 렌더링 (그룹화 없음) const hasLeftTableActions = !isDesignMode && ( (componentConfig.leftPanel?.showEdit !== false) || (componentConfig.leftPanel?.showDelete !== false) ); return (
{columnsToShow.map((col, idx) => ( ))} {hasLeftTableActions && ( )} {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={`group hover:bg-accent cursor-pointer transition-colors ${ isSelected ? "bg-primary/10" : "" }`} > {columnsToShow.map((col, colIdx) => ( ))} {hasLeftTableActions && ( )} ); })}
{col.label}
{formatCellValue( col.name, getEntityJoinValue(item, col.name), leftCategoryMappings, col.format, )}
{(componentConfig.leftPanel?.showEdit !== false) && ( )} {(componentConfig.leftPanel?.showDelete !== false) && ( )}
); })() )}
) : ( // 목록 모드 (기존)
{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; const rawValue = getEntityJoinValue(item, colName); // 카테고리 매핑이 있으면 라벨로 변환 let displayValue = rawValue; if (rawValue != null && rawValue !== "") { const strVal = String(rawValue); let mapping = leftCategoryMappings[colName]; if (!mapping && colName.includes(".")) { mapping = leftCategoryMappings[colName.split(".").pop() || colName]; } if (mapping && mapping[strVal]) { displayValue = mapping[strVal].label; } } return { label: colLabel, value: displayValue, }; }); 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) => { const rawValue = item[key]; let displayValue = rawValue; if (rawValue != null && rawValue !== "") { const strVal = String(rawValue); const mapping = leftCategoryMappings[key]; if (mapping && mapping[strVal]) { displayValue = mapping[strVal].label; } } return { label: leftColumnLabels[key] || key, value: displayValue, }; }); 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 && (
{/* 수정 버튼 (showEdit 활성화 시에만 표시) */} {(componentConfig.leftPanel?.showEdit !== false) && ( )} {/* 삭제 버튼 (showDelete 활성화 시에만 표시) */} {(componentConfig.leftPanel?.showDelete !== false) && ( )} {/* 항목별 추가 버튼 */} {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 ? ( <>

검색 결과가 없습니다.

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

) : ( "데이터가 없습니다." )}
); })() )}
)} {/* 좌측 페이징 UI */} {leftPaginationEnabled && !isDesignMode && ( renderPaginationBar({ currentPage: leftCurrentPage, totalPages: leftTotalPages, total: leftTotal, pageSize: leftPageSize, pageInput: leftPageInput, setPageInput: setLeftPageInput, onPageChange: handleLeftPageChange, onPageSizeChange: handleLeftPageSizeChange, commitPageInput: commitLeftPageInput, loading: isLoadingLeft, }) )}
{/* 리사이저 */} {resizable && (
)} {/* 우측 패널 */}
{/* 탭이 없으면 제목만, 있으면 탭으로 전환 */} {(componentConfig.rightPanel?.additionalTabs?.length || 0) > 0 ? (
{componentConfig.rightPanel?.additionalTabs?.map((tab: any, index: number) => ( ))}
) : ( {componentConfig.rightPanel?.title || "우측 패널"} )}
{!isDesignMode && (
{/* 커스텀 모드 기본정보 탭: 저장 버튼 */} {activeTabIndex === 0 && componentConfig.rightPanel?.displayMode === "custom" && selectedLeftItem && ( )} {activeTabIndex === 0 ? componentConfig.rightPanel?.showAdd && ( ) : (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.showAdd && ( )}
)}
{componentConfig.rightPanel?.showSearch && (
setRightSearchQuery(e.target.value)} className="pl-9" />
)} {/* 추가 탭 컨텐츠 */} {activeTabIndex > 0 ? ( (() => { const currentTabConfig = componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any; const currentTabData = tabsData[activeTabIndex] || []; const isTabLoading = tabsLoading[activeTabIndex]; // 메인 패널이 "detail"(선택 시 표시)이면 좌측 미선택 시 안내 메시지 const mainRelationType = componentConfig.rightPanel?.relation?.type || "detail"; if (mainRelationType === "detail" && !selectedLeftItem && !isDesignMode) { return (

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

선택한 항목의 관련 데이터가 여기에 표시됩니다

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

관련 데이터가 없습니다.

); } // 탭 컬럼 설정 const tabColumns = currentTabConfig?.columns || []; // 테이블 모드로 표시 (행 클릭 시 상세 정보 펼치기) if (currentTabConfig?.displayMode === "table") { const hasTabActions = currentTabConfig?.showEdit || currentTabConfig?.showDelete; // showInSummary가 false가 아닌 것만 메인 테이블에 표시 const tabSummaryColumns = tabColumns.filter((col: any) => col.showInSummary !== false); return (
{tabSummaryColumns.map((col: any) => ( ))} {hasTabActions && ( )} {currentTabData.map((item: any, idx: number) => { const tabItemId = item.id || item.ID || idx; const isTabExpanded = expandedRightItems.has(`tab_${activeTabIndex}_${tabItemId}`); // 상세 정보용 전체 값 목록 (showInDetail이 false가 아닌 것만) const tabDetailColumns = tabColumns.filter((col: any) => col.showInDetail !== false); const tabAllValues: [string, any, string][] = tabDetailColumns.length > 0 ? tabDetailColumns.map((col: any) => [col.name, getEntityJoinValue(item, col.name), col.label || col.name] as [string, any, string]) : Object.entries(item) .filter(([, v]) => v !== null && v !== undefined && v !== "") .map(([k, v]) => [k, v, ""] as [string, any, string]); return ( toggleRightItemExpansion(`tab_${activeTabIndex}_${tabItemId}`)} > {tabSummaryColumns.map((col: any) => ( ))} {hasTabActions && ( )} {/* 상세 정보 (행 클릭 시 펼쳐짐) */} {isTabExpanded && ( )} ); })}
{col.label || col.name} 작업
{col.type === "progress" ? renderProgressCell(col, item, selectedLeftItem) : formatCellValue( col.name, getEntityJoinValue(item, col.name), rightCategoryMappings, col.format, )}
{currentTabConfig?.showEdit && ( )} {currentTabConfig?.showDelete && ( )}
상세 정보
{tabAllValues.map(([key, value, label]) => { const displayValue = (value === null || value === undefined || value === "") ? "-" : formatCellValue(key, value, rightCategoryMappings); return ( ); })}
{label || getColumnLabel(key)} {displayValue}
); } // 리스트 모드도 테이블형으로 통일 (행 클릭 시 상세 정보 표시) { const hasTabActions = currentTabConfig?.showEdit || currentTabConfig?.showDelete; // showInSummary가 false가 아닌 것만 메인 테이블에 표시 const listSummaryColumns = tabColumns.filter((col: any) => col.showInSummary !== false); return (
{listSummaryColumns.map((col: any) => ( ))} {hasTabActions && ( )} {currentTabData.map((item: any, idx: number) => { const tabItemId = item.id || item.ID || idx; const isTabExpanded = expandedRightItems.has(`tab_${activeTabIndex}_${tabItemId}`); // showInDetail이 false가 아닌 것만 상세에 표시 const listDetailColumns = tabColumns.filter((col: any) => col.showInDetail !== false); const tabAllValues: [string, any, string][] = listDetailColumns.length > 0 ? listDetailColumns.map((col: any) => [col.name, getEntityJoinValue(item, col.name), col.label || col.name] as [string, any, string]) : Object.entries(item) .filter(([, v]) => v !== null && v !== undefined && v !== "") .map(([k, v]) => [k, v, ""] as [string, any, string]); return ( toggleRightItemExpansion(`tab_${activeTabIndex}_${tabItemId}`)} > {listSummaryColumns.map((col: any) => ( ))} {hasTabActions && ( )} {isTabExpanded && ( )} ); })}
{col.label || col.name} 작업
{col.type === "progress" ? renderProgressCell(col, item, selectedLeftItem) : formatCellValue( col.name, getEntityJoinValue(item, col.name), rightCategoryMappings, col.format, )}
{currentTabConfig?.showEdit && ( )} {currentTabConfig?.showDelete && ( )}
상세 정보
{tabAllValues.map(([key, value, label]) => { const displayValue = (value === null || value === undefined || value === "") ? "-" : formatCellValue(key, value, rightCategoryMappings); return ( ); })}
{label || getColumnLabel(key)} {displayValue}
); } })() ) : componentConfig.rightPanel?.displayMode === "custom" ? ( // 커스텀 모드: alwaysShow가 아닌 경우에만 좌측 선택 필요 !isDesignMode && !selectedLeftItem && !componentConfig.rightPanel?.alwaysShow ? (

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

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

) : (
{/* 커스텀 모드: 디자인/실행 모드 분기 렌더링 */} {componentConfig.rightPanel?.components && componentConfig.rightPanel.components.length > 0 ? ( !isDesignMode ? ( { setCustomLeftSelectedData((prev: Record) => ({ ...prev, [fieldName]: value })); }} tableName={componentConfig.rightPanel?.tableName || componentConfig.leftPanel?.tableName} menuObjid={(props as any).menuObjid} screenId={(props as any).screenId} userId={(props as any).userId} userName={(props as any).userName} companyCode={companyCode} allComponents={(props as any).allComponents} selectedRowsData={localSelectedRowsData} onSelectedRowsChange={handleLocalSelectedRowsChange} /> ) : (
{componentConfig.rightPanel.components.map((comp: PanelInlineComponent) => { const isSelectedComp = selectedPanelComponentId === comp.id; const isDraggingComp = draggingCompId === comp.id; const isResizingComp = resizingCompId === comp.id; const displayX = isDraggingComp && dragPosition ? dragPosition.x : (comp.position?.x || 0); const displayY = isDraggingComp && dragPosition ? dragPosition.y : (comp.position?.y || 0); const displayWidth = isResizingComp && resizeSize ? resizeSize.width : (comp.size?.width || 200); const displayHeight = isResizingComp && resizeSize ? resizeSize.height : (comp.size?.height || 100); const componentData = { id: comp.id, type: "component" as const, componentType: comp.componentType, label: comp.label, position: comp.position || { x: 0, y: 0 }, size: { width: displayWidth, height: displayHeight }, componentConfig: comp.componentConfig || {}, style: comp.style || {}, tableName: comp.componentConfig?.tableName, columnName: comp.componentConfig?.columnName, webType: comp.componentConfig?.webType, inputType: (comp as any).inputType || comp.componentConfig?.inputType, }; return (
{ e.stopPropagation(); if (comp.componentType !== "v2-tabs-widget") { setNestedTabSelectedCompId(undefined); } setInternalSelectedCompId(comp.id); onSelectPanelComponent?.("right", comp.id, comp); }} >
handlePanelDragStart(e, "right", comp)} >
{comp.label || comp.componentType}
{ handleNestedComponentUpdate("right", comp.id, updatedComp); }} onSelectTabComponent={(tabId: string, compId: string, tabComp: any) => { setNestedTabSelectedCompId(compId); const event = new CustomEvent("nested-tab-component-select", { detail: { tabsComponentId: comp.id, tabId, componentId: compId, component: tabComp, parentSplitPanelId: component.id, parentPanelSide: "right", }, }); window.dispatchEvent(event); }} selectedTabComponentId={nestedTabSelectedCompId} />
{isSelectedComp && ( <>
handlePanelResizeStart(e, "right", comp, "e")} />
handlePanelResizeStart(e, "right", comp, "s")} />
handlePanelResizeStart(e, "right", comp, "se")} /> )}
); })}
) ) : ( // 컴포넌트가 없을 때 드롭 영역 표시

커스텀 모드

{isDesignMode ? "컴포넌트를 드래그하여 배치하세요" : "배치된 컴포넌트가 없습니다"}

)}
) ) : 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) { // 설정된 컬럼 사용 (showInSummary가 false가 아닌 것만 테이블에 표시) columnsToShow = displayColumns .filter((col) => col.showInSummary !== false) .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 (
{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} 작업
{col.type === "progress" ? renderProgressCell(col, item, selectedLeftItem) : formatCellValue( col.name, getEntityJoinValue(item, col.name), rightCategoryMappings, col.format, )}
{(componentConfig.rightPanel?.editButton?.enabled ?? true) && ( )} {(componentConfig.rightPanel?.deleteButton?.enabled ?? true) && ( )}
); } // 목록 모드 - 테이블형 디자인 (행 클릭 시 상세 정보 표시) { // 표시 컬럼 결정 const rightColumns = componentConfig.rightPanel?.columns; let columnsToDisplay: { name: string; label: string; format?: string; bold?: boolean; width?: number }[] = []; if (rightColumns && rightColumns.length > 0) { // showInSummary가 false가 아닌 것만 메인 테이블에 표시 columnsToDisplay = rightColumns .filter((col) => col.showInSummary !== false) .map((col) => ({ name: col.name, label: rightColumnLabels[col.name] || col.label || col.name, format: col.format, bold: col.bold, width: col.width, })); } else if (filteredData.length > 0) { columnsToDisplay = Object.keys(filteredData[0]) .filter((key) => shouldShowField(key)) .slice(0, 6) .map((key) => ({ name: key, label: rightColumnLabels[key] || key, })); } const hasEditButton = !isDesignMode && (componentConfig.rightPanel?.editButton?.enabled ?? true); const hasDeleteButton = !isDesignMode && (componentConfig.rightPanel?.deleteButton?.enabled ?? true); const hasActions = hasEditButton || hasDeleteButton; return filteredData.length > 0 ? (
{columnsToDisplay.map((col) => ( ))} {hasActions && ( )} {filteredData.map((item, idx) => { const itemId = item.id || item.ID || idx; const isExpanded = expandedRightItems.has(itemId); // 상세 정보용 전체 값 목록 (showInDetail이 false가 아닌 것만 표시) let allValues: [string, any, string][] = []; if (rightColumns && rightColumns.length > 0) { allValues = rightColumns .filter((col) => col.showInDetail !== false) .map((col) => { const value = getEntityJoinValue(item, col.name); return [col.name, value, col.label] as [string, any, string]; }); } else { allValues = Object.entries(item) .filter(([, value]) => value !== null && value !== undefined && value !== "") .map(([key, value]) => [key, value, ""] as [string, any, string]); } return ( toggleRightItemExpansion(itemId)} > {columnsToDisplay.map((col) => ( ))} {hasActions && ( )} {/* 상세 정보 (행 클릭 시 펼쳐짐) */} {isExpanded && ( )} ); })}
{col.label} 작업
{formatCellValue( col.name, getEntityJoinValue(item, col.name), rightCategoryMappings, col.format, )}
{hasEditButton && ( )} {hasDeleteButton && ( )}
상세 정보
{allValues.map(([key, value, label]) => { const colConfig = rightColumns?.find((c) => c.name === key); const format = colConfig?.format; const displayValue = (value === null || value === undefined || value === "") ? "-" : 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})`), ); // 설정된 컬럼만 표시 (showInDetail이 false가 아닌 것만) displayEntries = rightColumns .filter((col) => col.showInDetail !== false) .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]; }) ; // 설정된 컬럼은 null/empty여도 항상 표시 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)}
{(value === null || value === undefined || value === "") ? - : String(value)}
))}
); })() ) ) : selectedLeftItem && isDesignMode ? ( // 디자인 모드: 샘플 데이터

{selectedLeftItem.name} 상세 정보

항목 1: 값 1
항목 2: 값 2
항목 3: 값 3
) : ( // 데이터 없음 또는 초기 로딩 대기
{componentConfig.rightPanel?.relation?.type === "join" ? ( <>

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

) : ( <>

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

선택한 항목의 관련 데이터가 여기에 표시됩니다

)}
)} {/* 우측/탭 페이징 UI */} {rightPaginationEnabled && !isDesignMode && renderPaginationBar({ currentPage: rightPagState.currentPage, totalPages: rightPagState.totalPages, total: rightPagState.total, pageSize: rightPagState.pageSize, pageInput: rightPageInput, setPageInput: setRightPageInput, onPageChange: (p) => { if (rightPagState.isTab) { setTabsPagination((prev) => ({ ...prev, [activeTabIndex]: { ...(prev[activeTabIndex] || { currentPage: 1, totalPages: 1, total: 0, pageSize: rightPageSize }), currentPage: p }, })); setRightPageInput(String(p)); loadTabData(activeTabIndex, selectedLeftItem, p); } else { handleRightPageChange(p); } }, onPageSizeChange: handleRightPageSizeChange, commitPageInput: commitRightPageInput, loading: isLoadingRight || (tabsLoading[activeTabIndex] ?? false), })}
{/* 추가 모달 */} {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; })()}
{/* 삭제 확인 모달 */} 삭제 확인 정말로 이 데이터를 삭제하시겠습니까?
이 작업은 되돌릴 수 없습니다.
{(componentConfig.leftPanel as any)?.showBomExcelUpload && ( { loadLeftData(); }} /> )}
); }; /** * SplitPanelLayout 래퍼 컴포넌트 */ export const SplitPanelLayoutWrapper: React.FC = (props) => { return ; };