From d7c41fc35d7b1f6258bfb615824895e244bb461a Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 4 Sep 2025 14:22:11 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=ED=8F=BC=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/prisma/schema.prisma | 304 +++++++++++------------------- 1 file changed, 107 insertions(+), 197 deletions(-) diff --git a/backend-node/prisma/schema.prisma b/backend-node/prisma/schema.prisma index 34099936..1f68eda9 100644 --- a/backend-node/prisma/schema.prisma +++ b/backend-node/prisma/schema.prisma @@ -8,7 +8,17 @@ datasource db { url = env("DATABASE_URL") } -// 테이블 타입관리 관련 모델은 이미 정의되어 있음 (line 11, 717) +model dynamic_form_data { + id Int @id @default(autoincrement()) + screen_id Int + table_name String @db.VarChar(100) + form_data Json + created_at DateTime? @default(now()) @db.Timestamp(6) + updated_at DateTime? @default(now()) @updatedAt @db.Timestamp(6) + created_by String @db.VarChar(50) + updated_by String @db.VarChar(50) + company_code String @db.VarChar(20) +} model admin_supply_mng { objid Decimal @id @default(0) @db.Decimal @@ -74,15 +84,12 @@ model approval { @@ignore } - - model approval_kind { - target_type String @db.VarChar + target_type String @id @db.VarChar target_name String? @db.VarChar regdate DateTime? @db.Timestamp(6) status String? @db.VarChar - @@id([target_type]) @@index([status]) } @@ -135,8 +142,6 @@ model arrival_plan { @@index([part_objid]) } - - model as_mng { objid Int @id as_no String? @db.VarChar @@ -247,20 +252,13 @@ model attach_file_info { @@ignore } - - - - - model authority_master { - objid Decimal @id @default(0) @db.Decimal - auth_name String? @default("NULL::character varying") @db.VarChar(256) - auth_code String? @default("NULL::character varying") @db.VarChar(64) - writer String? @default("NULL::character varying") @db.VarChar(32) - regdate DateTime? @db.Timestamp(6) - status String? @default("NULL::character varying") @db.VarChar(32) - - // 관계 설정 + objid Decimal @id @default(0) @db.Decimal + auth_name String? @default("NULL::character varying") @db.VarChar(256) + auth_code String? @default("NULL::character varying") @db.VarChar(64) + writer String? @default("NULL::character varying") @db.VarChar(32) + regdate DateTime? @db.Timestamp(6) + status String? @default("NULL::character varying") @db.VarChar(32) sub_users authority_sub_user[] } @@ -277,15 +275,12 @@ model authority_master_history { } model authority_sub_user { - objid Decimal @id @default(0) @db.Decimal - master_objid Decimal? @db.Decimal - user_id String? @default("NULL::character varying") @db.VarChar(64) - writer String? @default("NULL::character varying") @db.VarChar(64) - regdate DateTime? @db.Timestamp(6) - - // 관계 설정 + objid Decimal @id @default(0) @db.Decimal + master_objid Decimal? @db.Decimal + user_id String? @default("NULL::character varying") @db.VarChar(64) + writer String? @default("NULL::character varying") @db.VarChar(64) + regdate DateTime? @db.Timestamp(6) authority_master authority_master? @relation(fields: [master_objid], references: [objid]) - user user_info? @relation(fields: [user_id], references: [user_id]) @@index([master_objid]) @@index([user_id]) @@ -343,13 +338,6 @@ model bom_part_qty { @@index([parent_objid]) } - - - - - - - model car_distribute_member { objid Decimal @db.Decimal car_objid Decimal @db.Decimal @@ -439,6 +427,7 @@ model column_labels { column_name String? @db.VarChar(100) column_label String? @db.VarChar(200) web_type String? @db.VarChar(50) + input_type String? @default("direct") @db.VarChar(20) // direct, auto detail_settings String? description String? display_order Int? @default(0) @@ -530,9 +519,10 @@ model company_mng { regdate DateTime? @db.Timestamp(6) status String? @db.VarChar(32) - // 관계 설정 + // 관계 정의 menus menu_info[] } + model contract_mgmt { objid String @id @db.VarChar category_cd String? @db.VarChar @@ -599,8 +589,6 @@ model contract_mgmt_option { @@index([option_objid]) } - - model counselingmgmt { objid String @id @db.VarChar reg_date String? @db.VarChar @@ -625,8 +613,6 @@ model counselingmgmt { parent_seq String? @db.VarChar } - - model customer_service_mgmt { objid String @id @db.VarChar service_no String? @db.VarChar @@ -1181,8 +1167,6 @@ model inventory_mgmt_in { @@index([parent_objid]) } - - model inventory_mgmt_out { objid String @id @db.VarChar parent_objid String? @db.VarChar @@ -1262,6 +1246,7 @@ model invoice_mgmt { discount_percentage String? @db.VarChar inv_discount_price String? @db.VarChar } + model invoice_mgmt_part { objid String @id @db.VarChar invoice_objid String? @db.VarChar @@ -1506,7 +1491,7 @@ model menu_info { 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]) @@ -1537,8 +1522,6 @@ model mold_dev_request_info { status String? @db.VarChar(50) } - - model multi_lang_key_master { key_id Int @id @default(autoincrement()) company_code String @default("*") @db.VarChar(20) @@ -1801,7 +1784,6 @@ model order_spec_mng_history { @@ignore } - model part_bom_qty { bom_report_objid Decimal @db.Decimal objid Decimal @id @db.Decimal @@ -1837,9 +1819,6 @@ model part_bom_report { @@index([unit_code, contract_objid], map: "part_bom_report_unit_code_idx") } - - - model part_distribution_list { part_objid Decimal @db.Decimal product_mgmt_objid String? @db.VarChar(100) @@ -2021,9 +2000,6 @@ model part_mng_history { @@ignore } - - - model planning_issue { objid String @id @db.VarChar issue_no String @db.VarChar @@ -2221,10 +2197,6 @@ model pms_wbs_task { @@ignore } - - - - model pms_wbs_task_confirm { objid Decimal? @db.Decimal target_objid Decimal? @db.Decimal @@ -2349,6 +2321,7 @@ model product_group_mng { @@ignore } + model product_kind_spec { objid String @db.VarChar objid_parent String @db.VarChar @@ -2798,13 +2771,6 @@ model purchase_order_master { @@index([multi_master_objid]) } - - - - - - - model purchase_order_master_241216 { objid String? @db.VarChar purchase_order_no String? @db.VarChar @@ -2920,13 +2886,6 @@ model purchase_order_part { @@index([purchase_order_master_objid]) } - - - - - - - model ratecal_mgmt { ratecal_mgmt_objid Decimal @default(0) @db.Decimal position String? @db.VarChar(100) @@ -2944,8 +2903,6 @@ model ratecal_mgmt { @@ignore } - - model receive_history { objid String @id @db.VarChar part_objid String @db.VarChar @@ -3071,7 +3028,6 @@ model route { @@ignore } - model sales_bom_part_qty { sales_bom_objid String @db.VarChar objid String @id @db.VarChar @@ -3106,7 +3062,6 @@ model sales_bom_part_qty { sales_part_code String? @db.VarChar } - model sales_bom_report { objid String @id @default("") @db.VarChar parent_objid String? @unique(map: "sales_bom_report_parent_objid_idx") @db.VarChar @@ -3152,7 +3107,6 @@ model sales_bom_report_part { @@index([parent_objid]) } - model sales_bom_report_part_241218 { objid String? @db.VarChar parent_objid String? @db.VarChar @@ -3193,8 +3147,6 @@ model sales_long_delivery { price String? @default("0") @db.VarChar } - - model sales_long_delivery_input { objid String @id(map: "sales_long_delivery_plan_pkey") @db.VarChar parent_objid String? @db.VarChar @@ -3205,8 +3157,6 @@ model sales_long_delivery_input { admin_editor String? @db.VarChar } - - model sales_long_delivery_predict { objid String @id @db.VarChar parent_objid String? @db.VarChar @@ -3249,7 +3199,6 @@ model sales_request_master { remark String? @db.VarChar } - model sales_request_part { objid String @id @db.VarChar sales_bom_qty_objid String? @db.VarChar @@ -3309,7 +3258,7 @@ model setup_wbs_task { } model setup_wbs_task_standard { - objid String? @unique(map: "setup_wbs_task_standard_objid_key") @db.VarChar + objid String? @unique @db.VarChar contract_objid String? @db.VarChar parent_objid String? @db.VarChar task_category String? @db.VarChar @@ -3808,6 +3757,7 @@ model swpc120a_tbl { @@id([imitemid, suvndcd], map: "pk_swpc120a_tbl") } + model swpc130a_tbl { imitemid String @db.VarChar(15) suvndcd String @db.VarChar(5) @@ -4566,6 +4516,7 @@ model swsb500a_tbl { edit_date DateTime? @db.Timestamp(6) edit_emp String? @db.VarChar(30) } + model swsb510a_tbl { frw_req_no String @id(map: "pk_swsb510a_tbl") @db.VarChar(10) req_date String? @db.VarChar(8) @@ -4874,15 +4825,6 @@ model table_labels { column_labels column_labels[] } - - - - - - - - - model template_mng { objid Int @id template_code String? @db.VarChar @@ -4893,12 +4835,6 @@ model template_mng { template_code_detail String? @db.VarChar } - - - - - - model time_sheet { objid Decimal @default(0) @db.Decimal project_mgmt_objid Decimal? @db.Decimal @@ -4971,7 +4907,6 @@ model user_info { user_type String? @db.VarChar(1024) user_type_name String? @db.VarChar(1024) regdate DateTime? @db.Timestamp(6) - data_type String? @db.VarChar(64) status String? @db.VarChar(32) end_date DateTime? @db.Timestamp(6) fax_no String? @db.VarChar @@ -4980,9 +4915,7 @@ model user_info { photo Bytes? locale String? @db.VarChar company_code String? @db.VarChar(50) - - // 관계 설정 - authorities authority_sub_user[] + data_type String? @db.VarChar(64) } model user_info_history { @@ -5051,44 +4984,38 @@ model zz_230410_user_info { @@ignore } -// 화면관리 시스템 Prisma 스키마 -// 기존 schema.prisma에 추가할 모델들 model screen_definitions { - screen_id Int @id @default(autoincrement()) - screen_name String @db.VarChar(100) - screen_code String @unique @db.VarChar(50) - table_name String @db.VarChar(100) - company_code String @db.VarChar(50) - description String? @db.Text - is_active String @default("Y") @db.Char(1) - created_date DateTime @default(now()) @db.Timestamp(6) - created_by String? @db.VarChar(50) - updated_date DateTime @default(now()) @db.Timestamp(6) - updated_by String? @db.VarChar(50) - - // 관계 - layouts screen_layouts[] + 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? + 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()) + 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) + 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) - - // 관계 + 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[] @@ -5096,99 +5023,82 @@ model screen_layouts { } 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) + 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) + 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? + 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) + 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]) } -// ===================================================== -// 공통코드 관리 시스템 모델 -// ===================================================== - /// 공통코드 카테고리 테이블 model code_category { - category_code String @id @db.VarChar(50) - category_name String @db.VarChar(100) - category_name_eng String? @db.VarChar(100) - description String? @db.Text - 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) - - // 관계 - 코드 상세 정보 - codes code_info[] - - @@index([is_active]) - @@index([sort_order]) + category_code String @id @db.VarChar(50) + category_name String @db.VarChar(100) + category_name_eng String? @db.VarChar(100) + description String? + 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) + codes code_info[] } /// 공통코드 상세 정보 테이블 model code_info { - code_category String @db.VarChar(50) - code_value String @db.VarChar(50) - code_name String @db.VarChar(100) - code_name_eng String? @db.VarChar(100) - description String? @db.Text - 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) + code_category String @db.VarChar(50) + code_value String @db.VarChar(50) + code_name String @db.VarChar(100) + code_name_eng String? @db.VarChar(100) + description String? + 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) + category code_category @relation(fields: [code_category], references: [category_code], onDelete: Cascade, map: "fk_code_info_category") - // 관계 - 코드 카테고리 - category code_category @relation(fields: [code_category], references: [category_code], onDelete: Cascade, onUpdate: Cascade) - - @@id([code_category, code_value]) - @@index([code_category]) - @@index([is_active]) - @@index([code_category, sort_order]) + @@id([code_category, code_value], map: "pk_code_info") + @@index([code_category, sort_order], map: "idx_code_info_sort") } From 78d4d7de23a739f76bca2f803b01a066856a8158 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 4 Sep 2025 14:23:35 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=EA=B0=9C=EB=B3=84=20=EC=9C=84?= =?UTF-8?q?=EC=A0=AF=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=20=ED=83=80=EC=9E=85=20=EB=B0=8F=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=20=EA=B0=92=20=EC=84=A4=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BaseComponent에 inputType, autoValueType 속성 추가 - DetailSettingsPanel에 입력 타입 및 자동 값 타입 선택 UI 추가 - RealtimePreview에서 자동 값 타입별 값 생성 및 표시 로직 구현 - 텍스트, 숫자, 날짜 위젯에서 7가지 자동 값 타입 지원 - 현재 날짜시간, 현재 날짜, 현재 시간 - 현재 사용자, UUID, 시퀀스, 사용자 정의 - 자동입력 모드에서 읽기 전용 스타일 적용 (회색 배경) - 백엔드 API에 input_type 처리 로직 추가 - TableTypeSelector에 입력 타입 설정 UI 추가 --- backend-node/src/app.ts | 2 + .../src/controllers/dynamicFormController.ts | 310 ++++++++++ .../controllers/tableManagementController.ts | 5 +- backend-node/src/routes/dynamicFormRoutes.ts | 33 + .../src/services/dynamicFormService.ts | 573 ++++++++++++++++++ .../src/services/tableManagementService.ts | 42 +- backend-node/src/types/screen.ts | 1 + backend-node/src/types/tableManagement.ts | 1 + .../app/(main)/screens/[screenId]/page.tsx | 4 + .../screen/InteractiveScreenViewer.tsx | 305 +++++++++- .../components/screen/RealtimePreview.tsx | 175 +++++- .../components/screen/TableTypeSelector.tsx | 41 ++ .../screen/panels/ButtonConfigPanel.tsx | 56 +- .../screen/panels/DetailSettingsPanel.tsx | 82 +++ frontend/lib/api/dynamicForm.ts | 319 ++++++++++ frontend/lib/api/screen.ts | 2 + frontend/types/screen.ts | 10 + 17 files changed, 1912 insertions(+), 49 deletions(-) create mode 100644 backend-node/src/controllers/dynamicFormController.ts create mode 100644 backend-node/src/routes/dynamicFormRoutes.ts create mode 100644 backend-node/src/services/dynamicFormService.ts create mode 100644 frontend/lib/api/dynamicForm.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 3b281d19..53a00c0b 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -15,6 +15,7 @@ import multilangRoutes from "./routes/multilangRoutes"; import tableManagementRoutes from "./routes/tableManagementRoutes"; import screenManagementRoutes from "./routes/screenManagementRoutes"; import commonCodeRoutes from "./routes/commonCodeRoutes"; +import dynamicFormRoutes from "./routes/dynamicFormRoutes"; // import userRoutes from './routes/userRoutes'; // import menuRoutes from './routes/menuRoutes'; @@ -67,6 +68,7 @@ app.use("/api/multilang", multilangRoutes); app.use("/api/table-management", tableManagementRoutes); app.use("/api/screen-management", screenManagementRoutes); app.use("/api/common-codes", commonCodeRoutes); +app.use("/api/dynamic-form", dynamicFormRoutes); // app.use('/api/users', userRoutes); // app.use('/api/menus', menuRoutes); diff --git a/backend-node/src/controllers/dynamicFormController.ts b/backend-node/src/controllers/dynamicFormController.ts new file mode 100644 index 00000000..b3cb79da --- /dev/null +++ b/backend-node/src/controllers/dynamicFormController.ts @@ -0,0 +1,310 @@ +import { Response } from "express"; +import { dynamicFormService } from "../services/dynamicFormService"; +import { AuthenticatedRequest } from "../types/auth"; + +// 폼 데이터 저장 +export const saveFormData = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode, userId } = req.user as any; + const { screenId, tableName, data } = req.body; + + console.log("💾 폼 데이터 저장 요청:", { + userId, + companyCode, + screenId, + tableName, + data, + }); + + // 필수 필드 검증 + if (!screenId || !tableName || !data) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다. (screenId, tableName, data)", + }); + } + + // 메타데이터 추가 (사용자가 입력한 경우에만 company_code 추가) + const formDataWithMeta = { + ...data, + created_by: userId, + updated_by: userId, + screen_id: screenId, + }; + + // company_code는 사용자가 명시적으로 입력한 경우에만 추가 + if (data.company_code !== undefined) { + formDataWithMeta.company_code = data.company_code; + } else if (companyCode && companyCode !== "*") { + // 기본 company_code가 '*'가 아닌 경우에만 추가 + formDataWithMeta.company_code = companyCode; + } + + const result = await dynamicFormService.saveFormData( + screenId, + tableName, + formDataWithMeta + ); + + console.log("✅ 폼 데이터 저장 성공:", result); + + res.json({ + success: true, + data: result, + message: "데이터가 성공적으로 저장되었습니다.", + }); + } catch (error: any) { + console.error("❌ 폼 데이터 저장 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "데이터 저장에 실패했습니다.", + }); + } +}; + +// 폼 데이터 업데이트 +export const updateFormData = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { id } = req.params; + const { companyCode, userId } = req.user as any; + const { tableName, data } = req.body; + + console.log("🔄 폼 데이터 업데이트 요청:", { + id, + userId, + companyCode, + tableName, + data, + }); + + if (!tableName || !data) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다. (tableName, data)", + }); + } + + // 메타데이터 추가 + const formDataWithMeta = { + ...data, + updated_by: userId, + updated_at: new Date(), + }; + + const result = await dynamicFormService.updateFormData( + parseInt(id), + tableName, + formDataWithMeta + ); + + console.log("✅ 폼 데이터 업데이트 성공:", result); + + res.json({ + success: true, + data: result, + message: "데이터가 성공적으로 업데이트되었습니다.", + }); + } catch (error: any) { + console.error("❌ 폼 데이터 업데이트 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "데이터 업데이트에 실패했습니다.", + }); + } +}; + +// 폼 데이터 삭제 +export const deleteFormData = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { id } = req.params; + const { companyCode } = req.user as any; + const { tableName } = req.body; + + console.log("🗑️ 폼 데이터 삭제 요청:", { id, companyCode, tableName }); + + if (!tableName) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다. (tableName)", + }); + } + + await dynamicFormService.deleteFormData(parseInt(id), tableName); + + console.log("✅ 폼 데이터 삭제 성공"); + + res.json({ + success: true, + message: "데이터가 성공적으로 삭제되었습니다.", + }); + } catch (error: any) { + console.error("❌ 폼 데이터 삭제 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "데이터 삭제에 실패했습니다.", + }); + } +}; + +// 단일 폼 데이터 조회 +export const getFormData = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { id } = req.params; + const { companyCode } = req.user as any; + + console.log("📄 폼 데이터 단건 조회 요청:", { id, companyCode }); + + const data = await dynamicFormService.getFormData(parseInt(id)); + + if (!data) { + return res.status(404).json({ + success: false, + message: "데이터를 찾을 수 없습니다.", + }); + } + + console.log("✅ 폼 데이터 단건 조회 성공"); + + res.json({ + success: true, + data: data, + }); + } catch (error: any) { + console.error("❌ 폼 데이터 단건 조회 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "데이터 조회에 실패했습니다.", + }); + } +}; + +// 화면별 폼 데이터 목록 조회 +export const getFormDataList = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { screenId } = req.params; + const { companyCode } = req.user as any; + const { + page = 1, + size = 10, + search = "", + sortBy = "created_at", + sortOrder = "desc", + } = req.query; + + console.log("📋 폼 데이터 목록 조회 요청:", { + screenId, + companyCode, + page, + size, + search, + sortBy, + sortOrder, + }); + + const result = await dynamicFormService.getFormDataList( + parseInt(screenId as string), + { + page: parseInt(page as string), + size: parseInt(size as string), + search: search as string, + sortBy: sortBy as string, + sortOrder: sortOrder as "asc" | "desc", + } + ); + + console.log("✅ 폼 데이터 목록 조회 성공"); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + console.error("❌ 폼 데이터 목록 조회 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "데이터 조회에 실패했습니다.", + }); + } +}; + +// 폼 데이터 검증 +export const validateFormData = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { tableName, data } = req.body; + + console.log("✅ 폼 데이터 검증 요청:", { tableName, data }); + + if (!tableName || !data) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다. (tableName, data)", + }); + } + + const validationResult = await dynamicFormService.validateFormData( + tableName, + data + ); + + console.log("✅ 폼 데이터 검증 성공:", validationResult); + + res.json({ + success: true, + data: validationResult, + }); + } catch (error: any) { + console.error("❌ 폼 데이터 검증 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "데이터 검증에 실패했습니다.", + }); + } +}; + +// 테이블 컬럼 정보 조회 (검증용) +export const getTableColumns = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { tableName } = req.params; + + console.log("📊 테이블 컬럼 정보 조회 요청:", { tableName }); + + const columns = await dynamicFormService.getTableColumns(tableName); + + console.log("✅ 테이블 컬럼 정보 조회 성공"); + + res.json({ + success: true, + data: { + tableName, + columns, + }, + }); + } catch (error: any) { + console.error("❌ 테이블 컬럼 정보 조회 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "테이블 정보 조회에 실패했습니다.", + }); + } +}; diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index bbee31d4..c189b9d8 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -386,7 +386,7 @@ export async function updateColumnWebType( ): Promise { try { const { tableName, columnName } = req.params; - const { webType, detailSettings } = req.body; + const { webType, detailSettings, inputType } = req.body; logger.info( `=== 컬럼 웹 타입 설정 시작: ${tableName}.${columnName} = ${webType} ===` @@ -410,7 +410,8 @@ export async function updateColumnWebType( tableName, columnName, webType, - detailSettings + detailSettings, + inputType ); logger.info( diff --git a/backend-node/src/routes/dynamicFormRoutes.ts b/backend-node/src/routes/dynamicFormRoutes.ts new file mode 100644 index 00000000..f37a84ae --- /dev/null +++ b/backend-node/src/routes/dynamicFormRoutes.ts @@ -0,0 +1,33 @@ +import express from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { + saveFormData, + updateFormData, + deleteFormData, + getFormData, + getFormDataList, + validateFormData, + getTableColumns, +} from "../controllers/dynamicFormController"; + +const router = express.Router(); + +// 모든 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + +// 폼 데이터 CRUD +router.post("/save", saveFormData); +router.put("/:id", updateFormData); +router.delete("/:id", deleteFormData); +router.get("/:id", getFormData); + +// 화면별 폼 데이터 목록 조회 +router.get("/screen/:screenId", getFormDataList); + +// 폼 데이터 검증 +router.post("/validate", validateFormData); + +// 테이블 컬럼 정보 조회 (검증용) +router.get("/table/:tableName/columns", getTableColumns); + +export default router; diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts new file mode 100644 index 00000000..3b424dac --- /dev/null +++ b/backend-node/src/services/dynamicFormService.ts @@ -0,0 +1,573 @@ +import prisma from "../config/database"; +import { Prisma } from "@prisma/client"; + +export interface FormDataResult { + id: number; + screenId: number; + tableName: string; + data: Record; + createdAt: Date | null; + updatedAt: Date | null; + createdBy: string; + updatedBy: string; +} + +export interface PaginatedFormData { + content: FormDataResult[]; + totalElements: number; + totalPages: number; + currentPage: number; + size: number; +} + +export interface ValidationError { + field: string; + message: string; + code: string; +} + +export interface ValidationResult { + valid: boolean; + errors: ValidationError[]; +} + +export interface TableColumn { + columnName: string; + dataType: string; + nullable: boolean; + primaryKey: boolean; + maxLength?: number; + defaultValue?: any; +} + +export class DynamicFormService { + /** + * 테이블의 컬럼명 목록 조회 (간단 버전) + */ + private async getTableColumnNames(tableName: string): Promise { + try { + const result = (await prisma.$queryRawUnsafe(` + SELECT column_name + FROM information_schema.columns + WHERE table_name = '${tableName}' + AND table_schema = 'public' + `)) as any[]; + + return result.map((row) => row.column_name); + } catch (error) { + console.error(`❌ 테이블 ${tableName} 컬럼 정보 조회 실패:`, error); + return []; + } + } + + /** + * 테이블의 Primary Key 컬럼 조회 + */ + private async getTablePrimaryKeys(tableName: string): Promise { + try { + const result = (await prisma.$queryRawUnsafe(` + SELECT kcu.column_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + WHERE tc.table_name = '${tableName}' + AND tc.constraint_type = 'PRIMARY KEY' + AND tc.table_schema = 'public' + `)) as any[]; + + return result.map((row) => row.column_name); + } catch (error) { + console.error(`❌ 테이블 ${tableName} Primary Key 조회 실패:`, error); + return []; + } + } + + /** + * 폼 데이터 저장 (실제 테이블에 직접 저장) + */ + async saveFormData( + screenId: number, + tableName: string, + data: Record + ): Promise { + try { + console.log("💾 서비스: 실제 테이블에 폼 데이터 저장 시작:", { + screenId, + tableName, + data, + }); + + // 테이블의 실제 컬럼 정보와 Primary Key 조회 + const tableColumns = await this.getTableColumnNames(tableName); + const primaryKeys = await this.getTablePrimaryKeys(tableName); + console.log(`📋 테이블 ${tableName}의 컬럼:`, tableColumns); + console.log(`🔑 테이블 ${tableName}의 Primary Key:`, primaryKeys); + + // 메타데이터 제거 (실제 테이블 컬럼이 아님) + const { created_by, updated_by, company_code, screen_id, ...actualData } = + data; + + // 기본 데이터 준비 + const dataToInsert: any = { ...actualData }; + + // 테이블에 존재하는 공통 필드들만 추가 + if (tableColumns.includes("created_at")) { + dataToInsert.created_at = new Date(); + } + if (tableColumns.includes("updated_at")) { + dataToInsert.updated_at = new Date(); + } + if (tableColumns.includes("regdate") && !dataToInsert.regdate) { + dataToInsert.regdate = new Date(); + } + + // 생성자/수정자 정보가 있고 해당 컬럼이 존재한다면 추가 + if (created_by && tableColumns.includes("created_by")) { + dataToInsert.created_by = created_by; + } + if (updated_by && tableColumns.includes("updated_by")) { + dataToInsert.updated_by = updated_by; + } + if (company_code && tableColumns.includes("company_code")) { + dataToInsert.company_code = company_code; + } + + // 존재하지 않는 컬럼 제거 + Object.keys(dataToInsert).forEach((key) => { + if (!tableColumns.includes(key)) { + console.log( + `⚠️ 컬럼 ${key}는 테이블 ${tableName}에 존재하지 않아 제거됨` + ); + delete dataToInsert[key]; + } + }); + + console.log("🎯 실제 테이블에 삽입할 데이터:", { + tableName, + dataToInsert, + }); + + // 동적 SQL을 사용하여 실제 테이블에 UPSERT + const columns = Object.keys(dataToInsert); + const values: any[] = Object.values(dataToInsert); + const placeholders = values.map((_, index) => `$${index + 1}`).join(", "); + + let upsertQuery: string; + + if (primaryKeys.length > 0) { + // Primary Key가 있는 경우 UPSERT 사용 + const conflictColumns = primaryKeys.join(", "); + const updateSet = columns + .filter((col) => !primaryKeys.includes(col)) // Primary Key는 UPDATE에서 제외 + .map((col) => `${col} = EXCLUDED.${col}`) + .join(", "); + + if (updateSet) { + upsertQuery = ` + INSERT INTO ${tableName} (${columns.join(", ")}) + VALUES (${placeholders}) + ON CONFLICT (${conflictColumns}) + DO UPDATE SET ${updateSet} + RETURNING * + `; + } else { + // 업데이트할 컬럼이 없는 경우 (Primary Key만 있는 테이블) + upsertQuery = ` + INSERT INTO ${tableName} (${columns.join(", ")}) + VALUES (${placeholders}) + ON CONFLICT (${conflictColumns}) + DO NOTHING + RETURNING * + `; + } + } else { + // Primary Key가 없는 경우 일반 INSERT + upsertQuery = ` + INSERT INTO ${tableName} (${columns.join(", ")}) + VALUES (${placeholders}) + RETURNING * + `; + } + + console.log("📝 실행할 UPSERT SQL:", upsertQuery); + console.log("📊 SQL 파라미터:", values); + + const result = await prisma.$queryRawUnsafe(upsertQuery, ...values); + + console.log("✅ 서비스: 실제 테이블 저장 성공:", result); + + // 결과를 표준 형식으로 변환 + const insertedRecord = Array.isArray(result) ? result[0] : result; + + return { + id: insertedRecord.id || insertedRecord.objid || 0, + screenId: screenId, + tableName: tableName, + data: insertedRecord as Record, + createdAt: insertedRecord.created_at || new Date(), + updatedAt: insertedRecord.updated_at || new Date(), + createdBy: insertedRecord.created_by || created_by || "system", + updatedBy: insertedRecord.updated_by || updated_by || "system", + }; + } catch (error) { + console.error("❌ 서비스: 실제 테이블 저장 실패:", error); + throw new Error(`실제 테이블 저장 실패: ${error}`); + } + } + + /** + * 폼 데이터 업데이트 (실제 테이블에서 직접 업데이트) + */ + async updateFormData( + id: number, + tableName: string, + data: Record + ): Promise { + try { + console.log("🔄 서비스: 실제 테이블에서 폼 데이터 업데이트 시작:", { + id, + tableName, + data, + }); + + // 테이블의 실제 컬럼 정보 조회 + const tableColumns = await this.getTableColumnNames(tableName); + console.log(`📋 테이블 ${tableName}의 컬럼:`, tableColumns); + + // 메타데이터 제거 + const { created_by, updated_by, company_code, screen_id, ...actualData } = + data; + + // 기본 데이터 준비 + const dataToUpdate: any = { ...actualData }; + + // 테이블에 존재하는 업데이트 관련 필드들만 추가 + if (tableColumns.includes("updated_at")) { + dataToUpdate.updated_at = new Date(); + } + if (tableColumns.includes("regdate") && !dataToUpdate.regdate) { + dataToUpdate.regdate = new Date(); + } + + // 수정자 정보가 있고 해당 컬럼이 존재한다면 추가 + if (updated_by && tableColumns.includes("updated_by")) { + dataToUpdate.updated_by = updated_by; + } + + // 존재하지 않는 컬럼 제거 + Object.keys(dataToUpdate).forEach((key) => { + if (!tableColumns.includes(key)) { + console.log( + `⚠️ 컬럼 ${key}는 테이블 ${tableName}에 존재하지 않아 제거됨` + ); + delete dataToUpdate[key]; + } + }); + + console.log("🎯 실제 테이블에서 업데이트할 데이터:", { + tableName, + id, + dataToUpdate, + }); + + // 동적 UPDATE SQL 생성 + const setClause = Object.keys(dataToUpdate) + .map((key, index) => `${key} = $${index + 1}`) + .join(", "); + + const values: any[] = Object.values(dataToUpdate); + values.push(id); // WHERE 조건용 ID 추가 + + // ID 또는 objid로 찾기 시도 + const updateQuery = ` + UPDATE ${tableName} + SET ${setClause} + WHERE (id = $${values.length} OR objid = $${values.length}) + RETURNING * + `; + + console.log("📝 실행할 UPDATE SQL:", updateQuery); + console.log("📊 SQL 파라미터:", values); + + const result = await prisma.$queryRawUnsafe(updateQuery, ...values); + + console.log("✅ 서비스: 실제 테이블 업데이트 성공:", result); + + const updatedRecord = Array.isArray(result) ? result[0] : result; + + return { + id: updatedRecord.id || updatedRecord.objid || id, + screenId: 0, // 실제 테이블에는 screenId가 없으므로 0으로 설정 + tableName: tableName, + data: updatedRecord as Record, + createdAt: updatedRecord.created_at || new Date(), + updatedAt: updatedRecord.updated_at || new Date(), + createdBy: updatedRecord.created_by || "system", + updatedBy: updatedRecord.updated_by || updated_by || "system", + }; + } catch (error) { + console.error("❌ 서비스: 실제 테이블 업데이트 실패:", error); + throw new Error(`실제 테이블 업데이트 실패: ${error}`); + } + } + + /** + * 폼 데이터 삭제 (실제 테이블에서 직접 삭제) + */ + async deleteFormData(id: number, tableName: string): Promise { + try { + console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", { + id, + tableName, + }); + + // 동적 DELETE SQL 생성 + const deleteQuery = ` + DELETE FROM ${tableName} + WHERE (id = $1 OR objid = $1) + RETURNING * + `; + + console.log("📝 실행할 DELETE SQL:", deleteQuery); + console.log("📊 SQL 파라미터:", [id]); + + const result = await prisma.$queryRawUnsafe(deleteQuery, id); + + console.log("✅ 서비스: 실제 테이블 삭제 성공:", result); + } catch (error) { + console.error("❌ 서비스: 실제 테이블 삭제 실패:", error); + throw new Error(`실제 테이블 삭제 실패: ${error}`); + } + } + + /** + * 단일 폼 데이터 조회 + */ + async getFormData(id: number): Promise { + try { + console.log("📄 서비스: 폼 데이터 단건 조회 시작:", { id }); + + const result = await prisma.dynamic_form_data.findUnique({ + where: { id }, + }); + + if (!result) { + console.log("❌ 서비스: 폼 데이터를 찾을 수 없음"); + return null; + } + + console.log("✅ 서비스: 폼 데이터 단건 조회 성공"); + + return { + id: result.id, + screenId: result.screen_id, + tableName: result.table_name, + data: result.form_data as Record, + createdAt: result.created_at, + updatedAt: result.updated_at, + createdBy: result.created_by, + updatedBy: result.updated_by, + }; + } catch (error) { + console.error("❌ 서비스: 폼 데이터 단건 조회 실패:", error); + throw new Error(`폼 데이터 조회 실패: ${error}`); + } + } + + /** + * 화면별 폼 데이터 목록 조회 (페이징) + */ + async getFormDataList( + screenId: number, + params: { + page: number; + size: number; + search?: string; + sortBy?: string; + sortOrder?: "asc" | "desc"; + } + ): Promise { + try { + console.log("📋 서비스: 폼 데이터 목록 조회 시작:", { screenId, params }); + + const { + page, + size, + search, + sortBy = "created_at", + sortOrder = "desc", + } = params; + const skip = (page - 1) * size; + + // 검색 조건 구성 + const where: Prisma.dynamic_form_dataWhereInput = { + screen_id: screenId, + }; + + // 검색어가 있는 경우 form_data 필드에서 검색 + if (search) { + where.OR = [ + { + form_data: { + path: [], + string_contains: search, + }, + }, + { + table_name: { + contains: search, + mode: "insensitive", + }, + }, + ]; + } + + // 정렬 조건 구성 + const orderBy: Prisma.dynamic_form_dataOrderByWithRelationInput = {}; + if (sortBy === "created_at" || sortBy === "updated_at") { + orderBy[sortBy] = sortOrder; + } else { + orderBy.created_at = "desc"; // 기본값 + } + + // 데이터 조회 + const [results, totalCount] = await Promise.all([ + prisma.dynamic_form_data.findMany({ + where, + orderBy, + skip, + take: size, + }), + prisma.dynamic_form_data.count({ where }), + ]); + + const formDataResults: FormDataResult[] = results.map((result) => ({ + id: result.id, + screenId: result.screen_id, + tableName: result.table_name, + data: result.form_data as Record, + createdAt: result.created_at, + updatedAt: result.updated_at, + createdBy: result.created_by, + updatedBy: result.updated_by, + })); + + const totalPages = Math.ceil(totalCount / size); + + console.log("✅ 서비스: 폼 데이터 목록 조회 성공:", { + totalCount, + totalPages, + currentPage: page, + }); + + return { + content: formDataResults, + totalElements: totalCount, + totalPages, + currentPage: page, + size, + }; + } catch (error) { + console.error("❌ 서비스: 폼 데이터 목록 조회 실패:", error); + throw new Error(`폼 데이터 목록 조회 실패: ${error}`); + } + } + + /** + * 폼 데이터 검증 + */ + async validateFormData( + tableName: string, + data: Record + ): Promise { + try { + console.log("✅ 서비스: 폼 데이터 검증 시작:", { tableName, data }); + + const errors: ValidationError[] = []; + + // 기본 검증 로직 (실제로는 테이블 스키마를 확인해야 함) + Object.entries(data).forEach(([key, value]) => { + // 예시: 빈 값 검증 + if (value === null || value === undefined || value === "") { + // 특정 필드가 required인지 확인하는 로직이 필요 + // 지금은 간단히 모든 필드를 선택사항으로 처리 + } + + // 예시: 데이터 타입 검증 + // 실제로는 테이블 스키마의 컬럼 타입과 비교해야 함 + }); + + const result: ValidationResult = { + valid: errors.length === 0, + errors, + }; + + console.log("✅ 서비스: 폼 데이터 검증 완료:", result); + + return result; + } catch (error) { + console.error("❌ 서비스: 폼 데이터 검증 실패:", error); + throw new Error(`폼 데이터 검증 실패: ${error}`); + } + } + + /** + * 테이블 컬럼 정보 조회 (PostgreSQL 시스템 테이블 활용) + */ + async getTableColumns(tableName: string): Promise { + try { + console.log("📊 서비스: 테이블 컬럼 정보 조회 시작:", { tableName }); + + // PostgreSQL의 information_schema를 사용하여 컬럼 정보 조회 + const columns = await prisma.$queryRaw` + SELECT + column_name, + data_type, + is_nullable, + column_default, + character_maximum_length + FROM information_schema.columns + WHERE table_name = ${tableName} + AND table_schema = 'public' + ORDER BY ordinal_position + `; + + // Primary key 정보 조회 + const primaryKeys = await prisma.$queryRaw` + SELECT + kcu.column_name + FROM + information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + WHERE + tc.constraint_type = 'PRIMARY KEY' + AND tc.table_name = ${tableName} + AND tc.table_schema = 'public' + `; + + const primaryKeyColumns = new Set( + primaryKeys.map((pk) => pk.column_name) + ); + + const result: TableColumn[] = columns.map((col) => ({ + columnName: col.column_name, + dataType: col.data_type, + nullable: col.is_nullable === "YES", + primaryKey: primaryKeyColumns.has(col.column_name), + maxLength: col.character_maximum_length, + defaultValue: col.column_default, + })); + + console.log("✅ 서비스: 테이블 컬럼 정보 조회 성공:", result); + + return result; + } catch (error) { + console.error("❌ 서비스: 테이블 컬럼 정보 조회 실패:", error); + throw new Error(`테이블 컬럼 정보 조회 실패: ${error}`); + } + } +} + +// 싱글톤 인스턴스 생성 및 export +export const dynamicFormService = new DynamicFormService(); diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 2f2b76a5..7abda41a 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -69,6 +69,7 @@ export class TableManagementService { COALESCE(cl.column_label, c.column_name) as "displayName", c.data_type as "dbType", COALESCE(cl.web_type, 'text') as "webType", + COALESCE(cl.input_type, 'direct') as "inputType", COALESCE(cl.detail_settings, '') as "detailSettings", COALESCE(cl.description, '') as "description", c.is_nullable as "isNullable", @@ -368,7 +369,8 @@ export class TableManagementService { tableName: string, columnName: string, webType: string, - detailSettings?: Record + detailSettings?: Record, + inputType?: string ): Promise { try { logger.info( @@ -394,30 +396,42 @@ export class TableManagementService { if (existingColumn) { // 기존 컬럼 라벨 업데이트 + const updateData: any = { + web_type: webType, + detail_settings: JSON.stringify(finalDetailSettings), + updated_date: new Date(), + }; + + if (inputType) { + updateData.input_type = inputType; + } + await prisma.column_labels.update({ where: { id: existingColumn.id, }, - data: { - web_type: webType, - detail_settings: JSON.stringify(finalDetailSettings), - updated_date: new Date(), - }, + data: updateData, }); logger.info( `컬럼 웹 타입 업데이트 완료: ${tableName}.${columnName} = ${webType}` ); } else { // 새로운 컬럼 라벨 생성 + const createData: any = { + table_name: tableName, + column_name: columnName, + web_type: webType, + detail_settings: JSON.stringify(finalDetailSettings), + created_date: new Date(), + updated_date: new Date(), + }; + + if (inputType) { + createData.input_type = inputType; + } + await prisma.column_labels.create({ - data: { - table_name: tableName, - column_name: columnName, - web_type: webType, - detail_settings: JSON.stringify(finalDetailSettings), - created_date: new Date(), - updated_date: new Date(), - }, + data: createData, }); logger.info( `컬럼 라벨 생성 및 웹 타입 설정 완료: ${tableName}.${columnName} = ${webType}` diff --git a/backend-node/src/types/screen.ts b/backend-node/src/types/screen.ts index 8c76b7c4..9cb63cea 100644 --- a/backend-node/src/types/screen.ts +++ b/backend-node/src/types/screen.ts @@ -224,6 +224,7 @@ export interface ColumnInfo { columnLabel?: string; dataType: string; webType?: WebType; + inputType?: "direct" | "auto"; // 입력 타입 isNullable: string; columnDefault?: string; characterMaximumLength?: number; diff --git a/backend-node/src/types/tableManagement.ts b/backend-node/src/types/tableManagement.ts index 8aeb0727..a8a65332 100644 --- a/backend-node/src/types/tableManagement.ts +++ b/backend-node/src/types/tableManagement.ts @@ -12,6 +12,7 @@ export interface ColumnTypeInfo { displayName: string; dbType: string; webType: string; + inputType?: "direct" | "auto"; detailSettings: string; description: string; isNullable: string; diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index d4bf6448..d5d2f8ba 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -206,6 +206,10 @@ export default function ScreenViewPage() { })); }} hideLabel={true} // 라벨 숨김 플래그 전달 + screenInfo={{ + id: screenId, + tableName: screen?.tableName, + }} /> diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index be0cd1af..32cea32b 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -28,6 +28,8 @@ import { ButtonTypeConfig, } from "@/types/screen"; import { InteractiveDataTable } from "./InteractiveDataTable"; +import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm"; +import { useParams } from "next/navigation"; interface InteractiveScreenViewerProps { component: ComponentData; @@ -35,6 +37,10 @@ interface InteractiveScreenViewerProps { formData?: Record; onFormDataChange?: (fieldName: string, value: any) => void; hideLabel?: boolean; + screenInfo?: { + id: number; + tableName?: string; + }; } export const InteractiveScreenViewer: React.FC = ({ @@ -43,6 +49,7 @@ export const InteractiveScreenViewer: React.FC = ( formData: externalFormData, onFormDataChange, hideLabel = false, + screenInfo, }) => { const [localFormData, setLocalFormData] = useState>({}); const [dateValues, setDateValues] = useState>({}); @@ -683,28 +690,300 @@ export const InteractiveScreenViewer: React.FC = ( const widget = comp as WidgetComponent; const config = widget.webTypeConfig as ButtonTypeConfig | undefined; - const handleButtonClick = () => { - if (config?.actionType === "popup" && config.popupTitle) { - alert(`${config.popupTitle}\n\n${config.popupContent || "팝업 내용이 없습니다."}`); - } else if (config?.actionType === "navigate" && config.navigateUrl) { + const handleButtonClick = async () => { + const actionType = config?.actionType || "save"; + + try { + switch (actionType) { + case "save": + await handleSaveAction(); + break; + case "cancel": + handleCancelAction(); + break; + case "delete": + await handleDeleteAction(); + break; + case "edit": + handleEditAction(); + break; + case "add": + handleAddAction(); + break; + case "search": + handleSearchAction(); + break; + case "reset": + handleResetAction(); + break; + case "submit": + await handleSubmitAction(); + break; + case "close": + handleCloseAction(); + break; + case "popup": + handlePopupAction(); + break; + case "navigate": + handleNavigateAction(); + break; + case "custom": + await handleCustomAction(); + break; + default: + console.log(`알 수 없는 액션 타입: ${actionType}`); + } + } catch (error) { + console.error(`버튼 액션 실행 오류 (${actionType}):`, error); + alert(`작업 중 오류가 발생했습니다: ${error.message}`); + } + }; + + // 저장 액션 + const handleSaveAction = async () => { + if (!formData || Object.keys(formData).length === 0) { + alert("저장할 데이터가 없습니다."); + return; + } + + // 필수 항목 검증 + const requiredFields = allComponents.filter(c => c.required && (c.columnName || c.id)); + const missingFields = requiredFields.filter(field => { + const fieldName = field.columnName || field.id; + const value = formData[fieldName]; + return !value || value.toString().trim() === ""; + }); + + if (missingFields.length > 0) { + const fieldNames = missingFields.map(f => f.label || f.columnName || f.id).join(", "); + alert(`다음 필수 항목을 입력해주세요: ${fieldNames}`); + return; + } + + if (!screenInfo?.id) { + alert("화면 정보가 없어 저장할 수 없습니다."); + return; + } + + try { + // 컬럼명 기반으로 데이터 매핑 + const mappedData: Record = {}; + + // 컴포넌트에서 컬럼명이 있는 것들만 매핑 + allComponents.forEach(comp => { + if (comp.columnName) { + const fieldName = comp.columnName; + const componentId = comp.id; + + // formData에서 해당 값 찾기 (컬럼명 우선, 없으면 컴포넌트 ID) + const value = formData[fieldName] || formData[componentId]; + + if (value !== undefined && value !== "") { + mappedData[fieldName] = value; + } + } + }); + + console.log("💾 저장할 데이터 매핑:", { + 원본데이터: formData, + 매핑된데이터: mappedData, + 화면정보: screenInfo, + }); + + // 테이블명 결정 (화면 정보에서 가져오거나 첫 번째 컴포넌트의 테이블명 사용) + const tableName = screenInfo.tableName || + allComponents.find(c => c.columnName)?.tableName || + "dynamic_form_data"; // 기본값 + + const saveData: DynamicFormData = { + screenId: screenInfo.id, + tableName: tableName, + data: mappedData, + }; + + console.log("🚀 API 저장 요청:", saveData); + + const result = await dynamicFormApi.saveFormData(saveData); + + if (result.success) { + alert("저장되었습니다."); + console.log("✅ 저장 성공:", result.data); + + // 저장 후 데이터 초기화 (선택사항) + if (onFormDataChange) { + Object.keys(formData).forEach(key => { + onFormDataChange(key, ""); + }); + } + } else { + throw new Error(result.message || "저장에 실패했습니다."); + } + } catch (error: any) { + console.error("❌ 저장 실패:", error); + alert(`저장 중 오류가 발생했습니다: ${error.message}`); + } + }; + + // 취소 액션 + const handleCancelAction = () => { + if (confirm("변경사항을 취소하시겠습니까?")) { + // 폼 초기화 또는 이전 페이지로 이동 + if (onFormDataChange) { + // 모든 폼 데이터 초기화 + Object.keys(formData).forEach(key => { + onFormDataChange(key, ""); + }); + } + console.log("❌ 작업이 취소되었습니다."); + } + }; + + // 삭제 액션 + const handleDeleteAction = async () => { + const confirmMessage = config?.confirmMessage || "정말로 삭제하시겠습니까?"; + + if (!confirm(confirmMessage)) { + return; + } + + // 삭제할 레코드 ID가 필요 (폼 데이터에서 id 필드 찾기) + const recordId = formData["id"] || formData["ID"] || formData["objid"]; + + if (!recordId) { + alert("삭제할 데이터를 찾을 수 없습니다. (ID가 없음)"); + return; + } + + // 테이블명 결정 + const tableName = screenInfo?.tableName || + allComponents.find(c => c.columnName)?.tableName || + "unknown_table"; + + if (!tableName || tableName === "unknown_table") { + alert("테이블 정보가 없어 삭제할 수 없습니다."); + return; + } + + try { + console.log("🗑️ 삭제 실행:", { recordId, tableName, formData }); + + const result = await dynamicFormApi.deleteFormDataFromTable(recordId, tableName); + + if (result.success) { + alert("삭제되었습니다."); + console.log("✅ 삭제 성공"); + + // 삭제 후 폼 초기화 + if (onFormDataChange) { + Object.keys(formData).forEach(key => { + onFormDataChange(key, ""); + }); + } + } else { + throw new Error(result.message || "삭제에 실패했습니다."); + } + } catch (error: any) { + console.error("❌ 삭제 실패:", error); + alert(`삭제 중 오류가 발생했습니다: ${error.message}`); + } + }; + + // 편집 액션 + const handleEditAction = () => { + console.log("✏️ 편집 모드 활성화"); + // 읽기 전용 모드를 편집 모드로 전환 + alert("편집 모드로 전환되었습니다."); + }; + + // 추가 액션 + const handleAddAction = () => { + console.log("➕ 새 항목 추가"); + // 새 항목 추가 로직 + alert("새 항목을 추가할 수 있습니다."); + }; + + // 검색 액션 + const handleSearchAction = () => { + console.log("🔍 검색 실행:", formData); + // 검색 로직 + const searchTerms = Object.values(formData).filter(v => v && v.toString().trim()); + if (searchTerms.length === 0) { + alert("검색할 내용을 입력해주세요."); + } else { + alert(`검색 실행: ${searchTerms.join(", ")}`); + } + }; + + // 초기화 액션 + const handleResetAction = () => { + if (confirm("모든 입력을 초기화하시겠습니까?")) { + if (onFormDataChange) { + Object.keys(formData).forEach(key => { + onFormDataChange(key, ""); + }); + } + console.log("🔄 폼 초기화 완료"); + alert("입력이 초기화되었습니다."); + } + }; + + // 제출 액션 + const handleSubmitAction = async () => { + console.log("📤 폼 제출:", formData); + // 제출 로직 + alert("제출되었습니다."); + }; + + // 닫기 액션 + const handleCloseAction = () => { + console.log("❌ 창 닫기"); + // 창 닫기 또는 모달 닫기 + if (window.opener) { + window.close(); + } else { + history.back(); + } + }; + + // 팝업 액션 + const handlePopupAction = () => { + if (config?.popupTitle && config?.popupContent) { + // 커스텀 모달 대신 기본 alert 사용 (향후 모달 컴포넌트로 교체 가능) + alert(`${config.popupTitle}\n\n${config.popupContent}`); + } else { + alert("팝업을 표시합니다."); + } + }; + + // 네비게이션 액션 + const handleNavigateAction = () => { + if (config?.navigateUrl) { if (config.navigateTarget === "_blank") { window.open(config.navigateUrl, "_blank"); } else { window.location.href = config.navigateUrl; } - } else if (config?.actionType === "custom" && config.customAction) { + } else { + console.log("🔗 네비게이션 URL이 설정되지 않았습니다."); + } + }; + + // 커스텀 액션 + const handleCustomAction = async () => { + if (config?.customAction) { try { - // 간단한 JavaScript 실행 (보안상 제한적) - eval(config.customAction); + // 보안상 제한적인 eval 사용 + const result = eval(config.customAction); + if (result instanceof Promise) { + await result; + } + console.log("⚡ 커스텀 액션 실행 완료"); } catch (error) { - console.error("커스텀 액션 실행 오류:", error); - } - } else if (config?.actionType === "delete" && config.confirmMessage) { - if (confirm(config.confirmMessage)) { - console.log("삭제 확인됨"); + throw new Error(`커스텀 액션 실행 실패: ${error.message}`); } } else { - console.log(`버튼 클릭: ${config?.actionType || "기본"} 액션`); + console.log("⚡ 커스텀 액션이 설정되지 않았습니다."); } }; diff --git a/frontend/components/screen/RealtimePreview.tsx b/frontend/components/screen/RealtimePreview.tsx index 716cb168..0937017f 100644 --- a/frontend/components/screen/RealtimePreview.tsx +++ b/frontend/components/screen/RealtimePreview.tsx @@ -85,8 +85,64 @@ const renderWidget = (component: ComponentData) => { const widget = component as WidgetComponent; const config = widget.webTypeConfig as TextTypeConfig | undefined; + // 입력 타입에 따른 처리 + const isAutoInput = widget.inputType === "auto"; + + // 자동 값 생성 함수 + const getAutoValue = (autoValueType: string) => { + switch (autoValueType) { + case "current_datetime": + return new Date().toLocaleString("ko-KR"); + case "current_date": + return new Date().toLocaleDateString("ko-KR"); + case "current_time": + return new Date().toLocaleTimeString("ko-KR"); + case "current_user": + return "현재사용자"; + case "uuid": + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); + case "sequence": + return "SEQ_001"; + case "user_defined": + return "사용자정의값"; + default: + return "자동생성값"; + } + }; + + // 자동 값 플레이스홀더 생성 함수 + const getAutoPlaceholder = (autoValueType: string) => { + switch (autoValueType) { + case "current_datetime": + return "현재 날짜시간"; + case "current_date": + return "현재 날짜"; + case "current_time": + return "현재 시간"; + case "current_user": + return "현재 사용자"; + case "uuid": + return "UUID"; + case "sequence": + return "시퀀스"; + case "user_defined": + return "사용자 정의"; + default: + return "자동 생성됨"; + } + }; + // 플레이스홀더 처리 - const finalPlaceholder = config?.placeholder || placeholder || "텍스트를 입력하세요"; + const finalPlaceholder = isAutoInput + ? getAutoPlaceholder(widget.autoValueType || "current_datetime") + : config?.placeholder || placeholder || "텍스트를 입력하세요"; + + // 자동 값 처리 + const autoValue = isAutoInput ? getAutoValue(widget.autoValueType || "current_datetime") : ""; const inputType = widgetType === "email" ? "email" : widgetType === "tel" ? "tel" : "text"; @@ -139,12 +195,14 @@ const renderWidget = (component: ComponentData) => { const inputProps = { ...commonProps, placeholder: finalPlaceholder, + value: isAutoInput ? autoValue : undefined, // 자동입력인 경우 자동 값 표시 minLength: config?.minLength, maxLength: config?.maxLength, pattern: getPatternByFormat(config?.format || "none"), onInput: handleInputChange, onChange: () => {}, // 읽기 전용으로 처리 - readOnly: true, + readOnly: readonly || isAutoInput, // 자동입력인 경우 읽기 전용 + className: `w-full h-full ${borderClass} ${isAutoInput ? "bg-gray-50 text-gray-600" : ""}`, }; // multiline이면 Textarea로 렌더링 @@ -160,19 +218,69 @@ const renderWidget = (component: ComponentData) => { const widget = component as WidgetComponent; const config = widget.webTypeConfig as NumberTypeConfig | undefined; + // 입력 타입에 따른 처리 + const isAutoInput = widget.inputType === "auto"; + + // 자동 값 생성 함수 (숫자용) + const getAutoNumberValue = (autoValueType: string) => { + switch (autoValueType) { + case "current_datetime": + return Date.now().toString(); + case "current_date": + return new Date().getDate().toString(); + case "current_time": + return new Date().getHours().toString(); + case "sequence": + return "1001"; + case "uuid": + return Math.floor(Math.random() * 1000000).toString(); + case "user_defined": + return "999"; + default: + return "0"; + } + }; + + // 자동 값 플레이스홀더 생성 함수 (숫자용) + const getAutoNumberPlaceholder = (autoValueType: string) => { + switch (autoValueType) { + case "current_datetime": + return "타임스탬프"; + case "current_date": + return "현재 일"; + case "current_time": + return "현재 시"; + case "sequence": + return "시퀀스"; + case "uuid": + return "랜덤 숫자"; + case "user_defined": + return "사용자 정의"; + default: + return "자동 생성"; + } + }; + + // 자동 값 처리 + const autoValue = isAutoInput ? getAutoNumberValue(widget.autoValueType || "sequence") : ""; + // 디버깅: 현재 설정값 확인 console.log("🔢 숫자 위젯 렌더링:", { componentId: widget.id, widgetType: widget.widgetType, config, placeholder: widget.placeholder, + inputType: widget.inputType, + isAutoInput, }); // 단계값 결정: webTypeConfig > 기본값 (소수는 0.01, 정수는 1) const step = config?.step || (widgetType === "decimal" ? 0.01 : 1); // 플레이스홀더 처리 - const finalPlaceholder = config?.placeholder || placeholder || "숫자를 입력하세요"; + const finalPlaceholder = isAutoInput + ? getAutoNumberPlaceholder(widget.autoValueType || "sequence") + : config?.placeholder || placeholder || "숫자를 입력하세요"; // 형식에 따른 표시값 처리 const formatValue = (value: string) => { @@ -215,9 +323,10 @@ const renderWidget = (component: ComponentData) => { max={config?.max} {...commonProps} placeholder={finalPlaceholder} - className={`${config?.prefix ? "rounded-l-none" : ""} ${config?.suffix ? "rounded-r-none" : ""} ${borderClass}`} + value={isAutoInput ? autoValue : undefined} // 자동입력인 경우 자동 값 표시 + className={`${config?.prefix ? "rounded-l-none" : ""} ${config?.suffix ? "rounded-r-none" : ""} ${borderClass} ${isAutoInput ? "bg-gray-50 text-gray-600" : ""}`} onChange={() => {}} // 읽기 전용으로 처리 - readOnly + readOnly={readonly || isAutoInput} /> {config.suffix && ( @@ -236,8 +345,10 @@ const renderWidget = (component: ComponentData) => { max={config?.max} {...commonProps} placeholder={finalPlaceholder} + value={isAutoInput ? autoValue : undefined} // 자동입력인 경우 자동 값 표시 + className={`h-full w-full ${borderClass} ${isAutoInput ? "bg-gray-50 text-gray-600" : ""}`} onChange={() => {}} // 읽기 전용으로 처리 - readOnly + readOnly={readonly || isAutoInput} /> ); } @@ -247,6 +358,46 @@ const renderWidget = (component: ComponentData) => { const widget = component as WidgetComponent; const config = widget.webTypeConfig as DateTypeConfig | undefined; + // 입력 타입에 따른 처리 + const isAutoInput = widget.inputType === "auto"; + + // 자동 값 생성 함수 (날짜용) + const getAutoDateValue = (autoValueType: string, inputType: string) => { + const now = new Date(); + switch (autoValueType) { + case "current_datetime": + return inputType === "datetime-local" + ? now.toISOString().slice(0, 16) // YYYY-MM-DDTHH:mm + : now.toISOString().slice(0, 10); // YYYY-MM-DD + case "current_date": + return now.toISOString().slice(0, 10); // YYYY-MM-DD + case "current_time": + return inputType === "datetime-local" + ? now.toISOString().slice(0, 16) // YYYY-MM-DDTHH:mm + : now.toTimeString().slice(0, 5); // HH:mm + case "user_defined": + return inputType === "datetime-local" ? "2024-01-01T09:00" : "2024-01-01"; + default: + return inputType === "datetime-local" ? now.toISOString().slice(0, 16) : now.toISOString().slice(0, 10); + } + }; + + // 자동 값 플레이스홀더 생성 함수 (날짜용) + const getAutoDatePlaceholder = (autoValueType: string) => { + switch (autoValueType) { + case "current_datetime": + return "현재 날짜시간"; + case "current_date": + return "현재 날짜"; + case "current_time": + return "현재 시간"; + case "user_defined": + return "사용자 정의"; + default: + return "자동 생성"; + } + }; + // 웹타입 설정에 따른 input type 결정 let inputType = "date"; if (config?.showTime || config?.format?.includes("HH:mm")) { @@ -273,8 +424,13 @@ const renderWidget = (component: ComponentData) => { } } + // 자동 값 처리 + const autoValue = isAutoInput ? getAutoDateValue(widget.autoValueType || "current_date", inputType) : ""; + // 플레이스홀더 우선순위: webTypeConfig > placeholder > 기본값 - const finalPlaceholder = config?.placeholder || placeholder || "날짜를 선택하세요"; + const finalPlaceholder = isAutoInput + ? getAutoDatePlaceholder(widget.autoValueType || "current_date") + : config?.placeholder || placeholder || "날짜를 선택하세요"; // 디버깅: 현재 설정값 확인 console.log("📅 날짜 위젯 렌더링:", { @@ -331,9 +487,10 @@ const renderWidget = (component: ComponentData) => { placeholder={finalPlaceholder} min={config?.minDate} max={config?.maxDate} - value={processedDefaultValue} + value={isAutoInput ? autoValue : processedDefaultValue} + className={`h-full w-full ${borderClass} ${isAutoInput ? "bg-gray-50 text-gray-600" : ""}`} onChange={() => {}} // 읽기 전용으로 처리 - readOnly + readOnly={readonly || isAutoInput} /> ); } diff --git a/frontend/components/screen/TableTypeSelector.tsx b/frontend/components/screen/TableTypeSelector.tsx index 67557248..50a64587 100644 --- a/frontend/components/screen/TableTypeSelector.tsx +++ b/frontend/components/screen/TableTypeSelector.tsx @@ -173,6 +173,32 @@ export default function TableTypeSelector({ } }; + // 입력 타입 변경 + const handleInputTypeChange = async (columnName: string, inputType: "direct" | "auto") => { + try { + // 현재 컬럼 정보 가져오기 + const currentColumn = columns.find((col) => col.columnName === columnName); + if (!currentColumn) return; + + // 웹 타입과 함께 입력 타입 업데이트 + await tableTypeApi.setColumnWebType( + selectedTable, + columnName, + currentColumn.webType || "text", + undefined, // detailSettings + inputType, + ); + + // 로컬 상태 업데이트 + setColumns((prev) => prev.map((col) => (col.columnName === columnName ? { ...col, inputType } : col))); + + console.log(`컬럼 ${columnName}의 입력 타입을 ${inputType}로 변경했습니다.`); + } catch (error) { + console.error("입력 타입 변경 실패:", error); + alert("입력 타입 설정에 실패했습니다. 다시 시도해주세요."); + } + }; + const filteredTables = tables.filter((table) => table.displayName.toLowerCase().includes(searchTerm.toLowerCase())); return ( @@ -233,6 +259,7 @@ export default function TableTypeSelector({ 라벨 데이터 타입 웹 타입 + 입력 타입 필수 표시 액션 @@ -267,6 +294,20 @@ export default function TableTypeSelector({ + + + {column.isNullable === "NO" ? "필수" : "선택"} diff --git a/frontend/components/screen/panels/ButtonConfigPanel.tsx b/frontend/components/screen/panels/ButtonConfigPanel.tsx index 6aeac75c..a77bdc54 100644 --- a/frontend/components/screen/panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/panels/ButtonConfigPanel.tsx @@ -48,23 +48,42 @@ export const ButtonConfigPanel: React.FC = ({ component, const config = (component.webTypeConfig as ButtonTypeConfig) || {}; // 로컬 상태 관리 - const [localConfig, setLocalConfig] = useState({ - actionType: "custom", - variant: "default", - size: "sm", - ...config, + const [localConfig, setLocalConfig] = useState(() => { + const defaultConfig = { + actionType: "custom" as ButtonActionType, + variant: "default" as ButtonVariant, + size: "sm" as ButtonSize, + }; + + return { + ...defaultConfig, + ...config, // 저장된 값이 기본값을 덮어씀 + }; }); // 컴포넌트 변경 시 로컬 상태 동기화 useEffect(() => { const newConfig = (component.webTypeConfig as ButtonTypeConfig) || {}; + + // 기본값 설정 (실제 값이 있으면 덮어쓰지 않음) + const defaultConfig = { + actionType: "custom" as ButtonActionType, + variant: "default" as ButtonVariant, + size: "sm" as ButtonSize, + }; + + // 실제 저장된 값이 우선순위를 가지도록 설정 setLocalConfig({ - actionType: "custom", - variant: "default", - size: "sm", - ...newConfig, + ...defaultConfig, + ...newConfig, // 저장된 값이 기본값을 덮어씀 }); - }, [component.webTypeConfig]); + + console.log("🔄 ButtonConfigPanel 로컬 상태 동기화:", { + componentId: component.id, + savedConfig: newConfig, + finalConfig: { ...defaultConfig, ...newConfig }, + }); + }, [component.webTypeConfig, component.id]); // 설정 업데이트 함수 const updateConfig = (updates: Partial) => { @@ -194,7 +213,22 @@ export const ButtonConfigPanel: React.FC = ({ component, break; } - updateConfig(updates); + // 로컬 상태 업데이트 후 webTypeConfig도 함께 업데이트 + const newConfig = { ...localConfig, ...updates }; + setLocalConfig(newConfig); + + // webTypeConfig를 마지막에 다시 업데이트하여 확실히 저장되도록 함 + setTimeout(() => { + onUpdateComponent({ + webTypeConfig: newConfig, + }); + + console.log("🎯 ButtonActionType webTypeConfig 최종 업데이트:", { + actionType, + newConfig, + componentId: component.id, + }); + }, 0); }; const selectedActionOption = actionTypeOptions.find((opt) => opt.value === localConfig.actionType); diff --git a/frontend/components/screen/panels/DetailSettingsPanel.tsx b/frontend/components/screen/panels/DetailSettingsPanel.tsx index ffe09415..f77b96bc 100644 --- a/frontend/components/screen/panels/DetailSettingsPanel.tsx +++ b/frontend/components/screen/panels/DetailSettingsPanel.tsx @@ -2,6 +2,7 @@ import React from "react"; import { Settings } from "lucide-react"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { ComponentData, WidgetComponent, @@ -223,6 +224,87 @@ export const DetailSettingsPanel: React.FC = ({ select {widget.widgetType}
컬럼: {widget.columnName}
+ + {/* 입력 타입 설정 */} +
+ + +

+ {widget.inputType === "auto" + ? "시스템에서 자동으로 값을 생성합니다 (읽기 전용)" + : "사용자가 직접 값을 입력할 수 있습니다"} +

+ + {/* 자동 값 타입 설정 (자동입력일 때만 표시) */} + {widget.inputType === "auto" && ( +
+ + +

+ {(() => { + switch (widget.autoValueType || "current_datetime") { + case "current_datetime": + return "현재 날짜와 시간을 자동으로 입력합니다"; + case "current_date": + return "현재 날짜를 자동으로 입력합니다"; + case "current_time": + return "현재 시간을 자동으로 입력합니다"; + case "current_user": + return "현재 로그인한 사용자 정보를 입력합니다"; + case "uuid": + return "고유한 UUID를 생성합니다"; + case "sequence": + return "순차적인 번호를 생성합니다"; + case "user_defined": + return "사용자가 정의한 규칙에 따라 값을 생성합니다"; + default: + return ""; + } + })()} +

+
+ )} +
{/* 상세 설정 영역 */} diff --git a/frontend/lib/api/dynamicForm.ts b/frontend/lib/api/dynamicForm.ts new file mode 100644 index 00000000..d306fa95 --- /dev/null +++ b/frontend/lib/api/dynamicForm.ts @@ -0,0 +1,319 @@ +import { apiClient, ApiResponse } from "./client"; + +// 동적 폼 데이터 타입 +export interface DynamicFormData { + screenId: number; + tableName: string; + data: Record; +} + +// 폼 데이터 저장 응답 타입 +export interface SaveFormDataResponse { + id: number; + success: boolean; + message: string; + data?: Record; +} + +// 폼 데이터 조회 응답 타입 +export interface FormDataResponse { + id: number; + screenId: number; + tableName: string; + data: Record; + createdAt: string; + updatedAt: string; +} + +// 동적 폼 API 클래스 +export class DynamicFormApi { + /** + * 폼 데이터 저장 + * @param formData 저장할 폼 데이터 + * @returns 저장 결과 + */ + static async saveFormData(formData: DynamicFormData): Promise> { + try { + console.log("💾 폼 데이터 저장 요청:", formData); + + const response = await apiClient.post("/dynamic-form/save", formData); + + console.log("✅ 폼 데이터 저장 성공:", response.data); + return { + success: true, + data: response.data, + message: "데이터가 성공적으로 저장되었습니다.", + }; + } catch (error: any) { + console.error("❌ 폼 데이터 저장 실패:", error); + + const errorMessage = error.response?.data?.message || error.message || "데이터 저장 중 오류가 발생했습니다."; + + return { + success: false, + message: errorMessage, + errorCode: error.response?.data?.errorCode, + }; + } + } + + /** + * 폼 데이터 업데이트 + * @param id 레코드 ID + * @param formData 업데이트할 폼 데이터 + * @returns 업데이트 결과 + */ + static async updateFormData( + id: number, + formData: Partial, + ): Promise> { + try { + console.log("🔄 폼 데이터 업데이트 요청:", { id, formData }); + + const response = await apiClient.put(`/dynamic-form/${id}`, formData); + + console.log("✅ 폼 데이터 업데이트 성공:", response.data); + return { + success: true, + data: response.data, + message: "데이터가 성공적으로 업데이트되었습니다.", + }; + } catch (error: any) { + console.error("❌ 폼 데이터 업데이트 실패:", error); + + const errorMessage = error.response?.data?.message || error.message || "데이터 업데이트 중 오류가 발생했습니다."; + + return { + success: false, + message: errorMessage, + errorCode: error.response?.data?.errorCode, + }; + } + } + + /** + * 폼 데이터 삭제 + * @param id 레코드 ID + * @returns 삭제 결과 + */ + static async deleteFormData(id: number): Promise> { + try { + console.log("🗑️ 폼 데이터 삭제 요청:", id); + + await apiClient.delete(`/dynamic-form/${id}`); + + console.log("✅ 폼 데이터 삭제 성공"); + return { + success: true, + message: "데이터가 성공적으로 삭제되었습니다.", + }; + } catch (error: any) { + console.error("❌ 폼 데이터 삭제 실패:", error); + + const errorMessage = error.response?.data?.message || error.message || "데이터 삭제 중 오류가 발생했습니다."; + + return { + success: false, + message: errorMessage, + errorCode: error.response?.data?.errorCode, + }; + } + } + + /** + * 실제 테이블에서 폼 데이터 삭제 + * @param id 레코드 ID + * @param tableName 테이블명 + * @returns 삭제 결과 + */ + static async deleteFormDataFromTable(id: number, tableName: string): Promise> { + try { + console.log("🗑️ 실제 테이블에서 폼 데이터 삭제 요청:", { id, tableName }); + + await apiClient.delete(`/dynamic-form/${id}`, { + data: { tableName }, + }); + + console.log("✅ 실제 테이블에서 폼 데이터 삭제 성공"); + return { + success: true, + message: "데이터가 성공적으로 삭제되었습니다.", + }; + } catch (error: any) { + console.error("❌ 실제 테이블에서 폼 데이터 삭제 실패:", error); + + const errorMessage = error.response?.data?.message || error.message || "데이터 삭제 중 오류가 발생했습니다."; + + return { + success: false, + message: errorMessage, + errorCode: error.response?.data?.errorCode, + }; + } + } + + /** + * 폼 데이터 목록 조회 + * @param screenId 화면 ID + * @param params 검색 파라미터 + * @returns 폼 데이터 목록 + */ + static async getFormDataList( + screenId: number, + params?: { + page?: number; + size?: number; + search?: string; + sortBy?: string; + sortOrder?: "asc" | "desc"; + }, + ): Promise< + ApiResponse<{ + content: FormDataResponse[]; + totalElements: number; + totalPages: number; + currentPage: number; + size: number; + }> + > { + try { + console.log("📋 폼 데이터 목록 조회 요청:", { screenId, params }); + + const response = await apiClient.get(`/dynamic-form/screen/${screenId}`, { params }); + + console.log("✅ 폼 데이터 목록 조회 성공:", response.data); + return { + success: true, + data: response.data, + }; + } catch (error: any) { + console.error("❌ 폼 데이터 목록 조회 실패:", error); + + const errorMessage = error.response?.data?.message || error.message || "데이터 조회 중 오류가 발생했습니다."; + + return { + success: false, + message: errorMessage, + errorCode: error.response?.data?.errorCode, + }; + } + } + + /** + * 특정 폼 데이터 조회 + * @param id 레코드 ID + * @returns 폼 데이터 + */ + static async getFormData(id: number): Promise> { + try { + console.log("📄 폼 데이터 단건 조회 요청:", id); + + const response = await apiClient.get(`/dynamic-form/${id}`); + + console.log("✅ 폼 데이터 단건 조회 성공:", response.data); + return { + success: true, + data: response.data, + }; + } catch (error: any) { + console.error("❌ 폼 데이터 단건 조회 실패:", error); + + const errorMessage = error.response?.data?.message || error.message || "데이터 조회 중 오류가 발생했습니다."; + + return { + success: false, + message: errorMessage, + errorCode: error.response?.data?.errorCode, + }; + } + } + + /** + * 테이블의 컬럼 정보 조회 (폼 검증용) + * @param tableName 테이블명 + * @returns 컬럼 정보 + */ + static async getTableColumns(tableName: string): Promise< + ApiResponse<{ + tableName: string; + columns: Array<{ + columnName: string; + dataType: string; + nullable: boolean; + primaryKey: boolean; + maxLength?: number; + defaultValue?: any; + }>; + }> + > { + try { + console.log("📊 테이블 컬럼 정보 조회 요청:", tableName); + + const response = await apiClient.get(`/dynamic-form/table/${tableName}/columns`); + + console.log("✅ 테이블 컬럼 정보 조회 성공:", response.data); + return { + success: true, + data: response.data, + }; + } catch (error: any) { + console.error("❌ 테이블 컬럼 정보 조회 실패:", error); + + const errorMessage = error.response?.data?.message || error.message || "테이블 정보 조회 중 오류가 발생했습니다."; + + return { + success: false, + message: errorMessage, + errorCode: error.response?.data?.errorCode, + }; + } + } + + /** + * 폼 데이터 검증 + * @param tableName 테이블명 + * @param data 검증할 데이터 + * @returns 검증 결과 + */ + static async validateFormData( + tableName: string, + data: Record, + ): Promise< + ApiResponse<{ + valid: boolean; + errors: Array<{ + field: string; + message: string; + code: string; + }>; + }> + > { + try { + console.log("✅ 폼 데이터 검증 요청:", { tableName, data }); + + const response = await apiClient.post(`/dynamic-form/validate`, { + tableName, + data, + }); + + console.log("✅ 폼 데이터 검증 성공:", response.data); + return { + success: true, + data: response.data, + }; + } catch (error: any) { + console.error("❌ 폼 데이터 검증 실패:", error); + + const errorMessage = error.response?.data?.message || error.message || "데이터 검증 중 오류가 발생했습니다."; + + return { + success: false, + message: errorMessage, + errorCode: error.response?.data?.errorCode, + }; + } + } +} + +// 편의를 위한 기본 export +export const dynamicFormApi = DynamicFormApi; diff --git a/frontend/lib/api/screen.ts b/frontend/lib/api/screen.ts index 3e593136..dc515865 100644 --- a/frontend/lib/api/screen.ts +++ b/frontend/lib/api/screen.ts @@ -152,10 +152,12 @@ export const tableTypeApi = { columnName: string, webType: string, detailSettings?: Record, + inputType?: "direct" | "auto", ): Promise => { await apiClient.put(`/table-management/tables/${tableName}/columns/${columnName}/web-type`, { webType, detailSettings, + inputType, }); }, diff --git a/frontend/types/screen.ts b/frontend/types/screen.ts index 54f04d0a..e42da10b 100644 --- a/frontend/types/screen.ts +++ b/frontend/types/screen.ts @@ -156,6 +156,15 @@ export interface BaseComponent { tableName?: string; // 테이블명 추가 label?: string; // 라벨 추가 gridColumns?: number; // 그리드에서 차지할 컬럼 수 (1-12) + inputType?: "direct" | "auto"; // 입력 타입 (직접입력/자동입력) + autoValueType?: + | "current_datetime" + | "current_date" + | "current_time" + | "current_user" + | "uuid" + | "sequence" + | "user_defined"; // 자동 값 타입 } // 컨테이너 컴포넌트 @@ -460,6 +469,7 @@ export interface ColumnInfo { dataType: string; webType?: WebType; widgetType?: WebType; // 프론트엔드에서 사용하는 필드 (webType과 동일) + inputType?: "direct" | "auto"; // 입력 타입 isNullable: string; required?: boolean; // isNullable에서 변환된 필드 columnDefault?: string;