From 42dbfd98f8439cc2d82510ea1b2e57813eaa1668 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 1 Sep 2025 11:48:12 +0900 Subject: [PATCH] =?UTF-8?q?=ED=99=94=EB=A9=B4=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EC=A4=91=EA=B0=84=20=EC=BB=A4=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/clean-screen-tables.js | 36 + backend-node/prisma/schema.prisma | 98 +- backend-node/prisma/screen-management.prisma | 94 ++ backend-node/src/app.ts | 2 + .../controllers/screenManagementController.ts | 455 ++++++++++ .../controllers/tableManagementController.ts | 74 ++ .../src/routes/screenManagementRoutes.ts | 159 ++++ .../src/routes/tableManagementRoutes.ts | 10 + .../src/services/screenManagementService.ts | 618 +++++++++++++ .../src/services/tableManagementService.ts | 170 +++- backend-node/src/types/screen.ts | 284 ++++++ backend-node/src/types/tableManagement.ts | 5 + backend-node/src/utils/generateId.ts | 56 ++ .../pms/service/TableManagementService.java | 11 +- docs/화면관리_시스템_설계.md | 5 +- frontend/app/(main)/admin/page.tsx | 39 +- frontend/app/(main)/admin/screenMng/page.tsx | 122 +++ frontend/app/(main)/admin/tableMng/page.tsx | 97 +- frontend/components/layout/AppLayout.tsx | 1 + frontend/components/screen/ScreenDesigner.tsx | 840 ++++++++++++++++++ frontend/components/screen/ScreenList.tsx | 255 ++++++ frontend/components/screen/ScreenPreview.tsx | 203 +++++ frontend/components/screen/StyleEditor.tsx | 385 ++++++++ .../components/screen/TableTypeSelector.tsx | 285 ++++++ .../components/screen/TemplateManager.tsx | 386 ++++++++ frontend/components/screen/WidgetFactory.tsx | 209 +++++ .../screen/layout/ColumnComponent.tsx | 78 ++ .../screen/layout/ContainerComponent.tsx | 78 ++ .../components/screen/layout/RowComponent.tsx | 78 ++ .../components/screen/widgets/InputWidget.tsx | 40 + .../screen/widgets/SelectWidget.tsx | 69 ++ .../screen/widgets/TextareaWidget.tsx | 39 + frontend/components/ui/separator.tsx | 21 + frontend/constants/tableManagement.ts | 35 + frontend/hooks/useAuth.ts | 1 + frontend/lib/api/screen.ts | 123 +++ frontend/lib/utils/generateId.ts | 56 ++ frontend/package-lock.json | 24 + frontend/package.json | 1 + frontend/types/screen.ts | 343 +++++++ 40 files changed, 5833 insertions(+), 52 deletions(-) create mode 100644 backend-node/clean-screen-tables.js create mode 100644 backend-node/prisma/screen-management.prisma create mode 100644 backend-node/src/controllers/screenManagementController.ts create mode 100644 backend-node/src/routes/screenManagementRoutes.ts create mode 100644 backend-node/src/services/screenManagementService.ts create mode 100644 backend-node/src/types/screen.ts create mode 100644 backend-node/src/utils/generateId.ts create mode 100644 frontend/app/(main)/admin/screenMng/page.tsx create mode 100644 frontend/components/screen/ScreenDesigner.tsx create mode 100644 frontend/components/screen/ScreenList.tsx create mode 100644 frontend/components/screen/ScreenPreview.tsx create mode 100644 frontend/components/screen/StyleEditor.tsx create mode 100644 frontend/components/screen/TableTypeSelector.tsx create mode 100644 frontend/components/screen/TemplateManager.tsx create mode 100644 frontend/components/screen/WidgetFactory.tsx create mode 100644 frontend/components/screen/layout/ColumnComponent.tsx create mode 100644 frontend/components/screen/layout/ContainerComponent.tsx create mode 100644 frontend/components/screen/layout/RowComponent.tsx create mode 100644 frontend/components/screen/widgets/InputWidget.tsx create mode 100644 frontend/components/screen/widgets/SelectWidget.tsx create mode 100644 frontend/components/screen/widgets/TextareaWidget.tsx create mode 100644 frontend/components/ui/separator.tsx create mode 100644 frontend/lib/api/screen.ts create mode 100644 frontend/lib/utils/generateId.ts create mode 100644 frontend/types/screen.ts diff --git a/backend-node/clean-screen-tables.js b/backend-node/clean-screen-tables.js new file mode 100644 index 00000000..61228f8d --- /dev/null +++ b/backend-node/clean-screen-tables.js @@ -0,0 +1,36 @@ +const { PrismaClient } = require("@prisma/client"); + +const prisma = new PrismaClient(); + +async function cleanScreenTables() { + try { + console.log("🧹 기존 화면관리 테이블들을 정리합니다..."); + + // 기존 테이블들을 순서대로 삭제 (외래키 제약조건 때문에 순서 중요) + await prisma.$executeRaw`DROP VIEW IF EXISTS v_screen_definitions_with_auth CASCADE`; + console.log("✅ 뷰 삭제 완료"); + + await prisma.$executeRaw`DROP TABLE IF EXISTS screen_menu_assignments CASCADE`; + console.log("✅ screen_menu_assignments 테이블 삭제 완료"); + + await prisma.$executeRaw`DROP TABLE IF EXISTS screen_widgets CASCADE`; + console.log("✅ screen_widgets 테이블 삭제 완료"); + + await prisma.$executeRaw`DROP TABLE IF EXISTS screen_layouts CASCADE`; + console.log("✅ screen_layouts 테이블 삭제 완료"); + + await prisma.$executeRaw`DROP TABLE IF EXISTS screen_templates CASCADE`; + console.log("✅ screen_templates 테이블 삭제 완료"); + + await prisma.$executeRaw`DROP TABLE IF EXISTS screen_definitions CASCADE`; + console.log("✅ screen_definitions 테이블 삭제 완료"); + + console.log("🎉 모든 화면관리 테이블 정리 완료!"); + } catch (error) { + console.error("❌ 테이블 정리 중 오류 발생:", error); + } finally { + await prisma.$disconnect(); + } +} + +cleanScreenTables(); diff --git a/backend-node/prisma/schema.prisma b/backend-node/prisma/schema.prisma index 31cbe566..23e0151d 100644 --- a/backend-node/prisma/schema.prisma +++ b/backend-node/prisma/schema.prisma @@ -5174,7 +5174,7 @@ model swhd010a_tbl { empno String @id(map: "pk_swhd010a_tbl") @db.Char(6) ltdcd String @db.Char(1) namehan String? @db.Char(10) - deptcd String? @db.Char(5) + deptcd String? @db.VarChar(5) resigngucd String? @db.VarChar(1) } @@ -6837,4 +6837,98 @@ model zz_230410_user_info { fax_no String? @db.VarChar @@ignore -} \ No newline at end of file +} +// 화면관리 시스템 Prisma 스키마 +// 기존 schema.prisma에 추가할 모델들 + +model screen_definitions { + screen_id Int @id @default(autoincrement()) + screen_name String @db.VarChar(100) + screen_code String @unique @db.VarChar(50) + table_name String @db.VarChar(100) + company_code String @db.VarChar(50) + description String? @db.Text + is_active String @default("Y") @db.Char(1) + created_date DateTime @default(now()) @db.Timestamp(6) + created_by String? @db.VarChar(50) + updated_date DateTime @default(now()) @db.Timestamp(6) + updated_by String? @db.VarChar(50) + + // 관계 + layouts screen_layouts[] + menu_assignments screen_menu_assignments[] + + @@index([company_code]) +} + +model screen_layouts { + layout_id Int @id @default(autoincrement()) + screen_id Int + component_type String @db.VarChar(50) + component_id String @unique @db.VarChar(100) + parent_id String? @db.VarChar(100) + position_x Int + position_y Int + width Int + height Int + properties Json? + display_order Int @default(0) + created_date DateTime @default(now()) @db.Timestamp(6) + + // 관계 + screen screen_definitions @relation(fields: [screen_id], references: [screen_id], onDelete: Cascade) + widgets screen_widgets[] + + @@index([screen_id]) +} + +model screen_widgets { + widget_id Int @id @default(autoincrement()) + layout_id Int + table_name String @db.VarChar(100) + column_name String @db.VarChar(100) + widget_type String @db.VarChar(50) + label String? @db.VarChar(200) + placeholder String? @db.VarChar(200) + is_required Boolean @default(false) + is_readonly Boolean @default(false) + validation_rules Json? + display_properties Json? + created_date DateTime @default(now()) @db.Timestamp(6) + + // 관계 + layout screen_layouts @relation(fields: [layout_id], references: [layout_id], onDelete: Cascade) + + @@index([layout_id]) +} + +model screen_templates { + template_id Int @id @default(autoincrement()) + template_name String @db.VarChar(100) + template_type String @db.VarChar(50) + company_code String @db.VarChar(50) + description String? @db.Text + layout_data Json? + is_public Boolean @default(false) + created_by String? @db.VarChar(50) + created_date DateTime @default(now()) @db.Timestamp(6) + + @@index([company_code]) +} + +model screen_menu_assignments { + assignment_id Int @id @default(autoincrement()) + screen_id Int + menu_objid Decimal @db.Decimal + company_code String @db.VarChar(50) + display_order Int @default(0) + is_active String @default("Y") @db.Char(1) + created_date DateTime @default(now()) @db.Timestamp(6) + created_by String? @db.VarChar(50) + + // 관계 + screen screen_definitions @relation(fields: [screen_id], references: [screen_id], onDelete: Cascade) + + @@unique([screen_id, menu_objid, company_code]) + @@index([company_code]) +} diff --git a/backend-node/prisma/screen-management.prisma b/backend-node/prisma/screen-management.prisma new file mode 100644 index 00000000..ab2475f6 --- /dev/null +++ b/backend-node/prisma/screen-management.prisma @@ -0,0 +1,94 @@ +// 화면관리 시스템 Prisma 스키마 +// 기존 schema.prisma에 추가할 모델들 + +model screen_definitions { + screen_id Int @id @default(autoincrement()) + screen_name String @db.VarChar(100) + screen_code String @unique @db.VarChar(50) + table_name String @db.VarChar(100) + company_code String @db.VarChar(50) + description String? @db.Text + is_active String @default("Y") @db.Char(1) + created_date DateTime @default(now()) @db.Timestamp(6) + created_by String? @db.VarChar(50) + updated_date DateTime @default(now()) @db.Timestamp(6) + updated_by String? @db.VarChar(50) + + // 관계 + layouts screen_layouts[] + menu_assignments screen_menu_assignments[] + + @@index([company_code]) +} + +model screen_layouts { + layout_id Int @id @default(autoincrement()) + screen_id Int + component_type String @db.VarChar(50) + component_id String @unique @db.VarChar(100) + parent_id String? @db.VarChar(100) + position_x Int + position_y Int + width Int + height Int + properties Json? + display_order Int @default(0) + created_date DateTime @default(now()) @db.Timestamp(6) + + // 관계 + screen screen_definitions @relation(fields: [screen_id], references: [screen_id], onDelete: Cascade) + widgets screen_widgets[] + + @@index([screen_id]) +} + +model screen_widgets { + widget_id Int @id @default(autoincrement()) + layout_id Int + table_name String @db.VarChar(100) + column_name String @db.VarChar(100) + widget_type String @db.VarChar(50) + label String? @db.VarChar(200) + placeholder String? @db.VarChar(200) + is_required Boolean @default(false) + is_readonly Boolean @default(false) + validation_rules Json? + display_properties Json? + created_date DateTime @default(now()) @db.Timestamp(6) + + // 관계 + layout screen_layouts @relation(fields: [layout_id], references: [layout_id], onDelete: Cascade) + + @@index([layout_id]) +} + +model screen_templates { + template_id Int @id @default(autoincrement()) + template_name String @db.VarChar(100) + template_type String @db.VarChar(50) + company_code String @db.VarChar(50) + description String? @db.Text + layout_data Json? + is_public Boolean @default(false) + created_by String? @db.VarChar(50) + created_date DateTime @default(now()) @db.Timestamp(6) + + @@index([company_code]) +} + +model screen_menu_assignments { + assignment_id Int @id @default(autoincrement()) + screen_id Int + menu_objid Decimal @db.Decimal + company_code String @db.VarChar(50) + display_order Int @default(0) + is_active String @default("Y") @db.Char(1) + created_date DateTime @default(now()) @db.Timestamp(6) + created_by String? @db.VarChar(50) + + // 관계 + screen screen_definitions @relation(fields: [screen_id], references: [screen_id], onDelete: Cascade) + + @@unique([screen_id, menu_objid, company_code]) + @@index([company_code]) +} diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index ac6f085d..df6cf3bd 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -13,6 +13,7 @@ import authRoutes from "./routes/authRoutes"; import adminRoutes from "./routes/adminRoutes"; import multilangRoutes from "./routes/multilangRoutes"; import tableManagementRoutes from "./routes/tableManagementRoutes"; +import screenManagementRoutes from "./routes/screenManagementRoutes"; // import userRoutes from './routes/userRoutes'; // import menuRoutes from './routes/menuRoutes'; @@ -63,6 +64,7 @@ app.use("/api/auth", authRoutes); app.use("/api/admin", adminRoutes); app.use("/api/multilang", multilangRoutes); app.use("/api/table-management", tableManagementRoutes); +app.use("/api/screen-management", screenManagementRoutes); // app.use('/api/users', userRoutes); // app.use('/api/menus', menuRoutes); diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts new file mode 100644 index 00000000..5b2c96b0 --- /dev/null +++ b/backend-node/src/controllers/screenManagementController.ts @@ -0,0 +1,455 @@ +import { Request, Response } from "express"; +import { ScreenManagementService } from "../services/screenManagementService"; +import { + CreateScreenRequest, + UpdateScreenRequest, + SaveLayoutRequest, + MenuAssignmentRequest, + ColumnWebTypeSetting, + WebType, +} from "../types/screen"; +import { logger } from "../utils/logger"; + +export class ScreenManagementController { + private screenService: ScreenManagementService; + + constructor() { + this.screenService = new ScreenManagementService(); + } + + // ======================================== + // 화면 정의 관리 + // ======================================== + + /** + * 화면 목록 조회 (회사별) + */ + async getScreens(req: Request, res: Response): Promise { + try { + const { page = 1, size = 20 } = req.query; + const userCompanyCode = (req as any).user?.company_code || "*"; + + const result = await this.screenService.getScreensByCompany( + userCompanyCode, + Number(page), + Number(size) + ); + + res.json({ + success: true, + data: result.data, + pagination: result.pagination, + }); + } catch (error) { + logger.error("화면 목록 조회 실패:", error); + res.status(500).json({ + success: false, + message: "화면 목록을 조회하는 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + /** + * 화면 생성 + */ + async createScreen(req: Request, res: Response): Promise { + try { + const screenData: CreateScreenRequest = req.body; + const userCompanyCode = (req as any).user?.company_code || "*"; + const userId = (req as any).user?.user_id || "system"; + + // 사용자 회사 코드 자동 설정 + if (userCompanyCode !== "*") { + screenData.companyCode = userCompanyCode; + } + screenData.createdBy = userId; + + const screen = await this.screenService.createScreen( + screenData, + userCompanyCode + ); + + res.status(201).json({ + success: true, + data: screen, + message: "화면이 성공적으로 생성되었습니다.", + }); + } catch (error) { + logger.error("화면 생성 실패:", error); + res.status(400).json({ + success: false, + message: + error instanceof Error ? error.message : "화면 생성에 실패했습니다.", + }); + } + } + + /** + * 화면 조회 + */ + async getScreen(req: Request, res: Response): Promise { + try { + const { screenId } = req.params; + const screen = await this.screenService.getScreenById(Number(screenId)); + + if (!screen) { + res.status(404).json({ + success: false, + message: "화면을 찾을 수 없습니다.", + }); + return; + } + + res.json({ + success: true, + data: screen, + }); + } catch (error) { + logger.error("화면 조회 실패:", error); + res.status(500).json({ + success: false, + message: "화면을 조회하는 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + /** + * 화면 수정 + */ + async updateScreen(req: Request, res: Response): Promise { + try { + const { screenId } = req.params; + const updateData: UpdateScreenRequest = req.body; + const userCompanyCode = (req as any).user?.company_code || "*"; + const userId = (req as any).user?.user_id || "system"; + + updateData.updatedBy = userId; + + const screen = await this.screenService.updateScreen( + Number(screenId), + updateData, + userCompanyCode + ); + + res.json({ + success: true, + data: screen, + message: "화면이 성공적으로 수정되었습니다.", + }); + } catch (error) { + logger.error("화면 수정 실패:", error); + res.status(400).json({ + success: false, + message: + error instanceof Error ? error.message : "화면 수정에 실패했습니다.", + }); + } + } + + /** + * 화면 삭제 + */ + async deleteScreen(req: Request, res: Response): Promise { + try { + const { screenId } = req.params; + const userCompanyCode = (req as any).user?.company_code || "*"; + + await this.screenService.deleteScreen(Number(screenId), userCompanyCode); + + res.json({ + success: true, + message: "화면이 성공적으로 삭제되었습니다.", + }); + } catch (error) { + logger.error("화면 삭제 실패:", error); + res.status(400).json({ + success: false, + message: + error instanceof Error ? error.message : "화면 삭제에 실패했습니다.", + }); + } + } + + // ======================================== + // 레이아웃 관리 + // ======================================== + + /** + * 레이아웃 조회 + */ + async getLayout(req: Request, res: Response): Promise { + try { + const { screenId } = req.params; + const layout = await this.screenService.getLayout(Number(screenId)); + + if (!layout) { + res.json({ + success: true, + data: { + components: [], + gridSettings: { + columns: 12, + gap: 16, + padding: 16, + }, + }, + }); + return; + } + + res.json({ + success: true, + data: layout, + }); + } catch (error) { + logger.error("레이아웃 조회 실패:", error); + res.status(500).json({ + success: false, + message: "레이아웃을 조회하는 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + /** + * 레이아웃 저장 + */ + async saveLayout(req: Request, res: Response): Promise { + try { + const { screenId } = req.params; + const layoutData: SaveLayoutRequest = req.body; + + await this.screenService.saveLayout(Number(screenId), layoutData); + + res.json({ + success: true, + message: "레이아웃이 성공적으로 저장되었습니다.", + }); + } catch (error) { + logger.error("레이아웃 저장 실패:", error); + res.status(400).json({ + success: false, + message: + error instanceof Error + ? error.message + : "레이아웃 저장에 실패했습니다.", + }); + } + } + + // ======================================== + // 템플릿 관리 + // ======================================== + + /** + * 템플릿 목록 조회 + */ + async getTemplates(req: Request, res: Response): Promise { + try { + const { type, isPublic } = req.query; + const userCompanyCode = (req as any).user?.company_code || "*"; + + const templates = await this.screenService.getTemplatesByCompany( + userCompanyCode, + type as string, + isPublic === "true" + ); + + res.json({ + success: true, + data: templates, + }); + } catch (error) { + logger.error("템플릿 목록 조회 실패:", error); + res.status(500).json({ + success: false, + message: "템플릿 목록을 조회하는 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + /** + * 템플릿 생성 + */ + async createTemplate(req: Request, res: Response): Promise { + try { + const templateData = req.body; + const userCompanyCode = (req as any).user?.company_code || "*"; + const userId = (req as any).user?.user_id || "system"; + + templateData.company_code = userCompanyCode; + templateData.created_by = userId; + + const template = await this.screenService.createTemplate(templateData); + + res.status(201).json({ + success: true, + data: template, + message: "템플릿이 성공적으로 생성되었습니다.", + }); + } catch (error) { + logger.error("템플릿 생성 실패:", error); + res.status(400).json({ + success: false, + message: + error instanceof Error + ? error.message + : "템플릿 생성에 실패했습니다.", + }); + } + } + + // ======================================== + // 메뉴 할당 관리 + // ======================================== + + /** + * 화면-메뉴 할당 + */ + async assignScreenToMenu(req: Request, res: Response): Promise { + try { + const { screenId } = req.params; + const assignmentData: MenuAssignmentRequest = req.body; + const userCompanyCode = (req as any).user?.company_code || "*"; + const userId = (req as any).user?.user_id || "system"; + + // 사용자 회사 코드 자동 설정 + if (userCompanyCode !== "*") { + assignmentData.companyCode = userCompanyCode; + } + assignmentData.createdBy = userId; + + await this.screenService.assignScreenToMenu( + Number(screenId), + assignmentData + ); + + res.json({ + success: true, + message: "화면이 메뉴에 성공적으로 할당되었습니다.", + }); + } catch (error) { + logger.error("메뉴 할당 실패:", error); + res.status(400).json({ + success: false, + message: + error instanceof Error ? error.message : "메뉴 할당에 실패했습니다.", + }); + } + } + + /** + * 메뉴별 화면 목록 조회 + */ + async getScreensByMenu(req: Request, res: Response): Promise { + try { + const { menuObjid } = req.params; + const userCompanyCode = (req as any).user?.company_code || "*"; + + const screens = await this.screenService.getScreensByMenu( + Number(menuObjid), + userCompanyCode + ); + + res.json({ + success: true, + data: screens, + }); + } catch (error) { + logger.error("메뉴별 화면 목록 조회 실패:", error); + res.status(500).json({ + success: false, + message: "메뉴별 화면 목록을 조회하는 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + // ======================================== + // 테이블 타입 연계 + // ======================================== + + /** + * 테이블 컬럼 정보 조회 + */ + async getTableColumns(req: Request, res: Response): Promise { + try { + const { tableName } = req.params; + const columns = await this.screenService.getColumnInfo(tableName); + + res.json({ + success: true, + data: columns, + }); + } catch (error) { + logger.error("테이블 컬럼 정보 조회 실패:", error); + res.status(500).json({ + success: false, + message: "테이블 컬럼 정보를 조회하는 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + /** + * 컬럼 웹 타입 설정 + */ + async setColumnWebType(req: Request, res: Response): Promise { + try { + const { tableName, columnName } = req.params; + const { webType, ...additionalSettings } = req.body; + + await this.screenService.setColumnWebType( + tableName, + columnName, + webType as WebType, + additionalSettings + ); + + res.json({ + success: true, + message: "컬럼 웹 타입이 성공적으로 설정되었습니다.", + }); + } catch (error) { + logger.error("컬럼 웹 타입 설정 실패:", error); + res.status(400).json({ + success: false, + message: + error instanceof Error + ? error.message + : "컬럼 웹 타입 설정에 실패했습니다.", + }); + } + } + + /** + * 테이블 목록 조회 (화면 생성용) + */ + async getTables(req: Request, res: Response): Promise { + try { + // PostgreSQL에서 사용 가능한 테이블 목록 조회 + const tables = await (this.screenService as any).prisma.$queryRaw` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_type = 'BASE TABLE' + ORDER BY table_name + `; + + res.json({ + success: true, + data: tables, + }); + } catch (error) { + logger.error("테이블 목록 조회 실패:", error); + res.status(500).json({ + success: false, + message: "테이블 목록을 조회하는 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } +} diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 10c38831..c755c5fd 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -443,3 +443,77 @@ export async function getColumnLabels( res.status(500).json(response); } } + +/** + * 컬럼 웹 타입 설정 + */ +export async function updateColumnWebType( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName, columnName } = req.params; + const { webType, detailSettings } = req.body; + + logger.info( + `=== 컬럼 웹 타입 설정 시작: ${tableName}.${columnName} = ${webType} ===` + ); + + if (!tableName || !columnName || !webType) { + const response: ApiResponse = { + success: false, + message: "테이블명, 컬럼명, 웹 타입이 모두 필요합니다.", + error: { + code: "MISSING_PARAMETERS", + details: "필수 파라미터가 누락되었습니다.", + }, + }; + res.status(400).json(response); + return; + } + + // PostgreSQL 클라이언트 생성 + const client = new Client({ + connectionString: process.env.DATABASE_URL, + }); + + await client.connect(); + + try { + const tableManagementService = new TableManagementService(client); + await tableManagementService.updateColumnWebType( + tableName, + columnName, + webType, + detailSettings + ); + + logger.info( + `컬럼 웹 타입 설정 완료: ${tableName}.${columnName} = ${webType}` + ); + + const response: ApiResponse = { + success: true, + message: "컬럼 웹 타입이 성공적으로 설정되었습니다.", + data: null, + }; + + res.status(200).json(response); + } finally { + await client.end(); + } + } catch (error) { + logger.error("컬럼 웹 타입 설정 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "컬럼 웹 타입 설정 중 오류가 발생했습니다.", + error: { + code: "WEB_TYPE_UPDATE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} diff --git a/backend-node/src/routes/screenManagementRoutes.ts b/backend-node/src/routes/screenManagementRoutes.ts new file mode 100644 index 00000000..e6d9655f --- /dev/null +++ b/backend-node/src/routes/screenManagementRoutes.ts @@ -0,0 +1,159 @@ +import express from "express"; +import { ScreenManagementController } from "../controllers/screenManagementController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = express.Router(); +const screenController = new ScreenManagementController(); + +// 모든 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + +// ======================================== +// 화면 정의 관리 +// ======================================== + +/** + * @route POST /screens + * @desc 새 화면 생성 + * @access Private + */ +router.post("/screens", screenController.createScreen.bind(screenController)); + +/** + * @route GET /screens + * @desc 회사별 화면 목록 조회 + * @access Private + */ +router.get("/screens", screenController.getScreens.bind(screenController)); + +/** + * @route GET /screens/:screenId + * @desc 특정 화면 조회 + * @access Private + */ +router.get( + "/screens/:screenId", + screenController.getScreen.bind(screenController) +); + +/** + * @route PUT /screens/:screenId + * @desc 화면 정보 수정 + * @access Private + */ +router.put( + "/screens/:screenId", + screenController.updateScreen.bind(screenController) +); + +/** + * @route DELETE /screens/:screenId + * @desc 화면 삭제 + * @access Private + */ +router.delete( + "/screens/:screenId", + screenController.deleteScreen.bind(screenController) +); + +// ======================================== +// 레이아웃 관리 +// ======================================== + +/** + * @route GET /screens/:screenId/layout + * @desc 화면 레이아웃 조회 + * @access Private + */ +router.get( + "/screens/:screenId/layout", + screenController.getLayout.bind(screenController) +); + +/** + * @route POST /screens/:screenId/layout + * @desc 화면 레이아웃 저장 + * @access Private + */ +router.post( + "/screens/:screenId/layout", + screenController.saveLayout.bind(screenController) +); + +// ======================================== +// 템플릿 관리 +// ======================================== + +/** + * @route GET /templates + * @desc 회사별 템플릿 목록 조회 + * @access Private + */ +router.get("/templates", screenController.getTemplates.bind(screenController)); + +/** + * @route POST /templates + * @desc 새 템플릿 생성 + * @access Private + */ +router.post( + "/templates", + screenController.createTemplate.bind(screenController) +); + +// ======================================== +// 메뉴 할당 관리 +// ======================================== + +/** + * @route POST /screens/:screenId/menu-assignments + * @desc 화면을 메뉴에 할당 + * @access Private + */ +router.post( + "/screens/:screenId/menu-assignments", + screenController.assignScreenToMenu.bind(screenController) +); + +/** + * @route GET /menus/:menuObjid/screens + * @desc 메뉴별 할당된 화면 목록 조회 + * @access Private + */ +router.get( + "/menus/:menuObjid/screens", + screenController.getScreensByMenu.bind(screenController) +); + +// ======================================== +// 테이블 타입 연계 +// ======================================== + +/** + * @route GET /tables + * @desc 사용 가능한 테이블 목록 조회 + * @access Private + */ +router.get("/tables", screenController.getTables.bind(screenController)); + +/** + * @route GET /tables/:tableName/columns + * @desc 테이블의 컬럼 정보 조회 + * @access Private + */ +router.get( + "/tables/:tableName/columns", + screenController.getTableColumns.bind(screenController) +); + +/** + * @route PUT /tables/:tableName/columns/:columnName/web-type + * @desc 컬럼의 웹 타입 설정 + * @access Private + */ +router.put( + "/tables/:tableName/columns/:columnName/web-type", + screenController.setColumnWebType.bind(screenController) +); + +export default router; diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index 9c7c7224..9851eb43 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -7,6 +7,7 @@ import { updateAllColumnSettings, getTableLabels, getColumnLabels, + updateColumnWebType, } from "../controllers/tableManagementController"; const router = express.Router(); @@ -53,4 +54,13 @@ router.get("/tables/:tableName/labels", getTableLabels); */ router.get("/tables/:tableName/columns/:columnName/labels", getColumnLabels); +/** + * 컬럼 웹 타입 설정 + * PUT /api/table-management/tables/:tableName/columns/:columnName/web-type + */ +router.put( + "/tables/:tableName/columns/:columnName/web-type", + updateColumnWebType +); + export default router; diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts new file mode 100644 index 00000000..dac621eb --- /dev/null +++ b/backend-node/src/services/screenManagementService.ts @@ -0,0 +1,618 @@ +import prisma from "../config/database"; +import { + ScreenDefinition, + CreateScreenRequest, + UpdateScreenRequest, + LayoutData, + SaveLayoutRequest, + ScreenTemplate, + MenuAssignmentRequest, + PaginatedResponse, + ComponentData, + ColumnInfo, + ColumnWebTypeSetting, + WebType, + WidgetData, +} from "../types/screen"; +import { generateId } from "../utils/generateId"; + +export class ScreenManagementService { + // ======================================== + // 화면 정의 관리 + // ======================================== + + /** + * 화면 정의 생성 + */ + async createScreen( + screenData: CreateScreenRequest, + userCompanyCode: string + ): Promise { + // 화면 코드 중복 확인 + const existingScreen = await prisma.screen_definitions.findUnique({ + where: { screen_code: screenData.screenCode }, + }); + + if (existingScreen) { + throw new Error("이미 존재하는 화면 코드입니다."); + } + + const screen = await prisma.screen_definitions.create({ + data: { + screen_name: screenData.screenName, + screen_code: screenData.screenCode, + table_name: screenData.tableName, + company_code: screenData.companyCode, + description: screenData.description, + created_by: screenData.createdBy, + }, + }); + + return this.mapToScreenDefinition(screen); + } + + /** + * 회사별 화면 목록 조회 (페이징 지원) + */ + async getScreensByCompany( + companyCode: string, + page: number = 1, + size: number = 20 + ): Promise> { + const whereClause = + companyCode === "*" ? {} : { company_code: companyCode }; + + const [screens, total] = await Promise.all([ + prisma.screen_definitions.findMany({ + where: whereClause, + skip: (page - 1) * size, + take: size, + orderBy: { created_date: "desc" }, + }), + prisma.screen_definitions.count({ where: whereClause }), + ]); + + return { + data: screens.map((screen) => this.mapToScreenDefinition(screen)), + pagination: { + page, + size, + total, + totalPages: Math.ceil(total / size), + }, + }; + } + + /** + * 화면 정의 조회 + */ + async getScreenById(screenId: number): Promise { + const screen = await prisma.screen_definitions.findUnique({ + where: { screen_id: screenId }, + }); + + return screen ? this.mapToScreenDefinition(screen) : null; + } + + /** + * 화면 정의 수정 + */ + async updateScreen( + screenId: number, + updateData: UpdateScreenRequest, + userCompanyCode: string + ): Promise { + // 권한 검증 + const screen = await prisma.screen_definitions.findUnique({ + where: { screen_id: screenId }, + }); + + if (!screen) { + throw new Error("화면을 찾을 수 없습니다."); + } + + if (userCompanyCode !== "*" && userCompanyCode !== screen.company_code) { + throw new Error("해당 화면을 수정할 권한이 없습니다."); + } + + const updatedScreen = await prisma.screen_definitions.update({ + where: { screen_id: screenId }, + data: { + screen_name: updateData.screenName, + description: updateData.description, + is_active: updateData.isActive, + updated_by: updateData.updatedBy, + updated_date: new Date(), + }, + }); + + return this.mapToScreenDefinition(updatedScreen); + } + + /** + * 화면 정의 삭제 + */ + async deleteScreen(screenId: number, userCompanyCode: string): Promise { + // 권한 검증 + const screen = await prisma.screen_definitions.findUnique({ + where: { screen_id: screenId }, + }); + + if (!screen) { + throw new Error("화면을 찾을 수 없습니다."); + } + + if (userCompanyCode !== "*" && userCompanyCode !== screen.company_code) { + throw new Error("해당 화면을 삭제할 권한이 없습니다."); + } + + // CASCADE로 인해 관련 레이아웃과 위젯도 자동 삭제됨 + await prisma.screen_definitions.delete({ + where: { screen_id: screenId }, + }); + } + + // ======================================== + // 레이아웃 관리 + // ======================================== + + /** + * 레이아웃 저장 + */ + async saveLayout( + screenId: number, + layoutData: SaveLayoutRequest + ): Promise { + // 화면 존재 확인 + const screen = await prisma.screen_definitions.findUnique({ + where: { screen_id: screenId }, + }); + + if (!screen) { + throw new Error("화면을 찾을 수 없습니다."); + } + + // 기존 레이아웃 삭제 + await prisma.screen_layouts.deleteMany({ + where: { screen_id: screenId }, + }); + + // 새 레이아웃 저장 + const layoutPromises = layoutData.components.map((component) => + prisma.screen_layouts.create({ + data: { + screen_id: screenId, + component_type: component.type, + component_id: component.id, + parent_id: component.parentId, + position_x: component.position.x, + position_y: component.position.y, + width: component.size.width, + height: component.size.height, + properties: component.properties, + display_order: component.displayOrder || 0, + }, + }) + ); + + await Promise.all(layoutPromises); + } + + /** + * 레이아웃 조회 + */ + async getLayout(screenId: number): Promise { + const layouts = await prisma.screen_layouts.findMany({ + where: { screen_id: screenId }, + orderBy: { display_order: "asc" }, + }); + + if (layouts.length === 0) { + return null; + } + + const components: ComponentData[] = layouts.map((layout) => { + const baseComponent = { + id: layout.component_id, + type: layout.component_type as any, + position: { x: layout.position_x, y: layout.position_y }, + size: { width: layout.width, height: layout.height }, + properties: layout.properties as Record, + displayOrder: layout.display_order, + }; + + // 컴포넌트 타입별 추가 속성 처리 + switch (layout.component_type) { + case "group": + return { + ...baseComponent, + type: "group", + title: (layout.properties as any)?.title, + backgroundColor: (layout.properties as any)?.backgroundColor, + border: (layout.properties as any)?.border, + borderRadius: (layout.properties as any)?.borderRadius, + shadow: (layout.properties as any)?.shadow, + padding: (layout.properties as any)?.padding, + margin: (layout.properties as any)?.margin, + collapsible: (layout.properties as any)?.collapsible, + collapsed: (layout.properties as any)?.collapsed, + children: (layout.properties as any)?.children || [], + }; + case "widget": + return { + ...baseComponent, + type: "widget", + tableName: (layout.properties as any)?.tableName, + columnName: (layout.properties as any)?.columnName, + widgetType: (layout.properties as any)?.widgetType, + label: (layout.properties as any)?.label, + placeholder: (layout.properties as any)?.placeholder, + required: (layout.properties as any)?.required, + readonly: (layout.properties as any)?.readonly, + validationRules: (layout.properties as any)?.validationRules, + displayProperties: (layout.properties as any)?.displayProperties, + }; + default: + return baseComponent; + } + }); + + return { + components, + gridSettings: { + columns: 12, + gap: 16, + padding: 16, + }, + }; + } + + // ======================================== + // 템플릿 관리 + // ======================================== + + /** + * 템플릿 목록 조회 (회사별) + */ + async getTemplatesByCompany( + companyCode: string, + type?: string, + isPublic?: boolean + ): Promise { + const whereClause: any = {}; + + if (companyCode !== "*") { + whereClause.company_code = companyCode; + } + + if (type) { + whereClause.template_type = type; + } + + if (isPublic !== undefined) { + whereClause.is_public = isPublic; + } + + const templates = await prisma.screen_templates.findMany({ + where: whereClause, + orderBy: { created_date: "desc" }, + }); + + return templates.map(this.mapToScreenTemplate); + } + + /** + * 템플릿 생성 + */ + async createTemplate( + templateData: Partial + ): Promise { + const template = await prisma.screen_templates.create({ + data: { + template_name: templateData.templateName!, + template_type: templateData.templateType!, + company_code: templateData.companyCode!, + description: templateData.description, + layout_data: templateData.layoutData + ? JSON.parse(JSON.stringify(templateData.layoutData)) + : null, + is_public: templateData.isPublic || false, + created_by: templateData.createdBy, + }, + }); + + return this.mapToScreenTemplate(template); + } + + // ======================================== + // 메뉴 할당 관리 + // ======================================== + + /** + * 화면-메뉴 할당 + */ + async assignScreenToMenu( + screenId: number, + assignmentData: MenuAssignmentRequest + ): Promise { + // 중복 할당 방지 + const existingAssignment = await prisma.screen_menu_assignments.findFirst({ + where: { + screen_id: screenId, + menu_objid: assignmentData.menuObjid, + company_code: assignmentData.companyCode, + }, + }); + + if (existingAssignment) { + throw new Error("이미 할당된 화면입니다."); + } + + await prisma.screen_menu_assignments.create({ + data: { + screen_id: screenId, + menu_objid: assignmentData.menuObjid, + company_code: assignmentData.companyCode, + display_order: assignmentData.displayOrder || 0, + created_by: assignmentData.createdBy, + }, + }); + } + + /** + * 메뉴별 화면 목록 조회 + */ + async getScreensByMenu( + menuObjid: number, + companyCode: string + ): Promise { + const assignments = await prisma.screen_menu_assignments.findMany({ + where: { + menu_objid: menuObjid, + company_code: companyCode, + is_active: "Y", + }, + include: { + screen: true, + }, + orderBy: { display_order: "asc" }, + }); + + return assignments.map((assignment) => + this.mapToScreenDefinition(assignment.screen) + ); + } + + // ======================================== + // 테이블 타입 연계 + // ======================================== + + /** + * 컬럼 정보 조회 (웹 타입 포함) + */ + async getColumnInfo(tableName: string): Promise { + const columns = await prisma.$queryRaw` + SELECT + c.column_name, + COALESCE(cl.column_label, c.column_name) as column_label, + c.data_type, + COALESCE(cl.web_type, 'text') as web_type, + c.is_nullable, + c.column_default, + c.character_maximum_length, + c.numeric_precision, + c.numeric_scale, + cl.detail_settings, + cl.code_category, + cl.reference_table, + cl.reference_column, + cl.is_visible, + cl.display_order, + cl.description + FROM information_schema.columns c + LEFT JOIN column_labels cl ON c.table_name = cl.table_name + AND c.column_name = cl.column_name + WHERE c.table_name = ${tableName} + ORDER BY COALESCE(cl.display_order, c.ordinal_position) + `; + + return columns as ColumnInfo[]; + } + + /** + * 웹 타입 설정 + */ + async setColumnWebType( + tableName: string, + columnName: string, + webType: WebType, + additionalSettings?: Partial + ): Promise { + await prisma.column_labels.upsert({ + where: { + table_name_column_name: { + table_name: tableName, + column_name: columnName, + }, + }, + update: { + web_type: webType, + column_label: additionalSettings?.columnLabel, + detail_settings: additionalSettings?.detailSettings + ? JSON.stringify(additionalSettings.detailSettings) + : null, + code_category: additionalSettings?.codeCategory, + reference_table: additionalSettings?.referenceTable, + reference_column: additionalSettings?.referenceColumn, + is_visible: additionalSettings?.isVisible ?? true, + display_order: additionalSettings?.displayOrder ?? 0, + description: additionalSettings?.description, + updated_date: new Date(), + }, + create: { + table_name: tableName, + column_name: columnName, + column_label: additionalSettings?.columnLabel, + web_type: webType, + detail_settings: additionalSettings?.detailSettings + ? JSON.stringify(additionalSettings.detailSettings) + : null, + code_category: additionalSettings?.codeCategory, + reference_table: additionalSettings?.referenceTable, + reference_column: additionalSettings?.referenceColumn, + is_visible: additionalSettings?.isVisible ?? true, + display_order: additionalSettings?.displayOrder ?? 0, + description: additionalSettings?.description, + created_date: new Date(), + }, + }); + } + + /** + * 웹 타입별 위젯 생성 + */ + generateWidgetFromColumn(column: ColumnInfo): WidgetData { + const baseWidget = { + id: generateId(), + tableName: column.tableName, + columnName: column.columnName, + type: column.webType || "text", + label: column.columnLabel || column.columnName, + required: column.isNullable === "N", + readonly: false, + }; + + // detail_settings JSON 파싱 + const detailSettings = column.detailSettings + ? JSON.parse(column.detailSettings) + : {}; + + switch (column.webType) { + case "text": + return { + ...baseWidget, + maxLength: detailSettings.maxLength || column.characterMaximumLength, + placeholder: `Enter ${column.columnLabel || column.columnName}`, + pattern: detailSettings.pattern, + }; + + case "number": + return { + ...baseWidget, + min: detailSettings.min, + max: + detailSettings.max || + (column.numericPrecision + ? Math.pow(10, column.numericPrecision) - 1 + : undefined), + step: + detailSettings.step || + (column.numericScale && column.numericScale > 0 + ? Math.pow(10, -column.numericScale) + : 1), + }; + + case "date": + return { + ...baseWidget, + format: detailSettings.format || "YYYY-MM-DD", + minDate: detailSettings.minDate, + maxDate: detailSettings.maxDate, + }; + + case "code": + return { + ...baseWidget, + codeCategory: column.codeCategory, + multiple: detailSettings.multiple || false, + searchable: detailSettings.searchable || false, + }; + + case "entity": + return { + ...baseWidget, + referenceTable: column.referenceTable, + referenceColumn: column.referenceColumn, + searchable: detailSettings.searchable || true, + multiple: detailSettings.multiple || false, + }; + + case "textarea": + return { + ...baseWidget, + rows: detailSettings.rows || 3, + maxLength: detailSettings.maxLength || column.characterMaximumLength, + }; + + case "select": + return { + ...baseWidget, + options: detailSettings.options || [], + multiple: detailSettings.multiple || false, + searchable: detailSettings.searchable || false, + }; + + case "checkbox": + return { + ...baseWidget, + defaultChecked: detailSettings.defaultChecked || false, + label: detailSettings.label || column.columnLabel, + }; + + case "radio": + return { + ...baseWidget, + options: detailSettings.options || [], + inline: detailSettings.inline || false, + }; + + case "file": + return { + ...baseWidget, + accept: detailSettings.accept || "*/*", + maxSize: detailSettings.maxSize || 10485760, // 10MB + multiple: detailSettings.multiple || false, + }; + + default: + return { + ...baseWidget, + type: "text", + }; + } + } + + // ======================================== + // 유틸리티 메서드 + // ======================================== + + private mapToScreenDefinition(data: any): ScreenDefinition { + return { + screenId: data.screen_id, + screenName: data.screen_name, + screenCode: data.screen_code, + tableName: data.table_name, + companyCode: data.company_code, + description: data.description, + isActive: data.is_active, + createdDate: data.created_date, + createdBy: data.created_by, + updatedDate: data.updated_date, + updatedBy: data.updated_by, + }; + } + + private mapToScreenTemplate(data: any): ScreenTemplate { + return { + templateId: data.template_id, + templateName: data.template_name, + templateType: data.template_type, + companyCode: data.company_code, + description: data.description, + layoutData: data.layout_data, + isPublic: data.is_public, + createdBy: data.created_by, + createdDate: data.created_date, + }; + } +} diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 863ab691..1dc3ce39 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -197,14 +197,18 @@ export class TableManagementService { // 각 컬럼 설정을 순차적으로 업데이트 for (const columnSetting of columnSettings) { - const columnName = - columnSetting.columnLabel || columnSetting.columnName; + // columnName은 실제 DB 컬럼명을 유지해야 함 + const columnName = columnSetting.columnName; if (columnName) { await this.updateColumnSettings( tableName, columnName, columnSetting ); + } else { + logger.warn( + `컬럼명이 누락된 설정: ${JSON.stringify(columnSetting)}` + ); } } @@ -310,4 +314,166 @@ export class TableManagementService { ); } } + + /** + * 컬럼 웹 타입 설정 + */ + async updateColumnWebType( + tableName: string, + columnName: string, + webType: string, + detailSettings?: Record + ): Promise { + try { + logger.info( + `컬럼 웹 타입 설정 시작: ${tableName}.${columnName} = ${webType}` + ); + + // 웹 타입별 기본 상세 설정 생성 + const defaultDetailSettings = this.generateDefaultDetailSettings(webType); + + // 사용자 정의 설정과 기본 설정 병합 + const finalDetailSettings = { + ...defaultDetailSettings, + ...detailSettings, + }; + + // column_labels 테이블에 해당 컬럼이 있는지 확인 + const checkQuery = ` + SELECT COUNT(*) as count + FROM column_labels + WHERE table_name = $1 AND column_name = $2 + `; + + const checkResult = await this.client.query(checkQuery, [ + tableName, + columnName, + ]); + + if (checkResult.rows[0].count > 0) { + // 기존 컬럼 라벨 업데이트 + const updateQuery = ` + UPDATE column_labels + SET web_type = $3, detail_settings = $4, updated_date = NOW() + WHERE table_name = $1 AND column_name = $2 + `; + + await this.client.query(updateQuery, [ + tableName, + columnName, + webType, + JSON.stringify(finalDetailSettings), + ]); + logger.info( + `컬럼 웹 타입 업데이트 완료: ${tableName}.${columnName} = ${webType}` + ); + } else { + // 새로운 컬럼 라벨 생성 + const insertQuery = ` + INSERT INTO column_labels ( + table_name, column_name, web_type, detail_settings, created_date, updated_date + ) VALUES ($1, $2, $3, $4, NOW(), NOW()) + `; + + await this.client.query(insertQuery, [ + tableName, + columnName, + webType, + JSON.stringify(finalDetailSettings), + ]); + logger.info( + `컬럼 라벨 생성 및 웹 타입 설정 완료: ${tableName}.${columnName} = ${webType}` + ); + } + } catch (error) { + logger.error( + `컬럼 웹 타입 설정 중 오류 발생: ${tableName}.${columnName}`, + error + ); + throw new Error( + `컬럼 웹 타입 설정 실패: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + /** + * 웹 타입별 기본 상세 설정 생성 + */ + private generateDefaultDetailSettings(webType: string): Record { + switch (webType) { + case "text": + return { + maxLength: 255, + pattern: null, + placeholder: null, + }; + + case "number": + return { + min: null, + max: null, + step: 1, + precision: 2, + }; + + case "date": + return { + format: "YYYY-MM-DD", + minDate: null, + maxDate: null, + }; + + case "code": + return { + codeCategory: null, + displayFormat: "label", + searchable: true, + multiple: false, + }; + + case "entity": + return { + referenceTable: null, + referenceColumn: null, + searchable: true, + multiple: false, + }; + + case "textarea": + return { + rows: 3, + maxLength: 1000, + placeholder: null, + }; + + case "select": + return { + options: [], + multiple: false, + searchable: false, + }; + + case "checkbox": + return { + defaultChecked: false, + label: null, + }; + + case "radio": + return { + options: [], + inline: false, + }; + + case "file": + return { + accept: "*/*", + maxSize: 10485760, // 10MB + multiple: false, + }; + + default: + return {}; + } + } } diff --git a/backend-node/src/types/screen.ts b/backend-node/src/types/screen.ts new file mode 100644 index 00000000..b44bc7fb --- /dev/null +++ b/backend-node/src/types/screen.ts @@ -0,0 +1,284 @@ +// 화면관리 시스템 타입 정의 + +// 기본 컴포넌트 타입 +export type ComponentType = "container" | "row" | "column" | "widget" | "group"; + +// 웹 타입 정의 +export type WebType = + | "text" + | "number" + | "date" + | "code" + | "entity" + | "textarea" + | "select" + | "checkbox" + | "radio" + | "file"; + +// 위치 정보 +export interface Position { + x: number; + y: number; +} + +// 크기 정보 +export interface Size { + width: number; // 1-12 그리드 + height: number; // 픽셀 +} + +// 기본 컴포넌트 인터페이스 +export interface BaseComponent { + id: string; + type: ComponentType; + position: Position; + size: Size; + properties?: Record; + displayOrder?: number; + parentId?: string; // 부모 컴포넌트 ID 추가 +} + +// 컨테이너 컴포넌트 +export interface ContainerComponent extends BaseComponent { + type: "container"; + backgroundColor?: string; + border?: string; + borderRadius?: number; + shadow?: string; + padding?: number; + margin?: number; +} + +// 그룹 컴포넌트 +export interface GroupComponent extends BaseComponent { + type: "group"; + title?: string; + backgroundColor?: string; + border?: string; + borderRadius?: number; + shadow?: string; + padding?: number; + margin?: number; + collapsible?: boolean; + collapsed?: boolean; + children: string[]; // 포함된 컴포넌트 ID 목록 +} + +// 행 컴포넌트 +export interface RowComponent extends BaseComponent { + type: "row"; + columns: number; // 1-12 + gap: number; + alignItems: "start" | "center" | "end"; + justifyContent: "start" | "center" | "end" | "space-between"; +} + +// 컬럼 컴포넌트 +export interface ColumnComponent extends BaseComponent { + type: "column"; + offset?: number; + order?: number; +} + +// 위젯 컴포넌트 +export interface WidgetComponent extends BaseComponent { + type: "widget"; + tableName: string; + columnName: string; + widgetType: WebType; + label: string; + placeholder?: string; + required: boolean; + readonly: boolean; + validationRules?: ValidationRule[]; + displayProperties?: Record; +} + +// 컴포넌트 유니온 타입 +export type ComponentData = + | ContainerComponent + | GroupComponent + | RowComponent + | ColumnComponent + | WidgetComponent; + +// 레이아웃 데이터 +export interface LayoutData { + components: ComponentData[]; + gridSettings?: GridSettings; +} + +// 그리드 설정 +export interface GridSettings { + columns: number; // 기본값: 12 + gap: number; // 기본값: 16px + padding: number; // 기본값: 16px +} + +// 유효성 검증 규칙 +export interface ValidationRule { + type: + | "required" + | "minLength" + | "maxLength" + | "pattern" + | "min" + | "max" + | "email" + | "url"; + value?: any; + message: string; +} + +// 화면 정의 +export interface ScreenDefinition { + screenId: number; + screenName: string; + screenCode: string; + tableName: string; + companyCode: string; + description?: string; + isActive: string; + createdDate: Date; + createdBy?: string; + updatedDate: Date; + updatedBy?: string; +} + +// 화면 생성 요청 +export interface CreateScreenRequest { + screenName: string; + screenCode: string; + tableName: string; + companyCode: string; + description?: string; + createdBy?: string; +} + +// 화면 수정 요청 +export interface UpdateScreenRequest { + screenName?: string; + description?: string; + isActive?: string; + updatedBy?: string; +} + +// 레이아웃 저장 요청 +export interface SaveLayoutRequest { + components: ComponentData[]; + gridSettings?: GridSettings; +} + +// 화면 템플릿 +export interface ScreenTemplate { + templateId: number; + templateName: string; + templateType: string; + companyCode: string; + description?: string; + layoutData?: LayoutData; + isPublic: boolean; + createdBy?: string; + createdDate: Date; +} + +// 메뉴 할당 요청 +export interface MenuAssignmentRequest { + menuObjid: number; + companyCode: string; + displayOrder?: number; + createdBy?: string; +} + +// 드래그 상태 +export interface DragState { + isDragging: boolean; + draggedItem: ComponentData | null; + dragSource: "toolbox" | "canvas"; + dropTarget: string | null; + dropZone?: DropZone; +} + +// 드롭 영역 +export interface DropZone { + id: string; + accepts: ComponentType[]; + position: Position; + size: Size; +} + +// 그룹화 상태 +export interface GroupState { + isGrouping: boolean; + selectedComponents: string[]; + groupTarget: string | null; + groupMode: "create" | "add" | "remove"; +} + +// 컬럼 정보 (테이블 타입관리 연계용) +export interface ColumnInfo { + tableName: string; + columnName: string; + columnLabel?: string; + dataType: string; + webType?: WebType; + isNullable: string; + columnDefault?: string; + characterMaximumLength?: number; + numericPrecision?: number; + numericScale?: number; + detailSettings?: string; // JSON 문자열 + codeCategory?: string; + referenceTable?: string; + referenceColumn?: string; + isVisible?: boolean; + displayOrder?: number; + description?: string; +} + +// 웹 타입 설정 +export interface ColumnWebTypeSetting { + tableName: string; + columnName: string; + webType: WebType; + columnLabel?: string; + detailSettings?: Record; + codeCategory?: string; + referenceTable?: string; + referenceColumn?: string; + isVisible?: boolean; + displayOrder?: number; + description?: string; +} + +// 위젯 데이터 +export interface WidgetData { + id: string; + tableName: string; + columnName: string; + type: WebType; + label: string; + required: boolean; + readonly: boolean; + [key: string]: any; // 추가 속성들 +} + +// API 응답 타입 +export interface ApiResponse { + success: boolean; + data?: T; + message?: string; + errorCode?: string; +} + +// 페이지네이션 응답 +export interface PaginatedResponse { + data: T[]; + pagination: { + total: number; + page: number; + size: number; + totalPages: number; + }; +} diff --git a/backend-node/src/types/tableManagement.ts b/backend-node/src/types/tableManagement.ts index 521c4c55..8aeb0727 100644 --- a/backend-node/src/types/tableManagement.ts +++ b/backend-node/src/types/tableManagement.ts @@ -107,6 +107,11 @@ export const WEB_TYPE_OPTIONS = [ label: "entity", description: "엔티티 참조 (참조테이블 지정)", }, + { value: "textarea", label: "textarea", description: "여러 줄 텍스트" }, + { value: "select", label: "select", description: "드롭다운 선택" }, + { value: "checkbox", label: "checkbox", description: "체크박스" }, + { value: "radio", label: "radio", description: "라디오 버튼" }, + { value: "file", label: "file", description: "파일 업로드" }, ] as const; export type WebType = (typeof WEB_TYPE_OPTIONS)[number]["value"]; diff --git a/backend-node/src/utils/generateId.ts b/backend-node/src/utils/generateId.ts new file mode 100644 index 00000000..415e5d40 --- /dev/null +++ b/backend-node/src/utils/generateId.ts @@ -0,0 +1,56 @@ +/** + * 고유 ID 생성 유틸리티 + */ + +/** + * UUID v4 생성 + */ +export function generateUUID(): string { + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) { + const r = (Math.random() * 16) | 0; + const v = c === "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +/** + * 짧은 고유 ID 생성 (8자리) + */ +export function generateShortId(): string { + return Math.random().toString(36).substring(2, 10); +} + +/** + * 긴 고유 ID 생성 (16자리) + */ +export function generateLongId(): string { + return Math.random().toString(36).substring(2, 18); +} + +/** + * 기본 ID 생성 (UUID v4) + */ +export function generateId(): string { + return generateUUID(); +} + +/** + * 타임스탬프 기반 ID 생성 + */ +export function generateTimestampId(): string { + return Date.now().toString(36) + Math.random().toString(36).substring(2); +} + +/** + * 컴포넌트 ID 생성 (화면관리 시스템용) + */ +export function generateComponentId(prefix: string = "comp"): string { + return `${prefix}_${generateShortId()}`; +} + +/** + * 화면 ID 생성 (화면관리 시스템용) + */ +export function generateScreenId(prefix: string = "screen"): string { + return `${prefix}_${generateShortId()}`; +} diff --git a/backend/src/main/java/com/pms/service/TableManagementService.java b/backend/src/main/java/com/pms/service/TableManagementService.java index b5dc7fff..305b5699 100644 --- a/backend/src/main/java/com/pms/service/TableManagementService.java +++ b/backend/src/main/java/com/pms/service/TableManagementService.java @@ -40,8 +40,8 @@ public class TableManagementService { Map paramMap = new HashMap<>(); paramMap.put("tableName", tableName); - paramMap.put("columnName", columnName); - paramMap.put("columnLabel", settings.get("columnLabel")); + paramMap.put("columnName", columnName); // 실제 DB 컬럼명 (변경 불가) + paramMap.put("columnLabel", settings.get("columnLabel")); // 사용자가 입력한 표시명 paramMap.put("webType", settings.get("webType")); paramMap.put("detailSettings", settings.get("detailSettings")); paramMap.put("codeCategory", settings.get("codeCategory")); @@ -49,6 +49,13 @@ public class TableManagementService { paramMap.put("referenceTable", settings.get("referenceTable")); paramMap.put("referenceColumn", settings.get("referenceColumn")); + // 디버깅을 위한 로그 추가 + System.out.println("저장할 컬럼 설정:"); + System.out.println(" tableName: " + tableName); + System.out.println(" columnName: " + columnName); + System.out.println(" columnLabel: " + settings.get("columnLabel")); + System.out.println(" webType: " + settings.get("webType")); + sqlSessionTemplate.update("tableManagement.updateColumnSettings", paramMap); } diff --git a/docs/화면관리_시스템_설계.md b/docs/화면관리_시스템_설계.md index 69b96058..105ff998 100644 --- a/docs/화면관리_시스템_설계.md +++ b/docs/화면관리_시스템_설계.md @@ -76,6 +76,7 @@ - **자동 상세 설정**: 웹 타입 선택 시 해당 타입에 맞는 기본 상세 설정을 자동으로 제공 - **실시간 저장**: 웹 타입 변경 시 즉시 백엔드 데이터베이스에 저장 - **오류 복구**: 저장 실패 시 원래 상태로 자동 복원 +- **상세 설정 편집**: 웹 타입별 상세 설정을 모달에서 JSON 형태로 편집 가능 #### 2. 웹 타입별 상세 설정 @@ -98,7 +99,8 @@ 2. **컬럼 확인**: 해당 테이블의 모든 컬럼 정보 표시 3. **웹 타입 설정**: 각 컬럼의 웹 타입을 드롭다운에서 선택 4. **자동 저장**: 선택 즉시 백엔드에 저장되고 상세 설정 자동 적용 -5. **추가 설정**: 필요시 상세 설정을 사용자 정의로 수정 +5. **상세 설정 편집**: "상세 설정 편집" 버튼을 클릭하여 JSON 형태로 추가 설정 수정 +6. **설정 저장**: 수정된 상세 설정을 저장하여 완료 ## 🏗️ 아키텍처 구조 @@ -1193,6 +1195,7 @@ return c; }; return ( +
{/* 관리자 기능 카드들 */} -
-
-
-
- -
-
-

사용자 관리

-

사용자 계정 및 권한 관리

+
+ +
+
+
+ +
+
+

사용자 관리

+

사용자 계정 및 권한 관리

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

화면관리

+

드래그앤드롭으로 화면 설계 및 관리

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

화면관리 시스템

+

드래그앤드롭으로 화면을 설계하고 관리하세요

+
+ +
+ + {/* 메인 컨텐츠 */} + + + + + 화면 관리 + + + + 화면 설계기 + + + + 템플릿 관리 + + + + 설정 + + + + {/* 화면 관리 탭 */} + + + + 화면 목록 + + + + + + + + {/* 화면 설계기 탭 */} + + + + 화면 설계기 + + + {selectedScreen ? ( + + ) : ( +
+ +

설계할 화면을 선택해주세요

+

화면 관리 탭에서 화면을 선택한 후 설계기를 사용하세요

+
+ )} +
+
+
+ + {/* 템플릿 관리 탭 */} + + + + 템플릿 관리 + + + + + + + + {/* 설정 탭 */} + + + + 화면관리 시스템 설정 + + +
+
+

테이블 타입 연계

+

테이블 타입관리 시스템과의 연계 설정을 관리합니다.

+
+
+

권한 관리

+

회사별 화면 접근 권한을 설정합니다.

+
+
+

기본 설정

+

그리드 시스템, 기본 컴포넌트 등의 기본 설정을 관리합니다.

+
+
+
+
+
+
+
+ ); +} diff --git a/frontend/app/(main)/admin/tableMng/page.tsx b/frontend/app/(main)/admin/tableMng/page.tsx index 36ddaba5..8e8463ff 100644 --- a/frontend/app/(main)/admin/tableMng/page.tsx +++ b/frontend/app/(main)/admin/tableMng/page.tsx @@ -93,6 +93,15 @@ export default function TableManagementPage() { description: getTextFromUI(option.descriptionKey, option.value), })); + // 웹타입 옵션 확인 (디버깅용) + useEffect(() => { + console.log("테이블 타입관리 - 웹타입 옵션 로드됨:", webTypeOptions); + console.log("테이블 타입관리 - 웹타입 옵션 개수:", webTypeOptions.length); + webTypeOptions.forEach((option, index) => { + console.log(`${index + 1}. ${option.value}: ${option.label}`); + }); + }, [webTypeOptions]); + // 참조 테이블 옵션 (실제 테이블 목록에서 가져옴) const referenceTableOptions = [ { value: "none", label: getTextFromUI(TABLE_MANAGEMENT_KEYS.LABEL_NONE, "선택 안함") }, @@ -258,16 +267,18 @@ export default function TableManagementPage() { try { const columnSetting = { - columnName: column.columnName, - columnLabel: column.displayName, - webType: column.webType, - detailSettings: column.detailSettings, - codeCategory: column.codeCategory, - codeValue: column.codeValue, - referenceTable: column.referenceTable, - referenceColumn: column.referenceColumn, + columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가) + columnLabel: column.displayName, // 사용자가 입력한 표시명 + webType: column.webType || "text", + detailSettings: column.detailSettings || "", + codeCategory: column.codeCategory || "", + codeValue: column.codeValue || "", + referenceTable: column.referenceTable || "", + referenceColumn: column.referenceColumn || "", }; + console.log("저장할 컬럼 설정:", columnSetting); + const response = await apiClient.post(`/table-management/tables/${selectedTable}/columns/settings`, [ columnSetting, ]); @@ -276,6 +287,11 @@ export default function TableManagementPage() { toast.success("컬럼 설정이 성공적으로 저장되었습니다."); // 원본 데이터 업데이트 setOriginalColumns((prev) => prev.map((col) => (col.columnName === column.columnName ? column : col))); + + // 저장 후 데이터 확인을 위해 다시 로드 + setTimeout(() => { + loadColumnTypes(selectedTable); + }, 1000); } else { toast.error(response.data.message || "컬럼 설정 저장에 실패했습니다."); } @@ -292,16 +308,18 @@ export default function TableManagementPage() { try { // 모든 컬럼의 설정 데이터 준비 const columnSettings = columns.map((column) => ({ - columnName: column.columnName, - columnLabel: column.displayName, // 라벨 추가 - webType: column.webType, - detailSettings: column.detailSettings, - codeCategory: column.codeCategory, - codeValue: column.codeValue, - referenceTable: column.referenceTable, - referenceColumn: column.referenceColumn, + columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가) + columnLabel: column.displayName, // 사용자가 입력한 표시명 + webType: column.webType || "text", + detailSettings: column.detailSettings || "", + codeCategory: column.codeCategory || "", + codeValue: column.codeValue || "", + referenceTable: column.referenceTable || "", + referenceColumn: column.referenceColumn || "", })); + console.log("저장할 전체 컬럼 설정:", columnSettings); + // 전체 테이블 설정을 한 번에 저장 const response = await apiClient.post( `/table-management/tables/${selectedTable}/columns/settings`, @@ -312,6 +330,11 @@ export default function TableManagementPage() { // 저장 성공 후 원본 데이터 업데이트 setOriginalColumns([...columns]); toast.success(`${columns.length}개의 컬럼 설정이 성공적으로 저장되었습니다.`); + + // 저장 후 데이터 확인을 위해 다시 로드 + setTimeout(() => { + loadColumnTypes(selectedTable); + }, 1000); } else { toast.error(response.data.message || "컬럼 설정 저장에 실패했습니다."); } @@ -503,24 +526,30 @@ export default function TableManagementPage() { {column.dbType} - +
+ + {/* 웹타입 옵션 개수 표시 */} +
+ 사용 가능한 웹타입: {webTypeOptions.length}개 +
+
{ if (name.includes("설정") || name.includes("setting")) return ; if (name.includes("로그") || name.includes("log")) return ; if (name.includes("메뉴") || name.includes("menu")) return ; + if (name.includes("화면관리") || name.includes("screen")) return ; return ; }; diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx new file mode 100644 index 00000000..9197096a --- /dev/null +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -0,0 +1,840 @@ +"use client"; + +import { useState, useCallback, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Palette, + Grid3X3, + Type, + Calendar, + Hash, + CheckSquare, + Radio, + FileText, + Save, + Undo, + Redo, + Eye, + Group, + Ungroup, + Database, + Trash2, +} from "lucide-react"; +import { + ScreenDefinition, + ComponentData, + LayoutData, + DragState, + GroupState, + ComponentType, + WebType, + WidgetComponent, + ColumnInfo, +} from "@/types/screen"; +import { generateComponentId } from "@/lib/utils/generateId"; +import ContainerComponent from "./layout/ContainerComponent"; +import RowComponent from "./layout/RowComponent"; +import ColumnComponent from "./layout/ColumnComponent"; +import WidgetFactory from "./WidgetFactory"; +import TableTypeSelector from "./TableTypeSelector"; +import ScreenPreview from "./ScreenPreview"; +import TemplateManager from "./TemplateManager"; +import StyleEditor from "./StyleEditor"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; + +interface ScreenDesignerProps { + screen: ScreenDefinition; +} + +interface ComponentMoveState { + isMoving: boolean; + movingComponent: ComponentData | null; + originalPosition: { x: number; y: number }; + currentPosition: { x: number; y: number }; +} + +export default function ScreenDesigner({ screen }: ScreenDesignerProps) { + const [layout, setLayout] = useState({ + components: [], + gridSettings: { columns: 12, gap: 16, padding: 16 }, + }); + const [selectedComponent, setSelectedComponent] = useState(null); + const [dragState, setDragState] = useState({ + isDragging: false, + draggedItem: null, + draggedComponent: null, + dragSource: "toolbox", + dropTarget: null, + dragOffset: { x: 0, y: 0 }, + }); + const [groupState, setGroupState] = useState({ + isGrouping: false, + selectedComponents: [], + groupTarget: null, + groupMode: "create", + }); + const [moveState, setMoveState] = useState({ + isMoving: false, + movingComponent: null, + originalPosition: { x: 0, y: 0 }, + currentPosition: { x: 0, y: 0 }, + }); + + // 기본 컴포넌트 정의 + const basicComponents = [ + { type: "text", label: "텍스트 입력", color: "bg-blue-500", icon: "Type" }, + { type: "number", label: "숫자 입력", color: "bg-green-500", icon: "Hash" }, + { type: "date", label: "날짜 선택", color: "bg-purple-500", icon: "Calendar" }, + { type: "select", label: "선택 박스", color: "bg-orange-500", icon: "CheckSquare" }, + { type: "textarea", label: "텍스트 영역", color: "bg-indigo-500", icon: "FileText" }, + { type: "checkbox", label: "체크박스", color: "bg-pink-500", icon: "CheckSquare" }, + { type: "radio", label: "라디오 버튼", color: "bg-yellow-500", icon: "Radio" }, + { type: "file", label: "파일 업로드", color: "bg-red-500", icon: "FileText" }, + { type: "code", label: "코드 입력", color: "bg-gray-500", icon: "Hash" }, + { type: "entity", label: "엔티티 선택", color: "bg-teal-500", icon: "Database" }, + ]; + + const layoutComponents = [ + { type: "container", label: "컨테이너", color: "bg-gray-500" }, + { type: "row", label: "행", color: "bg-yellow-500" }, + { type: "column", label: "열", color: "bg-red-500" }, + { type: "group", label: "그룹", color: "bg-teal-500" }, + ]; + + // 드래그 시작 + const startDrag = useCallback((componentType: ComponentType, source: "toolbox" | "canvas") => { + let componentData: ComponentData; + + if (componentType === "widget") { + // 위젯 컴포넌트 생성 + componentData = { + id: generateComponentId(componentType), + type: "widget", + position: { x: 0, y: 0 }, + size: { width: 6, height: 50 }, + label: "새 위젯", + tableName: "", + columnName: "", + widgetType: "text", + required: false, + readonly: false, + }; + } else if (componentType === "container") { + // 컨테이너 컴포넌트 생성 + componentData = { + id: generateComponentId(componentType), + type: "container", + position: { x: 0, y: 0 }, + size: { width: 12, height: 100 }, + title: "새 컨테이너", + children: [], + }; + } else if (componentType === "row") { + // 행 컴포넌트 생성 + componentData = { + id: generateComponentId(componentType), + type: "row", + position: { x: 0, y: 0 }, + size: { width: 12, height: 100 }, + children: [], + }; + } else if (componentType === "column") { + // 열 컴포넌트 생성 + componentData = { + id: generateComponentId(componentType), + type: "column", + position: { x: 0, y: 0 }, + size: { width: 6, height: 100 }, + children: [], + }; + } else if (componentType === "group") { + // 그룹 컴포넌트 생성 + componentData = { + id: generateComponentId(componentType), + type: "group", + position: { x: 0, y: 0 }, + size: { width: 12, height: 200 }, + title: "새 그룹", + children: [], + }; + } else { + throw new Error(`지원하지 않는 컴포넌트 타입: ${componentType}`); + } + + setDragState((prev) => ({ + ...prev, + isDragging: true, + draggedComponent: componentData, + dragOffset: { x: 0, y: 0 }, + })); + }, []); + + // 드래그 종료 + const endDrag = useCallback(() => { + setDragState({ + isDragging: false, + draggedComponent: null, + dragOffset: { x: 0, y: 0 }, + }); + }, []); + + // 컴포넌트 추가 + const addComponent = useCallback((component: ComponentData, position: { x: number; y: number }) => { + const newComponent = { + ...component, + id: generateComponentId(component.type), + position, + }; + + setLayout((prev) => ({ + ...prev, + components: [...prev.components, newComponent], + })); + }, []); + + // 컴포넌트 선택 + const selectComponent = useCallback((component: ComponentData) => { + setSelectedComponent(component); + }, []); + + // 컴포넌트 삭제 + const removeComponent = useCallback((componentId: string) => { + setLayout((prev) => ({ + ...prev, + components: prev.components.filter((c) => c.id !== componentId), + })); + setSelectedComponent(null); + }, []); + + // 컴포넌트 속성 업데이트 + const updateComponentProperty = useCallback((componentId: string, path: string, value: any) => { + setLayout((prev) => ({ + ...prev, + components: prev.components.map((c) => { + if (c.id === componentId) { + const newComponent = { ...c } as any; + const keys = path.split("."); + let current = newComponent; + for (let i = 0; i < keys.length - 1; i++) { + current = current[keys[i]]; + } + current[keys[keys.length - 1]] = value; + return newComponent; + } + return c; + }), + })); + }, []); + + // 레이아웃 저장 + const saveLayout = useCallback(() => { + console.log("레이아웃 저장:", layout); + // TODO: API 호출로 레이아웃 저장 + }, [layout]); + + // 컴포넌트 재배치 시작 + const startComponentMove = useCallback((component: ComponentData, e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + setMoveState({ + isMoving: true, + movingComponent: component, + originalPosition: { ...component.position }, + currentPosition: { ...component.position }, + }); + + setDragState((prev) => ({ + ...prev, + isDragging: true, + draggedComponent: component, + dragOffset: { x: 0, y: 0 }, + })); + }, []); + + // 컴포넌트 재배치 중 + const handleComponentMove = useCallback( + (e: MouseEvent) => { + if (!moveState.isMoving || !moveState.movingComponent) return; + + const canvas = document.getElementById("design-canvas"); + if (!canvas) return; + + const rect = canvas.getBoundingClientRect(); + const x = Math.floor((e.clientX - rect.left) / 50); + const y = Math.floor((e.clientY - rect.top) / 50); + + setMoveState((prev) => ({ + ...prev, + currentPosition: { x, y }, + })); + }, + [moveState.isMoving, moveState.movingComponent], + ); + + // 컴포넌트 재배치 완료 + const endComponentMove = useCallback(() => { + if (!moveState.isMoving || !moveState.movingComponent) return; + + const { movingComponent, currentPosition } = moveState; + + // 위치 업데이트 + setLayout((prev) => ({ + ...prev, + components: prev.components.map((c) => (c.id === movingComponent.id ? { ...c, position: currentPosition } : c)), + })); + + // 상태 초기화 + setMoveState({ + isMoving: false, + movingComponent: null, + originalPosition: { x: 0, y: 0 }, + currentPosition: { x: 0, y: 0 }, + }); + + setDragState((prev) => ({ + ...prev, + isDragging: false, + draggedComponent: null, + dragOffset: { x: 0, y: 0 }, + })); + }, [moveState]); + + // 마우스 이벤트 리스너 등록/해제 + useEffect(() => { + if (moveState.isMoving) { + document.addEventListener("mousemove", handleComponentMove); + document.addEventListener("mouseup", endComponentMove); + + return () => { + document.removeEventListener("mousemove", handleComponentMove); + document.removeEventListener("mouseup", endComponentMove); + }; + } + }, [moveState.isMoving, handleComponentMove, endComponentMove]); + + // 컴포넌트 렌더링 + const renderComponent = useCallback( + (component: ComponentData) => { + const isSelected = selectedComponent === component; + const isMoving = moveState.isMoving && moveState.movingComponent?.id === component.id; + const currentPosition = isMoving ? moveState.currentPosition : component.position; + + switch (component.type) { + case "container": + return ( + selectComponent(component)} + onMouseDown={(e) => startComponentMove(component, e)} + isMoving={isMoving} + > + {/* 컨테이너 내부의 자식 컴포넌트들 */} + {layout.components.filter((c) => c.parentId === component.id).map(renderComponent)} + + ); + case "row": + return ( + selectComponent(component)} + onMouseDown={(e) => startComponentMove(component, e)} + isMoving={isMoving} + > + {/* 행 내부의 자식 컴포넌트들 */} + {layout.components.filter((c) => c.parentId === component.id).map(renderComponent)} + + ); + case "column": + return ( + selectComponent(component)} + onMouseDown={(e) => startComponentMove(component, e)} + isMoving={isMoving} + > + {/* 열 내부의 자식 컴포넌트들 */} + {layout.components.filter((c) => c.parentId === component.id).map(renderComponent)} + + ); + case "widget": + return ( +
selectComponent(component)} + onMouseDown={(e) => startComponentMove(component, e)} + > + +
+ ); + default: + return null; + } + }, + [selectedComponent, moveState, selectComponent, startComponentMove, layout.components], + ); + + // 테이블 타입에서 컬럼 선택 시 위젯 생성 + const handleColumnSelect = useCallback( + (column: ColumnInfo) => { + const widgetComponent: WidgetComponent = { + id: generateComponentId("widget"), + type: "widget", + position: { x: 0, y: 0 }, + size: { width: 6, height: 50 }, + parentId: undefined, + tableName: column.tableName, + columnName: column.columnName, + widgetType: column.webType || "text", + label: column.columnLabel || column.columnName, + placeholder: `${column.columnLabel || column.columnName}을(를) 입력하세요`, + required: column.isNullable === "NO", + readonly: false, + validationRules: [], + displayProperties: {}, + style: { + // 웹 타입별 기본 스타일 + ...(column.webType === "date" && { + backgroundColor: "#fef3c7", + border: "1px solid #f59e0b", + }), + ...(column.webType === "number" && { + backgroundColor: "#dbeafe", + border: "1px solid #3b82f6", + }), + ...(column.webType === "select" && { + backgroundColor: "#f3e8ff", + border: "1px solid #8b5cf6", + }), + ...(column.webType === "checkbox" && { + backgroundColor: "#dcfce7", + border: "1px solid #22c55e", + }), + ...(column.webType === "radio" && { + backgroundColor: "#fef3c7", + border: "1px solid #f59e0b", + }), + ...(column.webType === "textarea" && { + backgroundColor: "#f1f5f9", + border: "1px solid #64748b", + }), + ...(column.webType === "file" && { + backgroundColor: "#fef2f2", + border: "1px solid #ef4444", + }), + ...(column.webType === "code" && { + backgroundColor: "#fef2f2", + border: "1px solid #ef4444", + fontFamily: "monospace", + }), + ...(column.webType === "entity" && { + backgroundColor: "#f0f9ff", + border: "1px solid #0ea5e9", + }), + }, + }; + + // 현재 캔버스의 빈 위치 찾기 + const occupiedPositions = new Set(); + layout.components.forEach((comp) => { + for (let x = comp.position.x; x < comp.position.x + comp.size.width; x++) { + for (let y = comp.position.y; y < comp.position.y + comp.size.height; y++) { + occupiedPositions.add(`${x},${y}`); + } + } + }); + + // 빈 위치 찾기 + let newX = 0, + newY = 0; + for (let y = 0; y < 20; y++) { + for (let x = 0; x < 12; x++) { + let canPlace = true; + for (let dx = 0; dx < widgetComponent.size.width; dx++) { + for (let dy = 0; dy < Math.ceil(widgetComponent.size.height / 50); dy++) { + if (occupiedPositions.has(`${x + dx},${y + dy}`)) { + canPlace = false; + break; + } + } + if (!canPlace) break; + } + if (canPlace) { + newX = x; + newY = y; + break; + } + } + if (newX !== 0 || newY !== 0) break; + } + + widgetComponent.position = { x: newX, y: newY }; + addComponent(widgetComponent, { x: newX, y: newY }); + }, + [layout.components, addComponent], + ); + + return ( +
+ {/* 왼쪽 툴바 */} +
+ {/* 기본 컴포넌트 */} + + + 기본 컴포넌트 + + + {basicComponents.map((component) => ( +
startDrag(component.type as ComponentType, "toolbox")} + onDragEnd={endDrag} + > +
+ {component.label} +
+ ))} + + + + {/* 레이아웃 컴포넌트 */} + + + 레이아웃 컴포넌트 + + + {layoutComponents.map((component) => ( +
startDrag(component.type as ComponentType, "toolbox")} + onDragEnd={endDrag} + > +
+ {component.label} +
+ ))} + + +
+ + {/* 중앙 메인 영역 */} +
+ + + + + 화면 설계 + + + + 테이블 타입 + + + + 미리보기 + + + + {/* 화면 설계 탭 */} + + + +
+ {screen.screenName} - 캔버스 +
+ + + +
+
+
+ +
{ + e.preventDefault(); + if (dragState.draggedComponent && dragState.draggedComponent.type === "widget") { + const rect = e.currentTarget.getBoundingClientRect(); + const x = Math.floor((e.clientX - rect.left) / 50); + const y = Math.floor((e.clientY - rect.top) / 50); + + // 위젯 컴포넌트의 경우 기본 컴포넌트에서 타입 정보를 가져옴 + const basicComponent = basicComponents.find( + (c) => c.type === (dragState.draggedComponent as any).widgetType, + ); + if (basicComponent) { + const widgetComponent: ComponentData = { + ...dragState.draggedComponent, + position: { x, y }, + label: basicComponent.label, + widgetType: basicComponent.type as WebType, + } as WidgetComponent; + addComponent(widgetComponent, { x, y }); + } + } else if (dragState.draggedComponent) { + const rect = e.currentTarget.getBoundingClientRect(); + const x = Math.floor((e.clientX - rect.left) / 50); + const y = Math.floor((e.clientY - rect.top) / 50); + addComponent(dragState.draggedComponent, { x, y }); + } + }} + onDragOver={(e) => e.preventDefault()} + > + {/* 그리드 가이드 */} +
+
+ {Array.from({ length: 12 }).map((_, i) => ( +
+ ))} +
+
+ + {/* 컴포넌트들 렌더링 */} + {layout.components.length > 0 ? ( + layout.components + .filter((c) => !c.parentId) // 최상위 컴포넌트만 렌더링 + .map(renderComponent) + ) : ( +
+
+ +

왼쪽 툴바에서 컴포넌트를 드래그하여 배치하세요

+
+
+ )} +
+ + + + + {/* 테이블 타입 탭 */} + + console.log("테이블 선택:", tableName)} + onColumnSelect={handleColumnSelect} + className="h-full" + /> + + + {/* 미리보기 탭 */} + + + + +
+ + {/* 오른쪽 속성 패널 */} +
+ + + 속성 + + + {selectedComponent ? ( + + + 일반 + + + 스타일 + + 고급 + + + {/* 일반 속성 탭 */} + +
+ + +
+
+ + +
+
+
+ + + updateComponentProperty(selectedComponent.id, "position.x", parseInt(e.target.value)) + } + /> +
+
+ + + updateComponentProperty(selectedComponent.id, "position.y", parseInt(e.target.value)) + } + /> +
+
+
+
+ + + updateComponentProperty(selectedComponent.id, "size.width", parseInt(e.target.value)) + } + /> +
+
+ + + updateComponentProperty(selectedComponent.id, "size.height", parseInt(e.target.value)) + } + /> +
+
+ + {/* 위젯 전용 속성 */} + {selectedComponent.type === "widget" && ( + <> + +
+ + updateComponentProperty(selectedComponent.id, "label", e.target.value)} + /> +
+
+ + updateComponentProperty(selectedComponent.id, "placeholder", e.target.value)} + /> +
+
+
+ + updateComponentProperty(selectedComponent.id, "required", e.target.checked) + } + /> + +
+
+ + updateComponentProperty(selectedComponent.id, "readonly", e.target.checked) + } + /> + +
+
+ + )} +
+ + {/* 스타일 속성 탭 */} + + updateComponentProperty(selectedComponent.id, "style", newStyle)} + /> + + + {/* 고급 속성 탭 */} + +
+ + +
+ +
+
+ ) : ( +
+ +

컴포넌트를 선택하여 속성을 편집하세요

+
+ )} +
+
+
+
+ ); +} diff --git a/frontend/components/screen/ScreenList.tsx b/frontend/components/screen/ScreenList.tsx new file mode 100644 index 00000000..02aedb7c --- /dev/null +++ b/frontend/components/screen/ScreenList.tsx @@ -0,0 +1,255 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search } from "lucide-react"; +import { ScreenDefinition } from "@/types/screen"; + +interface ScreenListProps { + onScreenSelect: (screen: ScreenDefinition) => void; + selectedScreen: ScreenDefinition | null; +} + +export default function ScreenList({ onScreenSelect, selectedScreen }: ScreenListProps) { + const [screens, setScreens] = useState([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(""); + const [currentPage, setCurrentPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + + // 샘플 데이터 (실제로는 API에서 가져옴) + useEffect(() => { + const mockScreens: ScreenDefinition[] = [ + { + screenId: 1, + screenName: "사용자 관리 화면", + screenCode: "USER_MANAGEMENT", + tableName: "user_info", + companyCode: "COMP001", + description: "사용자 정보를 관리하는 화면", + isActive: "Y", + createdDate: new Date("2024-01-15"), + updatedDate: new Date("2024-01-15"), + createdBy: "admin", + updatedBy: "admin", + }, + { + screenId: 2, + screenName: "부서 관리 화면", + screenCode: "DEPT_MANAGEMENT", + tableName: "dept_info", + companyCode: "COMP001", + description: "부서 정보를 관리하는 화면", + isActive: "Y", + createdDate: new Date("2024-01-16"), + updatedDate: new Date("2024-01-16"), + createdBy: "admin", + updatedBy: "admin", + }, + { + screenId: 3, + screenName: "제품 관리 화면", + screenCode: "PRODUCT_MANAGEMENT", + tableName: "product_info", + companyCode: "COMP001", + description: "제품 정보를 관리하는 화면", + isActive: "Y", + createdDate: new Date("2024-01-17"), + updatedDate: new Date("2024-01-17"), + createdBy: "admin", + updatedBy: "admin", + }, + ]; + + setScreens(mockScreens); + setLoading(false); + }, []); + + const filteredScreens = screens.filter( + (screen) => + screen.screenName.toLowerCase().includes(searchTerm.toLowerCase()) || + screen.screenCode.toLowerCase().includes(searchTerm.toLowerCase()) || + screen.tableName.toLowerCase().includes(searchTerm.toLowerCase()), + ); + + const handleScreenSelect = (screen: ScreenDefinition) => { + onScreenSelect(screen); + }; + + const handleEdit = (screen: ScreenDefinition) => { + // 편집 모달 열기 + console.log("편집:", screen); + }; + + const handleDelete = (screen: ScreenDefinition) => { + if (confirm(`"${screen.screenName}" 화면을 삭제하시겠습니까?`)) { + // 삭제 API 호출 + console.log("삭제:", screen); + } + }; + + const handleCopy = (screen: ScreenDefinition) => { + // 복사 모달 열기 + console.log("복사:", screen); + }; + + const handleView = (screen: ScreenDefinition) => { + // 미리보기 모달 열기 + console.log("미리보기:", screen); + }; + + if (loading) { + return ( +
+
로딩 중...
+
+ ); + } + + return ( +
+ {/* 검색 및 필터 */} +
+
+
+ + setSearchTerm(e.target.value)} + className="w-80 pl-10" + /> +
+
+ +
+ + {/* 화면 목록 테이블 */} + + + + 화면 목록 ({filteredScreens.length}) + + + + + + + 화면명 + 화면 코드 + 테이블명 + 상태 + 생성일 + 작업 + + + + {filteredScreens.map((screen) => ( + handleScreenSelect(screen)} + > + +
+
{screen.screenName}
+ {screen.description &&
{screen.description}
} +
+
+ + + {screen.screenCode} + + + + {screen.tableName} + + + + {screen.isActive === "Y" ? "활성" : "비활성"} + + + +
{screen.createdDate.toLocaleDateString()}
+
{screen.createdBy}
+
+ + + + + + + handleView(screen)}> + + 미리보기 + + handleEdit(screen)}> + + 편집 + + handleCopy(screen)}> + + 복사 + + handleDelete(screen)} className="text-red-600"> + + 삭제 + + + + +
+ ))} +
+
+ + {filteredScreens.length === 0 &&
검색 결과가 없습니다.
} +
+
+ + {/* 페이지네이션 */} + {totalPages > 1 && ( +
+ + + {currentPage} / {totalPages} + + +
+ )} +
+ ); +} diff --git a/frontend/components/screen/ScreenPreview.tsx b/frontend/components/screen/ScreenPreview.tsx new file mode 100644 index 00000000..742441c2 --- /dev/null +++ b/frontend/components/screen/ScreenPreview.tsx @@ -0,0 +1,203 @@ +"use client"; + +import { useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Eye, Smartphone, Monitor, Tablet } from "lucide-react"; +import { LayoutData, ComponentData } from "@/types/screen"; +import ContainerComponent from "./layout/ContainerComponent"; +import RowComponent from "./layout/RowComponent"; +import ColumnComponent from "./layout/ColumnComponent"; +import WidgetFactory from "./WidgetFactory"; + +interface ScreenPreviewProps { + layout: LayoutData; + screenName: string; + className?: string; +} + +type PreviewMode = "desktop" | "tablet" | "mobile"; + +export default function ScreenPreview({ layout, screenName, className }: ScreenPreviewProps) { + const [previewMode, setPreviewMode] = useState("desktop"); + const [formData, setFormData] = useState>({}); + + // 미리보기 모드별 스타일 + const getPreviewStyles = (mode: PreviewMode) => { + switch (mode) { + case "desktop": + return "w-full max-w-6xl mx-auto"; + case "tablet": + return "w-full max-w-2xl mx-auto border-x-8 border-gray-200"; + case "mobile": + return "w-full max-w-sm mx-auto border-x-4 border-gray-200"; + default: + return "w-full"; + } + }; + + // 폼 데이터 변경 처리 + const handleFormChange = (fieldId: string, value: any) => { + setFormData((prev) => ({ + ...prev, + [fieldId]: value, + })); + }; + + // 컴포넌트 렌더링 (미리보기용) + const renderPreviewComponent = (component: ComponentData) => { + const isSelected = false; // 미리보기에서는 선택 불가 + + switch (component.type) { + case "container": + return ( + {}}> + {layout.components.filter((c) => c.parentId === component.id).map(renderPreviewComponent)} + + ); + + case "row": + return ( + {}}> + {layout.components.filter((c) => c.parentId === component.id).map(renderPreviewComponent)} + + ); + + case "column": + return ( + {}}> + {layout.components.filter((c) => c.parentId === component.id).map(renderPreviewComponent)} + + ); + + case "widget": + return ( +
+ handleFormChange(component.id, value)} + /> +
+ ); + + default: + return null; + } + }; + + // 그리드 레이아웃으로 컴포넌트 배치 + const renderGridLayout = () => { + const { gridSettings } = layout; + const { columns, gap, padding } = gridSettings; + + return ( +
+
+ {layout.components + .filter((c) => !c.parentId) // 최상위 컴포넌트만 렌더링 + .map(renderPreviewComponent)} +
+
+ ); + }; + + // 폼 데이터 초기화 + const resetFormData = () => { + setFormData({}); + }; + + // 폼 데이터 출력 + const logFormData = () => { + console.log("폼 데이터:", formData); + }; + + return ( +
+ {/* 미리보기 헤더 */} + + +
+ + + {screenName} - 미리보기 + +
+ {/* 미리보기 모드 선택 */} +
+ + + +
+ + + +
+
+
+
+ + {/* 미리보기 컨텐츠 */} + + +
+ {layout.components.length > 0 ? ( + renderGridLayout() + ) : ( +
+
+ +

미리보기할 컴포넌트가 없습니다

+

화면 설계기에서 컴포넌트를 추가해주세요

+
+
+ )} +
+
+
+
+ ); +} diff --git a/frontend/components/screen/StyleEditor.tsx b/frontend/components/screen/StyleEditor.tsx new file mode 100644 index 00000000..991a7932 --- /dev/null +++ b/frontend/components/screen/StyleEditor.tsx @@ -0,0 +1,385 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Separator } from "@/components/ui/separator"; +import { Palette, Layout, Type, Square, Box, Eye, RotateCcw } from "lucide-react"; +import { ComponentStyle } from "@/types/screen"; + +interface StyleEditorProps { + style: ComponentStyle; + onStyleChange: (style: ComponentStyle) => void; + className?: string; +} + +export default function StyleEditor({ style, onStyleChange, className }: StyleEditorProps) { + const [localStyle, setLocalStyle] = useState(style); + + useEffect(() => { + setLocalStyle(style); + }, [style]); + + const handleStyleChange = (property: keyof ComponentStyle, value: any) => { + const newStyle = { ...localStyle, [property]: value }; + setLocalStyle(newStyle); + onStyleChange(newStyle); + }; + + const resetStyle = () => { + const resetStyle: ComponentStyle = {}; + setLocalStyle(resetStyle); + onStyleChange(resetStyle); + }; + + const applyStyle = () => { + onStyleChange(localStyle); + }; + + return ( +
+ + +
+ + + 스타일 편집 + +
+ + +
+
+
+ + + + + + 레이아웃 + + + + 여백 + + + + 테두리 + + + + 배경 + + + + 텍스트 + + + + {/* 레이아웃 탭 */} + +
+
+ + handleStyleChange("width", e.target.value)} + /> +
+
+ + handleStyleChange("height", e.target.value)} + /> +
+
+ +
+
+ + +
+
+ + +
+
+ + {localStyle.display === "flex" && ( + <> + +
+
+ + +
+
+ + +
+
+ + )} +
+ + {/* 여백 탭 */} + +
+
+ + handleStyleChange("margin", e.target.value)} + /> +
+
+ + handleStyleChange("padding", e.target.value)} + /> +
+
+ +
+
+ + handleStyleChange("gap", e.target.value)} + /> +
+
+
+ + {/* 테두리 탭 */} + +
+
+ + handleStyleChange("borderWidth", e.target.value)} + /> +
+
+ + +
+
+ +
+
+ + handleStyleChange("borderColor", e.target.value)} + /> +
+
+ + handleStyleChange("borderRadius", e.target.value)} + /> +
+
+
+ + {/* 배경 탭 */} + +
+ + handleStyleChange("backgroundColor", e.target.value)} + /> +
+ +
+ + handleStyleChange("backgroundImage", e.target.value)} + /> +
+
+ + {/* 텍스트 탭 */} + +
+
+ + handleStyleChange("color", e.target.value)} + /> +
+
+ + handleStyleChange("fontSize", e.target.value)} + /> +
+
+ +
+
+ + +
+
+ + +
+
+
+
+
+
+
+ ); +} diff --git a/frontend/components/screen/TableTypeSelector.tsx b/frontend/components/screen/TableTypeSelector.tsx new file mode 100644 index 00000000..c16a45de --- /dev/null +++ b/frontend/components/screen/TableTypeSelector.tsx @@ -0,0 +1,285 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Search, Database, Columns, Plus } from "lucide-react"; +import { ColumnInfo, WebType } from "@/types/screen"; +import { tableTypeApi } from "@/lib/api/screen"; +import { useAuth } from "@/hooks/useAuth"; + +interface TableTypeSelectorProps { + onTableSelect?: (tableName: string) => void; + onColumnSelect?: (column: ColumnInfo) => void; + className?: string; +} + +export default function TableTypeSelector({ onTableSelect, onColumnSelect, className }: TableTypeSelectorProps) { + const { user } = useAuth(); + const [tables, setTables] = useState< + Array<{ tableName: string; displayName: string; description: string; columnCount: string }> + >([]); + const [selectedTable, setSelectedTable] = useState(""); + const [columns, setColumns] = useState([]); + const [searchTerm, setSearchTerm] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + // 테이블 목록 조회 + useEffect(() => { + const fetchTables = async () => { + try { + setIsLoading(true); + const tableList = await tableTypeApi.getTables(); + setTables(tableList); + } catch (error) { + console.error("테이블 목록 조회 실패:", error); + // API 호출 실패 시 기본 테이블 목록 사용 + const fallbackTables = [ + { tableName: "user_info", displayName: "사용자 정보", description: "사용자 기본 정보", columnCount: "25" }, + { tableName: "product_info", displayName: "제품 정보", description: "제품 기본 정보", columnCount: "20" }, + { tableName: "order_info", displayName: "주문 정보", description: "주문 기본 정보", columnCount: "15" }, + { tableName: "company_info", displayName: "회사 정보", description: "회사 기본 정보", columnCount: "10" }, + { tableName: "menu_info", displayName: "메뉴 정보", description: "시스템 메뉴 정보", columnCount: "15" }, + { tableName: "auth_group", displayName: "권한 그룹", description: "사용자 권한 그룹", columnCount: "8" }, + ]; + setTables(fallbackTables); + } finally { + setIsLoading(false); + } + }; + + fetchTables(); + }, []); + + // 컬럼 정보 조회 + useEffect(() => { + if (!selectedTable) { + setColumns([]); + return; + } + + const fetchColumns = async () => { + try { + setIsLoading(true); + const columnList = await tableTypeApi.getColumns(selectedTable); + + // API 응답을 ColumnInfo 형식으로 변환 + const formattedColumns: ColumnInfo[] = columnList.map((col: any) => ({ + tableName: selectedTable, + columnName: col.column_name || col.columnName, + columnLabel: col.column_label || col.columnLabel || col.column_name || col.columnName, + dataType: col.data_type || col.dataType || "varchar", + webType: col.web_type || col.webType || "text", + isNullable: col.is_nullable || col.isNullable || "YES", + characterMaximumLength: col.character_maximum_length || col.characterMaximumLength, + isVisible: col.is_visible !== false, + displayOrder: col.display_order || col.displayOrder || 1, + })); + + setColumns(formattedColumns); + } catch (error) { + console.error("컬럼 정보 조회 실패:", error); + // API 호출 실패 시 기본 컬럼 정보 사용 + const fallbackColumns: ColumnInfo[] = [ + { + tableName: selectedTable, + columnName: "id", + columnLabel: "ID", + dataType: "integer", + webType: "number", + isNullable: "NO", + isVisible: true, + displayOrder: 1, + }, + { + tableName: selectedTable, + columnName: "name", + columnLabel: "이름", + dataType: "varchar", + webType: "text", + isNullable: "NO", + characterMaximumLength: 100, + isVisible: true, + displayOrder: 2, + }, + { + tableName: selectedTable, + columnName: "status", + columnLabel: "상태", + dataType: "varchar", + webType: "select", + isNullable: "YES", + characterMaximumLength: 20, + isVisible: true, + displayOrder: 3, + }, + { + tableName: selectedTable, + columnName: "created_date", + columnLabel: "생성일", + dataType: "timestamp", + webType: "date", + isNullable: "YES", + isVisible: true, + displayOrder: 4, + }, + ]; + setColumns(fallbackColumns); + } finally { + setIsLoading(false); + } + }; + + fetchColumns(); + }, [selectedTable]); + + // 테이블 선택 + const handleTableSelect = (tableName: string) => { + setSelectedTable(tableName); + onTableSelect?.(tableName); + }; + + // 컬럼 선택 + const handleColumnSelect = (column: ColumnInfo) => { + onColumnSelect?.(column); + }; + + // 웹 타입 변경 + const handleWebTypeChange = async (columnName: string, webType: WebType) => { + try { + await tableTypeApi.setColumnWebType(selectedTable, columnName, webType); + + // 로컬 상태 업데이트 + setColumns((prev) => prev.map((col) => (col.columnName === columnName ? { ...col, webType } : col))); + + console.log(`컬럼 ${columnName}의 웹 타입을 ${webType}로 변경했습니다.`); + } catch (error) { + console.error("웹 타입 설정 실패:", error); + alert("웹 타입 설정에 실패했습니다. 다시 시도해주세요."); + } + }; + + const filteredTables = tables.filter((table) => table.displayName.toLowerCase().includes(searchTerm.toLowerCase())); + + return ( +
+ {/* 테이블 선택 */} + + + + + 테이블 선택 + + + +
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> +
+ +
+ {filteredTables.map((table) => ( + + ))} +
+
+
+ + {/* 컬럼 정보 */} + {selectedTable && ( + + + + + {selectedTable} - 컬럼 정보 + + + + + + + 컬럼명 + 라벨 + 데이터 타입 + 웹 타입 + 필수 + 표시 + 액션 + + + + {columns.map((column) => ( + + {column.columnName} + {column.columnLabel} + + + {column.dataType} + + + + + + + + {column.isNullable === "NO" ? "필수" : "선택"} + + + + + {column.isVisible ? "표시" : "숨김"} + + + + + + + ))} + +
+
+
+ )} +
+ ); +} diff --git a/frontend/components/screen/TemplateManager.tsx b/frontend/components/screen/TemplateManager.tsx new file mode 100644 index 00000000..b30739a6 --- /dev/null +++ b/frontend/components/screen/TemplateManager.tsx @@ -0,0 +1,386 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Separator } from "@/components/ui/separator"; +import { Search, Plus, Download, Upload, Trash2, Eye, Edit } from "lucide-react"; +import { ScreenTemplate, LayoutData } from "@/types/screen"; +import { templateApi } from "@/lib/api/screen"; +import { useAuth } from "@/hooks/useAuth"; + +interface TemplateManagerProps { + onTemplateSelect?: (template: ScreenTemplate) => void; + onTemplateApply?: (template: ScreenTemplate) => void; + className?: string; +} + +export default function TemplateManager({ onTemplateSelect, onTemplateApply, className }: TemplateManagerProps) { + const { user } = useAuth(); + const [templates, setTemplates] = useState([]); + const [selectedTemplate, setSelectedTemplate] = useState(null); + const [searchTerm, setSearchTerm] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [activeTab, setActiveTab] = useState("my"); + + // 템플릿 목록 조회 + useEffect(() => { + const fetchTemplates = async () => { + try { + setIsLoading(true); + const templateList = await templateApi.getTemplates({ + companyCode: user?.company_code || "*", + }); + setTemplates(templateList); + } catch (error) { + console.error("템플릿 목록 조회 실패:", error); + // API 호출 실패 시 기본 템플릿 목록 사용 + const fallbackTemplates: ScreenTemplate[] = [ + { + templateId: 1, + templateName: "기본 CRUD 화면", + templateType: "CRUD", + companyCode: "*", + description: "기본적인 CRUD 기능을 제공하는 화면 템플릿", + layoutData: { + components: [ + { + id: "search-container", + type: "container", + position: { x: 0, y: 0 }, + size: { width: 12, height: 100 }, + title: "검색 영역", + children: [], + }, + { + id: "table-container", + type: "container", + position: { x: 0, y: 1 }, + size: { width: 12, height: 300 }, + title: "데이터 테이블", + children: [], + }, + { + id: "form-container", + type: "container", + position: { x: 0, y: 2 }, + size: { width: 12, height: 200 }, + title: "입력 폼", + children: [], + }, + ], + gridSettings: { columns: 12, gap: 16, padding: 16 }, + }, + isPublic: true, + createdBy: "admin", + createdDate: new Date(), + }, + { + templateId: 2, + templateName: "목록 화면", + templateType: "LIST", + companyCode: "*", + description: "데이터 목록을 표시하는 화면 템플릿", + layoutData: { + components: [ + { + id: "filter-container", + type: "container", + position: { x: 0, y: 0 }, + size: { width: 12, height: 80 }, + title: "필터 영역", + children: [], + }, + { + id: "list-container", + type: "container", + position: { x: 0, y: 1 }, + size: { width: 12, height: 400 }, + title: "목록 영역", + children: [], + }, + ], + gridSettings: { columns: 12, gap: 16, padding: 16 }, + }, + isPublic: true, + createdBy: "admin", + createdDate: new Date(), + }, + { + templateId: 3, + templateName: "상세 화면", + templateType: "DETAIL", + companyCode: "*", + description: "데이터 상세 정보를 표시하는 화면 템플릿", + layoutData: { + components: [ + { + id: "header-container", + type: "container", + position: { x: 0, y: 0 }, + size: { width: 12, height: 60 }, + title: "헤더 영역", + children: [], + }, + { + id: "detail-container", + type: "container", + position: { x: 0, y: 1 }, + size: { width: 12, height: 400 }, + title: "상세 정보", + children: [], + }, + { + id: "action-container", + type: "container", + position: { x: 0, y: 2 }, + size: { width: 12, height: 80 }, + title: "액션 버튼", + children: [], + }, + ], + gridSettings: { columns: 12, gap: 16, padding: 16 }, + }, + isPublic: true, + createdBy: "admin", + createdDate: new Date(), + }, + ]; + setTemplates(fallbackTemplates); + } finally { + setIsLoading(false); + } + }; + + fetchTemplates(); + }, [user?.company_code]); + + // 템플릿 검색 + const filteredTemplates = templates.filter( + (template) => + template.templateName.toLowerCase().includes(searchTerm.toLowerCase()) || + (template.description || "").toLowerCase().includes(searchTerm.toLowerCase()), + ); + + // 템플릿 선택 + const handleTemplateSelect = (template: ScreenTemplate) => { + setSelectedTemplate(template); + onTemplateSelect?.(template); + }; + + // 템플릿 적용 + const handleTemplateApply = (template: ScreenTemplate) => { + onTemplateApply?.(template); + }; + + // 템플릿 삭제 + const handleTemplateDelete = async (templateId: number) => { + if (!confirm("정말로 이 템플릿을 삭제하시겠습니까?")) return; + + try { + await templateApi.deleteTemplate(templateId); + setTemplates((prev) => prev.filter((t) => t.templateId !== templateId)); + if (selectedTemplate?.templateId === templateId) { + setSelectedTemplate(null); + } + alert("템플릿이 삭제되었습니다."); + } catch (error) { + console.error("템플릿 삭제 실패:", error); + alert("템플릿 삭제에 실패했습니다. 다시 시도해주세요."); + } + }; + + // 새 템플릿 생성 + const handleCreateTemplate = () => { + // TODO: 새 템플릿 생성 모달 또는 페이지로 이동 + console.log("새 템플릿 생성"); + }; + + // 템플릿 내보내기 + const handleExportTemplate = (template: ScreenTemplate) => { + const dataStr = JSON.stringify(template, null, 2); + const dataBlob = new Blob([dataStr], { type: "application/json" }); + const url = URL.createObjectURL(dataBlob); + const link = document.createElement("a"); + link.href = url; + link.download = `${template.templateName}.json`; + link.click(); + URL.revokeObjectURL(url); + }; + + return ( +
+ {/* 헤더 */} + + +
+ 화면 템플릿 관리 +
+ + +
+
+
+ +
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> +
+
+
+ + {/* 템플릿 목록 */} +
+ {/* 템플릿 카드 목록 */} +
+ + + 내 템플릿 + 공개 템플릿 + + + +
+ {filteredTemplates + .filter((template) => (activeTab === "my" ? template.companyCode !== "*" : template.isPublic)) + .map((template) => ( + handleTemplateSelect(template)} + > + +
+
+

{template.templateName}

+

{template.description || "설명 없음"}

+
+ + {template.templateType} + + {template.isPublic && ( + + 공개 + + )} + + {template.createdBy} • {template.createdDate.toLocaleDateString()} + +
+
+
+ + + {template.companyCode !== "*" && ( + + )} +
+
+
+
+ ))} +
+
+ + {/* 선택된 템플릿 상세 정보 */} +
+ {selectedTemplate ? ( + + + 템플릿 상세 정보 + + +
+ +

{selectedTemplate.templateName}

+
+
+ +

{selectedTemplate.description}

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

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

+
+
+ +

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

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

템플릿을 선택하면 상세 정보를 볼 수 있습니다

+
+
+ )} +
+
+
+ ); +} diff --git a/frontend/components/screen/WidgetFactory.tsx b/frontend/components/screen/WidgetFactory.tsx new file mode 100644 index 00000000..990740e8 --- /dev/null +++ b/frontend/components/screen/WidgetFactory.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { WidgetComponent } from "@/types/screen"; +import InputWidget from "./widgets/InputWidget"; +import SelectWidget from "./widgets/SelectWidget"; +import TextareaWidget from "./widgets/TextareaWidget"; +import { Calendar, CheckSquare, Radio, FileText, Hash, Database } from "lucide-react"; + +interface WidgetFactoryProps { + widget: WidgetComponent; + value?: string; + onChange?: (value: string) => void; + className?: string; +} + +export default function WidgetFactory({ widget, value, onChange, className }: WidgetFactoryProps) { + // 웹 타입에 따라 적절한 컴포넌트 렌더링 + switch (widget.widgetType) { + case "text": + return ; + + case "number": + return ; + + case "date": + return ( +
+ {widget.label && ( + + )} +
+ + onChange?.(e.target.value)} + required={widget.required} + readOnly={widget.readonly} + className="w-full rounded-md border border-gray-300 py-2 pr-3 pl-10 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none" + /> +
+
+ ); + + case "code": + return ( +
+ {widget.label && ( + + )} +
+ + onChange?.(e.target.value)} + required={widget.required} + readOnly={widget.readonly} + className="w-full rounded-md border border-gray-300 py-2 pr-3 pl-10 font-mono text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none" + /> +
+
+ ); + + case "entity": + return ( +
+ {widget.label && ( + + )} +
+ + onChange?.(e.target.value)} + required={widget.required} + readOnly={widget.readonly} + className="w-full rounded-md border border-gray-300 py-2 pr-3 pl-10 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none" + /> +
+
+ ); + + case "file": + return ( +
+ {widget.label && ( + + )} +
+ + onChange?.(e.target.files?.[0]?.name || "")} + required={widget.required} + disabled={widget.readonly} + className="w-full rounded-md border border-gray-300 py-2 pr-3 pl-10 text-sm file:mr-4 file:rounded-md file:border-0 file:bg-blue-50 file:px-4 file:py-1 file:text-sm file:font-medium file:text-blue-700 hover:file:bg-blue-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none" + /> +
+
+ ); + + case "select": + return ; + + case "checkbox": + return ( +
+
+ + onChange?.(e.target.checked ? "true" : "false")} + required={widget.required} + disabled={widget.readonly} + className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" + /> + {widget.label && ( + + )} +
+
+ ); + + case "radio": + return ( +
+ {widget.label && ( + + )} +
+
+ + onChange?.(e.target.value)} + required={widget.required} + disabled={widget.readonly} + className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500" + /> + +
+
+ + onChange?.(e.target.value)} + required={widget.required} + disabled={widget.readonly} + className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500" + /> + +
+
+
+ ); + + case "textarea": + return ; + + default: + return ( +
+

지원하지 않는 위젯 타입

+

타입: {widget.widgetType}

+
+ ); + } +} diff --git a/frontend/components/screen/layout/ColumnComponent.tsx b/frontend/components/screen/layout/ColumnComponent.tsx new file mode 100644 index 00000000..056b1478 --- /dev/null +++ b/frontend/components/screen/layout/ColumnComponent.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { ColumnComponent as ColumnComponentType } from "@/types/screen"; + +interface ColumnComponentProps { + component: ColumnComponentType; + children?: React.ReactNode; + className?: string; + onClick?: () => void; + isSelected?: boolean; + onMouseDown?: (e: React.MouseEvent) => void; + isMoving?: boolean; +} + +export default function ColumnComponent({ + component, + children, + className, + onClick, + isSelected, + onMouseDown, + isMoving, +}: ColumnComponentProps) { + // 스타일 객체 생성 + const style: React.CSSProperties = { + gridColumn: `span ${component.size.width}`, + minHeight: `${component.size.height}px`, + ...(component.style && { + width: component.style.width, + height: component.style.height, + margin: component.style.margin, + padding: component.style.padding, + backgroundColor: component.style.backgroundColor, + border: component.style.border, + borderRadius: component.style.borderRadius, + boxShadow: component.style.boxShadow, + display: component.style.display || "flex", + flexDirection: component.style.flexDirection || "column", + justifyContent: component.style.justifyContent, + alignItems: component.style.alignItems, + gap: component.style.gap, + color: component.style.color, + fontSize: component.style.fontSize, + fontWeight: component.style.fontWeight, + textAlign: component.style.textAlign, + position: component.style.position, + zIndex: component.style.zIndex, + opacity: component.style.opacity, + overflow: component.style.overflow, + cursor: component.style.cursor, + transition: component.style.transition, + transform: component.style.transform, + }), + ...(isMoving && { + zIndex: 50, + opacity: 0.8, + transform: "scale(1.02)", + }), + }; + + return ( +
+
+ {children} +
+ ); +} diff --git a/frontend/components/screen/layout/ContainerComponent.tsx b/frontend/components/screen/layout/ContainerComponent.tsx new file mode 100644 index 00000000..9a4c3002 --- /dev/null +++ b/frontend/components/screen/layout/ContainerComponent.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { ContainerComponent as ContainerComponentType } from "@/types/screen"; + +interface ContainerComponentProps { + component: ContainerComponentType; + children?: React.ReactNode; + className?: string; + onClick?: () => void; + isSelected?: boolean; + onMouseDown?: (e: React.MouseEvent) => void; + isMoving?: boolean; +} + +export default function ContainerComponent({ + component, + children, + className, + onClick, + isSelected, + onMouseDown, + isMoving, +}: ContainerComponentProps) { + // 스타일 객체 생성 + const style: React.CSSProperties = { + gridColumn: `span ${component.size.width}`, + minHeight: `${component.size.height}px`, + ...(component.style && { + width: component.style.width, + height: component.style.height, + margin: component.style.margin, + padding: component.style.padding, + backgroundColor: component.style.backgroundColor, + border: component.style.border, + borderRadius: component.style.borderRadius, + boxShadow: component.style.boxShadow, + display: component.style.display, + flexDirection: component.style.flexDirection, + justifyContent: component.style.justifyContent, + alignItems: component.style.alignItems, + gap: component.style.gap, + color: component.style.color, + fontSize: component.style.fontSize, + fontWeight: component.style.fontWeight, + textAlign: component.style.textAlign, + position: component.style.position, + zIndex: component.style.zIndex, + opacity: component.style.opacity, + overflow: component.style.overflow, + cursor: component.style.cursor, + transition: component.style.transition, + transform: component.style.transform, + }), + ...(isMoving && { + zIndex: 50, + opacity: 0.8, + transform: "scale(1.02)", + }), + }; + + return ( +
+
{component.title || "컨테이너"}
+ {children} +
+ ); +} diff --git a/frontend/components/screen/layout/RowComponent.tsx b/frontend/components/screen/layout/RowComponent.tsx new file mode 100644 index 00000000..9bb2e00e --- /dev/null +++ b/frontend/components/screen/layout/RowComponent.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { RowComponent as RowComponentType } from "@/types/screen"; + +interface RowComponentProps { + component: RowComponentType; + children?: React.ReactNode; + className?: string; + onClick?: () => void; + isSelected?: boolean; + onMouseDown?: (e: React.MouseEvent) => void; + isMoving?: boolean; +} + +export default function RowComponent({ + component, + children, + className, + onClick, + isSelected, + onMouseDown, + isMoving, +}: RowComponentProps) { + // 스타일 객체 생성 + const style: React.CSSProperties = { + gridColumn: `span ${component.size.width}`, + minHeight: `${component.size.height}px`, + ...(component.style && { + width: component.style.width, + height: component.style.height, + margin: component.style.margin, + padding: component.style.padding, + backgroundColor: component.style.backgroundColor, + border: component.style.border, + borderRadius: component.style.borderRadius, + boxShadow: component.style.boxShadow, + display: component.style.display || "flex", + flexDirection: component.style.flexDirection || "row", + justifyContent: component.style.justifyContent, + alignItems: component.style.alignItems, + gap: component.style.gap, + color: component.style.color, + fontSize: component.style.fontSize, + fontWeight: component.style.fontWeight, + textAlign: component.style.textAlign, + position: component.style.position, + zIndex: component.style.zIndex, + opacity: component.style.opacity, + overflow: component.style.overflow, + cursor: component.style.cursor, + transition: component.style.transition, + transform: component.style.transform, + }), + ...(isMoving && { + zIndex: 50, + opacity: 0.8, + transform: "scale(1.02)", + }), + }; + + return ( +
+
+ {children} +
+ ); +} diff --git a/frontend/components/screen/widgets/InputWidget.tsx b/frontend/components/screen/widgets/InputWidget.tsx new file mode 100644 index 00000000..9e5a7ec5 --- /dev/null +++ b/frontend/components/screen/widgets/InputWidget.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; +import { WidgetComponent } from "@/types/screen"; + +interface InputWidgetProps { + widget: WidgetComponent; + value?: string; + onChange?: (value: string) => void; + className?: string; +} + +export default function InputWidget({ widget, value, onChange, className }: InputWidgetProps) { + const handleChange = (e: React.ChangeEvent) => { + onChange?.(e.target.value); + }; + + return ( +
+ {widget.label && ( + + )} + +
+ ); +} diff --git a/frontend/components/screen/widgets/SelectWidget.tsx b/frontend/components/screen/widgets/SelectWidget.tsx new file mode 100644 index 00000000..306d2dfa --- /dev/null +++ b/frontend/components/screen/widgets/SelectWidget.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { cn } from "@/lib/utils"; +import { WidgetComponent } from "@/types/screen"; + +interface SelectWidgetProps { + widget: WidgetComponent; + value?: string; + onChange?: (value: string) => void; + options?: { value: string; label: string }[]; + className?: string; +} + +export default function SelectWidget({ widget, value, onChange, options = [], className }: SelectWidgetProps) { + const handleChange = (newValue: string) => { + onChange?.(newValue); + }; + + // 위젯 타입에 따른 기본 옵션 생성 + const getDefaultOptions = () => { + switch (widget.widgetType) { + case "select": + return [ + { value: "option1", label: "옵션 1" }, + { value: "option2", label: "옵션 2" }, + { value: "option3", label: "옵션 3" }, + ]; + case "checkbox": + return [ + { value: "true", label: "체크됨" }, + { value: "false", label: "체크 안됨" }, + ]; + case "radio": + return [ + { value: "yes", label: "예" }, + { value: "no", label: "아니오" }, + ]; + default: + return options.length > 0 ? options : [{ value: "default", label: "기본값" }]; + } + }; + + const displayOptions = options.length > 0 ? options : getDefaultOptions(); + + return ( +
+ {widget.label && ( + + )} + +
+ ); +} diff --git a/frontend/components/screen/widgets/TextareaWidget.tsx b/frontend/components/screen/widgets/TextareaWidget.tsx new file mode 100644 index 00000000..f05bb9f9 --- /dev/null +++ b/frontend/components/screen/widgets/TextareaWidget.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; +import { WidgetComponent } from "@/types/screen"; + +interface TextareaWidgetProps { + widget: WidgetComponent; + value?: string; + onChange?: (value: string) => void; + className?: string; +} + +export default function TextareaWidget({ widget, value, onChange, className }: TextareaWidgetProps) { + const handleChange = (e: React.ChangeEvent) => { + onChange?.(e.target.value); + }; + + return ( +
+ {widget.label && ( + + )} +