"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, ChevronRight, Pencil, Trash2 } 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 = ({ 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([]); const [rightData, setRightData] = useState(null); // 조인 모드는 배열, 상세 모드는 객체 const [selectedLeftItem, setSelectedLeftItem] = useState(null); const [expandedRightItems, setExpandedRightItems] = useState>(new Set()); // 확장된 우측 아이템 const [leftSearchQuery, setLeftSearchQuery] = useState(""); const [rightSearchQuery, setRightSearchQuery] = useState(""); const [isLoadingLeft, setIsLoadingLeft] = useState(false); const [isLoadingRight, setIsLoadingRight] = useState(false); const [rightTableColumns, setRightTableColumns] = useState([]); // 우측 테이블 컬럼 정보 const [expandedItems, setExpandedItems] = useState>(new Set()); // 펼쳐진 항목들 const [leftColumnLabels, setLeftColumnLabels] = useState>({}); // 좌측 컬럼 라벨 const [rightColumnLabels, setRightColumnLabels] = useState>({}); // 우측 컬럼 라벨 const { toast } = useToast(); // 추가 모달 상태 const [showAddModal, setShowAddModal] = useState(false); const [addModalPanel, setAddModalPanel] = useState<"left" | "right" | "left-item" | null>(null); const [addModalFormData, setAddModalFormData] = useState>({}); // 수정 모달 상태 const [showEditModal, setShowEditModal] = useState(false); const [editModalPanel, setEditModalPanel] = useState<"left" | "right" | null>(null); const [editModalItem, setEditModalItem] = useState(null); const [editModalFormData, setEditModalFormData] = useState>({}); // 삭제 확인 모달 상태 const [showDeleteModal, setShowDeleteModal] = useState(false); const [deleteModalPanel, setDeleteModalPanel] = useState<"left" | "right" | null>(null); const [deleteModalItem, setDeleteModalItem] = useState(null); // 리사이저 드래그 상태 const [isDragging, setIsDragging] = useState(false); const [leftWidth, setLeftWidth] = useState(splitRatio); const containerRef = React.useRef(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 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(); 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; if (!leftTableName || isDesignMode) return; setIsLoadingLeft(true); try { const result = await dataApi.getTableData(leftTableName, { page: 1, size: 100, // searchTerm 제거 - 클라이언트 사이드에서 필터링 }); // 가나다순 정렬 (좌측 패널의 표시 컬럼 기준) const leftColumn = componentConfig.rightPanel?.relation?.leftColumn; if (leftColumn && result.data.length > 0) { result.data.sort((a, b) => { const aValue = String(a[leftColumn] || ''); const bValue = String(b[leftColumn] || ''); return aValue.localeCompare(bValue, 'ko-KR'); }); } // 계층 구조 빌드 const hierarchicalData = buildHierarchy(result.data); setLeftData(hierarchicalData); } catch (error) { console.error("좌측 데이터 로드 실패:", error); toast({ title: "데이터 로드 실패", description: "좌측 패널 데이터를 불러올 수 없습니다.", variant: "destructive", }); } finally { setIsLoadingLeft(false); } }, [componentConfig.leftPanel?.tableName, componentConfig.rightPanel?.relation?.leftColumn, isDesignMode, toast, buildHierarchy]); // 우측 데이터 로드 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 loadLeftColumnLabels = async () => { const leftTableName = componentConfig.leftPanel?.tableName; if (!leftTableName || isDesignMode) return; try { const columnsResponse = await tableTypeApi.getColumns(leftTableName); const labels: Record = {}; columnsResponse.forEach((col: any) => { const columnName = col.columnName || col.column_name; const label = col.columnLabel || col.column_label || col.displayName || columnName; if (columnName) { labels[columnName] = label; } }); setLeftColumnLabels(labels); console.log("✅ 좌측 컬럼 라벨 로드:", labels); } catch (error) { console.error("좌측 테이블 컬럼 라벨 로드 실패:", error); } }; loadLeftColumnLabels(); }, [componentConfig.leftPanel?.tableName, isDesignMode]); // 우측 테이블 컬럼 정보 로드 useEffect(() => { const loadRightTableColumns = async () => { const rightTableName = componentConfig.rightPanel?.tableName; if (!rightTableName || isDesignMode) return; try { const columnsResponse = await tableTypeApi.getColumns(rightTableName); setRightTableColumns(columnsResponse || []); // 우측 컬럼 라벨도 함께 로드 const labels: Record = {}; columnsResponse.forEach((col: any) => { const columnName = col.columnName || col.column_name; const label = col.columnLabel || col.column_label || col.displayName || columnName; if (columnName) { labels[columnName] = label; } }); setRightColumnLabels(labels); console.log("✅ 우측 컬럼 라벨 로드:", labels); } catch (error) { console.error("우측 테이블 컬럼 정보 로드 실패:", error); } }; 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); // 우측 패널 추가 시, 좌측에서 선택된 항목의 조인 컬럼 값을 자동으로 채움 if (panel === "right" && selectedLeftItem && componentConfig.leftPanel?.leftColumn && componentConfig.rightPanel?.rightColumn) { const leftColumnValue = selectedLeftItem[componentConfig.leftPanel.leftColumn]; setAddModalFormData({ [componentConfig.rightPanel.rightColumn]: leftColumnValue }); } else { setAddModalFormData({}); } setShowAddModal(true); }, [selectedLeftItem, componentConfig]); // 수정 버튼 핸들러 const handleEditClick = useCallback((panel: "left" | "right", item: any) => { setEditModalPanel(panel); setEditModalItem(item); setEditModalFormData({ ...item }); setShowEditModal(true); }, []); // 수정 모달 저장 const handleEditModalSave = useCallback(async () => { const tableName = editModalPanel === "left" ? componentConfig.leftPanel?.tableName : componentConfig.rightPanel?.tableName; const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || 'id'; const primaryKey = editModalItem[sourceColumn] || editModalItem.id || editModalItem.ID; if (!tableName || !primaryKey) { toast({ title: "수정 오류", description: "테이블명 또는 Primary Key가 없습니다.", variant: "destructive", }); return; } try { console.log("📝 데이터 수정:", { tableName, primaryKey, data: editModalFormData }); // 프론트엔드 전용 필드 제거 (children, level 등) const cleanData = { ...editModalFormData }; delete cleanData.children; delete cleanData.level; // 좌측 패널 수정 시, 조인 관계 정보 포함 let updatePayload: any = cleanData; if (editModalPanel === "left" && componentConfig.rightPanel?.relation?.type === "join") { // 조인 관계가 있는 경우, 관계 정보를 페이로드에 추가 updatePayload._relationInfo = { rightTable: componentConfig.rightPanel.tableName, leftColumn: componentConfig.rightPanel.relation.leftColumn, rightColumn: componentConfig.rightPanel.relation.rightColumn, oldLeftValue: editModalItem[componentConfig.rightPanel.relation.leftColumn], }; console.log("🔗 조인 관계 정보 추가:", updatePayload._relationInfo); } const result = await dataApi.updateRecord(tableName, primaryKey, updatePayload); if (result.success) { toast({ title: "성공", description: "데이터가 성공적으로 수정되었습니다.", }); // 모달 닫기 setShowEditModal(false); setEditModalFormData({}); setEditModalItem(null); // 데이터 새로고침 if (editModalPanel === "left") { loadLeftData(); // 우측 패널도 새로고침 (FK가 변경되었을 수 있음) if (selectedLeftItem) { loadRightData(selectedLeftItem); } } else if (editModalPanel === "right" && selectedLeftItem) { loadRightData(selectedLeftItem); } } else { toast({ title: "수정 실패", description: result.message || "데이터 수정에 실패했습니다.", variant: "destructive", }); } } catch (error: any) { console.error("데이터 수정 오류:", error); toast({ title: "오류", description: error?.response?.data?.message || "데이터 수정 중 오류가 발생했습니다.", variant: "destructive", }); } }, [editModalPanel, componentConfig, editModalItem, editModalFormData, toast, selectedLeftItem, loadLeftData, loadRightData]); // 삭제 버튼 핸들러 const handleDeleteClick = useCallback((panel: "left" | "right", item: any) => { setDeleteModalPanel(panel); setDeleteModalItem(item); setShowDeleteModal(true); }, []); // 삭제 확인 const handleDeleteConfirm = useCallback(async () => { // 우측 패널 삭제 시 중계 테이블 확인 let tableName = deleteModalPanel === "left" ? componentConfig.leftPanel?.tableName : componentConfig.rightPanel?.tableName; // 우측 패널 + 중계 테이블 모드인 경우 if (deleteModalPanel === "right" && componentConfig.rightPanel?.addConfig?.targetTable) { tableName = componentConfig.rightPanel.addConfig.targetTable; console.log("🔗 중계 테이블 모드: 삭제 대상 테이블 =", tableName); } const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || 'id'; let primaryKey: any = deleteModalItem[sourceColumn] || deleteModalItem.id || deleteModalItem.ID; // 복합키 처리: deleteModalItem 전체를 전달 (백엔드에서 복합키 자동 처리) if (deleteModalItem && typeof deleteModalItem === 'object') { primaryKey = deleteModalItem; console.log("🔑 복합키 가능성: 전체 객체 전달", primaryKey); } if (!tableName || !primaryKey) { toast({ title: "삭제 오류", description: "테이블명 또는 Primary Key가 없습니다.", variant: "destructive", }); return; } try { console.log("🗑️ 데이터 삭제:", { tableName, primaryKey }); const result = await dataApi.deleteRecord(tableName, primaryKey); if (result.success) { toast({ title: "성공", description: "데이터가 성공적으로 삭제되었습니다.", }); // 모달 닫기 setShowDeleteModal(false); setDeleteModalItem(null); // 데이터 새로고침 if (deleteModalPanel === "left") { loadLeftData(); // 삭제된 항목이 선택되어 있었으면 선택 해제 if (selectedLeftItem && selectedLeftItem[sourceColumn] === primaryKey) { setSelectedLeftItem(null); setRightData(null); } } else if (deleteModalPanel === "right" && selectedLeftItem) { loadRightData(selectedLeftItem); } } else { toast({ title: "삭제 실패", description: result.message || "데이터 삭제에 실패했습니다.", variant: "destructive", }); } } catch (error: any) { console.error("데이터 삭제 오류:", error); // 외래키 제약조건 에러 처리 let errorMessage = "데이터 삭제 중 오류가 발생했습니다."; if (error?.response?.data?.error?.includes("foreign key")) { errorMessage = "이 데이터를 참조하는 다른 데이터가 있어 삭제할 수 없습니다."; } toast({ title: "오류", description: errorMessage, variant: "destructive", }); } }, [deleteModalPanel, componentConfig, deleteModalItem, toast, selectedLeftItem, loadLeftData, loadRightData]); // 항목별 추가 버튼 핸들러 (좌측 항목의 + 버튼 - 하위 항목 추가) 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 () => { // 테이블명과 모달 컬럼 결정 let tableName: string | undefined; let modalColumns: Array<{ name: string; label: string; required?: boolean }> | undefined; let finalData = { ...addModalFormData }; if (addModalPanel === "left") { tableName = componentConfig.leftPanel?.tableName; modalColumns = componentConfig.leftPanel?.addModalColumns; } else if (addModalPanel === "right") { // 우측 패널: 중계 테이블 설정이 있는지 확인 const addConfig = componentConfig.rightPanel?.addConfig; if (addConfig?.targetTable) { // 중계 테이블 모드 tableName = addConfig.targetTable; modalColumns = componentConfig.rightPanel?.addModalColumns; // 좌측 패널에서 선택된 값 자동 채우기 if (addConfig.leftPanelColumn && addConfig.targetColumn && selectedLeftItem) { const leftValue = selectedLeftItem[addConfig.leftPanelColumn]; finalData[addConfig.targetColumn] = leftValue; console.log(`🔗 좌측 패널 값 자동 채움: ${addConfig.targetColumn} = ${leftValue}`); } // 자동 채움 컬럼 추가 if (addConfig.autoFillColumns) { Object.entries(addConfig.autoFillColumns).forEach(([key, value]) => { finalData[key] = value; }); console.log("🔧 자동 채움 컬럼:", addConfig.autoFillColumns); } } else { // 일반 테이블 모드 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({ 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: finalData }); const result = await dataApi.createRecord(tableName, finalData); if (result.success) { toast({ title: "성공", description: "데이터가 성공적으로 추가되었습니다.", }); // 모달 닫기 setShowAddModal(false); setAddModalFormData({}); // 데이터 새로고침 if (addModalPanel === "left" || addModalPanel === "left-item") { // 좌측 패널 데이터 새로고침 (일반 추가 또는 하위 항목 추가) loadLeftData(); } else if (addModalPanel === "right" && 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 (
{ if (isDesignMode) { e.stopPropagation(); onClick?.(e); } }} className="w-full overflow-hidden rounded-lg bg-white shadow-sm" > {/* 좌측 패널 */}
{componentConfig.leftPanel?.title || "좌측 패널"} {!isDesignMode && componentConfig.leftPanel?.showAdd && ( )}
{componentConfig.leftPanel?.showSearch && (
setLeftSearchQuery(e.target.value)} className="pl-9" />
)}
{/* 좌측 데이터 목록/테이블 */} {componentConfig.leftPanel?.displayMode === "table" ? ( // 테이블 모드
{isDesignMode ? ( // 디자인 모드: 샘플 테이블
컬럼 1 컬럼 2 컬럼 3
데이터 1-1 데이터 1-2 데이터 1-3
데이터 2-1 데이터 2-2 데이터 2-3
) : isLoadingLeft ? (
데이터를 불러오는 중...
) : ( (() => { const filteredData = 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; const displayColumns = componentConfig.leftPanel?.columns || []; const columnsToShow = displayColumns.length > 0 ? displayColumns.map(col => ({ ...col, label: leftColumnLabels[col.name] || col.label || col.name })) : Object.keys(filteredData[0] || {}).filter(key => key !== 'children' && key !== 'level').slice(0, 5).map(key => ({ name: key, label: leftColumnLabels[key] || key, width: 150, align: "left" as const })); return (
{columnsToShow.map((col, idx) => ( ))} {filteredData.map((item, idx) => { const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || 'id'; const itemId = item[sourceColumn] || item.id || item.ID || idx; const isSelected = selectedLeftItem && (selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item); return ( handleLeftItemSelect(item)} className={`hover:bg-accent cursor-pointer transition-colors ${ isSelected ? "bg-primary/10" : "" }`} > {columnsToShow.map((col, colIdx) => ( ))} ); })}
{col.label}
{item[col.name] !== null && item[col.name] !== undefined ? String(item[col.name]) : "-"}
); })() )}
) : ( // 목록 모드 (기존)
{isDesignMode ? ( // 디자인 모드: 샘플 데이터 <>
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" : "" }`} >
항목 1
설명 텍스트
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" : "" }`} >
항목 2
설명 텍스트
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" : "" }`} >
항목 3
설명 텍스트
) : isLoadingLeft ? ( // 로딩 중
데이터를 불러오는 중...
) : ( (() => { // 검색 필터링 (클라이언트 사이드) 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; // 재귀 렌더링 함수 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 }[] = []; // 디버그 로그 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 ( {/* 현재 항목 */}
{ handleLeftItemSelect(item); if (hasChildren) { toggleExpand(itemId); } }} > {/* 펼치기/접기 아이콘 */} {hasChildren ? (
{isExpanded ? ( ) : ( )}
) : (
)} {/* 항목 내용 */}
{displayTitle}
{displaySubtitle &&
{displaySubtitle}
}
{/* 항목별 버튼들 */} {!isDesignMode && (
{/* 수정 버튼 */} {/* 삭제 버튼 */} {/* 항목별 추가 버튼 */} {componentConfig.leftPanel?.showItemAddButton && ( )}
)}
{/* 자식 항목들 (접혀있으면 표시 안함) */} {hasChildren && isExpanded && item.children.map((child: any, childIndex: number) => renderTreeItem(child, childIndex))} ); }; return filteredLeftData.length > 0 ? ( // 실제 데이터 표시 filteredLeftData.map((item, index) => renderTreeItem(item, index)) ) : ( // 검색 결과 없음
{leftSearchQuery ? ( <>

검색 결과가 없습니다.

다른 검색어를 입력해보세요.

) : ( "데이터가 없습니다." )}
); })() )}
)}
{/* 리사이저 */} {resizable && (
)} {/* 우측 패널 */}
{componentConfig.rightPanel?.title || "우측 패널"} {!isDesignMode && (
{componentConfig.rightPanel?.showAdd && ( )} {/* 우측 패널 수정/삭제는 각 카드에서 처리 */}
)}
{componentConfig.rightPanel?.showSearch && (
setRightSearchQuery(e.target.value)} className="pl-9" />
)}
{/* 우측 데이터 */} {isLoadingRight ? ( // 로딩 중

데이터를 불러오는 중...

) : 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; // 테이블 모드 체크 const isTableMode = componentConfig.rightPanel?.displayMode === "table"; if (isTableMode) { // 테이블 모드 렌더링 const displayColumns = componentConfig.rightPanel?.columns || []; const columnsToShow = displayColumns.length > 0 ? displayColumns.map(col => ({ ...col, label: rightColumnLabels[col.name] || col.label || col.name })) : Object.keys(filteredData[0] || {}).filter(key => !key.toLowerCase().includes("password")).slice(0, 5).map(key => ({ name: key, label: rightColumnLabels[key] || key, width: 150, align: "left" as const })); return (
{filteredData.length}개의 관련 데이터 {rightSearchQuery && filteredData.length !== rightData.length && ( (전체 {rightData.length}개 중) )}
{columnsToShow.map((col, idx) => ( ))} {!isDesignMode && ( )} {filteredData.map((item, idx) => { const itemId = item.id || item.ID || idx; return ( {columnsToShow.map((col, colIdx) => ( ))} {!isDesignMode && ( )} ); })}
{col.label} 작업
{item[col.name] !== null && item[col.name] !== undefined ? String(item[col.name]) : "-"}
); } // 목록 모드 (기존) return filteredData.length > 0 ? (
{filteredData.length}개의 관련 데이터 {rightSearchQuery && filteredData.length !== rightData.length && ( (전체 {rightData.length}개 중) )}
{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 (
{/* 요약 정보 */}
toggleRightItemExpansion(itemId)} > {firstValues.map(([key, value], idx) => (
{getColumnLabel(key)}
{String(value || "-")}
))}
{/* 수정 버튼 */} {!isDesignMode && ( )} {/* 삭제 버튼 */} {!isDesignMode && ( )} {/* 확장/접기 버튼 */}
{/* 상세 정보 (확장 시 표시) */} {isExpanded && (
전체 상세 정보
{allValues.map(([key, value]) => ( ))}
{getColumnLabel(key)} {String(value)}
)}
); })}
) : (
{rightSearchQuery ? ( <>

검색 결과가 없습니다.

다른 검색어를 입력해보세요.

) : ( "관련 데이터가 없습니다." )}
); })() ) : ( // 상세 모드: 단일 객체를 상세 정보로 표시 (() => { 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 (
{displayEntries.map(([key, value]) => (
{getColumnLabel(key)}
{String(value)}
))}
); })() ) ) : selectedLeftItem && isDesignMode ? ( // 디자인 모드: 샘플 데이터

{selectedLeftItem.name} 상세 정보

항목 1: 값 1
항목 2: 값 2
항목 3: 값 3
) : ( // 선택 없음

좌측에서 항목을 선택하세요

선택한 항목의 상세 정보가 여기에 표시됩니다

)}
{/* 추가 모달 */} {addModalPanel === "left" ? `${componentConfig.leftPanel?.title} 추가` : addModalPanel === "right" ? `${componentConfig.rightPanel?.title} 추가` : `하위 ${componentConfig.leftPanel?.title} 추가`} {addModalPanel === "left-item" ? "선택한 항목의 하위 항목을 추가합니다. 필수 항목을 입력해주세요." : "새로운 데이터를 추가합니다. 필수 항목을 입력해주세요."}
{(() => { // 어떤 컬럼들을 표시할지 결정 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 isItemAddPreFilled = addModalPanel === "left-item" && componentConfig.leftPanel?.itemAddConfig?.parentColumn === col.name && addModalFormData[col.name]; // 우측 패널 추가 시, 조인 컬럼(rightColumn)은 미리 채워져 있고 수정 불가 const isRightJoinPreFilled = addModalPanel === "right" && componentConfig.rightPanel?.rightColumn === col.name && addModalFormData[col.name]; const isPreFilled = isItemAddPreFilled || isRightJoinPreFilled; return (
{ 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} />
); }); })()}
{/* 수정 모달 */} {editModalPanel === "left" ? `${componentConfig.leftPanel?.title} 수정` : `${componentConfig.rightPanel?.title} 수정`} 데이터를 수정합니다. 필요한 항목을 변경해주세요.
{editModalItem && (() => { // 좌측 패널 수정: leftColumn만 수정 가능 if (editModalPanel === "left") { const leftColumn = componentConfig.rightPanel?.relation?.leftColumn; // leftColumn만 표시 if (!leftColumn || editModalFormData[leftColumn] === undefined) { return

수정 가능한 컬럼이 없습니다.

; } return (
{ setEditModalFormData(prev => ({ ...prev, [leftColumn]: e.target.value })); }} placeholder={`${leftColumn} 입력`} className="h-8 text-xs sm:h-10 sm:text-sm" />
); } // 우측 패널 수정: 우측 패널에 설정된 표시 컬럼들만 if (editModalPanel === "right") { const rightColumns = componentConfig.rightPanel?.columns; if (rightColumns && rightColumns.length > 0) { // 설정된 컬럼만 표시 return rightColumns.map((col) => (
{ setEditModalFormData(prev => ({ ...prev, [col.name]: e.target.value })); }} placeholder={`${col.label || col.name} 입력`} className="h-8 text-xs sm:h-10 sm:text-sm" />
)); } else { // 설정이 없으면 모든 컬럼 표시 (company_code, company_name 제외) return Object.entries(editModalFormData) .filter(([key]) => key !== 'company_code' && key !== 'company_name') .map(([key, value]) => (
{ setEditModalFormData(prev => ({ ...prev, [key]: e.target.value })); }} placeholder={`${key} 입력`} className="h-8 text-xs sm:h-10 sm:text-sm" />
)); } } return null; })()}
{/* 삭제 확인 모달 */} 삭제 확인 정말로 이 데이터를 삭제하시겠습니까?
이 작업은 되돌릴 수 없습니다.
); }; /** * SplitPanelLayout 래퍼 컴포넌트 */ export const SplitPanelLayoutWrapper: React.FC = (props) => { return ; };