"use client"; import React, { useState, useEffect, useCallback, useMemo } from "react"; import { ComponentRendererProps } from "@/types/component"; import { SplitPanelLayout2Config, ColumnConfig, DataTransferField, ActionButtonConfig, JoinTableConfig, } from "./types"; import { defaultConfig } from "./config"; import { cn } from "@/lib/utils"; import { Search, Plus, ChevronRight, ChevronDown, Edit, Trash2, Users, Building2, Check, MoreHorizontal } from "lucide-react"; import { Checkbox } from "@/components/ui/checkbox"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; 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 [selectedRightItems, setSelectedRightItems] = useState>(new Set()); // 삭제 확인 다이얼로그 상태 const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [itemToDelete, setItemToDelete] = useState(null); const [isBulkDelete, setIsBulkDelete] = useState(false); // 좌측 데이터 로드 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 loadJoinTableData = useCallback(async ( joinConfig: JoinTableConfig, mainData: any[] ): Promise> => { const resultMap = new Map(); if (!joinConfig.joinTable || !joinConfig.mainColumn || !joinConfig.joinColumn || mainData.length === 0) { return resultMap; } // 메인 데이터에서 조인할 키 값들 추출 const joinKeys = [...new Set(mainData.map((item) => item[joinConfig.mainColumn]).filter(Boolean))]; if (joinKeys.length === 0) return resultMap; try { console.log(`[SplitPanelLayout2] 조인 테이블 로드: ${joinConfig.joinTable}, 키: ${joinKeys.length}개`); const response = await apiClient.post(`/table-management/tables/${joinConfig.joinTable}/data`, { page: 1, size: 1000, // 조인 키 값들로 필터링 dataFilter: { enabled: true, matchType: "any", // OR 조건으로 여러 키 매칭 filters: joinKeys.map((key, idx) => ({ id: `join_key_${idx}`, columnName: joinConfig.joinColumn, operator: "equals", value: String(key), valueType: "static", })), }, autoFilter: { enabled: true, filterColumn: "company_code", filterType: "company", }, }); if (response.data.success) { const joinData = response.data.data?.data || []; // 조인 컬럼 값을 키로 하는 Map 생성 joinData.forEach((item: any) => { const key = item[joinConfig.joinColumn]; if (key) { resultMap.set(String(key), item); } }); console.log(`[SplitPanelLayout2] 조인 테이블 로드 완료: ${joinData.length}건`); } } catch (error) { console.error(`[SplitPanelLayout2] 조인 테이블 로드 실패 (${joinConfig.joinTable}):`, error); } return resultMap; }, []); // 메인 데이터에 조인 테이블 데이터 병합 const mergeJoinData = useCallback(( mainData: any[], joinConfig: JoinTableConfig, joinDataMap: Map ): any[] => { return mainData.map((item) => { const joinKey = item[joinConfig.mainColumn]; const joinRow = joinDataMap.get(String(joinKey)); if (joinRow && joinConfig.selectColumns) { // 선택된 컬럼만 병합 const mergedItem = { ...item }; joinConfig.selectColumns.forEach((col) => { // 조인 테이블명.컬럼명 형식으로 저장 (sourceTable 참조용) const tableColumnKey = `${joinConfig.joinTable}.${col}`; mergedItem[tableColumnKey] = joinRow[col]; // alias가 있으면 alias_컬럼명, 없으면 그냥 컬럼명으로도 저장 (하위 호환성) const targetKey = joinConfig.alias ? `${joinConfig.alias}_${col}` : col; // 메인 테이블에 같은 컬럼이 없으면 추가 if (!(col in mergedItem)) { mergedItem[col] = joinRow[col]; } else if (joinConfig.alias) { // 메인 테이블에 같은 컬럼이 있으면 alias로 추가 mergedItem[targetKey] = joinRow[col]; } }); console.log(`[SplitPanelLayout2] 조인 데이터 병합:`, { mainKey: joinKey, mergedKeys: Object.keys(mergedItem) }); return mergedItem; } return item; }); }, []); // 우측 데이터 로드 (좌측 선택 항목 기반) 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, ... } } let data = response.data.data?.data || []; console.log(`[SplitPanelLayout2] 메인 데이터 로드 완료: ${data.length}건`); // 추가 조인 테이블 처리 const joinTables = config.rightPanel?.joinTables || []; if (joinTables.length > 0 && data.length > 0) { console.log(`[SplitPanelLayout2] 조인 테이블 처리 시작: ${joinTables.length}개`); for (const joinTableConfig of joinTables) { const joinDataMap = await loadJoinTableData(joinTableConfig, data); if (joinDataMap.size > 0) { data = mergeJoinData(data, joinTableConfig, joinDataMap); } } console.log(`[SplitPanelLayout2] 조인 데이터 병합 완료`); } 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.rightPanel?.joinTables, config.joinConfig, loadJoinTableData, mergeJoinData]); // 좌측 패널 추가 버튼 클릭 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 getPrimaryKeyColumn = useCallback(() => { return config.rightPanel?.primaryKeyColumn || "id"; }, [config.rightPanel?.primaryKeyColumn]); // 우측 패널 수정 버튼 클릭 const handleEditItem = useCallback((item: any) => { // 수정용 모달 화면 ID 결정 (editModalScreenId 우선, 없으면 addModalScreenId 사용) const modalScreenId = config.rightPanel?.editModalScreenId || config.rightPanel?.addModalScreenId; if (!modalScreenId) { toast.error("연결된 모달 화면이 없습니다."); return; } // EditModal 열기 이벤트 발생 (수정 모드) const event = new CustomEvent("openEditModal", { detail: { screenId: modalScreenId, title: "수정", modalSize: "lg", editData: item, // 기존 데이터 전달 isCreateMode: false, // 수정 모드 onSave: () => { if (selectedLeftItem) { loadRightData(selectedLeftItem); } }, }, }); window.dispatchEvent(event); console.log("[SplitPanelLayout2] 수정 모달 열기:", item); }, [config.rightPanel?.editModalScreenId, config.rightPanel?.addModalScreenId, selectedLeftItem, loadRightData]); // 우측 패널 삭제 버튼 클릭 (확인 다이얼로그 표시) const handleDeleteClick = useCallback((item: any) => { setItemToDelete(item); setIsBulkDelete(false); setDeleteDialogOpen(true); }, []); // 일괄 삭제 버튼 클릭 (확인 다이얼로그 표시) const handleBulkDeleteClick = useCallback(() => { if (selectedRightItems.size === 0) { toast.error("삭제할 항목을 선택해주세요."); return; } setIsBulkDelete(true); setDeleteDialogOpen(true); }, [selectedRightItems.size]); // 실제 삭제 실행 const executeDelete = useCallback(async () => { if (!config.rightPanel?.tableName) { toast.error("테이블 설정이 없습니다."); return; } const pkColumn = getPrimaryKeyColumn(); try { if (isBulkDelete) { // 일괄 삭제 const idsToDelete = Array.from(selectedRightItems); console.log("[SplitPanelLayout2] 일괄 삭제:", idsToDelete); for (const id of idsToDelete) { await apiClient.delete(`/table-management/tables/${config.rightPanel.tableName}/data/${id}`); } toast.success(`${idsToDelete.length}개 항목이 삭제되었습니다.`); setSelectedRightItems(new Set()); } else if (itemToDelete) { // 단일 삭제 const itemId = itemToDelete[pkColumn]; console.log("[SplitPanelLayout2] 단일 삭제:", itemId); await apiClient.delete(`/table-management/tables/${config.rightPanel.tableName}/data/${itemId}`); toast.success("항목이 삭제되었습니다."); } // 데이터 새로고침 if (selectedLeftItem) { loadRightData(selectedLeftItem); } } catch (error: any) { console.error("[SplitPanelLayout2] 삭제 실패:", error); toast.error(`삭제 실패: ${error.message}`); } finally { setDeleteDialogOpen(false); setItemToDelete(null); setIsBulkDelete(false); } }, [config.rightPanel?.tableName, getPrimaryKeyColumn, isBulkDelete, selectedRightItems, itemToDelete, selectedLeftItem, loadRightData]); // 개별 체크박스 선택/해제 const handleSelectItem = useCallback((itemId: string | number, checked: boolean) => { setSelectedRightItems((prev) => { const newSet = new Set(prev); if (checked) { newSet.add(itemId); } else { newSet.delete(itemId); } return newSet; }); }, []); // 액션 버튼 클릭 핸들러 const handleActionButton = useCallback((btn: ActionButtonConfig) => { switch (btn.action) { case "add": if (btn.modalScreenId) { // 데이터 전달 필드 설정 const initialData: Record = {}; if (selectedLeftItem && config.dataTransferFields) { for (const field of config.dataTransferFields) { if (field.sourceColumn && field.targetColumn) { initialData[field.targetColumn] = selectedLeftItem[field.sourceColumn]; } } } const event = new CustomEvent("openEditModal", { detail: { screenId: btn.modalScreenId, title: btn.label || "추가", modalSize: "lg", editData: initialData, isCreateMode: true, onSave: () => { if (selectedLeftItem) { loadRightData(selectedLeftItem); } }, }, }); window.dispatchEvent(event); } break; case "edit": // 선택된 항목이 1개일 때만 수정 if (selectedRightItems.size === 1) { const pkColumn = getPrimaryKeyColumn(); const selectedId = Array.from(selectedRightItems)[0]; const item = rightData.find((d) => d[pkColumn] === selectedId); if (item) { handleEditItem(item); } } else if (selectedRightItems.size > 1) { toast.error("수정할 항목을 1개만 선택해주세요."); } else { toast.error("수정할 항목을 선택해주세요."); } break; case "delete": case "bulk-delete": handleBulkDeleteClick(); break; case "custom": // 커스텀 액션 (추후 확장) console.log("[SplitPanelLayout2] 커스텀 액션:", btn); break; default: break; } }, [selectedLeftItem, config.dataTransferFields, loadRightData, selectedRightItems, getPrimaryKeyColumn, rightData, handleEditItem, handleBulkDeleteClick]); // 컬럼 라벨 로드 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; // 복수 검색 컬럼 지원 (searchColumns 우선, 없으면 searchColumn 사용) const searchColumns = config.leftPanel?.searchColumns?.map((c) => c.columnName).filter(Boolean) || []; const legacyColumn = config.leftPanel?.searchColumn; const columnsToSearch = searchColumns.length > 0 ? searchColumns : legacyColumn ? [legacyColumn] : []; if (columnsToSearch.length === 0) return leftData; const filterRecursive = (items: any[]): any[] => { return items.filter((item) => { // 여러 컬럼 중 하나라도 매칭되면 포함 const matches = columnsToSearch.some((col) => { const value = String(item[col] || "").toLowerCase(); return 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?.searchColumns, config.leftPanel?.searchColumn]); const filteredRightData = useMemo(() => { if (!rightSearchTerm) return rightData; // 복수 검색 컬럼 지원 (searchColumns 우선, 없으면 searchColumn 사용) const searchColumns = config.rightPanel?.searchColumns?.map((c) => c.columnName).filter(Boolean) || []; const legacyColumn = config.rightPanel?.searchColumn; const columnsToSearch = searchColumns.length > 0 ? searchColumns : legacyColumn ? [legacyColumn] : []; if (columnsToSearch.length === 0) return rightData; return rightData.filter((item) => { // 여러 컬럼 중 하나라도 매칭되면 포함 return columnsToSearch.some((col) => { const value = String(item[col] || "").toLowerCase(); return value.includes(rightSearchTerm.toLowerCase()); }); }); }, [rightData, rightSearchTerm, config.rightPanel?.searchColumns, config.rightPanel?.searchColumn]); // 체크박스 전체 선택/해제 (filteredRightData 정의 이후에 위치해야 함) const handleSelectAll = useCallback((checked: boolean) => { if (checked) { const pkColumn = getPrimaryKeyColumn(); const allIds = new Set(filteredRightData.map((item) => item[pkColumn])); setSelectedRightItems(allIds); } else { setSelectedRightItems(new Set()); } }, [filteredRightData, getPrimaryKeyColumn]); // 리사이즈 핸들러 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]); // 컬럼 값 가져오기 (sourceTable 고려) const getColumnValue = useCallback((item: any, col: ColumnConfig): any => { // col.name이 "테이블명.컬럼명" 형식인 경우 처리 const actualColName = col.name.includes(".") ? col.name.split(".")[1] : col.name; const tableFromName = col.name.includes(".") ? col.name.split(".")[0] : null; const effectiveSourceTable = col.sourceTable || tableFromName; // sourceTable이 설정되어 있고, 메인 테이블이 아닌 경우 if (effectiveSourceTable && effectiveSourceTable !== config.rightPanel?.tableName) { // 1. 테이블명.컬럼명 형식으로 먼저 시도 (mergeJoinData에서 저장한 형식) const tableColumnKey = `${effectiveSourceTable}.${actualColName}`; if (item[tableColumnKey] !== undefined) { return item[tableColumnKey]; } // 2. 조인 테이블의 alias가 설정된 경우 alias_컬럼명으로 시도 const joinTable = config.rightPanel?.joinTables?.find(jt => jt.joinTable === effectiveSourceTable); if (joinTable?.alias) { const aliasKey = `${joinTable.alias}_${actualColName}`; if (item[aliasKey] !== undefined) { return item[aliasKey]; } } // 3. 그냥 컬럼명으로 시도 (메인 테이블에 없는 경우 조인 데이터가 직접 들어감) if (item[actualColName] !== undefined) { return item[actualColName]; } } // 4. 기본: 컬럼명으로 직접 접근 return item[actualColName]; }, [config.rightPanel?.tableName, config.rightPanel?.joinTables]); // 값 포맷팅 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]; // displayRow 설정에 따라 컬럼 분류 const displayColumns = config.leftPanel?.displayColumns || []; const nameRowColumns = displayColumns.filter((col, idx) => col.displayRow === "name" || (!col.displayRow && idx === 0) ); const infoRowColumns = displayColumns.filter((col, idx) => col.displayRow === "info" || (!col.displayRow && idx > 0) ); // 이름 행의 첫 번째 값 (주요 표시 값) const primaryValue = nameRowColumns[0] ? item[nameRowColumns[0].name] : Object.values(item).find((v) => typeof v === "string" && v.length > 0); return (
handleLeftItemSelect(item)} > {/* 확장/축소 버튼 */} {hasChildren ? ( ) : (
)} {/* 아이콘 */} {/* 내용 */}
{/* 이름 행 (Name Row) */}
{primaryValue || "이름 없음"} {/* 이름 행의 추가 컬럼들 (배지 스타일) */} {nameRowColumns.slice(1).map((col, idx) => { const value = item[col.name]; if (!value) return null; return ( {formatValue(value, col.format)} ); })}
{/* 정보 행 (Info Row) */} {infoRowColumns.length > 0 && (
{infoRowColumns.map((col, idx) => { const value = item[col.name]; if (!value) return null; return ( {formatValue(value, col.format)} ); }).filter(Boolean).reduce((acc: React.ReactNode[], curr, idx) => { if (idx > 0) acc.push(|); acc.push(curr); return acc; }, [])}
)}
{/* 자식 항목 */} {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 showLabels = config.rightPanel?.showLabels ?? false; const showCheckbox = config.rightPanel?.showCheckbox ?? false; const pkColumn = getPrimaryKeyColumn(); const itemId = item[pkColumn]; // displayRow 설정에 따라 컬럼 분류 // displayRow가 "name"이면 이름 행, "info"이면 정보 행 (기본값: 첫 번째는 name, 나머지는 info) const nameRowColumns = displayColumns.filter((col, idx) => col.displayRow === "name" || (!col.displayRow && idx === 0) ); const infoRowColumns = displayColumns.filter((col, idx) => col.displayRow === "info" || (!col.displayRow && idx > 0) ); return (
{/* 체크박스 */} {showCheckbox && ( handleSelectItem(itemId, !!checked)} className="mt-1" /> )}
{/* showLabels가 true이면 라벨: 값 형식으로 가로 배치 */} {showLabels ? (
{/* 이름 행: 라벨: 값, 라벨: 값 형식으로 가로 배치 */} {nameRowColumns.length > 0 && (
{nameRowColumns.map((col, idx) => { const value = getColumnValue(item, col); if (value === null || value === undefined) return null; return ( {col.label || col.name}: {formatValue(value, col.format)} ); })}
)} {/* 정보 행: 라벨: 값, 라벨: 값 형식으로 가로 배치 */} {infoRowColumns.length > 0 && (
{infoRowColumns.map((col, idx) => { const value = getColumnValue(item, col); if (value === null || value === undefined) return null; return ( {col.label || col.name}: {formatValue(value, col.format)} ); })}
)}
) : ( // showLabels가 false일 때 기존 방식 유지 (라벨 없이 값만)
{/* 이름 행 */} {nameRowColumns.length > 0 && (
{nameRowColumns.map((col, idx) => { const value = getColumnValue(item, col); if (value === null || value === undefined) return null; if (idx === 0) { return ( {formatValue(value, col.format)} ); } return ( {formatValue(value, col.format)} ); })}
)} {/* 정보 행 */} {infoRowColumns.length > 0 && (
{infoRowColumns.map((col, idx) => { const value = getColumnValue(item, col); if (value === null || value === undefined) return null; return ( {formatValue(value, col.format)} ); })}
)}
)}
{/* 액션 버튼 (개별 수정/삭제) */}
{config.rightPanel?.showEditButton && ( )} {config.rightPanel?.showDeleteButton && ( )}
); }; // 우측 패널 테이블 렌더링 const renderRightTable = () => { const displayColumns = config.rightPanel?.displayColumns || []; const showCheckbox = config.rightPanel?.showCheckbox ?? true; // 테이블 모드는 기본 체크박스 표시 const pkColumn = getPrimaryKeyColumn(); const allSelected = filteredRightData.length > 0 && filteredRightData.every((item) => selectedRightItems.has(item[pkColumn])); const someSelected = filteredRightData.some((item) => selectedRightItems.has(item[pkColumn])); return (
{showCheckbox && ( { if (el) { (el as any).indeterminate = someSelected && !allSelected; } }} onCheckedChange={handleSelectAll} /> )} {displayColumns.map((col, idx) => ( {col.label || col.name} ))} {(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && ( 작업 )} {filteredRightData.length === 0 ? ( 등록된 항목이 없습니다 ) : ( filteredRightData.map((item, index) => { const itemId = item[pkColumn]; return ( {showCheckbox && ( handleSelectItem(itemId, !!checked)} /> )} {displayColumns.map((col, colIdx) => ( {formatValue(getColumnValue(item, col), col.format)} ))} {(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && (
{config.rightPanel?.showEditButton && ( )} {config.rightPanel?.showDeleteButton && ( )}
)}
); }) )}
); }; // 액션 버튼 렌더링 const renderActionButtons = () => { const actionButtons = config.rightPanel?.actionButtons; if (!actionButtons || actionButtons.length === 0) return null; return (
{actionButtons.map((btn) => ( ))}
); }; // 디자인 모드 렌더링 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}건) )} {/* 선택된 항목 수 표시 */} {selectedRightItems.size > 0 && ( {selectedRightItems.size}개 선택됨 )}
{/* 복수 액션 버튼 (actionButtons 설정 시) */} {selectedLeftItem && renderActionButtons()} {/* 기존 단일 추가 버튼 (하위 호환성) */} {config.rightPanel?.showAddButton && selectedLeftItem && !config.rightPanel?.actionButtons?.length && ( )}
{/* 검색 */} {config.rightPanel?.showSearch && selectedLeftItem && (
setRightSearchTerm(e.target.value)} className="pl-9 h-9 text-sm" />
)}
{/* 내용 */}
{!selectedLeftItem ? (
{config.rightPanel?.emptyMessage || "좌측에서 항목을 선택해주세요"}
) : rightLoading ? (
로딩 중...
) : ( <> {/* displayMode에 따라 카드 또는 테이블 렌더링 */} {config.rightPanel?.displayMode === "table" ? ( renderRightTable() ) : filteredRightData.length === 0 ? (
등록된 항목이 없습니다
) : (
{filteredRightData.map((item, index) => renderRightCard(item, index))}
)} )}
{/* 삭제 확인 다이얼로그 */} 삭제 확인 {isBulkDelete ? `선택한 ${selectedRightItems.size}개 항목을 삭제하시겠습니까?` : "이 항목을 삭제하시겠습니까?"}
이 작업은 되돌릴 수 없습니다.
취소 삭제
); }; /** * SplitPanelLayout2 래퍼 컴포넌트 */ export const SplitPanelLayout2Wrapper: React.FC = (props) => { return ; };