diff --git a/backend-node/clean-screen-tables.js b/backend-node/clean-screen-tables.js new file mode 100644 index 00000000..61228f8d --- /dev/null +++ b/backend-node/clean-screen-tables.js @@ -0,0 +1,36 @@ +const { PrismaClient } = require("@prisma/client"); + +const prisma = new PrismaClient(); + +async function cleanScreenTables() { + try { + console.log("🧹 κΈ°μ‘΄ 화면관리 ν…Œμ΄λΈ”λ“€μ„ μ •λ¦¬ν•©λ‹ˆλ‹€..."); + + // κΈ°μ‘΄ ν…Œμ΄λΈ”λ“€μ„ μˆœμ„œλŒ€λ‘œ μ‚­μ œ (μ™Έλž˜ν‚€ μ œμ•½μ‘°κ±΄ λ•Œλ¬Έμ— μˆœμ„œ μ€‘μš”) + await prisma.$executeRaw`DROP VIEW IF EXISTS v_screen_definitions_with_auth CASCADE`; + console.log("βœ… λ·° μ‚­μ œ μ™„λ£Œ"); + + await prisma.$executeRaw`DROP TABLE IF EXISTS screen_menu_assignments CASCADE`; + console.log("βœ… screen_menu_assignments ν…Œμ΄λΈ” μ‚­μ œ μ™„λ£Œ"); + + await prisma.$executeRaw`DROP TABLE IF EXISTS screen_widgets CASCADE`; + console.log("βœ… screen_widgets ν…Œμ΄λΈ” μ‚­μ œ μ™„λ£Œ"); + + await prisma.$executeRaw`DROP TABLE IF EXISTS screen_layouts CASCADE`; + console.log("βœ… screen_layouts ν…Œμ΄λΈ” μ‚­μ œ μ™„λ£Œ"); + + await prisma.$executeRaw`DROP TABLE IF EXISTS screen_templates CASCADE`; + console.log("βœ… screen_templates ν…Œμ΄λΈ” μ‚­μ œ μ™„λ£Œ"); + + await prisma.$executeRaw`DROP TABLE IF EXISTS screen_definitions CASCADE`; + console.log("βœ… screen_definitions ν…Œμ΄λΈ” μ‚­μ œ μ™„λ£Œ"); + + console.log("πŸŽ‰ λͺ¨λ“  화면관리 ν…Œμ΄λΈ” 정리 μ™„λ£Œ!"); + } catch (error) { + console.error("❌ ν…Œμ΄λΈ” 정리 쀑 였λ₯˜ λ°œμƒ:", error); + } finally { + await prisma.$disconnect(); + } +} + +cleanScreenTables(); diff --git a/backend-node/prisma/schema.prisma b/backend-node/prisma/schema.prisma index 31cbe566..23e0151d 100644 --- a/backend-node/prisma/schema.prisma +++ b/backend-node/prisma/schema.prisma @@ -5174,7 +5174,7 @@ model swhd010a_tbl { empno String @id(map: "pk_swhd010a_tbl") @db.Char(6) ltdcd String @db.Char(1) namehan String? @db.Char(10) - deptcd String? @db.Char(5) + deptcd String? @db.VarChar(5) resigngucd String? @db.VarChar(1) } @@ -6837,4 +6837,98 @@ model zz_230410_user_info { fax_no String? @db.VarChar @@ignore -} \ No newline at end of file +} +// 화면관리 μ‹œμŠ€ν…œ Prisma μŠ€ν‚€λ§ˆ +// κΈ°μ‘΄ schema.prisma에 μΆ”κ°€ν•  λͺ¨λΈλ“€ + +model screen_definitions { + screen_id Int @id @default(autoincrement()) + screen_name String @db.VarChar(100) + screen_code String @unique @db.VarChar(50) + table_name String @db.VarChar(100) + company_code String @db.VarChar(50) + description String? @db.Text + is_active String @default("Y") @db.Char(1) + 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) + + // 관계 + layouts screen_layouts[] + menu_assignments screen_menu_assignments[] + + @@index([company_code]) +} + +model screen_layouts { + layout_id Int @id @default(autoincrement()) + screen_id Int + component_type String @db.VarChar(50) + component_id String @unique @db.VarChar(100) + parent_id String? @db.VarChar(100) + position_x Int + position_y Int + width Int + height Int + properties Json? + display_order Int @default(0) + created_date DateTime @default(now()) @db.Timestamp(6) + + // 관계 + screen screen_definitions @relation(fields: [screen_id], references: [screen_id], onDelete: Cascade) + widgets screen_widgets[] + + @@index([screen_id]) +} + +model screen_widgets { + widget_id Int @id @default(autoincrement()) + layout_id Int + table_name String @db.VarChar(100) + column_name String @db.VarChar(100) + widget_type String @db.VarChar(50) + label String? @db.VarChar(200) + placeholder String? @db.VarChar(200) + is_required Boolean @default(false) + is_readonly Boolean @default(false) + validation_rules Json? + display_properties Json? + created_date DateTime @default(now()) @db.Timestamp(6) + + // 관계 + layout screen_layouts @relation(fields: [layout_id], references: [layout_id], onDelete: Cascade) + + @@index([layout_id]) +} + +model screen_templates { + template_id Int @id @default(autoincrement()) + template_name String @db.VarChar(100) + template_type String @db.VarChar(50) + company_code String @db.VarChar(50) + description String? @db.Text + layout_data Json? + is_public Boolean @default(false) + created_by String? @db.VarChar(50) + created_date DateTime @default(now()) @db.Timestamp(6) + + @@index([company_code]) +} + +model screen_menu_assignments { + assignment_id Int @id @default(autoincrement()) + screen_id Int + menu_objid Decimal @db.Decimal + company_code String @db.VarChar(50) + display_order Int @default(0) + is_active String @default("Y") @db.Char(1) + created_date DateTime @default(now()) @db.Timestamp(6) + created_by String? @db.VarChar(50) + + // 관계 + screen screen_definitions @relation(fields: [screen_id], references: [screen_id], onDelete: Cascade) + + @@unique([screen_id, menu_objid, company_code]) + @@index([company_code]) +} diff --git a/backend-node/prisma/screen-management.prisma b/backend-node/prisma/screen-management.prisma new file mode 100644 index 00000000..ab2475f6 --- /dev/null +++ b/backend-node/prisma/screen-management.prisma @@ -0,0 +1,94 @@ +// 화면관리 μ‹œμŠ€ν…œ Prisma μŠ€ν‚€λ§ˆ +// κΈ°μ‘΄ schema.prisma에 μΆ”κ°€ν•  λͺ¨λΈλ“€ + +model screen_definitions { + screen_id Int @id @default(autoincrement()) + screen_name String @db.VarChar(100) + screen_code String @unique @db.VarChar(50) + table_name String @db.VarChar(100) + company_code String @db.VarChar(50) + description String? @db.Text + is_active String @default("Y") @db.Char(1) + 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) + + // 관계 + layouts screen_layouts[] + menu_assignments screen_menu_assignments[] + + @@index([company_code]) +} + +model screen_layouts { + layout_id Int @id @default(autoincrement()) + screen_id Int + component_type String @db.VarChar(50) + component_id String @unique @db.VarChar(100) + parent_id String? @db.VarChar(100) + position_x Int + position_y Int + width Int + height Int + properties Json? + display_order Int @default(0) + created_date DateTime @default(now()) @db.Timestamp(6) + + // 관계 + screen screen_definitions @relation(fields: [screen_id], references: [screen_id], onDelete: Cascade) + widgets screen_widgets[] + + @@index([screen_id]) +} + +model screen_widgets { + widget_id Int @id @default(autoincrement()) + layout_id Int + table_name String @db.VarChar(100) + column_name String @db.VarChar(100) + widget_type String @db.VarChar(50) + label String? @db.VarChar(200) + placeholder String? @db.VarChar(200) + is_required Boolean @default(false) + is_readonly Boolean @default(false) + validation_rules Json? + display_properties Json? + created_date DateTime @default(now()) @db.Timestamp(6) + + // 관계 + layout screen_layouts @relation(fields: [layout_id], references: [layout_id], onDelete: Cascade) + + @@index([layout_id]) +} + +model screen_templates { + template_id Int @id @default(autoincrement()) + template_name String @db.VarChar(100) + template_type String @db.VarChar(50) + company_code String @db.VarChar(50) + description String? @db.Text + layout_data Json? + is_public Boolean @default(false) + created_by String? @db.VarChar(50) + created_date DateTime @default(now()) @db.Timestamp(6) + + @@index([company_code]) +} + +model screen_menu_assignments { + assignment_id Int @id @default(autoincrement()) + screen_id Int + menu_objid Decimal @db.Decimal + company_code String @db.VarChar(50) + display_order Int @default(0) + is_active String @default("Y") @db.Char(1) + created_date DateTime @default(now()) @db.Timestamp(6) + created_by String? @db.VarChar(50) + + // 관계 + screen screen_definitions @relation(fields: [screen_id], references: [screen_id], onDelete: Cascade) + + @@unique([screen_id, menu_objid, company_code]) + @@index([company_code]) +} diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index ac6f085d..df6cf3bd 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -13,6 +13,7 @@ import authRoutes from "./routes/authRoutes"; import adminRoutes from "./routes/adminRoutes"; import multilangRoutes from "./routes/multilangRoutes"; import tableManagementRoutes from "./routes/tableManagementRoutes"; +import screenManagementRoutes from "./routes/screenManagementRoutes"; // import userRoutes from './routes/userRoutes'; // import menuRoutes from './routes/menuRoutes'; @@ -63,6 +64,7 @@ app.use("/api/auth", authRoutes); app.use("/api/admin", adminRoutes); app.use("/api/multilang", multilangRoutes); app.use("/api/table-management", tableManagementRoutes); +app.use("/api/screen-management", screenManagementRoutes); // app.use('/api/users', userRoutes); // app.use('/api/menus', menuRoutes); diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts new file mode 100644 index 00000000..5b2c96b0 --- /dev/null +++ b/backend-node/src/controllers/screenManagementController.ts @@ -0,0 +1,455 @@ +import { Request, Response } from "express"; +import { ScreenManagementService } from "../services/screenManagementService"; +import { + CreateScreenRequest, + UpdateScreenRequest, + SaveLayoutRequest, + MenuAssignmentRequest, + ColumnWebTypeSetting, + WebType, +} from "../types/screen"; +import { logger } from "../utils/logger"; + +export class ScreenManagementController { + private screenService: ScreenManagementService; + + constructor() { + this.screenService = new ScreenManagementService(); + } + + // ======================================== + // ν™”λ©΄ μ •μ˜ 관리 + // ======================================== + + /** + * ν™”λ©΄ λͺ©λ‘ 쑰회 (νšŒμ‚¬λ³„) + */ + async getScreens(req: Request, res: Response): Promise { + try { + const { page = 1, size = 20 } = req.query; + const userCompanyCode = (req as any).user?.company_code || "*"; + + const result = await this.screenService.getScreensByCompany( + userCompanyCode, + Number(page), + Number(size) + ); + + res.json({ + success: true, + data: result.data, + pagination: result.pagination, + }); + } catch (error) { + logger.error("ν™”λ©΄ λͺ©λ‘ 쑰회 μ‹€νŒ¨:", error); + res.status(500).json({ + success: false, + message: "ν™”λ©΄ λͺ©λ‘μ„ μ‘°νšŒν•˜λŠ” 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + /** + * ν™”λ©΄ 생성 + */ + async createScreen(req: Request, res: Response): Promise { + try { + const screenData: CreateScreenRequest = req.body; + const userCompanyCode = (req as any).user?.company_code || "*"; + const userId = (req as any).user?.user_id || "system"; + + // μ‚¬μš©μž νšŒμ‚¬ μ½”λ“œ μžλ™ μ„€μ • + if (userCompanyCode !== "*") { + screenData.companyCode = userCompanyCode; + } + screenData.createdBy = userId; + + const screen = await this.screenService.createScreen( + screenData, + userCompanyCode + ); + + res.status(201).json({ + success: true, + data: screen, + message: "화면이 μ„±κ³΅μ μœΌλ‘œ μƒμ„±λ˜μ—ˆμŠ΅λ‹ˆλ‹€.", + }); + } catch (error) { + logger.error("ν™”λ©΄ 생성 μ‹€νŒ¨:", error); + res.status(400).json({ + success: false, + message: + error instanceof Error ? error.message : "ν™”λ©΄ 생성에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.", + }); + } + } + + /** + * ν™”λ©΄ 쑰회 + */ + async getScreen(req: Request, res: Response): Promise { + try { + const { screenId } = req.params; + const screen = await this.screenService.getScreenById(Number(screenId)); + + if (!screen) { + res.status(404).json({ + success: false, + message: "화면을 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.", + }); + return; + } + + res.json({ + success: true, + data: screen, + }); + } catch (error) { + logger.error("ν™”λ©΄ 쑰회 μ‹€νŒ¨:", error); + res.status(500).json({ + success: false, + message: "화면을 μ‘°νšŒν•˜λŠ” 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + /** + * ν™”λ©΄ μˆ˜μ • + */ + async updateScreen(req: Request, res: Response): Promise { + try { + const { screenId } = req.params; + const updateData: UpdateScreenRequest = req.body; + const userCompanyCode = (req as any).user?.company_code || "*"; + const userId = (req as any).user?.user_id || "system"; + + updateData.updatedBy = userId; + + const screen = await this.screenService.updateScreen( + Number(screenId), + updateData, + userCompanyCode + ); + + res.json({ + success: true, + data: screen, + message: "화면이 μ„±κ³΅μ μœΌλ‘œ μˆ˜μ •λ˜μ—ˆμŠ΅λ‹ˆλ‹€.", + }); + } catch (error) { + logger.error("ν™”λ©΄ μˆ˜μ • μ‹€νŒ¨:", error); + res.status(400).json({ + success: false, + message: + error instanceof Error ? error.message : "ν™”λ©΄ μˆ˜μ •μ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.", + }); + } + } + + /** + * ν™”λ©΄ μ‚­μ œ + */ + async deleteScreen(req: Request, res: Response): Promise { + try { + const { screenId } = req.params; + const userCompanyCode = (req as any).user?.company_code || "*"; + + await this.screenService.deleteScreen(Number(screenId), userCompanyCode); + + res.json({ + success: true, + message: "화면이 μ„±κ³΅μ μœΌλ‘œ μ‚­μ œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.", + }); + } catch (error) { + logger.error("ν™”λ©΄ μ‚­μ œ μ‹€νŒ¨:", error); + res.status(400).json({ + success: false, + message: + error instanceof Error ? error.message : "ν™”λ©΄ μ‚­μ œμ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.", + }); + } + } + + // ======================================== + // λ ˆμ΄μ•„μ›ƒ 관리 + // ======================================== + + /** + * λ ˆμ΄μ•„μ›ƒ 쑰회 + */ + async getLayout(req: Request, res: Response): Promise { + try { + const { screenId } = req.params; + const layout = await this.screenService.getLayout(Number(screenId)); + + if (!layout) { + res.json({ + success: true, + data: { + components: [], + gridSettings: { + columns: 12, + gap: 16, + padding: 16, + }, + }, + }); + return; + } + + res.json({ + success: true, + data: layout, + }); + } catch (error) { + logger.error("λ ˆμ΄μ•„μ›ƒ 쑰회 μ‹€νŒ¨:", error); + res.status(500).json({ + success: false, + message: "λ ˆμ΄μ•„μ›ƒμ„ μ‘°νšŒν•˜λŠ” 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + /** + * λ ˆμ΄μ•„μ›ƒ μ €μž₯ + */ + async saveLayout(req: Request, res: Response): Promise { + try { + const { screenId } = req.params; + const layoutData: SaveLayoutRequest = req.body; + + await this.screenService.saveLayout(Number(screenId), layoutData); + + res.json({ + success: true, + message: "λ ˆμ΄μ•„μ›ƒμ΄ μ„±κ³΅μ μœΌλ‘œ μ €μž₯λ˜μ—ˆμŠ΅λ‹ˆλ‹€.", + }); + } catch (error) { + logger.error("λ ˆμ΄μ•„μ›ƒ μ €μž₯ μ‹€νŒ¨:", error); + res.status(400).json({ + success: false, + message: + error instanceof Error + ? error.message + : "λ ˆμ΄μ•„μ›ƒ μ €μž₯에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.", + }); + } + } + + // ======================================== + // ν…œν”Œλ¦Ώ 관리 + // ======================================== + + /** + * ν…œν”Œλ¦Ώ λͺ©λ‘ 쑰회 + */ + async getTemplates(req: Request, res: Response): Promise { + try { + const { type, isPublic } = req.query; + const userCompanyCode = (req as any).user?.company_code || "*"; + + const templates = await this.screenService.getTemplatesByCompany( + userCompanyCode, + type as string, + isPublic === "true" + ); + + res.json({ + success: true, + data: templates, + }); + } catch (error) { + logger.error("ν…œν”Œλ¦Ώ λͺ©λ‘ 쑰회 μ‹€νŒ¨:", error); + res.status(500).json({ + success: false, + message: "ν…œν”Œλ¦Ώ λͺ©λ‘μ„ μ‘°νšŒν•˜λŠ” 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + /** + * ν…œν”Œλ¦Ώ 생성 + */ + async createTemplate(req: Request, res: Response): Promise { + try { + const templateData = req.body; + const userCompanyCode = (req as any).user?.company_code || "*"; + const userId = (req as any).user?.user_id || "system"; + + templateData.company_code = userCompanyCode; + templateData.created_by = userId; + + const template = await this.screenService.createTemplate(templateData); + + res.status(201).json({ + success: true, + data: template, + message: "ν…œν”Œλ¦Ώμ΄ μ„±κ³΅μ μœΌλ‘œ μƒμ„±λ˜μ—ˆμŠ΅λ‹ˆλ‹€.", + }); + } catch (error) { + logger.error("ν…œν”Œλ¦Ώ 생성 μ‹€νŒ¨:", error); + res.status(400).json({ + success: false, + message: + error instanceof Error + ? error.message + : "ν…œν”Œλ¦Ώ 생성에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.", + }); + } + } + + // ======================================== + // 메뉴 ν• λ‹Ή 관리 + // ======================================== + + /** + * ν™”λ©΄-메뉴 ν• λ‹Ή + */ + async assignScreenToMenu(req: Request, res: Response): Promise { + try { + const { screenId } = req.params; + const assignmentData: MenuAssignmentRequest = req.body; + const userCompanyCode = (req as any).user?.company_code || "*"; + const userId = (req as any).user?.user_id || "system"; + + // μ‚¬μš©μž νšŒμ‚¬ μ½”λ“œ μžλ™ μ„€μ • + if (userCompanyCode !== "*") { + assignmentData.companyCode = userCompanyCode; + } + assignmentData.createdBy = userId; + + await this.screenService.assignScreenToMenu( + Number(screenId), + assignmentData + ); + + res.json({ + success: true, + message: "화면이 메뉴에 μ„±κ³΅μ μœΌλ‘œ ν• λ‹Ήλ˜μ—ˆμŠ΅λ‹ˆλ‹€.", + }); + } catch (error) { + logger.error("메뉴 ν• λ‹Ή μ‹€νŒ¨:", error); + res.status(400).json({ + success: false, + message: + error instanceof Error ? error.message : "메뉴 할당에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.", + }); + } + } + + /** + * 메뉴별 ν™”λ©΄ λͺ©λ‘ 쑰회 + */ + async getScreensByMenu(req: Request, res: Response): Promise { + try { + const { menuObjid } = req.params; + const userCompanyCode = (req as any).user?.company_code || "*"; + + const screens = await this.screenService.getScreensByMenu( + Number(menuObjid), + userCompanyCode + ); + + res.json({ + success: true, + data: screens, + }); + } catch (error) { + logger.error("메뉴별 ν™”λ©΄ λͺ©λ‘ 쑰회 μ‹€νŒ¨:", error); + res.status(500).json({ + success: false, + message: "메뉴별 ν™”λ©΄ λͺ©λ‘μ„ μ‘°νšŒν•˜λŠ” 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + // ======================================== + // ν…Œμ΄λΈ” νƒ€μž… 연계 + // ======================================== + + /** + * ν…Œμ΄λΈ” 컬럼 정보 쑰회 + */ + async getTableColumns(req: Request, res: Response): Promise { + try { + const { tableName } = req.params; + const columns = await this.screenService.getColumnInfo(tableName); + + res.json({ + success: true, + data: columns, + }); + } catch (error) { + logger.error("ν…Œμ΄λΈ” 컬럼 정보 쑰회 μ‹€νŒ¨:", error); + res.status(500).json({ + success: false, + message: "ν…Œμ΄λΈ” 컬럼 정보λ₯Ό μ‘°νšŒν•˜λŠ” 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + /** + * 컬럼 μ›Ή νƒ€μž… μ„€μ • + */ + async setColumnWebType(req: Request, res: Response): Promise { + try { + const { tableName, columnName } = req.params; + const { webType, ...additionalSettings } = req.body; + + await this.screenService.setColumnWebType( + tableName, + columnName, + webType as WebType, + additionalSettings + ); + + res.json({ + success: true, + message: "컬럼 μ›Ή νƒ€μž…μ΄ μ„±κ³΅μ μœΌλ‘œ μ„€μ •λ˜μ—ˆμŠ΅λ‹ˆλ‹€.", + }); + } catch (error) { + logger.error("컬럼 μ›Ή νƒ€μž… μ„€μ • μ‹€νŒ¨:", error); + res.status(400).json({ + success: false, + message: + error instanceof Error + ? error.message + : "컬럼 μ›Ή νƒ€μž… 섀정에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.", + }); + } + } + + /** + * ν…Œμ΄λΈ” λͺ©λ‘ 쑰회 (ν™”λ©΄ μƒμ„±μš©) + */ + async getTables(req: Request, res: Response): Promise { + try { + // PostgreSQLμ—μ„œ μ‚¬μš© κ°€λŠ₯ν•œ ν…Œμ΄λΈ” λͺ©λ‘ 쑰회 + const tables = await (this.screenService as any).prisma.$queryRaw` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_type = 'BASE TABLE' + ORDER BY table_name + `; + + res.json({ + success: true, + data: tables, + }); + } catch (error) { + logger.error("ν…Œμ΄λΈ” λͺ©λ‘ 쑰회 μ‹€νŒ¨:", error); + res.status(500).json({ + success: false, + message: "ν…Œμ΄λΈ” λͺ©λ‘μ„ μ‘°νšŒν•˜λŠ” 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } +} diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 10c38831..c755c5fd 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -443,3 +443,77 @@ export async function getColumnLabels( res.status(500).json(response); } } + +/** + * 컬럼 μ›Ή νƒ€μž… μ„€μ • + */ +export async function updateColumnWebType( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName, columnName } = req.params; + const { webType, detailSettings } = req.body; + + logger.info( + `=== 컬럼 μ›Ή νƒ€μž… μ„€μ • μ‹œμž‘: ${tableName}.${columnName} = ${webType} ===` + ); + + if (!tableName || !columnName || !webType) { + const response: ApiResponse = { + success: false, + message: "ν…Œμ΄λΈ”λͺ…, 컬럼λͺ…, μ›Ή νƒ€μž…μ΄ λͺ¨λ‘ ν•„μš”ν•©λ‹ˆλ‹€.", + error: { + code: "MISSING_PARAMETERS", + details: "ν•„μˆ˜ νŒŒλΌλ―Έν„°κ°€ λˆ„λ½λ˜μ—ˆμŠ΅λ‹ˆλ‹€.", + }, + }; + res.status(400).json(response); + return; + } + + // PostgreSQL ν΄λΌμ΄μ–ΈνŠΈ 생성 + const client = new Client({ + connectionString: process.env.DATABASE_URL, + }); + + await client.connect(); + + try { + const tableManagementService = new TableManagementService(client); + await tableManagementService.updateColumnWebType( + tableName, + columnName, + webType, + detailSettings + ); + + logger.info( + `컬럼 μ›Ή νƒ€μž… μ„€μ • μ™„λ£Œ: ${tableName}.${columnName} = ${webType}` + ); + + const response: ApiResponse = { + success: true, + message: "컬럼 μ›Ή νƒ€μž…μ΄ μ„±κ³΅μ μœΌλ‘œ μ„€μ •λ˜μ—ˆμŠ΅λ‹ˆλ‹€.", + data: null, + }; + + res.status(200).json(response); + } finally { + await client.end(); + } + } catch (error) { + logger.error("컬럼 μ›Ή νƒ€μž… μ„€μ • 쀑 였λ₯˜ λ°œμƒ:", error); + + const response: ApiResponse = { + success: false, + message: "컬럼 μ›Ή νƒ€μž… μ„€μ • 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.", + error: { + code: "WEB_TYPE_UPDATE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} diff --git a/backend-node/src/routes/screenManagementRoutes.ts b/backend-node/src/routes/screenManagementRoutes.ts new file mode 100644 index 00000000..e6d9655f --- /dev/null +++ b/backend-node/src/routes/screenManagementRoutes.ts @@ -0,0 +1,159 @@ +import express from "express"; +import { ScreenManagementController } from "../controllers/screenManagementController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = express.Router(); +const screenController = new ScreenManagementController(); + +// λͺ¨λ“  λΌμš°νŠΈμ— 인증 미듀웨어 적용 +router.use(authenticateToken); + +// ======================================== +// ν™”λ©΄ μ •μ˜ 관리 +// ======================================== + +/** + * @route POST /screens + * @desc μƒˆ ν™”λ©΄ 생성 + * @access Private + */ +router.post("/screens", screenController.createScreen.bind(screenController)); + +/** + * @route GET /screens + * @desc νšŒμ‚¬λ³„ ν™”λ©΄ λͺ©λ‘ 쑰회 + * @access Private + */ +router.get("/screens", screenController.getScreens.bind(screenController)); + +/** + * @route GET /screens/:screenId + * @desc νŠΉμ • ν™”λ©΄ 쑰회 + * @access Private + */ +router.get( + "/screens/:screenId", + screenController.getScreen.bind(screenController) +); + +/** + * @route PUT /screens/:screenId + * @desc ν™”λ©΄ 정보 μˆ˜μ • + * @access Private + */ +router.put( + "/screens/:screenId", + screenController.updateScreen.bind(screenController) +); + +/** + * @route DELETE /screens/:screenId + * @desc ν™”λ©΄ μ‚­μ œ + * @access Private + */ +router.delete( + "/screens/:screenId", + screenController.deleteScreen.bind(screenController) +); + +// ======================================== +// λ ˆμ΄μ•„μ›ƒ 관리 +// ======================================== + +/** + * @route GET /screens/:screenId/layout + * @desc ν™”λ©΄ λ ˆμ΄μ•„μ›ƒ 쑰회 + * @access Private + */ +router.get( + "/screens/:screenId/layout", + screenController.getLayout.bind(screenController) +); + +/** + * @route POST /screens/:screenId/layout + * @desc ν™”λ©΄ λ ˆμ΄μ•„μ›ƒ μ €μž₯ + * @access Private + */ +router.post( + "/screens/:screenId/layout", + screenController.saveLayout.bind(screenController) +); + +// ======================================== +// ν…œν”Œλ¦Ώ 관리 +// ======================================== + +/** + * @route GET /templates + * @desc νšŒμ‚¬λ³„ ν…œν”Œλ¦Ώ λͺ©λ‘ 쑰회 + * @access Private + */ +router.get("/templates", screenController.getTemplates.bind(screenController)); + +/** + * @route POST /templates + * @desc μƒˆ ν…œν”Œλ¦Ώ 생성 + * @access Private + */ +router.post( + "/templates", + screenController.createTemplate.bind(screenController) +); + +// ======================================== +// 메뉴 ν• λ‹Ή 관리 +// ======================================== + +/** + * @route POST /screens/:screenId/menu-assignments + * @desc 화면을 메뉴에 ν• λ‹Ή + * @access Private + */ +router.post( + "/screens/:screenId/menu-assignments", + screenController.assignScreenToMenu.bind(screenController) +); + +/** + * @route GET /menus/:menuObjid/screens + * @desc 메뉴별 ν• λ‹Ήλœ ν™”λ©΄ λͺ©λ‘ 쑰회 + * @access Private + */ +router.get( + "/menus/:menuObjid/screens", + screenController.getScreensByMenu.bind(screenController) +); + +// ======================================== +// ν…Œμ΄λΈ” νƒ€μž… 연계 +// ======================================== + +/** + * @route GET /tables + * @desc μ‚¬μš© κ°€λŠ₯ν•œ ν…Œμ΄λΈ” λͺ©λ‘ 쑰회 + * @access Private + */ +router.get("/tables", screenController.getTables.bind(screenController)); + +/** + * @route GET /tables/:tableName/columns + * @desc ν…Œμ΄λΈ”μ˜ 컬럼 정보 쑰회 + * @access Private + */ +router.get( + "/tables/:tableName/columns", + screenController.getTableColumns.bind(screenController) +); + +/** + * @route PUT /tables/:tableName/columns/:columnName/web-type + * @desc 컬럼의 μ›Ή νƒ€μž… μ„€μ • + * @access Private + */ +router.put( + "/tables/:tableName/columns/:columnName/web-type", + screenController.setColumnWebType.bind(screenController) +); + +export default router; diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index 9c7c7224..9851eb43 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -7,6 +7,7 @@ import { updateAllColumnSettings, getTableLabels, getColumnLabels, + updateColumnWebType, } from "../controllers/tableManagementController"; const router = express.Router(); @@ -53,4 +54,13 @@ router.get("/tables/:tableName/labels", getTableLabels); */ router.get("/tables/:tableName/columns/:columnName/labels", getColumnLabels); +/** + * 컬럼 μ›Ή νƒ€μž… μ„€μ • + * PUT /api/table-management/tables/:tableName/columns/:columnName/web-type + */ +router.put( + "/tables/:tableName/columns/:columnName/web-type", + updateColumnWebType +); + export default router; diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts new file mode 100644 index 00000000..dac621eb --- /dev/null +++ b/backend-node/src/services/screenManagementService.ts @@ -0,0 +1,618 @@ +import prisma from "../config/database"; +import { + ScreenDefinition, + CreateScreenRequest, + UpdateScreenRequest, + LayoutData, + SaveLayoutRequest, + ScreenTemplate, + MenuAssignmentRequest, + PaginatedResponse, + ComponentData, + ColumnInfo, + ColumnWebTypeSetting, + WebType, + WidgetData, +} from "../types/screen"; +import { generateId } from "../utils/generateId"; + +export class ScreenManagementService { + // ======================================== + // ν™”λ©΄ μ •μ˜ 관리 + // ======================================== + + /** + * ν™”λ©΄ μ •μ˜ 생성 + */ + async createScreen( + screenData: CreateScreenRequest, + userCompanyCode: string + ): Promise { + // ν™”λ©΄ μ½”λ“œ 쀑볡 확인 + const existingScreen = await prisma.screen_definitions.findUnique({ + where: { screen_code: screenData.screenCode }, + }); + + if (existingScreen) { + throw new Error("이미 μ‘΄μž¬ν•˜λŠ” ν™”λ©΄ μ½”λ“œμž…λ‹ˆλ‹€."); + } + + const screen = await prisma.screen_definitions.create({ + data: { + screen_name: screenData.screenName, + screen_code: screenData.screenCode, + table_name: screenData.tableName, + company_code: screenData.companyCode, + description: screenData.description, + created_by: screenData.createdBy, + }, + }); + + return this.mapToScreenDefinition(screen); + } + + /** + * νšŒμ‚¬λ³„ ν™”λ©΄ λͺ©λ‘ 쑰회 (νŽ˜μ΄μ§• 지원) + */ + async getScreensByCompany( + companyCode: string, + page: number = 1, + size: number = 20 + ): Promise> { + const whereClause = + companyCode === "*" ? {} : { company_code: companyCode }; + + const [screens, total] = await Promise.all([ + prisma.screen_definitions.findMany({ + where: whereClause, + skip: (page - 1) * size, + take: size, + orderBy: { created_date: "desc" }, + }), + prisma.screen_definitions.count({ where: whereClause }), + ]); + + return { + data: screens.map((screen) => this.mapToScreenDefinition(screen)), + pagination: { + page, + size, + total, + totalPages: Math.ceil(total / size), + }, + }; + } + + /** + * ν™”λ©΄ μ •μ˜ 쑰회 + */ + async getScreenById(screenId: number): Promise { + const screen = await prisma.screen_definitions.findUnique({ + where: { screen_id: screenId }, + }); + + return screen ? this.mapToScreenDefinition(screen) : null; + } + + /** + * ν™”λ©΄ μ •μ˜ μˆ˜μ • + */ + async updateScreen( + screenId: number, + updateData: UpdateScreenRequest, + userCompanyCode: string + ): Promise { + // κΆŒν•œ 검증 + const screen = await prisma.screen_definitions.findUnique({ + where: { screen_id: screenId }, + }); + + if (!screen) { + throw new Error("화면을 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."); + } + + if (userCompanyCode !== "*" && userCompanyCode !== screen.company_code) { + throw new Error("ν•΄λ‹Ή 화면을 μˆ˜μ •ν•  κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€."); + } + + const updatedScreen = await prisma.screen_definitions.update({ + where: { screen_id: screenId }, + data: { + screen_name: updateData.screenName, + description: updateData.description, + is_active: updateData.isActive, + updated_by: updateData.updatedBy, + updated_date: new Date(), + }, + }); + + return this.mapToScreenDefinition(updatedScreen); + } + + /** + * ν™”λ©΄ μ •μ˜ μ‚­μ œ + */ + async deleteScreen(screenId: number, userCompanyCode: string): Promise { + // κΆŒν•œ 검증 + const screen = await prisma.screen_definitions.findUnique({ + where: { screen_id: screenId }, + }); + + if (!screen) { + throw new Error("화면을 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."); + } + + if (userCompanyCode !== "*" && userCompanyCode !== screen.company_code) { + throw new Error("ν•΄λ‹Ή 화면을 μ‚­μ œν•  κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€."); + } + + // CASCADE둜 인해 κ΄€λ ¨ λ ˆμ΄μ•„μ›ƒκ³Ό μœ„μ ―λ„ μžλ™ μ‚­μ œλ¨ + await prisma.screen_definitions.delete({ + where: { screen_id: screenId }, + }); + } + + // ======================================== + // λ ˆμ΄μ•„μ›ƒ 관리 + // ======================================== + + /** + * λ ˆμ΄μ•„μ›ƒ μ €μž₯ + */ + async saveLayout( + screenId: number, + layoutData: SaveLayoutRequest + ): Promise { + // ν™”λ©΄ 쑴재 확인 + const screen = await prisma.screen_definitions.findUnique({ + where: { screen_id: screenId }, + }); + + if (!screen) { + throw new Error("화면을 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."); + } + + // κΈ°μ‘΄ λ ˆμ΄μ•„μ›ƒ μ‚­μ œ + await prisma.screen_layouts.deleteMany({ + where: { screen_id: screenId }, + }); + + // μƒˆ λ ˆμ΄μ•„μ›ƒ μ €μž₯ + const layoutPromises = layoutData.components.map((component) => + prisma.screen_layouts.create({ + data: { + screen_id: screenId, + component_type: component.type, + component_id: component.id, + parent_id: component.parentId, + position_x: component.position.x, + position_y: component.position.y, + width: component.size.width, + height: component.size.height, + properties: component.properties, + display_order: component.displayOrder || 0, + }, + }) + ); + + await Promise.all(layoutPromises); + } + + /** + * λ ˆμ΄μ•„μ›ƒ 쑰회 + */ + async getLayout(screenId: number): Promise { + const layouts = await prisma.screen_layouts.findMany({ + where: { screen_id: screenId }, + orderBy: { display_order: "asc" }, + }); + + if (layouts.length === 0) { + return null; + } + + const components: ComponentData[] = layouts.map((layout) => { + const baseComponent = { + id: layout.component_id, + type: layout.component_type as any, + position: { x: layout.position_x, y: layout.position_y }, + size: { width: layout.width, height: layout.height }, + properties: layout.properties as Record, + displayOrder: layout.display_order, + }; + + // μ»΄ν¬λ„ŒνŠΈ νƒ€μž…λ³„ μΆ”κ°€ 속성 처리 + switch (layout.component_type) { + case "group": + return { + ...baseComponent, + type: "group", + title: (layout.properties as any)?.title, + backgroundColor: (layout.properties as any)?.backgroundColor, + border: (layout.properties as any)?.border, + borderRadius: (layout.properties as any)?.borderRadius, + shadow: (layout.properties as any)?.shadow, + padding: (layout.properties as any)?.padding, + margin: (layout.properties as any)?.margin, + collapsible: (layout.properties as any)?.collapsible, + collapsed: (layout.properties as any)?.collapsed, + children: (layout.properties as any)?.children || [], + }; + case "widget": + return { + ...baseComponent, + type: "widget", + tableName: (layout.properties as any)?.tableName, + columnName: (layout.properties as any)?.columnName, + widgetType: (layout.properties as any)?.widgetType, + label: (layout.properties as any)?.label, + placeholder: (layout.properties as any)?.placeholder, + required: (layout.properties as any)?.required, + readonly: (layout.properties as any)?.readonly, + validationRules: (layout.properties as any)?.validationRules, + displayProperties: (layout.properties as any)?.displayProperties, + }; + default: + return baseComponent; + } + }); + + return { + components, + gridSettings: { + columns: 12, + gap: 16, + padding: 16, + }, + }; + } + + // ======================================== + // ν…œν”Œλ¦Ώ 관리 + // ======================================== + + /** + * ν…œν”Œλ¦Ώ λͺ©λ‘ 쑰회 (νšŒμ‚¬λ³„) + */ + async getTemplatesByCompany( + companyCode: string, + type?: string, + isPublic?: boolean + ): Promise { + const whereClause: any = {}; + + if (companyCode !== "*") { + whereClause.company_code = companyCode; + } + + if (type) { + whereClause.template_type = type; + } + + if (isPublic !== undefined) { + whereClause.is_public = isPublic; + } + + const templates = await prisma.screen_templates.findMany({ + where: whereClause, + orderBy: { created_date: "desc" }, + }); + + return templates.map(this.mapToScreenTemplate); + } + + /** + * ν…œν”Œλ¦Ώ 생성 + */ + async createTemplate( + templateData: Partial + ): Promise { + const template = await prisma.screen_templates.create({ + data: { + template_name: templateData.templateName!, + template_type: templateData.templateType!, + company_code: templateData.companyCode!, + description: templateData.description, + layout_data: templateData.layoutData + ? JSON.parse(JSON.stringify(templateData.layoutData)) + : null, + is_public: templateData.isPublic || false, + created_by: templateData.createdBy, + }, + }); + + return this.mapToScreenTemplate(template); + } + + // ======================================== + // 메뉴 ν• λ‹Ή 관리 + // ======================================== + + /** + * ν™”λ©΄-메뉴 ν• λ‹Ή + */ + async assignScreenToMenu( + screenId: number, + assignmentData: MenuAssignmentRequest + ): Promise { + // 쀑볡 ν• λ‹Ή λ°©μ§€ + const existingAssignment = await prisma.screen_menu_assignments.findFirst({ + where: { + screen_id: screenId, + menu_objid: assignmentData.menuObjid, + company_code: assignmentData.companyCode, + }, + }); + + if (existingAssignment) { + throw new Error("이미 ν• λ‹Ήλœ ν™”λ©΄μž…λ‹ˆλ‹€."); + } + + await prisma.screen_menu_assignments.create({ + data: { + screen_id: screenId, + menu_objid: assignmentData.menuObjid, + company_code: assignmentData.companyCode, + display_order: assignmentData.displayOrder || 0, + created_by: assignmentData.createdBy, + }, + }); + } + + /** + * 메뉴별 ν™”λ©΄ λͺ©λ‘ 쑰회 + */ + async getScreensByMenu( + menuObjid: number, + companyCode: string + ): Promise { + const assignments = await prisma.screen_menu_assignments.findMany({ + where: { + menu_objid: menuObjid, + company_code: companyCode, + is_active: "Y", + }, + include: { + screen: true, + }, + orderBy: { display_order: "asc" }, + }); + + return assignments.map((assignment) => + this.mapToScreenDefinition(assignment.screen) + ); + } + + // ======================================== + // ν…Œμ΄λΈ” νƒ€μž… 연계 + // ======================================== + + /** + * 컬럼 정보 쑰회 (μ›Ή νƒ€μž… 포함) + */ + async getColumnInfo(tableName: string): Promise { + const columns = await prisma.$queryRaw` + SELECT + c.column_name, + COALESCE(cl.column_label, c.column_name) as column_label, + c.data_type, + COALESCE(cl.web_type, 'text') as web_type, + c.is_nullable, + c.column_default, + c.character_maximum_length, + c.numeric_precision, + c.numeric_scale, + cl.detail_settings, + cl.code_category, + cl.reference_table, + cl.reference_column, + cl.is_visible, + cl.display_order, + cl.description + FROM information_schema.columns c + LEFT JOIN column_labels cl ON c.table_name = cl.table_name + AND c.column_name = cl.column_name + WHERE c.table_name = ${tableName} + ORDER BY COALESCE(cl.display_order, c.ordinal_position) + `; + + return columns as ColumnInfo[]; + } + + /** + * μ›Ή νƒ€μž… μ„€μ • + */ + async setColumnWebType( + tableName: string, + columnName: string, + webType: WebType, + additionalSettings?: Partial + ): Promise { + await prisma.column_labels.upsert({ + where: { + table_name_column_name: { + table_name: tableName, + column_name: columnName, + }, + }, + update: { + web_type: webType, + column_label: additionalSettings?.columnLabel, + detail_settings: additionalSettings?.detailSettings + ? JSON.stringify(additionalSettings.detailSettings) + : null, + code_category: additionalSettings?.codeCategory, + reference_table: additionalSettings?.referenceTable, + reference_column: additionalSettings?.referenceColumn, + is_visible: additionalSettings?.isVisible ?? true, + display_order: additionalSettings?.displayOrder ?? 0, + description: additionalSettings?.description, + updated_date: new Date(), + }, + create: { + table_name: tableName, + column_name: columnName, + column_label: additionalSettings?.columnLabel, + web_type: webType, + detail_settings: additionalSettings?.detailSettings + ? JSON.stringify(additionalSettings.detailSettings) + : null, + code_category: additionalSettings?.codeCategory, + reference_table: additionalSettings?.referenceTable, + reference_column: additionalSettings?.referenceColumn, + is_visible: additionalSettings?.isVisible ?? true, + display_order: additionalSettings?.displayOrder ?? 0, + description: additionalSettings?.description, + created_date: new Date(), + }, + }); + } + + /** + * μ›Ή νƒ€μž…λ³„ μœ„μ ― 생성 + */ + generateWidgetFromColumn(column: ColumnInfo): WidgetData { + const baseWidget = { + id: generateId(), + tableName: column.tableName, + columnName: column.columnName, + type: column.webType || "text", + label: column.columnLabel || column.columnName, + required: column.isNullable === "N", + readonly: false, + }; + + // detail_settings JSON νŒŒμ‹± + const detailSettings = column.detailSettings + ? JSON.parse(column.detailSettings) + : {}; + + switch (column.webType) { + case "text": + return { + ...baseWidget, + maxLength: detailSettings.maxLength || column.characterMaximumLength, + placeholder: `Enter ${column.columnLabel || column.columnName}`, + pattern: detailSettings.pattern, + }; + + case "number": + return { + ...baseWidget, + min: detailSettings.min, + max: + detailSettings.max || + (column.numericPrecision + ? Math.pow(10, column.numericPrecision) - 1 + : undefined), + step: + detailSettings.step || + (column.numericScale && column.numericScale > 0 + ? Math.pow(10, -column.numericScale) + : 1), + }; + + case "date": + return { + ...baseWidget, + format: detailSettings.format || "YYYY-MM-DD", + minDate: detailSettings.minDate, + maxDate: detailSettings.maxDate, + }; + + case "code": + return { + ...baseWidget, + codeCategory: column.codeCategory, + multiple: detailSettings.multiple || false, + searchable: detailSettings.searchable || false, + }; + + case "entity": + return { + ...baseWidget, + referenceTable: column.referenceTable, + referenceColumn: column.referenceColumn, + searchable: detailSettings.searchable || true, + multiple: detailSettings.multiple || false, + }; + + case "textarea": + return { + ...baseWidget, + rows: detailSettings.rows || 3, + maxLength: detailSettings.maxLength || column.characterMaximumLength, + }; + + case "select": + return { + ...baseWidget, + options: detailSettings.options || [], + multiple: detailSettings.multiple || false, + searchable: detailSettings.searchable || false, + }; + + case "checkbox": + return { + ...baseWidget, + defaultChecked: detailSettings.defaultChecked || false, + label: detailSettings.label || column.columnLabel, + }; + + case "radio": + return { + ...baseWidget, + options: detailSettings.options || [], + inline: detailSettings.inline || false, + }; + + case "file": + return { + ...baseWidget, + accept: detailSettings.accept || "*/*", + maxSize: detailSettings.maxSize || 10485760, // 10MB + multiple: detailSettings.multiple || false, + }; + + default: + return { + ...baseWidget, + type: "text", + }; + } + } + + // ======================================== + // μœ ν‹Έλ¦¬ν‹° λ©”μ„œλ“œ + // ======================================== + + private mapToScreenDefinition(data: any): ScreenDefinition { + return { + screenId: data.screen_id, + screenName: data.screen_name, + screenCode: data.screen_code, + tableName: data.table_name, + companyCode: data.company_code, + description: data.description, + isActive: data.is_active, + createdDate: data.created_date, + createdBy: data.created_by, + updatedDate: data.updated_date, + updatedBy: data.updated_by, + }; + } + + private mapToScreenTemplate(data: any): ScreenTemplate { + return { + templateId: data.template_id, + templateName: data.template_name, + templateType: data.template_type, + companyCode: data.company_code, + description: data.description, + layoutData: data.layout_data, + isPublic: data.is_public, + createdBy: data.created_by, + createdDate: data.created_date, + }; + } +} diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 863ab691..1dc3ce39 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -197,14 +197,18 @@ export class TableManagementService { // 각 컬럼 섀정을 순차적으둜 μ—…λ°μ΄νŠΈ for (const columnSetting of columnSettings) { - const columnName = - columnSetting.columnLabel || columnSetting.columnName; + // columnName은 μ‹€μ œ DB 컬럼λͺ…을 μœ μ§€ν•΄μ•Ό 함 + const columnName = columnSetting.columnName; if (columnName) { await this.updateColumnSettings( tableName, columnName, columnSetting ); + } else { + logger.warn( + `컬럼λͺ…이 λˆ„λ½λœ μ„€μ •: ${JSON.stringify(columnSetting)}` + ); } } @@ -310,4 +314,166 @@ export class TableManagementService { ); } } + + /** + * 컬럼 μ›Ή νƒ€μž… μ„€μ • + */ + async updateColumnWebType( + tableName: string, + columnName: string, + webType: string, + detailSettings?: Record + ): Promise { + try { + logger.info( + `컬럼 μ›Ή νƒ€μž… μ„€μ • μ‹œμž‘: ${tableName}.${columnName} = ${webType}` + ); + + // μ›Ή νƒ€μž…λ³„ κΈ°λ³Έ 상세 μ„€μ • 생성 + const defaultDetailSettings = this.generateDefaultDetailSettings(webType); + + // μ‚¬μš©μž μ •μ˜ μ„€μ •κ³Ό κΈ°λ³Έ μ„€μ • 병합 + const finalDetailSettings = { + ...defaultDetailSettings, + ...detailSettings, + }; + + // column_labels ν…Œμ΄λΈ”μ— ν•΄λ‹Ή 컬럼이 μžˆλŠ”μ§€ 확인 + const checkQuery = ` + SELECT COUNT(*) as count + FROM column_labels + WHERE table_name = $1 AND column_name = $2 + `; + + const checkResult = await this.client.query(checkQuery, [ + tableName, + columnName, + ]); + + if (checkResult.rows[0].count > 0) { + // κΈ°μ‘΄ 컬럼 라벨 μ—…λ°μ΄νŠΈ + const updateQuery = ` + UPDATE column_labels + SET web_type = $3, detail_settings = $4, updated_date = NOW() + WHERE table_name = $1 AND column_name = $2 + `; + + await this.client.query(updateQuery, [ + tableName, + columnName, + webType, + JSON.stringify(finalDetailSettings), + ]); + logger.info( + `컬럼 μ›Ή νƒ€μž… μ—…λ°μ΄νŠΈ μ™„λ£Œ: ${tableName}.${columnName} = ${webType}` + ); + } else { + // μƒˆλ‘œμš΄ 컬럼 라벨 생성 + const insertQuery = ` + INSERT INTO column_labels ( + table_name, column_name, web_type, detail_settings, created_date, updated_date + ) VALUES ($1, $2, $3, $4, NOW(), NOW()) + `; + + await this.client.query(insertQuery, [ + tableName, + columnName, + webType, + JSON.stringify(finalDetailSettings), + ]); + logger.info( + `컬럼 라벨 생성 및 μ›Ή νƒ€μž… μ„€μ • μ™„λ£Œ: ${tableName}.${columnName} = ${webType}` + ); + } + } catch (error) { + logger.error( + `컬럼 μ›Ή νƒ€μž… μ„€μ • 쀑 였λ₯˜ λ°œμƒ: ${tableName}.${columnName}`, + error + ); + throw new Error( + `컬럼 μ›Ή νƒ€μž… μ„€μ • μ‹€νŒ¨: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + /** + * μ›Ή νƒ€μž…λ³„ κΈ°λ³Έ 상세 μ„€μ • 생성 + */ + private generateDefaultDetailSettings(webType: string): Record { + switch (webType) { + case "text": + return { + maxLength: 255, + pattern: null, + placeholder: null, + }; + + case "number": + return { + min: null, + max: null, + step: 1, + precision: 2, + }; + + case "date": + return { + format: "YYYY-MM-DD", + minDate: null, + maxDate: null, + }; + + case "code": + return { + codeCategory: null, + displayFormat: "label", + searchable: true, + multiple: false, + }; + + case "entity": + return { + referenceTable: null, + referenceColumn: null, + searchable: true, + multiple: false, + }; + + case "textarea": + return { + rows: 3, + maxLength: 1000, + placeholder: null, + }; + + case "select": + return { + options: [], + multiple: false, + searchable: false, + }; + + case "checkbox": + return { + defaultChecked: false, + label: null, + }; + + case "radio": + return { + options: [], + inline: false, + }; + + case "file": + return { + accept: "*/*", + maxSize: 10485760, // 10MB + multiple: false, + }; + + default: + return {}; + } + } } diff --git a/backend-node/src/types/screen.ts b/backend-node/src/types/screen.ts new file mode 100644 index 00000000..b44bc7fb --- /dev/null +++ b/backend-node/src/types/screen.ts @@ -0,0 +1,284 @@ +// 화면관리 μ‹œμŠ€ν…œ νƒ€μž… μ •μ˜ + +// κΈ°λ³Έ μ»΄ν¬λ„ŒνŠΈ νƒ€μž… +export type ComponentType = "container" | "row" | "column" | "widget" | "group"; + +// μ›Ή νƒ€μž… μ •μ˜ +export type WebType = + | "text" + | "number" + | "date" + | "code" + | "entity" + | "textarea" + | "select" + | "checkbox" + | "radio" + | "file"; + +// μœ„μΉ˜ 정보 +export interface Position { + x: number; + y: number; +} + +// 크기 정보 +export interface Size { + width: number; // 1-12 κ·Έλ¦¬λ“œ + height: number; // ν”½μ…€ +} + +// κΈ°λ³Έ μ»΄ν¬λ„ŒνŠΈ μΈν„°νŽ˜μ΄μŠ€ +export interface BaseComponent { + id: string; + type: ComponentType; + position: Position; + size: Size; + properties?: Record; + displayOrder?: number; + parentId?: string; // λΆ€λͺ¨ μ»΄ν¬λ„ŒνŠΈ ID μΆ”κ°€ +} + +// μ»¨ν…Œμ΄λ„ˆ μ»΄ν¬λ„ŒνŠΈ +export interface ContainerComponent extends BaseComponent { + type: "container"; + backgroundColor?: string; + border?: string; + borderRadius?: number; + shadow?: string; + padding?: number; + margin?: number; +} + +// κ·Έλ£Ή μ»΄ν¬λ„ŒνŠΈ +export interface GroupComponent extends BaseComponent { + type: "group"; + title?: string; + backgroundColor?: string; + border?: string; + borderRadius?: number; + shadow?: string; + padding?: number; + margin?: number; + collapsible?: boolean; + collapsed?: boolean; + children: string[]; // ν¬ν•¨λœ μ»΄ν¬λ„ŒνŠΈ ID λͺ©λ‘ +} + +// ν–‰ μ»΄ν¬λ„ŒνŠΈ +export interface RowComponent extends BaseComponent { + type: "row"; + columns: number; // 1-12 + gap: number; + alignItems: "start" | "center" | "end"; + justifyContent: "start" | "center" | "end" | "space-between"; +} + +// 컬럼 μ»΄ν¬λ„ŒνŠΈ +export interface ColumnComponent extends BaseComponent { + type: "column"; + offset?: number; + order?: number; +} + +// μœ„μ ― μ»΄ν¬λ„ŒνŠΈ +export interface WidgetComponent extends BaseComponent { + type: "widget"; + tableName: string; + columnName: string; + widgetType: WebType; + label: string; + placeholder?: string; + required: boolean; + readonly: boolean; + validationRules?: ValidationRule[]; + displayProperties?: Record; +} + +// μ»΄ν¬λ„ŒνŠΈ μœ λ‹ˆμ˜¨ νƒ€μž… +export type ComponentData = + | ContainerComponent + | GroupComponent + | RowComponent + | ColumnComponent + | WidgetComponent; + +// λ ˆμ΄μ•„μ›ƒ 데이터 +export interface LayoutData { + components: ComponentData[]; + gridSettings?: GridSettings; +} + +// κ·Έλ¦¬λ“œ μ„€μ • +export interface GridSettings { + columns: number; // κΈ°λ³Έκ°’: 12 + gap: number; // κΈ°λ³Έκ°’: 16px + padding: number; // κΈ°λ³Έκ°’: 16px +} + +// μœ νš¨μ„± 검증 κ·œμΉ™ +export interface ValidationRule { + type: + | "required" + | "minLength" + | "maxLength" + | "pattern" + | "min" + | "max" + | "email" + | "url"; + value?: any; + message: string; +} + +// ν™”λ©΄ μ •μ˜ +export interface ScreenDefinition { + screenId: number; + screenName: string; + screenCode: string; + tableName: string; + companyCode: string; + description?: string; + isActive: string; + createdDate: Date; + createdBy?: string; + updatedDate: Date; + updatedBy?: string; +} + +// ν™”λ©΄ 생성 μš”μ²­ +export interface CreateScreenRequest { + screenName: string; + screenCode: string; + tableName: string; + companyCode: string; + description?: string; + createdBy?: string; +} + +// ν™”λ©΄ μˆ˜μ • μš”μ²­ +export interface UpdateScreenRequest { + screenName?: string; + description?: string; + isActive?: string; + updatedBy?: string; +} + +// λ ˆμ΄μ•„μ›ƒ μ €μž₯ μš”μ²­ +export interface SaveLayoutRequest { + components: ComponentData[]; + gridSettings?: GridSettings; +} + +// ν™”λ©΄ ν…œν”Œλ¦Ώ +export interface ScreenTemplate { + templateId: number; + templateName: string; + templateType: string; + companyCode: string; + description?: string; + layoutData?: LayoutData; + isPublic: boolean; + createdBy?: string; + createdDate: Date; +} + +// 메뉴 ν• λ‹Ή μš”μ²­ +export interface MenuAssignmentRequest { + menuObjid: number; + companyCode: string; + displayOrder?: number; + createdBy?: string; +} + +// λ“œλž˜κ·Έ μƒνƒœ +export interface DragState { + isDragging: boolean; + draggedItem: ComponentData | null; + dragSource: "toolbox" | "canvas"; + dropTarget: string | null; + dropZone?: DropZone; +} + +// λ“œλ‘­ μ˜μ—­ +export interface DropZone { + id: string; + accepts: ComponentType[]; + position: Position; + size: Size; +} + +// κ·Έλ£Ήν™” μƒνƒœ +export interface GroupState { + isGrouping: boolean; + selectedComponents: string[]; + groupTarget: string | null; + groupMode: "create" | "add" | "remove"; +} + +// 컬럼 정보 (ν…Œμ΄λΈ” νƒ€μž…κ΄€λ¦¬ μ—°κ³„μš©) +export interface ColumnInfo { + tableName: string; + columnName: string; + columnLabel?: string; + dataType: string; + webType?: WebType; + isNullable: string; + columnDefault?: string; + characterMaximumLength?: number; + numericPrecision?: number; + numericScale?: number; + detailSettings?: string; // JSON λ¬Έμžμ—΄ + codeCategory?: string; + referenceTable?: string; + referenceColumn?: string; + isVisible?: boolean; + displayOrder?: number; + description?: string; +} + +// μ›Ή νƒ€μž… μ„€μ • +export interface ColumnWebTypeSetting { + tableName: string; + columnName: string; + webType: WebType; + columnLabel?: string; + detailSettings?: Record; + codeCategory?: string; + referenceTable?: string; + referenceColumn?: string; + isVisible?: boolean; + displayOrder?: number; + description?: string; +} + +// μœ„μ ― 데이터 +export interface WidgetData { + id: string; + tableName: string; + columnName: string; + type: WebType; + label: string; + required: boolean; + readonly: boolean; + [key: string]: any; // μΆ”κ°€ 속성듀 +} + +// API 응닡 νƒ€μž… +export interface ApiResponse { + success: boolean; + data?: T; + message?: string; + errorCode?: string; +} + +// νŽ˜μ΄μ§€λ„€μ΄μ…˜ 응닡 +export interface PaginatedResponse { + data: T[]; + pagination: { + total: number; + page: number; + size: number; + totalPages: number; + }; +} diff --git a/backend-node/src/types/tableManagement.ts b/backend-node/src/types/tableManagement.ts index 521c4c55..8aeb0727 100644 --- a/backend-node/src/types/tableManagement.ts +++ b/backend-node/src/types/tableManagement.ts @@ -107,6 +107,11 @@ export const WEB_TYPE_OPTIONS = [ label: "entity", description: "μ—”ν‹°ν‹° μ°Έμ‘° (μ°Έμ‘°ν…Œμ΄λΈ” μ§€μ •)", }, + { value: "textarea", label: "textarea", description: "μ—¬λŸ¬ 쀄 ν…μŠ€νŠΈ" }, + { value: "select", label: "select", description: "λ“œλ‘­λ‹€μš΄ 선택" }, + { value: "checkbox", label: "checkbox", description: "μ²΄ν¬λ°•μŠ€" }, + { value: "radio", label: "radio", description: "λΌλ””μ˜€ λ²„νŠΌ" }, + { value: "file", label: "file", description: "파일 μ—…λ‘œλ“œ" }, ] as const; export type WebType = (typeof WEB_TYPE_OPTIONS)[number]["value"]; diff --git a/backend-node/src/utils/generateId.ts b/backend-node/src/utils/generateId.ts new file mode 100644 index 00000000..415e5d40 --- /dev/null +++ b/backend-node/src/utils/generateId.ts @@ -0,0 +1,56 @@ +/** + * 고유 ID 생성 μœ ν‹Έλ¦¬ν‹° + */ + +/** + * UUID v4 생성 + */ +export function generateUUID(): string { + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) { + const r = (Math.random() * 16) | 0; + const v = c === "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +/** + * 짧은 고유 ID 생성 (8자리) + */ +export function generateShortId(): string { + return Math.random().toString(36).substring(2, 10); +} + +/** + * κΈ΄ 고유 ID 생성 (16자리) + */ +export function generateLongId(): string { + return Math.random().toString(36).substring(2, 18); +} + +/** + * κΈ°λ³Έ ID 생성 (UUID v4) + */ +export function generateId(): string { + return generateUUID(); +} + +/** + * νƒ€μž„μŠ€νƒ¬ν”„ 기반 ID 생성 + */ +export function generateTimestampId(): string { + return Date.now().toString(36) + Math.random().toString(36).substring(2); +} + +/** + * μ»΄ν¬λ„ŒνŠΈ ID 생성 (화면관리 μ‹œμŠ€ν…œμš©) + */ +export function generateComponentId(prefix: string = "comp"): string { + return `${prefix}_${generateShortId()}`; +} + +/** + * ν™”λ©΄ ID 생성 (화면관리 μ‹œμŠ€ν…œμš©) + */ +export function generateScreenId(prefix: string = "screen"): string { + return `${prefix}_${generateShortId()}`; +} diff --git a/backend/src/main/java/com/pms/service/TableManagementService.java b/backend/src/main/java/com/pms/service/TableManagementService.java index b5dc7fff..305b5699 100644 --- a/backend/src/main/java/com/pms/service/TableManagementService.java +++ b/backend/src/main/java/com/pms/service/TableManagementService.java @@ -40,8 +40,8 @@ public class TableManagementService { Map paramMap = new HashMap<>(); paramMap.put("tableName", tableName); - paramMap.put("columnName", columnName); - paramMap.put("columnLabel", settings.get("columnLabel")); + paramMap.put("columnName", columnName); // μ‹€μ œ DB 컬럼λͺ… (λ³€κ²½ λΆˆκ°€) + paramMap.put("columnLabel", settings.get("columnLabel")); // μ‚¬μš©μžκ°€ μž…λ ₯ν•œ ν‘œμ‹œλͺ… paramMap.put("webType", settings.get("webType")); paramMap.put("detailSettings", settings.get("detailSettings")); paramMap.put("codeCategory", settings.get("codeCategory")); @@ -49,6 +49,13 @@ public class TableManagementService { paramMap.put("referenceTable", settings.get("referenceTable")); paramMap.put("referenceColumn", settings.get("referenceColumn")); + // 디버깅을 μœ„ν•œ 둜그 μΆ”κ°€ + System.out.println("μ €μž₯ν•  컬럼 μ„€μ •:"); + System.out.println(" tableName: " + tableName); + System.out.println(" columnName: " + columnName); + System.out.println(" columnLabel: " + settings.get("columnLabel")); + System.out.println(" webType: " + settings.get("webType")); + sqlSessionTemplate.update("tableManagement.updateColumnSettings", paramMap); } diff --git a/docs/화면관리_μ‹œμŠ€ν…œ_섀계.md b/docs/화면관리_μ‹œμŠ€ν…œ_섀계.md index 69b96058..105ff998 100644 --- a/docs/화면관리_μ‹œμŠ€ν…œ_섀계.md +++ b/docs/화면관리_μ‹œμŠ€ν…œ_섀계.md @@ -76,6 +76,7 @@ - **μžλ™ 상세 μ„€μ •**: μ›Ή νƒ€μž… 선택 μ‹œ ν•΄λ‹Ή νƒ€μž…μ— λ§žλŠ” κΈ°λ³Έ 상세 섀정을 μžλ™μœΌλ‘œ 제곡 - **μ‹€μ‹œκ°„ μ €μž₯**: μ›Ή νƒ€μž… λ³€κ²½ μ‹œ μ¦‰μ‹œ λ°±μ—”λ“œ λ°μ΄ν„°λ² μ΄μŠ€μ— μ €μž₯ - **였λ₯˜ 볡ꡬ**: μ €μž₯ μ‹€νŒ¨ μ‹œ μ›λž˜ μƒνƒœλ‘œ μžλ™ 볡원 +- **상세 μ„€μ • νŽΈμ§‘**: μ›Ή νƒ€μž…λ³„ 상세 섀정을 λͺ¨λ‹¬μ—μ„œ JSON ν˜•νƒœλ‘œ νŽΈμ§‘ κ°€λŠ₯ #### 2. μ›Ή νƒ€μž…λ³„ 상세 μ„€μ • @@ -98,7 +99,8 @@ 2. **컬럼 확인**: ν•΄λ‹Ή ν…Œμ΄λΈ”μ˜ λͺ¨λ“  컬럼 정보 ν‘œμ‹œ 3. **μ›Ή νƒ€μž… μ„€μ •**: 각 컬럼의 μ›Ή νƒ€μž…μ„ λ“œλ‘­λ‹€μš΄μ—μ„œ 선택 4. **μžλ™ μ €μž₯**: 선택 μ¦‰μ‹œ λ°±μ—”λ“œμ— μ €μž₯되고 상세 μ„€μ • μžλ™ 적용 -5. **μΆ”κ°€ μ„€μ •**: ν•„μš”μ‹œ 상세 섀정을 μ‚¬μš©μž μ •μ˜λ‘œ μˆ˜μ • +5. **상세 μ„€μ • νŽΈμ§‘**: "상세 μ„€μ • νŽΈμ§‘" λ²„νŠΌμ„ ν΄λ¦­ν•˜μ—¬ JSON ν˜•νƒœλ‘œ μΆ”κ°€ μ„€μ • μˆ˜μ • +6. **μ„€μ • μ €μž₯**: μˆ˜μ •λœ 상세 섀정을 μ €μž₯ν•˜μ—¬ μ™„λ£Œ ## πŸ—οΈ μ•„ν‚€ν…μ²˜ ꡬ쑰 @@ -1193,6 +1195,7 @@ return c; }; return ( +
{/* κ΄€λ¦¬μž κΈ°λŠ₯ μΉ΄λ“œλ“€ */} -
-
-
-
- -
-
-

μ‚¬μš©μž 관리

-

μ‚¬μš©μž 계정 및 κΆŒν•œ 관리

+
+ +
+
+
+ +
+
+

μ‚¬μš©μž 관리

+

μ‚¬μš©μž 계정 및 κΆŒν•œ 관리

+
-
+
@@ -54,6 +57,20 @@ export default function AdminPage() {
+ + +
+
+
+ +
+
+

화면관리

+

λ“œλž˜κ·Έμ•€λ“œλ‘­μœΌλ‘œ ν™”λ©΄ 섀계 및 관리

+
+
+
+
{/* 졜근 ν™œλ™ */} diff --git a/frontend/app/(main)/admin/screenMng/page.tsx b/frontend/app/(main)/admin/screenMng/page.tsx new file mode 100644 index 00000000..27025dec --- /dev/null +++ b/frontend/app/(main)/admin/screenMng/page.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Plus, Grid3X3, Palette, Settings, FileText } from "lucide-react"; +import ScreenList from "@/components/screen/ScreenList"; +import ScreenDesigner from "@/components/screen/ScreenDesigner"; +import TemplateManager from "@/components/screen/TemplateManager"; +import { ScreenDefinition } from "@/types/screen"; + +export default function ScreenManagementPage() { + const [selectedScreen, setSelectedScreen] = useState(null); + const [activeTab, setActiveTab] = useState("screens"); + + return ( +
+ {/* νŽ˜μ΄μ§€ 헀더 */} +
+
+

화면관리 μ‹œμŠ€ν…œ

+

λ“œλž˜κ·Έμ•€λ“œλ‘­μœΌλ‘œ 화면을 μ„€κ³„ν•˜κ³  κ΄€λ¦¬ν•˜μ„Έμš”

+
+ +
+ + {/* 메인 컨텐츠 */} + + + + + ν™”λ©΄ 관리 + + + + ν™”λ©΄ 섀계기 + + + + ν…œν”Œλ¦Ώ 관리 + + + + μ„€μ • + + + + {/* ν™”λ©΄ 관리 νƒ­ */} + + + + ν™”λ©΄ λͺ©λ‘ + + + + + + + + {/* ν™”λ©΄ 섀계기 νƒ­ */} + + + + ν™”λ©΄ 섀계기 + + + {selectedScreen ? ( + + ) : ( +
+ +

섀계할 화면을 μ„ νƒν•΄μ£Όμ„Έμš”

+

ν™”λ©΄ 관리 νƒ­μ—μ„œ 화면을 μ„ νƒν•œ ν›„ 섀계기λ₯Ό μ‚¬μš©ν•˜μ„Έμš”

+
+ )} +
+
+
+ + {/* ν…œν”Œλ¦Ώ 관리 νƒ­ */} + + + + ν…œν”Œλ¦Ώ 관리 + + + + + + + + {/* μ„€μ • νƒ­ */} + + + + 화면관리 μ‹œμŠ€ν…œ μ„€μ • + + +
+
+

ν…Œμ΄λΈ” νƒ€μž… 연계

+

ν…Œμ΄λΈ” νƒ€μž…κ΄€λ¦¬ μ‹œμŠ€ν…œκ³Όμ˜ 연계 섀정을 κ΄€λ¦¬ν•©λ‹ˆλ‹€.

+
+
+

κΆŒν•œ 관리

+

νšŒμ‚¬λ³„ ν™”λ©΄ μ ‘κ·Ό κΆŒν•œμ„ μ„€μ •ν•©λ‹ˆλ‹€.

+
+
+

κΈ°λ³Έ μ„€μ •

+

κ·Έλ¦¬λ“œ μ‹œμŠ€ν…œ, κΈ°λ³Έ μ»΄ν¬λ„ŒνŠΈ λ“±μ˜ κΈ°λ³Έ 섀정을 κ΄€λ¦¬ν•©λ‹ˆλ‹€.

+
+
+
+
+
+
+
+ ); +} diff --git a/frontend/app/(main)/admin/tableMng/page.tsx b/frontend/app/(main)/admin/tableMng/page.tsx index 36ddaba5..8e8463ff 100644 --- a/frontend/app/(main)/admin/tableMng/page.tsx +++ b/frontend/app/(main)/admin/tableMng/page.tsx @@ -93,6 +93,15 @@ export default function TableManagementPage() { description: getTextFromUI(option.descriptionKey, option.value), })); + // μ›Ήνƒ€μž… μ˜΅μ…˜ 확인 (λ””λ²„κΉ…μš©) + useEffect(() => { + console.log("ν…Œμ΄λΈ” νƒ€μž…κ΄€λ¦¬ - μ›Ήνƒ€μž… μ˜΅μ…˜ λ‘œλ“œλ¨:", webTypeOptions); + console.log("ν…Œμ΄λΈ” νƒ€μž…κ΄€λ¦¬ - μ›Ήνƒ€μž… μ˜΅μ…˜ 개수:", webTypeOptions.length); + webTypeOptions.forEach((option, index) => { + console.log(`${index + 1}. ${option.value}: ${option.label}`); + }); + }, [webTypeOptions]); + // μ°Έμ‘° ν…Œμ΄λΈ” μ˜΅μ…˜ (μ‹€μ œ ν…Œμ΄λΈ” λͺ©λ‘μ—μ„œ κ°€μ Έμ˜΄) const referenceTableOptions = [ { value: "none", label: getTextFromUI(TABLE_MANAGEMENT_KEYS.LABEL_NONE, "선택 μ•ˆν•¨") }, @@ -258,16 +267,18 @@ export default function TableManagementPage() { try { const columnSetting = { - columnName: column.columnName, - columnLabel: column.displayName, - webType: column.webType, - detailSettings: column.detailSettings, - codeCategory: column.codeCategory, - codeValue: column.codeValue, - referenceTable: column.referenceTable, - referenceColumn: column.referenceColumn, + columnName: column.columnName, // μ‹€μ œ DB 컬럼λͺ… (λ³€κ²½ λΆˆκ°€) + columnLabel: column.displayName, // μ‚¬μš©μžκ°€ μž…λ ₯ν•œ ν‘œμ‹œλͺ… + webType: column.webType || "text", + detailSettings: column.detailSettings || "", + codeCategory: column.codeCategory || "", + codeValue: column.codeValue || "", + referenceTable: column.referenceTable || "", + referenceColumn: column.referenceColumn || "", }; + console.log("μ €μž₯ν•  컬럼 μ„€μ •:", columnSetting); + const response = await apiClient.post(`/table-management/tables/${selectedTable}/columns/settings`, [ columnSetting, ]); @@ -276,6 +287,11 @@ export default function TableManagementPage() { toast.success("컬럼 섀정이 μ„±κ³΅μ μœΌλ‘œ μ €μž₯λ˜μ—ˆμŠ΅λ‹ˆλ‹€."); // 원본 데이터 μ—…λ°μ΄νŠΈ setOriginalColumns((prev) => prev.map((col) => (col.columnName === column.columnName ? column : col))); + + // μ €μž₯ ν›„ 데이터 확인을 μœ„ν•΄ λ‹€μ‹œ λ‘œλ“œ + setTimeout(() => { + loadColumnTypes(selectedTable); + }, 1000); } else { toast.error(response.data.message || "컬럼 μ„€μ • μ €μž₯에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€."); } @@ -292,16 +308,18 @@ export default function TableManagementPage() { try { // λͺ¨λ“  컬럼의 μ„€μ • 데이터 μ€€λΉ„ const columnSettings = columns.map((column) => ({ - columnName: column.columnName, - columnLabel: column.displayName, // 라벨 μΆ”κ°€ - webType: column.webType, - detailSettings: column.detailSettings, - codeCategory: column.codeCategory, - codeValue: column.codeValue, - referenceTable: column.referenceTable, - referenceColumn: column.referenceColumn, + columnName: column.columnName, // μ‹€μ œ DB 컬럼λͺ… (λ³€κ²½ λΆˆκ°€) + columnLabel: column.displayName, // μ‚¬μš©μžκ°€ μž…λ ₯ν•œ ν‘œμ‹œλͺ… + webType: column.webType || "text", + detailSettings: column.detailSettings || "", + codeCategory: column.codeCategory || "", + codeValue: column.codeValue || "", + referenceTable: column.referenceTable || "", + referenceColumn: column.referenceColumn || "", })); + console.log("μ €μž₯ν•  전체 컬럼 μ„€μ •:", columnSettings); + // 전체 ν…Œμ΄λΈ” 섀정을 ν•œ λ²ˆμ— μ €μž₯ const response = await apiClient.post( `/table-management/tables/${selectedTable}/columns/settings`, @@ -312,6 +330,11 @@ export default function TableManagementPage() { // μ €μž₯ 성곡 ν›„ 원본 데이터 μ—…λ°μ΄νŠΈ setOriginalColumns([...columns]); toast.success(`${columns.length}개의 컬럼 섀정이 μ„±κ³΅μ μœΌλ‘œ μ €μž₯λ˜μ—ˆμŠ΅λ‹ˆλ‹€.`); + + // μ €μž₯ ν›„ 데이터 확인을 μœ„ν•΄ λ‹€μ‹œ λ‘œλ“œ + setTimeout(() => { + loadColumnTypes(selectedTable); + }, 1000); } else { toast.error(response.data.message || "컬럼 μ„€μ • μ €μž₯에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€."); } @@ -503,24 +526,30 @@ export default function TableManagementPage() { {column.dbType} - +
+ + {/* μ›Ήνƒ€μž… μ˜΅μ…˜ 개수 ν‘œμ‹œ */} +
+ μ‚¬μš© κ°€λŠ₯ν•œ μ›Ήνƒ€μž…: {webTypeOptions.length}개 +
+
{ if (name.includes("μ„€μ •") || name.includes("setting")) return ; if (name.includes("둜그") || name.includes("log")) return ; if (name.includes("메뉴") || name.includes("menu")) return ; + if (name.includes("화면관리") || name.includes("screen")) return ; return ; }; diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx new file mode 100644 index 00000000..9197096a --- /dev/null +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -0,0 +1,840 @@ +"use client"; + +import { useState, useCallback, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Palette, + Grid3X3, + Type, + Calendar, + Hash, + CheckSquare, + Radio, + FileText, + Save, + Undo, + Redo, + Eye, + Group, + Ungroup, + Database, + Trash2, +} from "lucide-react"; +import { + ScreenDefinition, + ComponentData, + LayoutData, + DragState, + GroupState, + ComponentType, + WebType, + WidgetComponent, + ColumnInfo, +} from "@/types/screen"; +import { generateComponentId } from "@/lib/utils/generateId"; +import ContainerComponent from "./layout/ContainerComponent"; +import RowComponent from "./layout/RowComponent"; +import ColumnComponent from "./layout/ColumnComponent"; +import WidgetFactory from "./WidgetFactory"; +import TableTypeSelector from "./TableTypeSelector"; +import ScreenPreview from "./ScreenPreview"; +import TemplateManager from "./TemplateManager"; +import StyleEditor from "./StyleEditor"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; + +interface ScreenDesignerProps { + screen: ScreenDefinition; +} + +interface ComponentMoveState { + isMoving: boolean; + movingComponent: ComponentData | null; + originalPosition: { x: number; y: number }; + currentPosition: { x: number; y: number }; +} + +export default function ScreenDesigner({ screen }: ScreenDesignerProps) { + const [layout, setLayout] = useState({ + components: [], + gridSettings: { columns: 12, gap: 16, padding: 16 }, + }); + const [selectedComponent, setSelectedComponent] = useState(null); + const [dragState, setDragState] = useState({ + isDragging: false, + draggedItem: null, + draggedComponent: null, + dragSource: "toolbox", + dropTarget: null, + dragOffset: { x: 0, y: 0 }, + }); + const [groupState, setGroupState] = useState({ + isGrouping: false, + selectedComponents: [], + groupTarget: null, + groupMode: "create", + }); + const [moveState, setMoveState] = useState({ + isMoving: false, + movingComponent: null, + originalPosition: { x: 0, y: 0 }, + currentPosition: { x: 0, y: 0 }, + }); + + // κΈ°λ³Έ μ»΄ν¬λ„ŒνŠΈ μ •μ˜ + const basicComponents = [ + { type: "text", label: "ν…μŠ€νŠΈ μž…λ ₯", color: "bg-blue-500", icon: "Type" }, + { type: "number", label: "숫자 μž…λ ₯", color: "bg-green-500", icon: "Hash" }, + { type: "date", label: "λ‚ μ§œ 선택", color: "bg-purple-500", icon: "Calendar" }, + { type: "select", label: "선택 λ°•μŠ€", color: "bg-orange-500", icon: "CheckSquare" }, + { type: "textarea", label: "ν…μŠ€νŠΈ μ˜μ—­", color: "bg-indigo-500", icon: "FileText" }, + { type: "checkbox", label: "μ²΄ν¬λ°•μŠ€", color: "bg-pink-500", icon: "CheckSquare" }, + { type: "radio", label: "λΌλ””μ˜€ λ²„νŠΌ", color: "bg-yellow-500", icon: "Radio" }, + { type: "file", label: "파일 μ—…λ‘œλ“œ", color: "bg-red-500", icon: "FileText" }, + { type: "code", label: "μ½”λ“œ μž…λ ₯", color: "bg-gray-500", icon: "Hash" }, + { type: "entity", label: "μ—”ν‹°ν‹° 선택", color: "bg-teal-500", icon: "Database" }, + ]; + + const layoutComponents = [ + { type: "container", label: "μ»¨ν…Œμ΄λ„ˆ", color: "bg-gray-500" }, + { type: "row", label: "ν–‰", color: "bg-yellow-500" }, + { type: "column", label: "μ—΄", color: "bg-red-500" }, + { type: "group", label: "κ·Έλ£Ή", color: "bg-teal-500" }, + ]; + + // λ“œλž˜κ·Έ μ‹œμž‘ + const startDrag = useCallback((componentType: ComponentType, source: "toolbox" | "canvas") => { + let componentData: ComponentData; + + if (componentType === "widget") { + // μœ„μ ― μ»΄ν¬λ„ŒνŠΈ 생성 + componentData = { + id: generateComponentId(componentType), + type: "widget", + position: { x: 0, y: 0 }, + size: { width: 6, height: 50 }, + label: "μƒˆ μœ„μ ―", + tableName: "", + columnName: "", + widgetType: "text", + required: false, + readonly: false, + }; + } else if (componentType === "container") { + // μ»¨ν…Œμ΄λ„ˆ μ»΄ν¬λ„ŒνŠΈ 생성 + componentData = { + id: generateComponentId(componentType), + type: "container", + position: { x: 0, y: 0 }, + size: { width: 12, height: 100 }, + title: "μƒˆ μ»¨ν…Œμ΄λ„ˆ", + children: [], + }; + } else if (componentType === "row") { + // ν–‰ μ»΄ν¬λ„ŒνŠΈ 생성 + componentData = { + id: generateComponentId(componentType), + type: "row", + position: { x: 0, y: 0 }, + size: { width: 12, height: 100 }, + children: [], + }; + } else if (componentType === "column") { + // μ—΄ μ»΄ν¬λ„ŒνŠΈ 생성 + componentData = { + id: generateComponentId(componentType), + type: "column", + position: { x: 0, y: 0 }, + size: { width: 6, height: 100 }, + children: [], + }; + } else if (componentType === "group") { + // κ·Έλ£Ή μ»΄ν¬λ„ŒνŠΈ 생성 + componentData = { + id: generateComponentId(componentType), + type: "group", + position: { x: 0, y: 0 }, + size: { width: 12, height: 200 }, + title: "μƒˆ κ·Έλ£Ή", + children: [], + }; + } else { + throw new Error(`μ§€μ›ν•˜μ§€ μ•ŠλŠ” μ»΄ν¬λ„ŒνŠΈ νƒ€μž…: ${componentType}`); + } + + setDragState((prev) => ({ + ...prev, + isDragging: true, + draggedComponent: componentData, + dragOffset: { x: 0, y: 0 }, + })); + }, []); + + // λ“œλž˜κ·Έ μ’…λ£Œ + const endDrag = useCallback(() => { + setDragState({ + isDragging: false, + draggedComponent: null, + dragOffset: { x: 0, y: 0 }, + }); + }, []); + + // μ»΄ν¬λ„ŒνŠΈ μΆ”κ°€ + const addComponent = useCallback((component: ComponentData, position: { x: number; y: number }) => { + const newComponent = { + ...component, + id: generateComponentId(component.type), + position, + }; + + setLayout((prev) => ({ + ...prev, + components: [...prev.components, newComponent], + })); + }, []); + + // μ»΄ν¬λ„ŒνŠΈ 선택 + const selectComponent = useCallback((component: ComponentData) => { + setSelectedComponent(component); + }, []); + + // μ»΄ν¬λ„ŒνŠΈ μ‚­μ œ + const removeComponent = useCallback((componentId: string) => { + setLayout((prev) => ({ + ...prev, + components: prev.components.filter((c) => c.id !== componentId), + })); + setSelectedComponent(null); + }, []); + + // μ»΄ν¬λ„ŒνŠΈ 속성 μ—…λ°μ΄νŠΈ + const updateComponentProperty = useCallback((componentId: string, path: string, value: any) => { + setLayout((prev) => ({ + ...prev, + components: prev.components.map((c) => { + if (c.id === componentId) { + const newComponent = { ...c } as any; + const keys = path.split("."); + let current = newComponent; + for (let i = 0; i < keys.length - 1; i++) { + current = current[keys[i]]; + } + current[keys[keys.length - 1]] = value; + return newComponent; + } + return c; + }), + })); + }, []); + + // λ ˆμ΄μ•„μ›ƒ μ €μž₯ + const saveLayout = useCallback(() => { + console.log("λ ˆμ΄μ•„μ›ƒ μ €μž₯:", layout); + // TODO: API 호좜둜 λ ˆμ΄μ•„μ›ƒ μ €μž₯ + }, [layout]); + + // μ»΄ν¬λ„ŒνŠΈ 재배치 μ‹œμž‘ + const startComponentMove = useCallback((component: ComponentData, e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + setMoveState({ + isMoving: true, + movingComponent: component, + originalPosition: { ...component.position }, + currentPosition: { ...component.position }, + }); + + setDragState((prev) => ({ + ...prev, + isDragging: true, + draggedComponent: component, + dragOffset: { x: 0, y: 0 }, + })); + }, []); + + // μ»΄ν¬λ„ŒνŠΈ 재배치 쀑 + const handleComponentMove = useCallback( + (e: MouseEvent) => { + if (!moveState.isMoving || !moveState.movingComponent) return; + + const canvas = document.getElementById("design-canvas"); + if (!canvas) return; + + const rect = canvas.getBoundingClientRect(); + const x = Math.floor((e.clientX - rect.left) / 50); + const y = Math.floor((e.clientY - rect.top) / 50); + + setMoveState((prev) => ({ + ...prev, + currentPosition: { x, y }, + })); + }, + [moveState.isMoving, moveState.movingComponent], + ); + + // μ»΄ν¬λ„ŒνŠΈ 재배치 μ™„λ£Œ + const endComponentMove = useCallback(() => { + if (!moveState.isMoving || !moveState.movingComponent) return; + + const { movingComponent, currentPosition } = moveState; + + // μœ„μΉ˜ μ—…λ°μ΄νŠΈ + setLayout((prev) => ({ + ...prev, + components: prev.components.map((c) => (c.id === movingComponent.id ? { ...c, position: currentPosition } : c)), + })); + + // μƒνƒœ μ΄ˆκΈ°ν™” + setMoveState({ + isMoving: false, + movingComponent: null, + originalPosition: { x: 0, y: 0 }, + currentPosition: { x: 0, y: 0 }, + }); + + setDragState((prev) => ({ + ...prev, + isDragging: false, + draggedComponent: null, + dragOffset: { x: 0, y: 0 }, + })); + }, [moveState]); + + // 마우슀 이벀트 λ¦¬μŠ€λ„ˆ 등둝/ν•΄μ œ + useEffect(() => { + if (moveState.isMoving) { + document.addEventListener("mousemove", handleComponentMove); + document.addEventListener("mouseup", endComponentMove); + + return () => { + document.removeEventListener("mousemove", handleComponentMove); + document.removeEventListener("mouseup", endComponentMove); + }; + } + }, [moveState.isMoving, handleComponentMove, endComponentMove]); + + // μ»΄ν¬λ„ŒνŠΈ λ Œλ”λ§ + const renderComponent = useCallback( + (component: ComponentData) => { + const isSelected = selectedComponent === component; + const isMoving = moveState.isMoving && moveState.movingComponent?.id === component.id; + const currentPosition = isMoving ? moveState.currentPosition : component.position; + + switch (component.type) { + case "container": + return ( + selectComponent(component)} + onMouseDown={(e) => startComponentMove(component, e)} + isMoving={isMoving} + > + {/* μ»¨ν…Œμ΄λ„ˆ λ‚΄λΆ€μ˜ μžμ‹ μ»΄ν¬λ„ŒνŠΈλ“€ */} + {layout.components.filter((c) => c.parentId === component.id).map(renderComponent)} + + ); + case "row": + return ( + selectComponent(component)} + onMouseDown={(e) => startComponentMove(component, e)} + isMoving={isMoving} + > + {/* ν–‰ λ‚΄λΆ€μ˜ μžμ‹ μ»΄ν¬λ„ŒνŠΈλ“€ */} + {layout.components.filter((c) => c.parentId === component.id).map(renderComponent)} + + ); + case "column": + return ( + selectComponent(component)} + onMouseDown={(e) => startComponentMove(component, e)} + isMoving={isMoving} + > + {/* μ—΄ λ‚΄λΆ€μ˜ μžμ‹ μ»΄ν¬λ„ŒνŠΈλ“€ */} + {layout.components.filter((c) => c.parentId === component.id).map(renderComponent)} + + ); + case "widget": + return ( +
selectComponent(component)} + onMouseDown={(e) => startComponentMove(component, e)} + > + +
+ ); + default: + return null; + } + }, + [selectedComponent, moveState, selectComponent, startComponentMove, layout.components], + ); + + // ν…Œμ΄λΈ” νƒ€μž…μ—μ„œ 컬럼 선택 μ‹œ μœ„μ ― 생성 + const handleColumnSelect = useCallback( + (column: ColumnInfo) => { + const widgetComponent: WidgetComponent = { + id: generateComponentId("widget"), + type: "widget", + position: { x: 0, y: 0 }, + size: { width: 6, height: 50 }, + parentId: undefined, + tableName: column.tableName, + columnName: column.columnName, + widgetType: column.webType || "text", + label: column.columnLabel || column.columnName, + placeholder: `${column.columnLabel || column.columnName}을(λ₯Ό) μž…λ ₯ν•˜μ„Έμš”`, + required: column.isNullable === "NO", + readonly: false, + validationRules: [], + displayProperties: {}, + style: { + // μ›Ή νƒ€μž…λ³„ κΈ°λ³Έ μŠ€νƒ€μΌ + ...(column.webType === "date" && { + backgroundColor: "#fef3c7", + border: "1px solid #f59e0b", + }), + ...(column.webType === "number" && { + backgroundColor: "#dbeafe", + border: "1px solid #3b82f6", + }), + ...(column.webType === "select" && { + backgroundColor: "#f3e8ff", + border: "1px solid #8b5cf6", + }), + ...(column.webType === "checkbox" && { + backgroundColor: "#dcfce7", + border: "1px solid #22c55e", + }), + ...(column.webType === "radio" && { + backgroundColor: "#fef3c7", + border: "1px solid #f59e0b", + }), + ...(column.webType === "textarea" && { + backgroundColor: "#f1f5f9", + border: "1px solid #64748b", + }), + ...(column.webType === "file" && { + backgroundColor: "#fef2f2", + border: "1px solid #ef4444", + }), + ...(column.webType === "code" && { + backgroundColor: "#fef2f2", + border: "1px solid #ef4444", + fontFamily: "monospace", + }), + ...(column.webType === "entity" && { + backgroundColor: "#f0f9ff", + border: "1px solid #0ea5e9", + }), + }, + }; + + // ν˜„μž¬ μΊ”λ²„μŠ€μ˜ 빈 μœ„μΉ˜ μ°ΎκΈ° + const occupiedPositions = new Set(); + layout.components.forEach((comp) => { + for (let x = comp.position.x; x < comp.position.x + comp.size.width; x++) { + for (let y = comp.position.y; y < comp.position.y + comp.size.height; y++) { + occupiedPositions.add(`${x},${y}`); + } + } + }); + + // 빈 μœ„μΉ˜ μ°ΎκΈ° + let newX = 0, + newY = 0; + for (let y = 0; y < 20; y++) { + for (let x = 0; x < 12; x++) { + let canPlace = true; + for (let dx = 0; dx < widgetComponent.size.width; dx++) { + for (let dy = 0; dy < Math.ceil(widgetComponent.size.height / 50); dy++) { + if (occupiedPositions.has(`${x + dx},${y + dy}`)) { + canPlace = false; + break; + } + } + if (!canPlace) break; + } + if (canPlace) { + newX = x; + newY = y; + break; + } + } + if (newX !== 0 || newY !== 0) break; + } + + widgetComponent.position = { x: newX, y: newY }; + addComponent(widgetComponent, { x: newX, y: newY }); + }, + [layout.components, addComponent], + ); + + return ( +
+ {/* μ™Όμͺ½ νˆ΄λ°” */} +
+ {/* κΈ°λ³Έ μ»΄ν¬λ„ŒνŠΈ */} + + + κΈ°λ³Έ μ»΄ν¬λ„ŒνŠΈ + + + {basicComponents.map((component) => ( +
startDrag(component.type as ComponentType, "toolbox")} + onDragEnd={endDrag} + > +
+ {component.label} +
+ ))} + + + + {/* λ ˆμ΄μ•„μ›ƒ μ»΄ν¬λ„ŒνŠΈ */} + + + λ ˆμ΄μ•„μ›ƒ μ»΄ν¬λ„ŒνŠΈ + + + {layoutComponents.map((component) => ( +
startDrag(component.type as ComponentType, "toolbox")} + onDragEnd={endDrag} + > +
+ {component.label} +
+ ))} + + +
+ + {/* 쀑앙 메인 μ˜μ—­ */} +
+ + + + + ν™”λ©΄ 섀계 + + + + ν…Œμ΄λΈ” νƒ€μž… + + + + 미리보기 + + + + {/* ν™”λ©΄ 섀계 νƒ­ */} + + + +
+ {screen.screenName} - μΊ”λ²„μŠ€ +
+ + + +
+
+
+ +
{ + e.preventDefault(); + if (dragState.draggedComponent && dragState.draggedComponent.type === "widget") { + const rect = e.currentTarget.getBoundingClientRect(); + const x = Math.floor((e.clientX - rect.left) / 50); + const y = Math.floor((e.clientY - rect.top) / 50); + + // μœ„μ ― μ»΄ν¬λ„ŒνŠΈμ˜ 경우 κΈ°λ³Έ μ»΄ν¬λ„ŒνŠΈμ—μ„œ νƒ€μž… 정보λ₯Ό κ°€μ Έμ˜΄ + const basicComponent = basicComponents.find( + (c) => c.type === (dragState.draggedComponent as any).widgetType, + ); + if (basicComponent) { + const widgetComponent: ComponentData = { + ...dragState.draggedComponent, + position: { x, y }, + label: basicComponent.label, + widgetType: basicComponent.type as WebType, + } as WidgetComponent; + addComponent(widgetComponent, { x, y }); + } + } else if (dragState.draggedComponent) { + const rect = e.currentTarget.getBoundingClientRect(); + const x = Math.floor((e.clientX - rect.left) / 50); + const y = Math.floor((e.clientY - rect.top) / 50); + addComponent(dragState.draggedComponent, { x, y }); + } + }} + onDragOver={(e) => e.preventDefault()} + > + {/* κ·Έλ¦¬λ“œ κ°€μ΄λ“œ */} +
+
+ {Array.from({ length: 12 }).map((_, i) => ( +
+ ))} +
+
+ + {/* μ»΄ν¬λ„ŒνŠΈλ“€ λ Œλ”λ§ */} + {layout.components.length > 0 ? ( + layout.components + .filter((c) => !c.parentId) // μ΅œμƒμœ„ μ»΄ν¬λ„ŒνŠΈλ§Œ λ Œλ”λ§ + .map(renderComponent) + ) : ( +
+
+ +

μ™Όμͺ½ νˆ΄λ°”μ—μ„œ μ»΄ν¬λ„ŒνŠΈλ₯Ό λ“œλž˜κ·Έν•˜μ—¬ λ°°μΉ˜ν•˜μ„Έμš”

+
+
+ )} +
+ + + + + {/* ν…Œμ΄λΈ” νƒ€μž… νƒ­ */} + + console.log("ν…Œμ΄λΈ” 선택:", tableName)} + onColumnSelect={handleColumnSelect} + className="h-full" + /> + + + {/* 미리보기 νƒ­ */} + + + + +
+ + {/* 였λ₯Έμͺ½ 속성 νŒ¨λ„ */} +
+ + + 속성 + + + {selectedComponent ? ( + + + 일반 + + + μŠ€νƒ€μΌ + + κ³ κΈ‰ + + + {/* 일반 속성 νƒ­ */} + +
+ + +
+
+ + +
+
+
+ + + updateComponentProperty(selectedComponent.id, "position.x", parseInt(e.target.value)) + } + /> +
+
+ + + updateComponentProperty(selectedComponent.id, "position.y", parseInt(e.target.value)) + } + /> +
+
+
+
+ + + updateComponentProperty(selectedComponent.id, "size.width", parseInt(e.target.value)) + } + /> +
+
+ + + updateComponentProperty(selectedComponent.id, "size.height", parseInt(e.target.value)) + } + /> +
+
+ + {/* μœ„μ ― μ „μš© 속성 */} + {selectedComponent.type === "widget" && ( + <> + +
+ + updateComponentProperty(selectedComponent.id, "label", e.target.value)} + /> +
+
+ + updateComponentProperty(selectedComponent.id, "placeholder", e.target.value)} + /> +
+
+
+ + updateComponentProperty(selectedComponent.id, "required", e.target.checked) + } + /> + +
+
+ + updateComponentProperty(selectedComponent.id, "readonly", e.target.checked) + } + /> + +
+
+ + )} +
+ + {/* μŠ€νƒ€μΌ 속성 νƒ­ */} + + updateComponentProperty(selectedComponent.id, "style", newStyle)} + /> + + + {/* κ³ κΈ‰ 속성 νƒ­ */} + +
+ + +
+ +
+
+ ) : ( +
+ +

μ»΄ν¬λ„ŒνŠΈλ₯Ό μ„ νƒν•˜μ—¬ 속성을 νŽΈμ§‘ν•˜μ„Έμš”

+
+ )} +
+
+
+
+ ); +} diff --git a/frontend/components/screen/ScreenList.tsx b/frontend/components/screen/ScreenList.tsx new file mode 100644 index 00000000..02aedb7c --- /dev/null +++ b/frontend/components/screen/ScreenList.tsx @@ -0,0 +1,255 @@ +"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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search } from "lucide-react"; +import { ScreenDefinition } from "@/types/screen"; + +interface ScreenListProps { + onScreenSelect: (screen: ScreenDefinition) => void; + selectedScreen: ScreenDefinition | null; +} + +export default function ScreenList({ onScreenSelect, selectedScreen }: ScreenListProps) { + const [screens, setScreens] = useState([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(""); + const [currentPage, setCurrentPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + + // μƒ˜ν”Œ 데이터 (μ‹€μ œλ‘œλŠ” APIμ—μ„œ κ°€μ Έμ˜΄) + useEffect(() => { + const mockScreens: ScreenDefinition[] = [ + { + screenId: 1, + screenName: "μ‚¬μš©μž 관리 ν™”λ©΄", + screenCode: "USER_MANAGEMENT", + tableName: "user_info", + companyCode: "COMP001", + description: "μ‚¬μš©μž 정보λ₯Ό κ΄€λ¦¬ν•˜λŠ” ν™”λ©΄", + isActive: "Y", + createdDate: new Date("2024-01-15"), + updatedDate: new Date("2024-01-15"), + createdBy: "admin", + updatedBy: "admin", + }, + { + screenId: 2, + screenName: "λΆ€μ„œ 관리 ν™”λ©΄", + screenCode: "DEPT_MANAGEMENT", + tableName: "dept_info", + companyCode: "COMP001", + description: "λΆ€μ„œ 정보λ₯Ό κ΄€λ¦¬ν•˜λŠ” ν™”λ©΄", + isActive: "Y", + createdDate: new Date("2024-01-16"), + updatedDate: new Date("2024-01-16"), + createdBy: "admin", + updatedBy: "admin", + }, + { + screenId: 3, + screenName: "μ œν’ˆ 관리 ν™”λ©΄", + screenCode: "PRODUCT_MANAGEMENT", + tableName: "product_info", + companyCode: "COMP001", + description: "μ œν’ˆ 정보λ₯Ό κ΄€λ¦¬ν•˜λŠ” ν™”λ©΄", + isActive: "Y", + createdDate: new Date("2024-01-17"), + updatedDate: new Date("2024-01-17"), + createdBy: "admin", + updatedBy: "admin", + }, + ]; + + setScreens(mockScreens); + setLoading(false); + }, []); + + const filteredScreens = screens.filter( + (screen) => + screen.screenName.toLowerCase().includes(searchTerm.toLowerCase()) || + screen.screenCode.toLowerCase().includes(searchTerm.toLowerCase()) || + screen.tableName.toLowerCase().includes(searchTerm.toLowerCase()), + ); + + const handleScreenSelect = (screen: ScreenDefinition) => { + onScreenSelect(screen); + }; + + const handleEdit = (screen: ScreenDefinition) => { + // νŽΈμ§‘ λͺ¨λ‹¬ μ—΄κΈ° + console.log("νŽΈμ§‘:", screen); + }; + + const handleDelete = (screen: ScreenDefinition) => { + if (confirm(`"${screen.screenName}" 화면을 μ‚­μ œν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?`)) { + // μ‚­μ œ API 호좜 + console.log("μ‚­μ œ:", screen); + } + }; + + const handleCopy = (screen: ScreenDefinition) => { + // 볡사 λͺ¨λ‹¬ μ—΄κΈ° + console.log("볡사:", screen); + }; + + const handleView = (screen: ScreenDefinition) => { + // 미리보기 λͺ¨λ‹¬ μ—΄κΈ° + console.log("미리보기:", screen); + }; + + if (loading) { + return ( +
+
λ‘œλ”© 쀑...
+
+ ); + } + + return ( +
+ {/* 검색 및 ν•„ν„° */} +
+
+
+ + setSearchTerm(e.target.value)} + className="w-80 pl-10" + /> +
+
+ +
+ + {/* ν™”λ©΄ λͺ©λ‘ ν…Œμ΄λΈ” */} + + + + ν™”λ©΄ λͺ©λ‘ ({filteredScreens.length}) + + + + + + + ν™”λ©΄λͺ… + ν™”λ©΄ μ½”λ“œ + ν…Œμ΄λΈ”λͺ… + μƒνƒœ + 생성일 + μž‘μ—… + + + + {filteredScreens.map((screen) => ( + handleScreenSelect(screen)} + > + +
+
{screen.screenName}
+ {screen.description &&
{screen.description}
} +
+
+ + + {screen.screenCode} + + + + {screen.tableName} + + + + {screen.isActive === "Y" ? "ν™œμ„±" : "λΉ„ν™œμ„±"} + + + +
{screen.createdDate.toLocaleDateString()}
+
{screen.createdBy}
+
+ + + + + + + handleView(screen)}> + + 미리보기 + + handleEdit(screen)}> + + νŽΈμ§‘ + + handleCopy(screen)}> + + 볡사 + + handleDelete(screen)} className="text-red-600"> + + μ‚­μ œ + + + + +
+ ))} +
+
+ + {filteredScreens.length === 0 &&
검색 κ²°κ³Όκ°€ μ—†μŠ΅λ‹ˆλ‹€.
} +
+
+ + {/* νŽ˜μ΄μ§€λ„€μ΄μ…˜ */} + {totalPages > 1 && ( +
+ + + {currentPage} / {totalPages} + + +
+ )} +
+ ); +} diff --git a/frontend/components/screen/ScreenPreview.tsx b/frontend/components/screen/ScreenPreview.tsx new file mode 100644 index 00000000..742441c2 --- /dev/null +++ b/frontend/components/screen/ScreenPreview.tsx @@ -0,0 +1,203 @@ +"use client"; + +import { useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Eye, Smartphone, Monitor, Tablet } from "lucide-react"; +import { LayoutData, ComponentData } from "@/types/screen"; +import ContainerComponent from "./layout/ContainerComponent"; +import RowComponent from "./layout/RowComponent"; +import ColumnComponent from "./layout/ColumnComponent"; +import WidgetFactory from "./WidgetFactory"; + +interface ScreenPreviewProps { + layout: LayoutData; + screenName: string; + className?: string; +} + +type PreviewMode = "desktop" | "tablet" | "mobile"; + +export default function ScreenPreview({ layout, screenName, className }: ScreenPreviewProps) { + const [previewMode, setPreviewMode] = useState("desktop"); + const [formData, setFormData] = useState>({}); + + // 미리보기 λͺ¨λ“œλ³„ μŠ€νƒ€μΌ + const getPreviewStyles = (mode: PreviewMode) => { + switch (mode) { + case "desktop": + return "w-full max-w-6xl mx-auto"; + case "tablet": + return "w-full max-w-2xl mx-auto border-x-8 border-gray-200"; + case "mobile": + return "w-full max-w-sm mx-auto border-x-4 border-gray-200"; + default: + return "w-full"; + } + }; + + // 폼 데이터 λ³€κ²½ 처리 + const handleFormChange = (fieldId: string, value: any) => { + setFormData((prev) => ({ + ...prev, + [fieldId]: value, + })); + }; + + // μ»΄ν¬λ„ŒνŠΈ λ Œλ”λ§ (미리보기용) + const renderPreviewComponent = (component: ComponentData) => { + const isSelected = false; // λ―Έλ¦¬λ³΄κΈ°μ—μ„œλŠ” 선택 λΆˆκ°€ + + switch (component.type) { + case "container": + return ( + {}}> + {layout.components.filter((c) => c.parentId === component.id).map(renderPreviewComponent)} + + ); + + case "row": + return ( + {}}> + {layout.components.filter((c) => c.parentId === component.id).map(renderPreviewComponent)} + + ); + + case "column": + return ( + {}}> + {layout.components.filter((c) => c.parentId === component.id).map(renderPreviewComponent)} + + ); + + case "widget": + return ( +
+ handleFormChange(component.id, value)} + /> +
+ ); + + default: + return null; + } + }; + + // κ·Έλ¦¬λ“œ λ ˆμ΄μ•„μ›ƒμœΌλ‘œ μ»΄ν¬λ„ŒνŠΈ 배치 + const renderGridLayout = () => { + const { gridSettings } = layout; + const { columns, gap, padding } = gridSettings; + + return ( +
+
+ {layout.components + .filter((c) => !c.parentId) // μ΅œμƒμœ„ μ»΄ν¬λ„ŒνŠΈλ§Œ λ Œλ”λ§ + .map(renderPreviewComponent)} +
+
+ ); + }; + + // 폼 데이터 μ΄ˆκΈ°ν™” + const resetFormData = () => { + setFormData({}); + }; + + // 폼 데이터 좜λ ₯ + const logFormData = () => { + console.log("폼 데이터:", formData); + }; + + return ( +
+ {/* 미리보기 헀더 */} + + +
+ + + {screenName} - 미리보기 + +
+ {/* 미리보기 λͺ¨λ“œ 선택 */} +
+ + + +
+ + + +
+
+
+
+ + {/* 미리보기 컨텐츠 */} + + +
+ {layout.components.length > 0 ? ( + renderGridLayout() + ) : ( +
+
+ +

미리보기할 μ»΄ν¬λ„ŒνŠΈκ°€ μ—†μŠ΅λ‹ˆλ‹€

+

ν™”λ©΄ μ„€κ³„κΈ°μ—μ„œ μ»΄ν¬λ„ŒνŠΈλ₯Ό μΆ”κ°€ν•΄μ£Όμ„Έμš”

+
+
+ )} +
+
+
+
+ ); +} diff --git a/frontend/components/screen/StyleEditor.tsx b/frontend/components/screen/StyleEditor.tsx new file mode 100644 index 00000000..991a7932 --- /dev/null +++ b/frontend/components/screen/StyleEditor.tsx @@ -0,0 +1,385 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Separator } from "@/components/ui/separator"; +import { Palette, Layout, Type, Square, Box, Eye, RotateCcw } from "lucide-react"; +import { ComponentStyle } from "@/types/screen"; + +interface StyleEditorProps { + style: ComponentStyle; + onStyleChange: (style: ComponentStyle) => void; + className?: string; +} + +export default function StyleEditor({ style, onStyleChange, className }: StyleEditorProps) { + const [localStyle, setLocalStyle] = useState(style); + + useEffect(() => { + setLocalStyle(style); + }, [style]); + + const handleStyleChange = (property: keyof ComponentStyle, value: any) => { + const newStyle = { ...localStyle, [property]: value }; + setLocalStyle(newStyle); + onStyleChange(newStyle); + }; + + const resetStyle = () => { + const resetStyle: ComponentStyle = {}; + setLocalStyle(resetStyle); + onStyleChange(resetStyle); + }; + + const applyStyle = () => { + onStyleChange(localStyle); + }; + + return ( +
+ + +
+ + + μŠ€νƒ€μΌ νŽΈμ§‘ + +
+ + +
+
+
+ + + + + + λ ˆμ΄μ•„μ›ƒ + + + + μ—¬λ°± + + + + ν…Œλ‘λ¦¬ + + + + λ°°κ²½ + + + + ν…μŠ€νŠΈ + + + + {/* λ ˆμ΄μ•„μ›ƒ νƒ­ */} + +
+
+ + handleStyleChange("width", e.target.value)} + /> +
+
+ + handleStyleChange("height", e.target.value)} + /> +
+
+ +
+
+ + +
+
+ + +
+
+ + {localStyle.display === "flex" && ( + <> + +
+
+ + +
+
+ + +
+
+ + )} +
+ + {/* μ—¬λ°± νƒ­ */} + +
+
+ + handleStyleChange("margin", e.target.value)} + /> +
+
+ + handleStyleChange("padding", e.target.value)} + /> +
+
+ +
+
+ + handleStyleChange("gap", e.target.value)} + /> +
+
+
+ + {/* ν…Œλ‘λ¦¬ νƒ­ */} + +
+
+ + handleStyleChange("borderWidth", e.target.value)} + /> +
+
+ + +
+
+ +
+
+ + handleStyleChange("borderColor", e.target.value)} + /> +
+
+ + handleStyleChange("borderRadius", e.target.value)} + /> +
+
+
+ + {/* λ°°κ²½ νƒ­ */} + +
+ + handleStyleChange("backgroundColor", e.target.value)} + /> +
+ +
+ + handleStyleChange("backgroundImage", e.target.value)} + /> +
+
+ + {/* ν…μŠ€νŠΈ νƒ­ */} + +
+
+ + handleStyleChange("color", e.target.value)} + /> +
+
+ + handleStyleChange("fontSize", e.target.value)} + /> +
+
+ +
+
+ + +
+
+ + +
+
+
+
+
+
+
+ ); +} diff --git a/frontend/components/screen/TableTypeSelector.tsx b/frontend/components/screen/TableTypeSelector.tsx new file mode 100644 index 00000000..c16a45de --- /dev/null +++ b/frontend/components/screen/TableTypeSelector.tsx @@ -0,0 +1,285 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Search, Database, Columns, Plus } from "lucide-react"; +import { ColumnInfo, WebType } from "@/types/screen"; +import { tableTypeApi } from "@/lib/api/screen"; +import { useAuth } from "@/hooks/useAuth"; + +interface TableTypeSelectorProps { + onTableSelect?: (tableName: string) => void; + onColumnSelect?: (column: ColumnInfo) => void; + className?: string; +} + +export default function TableTypeSelector({ onTableSelect, onColumnSelect, className }: TableTypeSelectorProps) { + const { user } = useAuth(); + const [tables, setTables] = useState< + Array<{ tableName: string; displayName: string; description: string; columnCount: string }> + >([]); + const [selectedTable, setSelectedTable] = useState(""); + const [columns, setColumns] = useState([]); + const [searchTerm, setSearchTerm] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + // ν…Œμ΄λΈ” λͺ©λ‘ 쑰회 + useEffect(() => { + const fetchTables = async () => { + try { + setIsLoading(true); + const tableList = await tableTypeApi.getTables(); + setTables(tableList); + } catch (error) { + console.error("ν…Œμ΄λΈ” λͺ©λ‘ 쑰회 μ‹€νŒ¨:", error); + // API 호좜 μ‹€νŒ¨ μ‹œ κΈ°λ³Έ ν…Œμ΄λΈ” λͺ©λ‘ μ‚¬μš© + const fallbackTables = [ + { tableName: "user_info", displayName: "μ‚¬μš©μž 정보", description: "μ‚¬μš©μž κΈ°λ³Έ 정보", columnCount: "25" }, + { tableName: "product_info", displayName: "μ œν’ˆ 정보", description: "μ œν’ˆ κΈ°λ³Έ 정보", columnCount: "20" }, + { tableName: "order_info", displayName: "μ£Όλ¬Έ 정보", description: "μ£Όλ¬Έ κΈ°λ³Έ 정보", columnCount: "15" }, + { tableName: "company_info", displayName: "νšŒμ‚¬ 정보", description: "νšŒμ‚¬ κΈ°λ³Έ 정보", columnCount: "10" }, + { tableName: "menu_info", displayName: "메뉴 정보", description: "μ‹œμŠ€ν…œ 메뉴 정보", columnCount: "15" }, + { tableName: "auth_group", displayName: "κΆŒν•œ κ·Έλ£Ή", description: "μ‚¬μš©μž κΆŒν•œ κ·Έλ£Ή", columnCount: "8" }, + ]; + setTables(fallbackTables); + } finally { + setIsLoading(false); + } + }; + + fetchTables(); + }, []); + + // 컬럼 정보 쑰회 + useEffect(() => { + if (!selectedTable) { + setColumns([]); + return; + } + + const fetchColumns = async () => { + try { + setIsLoading(true); + const columnList = await tableTypeApi.getColumns(selectedTable); + + // API 응닡을 ColumnInfo ν˜•μ‹μœΌλ‘œ λ³€ν™˜ + const formattedColumns: ColumnInfo[] = columnList.map((col: any) => ({ + tableName: selectedTable, + columnName: col.column_name || col.columnName, + columnLabel: col.column_label || col.columnLabel || col.column_name || col.columnName, + dataType: col.data_type || col.dataType || "varchar", + webType: col.web_type || col.webType || "text", + isNullable: col.is_nullable || col.isNullable || "YES", + characterMaximumLength: col.character_maximum_length || col.characterMaximumLength, + isVisible: col.is_visible !== false, + displayOrder: col.display_order || col.displayOrder || 1, + })); + + setColumns(formattedColumns); + } catch (error) { + console.error("컬럼 정보 쑰회 μ‹€νŒ¨:", error); + // API 호좜 μ‹€νŒ¨ μ‹œ κΈ°λ³Έ 컬럼 정보 μ‚¬μš© + const fallbackColumns: ColumnInfo[] = [ + { + tableName: selectedTable, + columnName: "id", + columnLabel: "ID", + dataType: "integer", + webType: "number", + isNullable: "NO", + isVisible: true, + displayOrder: 1, + }, + { + tableName: selectedTable, + columnName: "name", + columnLabel: "이름", + dataType: "varchar", + webType: "text", + isNullable: "NO", + characterMaximumLength: 100, + isVisible: true, + displayOrder: 2, + }, + { + tableName: selectedTable, + columnName: "status", + columnLabel: "μƒνƒœ", + dataType: "varchar", + webType: "select", + isNullable: "YES", + characterMaximumLength: 20, + isVisible: true, + displayOrder: 3, + }, + { + tableName: selectedTable, + columnName: "created_date", + columnLabel: "생성일", + dataType: "timestamp", + webType: "date", + isNullable: "YES", + isVisible: true, + displayOrder: 4, + }, + ]; + setColumns(fallbackColumns); + } finally { + setIsLoading(false); + } + }; + + fetchColumns(); + }, [selectedTable]); + + // ν…Œμ΄λΈ” 선택 + const handleTableSelect = (tableName: string) => { + setSelectedTable(tableName); + onTableSelect?.(tableName); + }; + + // 컬럼 선택 + const handleColumnSelect = (column: ColumnInfo) => { + onColumnSelect?.(column); + }; + + // μ›Ή νƒ€μž… λ³€κ²½ + const handleWebTypeChange = async (columnName: string, webType: WebType) => { + try { + await tableTypeApi.setColumnWebType(selectedTable, columnName, webType); + + // 둜컬 μƒνƒœ μ—…λ°μ΄νŠΈ + setColumns((prev) => prev.map((col) => (col.columnName === columnName ? { ...col, webType } : col))); + + console.log(`컬럼 ${columnName}의 μ›Ή νƒ€μž…μ„ ${webType}둜 λ³€κ²½ν–ˆμŠ΅λ‹ˆλ‹€.`); + } catch (error) { + console.error("μ›Ή νƒ€μž… μ„€μ • μ‹€νŒ¨:", error); + alert("μ›Ή νƒ€μž… 섀정에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€. λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”."); + } + }; + + const filteredTables = tables.filter((table) => table.displayName.toLowerCase().includes(searchTerm.toLowerCase())); + + return ( +
+ {/* ν…Œμ΄λΈ” 선택 */} + + + + + ν…Œμ΄λΈ” 선택 + + + +
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> +
+ +
+ {filteredTables.map((table) => ( + + ))} +
+
+
+ + {/* 컬럼 정보 */} + {selectedTable && ( + + + + + {selectedTable} - 컬럼 정보 + + + + + + + 컬럼λͺ… + 라벨 + 데이터 νƒ€μž… + μ›Ή νƒ€μž… + ν•„μˆ˜ + ν‘œμ‹œ + μ•‘μ…˜ + + + + {columns.map((column) => ( + + {column.columnName} + {column.columnLabel} + + + {column.dataType} + + + + + + + + {column.isNullable === "NO" ? "ν•„μˆ˜" : "선택"} + + + + + {column.isVisible ? "ν‘œμ‹œ" : "μˆ¨κΉ€"} + + + + + + + ))} + +
+
+
+ )} +
+ ); +} diff --git a/frontend/components/screen/TemplateManager.tsx b/frontend/components/screen/TemplateManager.tsx new file mode 100644 index 00000000..b30739a6 --- /dev/null +++ b/frontend/components/screen/TemplateManager.tsx @@ -0,0 +1,386 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Separator } from "@/components/ui/separator"; +import { Search, Plus, Download, Upload, Trash2, Eye, Edit } from "lucide-react"; +import { ScreenTemplate, LayoutData } from "@/types/screen"; +import { templateApi } from "@/lib/api/screen"; +import { useAuth } from "@/hooks/useAuth"; + +interface TemplateManagerProps { + onTemplateSelect?: (template: ScreenTemplate) => void; + onTemplateApply?: (template: ScreenTemplate) => void; + className?: string; +} + +export default function TemplateManager({ onTemplateSelect, onTemplateApply, className }: TemplateManagerProps) { + const { user } = useAuth(); + const [templates, setTemplates] = useState([]); + const [selectedTemplate, setSelectedTemplate] = useState(null); + const [searchTerm, setSearchTerm] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [activeTab, setActiveTab] = useState("my"); + + // ν…œν”Œλ¦Ώ λͺ©λ‘ 쑰회 + useEffect(() => { + const fetchTemplates = async () => { + try { + setIsLoading(true); + const templateList = await templateApi.getTemplates({ + companyCode: user?.company_code || "*", + }); + setTemplates(templateList); + } catch (error) { + console.error("ν…œν”Œλ¦Ώ λͺ©λ‘ 쑰회 μ‹€νŒ¨:", error); + // API 호좜 μ‹€νŒ¨ μ‹œ κΈ°λ³Έ ν…œν”Œλ¦Ώ λͺ©λ‘ μ‚¬μš© + const fallbackTemplates: ScreenTemplate[] = [ + { + templateId: 1, + templateName: "κΈ°λ³Έ CRUD ν™”λ©΄", + templateType: "CRUD", + companyCode: "*", + description: "기본적인 CRUD κΈ°λŠ₯을 μ œκ³΅ν•˜λŠ” ν™”λ©΄ ν…œν”Œλ¦Ώ", + layoutData: { + components: [ + { + id: "search-container", + type: "container", + position: { x: 0, y: 0 }, + size: { width: 12, height: 100 }, + title: "검색 μ˜μ—­", + children: [], + }, + { + id: "table-container", + type: "container", + position: { x: 0, y: 1 }, + size: { width: 12, height: 300 }, + title: "데이터 ν…Œμ΄λΈ”", + children: [], + }, + { + id: "form-container", + type: "container", + position: { x: 0, y: 2 }, + size: { width: 12, height: 200 }, + title: "μž…λ ₯ 폼", + children: [], + }, + ], + gridSettings: { columns: 12, gap: 16, padding: 16 }, + }, + isPublic: true, + createdBy: "admin", + createdDate: new Date(), + }, + { + templateId: 2, + templateName: "λͺ©λ‘ ν™”λ©΄", + templateType: "LIST", + companyCode: "*", + description: "데이터 λͺ©λ‘μ„ ν‘œμ‹œν•˜λŠ” ν™”λ©΄ ν…œν”Œλ¦Ώ", + layoutData: { + components: [ + { + id: "filter-container", + type: "container", + position: { x: 0, y: 0 }, + size: { width: 12, height: 80 }, + title: "ν•„ν„° μ˜μ—­", + children: [], + }, + { + id: "list-container", + type: "container", + position: { x: 0, y: 1 }, + size: { width: 12, height: 400 }, + title: "λͺ©λ‘ μ˜μ—­", + children: [], + }, + ], + gridSettings: { columns: 12, gap: 16, padding: 16 }, + }, + isPublic: true, + createdBy: "admin", + createdDate: new Date(), + }, + { + templateId: 3, + templateName: "상세 ν™”λ©΄", + templateType: "DETAIL", + companyCode: "*", + description: "데이터 상세 정보λ₯Ό ν‘œμ‹œν•˜λŠ” ν™”λ©΄ ν…œν”Œλ¦Ώ", + layoutData: { + components: [ + { + id: "header-container", + type: "container", + position: { x: 0, y: 0 }, + size: { width: 12, height: 60 }, + title: "헀더 μ˜μ—­", + children: [], + }, + { + id: "detail-container", + type: "container", + position: { x: 0, y: 1 }, + size: { width: 12, height: 400 }, + title: "상세 정보", + children: [], + }, + { + id: "action-container", + type: "container", + position: { x: 0, y: 2 }, + size: { width: 12, height: 80 }, + title: "μ•‘μ…˜ λ²„νŠΌ", + children: [], + }, + ], + gridSettings: { columns: 12, gap: 16, padding: 16 }, + }, + isPublic: true, + createdBy: "admin", + createdDate: new Date(), + }, + ]; + setTemplates(fallbackTemplates); + } finally { + setIsLoading(false); + } + }; + + fetchTemplates(); + }, [user?.company_code]); + + // ν…œν”Œλ¦Ώ 검색 + const filteredTemplates = templates.filter( + (template) => + template.templateName.toLowerCase().includes(searchTerm.toLowerCase()) || + (template.description || "").toLowerCase().includes(searchTerm.toLowerCase()), + ); + + // ν…œν”Œλ¦Ώ 선택 + const handleTemplateSelect = (template: ScreenTemplate) => { + setSelectedTemplate(template); + onTemplateSelect?.(template); + }; + + // ν…œν”Œλ¦Ώ 적용 + const handleTemplateApply = (template: ScreenTemplate) => { + onTemplateApply?.(template); + }; + + // ν…œν”Œλ¦Ώ μ‚­μ œ + const handleTemplateDelete = async (templateId: number) => { + if (!confirm("μ •λ§λ‘œ 이 ν…œν”Œλ¦Ώμ„ μ‚­μ œν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?")) return; + + try { + await templateApi.deleteTemplate(templateId); + setTemplates((prev) => prev.filter((t) => t.templateId !== templateId)); + if (selectedTemplate?.templateId === templateId) { + setSelectedTemplate(null); + } + alert("ν…œν”Œλ¦Ώμ΄ μ‚­μ œλ˜μ—ˆμŠ΅λ‹ˆλ‹€."); + } catch (error) { + console.error("ν…œν”Œλ¦Ώ μ‚­μ œ μ‹€νŒ¨:", error); + alert("ν…œν”Œλ¦Ώ μ‚­μ œμ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€. λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”."); + } + }; + + // μƒˆ ν…œν”Œλ¦Ώ 생성 + const handleCreateTemplate = () => { + // TODO: μƒˆ ν…œν”Œλ¦Ώ 생성 λͺ¨λ‹¬ λ˜λŠ” νŽ˜μ΄μ§€λ‘œ 이동 + console.log("μƒˆ ν…œν”Œλ¦Ώ 생성"); + }; + + // ν…œν”Œλ¦Ώ 내보내기 + const handleExportTemplate = (template: ScreenTemplate) => { + const dataStr = JSON.stringify(template, null, 2); + const dataBlob = new Blob([dataStr], { type: "application/json" }); + const url = URL.createObjectURL(dataBlob); + const link = document.createElement("a"); + link.href = url; + link.download = `${template.templateName}.json`; + link.click(); + URL.revokeObjectURL(url); + }; + + return ( +
+ {/* 헀더 */} + + +
+ ν™”λ©΄ ν…œν”Œλ¦Ώ 관리 +
+ + +
+
+
+ +
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> +
+
+
+ + {/* ν…œν”Œλ¦Ώ λͺ©λ‘ */} +
+ {/* ν…œν”Œλ¦Ώ μΉ΄λ“œ λͺ©λ‘ */} +
+ + + λ‚΄ ν…œν”Œλ¦Ώ + 곡개 ν…œν”Œλ¦Ώ + + + +
+ {filteredTemplates + .filter((template) => (activeTab === "my" ? template.companyCode !== "*" : template.isPublic)) + .map((template) => ( + handleTemplateSelect(template)} + > + +
+
+

{template.templateName}

+

{template.description || "μ„€λͺ… μ—†μŒ"}

+
+ + {template.templateType} + + {template.isPublic && ( + + 곡개 + + )} + + {template.createdBy} β€’ {template.createdDate.toLocaleDateString()} + +
+
+
+ + + {template.companyCode !== "*" && ( + + )} +
+
+
+
+ ))} +
+
+ + {/* μ„ νƒλœ ν…œν”Œλ¦Ώ 상세 정보 */} +
+ {selectedTemplate ? ( + + + ν…œν”Œλ¦Ώ 상세 정보 + + +
+ +

{selectedTemplate.templateName}

+
+
+ +

{selectedTemplate.description}

+
+
+ + + {selectedTemplate.templateType} + +
+
+ +

{selectedTemplate.layoutData?.components?.length || 0}개

+
+
+ +

+ {selectedTemplate.layoutData?.gridSettings?.columns || 12} 컬럼, 간격:{" "} + {selectedTemplate.layoutData?.gridSettings?.gap || 16}px +

+
+ +
+ + +
+
+
+ ) : ( + + + +

ν…œν”Œλ¦Ώμ„ μ„ νƒν•˜λ©΄ 상세 정보λ₯Ό λ³Ό 수 μžˆμŠ΅λ‹ˆλ‹€

+
+
+ )} +
+
+
+ ); +} diff --git a/frontend/components/screen/WidgetFactory.tsx b/frontend/components/screen/WidgetFactory.tsx new file mode 100644 index 00000000..990740e8 --- /dev/null +++ b/frontend/components/screen/WidgetFactory.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { WidgetComponent } from "@/types/screen"; +import InputWidget from "./widgets/InputWidget"; +import SelectWidget from "./widgets/SelectWidget"; +import TextareaWidget from "./widgets/TextareaWidget"; +import { Calendar, CheckSquare, Radio, FileText, Hash, Database } from "lucide-react"; + +interface WidgetFactoryProps { + widget: WidgetComponent; + value?: string; + onChange?: (value: string) => void; + className?: string; +} + +export default function WidgetFactory({ widget, value, onChange, className }: WidgetFactoryProps) { + // μ›Ή νƒ€μž…μ— 따라 μ μ ˆν•œ μ»΄ν¬λ„ŒνŠΈ λ Œλ”λ§ + switch (widget.widgetType) { + case "text": + return ; + + case "number": + return ; + + case "date": + return ( +
+ {widget.label && ( + + )} +
+ + onChange?.(e.target.value)} + required={widget.required} + readOnly={widget.readonly} + className="w-full rounded-md border border-gray-300 py-2 pr-3 pl-10 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none" + /> +
+
+ ); + + case "code": + return ( +
+ {widget.label && ( + + )} +
+ + onChange?.(e.target.value)} + required={widget.required} + readOnly={widget.readonly} + className="w-full rounded-md border border-gray-300 py-2 pr-3 pl-10 font-mono text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none" + /> +
+
+ ); + + case "entity": + return ( +
+ {widget.label && ( + + )} +
+ + onChange?.(e.target.value)} + required={widget.required} + readOnly={widget.readonly} + className="w-full rounded-md border border-gray-300 py-2 pr-3 pl-10 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none" + /> +
+
+ ); + + case "file": + return ( +
+ {widget.label && ( + + )} +
+ + onChange?.(e.target.files?.[0]?.name || "")} + required={widget.required} + disabled={widget.readonly} + className="w-full rounded-md border border-gray-300 py-2 pr-3 pl-10 text-sm file:mr-4 file:rounded-md file:border-0 file:bg-blue-50 file:px-4 file:py-1 file:text-sm file:font-medium file:text-blue-700 hover:file:bg-blue-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none" + /> +
+
+ ); + + case "select": + return ; + + case "checkbox": + return ( +
+
+ + onChange?.(e.target.checked ? "true" : "false")} + required={widget.required} + disabled={widget.readonly} + className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" + /> + {widget.label && ( + + )} +
+
+ ); + + case "radio": + return ( +
+ {widget.label && ( + + )} +
+
+ + onChange?.(e.target.value)} + required={widget.required} + disabled={widget.readonly} + className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500" + /> + +
+
+ + onChange?.(e.target.value)} + required={widget.required} + disabled={widget.readonly} + className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500" + /> + +
+
+
+ ); + + case "textarea": + return ; + + default: + return ( +
+

μ§€μ›ν•˜μ§€ μ•ŠλŠ” μœ„μ ― νƒ€μž…

+

νƒ€μž…: {widget.widgetType}

+
+ ); + } +} diff --git a/frontend/components/screen/layout/ColumnComponent.tsx b/frontend/components/screen/layout/ColumnComponent.tsx new file mode 100644 index 00000000..056b1478 --- /dev/null +++ b/frontend/components/screen/layout/ColumnComponent.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { ColumnComponent as ColumnComponentType } from "@/types/screen"; + +interface ColumnComponentProps { + component: ColumnComponentType; + children?: React.ReactNode; + className?: string; + onClick?: () => void; + isSelected?: boolean; + onMouseDown?: (e: React.MouseEvent) => void; + isMoving?: boolean; +} + +export default function ColumnComponent({ + component, + children, + className, + onClick, + isSelected, + onMouseDown, + isMoving, +}: ColumnComponentProps) { + // μŠ€νƒ€μΌ 객체 생성 + const style: React.CSSProperties = { + gridColumn: `span ${component.size.width}`, + minHeight: `${component.size.height}px`, + ...(component.style && { + width: component.style.width, + height: component.style.height, + margin: component.style.margin, + padding: component.style.padding, + backgroundColor: component.style.backgroundColor, + border: component.style.border, + borderRadius: component.style.borderRadius, + boxShadow: component.style.boxShadow, + display: component.style.display || "flex", + flexDirection: component.style.flexDirection || "column", + justifyContent: component.style.justifyContent, + alignItems: component.style.alignItems, + gap: component.style.gap, + color: component.style.color, + fontSize: component.style.fontSize, + fontWeight: component.style.fontWeight, + textAlign: component.style.textAlign, + position: component.style.position, + zIndex: component.style.zIndex, + opacity: component.style.opacity, + overflow: component.style.overflow, + cursor: component.style.cursor, + transition: component.style.transition, + transform: component.style.transform, + }), + ...(isMoving && { + zIndex: 50, + opacity: 0.8, + transform: "scale(1.02)", + }), + }; + + return ( +
+
μ—΄
+ {children} +
+ ); +} diff --git a/frontend/components/screen/layout/ContainerComponent.tsx b/frontend/components/screen/layout/ContainerComponent.tsx new file mode 100644 index 00000000..9a4c3002 --- /dev/null +++ b/frontend/components/screen/layout/ContainerComponent.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { ContainerComponent as ContainerComponentType } from "@/types/screen"; + +interface ContainerComponentProps { + component: ContainerComponentType; + children?: React.ReactNode; + className?: string; + onClick?: () => void; + isSelected?: boolean; + onMouseDown?: (e: React.MouseEvent) => void; + isMoving?: boolean; +} + +export default function ContainerComponent({ + component, + children, + className, + onClick, + isSelected, + onMouseDown, + isMoving, +}: ContainerComponentProps) { + // μŠ€νƒ€μΌ 객체 생성 + const style: React.CSSProperties = { + gridColumn: `span ${component.size.width}`, + minHeight: `${component.size.height}px`, + ...(component.style && { + width: component.style.width, + height: component.style.height, + margin: component.style.margin, + padding: component.style.padding, + backgroundColor: component.style.backgroundColor, + border: component.style.border, + borderRadius: component.style.borderRadius, + boxShadow: component.style.boxShadow, + display: component.style.display, + flexDirection: component.style.flexDirection, + justifyContent: component.style.justifyContent, + alignItems: component.style.alignItems, + gap: component.style.gap, + color: component.style.color, + fontSize: component.style.fontSize, + fontWeight: component.style.fontWeight, + textAlign: component.style.textAlign, + position: component.style.position, + zIndex: component.style.zIndex, + opacity: component.style.opacity, + overflow: component.style.overflow, + cursor: component.style.cursor, + transition: component.style.transition, + transform: component.style.transform, + }), + ...(isMoving && { + zIndex: 50, + opacity: 0.8, + transform: "scale(1.02)", + }), + }; + + return ( +
+
{component.title || "μ»¨ν…Œμ΄λ„ˆ"}
+ {children} +
+ ); +} diff --git a/frontend/components/screen/layout/RowComponent.tsx b/frontend/components/screen/layout/RowComponent.tsx new file mode 100644 index 00000000..9bb2e00e --- /dev/null +++ b/frontend/components/screen/layout/RowComponent.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { RowComponent as RowComponentType } from "@/types/screen"; + +interface RowComponentProps { + component: RowComponentType; + children?: React.ReactNode; + className?: string; + onClick?: () => void; + isSelected?: boolean; + onMouseDown?: (e: React.MouseEvent) => void; + isMoving?: boolean; +} + +export default function RowComponent({ + component, + children, + className, + onClick, + isSelected, + onMouseDown, + isMoving, +}: RowComponentProps) { + // μŠ€νƒ€μΌ 객체 생성 + const style: React.CSSProperties = { + gridColumn: `span ${component.size.width}`, + minHeight: `${component.size.height}px`, + ...(component.style && { + width: component.style.width, + height: component.style.height, + margin: component.style.margin, + padding: component.style.padding, + backgroundColor: component.style.backgroundColor, + border: component.style.border, + borderRadius: component.style.borderRadius, + boxShadow: component.style.boxShadow, + display: component.style.display || "flex", + flexDirection: component.style.flexDirection || "row", + justifyContent: component.style.justifyContent, + alignItems: component.style.alignItems, + gap: component.style.gap, + color: component.style.color, + fontSize: component.style.fontSize, + fontWeight: component.style.fontWeight, + textAlign: component.style.textAlign, + position: component.style.position, + zIndex: component.style.zIndex, + opacity: component.style.opacity, + overflow: component.style.overflow, + cursor: component.style.cursor, + transition: component.style.transition, + transform: component.style.transform, + }), + ...(isMoving && { + zIndex: 50, + opacity: 0.8, + transform: "scale(1.02)", + }), + }; + + return ( +
+
ν–‰
+ {children} +
+ ); +} diff --git a/frontend/components/screen/widgets/InputWidget.tsx b/frontend/components/screen/widgets/InputWidget.tsx new file mode 100644 index 00000000..9e5a7ec5 --- /dev/null +++ b/frontend/components/screen/widgets/InputWidget.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; +import { WidgetComponent } from "@/types/screen"; + +interface InputWidgetProps { + widget: WidgetComponent; + value?: string; + onChange?: (value: string) => void; + className?: string; +} + +export default function InputWidget({ widget, value, onChange, className }: InputWidgetProps) { + const handleChange = (e: React.ChangeEvent) => { + onChange?.(e.target.value); + }; + + return ( +
+ {widget.label && ( + + )} + +
+ ); +} diff --git a/frontend/components/screen/widgets/SelectWidget.tsx b/frontend/components/screen/widgets/SelectWidget.tsx new file mode 100644 index 00000000..306d2dfa --- /dev/null +++ b/frontend/components/screen/widgets/SelectWidget.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { cn } from "@/lib/utils"; +import { WidgetComponent } from "@/types/screen"; + +interface SelectWidgetProps { + widget: WidgetComponent; + value?: string; + onChange?: (value: string) => void; + options?: { value: string; label: string }[]; + className?: string; +} + +export default function SelectWidget({ widget, value, onChange, options = [], className }: SelectWidgetProps) { + const handleChange = (newValue: string) => { + onChange?.(newValue); + }; + + // μœ„μ ― νƒ€μž…μ— λ”°λ₯Έ κΈ°λ³Έ μ˜΅μ…˜ 생성 + const getDefaultOptions = () => { + switch (widget.widgetType) { + case "select": + return [ + { value: "option1", label: "μ˜΅μ…˜ 1" }, + { value: "option2", label: "μ˜΅μ…˜ 2" }, + { value: "option3", label: "μ˜΅μ…˜ 3" }, + ]; + case "checkbox": + return [ + { value: "true", label: "체크됨" }, + { value: "false", label: "체크 μ•ˆλ¨" }, + ]; + case "radio": + return [ + { value: "yes", label: "예" }, + { value: "no", label: "μ•„λ‹ˆμ˜€" }, + ]; + default: + return options.length > 0 ? options : [{ value: "default", label: "κΈ°λ³Έκ°’" }]; + } + }; + + const displayOptions = options.length > 0 ? options : getDefaultOptions(); + + return ( +
+ {widget.label && ( + + )} + +
+ ); +} diff --git a/frontend/components/screen/widgets/TextareaWidget.tsx b/frontend/components/screen/widgets/TextareaWidget.tsx new file mode 100644 index 00000000..f05bb9f9 --- /dev/null +++ b/frontend/components/screen/widgets/TextareaWidget.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; +import { WidgetComponent } from "@/types/screen"; + +interface TextareaWidgetProps { + widget: WidgetComponent; + value?: string; + onChange?: (value: string) => void; + className?: string; +} + +export default function TextareaWidget({ widget, value, onChange, className }: TextareaWidgetProps) { + const handleChange = (e: React.ChangeEvent) => { + onChange?.(e.target.value); + }; + + return ( +
+ {widget.label && ( + + )} +