분할레이아웃

This commit is contained in:
kjs
2025-10-15 17:25:38 +09:00
parent 3d242c1c8e
commit 7686158a01
22 changed files with 2119 additions and 218 deletions

View File

@@ -0,0 +1,80 @@
# SplitPanelLayout 컴포넌트
마스터-디테일 패턴의 좌우 분할 레이아웃 컴포넌트입니다.
## 특징
- 🔄 **마스터-디테일 패턴**: 좌측에서 항목 선택 시 우측에 상세 정보 표시
- 📏 **크기 조절 가능**: 드래그하여 좌우 패널 크기 조정
- 🔍 **검색 기능**: 각 패널에 독립적인 검색 기능
- 🔗 **관계 설정**: JOIN, DETAIL, CUSTOM 관계 타입 지원
- ⚙️ **유연한 설정**: 다양한 옵션으로 커스터마이징 가능
## 사용 사례
### 1. 코드 관리
- 좌측: 코드 카테고리 목록
- 우측: 선택된 카테고리의 코드 목록
### 2. 테이블 조인 설정
- 좌측: 기본 테이블 목록
- 우측: 선택된 테이블의 조인 조건 설정
### 3. 메뉴 관리
- 좌측: 메뉴 트리 구조
- 우측: 선택된 메뉴의 상세 설정
## 설정 옵션
### 좌측 패널 (leftPanel)
- `title`: 패널 제목
- `tableName`: 데이터베이스 테이블명
- `showSearch`: 검색 기능 표시 여부
- `showAdd`: 추가 버튼 표시 여부
### 우측 패널 (rightPanel)
- `title`: 패널 제목
- `tableName`: 데이터베이스 테이블명
- `showSearch`: 검색 기능 표시 여부
- `showAdd`: 추가 버튼 표시 여부
- `relation`: 좌측 항목과의 관계 설정
- `type`: "join" | "detail" | "custom"
- `foreignKey`: 외래키 컬럼명
### 레이아웃 설정
- `splitRatio`: 좌측 패널 너비 비율 (0-100, 기본 30)
- `resizable`: 크기 조절 가능 여부 (기본 true)
- `minLeftWidth`: 좌측 최소 너비 (기본 200px)
- `minRightWidth`: 우측 최소 너비 (기본 300px)
- `autoLoad`: 자동 데이터 로드 (기본 true)
## 예시
```typescript
const config: SplitPanelLayoutConfig = {
leftPanel: {
title: "코드 카테고리",
tableName: "code_category",
showSearch: true,
showAdd: true,
},
rightPanel: {
title: "코드 목록",
tableName: "code_info",
showSearch: true,
showAdd: true,
relation: {
type: "detail",
foreignKey: "category_id",
},
},
splitRatio: 30,
resizable: true,
};
```

View File

@@ -0,0 +1,425 @@
"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";
import { Plus, Search, GripVertical, Loader2 } from "lucide-react";
import { dataApi } from "@/lib/api/data";
import { useToast } from "@/hooks/use-toast";
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
// 추가 props
}
/**
* SplitPanelLayout 컴포넌트
* 마스터-디테일 패턴의 좌우 분할 레이아웃
*/
export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps> = ({
component,
isDesignMode = false,
isSelected = false,
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[]>([]);
const [rightData, setRightData] = useState<any>(null);
const [selectedLeftItem, setSelectedLeftItem] = useState<any>(null);
const [leftSearchQuery, setLeftSearchQuery] = useState("");
const [rightSearchQuery, setRightSearchQuery] = useState("");
const [isLoadingLeft, setIsLoadingLeft] = useState(false);
const [isLoadingRight, setIsLoadingRight] = useState(false);
const { toast } = useToast();
// 리사이저 드래그 상태
const [isDragging, setIsDragging] = useState(false);
const [leftWidth, setLeftWidth] = useState(splitRatio);
// 컴포넌트 스타일
const componentStyle: React.CSSProperties = {
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",
};
// 좌측 데이터 로드
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,
searchTerm: leftSearchQuery || undefined,
});
setLeftData(result.data);
} catch (error) {
console.error("좌측 데이터 로드 실패:", error);
toast({
title: "데이터 로드 실패",
description: "좌측 패널 데이터를 불러올 수 없습니다.",
variant: "destructive",
});
} finally {
setIsLoadingLeft(false);
}
}, [componentConfig.leftPanel?.tableName, leftSearchQuery, isDesignMode, toast]);
// 우측 데이터 로드
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") {
// 조인 모드: 다른 테이블의 관련 데이터
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,
);
setRightData(joinedData[0] || null); // 첫 번째 관련 레코드
}
} else {
// 커스텀 모드: 상세 정보로 폴백
const primaryKey = leftItem.id || leftItem.ID || Object.values(leftItem)[0];
const detail = await dataApi.getRecordDetail(rightTableName, primaryKey);
setRightData(detail);
}
} 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);
loadRightData(item);
},
[loadRightData],
);
// 초기 데이터 로드
useEffect(() => {
if (!isDesignMode && componentConfig.autoLoad !== false) {
loadLeftData();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDesignMode, componentConfig.autoLoad]);
// 검색어 변경 시 재로드
useEffect(() => {
if (!isDesignMode && leftSearchQuery) {
const timer = setTimeout(() => {
loadLeftData();
}, 300); // 디바운스
return () => clearTimeout(timer);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [leftSearchQuery, isDesignMode]);
// 리사이저 드래그 핸들러
const handleMouseDown = (e: React.MouseEvent) => {
if (!resizable) return;
setIsDragging(true);
e.preventDefault();
};
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!isDragging) return;
const containerWidth = (e.currentTarget as HTMLElement)?.offsetWidth || 1000;
const newLeftWidth = (e.clientX / containerWidth) * 100;
if (newLeftWidth > 20 && newLeftWidth < 80) {
setLeftWidth(newLeftWidth);
}
},
[isDragging],
);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
}, []);
React.useEffect(() => {
if (isDragging) {
document.addEventListener("mousemove", handleMouseMove as any);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove as any);
document.removeEventListener("mouseup", handleMouseUp);
};
}
}, [isDragging, handleMouseMove, handleMouseUp]);
return (
<div
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>
) : leftData.length > 0 ? (
// 실제 데이터 표시
leftData.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"> .</div>
)}
</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">
{/* 우측 상세 데이터 */}
{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" />
<p className="mt-2 text-sm text-gray-500"> ...</p>
</div>
</div>
) : rightData ? (
// 실제 데이터 표시
<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>
) : 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} />;
};

View File

@@ -0,0 +1,457 @@
"use client";
import React, { useState, useMemo, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Check, ChevronsUpDown, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { SplitPanelLayoutConfig } from "./types";
import { TableInfo } from "@/types/screen";
interface SplitPanelLayoutConfigPanelProps {
config: SplitPanelLayoutConfig;
onChange: (config: SplitPanelLayoutConfig) => void;
tables?: TableInfo[]; // 전체 테이블 목록 (선택적)
screenTableName?: string; // 현재 화면의 테이블명 (좌측 패널에서 사용)
}
/**
* SplitPanelLayout 설정 패널
*/
export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelProps> = ({
config,
onChange,
tables = [], // 기본값 빈 배열
screenTableName, // 현재 화면의 테이블명
}) => {
const [rightTableOpen, setRightTableOpen] = useState(false);
const [leftColumnOpen, setLeftColumnOpen] = useState(false);
const [rightColumnOpen, setRightColumnOpen] = useState(false);
// screenTableName이 변경되면 leftPanel.tableName 자동 업데이트
useEffect(() => {
if (screenTableName) {
// 좌측 패널 테이블명 업데이트
if (config.leftPanel?.tableName !== screenTableName) {
updateLeftPanel({ tableName: screenTableName });
}
// 관계 타입이 detail이면 우측 패널도 동일한 테이블 사용
const relationshipType = config.rightPanel?.relation?.type || "detail";
if (relationshipType === "detail" && config.rightPanel?.tableName !== screenTableName) {
updateRightPanel({ tableName: screenTableName });
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [screenTableName]);
console.log("🔧 SplitPanelLayoutConfigPanel 렌더링");
console.log(" - config:", config);
console.log(" - tables:", tables);
console.log(" - tablesCount:", tables.length);
console.log(" - screenTableName:", screenTableName);
console.log(" - leftTable:", config.leftPanel?.tableName);
console.log(" - rightTable:", config.rightPanel?.tableName);
const updateConfig = (updates: Partial<SplitPanelLayoutConfig>) => {
const newConfig = { ...config, ...updates };
console.log("🔄 Config 업데이트:", newConfig);
onChange(newConfig);
};
const updateLeftPanel = (updates: Partial<SplitPanelLayoutConfig["leftPanel"]>) => {
const newConfig = {
...config,
leftPanel: { ...config.leftPanel, ...updates },
};
console.log("🔄 Left Panel 업데이트:", newConfig);
onChange(newConfig);
};
const updateRightPanel = (updates: Partial<SplitPanelLayoutConfig["rightPanel"]>) => {
const newConfig = {
...config,
rightPanel: { ...config.rightPanel, ...updates },
};
console.log("🔄 Right Panel 업데이트:", newConfig);
onChange(newConfig);
};
// 좌측 테이블은 현재 화면의 테이블 (screenTableName) 사용
const leftTableColumns = useMemo(() => {
const tableName = screenTableName || config.leftPanel?.tableName;
const table = tables.find((t) => t.tableName === tableName);
return table?.columns || [];
}, [tables, screenTableName, config.leftPanel?.tableName]);
// 우측 테이블의 컬럼 목록 가져오기
const rightTableColumns = useMemo(() => {
const table = tables.find((t) => t.tableName === config.rightPanel?.tableName);
return table?.columns || [];
}, [tables, config.rightPanel?.tableName]);
// 테이블 데이터 로딩 상태 확인
if (!tables || tables.length === 0) {
return (
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4">
<p className="text-sm text-yellow-800"> .</p>
<p className="mt-1 text-xs text-yellow-600">
.
</p>
</div>
);
}
// 관계 타입에 따라 우측 테이블을 자동으로 설정
const relationshipType = config.rightPanel?.relation?.type || "detail";
return (
<div className="space-y-6">
{/* 테이블 정보 표시 */}
<div className="rounded-lg bg-blue-50 p-3">
<p className="text-xs text-blue-600">📊 : {tables.length}</p>
</div>
{/* 관계 타입 선택 (최상단) */}
<div className="space-y-3 rounded-lg border-2 border-indigo-200 bg-indigo-50 p-4">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-indigo-600 text-white">
<span className="text-sm font-bold">1</span>
</div>
<h3 className="text-sm font-semibold text-indigo-900"> </h3>
</div>
<p className="text-xs text-indigo-700"> </p>
<Select
value={relationshipType}
onValueChange={(value: "join" | "detail" | "custom") => {
// 상세 모드로 변경 시 우측 테이블을 현재 화면 테이블로 설정
if (value === "detail" && screenTableName) {
updateRightPanel({
relation: { ...config.rightPanel?.relation, type: value },
tableName: screenTableName,
});
} else {
updateRightPanel({
relation: { ...config.rightPanel?.relation, type: value },
});
}
}}
>
<SelectTrigger className="bg-white">
<SelectValue placeholder="관계 타입 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="detail">
<div className="flex flex-col">
<span className="font-medium"> (DETAIL)</span>
<span className="text-xs text-gray-500"> ( )</span>
</div>
</SelectItem>
<SelectItem value="join">
<div className="flex flex-col">
<span className="font-medium"> (JOIN)</span>
<span className="text-xs text-gray-500"> ( )</span>
</div>
</SelectItem>
<SelectItem value="custom">
<div className="flex flex-col">
<span className="font-medium"> (CUSTOM)</span>
<span className="text-xs text-gray-500"> </span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* 좌측 패널 설정 (마스터) */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-600 text-white">
<span className="text-sm font-bold">2</span>
</div>
<h3 className="text-sm font-semibold text-gray-900"> ()</h3>
</div>
<div className="space-y-2">
<Label> </Label>
<Input
value={config.leftPanel?.title || ""}
onChange={(e) => updateLeftPanel({ title: e.target.value })}
placeholder="좌측 패널 제목"
/>
</div>
<div className="space-y-2">
<Label> ( )</Label>
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3">
<p className="text-sm font-medium text-gray-900">{screenTableName || "테이블이 지정되지 않음"}</p>
<p className="mt-1 text-xs text-gray-500"> </p>
</div>
</div>
<div className="flex items-center justify-between">
<Label> </Label>
<Switch
checked={config.leftPanel?.showSearch ?? true}
onCheckedChange={(checked) => updateLeftPanel({ showSearch: checked })}
/>
</div>
<div className="flex items-center justify-between">
<Label> </Label>
<Switch
checked={config.leftPanel?.showAdd ?? false}
onCheckedChange={(checked) => updateLeftPanel({ showAdd: checked })}
/>
</div>
</div>
{/* 우측 패널 설정 */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-600 text-white">
<span className="text-sm font-bold">3</span>
</div>
<h3 className="text-sm font-semibold text-gray-900">
({relationshipType === "detail" ? "상세" : relationshipType === "join" ? "조인" : "커스텀"})
</h3>
</div>
<div className="space-y-2">
<Label> </Label>
<Input
value={config.rightPanel?.title || ""}
onChange={(e) => updateRightPanel({ title: e.target.value })}
placeholder="우측 패널 제목"
/>
</div>
{/* 관계 타입에 따라 테이블 선택 UI 변경 */}
{relationshipType === "detail" ? (
// 상세 모드: 좌측과 동일한 테이블 (비활성화)
<div className="space-y-2">
<Label> ( )</Label>
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3">
<p className="text-sm font-medium text-gray-900">{screenTableName || "테이블이 지정되지 않음"}</p>
<p className="mt-1 text-xs text-gray-500"> </p>
</div>
</div>
) : (
// 조인/커스텀 모드: 전체 테이블에서 선택 가능
<div className="space-y-2">
<Label> ( )</Label>
<Popover open={rightTableOpen} onOpenChange={setRightTableOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={rightTableOpen}
className="w-full justify-between"
>
{config.rightPanel?.tableName || "테이블을 선택하세요"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder="테이블 검색..." />
<CommandEmpty> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{tables.map((table) => (
<CommandItem
key={table.tableName}
value={table.tableName}
onSelect={(value) => {
updateRightPanel({ tableName: value });
setRightTableOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
config.rightPanel?.tableName === table.tableName ? "opacity-100" : "opacity-0",
)}
/>
{table.tableName}
<span className="ml-2 text-xs text-gray-500">({table.tableLabel || ""})</span>
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
)}
{/* 컬럼 매핑 - 조인/커스텀 모드에서만 표시 */}
{relationshipType !== "detail" && (
<div className="space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
<Label className="text-sm font-semibold"> ( )</Label>
<p className="text-xs text-gray-600"> </p>
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Popover open={leftColumnOpen} onOpenChange={setLeftColumnOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={leftColumnOpen}
className="w-full justify-between"
disabled={!config.leftPanel?.tableName}
>
{config.rightPanel?.relation?.leftColumn || "좌측 컬럼 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder="컬럼 검색..." />
<CommandEmpty> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{leftTableColumns.map((column) => (
<CommandItem
key={column.columnName}
value={column.columnName}
onSelect={(value) => {
updateRightPanel({
relation: { ...config.rightPanel?.relation, leftColumn: value },
});
setLeftColumnOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
config.rightPanel?.relation?.leftColumn === column.columnName
? "opacity-100"
: "opacity-0",
)}
/>
{column.columnName}
<span className="ml-2 text-xs text-gray-500">({column.columnLabel || ""})</span>
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="flex items-center justify-center">
<ArrowRight className="h-4 w-4 text-gray-400" />
</div>
<div className="space-y-2">
<Label className="text-xs"> ()</Label>
<Popover open={rightColumnOpen} onOpenChange={setRightColumnOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={rightColumnOpen}
className="w-full justify-between"
disabled={!config.rightPanel?.tableName}
>
{config.rightPanel?.relation?.foreignKey || "우측 컬럼 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder="컬럼 검색..." />
<CommandEmpty> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{rightTableColumns.map((column) => (
<CommandItem
key={column.columnName}
value={column.columnName}
onSelect={(value) => {
updateRightPanel({
relation: { ...config.rightPanel?.relation, foreignKey: value },
});
setRightColumnOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
config.rightPanel?.relation?.foreignKey === column.columnName
? "opacity-100"
: "opacity-0",
)}
/>
{column.columnName}
<span className="ml-2 text-xs text-gray-500">({column.columnLabel || ""})</span>
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
)}
<div className="flex items-center justify-between">
<Label> </Label>
<Switch
checked={config.rightPanel?.showSearch ?? true}
onCheckedChange={(checked) => updateRightPanel({ showSearch: checked })}
/>
</div>
<div className="flex items-center justify-between">
<Label> </Label>
<Switch
checked={config.rightPanel?.showAdd ?? false}
onCheckedChange={(checked) => updateRightPanel({ showAdd: checked })}
/>
</div>
</div>
{/* 레이아웃 설정 */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-purple-600 text-white">
<span className="text-sm font-bold">4</span>
</div>
<h3 className="text-sm font-semibold text-gray-900"> </h3>
</div>
<div className="space-y-2">
<Label> : {config.splitRatio || 30}%</Label>
<Slider
value={[config.splitRatio || 30]}
onValueChange={(value) => updateConfig({ splitRatio: value[0] })}
min={20}
max={80}
step={5}
/>
</div>
<div className="flex items-center justify-between">
<Label> </Label>
<Switch
checked={config.resizable ?? true}
onCheckedChange={(checked) => updateConfig({ resizable: checked })}
/>
</div>
<div className="flex items-center justify-between">
<Label> </Label>
<Switch
checked={config.autoLoad ?? true}
onCheckedChange={(checked) => updateConfig({ autoLoad: checked })}
/>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,40 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { SplitPanelLayoutDefinition } from "./index";
import { SplitPanelLayoutComponent } from "./SplitPanelLayoutComponent";
/**
* SplitPanelLayout 렌더러
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
*/
export class SplitPanelLayoutRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = SplitPanelLayoutDefinition;
render(): React.ReactElement {
return <SplitPanelLayoutComponent {...this.props} renderer={this} />;
}
/**
* 컴포넌트별 특화 메서드들
*/
// 좌측 패널 데이터 로드
protected async loadLeftPanelData() {
// 좌측 패널 데이터 로드 로직
}
// 우측 패널 데이터 로드 (선택된 항목 기반)
protected async loadRightPanelData(selectedItem: any) {
// 우측 패널 데이터 로드 로직
}
}
// 자동 등록 실행
SplitPanelLayoutRenderer.registerSelf();
// Hot Reload 지원 (개발 모드)
if (process.env.NODE_ENV === "development") {
SplitPanelLayoutRenderer.enableHotReload();
}

View File

@@ -0,0 +1,69 @@
/**
* SplitPanelLayout 컴포넌트 설정
*/
export const splitPanelLayoutConfig = {
// 기본 스타일
defaultStyle: {
border: "1px solid #e5e7eb",
borderRadius: "8px",
backgroundColor: "#ffffff",
},
// 프리셋 설정들
presets: {
codeManagement: {
name: "코드 관리",
leftPanel: {
title: "코드 카테고리",
showSearch: true,
showAdd: true,
},
rightPanel: {
title: "코드 목록",
showSearch: true,
showAdd: true,
relation: {
type: "detail",
foreignKey: "category_id",
},
},
splitRatio: 30,
},
tableJoin: {
name: "테이블 조인",
leftPanel: {
title: "기본 테이블",
showSearch: true,
showAdd: false,
},
rightPanel: {
title: "조인 조건",
showSearch: false,
showAdd: true,
relation: {
type: "join",
},
},
splitRatio: 35,
},
menuSettings: {
name: "메뉴 설정",
leftPanel: {
title: "메뉴 트리",
showSearch: true,
showAdd: true,
},
rightPanel: {
title: "메뉴 상세",
showSearch: false,
showAdd: false,
relation: {
type: "detail",
foreignKey: "menu_id",
},
},
splitRatio: 25,
},
},
};

View File

@@ -0,0 +1,60 @@
"use client";
import React from "react";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { SplitPanelLayoutWrapper } from "./SplitPanelLayoutComponent";
import { SplitPanelLayoutConfigPanel } from "./SplitPanelLayoutConfigPanel";
import { SplitPanelLayoutConfig } from "./types";
/**
* SplitPanelLayout 컴포넌트 정의
* 마스터-디테일 패턴의 좌우 분할 레이아웃
*/
export const SplitPanelLayoutDefinition = createComponentDefinition({
id: "split-panel-layout",
name: "분할 패널",
nameEng: "SplitPanelLayout Component",
description: "마스터-디테일 패턴의 좌우 분할 레이아웃 컴포넌트",
category: ComponentCategory.DISPLAY,
webType: "text",
component: SplitPanelLayoutWrapper,
defaultConfig: {
leftPanel: {
title: "마스터",
showSearch: true,
showAdd: false,
},
rightPanel: {
title: "디테일",
showSearch: true,
showAdd: false,
relation: {
type: "detail",
foreignKey: "parent_id",
},
},
splitRatio: 30,
resizable: true,
minLeftWidth: 200,
minRightWidth: 300,
autoLoad: true,
syncSelection: true,
} as SplitPanelLayoutConfig,
defaultSize: { width: 1000, height: 600 },
configPanel: SplitPanelLayoutConfigPanel,
icon: "PanelLeftRight",
tags: ["분할", "마스터", "디테일", "레이아웃"],
version: "1.0.0",
author: "개발팀",
documentation: "https://docs.example.com/components/split-panel-layout",
});
// 컴포넌트는 SplitPanelLayoutRenderer에서 자동 등록됩니다
// 타입 내보내기
export type { SplitPanelLayoutConfig } from "./types";
// 컴포넌트 내보내기
export { SplitPanelLayoutComponent } from "./SplitPanelLayoutComponent";
export { SplitPanelLayoutRenderer } from "./SplitPanelLayoutRenderer";

View File

@@ -0,0 +1,51 @@
/**
* SplitPanelLayout 컴포넌트 타입 정의
*/
export interface SplitPanelLayoutConfig {
// 좌측 패널 설정
leftPanel: {
title: string;
tableName?: string; // 데이터베이스 테이블명
dataSource?: string; // API 엔드포인트
showSearch?: boolean;
showAdd?: boolean;
columns?: Array<{
name: string;
label: string;
width?: number;
}>;
};
// 우측 패널 설정
rightPanel: {
title: string;
tableName?: string;
dataSource?: string;
showSearch?: boolean;
showAdd?: boolean;
columns?: Array<{
name: string;
label: string;
width?: number;
}>;
// 좌측 선택 항목과의 관계 설정
relation?: {
type: "join" | "detail" | "custom"; // 관계 타입
leftColumn?: string; // 좌측 테이블의 연결 컬럼
foreignKey?: string; // 우측 테이블의 외래키 컬럼명
condition?: string; // 커스텀 조건
};
};
// 레이아웃 설정
splitRatio?: number; // 좌우 비율 (0-100, 기본 30)
resizable?: boolean; // 크기 조절 가능 여부
minLeftWidth?: number; // 좌측 최소 너비 (px)
minRightWidth?: number; // 우측 최소 너비 (px)
// 동작 설정
autoLoad?: boolean; // 자동 데이터 로드
syncSelection?: boolean; // 선택 항목 동기화
}