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 1a2ab31b..a80080bf 100644 --- a/backend-node/prisma/schema.prisma +++ b/backend-node/prisma/schema.prisma @@ -3491,7 +3491,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) } @@ -5025,3 +5025,123 @@ model work_mail_list { @@ignore } + +/// The underlying table does not contain a valid unique identifier and can therefore currently not be handled by Prisma Client. +model zz_230410_user_info { + sabun String? @db.VarChar(1024) + user_id String? @db.VarChar(1024) + user_password String? @db.VarChar(1024) + user_name String? @db.VarChar(1024) + user_name_eng String? @db.VarChar(1024) + user_name_cn String? @db.VarChar(1024) + dept_code String? @db.VarChar(1024) + dept_name String? @db.VarChar(1024) + position_code String? @db.VarChar(1024) + position_name String? @db.VarChar(1024) + email String? @db.VarChar(1024) + tel String? @db.VarChar(1024) + cell_phone String? @db.VarChar(1024) + user_type String? @db.VarChar(1024) + user_type_name String? @db.VarChar(1024) + regdate DateTime? @db.Timestamp(6) + data_type String? @db.VarChar(64) + status String? @db.VarChar(32) + end_date DateTime? @db.Timestamp(6) + fax_no String? @db.VarChar + + @@ignore +} +// 화면관리 μ‹œμŠ€ν…œ 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..4aa13bdb --- /dev/null +++ b/backend-node/src/controllers/screenManagementController.ts @@ -0,0 +1,152 @@ +import { Response } from "express"; +import { screenManagementService } from "../services/screenManagementService"; +import { AuthenticatedRequest } from "../types/auth"; + +// ν™”λ©΄ λͺ©λ‘ 쑰회 +export const getScreens = async (req: AuthenticatedRequest, res: Response) => { + try { + const { companyCode } = req.user as any; + const screens = await screenManagementService.getScreens(companyCode); + res.json({ success: true, data: screens }); + } catch (error) { + console.error("ν™”λ©΄ λͺ©λ‘ 쑰회 μ‹€νŒ¨:", error); + res + .status(500) + .json({ success: false, message: "ν™”λ©΄ λͺ©λ‘ μ‘°νšŒμ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€." }); + } +}; + +// ν™”λ©΄ 생성 +export const createScreen = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { companyCode } = req.user as any; + const screenData = { ...req.body, companyCode }; + const newScreen = await screenManagementService.createScreen( + screenData, + companyCode + ); + res.status(201).json({ success: true, data: newScreen }); + } catch (error) { + console.error("ν™”λ©΄ 생성 μ‹€νŒ¨:", error); + res + .status(500) + .json({ success: false, message: "ν™”λ©΄ 생성에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€." }); + } +}; + +// ν™”λ©΄ μˆ˜μ • +export const updateScreen = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { id } = req.params; + const { companyCode } = req.user as any; + const updateData = { ...req.body, companyCode }; + const updatedScreen = await screenManagementService.updateScreen( + parseInt(id), + updateData, + companyCode + ); + res.json({ success: true, data: updatedScreen }); + } catch (error) { + console.error("ν™”λ©΄ μˆ˜μ • μ‹€νŒ¨:", error); + res + .status(500) + .json({ success: false, message: "ν™”λ©΄ μˆ˜μ •μ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€." }); + } +}; + +// ν™”λ©΄ μ‚­μ œ +export const deleteScreen = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { id } = req.params; + const { companyCode } = req.user as any; + await screenManagementService.deleteScreen(parseInt(id), companyCode); + res.json({ success: true, message: "화면이 μ‚­μ œλ˜μ—ˆμŠ΅λ‹ˆλ‹€." }); + } catch (error) { + console.error("ν™”λ©΄ μ‚­μ œ μ‹€νŒ¨:", error); + res + .status(500) + .json({ success: false, message: "ν™”λ©΄ μ‚­μ œμ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€." }); + } +}; + +// ν…Œμ΄λΈ” λͺ©λ‘ 쑰회 +export const getTables = async (req: AuthenticatedRequest, res: Response) => { + try { + const { companyCode } = req.user as any; + const tables = await screenManagementService.getTables(companyCode); + res.json({ success: true, data: tables }); + } catch (error) { + console.error("ν…Œμ΄λΈ” λͺ©λ‘ 쑰회 μ‹€νŒ¨:", error); + res + .status(500) + .json({ success: false, message: "ν…Œμ΄λΈ” λͺ©λ‘ μ‘°νšŒμ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€." }); + } +}; + +// ν…Œμ΄λΈ” 컬럼 정보 쑰회 +export const getTableColumns = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { tableName } = req.params; + const { companyCode } = req.user as any; + const columns = await screenManagementService.getTableColumns( + tableName, + companyCode + ); + res.json({ success: true, data: columns }); + } catch (error) { + console.error("ν…Œμ΄λΈ” 컬럼 쑰회 μ‹€νŒ¨:", error); + res + .status(500) + .json({ success: false, message: "ν…Œμ΄λΈ” 컬럼 μ‘°νšŒμ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€." }); + } +}; + +// λ ˆμ΄μ•„μ›ƒ μ €μž₯ +export const saveLayout = async (req: AuthenticatedRequest, res: Response) => { + try { + const { screenId } = req.params; + const { companyCode } = req.user as any; + const layoutData = req.body; + const savedLayout = await screenManagementService.saveLayout( + parseInt(screenId), + layoutData, + companyCode + ); + res.json({ success: true, data: savedLayout }); + } catch (error) { + console.error("λ ˆμ΄μ•„μ›ƒ μ €μž₯ μ‹€νŒ¨:", error); + res + .status(500) + .json({ success: false, message: "λ ˆμ΄μ•„μ›ƒ μ €μž₯에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€." }); + } +}; + +// λ ˆμ΄μ•„μ›ƒ 쑰회 +export const getLayout = async (req: AuthenticatedRequest, res: Response) => { + try { + const { screenId } = req.params; + const { companyCode } = req.user as any; + const layout = await screenManagementService.getLayout( + parseInt(screenId), + companyCode + ); + res.json({ success: true, data: layout }); + } catch (error) { + console.error("λ ˆμ΄μ•„μ›ƒ 쑰회 μ‹€νŒ¨:", error); + res + .status(500) + .json({ success: false, message: "λ ˆμ΄μ•„μ›ƒ μ‘°νšŒμ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€." }); + } +}; diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index cf54176a..29528ebc 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -375,3 +375,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..10459f7c --- /dev/null +++ b/backend-node/src/routes/screenManagementRoutes.ts @@ -0,0 +1,33 @@ +import express from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { + getScreens, + createScreen, + updateScreen, + deleteScreen, + getTables, + getTableColumns, + saveLayout, + getLayout, +} from "../controllers/screenManagementController"; + +const router = express.Router(); + +// λͺ¨λ“  λΌμš°νŠΈμ— 인증 미듀웨어 적용 +router.use(authenticateToken); + +// ν™”λ©΄ 관리 +router.get("/screens", getScreens); +router.post("/screens", createScreen); +router.put("/screens/:id", updateScreen); +router.delete("/screens/:id", deleteScreen); + +// ν…Œμ΄λΈ” 관리 +router.get("/tables", getTables); +router.get("/tables/:tableName/columns", getTableColumns); + +// λ ˆμ΄μ•„μ›ƒ 관리 +router.post("/screens/:screenId/layout", saveLayout); +router.get("/screens/:screenId/layout", getLayout); + +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..88e4b406 --- /dev/null +++ b/backend-node/src/services/screenManagementService.ts @@ -0,0 +1,810 @@ +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"; + +// λ°±μ—”λ“œμ—μ„œ μ‚¬μš©ν•  ν…Œμ΄λΈ” 정보 νƒ€μž… +interface TableInfo { + tableName: string; + tableLabel: string; + columns: ColumnInfo[]; +} + +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 getScreens(companyCode: string): Promise { + const whereClause = + companyCode === "*" ? {} : { company_code: companyCode }; + + const screens = await prisma.screen_definitions.findMany({ + where: whereClause, + orderBy: { created_date: "desc" }, + }); + + return screens.map((screen) => this.mapToScreenDefinition(screen)); + } + + /** + * ν™”λ©΄ μ •μ˜ 쑰회 + */ + 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 existingScreen = await prisma.screen_definitions.findUnique({ + where: { screen_id: screenId }, + }); + + if (!existingScreen) { + throw new Error("화면을 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."); + } + + if ( + userCompanyCode !== "*" && + existingScreen.company_code !== userCompanyCode + ) { + throw new Error("이 화면을 μˆ˜μ •ν•  κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€."); + } + + const screen = await prisma.screen_definitions.update({ + where: { screen_id: screenId }, + data: { + screen_name: updateData.screenName, + description: updateData.description, + is_active: updateData.isActive ? "Y" : "N", + updated_by: updateData.updatedBy, + updated_date: new Date(), + }, + }); + + return this.mapToScreenDefinition(screen); + } + + /** + * ν™”λ©΄ μ •μ˜ μ‚­μ œ + */ + async deleteScreen(screenId: number, userCompanyCode: string): Promise { + // κΆŒν•œ 확인 + const existingScreen = await prisma.screen_definitions.findUnique({ + where: { screen_id: screenId }, + }); + + if (!existingScreen) { + throw new Error("화면을 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."); + } + + if ( + userCompanyCode !== "*" && + existingScreen.company_code !== userCompanyCode + ) { + throw new Error("이 화면을 μ‚­μ œν•  κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€."); + } + + await prisma.screen_definitions.delete({ + where: { screen_id: screenId }, + }); + } + + // ======================================== + // ν…Œμ΄λΈ” 관리 + // ======================================== + + /** + * ν…Œμ΄λΈ” λͺ©λ‘ 쑰회 + */ + async getTables(companyCode: string): Promise { + try { + // PostgreSQLμ—μ„œ μ‚¬μš© κ°€λŠ₯ν•œ ν…Œμ΄λΈ” λͺ©λ‘ 쑰회 + const tables = await prisma.$queryRaw>` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_type = 'BASE TABLE' + ORDER BY table_name + `; + + // 각 ν…Œμ΄λΈ”μ˜ 컬럼 정보도 ν•¨κ»˜ 쑰회 + const tableInfos: TableInfo[] = []; + + for (const table of tables) { + const columns = await this.getTableColumns( + table.table_name, + companyCode + ); + if (columns.length > 0) { + tableInfos.push({ + tableName: table.table_name, + tableLabel: this.getTableLabel(table.table_name), + columns: columns, + }); + } + } + + return tableInfos; + } catch (error) { + console.error("ν…Œμ΄λΈ” λͺ©λ‘ 쑰회 μ‹€νŒ¨:", error); + throw new Error("ν…Œμ΄λΈ” λͺ©λ‘μ„ μ‘°νšŒν•  수 μ—†μŠ΅λ‹ˆλ‹€."); + } + } + + /** + * ν…Œμ΄λΈ” 컬럼 정보 쑰회 + */ + async getTableColumns( + tableName: string, + companyCode: string + ): Promise { + try { + // ν…Œμ΄λΈ” 컬럼 정보 쑰회 + const columns = await prisma.$queryRaw< + Array<{ + column_name: string; + data_type: string; + is_nullable: string; + column_default: string | null; + character_maximum_length: number | null; + numeric_precision: number | null; + numeric_scale: number | null; + }> + >` + SELECT + column_name, + data_type, + is_nullable, + column_default, + character_maximum_length, + numeric_precision, + numeric_scale + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = ${tableName} + ORDER BY ordinal_position + `; + + // column_labels ν…Œμ΄λΈ”μ—μ„œ μ›Ήνƒ€μž… 정보 쑰회 (μžˆλŠ” 경우) + const webTypeInfo = await prisma.column_labels.findMany({ + where: { table_name: tableName }, + select: { + column_name: true, + web_type: true, + column_label: true, + detail_settings: true, + }, + }); + + // 컬럼 정보 λ§€ν•‘ + return columns.map((column) => { + const webTypeData = webTypeInfo.find( + (wt) => wt.column_name === column.column_name + ); + + return { + tableName: tableName, + columnName: column.column_name, + columnLabel: + webTypeData?.column_label || + this.getColumnLabel(column.column_name), + dataType: column.data_type, + webType: + (webTypeData?.web_type as WebType) || + this.inferWebType(column.data_type), + isNullable: column.is_nullable, + columnDefault: column.column_default || undefined, + characterMaximumLength: column.character_maximum_length || undefined, + numericPrecision: column.numeric_precision || undefined, + numericScale: column.numeric_scale || undefined, + detailSettings: webTypeData?.detail_settings || undefined, + }; + }); + } catch (error) { + console.error("ν…Œμ΄λΈ” 컬럼 쑰회 μ‹€νŒ¨:", error); + throw new Error("ν…Œμ΄λΈ” 컬럼 정보λ₯Ό μ‘°νšŒν•  수 μ—†μŠ΅λ‹ˆλ‹€."); + } + } + + /** + * ν…Œμ΄λΈ” 라벨 생성 + */ + private getTableLabel(tableName: string): string { + // snake_caseλ₯Ό 읽기 μ‰¬μš΄ ν˜•νƒœλ‘œ λ³€ν™˜ + return tableName + .replace(/_/g, " ") + .replace(/\b\w/g, (l) => l.toUpperCase()) + .replace(/\s+/g, " ") + .trim(); + } + + /** + * 컬럼 라벨 생성 + */ + private getColumnLabel(columnName: string): string { + // snake_caseλ₯Ό 읽기 μ‰¬μš΄ ν˜•νƒœλ‘œ λ³€ν™˜ + return columnName + .replace(/_/g, " ") + .replace(/\b\w/g, (l) => l.toUpperCase()) + .replace(/\s+/g, " ") + .trim(); + } + + /** + * 데이터 νƒ€μž…μœΌλ‘œλΆ€ν„° μ›Ήνƒ€μž… μΆ”λ‘  + */ + private inferWebType(dataType: string): WebType { + const lowerType = dataType.toLowerCase(); + + if (lowerType.includes("char") || lowerType.includes("text")) { + return "text"; + } else if ( + lowerType.includes("int") || + lowerType.includes("numeric") || + lowerType.includes("decimal") + ) { + return "number"; + } else if (lowerType.includes("date") || lowerType.includes("time")) { + return "date"; + } else if (lowerType.includes("bool")) { + return "checkbox"; + } else { + return "text"; + } + } + + // ======================================== + // λ ˆμ΄μ•„μ›ƒ 관리 + // ======================================== + + /** + * λ ˆμ΄μ•„μ›ƒ μ €μž₯ + */ + async saveLayout( + screenId: number, + layoutData: LayoutData, + companyCode: string + ): Promise { + // κΆŒν•œ 확인 + const existingScreen = await prisma.screen_definitions.findUnique({ + where: { screen_id: screenId }, + }); + + if (!existingScreen) { + throw new Error("화면을 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."); + } + + if (companyCode !== "*" && existingScreen.company_code !== companyCode) { + throw new Error("이 ν™”λ©΄μ˜ λ ˆμ΄μ•„μ›ƒμ„ μ €μž₯ν•  κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€."); + } + + // κΈ°μ‘΄ λ ˆμ΄μ•„μ›ƒ μ‚­μ œ + await prisma.screen_layouts.deleteMany({ + where: { screen_id: screenId }, + }); + + // μƒˆ λ ˆμ΄μ•„μ›ƒ μ €μž₯ + for (const component of layoutData.components) { + const { id, ...componentData } = component; + + // Prisma JSON ν•„λ“œμ— λ§žλŠ” νƒ€μž…μœΌλ‘œ λ³€ν™˜ + const properties: any = { + ...componentData, + position: { + x: component.position.x, + y: component.position.y, + }, + size: { + width: component.size.width, + height: component.size.height, + }, + }; + + await prisma.screen_layouts.create({ + data: { + screen_id: screenId, + component_type: component.type, + component_id: component.id, + parent_id: component.parentId || null, + position_x: component.position.x, + position_y: component.position.y, + width: component.size.width, + height: component.size.height, + properties: properties, + }, + }); + } + } + + /** + * λ ˆμ΄μ•„μ›ƒ 쑰회 + */ + async getLayout( + screenId: number, + companyCode: string + ): Promise { + // κΆŒν•œ 확인 + const existingScreen = await prisma.screen_definitions.findUnique({ + where: { screen_id: screenId }, + }); + + if (!existingScreen) { + return null; + } + + if (companyCode !== "*" && existingScreen.company_code !== companyCode) { + throw new Error("이 ν™”λ©΄μ˜ λ ˆμ΄μ•„μ›ƒμ„ μ‘°νšŒν•  κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€."); + } + + const layouts = await prisma.screen_layouts.findMany({ + where: { screen_id: screenId }, + orderBy: { display_order: "asc" }, + }); + + if (layouts.length === 0) { + return { + components: [], + gridSettings: { columns: 12, gap: 16, padding: 16 }, + }; + } + + const components: ComponentData[] = layouts.map((layout) => { + const properties = layout.properties as any; + return { + 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 }, + parentId: layout.parent_id, + ...properties, + }; + }); + + 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, + }; + } +} + +// μ„œλΉ„μŠ€ μΈμŠ€ν„΄μŠ€ export +export const screenManagementService = new ScreenManagementService(); diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 6c4cada4..d7fbab1f 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -203,14 +203,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)}` + ); } } }); @@ -339,4 +343,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 d39421a8..105ff998 100644 --- a/docs/화면관리_μ‹œμŠ€ν…œ_섀계.md +++ b/docs/화면관리_μ‹œμŠ€ν…œ_섀계.md @@ -19,15 +19,16 @@ ### 화면관리 μ‹œμŠ€ν…œμ΄λž€? -화면관리 μ‹œμŠ€ν…œμ€ μ‹€μ œ μ„œλΉ„μŠ€λ˜λŠ” 화면을 λ“œλž˜κ·Έμ•€λ“œλ‘­μœΌλ‘œ μ„€κ³„ν•˜κ³  관리할 수 μžˆλŠ” μ‹œμŠ€ν…œμž…λ‹ˆλ‹€. ν…Œμ΄λΈ” νƒ€μž…κ΄€λ¦¬μ™€ μ—°κ³„ν•˜μ—¬ 각 ν•„λ“œκ°€ μ›Ήμ—μ„œ μ–΄λ–»κ²Œ ν‘œμ‹œλ μ§€λ₯Ό μ •μ˜ν•˜κ³ , μ‚¬μš©μžκ°€ μ§κ΄€μ μœΌλ‘œ 화면을 ꡬ성할 수 μžˆμŠ΅λ‹ˆλ‹€. +화면관리 μ‹œμŠ€ν…œμ€ μ‚¬μš©μžκ°€ μ†ν•œ νšŒμ‚¬μ— 맞좰 화면을 λ“œλž˜κ·Έμ•€λ“œλ‘­μœΌλ‘œ μ„€κ³„ν•˜κ³  관리할 수 μžˆλŠ” μ‹œμŠ€ν…œμž…λ‹ˆλ‹€. ν…Œμ΄λΈ” νƒ€μž…κ΄€λ¦¬μ™€ μ—°κ³„ν•˜μ—¬ 각 ν•„λ“œκ°€ μ›Ήμ—μ„œ μ–΄λ–»κ²Œ ν‘œμ‹œλ μ§€λ₯Ό μ •μ˜ν•˜κ³ , μ‚¬μš©μžκ°€ μ§κ΄€μ μœΌλ‘œ 화면을 ꡬ성할 수 μžˆμŠ΅λ‹ˆλ‹€. ### μ£Όμš” νŠΉμ§• +- **νšŒμ‚¬λ³„ ν™”λ©΄ 관리**: μ‚¬μš©μž νšŒμ‚¬ μ½”λ“œμ— λ”°λ₯Έ ν™”λ©΄ μ ‘κ·Ό μ œμ–΄ - **λ“œλž˜κ·Έμ•€λ“œλ‘­ μΈν„°νŽ˜μ΄μŠ€**: 직관적인 ν™”λ©΄ 섀계 +- **μ»¨ν…Œμ΄λ„ˆ κ·Έλ£Ήν™”**: μ»΄ν¬λ„ŒνŠΈλ₯Ό κΉ”λ”ν•˜κ²Œ μ •λ ¬ν•˜λŠ” κ·Έλ£Ή κΈ°λŠ₯ - **ν…Œμ΄λΈ” νƒ€μž… 연계**: 컬럼의 μ›Ή νƒ€μž…μ— λ”°λ₯Έ μžλ™ μœ„μ ― 생성 -- **μ‹€μ‹œκ°„ 미리보기**: μ„€κ³„ν•œ 화면을 μ¦‰μ‹œ 확인 κ°€λŠ₯ -- **λ°˜μ‘ν˜• λ””μžμΈ**: λ‹€μ–‘ν•œ ν™”λ©΄ 크기에 λŒ€μ‘ -- **ν…œν”Œλ¦Ώ μ‹œμŠ€ν…œ**: μž¬μ‚¬μš© κ°€λŠ₯ν•œ ν™”λ©΄ ν…œν”Œλ¦Ώ 제곡 +- **μ‹€μ‹œκ°„ 미리보기**: μ„€κ³„ν•œ 화면을 μ‹€μ œ ν™”λ©΄κ³Ό λ™μΌν•˜κ²Œ 확인 κ°€λŠ₯ +- **메뉴 연동**: 각 νšŒμ‚¬μ˜ 메뉴에 ν™”λ©΄ ν• λ‹Ή 및 관리 ### 🎯 **ν˜„μž¬ ν…Œμ΄λΈ” ꡬ쑰와 100% ν˜Έν™˜** @@ -42,6 +43,15 @@ **λ³„λ„μ˜ ν…Œμ΄λΈ” ꡬ쑰 λ³€κ²½ 없이 λ°”λ‘œ 개발 κ°€λŠ₯!** πŸš€ +### 🏒 **νšŒμ‚¬λ³„ ν™”λ©΄ 관리 μ‹œμŠ€ν…œ** + +**μ‚¬μš©μž κΆŒν•œμ— λ”°λ₯Έ ν™”λ©΄ μ ‘κ·Ό μ œμ–΄:** + +- βœ… **일반 μ‚¬μš©μž**: μžμ‹ μ΄ μ†ν•œ νšŒμ‚¬μ˜ ν™”λ©΄λ§Œ μ œμž‘/μˆ˜μ • κ°€λŠ₯ +- βœ… **κ΄€λ¦¬μž (νšŒμ‚¬μ½”λ“œ '\*')**: λͺ¨λ“  νšŒμ‚¬μ˜ 화면을 μ œμ–΄ κ°€λŠ₯ +- βœ… **νšŒμ‚¬λ³„ 메뉴 ν• λ‹Ή**: 각 νšŒμ‚¬μ˜ λ©”λ‰΄μ—λ§Œ ν™”λ©΄ ν• λ‹Ή κ°€λŠ₯ +- βœ… **κΆŒν•œ 격리**: νšŒμ‚¬ κ°„ ν™”λ©΄ 데이터 μ™„μ „ 뢄리 + ### μ§€μ›ν•˜λŠ” μ›Ή νƒ€μž… ν…Œμ΄λΈ” νƒ€μž…κ΄€λ¦¬μ—μ„œ 각 μ»¬λŸΌλ³„λ‘œ μ„€μ •ν•  수 μžˆλŠ” μ›Ή νƒ€μž…μž…λ‹ˆλ‹€: @@ -66,6 +76,7 @@ - **μžλ™ 상세 μ„€μ •**: μ›Ή νƒ€μž… 선택 μ‹œ ν•΄λ‹Ή νƒ€μž…μ— λ§žλŠ” κΈ°λ³Έ 상세 섀정을 μžλ™μœΌλ‘œ 제곡 - **μ‹€μ‹œκ°„ μ €μž₯**: μ›Ή νƒ€μž… λ³€κ²½ μ‹œ μ¦‰μ‹œ λ°±μ—”λ“œ λ°μ΄ν„°λ² μ΄μŠ€μ— μ €μž₯ - **였λ₯˜ 볡ꡬ**: μ €μž₯ μ‹€νŒ¨ μ‹œ μ›λž˜ μƒνƒœλ‘œ μžλ™ 볡원 +- **상세 μ„€μ • νŽΈμ§‘**: μ›Ή νƒ€μž…λ³„ 상세 섀정을 λͺ¨λ‹¬μ—μ„œ JSON ν˜•νƒœλ‘œ νŽΈμ§‘ κ°€λŠ₯ #### 2. μ›Ή νƒ€μž…λ³„ 상세 μ„€μ • @@ -88,7 +99,8 @@ 2. **컬럼 확인**: ν•΄λ‹Ή ν…Œμ΄λΈ”μ˜ λͺ¨λ“  컬럼 정보 ν‘œμ‹œ 3. **μ›Ή νƒ€μž… μ„€μ •**: 각 컬럼의 μ›Ή νƒ€μž…μ„ λ“œλ‘­λ‹€μš΄μ—μ„œ 선택 4. **μžλ™ μ €μž₯**: 선택 μ¦‰μ‹œ λ°±μ—”λ“œμ— μ €μž₯되고 상세 μ„€μ • μžλ™ 적용 -5. **μΆ”κ°€ μ„€μ •**: ν•„μš”μ‹œ 상세 섀정을 μ‚¬μš©μž μ •μ˜λ‘œ μˆ˜μ • +5. **상세 μ„€μ • νŽΈμ§‘**: "상세 μ„€μ • νŽΈμ§‘" λ²„νŠΌμ„ ν΄λ¦­ν•˜μ—¬ JSON ν˜•νƒœλ‘œ μΆ”κ°€ μ„€μ • μˆ˜μ • +6. **μ„€μ • μ €μž₯**: μˆ˜μ •λœ 상세 섀정을 μ €μž₯ν•˜μ—¬ μ™„λ£Œ ## πŸ—οΈ μ•„ν‚€ν…μ²˜ ꡬ쑰 @@ -118,6 +130,25 @@ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` +### νšŒμ‚¬λ³„ κΆŒν•œ 관리 ꡬ쑰 + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ μ‚¬μš©μž β”‚ β”‚ κΆŒν•œ 검증 β”‚ β”‚ ν™”λ©΄ 데이터 β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ νšŒμ‚¬μ½”λ“œ β”‚ │───▢│ β”‚ κΆŒν•œ 검증 β”‚ │───▢│ β”‚ νšŒμ‚¬λ³„ β”‚ β”‚ +β”‚ β”‚ (company_ β”‚ β”‚ β”‚ β”‚ 미듀웨어 β”‚ β”‚ β”‚ β”‚ ν™”λ©΄ β”‚ β”‚ +β”‚ β”‚ code) β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ 격리 β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ κΆŒν•œ 레벨 β”‚ β”‚ β”‚ β”‚ νšŒμ‚¬λ³„ β”‚ β”‚ β”‚ β”‚ 메뉴 ν• λ‹Ή β”‚ β”‚ +β”‚ β”‚ (admin: '*')β”‚ β”‚ β”‚ β”‚ 데이터 β”‚ β”‚ β”‚ β”‚ μ œν•œ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ 필터링 β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + ### 데이터 흐름 1. **ν…Œμ΄λΈ” νƒ€μž… μ •μ˜**: ν…Œμ΄λΈ” νƒ€μž…κ΄€λ¦¬μ—μ„œ 컬럼의 μ›Ή νƒ€μž… μ„€μ • @@ -132,28 +163,42 @@ - **λ“œλž˜κ·Έμ•€λ“œλ‘­ μΈν„°νŽ˜μ΄μŠ€**: μ»΄ν¬λ„ŒνŠΈλ₯Ό μΊ”λ²„μŠ€μ— 배치 - **κ·Έλ¦¬λ“œ μ‹œμŠ€ν…œ**: 12컬럼 κ·Έλ¦¬λ“œ 기반 λ ˆμ΄μ•„μ›ƒ -- **λ°˜μ‘ν˜• μ„€μ •**: ν™”λ©΄ 크기별 λ ˆμ΄μ•„μ›ƒ μ‘°μ • -- **μ‹€μ‹œκ°„ 미리보기**: μ„€κ³„ν•œ 화면을 μ¦‰μ‹œ 확인 +- **μ»¨ν…Œμ΄λ„ˆ κ·Έλ£Ήν™”**: μ»΄ν¬λ„ŒνŠΈλ₯Ό κΉ”λ”ν•˜κ²Œ μ •λ ¬ν•˜λŠ” κ·Έλ£Ή κΈ°λŠ₯ +- **μ‹€μ‹œκ°„ 미리보기**: μ„€κ³„ν•œ 화면을 μ‹€μ œ ν™”λ©΄κ³Ό λ™μΌν•˜κ²Œ 확인 ### 2. μ»΄ν¬λ„ŒνŠΈ 라이브러리 - **μž…λ ₯ μ»΄ν¬λ„ŒνŠΈ**: text, number, date, textarea λ“± - **선택 μ»΄ν¬λ„ŒνŠΈ**: select, checkbox, radio λ“± - **ν‘œμ‹œ μ»΄ν¬λ„ŒνŠΈ**: label, display, image λ“± -- **λ ˆμ΄μ•„μ›ƒ μ»΄ν¬λ„ŒνŠΈ**: container, row, column λ“± +- **λ ˆμ΄μ•„μ›ƒ μ»΄ν¬λ„ŒνŠΈ**: container, row, column, group λ“± +- **μ»¨ν…Œμ΄λ„ˆ μ»΄ν¬λ„ŒνŠΈ**: μ»΄ν¬λ„ŒνŠΈλ“€μ„ 그룹으둜 λ¬ΆλŠ” κΈ°λŠ₯ -### 3. ν…Œμ΄λΈ” 연계 μ‹œμŠ€ν…œ +### 3. νšŒμ‚¬λ³„ κΆŒν•œ 관리 + +- **νšŒμ‚¬ μ½”λ“œ 기반 μ ‘κ·Ό μ œμ–΄**: μ‚¬μš©μž νšŒμ‚¬ μ½”λ“œμ— λ”°λ₯Έ ν™”λ©΄ μ ‘κ·Ό +- **κ΄€λ¦¬μž κΆŒν•œ**: νšŒμ‚¬ μ½”λ“œ '\*'인 μ‚¬μš©μžλŠ” λͺ¨λ“  νšŒμ‚¬ ν™”λ©΄ μ œμ–΄ +- **νšŒμ‚¬λ³„ 메뉴 ν• λ‹Ή**: 각 νšŒμ‚¬μ˜ λ©”λ‰΄μ—λ§Œ ν™”λ©΄ ν• λ‹Ή κ°€λŠ₯ +- **데이터 격리**: νšŒμ‚¬ κ°„ ν™”λ©΄ 데이터 μ™„μ „ 뢄리 + +### 4. ν…Œμ΄λΈ” 연계 μ‹œμŠ€ν…œ - **μžλ™ μœ„μ ― 생성**: 컬럼의 μ›Ή νƒ€μž…μ— λ”°λ₯Έ μœ„μ ― μžλ™ 생성 - **데이터 바인딩**: 컬럼과 μœ„μ ―μ˜ μžλ™ μ—°κ²° - **μœ νš¨μ„± 검증**: 컬럼 섀정에 λ”°λ₯Έ μžλ™ 검증 κ·œμΉ™ 적용 -### 4. ν…œν”Œλ¦Ώ μ‹œμŠ€ν…œ +### 5. ν…œν”Œλ¦Ώ μ‹œμŠ€ν…œ - **κΈ°λ³Έ ν…œν”Œλ¦Ώ**: CRUD, λͺ©λ‘, 상세 λ“± κΈ°λ³Έ νŒ¨ν„΄ - **μ‚¬μš©μž μ •μ˜ ν…œν”Œλ¦Ώ**: 자주 μ‚¬μš©ν•˜λŠ” λ ˆμ΄μ•„μ›ƒ μ €μž₯ - **ν…œν”Œλ¦Ώ 곡유**: νŒ€μ› κ°„ ν…œν”Œλ¦Ώ 곡유 및 μž¬μ‚¬μš© +### 6. 메뉴 연동 μ‹œμŠ€ν…œ + +- **νšŒμ‚¬λ³„ 메뉴 ν• λ‹Ή**: 각 νšŒμ‚¬μ˜ λ©”λ‰΄μ—λ§Œ ν™”λ©΄ ν• λ‹Ή +- **메뉴-ν™”λ©΄ μ—°κ²°**: 메뉴와 ν™”λ©΄μ˜ 1:1 λ˜λŠ” 1:N μ—°κ²° +- **κΆŒν•œ 기반 메뉴 ν‘œμ‹œ**: μ‚¬μš©μž κΆŒν•œμ— λ”°λ₯Έ 메뉴 ν‘œμ‹œ μ œμ–΄ + ## πŸ—„οΈ λ°μ΄ν„°λ² μ΄μŠ€ 섀계 ### 1. κΈ°μ‘΄ ν…Œμ΄λΈ” ꡬ쑰 (ν…Œμ΄λΈ” νƒ€μž…κ΄€λ¦¬) @@ -221,6 +266,7 @@ CREATE TABLE screen_definitions ( screen_name VARCHAR(100) NOT NULL, screen_code VARCHAR(50) UNIQUE NOT NULL, table_name VARCHAR(100) NOT NULL, -- 🎯 table_labels와 연계 + company_code VARCHAR(50) NOT NULL, -- 🎯 νšŒμ‚¬ μ½”λ“œ (κΆŒν•œ κ΄€λ¦¬μš©) description TEXT, is_active CHAR(1) DEFAULT 'Y', created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, @@ -232,6 +278,9 @@ CREATE TABLE screen_definitions ( CONSTRAINT fk_screen_definitions_table_name FOREIGN KEY (table_name) REFERENCES table_labels(table_name) ); + +-- νšŒμ‚¬ μ½”λ“œ 인덱슀 μΆ”κ°€ +CREATE INDEX idx_screen_definitions_company_code ON screen_definitions(company_code); ``` #### screen_layouts (ν™”λ©΄ λ ˆμ΄μ•„μ›ƒ) @@ -283,12 +332,43 @@ CREATE TABLE screen_templates ( template_id SERIAL PRIMARY KEY, template_name VARCHAR(100) NOT NULL, template_type VARCHAR(50) NOT NULL, -- CRUD, LIST, DETAIL λ“± + company_code VARCHAR(50) NOT NULL, -- 🎯 νšŒμ‚¬ μ½”λ“œ (κΆŒν•œ κ΄€λ¦¬μš©) description TEXT, layout_data JSONB, -- λ ˆμ΄μ•„μ›ƒ 데이터 is_public BOOLEAN DEFAULT FALSE, -- 곡개 μ—¬λΆ€ created_by VARCHAR(50), created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); + +-- νšŒμ‚¬ μ½”λ“œ 인덱슀 μΆ”κ°€ +CREATE INDEX idx_screen_templates_company_code ON screen_templates(company_code); +``` + +#### screen_menu_assignments (ν™”λ©΄-메뉴 ν• λ‹Ή) + +```sql +CREATE TABLE screen_menu_assignments ( + assignment_id SERIAL PRIMARY KEY, + screen_id INTEGER NOT NULL, + menu_id INTEGER NOT NULL, + company_code VARCHAR(50) NOT NULL, -- 🎯 νšŒμ‚¬ μ½”λ“œ (κΆŒν•œ κ΄€λ¦¬μš©) + display_order INTEGER DEFAULT 0, + is_active CHAR(1) DEFAULT 'Y', + created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by VARCHAR(50), + + -- μ™Έλž˜ν‚€ μ œμ•½μ‘°κ±΄ + CONSTRAINT fk_screen_menu_assignments_screen_id + FOREIGN KEY (screen_id) REFERENCES screen_definitions(screen_id), + CONSTRAINT fk_screen_menu_assignments_menu_id + FOREIGN KEY (menu_id) REFERENCES menu_info(menu_id), + + -- μœ λ‹ˆν¬ μ œμ•½μ‘°κ±΄ (ν•œ 메뉴에 같은 ν™”λ©΄ 쀑볡 ν• λ‹Ή λ°©μ§€) + CONSTRAINT uk_screen_menu_company UNIQUE (screen_id, menu_id, company_code) +); + +-- νšŒμ‚¬ μ½”λ“œ 인덱슀 μΆ”κ°€ +CREATE INDEX idx_screen_menu_assignments_company_code ON screen_menu_assignments(company_code); ``` ### 3. ν…Œμ΄λΈ” κ°„ 연계 관계 @@ -303,6 +383,8 @@ screen_definitions (ν™”λ©΄ μ •μ˜) screen_layouts (ν™”λ©΄ λ ˆμ΄μ•„μ›ƒ) ↓ (1:N) screen_widgets (ν™”λ©΄ μœ„μ ―) + ↓ (1:N) +screen_menu_assignments (ν™”λ©΄-메뉴 ν• λ‹Ή) ``` **핡심 연계 포인트:** @@ -310,6 +392,8 @@ screen_widgets (ν™”λ©΄ μœ„μ ―) - βœ… `screen_definitions.table_name` ↔ `table_labels.table_name` - βœ… `screen_widgets.table_name, column_name` ↔ `column_labels.table_name, column_name` - βœ… `screen_widgets.widget_type` ↔ `column_labels.web_type` (μžλ™ 동기화) +- βœ… `screen_definitions.company_code` ↔ μ‚¬μš©μž νšŒμ‚¬ μ½”λ“œ (κΆŒν•œ 관리) +- βœ… `screen_menu_assignments.company_code` ↔ 메뉴 νšŒμ‚¬ μ½”λ“œ (메뉴 ν• λ‹Ή μ œν•œ) ## 🎨 ν™”λ©΄ ꡬ성 μš”μ†Œ @@ -327,6 +411,29 @@ interface ContainerProps { margin: number; backgroundColor?: string; border?: string; + borderRadius?: number; + shadow?: string; +} +``` + +#### Group (κ·Έλ£Ή) + +```typescript +interface GroupProps { + id: string; + type: "group"; + title?: string; + width: number; // 1-12 + height: number; + padding: number; + margin: number; + backgroundColor?: string; + border?: string; + borderRadius?: number; + shadow?: string; + collapsible?: boolean; // 접을 수 μžˆλŠ” κ·Έλ£Ή + collapsed?: boolean; // μ ‘νžŒ μƒνƒœ + children: string[]; // ν¬ν•¨λœ μ»΄ν¬λ„ŒνŠΈ ID λͺ©λ‘ } ``` @@ -440,16 +547,27 @@ interface DragState { draggedItem: ComponentData | null; dragSource: "toolbox" | "canvas"; dropTarget: string | null; + dropZone?: DropZone; // λ“œλ‘­ κ°€λŠ₯ν•œ μ˜μ—­ 정보 } +// κ·Έλ£Ήν™” μƒνƒœ 관리 +interface GroupState { + isGrouping: boolean; + selectedComponents: string[]; + groupTarget: string | null; + groupMode: "create" | "add" | "remove"; +} +``` + // λ“œλ‘­ μ˜μ—­ μ •μ˜ interface DropZone { - id: string; - accepts: string[]; // ν—ˆμš©λ˜λŠ” μ»΄ν¬λ„ŒνŠΈ νƒ€μž… - position: { x: number; y: number }; - size: { width: number; height: number }; +id: string; +accepts: string[]; // ν—ˆμš©λ˜λŠ” μ»΄ν¬λ„ŒνŠΈ νƒ€μž… +position: { x: number; y: number }; +size: { width: number; height: number }; } -``` + +```` ### 2. μ»΄ν¬λ„ŒνŠΈ 배치 둜직 @@ -478,7 +596,7 @@ function resizeComponent( height: Math.max(50, newHeight), }; } -``` +```` ### 3. μ‹€μ‹œκ°„ 미리보기 @@ -492,21 +610,40 @@ function generatePreview(layout: LayoutData): React.ReactElement { ); } -// μ»΄ν¬λ„ŒνŠΈ λ Œλ”λ§ -function renderComponent(component: ComponentData): React.ReactElement { - switch (component.type) { - case "text": - return ; - case "select": - return ; +case "date": +return ; +default: +return
Unknown component
; +} +} + +```` + ## πŸ”— ν…Œμ΄λΈ” νƒ€μž… 연계 ### 1. μ›Ή νƒ€μž… μ„€μ • 방법 @@ -552,7 +689,7 @@ async function getTableColumnWebTypes( ): Promise { return api.get(`/table-management/tables/${tableName}/columns/web-types`); } -``` +```` #### μ›Ή νƒ€μž…λ³„ μΆ”κ°€ μ„€μ • (ν˜„μž¬ ν…Œμ΄λΈ” ꡬ쑰 기반) @@ -798,10 +935,15 @@ function generateValidationRules(column: ColumnInfo): ValidationRule[] { ### 1. ν™”λ©΄ μ •μ˜ API -#### ν™”λ©΄ λͺ©λ‘ 쑰회 +#### ν™”λ©΄ λͺ©λ‘ 쑰회 (νšŒμ‚¬λ³„) ```typescript GET /api/screen-management/screens +Query: { + companyCode?: string; // νšŒμ‚¬ μ½”λ“œ (κ΄€λ¦¬μžλŠ” μƒλž΅ κ°€λŠ₯) + page?: number; + size?: number; +} Response: { success: boolean; data: ScreenDefinition[]; @@ -809,6 +951,19 @@ Response: { } ``` +#### ν™”λ©΄ 생성 (νšŒμ‚¬λ³„) + +```typescript +POST /api/screen-management/screens +Body: { + screenName: string; + screenCode: string; + tableName: string; + companyCode: string; // μ‚¬μš©μž νšŒμ‚¬ μ½”λ“œ μžλ™ μ„€μ • + description?: string; +} +``` + #### ν™”λ©΄ 생성 ```typescript @@ -896,17 +1051,44 @@ Body: { ### 4. ν…œν”Œλ¦Ώ API -#### ν…œν”Œλ¦Ώ λͺ©λ‘ 쑰회 +#### ν…œν”Œλ¦Ώ λͺ©λ‘ 쑰회 (νšŒμ‚¬λ³„) ```typescript GET /api/screen-management/templates Query: { + companyCode?: string; // νšŒμ‚¬ μ½”λ“œ (κ΄€λ¦¬μžλŠ” μƒλž΅ κ°€λŠ₯) type?: string; isPublic?: boolean; createdBy?: string; } ``` +### 5. 메뉴 ν• λ‹Ή API + +#### ν™”λ©΄-메뉴 ν• λ‹Ή + +```typescript +POST /api/screen-management/screens/:screenId/menu-assignments +Body: { + menuId: number; + companyCode: string; // μ‚¬μš©μž νšŒμ‚¬ μ½”λ“œ μžλ™ μ„€μ • + displayOrder?: number; +} +``` + +#### 메뉴별 ν™”λ©΄ λͺ©λ‘ 쑰회 + +```typescript +GET /api/screen-management/menus/:menuId/screens +Query: { + companyCode: string; // νšŒμ‚¬ μ½”λ“œ ν•„μˆ˜ +} +Response: { + success: boolean; + data: ScreenDefinition[]; +} +``` + #### ν…œν”Œλ¦Ώ 적용 ```typescript @@ -934,37 +1116,89 @@ export default function ScreenDesigner() { dragSource: "toolbox", dropTarget: null, }); + const [groupState, setGroupState] = useState({ + isGrouping: false, + selectedComponents: [], + groupTarget: null, + groupMode: "create", + }); + const [userCompanyCode, setUserCompanyCode] = useState(""); +``` + +// μ»΄ν¬λ„ŒνŠΈ μΆ”κ°€ +const addComponent = (component: ComponentData) => { +setLayout((prev) => ({ +...prev, +components: [...prev.components, component], +})); +}; + +// μ»΄ν¬λ„ŒνŠΈ μ‚­μ œ +const removeComponent = (componentId: string) => { +setLayout((prev) => ({ +...prev, +components: prev.components.filter((c) => c.id !== componentId), +})); +}; + +// μ»΄ν¬λ„ŒνŠΈ 이동 +const moveComponent = (componentId: string, newPosition: Position) => { +setLayout((prev) => ({ +...prev, +components: prev.components.map((c) => +c.id === componentId ? { ...c, position: newPosition } : c +), +})); +}; + +// μ»΄ν¬λ„ŒνŠΈ κ·Έλ£Ήν™” +const groupComponents = (componentIds: string[], groupTitle?: string) => { +const groupId = generateId(); +const groupComponent: GroupProps = { +id: groupId, +type: "group", +title: groupTitle || "κ·Έλ£Ή", +width: 12, +height: 200, +padding: 16, +margin: 8, +backgroundColor: "#f8f9fa", +border: "1px solid #dee2e6", +borderRadius: 8, +shadow: "0 2px 4px rgba(0,0,0,0.1)", +collapsible: true, +collapsed: false, +children: componentIds, +}; - // μ»΄ν¬λ„ŒνŠΈ μΆ”κ°€ - const addComponent = (component: ComponentData) => { setLayout((prev) => ({ ...prev, - components: [...prev.components, component], + components: [...prev.components, groupComponent], })); - }; - // μ»΄ν¬λ„ŒνŠΈ μ‚­μ œ - const removeComponent = (componentId: string) => { - setLayout((prev) => ({ - ...prev, - components: prev.components.filter((c) => c.id !== componentId), - })); - }; +}; - // μ»΄ν¬λ„ŒνŠΈ 이동 - const moveComponent = (componentId: string, newPosition: Position) => { - setLayout((prev) => ({ - ...prev, - components: prev.components.map((c) => - c.id === componentId ? { ...c, position: newPosition } : c - ), - })); - }; +// κ·Έλ£Ήμ—μ„œ μ»΄ν¬λ„ŒνŠΈ 제거 +const ungroupComponent = (componentId: string, groupId: string) => { +setLayout((prev) => ({ +...prev, +components: prev.components.map((c) => { +if (c.id === groupId && c.type === "group") { +return { +...c, +children: c.children.filter((id) => id !== componentId), +}; +} +return c; +}), +})); +}; - return ( -
- - + + - c.id === selectedComponent)} - onPropertyChange={updateComponentProperty} + c.id === selectedComponent)} +onPropertyChange={updateComponentProperty} +onGroupCreate={groupComponents} +onGroupRemove={ungroupComponent} +/> + + - -
- ); + +); } -``` + +```` ### 2. λ“œλž˜κ·Έμ•€λ“œλ‘­ κ΅¬ν˜„ @@ -1024,7 +1267,7 @@ export function useDragAndDrop() { updateDropTarget, }; } -``` +```` ### 3. κ·Έλ¦¬λ“œ μ‹œμŠ€ν…œ @@ -1045,6 +1288,56 @@ export default function GridSystem({ children, columns = 12 }) { ); } +// κ·Έλ£Ήν™” 도ꡬ λͺ¨μŒ +export function GroupingToolbar({ + groupState, + onGroupStateChange, + onGroupCreate, + onGroupRemove, +}) { + const handleGroupCreate = () => { + if (groupState.selectedComponents.length > 1) { + const groupTitle = prompt("κ·Έλ£Ή 제λͺ©μ„ μž…λ ₯ν•˜μ„Έμš”:"); + onGroupCreate(groupState.selectedComponents, groupTitle); + onGroupStateChange({ + ...groupState, + isGrouping: false, + selectedComponents: [], + }); + } + }; + + const handleGroupRemove = () => { + if (groupState.groupTarget) { + onGroupRemove(groupState.selectedComponents[0], groupState.groupTarget); + onGroupStateChange({ + ...groupState, + isGrouping: false, + selectedComponents: [], + }); + } + }; + + return ( +
+ + +
+ ); +} + // GridItem.tsx interface GridItemProps { width: number; // 1-12 @@ -1080,15 +1373,22 @@ export default function GridItem({ ```typescript // screenManagementService.ts export class ScreenManagementService { - // ν™”λ©΄ μ •μ˜ 생성 + // ν™”λ©΄ μ •μ˜ 생성 (νšŒμ‚¬λ³„) async createScreen( - screenData: CreateScreenRequest + screenData: CreateScreenRequest, + userCompanyCode: string ): Promise { + // κΆŒν•œ 검증: μ‚¬μš©μž νšŒμ‚¬ μ½”λ“œ 확인 + if (userCompanyCode !== "*" && userCompanyCode !== screenData.companyCode) { + 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, }, @@ -1097,6 +1397,30 @@ export class ScreenManagementService { return this.mapToScreenDefinition(screen); } + // νšŒμ‚¬λ³„ ν™”λ©΄ λͺ©λ‘ 쑰회 + async getScreensByCompany( + companyCode: string, + page: number = 1, + size: number = 20 + ): Promise<{ screens: ScreenDefinition[]; total: number }> { + 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 { + screens: screens.map(this.mapToScreenDefinition), + total, + }; + + // λ ˆμ΄μ•„μ›ƒ μ €μž₯ async saveLayout(screenId: number, layoutData: LayoutData): Promise { // κΈ°μ‘΄ λ ˆμ΄μ•„μ›ƒ μ‚­μ œ @@ -1449,7 +1773,23 @@ export class TableTypeIntegrationService { ## 🎬 μ‚¬μš© μ‹œλ‚˜λ¦¬μ˜€ -### 1. μ›Ή νƒ€μž… μ„€μ • (ν…Œμ΄λΈ” νƒ€μž…κ΄€λ¦¬) +### 1. νšŒμ‚¬λ³„ ν™”λ©΄ 관리 + +#### 일반 μ‚¬μš©μž (νšŒμ‚¬ μ½”λ“œ: 'COMP001') + +1. **둜그인**: μžμ‹ μ˜ νšŒμ‚¬ μ½”λ“œλ‘œ μ‹œμŠ€ν…œ 둜그인 +2. **ν™”λ©΄ λͺ©λ‘ 쑰회**: μžμ‹ μ΄ μ†ν•œ νšŒμ‚¬μ˜ ν™”λ©΄λ§Œ ν‘œμ‹œ +3. **ν™”λ©΄ 생성**: νšŒμ‚¬ μ½”λ“œκ°€ μžλ™μœΌλ‘œ μ„€μ •λ˜μ–΄ 생성 +4. **메뉴 ν• λ‹Ή**: μžμ‹ μ˜ νšŒμ‚¬ λ©”λ‰΄μ—λ§Œ ν™”λ©΄ ν• λ‹Ή κ°€λŠ₯ + +#### κ΄€λ¦¬μž (νšŒμ‚¬ μ½”λ“œ: '\*') + +1. **둜그인**: κ΄€λ¦¬μž κΆŒν•œμœΌλ‘œ μ‹œμŠ€ν…œ 둜그인 +2. **전체 ν™”λ©΄ 쑰회**: λͺ¨λ“  νšŒμ‚¬μ˜ 화면을 쑰회/μˆ˜μ • κ°€λŠ₯ +3. **νšŒμ‚¬λ³„ ν™”λ©΄ 관리**: 각 νšŒμ‚¬λ³„λ‘œ ν™”λ©΄ 생성/μˆ˜μ •/μ‚­μ œ +4. **크둜슀 νšŒμ‚¬ 메뉴 ν• λ‹Ή**: λͺ¨λ“  νšŒμ‚¬μ˜ 메뉴에 ν™”λ©΄ ν• λ‹Ή κ°€λŠ₯ + +### 2. μ›Ή νƒ€μž… μ„€μ • (ν…Œμ΄λΈ” νƒ€μž…κ΄€λ¦¬) 1. **ν…Œμ΄λΈ” 선택**: ν…Œμ΄λΈ” νƒ€μž…κ΄€λ¦¬μ—μ„œ μ›Ή νƒ€μž…μ„ μ„€μ •ν•  ν…Œμ΄λΈ” 선택 2. **컬럼 관리**: ν•΄λ‹Ή ν…Œμ΄λΈ”μ˜ 컬럼 λͺ©λ‘μ—μ„œ μ›Ή νƒ€μž…μ„ μ„€μ •ν•  컬럼 선택 @@ -1462,38 +1802,48 @@ export class TableTypeIntegrationService { 5. **μ €μž₯**: μ›Ή νƒ€μž… 섀정을 λ°μ΄ν„°λ² μ΄μŠ€μ— μ €μž₯ 6. **연계 확인**: 화면관리 μ‹œμŠ€ν…œμ—μ„œ μžλ™ μœ„μ ― 생성 확인 -### 2. μƒˆλ‘œμš΄ ν™”λ©΄ 섀계 +### 3. μƒˆλ‘œμš΄ ν™”λ©΄ 섀계 1. **ν…Œμ΄λΈ” 선택**: ν…Œμ΄λΈ” νƒ€μž…κ΄€λ¦¬μ—μ„œ 섀계할 ν…Œμ΄λΈ” 선택 2. **μ›Ή νƒ€μž… 확인**: 각 컬럼의 μ›Ή νƒ€μž… μ„€μ • μƒνƒœ 확인 -3. **ν™”λ©΄ 생성**: ν™”λ©΄λͺ…κ³Ό μ½”λ“œλ₯Ό μž…λ ₯ν•˜μ—¬ μƒˆ ν™”λ©΄ 생성 +3. **ν™”λ©΄ 생성**: ν™”λ©΄λͺ…κ³Ό μ½”λ“œλ₯Ό μž…λ ₯ν•˜μ—¬ μƒˆ ν™”λ©΄ 생성 (νšŒμ‚¬ μ½”λ“œ μžλ™ μ„€μ •) 4. **μžλ™ μœ„μ ― 생성**: 컬럼의 μ›Ή νƒ€μž…μ— 따라 μžλ™μœΌλ‘œ μœ„μ ― 생성 5. **μ»΄ν¬λ„ŒνŠΈ 배치**: λ“œλž˜κ·Έμ•€λ“œλ‘­μœΌλ‘œ μ»΄ν¬λ„ŒνŠΈλ₯Ό μΊ”λ²„μŠ€μ— 배치 -6. **속성 μ„€μ •**: 각 μ»΄ν¬λ„ŒνŠΈμ˜ 속성을 Properties νŒ¨λ„μ—μ„œ μ„€μ • -7. **미리보기**: μ‹€μ‹œκ°„μœΌλ‘œ μ„€κ³„ν•œ ν™”λ©΄ 확인 -8. **μ €μž₯**: μ™„μ„±λœ ν™”λ©΄ λ ˆμ΄μ•„μ›ƒμ„ λ°μ΄ν„°λ² μ΄μŠ€μ— μ €μž₯ +6. **μ»¨ν…Œμ΄λ„ˆ κ·Έλ£Ήν™”**: κ΄€λ ¨ μ»΄ν¬λ„ŒνŠΈλ“€μ„ 그룹으둜 λ¬Άμ–΄ κΉ”λ”ν•˜κ²Œ μ •λ ¬ +7. **속성 μ„€μ •**: 각 μ»΄ν¬λ„ŒνŠΈμ˜ 속성을 Properties νŒ¨λ„μ—μ„œ μ„€μ • +8. **μ‹€μ‹œκ°„ 미리보기**: μ„€κ³„ν•œ 화면을 μ‹€μ œ ν™”λ©΄κ³Ό λ™μΌν•˜κ²Œ 확인 +9. **μ €μž₯**: μ™„μ„±λœ ν™”λ©΄ λ ˆμ΄μ•„μ›ƒμ„ λ°μ΄ν„°λ² μ΄μŠ€μ— μ €μž₯ -### 2. κΈ°μ‘΄ ν™”λ©΄ μˆ˜μ • +### 4. κΈ°μ‘΄ ν™”λ©΄ μˆ˜μ • -1. **ν™”λ©΄ 선택**: μˆ˜μ •ν•  화면을 λͺ©λ‘μ—μ„œ 선택 +1. **ν™”λ©΄ 선택**: μˆ˜μ •ν•  화면을 λͺ©λ‘μ—μ„œ 선택 (κΆŒν•œ 확인) 2. **λ ˆμ΄μ•„μ›ƒ λ‘œλ“œ**: κΈ°μ‘΄ λ ˆμ΄μ•„μ›ƒμ„ μΊ”λ²„μŠ€μ— λ‘œλ“œ 3. **μ»΄ν¬λ„ŒνŠΈ μˆ˜μ •**: μ»΄ν¬λ„ŒνŠΈ μΆ”κ°€/μ‚­μ œ/이동/μˆ˜μ • -4. **속성 λ³€κ²½**: μ»΄ν¬λ„ŒνŠΈ 속성 λ³€κ²½ -5. **변경사항 확인**: 미리보기둜 변경사항 확인 -6. **μ €μž₯**: μˆ˜μ •λœ λ ˆμ΄μ•„μ›ƒ μ €μž₯ +4. **κ·Έλ£Ή ꡬ쑰 μ‘°μ •**: μ»΄ν¬λ„ŒνŠΈ κ·Έλ£Ήν™”/κ·Έλ£Ή ν•΄μ œ/κ·Έλ£Ή 속성 λ³€κ²½ +5. **속성 λ³€κ²½**: μ»΄ν¬λ„ŒνŠΈ 속성 λ³€κ²½ +6. **변경사항 확인**: μ‹€μ‹œκ°„ 미리보기둜 변경사항 확인 +7. **μ €μž₯**: μˆ˜μ •λœ λ ˆμ΄μ•„μ›ƒ μ €μž₯ -### 3. ν…œν”Œλ¦Ώ ν™œμš© +### 5. ν…œν”Œλ¦Ώ ν™œμš© -1. **ν…œν”Œλ¦Ώ 선택**: μ ν•©ν•œ ν…œν”Œλ¦Ώμ„ λͺ©λ‘μ—μ„œ 선택 +1. **ν…œν”Œλ¦Ώ 선택**: μ ν•©ν•œ ν…œν”Œλ¦Ώμ„ λͺ©λ‘μ—μ„œ 선택 (νšŒμ‚¬λ³„ ν…œν”Œλ¦Ώ) 2. **ν…œν”Œλ¦Ώ 적용**: μ„ νƒν•œ ν…œν”Œλ¦Ώμ„ ν˜„μž¬ 화면에 적용 3. **μ»€μŠ€ν„°λ§ˆμ΄μ§•**: ν…œν”Œλ¦Ώμ„ 기반으둜 ν•„μš”ν•œ λΆ€λΆ„ μˆ˜μ • 4. **μ €μž₯**: μ»€μŠ€ν„°λ§ˆμ΄μ§•λœ ν™”λ©΄ μ €μž₯ -### 4. ν™”λ©΄ 배포 +### 6. 메뉴 ν• λ‹Ή 및 관리 + +1. **메뉴 선택**: 화면을 ν• λ‹Ήν•  메뉴 선택 (νšŒμ‚¬λ³„ λ©”λ‰΄λ§Œ ν‘œμ‹œ) +2. **ν™”λ©΄ ν• λ‹Ή**: μ„ νƒν•œ 화면을 메뉴에 ν• λ‹Ή +3. **ν• λ‹Ή μˆœμ„œ μ‘°μ •**: 메뉴 λ‚΄ ν™”λ©΄ ν‘œμ‹œ μˆœμ„œ μ‘°μ • +4. **ν• λ‹Ή ν•΄μ œ**: λ©”λ‰΄μ—μ„œ ν™”λ©΄ ν• λ‹Ή ν•΄μ œ +5. **κΆŒν•œ 확인**: 메뉴 ν• λ‹Ή μ‹œ νšŒμ‚¬ μ½”λ“œ 일치 μ—¬λΆ€ 확인 + +### 7. ν™”λ©΄ 배포 1. **ν™”λ©΄ ν™œμ„±ν™”**: 섀계 μ™„λ£Œλœ 화면을 ν™œμ„± μƒνƒœλ‘œ λ³€κ²½ -2. **κΆŒν•œ μ„€μ •**: ν™”λ©΄ μ ‘κ·Ό κΆŒν•œ μ„€μ • -3. **메뉴 μ—°κ²°**: 메뉴 μ‹œμŠ€ν…œμ— ν™”λ©΄ μ—°κ²° +2. **κΆŒν•œ μ„€μ •**: ν™”λ©΄ μ ‘κ·Ό κΆŒν•œ μ„€μ • (νšŒμ‚¬λ³„ κΆŒν•œ) +3. **메뉴 μ—°κ²°**: 메뉴 μ‹œμŠ€ν…œμ— ν™”λ©΄ μ—°κ²° (νšŒμ‚¬λ³„ 메뉴) 4. **ν…ŒμŠ€νŠΈ**: μ‹€μ œ ν™˜κ²½μ—μ„œ ν™”λ©΄ λ™μž‘ ν…ŒμŠ€νŠΈ 5. **배포**: 운영 ν™˜κ²½μ— ν™”λ©΄ 배포 @@ -1545,6 +1895,25 @@ export class TableTypeIntegrationService { ## 🎯 κ²°λ‘  -화면관리 μ‹œμŠ€ν…œμ€ ν…Œμ΄λΈ” νƒ€μž…κ΄€λ¦¬μ™€ μ—°κ³„ν•˜μ—¬ μ‚¬μš©μžκ°€ μ§κ΄€μ μœΌλ‘œ μ›Ή 화면을 섀계할 수 μžˆλŠ” κ°•λ ₯ν•œ λ„κ΅¬μž…λ‹ˆλ‹€. λ“œλž˜κ·Έμ•€λ“œλ‘­ μΈν„°νŽ˜μ΄μŠ€μ™€ μžλ™ μœ„μ ― 생성 κΈ°λŠ₯을 톡해 κ°œλ°œμžκ°€ μ•„λ‹Œ μ‚¬μš©μžλ„ 전문적인 μ›Ή 화면을 μ‰½κ²Œ ꡬ성할 수 μžˆμŠ΅λ‹ˆλ‹€. +화면관리 μ‹œμŠ€ν…œμ€ **νšŒμ‚¬λ³„ κΆŒν•œ 관리**와 **ν…Œμ΄λΈ” νƒ€μž…κ΄€λ¦¬ 연계**λ₯Ό 톡해 μ‚¬μš©μžκ°€ μ§κ΄€μ μœΌλ‘œ μ›Ή 화면을 섀계할 수 μžˆλŠ” κ°•λ ₯ν•œ λ„κ΅¬μž…λ‹ˆλ‹€. -이 μ‹œμŠ€ν…œμ„ 톡해 ERP μ‹œμŠ€ν…œμ˜ ν™”λ©΄ 개발 생산성을 크게 ν–₯μƒμ‹œν‚€κ³ , μ‚¬μš©μž μš”κ΅¬μ‚¬ν•­μ— λ”°λ₯Έ λΉ λ₯Έ ν™”λ©΄ ꡬ성이 κ°€λŠ₯ν•΄μ§ˆ κ²ƒμž…λ‹ˆλ‹€. +### 🏒 **νšŒμ‚¬λ³„ ν™”λ©΄ κ΄€λ¦¬μ˜ 핡심 κ°€μΉ˜** + +- **κΆŒν•œ 격리**: μ‚¬μš©μžλŠ” μžμ‹ μ΄ μ†ν•œ νšŒμ‚¬μ˜ ν™”λ©΄λ§Œ μ œμž‘/μˆ˜μ • κ°€λŠ₯ +- **κ΄€λ¦¬μž ν†΅μ œ**: νšŒμ‚¬ μ½”λ“œ '\*'인 κ΄€λ¦¬μžλŠ” λͺ¨λ“  νšŒμ‚¬μ˜ 화면을 μ œμ–΄ +- **메뉴 연동**: 각 νšŒμ‚¬μ˜ λ©”λ‰΄μ—λ§Œ ν™”λ©΄ ν• λ‹Ήν•˜μ—¬ μ™„λ²½ν•œ 데이터 뢄리 + +### 🎨 **ν–₯μƒλœ μ‚¬μš©μž κ²½ν—˜** + +- **λ“œλž˜κ·Έμ•€λ“œλ‘­ μΈν„°νŽ˜μ΄μŠ€**: 직관적인 ν™”λ©΄ 섀계 +- **μ»¨ν…Œμ΄λ„ˆ κ·Έλ£Ήν™”**: μ»΄ν¬λ„ŒνŠΈλ₯Ό κΉ”λ”ν•˜κ²Œ μ •λ ¬ν•˜λŠ” κ·Έλ£Ή κΈ°λŠ₯ +- **μ‹€μ‹œκ°„ 미리보기**: μ„€κ³„ν•œ 화면을 μ‹€μ œ ν™”λ©΄κ³Ό λ™μΌν•˜κ²Œ 확인 +- **μžλ™ μœ„μ ― 생성**: 컬럼의 μ›Ή νƒ€μž…μ— λ”°λ₯Έ μŠ€λ§ˆνŠΈν•œ μœ„μ ― 생성 + +### πŸš€ **기술적 ν˜œνƒ** + +- **κΈ°μ‘΄ ν…Œμ΄λΈ” ꡬ쑰 100% ν˜Έν™˜**: 별도 μŠ€ν‚€λ§ˆ λ³€κ²½ 없이 λ°”λ‘œ 개발 κ°€λŠ₯ +- **κΆŒν•œ 기반 λ³΄μ•ˆ**: νšŒμ‚¬ κ°„ 데이터 μ™„μ „ 격리 +- **ν™•μž₯ κ°€λŠ₯ν•œ μ•„ν‚€ν…μ²˜**: μƒˆλ‘œμš΄ μ›Ή νƒ€μž…κ³Ό μ»΄ν¬λ„ŒνŠΈ μ‰½κ²Œ μΆ”κ°€ + +이 μ‹œμŠ€ν…œμ„ 톡해 ERP μ‹œμŠ€ν…œμ˜ ν™”λ©΄ 개발 생산성을 크게 ν–₯μƒμ‹œν‚€κ³ , **νšŒμ‚¬λ³„ λ§žμΆ€ν˜• ν™”λ©΄ ꡬ성**κ³Ό **μ‚¬μš©μž μš”κ΅¬μ‚¬ν•­μ— λ”°λ₯Έ λΉ λ₯Έ ν™”λ©΄ ꡬ성**이 κ°€λŠ₯ν•΄μ§ˆ κ²ƒμž…λ‹ˆλ‹€. diff --git a/frontend/app/(main)/admin/page.tsx b/frontend/app/(main)/admin/page.tsx index 3f54bb3a..1de9f6a3 100644 --- a/frontend/app/(main)/admin/page.tsx +++ b/frontend/app/(main)/admin/page.tsx @@ -1,4 +1,5 @@ -import { Users, Shield, Settings, BarChart3 } from "lucide-react"; +import { Users, Shield, Settings, BarChart3, Palette } from "lucide-react"; +import Link from "next/link"; /** * κ΄€λ¦¬μž 메인 νŽ˜μ΄μ§€ */ @@ -6,18 +7,20 @@ export default function AdminPage() { 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..0b481e39 --- /dev/null +++ b/frontend/app/(main)/admin/screenMng/page.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Plus, ArrowLeft, ArrowRight, CheckCircle, Circle } 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"; + +// 단계별 진행을 μœ„ν•œ νƒ€μž… μ •μ˜ +type Step = "list" | "design" | "template"; + +export default function ScreenManagementPage() { + const [currentStep, setCurrentStep] = useState("list"); + const [selectedScreen, setSelectedScreen] = useState(null); + const [stepHistory, setStepHistory] = useState(["list"]); + + // 단계별 제λͺ©κ³Ό μ„€λͺ… + const stepConfig = { + list: { + title: "ν™”λ©΄ λͺ©λ‘ 관리", + description: "μƒμ„±λœ 화면듀을 ν™•μΈν•˜κ³  κ΄€λ¦¬ν•˜μ„Έμš”", + icon: "πŸ“‹", + }, + design: { + title: "ν™”λ©΄ 섀계", + description: "λ“œλž˜κ·Έμ•€λ“œλ‘­μœΌλ‘œ 화면을 μ„€κ³„ν•˜μ„Έμš”", + icon: "🎨", + }, + template: { + title: "ν…œν”Œλ¦Ώ 관리", + description: "ν™”λ©΄ ν…œν”Œλ¦Ώμ„ κ΄€λ¦¬ν•˜κ³  μž¬μ‚¬μš©ν•˜μ„Έμš”", + icon: "πŸ“", + }, + }; + + // λ‹€μŒ λ‹¨κ³„λ‘œ 이동 + const goToNextStep = (nextStep: Step) => { + setStepHistory((prev) => [...prev, nextStep]); + setCurrentStep(nextStep); + }; + + // 이전 λ‹¨κ³„λ‘œ 이동 + const goToPreviousStep = () => { + if (stepHistory.length > 1) { + const newHistory = stepHistory.slice(0, -1); + const previousStep = newHistory[newHistory.length - 1]; + setStepHistory(newHistory); + setCurrentStep(previousStep); + } + }; + + // νŠΉμ • λ‹¨κ³„λ‘œ 이동 + const goToStep = (step: Step) => { + setCurrentStep(step); + // ν•΄λ‹Ή λ‹¨κ³„κΉŒμ§€μ˜ νžˆμŠ€ν† λ¦¬λ§Œ μœ μ§€ + const stepIndex = stepHistory.findIndex((s) => s === step); + if (stepIndex !== -1) { + setStepHistory(stepHistory.slice(0, stepIndex + 1)); + } + }; + + // 단계별 μ§„ν–‰ μƒνƒœ 확인 + const isStepCompleted = (step: Step) => { + return stepHistory.includes(step); + }; + + // ν˜„μž¬ 단계가 λ§ˆμ§€λ§‰ 단계인지 확인 + const isLastStep = currentStep === "template"; + + return ( +
+ {/* νŽ˜μ΄μ§€ 헀더 */} +
+
+

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

+

λ‹¨κ³„λ³„λ‘œ 화면을 κ΄€λ¦¬ν•˜κ³  μ„€κ³„ν•˜μ„Έμš”

+
+
{stepConfig[currentStep].description}
+
+ + {/* 단계별 μ§„ν–‰ ν‘œμ‹œ */} +
+
+ {Object.entries(stepConfig).map(([step, config], index) => ( +
+
+ +
+
+ {config.title} +
+
+
+ {index < Object.keys(stepConfig).length - 1 && ( +
+ )} +
+ ))} +
+
+ + {/* 단계별 λ‚΄μš© */} +
+ {/* ν™”λ©΄ λͺ©λ‘ 단계 */} + {currentStep === "list" && ( +
+
+

{stepConfig.list.title}

+ +
+ { + setSelectedScreen(screen); + goToNextStep("design"); + }} + /> +
+ )} + + {/* ν™”λ©΄ 섀계 단계 */} + {currentStep === "design" && ( +
+ goToStep("list")} /> +
+ )} + + {/* ν…œν”Œλ¦Ώ 관리 단계 */} + {currentStep === "template" && ( +
+
+

{stepConfig.template.title}

+
+ + +
+
+ goToStep("list")} /> +
+ )} +
+
+ ); +} 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..82ac8100 --- /dev/null +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -0,0 +1,1021 @@ +"use client"; + +import { useState, useCallback, useEffect, useMemo } 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, + Table, + Settings, + ChevronDown, + ChevronRight, + List, + AlignLeft, +} from "lucide-react"; +import { + ScreenDefinition, + ComponentData, + LayoutData, + DragState, + GroupState, + ComponentType, + WebType, + WidgetComponent, + ColumnInfo, + TableInfo, +} 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"; +import { Badge } from "@/components/ui/badge"; + +interface ScreenDesignerProps { + selectedScreen: ScreenDefinition | null; + onBackToList: () => void; +} + +interface ComponentMoveState { + isMoving: boolean; + movingComponent: ComponentData | null; + originalPosition: { x: number; y: number }; + currentPosition: { x: number; y: number }; +} + +export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenDesignerProps) { + const [layout, setLayout] = useState({ + components: [], + gridSettings: { columns: 12, gap: 16, padding: 16 }, + }); + const [selectedComponent, setSelectedComponent] = useState(null); + const [dragState, setDragState] = useState({ + isDragging: false, + draggedComponent: null as ComponentData | null, + originalPosition: { x: 0, y: 0 }, + currentPosition: { 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 [activeTab, setActiveTab] = useState("tables"); + const [tables, setTables] = useState([]); + const [expandedTables, setExpandedTables] = useState>(new Set()); + + // ν…Œμ΄λΈ” 검색 및 νŽ˜μ΄μ§• μƒνƒœ μΆ”κ°€ + const [searchTerm, setSearchTerm] = useState(""); + const [currentPage, setCurrentPage] = useState(1); + const [itemsPerPage] = useState(10); + + // ν…Œμ΄λΈ” 데이터 λ‘œλ“œ (μ‹€μ œλ‘œλŠ” APIμ—μ„œ 가져와야 함) + useEffect(() => { + const fetchTables = async () => { + try { + const response = await fetch("http://localhost:8080/api/screen-management/tables", { + headers: { + Authorization: `Bearer ${localStorage.getItem("authToken")}`, + }, + }); + + if (response.ok) { + const data = await response.json(); + if (data.success) { + setTables(data.data); + } else { + console.error("ν…Œμ΄λΈ” 쑰회 μ‹€νŒ¨:", data.message); + // μž„μ‹œ λ°μ΄ν„°λ‘œ 폴백 + setTables(getMockTables()); + } + } else { + console.error("ν…Œμ΄λΈ” 쑰회 μ‹€νŒ¨:", response.status); + // μž„μ‹œ λ°μ΄ν„°λ‘œ 폴백 + setTables(getMockTables()); + } + } catch (error) { + console.error("ν…Œμ΄λΈ” 쑰회 쀑 였λ₯˜:", error); + // μž„μ‹œ λ°μ΄ν„°λ‘œ 폴백 + setTables(getMockTables()); + } + }; + + fetchTables(); + }, []); + + // κ²€μƒ‰λœ ν…Œμ΄λΈ” 필터링 + const filteredTables = useMemo(() => { + if (!searchTerm.trim()) return tables; + + return tables.filter( + (table) => + table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) || + table.tableLabel.toLowerCase().includes(searchTerm.toLowerCase()) || + table.columns.some( + (column) => + column.columnName.toLowerCase().includes(searchTerm.toLowerCase()) || + (column.columnLabel || column.columnName).toLowerCase().includes(searchTerm.toLowerCase()), + ), + ); + }, [tables, searchTerm]); + + // νŽ˜μ΄μ§•λœ ν…Œμ΄λΈ” + const paginatedTables = useMemo(() => { + const startIndex = (currentPage - 1) * itemsPerPage; + const endIndex = startIndex + itemsPerPage; + return filteredTables.slice(startIndex, endIndex); + }, [filteredTables, currentPage, itemsPerPage]); + + // 총 νŽ˜μ΄μ§€ 수 계산 + const totalPages = Math.ceil(filteredTables.length / itemsPerPage); + + // νŽ˜μ΄μ§€ λ³€κ²½ ν•Έλ“€λŸ¬ + const handlePageChange = (page: number) => { + setCurrentPage(page); + setExpandedTables(new Set()); // νŽ˜μ΄μ§€ λ³€κ²½ μ‹œ ν™•μž₯ μƒνƒœ μ΄ˆκΈ°ν™” + }; + + // 검색어 λ³€κ²½ ν•Έλ“€λŸ¬ + const handleSearchChange = (value: string) => { + setSearchTerm(value); + setCurrentPage(1); // 검색 μ‹œ 첫 νŽ˜μ΄μ§€λ‘œ 이동 + setExpandedTables(new Set()); // 검색 μ‹œ ν™•μž₯ μƒνƒœ μ΄ˆκΈ°ν™” + }; + + // μž„μ‹œ ν…Œμ΄λΈ” 데이터 (API μ‹€νŒ¨ μ‹œ μ‚¬μš©) + const getMockTables = (): TableInfo[] => [ + { + tableName: "user_info", + tableLabel: "μ‚¬μš©μž 정보", + columns: [ + { + tableName: "user_info", + columnName: "user_id", + columnLabel: "μ‚¬μš©μž ID", + webType: "text", + dataType: "VARCHAR", + isNullable: "NO", + }, + { + tableName: "user_info", + columnName: "user_name", + columnLabel: "μ‚¬μš©μžλͺ…", + webType: "text", + dataType: "VARCHAR", + isNullable: "NO", + }, + { + tableName: "user_info", + columnName: "email", + columnLabel: "이메일", + webType: "email", + dataType: "VARCHAR", + isNullable: "YES", + }, + { + tableName: "user_info", + columnName: "phone", + columnLabel: "μ „ν™”λ²ˆν˜Έ", + webType: "tel", + dataType: "VARCHAR", + isNullable: "YES", + }, + { + tableName: "user_info", + columnName: "birth_date", + columnLabel: "생년월일", + webType: "date", + dataType: "DATE", + isNullable: "YES", + }, + { + tableName: "user_info", + columnName: "is_active", + columnLabel: "ν™œμ„±ν™”", + webType: "checkbox", + dataType: "BOOLEAN", + isNullable: "NO", + }, + ], + }, + { + tableName: "product_info", + tableLabel: "μ œν’ˆ 정보", + columns: [ + { + tableName: "product_info", + columnName: "product_id", + columnLabel: "μ œν’ˆ ID", + webType: "text", + dataType: "VARCHAR", + isNullable: "NO", + }, + { + tableName: "product_info", + columnName: "product_name", + columnLabel: "μ œν’ˆλͺ…", + webType: "text", + dataType: "VARCHAR", + isNullable: "NO", + }, + { + tableName: "product_info", + columnName: "category", + columnLabel: "μΉ΄ν…Œκ³ λ¦¬", + webType: "select", + dataType: "VARCHAR", + isNullable: "YES", + }, + { + tableName: "product_info", + columnName: "price", + columnLabel: "가격", + webType: "number", + dataType: "DECIMAL", + isNullable: "YES", + }, + { + tableName: "product_info", + columnName: "description", + columnLabel: "μ„€λͺ…", + webType: "textarea", + dataType: "TEXT", + isNullable: "YES", + }, + { + tableName: "product_info", + columnName: "created_date", + columnLabel: "생성일", + webType: "date", + dataType: "TIMESTAMP", + isNullable: "NO", + }, + ], + }, + { + tableName: "order_info", + tableLabel: "μ£Όλ¬Έ 정보", + columns: [ + { + tableName: "order_info", + columnName: "order_id", + columnLabel: "μ£Όλ¬Έ ID", + webType: "text", + dataType: "VARCHAR", + isNullable: "NO", + }, + { + tableName: "order_info", + columnName: "customer_name", + columnLabel: "고객λͺ…", + webType: "text", + dataType: "VARCHAR", + isNullable: "NO", + }, + { + tableName: "order_info", + columnName: "order_date", + columnLabel: "주문일", + webType: "date", + dataType: "DATE", + isNullable: "NO", + }, + { + tableName: "order_info", + columnName: "total_amount", + columnLabel: "총 κΈˆμ•‘", + webType: "number", + dataType: "DECIMAL", + isNullable: "NO", + }, + { + tableName: "order_info", + columnName: "status", + columnLabel: "μƒνƒœ", + webType: "select", + dataType: "VARCHAR", + isNullable: "NO", + }, + { + tableName: "order_info", + columnName: "notes", + columnLabel: "λΉ„κ³ ", + webType: "textarea", + dataType: "TEXT", + isNullable: "YES", + }, + ], + }, + ]; + + // ν…Œμ΄λΈ” ν™•μž₯/μΆ•μ†Œ ν† κΈ€ + const toggleTableExpansion = useCallback((tableName: string) => { + setExpandedTables((prev) => { + const newSet = new Set(prev); + if (newSet.has(tableName)) { + newSet.delete(tableName); + } else { + newSet.add(tableName); + } + return newSet; + }); + }, []); + + // μ›Ήνƒ€μž…μ— λ”°λ₯Έ μœ„μ ― νƒ€μž… λ§€ν•‘ + const getWidgetTypeFromWebType = useCallback((webType: string): string => { + switch (webType) { + case "text": + case "email": + case "tel": + return "text"; + case "number": + case "decimal": + return "number"; + case "date": + case "datetime": + return "date"; + case "select": + case "dropdown": + return "select"; + case "textarea": + case "text_area": + return "textarea"; + case "checkbox": + case "boolean": + return "checkbox"; + case "radio": + return "radio"; + default: + return "text"; + } + }, []); + + // μ»΄ν¬λ„ŒνŠΈ μΆ”κ°€ ν•¨μˆ˜ + const addComponent = useCallback((componentData: Partial, position: { x: number; y: number }) => { + const newComponent: ComponentData = { + id: generateComponentId(), + type: "widget", + position, + size: { width: 6, height: 60 }, + tableName: "", + columnName: "", + widgetType: "text", + label: "", + required: false, + readonly: false, + ...componentData, + } as ComponentData; + + setLayout((prev) => ({ + ...prev, + components: [...prev.components, newComponent], + })); + }, []); + + // μ»΄ν¬λ„ŒνŠΈ 제거 ν•¨μˆ˜ + const removeComponent = useCallback( + (componentId: string) => { + setLayout((prev) => ({ + ...prev, + components: prev.components.filter((comp) => comp.id !== componentId), + })); + if (selectedComponent?.id === componentId) { + setSelectedComponent(null); + } + }, + [selectedComponent], + ); + + // μ»΄ν¬λ„ŒνŠΈ 속성 μ—…λ°μ΄νŠΈ ν•¨μˆ˜ + const updateComponentProperty = useCallback((componentId: string, propertyPath: string, value: any) => { + setLayout((prev) => ({ + ...prev, + components: prev.components.map((comp) => { + if (comp.id === componentId) { + const newComp = { ...comp }; + const pathParts = propertyPath.split("."); + let current: any = newComp; + + for (let i = 0; i < pathParts.length - 1; i++) { + current = current[pathParts[i]]; + } + current[pathParts[pathParts.length - 1]] = value; + + return newComp; + } + return comp; + }), + })); + }, []); + + // λ ˆμ΄μ•„μ›ƒ μ €μž₯ ν•¨μˆ˜ + const saveLayout = useCallback(async () => { + try { + // TODO: μ‹€μ œ API 호좜둜 λ³€κ²½ + console.log("λ ˆμ΄μ•„μ›ƒ μ €μž₯:", layout); + // await saveLayoutAPI(selectedScreen.screenId, layout); + } catch (error) { + console.error("λ ˆμ΄μ•„μ›ƒ μ €μž₯ μ‹€νŒ¨:", error); + } + }, [layout, selectedScreen]); + + // λ“œλž˜κ·Έ μ‹œμž‘ (μƒˆ μ»΄ν¬λ„ŒνŠΈ μΆ”κ°€) + const startDrag = useCallback((component: Partial, e: React.DragEvent) => { + setDragState({ + isDragging: true, + draggedComponent: component as ComponentData, + originalPosition: { x: 0, y: 0 }, + currentPosition: { x: 0, y: 0 }, + }); + e.dataTransfer.setData("application/json", JSON.stringify(component)); + }, []); + + // κΈ°μ‘΄ μ»΄ν¬λ„ŒνŠΈ λ“œλž˜κ·Έ μ‹œμž‘ (재배치) + const startComponentDrag = useCallback((component: ComponentData, e: React.DragEvent) => { + setDragState({ + isDragging: true, + draggedComponent: component, + originalPosition: component.position, + currentPosition: component.position, + }); + e.dataTransfer.setData("application/json", JSON.stringify({ ...component, isMoving: true })); + }, []); + + // λ“œλž˜κ·Έ 쀑 + const onDragOver = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + if (dragState.isDragging) { + const rect = e.currentTarget.getBoundingClientRect(); + const x = Math.floor((e.clientX - rect.left) / 80) * 80; + const y = Math.floor((e.clientY - rect.top) / 60) * 60; + + setDragState((prev) => ({ + ...prev, + currentPosition: { x, y }, + })); + } + }, + [dragState.isDragging], + ); + + // λ“œλ‘­ 처리 + const onDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + + try { + const data = JSON.parse(e.dataTransfer.getData("application/json")); + + if (data.isMoving) { + // κΈ°μ‘΄ μ»΄ν¬λ„ŒνŠΈ 재배치 + const rect = e.currentTarget.getBoundingClientRect(); + const x = Math.floor((e.clientX - rect.left) / 80) * 80; + const y = Math.floor((e.clientY - rect.top) / 60) * 60; + + setLayout((prev) => ({ + ...prev, + components: prev.components.map((comp) => (comp.id === data.id ? { ...comp, position: { x, y } } : comp)), + })); + } else { + // μƒˆ μ»΄ν¬λ„ŒνŠΈ μΆ”κ°€ + const rect = e.currentTarget.getBoundingClientRect(); + const x = Math.floor((e.clientX - rect.left) / 80) * 80; + const y = Math.floor((e.clientY - rect.top) / 60) * 60; + + const newComponent: ComponentData = { + ...data, + id: generateComponentId(), + position: { x, y }, + } as ComponentData; + + setLayout((prev) => ({ + ...prev, + components: [...prev.components, newComponent], + })); + } + } catch (error) { + console.error("λ“œλ‘­ 처리 쀑 였λ₯˜:", error); + } + + setDragState({ + isDragging: false, + draggedComponent: null, + originalPosition: { x: 0, y: 0 }, + currentPosition: { x: 0, y: 0 }, + }); + }, []); + + // λ“œλž˜κ·Έ μ’…λ£Œ + const endDrag = useCallback(() => { + setDragState({ + isDragging: false, + draggedComponent: null, + originalPosition: { x: 0, y: 0 }, + currentPosition: { x: 0, y: 0 }, + }); + }, []); + + // μ»΄ν¬λ„ŒνŠΈ 클릭 (선택) + const handleComponentClick = useCallback((component: ComponentData) => { + setSelectedComponent(component); + }, []); + + // μ»΄ν¬λ„ŒνŠΈ μ‚­μ œ + const deleteComponent = useCallback( + (componentId: string) => { + setLayout((prev) => ({ + ...prev, + components: prev.components.filter((comp) => comp.id !== componentId), + })); + if (selectedComponent?.id === componentId) { + setSelectedComponent(null); + } + }, + [selectedComponent], + ); + + // 화면이 μ„ νƒλ˜μ§€ μ•Šμ•˜μ„ λ•Œ 처리 + if (!selectedScreen) { + return ( +
+
+ +

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

+

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

+ +
+
+ ); + } + + return ( +
+ {/* 상단 헀더 */} +
+
+

{selectedScreen.screenName} - ν™”λ©΄ 섀계

+ + {selectedScreen.tableName} + +
+
+ + + +
+
+ + {/* 메인 컨텐츠 μ˜μ—­ */} +
+ {/* 쒌츑 μ‚¬μ΄λ“œλ°” - ν…Œμ΄λΈ” νƒ€μž… */} +
+
+

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

+ + {/* 검색 μž…λ ₯μ°½ */} +
+ handleSearchChange(e.target.value)} + className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-transparent focus:ring-2 focus:ring-blue-500 focus:outline-none" + /> +
+ + {/* 검색 κ²°κ³Ό 정보 */} +
+ 총 {filteredTables.length}개 ν…Œμ΄λΈ” 쀑 {(currentPage - 1) * itemsPerPage + 1}- + {Math.min(currentPage * itemsPerPage, filteredTables.length)}번째 +
+ +

ν…Œμ΄λΈ”κ³Ό μ»¬λŸΌμ„ λ“œλž˜κ·Έν•˜μ—¬ μΊ”λ²„μŠ€μ— λ°°μΉ˜ν•˜μ„Έμš”.

+
+ + {/* ν…Œμ΄λΈ” λͺ©λ‘ */} +
+ {paginatedTables.map((table) => ( +
+ {/* ν…Œμ΄λΈ” 헀더 */} +
+ startDrag( + { + type: "container", + tableName: table.tableName, + label: table.tableLabel, + size: { width: 12, height: 120 }, + }, + e, + ) + } + > +
+ +
+
{table.tableLabel}
+
{table.tableName}
+
+
+ +
+ + {/* 컬럼 λͺ©λ‘ */} + {expandedTables.has(table.tableName) && ( +
+ {table.columns.map((column) => ( +
+ startDrag( + { + type: "widget", + tableName: table.tableName, + columnName: column.columnName, + widgetType: getWidgetTypeFromWebType(column.webType || "text"), + label: column.columnLabel || column.columnName, + size: { width: 6, height: 60 }, + }, + e, + ) + } + > +
+ {column.webType === "text" && } + {column.webType === "number" && } + {column.webType === "date" && } + {column.webType === "select" && } + {column.webType === "textarea" && } + {column.webType === "checkbox" && } + {column.webType === "radio" && } + {!["text", "number", "date", "select", "textarea", "checkbox", "radio"].includes( + column.webType, + ) && } +
+
+
{column.columnLabel || column.columnName}
+
{column.columnName}
+
+
+ ))} +
+ )} +
+ ))} +
+ + {/* νŽ˜μ΄μ§• 컨트둀 */} + {totalPages > 1 && ( +
+
+ + +
+ {currentPage} / {totalPages} +
+ + +
+
+ )} +
+ + {/* 쀑앙: μΊ”λ²„μŠ€ μ˜μ—­ */} +
+
+
+ {layout.components.length === 0 ? ( +
+
+ +

빈 μΊ”λ²„μŠ€

+

μ’ŒμΈ‘μ—μ„œ ν…Œμ΄λΈ”μ΄λ‚˜ μ»¬λŸΌμ„ λ“œλž˜κ·Έν•˜μ—¬ λ°°μΉ˜ν•˜μ„Έμš”

+
+
+ ) : ( +
+ {/* κ·Έλ¦¬λ“œ κ°€μ΄λ“œ */} +
+
+ {Array.from({ length: 12 }).map((_, i) => ( +
+ ))} +
+
+ + {/* μ»΄ν¬λ„ŒνŠΈλ“€ */} + {layout.components.map((component) => ( +
handleComponentClick(component)} + draggable + onDragStart={(e) => startComponentDrag(component, e)} + onDragEnd={endDrag} + > +
+ {component.type === "container" && ( +
+ +
+
{component.label}
+
{component.tableName}
+
+
+ )} + {component.type === "widget" && ( +
+ {component.widgetType === "text" && } + {component.widgetType === "number" && } + {component.widgetType === "date" && } + {component.widgetType === "select" && } + {component.widgetType === "textarea" && } + {component.widgetType === "checkbox" && } + {component.widgetType === "radio" && } + {!["text", "number", "date", "select", "textarea", "checkbox", "radio"].includes( + component.widgetType || "text", + ) && } +
+
{component.label}
+
{component.columnName}
+
+
+ )} +
+
+ ))} +
+ )} +
+
+
+ + {/* 우츑: μ»΄ν¬λ„ŒνŠΈ μŠ€νƒ€μΌ νŽΈμ§‘ */} +
+
+

μ»΄ν¬λ„ŒνŠΈ 속성

+ + {selectedComponent ? ( +
+ + + + {selectedComponent.type === "container" && "ν…Œμ΄λΈ” 속성"} + {selectedComponent.type === "widget" && "μœ„μ ― 속성"} + + + + {/* μœ„μΉ˜ 속성 */} +
+
+ + + 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..c2999cee --- /dev/null +++ b/frontend/components/screen/ScreenList.tsx @@ -0,0 +1,260 @@ +"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, Palette } from "lucide-react"; +import { ScreenDefinition } from "@/types/screen"; + +interface ScreenListProps { + onScreenSelect: (screen: ScreenDefinition) => void; + selectedScreen: ScreenDefinition | null; + onDesignScreen: (screen: ScreenDefinition) => void; +} + +export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScreen }: 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}
+
+ + + + + + + onDesignScreen(screen)}> + + ν™”λ©΄ 섀계 + + 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..67557248 --- /dev/null +++ b/frontend/components/screen/TableTypeSelector.tsx @@ -0,0 +1,295 @@ +"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 { + selectedTable?: string; + onTableChange?: (tableName: string) => void; + onColumnWebTypeChange?: (columnInfo: ColumnInfo) => void; + className?: string; +} + +export default function TableTypeSelector({ + selectedTable: propSelectedTable, + onTableChange, + onColumnWebTypeChange, + 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); + if (onTableChange) { + onTableChange(tableName); + } + }; + + // 컬럼 선택 + const handleColumnSelect = (column: ColumnInfo) => { + if (onColumnWebTypeChange) { + onColumnWebTypeChange(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..4d759137 --- /dev/null +++ b/frontend/components/screen/TemplateManager.tsx @@ -0,0 +1,408 @@ +"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, FileText } from "lucide-react"; +import { ScreenTemplate, LayoutData, ScreenDefinition } from "@/types/screen"; +import { templateApi } from "@/lib/api/screen"; +import { useAuth } from "@/hooks/useAuth"; + +interface TemplateManagerProps { + selectedScreen: ScreenDefinition | null; + onBackToList: () => void; + onTemplateSelect?: (template: ScreenTemplate) => void; + onTemplateApply?: (template: ScreenTemplate) => void; + className?: string; +} + +export default function TemplateManager({ + selectedScreen, + onBackToList, + 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); + }; + + // 화면이 μ„ νƒλ˜μ§€ μ•Šμ•˜μ„ λ•Œ 처리 + if (!selectedScreen) { + return ( +
+ +

ν…œν”Œλ¦Ώμ„ μ μš©ν•  화면을 μ„ νƒν•΄μ£Όμ„Έμš”

+

ν™”λ©΄ λͺ©λ‘μ—μ„œ 화면을 μ„ νƒν•œ ν›„ ν…œν”Œλ¦Ώμ„ κ΄€λ¦¬ν•˜μ„Έμš”

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