Files
vexplor/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx
kmh cc61ef3ff4 feat: enhance category mapping and label resolution in split panel layouts
- Added functionality to resolve unresolved category labels after data loading in SplitPanelLayout2Component.
- Implemented batch API calls to fetch missing category labels based on unresolved codes.
- Improved category mapping logic in SplitPanelLayoutComponent to handle join tables and provide fallback mappings.
- Enhanced the user experience by ensuring that category labels are correctly displayed even when they are initially unresolved.

These changes aim to improve the robustness of category handling across the split panel components.
2026-03-12 07:54:12 +09:00

2544 lines
98 KiB
TypeScript

"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";
import { getCategoryValues, getCategoryLabelsByCodes } from "@/lib/api/tableCategoryValue";
export interface SplitPanelLayout2ComponentProps extends ComponentRendererProps {
// 추가 props
}
/**
* SplitPanelLayout2 컴포넌트
* 마스터-디테일 패턴의 좌우 분할 레이아웃 (개선 버전)
*/
export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProps> = ({
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<any[]>([]);
const [rightData, setRightData] = useState<any[]>([]);
const [selectedLeftItem, setSelectedLeftItem] = useState<any>(null);
const [leftSearchTerm, setLeftSearchTerm] = useState("");
const [rightSearchTerm, setRightSearchTerm] = useState("");
const [leftLoading, setLeftLoading] = useState(false);
const [rightLoading, setRightLoading] = useState(false);
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
const [splitPosition, setSplitPosition] = useState(config.splitRatio || 30);
const [isResizing, setIsResizing] = useState(false);
// 좌측 패널 컬럼 라벨 매핑
const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({});
const [rightColumnLabels, setRightColumnLabels] = useState<Record<string, string>>({});
// 우측 패널 선택 상태 (체크박스용)
const [selectedRightItems, setSelectedRightItems] = useState<Set<string | number>>(new Set());
// 삭제 확인 다이얼로그 상태
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [itemToDelete, setItemToDelete] = useState<any>(null);
const [isBulkDelete, setIsBulkDelete] = useState(false);
const [deleteTargetPanel, setDeleteTargetPanel] = useState<"left" | "right">("right");
// 탭 상태 (좌측/우측 각각)
const [leftActiveTab, setLeftActiveTab] = useState<string | null>(null);
const [rightActiveTab, setRightActiveTab] = useState<string | null>(null);
// 카테고리 코드→라벨 매핑
const [categoryLabelMap, setCategoryLabelMap] = useState<Record<string, string>>({});
// 프론트엔드 그룹핑 함수
const groupData = useCallback(
(data: Record<string, any>[], groupingConfig: GroupingConfig, columns: ColumnConfig[]): Record<string, any>[] => {
if (!groupingConfig.enabled || !groupingConfig.groupByColumn) {
return data;
}
const groupByColumn = groupingConfig.groupByColumn;
const groupMap = new Map<string, Record<string, any>>();
// 데이터를 그룹별로 수집
data.forEach((item) => {
const groupKey = String(item[groupByColumn] ?? "");
if (!groupMap.has(groupKey)) {
// 첫 번째 항목을 기준으로 그룹 초기화
const groupedItem: Record<string, any> = { ...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<string, any>[] = [];
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<string, unknown>[], tabConfig: TabConfig | undefined): { id: string; label: string; count: number }[] => {
if (!tabConfig?.enabled || !tabConfig.tabSourceColumn) {
return [];
}
const sourceColumn = tabConfig.tabSourceColumn;
// 데이터에서 고유값 추출 및 개수 카운트
const valueCount = new Map<string, number>();
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: categoryLabelMap[value] || value,
count: tabConfig.showCount ? count : 0,
}));
console.log(`[SplitPanelLayout2] 탭 생성: ${tabs.length}개 탭 (컬럼: ${sourceColumn})`);
return tabs;
},
[categoryLabelMap],
);
// 탭으로 필터링된 데이터 반환
const filterDataByTab = useCallback(
(data: Record<string, unknown>[], activeTab: string | null, tabConfig: TabConfig | undefined): Record<string, unknown>[] => {
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<string, unknown>) => 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<string, Record<string, unknown>>();
joinDataArray.forEach((item: Record<string, unknown>) => {
const key = item[joinTableConfig.joinColumn];
if (key) joinDataMap.set(String(key), item);
});
if (joinDataMap.size > 0) {
data = data.map((item: Record<string, unknown>) => {
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<Map<string, any>> => {
const resultMap = new Map<string, any>();
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<string, any>): 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<string, any> = {};
if (selectedLeftItem && config.dataTransferFields) {
for (const field of config.dataTransferFields) {
if (field.sourceColumn && field.targetColumn) {
initialData[field.targetColumn] = selectedLeftItem[field.sourceColumn];
}
}
}
// 메인 레코드 ID 전달 (분할패널에서는 좌측 선택 항목이 메인 레코드)
const leftPkColumn = config.leftPanel?.primaryKeyColumn || "id";
if (selectedLeftItem?.[leftPkColumn]) {
initialData._mainRecordId = selectedLeftItem[leftPkColumn];
}
// 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);
}
loadLeftData();
},
},
});
window.dispatchEvent(event);
}, [
config.rightPanel?.addModalScreenId,
config.rightPanel?.addButtonLabel,
config.leftPanel?.primaryKeyColumn,
config.dataTransferFields,
selectedLeftItem,
loadRightData,
loadLeftData,
]);
// 기본키 컬럼명 가져오기 (우측 패널)
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);
}
}
}
// 메인 레코드 ID 전달 (디테일 레코드의 id와 구분)
const editItemLeftPkColumn = config.leftPanel?.primaryKeyColumn || "id";
if (selectedLeftItem?.[editItemLeftPkColumn]) {
editData._mainRecordId = selectedLeftItem[editItemLeftPkColumn];
}
// EditModal 열기 이벤트 발생 (수정 모드)
const event = new CustomEvent("openEditModal", {
detail: {
screenId: modalScreenId,
title: "수정",
modalSize: "lg",
editData: editData,
isCreateMode: false,
onSave: () => {
if (selectedLeftItem) {
loadRightData(selectedLeftItem);
}
loadLeftData();
},
},
});
window.dispatchEvent(event);
console.log("[SplitPanelLayout2] 우측 수정 모달 열기:", editData);
},
[config.rightPanel?.editModalScreenId, config.rightPanel?.addModalScreenId, config.rightPanel?.mainTableForEdit, config.leftPanel?.primaryKeyColumn, selectedLeftItem, loadRightData, loadLeftData],
);
// 좌측 패널 수정 버튼 클릭
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<string | number>());
} 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);
loadLeftData();
}
} 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<string, any> = {};
if (selectedLeftItem && config.dataTransferFields) {
for (const field of config.dataTransferFields) {
if (field.sourceColumn && field.targetColumn) {
initialData[field.targetColumn] = selectedLeftItem[field.sourceColumn];
}
}
}
// 메인 레코드 ID 전달 (분할패널에서는 좌측 선택 항목이 메인 레코드)
const addLeftPkColumn = config.leftPanel?.primaryKeyColumn || "id";
if (selectedLeftItem?.[addLeftPkColumn]) {
initialData._mainRecordId = selectedLeftItem[addLeftPkColumn];
}
const event = new CustomEvent("openEditModal", {
detail: {
screenId: btn.modalScreenId,
title: btn.label || "추가",
modalSize: "lg",
editData: initialData,
isCreateMode: true,
onSave: () => {
if (selectedLeftItem) {
loadRightData(selectedLeftItem);
}
loadLeftData();
},
},
});
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;
}
// 메인 레코드 ID 전달 (디테일 레코드의 id와 구분)
const editLeftPkColumn = config.leftPanel?.primaryKeyColumn || "id";
const editData = { ...item };
if (selectedLeftItem?.[editLeftPkColumn]) {
editData._mainRecordId = selectedLeftItem[editLeftPkColumn];
}
const event = new CustomEvent("openEditModal", {
detail: {
screenId: modalScreenId,
title: btn.label || "수정",
modalSize: "lg",
editData,
isCreateMode: false,
onSave: () => {
if (selectedLeftItem) {
loadRightData(selectedLeftItem);
}
loadLeftData();
},
},
});
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,
loadLeftData,
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": {
if (!selectedLeftItem) {
toast.error("수정할 항목을 선택해주세요.");
return;
}
const editModalScreenId = btn.modalScreenId || config.leftPanel?.editModalScreenId || config.leftPanel?.addModalScreenId;
if (!editModalScreenId) {
toast.error("연결된 모달 화면이 없습니다.");
return;
}
const editEvent = new CustomEvent("openEditModal", {
detail: {
screenId: editModalScreenId,
title: btn.label || "수정",
modalSize: "lg",
editData: selectedLeftItem,
isCreateMode: false,
onSave: () => {
loadLeftData();
if (selectedLeftItem) {
loadRightData(selectedLeftItem);
}
},
},
});
window.dispatchEvent(editEvent);
console.log("[SplitPanelLayout2] 좌측 수정 모달 열기:", selectedLeftItem);
break;
}
case "delete":
// 좌측 패널에서 삭제 (필요시 구현)
console.log("[SplitPanelLayout2] 좌측 삭제 액션:", btn);
break;
case "custom":
console.log("[SplitPanelLayout2] 좌측 커스텀 액션:", btn);
break;
default:
break;
}
},
[config.leftPanel?.addModalScreenId, config.leftPanel?.editModalScreenId, loadLeftData, loadRightData, selectedLeftItem],
);
// 컬럼 라벨 로드
const loadColumnLabels = useCallback(
async (tableName: string, setLabels: (labels: Record<string, string>) => void) => {
if (!tableName) return;
try {
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
if (response.data.success) {
const labels: Record<string, string> = {};
// 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<string, any>();
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<string | number>(filteredRightData.map((item) => item[pkColumn] as string | number));
setSelectedRightItems(allIds);
} else {
setSelectedRightItems(new Set<string | number>());
}
},
[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,
]);
// 카테고리 컬럼에 대한 라벨 매핑 로드
useEffect(() => {
if (isDesignMode) return;
const loadCategoryLabels = async () => {
const allColumns = new Set<string>();
const tableName = config.leftPanel?.tableName || config.rightPanel?.tableName;
if (!tableName) return;
// 좌우 패널의 표시 컬럼에서 카테고리 후보 수집
for (const col of config.leftPanel?.displayColumns || []) {
allColumns.add(col.name);
}
for (const col of config.rightPanel?.displayColumns || []) {
allColumns.add(col.name);
}
// 탭 소스 컬럼도 추가
if (config.rightPanel?.tabConfig?.tabSourceColumn) {
allColumns.add(config.rightPanel.tabConfig.tabSourceColumn);
}
if (config.leftPanel?.tabConfig?.tabSourceColumn) {
allColumns.add(config.leftPanel.tabConfig.tabSourceColumn);
}
const labelMap: Record<string, string> = {};
for (const columnName of allColumns) {
try {
const result = await getCategoryValues(tableName, columnName);
if (result.success && Array.isArray(result.data) && result.data.length > 0) {
for (const item of result.data) {
if (item.valueCode && item.valueLabel) {
labelMap[item.valueCode] = item.valueLabel;
}
}
}
} catch {
// 카테고리가 아닌 컬럼은 무시
}
}
if (Object.keys(labelMap).length > 0) {
setCategoryLabelMap(labelMap);
}
};
loadCategoryLabels();
}, [isDesignMode, config.leftPanel?.tableName, config.rightPanel?.tableName, config.leftPanel?.displayColumns, config.rightPanel?.displayColumns, config.rightPanel?.tabConfig?.tabSourceColumn, config.leftPanel?.tabConfig?.tabSourceColumn]);
// 데이터 로드 후 미해결 카테고리 코드를 batch API로 변환
useEffect(() => {
if (isDesignMode) return;
const allData = [...leftData, ...rightData];
if (allData.length === 0) return;
const unresolvedCodes = new Set<string>();
const checkValue = (v: unknown) => {
if (typeof v === "string" && (v.startsWith("CAT_") || v.startsWith("CATEGORY_"))) {
if (!categoryLabelMap[v]) unresolvedCodes.add(v);
}
};
for (const item of allData) {
for (const val of Object.values(item)) {
if (Array.isArray(val)) {
val.forEach(checkValue);
} else {
checkValue(val);
}
}
}
if (unresolvedCodes.size === 0) return;
const resolveMissingLabels = async () => {
const result = await getCategoryLabelsByCodes(Array.from(unresolvedCodes));
if (result.success && result.data && Object.keys(result.data).length > 0) {
setCategoryLabelMap((prev) => ({ ...prev, ...result.data }));
}
};
resolveMissingLabels();
}, [isDesignMode, leftData, rightData, categoryLabelMap]);
// 컴포넌트 언마운트 시 DataProvider 해제
useEffect(() => {
return () => {
if (screenContext) {
screenContext.unregisterDataProvider(component.id);
}
};
}, [screenContext, component.id]);
// 카테고리 코드를 라벨로 변환
const resolveCategoryLabel = useCallback(
(value: any): string => {
if (value === null || value === undefined) return "";
const strVal = String(value);
if (categoryLabelMap[strVal]) return categoryLabelMap[strVal];
// 콤마 구분 다중 값 처리
if (strVal.includes(",")) {
const codes = strVal.split(",").map((c) => c.trim()).filter(Boolean);
const labels = codes.map((code) => categoryLabelMap[code] || code);
return labels.join(", ");
}
return strVal;
},
[categoryLabelMap],
);
// 컬럼 값 가져오기 (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 (
<div key={itemId}>
<div
className={cn(
"flex cursor-pointer items-center gap-3 rounded-md px-4 py-3 transition-colors",
"hover:bg-accent",
isSelected && "bg-primary/10 border-primary border-l-2",
)}
style={{ paddingLeft: `${level * 16 + 16}px` }}
onClick={() => handleLeftItemSelect(item)}
>
{/* 확장/축소 버튼 */}
{hasChildren ? (
<button
className="hover:bg-accent rounded p-0.5"
onClick={(e) => {
e.stopPropagation();
toggleExpand(String(itemId));
}}
>
{isExpanded ? (
<ChevronDown className="text-muted-foreground h-4 w-4" />
) : (
<ChevronRight className="text-muted-foreground h-4 w-4" />
)}
</button>
) : (
<div className="w-5" />
)}
{/* 아이콘 */}
<Building2 className="text-muted-foreground h-5 w-5" />
{/* 내용 */}
<div className="min-w-0 flex-1">
{/* 이름 행 (Name Row) */}
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-base font-medium">{primaryValue || "이름 없음"}</span>
{/* 이름 행의 추가 컬럼들 */}
{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 (
<div key={idx} className="flex flex-wrap gap-1">
{value.map((v, vIdx) => (
<Badge key={vIdx} variant="secondary" className="shrink-0 text-xs">
{formatValue(v, col.format)}
</Badge>
))}
</div>
);
}
// 배지 타입이지만 단일 값인 경우
if (col.displayConfig?.displayType === "badge") {
return (
<Badge key={idx} variant="secondary" className="shrink-0 text-xs">
{formatValue(value, col.format)}
</Badge>
);
}
// 기본 텍스트 스타일
return (
<span key={idx} className="bg-muted shrink-0 rounded px-1.5 py-0.5 text-xs">
{formatValue(value, col.format)}
</span>
);
})}
</div>
{/* 정보 행 (Info Row) */}
{infoRowColumns.length > 0 && (
<div className="text-muted-foreground flex flex-wrap items-center gap-2 text-sm">
{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 (
<div key={idx} className="flex flex-wrap gap-1">
{value.map((v, vIdx) => (
<Badge key={vIdx} variant="outline" className="text-xs">
{formatValue(v, col.format)}
</Badge>
))}
</div>
);
}
// 배지 타입이지만 단일 값인 경우
if (col.displayConfig?.displayType === "badge") {
return (
<Badge key={idx} variant="outline" className="text-xs">
{formatValue(value, col.format)}
</Badge>
);
}
// 기본 텍스트
return <span key={idx}>{formatValue(value, col.format)}</span>;
})
.filter(Boolean)
.reduce((acc: React.ReactNode[], curr, idx) => {
if (idx > 0 && !React.isValidElement(curr))
acc.push(
<span key={`sep-${idx}`} className="text-muted-foreground/50">
|
</span>,
);
acc.push(curr);
return acc;
}, [])}
</div>
)}
</div>
{/* 좌측 패널 수정/삭제 버튼 */}
{(config.leftPanel?.showEditButton || config.leftPanel?.showDeleteButton) && (
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
{config.leftPanel?.showEditButton && (
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => handleLeftEditItem(item)}>
<Edit className="h-4 w-4" />
</Button>
)}
{config.leftPanel?.showDeleteButton && (
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive h-8 w-8"
onClick={() => handleLeftDeleteClick(item)}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
)}
</div>
{/* 자식 항목 */}
{hasChildren && isExpanded && (
<div>
{item.children.map((child: any, childIndex: number) => renderLeftItem(child, level + 1, childIndex))}
</div>
)}
</div>
);
};
// 왼쪽 패널 테이블 렌더링
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 (
<div className="flex flex-wrap gap-1">
{value.map((v, vIdx) => (
<Badge key={vIdx} variant="secondary" className="text-xs">
{resolveCategoryLabel(v) || formatValue(v, col.format)}
</Badge>
))}
</div>
);
}
// 배지 타입이지만 단일 값인 경우
if (col.displayConfig?.displayType === "badge") {
const label = resolveCategoryLabel(value);
return (
<Badge variant="secondary" className="text-xs">
{label !== String(value) ? label : formatValue(value, col.format)}
</Badge>
);
}
// 카테고리 라벨 변환 시도 후 기본 텍스트
const label = resolveCategoryLabel(value);
if (label !== String(value)) return label;
return formatValue(value, col.format);
};
return (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
{displayColumns.map((col, idx) => (
<TableHead key={idx} style={{ width: col.width ? `${col.width}px` : "auto" }}>
{col.label || col.name}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{filteredLeftData.length === 0 ? (
<TableRow>
<TableCell colSpan={displayColumns.length} className="text-muted-foreground h-24 text-center">
</TableCell>
</TableRow>
) : (
filteredLeftData.map((item, index) => {
const itemId = item[pkColumn];
const isItemSelected =
selectedLeftItem &&
(selectedLeftItem === item ||
(item[pkColumn] !== undefined &&
selectedLeftItem[pkColumn] !== undefined &&
selectedLeftItem[pkColumn] === item[pkColumn]));
return (
<TableRow
key={itemId ?? index}
className={cn("cursor-pointer hover:bg-muted/50", isItemSelected && "bg-primary/10")}
onClick={() => handleLeftItemSelect(item)}
>
{displayColumns.map((col, colIdx) => (
<TableCell key={colIdx}>{renderCellValue(item, col)}</TableCell>
))}
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
);
};
// 우측 패널 카드 렌더링
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 (
<Card key={index} className="mb-2 py-0 transition-shadow hover:shadow-md">
<CardContent className="px-4 py-2">
<div className="flex items-start gap-3">
{/* 체크박스 */}
{showCheckbox && (
<Checkbox
checked={selectedRightItems.has(itemId)}
onCheckedChange={(checked) => handleSelectItem(itemId, !!checked)}
className="mt-1"
/>
)}
<div className="flex-1">
{/* showLabels가 true이면 라벨: 값 형식으로 가로 배치 */}
{showLabels ? (
<div className="space-y-1">
{/* 이름 행: 라벨: 값, 라벨: 값 형식으로 가로 배치 */}
{nameRowColumns.length > 0 && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
{nameRowColumns.map((col, idx) => {
const value = getColumnValue(item, col);
if (value === null || value === undefined) return null;
return (
<span key={idx} className="flex items-center gap-1">
<span className="text-muted-foreground text-sm">{col.label || col.name}:</span>
<span className="text-sm font-semibold">{formatValue(value, col.format)}</span>
</span>
);
})}
</div>
)}
{/* 정보 행: 라벨: 값, 라벨: 값 형식으로 가로 배치 */}
{infoRowColumns.length > 0 && (
<div className="text-muted-foreground flex flex-wrap items-center gap-x-4 gap-y-1">
{infoRowColumns.map((col, idx) => {
const value = getColumnValue(item, col);
if (value === null || value === undefined) return null;
return (
<span key={idx} className="flex items-center gap-1">
<span className="text-sm">{col.label || col.name}:</span>
<span className="text-sm">{formatValue(value, col.format)}</span>
</span>
);
})}
</div>
)}
</div>
) : (
// showLabels가 false일 때 기존 방식 유지 (라벨 없이 값만)
<div className="space-y-1">
{/* 이름 행 */}
{nameRowColumns.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
{nameRowColumns.map((col, idx) => {
const value = getColumnValue(item, col);
if (value === null || value === undefined) return null;
if (idx === 0) {
return (
<span key={idx} className="text-base font-semibold">
{formatValue(value, col.format)}
</span>
);
}
return (
<span key={idx} className="bg-muted rounded px-2 py-0.5 text-sm">
{formatValue(value, col.format)}
</span>
);
})}
</div>
)}
{/* 정보 행 */}
{infoRowColumns.length > 0 && (
<div className="text-muted-foreground flex flex-wrap items-center gap-x-4 gap-y-1">
{infoRowColumns.map((col, idx) => {
const value = getColumnValue(item, col);
if (value === null || value === undefined) return null;
return (
<span key={idx} className="text-sm">
{formatValue(value, col.format)}
</span>
);
})}
</div>
)}
</div>
)}
</div>
{/* 액션 버튼 (개별 수정/삭제) */}
<div className="flex gap-1">
{config.rightPanel?.showEditButton && (
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => handleEditItem(item)}>
<Edit className="h-4 w-4" />
</Button>
)}
{config.rightPanel?.showDeleteButton && (
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive h-8 w-8"
onClick={() => handleDeleteClick(item)}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</div>
</CardContent>
</Card>
);
};
// 우측 패널 테이블 렌더링
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 (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
{showCheckbox && (
<TableHead className="w-12">
<Checkbox
checked={allSelected}
ref={(el) => {
if (el) {
(el as any).indeterminate = someSelected && !allSelected;
}
}}
onCheckedChange={handleSelectAll}
/>
</TableHead>
)}
{displayColumns.map((col, idx) => (
<TableHead key={idx} style={{ width: col.width ? `${col.width}px` : "auto" }}>
{col.label || col.name}
</TableHead>
))}
{(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && (
<TableHead className="w-24 text-center"></TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{filteredRightData.length === 0 ? (
<TableRow>
<TableCell
colSpan={
displayColumns.length +
(showCheckbox ? 1 : 0) +
(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton ? 1 : 0)
}
className="text-muted-foreground h-24 text-center"
>
</TableCell>
</TableRow>
) : (
filteredRightData.map((item, index) => {
const itemId = item[pkColumn] as string | number;
return (
<TableRow key={index} className="hover:bg-muted/50">
{showCheckbox && (
<TableCell>
<Checkbox
checked={selectedRightItems.has(itemId)}
onCheckedChange={(checked) => handleSelectItem(itemId, !!checked)}
/>
</TableCell>
)}
{displayColumns.map((col, colIdx) => {
const rawVal = getColumnValue(item, col);
const resolved = resolveCategoryLabel(rawVal);
const display = resolved !== String(rawVal ?? "") ? resolved : formatValue(rawVal, col.format);
return <TableCell key={colIdx}>{display || "-"}</TableCell>;
})}
{(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && (
<TableCell className="text-center">
<div className="flex justify-center gap-1">
{config.rightPanel?.showEditButton && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleEditItem(item)}
>
<Edit className="h-3.5 w-3.5" />
</Button>
)}
{config.rightPanel?.showDeleteButton && (
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive h-7 w-7"
onClick={() => handleDeleteClick(item)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</TableCell>
)}
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
);
};
// 액션 버튼 렌더링
const renderActionButtons = () => {
const actionButtons = config.rightPanel?.actionButtons;
if (!actionButtons || actionButtons.length === 0) return null;
return (
<div className="flex gap-2">
{actionButtons.map((btn) => (
<Button
key={btn.id}
variant={btn.variant || "default"}
size="sm"
className="h-8 text-sm"
onClick={() => handleActionButton(btn)}
disabled={
// 일괄 삭제 버튼은 선택된 항목이 없으면 비활성화
(btn.action === "bulk-delete" || btn.action === "delete") && selectedRightItems.size === 0
}
>
{btn.icon === "Plus" && <Plus className="mr-1 h-4 w-4" />}
{btn.icon === "Edit" && <Edit className="mr-1 h-4 w-4" />}
{btn.icon === "Trash2" && <Trash2 className="mr-1 h-4 w-4" />}
{btn.label}
</Button>
))}
</div>
);
};
// 디자인 모드 렌더링
if (isDesignMode) {
const leftButtons = config.leftPanel?.actionButtons || [];
const rightButtons = config.rightPanel?.actionButtons || [];
const leftDisplayColumns = config.leftPanel?.displayColumns || [];
const rightDisplayColumns = config.rightPanel?.displayColumns || [];
return (
<div
className={cn(
"flex h-full w-full rounded-lg border-2 border-dashed",
isSelected ? "border-primary" : "border-muted-foreground/30",
)}
onClick={onClick}
style={{ minHeight: "300px" }}
>
{/* 좌측 패널 미리보기 */}
<div className="bg-muted/20 flex flex-col border-r" style={{ width: `${splitPosition}%` }}>
{/* 헤더 */}
<div className="flex items-center justify-between border-b px-3 py-2">
<div>
<div className="text-sm font-medium">{config.leftPanel?.title || "좌측 패널"}</div>
<div className="text-muted-foreground text-[10px]">
{config.leftPanel?.tableName || "테이블 미설정"}
</div>
</div>
{leftButtons.length > 0 && (
<div className="flex gap-1">
{leftButtons.slice(0, 2).map((btn) => (
<div
key={btn.id}
className="bg-primary/10 text-primary rounded px-2 py-0.5 text-[10px]"
>
{btn.label}
</div>
))}
{leftButtons.length > 2 && (
<div className="text-muted-foreground text-[10px]">+{leftButtons.length - 2}</div>
)}
</div>
)}
</div>
{/* 검색 표시 */}
{config.leftPanel?.showSearch && (
<div className="border-b px-3 py-2">
<div className="bg-muted h-7 w-full rounded text-[10px] leading-7 text-center text-muted-foreground">
</div>
</div>
)}
{/* 컬럼 미리보기 */}
<div className="flex-1 overflow-hidden p-3">
{leftDisplayColumns.length > 0 ? (
<div className="space-y-2">
{/* 샘플 카드 */}
{[1, 2, 3].map((i) => (
<div key={i} className="rounded-md border bg-background/50 p-2">
<div className="flex items-center gap-2">
{leftDisplayColumns
.filter((col) => col.displayRow === "name" || !col.displayRow)
.slice(0, 2)
.map((col, idx) => (
<div
key={col.name}
className={cn(
"text-[10px]",
idx === 0 ? "font-medium" : "text-muted-foreground"
)}
>
{col.label || col.name}
</div>
))}
</div>
{leftDisplayColumns.filter((col) => col.displayRow === "info").length > 0 && (
<div className="text-muted-foreground mt-1 flex gap-2 text-[10px]">
{leftDisplayColumns
.filter((col) => col.displayRow === "info")
.slice(0, 3)
.map((col) => (
<span key={col.name}>{col.label || col.name}</span>
))}
</div>
)}
</div>
))}
</div>
) : (
<div className="flex h-full items-center justify-center">
<div className="text-muted-foreground text-xs"> </div>
</div>
)}
</div>
</div>
{/* 우측 패널 미리보기 */}
<div className="flex flex-1 flex-col">
{/* 헤더 */}
<div className="flex items-center justify-between border-b px-3 py-2">
<div>
<div className="text-sm font-medium">{config.rightPanel?.title || "우측 패널"}</div>
<div className="text-muted-foreground text-[10px]">
{config.rightPanel?.tableName || "테이블 미설정"}
</div>
</div>
{rightButtons.length > 0 && (
<div className="flex gap-1">
{rightButtons.slice(0, 2).map((btn) => (
<div
key={btn.id}
className={cn(
"rounded px-2 py-0.5 text-[10px]",
btn.variant === "destructive"
? "bg-destructive/10 text-destructive"
: "bg-primary/10 text-primary"
)}
>
{btn.label}
</div>
))}
{rightButtons.length > 2 && (
<div className="text-muted-foreground text-[10px]">+{rightButtons.length - 2}</div>
)}
</div>
)}
</div>
{/* 검색 표시 */}
{config.rightPanel?.showSearch && (
<div className="border-b px-3 py-2">
<div className="bg-muted h-7 w-full rounded text-[10px] leading-7 text-center text-muted-foreground">
</div>
</div>
)}
{/* 컬럼 미리보기 */}
<div className="flex-1 overflow-hidden p-3">
{rightDisplayColumns.length > 0 ? (
config.rightPanel?.displayMode === "table" ? (
// 테이블 모드 미리보기
<div className="rounded-md border">
<div className="bg-muted/50 flex border-b px-2 py-1">
{config.rightPanel?.showCheckbox && (
<div className="w-8 text-[10px]"></div>
)}
{rightDisplayColumns.slice(0, 4).map((col) => (
<div key={col.name} className="flex-1 text-[10px] font-medium">
{col.label || col.name}
</div>
))}
</div>
{[1, 2, 3].map((i) => (
<div key={i} className="flex border-b px-2 py-1 last:border-b-0">
{config.rightPanel?.showCheckbox && (
<div className="w-8">
<div className="border h-3 w-3 rounded-sm"></div>
</div>
)}
{rightDisplayColumns.slice(0, 4).map((col) => (
<div key={col.name} className="text-muted-foreground flex-1 text-[10px]">
---
</div>
))}
</div>
))}
</div>
) : (
// 카드 모드 미리보기
<div className="space-y-2">
{[1, 2].map((i) => (
<div key={i} className="rounded-md border bg-background/50 p-2">
<div className="flex items-center gap-2">
{rightDisplayColumns
.filter((col) => col.displayRow === "name" || !col.displayRow)
.slice(0, 2)
.map((col, idx) => (
<div
key={col.name}
className={cn(
"text-[10px]",
idx === 0 ? "font-medium" : "text-muted-foreground"
)}
>
{col.label || col.name}
</div>
))}
</div>
{rightDisplayColumns.filter((col) => col.displayRow === "info").length > 0 && (
<div className="text-muted-foreground mt-1 flex gap-2 text-[10px]">
{rightDisplayColumns
.filter((col) => col.displayRow === "info")
.slice(0, 3)
.map((col) => (
<span key={col.name}>{col.label || col.name}</span>
))}
</div>
)}
</div>
))}
</div>
)
) : (
<div className="flex h-full items-center justify-center">
<div className="text-muted-foreground text-xs"> </div>
</div>
)}
</div>
{/* 연결 설정 표시 */}
{(config.joinConfig?.leftColumn || config.joinConfig?.keys?.length) && (
<div className="border-t px-3 py-1">
<div className="text-muted-foreground text-[10px]">
: {config.joinConfig?.leftColumn || config.joinConfig?.keys?.[0]?.leftColumn} {" "}
{config.joinConfig?.rightColumn || config.joinConfig?.keys?.[0]?.rightColumn}
</div>
</div>
)}
</div>
</div>
);
}
return (
<div
id={`split-panel-${component.id}`}
className="bg-background flex h-full w-full overflow-hidden rounded-lg border"
style={{ minHeight: "400px" }}
>
{/* 좌측 패널 */}
<div
className="bg-card flex flex-col border-r"
style={{ width: `${splitPosition}%`, minWidth: config.minLeftWidth }}
>
{/* 헤더 */}
<div className="bg-muted/30 border-b p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-base font-semibold">{config.leftPanel?.title || "목록"}</h3>
{/* actionButtons 배열이 정의되어 있으면(빈 배열 포함) 해당 배열 사용, undefined면 기존 showAddButton 사용 (하위호환) */}
{config.leftPanel?.actionButtons !== undefined ? (
// 새로운 actionButtons 배열 기반 렌더링 (빈 배열이면 버튼 없음)
config.leftPanel.actionButtons.length > 0 && (
<div className="flex items-center gap-2">
{config.leftPanel.actionButtons
.filter((btn) => {
if (btn.showCondition === "selected") return !!selectedLeftItem;
return true;
})
.map((btn, idx) => (
<Button
key={idx}
size="sm"
variant={btn.variant || "default"}
className="h-8 text-sm"
onClick={() => handleLeftActionButton(btn)}
>
{btn.icon && <span className="mr-1">{btn.icon}</span>}
{btn.label || "버튼"}
</Button>
))}
</div>
)
) : config.leftPanel?.showAddButton ? (
// 기존 showAddButton 기반 렌더링 (하위호환 - actionButtons가 undefined일 때만)
<Button size="sm" variant="default" className="h-8 text-sm" onClick={handleLeftAddClick}>
<Plus className="mr-1 h-4 w-4" />
{config.leftPanel?.addButtonLabel || "추가"}
</Button>
) : null}
</div>
{/* 검색 */}
{config.leftPanel?.showSearch && (
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="검색..."
value={leftSearchTerm}
onChange={(e) => setLeftSearchTerm(e.target.value)}
className="h-9 pl-9 text-sm"
/>
</div>
)}
</div>
{/* 좌측 패널 탭 */}
{config.leftPanel?.tabConfig?.enabled && leftTabs.length > 0 && (
<div className="flex flex-wrap gap-2 border-b px-4 py-3">
{leftTabs.map((tab) => (
<button
key={tab.id}
onClick={() => setLeftActiveTab(tab.id)}
className={cn(
"inline-flex items-center gap-2 rounded-lg border-2 px-4 py-2 text-sm font-medium transition-all",
leftActiveTab === tab.id
? "border-primary bg-primary/5 text-primary"
: "border-muted bg-background text-muted-foreground hover:border-primary/50 hover:text-foreground"
)}
>
<span>{tab.label}</span>
{config.leftPanel?.tabConfig?.showCount && (
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs",
leftActiveTab === tab.id ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
)}
>
{tab.count}
</span>
)}
</button>
))}
</div>
)}
{/* 목록 */}
<div className="flex-1 overflow-auto p-2">
{leftLoading ? (
<div className="text-muted-foreground flex h-full items-center justify-center text-base"> ...</div>
) : (config.leftPanel?.displayMode || "card") === "table" ? (
// 테이블 모드
renderLeftTable()
) : filteredLeftData.length === 0 ? (
<div className="text-muted-foreground flex h-full items-center justify-center text-base">
</div>
) : (
// 카드 모드 (기본)
<div className="py-1">{filteredLeftData.map((item, index) => renderLeftItem(item, 0, index))}</div>
)}
</div>
</div>
{/* 리사이저 */}
{config.resizable && (
<div
className={cn("hover:bg-primary/50 w-1 cursor-col-resize transition-colors", isResizing && "bg-primary/50")}
onMouseDown={handleResizeStart}
/>
)}
{/* 우측 패널 */}
<div className="bg-card flex flex-1 flex-col">
{/* 헤더 */}
<div className="bg-muted/30 border-b p-4">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<h3 className="text-base font-semibold">
{selectedLeftItem
? config.leftPanel?.displayColumns?.[0]
? selectedLeftItem[config.leftPanel.displayColumns[0].name]
: config.rightPanel?.title || "상세"
: config.rightPanel?.title || "상세"}
</h3>
{selectedLeftItem && <span className="text-muted-foreground text-sm">({rightData.length})</span>}
{/* 선택된 항목 수 표시 */}
{selectedRightItems.size > 0 && (
<span className="text-primary text-sm font-medium">{selectedRightItems.size} </span>
)}
</div>
<div className="flex items-center gap-2">
{/* actionButtons 배열이 정의되어 있으면(빈 배열 포함) 해당 배열 사용, undefined면 기존 showAddButton 사용 (하위호환) */}
{selectedLeftItem && (
config.rightPanel?.actionButtons !== undefined ? (
// 새로운 actionButtons 배열 기반 렌더링 (빈 배열이면 버튼 없음)
config.rightPanel.actionButtons.length > 0 && renderActionButtons()
) : config.rightPanel?.showAddButton ? (
// 기존 showAddButton 기반 렌더링 (하위호환 - actionButtons가 undefined일 때만)
<Button size="sm" variant="default" className="h-8 text-sm" onClick={handleRightAddClick}>
<Plus className="mr-1 h-4 w-4" />
{config.rightPanel?.addButtonLabel || "추가"}
</Button>
) : null
)}
</div>
</div>
{/* 검색 */}
{config.rightPanel?.showSearch && selectedLeftItem && (
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="검색..."
value={rightSearchTerm}
onChange={(e) => setRightSearchTerm(e.target.value)}
className="h-9 pl-9 text-sm"
/>
</div>
)}
</div>
{/* 우측 패널 탭 */}
{config.rightPanel?.tabConfig?.enabled && rightTabs.length > 0 && selectedLeftItem && (
<div className="flex flex-wrap gap-2 border-b px-4 py-3">
{rightTabs.map((tab) => (
<button
key={tab.id}
onClick={() => setRightActiveTab(tab.id)}
className={cn(
"inline-flex items-center gap-2 rounded-lg border-2 px-4 py-2 text-sm font-medium transition-all",
rightActiveTab === tab.id
? "border-primary bg-primary/5 text-primary"
: "border-muted bg-background text-muted-foreground hover:border-primary/50 hover:text-foreground"
)}
>
<span>{tab.label}</span>
{config.rightPanel?.tabConfig?.showCount && (
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs",
rightActiveTab === tab.id ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
)}
>
{tab.count}
</span>
)}
</button>
))}
</div>
)}
{/* 내용 */}
<div className="flex-1 overflow-auto p-4">
{!selectedLeftItem ? (
<div className="text-muted-foreground flex h-full flex-col items-center justify-center">
<Users className="mb-3 h-16 w-16 opacity-30" />
<span className="text-base">{config.rightPanel?.emptyMessage || "좌측에서 항목을 선택해주세요"}</span>
</div>
) : rightLoading ? (
<div className="text-muted-foreground flex h-full items-center justify-center text-base"> ...</div>
) : (
<>
{/* displayMode에 따라 카드 또는 테이블 렌더링 */}
{config.rightPanel?.displayMode === "table" ? (
renderRightTable()
) : filteredRightData.length === 0 ? (
<div className="text-muted-foreground flex h-full flex-col items-center justify-center">
<Users className="mb-3 h-16 w-16 opacity-30" />
<span className="text-base"> </span>
</div>
) : (
<div>{filteredRightData.map((item, index) => renderRightCard(item, index))}</div>
)}
</>
)}
</div>
</div>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{isBulkDelete
? `선택한 ${selectedRightItems.size}개 항목을 삭제하시겠습니까?`
: "이 항목을 삭제하시겠습니까?"}
<br /> .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={executeDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
/**
* SplitPanelLayout2 래퍼 컴포넌트
*/
export const SplitPanelLayout2Wrapper: React.FC<SplitPanelLayout2ComponentProps> = (props) => {
return <SplitPanelLayout2Component {...props} />;
};