From a17602c643afd372a97d583e33b598b965a52ccb Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 9 Sep 2025 14:29:04 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9B=B9=ED=83=80=EC=9E=85=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=B6=84=EB=A6=AC=EC=9E=91?= =?UTF-8?q?=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/prisma/schema.prisma | 198 +- backend-node/src/app.ts | 6 + .../buttonActionStandardController.ts | 349 ++++ .../controllers/webTypeStandardController.ts | 330 ++++ .../src/routes/buttonActionStandardRoutes.ts | 30 + .../src/routes/screenStandardRoutes.ts | 25 + .../src/routes/webTypeStandardRoutes.ts | 24 + docs/screen-management-dynamic-system-plan.md | 1014 +++++++++++ fix-selects.sh | 34 + .../button-actions/[actionType]/edit/page.tsx | 513 ++++++ .../button-actions/[actionType]/page.tsx | 344 ++++ .../button-actions/new/page.tsx | 466 +++++ .../system-settings/button-actions/page.tsx | 376 ++++ .../web-types/[webType]/edit/page.tsx | 430 +++++ .../web-types/[webType]/page.tsx | 285 +++ .../system-settings/web-types/new/page.tsx | 381 ++++ .../admin/system-settings/web-types/page.tsx | 345 ++++ .../admin/standards/[webType]/edit/page.tsx | 466 +++++ .../(main)/admin/standards/[webType]/page.tsx | 285 +++ .../app/(main)/admin/standards/new/page.tsx | 417 +++++ frontend/app/(main)/admin/standards/page.tsx | 357 ++++ .../app/(main)/screens/[screenId]/page.tsx | 2 +- frontend/app/layout.tsx | 3 +- frontend/app/registry-provider.tsx | 64 + .../screen/InteractiveScreenViewerDynamic.tsx | 496 +++++ .../components/screen/RealtimePreview.tsx | 1588 +++-------------- .../screen/RealtimePreviewDynamic.tsx | 430 +++++ .../screen/SimpleScreenDesigner.tsx | 260 +++ .../config-panels/ButtonConfigPanel.tsx | 139 ++ .../config-panels/CheckboxConfigPanel.tsx | 409 +++++ .../screen/config-panels/CodeConfigPanel.tsx | 425 +++++ .../screen/config-panels/DateConfigPanel.tsx | 263 +++ .../config-panels/EntityConfigPanel.tsx | 548 ++++++ .../screen/config-panels/FileConfigPanel.tsx | 400 +++++ .../config-panels/NumberConfigPanel.tsx | 242 +++ .../screen/config-panels/RadioConfigPanel.tsx | 416 +++++ .../config-panels/SelectConfigPanel.tsx | 405 +++++ .../screen/config-panels/TextConfigPanel.tsx | 231 +++ .../config-panels/TextareaConfigPanel.tsx | 358 ++++ .../components/screen/config-panels/index.ts | 57 + .../screen/panels/DataTableConfigPanel.tsx | 328 ++-- .../screen/panels/DetailSettingsPanel.tsx | 34 +- .../screen/panels/PropertiesPanel.tsx | 388 ++-- .../webtype-configs/RadioTypeConfigPanel.tsx | 44 +- .../webtype-configs/RatingTypeConfigPanel.tsx | 81 + .../screen/widgets/types/ButtonWidget.tsx | 40 + .../screen/widgets/types/CheckboxWidget.tsx | 58 + .../screen/widgets/types/CodeWidget.tsx | 83 + .../screen/widgets/types/DateWidget.tsx | 108 ++ .../screen/widgets/types/EntityWidget.tsx | 175 ++ .../screen/widgets/types/FileWidget.tsx | 211 +++ .../screen/widgets/types/NumberWidget.tsx | 101 ++ .../screen/widgets/types/RadioWidget.tsx | 65 + .../screen/widgets/types/RatingWidget.tsx | 102 ++ .../screen/widgets/types/SelectWidget.tsx | 50 + .../screen/widgets/types/TextWidget.tsx | 110 ++ .../screen/widgets/types/TextareaWidget.tsx | 48 + .../components/screen/widgets/types/index.ts | 161 ++ frontend/hooks/admin/useButtonActions.ts | 314 ++++ frontend/hooks/admin/useWebTypes.ts | 231 +++ frontend/hooks/useAuth.ts | 12 +- frontend/lib/registry/DynamicConfigPanel.tsx | 112 ++ .../lib/registry/DynamicWebTypeRenderer.tsx | 148 ++ frontend/lib/registry/WebTypeRegistry.ts | 283 +++ frontend/lib/registry/index.ts | 37 + frontend/lib/registry/init.ts | 401 +++++ frontend/lib/registry/types.ts | 198 ++ frontend/lib/registry/useRegistry.ts | 221 +++ frontend/lib/utils/availableComponents.ts | 26 + hooks/useScreenStandards.ts | 166 ++ lib/registry/DynamicConfigPanel.tsx | 100 ++ lib/registry/DynamicWebTypeRenderer.tsx | 103 ++ lib/registry/WebTypeRegistry.ts | 265 +++ lib/registry/index.ts | 16 + lib/registry/types.ts | 77 + replace-selects.js | 87 + 76 files changed, 16660 insertions(+), 1735 deletions(-) create mode 100644 backend-node/src/controllers/buttonActionStandardController.ts create mode 100644 backend-node/src/controllers/webTypeStandardController.ts create mode 100644 backend-node/src/routes/buttonActionStandardRoutes.ts create mode 100644 backend-node/src/routes/screenStandardRoutes.ts create mode 100644 backend-node/src/routes/webTypeStandardRoutes.ts create mode 100644 docs/screen-management-dynamic-system-plan.md create mode 100644 fix-selects.sh create mode 100644 frontend/app/(dashboard)/admin/system-settings/button-actions/[actionType]/edit/page.tsx create mode 100644 frontend/app/(dashboard)/admin/system-settings/button-actions/[actionType]/page.tsx create mode 100644 frontend/app/(dashboard)/admin/system-settings/button-actions/new/page.tsx create mode 100644 frontend/app/(dashboard)/admin/system-settings/button-actions/page.tsx create mode 100644 frontend/app/(dashboard)/admin/system-settings/web-types/[webType]/edit/page.tsx create mode 100644 frontend/app/(dashboard)/admin/system-settings/web-types/[webType]/page.tsx create mode 100644 frontend/app/(dashboard)/admin/system-settings/web-types/new/page.tsx create mode 100644 frontend/app/(dashboard)/admin/system-settings/web-types/page.tsx create mode 100644 frontend/app/(main)/admin/standards/[webType]/edit/page.tsx create mode 100644 frontend/app/(main)/admin/standards/[webType]/page.tsx create mode 100644 frontend/app/(main)/admin/standards/new/page.tsx create mode 100644 frontend/app/(main)/admin/standards/page.tsx create mode 100644 frontend/app/registry-provider.tsx create mode 100644 frontend/components/screen/InteractiveScreenViewerDynamic.tsx create mode 100644 frontend/components/screen/RealtimePreviewDynamic.tsx create mode 100644 frontend/components/screen/SimpleScreenDesigner.tsx create mode 100644 frontend/components/screen/config-panels/ButtonConfigPanel.tsx create mode 100644 frontend/components/screen/config-panels/CheckboxConfigPanel.tsx create mode 100644 frontend/components/screen/config-panels/CodeConfigPanel.tsx create mode 100644 frontend/components/screen/config-panels/DateConfigPanel.tsx create mode 100644 frontend/components/screen/config-panels/EntityConfigPanel.tsx create mode 100644 frontend/components/screen/config-panels/FileConfigPanel.tsx create mode 100644 frontend/components/screen/config-panels/NumberConfigPanel.tsx create mode 100644 frontend/components/screen/config-panels/RadioConfigPanel.tsx create mode 100644 frontend/components/screen/config-panels/SelectConfigPanel.tsx create mode 100644 frontend/components/screen/config-panels/TextConfigPanel.tsx create mode 100644 frontend/components/screen/config-panels/TextareaConfigPanel.tsx create mode 100644 frontend/components/screen/config-panels/index.ts create mode 100644 frontend/components/screen/panels/webtype-configs/RatingTypeConfigPanel.tsx create mode 100644 frontend/components/screen/widgets/types/ButtonWidget.tsx create mode 100644 frontend/components/screen/widgets/types/CheckboxWidget.tsx create mode 100644 frontend/components/screen/widgets/types/CodeWidget.tsx create mode 100644 frontend/components/screen/widgets/types/DateWidget.tsx create mode 100644 frontend/components/screen/widgets/types/EntityWidget.tsx create mode 100644 frontend/components/screen/widgets/types/FileWidget.tsx create mode 100644 frontend/components/screen/widgets/types/NumberWidget.tsx create mode 100644 frontend/components/screen/widgets/types/RadioWidget.tsx create mode 100644 frontend/components/screen/widgets/types/RatingWidget.tsx create mode 100644 frontend/components/screen/widgets/types/SelectWidget.tsx create mode 100644 frontend/components/screen/widgets/types/TextWidget.tsx create mode 100644 frontend/components/screen/widgets/types/TextareaWidget.tsx create mode 100644 frontend/components/screen/widgets/types/index.ts create mode 100644 frontend/hooks/admin/useButtonActions.ts create mode 100644 frontend/hooks/admin/useWebTypes.ts create mode 100644 frontend/lib/registry/DynamicConfigPanel.tsx create mode 100644 frontend/lib/registry/DynamicWebTypeRenderer.tsx create mode 100644 frontend/lib/registry/WebTypeRegistry.ts create mode 100644 frontend/lib/registry/index.ts create mode 100644 frontend/lib/registry/init.ts create mode 100644 frontend/lib/registry/types.ts create mode 100644 frontend/lib/registry/useRegistry.ts create mode 100644 frontend/lib/utils/availableComponents.ts create mode 100644 hooks/useScreenStandards.ts create mode 100644 lib/registry/DynamicConfigPanel.tsx create mode 100644 lib/registry/DynamicWebTypeRenderer.tsx create mode 100644 lib/registry/WebTypeRegistry.ts create mode 100644 lib/registry/index.ts create mode 100644 lib/registry/types.ts create mode 100644 replace-selects.js diff --git a/backend-node/prisma/schema.prisma b/backend-node/prisma/schema.prisma index 6189aa8e..9944035f 100644 --- a/backend-node/prisma/schema.prisma +++ b/backend-node/prisma/schema.prisma @@ -234,7 +234,7 @@ model assembly_wbs_task { } model attach_file_info { - objid Decimal @default(0) @db.Decimal + objid Decimal @id @default(0) @db.Decimal target_objid String? @db.VarChar saved_file_name String? @default("NULL::character varying") @db.VarChar(128) real_file_name String? @default("NULL::character varying") @db.VarChar(128) @@ -243,18 +243,17 @@ model attach_file_info { file_size Decimal? @db.Decimal file_ext String? @default("NULL::character varying") @db.VarChar(32) file_path String? @default("NULL::character varying") @db.VarChar(512) - company_code String? @default("default") @db.VarChar(32) writer String? @default("NULL::character varying") @db.VarChar(32) regdate DateTime? @db.Timestamp(6) status String? @default("NULL::character varying") @db.VarChar(32) parent_target_objid String? @db.VarChar + company_code String? @default("default") @db.VarChar(32) @@index([doc_type, objid], map: "attach_file_info_doc_type_idx") @@index([target_objid]) - @@index([company_code], map: "attach_file_info_company_code_idx") + @@index([company_code]) @@index([company_code, doc_type], map: "attach_file_info_company_doc_type_idx") @@index([company_code, target_objid], map: "attach_file_info_company_target_idx") - @@id([objid]) } model authority_master { @@ -1478,23 +1477,22 @@ model material_release { } model menu_info { - objid Decimal @id @default(0) @db.Decimal - menu_type Decimal? @db.Decimal - parent_obj_id Decimal? @db.Decimal - menu_name_kor String? @db.VarChar(64) - menu_name_eng String? @db.VarChar(64) - seq Decimal? @db.Decimal - menu_url String? @db.VarChar(256) - menu_desc String? @db.VarChar(1024) - writer String? @db.VarChar(32) - regdate DateTime? @db.Timestamp(6) - status String? @db.VarChar(32) - system_name String? @db.VarChar(32) - company_code String? @default("*") @db.VarChar(50) - lang_key String? @db.VarChar(100) - lang_key_desc String? @db.VarChar(100) - company company_mng? @relation(fields: [company_code], references: [company_code]) - screen_assignments screen_menu_assignments[] + objid Decimal @id @default(0) @db.Decimal + menu_type Decimal? @db.Decimal + parent_obj_id Decimal? @db.Decimal + menu_name_kor String? @db.VarChar(64) + menu_name_eng String? @db.VarChar(64) + seq Decimal? @db.Decimal + menu_url String? @db.VarChar(256) + menu_desc String? @db.VarChar(1024) + writer String? @db.VarChar(32) + regdate DateTime? @db.Timestamp(6) + status String? @db.VarChar(32) + system_name String? @db.VarChar(32) + company_code String? @default("*") @db.VarChar(50) + lang_key String? @db.VarChar(100) + lang_key_desc String? @db.VarChar(100) + company company_mng? @relation(fields: [company_code], references: [company_code]) @@index([parent_obj_id]) @@index([company_code]) @@ -4994,21 +4992,20 @@ model screen_definitions { table_name String @db.VarChar(100) company_code String @db.VarChar(50) description String? - is_active String @default("Y") @db.Char(1) // Y=활성, N=비활성, D=삭제됨(휴지통) + is_active String @default("Y") @db.Char(1) layout_metadata Json? 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) - deleted_date DateTime? @db.Timestamp(6) // 삭제 일시 (휴지통 이동 시점) - deleted_by String? @db.VarChar(50) // 삭제한 사용자 - delete_reason String? // 삭제 사유 (선택사항) + deleted_date DateTime? @db.Timestamp(6) + deleted_by String? @db.VarChar(50) + delete_reason String? layouts screen_layouts[] menu_assignments screen_menu_assignments[] @@index([company_code]) - @@index([is_active, company_code]) - @@index([deleted_date], map: "idx_screen_definitions_deleted") + @@index([is_active, company_code], map: "idx_screen_definitions_status") } model screen_layouts { @@ -5072,7 +5069,6 @@ model screen_menu_assignments { 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) - menu_info menu_info @relation(fields: [menu_objid], references: [objid]) @@unique([screen_id, menu_objid, company_code]) @@index([company_code]) @@ -5111,3 +5107,149 @@ model code_info { @@id([code_category, code_value], map: "pk_code_info") @@index([code_category, sort_order], map: "idx_code_info_sort") } + +model web_type_standards { + web_type String @id @db.VarChar(50) + type_name String @db.VarChar(100) + type_name_eng String? @db.VarChar(100) + description String? + category String? @default("input") @db.VarChar(50) + component_name String? @default("TextWidget") @db.VarChar(100) + default_config Json? + validation_rules Json? + default_style Json? + input_properties Json? + sort_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) + updated_date DateTime? @default(now()) @db.Timestamp(6) + updated_by String? @db.VarChar(50) + + @@index([is_active], map: "idx_web_type_standards_active") + @@index([category], map: "idx_web_type_standards_category") + @@index([sort_order], map: "idx_web_type_standards_sort") +} + +model style_templates { + template_id Int @id @default(autoincrement()) + template_name String @db.VarChar(100) + template_name_eng String? @db.VarChar(100) + template_type String @db.VarChar(50) + category String? @db.VarChar(50) + style_config Json + preview_config Json? + company_code String? @default("*") @db.VarChar(50) + is_default Boolean? @default(false) + is_public Boolean? @default(true) + sort_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) + updated_date DateTime? @default(now()) @db.Timestamp(6) + updated_by String? @db.VarChar(50) + + @@index([is_active], map: "idx_style_templates_active") + @@index([category], map: "idx_style_templates_category") + @@index([company_code], map: "idx_style_templates_company") + @@index([template_type], map: "idx_style_templates_type") +} + +model button_action_standards { + action_type String @id @db.VarChar(50) + action_name String @db.VarChar(100) + action_name_eng String? @db.VarChar(100) + description String? + category String? @default("general") @db.VarChar(50) + default_text String? @db.VarChar(100) + default_text_eng String? @db.VarChar(100) + default_icon String? @db.VarChar(50) + default_color String? @db.VarChar(50) + default_variant String? @db.VarChar(50) + confirmation_required Boolean? @default(false) + confirmation_message String? + validation_rules Json? + action_config Json? + sort_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) + updated_date DateTime? @default(now()) @db.Timestamp(6) + updated_by String? @db.VarChar(50) + + @@index([is_active], map: "idx_button_action_standards_active") + @@index([category], map: "idx_button_action_standards_category") + @@index([sort_order], map: "idx_button_action_standards_sort") +} + +model grid_standards { + grid_id Int @id @default(autoincrement()) + grid_name String @db.VarChar(100) + grid_name_eng String? @db.VarChar(100) + description String? + grid_size Int + grid_color String? @default("#e5e7eb") @db.VarChar(50) + grid_opacity Decimal? @default(0.5) @db.Decimal(3, 2) + snap_enabled Boolean? @default(true) + snap_threshold Int? @default(5) + grid_config Json? + company_code String? @default("*") @db.VarChar(50) + is_default Boolean? @default(false) + sort_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) + updated_date DateTime? @default(now()) @db.Timestamp(6) + updated_by String? @db.VarChar(50) + + @@index([is_active], map: "idx_grid_standards_active") + @@index([company_code], map: "idx_grid_standards_company") +} + +/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments +model data_relationship_bridge { + bridge_id Int @id @default(autoincrement()) + relationship_id Int? + from_table_name String @db.VarChar(100) + from_column_name String @db.VarChar(100) + from_key_value String? @db.VarChar(500) + from_record_id String? @db.VarChar(100) + to_table_name String @db.VarChar(100) + to_column_name String @db.VarChar(100) + to_key_value String? @db.VarChar(500) + to_record_id String? @db.VarChar(100) + connection_type String @db.VarChar(20) + company_code String @db.VarChar(50) + created_at DateTime? @default(now()) @db.Timestamp(6) + created_by String? @db.VarChar(50) + updated_at DateTime? @default(now()) @db.Timestamp(6) + updated_by String? @db.VarChar(50) + is_active String? @default("Y") @db.Char(1) + bridge_data Json? + table_relationships table_relationships? @relation(fields: [relationship_id], references: [relationship_id], onDelete: NoAction, onUpdate: NoAction) + + @@index([company_code, is_active], map: "idx_data_bridge_company_active") + @@index([connection_type], map: "idx_data_bridge_connection_type") +} + +/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments +model table_relationships { + relationship_id Int @id @default(autoincrement()) + relationship_name String @db.VarChar(200) + from_table_name String @db.VarChar(100) + from_column_name String @db.VarChar(100) + to_table_name String @db.VarChar(100) + to_column_name String @db.VarChar(100) + relationship_type String @db.VarChar(20) + connection_type String @db.VarChar(20) + company_code String @db.VarChar(50) + settings Json? + 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) + data_relationship_bridge data_relationship_bridge[] + + @@index([to_table_name], map: "idx_table_relationships_to_table") +} diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index f84004e4..061c5749 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -19,6 +19,9 @@ import commonCodeRoutes from "./routes/commonCodeRoutes"; import dynamicFormRoutes from "./routes/dynamicFormRoutes"; import fileRoutes from "./routes/fileRoutes"; import companyManagementRoutes from "./routes/companyManagementRoutes"; +import webTypeStandardRoutes from "./routes/webTypeStandardRoutes"; +import buttonActionStandardRoutes from "./routes/buttonActionStandardRoutes"; +import screenStandardRoutes from "./routes/screenStandardRoutes"; // import userRoutes from './routes/userRoutes'; // import menuRoutes from './routes/menuRoutes'; @@ -101,6 +104,9 @@ app.use("/api/common-codes", commonCodeRoutes); app.use("/api/dynamic-form", dynamicFormRoutes); app.use("/api/files", fileRoutes); app.use("/api/company-management", companyManagementRoutes); +app.use("/api/admin/web-types", webTypeStandardRoutes); +app.use("/api/admin/button-actions", buttonActionStandardRoutes); +app.use("/api/screen", screenStandardRoutes); // app.use('/api/users', userRoutes); // app.use('/api/menus', menuRoutes); diff --git a/backend-node/src/controllers/buttonActionStandardController.ts b/backend-node/src/controllers/buttonActionStandardController.ts new file mode 100644 index 00000000..271ebb1c --- /dev/null +++ b/backend-node/src/controllers/buttonActionStandardController.ts @@ -0,0 +1,349 @@ +import { Request, Response } from "express"; +import { PrismaClient } from "@prisma/client"; +import { AuthenticatedRequest } from "../types/auth"; + +const prisma = new PrismaClient(); + +export class ButtonActionStandardController { + // 버튼 액션 목록 조회 + static async getButtonActions(req: Request, res: Response) { + try { + const { active, category, search } = req.query; + + const where: any = {}; + + if (active) { + where.is_active = active as string; + } + + if (category) { + where.category = category as string; + } + + if (search) { + where.OR = [ + { action_name: { contains: search as string, mode: "insensitive" } }, + { + action_name_eng: { + contains: search as string, + mode: "insensitive", + }, + }, + { description: { contains: search as string, mode: "insensitive" } }, + ]; + } + + const buttonActions = await prisma.button_action_standards.findMany({ + where, + orderBy: [{ sort_order: "asc" }, { action_type: "asc" }], + }); + + return res.json({ + success: true, + data: buttonActions, + message: "버튼 액션 목록을 성공적으로 조회했습니다.", + }); + } catch (error) { + console.error("버튼 액션 목록 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "버튼 액션 목록 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } + + // 버튼 액션 상세 조회 + static async getButtonAction(req: Request, res: Response) { + try { + const { actionType } = req.params; + + const buttonAction = await prisma.button_action_standards.findUnique({ + where: { action_type: actionType }, + }); + + if (!buttonAction) { + return res.status(404).json({ + success: false, + message: "해당 버튼 액션을 찾을 수 없습니다.", + }); + } + + return res.json({ + success: true, + data: buttonAction, + message: "버튼 액션 정보를 성공적으로 조회했습니다.", + }); + } catch (error) { + console.error("버튼 액션 상세 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "버튼 액션 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } + + // 버튼 액션 생성 + static async createButtonAction(req: AuthenticatedRequest, res: Response) { + try { + const { + action_type, + action_name, + action_name_eng, + description, + category = "general", + default_text, + default_text_eng, + default_icon, + default_color, + default_variant = "default", + confirmation_required = false, + confirmation_message, + validation_rules, + action_config, + sort_order = 0, + is_active = "Y", + } = req.body; + + // 필수 필드 검증 + if (!action_type || !action_name) { + return res.status(400).json({ + success: false, + message: "액션 타입과 이름은 필수입니다.", + }); + } + + // 중복 체크 + const existingAction = await prisma.button_action_standards.findUnique({ + where: { action_type }, + }); + + if (existingAction) { + return res.status(409).json({ + success: false, + message: "이미 존재하는 액션 타입입니다.", + }); + } + + const newButtonAction = await prisma.button_action_standards.create({ + data: { + action_type, + action_name, + action_name_eng, + description, + category, + default_text, + default_text_eng, + default_icon, + default_color, + default_variant, + confirmation_required, + confirmation_message, + validation_rules, + action_config, + sort_order, + is_active, + created_by: req.user?.userId || "system", + updated_by: req.user?.userId || "system", + }, + }); + + return res.status(201).json({ + success: true, + data: newButtonAction, + message: "버튼 액션이 성공적으로 생성되었습니다.", + }); + } catch (error) { + console.error("버튼 액션 생성 오류:", error); + return res.status(500).json({ + success: false, + message: "버튼 액션 생성 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } + + // 버튼 액션 수정 + static async updateButtonAction(req: AuthenticatedRequest, res: Response) { + try { + const { actionType } = req.params; + const { + action_name, + action_name_eng, + description, + category, + default_text, + default_text_eng, + default_icon, + default_color, + default_variant, + confirmation_required, + confirmation_message, + validation_rules, + action_config, + sort_order, + is_active, + } = req.body; + + // 존재 여부 확인 + const existingAction = await prisma.button_action_standards.findUnique({ + where: { action_type: actionType }, + }); + + if (!existingAction) { + return res.status(404).json({ + success: false, + message: "해당 버튼 액션을 찾을 수 없습니다.", + }); + } + + const updatedButtonAction = await prisma.button_action_standards.update({ + where: { action_type: actionType }, + data: { + action_name, + action_name_eng, + description, + category, + default_text, + default_text_eng, + default_icon, + default_color, + default_variant, + confirmation_required, + confirmation_message, + validation_rules, + action_config, + sort_order, + is_active, + updated_by: req.user?.userId || "system", + updated_date: new Date(), + }, + }); + + return res.json({ + success: true, + data: updatedButtonAction, + message: "버튼 액션이 성공적으로 수정되었습니다.", + }); + } catch (error) { + console.error("버튼 액션 수정 오류:", error); + return res.status(500).json({ + success: false, + message: "버튼 액션 수정 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } + + // 버튼 액션 삭제 + static async deleteButtonAction(req: Request, res: Response) { + try { + const { actionType } = req.params; + + // 존재 여부 확인 + const existingAction = await prisma.button_action_standards.findUnique({ + where: { action_type: actionType }, + }); + + if (!existingAction) { + return res.status(404).json({ + success: false, + message: "해당 버튼 액션을 찾을 수 없습니다.", + }); + } + + await prisma.button_action_standards.delete({ + where: { action_type: actionType }, + }); + + return res.json({ + success: true, + message: "버튼 액션이 성공적으로 삭제되었습니다.", + }); + } catch (error) { + console.error("버튼 액션 삭제 오류:", error); + return res.status(500).json({ + success: false, + message: "버튼 액션 삭제 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } + + // 버튼 액션 정렬 순서 업데이트 + static async updateButtonActionSortOrder( + req: AuthenticatedRequest, + res: Response + ) { + try { + const { buttonActions } = req.body; // [{ action_type: 'save', sort_order: 1 }, ...] + + if (!Array.isArray(buttonActions)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 데이터 형식입니다.", + }); + } + + // 트랜잭션으로 일괄 업데이트 + await prisma.$transaction( + buttonActions.map((item) => + prisma.button_action_standards.update({ + where: { action_type: item.action_type }, + data: { + sort_order: item.sort_order, + updated_by: req.user?.userId || "system", + updated_date: new Date(), + }, + }) + ) + ); + + return res.json({ + success: true, + message: "버튼 액션 정렬 순서가 성공적으로 업데이트되었습니다.", + }); + } catch (error) { + console.error("버튼 액션 정렬 순서 업데이트 오류:", error); + return res.status(500).json({ + success: false, + message: "정렬 순서 업데이트 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } + + // 버튼 액션 카테고리 목록 조회 + static async getButtonActionCategories(req: Request, res: Response) { + try { + const categories = await prisma.button_action_standards.groupBy({ + by: ["category"], + where: { + is_active: "Y", + }, + _count: { + category: true, + }, + }); + + const categoryList = categories.map((item) => ({ + category: item.category, + count: item._count.category, + })); + + return res.json({ + success: true, + data: categoryList, + message: "버튼 액션 카테고리 목록을 성공적으로 조회했습니다.", + }); + } catch (error) { + console.error("버튼 액션 카테고리 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "카테고리 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } +} diff --git a/backend-node/src/controllers/webTypeStandardController.ts b/backend-node/src/controllers/webTypeStandardController.ts new file mode 100644 index 00000000..de3cea09 --- /dev/null +++ b/backend-node/src/controllers/webTypeStandardController.ts @@ -0,0 +1,330 @@ +import { Request, Response } from "express"; +import { PrismaClient } from "@prisma/client"; +import { AuthenticatedRequest } from "../types/auth"; + +const prisma = new PrismaClient(); + +export class WebTypeStandardController { + // 웹타입 목록 조회 + static async getWebTypes(req: Request, res: Response) { + try { + const { active, category, search } = req.query; + + const where: any = {}; + + if (active) { + where.is_active = active as string; + } + + if (category) { + where.category = category as string; + } + + if (search) { + where.OR = [ + { type_name: { contains: search as string, mode: "insensitive" } }, + { + type_name_eng: { contains: search as string, mode: "insensitive" }, + }, + { description: { contains: search as string, mode: "insensitive" } }, + ]; + } + + const webTypes = await prisma.web_type_standards.findMany({ + where, + orderBy: [{ sort_order: "asc" }, { web_type: "asc" }], + }); + + return res.json({ + success: true, + data: webTypes, + message: "웹타입 목록을 성공적으로 조회했습니다.", + }); + } catch (error) { + console.error("웹타입 목록 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "웹타입 목록 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } + + // 웹타입 상세 조회 + static async getWebType(req: Request, res: Response) { + try { + const { webType } = req.params; + + const webTypeData = await prisma.web_type_standards.findUnique({ + where: { web_type: webType }, + }); + + if (!webTypeData) { + return res.status(404).json({ + success: false, + message: "해당 웹타입을 찾을 수 없습니다.", + }); + } + + return res.json({ + success: true, + data: webTypeData, + message: "웹타입 정보를 성공적으로 조회했습니다.", + }); + } catch (error) { + console.error("웹타입 상세 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "웹타입 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } + + // 웹타입 생성 + static async createWebType(req: AuthenticatedRequest, res: Response) { + try { + const { + web_type, + type_name, + type_name_eng, + description, + category = "input", + component_name = "TextWidget", + default_config, + validation_rules, + default_style, + input_properties, + sort_order = 0, + is_active = "Y", + } = req.body; + + // 필수 필드 검증 + if (!web_type || !type_name) { + return res.status(400).json({ + success: false, + message: "웹타입 코드와 이름은 필수입니다.", + }); + } + + // 중복 체크 + const existingWebType = await prisma.web_type_standards.findUnique({ + where: { web_type }, + }); + + if (existingWebType) { + return res.status(409).json({ + success: false, + message: "이미 존재하는 웹타입 코드입니다.", + }); + } + + const newWebType = await prisma.web_type_standards.create({ + data: { + web_type, + type_name, + type_name_eng, + description, + category, + component_name, + default_config, + validation_rules, + default_style, + input_properties, + sort_order, + is_active, + created_by: req.user?.userId || "system", + updated_by: req.user?.userId || "system", + }, + }); + + return res.status(201).json({ + success: true, + data: newWebType, + message: "웹타입이 성공적으로 생성되었습니다.", + }); + } catch (error) { + console.error("웹타입 생성 오류:", error); + return res.status(500).json({ + success: false, + message: "웹타입 생성 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } + + // 웹타입 수정 + static async updateWebType(req: AuthenticatedRequest, res: Response) { + try { + const { webType } = req.params; + const { + type_name, + type_name_eng, + description, + category, + component_name, + default_config, + validation_rules, + default_style, + input_properties, + sort_order, + is_active, + } = req.body; + + // 존재 여부 확인 + const existingWebType = await prisma.web_type_standards.findUnique({ + where: { web_type: webType }, + }); + + if (!existingWebType) { + return res.status(404).json({ + success: false, + message: "해당 웹타입을 찾을 수 없습니다.", + }); + } + + const updatedWebType = await prisma.web_type_standards.update({ + where: { web_type: webType }, + data: { + type_name, + type_name_eng, + description, + category, + component_name, + default_config, + validation_rules, + default_style, + input_properties, + sort_order, + is_active, + updated_by: req.user?.userId || "system", + updated_date: new Date(), + }, + }); + + return res.json({ + success: true, + data: updatedWebType, + message: "웹타입이 성공적으로 수정되었습니다.", + }); + } catch (error) { + console.error("웹타입 수정 오류:", error); + return res.status(500).json({ + success: false, + message: "웹타입 수정 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } + + // 웹타입 삭제 + static async deleteWebType(req: Request, res: Response) { + try { + const { webType } = req.params; + + // 존재 여부 확인 + const existingWebType = await prisma.web_type_standards.findUnique({ + where: { web_type: webType }, + }); + + if (!existingWebType) { + return res.status(404).json({ + success: false, + message: "해당 웹타입을 찾을 수 없습니다.", + }); + } + + await prisma.web_type_standards.delete({ + where: { web_type: webType }, + }); + + return res.json({ + success: true, + message: "웹타입이 성공적으로 삭제되었습니다.", + }); + } catch (error) { + console.error("웹타입 삭제 오류:", error); + return res.status(500).json({ + success: false, + message: "웹타입 삭제 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } + + // 웹타입 정렬 순서 업데이트 + static async updateWebTypeSortOrder( + req: AuthenticatedRequest, + res: Response + ) { + try { + const { webTypes } = req.body; // [{ web_type: 'text', sort_order: 1 }, ...] + + if (!Array.isArray(webTypes)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 데이터 형식입니다.", + }); + } + + // 트랜잭션으로 일괄 업데이트 + await prisma.$transaction( + webTypes.map((item) => + prisma.web_type_standards.update({ + where: { web_type: item.web_type }, + data: { + sort_order: item.sort_order, + updated_by: req.user?.userId || "system", + updated_date: new Date(), + }, + }) + ) + ); + + return res.json({ + success: true, + message: "웹타입 정렬 순서가 성공적으로 업데이트되었습니다.", + }); + } catch (error) { + console.error("웹타입 정렬 순서 업데이트 오류:", error); + return res.status(500).json({ + success: false, + message: "정렬 순서 업데이트 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } + + // 웹타입 카테고리 목록 조회 + static async getWebTypeCategories(req: Request, res: Response) { + try { + const categories = await prisma.web_type_standards.groupBy({ + by: ["category"], + where: { + is_active: "Y", + }, + _count: { + category: true, + }, + }); + + const categoryList = categories.map((item) => ({ + category: item.category, + count: item._count.category, + })); + + return res.json({ + success: true, + data: categoryList, + message: "웹타입 카테고리 목록을 성공적으로 조회했습니다.", + }); + } catch (error) { + console.error("웹타입 카테고리 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "카테고리 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } +} diff --git a/backend-node/src/routes/buttonActionStandardRoutes.ts b/backend-node/src/routes/buttonActionStandardRoutes.ts new file mode 100644 index 00000000..5362a390 --- /dev/null +++ b/backend-node/src/routes/buttonActionStandardRoutes.ts @@ -0,0 +1,30 @@ +import express from "express"; +import { ButtonActionStandardController } from "../controllers/buttonActionStandardController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = express.Router(); + +// 모든 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + +// 버튼 액션 표준 관리 라우트 +router.get("/", ButtonActionStandardController.getButtonActions); +router.get( + "/categories", + ButtonActionStandardController.getButtonActionCategories +); +router.get("/:actionType", ButtonActionStandardController.getButtonAction); +router.post("/", ButtonActionStandardController.createButtonAction); +router.put("/:actionType", ButtonActionStandardController.updateButtonAction); +router.delete( + "/:actionType", + ButtonActionStandardController.deleteButtonAction +); +router.put( + "/sort-order/bulk", + ButtonActionStandardController.updateButtonActionSortOrder +); + +export default router; + + diff --git a/backend-node/src/routes/screenStandardRoutes.ts b/backend-node/src/routes/screenStandardRoutes.ts new file mode 100644 index 00000000..c360c80b --- /dev/null +++ b/backend-node/src/routes/screenStandardRoutes.ts @@ -0,0 +1,25 @@ +import express from "express"; +import { WebTypeStandardController } from "../controllers/webTypeStandardController"; +import { ButtonActionStandardController } from "../controllers/buttonActionStandardController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = express.Router(); + +// 모든 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + +// 화면관리에서 사용할 조회 전용 API +router.get("/web-types", WebTypeStandardController.getWebTypes); +router.get( + "/web-types/categories", + WebTypeStandardController.getWebTypeCategories +); +router.get("/button-actions", ButtonActionStandardController.getButtonActions); +router.get( + "/button-actions/categories", + ButtonActionStandardController.getButtonActionCategories +); + +export default router; + + diff --git a/backend-node/src/routes/webTypeStandardRoutes.ts b/backend-node/src/routes/webTypeStandardRoutes.ts new file mode 100644 index 00000000..9ae8e07f --- /dev/null +++ b/backend-node/src/routes/webTypeStandardRoutes.ts @@ -0,0 +1,24 @@ +import express from "express"; +import { WebTypeStandardController } from "../controllers/webTypeStandardController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = express.Router(); + +// 모든 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + +// 웹타입 표준 관리 라우트 +router.get("/", WebTypeStandardController.getWebTypes); +router.get("/categories", WebTypeStandardController.getWebTypeCategories); +router.get("/:webType", WebTypeStandardController.getWebType); +router.post("/", WebTypeStandardController.createWebType); +router.put("/:webType", WebTypeStandardController.updateWebType); +router.delete("/:webType", WebTypeStandardController.deleteWebType); +router.put( + "/sort-order/bulk", + WebTypeStandardController.updateWebTypeSortOrder +); + +export default router; + + diff --git a/docs/screen-management-dynamic-system-plan.md b/docs/screen-management-dynamic-system-plan.md new file mode 100644 index 00000000..0b96e362 --- /dev/null +++ b/docs/screen-management-dynamic-system-plan.md @@ -0,0 +1,1014 @@ +# 화면관리 시스템 동적 설정 관리 계획서 + +> 하드코딩된 웹타입과 버튼 기능을 동적으로 관리할 수 있는 시스템 구축 계획 + +## 📋 목차 + +- [개요](#개요) +- [현재 상황 분석](#현재-상황-분석) +- [목표 시스템 아키텍처](#목표-시스템-아키텍처) +- [단계별 구현 계획](#단계별-구현-계획) +- [기대 효과](#기대-효과) +- [실행 일정](#실행-일정) + +## 개요 + +### 🎯 목표 + +현재 화면관리 시스템에서 하드코딩되어 있는 웹타입과 버튼 기능을 동적으로 관리할 수 있는 설정 페이지를 구축하여, 개발자는 컴포넌트만 작성하고 비개발자는 관리 페이지에서 타입을 추가/수정할 수 있는 유연한 시스템으로 전환 + +### 🔧 핵심 과제 + +- 25개의 하드코딩된 웹타입을 데이터베이스 기반 동적 관리로 전환 +- 11개의 하드코딩된 버튼 액션을 설정 가능한 시스템으로 변경 +- 플러그인 방식의 컴포넌트 아키텍처 구축 +- 비개발자도 사용 가능한 설정 관리 인터페이스 제공 + +## 현재 상황 분석 + +### 📊 하드코딩된 부분들 + +#### 1. 웹타입 (25개) + +**위치**: `frontend/types/screen.ts` + +```typescript +export type WebType = + | "text" + | "number" + | "date" + | "code" + | "entity" + | "textarea" + | "select" + | "checkbox" + | "radio" + | "file" + | "email" + | "tel" + | "datetime" + | "dropdown" + | "text_area" + | "boolean" + | "decimal" + | "button"; +``` + +#### 2. 버튼 액션 (11개) + +**위치**: `frontend/types/screen.ts` + +```typescript +export type ButtonActionType = + | "save" + | "delete" + | "edit" + | "add" + | "search" + | "reset" + | "submit" + | "close" + | "popup" + | "navigate" + | "custom"; +``` + +#### 3. 렌더링 로직 + +**위치**: `frontend/components/screen/RealtimePreview.tsx` + +- 970줄의 switch-case 문으로 웹타입별 렌더링 처리 +- 각 웹타입별 고정된 렌더링 로직 + +#### 4. 설정 패널 + +**위치**: `frontend/components/screen/panels/ButtonConfigPanel.tsx` + +- 45줄의 하드코딩된 액션타입 옵션 배열 +- 각 액션별 고정된 기본값 설정 + +### ⚠️ 현재 시스템의 문제점 + +- 새로운 웹타입 추가 시 여러 파일 수정 필요 +- 타입별 설정 변경을 위해 코드 수정 및 배포 필요 +- 회사별/프로젝트별 커스텀 타입 관리 어려움 +- 비개발자의 시스템 설정 변경 불가능 + +## 목표 시스템 아키텍처 + +### 🎨 전체 시스템 구조 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 관리자 설정 페이지 │ +├─────────────────────────────────────────────────────────────┤ +│ 웹타입 관리 │ 버튼액션 관리 │ 스타일템플릿 │ 격자설정 │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ API Layer │ +├─────────────────────────────────────────────────────────────┤ +│ /api/admin/web-types │ /api/admin/button-actions │ +│ /api/admin/style-templates │ /api/admin/grid-standards │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Database Layer │ +├─────────────────────────────────────────────────────────────┤ +│ web_type_standards │ button_action_standards │ +│ style_templates │ grid_standards │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 웹타입 레지스트리 시스템 │ +├─────────────────────────────────────────────────────────────┤ +│ WebTypeRegistry.register() │ DynamicRenderer │ +│ 플러그인 방식 컴포넌트 등록 │ 동적 컴포넌트 렌더링 │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 화면관리 시스템 │ +├─────────────────────────────────────────────────────────────┤ +│ ScreenDesigner │ RealtimePreview │ +│ PropertiesPanel │ InteractiveScreenViewer │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 🔌 플러그인 방식 컴포넌트 시스템 + +#### 웹타입 정의 인터페이스 + +```typescript +interface WebTypeDefinition { + webType: string; // 웹타입 식별자 + name: string; // 표시명 + category: string; // 카테고리 (input, select, display, special) + defaultConfig: any; // 기본 설정 + validationRules: any; // 유효성 검사 규칙 + defaultStyle: any; // 기본 스타일 + inputProperties: any; // HTML input 속성 + component: React.ComponentType; // 렌더링 컴포넌트 + configPanel: React.ComponentType; // 설정 패널 컴포넌트 + icon?: React.ComponentType; // 아이콘 컴포넌트 + sortOrder?: number; // 정렬 순서 + isActive?: boolean; // 활성화 여부 +} +``` + +#### 웹타입 레지스트리 + +```typescript +class WebTypeRegistry { + private static types = new Map(); + + // 웹타입 등록 + static register(definition: WebTypeDefinition) { + this.types.set(definition.webType, definition); + } + + // 웹타입 조회 + static get(webType: string): WebTypeDefinition | undefined { + return this.types.get(webType); + } + + // 모든 웹타입 조회 + static getAll(): WebTypeDefinition[] { + return Array.from(this.types.values()) + .filter((type) => type.isActive) + .sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)); + } + + // 카테고리별 조회 + static getByCategory(category: string): WebTypeDefinition[] { + return this.getAll().filter((type) => type.category === category); + } +} +``` + +#### 동적 컴포넌트 렌더러 + +```typescript +const DynamicWebTypeRenderer: React.FC<{ + widgetType: string; + component: WidgetComponent; + [key: string]: any; +}> = ({ widgetType, component, ...props }) => { + const definition = WebTypeRegistry.get(widgetType); + + if (!definition) { + return ( +
+ 알 수 없는 웹타입: {widgetType} +
+ ); + } + + const Component = definition.component; + return ; +}; +``` + +## 단계별 구현 계획 + +### 📅 Phase 1: 기반 구조 구축 (1-2주) + +#### 1.1 데이터베이스 스키마 적용 + +- ✅ **이미 준비됨**: `db/08-create-webtype-standards.sql` +- 4개 테이블: `web_type_standards`, `button_action_standards`, `style_templates`, `grid_standards` +- 기본 데이터 25개 웹타입, 12개 버튼액션 포함 + +#### 1.2 Backend API 개발 + +``` +backend-node/src/routes/admin/ +├── web-types.ts # 웹타입 CRUD API +├── button-actions.ts # 버튼액션 CRUD API +├── style-templates.ts # 스타일템플릿 CRUD API +└── grid-standards.ts # 격자설정 CRUD API +``` + +**주요 API 엔드포인트:** + +```typescript +// 웹타입 관리 +GET /api/admin/web-types # 목록 조회 +POST /api/admin/web-types # 생성 +PUT /api/admin/web-types/:webType # 수정 +DELETE /api/admin/web-types/:webType # 삭제 + +// 버튼액션 관리 +GET /api/admin/button-actions # 목록 조회 +POST /api/admin/button-actions # 생성 +PUT /api/admin/button-actions/:actionType # 수정 +DELETE /api/admin/button-actions/:actionType # 삭제 + +// 화면관리에서 사용할 조회 API +GET /api/screen/web-types?active=Y&category=input +GET /api/screen/button-actions?active=Y&category=crud +``` + +#### 1.3 프론트엔드 기반 구조 + +``` +frontend/lib/registry/ +├── WebTypeRegistry.ts # 웹타입 레지스트리 +├── ButtonActionRegistry.ts # 버튼액션 레지스트리 +└── types.ts # 레지스트리 관련 타입 정의 + +frontend/components/screen/dynamic/ +├── DynamicWebTypeRenderer.tsx # 동적 웹타입 렌더러 +├── DynamicConfigPanel.tsx # 동적 설정 패널 +└── DynamicActionHandler.tsx # 동적 액션 핸들러 + +frontend/hooks/ +├── useWebTypes.ts # 웹타입 관리 훅 +├── useButtonActions.ts # 버튼액션 관리 훅 +├── useStyleTemplates.ts # 스타일템플릿 관리 훅 +└── useGridStandards.ts # 격자설정 관리 훅 +``` + +### 📅 Phase 2: 설정 관리 페이지 개발 (2-3주) + +#### 2.1 관리 페이지 라우팅 구조 + +``` +frontend/app/(dashboard)/admin/system-settings/ +├── page.tsx # 메인 설정 페이지 +├── layout.tsx # 설정 페이지 레이아웃 +├── web-types/ +│ ├── page.tsx # 웹타입 목록 +│ ├── new/ +│ │ └── page.tsx # 새 웹타입 생성 +│ ├── [webType]/ +│ │ ├── page.tsx # 웹타입 상세/편집 +│ │ └── preview/ +│ │ └── page.tsx # 웹타입 미리보기 +│ └── components/ +│ ├── WebTypeList.tsx # 웹타입 목록 컴포넌트 +│ ├── WebTypeForm.tsx # 웹타입 생성/편집 폼 +│ ├── WebTypePreview.tsx # 웹타입 미리보기 +│ └── WebTypeCard.tsx # 웹타입 카드 +├── button-actions/ +│ ├── page.tsx # 버튼액션 목록 +│ ├── new/page.tsx # 새 액션 생성 +│ ├── [actionType]/page.tsx # 액션 상세/편집 +│ └── components/ +│ ├── ActionList.tsx # 액션 목록 +│ ├── ActionForm.tsx # 액션 생성/편집 폼 +│ └── ActionPreview.tsx # 액션 미리보기 +├── style-templates/ +│ ├── page.tsx # 스타일 템플릿 목록 +│ └── components/ +│ ├── TemplateList.tsx # 템플릿 목록 +│ ├── TemplateEditor.tsx # 템플릿 편집기 +│ └── StylePreview.tsx # 스타일 미리보기 +└── grid-standards/ + ├── page.tsx # 격자 설정 목록 + └── components/ + ├── GridList.tsx # 격자 목록 + ├── GridEditor.tsx # 격자 편집기 + └── GridPreview.tsx # 격자 미리보기 +``` + +#### 2.2 웹타입 관리 페이지 주요 기능 + +**목록 페이지 (`web-types/page.tsx`)** + +- 📋 웹타입 목록 조회 (카테고리별 필터링) +- 🔍 검색 및 정렬 기능 +- ✅ 활성화/비활성화 토글 +- 🎯 정렬 순서 드래그앤드롭 변경 +- ➕ 새 웹타입 추가 버튼 + +**생성/편집 페이지 (`web-types/[webType]/page.tsx`)** + +- 📝 기본 정보 입력 (이름, 설명, 카테고리) +- ⚙️ 기본 설정 JSON 편집기 +- 🔒 유효성 검사 규칙 설정 +- 🎨 기본 스타일 설정 +- 🏷️ HTML 속성 설정 +- 👀 실시간 미리보기 + +**미리보기 페이지 (`web-types/[webType]/preview/page.tsx`)** + +- 📱 다양한 화면 크기별 미리보기 +- 🎭 여러 테마 적용 테스트 +- 📊 설정값별 렌더링 결과 확인 + +#### 2.3 버튼액션 관리 페이지 주요 기능 + +**목록 페이지 (`button-actions/page.tsx`)** + +- 📋 액션 목록 조회 (카테고리별 필터링) +- 🏷️ 액션별 기본 설정 미리보기 +- ✅ 활성화/비활성화 관리 +- 🎯 정렬 순서 관리 + +**생성/편집 페이지 (`button-actions/[actionType]/page.tsx`)** + +- 📝 기본 정보 (이름, 설명, 카테고리) +- 🎨 기본 스타일 (텍스트, 아이콘, 색상, 변형) +- ⚠️ 확인 메시지 설정 +- 🔒 실행 조건 및 검증 규칙 +- ⚙️ 액션별 추가 설정 (JSON) + +### 📅 Phase 3: 컴포넌트 분리 및 등록 (3-4주) + +#### 3.1 기존 웹타입 컴포넌트 분리 + +**Before (기존 구조)**: + +```typescript +// RealtimePreview.tsx - 970줄의 거대한 switch문 +const renderWidget = (component: ComponentData) => { + switch (widgetType) { + case "text": + return ; + case "number": + return ; + case "date": + return ; + // ... 25개 케이스 + } +}; +``` + +**After (새로운 구조)**: + +``` +frontend/components/screen/widgets/ +├── base/ +│ ├── BaseWebTypeComponent.tsx # 기본 웹타입 컴포넌트 +│ ├── WebTypeProps.ts # 공통 프로퍼티 인터페이스 +│ └── WebTypeHooks.ts # 공통 훅 +├── input/ +│ ├── TextWidget.tsx # 텍스트 입력 +│ ├── NumberWidget.tsx # 숫자 입력 +│ ├── DecimalWidget.tsx # 소수 입력 +│ ├── DateWidget.tsx # 날짜 입력 +│ ├── DateTimeWidget.tsx # 날짜시간 입력 +│ ├── EmailWidget.tsx # 이메일 입력 +│ ├── TelWidget.tsx # 전화번호 입력 +│ └── TextareaWidget.tsx # 텍스트영역 +├── select/ +│ ├── SelectWidget.tsx # 선택박스 +│ ├── DropdownWidget.tsx # 드롭다운 +│ ├── RadioWidget.tsx # 라디오버튼 +│ ├── CheckboxWidget.tsx # 체크박스 +│ └── BooleanWidget.tsx # 불린 선택 +├── special/ +│ ├── FileWidget.tsx # 파일 업로드 +│ ├── CodeWidget.tsx # 공통코드 +│ ├── EntityWidget.tsx # 엔티티 참조 +│ └── ButtonWidget.tsx # 버튼 +└── registry/ + ├── index.ts # 모든 웹타입 등록 + └── registerWebTypes.ts # 웹타입 등록 함수 +``` + +**개별 컴포넌트 예시**: + +```typescript +// TextWidget.tsx +import React from "react"; +import { Input } from "@/components/ui/input"; +import { BaseWebTypeComponent } from "../base/BaseWebTypeComponent"; +import { TextTypeConfig } from "@/types/screen"; + +interface TextWidgetProps { + component: WidgetComponent; + value?: string; + onChange?: (value: string) => void; + readonly?: boolean; +} + +export const TextWidget: React.FC = ({ + component, + value, + onChange, + readonly = false, +}) => { + const config = component.webTypeConfig as TextTypeConfig; + + return ( + + onChange?.(e.target.value)} + placeholder={component.placeholder || config?.placeholder} + disabled={readonly} + maxLength={config?.maxLength} + minLength={config?.minLength} + pattern={config?.pattern} + className="w-full h-full" + /> + + ); +}; +``` + +#### 3.2 설정 패널 분리 + +``` +frontend/components/screen/config-panels/ +├── base/ +│ ├── BaseConfigPanel.tsx # 기본 설정 패널 +│ ├── ConfigPanelProps.ts # 공통 프로퍼티 +│ └── ConfigPanelHooks.ts # 공통 훅 +├── input/ +│ ├── TextConfigPanel.tsx # 텍스트 설정 +│ ├── NumberConfigPanel.tsx # 숫자 설정 +│ ├── DateConfigPanel.tsx # 날짜 설정 +│ └── TextareaConfigPanel.tsx # 텍스트영역 설정 +├── select/ +│ ├── SelectConfigPanel.tsx # 선택박스 설정 +│ ├── RadioConfigPanel.tsx # 라디오 설정 +│ └── CheckboxConfigPanel.tsx # 체크박스 설정 +├── special/ +│ ├── FileConfigPanel.tsx # 파일 설정 +│ ├── CodeConfigPanel.tsx # 코드 설정 +│ ├── EntityConfigPanel.tsx # 엔티티 설정 +│ └── ButtonConfigPanel.tsx # 버튼 설정 (기존 이전) +└── registry/ + └── registerConfigPanels.ts # 설정 패널 등록 +``` + +#### 3.3 웹타입 등록 시스템 + +```typescript +// frontend/lib/registry/registerWebTypes.ts +import { WebTypeRegistry } from "./WebTypeRegistry"; + +// 입력 타입 등록 +import { TextWidget } from "@/components/screen/widgets/input/TextWidget"; +import { TextConfigPanel } from "@/components/screen/config-panels/input/TextConfigPanel"; + +export const registerAllWebTypes = async () => { + // 데이터베이스에서 웹타입 설정 조회 + const webTypeSettings = await fetch("/api/screen/web-types?active=Y").then( + (r) => r.json() + ); + + // 각 웹타입별 컴포넌트 매핑 + const componentMap = { + text: { component: TextWidget, configPanel: TextConfigPanel }, + number: { component: NumberWidget, configPanel: NumberConfigPanel }, + // ... 기타 매핑 + }; + + // 웹타입 등록 + webTypeSettings.forEach((setting) => { + const components = componentMap[setting.webType]; + if (components) { + WebTypeRegistry.register({ + webType: setting.webType, + name: setting.typeName, + category: setting.category, + defaultConfig: setting.defaultConfig, + validationRules: setting.validationRules, + defaultStyle: setting.defaultStyle, + inputProperties: setting.inputProperties, + component: components.component, + configPanel: components.configPanel, + sortOrder: setting.sortOrder, + isActive: setting.isActive === "Y", + }); + } + }); +}; +``` + +### 📅 Phase 4: 화면관리 시스템 연동 (2-3주) + +#### 4.1 화면 설계 시 동적 웹타입 사용 + +**ScreenDesigner.tsx 수정**: + +```typescript +const ScreenDesigner = () => { + // 동적 웹타입/버튼액션 조회 + const { data: webTypes } = useWebTypes({ active: "Y" }); + const { data: buttonActions } = useButtonActions({ active: "Y" }); + + // 웹타입 드롭다운 옵션 동적 생성 + const webTypeOptions = useMemo(() => { + return ( + webTypes?.map((type) => ({ + value: type.webType, + label: type.typeName, + category: type.category, + icon: type.icon, + })) || [] + ); + }, [webTypes]); + + // 카테고리별 그룹화 + const webTypesByCategory = useMemo(() => { + return webTypeOptions.reduce((acc, type) => { + if (!acc[type.category]) acc[type.category] = []; + acc[type.category].push(type); + return acc; + }, {}); + }, [webTypeOptions]); + + // 버튼 액션 옵션 동적 생성 + const buttonActionOptions = useMemo(() => { + return ( + buttonActions?.map((action) => ({ + value: action.actionType, + label: action.actionName, + category: action.category, + icon: action.defaultIcon, + color: action.defaultColor, + })) || [] + ); + }, [buttonActions]); + + // ...기존 로직 +}; +``` + +**PropertiesPanel.tsx 수정**: + +```typescript +const PropertiesPanel = ({ component, onUpdateComponent }) => { + const webTypes = WebTypeRegistry.getAll(); + + return ( +
+ {/* 웹타입 선택 드롭다운 */} + + + {/* 동적 설정 패널 */} + +
+ ); +}; +``` + +#### 4.2 동적 렌더링 시스템 적용 + +**RealtimePreview.tsx 대폭 간소화**: + +```typescript +// Before: 970줄의 거대한 switch문 +const renderWidget = (component: ComponentData) => { + switch (widgetType) { + case "text": /* 복잡한 로직 */ + case "number": /* 복잡한 로직 */ + // ... 25개 케이스 + } +}; + +// After: 간단한 동적 렌더링 +const renderWidget = (component: ComponentData) => { + if (component.type !== "widget") { + return
위젯이 아닙니다
; + } + + return ( + + ); +}; +``` + +**InteractiveScreenViewer.tsx 업데이트**: + +```typescript +const InteractiveScreenViewer = ({ component, formData, onFormDataChange }) => { + const renderInteractiveWidget = (comp) => { + if (comp.type !== "widget") return null; + + return ( + onFormDataChange(comp.columnName, value)} + readonly={comp.readonly} + /> + ); + }; + + // 기존 switch문 제거, 동적 렌더링으로 대체 + return renderInteractiveWidget(component); +}; +``` + +### 📅 Phase 5: 테스트 및 최적화 (1-2주) + +#### 5.1 기능 테스트 체크리스트 + +**관리 페이지 테스트**: + +- [ ] 웹타입 생성/수정/삭제 기능 +- [ ] 버튼액션 생성/수정/삭제 기능 +- [ ] 활성화/비활성화 토글 기능 +- [ ] 정렬 순서 변경 기능 +- [ ] 설정값 변경 시 실시간 미리보기 +- [ ] JSON 설정 유효성 검사 +- [ ] 다국어 지원 테스트 + +**화면관리 시스템 테스트**: + +- [ ] 동적 웹타입 드롭다운 표시 +- [ ] 새로 추가된 웹타입 정상 렌더링 +- [ ] 설정 변경 시 실시간 반영 +- [ ] 기존 화면과의 호환성 확인 +- [ ] 웹타입별 설정 패널 정상 동작 +- [ ] 버튼 액션 동적 처리 확인 + +**성능 테스트**: + +- [ ] 웹타입 정보 로딩 속도 +- [ ] 대량 컴포넌트 렌더링 성능 +- [ ] 메모리 사용량 최적화 +- [ ] 불필요한 리렌더링 방지 + +#### 5.2 성능 최적화 + +**웹타입 정보 캐싱**: + +```typescript +// React Query를 활용한 캐싱 +const useWebTypes = (params = {}) => { + return useQuery({ + queryKey: ["webTypes", params], + queryFn: () => fetchWebTypes(params), + staleTime: 5 * 60 * 1000, // 5분간 캐시 유지 + cacheTime: 10 * 60 * 1000, // 10분간 메모리 보관 + }); +}; +``` + +**컴포넌트 Lazy Loading**: + +```typescript +// 웹타입 컴포넌트 지연 로딩 +const LazyTextWidget = React.lazy(() => import("./widgets/input/TextWidget")); +const LazyNumberWidget = React.lazy( + () => import("./widgets/input/NumberWidget") +); + +const DynamicWebTypeRenderer = ({ widgetType, ...props }) => { + const Component = useMemo(() => { + const definition = WebTypeRegistry.get(widgetType); + return definition?.component; + }, [widgetType]); + + if (!Component) return
알 수 없는 웹타입
; + + return ( + 로딩 중...}> + + + ); +}; +``` + +**불필요한 리렌더링 방지**: + +```typescript +// React.memo를 활용한 최적화 +export const DynamicWebTypeRenderer = React.memo( + ({ widgetType, component, ...props }) => { + // 렌더링 로직 + }, + (prevProps, nextProps) => { + // 얕은 비교로 리렌더링 최소화 + return ( + prevProps.widgetType === nextProps.widgetType && + prevProps.component.id === nextProps.component.id && + JSON.stringify(prevProps.component.webTypeConfig) === + JSON.stringify(nextProps.component.webTypeConfig) + ); + } +); +``` + +## 기대 효과 + +### 🚀 개발자 경험 개선 + +#### Before (기존 방식) + +새로운 웹타입 '전화번호' 추가 시: + +1. `types/screen.ts`에 타입 추가 +2. `RealtimePreview.tsx`에 switch case 추가 (50줄) +3. `InteractiveScreenViewer.tsx`에 switch case 추가 (30줄) +4. `PropertiesPanel.tsx`에 설정 로직 추가 (100줄) +5. `ButtonConfigPanel.tsx`에 옵션 추가 (20줄) +6. 다국어 파일 업데이트 +7. 테스트 코드 작성 +8. **총 200줄+ 코드 수정, 7개 파일 변경** + +#### After (새로운 방식) + +새로운 웹타입 '전화번호' 추가 시: + +1. **관리 페이지에서 웹타입 등록** (클릭만으로!) +2. **컴포넌트 파일 1개만 작성** (20줄): + +```typescript +// PhoneWidget.tsx +export const PhoneWidget = ({ component, value, onChange, readonly }) => { + const config = component.webTypeConfig as PhoneTypeConfig; + + return ( + onChange(e.target.value)} + placeholder={config.placeholder || "전화번호를 입력하세요"} + pattern={config.pattern || "[0-9]{3}-[0-9]{4}-[0-9]{4}"} + disabled={readonly} + /> + ); +}; + +// 등록 (앱 초기화 시) +WebTypeRegistry.register({ + webType: "phone", + name: "전화번호", + component: PhoneWidget, + configPanel: PhoneConfigPanel, + defaultConfig: { placeholder: "전화번호를 입력하세요" }, +}); +``` + +3. **총 20줄 코드 작성, 1개 파일 생성** + +### 📈 비개발자 업무 효율성 + +#### 시스템 관리자 / 기획자가 할 수 있는 일 + +- ✅ 새로운 웹타입 추가 (개발자 도움 없이) +- ✅ 웹타입별 기본 설정 변경 +- ✅ 버튼 액션 커스터마이징 +- ✅ 스타일 템플릿 관리 +- ✅ 회사별/프로젝트별 커스텀 설정 +- ✅ A/B 테스트를 위한 임시 설정 변경 + +#### 실시간 설정 변경 시나리오 + +``` +시나리오: 고객사 요청으로 '이메일' 입력 필드의 기본 검증 규칙 변경 + +Before (기존): +1. 개발자가 코드 수정 +2. 테스트 환경 배포 +3. 검수 후 운영 배포 +4. 소요 시간: 1-2일 + +After (새로운): +1. 관리자가 웹타입 관리 페이지 접속 +2. '이메일' 웹타입 편집 +3. 검증 규칙 JSON 수정 +4. 저장 → 즉시 반영 +5. 소요 시간: 2-3분 +``` + +### 🔧 확장성 및 유지보수성 + +#### 플러그인 방식의 장점 + +- **독립적 개발**: 각 웹타입별 독립적인 개발 및 테스트 +- **점진적 확장**: 필요에 따른 점진적 기능 추가 +- **버전 관리**: 웹타입별 버전 관리 가능 +- **A/B 테스트**: 다른 구현체로 쉬운 교체 가능 +- **재사용성**: 다른 프로젝트에서 컴포넌트 재사용 + +#### 코드 품질 향상 + +- **관심사 분리**: 각 웹타입별 로직 분리 +- **테스트 용이성**: 작은 단위 컴포넌트 테스트 +- **코드 리뷰**: 작은 단위로 리뷰 가능 +- **문서화**: 웹타입별 독립적인 문서화 + +### ⚡ 성능 최적화 + +#### 지연 로딩 (Lazy Loading) + +- 사용하지 않는 웹타입 컴포넌트는 로딩하지 않음 +- 초기 번들 크기 50% 이상 감소 예상 +- 화면 로딩 속도 향상 + +#### 효율적인 캐싱 + +- 웹타입 설정 정보는 앱 시작 시 한 번만 로딩 +- 변경 시에만 갱신하는 무효화 정책 +- 메모리 사용량 최적화 + +#### 렌더링 최적화 + +- 웹타입별 최적화된 렌더링 로직 +- 불필요한 리렌더링 방지 +- Virtual DOM 업데이트 최소화 + +## 실행 일정 + +### 📅 전체 일정표 + +| Phase | 기간 | 주요 작업 | 담당자 | 산출물 | +| ----------- | ---------- | ---------------- | ----------------- | ---------------------- | +| **Phase 1** | 1-2주 | 기반 구조 구축 | 백엔드/프론트엔드 | API, 레지스트리 시스템 | +| **Phase 2** | 2-3주 | 설정 관리 페이지 | 프론트엔드 | 관리 페이지 UI | +| **Phase 3** | 3-4주 | 컴포넌트 분리 | 프론트엔드 | 웹타입 컴포넌트들 | +| **Phase 4** | 2-3주 | 화면관리 연동 | 프론트엔드 | 동적 렌더링 시스템 | +| **Phase 5** | 1-2주 | 테스트/최적화 | 전체 팀 | 완성된 시스템 | +| **총 기간** | **9-14주** | | | | + +### 🎯 마일스톤 + +#### Milestone 1 (2주 후) + +- ✅ 데이터베이스 스키마 적용 +- ✅ Backend API 완성 +- ✅ 웹타입 레지스트리 시스템 구축 +- ✅ 기본 관리 페이지 프레임워크 + +#### Milestone 2 (5주 후) + +- ✅ 웹타입 관리 페이지 완성 +- ✅ 버튼액션 관리 페이지 완성 +- ✅ 스타일 템플릿 관리 페이지 완성 +- ✅ 실시간 미리보기 기능 + +#### Milestone 3 (9주 후) + +- ✅ 모든 웹타입 컴포넌트 분리 완성 +- ✅ 설정 패널 분리 완성 +- ✅ 웹타입 등록 시스템 완성 +- ✅ 기존 화면과의 호환성 확보 + +#### Milestone 4 (12주 후) + +- ✅ 화면관리 시스템 동적 연동 완성 +- ✅ RealtimePreview/InteractiveScreenViewer 개선 +- ✅ PropertiesPanel 동적 업데이트 +- ✅ 성능 최적화 적용 + +#### Final Release (14주 후) + +- ✅ 전체 시스템 통합 테스트 완료 +- ✅ 성능 최적화 완료 +- ✅ 문서화 완료 +- ✅ 운영 배포 준비 완료 + +### 🚀 우선순위별 실행 전략 + +#### 🔥 즉시 시작 가능 (우선순위 High) + +1. **데이터베이스 스키마 적용** + + - 이미 준비된 SQL 파일 실행 + - 기본 데이터 확인 및 검증 + +2. **Backend API 개발** + + - 표준적인 CRUD API 구현 + - 기존 패턴 재사용 가능 + +3. **웹타입 레지스트리 시스템** + - 핵심 아키텍처 구성요소 + - 다른 모든 기능의 기반 + +#### 📋 병렬 진행 가능 (우선순위 Medium) + +1. **관리 페이지 UI 개발** + + - Backend API와 독립적으로 개발 가능 + - 목업 데이터로 프로토타입 제작 + +2. **기존 컴포넌트 분석 및 분리 계획** + - 현재 RealtimePreview 분석 + - 컴포넌트 분리 전략 수립 + +#### ⏳ 순차 진행 필요 (우선순위 Low) + +1. **화면관리 시스템 연동** + + - 웹타입 컴포넌트 분리 완료 후 진행 + - 레지스트리 시스템 안정화 후 진행 + +2. **성능 최적화** + - 전체 시스템 완성 후 진행 + - 실제 사용 패턴 분석 후 최적화 + +### 💡 성공을 위한 핵심 요소 + +#### 기술적 성공 요소 + +- **점진적 마이그레이션**: 기존 시스템과의 호환성 유지 +- **철저한 테스트**: 각 단계별 충분한 테스트 +- **성능 모니터링**: 성능 저하 없는 기능 확장 +- **에러 핸들링**: 견고한 에러 처리 로직 + +#### 조직적 성공 요소 + +- **명확한 역할 분담**: 개발자/기획자/관리자 역할 정의 +- **충분한 교육**: 새로운 시스템 사용법 교육 +- **단계적 도입**: 파일럿 테스트 후 전면 도입 +- **피드백 수집**: 사용자 피드백 기반 개선 + +--- + +## 🎉 결론 + +이 계획을 통해 화면관리 시스템은 **하드코딩된 정적 시스템**에서 **유연하고 확장 가능한 동적 시스템**으로 진화할 것입니다. + +### 핵심 성과 지표 + +- **개발 효율성**: 새 웹타입 추가 시간 **95% 단축** (2일 → 2시간) +- **시스템 유연성**: **비개발자도 설정 변경 가능** +- **코드 품질**: **관심사 분리**로 유지보수성 **대폭 향상** +- **성능**: **지연 로딩**으로 초기 로딩 시간 **50% 이상 개선** + +### 장기적 비전 + +- **플러그인 생태계**: 커뮤니티 기반 웹타입 확장 +- **AI 기반 최적화**: 사용 패턴 기반 자동 설정 추천 +- **마켓플레이스**: 웹타입/템플릿 공유 플랫폼 +- **다중 플랫폼**: 모바일/데스크톱 앱에서도 동일한 시스템 사용 + +**이제 미래 지향적이고 확장 가능한 화면관리 시스템을 구축할 준비가 완료되었습니다!** 🚀 + + diff --git a/fix-selects.sh b/fix-selects.sh new file mode 100644 index 00000000..e99856c4 --- /dev/null +++ b/fix-selects.sh @@ -0,0 +1,34 @@ +#!/bin/bash + +# DataTableConfigPanel의 모든 Select를 HTML select로 교체하는 스크립트 + +FILE="frontend/components/screen/panels/DataTableConfigPanel.tsx" + +echo "🔄 DataTableConfigPanel의 Select 컴포넌트들을 교체 중..." + +# 1. Select 컴포넌트를 select로 교체 (기본 패턴) +sed -i '' 's/]*\)>/를 로 교체 +sed -i '' 's/<\/Select>/<\/select>/g' "$FILE" + +echo "✅ 완료!" + diff --git a/frontend/app/(dashboard)/admin/system-settings/button-actions/[actionType]/edit/page.tsx b/frontend/app/(dashboard)/admin/system-settings/button-actions/[actionType]/edit/page.tsx new file mode 100644 index 00000000..306b2e0c --- /dev/null +++ b/frontend/app/(dashboard)/admin/system-settings/button-actions/[actionType]/edit/page.tsx @@ -0,0 +1,513 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Switch } from "@/components/ui/switch"; +import { Badge } from "@/components/ui/badge"; +import { toast } from "sonner"; +import { ArrowLeft, Save, RotateCcw, Eye } from "lucide-react"; +import { useButtonActions, type ButtonActionFormData } from "@/hooks/admin/useButtonActions"; +import Link from "next/link"; + +// 기본 카테고리 목록 +const DEFAULT_CATEGORIES = ["crud", "navigation", "utility", "custom"]; + +// 기본 변형 목록 +const DEFAULT_VARIANTS = ["default", "destructive", "outline", "secondary", "ghost", "link"]; + +export default function EditButtonActionPage() { + const params = useParams(); + const router = useRouter(); + const actionType = params.actionType as string; + + const { buttonActions, updateButtonAction, isUpdating, updateError, isLoading } = useButtonActions(); + + const [formData, setFormData] = useState>({}); + const [originalData, setOriginalData] = useState(null); + const [isDataLoaded, setIsDataLoaded] = useState(false); + + const [jsonErrors, setJsonErrors] = useState<{ + validation_rules?: string; + action_config?: string; + }>({}); + + // JSON 문자열 상태 (편집용) + const [jsonStrings, setJsonStrings] = useState({ + validation_rules: "{}", + action_config: "{}", + }); + + // 버튼 액션 데이터 로드 + useEffect(() => { + if (buttonActions && actionType && !isDataLoaded) { + const found = buttonActions.find((ba) => ba.action_type === actionType); + if (found) { + setOriginalData(found); + setFormData({ + action_name: found.action_name, + action_name_eng: found.action_name_eng || "", + description: found.description || "", + category: found.category, + default_text: found.default_text || "", + default_text_eng: found.default_text_eng || "", + default_icon: found.default_icon || "", + default_color: found.default_color || "", + default_variant: found.default_variant || "default", + confirmation_required: found.confirmation_required || false, + confirmation_message: found.confirmation_message || "", + validation_rules: found.validation_rules || {}, + action_config: found.action_config || {}, + sort_order: found.sort_order || 0, + is_active: found.is_active, + }); + setJsonStrings({ + validation_rules: JSON.stringify(found.validation_rules || {}, null, 2), + action_config: JSON.stringify(found.action_config || {}, null, 2), + }); + setIsDataLoaded(true); + } else { + toast.error("버튼 액션을 찾을 수 없습니다."); + router.push("/admin/system-settings/button-actions"); + } + } + }, [buttonActions, actionType, isDataLoaded, router]); + + // 입력값 변경 핸들러 + const handleInputChange = (field: keyof ButtonActionFormData, value: any) => { + setFormData((prev) => ({ + ...prev, + [field]: value, + })); + }; + + // JSON 입력 변경 핸들러 + const handleJsonChange = (field: "validation_rules" | "action_config", value: string) => { + setJsonStrings((prev) => ({ + ...prev, + [field]: value, + })); + + // JSON 파싱 시도 + try { + const parsed = value.trim() ? JSON.parse(value) : {}; + setFormData((prev) => ({ + ...prev, + [field]: parsed, + })); + setJsonErrors((prev) => ({ + ...prev, + [field]: undefined, + })); + } catch (error) { + setJsonErrors((prev) => ({ + ...prev, + [field]: "유효하지 않은 JSON 형식입니다.", + })); + } + }; + + // 폼 유효성 검사 + const validateForm = (): boolean => { + if (!formData.action_name?.trim()) { + toast.error("액션명을 입력해주세요."); + return false; + } + + if (!formData.category?.trim()) { + toast.error("카테고리를 선택해주세요."); + return false; + } + + // JSON 에러가 있는지 확인 + const hasJsonErrors = Object.values(jsonErrors).some((error) => error); + if (hasJsonErrors) { + toast.error("JSON 형식 오류를 수정해주세요."); + return false; + } + + return true; + }; + + // 저장 핸들러 + const handleSave = async () => { + if (!validateForm()) return; + + try { + await updateButtonAction(actionType, formData); + toast.success("버튼 액션이 성공적으로 수정되었습니다."); + router.push(`/admin/system-settings/button-actions/${actionType}`); + } catch (error) { + toast.error(error instanceof Error ? error.message : "수정 중 오류가 발생했습니다."); + } + }; + + // 폼 초기화 (원본 데이터로 되돌리기) + const handleReset = () => { + if (originalData) { + setFormData({ + action_name: originalData.action_name, + action_name_eng: originalData.action_name_eng || "", + description: originalData.description || "", + category: originalData.category, + default_text: originalData.default_text || "", + default_text_eng: originalData.default_text_eng || "", + default_icon: originalData.default_icon || "", + default_color: originalData.default_color || "", + default_variant: originalData.default_variant || "default", + confirmation_required: originalData.confirmation_required || false, + confirmation_message: originalData.confirmation_message || "", + validation_rules: originalData.validation_rules || {}, + action_config: originalData.action_config || {}, + sort_order: originalData.sort_order || 0, + is_active: originalData.is_active, + }); + setJsonStrings({ + validation_rules: JSON.stringify(originalData.validation_rules || {}, null, 2), + action_config: JSON.stringify(originalData.action_config || {}, null, 2), + }); + setJsonErrors({}); + } + }; + + // 로딩 상태 + if (isLoading || !isDataLoaded) { + return ( +
+
버튼 액션 정보를 불러오는 중...
+
+ ); + } + + // 버튼 액션을 찾지 못한 경우 + if (!originalData) { + return ( +
+
+
버튼 액션을 찾을 수 없습니다.
+ + + +
+
+ ); + } + + return ( +
+ {/* 헤더 */} +
+ + + +
+
+

버튼 액션 편집

+ + {actionType} + +
+

{originalData.action_name} 버튼 액션의 정보를 수정합니다.

+
+
+ +
+ {/* 기본 정보 */} + + + 기본 정보 + 버튼 액션의 기본적인 정보를 수정해주세요. + + + {/* 액션 타입 (읽기 전용) */} +
+ + +

액션 타입은 수정할 수 없습니다.

+
+ + {/* 액션명 */} +
+
+ + handleInputChange("action_name", e.target.value)} + placeholder="예: 저장" + /> +
+
+ + handleInputChange("action_name_eng", e.target.value)} + placeholder="예: Save" + /> +
+
+ + {/* 카테고리 */} +
+ + +
+ + {/* 설명 */} +
+ +