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 ( ); };