2025-10-15 17:25:38 +09:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import React, { useState, useCallback, useEffect } from "react";
|
|
|
|
|
import { ComponentRendererProps } from "../../types";
|
|
|
|
|
import { SplitPanelLayoutConfig } from "./types";
|
|
|
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
2025-10-16 15:05:24 +09:00
|
|
|
import { Plus, Search, GripVertical, Loader2, ChevronDown, ChevronUp } from "lucide-react";
|
2025-10-15 17:25:38 +09:00
|
|
|
import { dataApi } from "@/lib/api/data";
|
|
|
|
|
import { useToast } from "@/hooks/use-toast";
|
2025-10-16 15:05:24 +09:00
|
|
|
import { tableTypeApi } from "@/lib/api/screen";
|
2025-10-15 17:25:38 +09:00
|
|
|
|
|
|
|
|
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
|
|
|
|
|
// 추가 props
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* SplitPanelLayout 컴포넌트
|
|
|
|
|
* 마스터-디테일 패턴의 좌우 분할 레이아웃
|
|
|
|
|
*/
|
|
|
|
|
export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps> = ({
|
|
|
|
|
component,
|
|
|
|
|
isDesignMode = false,
|
|
|
|
|
isSelected = false,
|
2025-10-16 18:16:57 +09:00
|
|
|
isPreview = false,
|
2025-10-15 17:25:38 +09:00
|
|
|
onClick,
|
|
|
|
|
...props
|
|
|
|
|
}) => {
|
|
|
|
|
const componentConfig = (component.componentConfig || {}) as SplitPanelLayoutConfig;
|
|
|
|
|
|
|
|
|
|
// 기본 설정값
|
|
|
|
|
const splitRatio = componentConfig.splitRatio || 30;
|
|
|
|
|
const resizable = componentConfig.resizable ?? true;
|
|
|
|
|
const minLeftWidth = componentConfig.minLeftWidth || 200;
|
|
|
|
|
const minRightWidth = componentConfig.minRightWidth || 300;
|
|
|
|
|
|
|
|
|
|
// 데이터 상태
|
|
|
|
|
const [leftData, setLeftData] = useState<any[]>([]);
|
2025-10-16 15:05:24 +09:00
|
|
|
const [rightData, setRightData] = useState<any[] | any>(null); // 조인 모드는 배열, 상세 모드는 객체
|
2025-10-15 17:25:38 +09:00
|
|
|
const [selectedLeftItem, setSelectedLeftItem] = useState<any>(null);
|
2025-10-16 15:05:24 +09:00
|
|
|
const [expandedRightItems, setExpandedRightItems] = useState<Set<string | number>>(new Set()); // 확장된 우측 아이템
|
2025-10-15 17:25:38 +09:00
|
|
|
const [leftSearchQuery, setLeftSearchQuery] = useState("");
|
|
|
|
|
const [rightSearchQuery, setRightSearchQuery] = useState("");
|
|
|
|
|
const [isLoadingLeft, setIsLoadingLeft] = useState(false);
|
|
|
|
|
const [isLoadingRight, setIsLoadingRight] = useState(false);
|
2025-10-16 15:05:24 +09:00
|
|
|
const [rightTableColumns, setRightTableColumns] = useState<any[]>([]); // 우측 테이블 컬럼 정보
|
2025-10-15 17:25:38 +09:00
|
|
|
const { toast } = useToast();
|
|
|
|
|
|
|
|
|
|
// 리사이저 드래그 상태
|
|
|
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
|
|
|
const [leftWidth, setLeftWidth] = useState(splitRatio);
|
2025-10-16 15:05:24 +09:00
|
|
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
2025-10-15 17:25:38 +09:00
|
|
|
|
|
|
|
|
// 컴포넌트 스타일
|
2025-10-16 18:16:57 +09:00
|
|
|
const componentStyle: React.CSSProperties = isPreview
|
|
|
|
|
? {
|
|
|
|
|
// 반응형 모드: position relative, width/height 100%
|
|
|
|
|
position: "relative",
|
|
|
|
|
width: "100%",
|
|
|
|
|
height: `${component.style?.height || 600}px`,
|
|
|
|
|
border: "1px solid #e5e7eb",
|
|
|
|
|
}
|
|
|
|
|
: {
|
|
|
|
|
// 디자이너 모드: position absolute
|
|
|
|
|
position: "absolute",
|
|
|
|
|
left: `${component.style?.positionX || 0}px`,
|
|
|
|
|
top: `${component.style?.positionY || 0}px`,
|
|
|
|
|
width: `${component.style?.width || 1000}px`,
|
|
|
|
|
height: `${component.style?.height || 600}px`,
|
|
|
|
|
zIndex: component.style?.positionZ || 1,
|
|
|
|
|
cursor: isDesignMode ? "pointer" : "default",
|
|
|
|
|
border: isSelected ? "2px solid #3b82f6" : "1px solid #e5e7eb",
|
|
|
|
|
};
|
2025-10-15 17:25:38 +09:00
|
|
|
|
|
|
|
|
// 좌측 데이터 로드
|
|
|
|
|
const loadLeftData = useCallback(async () => {
|
|
|
|
|
const leftTableName = componentConfig.leftPanel?.tableName;
|
|
|
|
|
if (!leftTableName || isDesignMode) return;
|
|
|
|
|
|
|
|
|
|
setIsLoadingLeft(true);
|
|
|
|
|
try {
|
|
|
|
|
const result = await dataApi.getTableData(leftTableName, {
|
|
|
|
|
page: 1,
|
|
|
|
|
size: 100,
|
2025-10-16 15:05:24 +09:00
|
|
|
// searchTerm 제거 - 클라이언트 사이드에서 필터링
|
2025-10-15 17:25:38 +09:00
|
|
|
});
|
|
|
|
|
setLeftData(result.data);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("좌측 데이터 로드 실패:", error);
|
|
|
|
|
toast({
|
|
|
|
|
title: "데이터 로드 실패",
|
|
|
|
|
description: "좌측 패널 데이터를 불러올 수 없습니다.",
|
|
|
|
|
variant: "destructive",
|
|
|
|
|
});
|
|
|
|
|
} finally {
|
|
|
|
|
setIsLoadingLeft(false);
|
|
|
|
|
}
|
2025-10-16 15:05:24 +09:00
|
|
|
}, [componentConfig.leftPanel?.tableName, isDesignMode, toast]);
|
2025-10-15 17:25:38 +09:00
|
|
|
|
|
|
|
|
// 우측 데이터 로드
|
|
|
|
|
const loadRightData = useCallback(
|
|
|
|
|
async (leftItem: any) => {
|
|
|
|
|
const relationshipType = componentConfig.rightPanel?.relation?.type || "detail";
|
|
|
|
|
const rightTableName = componentConfig.rightPanel?.tableName;
|
|
|
|
|
|
|
|
|
|
if (!rightTableName || isDesignMode) return;
|
|
|
|
|
|
|
|
|
|
setIsLoadingRight(true);
|
|
|
|
|
try {
|
|
|
|
|
if (relationshipType === "detail") {
|
|
|
|
|
// 상세 모드: 동일 테이블의 상세 정보
|
|
|
|
|
const primaryKey = leftItem.id || leftItem.ID || Object.values(leftItem)[0];
|
|
|
|
|
const detail = await dataApi.getRecordDetail(rightTableName, primaryKey);
|
|
|
|
|
setRightData(detail);
|
|
|
|
|
} else if (relationshipType === "join") {
|
2025-10-16 15:05:24 +09:00
|
|
|
// 조인 모드: 다른 테이블의 관련 데이터 (여러 개)
|
2025-10-15 17:25:38 +09:00
|
|
|
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
|
|
|
|
|
const rightColumn = componentConfig.rightPanel?.relation?.foreignKey;
|
|
|
|
|
const leftTable = componentConfig.leftPanel?.tableName;
|
|
|
|
|
|
|
|
|
|
if (leftColumn && rightColumn && leftTable) {
|
|
|
|
|
const leftValue = leftItem[leftColumn];
|
|
|
|
|
const joinedData = await dataApi.getJoinedData(
|
|
|
|
|
leftTable,
|
|
|
|
|
rightTableName,
|
|
|
|
|
leftColumn,
|
|
|
|
|
rightColumn,
|
|
|
|
|
leftValue,
|
|
|
|
|
);
|
2025-10-16 15:05:24 +09:00
|
|
|
setRightData(joinedData || []); // 모든 관련 레코드 (배열)
|
2025-10-15 17:25:38 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("우측 데이터 로드 실패:", error);
|
|
|
|
|
toast({
|
|
|
|
|
title: "데이터 로드 실패",
|
|
|
|
|
description: "우측 패널 데이터를 불러올 수 없습니다.",
|
|
|
|
|
variant: "destructive",
|
|
|
|
|
});
|
|
|
|
|
} finally {
|
|
|
|
|
setIsLoadingRight(false);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[
|
|
|
|
|
componentConfig.rightPanel?.tableName,
|
|
|
|
|
componentConfig.rightPanel?.relation,
|
|
|
|
|
componentConfig.leftPanel?.tableName,
|
|
|
|
|
isDesignMode,
|
|
|
|
|
toast,
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 좌측 항목 선택 핸들러
|
|
|
|
|
const handleLeftItemSelect = useCallback(
|
|
|
|
|
(item: any) => {
|
|
|
|
|
setSelectedLeftItem(item);
|
2025-10-16 15:05:24 +09:00
|
|
|
setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화
|
2025-10-15 17:25:38 +09:00
|
|
|
loadRightData(item);
|
|
|
|
|
},
|
|
|
|
|
[loadRightData],
|
|
|
|
|
);
|
|
|
|
|
|
2025-10-16 15:05:24 +09:00
|
|
|
// 우측 항목 확장/축소 토글
|
|
|
|
|
const toggleRightItemExpansion = useCallback((itemId: string | number) => {
|
|
|
|
|
setExpandedRightItems((prev) => {
|
|
|
|
|
const newSet = new Set(prev);
|
|
|
|
|
if (newSet.has(itemId)) {
|
|
|
|
|
newSet.delete(itemId);
|
|
|
|
|
} else {
|
|
|
|
|
newSet.add(itemId);
|
|
|
|
|
}
|
|
|
|
|
return newSet;
|
|
|
|
|
});
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 컬럼명을 라벨로 변환하는 함수
|
|
|
|
|
const getColumnLabel = useCallback(
|
|
|
|
|
(columnName: string) => {
|
|
|
|
|
const column = rightTableColumns.find((col) => col.columnName === columnName || col.column_name === columnName);
|
|
|
|
|
return column?.columnLabel || column?.column_label || column?.displayName || columnName;
|
|
|
|
|
},
|
|
|
|
|
[rightTableColumns],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 우측 테이블 컬럼 정보 로드
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const loadRightTableColumns = async () => {
|
|
|
|
|
const rightTableName = componentConfig.rightPanel?.tableName;
|
|
|
|
|
if (!rightTableName || isDesignMode) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const columnsResponse = await tableTypeApi.getColumns(rightTableName);
|
|
|
|
|
setRightTableColumns(columnsResponse || []);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("우측 테이블 컬럼 정보 로드 실패:", error);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
loadRightTableColumns();
|
|
|
|
|
}, [componentConfig.rightPanel?.tableName, isDesignMode]);
|
|
|
|
|
|
2025-10-15 17:25:38 +09:00
|
|
|
// 초기 데이터 로드
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!isDesignMode && componentConfig.autoLoad !== false) {
|
|
|
|
|
loadLeftData();
|
|
|
|
|
}
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [isDesignMode, componentConfig.autoLoad]);
|
|
|
|
|
|
|
|
|
|
// 리사이저 드래그 핸들러
|
|
|
|
|
const handleMouseDown = (e: React.MouseEvent) => {
|
|
|
|
|
if (!resizable) return;
|
|
|
|
|
setIsDragging(true);
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleMouseMove = useCallback(
|
|
|
|
|
(e: MouseEvent) => {
|
2025-10-16 15:05:24 +09:00
|
|
|
if (!isDragging || !containerRef.current) return;
|
2025-10-15 17:25:38 +09:00
|
|
|
|
2025-10-16 15:05:24 +09:00
|
|
|
const containerRect = containerRef.current.getBoundingClientRect();
|
|
|
|
|
const containerWidth = containerRect.width;
|
|
|
|
|
const relativeX = e.clientX - containerRect.left;
|
|
|
|
|
const newLeftWidth = (relativeX / containerWidth) * 100;
|
|
|
|
|
|
|
|
|
|
// 최소/최대 너비 제한 (20% ~ 80%)
|
|
|
|
|
if (newLeftWidth >= 20 && newLeftWidth <= 80) {
|
2025-10-15 17:25:38 +09:00
|
|
|
setLeftWidth(newLeftWidth);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[isDragging],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const handleMouseUp = useCallback(() => {
|
|
|
|
|
setIsDragging(false);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (isDragging) {
|
2025-10-16 15:05:24 +09:00
|
|
|
// 드래그 중에는 텍스트 선택 방지
|
|
|
|
|
document.body.style.userSelect = "none";
|
|
|
|
|
document.body.style.cursor = "col-resize";
|
|
|
|
|
|
|
|
|
|
document.addEventListener("mousemove", handleMouseMove);
|
2025-10-15 17:25:38 +09:00
|
|
|
document.addEventListener("mouseup", handleMouseUp);
|
2025-10-16 15:05:24 +09:00
|
|
|
|
2025-10-15 17:25:38 +09:00
|
|
|
return () => {
|
2025-10-16 15:05:24 +09:00
|
|
|
document.body.style.userSelect = "";
|
|
|
|
|
document.body.style.cursor = "";
|
|
|
|
|
document.removeEventListener("mousemove", handleMouseMove);
|
2025-10-15 17:25:38 +09:00
|
|
|
document.removeEventListener("mouseup", handleMouseUp);
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}, [isDragging, handleMouseMove, handleMouseUp]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
2025-10-16 15:05:24 +09:00
|
|
|
ref={containerRef}
|
2025-10-15 17:25:38 +09:00
|
|
|
style={componentStyle}
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
if (isDesignMode) {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
onClick?.(e);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
className="flex overflow-hidden rounded-lg bg-white shadow-sm"
|
|
|
|
|
>
|
|
|
|
|
{/* 좌측 패널 */}
|
|
|
|
|
<div
|
|
|
|
|
style={{ width: `${leftWidth}%`, minWidth: `${minLeftWidth}px` }}
|
|
|
|
|
className="flex flex-col border-r border-gray-200"
|
|
|
|
|
>
|
|
|
|
|
<Card className="flex h-full flex-col border-0 shadow-none">
|
|
|
|
|
<CardHeader className="border-b border-gray-100 pb-3">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<CardTitle className="text-base font-semibold">
|
|
|
|
|
{componentConfig.leftPanel?.title || "좌측 패널"}
|
|
|
|
|
</CardTitle>
|
|
|
|
|
{componentConfig.leftPanel?.showAdd && (
|
|
|
|
|
<Button size="sm" variant="outline">
|
|
|
|
|
<Plus className="mr-1 h-4 w-4" />
|
|
|
|
|
추가
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
{componentConfig.leftPanel?.showSearch && (
|
|
|
|
|
<div className="relative mt-2">
|
|
|
|
|
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
|
|
|
|
<Input
|
|
|
|
|
placeholder="검색..."
|
|
|
|
|
value={leftSearchQuery}
|
|
|
|
|
onChange={(e) => setLeftSearchQuery(e.target.value)}
|
|
|
|
|
className="pl-9"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent className="flex-1 overflow-auto p-2">
|
|
|
|
|
{/* 좌측 데이터 목록 */}
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
{isDesignMode ? (
|
|
|
|
|
// 디자인 모드: 샘플 데이터
|
|
|
|
|
<>
|
|
|
|
|
<div
|
|
|
|
|
onClick={() => handleLeftItemSelect({ id: 1, name: "항목 1" })}
|
|
|
|
|
className={`cursor-pointer rounded-md p-3 transition-colors hover:bg-gray-50 ${
|
|
|
|
|
selectedLeftItem?.id === 1 ? "bg-blue-50 text-blue-700" : "text-gray-700"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<div className="font-medium">항목 1</div>
|
|
|
|
|
<div className="text-xs text-gray-500">설명 텍스트</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div
|
|
|
|
|
onClick={() => handleLeftItemSelect({ id: 2, name: "항목 2" })}
|
|
|
|
|
className={`cursor-pointer rounded-md p-3 transition-colors hover:bg-gray-50 ${
|
|
|
|
|
selectedLeftItem?.id === 2 ? "bg-blue-50 text-blue-700" : "text-gray-700"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<div className="font-medium">항목 2</div>
|
|
|
|
|
<div className="text-xs text-gray-500">설명 텍스트</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div
|
|
|
|
|
onClick={() => handleLeftItemSelect({ id: 3, name: "항목 3" })}
|
|
|
|
|
className={`cursor-pointer rounded-md p-3 transition-colors hover:bg-gray-50 ${
|
|
|
|
|
selectedLeftItem?.id === 3 ? "bg-blue-50 text-blue-700" : "text-gray-700"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<div className="font-medium">항목 3</div>
|
|
|
|
|
<div className="text-xs text-gray-500">설명 텍스트</div>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
) : isLoadingLeft ? (
|
|
|
|
|
// 로딩 중
|
|
|
|
|
<div className="flex items-center justify-center py-8">
|
|
|
|
|
<Loader2 className="h-6 w-6 animate-spin text-blue-500" />
|
|
|
|
|
<span className="ml-2 text-sm text-gray-500">데이터를 불러오는 중...</span>
|
|
|
|
|
</div>
|
2025-10-16 15:05:24 +09:00
|
|
|
) : (
|
|
|
|
|
(() => {
|
|
|
|
|
// 검색 필터링 (클라이언트 사이드)
|
|
|
|
|
const filteredLeftData = leftSearchQuery
|
|
|
|
|
? leftData.filter((item) => {
|
|
|
|
|
const searchLower = leftSearchQuery.toLowerCase();
|
|
|
|
|
return Object.entries(item).some(([key, value]) => {
|
|
|
|
|
if (value === null || value === undefined) return false;
|
|
|
|
|
return String(value).toLowerCase().includes(searchLower);
|
|
|
|
|
});
|
|
|
|
|
})
|
|
|
|
|
: leftData;
|
|
|
|
|
|
|
|
|
|
return filteredLeftData.length > 0 ? (
|
|
|
|
|
// 실제 데이터 표시
|
|
|
|
|
filteredLeftData.map((item, index) => {
|
|
|
|
|
const itemId = item.id || item.ID || item[Object.keys(item)[0]] || index;
|
|
|
|
|
const isSelected =
|
|
|
|
|
selectedLeftItem && (selectedLeftItem.id === itemId || selectedLeftItem === item);
|
|
|
|
|
// 첫 번째 2-3개 필드를 표시
|
|
|
|
|
const keys = Object.keys(item).filter((k) => k !== "id" && k !== "ID");
|
|
|
|
|
const displayTitle = item[keys[0]] || item.name || item.title || `항목 ${index + 1}`;
|
|
|
|
|
const displaySubtitle = keys[1] ? item[keys[1]] : null;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={itemId}
|
|
|
|
|
onClick={() => handleLeftItemSelect(item)}
|
|
|
|
|
className={`cursor-pointer rounded-md p-3 transition-colors hover:bg-gray-50 ${
|
|
|
|
|
isSelected ? "bg-blue-50 text-blue-700" : "text-gray-700"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<div className="truncate font-medium">{displayTitle}</div>
|
|
|
|
|
{displaySubtitle && <div className="truncate text-xs text-gray-500">{displaySubtitle}</div>}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})
|
|
|
|
|
) : (
|
|
|
|
|
// 검색 결과 없음
|
|
|
|
|
<div className="py-8 text-center text-sm text-gray-500">
|
|
|
|
|
{leftSearchQuery ? (
|
|
|
|
|
<>
|
|
|
|
|
<p>검색 결과가 없습니다.</p>
|
|
|
|
|
<p className="mt-1 text-xs text-gray-400">다른 검색어를 입력해보세요.</p>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
"데이터가 없습니다."
|
|
|
|
|
)}
|
2025-10-15 17:25:38 +09:00
|
|
|
</div>
|
|
|
|
|
);
|
2025-10-16 15:05:24 +09:00
|
|
|
})()
|
2025-10-15 17:25:38 +09:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 리사이저 */}
|
|
|
|
|
{resizable && (
|
|
|
|
|
<div
|
|
|
|
|
onMouseDown={handleMouseDown}
|
|
|
|
|
className="group flex w-1 cursor-col-resize items-center justify-center bg-gray-200 transition-colors hover:bg-blue-400"
|
|
|
|
|
>
|
|
|
|
|
<GripVertical className="h-4 w-4 text-gray-400 group-hover:text-white" />
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 우측 패널 */}
|
|
|
|
|
<div style={{ width: `${100 - leftWidth}%`, minWidth: `${minRightWidth}px` }} className="flex flex-col">
|
|
|
|
|
<Card className="flex h-full flex-col border-0 shadow-none">
|
|
|
|
|
<CardHeader className="border-b border-gray-100 pb-3">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<CardTitle className="text-base font-semibold">
|
|
|
|
|
{componentConfig.rightPanel?.title || "우측 패널"}
|
|
|
|
|
</CardTitle>
|
|
|
|
|
{componentConfig.rightPanel?.showAdd && (
|
|
|
|
|
<Button size="sm" variant="outline">
|
|
|
|
|
<Plus className="mr-1 h-4 w-4" />
|
|
|
|
|
추가
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
{componentConfig.rightPanel?.showSearch && (
|
|
|
|
|
<div className="relative mt-2">
|
|
|
|
|
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
|
|
|
|
<Input
|
|
|
|
|
placeholder="검색..."
|
|
|
|
|
value={rightSearchQuery}
|
|
|
|
|
onChange={(e) => setRightSearchQuery(e.target.value)}
|
|
|
|
|
className="pl-9"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent className="flex-1 overflow-auto p-4">
|
2025-10-16 15:05:24 +09:00
|
|
|
{/* 우측 데이터 */}
|
2025-10-15 17:25:38 +09:00
|
|
|
{isLoadingRight ? (
|
|
|
|
|
// 로딩 중
|
|
|
|
|
<div className="flex h-full items-center justify-center">
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
<Loader2 className="mx-auto h-8 w-8 animate-spin text-blue-500" />
|
2025-10-16 15:05:24 +09:00
|
|
|
<p className="mt-2 text-sm text-gray-500">데이터를 불러오는 중...</p>
|
2025-10-15 17:25:38 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : rightData ? (
|
|
|
|
|
// 실제 데이터 표시
|
2025-10-16 15:05:24 +09:00
|
|
|
Array.isArray(rightData) ? (
|
|
|
|
|
// 조인 모드: 여러 데이터를 테이블/리스트로 표시
|
|
|
|
|
(() => {
|
|
|
|
|
// 검색 필터링
|
|
|
|
|
const filteredData = rightSearchQuery
|
|
|
|
|
? rightData.filter((item) => {
|
|
|
|
|
const searchLower = rightSearchQuery.toLowerCase();
|
|
|
|
|
return Object.entries(item).some(([key, value]) => {
|
|
|
|
|
if (value === null || value === undefined) return false;
|
|
|
|
|
return String(value).toLowerCase().includes(searchLower);
|
|
|
|
|
});
|
|
|
|
|
})
|
|
|
|
|
: rightData;
|
|
|
|
|
|
|
|
|
|
return filteredData.length > 0 ? (
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<div className="mb-2 text-xs text-gray-500">
|
|
|
|
|
{filteredData.length}개의 관련 데이터
|
|
|
|
|
{rightSearchQuery && filteredData.length !== rightData.length && (
|
|
|
|
|
<span className="ml-1 text-blue-600">(전체 {rightData.length}개 중)</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
{filteredData.map((item, index) => {
|
|
|
|
|
const itemId = item.id || item.ID || index;
|
|
|
|
|
const isExpanded = expandedRightItems.has(itemId);
|
|
|
|
|
const firstValues = Object.entries(item)
|
|
|
|
|
.filter(([key]) => !key.toLowerCase().includes("id"))
|
|
|
|
|
.slice(0, 3);
|
|
|
|
|
const allValues = Object.entries(item).filter(
|
|
|
|
|
([key, value]) => value !== null && value !== undefined && value !== "",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={itemId}
|
|
|
|
|
className="overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm transition-all hover:shadow-md"
|
|
|
|
|
>
|
|
|
|
|
{/* 요약 정보 (클릭 가능) */}
|
|
|
|
|
<div
|
|
|
|
|
onClick={() => toggleRightItemExpansion(itemId)}
|
|
|
|
|
className="cursor-pointer p-3 transition-colors hover:bg-gray-50"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-start justify-between gap-2">
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
{firstValues.map(([key, value], idx) => (
|
|
|
|
|
<div key={key} className="mb-1 last:mb-0">
|
|
|
|
|
<div className="text-xs font-medium text-gray-500">{getColumnLabel(key)}</div>
|
|
|
|
|
<div className="truncate text-sm text-gray-900" title={String(value || "-")}>
|
|
|
|
|
{String(value || "-")}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex flex-shrink-0 items-start pt-1">
|
|
|
|
|
{isExpanded ? (
|
|
|
|
|
<ChevronUp className="h-5 w-5 text-gray-400" />
|
|
|
|
|
) : (
|
|
|
|
|
<ChevronDown className="h-5 w-5 text-gray-400" />
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 상세 정보 (확장 시 표시) */}
|
|
|
|
|
{isExpanded && (
|
|
|
|
|
<div className="border-t border-gray-200 bg-gray-50 px-3 py-2">
|
|
|
|
|
<div className="mb-2 text-xs font-semibold text-gray-700">전체 상세 정보</div>
|
|
|
|
|
<div className="overflow-auto rounded-md border border-gray-200 bg-white">
|
|
|
|
|
<table className="w-full text-sm">
|
|
|
|
|
<tbody className="divide-y divide-gray-200">
|
|
|
|
|
{allValues.map(([key, value]) => (
|
|
|
|
|
<tr key={key} className="hover:bg-gray-50">
|
|
|
|
|
<td className="px-3 py-2 font-medium whitespace-nowrap text-gray-600">
|
|
|
|
|
{getColumnLabel(key)}
|
|
|
|
|
</td>
|
|
|
|
|
<td className="px-3 py-2 break-all text-gray-900">{String(value)}</td>
|
|
|
|
|
</tr>
|
|
|
|
|
))}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="py-8 text-center text-sm text-gray-500">
|
|
|
|
|
{rightSearchQuery ? (
|
|
|
|
|
<>
|
|
|
|
|
<p>검색 결과가 없습니다.</p>
|
|
|
|
|
<p className="mt-1 text-xs text-gray-400">다른 검색어를 입력해보세요.</p>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
"관련 데이터가 없습니다."
|
|
|
|
|
)}
|
2025-10-15 17:25:38 +09:00
|
|
|
</div>
|
|
|
|
|
);
|
2025-10-16 15:05:24 +09:00
|
|
|
})()
|
|
|
|
|
) : (
|
|
|
|
|
// 상세 모드: 단일 객체를 상세 정보로 표시
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
{Object.entries(rightData).map(([key, value]) => {
|
|
|
|
|
// null, undefined, 빈 문자열 제외
|
|
|
|
|
if (value === null || value === undefined || value === "") return null;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div key={key} className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
|
|
|
|
<div className="mb-1 text-xs font-semibold tracking-wide text-gray-500 uppercase">{key}</div>
|
|
|
|
|
<div className="text-sm text-gray-900">{String(value)}</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
2025-10-15 17:25:38 +09:00
|
|
|
) : selectedLeftItem && isDesignMode ? (
|
|
|
|
|
// 디자인 모드: 샘플 데이터
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
<div className="rounded-lg border border-gray-200 p-4">
|
|
|
|
|
<h3 className="mb-2 font-medium text-gray-900">{selectedLeftItem.name} 상세 정보</h3>
|
|
|
|
|
<div className="space-y-2 text-sm">
|
|
|
|
|
<div className="flex justify-between">
|
|
|
|
|
<span className="text-gray-600">항목 1:</span>
|
|
|
|
|
<span className="font-medium">값 1</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex justify-between">
|
|
|
|
|
<span className="text-gray-600">항목 2:</span>
|
|
|
|
|
<span className="font-medium">값 2</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex justify-between">
|
|
|
|
|
<span className="text-gray-600">항목 3:</span>
|
|
|
|
|
<span className="font-medium">값 3</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
// 선택 없음
|
|
|
|
|
<div className="flex h-full items-center justify-center">
|
|
|
|
|
<div className="text-center text-sm text-gray-500">
|
|
|
|
|
<p className="mb-2">좌측에서 항목을 선택하세요</p>
|
|
|
|
|
<p className="text-xs">선택한 항목의 상세 정보가 여기에 표시됩니다</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* SplitPanelLayout 래퍼 컴포넌트
|
|
|
|
|
*/
|
|
|
|
|
export const SplitPanelLayoutWrapper: React.FC<SplitPanelLayoutComponentProps> = (props) => {
|
|
|
|
|
return <SplitPanelLayoutComponent {...props} />;
|
|
|
|
|
};
|