906 lines
38 KiB
TypeScript
906 lines
38 KiB
TypeScript
"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, ChevronDown, ChevronUp, Save } from "lucide-react";
|
|
import { dataApi } from "@/lib/api/data";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { tableTypeApi } from "@/lib/api/screen";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
|
|
// 추가 props
|
|
}
|
|
|
|
/**
|
|
* SplitPanelLayout 컴포넌트
|
|
* 마스터-디테일 패턴의 좌우 분할 레이아웃
|
|
*/
|
|
export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps> = ({
|
|
component,
|
|
isDesignMode = false,
|
|
isSelected = false,
|
|
isPreview = 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[] | 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 [showAddModal, setShowAddModal] = useState(false);
|
|
const [addModalPanel, setAddModalPanel] = useState<"left" | "right" | null>(null);
|
|
const [addModalFormData, setAddModalFormData] = useState<Record<string, any>>({});
|
|
|
|
// 리사이저 드래그 상태
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const [leftWidth, setLeftWidth] = useState(splitRatio);
|
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
|
|
// 컴포넌트 스타일
|
|
// height 처리: 이미 px 단위면 그대로, 숫자면 px 추가
|
|
const getHeightValue = () => {
|
|
const height = component.style?.height;
|
|
if (!height) return "600px";
|
|
if (typeof height === "string") return height; // 이미 '540px' 형태
|
|
return `${height}px`; // 숫자면 px 추가
|
|
};
|
|
|
|
const componentStyle: React.CSSProperties = isPreview
|
|
? {
|
|
// 반응형 모드: position relative, 그리드 컨테이너가 제공하는 크기 사용
|
|
position: "relative",
|
|
width: "100%", // 🆕 부모 컨테이너 너비에 맞춤
|
|
height: getHeightValue(),
|
|
border: "1px solid #e5e7eb",
|
|
}
|
|
: {
|
|
// 디자이너 모드: position absolute
|
|
position: "absolute",
|
|
left: `${component.style?.positionX || 0}px`,
|
|
top: `${component.style?.positionY || 0}px`,
|
|
width: "100%", // 🆕 부모 컨테이너 너비에 맞춤 (그리드 기반)
|
|
height: getHeightValue(),
|
|
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 제거 - 클라이언트 사이드에서 필터링
|
|
});
|
|
setLeftData(result.data);
|
|
} catch (error) {
|
|
console.error("좌측 데이터 로드 실패:", error);
|
|
toast({
|
|
title: "데이터 로드 실패",
|
|
description: "좌측 패널 데이터를 불러올 수 없습니다.",
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
setIsLoadingLeft(false);
|
|
}
|
|
}, [componentConfig.leftPanel?.tableName, 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 || []); // 모든 관련 레코드 (배열)
|
|
}
|
|
}
|
|
} 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);
|
|
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]);
|
|
|
|
// 추가 버튼 핸들러
|
|
const handleAddClick = useCallback((panel: "left" | "right") => {
|
|
setAddModalPanel(panel);
|
|
setAddModalFormData({});
|
|
setShowAddModal(true);
|
|
}, []);
|
|
|
|
// 추가 모달 저장
|
|
const handleAddModalSave = useCallback(async () => {
|
|
const tableName = addModalPanel === "left"
|
|
? componentConfig.leftPanel?.tableName
|
|
: componentConfig.rightPanel?.tableName;
|
|
|
|
const modalColumns = addModalPanel === "left"
|
|
? componentConfig.leftPanel?.addModalColumns
|
|
: componentConfig.rightPanel?.addModalColumns;
|
|
|
|
if (!tableName) {
|
|
toast({
|
|
title: "테이블 오류",
|
|
description: "테이블명이 설정되지 않았습니다.",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
// 필수 필드 검증
|
|
const requiredFields = (modalColumns || []).filter(col => col.required);
|
|
for (const field of requiredFields) {
|
|
if (!addModalFormData[field.name]) {
|
|
toast({
|
|
title: "입력 오류",
|
|
description: `${field.label}은(는) 필수 입력 항목입니다.`,
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
console.log("📝 데이터 추가:", { tableName, data: addModalFormData });
|
|
|
|
const result = await dataApi.createRecord(tableName, addModalFormData);
|
|
|
|
if (result.success) {
|
|
toast({
|
|
title: "성공",
|
|
description: "데이터가 성공적으로 추가되었습니다.",
|
|
});
|
|
|
|
// 모달 닫기
|
|
setShowAddModal(false);
|
|
setAddModalFormData({});
|
|
|
|
// 데이터 새로고침
|
|
if (addModalPanel === "left") {
|
|
loadLeftData();
|
|
} else if (selectedLeftItem) {
|
|
loadRightData(selectedLeftItem);
|
|
}
|
|
} else {
|
|
toast({
|
|
title: "저장 실패",
|
|
description: result.message || "데이터 추가에 실패했습니다.",
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
} catch (error: any) {
|
|
console.error("데이터 추가 오류:", error);
|
|
|
|
// 에러 메시지 추출
|
|
let errorMessage = "데이터 추가 중 오류가 발생했습니다.";
|
|
|
|
if (error?.response?.data) {
|
|
const responseData = error.response.data;
|
|
|
|
// 백엔드에서 반환한 에러 메시지 확인
|
|
if (responseData.error) {
|
|
// 중복 키 에러 처리
|
|
if (responseData.error.includes("duplicate key")) {
|
|
errorMessage = "이미 존재하는 값입니다. 다른 값을 입력해주세요.";
|
|
}
|
|
// NOT NULL 제약조건 에러
|
|
else if (responseData.error.includes("null value")) {
|
|
const match = responseData.error.match(/column "(\w+)"/);
|
|
const columnName = match ? match[1] : "필수";
|
|
errorMessage = `${columnName} 필드는 필수 입력 항목입니다.`;
|
|
}
|
|
// 외래키 제약조건 에러
|
|
else if (responseData.error.includes("foreign key")) {
|
|
errorMessage = "참조하는 데이터가 존재하지 않습니다.";
|
|
}
|
|
// 기타 에러
|
|
else {
|
|
errorMessage = responseData.message || responseData.error;
|
|
}
|
|
} else if (responseData.message) {
|
|
errorMessage = responseData.message;
|
|
}
|
|
}
|
|
|
|
toast({
|
|
title: "오류",
|
|
description: errorMessage,
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
}, [addModalPanel, componentConfig, addModalFormData, toast, selectedLeftItem, loadLeftData, loadRightData]);
|
|
|
|
// 초기 데이터 로드
|
|
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) => {
|
|
if (!isDragging || !containerRef.current) return;
|
|
|
|
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);
|
|
}
|
|
},
|
|
[isDragging],
|
|
);
|
|
|
|
const handleMouseUp = useCallback(() => {
|
|
setIsDragging(false);
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
if (isDragging) {
|
|
// 드래그 중에는 텍스트 선택 방지
|
|
document.body.style.userSelect = "none";
|
|
document.body.style.cursor = "col-resize";
|
|
|
|
document.addEventListener("mousemove", handleMouseMove);
|
|
document.addEventListener("mouseup", handleMouseUp);
|
|
|
|
return () => {
|
|
document.body.style.userSelect = "";
|
|
document.body.style.cursor = "";
|
|
document.removeEventListener("mousemove", handleMouseMove);
|
|
document.removeEventListener("mouseup", handleMouseUp);
|
|
};
|
|
}
|
|
}, [isDragging, handleMouseMove, handleMouseUp]);
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
style={{
|
|
...(isPreview
|
|
? {
|
|
position: "relative",
|
|
height: `${component.style?.height || 600}px`,
|
|
border: "1px solid #e5e7eb",
|
|
}
|
|
: componentStyle),
|
|
display: "flex",
|
|
flexDirection: "row",
|
|
}}
|
|
onClick={(e) => {
|
|
if (isDesignMode) {
|
|
e.stopPropagation();
|
|
onClick?.(e);
|
|
}
|
|
}}
|
|
className="w-full overflow-hidden rounded-lg bg-white shadow-sm"
|
|
>
|
|
{/* 좌측 패널 */}
|
|
<div
|
|
style={{ width: `${leftWidth}%`, minWidth: isPreview ? "0" : `${minLeftWidth}px`, height: "100%" }}
|
|
className="border-border flex flex-shrink-0 flex-col border-r"
|
|
>
|
|
<Card className="flex flex-col border-0 shadow-none" style={{ height: "100%" }}>
|
|
<CardHeader className="border-b pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-base font-semibold">
|
|
{componentConfig.leftPanel?.title || "좌측 패널"}
|
|
</CardTitle>
|
|
{componentConfig.leftPanel?.showAdd && !isDesignMode && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => handleAddClick("left")}
|
|
>
|
|
<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-muted-foreground" />
|
|
<Input
|
|
placeholder="검색..."
|
|
value={leftSearchQuery}
|
|
onChange={(e) => setLeftSearchQuery(e.target.value)}
|
|
className="pl-9"
|
|
/>
|
|
</div>
|
|
)}
|
|
</CardHeader>
|
|
<CardContent className="flex-1 overflow-auto p-4">
|
|
{/* 좌측 데이터 목록 */}
|
|
<div className="space-y-1">
|
|
{isDesignMode ? (
|
|
// 디자인 모드: 샘플 데이터
|
|
<>
|
|
<div
|
|
onClick={() => handleLeftItemSelect({ id: 1, name: "항목 1" })}
|
|
className={`hover:bg-accent cursor-pointer rounded-md p-3 transition-colors ${
|
|
selectedLeftItem?.id === 1 ? "bg-primary/10 text-primary" : ""
|
|
}`}
|
|
>
|
|
<div className="font-medium">항목 1</div>
|
|
<div className="text-muted-foreground text-xs">설명 텍스트</div>
|
|
</div>
|
|
<div
|
|
onClick={() => handleLeftItemSelect({ id: 2, name: "항목 2" })}
|
|
className={`hover:bg-accent cursor-pointer rounded-md p-3 transition-colors ${
|
|
selectedLeftItem?.id === 2 ? "bg-primary/10 text-primary" : ""
|
|
}`}
|
|
>
|
|
<div className="font-medium">항목 2</div>
|
|
<div className="text-muted-foreground text-xs">설명 텍스트</div>
|
|
</div>
|
|
<div
|
|
onClick={() => handleLeftItemSelect({ id: 3, name: "항목 3" })}
|
|
className={`hover:bg-accent cursor-pointer rounded-md p-3 transition-colors ${
|
|
selectedLeftItem?.id === 3 ? "bg-primary/10 text-primary" : ""
|
|
}`}
|
|
>
|
|
<div className="font-medium">항목 3</div>
|
|
<div className="text-muted-foreground text-xs">설명 텍스트</div>
|
|
</div>
|
|
</>
|
|
) : isLoadingLeft ? (
|
|
// 로딩 중
|
|
<div className="flex items-center justify-center py-8">
|
|
<Loader2 className="text-primary h-6 w-6 animate-spin" />
|
|
<span className="text-muted-foreground ml-2 text-sm">데이터를 불러오는 중...</span>
|
|
</div>
|
|
) : (
|
|
(() => {
|
|
// 검색 필터링 (클라이언트 사이드)
|
|
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);
|
|
|
|
// 조인에 사용하는 leftColumn을 필수로 표시
|
|
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
|
|
let displayFields: { label: string; value: any }[] = [];
|
|
|
|
// 디버그 로그
|
|
if (index === 0) {
|
|
console.log("🔍 좌측 패널 표시 로직:");
|
|
console.log(" - leftColumn (조인 키):", leftColumn);
|
|
console.log(" - item keys:", Object.keys(item));
|
|
}
|
|
|
|
if (leftColumn) {
|
|
// 조인 모드: leftColumn 값을 첫 번째로 표시 (필수)
|
|
displayFields.push({
|
|
label: leftColumn,
|
|
value: item[leftColumn],
|
|
});
|
|
|
|
// 추가로 다른 의미있는 필드 1-2개 표시 (name, title 등)
|
|
const additionalKeys = Object.keys(item).filter(
|
|
(k) => k !== "id" && k !== "ID" && k !== leftColumn &&
|
|
(k.includes("name") || k.includes("title") || k.includes("desc"))
|
|
);
|
|
|
|
if (additionalKeys.length > 0) {
|
|
displayFields.push({
|
|
label: additionalKeys[0],
|
|
value: item[additionalKeys[0]],
|
|
});
|
|
}
|
|
|
|
if (index === 0) {
|
|
console.log(" ✅ 조인 키 기반 표시:", displayFields);
|
|
}
|
|
} else {
|
|
// 상세 모드 또는 설정 없음: 자동으로 첫 2개 필드 표시
|
|
const keys = Object.keys(item).filter((k) => k !== "id" && k !== "ID");
|
|
displayFields = keys.slice(0, 2).map((key) => ({
|
|
label: key,
|
|
value: item[key],
|
|
}));
|
|
|
|
if (index === 0) {
|
|
console.log(" ⚠️ 조인 키 없음, 자동 선택:", displayFields);
|
|
}
|
|
}
|
|
|
|
const displayTitle = displayFields[0]?.value || item.name || item.title || `항목 ${index + 1}`;
|
|
const displaySubtitle = displayFields[1]?.value || null;
|
|
|
|
return (
|
|
<div
|
|
key={itemId}
|
|
onClick={() => handleLeftItemSelect(item)}
|
|
className={`cursor-pointer rounded-md p-3 transition-colors hover:bg-muted ${
|
|
isSelected ? "bg-primary/10 text-primary" : "text-foreground"
|
|
}`}
|
|
>
|
|
<div className="truncate font-medium">{displayTitle}</div>
|
|
{displaySubtitle && <div className="truncate text-xs text-muted-foreground">{displaySubtitle}</div>}
|
|
</div>
|
|
);
|
|
})
|
|
) : (
|
|
// 검색 결과 없음
|
|
<div className="py-8 text-center text-sm text-muted-foreground">
|
|
{leftSearchQuery ? (
|
|
<>
|
|
<p>검색 결과가 없습니다.</p>
|
|
<p className="mt-1 text-xs text-muted-foreground/70">다른 검색어를 입력해보세요.</p>
|
|
</>
|
|
) : (
|
|
"데이터가 없습니다."
|
|
)}
|
|
</div>
|
|
);
|
|
})()
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* 리사이저 */}
|
|
{resizable && (
|
|
<div
|
|
onMouseDown={handleMouseDown}
|
|
className="group flex w-1 cursor-col-resize items-center justify-center bg-border transition-colors hover:bg-primary"
|
|
>
|
|
<GripVertical className="h-4 w-4 text-muted-foreground group-hover:text-primary-foreground" />
|
|
</div>
|
|
)}
|
|
|
|
{/* 우측 패널 */}
|
|
<div
|
|
style={{ width: `${100 - leftWidth}%`, minWidth: isPreview ? "0" : `${minRightWidth}px`, height: "100%" }}
|
|
className="flex flex-shrink-0 flex-col"
|
|
>
|
|
<Card className="flex flex-col border-0 shadow-none" style={{ height: "100%" }}>
|
|
<CardHeader className="border-b pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-base font-semibold">
|
|
{componentConfig.rightPanel?.title || "우측 패널"}
|
|
</CardTitle>
|
|
{componentConfig.rightPanel?.showAdd && !isDesignMode && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => handleAddClick("right")}
|
|
>
|
|
<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-muted-foreground" />
|
|
<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-primary" />
|
|
<p className="mt-2 text-sm text-muted-foreground">데이터를 불러오는 중...</p>
|
|
</div>
|
|
</div>
|
|
) : rightData ? (
|
|
// 실제 데이터 표시
|
|
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-muted-foreground">
|
|
{filteredData.length}개의 관련 데이터
|
|
{rightSearchQuery && filteredData.length !== rightData.length && (
|
|
<span className="ml-1 text-primary">(전체 {rightData.length}개 중)</span>
|
|
)}
|
|
</div>
|
|
{filteredData.map((item, index) => {
|
|
const itemId = item.id || item.ID || index;
|
|
const isExpanded = expandedRightItems.has(itemId);
|
|
|
|
// 우측 패널 표시 컬럼 설정 확인
|
|
const rightColumns = componentConfig.rightPanel?.columns;
|
|
let firstValues: [string, any][] = [];
|
|
let allValues: [string, any][] = [];
|
|
|
|
if (index === 0) {
|
|
console.log("🔍 우측 패널 표시 로직:");
|
|
console.log(" - rightColumns:", rightColumns);
|
|
console.log(" - item keys:", Object.keys(item));
|
|
}
|
|
|
|
if (rightColumns && rightColumns.length > 0) {
|
|
// 설정된 컬럼만 표시
|
|
firstValues = rightColumns
|
|
.slice(0, 3)
|
|
.map((col) => [col.name, item[col.name]] as [string, any])
|
|
.filter(([_, value]) => value !== null && value !== undefined && value !== "");
|
|
|
|
allValues = rightColumns
|
|
.map((col) => [col.name, item[col.name]] as [string, any])
|
|
.filter(([_, value]) => value !== null && value !== undefined && value !== "");
|
|
|
|
if (index === 0) {
|
|
console.log(" ✅ 설정된 컬럼 사용:", rightColumns.map(c => c.name));
|
|
}
|
|
} else {
|
|
// 설정 없으면 모든 컬럼 표시 (기존 로직)
|
|
firstValues = Object.entries(item)
|
|
.filter(([key]) => !key.toLowerCase().includes("id"))
|
|
.slice(0, 3);
|
|
|
|
allValues = Object.entries(item).filter(
|
|
([key, value]) => value !== null && value !== undefined && value !== "",
|
|
);
|
|
|
|
if (index === 0) {
|
|
console.log(" ⚠️ 컬럼 설정 없음, 모든 컬럼 표시");
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div
|
|
key={itemId}
|
|
className="bg-card overflow-hidden rounded-lg border shadow-sm transition-all hover:shadow-md"
|
|
>
|
|
{/* 요약 정보 (클릭 가능) */}
|
|
<div
|
|
onClick={() => toggleRightItemExpansion(itemId)}
|
|
className="cursor-pointer p-3 transition-colors hover:bg-muted"
|
|
>
|
|
<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-muted-foreground">{getColumnLabel(key)}</div>
|
|
<div className="truncate text-sm text-foreground" 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-muted-foreground" />
|
|
) : (
|
|
<ChevronDown className="h-5 w-5 text-muted-foreground" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 상세 정보 (확장 시 표시) */}
|
|
{isExpanded && (
|
|
<div className="bg-muted/50 border-t px-3 py-2">
|
|
<div className="mb-2 text-xs font-semibold">전체 상세 정보</div>
|
|
<div className="bg-card overflow-auto rounded-md border">
|
|
<table className="w-full text-sm">
|
|
<tbody className="divide-y divide-border">
|
|
{allValues.map(([key, value]) => (
|
|
<tr key={key} className="hover:bg-muted">
|
|
<td className="px-3 py-2 font-medium whitespace-nowrap text-muted-foreground">
|
|
{getColumnLabel(key)}
|
|
</td>
|
|
<td className="px-3 py-2 break-all text-foreground">{String(value)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div className="py-8 text-center text-sm text-muted-foreground">
|
|
{rightSearchQuery ? (
|
|
<>
|
|
<p>검색 결과가 없습니다.</p>
|
|
<p className="mt-1 text-xs text-muted-foreground/70">다른 검색어를 입력해보세요.</p>
|
|
</>
|
|
) : (
|
|
"관련 데이터가 없습니다."
|
|
)}
|
|
</div>
|
|
);
|
|
})()
|
|
) : (
|
|
// 상세 모드: 단일 객체를 상세 정보로 표시
|
|
(() => {
|
|
const rightColumns = componentConfig.rightPanel?.columns;
|
|
let displayEntries: [string, any][] = [];
|
|
|
|
if (rightColumns && rightColumns.length > 0) {
|
|
// 설정된 컬럼만 표시
|
|
displayEntries = rightColumns
|
|
.map((col) => [col.name, rightData[col.name]] as [string, any])
|
|
.filter(([_, value]) => value !== null && value !== undefined && value !== "");
|
|
|
|
console.log("🔍 상세 모드 표시 로직:");
|
|
console.log(" ✅ 설정된 컬럼 사용:", rightColumns.map(c => c.name));
|
|
} else {
|
|
// 설정 없으면 모든 컬럼 표시
|
|
displayEntries = Object.entries(rightData).filter(
|
|
([_, value]) => value !== null && value !== undefined && value !== ""
|
|
);
|
|
console.log(" ⚠️ 컬럼 설정 없음, 모든 컬럼 표시");
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
{displayEntries.map(([key, value]) => (
|
|
<div key={key} className="bg-card rounded-lg border p-4 shadow-sm">
|
|
<div className="text-muted-foreground mb-1 text-xs font-semibold tracking-wide uppercase">
|
|
{getColumnLabel(key)}
|
|
</div>
|
|
<div className="text-sm">{String(value)}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
})()
|
|
)
|
|
) : selectedLeftItem && isDesignMode ? (
|
|
// 디자인 모드: 샘플 데이터
|
|
<div className="space-y-4">
|
|
<div className="rounded-lg border p-4">
|
|
<h3 className="mb-2 font-medium">{selectedLeftItem.name} 상세 정보</h3>
|
|
<div className="space-y-2 text-sm">
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">항목 1:</span>
|
|
<span className="font-medium">값 1</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">항목 2:</span>
|
|
<span className="font-medium">값 2</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">항목 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-muted-foreground">
|
|
<p className="mb-2">좌측에서 항목을 선택하세요</p>
|
|
<p className="text-xs">선택한 항목의 상세 정보가 여기에 표시됩니다</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* 추가 모달 */}
|
|
<Dialog open={showAddModal} onOpenChange={setShowAddModal}>
|
|
<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} 추가
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs sm:text-sm">
|
|
새로운 데이터를 추가합니다. 필수 항목을 입력해주세요.
|
|
</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>
|
|
))}
|
|
</div>
|
|
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setShowAddModal(false)}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
취소
|
|
</Button>
|
|
<Button
|
|
onClick={handleAddModalSave}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
<Save className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
|
|
저장
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* SplitPanelLayout 래퍼 컴포넌트
|
|
*/
|
|
export const SplitPanelLayoutWrapper: React.FC<SplitPanelLayoutComponentProps> = (props) => {
|
|
return <SplitPanelLayoutComponent {...props} />;
|
|
};
|