diff --git a/backend-node/prisma/schema.prisma b/backend-node/prisma/schema.prisma index 5d0aa77b..beb91d68 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[] @@ -5255,6 +5259,33 @@ 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") +} model table_relationships { relationship_id Int @id @default(autoincrement()) diagram_id Int // 관계도 그룹 식별자 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/scripts/list-components.js b/backend-node/scripts/list-components.js new file mode 100644 index 00000000..a0ba6da4 --- /dev/null +++ b/backend-node/scripts/list-components.js @@ -0,0 +1,46 @@ +const { PrismaClient } = require("@prisma/client"); +const prisma = new PrismaClient(); + +async function getComponents() { + try { + const components = await prisma.component_standards.findMany({ + where: { is_active: "Y" }, + select: { + component_code: true, + component_name: true, + category: true, + component_config: true, + }, + orderBy: [{ category: "asc" }, { sort_order: "asc" }], + }); + + console.log("📋 데이터베이스 컴포넌트 목록:"); + console.log("=".repeat(60)); + + const grouped = components.reduce((acc, comp) => { + if (!acc[comp.category]) { + acc[comp.category] = []; + } + acc[comp.category].push(comp); + return acc; + }, {}); + + Object.entries(grouped).forEach(([category, comps]) => { + console.log(`\n🏷️ ${category.toUpperCase()} 카테고리:`); + comps.forEach((comp) => { + const type = comp.component_config?.type || "unknown"; + console.log( + ` - ${comp.component_code}: ${comp.component_name} (type: ${type})` + ); + }); + }); + + console.log(`\n총 ${components.length}개 컴포넌트 발견`); + } catch (error) { + console.error("Error:", error); + } finally { + await prisma.$disconnect(); + } +} + +getComponents(); diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 3c8b61cc..dba6dc4d 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -26,6 +26,8 @@ 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 dataRoutes from "./routes/dataRoutes"; // import userRoutes from './routes/userRoutes'; // import menuRoutes from './routes/menuRoutes'; @@ -114,7 +116,9 @@ 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/data", dataRoutes); // app.use('/api/users', userRoutes); // app.use('/api/menus', menuRoutes); diff --git a/backend-node/src/controllers/componentStandardController.ts b/backend-node/src/controllers/componentStandardController.ts index 33f2f95b..dc264870 100644 --- a/backend-node/src/controllers/componentStandardController.ts +++ b/backend-node/src/controllers/componentStandardController.ts @@ -204,7 +204,15 @@ class ComponentStandardController { }); return; } catch (error) { - console.error("컴포넌트 수정 실패:", error); + const { component_code } = req.params; + const updateData = req.body; + + console.error("컴포넌트 수정 실패 [상세]:", { + component_code, + updateData, + error: error instanceof Error ? error.message : error, + stack: error instanceof Error ? error.stack : undefined, + }); res.status(400).json({ success: false, message: "컴포넌트 수정에 실패했습니다.", @@ -382,6 +390,52 @@ class ComponentStandardController { return; } } + + /** + * 컴포넌트 코드 중복 체크 + */ + async checkDuplicate( + req: AuthenticatedRequest, + res: Response + ): Promise { + try { + const { component_code } = req.params; + + if (!component_code) { + res.status(400).json({ + success: false, + message: "컴포넌트 코드가 필요합니다.", + }); + return; + } + + const isDuplicate = await componentStandardService.checkDuplicate( + component_code, + req.user?.companyCode + ); + + console.log( + `🔍 중복 체크 결과: component_code=${component_code}, company_code=${req.user?.companyCode}, isDuplicate=${isDuplicate}` + ); + + res.status(200).json({ + success: true, + data: { isDuplicate, component_code }, + message: isDuplicate + ? "이미 사용 중인 컴포넌트 코드입니다." + : "사용 가능한 컴포넌트 코드입니다.", + }); + return; + } catch (error) { + console.error("컴포넌트 코드 중복 체크 실패:", error); + res.status(500).json({ + success: false, + message: "컴포넌트 코드 중복 체크에 실패했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + return; + } + } } export default new ComponentStandardController(); 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/controllers/templateStandardController.ts b/backend-node/src/controllers/templateStandardController.ts index a08245ad..73fbc328 100644 --- a/backend-node/src/controllers/templateStandardController.ts +++ b/backend-node/src/controllers/templateStandardController.ts @@ -1,8 +1,14 @@ -import { Response } from "express"; -import { AuthenticatedRequest } from "../types/auth"; +import { Request, Response } from "express"; import { templateStandardService } from "../services/templateStandardService"; -import { handleError } from "../utils/errorHandler"; -import { checkMissingFields } from "../utils/validation"; + +interface AuthenticatedRequest extends Request { + user?: { + userId: string; + companyCode: string; + company_code?: string; + [key: string]: any; + }; +} /** * 템플릿 표준 관리 컨트롤러 @@ -11,26 +17,26 @@ export class TemplateStandardController { /** * 템플릿 목록 조회 */ - async getTemplates(req: AuthenticatedRequest, res: Response) { + async getTemplates(req: AuthenticatedRequest, res: Response): Promise { try { const { active = "Y", category, search, - companyCode, + company_code, is_public = "Y", page = "1", limit = "50", } = req.query; const user = req.user; - const userCompanyCode = user?.companyCode || "DEFAULT"; + const userCompanyCode = user?.company_code || "DEFAULT"; const result = await templateStandardService.getTemplates({ active: active as string, category: category as string, search: search as string, - company_code: (companyCode as string) || userCompanyCode, + company_code: (company_code as string) || userCompanyCode, is_public: is_public as string, page: parseInt(page as string), limit: parseInt(limit as string), @@ -47,23 +53,24 @@ export class TemplateStandardController { }, }); } catch (error) { - return handleError( - res, - error, - "템플릿 목록 조회 중 오류가 발생했습니다." - ); + console.error("템플릿 목록 조회 중 오류:", error); + res.status(500).json({ + success: false, + message: "템플릿 목록 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); } } /** * 템플릿 상세 조회 */ - async getTemplate(req: AuthenticatedRequest, res: Response) { + async getTemplate(req: AuthenticatedRequest, res: Response): Promise { try { const { templateCode } = req.params; if (!templateCode) { - return res.status(400).json({ + res.status(400).json({ success: false, error: "템플릿 코드가 필요합니다.", }); @@ -72,7 +79,7 @@ export class TemplateStandardController { const template = await templateStandardService.getTemplate(templateCode); if (!template) { - return res.status(404).json({ + res.status(404).json({ success: false, error: "템플릿을 찾을 수 없습니다.", }); @@ -83,40 +90,46 @@ export class TemplateStandardController { data: template, }); } catch (error) { - return handleError(res, error, "템플릿 조회 중 오류가 발생했습니다."); + console.error("템플릿 조회 중 오류:", error); + res.status(500).json({ + success: false, + message: "템플릿 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); } } /** * 템플릿 생성 */ - async createTemplate(req: AuthenticatedRequest, res: Response) { + async createTemplate( + req: AuthenticatedRequest, + res: Response + ): Promise { try { const user = req.user; const templateData = req.body; // 필수 필드 검증 - const requiredFields = [ - "template_code", - "template_name", - "category", - "layout_config", - ]; - const missingFields = checkMissingFields(templateData, requiredFields); - - if (missingFields.length > 0) { - return res.status(400).json({ + if ( + !templateData.template_code || + !templateData.template_name || + !templateData.category || + !templateData.layout_config + ) { + res.status(400).json({ success: false, - error: `필수 필드가 누락되었습니다: ${missingFields.join(", ")}`, + message: + "필수 필드가 누락되었습니다. (template_code, template_name, category, layout_config)", }); } // 회사 코드와 생성자 정보 추가 const templateWithMeta = { ...templateData, - company_code: user?.companyCode || "DEFAULT", - created_by: user?.userId || "system", - updated_by: user?.userId || "system", + company_code: user?.company_code || "DEFAULT", + created_by: user?.user_id || "system", + updated_by: user?.user_id || "system", }; const newTemplate = @@ -128,21 +141,29 @@ export class TemplateStandardController { message: "템플릿이 성공적으로 생성되었습니다.", }); } catch (error) { - return handleError(res, error, "템플릿 생성 중 오류가 발생했습니다."); + console.error("템플릿 생성 중 오류:", error); + res.status(500).json({ + success: false, + message: "템플릿 생성 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); } } /** * 템플릿 수정 */ - async updateTemplate(req: AuthenticatedRequest, res: Response) { + async updateTemplate( + req: AuthenticatedRequest, + res: Response + ): Promise { try { const { templateCode } = req.params; const templateData = req.body; const user = req.user; if (!templateCode) { - return res.status(400).json({ + res.status(400).json({ success: false, error: "템플릿 코드가 필요합니다.", }); @@ -151,7 +172,7 @@ export class TemplateStandardController { // 수정자 정보 추가 const templateWithMeta = { ...templateData, - updated_by: user?.userId || "system", + updated_by: user?.user_id || "system", }; const updatedTemplate = await templateStandardService.updateTemplate( @@ -160,7 +181,7 @@ export class TemplateStandardController { ); if (!updatedTemplate) { - return res.status(404).json({ + res.status(404).json({ success: false, error: "템플릿을 찾을 수 없습니다.", }); @@ -172,19 +193,27 @@ export class TemplateStandardController { message: "템플릿이 성공적으로 수정되었습니다.", }); } catch (error) { - return handleError(res, error, "템플릿 수정 중 오류가 발생했습니다."); + console.error("템플릿 수정 중 오류:", error); + res.status(500).json({ + success: false, + message: "템플릿 수정 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); } } /** * 템플릿 삭제 */ - async deleteTemplate(req: AuthenticatedRequest, res: Response) { + async deleteTemplate( + req: AuthenticatedRequest, + res: Response + ): Promise { try { const { templateCode } = req.params; if (!templateCode) { - return res.status(400).json({ + res.status(400).json({ success: false, error: "템플릿 코드가 필요합니다.", }); @@ -194,7 +223,7 @@ export class TemplateStandardController { await templateStandardService.deleteTemplate(templateCode); if (!deleted) { - return res.status(404).json({ + res.status(404).json({ success: false, error: "템플릿을 찾을 수 없습니다.", }); @@ -205,19 +234,27 @@ export class TemplateStandardController { message: "템플릿이 성공적으로 삭제되었습니다.", }); } catch (error) { - return handleError(res, error, "템플릿 삭제 중 오류가 발생했습니다."); + console.error("템플릿 삭제 중 오류:", error); + res.status(500).json({ + success: false, + message: "템플릿 삭제 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); } } /** * 템플릿 정렬 순서 일괄 업데이트 */ - async updateSortOrder(req: AuthenticatedRequest, res: Response) { + async updateSortOrder( + req: AuthenticatedRequest, + res: Response + ): Promise { try { const { templates } = req.body; if (!Array.isArray(templates)) { - return res.status(400).json({ + res.status(400).json({ success: false, error: "templates는 배열이어야 합니다.", }); @@ -230,25 +267,29 @@ export class TemplateStandardController { message: "템플릿 정렬 순서가 성공적으로 업데이트되었습니다.", }); } catch (error) { - return handleError( - res, - error, - "템플릿 정렬 순서 업데이트 중 오류가 발생했습니다." - ); + console.error("템플릿 정렬 순서 업데이트 중 오류:", error); + res.status(500).json({ + success: false, + message: "템플릿 정렬 순서 업데이트 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); } } /** * 템플릿 복제 */ - async duplicateTemplate(req: AuthenticatedRequest, res: Response) { + async duplicateTemplate( + req: AuthenticatedRequest, + res: Response + ): Promise { try { const { templateCode } = req.params; const { new_template_code, new_template_name } = req.body; const user = req.user; if (!templateCode || !new_template_code || !new_template_name) { - return res.status(400).json({ + res.status(400).json({ success: false, error: "필수 필드가 누락되었습니다.", }); @@ -259,8 +300,8 @@ export class TemplateStandardController { originalCode: templateCode, newCode: new_template_code, newName: new_template_name, - company_code: user?.companyCode || "DEFAULT", - created_by: user?.userId || "system", + company_code: user?.company_code || "DEFAULT", + created_by: user?.user_id || "system", }); res.status(201).json({ @@ -269,17 +310,22 @@ export class TemplateStandardController { message: "템플릿이 성공적으로 복제되었습니다.", }); } catch (error) { - return handleError(res, error, "템플릿 복제 중 오류가 발생했습니다."); + console.error("템플릿 복제 중 오류:", error); + res.status(500).json({ + success: false, + message: "템플릿 복제 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); } } /** * 템플릿 카테고리 목록 조회 */ - async getCategories(req: AuthenticatedRequest, res: Response) { + async getCategories(req: AuthenticatedRequest, res: Response): Promise { try { const user = req.user; - const companyCode = user?.companyCode || "DEFAULT"; + const companyCode = user?.company_code || "DEFAULT"; const categories = await templateStandardService.getCategories(companyCode); @@ -289,24 +335,28 @@ export class TemplateStandardController { data: categories, }); } catch (error) { - return handleError( - res, - error, - "템플릿 카테고리 조회 중 오류가 발생했습니다." - ); + console.error("템플릿 카테고리 조회 중 오류:", error); + res.status(500).json({ + success: false, + message: "템플릿 카테고리 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); } } /** * 템플릿 가져오기 (JSON 파일에서) */ - async importTemplate(req: AuthenticatedRequest, res: Response) { + async importTemplate( + req: AuthenticatedRequest, + res: Response + ): Promise { try { const user = req.user; const templateData = req.body; if (!templateData.layout_config) { - return res.status(400).json({ + res.status(400).json({ success: false, error: "유효한 템플릿 데이터가 아닙니다.", }); @@ -315,9 +365,9 @@ export class TemplateStandardController { // 회사 코드와 생성자 정보 추가 const templateWithMeta = { ...templateData, - company_code: user?.companyCode || "DEFAULT", - created_by: user?.userId || "system", - updated_by: user?.userId || "system", + company_code: user?.company_code || "DEFAULT", + created_by: user?.user_id || "system", + updated_by: user?.user_id || "system", }; const importedTemplate = @@ -329,31 +379,41 @@ export class TemplateStandardController { message: "템플릿이 성공적으로 가져왔습니다.", }); } catch (error) { - return handleError(res, error, "템플릿 가져오기 중 오류가 발생했습니다."); + console.error("템플릿 가져오기 중 오류:", error); + res.status(500).json({ + success: false, + message: "템플릿 가져오기 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); } } /** * 템플릿 내보내기 (JSON 형태로) */ - async exportTemplate(req: AuthenticatedRequest, res: Response) { + async exportTemplate( + req: AuthenticatedRequest, + res: Response + ): Promise { try { const { templateCode } = req.params; if (!templateCode) { - return res.status(400).json({ + res.status(400).json({ success: false, error: "템플릿 코드가 필요합니다.", }); + return; } const template = await templateStandardService.getTemplate(templateCode); if (!template) { - return res.status(404).json({ + res.status(404).json({ success: false, error: "템플릿을 찾을 수 없습니다.", }); + return; } // 내보내기용 데이터 (메타데이터 제외) @@ -373,7 +433,12 @@ export class TemplateStandardController { data: exportData, }); } catch (error) { - return handleError(res, error, "템플릿 내보내기 중 오류가 발생했습니다."); + console.error("템플릿 내보내기 중 오류:", error); + res.status(500).json({ + success: false, + message: "템플릿 내보내기 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); } } } diff --git a/backend-node/src/routes/componentStandardRoutes.ts b/backend-node/src/routes/componentStandardRoutes.ts index 565fa7d0..1de7be83 100644 --- a/backend-node/src/routes/componentStandardRoutes.ts +++ b/backend-node/src/routes/componentStandardRoutes.ts @@ -25,6 +25,12 @@ router.get( componentStandardController.getStatistics.bind(componentStandardController) ); +// 컴포넌트 코드 중복 체크 +router.get( + "/check-duplicate/:component_code", + componentStandardController.checkDuplicate.bind(componentStandardController) +); + // 컴포넌트 상세 조회 router.get( "/:component_code", diff --git a/backend-node/src/routes/dataRoutes.ts b/backend-node/src/routes/dataRoutes.ts new file mode 100644 index 00000000..4ad42561 --- /dev/null +++ b/backend-node/src/routes/dataRoutes.ts @@ -0,0 +1,130 @@ +import express from "express"; +import { dataService } from "../services/dataService"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { AuthenticatedRequest } from "../types/auth"; + +const router = express.Router(); + +/** + * 동적 테이블 데이터 조회 API + * GET /api/data/{tableName} + */ +router.get( + "/:tableName", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const { tableName } = req.params; + const { limit = "10", offset = "0", orderBy, ...filters } = req.query; + + // 입력값 검증 + if (!tableName || typeof tableName !== "string") { + return res.status(400).json({ + success: false, + message: "테이블명이 필요합니다.", + error: "INVALID_TABLE_NAME", + }); + } + + // SQL 인젝션 방지를 위한 테이블명 검증 + 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}`, { + limit: parseInt(limit as string), + offset: parseInt(offset as string), + orderBy: orderBy as string, + filters, + user: req.user?.userId, + }); + + // 데이터 조회 + const result = await dataService.getTableData({ + tableName, + limit: parseInt(limit as string), + offset: parseInt(offset as string), + orderBy: orderBy as string, + filters: filters as Record, + userCompany: req.user?.companyCode, + }); + + if (!result.success) { + return res.status(400).json(result); + } + + console.log( + `✅ 데이터 조회 성공: ${tableName}, ${result.data?.length || 0}개 항목` + ); + + return res.json(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}/columns + */ +router.get( + "/:tableName/columns", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const { tableName } = req.params; + + // 입력값 검증 + if (!tableName || typeof tableName !== "string") { + return res.status(400).json({ + success: false, + message: "테이블명이 필요합니다.", + error: "INVALID_TABLE_NAME", + }); + } + + // SQL 인젝션 방지를 위한 테이블명 검증 + 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}`); + + // 컬럼 정보 조회 + const result = await dataService.getTableColumns(tableName); + + if (!result.success) { + return res.status(400).json(result); + } + + console.log( + `✅ 컬럼 정보 조회 성공: ${tableName}, ${result.data?.length || 0}개 컬럼` + ); + + return res.json(result); + } 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/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/componentStandardService.ts b/backend-node/src/services/componentStandardService.ts index 6a05c122..3ac00742 100644 --- a/backend-node/src/services/componentStandardService.ts +++ b/backend-node/src/services/componentStandardService.ts @@ -131,9 +131,16 @@ class ComponentStandardService { ); } + // 'active' 필드를 'is_active'로 변환 + const createData = { ...data }; + if ("active" in createData) { + createData.is_active = (createData as any).active; + delete (createData as any).active; + } + const component = await prisma.component_standards.create({ data: { - ...data, + ...createData, created_date: new Date(), updated_date: new Date(), }, @@ -151,10 +158,17 @@ class ComponentStandardService { ) { const existing = await this.getComponent(component_code); + // 'active' 필드를 'is_active'로 변환 + const updateData = { ...data }; + if ("active" in updateData) { + updateData.is_active = (updateData as any).active; + delete (updateData as any).active; + } + const component = await prisma.component_standards.update({ where: { component_code }, data: { - ...data, + ...updateData, updated_date: new Date(), }, }); @@ -216,21 +230,19 @@ class ComponentStandardService { data: { component_code: new_code, component_name: new_name, - component_name_eng: source.component_name_eng, - description: source.description, - category: source.category, - icon_name: source.icon_name, - default_size: source.default_size as any, - component_config: source.component_config as any, - preview_image: source.preview_image, - sort_order: source.sort_order, - is_active: source.is_active, - is_public: source.is_public, - company_code: source.company_code, + component_name_eng: source?.component_name_eng, + description: source?.description, + category: source?.category, + icon_name: source?.icon_name, + default_size: source?.default_size as any, + component_config: source?.component_config as any, + preview_image: source?.preview_image, + sort_order: source?.sort_order, + is_active: source?.is_active, + is_public: source?.is_public, + company_code: source?.company_code || "DEFAULT", created_date: new Date(), - created_by: source.created_by, updated_date: new Date(), - updated_by: source.updated_by, }, }); @@ -297,6 +309,27 @@ class ComponentStandardService { })), }; } + + /** + * 컴포넌트 코드 중복 체크 + */ + async checkDuplicate( + component_code: string, + company_code?: string + ): Promise { + const whereClause: any = { component_code }; + + // 회사 코드가 있고 "*"가 아닌 경우에만 조건 추가 + if (company_code && company_code !== "*") { + whereClause.company_code = company_code; + } + + const existingComponent = await prisma.component_standards.findFirst({ + where: whereClause, + }); + + return !!existingComponent; + } } export default new ComponentStandardService(); diff --git a/backend-node/src/services/dataService.ts b/backend-node/src/services/dataService.ts new file mode 100644 index 00000000..fc5935a8 --- /dev/null +++ b/backend-node/src/services/dataService.ts @@ -0,0 +1,328 @@ +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); + +interface GetTableDataParams { + tableName: string; + limit?: number; + offset?: number; + orderBy?: string; + filters?: Record; + userCompany?: string; +} + +interface ServiceResponse { + success: boolean; + data?: T; + message?: string; + error?: string; +} + +/** + * 안전한 테이블명 목록 (화이트리스트) + * SQL 인젝션 방지를 위해 허용된 테이블만 접근 가능 + */ +const ALLOWED_TABLES = [ + "company_mng", + "user_info", + "dept_info", + "code_info", + "code_category", + "menu_info", + "approval", + "approval_kind", + "board", + "comm_code", + "product_mng", + "part_mng", + "material_mng", + "order_mng_master", + "inventory_mng", + "contract_mgmt", + "project_mgmt", + "screen_definitions", + "screen_layouts", + "layout_standards", + "component_standards", + "web_type_standards", + "button_action_standards", + "template_standards", + "grid_standards", + "style_templates", + "multi_lang_key_master", + "multi_lang_text", + "language_master", + "table_labels", + "column_labels", + "dynamic_form_data", +]; + +/** + * 회사별 필터링이 필요한 테이블 목록 + */ +const COMPANY_FILTERED_TABLES = [ + "company_mng", + "user_info", + "dept_info", + "approval", + "board", + "product_mng", + "part_mng", + "material_mng", + "order_mng_master", + "inventory_mng", + "contract_mgmt", + "project_mgmt", +]; + +class DataService { + /** + * 테이블 데이터 조회 + */ + async getTableData( + params: GetTableDataParams + ): Promise> { + const { + tableName, + limit = 10, + offset = 0, + orderBy, + filters = {}, + userCompany, + } = params; + + try { + // 테이블명 화이트리스트 검증 + if (!ALLOWED_TABLES.includes(tableName)) { + return { + success: false, + message: `접근이 허용되지 않은 테이블입니다: ${tableName}`, + error: "TABLE_NOT_ALLOWED", + }; + } + + // 테이블 존재 여부 확인 + const tableExists = await this.checkTableExists(tableName); + if (!tableExists) { + return { + success: false, + message: `테이블을 찾을 수 없습니다: ${tableName}`, + error: "TABLE_NOT_FOUND", + }; + } + + // 동적 SQL 쿼리 생성 + let query = `SELECT * FROM "${tableName}"`; + const queryParams: any[] = []; + let paramIndex = 1; + + // WHERE 조건 생성 + const whereConditions: string[] = []; + + // 회사별 필터링 추가 + if (COMPANY_FILTERED_TABLES.includes(tableName) && userCompany) { + // 슈퍼관리자(*)가 아닌 경우에만 회사 필터 적용 + if (userCompany !== "*") { + whereConditions.push(`company_code = $${paramIndex}`); + queryParams.push(userCompany); + paramIndex++; + } + } + + // 사용자 정의 필터 추가 + for (const [key, value] of Object.entries(filters)) { + if ( + value && + key !== "limit" && + key !== "offset" && + key !== "orderBy" && + key !== "userLang" + ) { + // 컬럼명 검증 (SQL 인젝션 방지) + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) { + continue; // 유효하지 않은 컬럼명은 무시 + } + + whereConditions.push(`"${key}" ILIKE $${paramIndex}`); + queryParams.push(`%${value}%`); + paramIndex++; + } + } + + // WHERE 절 추가 + if (whereConditions.length > 0) { + query += ` WHERE ${whereConditions.join(" AND ")}`; + } + + // ORDER BY 절 추가 + if (orderBy) { + // ORDER BY 검증 (SQL 인젝션 방지) + const orderParts = orderBy.split(" "); + const columnName = orderParts[0]; + const direction = orderParts[1]?.toUpperCase(); + + if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(columnName)) { + const validDirection = direction === "DESC" ? "DESC" : "ASC"; + query += ` ORDER BY "${columnName}" ${validDirection}`; + } + } else { + // 기본 정렬: 최신순 (가능한 컬럼 시도) + const dateColumns = [ + "created_date", + "regdate", + "reg_date", + "updated_date", + "upd_date", + ]; + const tableColumns = await this.getTableColumnsSimple(tableName); + const availableDateColumn = dateColumns.find((col) => + tableColumns.some((tableCol) => tableCol.column_name === col) + ); + + if (availableDateColumn) { + query += ` ORDER BY "${availableDateColumn}" DESC`; + } + } + + // LIMIT과 OFFSET 추가 + query += ` LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`; + queryParams.push(limit, offset); + + console.log("🔍 실행할 쿼리:", query); + console.log("📊 쿼리 파라미터:", queryParams); + + // 쿼리 실행 + const result = await prisma.$queryRawUnsafe(query, ...queryParams); + + return { + success: true, + data: result as any[], + }; + } catch (error) { + console.error(`데이터 조회 오류 (${tableName}):`, error); + return { + success: false, + message: "데이터 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } + + /** + * 테이블 컬럼 정보 조회 + */ + async getTableColumns(tableName: string): Promise> { + try { + // 테이블명 화이트리스트 검증 + if (!ALLOWED_TABLES.includes(tableName)) { + return { + success: false, + message: `접근이 허용되지 않은 테이블입니다: ${tableName}`, + error: "TABLE_NOT_ALLOWED", + }; + } + + const columns = await this.getTableColumnsSimple(tableName); + + // 컬럼 라벨 정보 추가 + const columnsWithLabels = await Promise.all( + columns.map(async (column) => { + const label = await this.getColumnLabel( + tableName, + column.column_name + ); + return { + columnName: column.column_name, + columnLabel: label || column.column_name, + dataType: column.data_type, + isNullable: column.is_nullable === "YES", + defaultValue: column.column_default, + }; + }) + ); + + return { + success: true, + data: columnsWithLabels, + }; + } catch (error) { + console.error(`컬럼 정보 조회 오류 (${tableName}):`, error); + return { + success: false, + message: "컬럼 정보 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } + + /** + * 테이블 존재 여부 확인 + */ + private async checkTableExists(tableName: string): Promise { + try { + const result = await prisma.$queryRawUnsafe( + ` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = $1 + ); + `, + tableName + ); + + return (result as any)[0]?.exists || false; + } catch (error) { + console.error("테이블 존재 확인 오류:", error); + return false; + } + } + + /** + * 테이블 컬럼 정보 조회 (간단 버전) + */ + private async getTableColumnsSimple(tableName: string): Promise { + const result = await prisma.$queryRawUnsafe( + ` + SELECT column_name, data_type, is_nullable, column_default + FROM information_schema.columns + WHERE table_name = $1 + AND table_schema = 'public' + ORDER BY ordinal_position; + `, + tableName + ); + + return result as any[]; + } + + /** + * 컬럼 라벨 조회 + */ + private async getColumnLabel( + tableName: string, + columnName: string + ): Promise { + try { + // column_labels 테이블에서 라벨 조회 + const result = await prisma.$queryRawUnsafe( + ` + SELECT label_ko + FROM column_labels + WHERE table_name = $1 AND column_name = $2 + LIMIT 1; + `, + tableName, + columnName + ); + + const labelResult = result as any[]; + return labelResult[0]?.label_ko || null; + } catch (error) { + // column_labels 테이블이 없거나 오류가 발생하면 null 반환 + return null; + } + } +} + +export const dataService = new DataService(); 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/backend-node/src/utils/errorHandler.ts b/backend-node/src/utils/errorHandler.ts deleted file mode 100644 index e8239273..00000000 --- a/backend-node/src/utils/errorHandler.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { Response } from "express"; -import { logger } from "./logger"; - -/** - * 에러 처리 유틸리티 - */ -export const handleError = ( - res: Response, - error: any, - message: string = "서버 오류가 발생했습니다." -) => { - logger.error(`Error: ${message}`, error); - - res.status(500).json({ - success: false, - error: { - code: "SERVER_ERROR", - details: message, - }, - }); -}; - -/** - * 잘못된 요청 에러 처리 - */ -export const handleBadRequest = ( - res: Response, - message: string = "잘못된 요청입니다." -) => { - res.status(400).json({ - success: false, - error: { - code: "BAD_REQUEST", - details: message, - }, - }); -}; - -/** - * 찾을 수 없음 에러 처리 - */ -export const handleNotFound = ( - res: Response, - message: string = "요청한 리소스를 찾을 수 없습니다." -) => { - res.status(404).json({ - success: false, - error: { - code: "NOT_FOUND", - details: message, - }, - }); -}; - -/** - * 권한 없음 에러 처리 - */ -export const handleUnauthorized = ( - res: Response, - message: string = "권한이 없습니다." -) => { - res.status(403).json({ - success: false, - error: { - code: "UNAUTHORIZED", - details: message, - }, - }); -}; diff --git a/backend-node/src/utils/validation.ts b/backend-node/src/utils/validation.ts deleted file mode 100644 index ce3f909c..00000000 --- a/backend-node/src/utils/validation.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * 유효성 검증 유틸리티 - */ - -/** - * 필수 값 검증 - */ -export const validateRequired = (value: any, fieldName: string): void => { - if (value === null || value === undefined || value === "") { - throw new Error(`${fieldName}은(는) 필수 입력값입니다.`); - } -}; - -/** - * 여러 필수 값 검증 - */ -export const validateRequiredFields = ( - data: Record, - requiredFields: string[] -): void => { - for (const field of requiredFields) { - validateRequired(data[field], field); - } -}; - -/** - * 문자열 길이 검증 - */ -export const validateStringLength = ( - value: string, - fieldName: string, - minLength?: number, - maxLength?: number -): void => { - if (minLength !== undefined && value.length < minLength) { - throw new Error( - `${fieldName}은(는) 최소 ${minLength}자 이상이어야 합니다.` - ); - } - - if (maxLength !== undefined && value.length > maxLength) { - throw new Error(`${fieldName}은(는) 최대 ${maxLength}자 이하여야 합니다.`); - } -}; - -/** - * 이메일 형식 검증 - */ -export const validateEmail = (email: string): boolean => { - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - return emailRegex.test(email); -}; - -/** - * 숫자 범위 검증 - */ -export const validateNumberRange = ( - value: number, - fieldName: string, - min?: number, - max?: number -): void => { - if (min !== undefined && value < min) { - throw new Error(`${fieldName}은(는) ${min} 이상이어야 합니다.`); - } - - if (max !== undefined && value > max) { - throw new Error(`${fieldName}은(는) ${max} 이하여야 합니다.`); - } -}; - -/** - * 배열이 비어있지 않은지 검증 - */ -export const validateNonEmptyArray = ( - array: any[], - fieldName: string -): void => { - if (!Array.isArray(array) || array.length === 0) { - throw new Error(`${fieldName}은(는) 비어있을 수 없습니다.`); - } -}; - -/** - * 필수 필드 검증 후 누락된 필드 목록 반환 - */ -export const checkMissingFields = ( - data: Record, - requiredFields: string[] -): string[] => { - const missingFields: string[] = []; - - for (const field of requiredFields) { - const value = data[field]; - if (value === null || value === undefined || value === "") { - missingFields.push(field); - } - } - - return missingFields; -}; diff --git a/backend-node/uploads/company_COMPANY_2/README.txt b/backend-node/uploads/company_COMPANY_2/README.txt new file mode 100644 index 00000000..d05e1851 --- /dev/null +++ b/backend-node/uploads/company_COMPANY_2/README.txt @@ -0,0 +1,4 @@ +회사 코드: COMPANY_2 +생성일: 2025-09-11T02:07:40.033Z +폴더 구조: YYYY/MM/DD/파일명 +관리자: 시스템 자동 생성 \ No newline at end of file diff --git a/backend-node/uploads/company_COMPANY_3/README.txt b/backend-node/uploads/company_COMPANY_3/README.txt new file mode 100644 index 00000000..3b7f18c0 --- /dev/null +++ b/backend-node/uploads/company_COMPANY_3/README.txt @@ -0,0 +1,4 @@ +회사 코드: COMPANY_3 +생성일: 2025-09-11T02:08:06.303Z +폴더 구조: YYYY/MM/DD/파일명 +관리자: 시스템 자동 생성 \ No newline at end of file diff --git a/frontend/app/(main)/admin/components/page.tsx b/frontend/app/(main)/admin/components/page.tsx index 8273f6d1..dbd63be8 100644 --- a/frontend/app/(main)/admin/components/page.tsx +++ b/frontend/app/(main)/admin/components/page.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useState, useMemo } from "react"; -import { Search, Plus, Edit, Trash2, RefreshCw, Package, Filter, Download, Upload } from "lucide-react"; +import { Search, Plus, Edit, Trash2, RefreshCw, Package, Filter } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; @@ -14,7 +14,10 @@ import { useComponentCategories, useComponentStatistics, useDeleteComponent, + useCreateComponent, + useUpdateComponent, } from "@/hooks/admin/useComponents"; +import { ComponentFormModal } from "@/components/admin/ComponentFormModal"; // 컴포넌트 카테고리 정의 const COMPONENT_CATEGORIES = [ @@ -32,6 +35,8 @@ export default function ComponentManagementPage() { const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); const [selectedComponent, setSelectedComponent] = useState(null); const [showDeleteModal, setShowDeleteModal] = useState(false); + const [showNewComponentModal, setShowNewComponentModal] = useState(false); + const [showEditComponentModal, setShowEditComponentModal] = useState(false); // 컴포넌트 데이터 가져오기 const { @@ -51,8 +56,10 @@ export default function ComponentManagementPage() { const { data: categories } = useComponentCategories(); const { data: statistics } = useComponentStatistics(); - // 삭제 뮤테이션 + // 뮤테이션 const deleteComponentMutation = useDeleteComponent(); + const createComponentMutation = useCreateComponent(); + const updateComponentMutation = useUpdateComponent(); // 컴포넌트 목록 (이미 필터링과 정렬이 적용된 상태) const components = componentsData?.components || []; @@ -88,6 +95,23 @@ export default function ComponentManagementPage() { } }; + // 컴포넌트 생성 처리 + const handleCreate = async (data: any) => { + await createComponentMutation.mutateAsync(data); + setShowNewComponentModal(false); + }; + + // 컴포넌트 수정 처리 + const handleUpdate = async (data: any) => { + if (!selectedComponent) return; + await updateComponentMutation.mutateAsync({ + component_code: selectedComponent.component_code, + data, + }); + setShowEditComponentModal(false); + setSelectedComponent(null); + }; + if (loading) { return (
@@ -124,15 +148,7 @@ export default function ComponentManagementPage() {

화면 설계에 사용되는 컴포넌트들을 관리합니다

- - -
@@ -279,7 +295,14 @@ export default function ComponentManagementPage() {
-
); } 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/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 9cbfbd82..8cac05b4 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -7,8 +7,11 @@ import { Loader2 } from "lucide-react"; import { screenApi } from "@/lib/api/screen"; import { ScreenDefinition, LayoutData } from "@/types/screen"; import { InteractiveScreenViewer } from "@/components/screen/InteractiveScreenViewerDynamic"; +import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; +import { DynamicWebTypeRenderer } from "@/lib/registry"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; +import { initializeComponents } from "@/lib/registry/components"; export default function ScreenViewPage() { const params = useParams(); @@ -21,6 +24,20 @@ export default function ScreenViewPage() { const [error, setError] = useState(null); const [formData, setFormData] = useState>({}); + useEffect(() => { + const initComponents = async () => { + try { + console.log("🚀 할당된 화면에서 컴포넌트 시스템 초기화 시작..."); + await initializeComponents(); + console.log("✅ 할당된 화면에서 컴포넌트 시스템 초기화 완료"); + } catch (error) { + console.error("❌ 할당된 화면에서 컴포넌트 시스템 초기화 실패:", error); + } + }; + + initComponents(); + }, []); + useEffect(() => { const loadScreen = async () => { try { @@ -93,12 +110,13 @@ export default function ScreenViewPage() { {layout && layout.components.length > 0 ? ( // 캔버스 컴포넌트들을 정확한 해상도로 표시
{layout.components @@ -147,10 +165,19 @@ export default function ScreenViewPage() { allComponents={layout.components} formData={formData} onFormDataChange={(fieldName, value) => { - setFormData((prev) => ({ - ...prev, - [fieldName]: value, - })); + console.log("📝 폼 데이터 변경:", { fieldName, value }); + setFormData((prev) => { + const newFormData = { + ...prev, + [fieldName]: value, + }; + console.log("📊 전체 폼 데이터:", newFormData); + return newFormData; + }); + }} + screenInfo={{ + id: screenId, + tableName: screen?.tableName, }} />
@@ -202,27 +229,58 @@ export default function ScreenViewPage() { position: "absolute", left: `${component.position.x}px`, top: `${component.position.y}px`, - width: component.style?.width || `${component.size.width}px`, - height: component.style?.height || `${component.size.height}px`, + width: `${component.size.width}px`, + height: `${component.size.height}px`, zIndex: component.position.z || 1, }} + onMouseEnter={() => { + console.log("🎯 할당된 화면 컴포넌트:", { + id: component.id, + type: component.type, + position: component.position, + size: component.size, + styleWidth: component.style?.width, + styleHeight: component.style?.height, + finalWidth: `${component.size.width}px`, + finalHeight: `${component.size.height}px`, + }); + }} > - { - setFormData((prev) => ({ - ...prev, - [fieldName]: value, - })); - }} - hideLabel={true} // 라벨 숨김 플래그 전달 - screenInfo={{ - id: screenId, - tableName: screen?.tableName, - }} - /> + {/* 위젯 컴포넌트가 아닌 경우 DynamicComponentRenderer 사용 */} + {component.type !== "widget" ? ( + { + setFormData((prev) => ({ + ...prev, + [fieldName]: value, + })); + }} + screenId={screenId} + tableName={screen?.tableName} + onRefresh={() => { + console.log("화면 새로고침 요청"); + }} + onClose={() => { + console.log("화면 닫기 요청"); + }} + /> + ) : ( + { + setFormData((prev) => ({ + ...prev, + [fieldName]: value, + })); + }} + /> + )} ); 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/app/layout.tsx b/frontend/app/layout.tsx index 570a8c32..a94cd613 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -3,6 +3,8 @@ import { Inter, JetBrains_Mono } from "next/font/google"; import "./globals.css"; import { QueryProvider } from "@/providers/QueryProvider"; import { RegistryProvider } from "./registry-provider"; +import { Toaster } from "sonner"; +import ScreenModal from "@/components/common/ScreenModal"; const inter = Inter({ subsets: ["latin"], @@ -44,6 +46,8 @@ export default function RootLayout({ {children} + + diff --git a/frontend/components/admin/ComponentFormModal.tsx b/frontend/components/admin/ComponentFormModal.tsx new file mode 100644 index 00000000..c64b62c9 --- /dev/null +++ b/frontend/components/admin/ComponentFormModal.tsx @@ -0,0 +1,565 @@ +"use client"; + +import React, { useState, useEffect } 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 { Switch } from "@/components/ui/switch"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Plus, X, Save, RotateCcw, AlertTriangle, CheckCircle } from "lucide-react"; +import { toast } from "sonner"; +import { useComponentDuplicateCheck } from "@/hooks/admin/useComponentDuplicateCheck"; +import { Alert, AlertDescription } from "@/components/ui/alert"; + +// 컴포넌트 카테고리 정의 +const COMPONENT_CATEGORIES = [ + { id: "input", name: "입력", description: "사용자 입력을 받는 컴포넌트" }, + { id: "action", name: "액션", description: "사용자 액션을 처리하는 컴포넌트" }, + { id: "display", name: "표시", description: "정보를 표시하는 컴포넌트" }, + { id: "layout", name: "레이아웃", description: "레이아웃을 구성하는 컴포넌트" }, + { id: "other", name: "기타", description: "기타 컴포넌트" }, +]; + +// 컴포넌트 타입 정의 +const COMPONENT_TYPES = [ + { id: "widget", name: "위젯", description: "입력 양식 위젯" }, + { id: "button", name: "버튼", description: "액션 버튼" }, + { id: "card", name: "카드", description: "카드 컨테이너" }, + { id: "container", name: "컨테이너", description: "일반 컨테이너" }, + { id: "dashboard", name: "대시보드", description: "대시보드 그리드" }, + { id: "alert", name: "알림", description: "알림 메시지" }, + { id: "badge", name: "배지", description: "상태 배지" }, + { id: "progress", name: "진행률", description: "진행률 표시" }, + { id: "chart", name: "차트", description: "데이터 차트" }, +]; + +// 웹타입 정의 (위젯인 경우만) +const WEB_TYPES = [ + "text", + "number", + "decimal", + "date", + "datetime", + "select", + "dropdown", + "textarea", + "boolean", + "checkbox", + "radio", + "code", + "entity", + "file", + "email", + "tel", + "color", + "range", + "time", + "week", + "month", +]; + +interface ComponentFormData { + component_code: string; + component_name: string; + description: string; + category: string; + component_config: { + type: string; + webType?: string; + config_panel?: string; + }; + default_size: { + width: number; + height: number; + }; + icon_name: string; + active: string; + sort_order: number; +} + +interface ComponentFormModalProps { + isOpen: boolean; + onClose: () => void; + onSubmit: (data: ComponentFormData) => Promise; + initialData?: any; + mode?: "create" | "edit"; +} + +export const ComponentFormModal: React.FC = ({ + isOpen, + onClose, + onSubmit, + initialData, + mode = "create", +}) => { + const [formData, setFormData] = useState({ + component_code: "", + component_name: "", + description: "", + category: "other", + component_config: { + type: "widget", + }, + default_size: { + width: 200, + height: 40, + }, + icon_name: "", + is_active: "Y", + sort_order: 100, + }); + + const [isSubmitting, setIsSubmitting] = useState(false); + const [shouldCheckDuplicate, setShouldCheckDuplicate] = useState(false); + + // 중복 체크 쿼리 (생성 모드에서만 활성화) + const duplicateCheck = useComponentDuplicateCheck( + formData.component_code, + mode === "create" && shouldCheckDuplicate && formData.component_code.length > 0, + ); + + // 초기 데이터 설정 + useEffect(() => { + if (isOpen) { + if (mode === "edit" && initialData) { + setFormData({ + component_code: initialData.component_code || "", + component_name: initialData.component_name || "", + description: initialData.description || "", + category: initialData.category || "other", + component_config: initialData.component_config || { type: "widget" }, + default_size: initialData.default_size || { width: 200, height: 40 }, + icon_name: initialData.icon_name || "", + is_active: initialData.is_active || "Y", + sort_order: initialData.sort_order || 100, + }); + } else { + // 새 컴포넌트 생성 시 초기값 + setFormData({ + component_code: "", + component_name: "", + description: "", + category: "other", + component_config: { + type: "widget", + }, + default_size: { + width: 200, + height: 40, + }, + icon_name: "", + is_active: "Y", + sort_order: 100, + }); + } + } + }, [isOpen, mode, initialData]); + + // 컴포넌트 코드 자동 생성 + const generateComponentCode = (name: string, type: string) => { + if (!name) return ""; + + // 한글을 영문으로 매핑 + const koreanToEnglish: { [key: string]: string } = { + 도움말: "help", + 툴팁: "tooltip", + 안내: "guide", + 알림: "alert", + 버튼: "button", + 카드: "card", + 대시보드: "dashboard", + 패널: "panel", + 입력: "input", + 텍스트: "text", + 선택: "select", + 체크: "check", + 라디오: "radio", + 파일: "file", + 이미지: "image", + 테이블: "table", + 리스트: "list", + 폼: "form", + }; + + // 한글을 영문으로 변환 + let englishName = name; + Object.entries(koreanToEnglish).forEach(([korean, english]) => { + englishName = englishName.replace(new RegExp(korean, "g"), english); + }); + + const cleanName = englishName + .toLowerCase() + .replace(/[^a-z0-9\s]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); + + // 빈 문자열이거나 숫자로 시작하는 경우 기본값 설정 + const finalName = cleanName || "component"; + const validName = /^[0-9]/.test(finalName) ? `comp-${finalName}` : finalName; + + return type === "widget" ? validName : `${validName}-${type}`; + }; + + // 폼 필드 변경 처리 + const handleChange = (field: string, value: any) => { + setFormData((prev) => { + const newData = { ...prev }; + + if (field.includes(".")) { + const [parent, child] = field.split("."); + newData[parent as keyof ComponentFormData] = { + ...(newData[parent as keyof ComponentFormData] as any), + [child]: value, + }; + } else { + (newData as any)[field] = value; + } + + // 컴포넌트 이름이 변경되면 코드 자동 생성 + if (field === "component_name" || field === "component_config.type") { + const name = field === "component_name" ? value : newData.component_name; + const type = field === "component_config.type" ? value : newData.component_config.type; + + if (name && mode === "create") { + newData.component_code = generateComponentCode(name, type); + // 자동 생성된 코드에 대해서도 중복 체크 활성화 + setShouldCheckDuplicate(true); + } + } + + // 컴포넌트 코드가 직접 변경되면 중복 체크 활성화 + if (field === "component_code" && mode === "create") { + setShouldCheckDuplicate(true); + } + + return newData; + }); + }; + + // 폼 제출 + const handleSubmit = async () => { + // 유효성 검사 + if (!formData.component_code || !formData.component_name) { + toast.error("컴포넌트 코드와 이름은 필수입니다."); + return; + } + + if (!formData.component_config.type) { + toast.error("컴포넌트 타입을 선택해주세요."); + return; + } + + // 생성 모드에서 중복 체크 + if (mode === "create" && duplicateCheck.data?.isDuplicate) { + toast.error("이미 사용 중인 컴포넌트 코드입니다. 다른 코드를 사용해주세요."); + return; + } + + setIsSubmitting(true); + try { + await onSubmit(formData); + toast.success(mode === "create" ? "컴포넌트가 생성되었습니다." : "컴포넌트가 수정되었습니다."); + onClose(); + } catch (error) { + toast.error(mode === "create" ? "컴포넌트 생성에 실패했습니다." : "컴포넌트 수정에 실패했습니다."); + } finally { + setIsSubmitting(false); + } + }; + + // 폼 초기화 + const handleReset = () => { + if (mode === "edit" && initialData) { + setFormData({ + component_code: initialData.component_code || "", + component_name: initialData.component_name || "", + description: initialData.description || "", + category: initialData.category || "other", + component_config: initialData.component_config || { type: "widget" }, + default_size: initialData.default_size || { width: 200, height: 40 }, + icon_name: initialData.icon_name || "", + is_active: initialData.is_active || "Y", + sort_order: initialData.sort_order || 100, + }); + } else { + setFormData({ + component_code: "", + component_name: "", + description: "", + category: "other", + component_config: { + type: "widget", + }, + default_size: { + width: 200, + height: 40, + }, + icon_name: "", + is_active: "Y", + sort_order: 100, + }); + } + }; + + return ( + + + + {mode === "create" ? "새 컴포넌트 추가" : "컴포넌트 편집"} + + {mode === "create" + ? "화면 설계에 사용할 새로운 컴포넌트를 추가합니다." + : "선택한 컴포넌트의 정보를 수정합니다."} + + + +
+ {/* 기본 정보 */} + + + 기본 정보 + + +
+
+ + handleChange("component_name", e.target.value)} + placeholder="예: 정보 알림" + /> +
+
+ +
+ handleChange("component_code", e.target.value)} + placeholder="예: alert-info" + disabled={mode === "edit"} + className={ + mode === "create" && duplicateCheck.data?.isDuplicate + ? "border-red-500 pr-10" + : mode === "create" && duplicateCheck.data && !duplicateCheck.data.isDuplicate + ? "border-green-500 pr-10" + : "" + } + /> + {mode === "create" && formData.component_code && duplicateCheck.data && ( +
+ {duplicateCheck.data.isDuplicate ? ( + + ) : ( + + )} +
+ )} +
+ {mode === "create" && formData.component_code && duplicateCheck.data && ( + + + {duplicateCheck.data.isDuplicate + ? "⚠️ 이미 사용 중인 컴포넌트 코드입니다." + : "✅ 사용 가능한 컴포넌트 코드입니다."} + + + )} +
+
+ +
+ +