From 52ad67d44a64f526ddfd1cc3f333cb91e5a54ad4 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 3 Dec 2025 17:45:22 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20SplitPanelLayout2=20=EB=A7=88=EC=8A=A4?= =?UTF-8?q?=ED=84=B0-=EB=94=94=ED=85=8C=EC=9D=BC=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84=20=EC=A2=8C=EC=B8=A1=20?= =?UTF-8?q?=ED=8C=A8=EB=84=90(=EB=A7=88=EC=8A=A4=ED=84=B0)-=EC=9A=B0?= =?UTF-8?q?=EC=B8=A1=20=ED=8C=A8=EB=84=90(=EB=94=94=ED=85=8C=EC=9D=BC)=20?= =?UTF-8?q?=EB=B6=84=ED=95=A0=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80=20Edi?= =?UTF-8?q?tModal=EC=97=90=20isCreateMode=20=ED=94=8C=EB=9E=98=EA=B7=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=98=EC=97=AC=20INSERT/UPDATE=20?= =?UTF-8?q?=EB=B6=84=EA=B8=B0=20=EC=B2=98=EB=A6=AC=20dataFilter=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EC=A0=95=ED=99=95=ED=95=9C=20=EC=A1=B0?= =?UTF-8?q?=EC=9D=B8=20=ED=95=84=ED=84=B0=EB=A7=81=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EC=A2=8C=EC=B8=A1=20=ED=8C=A8=EB=84=90=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=A5=BC=20=EB=AA=A8=EB=8B=AC?= =?UTF-8?q?=EB=A1=9C=20=EC=9E=90=EB=8F=99=20=EC=A0=84=EB=8B=AC=ED=95=98?= =?UTF-8?q?=EB=8A=94=20dataTransferFields=20=EC=84=A4=EC=A0=95=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=20ConfigPanel=EC=97=90=EC=84=9C=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94,=20=EC=BB=AC=EB=9F=BC,=20=EC=A1=B0=EC=9D=B8=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EA=B0=80=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/screen/EditModal.tsx | 111 ++- frontend/lib/registry/components/index.ts | 1 + .../components/split-panel-layout2/README.md | 102 +++ .../SplitPanelLayout2Component.tsx | 774 ++++++++++++++++++ .../SplitPanelLayout2ConfigPanel.tsx | 684 ++++++++++++++++ .../SplitPanelLayout2Renderer.tsx | 42 + .../components/split-panel-layout2/config.ts | 57 ++ .../components/split-panel-layout2/index.ts | 41 + .../components/split-panel-layout2/types.ts | 102 +++ .../lib/utils/getComponentConfigPanel.tsx | 1 + 10 files changed, 1878 insertions(+), 37 deletions(-) create mode 100644 frontend/lib/registry/components/split-panel-layout2/README.md create mode 100644 frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx create mode 100644 frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx create mode 100644 frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Renderer.tsx create mode 100644 frontend/lib/registry/components/split-panel-layout2/config.ts create mode 100644 frontend/lib/registry/components/split-panel-layout2/index.ts create mode 100644 frontend/lib/registry/components/split-panel-layout2/types.ts diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 9945a19c..cde9086c 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -118,7 +118,7 @@ export const EditModal: React.FC = ({ className }) => { // 전역 모달 이벤트 리스너 useEffect(() => { const handleOpenEditModal = (event: CustomEvent) => { - const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName } = event.detail; + const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName, isCreateMode } = event.detail; setModalState({ isOpen: true, @@ -134,7 +134,13 @@ export const EditModal: React.FC = ({ className }) => { // 편집 데이터로 폼 데이터 초기화 setFormData(editData || {}); - setOriginalData(editData || {}); + // 🆕 isCreateMode가 true이면 originalData를 빈 객체로 설정 (INSERT 모드) + // originalData가 비어있으면 INSERT, 있으면 UPDATE로 처리됨 + setOriginalData(isCreateMode ? {} : (editData || {})); + + if (isCreateMode) { + console.log("[EditModal] 생성 모드로 열림, 초기값:", editData); + } }; const handleCloseEditModal = () => { @@ -567,46 +573,77 @@ export const EditModal: React.FC = ({ className }) => { return; } - // 기존 로직: 단일 레코드 수정 - const changedData: Record = {}; - Object.keys(formData).forEach((key) => { - if (formData[key] !== originalData[key]) { - changedData[key] = formData[key]; - } - }); + // originalData가 비어있으면 INSERT, 있으면 UPDATE + const isCreateMode = Object.keys(originalData).length === 0; + + if (isCreateMode) { + // INSERT 모드 + console.log("[EditModal] INSERT 모드 - 새 데이터 생성:", formData); + + const response = await dynamicFormApi.saveFormData({ + screenId: modalState.screenId!, + tableName: screenData.screenInfo.tableName, + data: formData, + }); - if (Object.keys(changedData).length === 0) { - toast.info("변경된 내용이 없습니다."); - handleClose(); - return; - } + if (response.success) { + toast.success("데이터가 생성되었습니다."); - // 기본키 확인 (id 또는 첫 번째 키) - const recordId = originalData.id || Object.values(originalData)[0]; - - // UPDATE 액션 실행 - const response = await dynamicFormApi.updateFormDataPartial( - recordId, - originalData, - changedData, - screenData.screenInfo.tableName, - ); - - if (response.success) { - toast.success("데이터가 수정되었습니다."); - - // 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침) - if (modalState.onSave) { - try { - modalState.onSave(); - } catch (callbackError) { - console.error("⚠️ onSave 콜백 에러:", callbackError); + // 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침) + if (modalState.onSave) { + try { + modalState.onSave(); + } catch (callbackError) { + console.error("onSave 콜백 에러:", callbackError); + } } + + handleClose(); + } else { + throw new Error(response.message || "생성에 실패했습니다."); + } + } else { + // UPDATE 모드 - 기존 로직 + const changedData: Record = {}; + Object.keys(formData).forEach((key) => { + if (formData[key] !== originalData[key]) { + changedData[key] = formData[key]; + } + }); + + if (Object.keys(changedData).length === 0) { + toast.info("변경된 내용이 없습니다."); + handleClose(); + return; } - handleClose(); - } else { - throw new Error(response.message || "수정에 실패했습니다."); + // 기본키 확인 (id 또는 첫 번째 키) + const recordId = originalData.id || Object.values(originalData)[0]; + + // UPDATE 액션 실행 + const response = await dynamicFormApi.updateFormDataPartial( + recordId, + originalData, + changedData, + screenData.screenInfo.tableName, + ); + + if (response.success) { + toast.success("데이터가 수정되었습니다."); + + // 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침) + if (modalState.onSave) { + try { + modalState.onSave(); + } catch (callbackError) { + console.error("onSave 콜백 에러:", callbackError); + } + } + + handleClose(); + } else { + throw new Error(response.message || "수정에 실패했습니다."); + } } } catch (error: any) { console.error("❌ 수정 실패:", error); diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index fb7cd30b..746e2c2d 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -37,6 +37,7 @@ import "./accordion-basic/AccordionBasicRenderer"; import "./table-list/TableListRenderer"; import "./card-display/CardDisplayRenderer"; import "./split-panel-layout/SplitPanelLayoutRenderer"; +import "./split-panel-layout2/SplitPanelLayout2Renderer"; // 분할 패널 레이아웃 v2 import "./map/MapRenderer"; import "./repeater-field-group/RepeaterFieldGroupRenderer"; import "./flow-widget/FlowWidgetRenderer"; diff --git a/frontend/lib/registry/components/split-panel-layout2/README.md b/frontend/lib/registry/components/split-panel-layout2/README.md new file mode 100644 index 00000000..f1d8544b --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout2/README.md @@ -0,0 +1,102 @@ +# SplitPanelLayout2 컴포넌트 + +마스터-디테일 패턴의 좌우 분할 레이아웃 컴포넌트 (개선 버전) + +## 개요 + +- **ID**: `split-panel-layout2` +- **카테고리**: layout +- **웹타입**: container +- **버전**: 2.0.0 + +## 주요 기능 + +- 좌측 패널: 마스터 데이터 목록 (예: 부서 목록) +- 우측 패널: 디테일 데이터 목록 (예: 부서원 목록) +- 조인 기반 데이터 연결 +- 검색 기능 (좌측/우측 모두) +- 계층 구조 지원 (트리 형태) +- 데이터 전달 기능 (모달로 선택된 데이터 전달) +- 리사이즈 가능한 분할 바 + +## 사용 예시 + +### 부서-사원 관리 + +1. 좌측 패널: `dept_info` 테이블 (부서 목록) +2. 우측 패널: `user_info` 테이블 (사원 목록) +3. 조인 조건: `dept_code` = `dept_code` +4. 데이터 전달: `dept_code`, `dept_name`, `company_code` + +## 설정 옵션 + +### 좌측 패널 설정 + +| 속성 | 타입 | 설명 | +|------|------|------| +| title | string | 패널 제목 | +| tableName | string | 테이블명 | +| displayColumns | ColumnConfig[] | 표시할 컬럼 목록 | +| searchColumn | string | 검색 대상 컬럼 | +| showSearch | boolean | 검색 기능 표시 여부 | +| hierarchyConfig | object | 계층 구조 설정 | + +### 우측 패널 설정 + +| 속성 | 타입 | 설명 | +|------|------|------| +| title | string | 패널 제목 | +| tableName | string | 테이블명 | +| displayColumns | ColumnConfig[] | 표시할 컬럼 목록 | +| searchColumn | string | 검색 대상 컬럼 | +| showSearch | boolean | 검색 기능 표시 여부 | +| showAddButton | boolean | 추가 버튼 표시 | +| showEditButton | boolean | 수정 버튼 표시 | +| showDeleteButton | boolean | 삭제 버튼 표시 | + +### 조인 설정 + +| 속성 | 타입 | 설명 | +|------|------|------| +| leftColumn | string | 좌측 테이블의 조인 컬럼 | +| rightColumn | string | 우측 테이블의 조인 컬럼 | + +### 데이터 전달 설정 + +| 속성 | 타입 | 설명 | +|------|------|------| +| sourceColumn | string | 좌측 패널의 소스 컬럼 | +| targetColumn | string | 모달로 전달할 타겟 컬럼명 | + +## 데이터 흐름 + +``` +[좌측 패널 항목 클릭] + ↓ +[selectedLeftItem 상태 저장] + ↓ +[modalDataStore에 테이블명으로 저장] + ↓ +[ScreenContext DataProvider 등록] + ↓ +[우측 패널 데이터 로드 (조인 조건 적용)] +``` + +## 버튼과 연동 + +버튼 컴포넌트에서 이 컴포넌트의 선택된 데이터에 접근하려면: + +1. 버튼의 액션 타입을 `openModalWithData`로 설정 +2. 데이터 소스 ID를 좌측 패널의 테이블명으로 설정 (예: `dept_info`) +3. `modalDataStore`에서 자동으로 데이터를 가져옴 + +## 개발자 정보 + +- **생성일**: 2024 +- **경로**: `lib/registry/components/split-panel-layout2/` + +## 관련 문서 + +- [컴포넌트 시스템 가이드](../../docs/컴포넌트_시스템_가이드.md) +- [split-panel-layout (v1)](../split-panel-layout/README.md) + diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx new file mode 100644 index 00000000..be14038f --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx @@ -0,0 +1,774 @@ +"use client"; + +import React, { useState, useEffect, useCallback, useMemo } from "react"; +import { ComponentRendererProps } from "@/types/component"; +import { + SplitPanelLayout2Config, + ColumnConfig, + DataTransferField, +} from "./types"; +import { defaultConfig } from "./config"; +import { cn } from "@/lib/utils"; +import { Search, Plus, ChevronRight, ChevronDown, Edit, Trash2, Users, Building2 } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { toast } from "sonner"; +import { useScreenContextOptional } from "@/contexts/ScreenContext"; +import { apiClient } from "@/lib/api/client"; + +export interface SplitPanelLayout2ComponentProps extends ComponentRendererProps { + // 추가 props +} + +/** + * SplitPanelLayout2 컴포넌트 + * 마스터-디테일 패턴의 좌우 분할 레이아웃 (개선 버전) + */ +export const SplitPanelLayout2Component: React.FC = ({ + component, + isDesignMode = false, + isSelected = false, + isPreview = false, + onClick, + ...props +}) => { + const config = useMemo(() => { + return { + ...defaultConfig, + ...component.componentConfig, + } as SplitPanelLayout2Config; + }, [component.componentConfig]); + + // ScreenContext (데이터 전달용) + const screenContext = useScreenContextOptional(); + + // 상태 관리 + const [leftData, setLeftData] = useState([]); + const [rightData, setRightData] = useState([]); + const [selectedLeftItem, setSelectedLeftItem] = useState(null); + const [leftSearchTerm, setLeftSearchTerm] = useState(""); + const [rightSearchTerm, setRightSearchTerm] = useState(""); + const [leftLoading, setLeftLoading] = useState(false); + const [rightLoading, setRightLoading] = useState(false); + const [expandedItems, setExpandedItems] = useState>(new Set()); + const [splitPosition, setSplitPosition] = useState(config.splitRatio || 30); + const [isResizing, setIsResizing] = useState(false); + + // 좌측 패널 컬럼 라벨 매핑 + const [leftColumnLabels, setLeftColumnLabels] = useState>({}); + const [rightColumnLabels, setRightColumnLabels] = useState>({}); + + + // 좌측 데이터 로드 + const loadLeftData = useCallback(async () => { + if (!config.leftPanel?.tableName || isDesignMode) return; + + setLeftLoading(true); + try { + const response = await apiClient.post(`/table-management/tables/${config.leftPanel.tableName}/data`, { + page: 1, + size: 1000, // 전체 데이터 로드 + // 멀티테넌시: 자동으로 company_code 필터링 적용 + autoFilter: { + enabled: true, + filterColumn: "company_code", + filterType: "company", + }, + }); + if (response.data.success) { + // API 응답 구조: { success: true, data: { data: [...], total, page, ... } } + let data = response.data.data?.data || []; + + // 계층 구조 처리 + if (config.leftPanel.hierarchyConfig?.enabled) { + data = buildHierarchy( + data, + config.leftPanel.hierarchyConfig.idColumn, + config.leftPanel.hierarchyConfig.parentColumn + ); + } + + setLeftData(data); + console.log(`[SplitPanelLayout2] 좌측 데이터 로드: ${data.length}건 (company_code 필터 적용)`); + } + } catch (error) { + console.error("[SplitPanelLayout2] 좌측 데이터 로드 실패:", error); + toast.error("좌측 패널 데이터를 불러오는데 실패했습니다."); + } finally { + setLeftLoading(false); + } + }, [config.leftPanel?.tableName, config.leftPanel?.hierarchyConfig, isDesignMode]); + + // 우측 데이터 로드 (좌측 선택 항목 기반) + const loadRightData = useCallback(async (selectedItem: any) => { + if (!config.rightPanel?.tableName || !config.joinConfig?.leftColumn || !config.joinConfig?.rightColumn || !selectedItem) { + setRightData([]); + return; + } + + const joinValue = selectedItem[config.joinConfig.leftColumn]; + if (joinValue === undefined || joinValue === null) { + console.log(`[SplitPanelLayout2] 조인 값이 없음: ${config.joinConfig.leftColumn}`); + setRightData([]); + return; + } + + setRightLoading(true); + try { + console.log(`[SplitPanelLayout2] 우측 데이터 로드 시작: ${config.rightPanel.tableName}, ${config.joinConfig.rightColumn}=${joinValue}`); + + const response = await apiClient.post(`/table-management/tables/${config.rightPanel.tableName}/data`, { + page: 1, + size: 1000, // 전체 데이터 로드 + // dataFilter를 사용하여 정확한 값 매칭 (Entity 타입 검색 문제 회피) + dataFilter: { + enabled: true, + matchType: "all", + filters: [ + { + id: "join_filter", + columnName: config.joinConfig.rightColumn, + operator: "equals", + value: String(joinValue), + valueType: "static", + } + ], + }, + // 멀티테넌시: 자동으로 company_code 필터링 적용 + autoFilter: { + enabled: true, + filterColumn: "company_code", + filterType: "company", + }, + }); + + if (response.data.success) { + // API 응답 구조: { success: true, data: { data: [...], total, page, ... } } + const data = response.data.data?.data || []; + setRightData(data); + console.log(`[SplitPanelLayout2] 우측 데이터 로드 완료: ${data.length}건`); + } else { + console.error("[SplitPanelLayout2] 우측 데이터 로드 실패:", response.data.message); + setRightData([]); + } + } catch (error: any) { + console.error("[SplitPanelLayout2] 우측 데이터 로드 에러:", { + message: error?.message, + status: error?.response?.status, + statusText: error?.response?.statusText, + data: error?.response?.data, + config: { + url: error?.config?.url, + method: error?.config?.method, + data: error?.config?.data, + } + }); + setRightData([]); + } finally { + setRightLoading(false); + } + }, [config.rightPanel?.tableName, config.joinConfig]); + + // 좌측 패널 추가 버튼 클릭 + const handleLeftAddClick = useCallback(() => { + if (!config.leftPanel?.addModalScreenId) { + toast.error("연결된 모달 화면이 없습니다."); + return; + } + + // EditModal 열기 이벤트 발생 + const event = new CustomEvent("openEditModal", { + detail: { + screenId: config.leftPanel.addModalScreenId, + title: config.leftPanel?.addButtonLabel || "추가", + modalSize: "lg", + editData: {}, + isCreateMode: true, // 생성 모드 + onSave: () => { + loadLeftData(); + }, + }, + }); + window.dispatchEvent(event); + console.log("[SplitPanelLayout2] 좌측 추가 모달 열기:", config.leftPanel.addModalScreenId); + }, [config.leftPanel?.addModalScreenId, config.leftPanel?.addButtonLabel, loadLeftData]); + + // 우측 패널 추가 버튼 클릭 + const handleRightAddClick = useCallback(() => { + if (!config.rightPanel?.addModalScreenId) { + toast.error("연결된 모달 화면이 없습니다."); + return; + } + + // 데이터 전달 필드 설정 + const initialData: Record = {}; + if (selectedLeftItem && config.dataTransferFields) { + for (const field of config.dataTransferFields) { + if (field.sourceColumn && field.targetColumn) { + initialData[field.targetColumn] = selectedLeftItem[field.sourceColumn]; + } + } + } + + console.log("[SplitPanelLayout2] 모달로 전달할 데이터:", initialData); + console.log("[SplitPanelLayout2] 모달 screenId:", config.rightPanel?.addModalScreenId); + + // EditModal 열기 이벤트 발생 + const event = new CustomEvent("openEditModal", { + detail: { + screenId: config.rightPanel.addModalScreenId, + title: config.rightPanel?.addButtonLabel || "추가", + modalSize: "lg", + editData: initialData, + isCreateMode: true, // 생성 모드 + onSave: () => { + if (selectedLeftItem) { + loadRightData(selectedLeftItem); + } + }, + }, + }); + window.dispatchEvent(event); + console.log("[SplitPanelLayout2] 우측 추가 모달 열기"); + }, [config.rightPanel?.addModalScreenId, config.rightPanel?.addButtonLabel, config.dataTransferFields, selectedLeftItem, loadRightData]); + + // 컬럼 라벨 로드 + const loadColumnLabels = useCallback(async (tableName: string, setLabels: (labels: Record) => void) => { + if (!tableName) return; + + try { + const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); + if (response.data.success) { + const labels: Record = {}; + // API 응답 구조: { success: true, data: { columns: [...] } } + const columns = response.data.data?.columns || []; + columns.forEach((col: any) => { + const colName = col.column_name || col.columnName; + const colLabel = col.column_label || col.columnLabel || colName; + if (colName) { + labels[colName] = colLabel; + } + }); + setLabels(labels); + } + } catch (error) { + console.error("[SplitPanelLayout2] 컬럼 라벨 로드 실패:", error); + } + }, []); + + // 계층 구조 빌드 + const buildHierarchy = (data: any[], idColumn: string, parentColumn: string): any[] => { + const itemMap = new Map(); + const roots: any[] = []; + + // 모든 항목을 맵에 저장 + data.forEach((item) => { + itemMap.set(item[idColumn], { ...item, children: [] }); + }); + + // 부모-자식 관계 설정 + data.forEach((item) => { + const current = itemMap.get(item[idColumn]); + const parentId = item[parentColumn]; + + if (parentId && itemMap.has(parentId)) { + itemMap.get(parentId).children.push(current); + } else { + roots.push(current); + } + }); + + return roots; + }; + + // 좌측 항목 선택 핸들러 + const handleLeftItemSelect = useCallback((item: any) => { + setSelectedLeftItem(item); + loadRightData(item); + + // ScreenContext DataProvider 등록 (버튼에서 접근 가능하도록) + if (screenContext && !isDesignMode) { + screenContext.registerDataProvider(component.id, { + componentId: component.id, + componentType: "split-panel-layout2", + getSelectedData: () => [item], + getAllData: () => leftData, + clearSelection: () => setSelectedLeftItem(null), + }); + console.log(`[SplitPanelLayout2] DataProvider 등록: ${component.id}`); + } + }, [isDesignMode, screenContext, component.id, leftData, loadRightData]); + + // 항목 확장/축소 토글 + const toggleExpand = useCallback((itemId: string) => { + setExpandedItems((prev) => { + const newSet = new Set(prev); + if (newSet.has(itemId)) { + newSet.delete(itemId); + } else { + newSet.add(itemId); + } + return newSet; + }); + }, []); + + // 검색 필터링 + const filteredLeftData = useMemo(() => { + if (!leftSearchTerm) return leftData; + + const searchColumn = config.leftPanel?.searchColumn; + if (!searchColumn) return leftData; + + const filterRecursive = (items: any[]): any[] => { + return items.filter((item) => { + const value = String(item[searchColumn] || "").toLowerCase(); + const matches = value.includes(leftSearchTerm.toLowerCase()); + + if (item.children?.length > 0) { + const filteredChildren = filterRecursive(item.children); + if (filteredChildren.length > 0) { + item.children = filteredChildren; + return true; + } + } + + return matches; + }); + }; + + return filterRecursive([...leftData]); + }, [leftData, leftSearchTerm, config.leftPanel?.searchColumn]); + + const filteredRightData = useMemo(() => { + if (!rightSearchTerm) return rightData; + + const searchColumn = config.rightPanel?.searchColumn; + if (!searchColumn) return rightData; + + return rightData.filter((item) => { + const value = String(item[searchColumn] || "").toLowerCase(); + return value.includes(rightSearchTerm.toLowerCase()); + }); + }, [rightData, rightSearchTerm, config.rightPanel?.searchColumn]); + + // 리사이즈 핸들러 + const handleResizeStart = useCallback((e: React.MouseEvent) => { + if (!config.resizable) return; + e.preventDefault(); + setIsResizing(true); + }, [config.resizable]); + + const handleResizeMove = useCallback((e: MouseEvent) => { + if (!isResizing) return; + + const container = document.getElementById(`split-panel-${component.id}`); + if (!container) return; + + const rect = container.getBoundingClientRect(); + const newPosition = ((e.clientX - rect.left) / rect.width) * 100; + const minLeft = (config.minLeftWidth || 200) / rect.width * 100; + const minRight = (config.minRightWidth || 300) / rect.width * 100; + + setSplitPosition(Math.max(minLeft, Math.min(100 - minRight, newPosition))); + }, [isResizing, component.id, config.minLeftWidth, config.minRightWidth]); + + const handleResizeEnd = useCallback(() => { + setIsResizing(false); + }, []); + + // 리사이즈 이벤트 리스너 + useEffect(() => { + if (isResizing) { + window.addEventListener("mousemove", handleResizeMove); + window.addEventListener("mouseup", handleResizeEnd); + } + return () => { + window.removeEventListener("mousemove", handleResizeMove); + window.removeEventListener("mouseup", handleResizeEnd); + }; + }, [isResizing, handleResizeMove, handleResizeEnd]); + + // 초기 데이터 로드 + useEffect(() => { + if (config.autoLoad && !isDesignMode) { + loadLeftData(); + loadColumnLabels(config.leftPanel?.tableName || "", setLeftColumnLabels); + loadColumnLabels(config.rightPanel?.tableName || "", setRightColumnLabels); + } + }, [config.autoLoad, isDesignMode, loadLeftData, loadColumnLabels, config.leftPanel?.tableName, config.rightPanel?.tableName]); + + // 컴포넌트 언마운트 시 DataProvider 해제 + useEffect(() => { + return () => { + if (screenContext) { + screenContext.unregisterDataProvider(component.id); + } + }; + }, [screenContext, component.id]); + + // 값 포맷팅 + const formatValue = (value: any, format?: ColumnConfig["format"]): string => { + if (value === null || value === undefined) return "-"; + if (!format) return String(value); + + switch (format.type) { + case "number": + const num = Number(value); + if (isNaN(num)) return String(value); + let formatted = format.decimalPlaces !== undefined + ? num.toFixed(format.decimalPlaces) + : String(num); + if (format.thousandSeparator) { + formatted = formatted.replace(/\B(?=(\d{3})+(?!\d))/g, ","); + } + return `${format.prefix || ""}${formatted}${format.suffix || ""}`; + + case "currency": + const currency = Number(value); + if (isNaN(currency)) return String(value); + const currencyFormatted = currency.toLocaleString("ko-KR"); + return `${format.prefix || ""}${currencyFormatted}${format.suffix || "원"}`; + + case "date": + try { + const date = new Date(value); + return date.toLocaleDateString("ko-KR"); + } catch { + return String(value); + } + + default: + return String(value); + } + }; + + // 좌측 패널 항목 렌더링 + const renderLeftItem = (item: any, level: number = 0, index: number = 0) => { + const idColumn = config.leftPanel?.hierarchyConfig?.idColumn || "id"; + const itemId = item[idColumn] ?? `item-${level}-${index}`; + const hasChildren = item.children?.length > 0; + const isExpanded = expandedItems.has(String(itemId)); + const isSelected = selectedLeftItem && selectedLeftItem[idColumn] === item[idColumn]; + + // 표시할 컬럼 결정 + const displayColumns = config.leftPanel?.displayColumns || []; + const primaryColumn = displayColumns[0]; + const secondaryColumn = displayColumns[1]; + + const primaryValue = primaryColumn + ? item[primaryColumn.name] + : Object.values(item).find((v) => typeof v === "string" && v.length > 0); + const secondaryValue = secondaryColumn ? item[secondaryColumn.name] : null; + + return ( +
+
handleLeftItemSelect(item)} + > + {/* 확장/축소 버튼 */} + {hasChildren ? ( + + ) : ( +
+ )} + + {/* 아이콘 */} + + + {/* 내용 */} +
+
+ {primaryValue || "이름 없음"} +
+ {secondaryValue && ( +
+ {secondaryValue} +
+ )} +
+
+ + {/* 자식 항목 */} + {hasChildren && isExpanded && ( +
+ {item.children.map((child: any, childIndex: number) => renderLeftItem(child, level + 1, childIndex))} +
+ )} +
+ ); + }; + + // 우측 패널 카드 렌더링 + const renderRightCard = (item: any, index: number) => { + const displayColumns = config.rightPanel?.displayColumns || []; + + // 첫 번째 컬럼을 이름으로 사용 + const nameColumn = displayColumns[0]; + const name = nameColumn ? item[nameColumn.name] : "이름 없음"; + + // 나머지 컬럼들 + const otherColumns = displayColumns.slice(1); + + return ( + + +
+
+ {/* 이름 */} +
+ {name} + {otherColumns[0] && ( + + {item[otherColumns[0].name]} + + )} +
+ + {/* 상세 정보 */} +
+ {otherColumns.slice(1).map((col, idx) => { + const value = item[col.name]; + if (!value) return null; + + // 아이콘 결정 + let icon = null; + const colName = col.name.toLowerCase(); + if (colName.includes("tel") || colName.includes("phone")) { + icon = tel; + } else if (colName.includes("email")) { + icon = @; + } else if (colName.includes("sabun") || colName.includes("id")) { + icon = ID; + } + + return ( + + {icon} + {formatValue(value, col.format)} + + ); + })} +
+
+ + {/* 액션 버튼 */} +
+ {config.rightPanel?.showEditButton && ( + + )} + {config.rightPanel?.showDeleteButton && ( + + )} +
+
+
+
+ ); + }; + + // 디자인 모드 렌더링 + if (isDesignMode) { + return ( +
+ {/* 좌측 패널 미리보기 */} +
+
+ {config.leftPanel?.title || "좌측 패널"} +
+
+ 테이블: {config.leftPanel?.tableName || "미설정"} +
+
+ 좌측 목록 영역 +
+
+ + {/* 우측 패널 미리보기 */} +
+
+ {config.rightPanel?.title || "우측 패널"} +
+
+ 테이블: {config.rightPanel?.tableName || "미설정"} +
+
+ 우측 상세 영역 +
+
+
+ ); + } + + return ( +
+ {/* 좌측 패널 */} +
+ {/* 헤더 */} +
+
+

{config.leftPanel?.title || "목록"}

+ {config.leftPanel?.showAddButton && ( + + )} +
+ + {/* 검색 */} + {config.leftPanel?.showSearch && ( +
+ + setLeftSearchTerm(e.target.value)} + className="pl-9 h-9 text-sm" + /> +
+ )} +
+ + {/* 목록 */} +
+ {leftLoading ? ( +
+ 로딩 중... +
+ ) : filteredLeftData.length === 0 ? ( +
+ 데이터가 없습니다 +
+ ) : ( +
+ {filteredLeftData.map((item, index) => renderLeftItem(item, 0, index))} +
+ )} +
+
+ + {/* 리사이저 */} + {config.resizable && ( +
+ )} + + {/* 우측 패널 */} +
+ {/* 헤더 */} +
+
+

+ {selectedLeftItem + ? config.leftPanel?.displayColumns?.[0] + ? selectedLeftItem[config.leftPanel.displayColumns[0].name] + : config.rightPanel?.title || "상세" + : config.rightPanel?.title || "상세"} +

+
+ {selectedLeftItem && ( + + {rightData.length}명 + + )} + {config.rightPanel?.showAddButton && selectedLeftItem && ( + + )} +
+
+ + {/* 검색 */} + {config.rightPanel?.showSearch && selectedLeftItem && ( +
+ + setRightSearchTerm(e.target.value)} + className="pl-9 h-9 text-sm" + /> +
+ )} +
+ + {/* 내용 */} +
+ {!selectedLeftItem ? ( +
+ + {config.rightPanel?.emptyMessage || "좌측에서 항목을 선택해주세요"} +
+ ) : rightLoading ? ( +
+ 로딩 중... +
+ ) : filteredRightData.length === 0 ? ( +
+ + 등록된 항목이 없습니다 +
+ ) : ( +
+ {filteredRightData.map((item, index) => renderRightCard(item, index))} +
+ )} +
+
+
+ ); +}; + +/** + * SplitPanelLayout2 래퍼 컴포넌트 + */ +export const SplitPanelLayout2Wrapper: React.FC = (props) => { + return ; +}; + diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx new file mode 100644 index 00000000..878ddb12 --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx @@ -0,0 +1,684 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Check, ChevronsUpDown, Plus, X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { apiClient } from "@/lib/api/client"; +import type { SplitPanelLayout2Config, ColumnConfig, DataTransferField } from "./types"; + +// lodash set 대체 함수 +const setPath = (obj: any, path: string, value: any): any => { + const keys = path.split("."); + const result = { ...obj }; + let current = result; + + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + current[key] = current[key] ? { ...current[key] } : {}; + current = current[key]; + } + + current[keys[keys.length - 1]] = value; + return result; +}; + +interface SplitPanelLayout2ConfigPanelProps { + config: SplitPanelLayout2Config; + onChange: (config: SplitPanelLayout2Config) => void; +} + +interface TableInfo { + table_name: string; + table_comment?: string; +} + +interface ColumnInfo { + column_name: string; + data_type: string; + column_comment?: string; +} + +interface ScreenInfo { + screen_id: number; + screen_name: string; + screen_code: string; +} + +export const SplitPanelLayout2ConfigPanel: React.FC = ({ + config, + onChange, +}) => { + // updateConfig 헬퍼 함수: 경로 기반으로 config를 업데이트 + const updateConfig = useCallback((path: string, value: any) => { + console.log(`[SplitPanelLayout2ConfigPanel] updateConfig: ${path} =`, value); + const newConfig = setPath(config, path, value); + console.log("[SplitPanelLayout2ConfigPanel] newConfig:", newConfig); + onChange(newConfig); + }, [config, onChange]); + + // 상태 + const [tables, setTables] = useState([]); + const [leftColumns, setLeftColumns] = useState([]); + const [rightColumns, setRightColumns] = useState([]); + const [screens, setScreens] = useState([]); + const [tablesLoading, setTablesLoading] = useState(false); + const [screensLoading, setScreensLoading] = useState(false); + + // Popover 상태 + const [leftTableOpen, setLeftTableOpen] = useState(false); + const [rightTableOpen, setRightTableOpen] = useState(false); + const [leftModalOpen, setLeftModalOpen] = useState(false); + const [rightModalOpen, setRightModalOpen] = useState(false); + + // 테이블 목록 로드 + const loadTables = useCallback(async () => { + setTablesLoading(true); + try { + const response = await apiClient.get("/table/list?userLang=KR"); + const tableList = response.data?.data || response.data || []; + if (Array.isArray(tableList)) { + setTables(tableList); + } + } catch (error) { + console.error("테이블 목록 로드 실패:", error); + } finally { + setTablesLoading(false); + } + }, []); + + // 화면 목록 로드 + const loadScreens = useCallback(async () => { + setScreensLoading(true); + try { + const response = await apiClient.get("/screen/list"); + console.log("[loadScreens] API 응답:", response.data); + const screenList = response.data?.data || response.data || []; + if (Array.isArray(screenList)) { + const transformedScreens = screenList.map((s: any) => ({ + screen_id: s.screen_id || s.id, + screen_name: s.screen_name || s.name, + screen_code: s.screen_code || s.code || "", + })); + console.log("[loadScreens] 변환된 화면 목록:", transformedScreens); + setScreens(transformedScreens); + } + } catch (error) { + console.error("화면 목록 로드 실패:", error); + } finally { + setScreensLoading(false); + } + }, []); + + // 컬럼 목록 로드 + const loadColumns = useCallback(async (tableName: string, side: "left" | "right") => { + if (!tableName) return; + try { + const response = await apiClient.get(`/table/${tableName}/columns`); + const columnList = response.data?.data || response.data || []; + if (Array.isArray(columnList)) { + if (side === "left") { + setLeftColumns(columnList); + } else { + setRightColumns(columnList); + } + } + } catch (error) { + console.error(`${side} 컬럼 목록 로드 실패:`, error); + } + }, []); + + // 초기 로드 + useEffect(() => { + loadTables(); + loadScreens(); + }, [loadTables, loadScreens]); + + // 테이블 변경 시 컬럼 로드 + useEffect(() => { + if (config.leftPanel?.tableName) { + loadColumns(config.leftPanel.tableName, "left"); + } + }, [config.leftPanel?.tableName, loadColumns]); + + useEffect(() => { + if (config.rightPanel?.tableName) { + loadColumns(config.rightPanel.tableName, "right"); + } + }, [config.rightPanel?.tableName, loadColumns]); + + // 테이블 선택 컴포넌트 + const TableSelect: React.FC<{ + value: string; + onValueChange: (value: string) => void; + placeholder: string; + open: boolean; + onOpenChange: (open: boolean) => void; + }> = ({ value, onValueChange, placeholder, open, onOpenChange }) => ( + + + + + + + + + 테이블이 없습니다 + + {tables.map((table) => ( + { + onValueChange(selectedValue); + onOpenChange(false); + }} + > + + + {table.table_comment || table.table_name} + {table.table_name} + + + ))} + + + + + + ); + + // 화면 선택 컴포넌트 + const ScreenSelect: React.FC<{ + value: number | undefined; + onValueChange: (value: number | undefined) => void; + placeholder: string; + open: boolean; + onOpenChange: (open: boolean) => void; + }> = ({ value, onValueChange, placeholder, open, onOpenChange }) => ( + + + + + + + + + 화면이 없습니다 + + {screens.map((screen, index) => ( + { + const screenId = parseInt(selectedValue.split("-")[0]); + console.log("[ScreenSelect] onSelect:", { selectedValue, screenId, screen }); + onValueChange(screenId); + onOpenChange(false); + }} + className="flex items-center" + > +
+ + + {screen.screen_name} + {screen.screen_code} + +
+
+ ))} +
+
+
+
+
+ ); + + // 컬럼 선택 컴포넌트 + const ColumnSelect: React.FC<{ + columns: ColumnInfo[]; + value: string; + onValueChange: (value: string) => void; + placeholder: string; + }> = ({ columns, value, onValueChange, placeholder }) => ( + + ); + + // 표시 컬럼 추가 + const addDisplayColumn = (side: "left" | "right") => { + const path = side === "left" ? "leftPanel.displayColumns" : "rightPanel.displayColumns"; + const currentColumns = side === "left" + ? config.leftPanel?.displayColumns || [] + : config.rightPanel?.displayColumns || []; + + updateConfig(path, [...currentColumns, { name: "", label: "" }]); + }; + + // 표시 컬럼 삭제 + const removeDisplayColumn = (side: "left" | "right", index: number) => { + const path = side === "left" ? "leftPanel.displayColumns" : "rightPanel.displayColumns"; + const currentColumns = side === "left" + ? config.leftPanel?.displayColumns || [] + : config.rightPanel?.displayColumns || []; + + updateConfig(path, currentColumns.filter((_, i) => i !== index)); + }; + + // 표시 컬럼 업데이트 + const updateDisplayColumn = (side: "left" | "right", index: number, field: keyof ColumnConfig, value: any) => { + const path = side === "left" ? "leftPanel.displayColumns" : "rightPanel.displayColumns"; + const currentColumns = side === "left" + ? [...(config.leftPanel?.displayColumns || [])] + : [...(config.rightPanel?.displayColumns || [])]; + + if (currentColumns[index]) { + currentColumns[index] = { ...currentColumns[index], [field]: value }; + updateConfig(path, currentColumns); + } + }; + + // 데이터 전달 필드 추가 + const addDataTransferField = () => { + const currentFields = config.dataTransferFields || []; + updateConfig("dataTransferFields", [...currentFields, { sourceColumn: "", targetColumn: "" }]); + }; + + // 데이터 전달 필드 삭제 + const removeDataTransferField = (index: number) => { + const currentFields = config.dataTransferFields || []; + updateConfig("dataTransferFields", currentFields.filter((_, i) => i !== index)); + }; + + // 데이터 전달 필드 업데이트 + const updateDataTransferField = (index: number, field: keyof DataTransferField, value: string) => { + const currentFields = [...(config.dataTransferFields || [])]; + if (currentFields[index]) { + currentFields[index] = { ...currentFields[index], [field]: value }; + updateConfig("dataTransferFields", currentFields); + } + }; + + return ( +
+ {/* 좌측 패널 설정 */} +
+

좌측 패널 설정 (마스터)

+ +
+
+ + updateConfig("leftPanel.title", e.target.value)} + placeholder="부서" + className="h-9 text-sm" + /> +
+ +
+ + updateConfig("leftPanel.tableName", value)} + placeholder="테이블 선택" + open={leftTableOpen} + onOpenChange={setLeftTableOpen} + /> +
+ + {/* 표시 컬럼 */} +
+
+ + +
+
+ {(config.leftPanel?.displayColumns || []).map((col, index) => ( +
+ updateDisplayColumn("left", index, "name", value)} + placeholder="컬럼" + /> + updateDisplayColumn("left", index, "label", e.target.value)} + placeholder="라벨" + className="h-9 text-sm flex-1" + /> + +
+ ))} +
+
+ +
+ + updateConfig("leftPanel.showSearch", checked)} + /> +
+ +
+ + updateConfig("leftPanel.showAddButton", checked)} + /> +
+ + {config.leftPanel?.showAddButton && ( + <> +
+ + updateConfig("leftPanel.addButtonLabel", e.target.value)} + placeholder="추가" + className="h-9 text-sm" + /> +
+
+ + updateConfig("leftPanel.addModalScreenId", value)} + placeholder="모달 화면 선택" + open={leftModalOpen} + onOpenChange={setLeftModalOpen} + /> +
+ + )} +
+
+ + {/* 우측 패널 설정 */} +
+

우측 패널 설정 (상세)

+ +
+
+ + updateConfig("rightPanel.title", e.target.value)} + placeholder="사원" + className="h-9 text-sm" + /> +
+ +
+ + updateConfig("rightPanel.tableName", value)} + placeholder="테이블 선택" + open={rightTableOpen} + onOpenChange={setRightTableOpen} + /> +
+ + {/* 표시 컬럼 */} +
+
+ + +
+
+ {(config.rightPanel?.displayColumns || []).map((col, index) => ( +
+ updateDisplayColumn("right", index, "name", value)} + placeholder="컬럼" + /> + updateDisplayColumn("right", index, "label", e.target.value)} + placeholder="라벨" + className="h-9 text-sm flex-1" + /> + +
+ ))} +
+
+ +
+ + updateConfig("rightPanel.showSearch", checked)} + /> +
+ +
+ + updateConfig("rightPanel.showAddButton", checked)} + /> +
+ + {config.rightPanel?.showAddButton && ( + <> +
+ + updateConfig("rightPanel.addButtonLabel", e.target.value)} + placeholder="추가" + className="h-9 text-sm" + /> +
+
+ + updateConfig("rightPanel.addModalScreenId", value)} + placeholder="모달 화면 선택" + open={rightModalOpen} + onOpenChange={setRightModalOpen} + /> +
+ + )} +
+
+ + {/* 연결 설정 */} +
+

연결 설정 (조인)

+ +
+
+ + updateConfig("joinConfig.leftColumn", value)} + placeholder="조인 컬럼 선택" + /> +
+ +
+ + updateConfig("joinConfig.rightColumn", value)} + placeholder="조인 컬럼 선택" + /> +
+
+
+ + {/* 데이터 전달 설정 */} +
+
+

데이터 전달 설정

+ +
+ +
+ {(config.dataTransferFields || []).map((field, index) => ( +
+
+ 필드 {index + 1} + +
+
+ + updateDataTransferField(index, "sourceColumn", value)} + placeholder="소스 컬럼" + /> +
+
+ + updateDataTransferField(index, "targetColumn", e.target.value)} + placeholder="모달에서 사용할 필드명" + className="h-9 text-sm" + /> +
+
+ ))} +
+
+ + {/* 레이아웃 설정 */} +
+

레이아웃 설정

+ +
+
+ + updateConfig("splitRatio", parseInt(e.target.value) || 30)} + min={10} + max={90} + className="h-9 text-sm" + /> +
+ +
+ + updateConfig("resizable", checked)} + /> +
+ +
+ + updateConfig("autoLoad", checked)} + /> +
+
+
+
+ ); +}; + +export default SplitPanelLayout2ConfigPanel; diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Renderer.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Renderer.tsx new file mode 100644 index 00000000..f582646e --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Renderer.tsx @@ -0,0 +1,42 @@ +"use client"; + +import React from "react"; +import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; +import { SplitPanelLayout2Definition } from "./index"; +import { SplitPanelLayout2Component } from "./SplitPanelLayout2Component"; + +/** + * SplitPanelLayout2 렌더러 + * 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록 + */ +export class SplitPanelLayout2Renderer extends AutoRegisteringComponentRenderer { + static componentDefinition = SplitPanelLayout2Definition; + + render(): React.ReactElement { + return ; + } + + /** + * 컴포넌트별 특화 메서드들 + */ + + // 좌측 패널 데이터 새로고침 + public refreshLeftPanel() { + // 컴포넌트 내부에서 처리 + } + + // 우측 패널 데이터 새로고침 + public refreshRightPanel() { + // 컴포넌트 내부에서 처리 + } + + // 선택된 좌측 항목 가져오기 + public getSelectedLeftItem(): any { + // 컴포넌트 내부 상태에서 가져옴 + return null; + } +} + +// 자동 등록 실행 +SplitPanelLayout2Renderer.registerSelf(); + diff --git a/frontend/lib/registry/components/split-panel-layout2/config.ts b/frontend/lib/registry/components/split-panel-layout2/config.ts new file mode 100644 index 00000000..493ddf2c --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout2/config.ts @@ -0,0 +1,57 @@ +/** + * SplitPanelLayout2 기본 설정 + */ + +import { SplitPanelLayout2Config } from "./types"; + +/** + * 기본 설정값 + */ +export const defaultConfig: Partial = { + leftPanel: { + title: "목록", + tableName: "", + displayColumns: [], + showSearch: true, + showAddButton: false, + }, + rightPanel: { + title: "상세", + tableName: "", + displayColumns: [], + showSearch: true, + showAddButton: true, + addButtonLabel: "추가", + showEditButton: true, + showDeleteButton: true, + displayMode: "card", + emptyMessage: "좌측에서 항목을 선택해주세요", + }, + joinConfig: { + leftColumn: "", + rightColumn: "", + }, + dataTransferFields: [], + splitRatio: 30, + resizable: true, + minLeftWidth: 250, + minRightWidth: 400, + autoLoad: true, +}; + +/** + * 컴포넌트 메타데이터 + */ +export const componentMeta = { + id: "split-panel-layout2", + name: "분할 패널 레이아웃 v2", + nameEng: "Split Panel Layout v2", + description: "마스터-디테일 패턴의 좌우 분할 레이아웃 (데이터 전달 기능 포함)", + category: "layout", + webType: "container", + icon: "LayoutPanelLeft", + tags: ["레이아웃", "분할", "마스터", "디테일", "패널", "부서", "사원"], + version: "2.0.0", + author: "개발팀", +}; + diff --git a/frontend/lib/registry/components/split-panel-layout2/index.ts b/frontend/lib/registry/components/split-panel-layout2/index.ts new file mode 100644 index 00000000..64a88b11 --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout2/index.ts @@ -0,0 +1,41 @@ +"use client"; + +import { createComponentDefinition } from "../../utils/createComponentDefinition"; +import { ComponentCategory } from "@/types/component"; +import type { WebType } from "@/types/screen"; +import { SplitPanelLayout2Wrapper } from "./SplitPanelLayout2Component"; +import { SplitPanelLayout2ConfigPanel } from "./SplitPanelLayout2ConfigPanel"; +import { defaultConfig, componentMeta } from "./config"; + +/** + * SplitPanelLayout2 컴포넌트 정의 + * 마스터-디테일 패턴의 좌우 분할 레이아웃 (개선 버전) + */ +export const SplitPanelLayout2Definition = createComponentDefinition({ + id: componentMeta.id, + name: componentMeta.name, + nameEng: componentMeta.nameEng, + description: componentMeta.description, + category: ComponentCategory.LAYOUT, + webType: componentMeta.webType as WebType, + component: SplitPanelLayout2Wrapper, + defaultConfig: defaultConfig, + defaultSize: { width: 1200, height: 600 }, + configPanel: SplitPanelLayout2ConfigPanel, + icon: componentMeta.icon, + tags: componentMeta.tags, + version: componentMeta.version, + author: componentMeta.author, + documentation: "https://docs.example.com/components/split-panel-layout2", +}); + +// 타입 내보내기 +export type { + SplitPanelLayout2Config, + LeftPanelConfig, + RightPanelConfig, + JoinConfig, + DataTransferField, + ColumnConfig, +} from "./types"; + diff --git a/frontend/lib/registry/components/split-panel-layout2/types.ts b/frontend/lib/registry/components/split-panel-layout2/types.ts new file mode 100644 index 00000000..ec0f61b5 --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout2/types.ts @@ -0,0 +1,102 @@ +/** + * SplitPanelLayout2 컴포넌트 타입 정의 + * 마스터-디테일 패턴의 좌우 분할 레이아웃 (개선 버전) + */ + +/** + * 컬럼 설정 + */ +export interface ColumnConfig { + name: string; // 컬럼명 + label: string; // 표시 라벨 + width?: number; // 너비 (px) + bold?: boolean; // 굵게 표시 + format?: { + type?: "text" | "number" | "currency" | "date"; + thousandSeparator?: boolean; + decimalPlaces?: number; + prefix?: string; + suffix?: string; + dateFormat?: string; + }; +} + +/** + * 데이터 전달 필드 설정 + */ +export interface DataTransferField { + sourceColumn: string; // 좌측 패널의 컬럼명 + targetColumn: string; // 모달로 전달할 컬럼명 + label?: string; // 표시용 라벨 +} + +/** + * 좌측 패널 설정 + */ +export interface LeftPanelConfig { + title?: string; // 패널 제목 + tableName: string; // 테이블명 + displayColumns: ColumnConfig[]; // 표시할 컬럼들 + searchColumn?: string; // 검색 대상 컬럼 + showSearch?: boolean; // 검색 표시 여부 + showAddButton?: boolean; // 추가 버튼 표시 + addButtonLabel?: string; // 추가 버튼 라벨 + addModalScreenId?: number; // 추가 모달 화면 ID + // 계층 구조 설정 + hierarchyConfig?: { + enabled: boolean; + parentColumn: string; // 부모 참조 컬럼 (예: parent_dept_code) + idColumn: string; // ID 컬럼 (예: dept_code) + }; +} + +/** + * 우측 패널 설정 + */ +export interface RightPanelConfig { + title?: string; // 패널 제목 + tableName: string; // 테이블명 + displayColumns: ColumnConfig[]; // 표시할 컬럼들 + searchColumn?: string; // 검색 대상 컬럼 + showSearch?: boolean; // 검색 표시 여부 + showAddButton?: boolean; // 추가 버튼 표시 + addButtonLabel?: string; // 추가 버튼 라벨 + addModalScreenId?: number; // 추가 모달 화면 ID + showEditButton?: boolean; // 수정 버튼 표시 + showDeleteButton?: boolean; // 삭제 버튼 표시 + displayMode?: "card" | "list"; // 표시 모드 + emptyMessage?: string; // 데이터 없을 때 메시지 +} + +/** + * 조인 설정 + */ +export interface JoinConfig { + leftColumn: string; // 좌측 테이블의 조인 컬럼 + rightColumn: string; // 우측 테이블의 조인 컬럼 +} + +/** + * 메인 설정 + */ +export interface SplitPanelLayout2Config { + // 패널 설정 + leftPanel: LeftPanelConfig; + rightPanel: RightPanelConfig; + + // 조인 설정 + joinConfig: JoinConfig; + + // 데이터 전달 설정 (모달로 전달할 필드) + dataTransferFields?: DataTransferField[]; + + // 레이아웃 설정 + splitRatio?: number; // 좌우 비율 (0-100, 기본 30) + resizable?: boolean; // 크기 조절 가능 여부 + minLeftWidth?: number; // 좌측 최소 너비 (px) + minRightWidth?: number; // 우측 최소 너비 (px) + + // 동작 설정 + autoLoad?: boolean; // 자동 데이터 로드 +} + diff --git a/frontend/lib/utils/getComponentConfigPanel.tsx b/frontend/lib/utils/getComponentConfigPanel.tsx index f921016c..cb2d3f52 100644 --- a/frontend/lib/utils/getComponentConfigPanel.tsx +++ b/frontend/lib/utils/getComponentConfigPanel.tsx @@ -24,6 +24,7 @@ const CONFIG_PANEL_MAP: Record Promise> = { "table-list": () => import("@/lib/registry/components/table-list/TableListConfigPanel"), "card-display": () => import("@/lib/registry/components/card-display/CardDisplayConfigPanel"), "split-panel-layout": () => import("@/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel"), + "split-panel-layout2": () => import("@/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel"), "repeater-field-group": () => import("@/components/webtypes/config/RepeaterConfigPanel"), "flow-widget": () => import("@/components/screen/config-panels/FlowWidgetConfigPanel"), // 🆕 수주 등록 관련 컴포넌트들