분할 패널 및 반복 필드 그룹 컴포넌트

This commit is contained in:
kjs
2025-10-16 15:05:24 +09:00
parent 716cfcb2cf
commit a0dde51109
26 changed files with 1899 additions and 753 deletions

View File

@@ -6,9 +6,10 @@ 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 { Plus, Search, GripVertical, Loader2, ChevronDown, ChevronUp } from "lucide-react";
import { dataApi } from "@/lib/api/data";
import { useToast } from "@/hooks/use-toast";
import { tableTypeApi } from "@/lib/api/screen";
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
// 추가 props
@@ -35,17 +36,20 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 데이터 상태
const [leftData, setLeftData] = useState<any[]>([]);
const [rightData, setRightData] = useState<any>(null);
const [rightData, setRightData] = useState<any[] | any>(null); // 조인 모드는 배열, 상세 모드는 객체
const [selectedLeftItem, setSelectedLeftItem] = useState<any>(null);
const [expandedRightItems, setExpandedRightItems] = useState<Set<string | number>>(new Set()); // 확장된 우측 아이템
const [leftSearchQuery, setLeftSearchQuery] = useState("");
const [rightSearchQuery, setRightSearchQuery] = useState("");
const [isLoadingLeft, setIsLoadingLeft] = useState(false);
const [isLoadingRight, setIsLoadingRight] = useState(false);
const [rightTableColumns, setRightTableColumns] = useState<any[]>([]); // 우측 테이블 컬럼 정보
const { toast } = useToast();
// 리사이저 드래그 상태
const [isDragging, setIsDragging] = useState(false);
const [leftWidth, setLeftWidth] = useState(splitRatio);
const containerRef = React.useRef<HTMLDivElement>(null);
// 컴포넌트 스타일
const componentStyle: React.CSSProperties = {
@@ -69,7 +73,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const result = await dataApi.getTableData(leftTableName, {
page: 1,
size: 100,
searchTerm: leftSearchQuery || undefined,
// searchTerm 제거 - 클라이언트 사이드에서 필터링
});
setLeftData(result.data);
} catch (error) {
@@ -82,7 +86,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
} finally {
setIsLoadingLeft(false);
}
}, [componentConfig.leftPanel?.tableName, leftSearchQuery, isDesignMode, toast]);
}, [componentConfig.leftPanel?.tableName, isDesignMode, toast]);
// 우측 데이터 로드
const loadRightData = useCallback(
@@ -100,7 +104,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
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;
@@ -114,13 +118,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
rightColumn,
leftValue,
);
setRightData(joinedData[0] || null); // 첫 번째 관련 레코드
setRightData(joinedData || []); // 모든 관련 레코드 (배열)
}
} 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);
@@ -146,11 +145,51 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const handleLeftItemSelect = useCallback(
(item: any) => {
setSelectedLeftItem(item);
setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화
loadRightData(item);
},
[loadRightData],
);
// 우측 항목 확장/축소 토글
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]);
// 초기 데이터 로드
useEffect(() => {
if (!isDesignMode && componentConfig.autoLoad !== false) {
@@ -159,17 +198,6 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 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;
@@ -179,11 +207,15 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!isDragging) return;
const containerWidth = (e.currentTarget as HTMLElement)?.offsetWidth || 1000;
const newLeftWidth = (e.clientX / containerWidth) * 100;
if (!isDragging || !containerRef.current) return;
if (newLeftWidth > 20 && newLeftWidth < 80) {
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) {
setLeftWidth(newLeftWidth);
}
},
@@ -196,10 +228,17 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
React.useEffect(() => {
if (isDragging) {
document.addEventListener("mousemove", handleMouseMove as any);
// 드래그 중에는 텍스트 선택 방지
document.body.style.userSelect = "none";
document.body.style.cursor = "col-resize";
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove as any);
document.body.style.userSelect = "";
document.body.style.cursor = "";
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}
@@ -207,6 +246,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
return (
<div
ref={containerRef}
style={componentStyle}
onClick={(e) => {
if (isDesignMode) {
@@ -286,32 +326,57 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<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;
) : (
(() => {
// 검색 필터링 (클라이언트 사이드)
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 (
<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>}
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>
</>
) : (
"데이터가 없습니다."
)}
</div>
);
})
) : (
// 데이터 없음
<div className="py-8 text-center text-sm text-gray-500"> .</div>
})()
)}
</div>
</CardContent>
@@ -356,30 +421,133 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
)}
</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>
<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;
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 (
<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>
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>
</>
) : (
"관련 데이터가 없습니다."
)}
</div>
);
})}
</div>
})()
) : (
// 상세 모드: 단일 객체를 상세 정보로 표시
<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">