From 672aba84047a7b8f0b039d08fa1649a6e459fc91 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Fri, 7 Nov 2025 15:21:44 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B3=84=EC=B8=B5=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC=20=EB=B7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SplitPanelLayoutComponent.tsx | 304 +++++++++++++--- .../SplitPanelLayoutConfigPanel.tsx | 324 ++++++++++++++++++ .../components/split-panel-layout/types.ts | 15 + 3 files changed, 587 insertions(+), 56 deletions(-) diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index 351dc218..c945b04f 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -6,7 +6,7 @@ import { SplitPanelLayoutConfig } from "./types"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Plus, Search, GripVertical, Loader2, ChevronDown, ChevronUp, Save } from "lucide-react"; +import { Plus, Search, GripVertical, Loader2, ChevronDown, ChevronUp, Save, ChevronRight } from "lucide-react"; import { dataApi } from "@/lib/api/data"; import { useToast } from "@/hooks/use-toast"; import { tableTypeApi } from "@/lib/api/screen"; @@ -47,11 +47,12 @@ export const SplitPanelLayoutComponent: React.FC const [isLoadingLeft, setIsLoadingLeft] = useState(false); const [isLoadingRight, setIsLoadingRight] = useState(false); const [rightTableColumns, setRightTableColumns] = useState([]); // 우측 테이블 컬럼 정보 + const [expandedItems, setExpandedItems] = useState>(new Set()); // 펼쳐진 항목들 const { toast } = useToast(); // 추가 모달 상태 const [showAddModal, setShowAddModal] = useState(false); - const [addModalPanel, setAddModalPanel] = useState<"left" | "right" | null>(null); + const [addModalPanel, setAddModalPanel] = useState<"left" | "right" | "left-item" | null>(null); const [addModalFormData, setAddModalFormData] = useState>({}); // 리사이저 드래그 상태 @@ -88,6 +89,53 @@ export const SplitPanelLayoutComponent: React.FC 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; @@ -100,7 +148,10 @@ export const SplitPanelLayoutComponent: React.FC size: 100, // searchTerm 제거 - 클라이언트 사이드에서 필터링 }); - setLeftData(result.data); + + // 계층 구조 빌드 + const hierarchicalData = buildHierarchy(result.data); + setLeftData(hierarchicalData); } catch (error) { console.error("좌측 데이터 로드 실패:", error); toast({ @@ -111,7 +162,7 @@ export const SplitPanelLayoutComponent: React.FC } finally { setIsLoadingLeft(false); } - }, [componentConfig.leftPanel?.tableName, isDesignMode, toast]); + }, [componentConfig.leftPanel?.tableName, isDesignMode, toast, buildHierarchy]); // 우측 데이터 로드 const loadRightData = useCallback( @@ -215,6 +266,19 @@ export const SplitPanelLayoutComponent: React.FC loadRightTableColumns(); }, [componentConfig.rightPanel?.tableName, isDesignMode]); + // 항목 펼치기/접기 토글 + const toggleExpand = useCallback((itemId: any) => { + setExpandedItems(prev => { + const newSet = new Set(prev); + if (newSet.has(itemId)) { + newSet.delete(itemId); + } else { + newSet.add(itemId); + } + return newSet; + }); + }, []); + // 추가 버튼 핸들러 const handleAddClick = useCallback((panel: "left" | "right") => { setAddModalPanel(panel); @@ -222,15 +286,65 @@ export const SplitPanelLayoutComponent: React.FC setShowAddModal(true); }, []); + // 항목별 추가 버튼 핸들러 (좌측 항목의 + 버튼 - 하위 항목 추가) + const handleItemAddClick = useCallback((item: any) => { + const itemAddConfig = componentConfig.leftPanel?.itemAddConfig; + + if (!itemAddConfig) { + toast({ + title: "설정 오류", + description: "하위 항목 추가 설정이 없습니다.", + variant: "destructive", + }); + return; + } + + const { sourceColumn, parentColumn } = itemAddConfig; + + if (!sourceColumn || !parentColumn) { + toast({ + title: "설정 오류", + description: "현재 항목 ID 컬럼과 상위 항목 저장 컬럼을 설정해주세요.", + variant: "destructive", + }); + return; + } + + // 선택된 항목의 sourceColumn 값을 가져와서 parentColumn에 매핑 + const sourceValue = item[sourceColumn]; + + if (!sourceValue) { + toast({ + title: "데이터 오류", + description: `선택한 항목의 ${sourceColumn} 값이 없습니다.`, + variant: "destructive", + }); + return; + } + + // 좌측 패널 추가 모달 열기 (parentColumn 값 미리 채우기) + setAddModalPanel("left-item"); + setAddModalFormData({ [parentColumn]: sourceValue }); + setShowAddModal(true); + }, [componentConfig, toast]); + // 추가 모달 저장 const handleAddModalSave = useCallback(async () => { - const tableName = addModalPanel === "left" - ? componentConfig.leftPanel?.tableName - : componentConfig.rightPanel?.tableName; + // 테이블명과 모달 컬럼 결정 + let tableName: string | undefined; + let modalColumns: Array<{ name: string; label: string; required?: boolean }> | undefined; - const modalColumns = addModalPanel === "left" - ? componentConfig.leftPanel?.addModalColumns - : componentConfig.rightPanel?.addModalColumns; + if (addModalPanel === "left") { + tableName = componentConfig.leftPanel?.tableName; + modalColumns = componentConfig.leftPanel?.addModalColumns; + } else if (addModalPanel === "right") { + tableName = componentConfig.rightPanel?.tableName; + modalColumns = componentConfig.rightPanel?.addModalColumns; + } else if (addModalPanel === "left-item") { + // 하위 항목 추가 (좌측 테이블에 추가) + tableName = componentConfig.leftPanel?.tableName; + modalColumns = componentConfig.leftPanel?.itemAddConfig?.addModalColumns; + } if (!tableName) { toast({ @@ -270,9 +384,11 @@ export const SplitPanelLayoutComponent: React.FC setAddModalFormData({}); // 데이터 새로고침 - if (addModalPanel === "left") { + if (addModalPanel === "left" || addModalPanel === "left-item") { + // 좌측 패널 데이터 새로고침 (일반 추가 또는 하위 항목 추가) loadLeftData(); - } else if (selectedLeftItem) { + } else if (addModalPanel === "right" && selectedLeftItem) { + // 우측 패널 데이터 새로고침 loadRightData(selectedLeftItem); } } else { @@ -487,16 +603,18 @@ export const SplitPanelLayoutComponent: React.FC }) : leftData; - return filteredLeftData.length > 0 ? ( - // 실제 데이터 표시 - filteredLeftData.map((item, index) => { - const itemId = item.id || item.ID || item[Object.keys(item)[0]] || index; - const isSelected = - selectedLeftItem && (selectedLeftItem.id === itemId || selectedLeftItem === item); + // 재귀 렌더링 함수 + const renderTreeItem = (item: any, index: number): React.ReactNode => { + const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || 'id'; + const itemId = item[sourceColumn] || item.id || item.ID || index; + const isSelected = selectedLeftItem && (selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item); + const hasChildren = item.children && item.children.length > 0; + const isExpanded = expandedItems.has(itemId); + const level = item.level || 0; - // 조인에 사용하는 leftColumn을 필수로 표시 - const leftColumn = componentConfig.rightPanel?.relation?.leftColumn; - let displayFields: { label: string; value: any }[] = []; + // 조인에 사용하는 leftColumn을 필수로 표시 + const leftColumn = componentConfig.rightPanel?.relation?.leftColumn; + let displayFields: { label: string; value: any }[] = []; // 디버그 로그 if (index === 0) { @@ -541,22 +659,71 @@ export const SplitPanelLayoutComponent: React.FC } } - const displayTitle = displayFields[0]?.value || item.name || item.title || `항목 ${index + 1}`; - const displaySubtitle = displayFields[1]?.value || null; + const displayTitle = displayFields[0]?.value || item.name || item.title || `항목 ${index + 1}`; + const displaySubtitle = displayFields[1]?.value || null; - return ( + return ( + + {/* 현재 항목 */}
handleLeftItemSelect(item)} - className={`cursor-pointer rounded-md p-3 transition-colors hover:bg-muted ${ + className={`group relative cursor-pointer rounded-md p-3 transition-colors hover:bg-muted ${ isSelected ? "bg-primary/10 text-primary" : "text-foreground" }`} + style={{ paddingLeft: `${12 + level * 24}px` }} > -
{displayTitle}
- {displaySubtitle &&
{displaySubtitle}
} +
{ + handleLeftItemSelect(item); + if (hasChildren) { + toggleExpand(itemId); + } + }} + > + {/* 펼치기/접기 아이콘 */} + {hasChildren ? ( +
+ {isExpanded ? ( + + ) : ( + + )} +
+ ) : ( +
+ )} + + {/* 항목 내용 */} +
+
{displayTitle}
+ {displaySubtitle &&
{displaySubtitle}
} +
+ + {/* 항목별 추가 버튼 */} + {componentConfig.leftPanel?.showItemAddButton && !isDesignMode && ( + + )} +
- ); - }) + + {/* 자식 항목들 (접혀있으면 표시 안함) */} + {hasChildren && isExpanded && item.children.map((child: any, childIndex: number) => renderTreeItem(child, childIndex))} + + ); + }; + + return filteredLeftData.length > 0 ? ( + // 실제 데이터 표시 + filteredLeftData.map((item, index) => renderTreeItem(item, index)) ) : ( // 검색 결과 없음
@@ -842,37 +1009,62 @@ export const SplitPanelLayoutComponent: React.FC - {addModalPanel === "left" ? componentConfig.leftPanel?.title : componentConfig.rightPanel?.title} 추가 + {addModalPanel === "left" + ? `${componentConfig.leftPanel?.title} 추가` + : addModalPanel === "right" + ? `${componentConfig.rightPanel?.title} 추가` + : `하위 ${componentConfig.leftPanel?.title} 추가`} - 새로운 데이터를 추가합니다. 필수 항목을 입력해주세요. + {addModalPanel === "left-item" + ? "선택한 항목의 하위 항목을 추가합니다. 필수 항목을 입력해주세요." + : "새로운 데이터를 추가합니다. 필수 항목을 입력해주세요."}
- {(addModalPanel === "left" - ? componentConfig.leftPanel?.addModalColumns - : componentConfig.rightPanel?.addModalColumns - )?.map((col, index) => ( -
- - { - 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} - /> -
- ))} + {(() => { + // 어떤 컬럼들을 표시할지 결정 + let modalColumns: Array<{ name: string; label: string; required?: boolean }> | undefined; + + if (addModalPanel === "left") { + modalColumns = componentConfig.leftPanel?.addModalColumns; + } else if (addModalPanel === "right") { + modalColumns = componentConfig.rightPanel?.addModalColumns; + } else if (addModalPanel === "left-item") { + modalColumns = componentConfig.leftPanel?.itemAddConfig?.addModalColumns; + } + + return modalColumns?.map((col, index) => { + // 항목별 추가 버튼으로 열렸을 때, parentColumn은 미리 채워져 있고 수정 불가 + const isPreFilled = addModalPanel === "left-item" + && componentConfig.leftPanel?.itemAddConfig?.parentColumn === col.name + && addModalFormData[col.name]; + + return ( +
+ + { + 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} + /> +
+ ); + }); + })()}
diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx index 2c7a0f00..fe513550 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx @@ -90,6 +90,29 @@ export const SplitPanelLayoutConfigPanel: React.FC { + const leftTableName = config.leftPanel?.tableName || screenTableName; + if (leftTableName && loadedTableColumns[leftTableName] && config.leftPanel?.showItemAddButton) { + const currentAddModalColumns = config.leftPanel?.itemAddConfig?.addModalColumns || []; + const updatedColumns = ensurePrimaryKeysInAddModal(leftTableName, currentAddModalColumns); + + // PK가 추가되었으면 업데이트 + if (updatedColumns.length !== currentAddModalColumns.length) { + console.log(`🔄 좌측 패널 하위 항목 추가: PK 컬럼 자동 추가 (${leftTableName})`); + updateLeftPanel({ + itemAddConfig: { + ...config.leftPanel?.itemAddConfig, + addModalColumns: updatedColumns, + parentColumn: config.leftPanel?.itemAddConfig?.parentColumn || "", + sourceColumn: config.leftPanel?.itemAddConfig?.sourceColumn || "", + } + }); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [config.leftPanel?.tableName, screenTableName, loadedTableColumns, config.leftPanel?.showItemAddButton]); + // 우측 패널 테이블 컬럼 로드 완료 시 PK 자동 추가 useEffect(() => { const rightTableName = config.rightPanel?.tableName; @@ -340,6 +363,307 @@ export const SplitPanelLayoutConfigPanel: React.FC
+
+ + updateLeftPanel({ showItemAddButton: checked })} + /> +
+ + {/* 항목별 + 버튼 설정 (하위 항목 추가) */} + {config.leftPanel?.showItemAddButton && ( +
+ +

+ + 버튼 클릭 시 선택된 항목의 하위 항목을 추가합니다 (예: 부서 → 하위 부서) +

+ + {/* 현재 항목의 값을 가져올 컬럼 (sourceColumn) */} +
+ +

+ 선택된 항목의 어떤 컬럼 값을 사용할지 (예: dept_code) +

+ + + + + + + + 컬럼을 찾을 수 없습니다. + + {leftTableColumns + .filter((column) => !['company_code', 'company_name'].includes(column.columnName)) + .map((column) => ( + { + updateLeftPanel({ + itemAddConfig: { + ...config.leftPanel?.itemAddConfig, + sourceColumn: value, + parentColumn: config.leftPanel?.itemAddConfig?.parentColumn || "", + } + }); + }} + className="text-xs" + > + + {column.columnLabel || column.columnName} + + ({column.columnName}) + + + ))} + + + + +
+ + {/* 상위 항목 ID를 저장할 컬럼 (parentColumn) */} +
+ +

+ 하위 항목에서 상위 항목 ID를 저장할 컬럼 (예: parent_dept_code) +

+ + + + + + + + 컬럼을 찾을 수 없습니다. + + {leftTableColumns + .filter((column) => !['company_code', 'company_name'].includes(column.columnName)) + .map((column) => ( + { + updateLeftPanel({ + itemAddConfig: { + ...config.leftPanel?.itemAddConfig, + parentColumn: value, + sourceColumn: config.leftPanel?.itemAddConfig?.sourceColumn || "", + } + }); + }} + className="text-xs" + > + + {column.columnLabel || column.columnName} + + ({column.columnName}) + + + ))} + + + + +
+ + {/* 하위 항목 추가 모달 컬럼 설정 */} +
+
+ + +
+

+ 하위 항목 추가 시 입력받을 필드를 선택하세요 +

+ +
+ {(config.leftPanel?.itemAddConfig?.addModalColumns || []).length === 0 ? ( +
+

설정된 컬럼이 없습니다

+
+ ) : ( + (config.leftPanel?.itemAddConfig?.addModalColumns || []).map((col, index) => { + const column = leftTableColumns.find(c => c.columnName === col.name); + const isPK = column?.isPrimaryKey || false; + + return ( +
+ {isPK && ( + + PK + + )} +
+ + + + + + + + 컬럼을 찾을 수 없습니다. + + {leftTableColumns + .filter((column) => !['company_code', 'company_name'].includes(column.columnName)) + .map((column) => ( + { + const newColumns = [...(config.leftPanel?.itemAddConfig?.addModalColumns || [])]; + newColumns[index] = { + ...newColumns[index], + name: value, + label: column.columnLabel || value, + }; + updateLeftPanel({ + itemAddConfig: { + ...config.leftPanel?.itemAddConfig, + addModalColumns: newColumns, + parentColumn: config.leftPanel?.itemAddConfig?.parentColumn || "", + sourceColumn: config.leftPanel?.itemAddConfig?.sourceColumn || "", + } + }); + }} + className="text-xs" + > + + {column.columnLabel || column.columnName} + + ({column.columnName}) + + + ))} + + + + +
+
+ +
+ +
+ ); + }) + )} +
+
+
+ )} + {/* 좌측 패널 추가 모달 컬럼 설정 */} {config.leftPanel?.showAdd && (
diff --git a/frontend/lib/registry/components/split-panel-layout/types.ts b/frontend/lib/registry/components/split-panel-layout/types.ts index a192587f..6e1a4d7f 100644 --- a/frontend/lib/registry/components/split-panel-layout/types.ts +++ b/frontend/lib/registry/components/split-panel-layout/types.ts @@ -21,6 +21,21 @@ export interface SplitPanelLayoutConfig { label: string; required?: boolean; }>; + // 각 항목에 + 버튼 표시 (하위 항목 추가) + showItemAddButton?: boolean; + // + 버튼 클릭 시 하위 항목 추가를 위한 설정 + itemAddConfig?: { + // 하위 항목 추가 모달에서 입력받을 컬럼 + addModalColumns?: Array<{ + name: string; + label: string; + required?: boolean; + }>; + // 상위 항목의 ID를 저장할 컬럼 (예: parent_dept_code) + parentColumn: string; + // 현재 항목의 어떤 컬럼 값을 parentColumn에 넣을지 (예: dept_code) + sourceColumn: string; + }; }; // 우측 패널 설정