분할 패널 및 반복 필드 그룹 컴포넌트
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -12,7 +12,8 @@ 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";
|
||||
import { TableInfo, ColumnInfo } from "@/types/screen";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
|
||||
interface SplitPanelLayoutConfigPanelProps {
|
||||
config: SplitPanelLayoutConfig;
|
||||
@@ -33,24 +34,71 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||
const [rightTableOpen, setRightTableOpen] = useState(false);
|
||||
const [leftColumnOpen, setLeftColumnOpen] = useState(false);
|
||||
const [rightColumnOpen, setRightColumnOpen] = useState(false);
|
||||
const [loadedTableColumns, setLoadedTableColumns] = useState<Record<string, ColumnInfo[]>>({});
|
||||
const [loadingColumns, setLoadingColumns] = useState<Record<string, boolean>>({});
|
||||
|
||||
// screenTableName이 변경되면 leftPanel.tableName 자동 업데이트
|
||||
// screenTableName이 변경되면 좌측 패널 테이블을 항상 화면 테이블로 설정
|
||||
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]);
|
||||
|
||||
// 테이블 컬럼 로드 함수
|
||||
const loadTableColumns = async (tableName: string) => {
|
||||
if (loadedTableColumns[tableName] || loadingColumns[tableName]) {
|
||||
return; // 이미 로드되었거나 로딩 중
|
||||
}
|
||||
|
||||
setLoadingColumns((prev) => ({ ...prev, [tableName]: true }));
|
||||
|
||||
try {
|
||||
const columnsResponse = await tableTypeApi.getColumns(tableName);
|
||||
console.log(`📊 테이블 ${tableName} 컬럼 응답:`, columnsResponse);
|
||||
|
||||
const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => ({
|
||||
tableName: col.tableName || tableName,
|
||||
columnName: col.columnName || col.column_name,
|
||||
columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
|
||||
dataType: col.dataType || col.data_type || col.dbType,
|
||||
webType: col.webType || col.web_type,
|
||||
input_type: col.inputType || col.input_type,
|
||||
widgetType: col.widgetType || col.widget_type || col.webType || col.web_type,
|
||||
isNullable: col.isNullable || col.is_nullable,
|
||||
required: col.required !== undefined ? col.required : col.isNullable === "NO" || col.is_nullable === "NO",
|
||||
columnDefault: col.columnDefault || col.column_default,
|
||||
characterMaximumLength: col.characterMaximumLength || col.character_maximum_length,
|
||||
codeCategory: col.codeCategory || col.code_category,
|
||||
codeValue: col.codeValue || col.code_value,
|
||||
}));
|
||||
|
||||
console.log(`✅ 테이블 ${tableName} 컬럼 ${columns.length}개 로드됨:`, columns);
|
||||
setLoadedTableColumns((prev) => ({ ...prev, [tableName]: columns }));
|
||||
} catch (error) {
|
||||
console.error(`테이블 ${tableName} 컬럼 로드 실패:`, error);
|
||||
setLoadedTableColumns((prev) => ({ ...prev, [tableName]: [] }));
|
||||
} finally {
|
||||
setLoadingColumns((prev) => ({ ...prev, [tableName]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 좌측/우측 테이블이 변경되면 해당 테이블의 컬럼 로드
|
||||
useEffect(() => {
|
||||
if (config.leftPanel?.tableName) {
|
||||
loadTableColumns(config.leftPanel.tableName);
|
||||
}
|
||||
}, [config.leftPanel?.tableName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (config.rightPanel?.tableName) {
|
||||
loadTableColumns(config.rightPanel.tableName);
|
||||
}
|
||||
}, [config.rightPanel?.tableName]);
|
||||
|
||||
console.log("🔧 SplitPanelLayoutConfigPanel 렌더링");
|
||||
console.log(" - config:", config);
|
||||
console.log(" - tables:", tables);
|
||||
@@ -83,25 +131,24 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||
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 tableName = config.leftPanel?.tableName || screenTableName;
|
||||
return tableName ? loadedTableColumns[tableName] || [] : [];
|
||||
}, [loadedTableColumns, config.leftPanel?.tableName, screenTableName]);
|
||||
|
||||
// 우측 테이블의 컬럼 목록 가져오기
|
||||
// 우측 테이블 컬럼 (로드된 컬럼 사용)
|
||||
const rightTableColumns = useMemo(() => {
|
||||
const table = tables.find((t) => t.tableName === config.rightPanel?.tableName);
|
||||
return table?.columns || [];
|
||||
}, [tables, config.rightPanel?.tableName]);
|
||||
const tableName = config.rightPanel?.tableName;
|
||||
return tableName ? loadedTableColumns[tableName] || [] : [];
|
||||
}, [loadedTableColumns, 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">
|
||||
<div className="rounded-lg border p-4">
|
||||
<p className="text-sm font-medium">테이블 데이터를 불러올 수 없습니다.</p>
|
||||
<p className="mt-1 text-xs text-gray-600">
|
||||
화면에 테이블이 연결되지 않았거나 테이블 목록이 로드되지 않았습니다.
|
||||
</p>
|
||||
</div>
|
||||
@@ -113,23 +160,12 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||
|
||||
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>
|
||||
{/* 관계 타입 선택 */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">패널 관계 타입</h3>
|
||||
<Select
|
||||
value={relationshipType}
|
||||
onValueChange={(value: "join" | "detail" | "custom") => {
|
||||
onValueChange={(value: "join" | "detail") => {
|
||||
// 상세 모드로 변경 시 우측 테이블을 현재 화면 테이블로 설정
|
||||
if (value === "detail" && screenTableName) {
|
||||
updateRightPanel({
|
||||
@@ -159,24 +195,13 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||
<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>
|
||||
<h3 className="text-sm font-semibold">좌측 패널 설정 (마스터)</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>패널 제목</Label>
|
||||
@@ -188,9 +213,11 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>테이블 (현재 화면)</Label>
|
||||
<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="text-sm font-medium text-gray-900">
|
||||
{config.leftPanel?.tableName || screenTableName || "테이블이 지정되지 않음"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-500">좌측 패널은 현재 화면의 테이블 데이터를 표시합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -214,14 +241,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||
|
||||
{/* 우측 패널 설정 */}
|
||||
<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>
|
||||
<h3 className="text-sm font-semibold">우측 패널 설정 ({relationshipType === "detail" ? "상세" : "조인"})</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>패널 제목</Label>
|
||||
@@ -234,16 +254,18 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||
|
||||
{/* 관계 타입에 따라 테이블 선택 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="text-sm font-medium text-gray-900">
|
||||
{config.leftPanel?.tableName || 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}>
|
||||
@@ -289,7 +311,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||
</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>
|
||||
@@ -418,12 +440,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||
|
||||
{/* 레이아웃 설정 */}
|
||||
<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>
|
||||
<h3 className="text-sm font-semibold">레이아웃 설정</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>좌측 패널 너비: {config.splitRatio || 30}%</Label>
|
||||
|
||||
@@ -32,10 +32,9 @@ export interface SplitPanelLayoutConfig {
|
||||
|
||||
// 좌측 선택 항목과의 관계 설정
|
||||
relation?: {
|
||||
type: "join" | "detail" | "custom"; // 관계 타입
|
||||
type: "join" | "detail"; // 관계 타입
|
||||
leftColumn?: string; // 좌측 테이블의 연결 컬럼
|
||||
foreignKey?: string; // 우측 테이블의 외래키 컬럼명
|
||||
condition?: string; // 커스텀 조건
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user