좌측 패널(마스터)-우측 패널(디테일) 분할 레이아웃 컴포넌트 추가 EditModal에 isCreateMode 플래그 추가하여 INSERT/UPDATE 분기 처리 dataFilter 기반 정확한 조인 필터링 구현 좌측 패널 선택 데이터를 모달로 자동 전달하는 dataTransferFields 설정 지원 ConfigPanel에서 테이블, 컬럼, 조인 설정 가능
775 lines
27 KiB
TypeScript
775 lines
27 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
|
import { ComponentRendererProps } from "@/types/component";
|
|
import {
|
|
SplitPanelLayout2Config,
|
|
ColumnConfig,
|
|
DataTransferField,
|
|
} from "./types";
|
|
import { defaultConfig } from "./config";
|
|
import { cn } from "@/lib/utils";
|
|
import { Search, Plus, ChevronRight, ChevronDown, Edit, Trash2, Users, Building2 } from "lucide-react";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { toast } from "sonner";
|
|
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
|
import { apiClient } from "@/lib/api/client";
|
|
|
|
export interface SplitPanelLayout2ComponentProps extends ComponentRendererProps {
|
|
// 추가 props
|
|
}
|
|
|
|
/**
|
|
* SplitPanelLayout2 컴포넌트
|
|
* 마스터-디테일 패턴의 좌우 분할 레이아웃 (개선 버전)
|
|
*/
|
|
export const SplitPanelLayout2Component: React.FC<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 loadLeftData = useCallback(async () => {
|
|
if (!config.leftPanel?.tableName || isDesignMode) return;
|
|
|
|
setLeftLoading(true);
|
|
try {
|
|
const response = await apiClient.post(`/table-management/tables/${config.leftPanel.tableName}/data`, {
|
|
page: 1,
|
|
size: 1000, // 전체 데이터 로드
|
|
// 멀티테넌시: 자동으로 company_code 필터링 적용
|
|
autoFilter: {
|
|
enabled: true,
|
|
filterColumn: "company_code",
|
|
filterType: "company",
|
|
},
|
|
});
|
|
if (response.data.success) {
|
|
// API 응답 구조: { success: true, data: { data: [...], total, page, ... } }
|
|
let data = response.data.data?.data || [];
|
|
|
|
// 계층 구조 처리
|
|
if (config.leftPanel.hierarchyConfig?.enabled) {
|
|
data = buildHierarchy(
|
|
data,
|
|
config.leftPanel.hierarchyConfig.idColumn,
|
|
config.leftPanel.hierarchyConfig.parentColumn
|
|
);
|
|
}
|
|
|
|
setLeftData(data);
|
|
console.log(`[SplitPanelLayout2] 좌측 데이터 로드: ${data.length}건 (company_code 필터 적용)`);
|
|
}
|
|
} catch (error) {
|
|
console.error("[SplitPanelLayout2] 좌측 데이터 로드 실패:", error);
|
|
toast.error("좌측 패널 데이터를 불러오는데 실패했습니다.");
|
|
} finally {
|
|
setLeftLoading(false);
|
|
}
|
|
}, [config.leftPanel?.tableName, config.leftPanel?.hierarchyConfig, isDesignMode]);
|
|
|
|
// 우측 데이터 로드 (좌측 선택 항목 기반)
|
|
const loadRightData = useCallback(async (selectedItem: any) => {
|
|
if (!config.rightPanel?.tableName || !config.joinConfig?.leftColumn || !config.joinConfig?.rightColumn || !selectedItem) {
|
|
setRightData([]);
|
|
return;
|
|
}
|
|
|
|
const joinValue = selectedItem[config.joinConfig.leftColumn];
|
|
if (joinValue === undefined || joinValue === null) {
|
|
console.log(`[SplitPanelLayout2] 조인 값이 없음: ${config.joinConfig.leftColumn}`);
|
|
setRightData([]);
|
|
return;
|
|
}
|
|
|
|
setRightLoading(true);
|
|
try {
|
|
console.log(`[SplitPanelLayout2] 우측 데이터 로드 시작: ${config.rightPanel.tableName}, ${config.joinConfig.rightColumn}=${joinValue}`);
|
|
|
|
const response = await apiClient.post(`/table-management/tables/${config.rightPanel.tableName}/data`, {
|
|
page: 1,
|
|
size: 1000, // 전체 데이터 로드
|
|
// dataFilter를 사용하여 정확한 값 매칭 (Entity 타입 검색 문제 회피)
|
|
dataFilter: {
|
|
enabled: true,
|
|
matchType: "all",
|
|
filters: [
|
|
{
|
|
id: "join_filter",
|
|
columnName: config.joinConfig.rightColumn,
|
|
operator: "equals",
|
|
value: String(joinValue),
|
|
valueType: "static",
|
|
}
|
|
],
|
|
},
|
|
// 멀티테넌시: 자동으로 company_code 필터링 적용
|
|
autoFilter: {
|
|
enabled: true,
|
|
filterColumn: "company_code",
|
|
filterType: "company",
|
|
},
|
|
});
|
|
|
|
if (response.data.success) {
|
|
// API 응답 구조: { success: true, data: { data: [...], total, page, ... } }
|
|
const data = response.data.data?.data || [];
|
|
setRightData(data);
|
|
console.log(`[SplitPanelLayout2] 우측 데이터 로드 완료: ${data.length}건`);
|
|
} else {
|
|
console.error("[SplitPanelLayout2] 우측 데이터 로드 실패:", response.data.message);
|
|
setRightData([]);
|
|
}
|
|
} catch (error: any) {
|
|
console.error("[SplitPanelLayout2] 우측 데이터 로드 에러:", {
|
|
message: error?.message,
|
|
status: error?.response?.status,
|
|
statusText: error?.response?.statusText,
|
|
data: error?.response?.data,
|
|
config: {
|
|
url: error?.config?.url,
|
|
method: error?.config?.method,
|
|
data: error?.config?.data,
|
|
}
|
|
});
|
|
setRightData([]);
|
|
} finally {
|
|
setRightLoading(false);
|
|
}
|
|
}, [config.rightPanel?.tableName, config.joinConfig]);
|
|
|
|
// 좌측 패널 추가 버튼 클릭
|
|
const handleLeftAddClick = useCallback(() => {
|
|
if (!config.leftPanel?.addModalScreenId) {
|
|
toast.error("연결된 모달 화면이 없습니다.");
|
|
return;
|
|
}
|
|
|
|
// EditModal 열기 이벤트 발생
|
|
const event = new CustomEvent("openEditModal", {
|
|
detail: {
|
|
screenId: config.leftPanel.addModalScreenId,
|
|
title: config.leftPanel?.addButtonLabel || "추가",
|
|
modalSize: "lg",
|
|
editData: {},
|
|
isCreateMode: true, // 생성 모드
|
|
onSave: () => {
|
|
loadLeftData();
|
|
},
|
|
},
|
|
});
|
|
window.dispatchEvent(event);
|
|
console.log("[SplitPanelLayout2] 좌측 추가 모달 열기:", config.leftPanel.addModalScreenId);
|
|
}, [config.leftPanel?.addModalScreenId, config.leftPanel?.addButtonLabel, loadLeftData]);
|
|
|
|
// 우측 패널 추가 버튼 클릭
|
|
const handleRightAddClick = useCallback(() => {
|
|
if (!config.rightPanel?.addModalScreenId) {
|
|
toast.error("연결된 모달 화면이 없습니다.");
|
|
return;
|
|
}
|
|
|
|
// 데이터 전달 필드 설정
|
|
const initialData: Record<string, any> = {};
|
|
if (selectedLeftItem && config.dataTransferFields) {
|
|
for (const field of config.dataTransferFields) {
|
|
if (field.sourceColumn && field.targetColumn) {
|
|
initialData[field.targetColumn] = selectedLeftItem[field.sourceColumn];
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log("[SplitPanelLayout2] 모달로 전달할 데이터:", initialData);
|
|
console.log("[SplitPanelLayout2] 모달 screenId:", config.rightPanel?.addModalScreenId);
|
|
|
|
// EditModal 열기 이벤트 발생
|
|
const event = new CustomEvent("openEditModal", {
|
|
detail: {
|
|
screenId: config.rightPanel.addModalScreenId,
|
|
title: config.rightPanel?.addButtonLabel || "추가",
|
|
modalSize: "lg",
|
|
editData: initialData,
|
|
isCreateMode: true, // 생성 모드
|
|
onSave: () => {
|
|
if (selectedLeftItem) {
|
|
loadRightData(selectedLeftItem);
|
|
}
|
|
},
|
|
},
|
|
});
|
|
window.dispatchEvent(event);
|
|
console.log("[SplitPanelLayout2] 우측 추가 모달 열기");
|
|
}, [config.rightPanel?.addModalScreenId, config.rightPanel?.addButtonLabel, config.dataTransferFields, selectedLeftItem, loadRightData]);
|
|
|
|
// 컬럼 라벨 로드
|
|
const loadColumnLabels = useCallback(async (tableName: string, setLabels: (labels: Record<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(() => {
|
|
if (!leftSearchTerm) return leftData;
|
|
|
|
const searchColumn = config.leftPanel?.searchColumn;
|
|
if (!searchColumn) return leftData;
|
|
|
|
const filterRecursive = (items: any[]): any[] => {
|
|
return items.filter((item) => {
|
|
const value = String(item[searchColumn] || "").toLowerCase();
|
|
const matches = value.includes(leftSearchTerm.toLowerCase());
|
|
|
|
if (item.children?.length > 0) {
|
|
const filteredChildren = filterRecursive(item.children);
|
|
if (filteredChildren.length > 0) {
|
|
item.children = filteredChildren;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return matches;
|
|
});
|
|
};
|
|
|
|
return filterRecursive([...leftData]);
|
|
}, [leftData, leftSearchTerm, config.leftPanel?.searchColumn]);
|
|
|
|
const filteredRightData = useMemo(() => {
|
|
if (!rightSearchTerm) return rightData;
|
|
|
|
const searchColumn = config.rightPanel?.searchColumn;
|
|
if (!searchColumn) return rightData;
|
|
|
|
return rightData.filter((item) => {
|
|
const value = String(item[searchColumn] || "").toLowerCase();
|
|
return value.includes(rightSearchTerm.toLowerCase());
|
|
});
|
|
}, [rightData, rightSearchTerm, config.rightPanel?.searchColumn]);
|
|
|
|
// 리사이즈 핸들러
|
|
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
|
if (!config.resizable) return;
|
|
e.preventDefault();
|
|
setIsResizing(true);
|
|
}, [config.resizable]);
|
|
|
|
const handleResizeMove = useCallback((e: MouseEvent) => {
|
|
if (!isResizing) return;
|
|
|
|
const container = document.getElementById(`split-panel-${component.id}`);
|
|
if (!container) return;
|
|
|
|
const rect = container.getBoundingClientRect();
|
|
const newPosition = ((e.clientX - rect.left) / rect.width) * 100;
|
|
const minLeft = (config.minLeftWidth || 200) / rect.width * 100;
|
|
const minRight = (config.minRightWidth || 300) / rect.width * 100;
|
|
|
|
setSplitPosition(Math.max(minLeft, Math.min(100 - minRight, newPosition)));
|
|
}, [isResizing, component.id, config.minLeftWidth, config.minRightWidth]);
|
|
|
|
const handleResizeEnd = useCallback(() => {
|
|
setIsResizing(false);
|
|
}, []);
|
|
|
|
// 리사이즈 이벤트 리스너
|
|
useEffect(() => {
|
|
if (isResizing) {
|
|
window.addEventListener("mousemove", handleResizeMove);
|
|
window.addEventListener("mouseup", handleResizeEnd);
|
|
}
|
|
return () => {
|
|
window.removeEventListener("mousemove", handleResizeMove);
|
|
window.removeEventListener("mouseup", handleResizeEnd);
|
|
};
|
|
}, [isResizing, handleResizeMove, handleResizeEnd]);
|
|
|
|
// 초기 데이터 로드
|
|
useEffect(() => {
|
|
if (config.autoLoad && !isDesignMode) {
|
|
loadLeftData();
|
|
loadColumnLabels(config.leftPanel?.tableName || "", setLeftColumnLabels);
|
|
loadColumnLabels(config.rightPanel?.tableName || "", setRightColumnLabels);
|
|
}
|
|
}, [config.autoLoad, isDesignMode, loadLeftData, loadColumnLabels, config.leftPanel?.tableName, config.rightPanel?.tableName]);
|
|
|
|
// 컴포넌트 언마운트 시 DataProvider 해제
|
|
useEffect(() => {
|
|
return () => {
|
|
if (screenContext) {
|
|
screenContext.unregisterDataProvider(component.id);
|
|
}
|
|
};
|
|
}, [screenContext, component.id]);
|
|
|
|
// 값 포맷팅
|
|
const formatValue = (value: any, format?: ColumnConfig["format"]): string => {
|
|
if (value === null || value === undefined) return "-";
|
|
if (!format) return String(value);
|
|
|
|
switch (format.type) {
|
|
case "number":
|
|
const num = Number(value);
|
|
if (isNaN(num)) return String(value);
|
|
let formatted = format.decimalPlaces !== undefined
|
|
? num.toFixed(format.decimalPlaces)
|
|
: String(num);
|
|
if (format.thousandSeparator) {
|
|
formatted = formatted.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
}
|
|
return `${format.prefix || ""}${formatted}${format.suffix || ""}`;
|
|
|
|
case "currency":
|
|
const currency = Number(value);
|
|
if (isNaN(currency)) return String(value);
|
|
const currencyFormatted = currency.toLocaleString("ko-KR");
|
|
return `${format.prefix || ""}${currencyFormatted}${format.suffix || "원"}`;
|
|
|
|
case "date":
|
|
try {
|
|
const date = new Date(value);
|
|
return date.toLocaleDateString("ko-KR");
|
|
} catch {
|
|
return String(value);
|
|
}
|
|
|
|
default:
|
|
return String(value);
|
|
}
|
|
};
|
|
|
|
// 좌측 패널 항목 렌더링
|
|
const renderLeftItem = (item: any, level: number = 0, index: number = 0) => {
|
|
const idColumn = config.leftPanel?.hierarchyConfig?.idColumn || "id";
|
|
const itemId = item[idColumn] ?? `item-${level}-${index}`;
|
|
const hasChildren = item.children?.length > 0;
|
|
const isExpanded = expandedItems.has(String(itemId));
|
|
const isSelected = selectedLeftItem && selectedLeftItem[idColumn] === item[idColumn];
|
|
|
|
// 표시할 컬럼 결정
|
|
const displayColumns = config.leftPanel?.displayColumns || [];
|
|
const primaryColumn = displayColumns[0];
|
|
const secondaryColumn = displayColumns[1];
|
|
|
|
const primaryValue = primaryColumn
|
|
? item[primaryColumn.name]
|
|
: Object.values(item).find((v) => typeof v === "string" && v.length > 0);
|
|
const secondaryValue = secondaryColumn ? item[secondaryColumn.name] : null;
|
|
|
|
return (
|
|
<div key={itemId}>
|
|
<div
|
|
className={cn(
|
|
"flex items-center gap-3 px-4 py-3 cursor-pointer rounded-md transition-colors",
|
|
"hover:bg-accent",
|
|
isSelected && "bg-primary/10 border-l-2 border-primary"
|
|
)}
|
|
style={{ paddingLeft: `${level * 16 + 16}px` }}
|
|
onClick={() => handleLeftItemSelect(item)}
|
|
>
|
|
{/* 확장/축소 버튼 */}
|
|
{hasChildren ? (
|
|
<button
|
|
className="p-0.5 hover:bg-accent rounded"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
toggleExpand(String(itemId));
|
|
}}
|
|
>
|
|
{isExpanded ? (
|
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
) : (
|
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
|
)}
|
|
</button>
|
|
) : (
|
|
<div className="w-5" />
|
|
)}
|
|
|
|
{/* 아이콘 */}
|
|
<Building2 className="h-5 w-5 text-muted-foreground" />
|
|
|
|
{/* 내용 */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="font-medium text-base truncate">
|
|
{primaryValue || "이름 없음"}
|
|
</div>
|
|
{secondaryValue && (
|
|
<div className="text-sm text-muted-foreground truncate">
|
|
{secondaryValue}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 자식 항목 */}
|
|
{hasChildren && isExpanded && (
|
|
<div>
|
|
{item.children.map((child: any, childIndex: number) => renderLeftItem(child, level + 1, childIndex))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// 우측 패널 카드 렌더링
|
|
const renderRightCard = (item: any, index: number) => {
|
|
const displayColumns = config.rightPanel?.displayColumns || [];
|
|
|
|
// 첫 번째 컬럼을 이름으로 사용
|
|
const nameColumn = displayColumns[0];
|
|
const name = nameColumn ? item[nameColumn.name] : "이름 없음";
|
|
|
|
// 나머지 컬럼들
|
|
const otherColumns = displayColumns.slice(1);
|
|
|
|
return (
|
|
<Card key={index} className="mb-3 hover:shadow-md transition-shadow">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1">
|
|
{/* 이름 */}
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<span className="font-semibold text-lg">{name}</span>
|
|
{otherColumns[0] && (
|
|
<span className="text-sm bg-muted px-2 py-0.5 rounded">
|
|
{item[otherColumns[0].name]}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* 상세 정보 */}
|
|
<div className="flex flex-wrap gap-x-4 gap-y-1 text-base text-muted-foreground">
|
|
{otherColumns.slice(1).map((col, idx) => {
|
|
const value = item[col.name];
|
|
if (!value) return null;
|
|
|
|
// 아이콘 결정
|
|
let icon = null;
|
|
const colName = col.name.toLowerCase();
|
|
if (colName.includes("tel") || colName.includes("phone")) {
|
|
icon = <span className="text-sm">tel</span>;
|
|
} else if (colName.includes("email")) {
|
|
icon = <span className="text-sm">@</span>;
|
|
} else if (colName.includes("sabun") || colName.includes("id")) {
|
|
icon = <span className="text-sm">ID</span>;
|
|
}
|
|
|
|
return (
|
|
<span key={idx} className="flex items-center gap-1">
|
|
{icon}
|
|
{formatValue(value, col.format)}
|
|
</span>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 액션 버튼 */}
|
|
<div className="flex gap-1">
|
|
{config.rightPanel?.showEditButton && (
|
|
<Button variant="outline" size="sm" className="h-8">
|
|
수정
|
|
</Button>
|
|
)}
|
|
{config.rightPanel?.showDeleteButton && (
|
|
<Button variant="outline" size="sm" className="h-8">
|
|
삭제
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
// 디자인 모드 렌더링
|
|
if (isDesignMode) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"w-full h-full border-2 border-dashed rounded-lg flex",
|
|
isSelected ? "border-primary" : "border-muted-foreground/30"
|
|
)}
|
|
onClick={onClick}
|
|
>
|
|
{/* 좌측 패널 미리보기 */}
|
|
<div
|
|
className="border-r bg-muted/30 p-4 flex flex-col"
|
|
style={{ width: `${splitPosition}%` }}
|
|
>
|
|
<div className="text-sm font-medium mb-2">
|
|
{config.leftPanel?.title || "좌측 패널"}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground mb-2">
|
|
테이블: {config.leftPanel?.tableName || "미설정"}
|
|
</div>
|
|
<div className="flex-1 flex items-center justify-center text-muted-foreground text-xs">
|
|
좌측 목록 영역
|
|
</div>
|
|
</div>
|
|
|
|
{/* 우측 패널 미리보기 */}
|
|
<div className="flex-1 p-4 flex flex-col">
|
|
<div className="text-sm font-medium mb-2">
|
|
{config.rightPanel?.title || "우측 패널"}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground mb-2">
|
|
테이블: {config.rightPanel?.tableName || "미설정"}
|
|
</div>
|
|
<div className="flex-1 flex items-center justify-center text-muted-foreground text-xs">
|
|
우측 상세 영역
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
id={`split-panel-${component.id}`}
|
|
className="w-full h-full flex bg-background rounded-lg border overflow-hidden"
|
|
style={{ minHeight: "400px" }}
|
|
>
|
|
{/* 좌측 패널 */}
|
|
<div
|
|
className="flex flex-col border-r bg-card"
|
|
style={{ width: `${splitPosition}%`, minWidth: config.minLeftWidth }}
|
|
>
|
|
{/* 헤더 */}
|
|
<div className="p-4 border-b bg-muted/30">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="font-semibold text-base">{config.leftPanel?.title || "목록"}</h3>
|
|
{config.leftPanel?.showAddButton && (
|
|
<Button size="sm" variant="default" className="h-8 text-sm" onClick={handleLeftAddClick}>
|
|
<Plus className="h-4 w-4 mr-1" />
|
|
{config.leftPanel?.addButtonLabel || "추가"}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* 검색 */}
|
|
{config.leftPanel?.showSearch && (
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="검색..."
|
|
value={leftSearchTerm}
|
|
onChange={(e) => setLeftSearchTerm(e.target.value)}
|
|
className="pl-9 h-9 text-sm"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 목록 */}
|
|
<div className="flex-1 overflow-auto">
|
|
{leftLoading ? (
|
|
<div className="flex items-center justify-center h-full text-muted-foreground text-base">
|
|
로딩 중...
|
|
</div>
|
|
) : filteredLeftData.length === 0 ? (
|
|
<div className="flex items-center justify-center h-full text-muted-foreground text-base">
|
|
데이터가 없습니다
|
|
</div>
|
|
) : (
|
|
<div className="py-1">
|
|
{filteredLeftData.map((item, index) => renderLeftItem(item, 0, index))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 리사이저 */}
|
|
{config.resizable && (
|
|
<div
|
|
className={cn(
|
|
"w-1 cursor-col-resize hover:bg-primary/50 transition-colors",
|
|
isResizing && "bg-primary/50"
|
|
)}
|
|
onMouseDown={handleResizeStart}
|
|
/>
|
|
)}
|
|
|
|
{/* 우측 패널 */}
|
|
<div className="flex-1 flex flex-col bg-card">
|
|
{/* 헤더 */}
|
|
<div className="p-4 border-b bg-muted/30">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="font-semibold text-base">
|
|
{selectedLeftItem
|
|
? config.leftPanel?.displayColumns?.[0]
|
|
? selectedLeftItem[config.leftPanel.displayColumns[0].name]
|
|
: config.rightPanel?.title || "상세"
|
|
: config.rightPanel?.title || "상세"}
|
|
</h3>
|
|
<div className="flex items-center gap-2">
|
|
{selectedLeftItem && (
|
|
<span className="text-sm text-muted-foreground">
|
|
{rightData.length}명
|
|
</span>
|
|
)}
|
|
{config.rightPanel?.showAddButton && selectedLeftItem && (
|
|
<Button size="sm" variant="default" className="h-8 text-sm" onClick={handleRightAddClick}>
|
|
<Plus className="h-4 w-4 mr-1" />
|
|
{config.rightPanel?.addButtonLabel || "추가"}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 검색 */}
|
|
{config.rightPanel?.showSearch && selectedLeftItem && (
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="검색..."
|
|
value={rightSearchTerm}
|
|
onChange={(e) => setRightSearchTerm(e.target.value)}
|
|
className="pl-9 h-9 text-sm"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 내용 */}
|
|
<div className="flex-1 overflow-auto p-4">
|
|
{!selectedLeftItem ? (
|
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
|
<Users className="h-16 w-16 mb-3 opacity-30" />
|
|
<span className="text-base">{config.rightPanel?.emptyMessage || "좌측에서 항목을 선택해주세요"}</span>
|
|
</div>
|
|
) : rightLoading ? (
|
|
<div className="flex items-center justify-center h-full text-muted-foreground text-base">
|
|
로딩 중...
|
|
</div>
|
|
) : filteredRightData.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
|
<Users className="h-16 w-16 mb-3 opacity-30" />
|
|
<span className="text-base">등록된 항목이 없습니다</span>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
{filteredRightData.map((item, index) => renderRightCard(item, index))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* SplitPanelLayout2 래퍼 컴포넌트
|
|
*/
|
|
export const SplitPanelLayout2Wrapper: React.FC<SplitPanelLayout2ComponentProps> = (props) => {
|
|
return <SplitPanelLayout2Component {...props} />;
|
|
};
|
|
|