From 083f0538516171eb883cf1c0cb5761cb5529ea57 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 10 Sep 2025 18:36:28 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/prisma/schema.prisma | 31 + backend-node/scripts/add-missing-columns.js | 105 +++ backend-node/scripts/init-layout-standards.js | 309 ++++++++ backend-node/src/app.ts | 2 + .../src/controllers/layoutController.ts | 276 +++++++ backend-node/src/routes/layoutRoutes.ts | 73 ++ backend-node/src/services/layoutService.ts | 425 +++++++++++ backend-node/src/types/layout.ts | 198 +++++ frontend/app/(main)/admin/layouts/page.tsx | 416 +++++++++++ .../app/api/admin/layouts/generate/route.ts | 124 ++++ frontend/app/api/admin/layouts/list/route.ts | 53 ++ frontend/components/admin/LayoutFormModal.tsx | 534 +++++++++++++ .../components/screen/DesignerToolbar.tsx | 22 + frontend/components/screen/ScreenDesigner.tsx | 107 +++ .../components/screen/panels/LayoutsPanel.tsx | 195 +++++ frontend/docs/new-layout-system-guide.md | 283 +++++++ frontend/docs/레이아웃_기능_설계서.md | 701 ++++++++++++++++++ frontend/docs/레이아웃_추가_가이드.md | 416 +++++++++++ frontend/lib/api/layout.ts | 132 ++++ .../lib/registry/DynamicComponentRenderer.tsx | 16 + .../lib/registry/DynamicLayoutRenderer.tsx | 106 +++ frontend/lib/registry/LayoutRegistry.ts | 317 ++++++++ .../layouts/AutoRegisteringLayoutRenderer.ts | 367 +++++++++ .../layouts/AutoRegisteringLayoutRenderer.tsx | 367 +++++++++ .../registry/layouts/BaseLayoutRenderer.tsx | 304 ++++++++ .../layouts/FlexboxLayoutRenderer.tsx | 139 ++++ .../registry/layouts/GridLayoutRenderer.tsx | 133 ++++ .../registry/layouts/SplitLayoutRenderer.tsx | 204 +++++ .../registry/layouts/TabsLayoutRenderer.tsx | 178 +++++ .../layouts/accordion/AccordionLayout.tsx | 164 ++++ .../accordion/AccordionLayoutRenderer.tsx | 49 ++ .../lib/registry/layouts/accordion/README.md | 43 ++ .../lib/registry/layouts/accordion/config.ts | 50 ++ .../lib/registry/layouts/accordion/index.ts | 63 ++ .../lib/registry/layouts/accordion/types.ts | 28 + .../layouts/card-layout/CardLayoutLayout.tsx | 102 +++ .../card-layout/CardLayoutRenderer.tsx | 109 +++ .../registry/layouts/card-layout/README.md | 46 ++ .../registry/layouts/card-layout/config.ts | 68 ++ .../lib/registry/layouts/card-layout/index.ts | 79 ++ .../lib/registry/layouts/card-layout/types.ts | 28 + .../layouts/flexbox/FlexboxLayout.tsx | 115 +++ .../layouts/flexbox/FlexboxLayoutRenderer.tsx | 88 +++ .../lib/registry/layouts/flexbox/README.md | 42 ++ .../lib/registry/layouts/flexbox/config.ts | 44 ++ .../lib/registry/layouts/flexbox/index.ts | 59 ++ .../lib/registry/layouts/flexbox/types.ts | 28 + .../lib/registry/layouts/grid/GridLayout.tsx | 160 ++++ .../layouts/grid/GridLayoutRenderer.tsx | 52 ++ frontend/lib/registry/layouts/grid/index.ts | 69 ++ .../hero-section/HeroSectionLayout.tsx | 98 +++ .../HeroSectionLayoutRenderer.tsx | 49 ++ .../registry/layouts/hero-section/README.md | 43 ++ .../registry/layouts/hero-section/config.ts | 50 ++ .../registry/layouts/hero-section/index.ts | 60 ++ .../registry/layouts/hero-section/types.ts | 28 + frontend/lib/registry/layouts/index.ts | 109 +++ frontend/lib/registry/layouts/split/README.md | 42 ++ .../registry/layouts/split/SplitLayout.tsx | 98 +++ .../layouts/split/SplitLayoutRenderer.tsx | 49 ++ frontend/lib/registry/layouts/split/config.ts | 44 ++ frontend/lib/registry/layouts/split/index.ts | 61 ++ frontend/lib/registry/layouts/split/types.ts | 28 + frontend/lib/registry/utils/autoDiscovery.ts | 288 +++++++ .../registry/utils/createLayoutDefinition.ts | 174 +++++ frontend/package.json | 3 +- frontend/scripts/create-layout.js | 524 +++++++++++++ frontend/types/layout.ts | 429 +++++++++++ frontend/types/screen.ts | 25 +- 69 files changed, 10218 insertions(+), 3 deletions(-) create mode 100644 backend-node/scripts/add-missing-columns.js create mode 100644 backend-node/scripts/init-layout-standards.js create mode 100644 backend-node/src/controllers/layoutController.ts create mode 100644 backend-node/src/routes/layoutRoutes.ts create mode 100644 backend-node/src/services/layoutService.ts create mode 100644 backend-node/src/types/layout.ts create mode 100644 frontend/app/(main)/admin/layouts/page.tsx create mode 100644 frontend/app/api/admin/layouts/generate/route.ts create mode 100644 frontend/app/api/admin/layouts/list/route.ts create mode 100644 frontend/components/admin/LayoutFormModal.tsx create mode 100644 frontend/components/screen/panels/LayoutsPanel.tsx create mode 100644 frontend/docs/new-layout-system-guide.md create mode 100644 frontend/docs/레이아웃_기능_설계서.md create mode 100644 frontend/docs/레이아웃_추가_가이드.md create mode 100644 frontend/lib/api/layout.ts create mode 100644 frontend/lib/registry/DynamicLayoutRenderer.tsx create mode 100644 frontend/lib/registry/LayoutRegistry.ts create mode 100644 frontend/lib/registry/layouts/AutoRegisteringLayoutRenderer.ts create mode 100644 frontend/lib/registry/layouts/AutoRegisteringLayoutRenderer.tsx create mode 100644 frontend/lib/registry/layouts/BaseLayoutRenderer.tsx create mode 100644 frontend/lib/registry/layouts/FlexboxLayoutRenderer.tsx create mode 100644 frontend/lib/registry/layouts/GridLayoutRenderer.tsx create mode 100644 frontend/lib/registry/layouts/SplitLayoutRenderer.tsx create mode 100644 frontend/lib/registry/layouts/TabsLayoutRenderer.tsx create mode 100644 frontend/lib/registry/layouts/accordion/AccordionLayout.tsx create mode 100644 frontend/lib/registry/layouts/accordion/AccordionLayoutRenderer.tsx create mode 100644 frontend/lib/registry/layouts/accordion/README.md create mode 100644 frontend/lib/registry/layouts/accordion/config.ts create mode 100644 frontend/lib/registry/layouts/accordion/index.ts create mode 100644 frontend/lib/registry/layouts/accordion/types.ts create mode 100644 frontend/lib/registry/layouts/card-layout/CardLayoutLayout.tsx create mode 100644 frontend/lib/registry/layouts/card-layout/CardLayoutRenderer.tsx create mode 100644 frontend/lib/registry/layouts/card-layout/README.md create mode 100644 frontend/lib/registry/layouts/card-layout/config.ts create mode 100644 frontend/lib/registry/layouts/card-layout/index.ts create mode 100644 frontend/lib/registry/layouts/card-layout/types.ts create mode 100644 frontend/lib/registry/layouts/flexbox/FlexboxLayout.tsx create mode 100644 frontend/lib/registry/layouts/flexbox/FlexboxLayoutRenderer.tsx create mode 100644 frontend/lib/registry/layouts/flexbox/README.md create mode 100644 frontend/lib/registry/layouts/flexbox/config.ts create mode 100644 frontend/lib/registry/layouts/flexbox/index.ts create mode 100644 frontend/lib/registry/layouts/flexbox/types.ts create mode 100644 frontend/lib/registry/layouts/grid/GridLayout.tsx create mode 100644 frontend/lib/registry/layouts/grid/GridLayoutRenderer.tsx create mode 100644 frontend/lib/registry/layouts/grid/index.ts create mode 100644 frontend/lib/registry/layouts/hero-section/HeroSectionLayout.tsx create mode 100644 frontend/lib/registry/layouts/hero-section/HeroSectionLayoutRenderer.tsx create mode 100644 frontend/lib/registry/layouts/hero-section/README.md create mode 100644 frontend/lib/registry/layouts/hero-section/config.ts create mode 100644 frontend/lib/registry/layouts/hero-section/index.ts create mode 100644 frontend/lib/registry/layouts/hero-section/types.ts create mode 100644 frontend/lib/registry/layouts/index.ts create mode 100644 frontend/lib/registry/layouts/split/README.md create mode 100644 frontend/lib/registry/layouts/split/SplitLayout.tsx create mode 100644 frontend/lib/registry/layouts/split/SplitLayoutRenderer.tsx create mode 100644 frontend/lib/registry/layouts/split/config.ts create mode 100644 frontend/lib/registry/layouts/split/index.ts create mode 100644 frontend/lib/registry/layouts/split/types.ts create mode 100644 frontend/lib/registry/utils/autoDiscovery.ts create mode 100644 frontend/lib/registry/utils/createLayoutDefinition.ts create mode 100644 frontend/scripts/create-layout.js create mode 100644 frontend/types/layout.ts diff --git a/backend-node/prisma/schema.prisma b/backend-node/prisma/schema.prisma index c7e15d81..7651beb1 100644 --- a/backend-node/prisma/schema.prisma +++ b/backend-node/prisma/schema.prisma @@ -5020,6 +5020,10 @@ model screen_layouts { height Int properties Json? display_order Int @default(0) + layout_type String? @db.VarChar(50) + layout_config Json? + zones_config Json? + zone_id String? @db.VarChar(100) created_date DateTime @default(now()) @db.Timestamp(6) screen screen_definitions @relation(fields: [screen_id], references: [screen_id], onDelete: Cascade) widgets screen_widgets[] @@ -5302,3 +5306,30 @@ model component_standards { @@index([category], map: "idx_component_standards_category") @@index([company_code], map: "idx_component_standards_company") } + +// 레이아웃 표준 관리 테이블 +model layout_standards { + layout_code String @id @db.VarChar(50) + layout_name String @db.VarChar(100) + layout_name_eng String? @db.VarChar(100) + description String? @db.Text + layout_type String @db.VarChar(50) + category String @db.VarChar(50) + icon_name String? @db.VarChar(50) + default_size Json? // { width: number, height: number } + layout_config Json // 레이아웃 설정 (그리드, 플렉스박스 등) + zones_config Json // 존 설정 (영역 정의) + preview_image String? @db.VarChar(255) + sort_order Int? @default(0) + is_active String? @default("Y") @db.Char(1) + is_public String? @default("Y") @db.Char(1) + company_code String @db.VarChar(50) + created_date DateTime? @default(now()) @db.Timestamp(6) + created_by String? @db.VarChar(50) + updated_date DateTime? @default(now()) @db.Timestamp(6) + updated_by String? @db.VarChar(50) + + @@index([layout_type], map: "idx_layout_standards_type") + @@index([category], map: "idx_layout_standards_category") + @@index([company_code], map: "idx_layout_standards_company") +} diff --git a/backend-node/scripts/add-missing-columns.js b/backend-node/scripts/add-missing-columns.js new file mode 100644 index 00000000..4b21e702 --- /dev/null +++ b/backend-node/scripts/add-missing-columns.js @@ -0,0 +1,105 @@ +const { PrismaClient } = require("@prisma/client"); + +const prisma = new PrismaClient(); + +async function addMissingColumns() { + try { + console.log("🔄 누락된 컬럼들을 screen_layouts 테이블에 추가 중..."); + + // layout_type 컬럼 추가 + try { + await prisma.$executeRaw` + ALTER TABLE screen_layouts + ADD COLUMN IF NOT EXISTS layout_type VARCHAR(50); + `; + console.log("✅ layout_type 컬럼 추가 완료"); + } catch (error) { + console.log( + "ℹ️ layout_type 컬럼이 이미 존재하거나 추가 중 오류:", + error.message + ); + } + + // layout_config 컬럼 추가 + try { + await prisma.$executeRaw` + ALTER TABLE screen_layouts + ADD COLUMN IF NOT EXISTS layout_config JSONB; + `; + console.log("✅ layout_config 컬럼 추가 완료"); + } catch (error) { + console.log( + "ℹ️ layout_config 컬럼이 이미 존재하거나 추가 중 오류:", + error.message + ); + } + + // zones_config 컬럼 추가 + try { + await prisma.$executeRaw` + ALTER TABLE screen_layouts + ADD COLUMN IF NOT EXISTS zones_config JSONB; + `; + console.log("✅ zones_config 컬럼 추가 완료"); + } catch (error) { + console.log( + "ℹ️ zones_config 컬럼이 이미 존재하거나 추가 중 오류:", + error.message + ); + } + + // zone_id 컬럼 추가 + try { + await prisma.$executeRaw` + ALTER TABLE screen_layouts + ADD COLUMN IF NOT EXISTS zone_id VARCHAR(100); + `; + console.log("✅ zone_id 컬럼 추가 완료"); + } catch (error) { + console.log( + "ℹ️ zone_id 컬럼이 이미 존재하거나 추가 중 오류:", + error.message + ); + } + + // 인덱스 생성 (성능 향상) + try { + await prisma.$executeRaw` + CREATE INDEX IF NOT EXISTS idx_screen_layouts_layout_type + ON screen_layouts(layout_type); + `; + console.log("✅ layout_type 인덱스 생성 완료"); + } catch (error) { + console.log("ℹ️ layout_type 인덱스 생성 중 오류:", error.message); + } + + try { + await prisma.$executeRaw` + CREATE INDEX IF NOT EXISTS idx_screen_layouts_zone_id + ON screen_layouts(zone_id); + `; + console.log("✅ zone_id 인덱스 생성 완료"); + } catch (error) { + console.log("ℹ️ zone_id 인덱스 생성 중 오류:", error.message); + } + + // 최종 테이블 구조 확인 + const columns = await prisma.$queryRaw` + SELECT column_name, data_type, is_nullable, column_default + FROM information_schema.columns + WHERE table_name = 'screen_layouts' + ORDER BY ordinal_position + `; + + console.log("\n📋 screen_layouts 테이블 최종 구조:"); + console.table(columns); + + console.log("\n🎉 모든 누락된 컬럼 추가 작업이 완료되었습니다!"); + } catch (error) { + console.error("❌ 컬럼 추가 중 오류 발생:", error); + } finally { + await prisma.$disconnect(); + } +} + +addMissingColumns(); diff --git a/backend-node/scripts/init-layout-standards.js b/backend-node/scripts/init-layout-standards.js new file mode 100644 index 00000000..688a328d --- /dev/null +++ b/backend-node/scripts/init-layout-standards.js @@ -0,0 +1,309 @@ +/** + * 레이아웃 표준 데이터 초기화 스크립트 + * 기본 레이아웃들을 layout_standards 테이블에 삽입합니다. + */ + +const { PrismaClient } = require("@prisma/client"); + +const prisma = new PrismaClient(); + +// 기본 레이아웃 데이터 +const PREDEFINED_LAYOUTS = [ + { + layout_code: "GRID_2X2_001", + layout_name: "2x2 그리드", + layout_name_eng: "2x2 Grid", + description: "2행 2열의 균등한 그리드 레이아웃입니다.", + layout_type: "grid", + category: "basic", + icon_name: "grid", + default_size: { width: 800, height: 600 }, + layout_config: { + grid: { rows: 2, columns: 2, gap: 16 }, + }, + zones_config: [ + { + id: "zone1", + name: "상단 좌측", + position: { row: 0, column: 0 }, + size: { width: "50%", height: "50%" }, + }, + { + id: "zone2", + name: "상단 우측", + position: { row: 0, column: 1 }, + size: { width: "50%", height: "50%" }, + }, + { + id: "zone3", + name: "하단 좌측", + position: { row: 1, column: 0 }, + size: { width: "50%", height: "50%" }, + }, + { + id: "zone4", + name: "하단 우측", + position: { row: 1, column: 1 }, + size: { width: "50%", height: "50%" }, + }, + ], + sort_order: 1, + is_active: "Y", + is_public: "Y", + company_code: "DEFAULT", + }, + { + layout_code: "FORM_TWO_COLUMN_001", + layout_name: "2단 폼 레이아웃", + layout_name_eng: "Two Column Form", + description: "좌우 2단으로 구성된 폼 레이아웃입니다.", + layout_type: "grid", + category: "form", + icon_name: "columns", + default_size: { width: 800, height: 400 }, + layout_config: { + grid: { rows: 1, columns: 2, gap: 24 }, + }, + zones_config: [ + { + id: "left", + name: "좌측 입력 영역", + position: { row: 0, column: 0 }, + size: { width: "50%", height: "100%" }, + }, + { + id: "right", + name: "우측 입력 영역", + position: { row: 0, column: 1 }, + size: { width: "50%", height: "100%" }, + }, + ], + sort_order: 2, + is_active: "Y", + is_public: "Y", + company_code: "DEFAULT", + }, + { + layout_code: "FLEXBOX_ROW_001", + layout_name: "가로 플렉스박스", + layout_name_eng: "Horizontal Flexbox", + description: "가로 방향으로 배치되는 플렉스박스 레이아웃입니다.", + layout_type: "flexbox", + category: "basic", + icon_name: "flex", + default_size: { width: 800, height: 300 }, + layout_config: { + flexbox: { + direction: "row", + justify: "flex-start", + align: "stretch", + wrap: "nowrap", + gap: 16, + }, + }, + zones_config: [ + { + id: "left", + name: "좌측 영역", + position: {}, + size: { width: "50%", height: "100%" }, + }, + { + id: "right", + name: "우측 영역", + position: {}, + size: { width: "50%", height: "100%" }, + }, + ], + sort_order: 3, + is_active: "Y", + is_public: "Y", + company_code: "DEFAULT", + }, + { + layout_code: "SPLIT_HORIZONTAL_001", + layout_name: "수평 분할", + layout_name_eng: "Horizontal Split", + description: "크기 조절이 가능한 수평 분할 레이아웃입니다.", + layout_type: "split", + category: "basic", + icon_name: "separator-horizontal", + default_size: { width: 800, height: 400 }, + layout_config: { + split: { + direction: "horizontal", + ratio: [50, 50], + minSize: [200, 200], + resizable: true, + splitterSize: 4, + }, + }, + zones_config: [ + { + id: "left", + name: "좌측 패널", + position: {}, + size: { width: "50%", height: "100%" }, + isResizable: true, + }, + { + id: "right", + name: "우측 패널", + position: {}, + size: { width: "50%", height: "100%" }, + isResizable: true, + }, + ], + sort_order: 4, + is_active: "Y", + is_public: "Y", + company_code: "DEFAULT", + }, + { + layout_code: "TABS_HORIZONTAL_001", + layout_name: "수평 탭", + layout_name_eng: "Horizontal Tabs", + description: "상단에 탭이 있는 탭 레이아웃입니다.", + layout_type: "tabs", + category: "navigation", + icon_name: "tabs", + default_size: { width: 800, height: 500 }, + layout_config: { + tabs: { + position: "top", + variant: "default", + size: "md", + defaultTab: "tab1", + closable: false, + }, + }, + zones_config: [ + { + id: "tab1", + name: "첫 번째 탭", + position: {}, + size: { width: "100%", height: "100%" }, + }, + { + id: "tab2", + name: "두 번째 탭", + position: {}, + size: { width: "100%", height: "100%" }, + }, + { + id: "tab3", + name: "세 번째 탭", + position: {}, + size: { width: "100%", height: "100%" }, + }, + ], + sort_order: 5, + is_active: "Y", + is_public: "Y", + company_code: "DEFAULT", + }, + { + layout_code: "TABLE_WITH_FILTERS_001", + layout_name: "필터가 있는 테이블", + layout_name_eng: "Table with Filters", + description: "상단에 필터가 있고 하단에 테이블이 있는 레이아웃입니다.", + layout_type: "flexbox", + category: "table", + icon_name: "table", + default_size: { width: 1000, height: 600 }, + layout_config: { + flexbox: { + direction: "column", + justify: "flex-start", + align: "stretch", + wrap: "nowrap", + gap: 16, + }, + }, + zones_config: [ + { + id: "filters", + name: "검색 필터", + position: {}, + size: { width: "100%", height: "auto" }, + }, + { + id: "table", + name: "데이터 테이블", + position: {}, + size: { width: "100%", height: "1fr" }, + }, + ], + sort_order: 6, + is_active: "Y", + is_public: "Y", + company_code: "DEFAULT", + }, +]; + +async function initializeLayoutStandards() { + try { + console.log("🏗️ 레이아웃 표준 데이터 초기화 시작..."); + + // 기존 데이터 확인 + const existingLayouts = await prisma.layout_standards.count(); + if (existingLayouts > 0) { + console.log(`⚠️ 이미 ${existingLayouts}개의 레이아웃이 존재합니다.`); + console.log( + "기존 데이터를 삭제하고 새로 생성하시겠습니까? (기본값: 건너뛰기)" + ); + + // 기존 데이터가 있으면 건너뛰기 (안전을 위해) + console.log("💡 기존 데이터를 유지하고 건너뜁니다."); + return; + } + + // 데이터 삽입 + let insertedCount = 0; + + for (const layoutData of PREDEFINED_LAYOUTS) { + try { + await prisma.layout_standards.create({ + data: { + ...layoutData, + created_date: new Date(), + updated_date: new Date(), + created_by: "SYSTEM", + updated_by: "SYSTEM", + }, + }); + + console.log(`✅ ${layoutData.layout_name} 생성 완료`); + insertedCount++; + } catch (error) { + console.error(`❌ ${layoutData.layout_name} 생성 실패:`, error.message); + } + } + + console.log( + `🎉 레이아웃 표준 데이터 초기화 완료! (${insertedCount}/${PREDEFINED_LAYOUTS.length})` + ); + } catch (error) { + console.error("❌ 레이아웃 표준 데이터 초기화 실패:", error); + throw error; + } +} + +// 스크립트 실행 +if (require.main === module) { + initializeLayoutStandards() + .then(() => { + console.log("✨ 스크립트 실행 완료"); + process.exit(0); + }) + .catch((error) => { + console.error("💥 스크립트 실행 실패:", error); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); +} + +module.exports = { initializeLayoutStandards }; + diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index b82b6fb0..46459bd1 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -24,6 +24,7 @@ import buttonActionStandardRoutes from "./routes/buttonActionStandardRoutes"; import screenStandardRoutes from "./routes/screenStandardRoutes"; import templateStandardRoutes from "./routes/templateStandardRoutes"; import componentStandardRoutes from "./routes/componentStandardRoutes"; +import layoutRoutes from "./routes/layoutRoutes"; // import userRoutes from './routes/userRoutes'; // import menuRoutes from './routes/menuRoutes'; @@ -110,6 +111,7 @@ app.use("/api/admin/web-types", webTypeStandardRoutes); app.use("/api/admin/button-actions", buttonActionStandardRoutes); app.use("/api/admin/template-standards", templateStandardRoutes); app.use("/api/admin/component-standards", componentStandardRoutes); +app.use("/api/layouts", layoutRoutes); app.use("/api/screen", screenStandardRoutes); // app.use('/api/users', userRoutes); // app.use('/api/menus', menuRoutes); diff --git a/backend-node/src/controllers/layoutController.ts b/backend-node/src/controllers/layoutController.ts new file mode 100644 index 00000000..4a975cfa --- /dev/null +++ b/backend-node/src/controllers/layoutController.ts @@ -0,0 +1,276 @@ +import { Request, Response } from "express"; +import { layoutService } from "../services/layoutService"; +import { + CreateLayoutRequest, + UpdateLayoutRequest, + GetLayoutsRequest, + DuplicateLayoutRequest, +} from "../types/layout"; + +export class LayoutController { + /** + * 레이아웃 목록 조회 + */ + async getLayouts(req: Request, res: Response): Promise { + try { + const { user } = req as any; + const { + page = 1, + size = 20, + category, + layoutType, + searchTerm, + includePublic = true, + } = req.query as any; + + const params = { + page: parseInt(page, 10), + size: parseInt(size, 10), + category, + layoutType, + searchTerm, + companyCode: user.companyCode, + includePublic: includePublic === "true", + }; + + const result = await layoutService.getLayouts(params); + + const response = { + ...result, + page: params.page, + size: params.size, + totalPages: Math.ceil(result.total / params.size), + }; + + res.json({ + success: true, + data: response, + }); + } catch (error) { + console.error("레이아웃 목록 조회 오류:", error); + res.status(500).json({ + success: false, + message: "레이아웃 목록 조회에 실패했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + /** + * 레이아웃 상세 조회 + */ + async getLayoutById(req: Request, res: Response): Promise { + try { + const { user } = req as any; + const { id: layoutCode } = req.params; + + const layout = await layoutService.getLayoutById( + layoutCode, + user.companyCode + ); + + if (!layout) { + res.status(404).json({ + success: false, + message: "레이아웃을 찾을 수 없습니다.", + }); + return; + } + + res.json({ + success: true, + data: layout, + }); + } catch (error) { + console.error("레이아웃 상세 조회 오류:", error); + res.status(500).json({ + success: false, + message: "레이아웃 상세 조회에 실패했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + /** + * 레이아웃 생성 + */ + async createLayout(req: Request, res: Response): Promise { + try { + const { user } = req as any; + const layoutRequest: CreateLayoutRequest = req.body; + + // 요청 데이터 검증 + if ( + !layoutRequest.layoutName || + !layoutRequest.layoutType || + !layoutRequest.category + ) { + res.status(400).json({ + success: false, + message: + "필수 필드가 누락되었습니다. (layoutName, layoutType, category)", + }); + return; + } + + if (!layoutRequest.layoutConfig || !layoutRequest.zonesConfig) { + res.status(400).json({ + success: false, + message: "레이아웃 설정과 존 설정은 필수입니다.", + }); + return; + } + + const layout = await layoutService.createLayout( + layoutRequest, + user.companyCode, + user.userId + ); + + res.status(201).json({ + success: true, + data: layout, + message: "레이아웃이 성공적으로 생성되었습니다.", + }); + } catch (error) { + console.error("레이아웃 생성 오류:", error); + res.status(500).json({ + success: false, + message: "레이아웃 생성에 실패했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + /** + * 레이아웃 수정 + */ + async updateLayout(req: Request, res: Response): Promise { + try { + const { user } = req as any; + const { id: layoutCode } = req.params; + const updateRequest: Partial = req.body; + + const updatedLayout = await layoutService.updateLayout( + { ...updateRequest, layoutCode }, + user.companyCode, + user.userId + ); + + if (!updatedLayout) { + res.status(404).json({ + success: false, + message: "레이아웃을 찾을 수 없거나 수정 권한이 없습니다.", + }); + return; + } + + res.json({ + success: true, + data: updatedLayout, + message: "레이아웃이 성공적으로 수정되었습니다.", + }); + } catch (error) { + console.error("레이아웃 수정 오류:", error); + res.status(500).json({ + success: false, + message: "레이아웃 수정에 실패했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + /** + * 레이아웃 삭제 + */ + async deleteLayout(req: Request, res: Response): Promise { + try { + const { user } = req as any; + const { id: layoutCode } = req.params; + + await layoutService.deleteLayout( + layoutCode, + user.companyCode, + user.userId + ); + + res.json({ + success: true, + message: "레이아웃이 성공적으로 삭제되었습니다.", + }); + } catch (error) { + console.error("레이아웃 삭제 오류:", error); + res.status(500).json({ + success: false, + message: "레이아웃 삭제에 실패했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + /** + * 레이아웃 복제 + */ + async duplicateLayout(req: Request, res: Response): Promise { + try { + const { user } = req as any; + const { id: layoutCode } = req.params; + const { newName }: DuplicateLayoutRequest = req.body; + + if (!newName) { + res.status(400).json({ + success: false, + message: "새 레이아웃 이름이 필요합니다.", + }); + return; + } + + const duplicatedLayout = await layoutService.duplicateLayout( + layoutCode, + newName, + user.companyCode, + user.userId + ); + + res.status(201).json({ + success: true, + data: duplicatedLayout, + message: "레이아웃이 성공적으로 복제되었습니다.", + }); + } catch (error) { + console.error("레이아웃 복제 오류:", error); + res.status(500).json({ + success: false, + message: "레이아웃 복제에 실패했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + /** + * 카테고리별 레이아웃 개수 조회 + */ + async getLayoutCountsByCategory(req: Request, res: Response): Promise { + try { + const { user } = req as any; + + const counts = await layoutService.getLayoutCountsByCategory( + user.companyCode + ); + + res.json({ + success: true, + data: counts, + }); + } catch (error) { + console.error("카테고리별 레이아웃 개수 조회 오류:", error); + res.status(500).json({ + success: false, + message: "카테고리별 레이아웃 개수 조회에 실패했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } +} + +export const layoutController = new LayoutController(); diff --git a/backend-node/src/routes/layoutRoutes.ts b/backend-node/src/routes/layoutRoutes.ts new file mode 100644 index 00000000..2bea16c8 --- /dev/null +++ b/backend-node/src/routes/layoutRoutes.ts @@ -0,0 +1,73 @@ +import { Router } from "express"; +import { layoutController } from "../controllers/layoutController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = Router(); + +// 모든 레이아웃 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + +/** + * @route GET /api/layouts + * @desc 레이아웃 목록 조회 + * @access Private + * @params page, size, category, layoutType, searchTerm, includePublic + */ +router.get("/", layoutController.getLayouts.bind(layoutController)); + +/** + * @route GET /api/layouts/counts-by-category + * @desc 카테고리별 레이아웃 개수 조회 + * @access Private + */ +router.get( + "/counts-by-category", + layoutController.getLayoutCountsByCategory.bind(layoutController) +); + +/** + * @route GET /api/layouts/:id + * @desc 레이아웃 상세 조회 + * @access Private + * @params id (layoutCode) + */ +router.get("/:id", layoutController.getLayoutById.bind(layoutController)); + +/** + * @route POST /api/layouts + * @desc 레이아웃 생성 + * @access Private + * @body CreateLayoutRequest + */ +router.post("/", layoutController.createLayout.bind(layoutController)); + +/** + * @route PUT /api/layouts/:id + * @desc 레이아웃 수정 + * @access Private + * @params id (layoutCode) + * @body Partial + */ +router.put("/:id", layoutController.updateLayout.bind(layoutController)); + +/** + * @route DELETE /api/layouts/:id + * @desc 레이아웃 삭제 + * @access Private + * @params id (layoutCode) + */ +router.delete("/:id", layoutController.deleteLayout.bind(layoutController)); + +/** + * @route POST /api/layouts/:id/duplicate + * @desc 레이아웃 복제 + * @access Private + * @params id (layoutCode) + * @body { newName: string } + */ +router.post( + "/:id/duplicate", + layoutController.duplicateLayout.bind(layoutController) +); + +export default router; diff --git a/backend-node/src/services/layoutService.ts b/backend-node/src/services/layoutService.ts new file mode 100644 index 00000000..0440f21a --- /dev/null +++ b/backend-node/src/services/layoutService.ts @@ -0,0 +1,425 @@ +import { PrismaClient } from "@prisma/client"; +import { + CreateLayoutRequest, + UpdateLayoutRequest, + LayoutStandard, + LayoutType, + LayoutCategory, +} from "../types/layout"; + +const prisma = new PrismaClient(); + +// JSON 데이터를 안전하게 파싱하는 헬퍼 함수 +function safeJSONParse(data: any): any { + if (data === null || data === undefined) { + return null; + } + + // 이미 객체인 경우 그대로 반환 + if (typeof data === "object") { + return data; + } + + // 문자열인 경우 파싱 시도 + if (typeof data === "string") { + try { + return JSON.parse(data); + } catch (error) { + console.error("JSON 파싱 오류:", error, "Data:", data); + return null; + } + } + + return data; +} + +// JSON 데이터를 안전하게 문자열화하는 헬퍼 함수 +function safeJSONStringify(data: any): string | null { + if (data === null || data === undefined) { + return null; + } + + // 이미 문자열인 경우 그대로 반환 + if (typeof data === "string") { + return data; + } + + // 객체인 경우 문자열로 변환 + try { + return JSON.stringify(data); + } catch (error) { + console.error("JSON 문자열화 오류:", error, "Data:", data); + return null; + } +} + +export class LayoutService { + /** + * 레이아웃 목록 조회 + */ + async getLayouts(params: { + page?: number; + size?: number; + category?: string; + layoutType?: string; + searchTerm?: string; + companyCode: string; + includePublic?: boolean; + }): Promise<{ data: LayoutStandard[]; total: number }> { + const { + page = 1, + size = 20, + category, + layoutType, + searchTerm, + companyCode, + includePublic = true, + } = params; + + const skip = (page - 1) * size; + + // 검색 조건 구성 + const where: any = { + is_active: "Y", + OR: [ + { company_code: companyCode }, + ...(includePublic ? [{ is_public: "Y" }] : []), + ], + }; + + if (category) { + where.category = category; + } + + if (layoutType) { + where.layout_type = layoutType; + } + + if (searchTerm) { + where.OR = [ + ...where.OR, + { layout_name: { contains: searchTerm, mode: "insensitive" } }, + { layout_name_eng: { contains: searchTerm, mode: "insensitive" } }, + { description: { contains: searchTerm, mode: "insensitive" } }, + ]; + } + + const [data, total] = await Promise.all([ + prisma.layout_standards.findMany({ + where, + skip, + take: size, + orderBy: [{ sort_order: "asc" }, { created_date: "desc" }], + }), + prisma.layout_standards.count({ where }), + ]); + + return { + data: data.map( + (layout) => + ({ + layoutCode: layout.layout_code, + layoutName: layout.layout_name, + layoutNameEng: layout.layout_name_eng, + description: layout.description, + layoutType: layout.layout_type as LayoutType, + category: layout.category as LayoutCategory, + iconName: layout.icon_name, + defaultSize: safeJSONParse(layout.default_size), + layoutConfig: safeJSONParse(layout.layout_config), + zonesConfig: safeJSONParse(layout.zones_config), + previewImage: layout.preview_image, + sortOrder: layout.sort_order, + isActive: layout.is_active, + isPublic: layout.is_public, + companyCode: layout.company_code, + createdDate: layout.created_date, + createdBy: layout.created_by, + updatedDate: layout.updated_date, + updatedBy: layout.updated_by, + }) as LayoutStandard + ), + total, + }; + } + + /** + * 레이아웃 상세 조회 + */ + async getLayoutById( + layoutCode: string, + companyCode: string + ): Promise { + const layout = await prisma.layout_standards.findFirst({ + where: { + layout_code: layoutCode, + is_active: "Y", + OR: [{ company_code: companyCode }, { is_public: "Y" }], + }, + }); + + if (!layout) return null; + + return { + layoutCode: layout.layout_code, + layoutName: layout.layout_name, + layoutNameEng: layout.layout_name_eng, + description: layout.description, + layoutType: layout.layout_type as LayoutType, + category: layout.category as LayoutCategory, + iconName: layout.icon_name, + defaultSize: safeJSONParse(layout.default_size), + layoutConfig: safeJSONParse(layout.layout_config), + zonesConfig: safeJSONParse(layout.zones_config), + previewImage: layout.preview_image, + sortOrder: layout.sort_order, + isActive: layout.is_active, + isPublic: layout.is_public, + companyCode: layout.company_code, + createdDate: layout.created_date, + createdBy: layout.created_by, + updatedDate: layout.updated_date, + updatedBy: layout.updated_by, + } as LayoutStandard; + } + + /** + * 레이아웃 생성 + */ + async createLayout( + request: CreateLayoutRequest, + companyCode: string, + userId: string + ): Promise { + // 레이아웃 코드 생성 (자동) + const layoutCode = await this.generateLayoutCode( + request.layoutType, + companyCode + ); + + const layout = await prisma.layout_standards.create({ + data: { + layout_code: layoutCode, + layout_name: request.layoutName, + layout_name_eng: request.layoutNameEng, + description: request.description, + layout_type: request.layoutType, + category: request.category, + icon_name: request.iconName, + default_size: safeJSONStringify(request.defaultSize) as any, + layout_config: safeJSONStringify(request.layoutConfig) as any, + zones_config: safeJSONStringify(request.zonesConfig) as any, + is_public: request.isPublic ? "Y" : "N", + company_code: companyCode, + created_by: userId, + updated_by: userId, + }, + }); + + return this.mapToLayoutStandard(layout); + } + + /** + * 레이아웃 수정 + */ + async updateLayout( + request: UpdateLayoutRequest, + companyCode: string, + userId: string + ): Promise { + // 수정 권한 확인 + const existing = await prisma.layout_standards.findFirst({ + where: { + layout_code: request.layoutCode, + company_code: companyCode, + is_active: "Y", + }, + }); + + if (!existing) { + throw new Error("레이아웃을 찾을 수 없거나 수정 권한이 없습니다."); + } + + const updateData: any = { + updated_by: userId, + updated_date: new Date(), + }; + + // 수정할 필드만 업데이트 + if (request.layoutName !== undefined) + updateData.layout_name = request.layoutName; + if (request.layoutNameEng !== undefined) + updateData.layout_name_eng = request.layoutNameEng; + if (request.description !== undefined) + updateData.description = request.description; + if (request.layoutType !== undefined) + updateData.layout_type = request.layoutType; + if (request.category !== undefined) updateData.category = request.category; + if (request.iconName !== undefined) updateData.icon_name = request.iconName; + if (request.defaultSize !== undefined) + updateData.default_size = safeJSONStringify(request.defaultSize) as any; + if (request.layoutConfig !== undefined) + updateData.layout_config = safeJSONStringify(request.layoutConfig) as any; + if (request.zonesConfig !== undefined) + updateData.zones_config = safeJSONStringify(request.zonesConfig) as any; + if (request.isPublic !== undefined) + updateData.is_public = request.isPublic ? "Y" : "N"; + + const updated = await prisma.layout_standards.update({ + where: { layout_code: request.layoutCode }, + data: updateData, + }); + + return this.mapToLayoutStandard(updated); + } + + /** + * 레이아웃 삭제 (소프트 삭제) + */ + async deleteLayout( + layoutCode: string, + companyCode: string, + userId: string + ): Promise { + const existing = await prisma.layout_standards.findFirst({ + where: { + layout_code: layoutCode, + company_code: companyCode, + is_active: "Y", + }, + }); + + if (!existing) { + throw new Error("레이아웃을 찾을 수 없거나 삭제 권한이 없습니다."); + } + + await prisma.layout_standards.update({ + where: { layout_code: layoutCode }, + data: { + is_active: "N", + updated_by: userId, + updated_date: new Date(), + }, + }); + + return true; + } + + /** + * 레이아웃 복제 + */ + async duplicateLayout( + layoutCode: string, + newName: string, + companyCode: string, + userId: string + ): Promise { + const original = await this.getLayoutById(layoutCode, companyCode); + if (!original) { + throw new Error("복제할 레이아웃을 찾을 수 없습니다."); + } + + const duplicateRequest: CreateLayoutRequest = { + layoutName: newName, + layoutNameEng: original.layoutNameEng + ? `${original.layoutNameEng} Copy` + : undefined, + description: original.description, + layoutType: original.layoutType, + category: original.category, + iconName: original.iconName, + defaultSize: original.defaultSize, + layoutConfig: original.layoutConfig, + zonesConfig: original.zonesConfig, + isPublic: false, // 복제본은 비공개로 시작 + }; + + return this.createLayout(duplicateRequest, companyCode, userId); + } + + /** + * 카테고리별 레이아웃 개수 조회 + */ + async getLayoutCountsByCategory( + companyCode: string + ): Promise> { + const counts = await prisma.layout_standards.groupBy({ + by: ["category"], + _count: { + layout_code: true, + }, + where: { + is_active: "Y", + OR: [{ company_code: companyCode }, { is_public: "Y" }], + }, + }); + + return counts.reduce( + (acc: Record, item: any) => { + acc[item.category] = item._count.layout_code; + return acc; + }, + {} as Record + ); + } + + /** + * 레이아웃 코드 자동 생성 + */ + private async generateLayoutCode( + layoutType: string, + companyCode: string + ): Promise { + const prefix = `${layoutType.toUpperCase()}_${companyCode}`; + const existingCodes = await prisma.layout_standards.findMany({ + where: { + layout_code: { + startsWith: prefix, + }, + }, + select: { + layout_code: true, + }, + }); + + const maxNumber = existingCodes.reduce((max: number, item: any) => { + const match = item.layout_code.match(/_(\d+)$/); + if (match) { + const number = parseInt(match[1], 10); + return Math.max(max, number); + } + return max; + }, 0); + + return `${prefix}_${String(maxNumber + 1).padStart(3, "0")}`; + } + + /** + * 데이터베이스 모델을 LayoutStandard 타입으로 변환 + */ + private mapToLayoutStandard(layout: any): LayoutStandard { + return { + layoutCode: layout.layout_code, + layoutName: layout.layout_name, + layoutNameEng: layout.layout_name_eng, + description: layout.description, + layoutType: layout.layout_type, + category: layout.category, + iconName: layout.icon_name, + defaultSize: layout.default_size, + layoutConfig: layout.layout_config, + zonesConfig: layout.zones_config, + previewImage: layout.preview_image, + sortOrder: layout.sort_order, + isActive: layout.is_active, + isPublic: layout.is_public, + companyCode: layout.company_code, + createdDate: layout.created_date, + createdBy: layout.created_by, + updatedDate: layout.updated_date, + updatedBy: layout.updated_by, + }; + } +} + +export const layoutService = new LayoutService(); diff --git a/backend-node/src/types/layout.ts b/backend-node/src/types/layout.ts new file mode 100644 index 00000000..35501dc9 --- /dev/null +++ b/backend-node/src/types/layout.ts @@ -0,0 +1,198 @@ +// 레이아웃 관련 타입 정의 + +// 레이아웃 타입 +export type LayoutType = + | "grid" + | "flexbox" + | "split" + | "card" + | "tabs" + | "accordion" + | "sidebar" + | "header-footer" + | "three-column" + | "dashboard" + | "form" + | "table" + | "custom"; + +// 레이아웃 카테고리 +export type LayoutCategory = + | "basic" + | "form" + | "table" + | "dashboard" + | "navigation" + | "content" + | "business"; + +// 레이아웃 존 정의 +export interface LayoutZone { + id: string; + name: string; + position: { + row?: number; + column?: number; + x?: number; + y?: number; + }; + size: { + width: number | string; + height: number | string; + minWidth?: number; + minHeight?: number; + maxWidth?: number; + maxHeight?: number; + }; + style?: Record; + allowedComponents?: string[]; + isResizable?: boolean; + isRequired?: boolean; +} + +// 레이아웃 설정 +export interface LayoutConfig { + grid?: { + rows: number; + columns: number; + gap: number; + rowGap?: number; + columnGap?: number; + autoRows?: string; + autoColumns?: string; + }; + + flexbox?: { + direction: "row" | "column" | "row-reverse" | "column-reverse"; + justify: + | "flex-start" + | "flex-end" + | "center" + | "space-between" + | "space-around" + | "space-evenly"; + align: "flex-start" | "flex-end" | "center" | "stretch" | "baseline"; + wrap: "nowrap" | "wrap" | "wrap-reverse"; + gap: number; + }; + + split?: { + direction: "horizontal" | "vertical"; + ratio: number[]; + minSize: number[]; + resizable: boolean; + splitterSize: number; + }; + + tabs?: { + position: "top" | "bottom" | "left" | "right"; + variant: "default" | "pills" | "underline"; + size: "sm" | "md" | "lg"; + defaultTab: string; + closable: boolean; + }; + + accordion?: { + multiple: boolean; + defaultExpanded: string[]; + collapsible: boolean; + }; + + sidebar?: { + position: "left" | "right"; + width: number | string; + collapsible: boolean; + collapsed: boolean; + overlay: boolean; + }; + + headerFooter?: { + headerHeight: number | string; + footerHeight: number | string; + stickyHeader: boolean; + stickyFooter: boolean; + }; + + dashboard?: { + columns: number; + rowHeight: number; + margin: [number, number]; + padding: [number, number]; + isDraggable: boolean; + isResizable: boolean; + }; + + custom?: { + cssProperties: Record; + className: string; + template: string; + }; +} + +// 레이아웃 표준 정의 +export interface LayoutStandard { + layoutCode: string; + layoutName: string; + layoutNameEng?: string; + description?: string; + layoutType: LayoutType; + category: LayoutCategory; + iconName?: string; + defaultSize?: { width: number; height: number }; + layoutConfig: LayoutConfig; + zonesConfig: LayoutZone[]; + previewImage?: string; + sortOrder?: number; + isActive?: string; + isPublic?: string; + companyCode: string; + createdDate?: Date; + createdBy?: string; + updatedDate?: Date; + updatedBy?: string; +} + +// 레이아웃 생성 요청 +export interface CreateLayoutRequest { + layoutName: string; + layoutNameEng?: string; + description?: string; + layoutType: LayoutType; + category: LayoutCategory; + iconName?: string; + defaultSize?: { width: number; height: number }; + layoutConfig: LayoutConfig; + zonesConfig: LayoutZone[]; + isPublic?: boolean; +} + +// 레이아웃 수정 요청 +export interface UpdateLayoutRequest extends Partial { + layoutCode: string; +} + +// 레이아웃 목록 조회 요청 +export interface GetLayoutsRequest { + page?: number; + size?: number; + category?: LayoutCategory; + layoutType?: LayoutType; + searchTerm?: string; + includePublic?: boolean; +} + +// 레이아웃 목록 응답 +export interface GetLayoutsResponse { + data: LayoutStandard[]; + total: number; + page: number; + size: number; + totalPages: number; +} + +// 레이아웃 복제 요청 +export interface DuplicateLayoutRequest { + layoutCode: string; + newName: string; +} + diff --git a/frontend/app/(main)/admin/layouts/page.tsx b/frontend/app/(main)/admin/layouts/page.tsx new file mode 100644 index 00000000..c5215057 --- /dev/null +++ b/frontend/app/(main)/admin/layouts/page.tsx @@ -0,0 +1,416 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { LayoutFormModal } from "@/components/admin/LayoutFormModal"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { + Plus, + Search, + MoreHorizontal, + Edit, + Copy, + Trash2, + Eye, + Grid, + Layout, + LayoutDashboard, + Table, + Navigation, + FileText, + Building, +} from "lucide-react"; +import { LayoutStandard, LAYOUT_CATEGORIES, LayoutCategory } from "@/types/layout"; +import { layoutApi } from "@/lib/api/layout"; +import { toast } from "sonner"; + +// 코드 레벨 레이아웃 타입 +interface CodeLayout { + id: string; + name: string; + nameEng?: string; + description?: string; + category: string; + type: "code"; + isActive: boolean; + tags: string[]; + metadata?: any; + zones: number; +} + +// 카테고리 아이콘 매핑 +const CATEGORY_ICONS = { + basic: Grid, + form: FileText, + table: Table, + dashboard: LayoutDashboard, + navigation: Navigation, + content: Layout, + business: Building, +}; + +// 카테고리 이름 매핑 +const CATEGORY_NAMES = { + basic: "기본", + form: "폼", + table: "테이블", + dashboard: "대시보드", + navigation: "네비게이션", + content: "컨텐츠", + business: "업무용", +}; + +export default function LayoutManagementPage() { + const [layouts, setLayouts] = useState([]); + const [codeLayouts, setCodeLayouts] = useState([]); + const [loading, setLoading] = useState(true); + const [codeLoading, setCodeLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(""); + const [selectedCategory, setSelectedCategory] = useState("all"); + const [currentPage, setCurrentPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [total, setTotal] = useState(0); + const [activeTab, setActiveTab] = useState("db"); + + // 모달 상태 + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [layoutToDelete, setLayoutToDelete] = useState(null); + const [createModalOpen, setCreateModalOpen] = useState(false); + + // 카테고리별 개수 + const [categoryCounts, setCategoryCounts] = useState>({}); + + // 레이아웃 목록 로드 + const loadLayouts = async () => { + try { + setLoading(true); + const params = { + page: currentPage, + size: 20, + searchTerm: searchTerm || undefined, + category: selectedCategory !== "all" ? (selectedCategory as LayoutCategory) : undefined, + }; + + const response = await layoutApi.getLayouts(params); + setLayouts(response.data); + setTotalPages(response.totalPages); + setTotal(response.total); + } catch (error) { + console.error("레이아웃 목록 조회 실패:", error); + toast.error("레이아웃 목록을 불러오는데 실패했습니다."); + setLayouts([]); + } finally { + setLoading(false); + } + }; + + // 카테고리별 개수 로드 + const loadCategoryCounts = async () => { + try { + const counts = await layoutApi.getLayoutCountsByCategory(); + setCategoryCounts(counts); + } catch (error) { + console.error("카테고리 개수 조회 실패:", error); + } + }; + + // 코드 레벨 레이아웃 로드 + const loadCodeLayouts = async () => { + try { + setCodeLoading(true); + const response = await fetch("/api/admin/layouts/list"); + const result = await response.json(); + + if (result.success) { + setCodeLayouts(result.data.codeLayouts); + } else { + toast.error("코드 레이아웃 목록을 불러오는데 실패했습니다."); + setCodeLayouts([]); + } + } catch (error) { + console.error("코드 레이아웃 조회 실패:", error); + toast.error("코드 레이아웃 목록을 불러오는데 실패했습니다."); + setCodeLayouts([]); + } finally { + setCodeLoading(false); + } + }; + + useEffect(() => { + loadLayouts(); + }, [currentPage, selectedCategory]); + + useEffect(() => { + loadCategoryCounts(); + loadCodeLayouts(); + }, []); + + // 검색 + const handleSearch = () => { + setCurrentPage(1); + loadLayouts(); + }; + + // 엔터키 검색 + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + handleSearch(); + } + }; + + // 레이아웃 삭제 + const handleDelete = async (layout: LayoutStandard) => { + setLayoutToDelete(layout); + setDeleteDialogOpen(true); + }; + + const confirmDelete = async () => { + if (!layoutToDelete) return; + + try { + await layoutApi.deleteLayout(layoutToDelete.layoutCode); + toast.success("레이아웃이 삭제되었습니다."); + loadLayouts(); + loadCategoryCounts(); + } catch (error) { + console.error("레이아웃 삭제 실패:", error); + toast.error("레이아웃 삭제에 실패했습니다."); + } finally { + setDeleteDialogOpen(false); + setLayoutToDelete(null); + } + }; + + // 레이아웃 복제 + const handleDuplicate = async (layout: LayoutStandard) => { + try { + const newName = `${layout.layoutName} (복사)`; + await layoutApi.duplicateLayout(layout.layoutCode, { newName }); + toast.success("레이아웃이 복제되었습니다."); + loadLayouts(); + loadCategoryCounts(); + } catch (error) { + console.error("레이아웃 복제 실패:", error); + toast.error("레이아웃 복제에 실패했습니다."); + } + }; + + // 페이지네이션 + const handlePageChange = (page: number) => { + setCurrentPage(page); + }; + + return ( +
+
+
+

레이아웃 관리

+

화면 레이아웃을 생성하고 관리합니다.

+
+ +
+ + {/* 검색 및 필터 */} + + +
+
+
+ + setSearchTerm(e.target.value)} + onKeyPress={handleKeyPress} + className="pl-10" + /> +
+
+ +
+
+
+ + {/* 카테고리 탭 */} + + + + 전체 ({total}) + + {Object.entries(LAYOUT_CATEGORIES).map(([key, value]) => { + const Icon = CATEGORY_ICONS[value as keyof typeof CATEGORY_ICONS]; + const count = categoryCounts[value] || 0; + return ( + + + {CATEGORY_NAMES[value as keyof typeof CATEGORY_NAMES]} ({count}) + + ); + })} + + +
+ {loading ? ( +
로딩 중...
+ ) : layouts.length === 0 ? ( +
레이아웃이 없습니다.
+ ) : ( + <> + {/* 레이아웃 그리드 */} +
+ {layouts.map((layout) => { + const CategoryIcon = CATEGORY_ICONS[layout.category as keyof typeof CATEGORY_ICONS]; + return ( + + +
+
+ + + {CATEGORY_NAMES[layout.category as keyof typeof CATEGORY_NAMES]} + +
+ + + + + + + + 미리보기 + + + + 편집 + + handleDuplicate(layout)}> + + 복제 + + handleDelete(layout)} className="text-red-600"> + + 삭제 + + + +
+ {layout.layoutName} + {layout.description && ( +

{layout.description}

+ )} +
+ +
+
+ 타입: + {layout.layoutType} +
+
+ 존 개수: + {layout.zonesConfig.length}개 +
+ {layout.isPublic === "Y" && ( + + 공개 레이아웃 + + )} +
+
+
+ ); + })} +
+ + {/* 페이지네이션 */} + {totalPages > 1 && ( +
+
+ + + {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => ( + + ))} + + +
+
+ )} + + )} +
+
+ + {/* 삭제 확인 다이얼로그 */} + + + + 레이아웃 삭제 + + 정말로 "{layoutToDelete?.layoutName}" 레이아웃을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다. + + + + 취소 + + 삭제 + + + + + + {/* 새 레이아웃 생성 모달 */} + { + loadLayouts(); + loadCategoryCounts(); + }} + /> +
+ ); +} diff --git a/frontend/app/api/admin/layouts/generate/route.ts b/frontend/app/api/admin/layouts/generate/route.ts new file mode 100644 index 00000000..f8ac78ef --- /dev/null +++ b/frontend/app/api/admin/layouts/generate/route.ts @@ -0,0 +1,124 @@ +import { NextRequest, NextResponse } from "next/server"; +import { exec } from "child_process"; +import { promisify } from "util"; +import path from "path"; +import fs from "fs"; + +const execAsync = promisify(exec); + +export async function POST(request: NextRequest) { + try { + const { command, layoutData } = await request.json(); + + if (!command || !layoutData) { + return NextResponse.json({ success: false, message: "명령어와 레이아웃 데이터가 필요합니다." }, { status: 400 }); + } + + // 프론트엔드 디렉토리 경로 + const frontendDir = path.join(process.cwd()); + + // CLI 명령어 실행 + const fullCommand = `cd ${frontendDir} && node scripts/create-layout.js ${command}`; + + console.log("실행할 명령어:", fullCommand); + + const { stdout, stderr } = await execAsync(fullCommand); + + if (stderr && !stderr.includes("warning")) { + console.error("CLI 실행 오류:", stderr); + return NextResponse.json( + { + success: false, + message: "레이아웃 생성 중 오류가 발생했습니다.", + error: stderr, + }, + { status: 500 }, + ); + } + + // 생성된 파일들 확인 + const layoutId = layoutData.name.toLowerCase().replace(/[^a-z0-9-]/g, "-"); + const layoutDir = path.join(frontendDir, "lib/registry/layouts", layoutId); + + const generatedFiles: string[] = []; + + if (fs.existsSync(layoutDir)) { + const files = fs.readdirSync(layoutDir); + files.forEach((file) => { + generatedFiles.push(`layouts/${layoutId}/${file}`); + }); + } + + // 자동 등록을 위해 index.ts 업데이트 + await updateLayoutIndex(layoutId); + + return NextResponse.json({ + success: true, + message: "레이아웃이 성공적으로 생성되었습니다.", + files: generatedFiles, + output: stdout, + }); + } catch (error) { + console.error("레이아웃 생성 API 오류:", error); + return NextResponse.json( + { + success: false, + message: "서버 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }, + { status: 500 }, + ); + } +} + +/** + * layouts/index.ts에 새 레이아웃 import 추가 + */ +async function updateLayoutIndex(layoutId: string) { + try { + const indexPath = path.join(process.cwd(), "lib/registry/layouts/index.ts"); + + if (!fs.existsSync(indexPath)) { + console.warn("layouts/index.ts 파일을 찾을 수 없습니다."); + return; + } + + let content = fs.readFileSync(indexPath, "utf8"); + + // 새 import 문 추가 + const newImport = `import "./${layoutId}/${layoutId.charAt(0).toUpperCase() + layoutId.slice(1)}LayoutRenderer";`; + + // 이미 import되어 있는지 확인 + if (content.includes(newImport)) { + console.log("이미 import되어 있습니다."); + return; + } + + // 다른 import 문들 찾기 + const importRegex = /import "\.\/.+\/\w+LayoutRenderer";/g; + const imports = content.match(importRegex) || []; + + if (imports.length > 0) { + // 마지막 import 뒤에 추가 + const lastImport = imports[imports.length - 1]; + const lastImportIndex = content.lastIndexOf(lastImport); + const insertPosition = lastImportIndex + lastImport.length; + + content = content.slice(0, insertPosition) + "\n" + newImport + content.slice(insertPosition); + } else { + // import가 없다면 파일 시작 부분에 추가 + const newStructureComment = "// 새 구조 레이아웃들 (자동 등록)"; + const commentIndex = content.indexOf(newStructureComment); + + if (commentIndex !== -1) { + const insertPosition = content.indexOf("\n", commentIndex) + 1; + content = content.slice(0, insertPosition) + newImport + "\n" + content.slice(insertPosition); + } + } + + fs.writeFileSync(indexPath, content); + console.log(`layouts/index.ts에 ${layoutId} import 추가 완료`); + } catch (error) { + console.error("index.ts 업데이트 오류:", error); + } +} diff --git a/frontend/app/api/admin/layouts/list/route.ts b/frontend/app/api/admin/layouts/list/route.ts new file mode 100644 index 00000000..dc57eecd --- /dev/null +++ b/frontend/app/api/admin/layouts/list/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from "next/server"; +import { LayoutRegistry } from "@/lib/registry/LayoutRegistry"; + +/** + * 현재 등록된 레이아웃 목록 조회 (코드 레벨 + DB) + */ +export async function GET(request: NextRequest) { + try { + // 코드 레벨에서 등록된 레이아웃들 + const codeLayouts = LayoutRegistry.getAllLayouts().map((layout) => ({ + id: layout.id, + name: layout.name, + nameEng: layout.nameEng, + description: layout.description, + category: layout.category, + type: "code", // 코드로 생성된 레이아웃 + isActive: layout.isActive !== false, + tags: layout.tags || [], + metadata: layout.metadata, + zones: layout.defaultZones?.length || 0, + })); + + // 레지스트리 통계 + const registryInfo = LayoutRegistry.getRegistryInfo(); + + return NextResponse.json({ + success: true, + data: { + codeLayouts, + statistics: { + total: registryInfo.totalLayouts, + active: registryInfo.activeLayouts, + categories: registryInfo.categoryCounts, + types: registryInfo.registeredTypes, + }, + summary: { + codeLayoutCount: codeLayouts.length, + activeCodeLayouts: codeLayouts.filter((l) => l.isActive).length, + }, + }, + }); + } catch (error) { + console.error("레이아웃 목록 조회 오류:", error); + return NextResponse.json( + { + success: false, + message: "레이아웃 목록 조회에 실패했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }, + { status: 500 }, + ); + } +} diff --git a/frontend/components/admin/LayoutFormModal.tsx b/frontend/components/admin/LayoutFormModal.tsx new file mode 100644 index 00000000..972caa7c --- /dev/null +++ b/frontend/components/admin/LayoutFormModal.tsx @@ -0,0 +1,534 @@ +"use client"; + +import React, { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + Grid, + Layout, + Navigation, + Building, + FileText, + Table, + LayoutDashboard, + Plus, + Minus, + Info, + Wand2, +} from "lucide-react"; +import { LayoutCategory } from "@/types/layout"; +import { toast } from "sonner"; + +interface LayoutFormModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess: () => void; +} + +// 카테고리 정의 +const CATEGORIES = [ + { id: "basic", name: "기본", icon: Grid, description: "그리드, 플렉스박스 등 기본 레이아웃" }, + { id: "navigation", name: "네비게이션", icon: Navigation, description: "메뉴, 탭, 아코디언 등" }, + { id: "business", name: "비즈니스", icon: Building, description: "대시보드, 차트, 리포트 등" }, + { id: "form", name: "폼", icon: FileText, description: "입력 폼, 설정 패널 등" }, + { id: "table", name: "테이블", icon: Table, description: "데이터 테이블, 목록 등" }, + { id: "dashboard", name: "대시보드", icon: LayoutDashboard, description: "위젯, 카드 레이아웃 등" }, +] as const; + +// 레이아웃 템플릿 정의 +const LAYOUT_TEMPLATES = [ + { + id: "2-column", + name: "2열 레이아웃", + description: "좌우 2개 영역으로 구성", + zones: 2, + example: "사이드바 + 메인 콘텐츠", + icon: "▢ ▢", + }, + { + id: "3-column", + name: "3열 레이아웃", + description: "좌측, 중앙, 우측 3개 영역", + zones: 3, + example: "네비 + 콘텐츠 + 사이드", + icon: "▢ ▢ ▢", + }, + { + id: "header-content", + name: "헤더-콘텐츠", + description: "상단 헤더 + 하단 콘텐츠", + zones: 2, + example: "제목 + 내용 영역", + icon: "▬\n▢", + }, + { + id: "card-grid", + name: "카드 그리드", + description: "2x2 카드 격자 구조", + zones: 4, + example: "대시보드, 통계 패널", + icon: "▢▢\n▢▢", + }, + { + id: "accordion", + name: "아코디언", + description: "접고 펼칠 수 있는 섹션들", + zones: 3, + example: "FAQ, 설정 패널", + icon: "▷ ▽ ▷", + }, + { + id: "tabs", + name: "탭 레이아웃", + description: "탭으로 구성된 다중 패널", + zones: 3, + example: "설정, 상세 정보", + icon: "[Tab1][Tab2][Tab3]", + }, +]; + +export const LayoutFormModal: React.FC = ({ open, onOpenChange, onSuccess }) => { + const [step, setStep] = useState<"basic" | "template" | "advanced">("basic"); + const [formData, setFormData] = useState({ + name: "", + nameEng: "", + description: "", + category: "" as LayoutCategory | "", + zones: 2, + template: "", + author: "Developer", + }); + const [isGenerating, setIsGenerating] = useState(false); + const [generationResult, setGenerationResult] = useState<{ + success: boolean; + message: string; + files?: string[]; + } | null>(null); + + const handleReset = () => { + setStep("basic"); + setFormData({ + name: "", + nameEng: "", + description: "", + category: "", + zones: 2, + template: "", + author: "Developer", + }); + setGenerationResult(null); + }; + + const handleClose = () => { + handleReset(); + onOpenChange(false); + }; + + const handleNext = () => { + if (step === "basic") { + setStep("template"); + } else if (step === "template") { + setStep("advanced"); + } + }; + + const handleBack = () => { + if (step === "template") { + setStep("basic"); + } else if (step === "advanced") { + setStep("template"); + } + }; + + const validateBasic = () => { + return formData.name.trim() && formData.category && formData.description.trim(); + }; + + const validateTemplate = () => { + return formData.template && formData.zones > 0; + }; + + const generateLayout = async () => { + try { + setIsGenerating(true); + + // CLI 명령어 구성 + const command = [ + formData.name.toLowerCase().replace(/[^a-z0-9-]/g, "-"), + `--category=${formData.category}`, + `--zones=${formData.zones}`, + `--description="${formData.description}"`, + formData.author !== "Developer" ? `--author="${formData.author}"` : null, + ] + .filter(Boolean) + .join(" "); + + // API 호출로 CLI 명령어 실행 + const response = await fetch("/api/admin/layouts/generate", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + command, + layoutData: formData, + }), + }); + + const result = await response.json(); + + if (result.success) { + setGenerationResult({ + success: true, + message: "레이아웃이 성공적으로 생성되었습니다!", + files: result.files || [], + }); + + toast.success("레이아웃 생성 완료"); + + // 3초 후 자동으로 모달 닫고 새로고침 + setTimeout(() => { + handleClose(); + onSuccess(); + }, 3000); + } else { + setGenerationResult({ + success: false, + message: result.message || "레이아웃 생성에 실패했습니다.", + }); + toast.error("레이아웃 생성 실패"); + } + } catch (error) { + console.error("레이아웃 생성 오류:", error); + setGenerationResult({ + success: false, + message: "서버 오류가 발생했습니다.", + }); + toast.error("서버 오류"); + } finally { + setIsGenerating(false); + } + }; + + return ( + + + + + 새 레이아웃 생성 + + GUI를 통해 새로운 레이아웃을 쉽게 생성할 수 있습니다. + + + {/* 단계 표시기 */} +
+
+
+
+ 1 +
+ 기본 정보 +
+
+
+
+ 2 +
+ 템플릿 선택 +
+
+
+
+ 3 +
+ 고급 설정 +
+
+
+ + {/* 단계별 컨텐츠 */} +
+ {step === "basic" && ( +
+
+
+ + setFormData((prev) => ({ ...prev, name: e.target.value }))} + /> +
+
+ + setFormData((prev) => ({ ...prev, nameEng: e.target.value }))} + /> +
+
+ +
+ +
+ {CATEGORIES.map((category) => { + const IconComponent = category.icon; + return ( + setFormData((prev) => ({ ...prev, category: category.id }))} + > + +
+ +
+
{category.name}
+
{category.description}
+
+
+
+
+ ); + })} +
+
+ +
+ +