From 7686158a01234a74e650bb4791b7e35e5535c39f Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 15 Oct 2025 17:25:38 +0900 Subject: [PATCH 01/11] =?UTF-8?q?=EB=B6=84=ED=95=A0=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=95=84=EC=9B=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/routes/dataRoutes.ts | 378 ++++++++++++++- backend-node/src/services/dataService.ts | 277 +++++++++++ frontend/components/screen/ScreenDesigner.tsx | 49 +- .../screen/panels/ComponentsPanel.tsx | 250 +++++----- .../screen/panels/UnifiedPropertiesPanel.tsx | 14 +- .../components/screen/toolbar/SlimToolbar.tsx | 46 +- frontend/lib/api/data.ts | 108 +++++ .../components/checkbox-basic/index.ts | 2 +- frontend/lib/registry/components/index.ts | 1 + .../registry/components/radio-basic/index.ts | 2 +- .../registry/components/select-basic/index.ts | 2 +- .../registry/components/slider-basic/index.ts | 2 +- .../components/split-panel-layout/README.md | 80 +++ .../SplitPanelLayoutComponent.tsx | 425 ++++++++++++++++ .../SplitPanelLayoutConfigPanel.tsx | 457 ++++++++++++++++++ .../SplitPanelLayoutRenderer.tsx | 40 ++ .../components/split-panel-layout/config.ts | 69 +++ .../components/split-panel-layout/index.ts | 60 +++ .../components/split-panel-layout/types.ts | 51 ++ .../text-display/TextDisplayComponent.tsx | 8 +- .../components/toggle-switch/index.ts | 2 +- .../lib/utils/getComponentConfigPanel.tsx | 14 +- 22 files changed, 2119 insertions(+), 218 deletions(-) create mode 100644 frontend/lib/api/data.ts create mode 100644 frontend/lib/registry/components/split-panel-layout/README.md create mode 100644 frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx create mode 100644 frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx create mode 100644 frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutRenderer.tsx create mode 100644 frontend/lib/registry/components/split-panel-layout/config.ts create mode 100644 frontend/lib/registry/components/split-panel-layout/index.ts create mode 100644 frontend/lib/registry/components/split-panel-layout/types.ts diff --git a/backend-node/src/routes/dataRoutes.ts b/backend-node/src/routes/dataRoutes.ts index 4ad42561..fb0f1518 100644 --- a/backend-node/src/routes/dataRoutes.ts +++ b/backend-node/src/routes/dataRoutes.ts @@ -5,6 +5,92 @@ import { AuthenticatedRequest } from "../types/auth"; const router = express.Router(); +/** + * 조인 데이터 조회 API (다른 라우트보다 먼저 정의) + * GET /api/data/join?leftTable=...&rightTable=...&leftColumn=...&rightColumn=...&leftValue=... + */ +router.get( + "/join", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const { leftTable, rightTable, leftColumn, rightColumn, leftValue } = + req.query; + + // 입력값 검증 + if (!leftTable || !rightTable || !leftColumn || !rightColumn) { + return res.status(400).json({ + success: false, + message: + "필수 파라미터가 누락되었습니다 (leftTable, rightTable, leftColumn, rightColumn).", + error: "MISSING_PARAMETERS", + }); + } + + // SQL 인젝션 방지를 위한 검증 + const tables = [leftTable as string, rightTable as string]; + const columns = [leftColumn as string, rightColumn as string]; + + for (const table of tables) { + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(table)) { + return res.status(400).json({ + success: false, + message: `유효하지 않은 테이블명입니다: ${table}`, + error: "INVALID_TABLE_NAME", + }); + } + } + + for (const column of columns) { + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(column)) { + return res.status(400).json({ + success: false, + message: `유효하지 않은 컬럼명입니다: ${column}`, + error: "INVALID_COLUMN_NAME", + }); + } + } + + console.log(`🔗 조인 데이터 조회:`, { + leftTable, + rightTable, + leftColumn, + rightColumn, + leftValue, + }); + + // 조인 데이터 조회 + const result = await dataService.getJoinedData( + leftTable as string, + rightTable as string, + leftColumn as string, + rightColumn as string, + leftValue as string + ); + + if (!result.success) { + return res.status(400).json(result); + } + + console.log( + `✅ 조인 데이터 조회 성공: ${result.data?.length || 0}개 항목` + ); + + return res.json({ + success: true, + data: result.data, + }); + } catch (error) { + console.error("조인 데이터 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "조인 데이터 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } +); + /** * 동적 테이블 데이터 조회 API * GET /api/data/{tableName} @@ -15,7 +101,18 @@ router.get( async (req: AuthenticatedRequest, res) => { try { const { tableName } = req.params; - const { limit = "10", offset = "0", orderBy, ...filters } = req.query; + const { + limit, + offset, + page, + size, + orderBy, + searchTerm, + sortBy, + sortOrder, + userLang, + ...filters + } = req.query; // 입력값 검증 if (!tableName || typeof tableName !== "string") { @@ -35,21 +132,43 @@ router.get( }); } + // page/size 또는 limit/offset 방식 지원 + let finalLimit = 100; + let finalOffset = 0; + + if (page && size) { + // page/size 방식 + const pageNum = parseInt(page as string) || 1; + const sizeNum = parseInt(size as string) || 100; + finalLimit = sizeNum; + finalOffset = (pageNum - 1) * sizeNum; + } else if (limit || offset) { + // limit/offset 방식 + finalLimit = parseInt(limit as string) || 10; + finalOffset = parseInt(offset as string) || 0; + } + console.log(`📊 데이터 조회 요청: ${tableName}`, { - limit: parseInt(limit as string), - offset: parseInt(offset as string), - orderBy: orderBy as string, + limit: finalLimit, + offset: finalOffset, + orderBy: orderBy || sortBy, + searchTerm, filters, user: req.user?.userId, }); + // filters에서 searchTerm과 sortOrder 제거 (이미 별도로 처리됨) + const cleanFilters = { ...filters }; + delete cleanFilters.searchTerm; + delete cleanFilters.sortOrder; + // 데이터 조회 const result = await dataService.getTableData({ tableName, - limit: parseInt(limit as string), - offset: parseInt(offset as string), - orderBy: orderBy as string, - filters: filters as Record, + limit: finalLimit, + offset: finalOffset, + orderBy: (orderBy || sortBy) as string, + filters: cleanFilters as Record, userCompany: req.user?.companyCode, }); @@ -61,7 +180,21 @@ router.get( `✅ 데이터 조회 성공: ${tableName}, ${result.data?.length || 0}개 항목` ); - return res.json(result.data); + // 페이징 정보 포함하여 반환 + const total = result.data?.length || 0; + const responsePage = + finalLimit > 0 ? Math.floor(finalOffset / finalLimit) + 1 : 1; + const responseSize = finalLimit; + const totalPages = responseSize > 0 ? Math.ceil(total / responseSize) : 1; + + return res.json({ + success: true, + data: result.data, + total, + page: responsePage, + size: responseSize, + totalPages, + }); } catch (error) { console.error("데이터 조회 오류:", error); return res.status(500).json({ @@ -127,4 +260,231 @@ router.get( } ); +/** + * 레코드 상세 조회 API + * GET /api/data/{tableName}/{id} + */ +router.get( + "/:tableName/:id", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const { tableName, id } = req.params; + + // 입력값 검증 + if (!tableName || typeof tableName !== "string") { + return res.status(400).json({ + success: false, + message: "테이블명이 필요합니다.", + error: "INVALID_TABLE_NAME", + }); + } + + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 테이블명입니다.", + error: "INVALID_TABLE_NAME", + }); + } + + console.log(`🔍 레코드 상세 조회: ${tableName}/${id}`); + + // 레코드 상세 조회 + const result = await dataService.getRecordDetail(tableName, id); + + if (!result.success) { + return res.status(400).json(result); + } + + if (!result.data) { + return res.status(404).json({ + success: false, + message: "레코드를 찾을 수 없습니다.", + }); + } + + console.log(`✅ 레코드 조회 성공: ${tableName}/${id}`); + + return res.json({ + success: true, + data: result.data, + }); + } catch (error) { + console.error("레코드 상세 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "레코드 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } +); + +/** + * 레코드 생성 API + * POST /api/data/{tableName} + */ +router.post( + "/:tableName", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const { tableName } = req.params; + const data = req.body; + + // 입력값 검증 + if (!tableName || typeof tableName !== "string") { + return res.status(400).json({ + success: false, + message: "테이블명이 필요합니다.", + error: "INVALID_TABLE_NAME", + }); + } + + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 테이블명입니다.", + error: "INVALID_TABLE_NAME", + }); + } + + console.log(`➕ 레코드 생성: ${tableName}`, data); + + // 레코드 생성 + const result = await dataService.createRecord(tableName, data); + + if (!result.success) { + return res.status(400).json(result); + } + + console.log(`✅ 레코드 생성 성공: ${tableName}`); + + return res.status(201).json({ + success: true, + data: result.data, + message: "레코드가 생성되었습니다.", + }); + } catch (error) { + console.error("레코드 생성 오류:", error); + return res.status(500).json({ + success: false, + message: "레코드 생성 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } +); + +/** + * 레코드 수정 API + * PUT /api/data/{tableName}/{id} + */ +router.put( + "/:tableName/:id", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const { tableName, id } = req.params; + const data = req.body; + + // 입력값 검증 + if (!tableName || typeof tableName !== "string") { + return res.status(400).json({ + success: false, + message: "테이블명이 필요합니다.", + error: "INVALID_TABLE_NAME", + }); + } + + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 테이블명입니다.", + error: "INVALID_TABLE_NAME", + }); + } + + console.log(`✏️ 레코드 수정: ${tableName}/${id}`, data); + + // 레코드 수정 + const result = await dataService.updateRecord(tableName, id, data); + + if (!result.success) { + return res.status(400).json(result); + } + + console.log(`✅ 레코드 수정 성공: ${tableName}/${id}`); + + return res.json({ + success: true, + data: result.data, + message: "레코드가 수정되었습니다.", + }); + } catch (error) { + console.error("레코드 수정 오류:", error); + return res.status(500).json({ + success: false, + message: "레코드 수정 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } +); + +/** + * 레코드 삭제 API + * DELETE /api/data/{tableName}/{id} + */ +router.delete( + "/:tableName/:id", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const { tableName, id } = req.params; + + // 입력값 검증 + if (!tableName || typeof tableName !== "string") { + return res.status(400).json({ + success: false, + message: "테이블명이 필요합니다.", + error: "INVALID_TABLE_NAME", + }); + } + + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 테이블명입니다.", + error: "INVALID_TABLE_NAME", + }); + } + + console.log(`🗑️ 레코드 삭제: ${tableName}/${id}`); + + // 레코드 삭제 + const result = await dataService.deleteRecord(tableName, id); + + if (!result.success) { + return res.status(400).json(result); + } + + console.log(`✅ 레코드 삭제 성공: ${tableName}/${id}`); + + return res.json({ + success: true, + message: "레코드가 삭제되었습니다.", + }); + } catch (error) { + console.error("레코드 삭제 오류:", error); + return res.status(500).json({ + success: false, + message: "레코드 삭제 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } +); + export default router; diff --git a/backend-node/src/services/dataService.ts b/backend-node/src/services/dataService.ts index 2608e8c9..661ffae1 100644 --- a/backend-node/src/services/dataService.ts +++ b/backend-node/src/services/dataService.ts @@ -313,6 +313,283 @@ class DataService { return null; } } + + /** + * 레코드 상세 조회 + */ + async getRecordDetail( + tableName: string, + id: string | number + ): Promise> { + try { + // 테이블명 화이트리스트 검증 + if (!ALLOWED_TABLES.includes(tableName)) { + return { + success: false, + message: `접근이 허용되지 않은 테이블입니다: ${tableName}`, + error: "TABLE_NOT_ALLOWED", + }; + } + + // Primary Key 컬럼 찾기 + const pkResult = await query<{ attname: string }>( + `SELECT a.attname + FROM pg_index i + JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) + WHERE i.indrelid = $1::regclass AND i.indisprimary`, + [tableName] + ); + + let pkColumn = "id"; // 기본값 + if (pkResult.length > 0) { + pkColumn = pkResult[0].attname; + } + + const queryText = `SELECT * FROM "${tableName}" WHERE "${pkColumn}" = $1`; + const result = await query(queryText, [id]); + + if (result.length === 0) { + return { + success: false, + message: "레코드를 찾을 수 없습니다.", + error: "RECORD_NOT_FOUND", + }; + } + + return { + success: true, + data: result[0], + }; + } catch (error) { + console.error(`레코드 상세 조회 오류 (${tableName}/${id}):`, error); + return { + success: false, + message: "레코드 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } + + /** + * 조인된 데이터 조회 + */ + async getJoinedData( + leftTable: string, + rightTable: string, + leftColumn: string, + rightColumn: string, + leftValue?: string | number + ): Promise> { + try { + // 테이블명 화이트리스트 검증 + if (!ALLOWED_TABLES.includes(leftTable)) { + return { + success: false, + message: `접근이 허용되지 않은 테이블입니다: ${leftTable}`, + error: "TABLE_NOT_ALLOWED", + }; + } + + if (!ALLOWED_TABLES.includes(rightTable)) { + return { + success: false, + message: `접근이 허용되지 않은 테이블입니다: ${rightTable}`, + error: "TABLE_NOT_ALLOWED", + }; + } + + let queryText = ` + SELECT r.* + FROM "${rightTable}" r + INNER JOIN "${leftTable}" l + ON l."${leftColumn}" = r."${rightColumn}" + `; + + const values: any[] = []; + if (leftValue !== undefined && leftValue !== null) { + queryText += ` WHERE l."${leftColumn}" = $1`; + values.push(leftValue); + } + + const result = await query(queryText, values); + + return { + success: true, + data: result, + }; + } catch (error) { + console.error( + `조인 데이터 조회 오류 (${leftTable} → ${rightTable}):`, + error + ); + return { + success: false, + message: "조인 데이터 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } + + /** + * 레코드 생성 + */ + async createRecord( + tableName: string, + data: Record + ): Promise> { + try { + // 테이블명 화이트리스트 검증 + if (!ALLOWED_TABLES.includes(tableName)) { + return { + success: false, + message: `접근이 허용되지 않은 테이블입니다: ${tableName}`, + error: "TABLE_NOT_ALLOWED", + }; + } + + const columns = Object.keys(data); + const values = Object.values(data); + const placeholders = values.map((_, index) => `$${index + 1}`).join(", "); + const columnNames = columns.map((col) => `"${col}"`).join(", "); + + const queryText = ` + INSERT INTO "${tableName}" (${columnNames}) + VALUES (${placeholders}) + RETURNING * + `; + + const result = await query(queryText, values); + + return { + success: true, + data: result[0], + }; + } catch (error) { + console.error(`레코드 생성 오류 (${tableName}):`, error); + return { + success: false, + message: "레코드 생성 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } + + /** + * 레코드 수정 + */ + async updateRecord( + tableName: string, + id: string | number, + data: Record + ): Promise> { + try { + // 테이블명 화이트리스트 검증 + if (!ALLOWED_TABLES.includes(tableName)) { + return { + success: false, + message: `접근이 허용되지 않은 테이블입니다: ${tableName}`, + error: "TABLE_NOT_ALLOWED", + }; + } + + // Primary Key 컬럼 찾기 + const pkResult = await query<{ attname: string }>( + `SELECT a.attname + FROM pg_index i + JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) + WHERE i.indrelid = $1::regclass AND i.indisprimary`, + [tableName] + ); + + let pkColumn = "id"; + if (pkResult.length > 0) { + pkColumn = pkResult[0].attname; + } + + const columns = Object.keys(data); + const values = Object.values(data); + const setClause = columns + .map((col, index) => `"${col}" = $${index + 1}`) + .join(", "); + + const queryText = ` + UPDATE "${tableName}" + SET ${setClause} + WHERE "${pkColumn}" = $${values.length + 1} + RETURNING * + `; + + values.push(id); + const result = await query(queryText, values); + + if (result.length === 0) { + return { + success: false, + message: "레코드를 찾을 수 없습니다.", + error: "RECORD_NOT_FOUND", + }; + } + + return { + success: true, + data: result[0], + }; + } catch (error) { + console.error(`레코드 수정 오류 (${tableName}/${id}):`, error); + return { + success: false, + message: "레코드 수정 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } + + /** + * 레코드 삭제 + */ + async deleteRecord( + tableName: string, + id: string | number + ): Promise> { + try { + // 테이블명 화이트리스트 검증 + if (!ALLOWED_TABLES.includes(tableName)) { + return { + success: false, + message: `접근이 허용되지 않은 테이블입니다: ${tableName}`, + error: "TABLE_NOT_ALLOWED", + }; + } + + // Primary Key 컬럼 찾기 + const pkResult = await query<{ attname: string }>( + `SELECT a.attname + FROM pg_index i + JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) + WHERE i.indrelid = $1::regclass AND i.indisprimary`, + [tableName] + ); + + let pkColumn = "id"; + if (pkResult.length > 0) { + pkColumn = pkResult[0].attname; + } + + const queryText = `DELETE FROM "${tableName}" WHERE "${pkColumn}" = $1`; + await query(queryText, [id]); + + return { + success: true, + }; + } catch (error) { + console.error(`레코드 삭제 오류 (${tableName}/${id}):`, error); + return { + success: false, + message: "레코드 삭제 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } } export const dataService = new DataService(); diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 9bf3d672..05602fbb 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -942,8 +942,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD migratedComponents: layoutToUse.components.length, sampleComponent: layoutToUse.components[0], }); - - toast.success("레이아웃이 새로운 그리드 시스템으로 자동 변환되었습니다."); } // 기본 격자 설정 보장 (격자 표시와 스냅 기본 활성화) @@ -1249,9 +1247,42 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD try { setIsSaving(true); + + // 분할 패널 컴포넌트의 rightPanel.tableName 자동 설정 + const updatedComponents = layout.components.map((comp) => { + if (comp.type === "component" && comp.componentType === "split-panel-layout") { + const config = comp.componentConfig || {}; + const rightPanel = config.rightPanel || {}; + const leftPanel = config.leftPanel || {}; + const relationshipType = rightPanel.relation?.type || "detail"; + + // 관계 타입이 detail이면 rightPanel.tableName을 leftPanel.tableName과 동일하게 설정 + if (relationshipType === "detail" && leftPanel.tableName) { + console.log("🔧 분할 패널 자동 수정:", { + componentId: comp.id, + leftTableName: leftPanel.tableName, + rightTableName: leftPanel.tableName, + }); + + return { + ...comp, + componentConfig: { + ...config, + rightPanel: { + ...rightPanel, + tableName: leftPanel.tableName, + }, + }, + }; + } + } + return comp; + }); + // 해상도 정보를 포함한 레이아웃 데이터 생성 const layoutWithResolution = { ...layout, + components: updatedComponents, screenResolution: screenResolution, }; console.log("💾 저장 시작:", { @@ -3744,13 +3775,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD screenResolution={screenResolution} onBack={onBackToList} onSave={handleSave} - onUndo={undo} - onRedo={redo} - onPreview={() => { - toast.info("미리보기 기능은 준비 중입니다."); - }} - canUndo={historyIndex > 0} - canRedo={historyIndex < history.length - 1} isSaving={isSaving} /> {/* 메인 컨테이너 (좌측 툴바 + 패널들 + 캔버스) */} @@ -3869,12 +3893,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD ref={canvasContainerRef} className="relative flex-1 overflow-auto bg-gradient-to-br from-gray-50 to-slate-100 px-2 py-6" > - {/* Pan 모드 안내 */} - {isPanMode && ( -
- 🖐️ Pan 모드 활성화 - 드래그하여 캔버스 이동 -
- )} + {/* Pan 모드 안내 - 제거됨 */} {/* 줌 레벨 표시 */}
diff --git a/frontend/components/screen/panels/ComponentsPanel.tsx b/frontend/components/screen/panels/ComponentsPanel.tsx index edf3cced..5ec0b646 100644 --- a/frontend/components/screen/panels/ComponentsPanel.tsx +++ b/frontend/components/screen/panels/ComponentsPanel.tsx @@ -3,10 +3,10 @@ import React, { useState, useMemo } from "react"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ComponentRegistry } from "@/lib/registry/ComponentRegistry"; import { ComponentDefinition, ComponentCategory } from "@/types/component"; -import { Search, Package, Grid, Layers, Palette, Zap, MousePointer } from "lucide-react"; +import { Search, Package, Grid, Layers, Palette, Zap, MousePointer, Edit3, BarChart3 } from "lucide-react"; interface ComponentsPanelProps { className?: string; @@ -14,21 +14,20 @@ interface ComponentsPanelProps { export function ComponentsPanel({ className }: ComponentsPanelProps) { const [searchQuery, setSearchQuery] = useState(""); - const [selectedCategory, setSelectedCategory] = useState<"all" | "display" | "action" | "layout" | "utility">("all"); // 레지스트리에서 모든 컴포넌트 조회 const allComponents = useMemo(() => { const components = ComponentRegistry.getAllComponents(); - + // 수동으로 table-list 컴포넌트 추가 (임시) - const hasTableList = components.some(c => c.id === 'table-list'); + const hasTableList = components.some((c) => c.id === "table-list"); if (!hasTableList) { components.push({ - id: 'table-list', - name: '데이터 테이블 v2', - description: '검색, 필터링, 페이징, CRUD 기능이 포함된 완전한 데이터 테이블 컴포넌트', - category: 'display', - tags: ['table', 'data', 'crud'], + id: "table-list", + name: "데이터 테이블 v2", + description: "검색, 필터링, 페이징, CRUD 기능이 포함된 완전한 데이터 테이블 컴포넌트", + category: "display", + tags: ["table", "data", "crud"], defaultSize: { width: 1000, height: 680 }, } as ComponentDefinition); } @@ -39,17 +38,16 @@ export function ComponentsPanel({ className }: ComponentsPanelProps) { // 카테고리별 컴포넌트 그룹화 const componentsByCategory = useMemo(() => { return { - all: allComponents, - display: allComponents.filter((c) => c.category === "display"), - action: allComponents.filter((c) => c.category === "action"), - layout: allComponents.filter((c) => c.category === "layout"), - utility: allComponents.filter((c) => c.category === "utility"), + input: allComponents.filter((c) => c.category === ComponentCategory.INPUT && c.id === "file-upload"), + action: allComponents.filter((c) => c.category === ComponentCategory.ACTION), + display: allComponents.filter((c) => c.category === ComponentCategory.DISPLAY), + layout: allComponents.filter((c) => c.category === ComponentCategory.LAYOUT), }; }, [allComponents]); - // 검색 및 필터링된 컴포넌트 - const filteredComponents = useMemo(() => { - let components = selectedCategory === "all" ? componentsByCategory.all : componentsByCategory[selectedCategory as keyof typeof componentsByCategory]; + // 카테고리별 검색 필터링 + const getFilteredComponents = (category: keyof typeof componentsByCategory) => { + let components = componentsByCategory[category]; if (searchQuery) { const query = searchQuery.toLowerCase(); @@ -57,12 +55,12 @@ export function ComponentsPanel({ className }: ComponentsPanelProps) { (component: ComponentDefinition) => component.name.toLowerCase().includes(query) || component.description.toLowerCase().includes(query) || - component.tags?.some((tag: string) => tag.toLowerCase().includes(query)) + component.tags?.some((tag: string) => tag.toLowerCase().includes(query)), ); } return components; - }, [componentsByCategory, selectedCategory, searchQuery]); + }; // 카테고리 아이콘 매핑 const getCategoryIcon = (category: ComponentCategory) => { @@ -90,139 +88,127 @@ export function ComponentsPanel({ className }: ComponentsPanelProps) { e.dataTransfer.effectAllowed = "copy"; }; + // 컴포넌트 카드 렌더링 함수 + const renderComponentCard = (component: ComponentDefinition) => ( +
{ + handleDragStart(e, component); + e.currentTarget.style.opacity = "0.6"; + e.currentTarget.style.transform = "rotate(2deg) scale(0.98)"; + }} + onDragEnd={(e) => { + e.currentTarget.style.opacity = "1"; + e.currentTarget.style.transform = "none"; + }} + className="group cursor-grab rounded-lg border border-gray-200/40 bg-white/90 p-4 shadow-sm backdrop-blur-sm transition-all duration-300 hover:-translate-y-1 hover:scale-[1.02] hover:border-purple-300/60 hover:bg-white hover:shadow-lg hover:shadow-purple-500/15 active:translate-y-0 active:scale-[0.98] active:cursor-grabbing" + > +
+
+ {getCategoryIcon(component.category)} +
+
+

{component.name}

+

{component.description}

+
+ + {component.defaultSize.width}×{component.defaultSize.height} + +
+
+
+
+ ); + + // 빈 상태 렌더링 + const renderEmptyState = () => ( +
+
+ +

컴포넌트를 찾을 수 없습니다

+

검색어를 조정해보세요

+
+
+ ); + return ( -
+
{/* 헤더 */} -
-

컴포넌트

-

7개의 사용 가능한 컴포넌트

+
+

컴포넌트

+

{allComponents.length}개의 사용 가능한 컴포넌트

{/* 검색 */} -
+
setSearchQuery(e.target.value)} - className="pl-10 border-0 bg-white/80 backdrop-blur-sm shadow-sm focus:bg-white transition-colors" + className="border-0 bg-white/80 pl-10 shadow-sm backdrop-blur-sm transition-colors focus:bg-white" />
+
- {/* 카테고리 필터 */} -
- - - - - -
-
+ 레이아웃 + + - {/* 컴포넌트 목록 */} -
- {filteredComponents.length > 0 ? ( - filteredComponents.map((component) => ( -
{ - handleDragStart(e, component); - // 드래그 시작 시 시각적 피드백 - e.currentTarget.style.opacity = '0.6'; - e.currentTarget.style.transform = 'rotate(2deg) scale(0.98)'; - }} - onDragEnd={(e) => { - // 드래그 종료 시 원래 상태로 복원 - e.currentTarget.style.opacity = '1'; - e.currentTarget.style.transform = 'none'; - }} - className="group cursor-grab rounded-lg border border-gray-200/40 bg-white/90 backdrop-blur-sm p-6 shadow-sm transition-all duration-300 hover:bg-white hover:shadow-lg hover:shadow-purple-500/15 hover:scale-[1.02] hover:border-purple-300/60 hover:-translate-y-1 active:cursor-grabbing active:scale-[0.98] active:translate-y-0" - > -
-
- {getCategoryIcon(component.category)} -
-
-
-

{component.name}

- - 신규 - -
-

{component.description}

-
-
- - {component.defaultSize.width}×{component.defaultSize.height} - -
- - {component.category} - -
-
-
-
- )) - ) : ( -
-
- -

컴포넌트를 찾을 수 없습니다

-

검색어나 필터를 조정해보세요

-
-
- )} -
+ {/* 입력 컴포넌트 */} + + {getFilteredComponents("input").length > 0 + ? getFilteredComponents("input").map(renderComponentCard) + : renderEmptyState()} + + + {/* 액션 컴포넌트 */} + + {getFilteredComponents("action").length > 0 + ? getFilteredComponents("action").map(renderComponentCard) + : renderEmptyState()} + + + {/* 표시 컴포넌트 */} + + {getFilteredComponents("display").length > 0 + ? getFilteredComponents("display").map(renderComponentCard) + : renderEmptyState()} + + + {/* 레이아웃 컴포넌트 */} + + {getFilteredComponents("layout").length > 0 + ? getFilteredComponents("layout").map(renderComponentCard) + : renderEmptyState()} + + {/* 도움말 */} -
+
- +
-

+

컴포넌트를 드래그하여 화면에 추가하세요

diff --git a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx index ef188fe1..e7877be6 100644 --- a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx +++ b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx @@ -546,10 +546,11 @@ export const UnifiedPropertiesPanel: React.FC = ({ config={selectedComponent.componentConfig || {}} screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} tableColumns={currentTable?.columns || []} + tables={tables} onChange={(newConfig) => { - Object.entries(newConfig).forEach(([key, value]) => { - handleUpdate(`componentConfig.${key}`, value); - }); + console.log("🔄 DynamicComponentConfigPanel onChange:", newConfig); + // 전체 componentConfig를 업데이트 + handleUpdate("componentConfig", newConfig); }} /> @@ -624,10 +625,11 @@ export const UnifiedPropertiesPanel: React.FC = ({ config={widget.componentConfig || {}} screenTableName={widget.tableName || currentTable?.tableName || currentTableName} tableColumns={currentTable?.columns || []} + tables={tables} onChange={(newConfig) => { - Object.entries(newConfig).forEach(([key, value]) => { - handleUpdate(`componentConfig.${key}`, value); - }); + console.log("🔄 DynamicComponentConfigPanel onChange (widget):", newConfig); + // 전체 componentConfig를 업데이트 + handleUpdate("componentConfig", newConfig); }} /> ); diff --git a/frontend/components/screen/toolbar/SlimToolbar.tsx b/frontend/components/screen/toolbar/SlimToolbar.tsx index 3cc2bc20..9b8cd8a3 100644 --- a/frontend/components/screen/toolbar/SlimToolbar.tsx +++ b/frontend/components/screen/toolbar/SlimToolbar.tsx @@ -2,8 +2,7 @@ import React from "react"; import { Button } from "@/components/ui/button"; -import { Database, ArrowLeft, Undo, Redo, Play, Save, Monitor } from "lucide-react"; -import { cn } from "@/lib/utils"; +import { Database, ArrowLeft, Save, Monitor } from "lucide-react"; import { ScreenResolution } from "@/types/screen"; interface SlimToolbarProps { @@ -12,11 +11,6 @@ interface SlimToolbarProps { screenResolution?: ScreenResolution; onBack: () => void; onSave: () => void; - onUndo: () => void; - onRedo: () => void; - onPreview: () => void; - canUndo: boolean; - canRedo: boolean; isSaving?: boolean; } @@ -26,11 +20,6 @@ export const SlimToolbar: React.FC = ({ screenResolution, onBack, onSave, - onUndo, - onRedo, - onPreview, - canUndo, - canRedo, isSaving = false, }) => { return ( @@ -71,37 +60,8 @@ export const SlimToolbar: React.FC = ({ )}
- {/* 우측: 액션 버튼들 */} -
- - - - -
- - - + {/* 우측: 저장 버튼 */} +
+ )} +
+ {componentConfig.leftPanel?.showSearch && ( +
+ + setLeftSearchQuery(e.target.value)} + className="pl-9" + /> +
+ )} + + + {/* 좌측 데이터 목록 */} +
+ {isDesignMode ? ( + // 디자인 모드: 샘플 데이터 + <> +
handleLeftItemSelect({ id: 1, name: "항목 1" })} + className={`cursor-pointer rounded-md p-3 transition-colors hover:bg-gray-50 ${ + selectedLeftItem?.id === 1 ? "bg-blue-50 text-blue-700" : "text-gray-700" + }`} + > +
항목 1
+
설명 텍스트
+
+
handleLeftItemSelect({ id: 2, name: "항목 2" })} + className={`cursor-pointer rounded-md p-3 transition-colors hover:bg-gray-50 ${ + selectedLeftItem?.id === 2 ? "bg-blue-50 text-blue-700" : "text-gray-700" + }`} + > +
항목 2
+
설명 텍스트
+
+
handleLeftItemSelect({ id: 3, name: "항목 3" })} + className={`cursor-pointer rounded-md p-3 transition-colors hover:bg-gray-50 ${ + selectedLeftItem?.id === 3 ? "bg-blue-50 text-blue-700" : "text-gray-700" + }`} + > +
항목 3
+
설명 텍스트
+
+ + ) : isLoadingLeft ? ( + // 로딩 중 +
+ + 데이터를 불러오는 중... +
+ ) : leftData.length > 0 ? ( + // 실제 데이터 표시 + leftData.map((item, index) => { + const itemId = item.id || item.ID || item[Object.keys(item)[0]] || index; + const isSelected = selectedLeftItem && (selectedLeftItem.id === itemId || selectedLeftItem === item); + // 첫 번째 2-3개 필드를 표시 + const keys = Object.keys(item).filter((k) => k !== "id" && k !== "ID"); + const displayTitle = item[keys[0]] || item.name || item.title || `항목 ${index + 1}`; + const displaySubtitle = keys[1] ? item[keys[1]] : null; + + return ( +
handleLeftItemSelect(item)} + className={`cursor-pointer rounded-md p-3 transition-colors hover:bg-gray-50 ${ + isSelected ? "bg-blue-50 text-blue-700" : "text-gray-700" + }`} + > +
{displayTitle}
+ {displaySubtitle &&
{displaySubtitle}
} +
+ ); + }) + ) : ( + // 데이터 없음 +
데이터가 없습니다.
+ )} +
+
+ +
+ + {/* 리사이저 */} + {resizable && ( +
+ +
+ )} + + {/* 우측 패널 */} +
+ + +
+ + {componentConfig.rightPanel?.title || "우측 패널"} + + {componentConfig.rightPanel?.showAdd && ( + + )} +
+ {componentConfig.rightPanel?.showSearch && ( +
+ + setRightSearchQuery(e.target.value)} + className="pl-9" + /> +
+ )} +
+ + {/* 우측 상세 데이터 */} + {isLoadingRight ? ( + // 로딩 중 +
+
+ +

상세 정보를 불러오는 중...

+
+
+ ) : rightData ? ( + // 실제 데이터 표시 +
+ {Object.entries(rightData).map(([key, value]) => { + // null, undefined, 빈 문자열 제외 + if (value === null || value === undefined || value === "") return null; + + return ( +
+
{key}
+
{String(value)}
+
+ ); + })} +
+ ) : selectedLeftItem && isDesignMode ? ( + // 디자인 모드: 샘플 데이터 +
+
+

{selectedLeftItem.name} 상세 정보

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

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

+

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

+
+
+ )} +
+
+
+
+ ); +}; + +/** + * SplitPanelLayout 래퍼 컴포넌트 + */ +export const SplitPanelLayoutWrapper: React.FC = (props) => { + return ; +}; diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx new file mode 100644 index 00000000..e8f68244 --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx @@ -0,0 +1,457 @@ +"use client"; + +import React, { useState, useMemo, useEffect } from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Slider } from "@/components/ui/slider"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Button } from "@/components/ui/button"; +import { Check, ChevronsUpDown, ArrowRight } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { SplitPanelLayoutConfig } from "./types"; +import { TableInfo } from "@/types/screen"; + +interface SplitPanelLayoutConfigPanelProps { + config: SplitPanelLayoutConfig; + onChange: (config: SplitPanelLayoutConfig) => void; + tables?: TableInfo[]; // 전체 테이블 목록 (선택적) + screenTableName?: string; // 현재 화면의 테이블명 (좌측 패널에서 사용) +} + +/** + * SplitPanelLayout 설정 패널 + */ +export const SplitPanelLayoutConfigPanel: React.FC = ({ + config, + onChange, + tables = [], // 기본값 빈 배열 + screenTableName, // 현재 화면의 테이블명 +}) => { + const [rightTableOpen, setRightTableOpen] = useState(false); + const [leftColumnOpen, setLeftColumnOpen] = useState(false); + const [rightColumnOpen, setRightColumnOpen] = useState(false); + + // screenTableName이 변경되면 leftPanel.tableName 자동 업데이트 + useEffect(() => { + if (screenTableName) { + // 좌측 패널 테이블명 업데이트 + if (config.leftPanel?.tableName !== screenTableName) { + updateLeftPanel({ tableName: screenTableName }); + } + + // 관계 타입이 detail이면 우측 패널도 동일한 테이블 사용 + const relationshipType = config.rightPanel?.relation?.type || "detail"; + if (relationshipType === "detail" && config.rightPanel?.tableName !== screenTableName) { + updateRightPanel({ tableName: screenTableName }); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [screenTableName]); + + console.log("🔧 SplitPanelLayoutConfigPanel 렌더링"); + console.log(" - config:", config); + console.log(" - tables:", tables); + console.log(" - tablesCount:", tables.length); + console.log(" - screenTableName:", screenTableName); + console.log(" - leftTable:", config.leftPanel?.tableName); + console.log(" - rightTable:", config.rightPanel?.tableName); + + const updateConfig = (updates: Partial) => { + const newConfig = { ...config, ...updates }; + console.log("🔄 Config 업데이트:", newConfig); + onChange(newConfig); + }; + + const updateLeftPanel = (updates: Partial) => { + const newConfig = { + ...config, + leftPanel: { ...config.leftPanel, ...updates }, + }; + console.log("🔄 Left Panel 업데이트:", newConfig); + onChange(newConfig); + }; + + const updateRightPanel = (updates: Partial) => { + const newConfig = { + ...config, + rightPanel: { ...config.rightPanel, ...updates }, + }; + console.log("🔄 Right Panel 업데이트:", newConfig); + onChange(newConfig); + }; + + // 좌측 테이블은 현재 화면의 테이블 (screenTableName) 사용 + const leftTableColumns = useMemo(() => { + const tableName = screenTableName || config.leftPanel?.tableName; + const table = tables.find((t) => t.tableName === tableName); + return table?.columns || []; + }, [tables, screenTableName, config.leftPanel?.tableName]); + + // 우측 테이블의 컬럼 목록 가져오기 + const rightTableColumns = useMemo(() => { + const table = tables.find((t) => t.tableName === config.rightPanel?.tableName); + return table?.columns || []; + }, [tables, config.rightPanel?.tableName]); + + // 테이블 데이터 로딩 상태 확인 + if (!tables || tables.length === 0) { + return ( +
+

⚠️ 테이블 데이터를 불러올 수 없습니다.

+

+ 화면에 테이블이 연결되지 않았거나 테이블 목록이 로드되지 않았습니다. +

+
+ ); + } + + // 관계 타입에 따라 우측 테이블을 자동으로 설정 + const relationshipType = config.rightPanel?.relation?.type || "detail"; + + return ( +
+ {/* 테이블 정보 표시 */} +
+

📊 사용 가능한 테이블: {tables.length}개

+
+ + {/* 관계 타입 선택 (최상단) */} +
+
+
+ 1 +
+

패널 관계 타입 선택

+
+

좌측과 우측 패널 간의 데이터 관계를 선택하세요

+ +
+ + {/* 좌측 패널 설정 (마스터) */} +
+
+
+ 2 +
+

좌측 패널 설정 (마스터)

+
+ +
+ + updateLeftPanel({ title: e.target.value })} + placeholder="좌측 패널 제목" + /> +
+ +
+ +
+

{screenTableName || "테이블이 지정되지 않음"}

+

좌측 패널은 현재 화면의 테이블 데이터를 표시합니다

+
+
+ +
+ + updateLeftPanel({ showSearch: checked })} + /> +
+ +
+ + updateLeftPanel({ showAdd: checked })} + /> +
+
+ + {/* 우측 패널 설정 */} +
+
+
+ 3 +
+

+ 우측 패널 설정 ({relationshipType === "detail" ? "상세" : relationshipType === "join" ? "조인" : "커스텀"}) +

+
+ +
+ + updateRightPanel({ title: e.target.value })} + placeholder="우측 패널 제목" + /> +
+ + {/* 관계 타입에 따라 테이블 선택 UI 변경 */} + {relationshipType === "detail" ? ( + // 상세 모드: 좌측과 동일한 테이블 (비활성화) +
+ +
+

{screenTableName || "테이블이 지정되지 않음"}

+

상세 모드에서는 좌측과 동일한 테이블을 사용합니다

+
+
+ ) : ( + // 조인/커스텀 모드: 전체 테이블에서 선택 가능 +
+ + + + + + + + + 테이블을 찾을 수 없습니다. + + {tables.map((table) => ( + { + updateRightPanel({ tableName: value }); + setRightTableOpen(false); + }} + > + + {table.tableName} + ({table.tableLabel || ""}) + + ))} + + + + +
+ )} + + {/* 컬럼 매핑 - 조인/커스텀 모드에서만 표시 */} + {relationshipType !== "detail" && ( +
+ +

좌측 테이블의 컬럼을 우측 테이블의 컬럼과 연결합니다

+ +
+ + + + + + + + + 컬럼을 찾을 수 없습니다. + + {leftTableColumns.map((column) => ( + { + updateRightPanel({ + relation: { ...config.rightPanel?.relation, leftColumn: value }, + }); + setLeftColumnOpen(false); + }} + > + + {column.columnName} + ({column.columnLabel || ""}) + + ))} + + + + +
+ +
+ +
+ +
+ + + + + + + + + 컬럼을 찾을 수 없습니다. + + {rightTableColumns.map((column) => ( + { + updateRightPanel({ + relation: { ...config.rightPanel?.relation, foreignKey: value }, + }); + setRightColumnOpen(false); + }} + > + + {column.columnName} + ({column.columnLabel || ""}) + + ))} + + + + +
+
+ )} + +
+ + updateRightPanel({ showSearch: checked })} + /> +
+ +
+ + updateRightPanel({ showAdd: checked })} + /> +
+
+ + {/* 레이아웃 설정 */} +
+
+
+ 4 +
+

레이아웃 설정

+
+ +
+ + updateConfig({ splitRatio: value[0] })} + min={20} + max={80} + step={5} + /> +
+ +
+ + updateConfig({ resizable: checked })} + /> +
+ +
+ + updateConfig({ autoLoad: checked })} + /> +
+
+
+ ); +}; diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutRenderer.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutRenderer.tsx new file mode 100644 index 00000000..9daaa646 --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutRenderer.tsx @@ -0,0 +1,40 @@ +"use client"; + +import React from "react"; +import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; +import { SplitPanelLayoutDefinition } from "./index"; +import { SplitPanelLayoutComponent } from "./SplitPanelLayoutComponent"; + +/** + * SplitPanelLayout 렌더러 + * 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록 + */ +export class SplitPanelLayoutRenderer extends AutoRegisteringComponentRenderer { + static componentDefinition = SplitPanelLayoutDefinition; + + render(): React.ReactElement { + return ; + } + + /** + * 컴포넌트별 특화 메서드들 + */ + + // 좌측 패널 데이터 로드 + protected async loadLeftPanelData() { + // 좌측 패널 데이터 로드 로직 + } + + // 우측 패널 데이터 로드 (선택된 항목 기반) + protected async loadRightPanelData(selectedItem: any) { + // 우측 패널 데이터 로드 로직 + } +} + +// 자동 등록 실행 +SplitPanelLayoutRenderer.registerSelf(); + +// Hot Reload 지원 (개발 모드) +if (process.env.NODE_ENV === "development") { + SplitPanelLayoutRenderer.enableHotReload(); +} diff --git a/frontend/lib/registry/components/split-panel-layout/config.ts b/frontend/lib/registry/components/split-panel-layout/config.ts new file mode 100644 index 00000000..f0713351 --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout/config.ts @@ -0,0 +1,69 @@ +/** + * SplitPanelLayout 컴포넌트 설정 + */ + +export const splitPanelLayoutConfig = { + // 기본 스타일 + defaultStyle: { + border: "1px solid #e5e7eb", + borderRadius: "8px", + backgroundColor: "#ffffff", + }, + + // 프리셋 설정들 + presets: { + codeManagement: { + name: "코드 관리", + leftPanel: { + title: "코드 카테고리", + showSearch: true, + showAdd: true, + }, + rightPanel: { + title: "코드 목록", + showSearch: true, + showAdd: true, + relation: { + type: "detail", + foreignKey: "category_id", + }, + }, + splitRatio: 30, + }, + tableJoin: { + name: "테이블 조인", + leftPanel: { + title: "기본 테이블", + showSearch: true, + showAdd: false, + }, + rightPanel: { + title: "조인 조건", + showSearch: false, + showAdd: true, + relation: { + type: "join", + }, + }, + splitRatio: 35, + }, + menuSettings: { + name: "메뉴 설정", + leftPanel: { + title: "메뉴 트리", + showSearch: true, + showAdd: true, + }, + rightPanel: { + title: "메뉴 상세", + showSearch: false, + showAdd: false, + relation: { + type: "detail", + foreignKey: "menu_id", + }, + }, + splitRatio: 25, + }, + }, +}; diff --git a/frontend/lib/registry/components/split-panel-layout/index.ts b/frontend/lib/registry/components/split-panel-layout/index.ts new file mode 100644 index 00000000..10c6ee7d --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout/index.ts @@ -0,0 +1,60 @@ +"use client"; + +import React from "react"; +import { createComponentDefinition } from "../../utils/createComponentDefinition"; +import { ComponentCategory } from "@/types/component"; +import { SplitPanelLayoutWrapper } from "./SplitPanelLayoutComponent"; +import { SplitPanelLayoutConfigPanel } from "./SplitPanelLayoutConfigPanel"; +import { SplitPanelLayoutConfig } from "./types"; + +/** + * SplitPanelLayout 컴포넌트 정의 + * 마스터-디테일 패턴의 좌우 분할 레이아웃 + */ +export const SplitPanelLayoutDefinition = createComponentDefinition({ + id: "split-panel-layout", + name: "분할 패널", + nameEng: "SplitPanelLayout Component", + description: "마스터-디테일 패턴의 좌우 분할 레이아웃 컴포넌트", + category: ComponentCategory.DISPLAY, + webType: "text", + component: SplitPanelLayoutWrapper, + defaultConfig: { + leftPanel: { + title: "마스터", + showSearch: true, + showAdd: false, + }, + rightPanel: { + title: "디테일", + showSearch: true, + showAdd: false, + relation: { + type: "detail", + foreignKey: "parent_id", + }, + }, + splitRatio: 30, + resizable: true, + minLeftWidth: 200, + minRightWidth: 300, + autoLoad: true, + syncSelection: true, + } as SplitPanelLayoutConfig, + defaultSize: { width: 1000, height: 600 }, + configPanel: SplitPanelLayoutConfigPanel, + icon: "PanelLeftRight", + tags: ["분할", "마스터", "디테일", "레이아웃"], + version: "1.0.0", + author: "개발팀", + documentation: "https://docs.example.com/components/split-panel-layout", +}); + +// 컴포넌트는 SplitPanelLayoutRenderer에서 자동 등록됩니다 + +// 타입 내보내기 +export type { SplitPanelLayoutConfig } from "./types"; + +// 컴포넌트 내보내기 +export { SplitPanelLayoutComponent } from "./SplitPanelLayoutComponent"; +export { SplitPanelLayoutRenderer } from "./SplitPanelLayoutRenderer"; diff --git a/frontend/lib/registry/components/split-panel-layout/types.ts b/frontend/lib/registry/components/split-panel-layout/types.ts new file mode 100644 index 00000000..3236d169 --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout/types.ts @@ -0,0 +1,51 @@ +/** + * SplitPanelLayout 컴포넌트 타입 정의 + */ + +export interface SplitPanelLayoutConfig { + // 좌측 패널 설정 + leftPanel: { + title: string; + tableName?: string; // 데이터베이스 테이블명 + dataSource?: string; // API 엔드포인트 + showSearch?: boolean; + showAdd?: boolean; + columns?: Array<{ + name: string; + label: string; + width?: number; + }>; + }; + + // 우측 패널 설정 + rightPanel: { + title: string; + tableName?: string; + dataSource?: string; + showSearch?: boolean; + showAdd?: boolean; + columns?: Array<{ + name: string; + label: string; + width?: number; + }>; + + // 좌측 선택 항목과의 관계 설정 + relation?: { + type: "join" | "detail" | "custom"; // 관계 타입 + leftColumn?: string; // 좌측 테이블의 연결 컬럼 + foreignKey?: string; // 우측 테이블의 외래키 컬럼명 + condition?: string; // 커스텀 조건 + }; + }; + + // 레이아웃 설정 + splitRatio?: number; // 좌우 비율 (0-100, 기본 30) + resizable?: boolean; // 크기 조절 가능 여부 + minLeftWidth?: number; // 좌측 최소 너비 (px) + minRightWidth?: number; // 우측 최소 너비 (px) + + // 동작 설정 + autoLoad?: boolean; // 자동 데이터 로드 + syncSelection?: boolean; // 선택 항목 동기화 +} diff --git a/frontend/lib/registry/components/text-display/TextDisplayComponent.tsx b/frontend/lib/registry/components/text-display/TextDisplayComponent.tsx index 721ddde2..dc4f15da 100644 --- a/frontend/lib/registry/components/text-display/TextDisplayComponent.tsx +++ b/frontend/lib/registry/components/text-display/TextDisplayComponent.tsx @@ -75,9 +75,9 @@ export const TextDisplayComponent: React.FC = ({ color: componentConfig.color || "#212121", textAlign: componentConfig.textAlign || "left", backgroundColor: componentConfig.backgroundColor || "transparent", - padding: componentConfig.padding || "8px 12px", - borderRadius: componentConfig.borderRadius || "8px", - border: componentConfig.border || "1px solid #e5e7eb", + padding: componentConfig.padding || "0", + borderRadius: componentConfig.borderRadius || "0", + border: componentConfig.border || "none", width: "100%", height: "100%", display: "flex", @@ -91,7 +91,7 @@ export const TextDisplayComponent: React.FC = ({ wordBreak: "break-word", overflow: "hidden", transition: "all 0.2s ease-in-out", - boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)", + boxShadow: "none", }; return ( diff --git a/frontend/lib/registry/components/toggle-switch/index.ts b/frontend/lib/registry/components/toggle-switch/index.ts index 948a52b2..7d38a1ef 100644 --- a/frontend/lib/registry/components/toggle-switch/index.ts +++ b/frontend/lib/registry/components/toggle-switch/index.ts @@ -17,7 +17,7 @@ export const ToggleSwitchDefinition = createComponentDefinition({ name: "토글 스위치", nameEng: "ToggleSwitch Component", description: "ON/OFF 상태 전환을 위한 토글 스위치 컴포넌트", - category: ComponentCategory.INPUT, + category: ComponentCategory.FORM, webType: "boolean", component: ToggleSwitchWrapper, defaultConfig: { diff --git a/frontend/lib/utils/getComponentConfigPanel.tsx b/frontend/lib/utils/getComponentConfigPanel.tsx index fa464377..dedffa7a 100644 --- a/frontend/lib/utils/getComponentConfigPanel.tsx +++ b/frontend/lib/utils/getComponentConfigPanel.tsx @@ -23,6 +23,7 @@ const CONFIG_PANEL_MAP: Record Promise> = { "accordion-basic": () => import("@/lib/registry/components/accordion-basic/AccordionBasicConfigPanel"), "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"), }; // ConfigPanel 컴포넌트 캐시 @@ -101,6 +102,7 @@ export interface ComponentConfigPanelProps { onChange: (config: Record) => void; screenTableName?: string; // 화면에서 지정한 테이블명 tableColumns?: any[]; // 테이블 컬럼 정보 + tables?: any[]; // 전체 테이블 목록 } export const DynamicComponentConfigPanel: React.FC = ({ @@ -109,9 +111,10 @@ export const DynamicComponentConfigPanel: React.FC = onChange, screenTableName, tableColumns, + tables, }) => { console.log(`🔥 DynamicComponentConfigPanel 렌더링 시작: ${componentId}`); - + const [ConfigPanelComponent, setConfigPanelComponent] = React.useState | null>(null); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); @@ -187,18 +190,21 @@ export const DynamicComponentConfigPanel: React.FC = ConfigPanelComponent: ConfigPanelComponent?.name, config, configType: typeof config, - configKeys: typeof config === 'object' ? Object.keys(config || {}) : 'not object', + configKeys: typeof config === "object" ? Object.keys(config || {}) : "not object", screenTableName, - tableColumns: Array.isArray(tableColumns) ? tableColumns.length : tableColumns + tableColumns: Array.isArray(tableColumns) ? tableColumns.length : tableColumns, + tables: Array.isArray(tables) ? tables.length : tables, + tablesType: typeof tables, }); return ( ); }; From 716cfcb2cfbf4dda5e3e585274a4481184499b18 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 15 Oct 2025 18:31:40 +0900 Subject: [PATCH 02/11] =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EB=AF=B8=EB=A6=AC=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/screenManagementController.ts | 24 + .../src/routes/screenManagementRoutes.ts | 2 + .../src/services/screenManagementService.ts | 45 ++ frontend/app/globals.css | 13 + .../screen/InteractiveScreenViewer.tsx | 6 + frontend/components/screen/ScreenList.tsx | 412 +++++++++++++++++- .../components/screen/widgets/FileUpload.tsx | 375 ++++++++-------- frontend/components/ui/dialog.tsx | 2 +- frontend/lib/api/screen.ts | 8 + frontend/lib/utils/componentTypeUtils.ts | 85 ++-- 10 files changed, 742 insertions(+), 230 deletions(-) diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts index 7130777c..f7900b94 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -104,6 +104,30 @@ export const updateScreen = async ( } }; +// 화면 정보 수정 (메타데이터만) +export const updateScreenInfo = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { id } = req.params; + const { companyCode } = req.user as any; + const { screenName, description, isActive } = req.body; + + await screenManagementService.updateScreenInfo( + parseInt(id), + { screenName, description, isActive }, + companyCode + ); + res.json({ success: true, message: "화면 정보가 수정되었습니다." }); + } catch (error) { + console.error("화면 정보 수정 실패:", error); + res + .status(500) + .json({ success: false, message: "화면 정보 수정에 실패했습니다." }); + } +}; + // 화면 의존성 체크 export const checkScreenDependencies = async ( req: AuthenticatedRequest, diff --git a/backend-node/src/routes/screenManagementRoutes.ts b/backend-node/src/routes/screenManagementRoutes.ts index bc15c279..3fed9129 100644 --- a/backend-node/src/routes/screenManagementRoutes.ts +++ b/backend-node/src/routes/screenManagementRoutes.ts @@ -5,6 +5,7 @@ import { getScreen, createScreen, updateScreen, + updateScreenInfo, deleteScreen, checkScreenDependencies, restoreScreen, @@ -34,6 +35,7 @@ router.get("/screens", getScreens); router.get("/screens/:id", getScreen); router.post("/screens", createScreen); router.put("/screens/:id", updateScreen); +router.put("/screens/:id/info", updateScreenInfo); // 화면 정보만 수정 router.get("/screens/:id/dependencies", checkScreenDependencies); // 의존성 체크 router.delete("/screens/:id", deleteScreen); // 휴지통으로 이동 router.post("/screens/:id/copy", copyScreen); diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 6da8d16a..a984fa85 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -300,6 +300,51 @@ export class ScreenManagementService { return this.mapToScreenDefinition(screen); } + /** + * 화면 정보 수정 (메타데이터만) - 편집 기능용 + */ + async updateScreenInfo( + screenId: number, + updateData: { screenName: string; description?: string; isActive: string }, + userCompanyCode: string + ): Promise { + // 권한 확인 + const existingResult = await query<{ company_code: string | null }>( + `SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, + [screenId] + ); + + if (existingResult.length === 0) { + throw new Error("화면을 찾을 수 없습니다."); + } + + const existingScreen = existingResult[0]; + + if ( + userCompanyCode !== "*" && + existingScreen.company_code !== userCompanyCode + ) { + throw new Error("이 화면을 수정할 권한이 없습니다."); + } + + // 화면 정보 업데이트 + await query( + `UPDATE screen_definitions + SET screen_name = $1, + description = $2, + is_active = $3, + updated_date = $4 + WHERE screen_id = $5`, + [ + updateData.screenName, + updateData.description || null, + updateData.isActive, + new Date(), + screenId, + ] + ); + } + /** * 화면 의존성 체크 - 다른 화면에서 이 화면을 참조하는지 확인 */ diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 8352502a..03abcd11 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -132,3 +132,16 @@ @apply bg-background text-foreground; } } + +/* Dialog 오버레이 커스터마이징 - 어두운 배경 */ +[data-radix-dialog-overlay], +.fixed.inset-0.z-50.bg-black { + background-color: rgba(0, 0, 0, 0.6) !important; + backdrop-filter: none !important; +} + +/* DialogPrimitive.Overlay 클래스 오버라이드 */ +.fixed.inset-0.z-50 { + background-color: rgba(0, 0, 0, 0.6) !important; + backdrop-filter: none !important; +} diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index fca43a6c..b7f77ab5 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -80,6 +80,12 @@ export const InteractiveScreenViewer: React.FC = ( showValidationPanel = false, validationOptions = {}, }) => { + // component가 없으면 빈 div 반환 + if (!component) { + console.warn("⚠️ InteractiveScreenViewer: component가 undefined입니다."); + return
; + } + const { userName, user } = useAuth(); // 현재 로그인한 사용자명과 사용자 정보 가져오기 const [localFormData, setLocalFormData] = useState>({}); const [dateValues, setDateValues] = useState>({}); diff --git a/frontend/components/screen/ScreenList.tsx b/frontend/components/screen/ScreenList.tsx index dc6bab87..746a2218 100644 --- a/frontend/components/screen/ScreenList.tsx +++ b/frontend/components/screen/ScreenList.tsx @@ -26,11 +26,26 @@ import { } from "@/components/ui/alert-dialog"; import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette, RotateCcw, Trash } from "lucide-react"; import { ScreenDefinition } from "@/types/screen"; import { screenApi } from "@/lib/api/screen"; import CreateScreenModal from "./CreateScreenModal"; import CopyScreenModal from "./CopyScreenModal"; +import dynamic from "next/dynamic"; +import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; +import { DynamicWebTypeRenderer } from "@/lib/registry"; +import { isFileComponent, getComponentWebType } from "@/lib/utils/componentTypeUtils"; + +// InteractiveScreenViewer를 동적으로 import (SSR 비활성화) +const InteractiveScreenViewer = dynamic( + () => import("./InteractiveScreenViewer").then((mod) => mod.InteractiveScreenViewer), + { + ssr: false, + loading: () =>
로딩 중...
, + }, +); interface ScreenListProps { onScreenSelect: (screen: ScreenDefinition) => void; @@ -82,6 +97,22 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false); const [bulkDeleting, setBulkDeleting] = useState(false); + // 편집 관련 상태 + const [editDialogOpen, setEditDialogOpen] = useState(false); + const [screenToEdit, setScreenToEdit] = useState(null); + const [editFormData, setEditFormData] = useState({ + screenName: "", + description: "", + isActive: "Y", + }); + + // 미리보기 관련 상태 + const [previewDialogOpen, setPreviewDialogOpen] = useState(false); + const [screenToPreview, setScreenToPreview] = useState(null); + const [previewLayout, setPreviewLayout] = useState(null); + const [isLoadingPreview, setIsLoadingPreview] = useState(false); + const [previewFormData, setPreviewFormData] = useState>({}); + // 화면 목록 로드 (실제 API) useEffect(() => { let abort = false; @@ -138,8 +169,42 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr }; const handleEdit = (screen: ScreenDefinition) => { - // 편집 모달 열기 - // console.log("편집:", screen); + setScreenToEdit(screen); + setEditFormData({ + screenName: screen.screenName, + description: screen.description || "", + isActive: screen.isActive, + }); + setEditDialogOpen(true); + }; + + const handleEditSave = async () => { + if (!screenToEdit) return; + + try { + // 화면 정보 업데이트 API 호출 + await screenApi.updateScreenInfo(screenToEdit.screenId, editFormData); + + // 목록에서 해당 화면 정보 업데이트 + setScreens((prev) => + prev.map((s) => + s.screenId === screenToEdit.screenId + ? { + ...s, + screenName: editFormData.screenName, + description: editFormData.description, + isActive: editFormData.isActive, + } + : s, + ), + ); + + setEditDialogOpen(false); + setScreenToEdit(null); + } catch (error) { + console.error("화면 정보 업데이트 실패:", error); + alert("화면 정보 업데이트에 실패했습니다."); + } }; const handleDelete = async (screen: ScreenDefinition) => { @@ -295,9 +360,22 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr setIsCopyOpen(true); }; - const handleView = (screen: ScreenDefinition) => { - // 미리보기 모달 열기 - // console.log("미리보기:", screen); + const handleView = async (screen: ScreenDefinition) => { + setScreenToPreview(screen); + setPreviewDialogOpen(true); + setIsLoadingPreview(true); + + try { + // 화면 레이아웃 로드 + const layoutData = await screenApi.getLayout(screen.screenId); + console.log("📊 미리보기 레이아웃 로드:", layoutData); + setPreviewLayout(layoutData); + } catch (error) { + console.error("❌ 레이아웃 로드 실패:", error); + toast.error("화면 레이아웃을 불러오는데 실패했습니다."); + } finally { + setIsLoadingPreview(false); + } }; const handleCopySuccess = () => { @@ -329,11 +407,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr />
-
@@ -386,7 +460,9 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr - {screen.tableLabel || screen.tableName} + + {screen.tableLabel || screen.tableName} + -
{screen.createdDate.toLocaleDateString()}
+
{screen.createdDate.toLocaleDateString()}
{screen.createdBy}
@@ -504,16 +580,18 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr - {screen.tableLabel || screen.tableName} + + {screen.tableLabel || screen.tableName} + -
{screen.deletedDate?.toLocaleDateString()}
+
{screen.deletedDate?.toLocaleDateString()}
-
{screen.deletedBy}
+
{screen.deletedBy}
-
+
{screen.deleteReason || "-"}
@@ -563,7 +641,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr > 이전 - + {currentPage} / {totalPages}