계층 구조 트리 뷰

This commit is contained in:
dohyeons
2025-11-07 15:21:44 +09:00
parent efaa267d78
commit 672aba8404
3 changed files with 587 additions and 56 deletions

View File

@@ -6,7 +6,7 @@ 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, ChevronDown, ChevronUp, Save } from "lucide-react";
import { Plus, Search, GripVertical, Loader2, ChevronDown, ChevronUp, Save, ChevronRight } from "lucide-react";
import { dataApi } from "@/lib/api/data";
import { useToast } from "@/hooks/use-toast";
import { tableTypeApi } from "@/lib/api/screen";
@@ -47,11 +47,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const [isLoadingLeft, setIsLoadingLeft] = useState(false);
const [isLoadingRight, setIsLoadingRight] = useState(false);
const [rightTableColumns, setRightTableColumns] = useState<any[]>([]); // 우측 테이블 컬럼 정보
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
const { toast } = useToast();
// 추가 모달 상태
const [showAddModal, setShowAddModal] = useState(false);
const [addModalPanel, setAddModalPanel] = useState<"left" | "right" | null>(null);
const [addModalPanel, setAddModalPanel] = useState<"left" | "right" | "left-item" | null>(null);
const [addModalFormData, setAddModalFormData] = useState<Record<string, any>>({});
// 리사이저 드래그 상태
@@ -88,6 +89,53 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
border: isSelected ? "2px solid #3b82f6" : "1px solid #e5e7eb",
};
// 계층 구조 빌드 함수 (트리 구조 유지)
const buildHierarchy = useCallback((items: any[]): any[] => {
if (!items || items.length === 0) return [];
const itemAddConfig = componentConfig.leftPanel?.itemAddConfig;
if (!itemAddConfig) return items.map(item => ({ ...item, children: [] })); // 계층 설정이 없으면 평면 목록
const { sourceColumn, parentColumn } = itemAddConfig;
if (!sourceColumn || !parentColumn) return items.map(item => ({ ...item, children: [] }));
// ID를 키로 하는 맵 생성
const itemMap = new Map<any, any>();
const rootItems: any[] = [];
// 모든 항목을 맵에 추가하고 children 배열 초기화
items.forEach(item => {
const id = item[sourceColumn];
itemMap.set(id, { ...item, children: [], level: 0 });
});
// 부모-자식 관계 설정
items.forEach(item => {
const id = item[sourceColumn];
const parentId = item[parentColumn];
const currentItem = itemMap.get(id);
if (!currentItem) return;
if (!parentId || parentId === null || parentId === '') {
// 최상위 항목
rootItems.push(currentItem);
} else {
// 부모가 있는 항목
const parentItem = itemMap.get(parentId);
if (parentItem) {
currentItem.level = parentItem.level + 1;
parentItem.children.push(currentItem);
} else {
// 부모를 찾을 수 없으면 최상위로 처리
rootItems.push(currentItem);
}
}
});
return rootItems;
}, [componentConfig.leftPanel?.itemAddConfig]);
// 좌측 데이터 로드
const loadLeftData = useCallback(async () => {
const leftTableName = componentConfig.leftPanel?.tableName;
@@ -100,7 +148,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
size: 100,
// searchTerm 제거 - 클라이언트 사이드에서 필터링
});
setLeftData(result.data);
// 계층 구조 빌드
const hierarchicalData = buildHierarchy(result.data);
setLeftData(hierarchicalData);
} catch (error) {
console.error("좌측 데이터 로드 실패:", error);
toast({
@@ -111,7 +162,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
} finally {
setIsLoadingLeft(false);
}
}, [componentConfig.leftPanel?.tableName, isDesignMode, toast]);
}, [componentConfig.leftPanel?.tableName, isDesignMode, toast, buildHierarchy]);
// 우측 데이터 로드
const loadRightData = useCallback(
@@ -215,6 +266,19 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
loadRightTableColumns();
}, [componentConfig.rightPanel?.tableName, isDesignMode]);
// 항목 펼치기/접기 토글
const toggleExpand = useCallback((itemId: any) => {
setExpandedItems(prev => {
const newSet = new Set(prev);
if (newSet.has(itemId)) {
newSet.delete(itemId);
} else {
newSet.add(itemId);
}
return newSet;
});
}, []);
// 추가 버튼 핸들러
const handleAddClick = useCallback((panel: "left" | "right") => {
setAddModalPanel(panel);
@@ -222,15 +286,65 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
setShowAddModal(true);
}, []);
// 항목별 추가 버튼 핸들러 (좌측 항목의 + 버튼 - 하위 항목 추가)
const handleItemAddClick = useCallback((item: any) => {
const itemAddConfig = componentConfig.leftPanel?.itemAddConfig;
if (!itemAddConfig) {
toast({
title: "설정 오류",
description: "하위 항목 추가 설정이 없습니다.",
variant: "destructive",
});
return;
}
const { sourceColumn, parentColumn } = itemAddConfig;
if (!sourceColumn || !parentColumn) {
toast({
title: "설정 오류",
description: "현재 항목 ID 컬럼과 상위 항목 저장 컬럼을 설정해주세요.",
variant: "destructive",
});
return;
}
// 선택된 항목의 sourceColumn 값을 가져와서 parentColumn에 매핑
const sourceValue = item[sourceColumn];
if (!sourceValue) {
toast({
title: "데이터 오류",
description: `선택한 항목의 ${sourceColumn} 값이 없습니다.`,
variant: "destructive",
});
return;
}
// 좌측 패널 추가 모달 열기 (parentColumn 값 미리 채우기)
setAddModalPanel("left-item");
setAddModalFormData({ [parentColumn]: sourceValue });
setShowAddModal(true);
}, [componentConfig, toast]);
// 추가 모달 저장
const handleAddModalSave = useCallback(async () => {
const tableName = addModalPanel === "left"
? componentConfig.leftPanel?.tableName
: componentConfig.rightPanel?.tableName;
// 테이블명과 모달 컬럼 결정
let tableName: string | undefined;
let modalColumns: Array<{ name: string; label: string; required?: boolean }> | undefined;
const modalColumns = addModalPanel === "left"
? componentConfig.leftPanel?.addModalColumns
: componentConfig.rightPanel?.addModalColumns;
if (addModalPanel === "left") {
tableName = componentConfig.leftPanel?.tableName;
modalColumns = componentConfig.leftPanel?.addModalColumns;
} else if (addModalPanel === "right") {
tableName = componentConfig.rightPanel?.tableName;
modalColumns = componentConfig.rightPanel?.addModalColumns;
} else if (addModalPanel === "left-item") {
// 하위 항목 추가 (좌측 테이블에 추가)
tableName = componentConfig.leftPanel?.tableName;
modalColumns = componentConfig.leftPanel?.itemAddConfig?.addModalColumns;
}
if (!tableName) {
toast({
@@ -270,9 +384,11 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
setAddModalFormData({});
// 데이터 새로고침
if (addModalPanel === "left") {
if (addModalPanel === "left" || addModalPanel === "left-item") {
// 좌측 패널 데이터 새로고침 (일반 추가 또는 하위 항목 추가)
loadLeftData();
} else if (selectedLeftItem) {
} else if (addModalPanel === "right" && selectedLeftItem) {
// 우측 패널 데이터 새로고침
loadRightData(selectedLeftItem);
}
} else {
@@ -487,16 +603,18 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
})
: 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);
// 재귀 렌더링 함수
const renderTreeItem = (item: any, index: number): React.ReactNode => {
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || 'id';
const itemId = item[sourceColumn] || item.id || item.ID || index;
const isSelected = selectedLeftItem && (selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item);
const hasChildren = item.children && item.children.length > 0;
const isExpanded = expandedItems.has(itemId);
const level = item.level || 0;
// 조인에 사용하는 leftColumn을 필수로 표시
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
let displayFields: { label: string; value: any }[] = [];
// 조인에 사용하는 leftColumn을 필수로 표시
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
let displayFields: { label: string; value: any }[] = [];
// 디버그 로그
if (index === 0) {
@@ -541,22 +659,71 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}
}
const displayTitle = displayFields[0]?.value || item.name || item.title || `항목 ${index + 1}`;
const displaySubtitle = displayFields[1]?.value || null;
const displayTitle = displayFields[0]?.value || item.name || item.title || `항목 ${index + 1}`;
const displaySubtitle = displayFields[1]?.value || null;
return (
return (
<React.Fragment key={itemId}>
{/* 현재 항목 */}
<div
key={itemId}
onClick={() => handleLeftItemSelect(item)}
className={`cursor-pointer rounded-md p-3 transition-colors hover:bg-muted ${
className={`group relative cursor-pointer rounded-md p-3 transition-colors hover:bg-muted ${
isSelected ? "bg-primary/10 text-primary" : "text-foreground"
}`}
style={{ paddingLeft: `${12 + level * 24}px` }}
>
<div className="truncate font-medium">{displayTitle}</div>
{displaySubtitle && <div className="truncate text-xs text-muted-foreground">{displaySubtitle}</div>}
<div
className="flex items-center gap-2"
onClick={() => {
handleLeftItemSelect(item);
if (hasChildren) {
toggleExpand(itemId);
}
}}
>
{/* 펼치기/접기 아이콘 */}
{hasChildren ? (
<div className="flex-shrink-0">
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-gray-500" />
) : (
<ChevronRight className="h-4 w-4 text-gray-500" />
)}
</div>
) : (
<div className="w-5" />
)}
{/* 항목 내용 */}
<div className="flex-1 min-w-0">
<div className="truncate font-medium">{displayTitle}</div>
{displaySubtitle && <div className="truncate text-xs text-muted-foreground">{displaySubtitle}</div>}
</div>
{/* 항목별 추가 버튼 */}
{componentConfig.leftPanel?.showItemAddButton && !isDesignMode && (
<button
onClick={(e) => {
e.stopPropagation();
handleItemAddClick(item);
}}
className="flex-shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
title="하위 항목 추가"
>
<Plus className="h-5 w-5 rounded-md bg-primary p-1 text-primary-foreground hover:bg-primary/90" />
</button>
)}
</div>
</div>
);
})
{/* 자식 항목들 (접혀있으면 표시 안함) */}
{hasChildren && isExpanded && item.children.map((child: any, childIndex: number) => renderTreeItem(child, childIndex))}
</React.Fragment>
);
};
return filteredLeftData.length > 0 ? (
// 실제 데이터 표시
filteredLeftData.map((item, index) => renderTreeItem(item, index))
) : (
// 검색 결과 없음
<div className="py-8 text-center text-sm text-muted-foreground">
@@ -842,37 +1009,62 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{addModalPanel === "left" ? componentConfig.leftPanel?.title : componentConfig.rightPanel?.title}
{addModalPanel === "left"
? `${componentConfig.leftPanel?.title} 추가`
: addModalPanel === "right"
? `${componentConfig.rightPanel?.title} 추가`
: `하위 ${componentConfig.leftPanel?.title} 추가`}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
. .
{addModalPanel === "left-item"
? "선택한 항목의 하위 항목을 추가합니다. 필수 항목을 입력해주세요."
: "새로운 데이터를 추가합니다. 필수 항목을 입력해주세요."}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
{(addModalPanel === "left"
? componentConfig.leftPanel?.addModalColumns
: componentConfig.rightPanel?.addModalColumns
)?.map((col, index) => (
<div key={index}>
<Label htmlFor={col.name} className="text-xs sm:text-sm">
{col.label} {col.required && <span className="text-destructive">*</span>}
</Label>
<Input
id={col.name}
value={addModalFormData[col.name] || ""}
onChange={(e) => {
setAddModalFormData(prev => ({
...prev,
[col.name]: e.target.value
}));
}}
placeholder={`${col.label} 입력`}
className="h-8 text-xs sm:h-10 sm:text-sm"
required={col.required}
/>
</div>
))}
{(() => {
// 어떤 컬럼들을 표시할지 결정
let modalColumns: Array<{ name: string; label: string; required?: boolean }> | undefined;
if (addModalPanel === "left") {
modalColumns = componentConfig.leftPanel?.addModalColumns;
} else if (addModalPanel === "right") {
modalColumns = componentConfig.rightPanel?.addModalColumns;
} else if (addModalPanel === "left-item") {
modalColumns = componentConfig.leftPanel?.itemAddConfig?.addModalColumns;
}
return modalColumns?.map((col, index) => {
// 항목별 추가 버튼으로 열렸을 때, parentColumn은 미리 채워져 있고 수정 불가
const isPreFilled = addModalPanel === "left-item"
&& componentConfig.leftPanel?.itemAddConfig?.parentColumn === col.name
&& addModalFormData[col.name];
return (
<div key={index}>
<Label htmlFor={col.name} className="text-xs sm:text-sm">
{col.label} {col.required && <span className="text-destructive">*</span>}
{isPreFilled && <span className="ml-2 text-[10px] text-blue-600">( )</span>}
</Label>
<Input
id={col.name}
value={addModalFormData[col.name] || ""}
onChange={(e) => {
setAddModalFormData(prev => ({
...prev,
[col.name]: e.target.value
}));
}}
placeholder={`${col.label} 입력`}
className="h-8 text-xs sm:h-10 sm:text-sm"
required={col.required}
disabled={isPreFilled}
/>
</div>
);
});
})()}
</div>
<DialogFooter className="gap-2 sm:gap-0">