"use client"; import React, { useState, useEffect, useCallback, useMemo } from "react"; import { ComponentRendererProps } from "@/types/component"; import { SplitPanelLayout2Config, ColumnConfig, DataTransferField, } from "./types"; import { defaultConfig } from "./config"; import { cn } from "@/lib/utils"; import { Search, Plus, ChevronRight, ChevronDown, Edit, Trash2, Users, Building2 } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { toast } from "sonner"; import { useScreenContextOptional } from "@/contexts/ScreenContext"; import { apiClient } from "@/lib/api/client"; export interface SplitPanelLayout2ComponentProps extends ComponentRendererProps { // 추가 props } /** * SplitPanelLayout2 컴포넌트 * 마스터-디테일 패턴의 좌우 분할 레이아웃 (개선 버전) */ export const SplitPanelLayout2Component: React.FC = ({ component, isDesignMode = false, isSelected = false, isPreview = false, onClick, ...props }) => { const config = useMemo(() => { return { ...defaultConfig, ...component.componentConfig, } as SplitPanelLayout2Config; }, [component.componentConfig]); // ScreenContext (데이터 전달용) const screenContext = useScreenContextOptional(); // 상태 관리 const [leftData, setLeftData] = useState([]); const [rightData, setRightData] = useState([]); const [selectedLeftItem, setSelectedLeftItem] = useState(null); const [leftSearchTerm, setLeftSearchTerm] = useState(""); const [rightSearchTerm, setRightSearchTerm] = useState(""); const [leftLoading, setLeftLoading] = useState(false); const [rightLoading, setRightLoading] = useState(false); const [expandedItems, setExpandedItems] = useState>(new Set()); const [splitPosition, setSplitPosition] = useState(config.splitRatio || 30); const [isResizing, setIsResizing] = useState(false); // 좌측 패널 컬럼 라벨 매핑 const [leftColumnLabels, setLeftColumnLabels] = useState>({}); const [rightColumnLabels, setRightColumnLabels] = useState>({}); // 좌측 데이터 로드 const loadLeftData = useCallback(async () => { if (!config.leftPanel?.tableName || isDesignMode) return; setLeftLoading(true); try { const response = await apiClient.post(`/table-management/tables/${config.leftPanel.tableName}/data`, { page: 1, size: 1000, // 전체 데이터 로드 // 멀티테넌시: 자동으로 company_code 필터링 적용 autoFilter: { enabled: true, filterColumn: "company_code", filterType: "company", }, }); if (response.data.success) { // API 응답 구조: { success: true, data: { data: [...], total, page, ... } } let data = response.data.data?.data || []; // 계층 구조 처리 if (config.leftPanel.hierarchyConfig?.enabled) { data = buildHierarchy( data, config.leftPanel.hierarchyConfig.idColumn, config.leftPanel.hierarchyConfig.parentColumn ); } setLeftData(data); console.log(`[SplitPanelLayout2] 좌측 데이터 로드: ${data.length}건 (company_code 필터 적용)`); } } catch (error) { console.error("[SplitPanelLayout2] 좌측 데이터 로드 실패:", error); toast.error("좌측 패널 데이터를 불러오는데 실패했습니다."); } finally { setLeftLoading(false); } }, [config.leftPanel?.tableName, config.leftPanel?.hierarchyConfig, isDesignMode]); // 우측 데이터 로드 (좌측 선택 항목 기반) const loadRightData = useCallback(async (selectedItem: any) => { if (!config.rightPanel?.tableName || !config.joinConfig?.leftColumn || !config.joinConfig?.rightColumn || !selectedItem) { setRightData([]); return; } const joinValue = selectedItem[config.joinConfig.leftColumn]; if (joinValue === undefined || joinValue === null) { console.log(`[SplitPanelLayout2] 조인 값이 없음: ${config.joinConfig.leftColumn}`); setRightData([]); return; } setRightLoading(true); try { console.log(`[SplitPanelLayout2] 우측 데이터 로드 시작: ${config.rightPanel.tableName}, ${config.joinConfig.rightColumn}=${joinValue}`); const response = await apiClient.post(`/table-management/tables/${config.rightPanel.tableName}/data`, { page: 1, size: 1000, // 전체 데이터 로드 // dataFilter를 사용하여 정확한 값 매칭 (Entity 타입 검색 문제 회피) dataFilter: { enabled: true, matchType: "all", filters: [ { id: "join_filter", columnName: config.joinConfig.rightColumn, operator: "equals", value: String(joinValue), valueType: "static", } ], }, // 멀티테넌시: 자동으로 company_code 필터링 적용 autoFilter: { enabled: true, filterColumn: "company_code", filterType: "company", }, }); if (response.data.success) { // API 응답 구조: { success: true, data: { data: [...], total, page, ... } } const data = response.data.data?.data || []; setRightData(data); console.log(`[SplitPanelLayout2] 우측 데이터 로드 완료: ${data.length}건`); } else { console.error("[SplitPanelLayout2] 우측 데이터 로드 실패:", response.data.message); setRightData([]); } } catch (error: any) { console.error("[SplitPanelLayout2] 우측 데이터 로드 에러:", { message: error?.message, status: error?.response?.status, statusText: error?.response?.statusText, data: error?.response?.data, config: { url: error?.config?.url, method: error?.config?.method, data: error?.config?.data, } }); setRightData([]); } finally { setRightLoading(false); } }, [config.rightPanel?.tableName, config.joinConfig]); // 좌측 패널 추가 버튼 클릭 const handleLeftAddClick = useCallback(() => { if (!config.leftPanel?.addModalScreenId) { toast.error("연결된 모달 화면이 없습니다."); return; } // EditModal 열기 이벤트 발생 const event = new CustomEvent("openEditModal", { detail: { screenId: config.leftPanel.addModalScreenId, title: config.leftPanel?.addButtonLabel || "추가", modalSize: "lg", editData: {}, isCreateMode: true, // 생성 모드 onSave: () => { loadLeftData(); }, }, }); window.dispatchEvent(event); console.log("[SplitPanelLayout2] 좌측 추가 모달 열기:", config.leftPanel.addModalScreenId); }, [config.leftPanel?.addModalScreenId, config.leftPanel?.addButtonLabel, loadLeftData]); // 우측 패널 추가 버튼 클릭 const handleRightAddClick = useCallback(() => { if (!config.rightPanel?.addModalScreenId) { toast.error("연결된 모달 화면이 없습니다."); return; } // 데이터 전달 필드 설정 const initialData: Record = {}; if (selectedLeftItem && config.dataTransferFields) { for (const field of config.dataTransferFields) { if (field.sourceColumn && field.targetColumn) { initialData[field.targetColumn] = selectedLeftItem[field.sourceColumn]; } } } console.log("[SplitPanelLayout2] 모달로 전달할 데이터:", initialData); console.log("[SplitPanelLayout2] 모달 screenId:", config.rightPanel?.addModalScreenId); // EditModal 열기 이벤트 발생 const event = new CustomEvent("openEditModal", { detail: { screenId: config.rightPanel.addModalScreenId, title: config.rightPanel?.addButtonLabel || "추가", modalSize: "lg", editData: initialData, isCreateMode: true, // 생성 모드 onSave: () => { if (selectedLeftItem) { loadRightData(selectedLeftItem); } }, }, }); window.dispatchEvent(event); console.log("[SplitPanelLayout2] 우측 추가 모달 열기"); }, [config.rightPanel?.addModalScreenId, config.rightPanel?.addButtonLabel, config.dataTransferFields, selectedLeftItem, loadRightData]); // 컬럼 라벨 로드 const loadColumnLabels = useCallback(async (tableName: string, setLabels: (labels: Record) => void) => { if (!tableName) return; try { const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); if (response.data.success) { const labels: Record = {}; // API 응답 구조: { success: true, data: { columns: [...] } } const columns = response.data.data?.columns || []; columns.forEach((col: any) => { const colName = col.column_name || col.columnName; const colLabel = col.column_label || col.columnLabel || colName; if (colName) { labels[colName] = colLabel; } }); setLabels(labels); } } catch (error) { console.error("[SplitPanelLayout2] 컬럼 라벨 로드 실패:", error); } }, []); // 계층 구조 빌드 const buildHierarchy = (data: any[], idColumn: string, parentColumn: string): any[] => { const itemMap = new Map(); const roots: any[] = []; // 모든 항목을 맵에 저장 data.forEach((item) => { itemMap.set(item[idColumn], { ...item, children: [] }); }); // 부모-자식 관계 설정 data.forEach((item) => { const current = itemMap.get(item[idColumn]); const parentId = item[parentColumn]; if (parentId && itemMap.has(parentId)) { itemMap.get(parentId).children.push(current); } else { roots.push(current); } }); return roots; }; // 좌측 항목 선택 핸들러 const handleLeftItemSelect = useCallback((item: any) => { setSelectedLeftItem(item); loadRightData(item); // ScreenContext DataProvider 등록 (버튼에서 접근 가능하도록) if (screenContext && !isDesignMode) { screenContext.registerDataProvider(component.id, { componentId: component.id, componentType: "split-panel-layout2", getSelectedData: () => [item], getAllData: () => leftData, clearSelection: () => setSelectedLeftItem(null), }); console.log(`[SplitPanelLayout2] DataProvider 등록: ${component.id}`); } }, [isDesignMode, screenContext, component.id, leftData, loadRightData]); // 항목 확장/축소 토글 const toggleExpand = useCallback((itemId: string) => { setExpandedItems((prev) => { const newSet = new Set(prev); if (newSet.has(itemId)) { newSet.delete(itemId); } else { newSet.add(itemId); } return newSet; }); }, []); // 검색 필터링 const filteredLeftData = useMemo(() => { if (!leftSearchTerm) return leftData; const searchColumn = config.leftPanel?.searchColumn; if (!searchColumn) return leftData; const filterRecursive = (items: any[]): any[] => { return items.filter((item) => { const value = String(item[searchColumn] || "").toLowerCase(); const matches = value.includes(leftSearchTerm.toLowerCase()); if (item.children?.length > 0) { const filteredChildren = filterRecursive(item.children); if (filteredChildren.length > 0) { item.children = filteredChildren; return true; } } return matches; }); }; return filterRecursive([...leftData]); }, [leftData, leftSearchTerm, config.leftPanel?.searchColumn]); const filteredRightData = useMemo(() => { if (!rightSearchTerm) return rightData; const searchColumn = config.rightPanel?.searchColumn; if (!searchColumn) return rightData; return rightData.filter((item) => { const value = String(item[searchColumn] || "").toLowerCase(); return value.includes(rightSearchTerm.toLowerCase()); }); }, [rightData, rightSearchTerm, config.rightPanel?.searchColumn]); // 리사이즈 핸들러 const handleResizeStart = useCallback((e: React.MouseEvent) => { if (!config.resizable) return; e.preventDefault(); setIsResizing(true); }, [config.resizable]); const handleResizeMove = useCallback((e: MouseEvent) => { if (!isResizing) return; const container = document.getElementById(`split-panel-${component.id}`); if (!container) return; const rect = container.getBoundingClientRect(); const newPosition = ((e.clientX - rect.left) / rect.width) * 100; const minLeft = (config.minLeftWidth || 200) / rect.width * 100; const minRight = (config.minRightWidth || 300) / rect.width * 100; setSplitPosition(Math.max(minLeft, Math.min(100 - minRight, newPosition))); }, [isResizing, component.id, config.minLeftWidth, config.minRightWidth]); const handleResizeEnd = useCallback(() => { setIsResizing(false); }, []); // 리사이즈 이벤트 리스너 useEffect(() => { if (isResizing) { window.addEventListener("mousemove", handleResizeMove); window.addEventListener("mouseup", handleResizeEnd); } return () => { window.removeEventListener("mousemove", handleResizeMove); window.removeEventListener("mouseup", handleResizeEnd); }; }, [isResizing, handleResizeMove, handleResizeEnd]); // 초기 데이터 로드 useEffect(() => { if (config.autoLoad && !isDesignMode) { loadLeftData(); loadColumnLabels(config.leftPanel?.tableName || "", setLeftColumnLabels); loadColumnLabels(config.rightPanel?.tableName || "", setRightColumnLabels); } }, [config.autoLoad, isDesignMode, loadLeftData, loadColumnLabels, config.leftPanel?.tableName, config.rightPanel?.tableName]); // 컴포넌트 언마운트 시 DataProvider 해제 useEffect(() => { return () => { if (screenContext) { screenContext.unregisterDataProvider(component.id); } }; }, [screenContext, component.id]); // 값 포맷팅 const formatValue = (value: any, format?: ColumnConfig["format"]): string => { if (value === null || value === undefined) return "-"; if (!format) return String(value); switch (format.type) { case "number": const num = Number(value); if (isNaN(num)) return String(value); let formatted = format.decimalPlaces !== undefined ? num.toFixed(format.decimalPlaces) : String(num); if (format.thousandSeparator) { formatted = formatted.replace(/\B(?=(\d{3})+(?!\d))/g, ","); } return `${format.prefix || ""}${formatted}${format.suffix || ""}`; case "currency": const currency = Number(value); if (isNaN(currency)) return String(value); const currencyFormatted = currency.toLocaleString("ko-KR"); return `${format.prefix || ""}${currencyFormatted}${format.suffix || "원"}`; case "date": try { const date = new Date(value); return date.toLocaleDateString("ko-KR"); } catch { return String(value); } default: return String(value); } }; // 좌측 패널 항목 렌더링 const renderLeftItem = (item: any, level: number = 0, index: number = 0) => { const idColumn = config.leftPanel?.hierarchyConfig?.idColumn || "id"; const itemId = item[idColumn] ?? `item-${level}-${index}`; const hasChildren = item.children?.length > 0; const isExpanded = expandedItems.has(String(itemId)); const isSelected = selectedLeftItem && selectedLeftItem[idColumn] === item[idColumn]; // 표시할 컬럼 결정 const displayColumns = config.leftPanel?.displayColumns || []; const primaryColumn = displayColumns[0]; const secondaryColumn = displayColumns[1]; const primaryValue = primaryColumn ? item[primaryColumn.name] : Object.values(item).find((v) => typeof v === "string" && v.length > 0); const secondaryValue = secondaryColumn ? item[secondaryColumn.name] : null; return (
handleLeftItemSelect(item)} > {/* 확장/축소 버튼 */} {hasChildren ? ( ) : (
)} {/* 아이콘 */} {/* 내용 */}
{primaryValue || "이름 없음"}
{secondaryValue && (
{secondaryValue}
)}
{/* 자식 항목 */} {hasChildren && isExpanded && (
{item.children.map((child: any, childIndex: number) => renderLeftItem(child, level + 1, childIndex))}
)}
); }; // 우측 패널 카드 렌더링 const renderRightCard = (item: any, index: number) => { const displayColumns = config.rightPanel?.displayColumns || []; // 첫 번째 컬럼을 이름으로 사용 const nameColumn = displayColumns[0]; const name = nameColumn ? item[nameColumn.name] : "이름 없음"; // 나머지 컬럼들 const otherColumns = displayColumns.slice(1); return (
{/* 이름 */}
{name} {otherColumns[0] && ( {item[otherColumns[0].name]} )}
{/* 상세 정보 */}
{otherColumns.slice(1).map((col, idx) => { const value = item[col.name]; if (!value) return null; // 아이콘 결정 let icon = null; const colName = col.name.toLowerCase(); if (colName.includes("tel") || colName.includes("phone")) { icon = tel; } else if (colName.includes("email")) { icon = @; } else if (colName.includes("sabun") || colName.includes("id")) { icon = ID; } return ( {icon} {formatValue(value, col.format)} ); })}
{/* 액션 버튼 */}
{config.rightPanel?.showEditButton && ( )} {config.rightPanel?.showDeleteButton && ( )}
); }; // 디자인 모드 렌더링 if (isDesignMode) { return (
{/* 좌측 패널 미리보기 */}
{config.leftPanel?.title || "좌측 패널"}
테이블: {config.leftPanel?.tableName || "미설정"}
좌측 목록 영역
{/* 우측 패널 미리보기 */}
{config.rightPanel?.title || "우측 패널"}
테이블: {config.rightPanel?.tableName || "미설정"}
우측 상세 영역
); } return (
{/* 좌측 패널 */}
{/* 헤더 */}

{config.leftPanel?.title || "목록"}

{config.leftPanel?.showAddButton && ( )}
{/* 검색 */} {config.leftPanel?.showSearch && (
setLeftSearchTerm(e.target.value)} className="pl-9 h-9 text-sm" />
)}
{/* 목록 */}
{leftLoading ? (
로딩 중...
) : filteredLeftData.length === 0 ? (
데이터가 없습니다
) : (
{filteredLeftData.map((item, index) => renderLeftItem(item, 0, index))}
)}
{/* 리사이저 */} {config.resizable && (
)} {/* 우측 패널 */}
{/* 헤더 */}

{selectedLeftItem ? config.leftPanel?.displayColumns?.[0] ? selectedLeftItem[config.leftPanel.displayColumns[0].name] : config.rightPanel?.title || "상세" : config.rightPanel?.title || "상세"}

{selectedLeftItem && ( {rightData.length}명 )} {config.rightPanel?.showAddButton && selectedLeftItem && ( )}
{/* 검색 */} {config.rightPanel?.showSearch && selectedLeftItem && (
setRightSearchTerm(e.target.value)} className="pl-9 h-9 text-sm" />
)}
{/* 내용 */}
{!selectedLeftItem ? (
{config.rightPanel?.emptyMessage || "좌측에서 항목을 선택해주세요"}
) : rightLoading ? (
로딩 중...
) : filteredRightData.length === 0 ? (
등록된 항목이 없습니다
) : (
{filteredRightData.map((item, index) => renderRightCard(item, index))}
)}
); }; /** * SplitPanelLayout2 래퍼 컴포넌트 */ export const SplitPanelLayout2Wrapper: React.FC = (props) => { return ; };