"use client"; import React, { useState, useEffect, useCallback, useMemo } from "react"; import { ComponentRendererProps } from "@/types/component"; import { SplitPanelLayout2Config, ColumnConfig, DataTransferField, ActionButtonConfig, JoinTableConfig, GroupingConfig, ColumnDisplayConfig, TabConfig } from "./types"; import { Badge } from "@/components/ui/badge"; 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 [deleteTargetPanel, setDeleteTargetPanel] = useState<"left" | "right">("right"); // 탭 상태 (좌측/우측 각각) const [leftActiveTab, setLeftActiveTab] = useState(null); const [rightActiveTab, setRightActiveTab] = useState(null); // 프론트엔드 그룹핑 함수 const groupData = useCallback( (data: Record[], groupingConfig: GroupingConfig, columns: ColumnConfig[]): Record[] => { if (!groupingConfig.enabled || !groupingConfig.groupByColumn) { return data; } const groupByColumn = groupingConfig.groupByColumn; const groupMap = new Map>(); // 데이터를 그룹별로 수집 data.forEach((item) => { const groupKey = String(item[groupByColumn] ?? ""); if (!groupMap.has(groupKey)) { // 첫 번째 항목을 기준으로 그룹 초기화 const groupedItem: Record = { ...item }; // 각 컬럼의 displayConfig 확인하여 집계 준비 columns.forEach((col) => { if (col.displayConfig?.aggregate?.enabled) { // 집계가 활성화된 컬럼은 배열로 초기화 groupedItem[`__agg_${col.name}`] = [item[col.name]]; } }); groupMap.set(groupKey, groupedItem); } else { // 기존 그룹에 값 추가 const existingGroup = groupMap.get(groupKey)!; columns.forEach((col) => { if (col.displayConfig?.aggregate?.enabled) { const aggKey = `__agg_${col.name}`; if (!existingGroup[aggKey]) { existingGroup[aggKey] = []; } existingGroup[aggKey].push(item[col.name]); } }); } }); // 집계 처리 및 결과 변환 const result: Record[] = []; groupMap.forEach((groupedItem) => { columns.forEach((col) => { if (col.displayConfig?.aggregate?.enabled) { const aggKey = `__agg_${col.name}`; const values = groupedItem[aggKey] || []; if (col.displayConfig.aggregate.function === "DISTINCT") { // 중복 제거 후 배열로 저장 const uniqueValues = [...new Set(values.filter((v: any) => v !== null && v !== undefined))]; groupedItem[col.name] = uniqueValues; } else if (col.displayConfig.aggregate.function === "COUNT") { // 개수를 숫자로 저장 groupedItem[col.name] = values.filter((v: any) => v !== null && v !== undefined).length; } // 임시 집계 키 제거 delete groupedItem[aggKey]; } }); result.push(groupedItem); }); console.log(`[SplitPanelLayout2] 그룹핑 완료: ${data.length}건 → ${result.length}개 그룹`); return result; }, [], ); // 탭 목록 생성 함수 (데이터에서 고유값 추출) const generateTabs = useCallback( (data: Record[], tabConfig: TabConfig | undefined): { id: string; label: string; count: number }[] => { if (!tabConfig?.enabled || !tabConfig.tabSourceColumn) { return []; } const sourceColumn = tabConfig.tabSourceColumn; // 데이터에서 고유값 추출 및 개수 카운트 const valueCount = new Map(); data.forEach((item) => { const value = String(item[sourceColumn] ?? ""); if (value) { valueCount.set(value, (valueCount.get(value) || 0) + 1); } }); // 탭 목록 생성 const tabs = Array.from(valueCount.entries()).map(([value, count]) => ({ id: value, label: value, count: tabConfig.showCount ? count : 0, })); console.log(`[SplitPanelLayout2] 탭 생성: ${tabs.length}개 탭 (컬럼: ${sourceColumn})`); return tabs; }, [], ); // 탭으로 필터링된 데이터 반환 const filterDataByTab = useCallback( (data: Record[], activeTab: string | null, tabConfig: TabConfig | undefined): Record[] => { if (!tabConfig?.enabled || !activeTab || !tabConfig.tabSourceColumn) { return data; } const sourceColumn = tabConfig.tabSourceColumn; return data.filter((item) => String(item[sourceColumn] ?? "") === activeTab); }, [], ); // 좌측 패널 탭 목록 (메모이제이션) const leftTabs = useMemo(() => { if (!config.leftPanel?.tabConfig?.enabled || !config.leftPanel?.tabConfig?.tabSourceColumn) { return []; } return generateTabs(leftData, config.leftPanel.tabConfig); }, [leftData, config.leftPanel?.tabConfig, generateTabs]); // 우측 패널 탭 목록 (메모이제이션) const rightTabs = useMemo(() => { if (!config.rightPanel?.tabConfig?.enabled || !config.rightPanel?.tabConfig?.tabSourceColumn) { return []; } return generateTabs(rightData, config.rightPanel.tabConfig); }, [rightData, config.rightPanel?.tabConfig, generateTabs]); // 탭 기본값 설정 (탭 목록이 변경되면 기본 탭 선택) useEffect(() => { if (leftTabs.length > 0 && !leftActiveTab) { const defaultTab = config.leftPanel?.tabConfig?.defaultTab; if (defaultTab && leftTabs.some((t) => t.id === defaultTab)) { setLeftActiveTab(defaultTab); } else { setLeftActiveTab(leftTabs[0].id); } } }, [leftTabs, leftActiveTab, config.leftPanel?.tabConfig?.defaultTab]); useEffect(() => { if (rightTabs.length > 0 && !rightActiveTab) { const defaultTab = config.rightPanel?.tabConfig?.defaultTab; if (defaultTab && rightTabs.some((t) => t.id === defaultTab)) { setRightActiveTab(defaultTab); } else { setRightActiveTab(rightTabs[0].id); } } }, [rightTabs, rightActiveTab, config.rightPanel?.tabConfig?.defaultTab]); // 탭 필터링된 데이터 (메모이제이션) const filteredLeftDataByTab = useMemo(() => { return filterDataByTab(leftData, leftActiveTab, config.leftPanel?.tabConfig); }, [leftData, leftActiveTab, config.leftPanel?.tabConfig, filterDataByTab]); const filteredRightDataByTab = useMemo(() => { return filterDataByTab(rightData, rightActiveTab, config.rightPanel?.tabConfig); }, [rightData, rightActiveTab, config.rightPanel?.tabConfig, filterDataByTab]); // 좌측 데이터 로드 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, ); } // 조인 테이블 처리 (좌측 패널) - 인라인 처리 if (config.leftPanel.joinTables && config.leftPanel.joinTables.length > 0) { for (const joinTableConfig of config.leftPanel.joinTables) { if (!joinTableConfig.joinTable || !joinTableConfig.mainColumn || !joinTableConfig.joinColumn) { continue; } // 메인 데이터에서 조인할 키 값들 추출 const joinKeys = [ ...new Set(data.map((item: Record) => item[joinTableConfig.mainColumn]).filter(Boolean)), ]; if (joinKeys.length === 0) continue; try { const joinResponse = await apiClient.post(`/table-management/tables/${joinTableConfig.joinTable}/data`, { page: 1, size: 1000, dataFilter: { enabled: true, matchType: "any", filters: joinKeys.map((key, idx) => ({ id: `join_key_${idx}`, columnName: joinTableConfig.joinColumn, operator: "equals", value: String(key), valueType: "static", })), }, autoFilter: { enabled: true, filterColumn: "company_code", filterType: "company", }, }); if (joinResponse.data.success) { const joinDataArray = joinResponse.data.data?.data || []; const joinDataMap = new Map>(); joinDataArray.forEach((item: Record) => { const key = item[joinTableConfig.joinColumn]; if (key) joinDataMap.set(String(key), item); }); if (joinDataMap.size > 0) { data = data.map((item: Record) => { const joinKey = item[joinTableConfig.mainColumn]; const joinData = joinDataMap.get(String(joinKey)); if (joinData) { const mergedData = { ...item }; joinTableConfig.selectColumns.forEach((col) => { // 테이블.컬럼명 형식으로 저장 mergedData[`${joinTableConfig.joinTable}.${col}`] = joinData[col]; // 컬럼명만으로도 저장 (기존 값이 없을 때) if (!(col in mergedData)) { mergedData[col] = joinData[col]; } }); return mergedData; } return item; }); } console.log(`[SplitPanelLayout2] 좌측 조인 테이블 로드: ${joinTableConfig.joinTable}, ${joinDataArray.length}건`); } } catch (error) { console.error(`[SplitPanelLayout2] 좌측 조인 테이블 로드 실패 (${joinTableConfig.joinTable}):`, error); } } } // 그룹핑 처리 if (config.leftPanel.grouping?.enabled && config.leftPanel.grouping.groupByColumn) { data = groupData(data, config.leftPanel.grouping, config.leftPanel.displayColumns || []); } 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, config.leftPanel?.grouping, config.leftPanel?.displayColumns, config.leftPanel?.joinTables, isDesignMode, groupData]); // 조인 테이블 데이터 로드 (단일 테이블) 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 || !selectedItem) { setRightData([]); return; } // 복합키 또는 단일키 처리 const joinKeys = config.joinConfig?.keys || []; const hasCompositeKeys = joinKeys.length > 0; const hasSingleKey = config.joinConfig?.leftColumn && config.joinConfig?.rightColumn; if (!hasCompositeKeys && !hasSingleKey) { console.log(`[SplitPanelLayout2] 조인 설정이 없음`); setRightData([]); return; } // 필터 배열 생성 const filters: any[] = []; if (hasCompositeKeys) { // 복합키 처리 for (let i = 0; i < joinKeys.length; i++) { const key = joinKeys[i]; const joinValue = selectedItem[key.leftColumn]; if (joinValue === undefined || joinValue === null) { console.log(`[SplitPanelLayout2] 복합키 조인 값이 없음: ${key.leftColumn}`); setRightData([]); return; } filters.push({ id: `join_filter_${i}`, columnName: key.rightColumn, operator: "equals", value: String(joinValue), valueType: "static", }); } console.log( `[SplitPanelLayout2] 복합키 조인: ${joinKeys.map((k) => `${k.leftColumn}→${k.rightColumn}`).join(", ")}`, ); } else { // 단일키 처리 (하위 호환성) const joinValue = selectedItem[config.joinConfig!.leftColumn!]; if (joinValue === undefined || joinValue === null) { console.log(`[SplitPanelLayout2] 조인 값이 없음: ${config.joinConfig!.leftColumn}`); setRightData([]); return; } filters.push({ id: "join_filter", columnName: config.joinConfig!.rightColumn, operator: "equals", value: String(joinValue), valueType: "static", }); } setRightLoading(true); try { console.log( `[SplitPanelLayout2] 우측 데이터 로드 시작: ${config.rightPanel.tableName}, 필터 ${filters.length}개`, ); const response = await apiClient.post(`/table-management/tables/${config.rightPanel.tableName}/data`, { page: 1, size: 1000, // 전체 데이터 로드 // dataFilter를 사용하여 정확한 값 매칭 (Entity 타입 검색 문제 회피) dataFilter: { enabled: true, matchType: "all", filters, }, // 멀티테넌시: 자동으로 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]; } } } // 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); }, [ config.rightPanel?.addModalScreenId, config.rightPanel?.addButtonLabel, config.dataTransferFields, selectedLeftItem, loadRightData, ]); // 기본키 컬럼명 가져오기 (우측 패널) const getPrimaryKeyColumn = useCallback(() => { return config.rightPanel?.primaryKeyColumn || "id"; }, [config.rightPanel?.primaryKeyColumn]); // 기본키 컬럼명 가져오기 (좌측 패널) const getLeftPrimaryKeyColumn = useCallback(() => { return config.leftPanel?.primaryKeyColumn || config.leftPanel?.hierarchyConfig?.idColumn || "id"; }, [config.leftPanel?.primaryKeyColumn, config.leftPanel?.hierarchyConfig?.idColumn]); // 우측 패널 수정 버튼 클릭 const handleEditItem = useCallback( async (item: any) => { // 수정용 모달 화면 ID 결정 (editModalScreenId 우선, 없으면 addModalScreenId 사용) const modalScreenId = config.rightPanel?.editModalScreenId || config.rightPanel?.addModalScreenId; if (!modalScreenId) { toast.error("연결된 모달 화면이 없습니다."); return; } // 메인 테이블 데이터 조회 (우측 패널이 서브 테이블인 경우) let editData = { ...item }; // 연결 설정이 있고, 메인 테이블이 설정되어 있으면 메인 테이블 데이터도 조회 if (config.rightPanel?.mainTableForEdit) { const { tableName, linkColumn } = config.rightPanel.mainTableForEdit; const linkValue = item[linkColumn?.subColumn || ""]; if (tableName && linkValue) { try { const response = await apiClient.get(`/table-management/tables/${tableName}/data`, { params: { filters: JSON.stringify({ [linkColumn?.mainColumn || linkColumn?.subColumn || ""]: linkValue }), page: 1, pageSize: 1, }, }); if (response.data?.success && response.data?.data?.items?.[0]) { // 메인 테이블 데이터를 editData에 병합 (서브 테이블 데이터 우선) editData = { ...response.data.data.items[0], ...item }; console.log("[SplitPanelLayout2] 메인 테이블 데이터 병합:", editData); } } catch (error) { console.error("[SplitPanelLayout2] 메인 테이블 데이터 조회 실패:", error); } } } // EditModal 열기 이벤트 발생 (수정 모드) const event = new CustomEvent("openEditModal", { detail: { screenId: modalScreenId, title: "수정", modalSize: "lg", editData: editData, // 병합된 데이터 전달 isCreateMode: false, // 수정 모드 onSave: () => { if (selectedLeftItem) { loadRightData(selectedLeftItem); } }, }, }); window.dispatchEvent(event); console.log("[SplitPanelLayout2] 우측 수정 모달 열기:", editData); }, [config.rightPanel?.editModalScreenId, config.rightPanel?.addModalScreenId, config.rightPanel?.mainTableForEdit, selectedLeftItem, loadRightData], ); // 좌측 패널 수정 버튼 클릭 const handleLeftEditItem = useCallback( (item: any) => { // 수정용 모달 화면 ID 결정 (editModalScreenId 우선, 없으면 addModalScreenId 사용) const modalScreenId = config.leftPanel?.editModalScreenId || config.leftPanel?.addModalScreenId; if (!modalScreenId) { toast.error("연결된 모달 화면이 없습니다."); return; } // EditModal 열기 이벤트 발생 (수정 모드) const event = new CustomEvent("openEditModal", { detail: { screenId: modalScreenId, title: "수정", modalSize: "lg", editData: item, // 기존 데이터 전달 isCreateMode: false, // 수정 모드 onSave: () => { loadLeftData(); }, }, }); window.dispatchEvent(event); console.log("[SplitPanelLayout2] 좌측 수정 모달 열기:", item); }, [config.leftPanel?.editModalScreenId, config.leftPanel?.addModalScreenId, loadLeftData], ); // 우측 패널 삭제 버튼 클릭 (확인 다이얼로그 표시) const handleDeleteClick = useCallback((item: any) => { setItemToDelete(item); setIsBulkDelete(false); setDeleteTargetPanel("right"); setDeleteDialogOpen(true); }, []); // 좌측 패널 삭제 버튼 클릭 (확인 다이얼로그 표시) const handleLeftDeleteClick = useCallback((item: any) => { setItemToDelete(item); setIsBulkDelete(false); setDeleteTargetPanel("left"); setDeleteDialogOpen(true); }, []); // 일괄 삭제 버튼 클릭 (확인 다이얼로그 표시) const handleBulkDeleteClick = useCallback(() => { if (selectedRightItems.size === 0) { toast.error("삭제할 항목을 선택해주세요."); return; } setIsBulkDelete(true); setDeleteTargetPanel("right"); setDeleteDialogOpen(true); }, [selectedRightItems.size]); // 실제 삭제 실행 const executeDelete = useCallback(async () => { // 대상 패널에 따라 테이블명과 기본키 컬럼 결정 const tableName = deleteTargetPanel === "left" ? config.leftPanel?.tableName : config.rightPanel?.tableName; const pkColumn = deleteTargetPanel === "left" ? getLeftPrimaryKeyColumn() : getPrimaryKeyColumn(); if (!tableName) { toast.error("테이블 설정이 없습니다."); return; } try { if (isBulkDelete) { // 일괄 삭제 - 선택된 항목들의 데이터를 body로 전달 const itemsToDelete = rightData.filter((item) => selectedRightItems.has(item[pkColumn] as string | number)); console.log("[SplitPanelLayout2] 일괄 삭제:", itemsToDelete); // 백엔드 API는 body로 삭제할 데이터를 받음 await apiClient.delete(`/table-management/tables/${tableName}/delete`, { data: itemsToDelete, }); toast.success(`${itemsToDelete.length}개 항목이 삭제되었습니다.`); setSelectedRightItems(new Set()); } else if (itemToDelete) { // 단일 삭제 - 해당 항목 데이터를 배열로 감싸서 body로 전달 (백엔드가 배열을 기대함) console.log(`[SplitPanelLayout2] ${deleteTargetPanel === "left" ? "좌측" : "우측"} 단일 삭제:`, itemToDelete); await apiClient.delete(`/table-management/tables/${tableName}/delete`, { data: [itemToDelete], }); toast.success("항목이 삭제되었습니다."); } // 데이터 새로고침 if (deleteTargetPanel === "left") { loadLeftData(); setSelectedLeftItem(null); // 좌측 선택 초기화 setRightData([]); // 우측 데이터도 초기화 } else if (selectedLeftItem) { loadRightData(selectedLeftItem); } } catch (error: any) { console.error("[SplitPanelLayout2] 삭제 실패:", error); toast.error(`삭제 실패: ${error.message}`); } finally { setDeleteDialogOpen(false); setItemToDelete(null); setIsBulkDelete(false); } }, [ deleteTargetPanel, config.leftPanel?.tableName, config.rightPanel?.tableName, getLeftPrimaryKeyColumn, getPrimaryKeyColumn, isBulkDelete, selectedRightItems, itemToDelete, selectedLeftItem, loadLeftData, loadRightData, rightData, ]); // 개별 체크박스 선택/해제 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) { // 액션 버튼에 모달 화면이 설정되어 있으면 해당 화면 사용 const modalScreenId = btn.modalScreenId || config.rightPanel?.editModalScreenId || config.rightPanel?.addModalScreenId; if (!modalScreenId) { toast.error("연결된 모달 화면이 없습니다."); return; } const event = new CustomEvent("openEditModal", { detail: { screenId: modalScreenId, title: btn.label || "수정", modalSize: "lg", editData: item, isCreateMode: false, onSave: () => { if (selectedLeftItem) { loadRightData(selectedLeftItem); } }, }, }); window.dispatchEvent(event); } } 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 handleLeftActionButton = useCallback( (btn: ActionButtonConfig) => { switch (btn.action) { case "add": // 액션 버튼에 설정된 modalScreenId 우선 사용 const modalScreenId = btn.modalScreenId || config.leftPanel?.addModalScreenId; if (!modalScreenId) { toast.error("연결된 모달 화면이 없습니다."); return; } // EditModal 열기 이벤트 발생 const event = new CustomEvent("openEditModal", { detail: { screenId: modalScreenId, title: btn.label || "추가", modalSize: "lg", editData: {}, isCreateMode: true, onSave: () => { loadLeftData(); }, }, }); window.dispatchEvent(event); console.log("[SplitPanelLayout2] 좌측 액션 버튼 모달 열기:", modalScreenId); break; case "edit": // 좌측 패널에서 수정 (필요시 구현) console.log("[SplitPanelLayout2] 좌측 수정 액션:", btn); break; case "delete": // 좌측 패널에서 삭제 (필요시 구현) console.log("[SplitPanelLayout2] 좌측 삭제 액션:", btn); break; case "custom": console.log("[SplitPanelLayout2] 좌측 커스텀 액션:", btn); break; default: break; } }, [config.leftPanel?.addModalScreenId, loadLeftData], ); // 컬럼 라벨 로드 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(() => { // 1. 먼저 탭 필터링 적용 const data = filteredLeftDataByTab; // 2. 검색어가 없으면 탭 필터링된 데이터 반환 if (!leftSearchTerm) return data; // 복수 검색 컬럼 지원 (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 data; 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([...data]); }, [filteredLeftDataByTab, leftSearchTerm, config.leftPanel?.searchColumns, config.leftPanel?.searchColumn]); const filteredRightData = useMemo(() => { // 1. 먼저 탭 필터링 적용 const data = filteredRightDataByTab; // 2. 검색어가 없으면 탭 필터링된 데이터 반환 if (!rightSearchTerm) return data; // 복수 검색 컬럼 지원 (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 data; return data.filter((item) => { // 여러 컬럼 중 하나라도 매칭되면 포함 return columnsToSearch.some((col) => { const value = String(item[col] || "").toLowerCase(); return value.includes(rightSearchTerm.toLowerCase()); }); }); }, [filteredRightDataByTab, 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] as string | number)); 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; // 기본 값 가져오기 let baseValue: any; // sourceTable이 설정되어 있고, 메인 테이블이 아닌 경우 if (effectiveSourceTable && effectiveSourceTable !== config.rightPanel?.tableName) { // 1. 테이블명.컬럼명 형식으로 먼저 시도 (mergeJoinData에서 저장한 형식) const tableColumnKey = `${effectiveSourceTable}.${actualColName}`; if (item[tableColumnKey] !== undefined) { baseValue = item[tableColumnKey]; } else { // 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) { baseValue = item[aliasKey]; } } // 3. 그냥 컬럼명으로 시도 (메인 테이블에 없는 경우 조인 데이터가 직접 들어감) if (baseValue === undefined && item[actualColName] !== undefined) { baseValue = item[actualColName]; } } } else { // 4. 기본: 컬럼명으로 직접 접근 baseValue = item[actualColName]; } // 엔티티 참조 설정이 있는 경우 - 선택된 컬럼들의 값을 결합 if (col.entityReference?.displayColumns && col.entityReference.displayColumns.length > 0) { // 엔티티 참조 컬럼들의 값을 수집 // 백엔드에서 entity 조인을 통해 "컬럼명_참조테이블컬럼" 형태로 데이터가 들어옴 const entityValues: string[] = []; for (const displayCol of col.entityReference.displayColumns) { // 다양한 형식으로 값을 찾아봄 // 1. 직접 컬럼명 (entity 조인 결과) if (item[displayCol] !== undefined && item[displayCol] !== null) { entityValues.push(String(item[displayCol])); } // 2. 컬럼명_참조컬럼 형식 else if (item[`${actualColName}_${displayCol}`] !== undefined) { entityValues.push(String(item[`${actualColName}_${displayCol}`])); } // 3. 참조테이블.컬럼 형식 else if (col.entityReference.entityId) { const refTableCol = `${col.entityReference.entityId}.${displayCol}`; if (item[refTableCol] !== undefined && item[refTableCol] !== null) { entityValues.push(String(item[refTableCol])); } } } // 엔티티 값들이 있으면 결합하여 반환 if (entityValues.length > 0) { return entityValues.join(" - "); } } return baseValue; }, [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) => { // ID 컬럼 결정: 설정값 > 데이터에 존재하는 일반적인 ID 컬럼 > 폴백 const configIdColumn = config.leftPanel?.hierarchyConfig?.idColumn; const idColumn = configIdColumn || (item["id"] !== undefined ? "id" : item["dept_code"] !== undefined ? "dept_code" : item["code"] !== undefined ? "code" : "id"); const itemId = item[idColumn] ?? `item-${level}-${index}`; const hasChildren = item.children?.length > 0; const isExpanded = expandedItems.has(String(itemId)); // 선택 상태 확인: 동일한 객체이거나 idColumn 값이 일치해야 함 const isSelected = selectedLeftItem && ( selectedLeftItem === item || (item[idColumn] !== undefined && selectedLeftItem[idColumn] !== undefined && 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 === null || value === undefined) return null; // 배지 타입이고 배열인 경우 if (col.displayConfig?.displayType === "badge" && Array.isArray(value)) { return (
{value.map((v, vIdx) => ( {formatValue(v, col.format)} ))}
); } // 배지 타입이지만 단일 값인 경우 if (col.displayConfig?.displayType === "badge") { return ( {formatValue(value, col.format)} ); } // 기본 텍스트 스타일 return ( {formatValue(value, col.format)} ); })}
{/* 정보 행 (Info Row) */} {infoRowColumns.length > 0 && (
{infoRowColumns .map((col, idx) => { const value = item[col.name]; if (value === null || value === undefined) return null; // 배지 타입이고 배열인 경우 if (col.displayConfig?.displayType === "badge" && Array.isArray(value)) { return (
{value.map((v, vIdx) => ( {formatValue(v, col.format)} ))}
); } // 배지 타입이지만 단일 값인 경우 if (col.displayConfig?.displayType === "badge") { return ( {formatValue(value, col.format)} ); } // 기본 텍스트 return {formatValue(value, col.format)}; }) .filter(Boolean) .reduce((acc: React.ReactNode[], curr, idx) => { if (idx > 0 && !React.isValidElement(curr)) acc.push( | , ); acc.push(curr); return acc; }, [])}
)}
{/* 좌측 패널 수정/삭제 버튼 */} {(config.leftPanel?.showEditButton || config.leftPanel?.showDeleteButton) && (
e.stopPropagation()}> {config.leftPanel?.showEditButton && ( )} {config.leftPanel?.showDeleteButton && ( )}
)}
{/* 자식 항목 */} {hasChildren && isExpanded && (
{item.children.map((child: any, childIndex: number) => renderLeftItem(child, level + 1, childIndex))}
)}
); }; // 왼쪽 패널 테이블 렌더링 const renderLeftTable = () => { const displayColumns = config.leftPanel?.displayColumns || []; const pkColumn = getLeftPrimaryKeyColumn(); // 값 렌더링 (배지 지원) const renderCellValue = (item: any, col: ColumnConfig) => { const value = item[col.name]; if (value === null || value === undefined) return "-"; // 배지 타입이고 배열인 경우 if (col.displayConfig?.displayType === "badge" && Array.isArray(value)) { return (
{value.map((v, vIdx) => ( {formatValue(v, col.format)} ))}
); } // 배지 타입이지만 단일 값인 경우 if (col.displayConfig?.displayType === "badge") { return ( {formatValue(value, col.format)} ); } // 기본 텍스트 return formatValue(value, col.format); }; return (
{displayColumns.map((col, idx) => ( {col.label || col.name} ))} {filteredLeftData.length === 0 ? ( 데이터가 없습니다 ) : ( filteredLeftData.map((item, index) => { const itemId = item[pkColumn]; const isItemSelected = selectedLeftItem && (selectedLeftItem === item || (item[pkColumn] !== undefined && selectedLeftItem[pkColumn] !== undefined && selectedLeftItem[pkColumn] === item[pkColumn])); return ( handleLeftItemSelect(item)} > {displayColumns.map((col, colIdx) => ( {renderCellValue(item, col)} ))} ); }) )}
); }; // 우측 패널 카드 렌더링 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] as string | number)); const someSelected = filteredRightData.some((item) => selectedRightItems.has(item[pkColumn] as string | number)); 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] as string | number; 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) { const leftButtons = config.leftPanel?.actionButtons || []; const rightButtons = config.rightPanel?.actionButtons || []; const leftDisplayColumns = config.leftPanel?.displayColumns || []; const rightDisplayColumns = config.rightPanel?.displayColumns || []; return (
{/* 좌측 패널 미리보기 */}
{/* 헤더 */}
{config.leftPanel?.title || "좌측 패널"}
{config.leftPanel?.tableName || "테이블 미설정"}
{leftButtons.length > 0 && (
{leftButtons.slice(0, 2).map((btn) => (
{btn.label}
))} {leftButtons.length > 2 && (
+{leftButtons.length - 2}
)}
)}
{/* 검색 표시 */} {config.leftPanel?.showSearch && (
검색
)} {/* 컬럼 미리보기 */}
{leftDisplayColumns.length > 0 ? (
{/* 샘플 카드 */} {[1, 2, 3].map((i) => (
{leftDisplayColumns .filter((col) => col.displayRow === "name" || !col.displayRow) .slice(0, 2) .map((col, idx) => (
{col.label || col.name}
))}
{leftDisplayColumns.filter((col) => col.displayRow === "info").length > 0 && (
{leftDisplayColumns .filter((col) => col.displayRow === "info") .slice(0, 3) .map((col) => ( {col.label || col.name} ))}
)}
))}
) : (
컬럼 미설정
)}
{/* 우측 패널 미리보기 */}
{/* 헤더 */}
{config.rightPanel?.title || "우측 패널"}
{config.rightPanel?.tableName || "테이블 미설정"}
{rightButtons.length > 0 && (
{rightButtons.slice(0, 2).map((btn) => (
{btn.label}
))} {rightButtons.length > 2 && (
+{rightButtons.length - 2}
)}
)}
{/* 검색 표시 */} {config.rightPanel?.showSearch && (
검색
)} {/* 컬럼 미리보기 */}
{rightDisplayColumns.length > 0 ? ( config.rightPanel?.displayMode === "table" ? ( // 테이블 모드 미리보기
{config.rightPanel?.showCheckbox && (
)} {rightDisplayColumns.slice(0, 4).map((col) => (
{col.label || col.name}
))}
{[1, 2, 3].map((i) => (
{config.rightPanel?.showCheckbox && (
)} {rightDisplayColumns.slice(0, 4).map((col) => (
---
))}
))}
) : ( // 카드 모드 미리보기
{[1, 2].map((i) => (
{rightDisplayColumns .filter((col) => col.displayRow === "name" || !col.displayRow) .slice(0, 2) .map((col, idx) => (
{col.label || col.name}
))}
{rightDisplayColumns.filter((col) => col.displayRow === "info").length > 0 && (
{rightDisplayColumns .filter((col) => col.displayRow === "info") .slice(0, 3) .map((col) => ( {col.label || col.name} ))}
)}
))}
) ) : (
컬럼 미설정
)}
{/* 연결 설정 표시 */} {(config.joinConfig?.leftColumn || config.joinConfig?.keys?.length) && (
연결: {config.joinConfig?.leftColumn || config.joinConfig?.keys?.[0]?.leftColumn} →{" "} {config.joinConfig?.rightColumn || config.joinConfig?.keys?.[0]?.rightColumn}
)}
); } return (
{/* 좌측 패널 */}
{/* 헤더 */}

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

{/* actionButtons 배열이 정의되어 있으면(빈 배열 포함) 해당 배열 사용, undefined면 기존 showAddButton 사용 (하위호환) */} {config.leftPanel?.actionButtons !== undefined ? ( // 새로운 actionButtons 배열 기반 렌더링 (빈 배열이면 버튼 없음) config.leftPanel.actionButtons.length > 0 && (
{config.leftPanel.actionButtons.map((btn, idx) => ( ))}
) ) : config.leftPanel?.showAddButton ? ( // 기존 showAddButton 기반 렌더링 (하위호환 - actionButtons가 undefined일 때만) ) : null}
{/* 검색 */} {config.leftPanel?.showSearch && (
setLeftSearchTerm(e.target.value)} className="h-9 pl-9 text-sm" />
)}
{/* 좌측 패널 탭 */} {config.leftPanel?.tabConfig?.enabled && leftTabs.length > 0 && (
{leftTabs.map((tab) => ( ))}
)} {/* 목록 */}
{leftLoading ? (
로딩 중...
) : (config.leftPanel?.displayMode || "card") === "table" ? ( // 테이블 모드 renderLeftTable() ) : 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 배열이 정의되어 있으면(빈 배열 포함) 해당 배열 사용, undefined면 기존 showAddButton 사용 (하위호환) */} {selectedLeftItem && ( config.rightPanel?.actionButtons !== undefined ? ( // 새로운 actionButtons 배열 기반 렌더링 (빈 배열이면 버튼 없음) config.rightPanel.actionButtons.length > 0 && renderActionButtons() ) : config.rightPanel?.showAddButton ? ( // 기존 showAddButton 기반 렌더링 (하위호환 - actionButtons가 undefined일 때만) ) : null )}
{/* 검색 */} {config.rightPanel?.showSearch && selectedLeftItem && (
setRightSearchTerm(e.target.value)} className="h-9 pl-9 text-sm" />
)}
{/* 우측 패널 탭 */} {config.rightPanel?.tabConfig?.enabled && rightTabs.length > 0 && selectedLeftItem && (
{rightTabs.map((tab) => ( ))}
)} {/* 내용 */}
{!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 ; };