diff --git a/backend-node/src/controllers/screenGroupController.ts b/backend-node/src/controllers/screenGroupController.ts index 88230f48..b53454b9 100644 --- a/backend-node/src/controllers/screenGroupController.ts +++ b/backend-node/src/controllers/screenGroupController.ts @@ -2839,4 +2839,4 @@ export const ensurePopRootGroup = async (req: AuthenticatedRequest, res: Respons logger.error("POP 루트 그룹 확보 실패:", error); res.status(500).json({ success: false, message: "POP 루트 그룹 확보에 실패했습니다.", error: error.message }); } -}; +}; \ No newline at end of file diff --git a/backend-node/src/routes/dataRoutes.ts b/backend-node/src/routes/dataRoutes.ts index a7757397..0abc6793 100644 --- a/backend-node/src/routes/dataRoutes.ts +++ b/backend-node/src/routes/dataRoutes.ts @@ -688,7 +688,7 @@ router.post( authenticateToken, async (req: AuthenticatedRequest, res) => { try { - const { tableName, parentKeys, records } = req.body; + const { tableName, parentKeys, records, deleteOrphans = true } = req.body; // 입력값 검증 if (!tableName || !parentKeys || !records || !Array.isArray(records)) { @@ -722,7 +722,8 @@ router.post( parentKeys, records, req.user?.companyCode, - req.user?.userId + req.user?.userId, + deleteOrphans ); if (!result.success) { diff --git a/backend-node/src/services/dataService.ts b/backend-node/src/services/dataService.ts index 9623d976..8c837697 100644 --- a/backend-node/src/services/dataService.ts +++ b/backend-node/src/services/dataService.ts @@ -1354,7 +1354,8 @@ class DataService { parentKeys: Record, records: Array>, userCompany?: string, - userId?: string + userId?: string, + deleteOrphans: boolean = true ): Promise< ServiceResponse<{ inserted: number; updated: number; deleted: number }> > { @@ -1405,7 +1406,7 @@ class DataService { console.log(`✅ 기존 레코드: ${existingRecords.rows.length}개`); - // 2. 새 레코드와 기존 레코드 비교 + // 2. id 기반 UPSERT: 레코드에 id(PK)가 있으면 UPDATE, 없으면 INSERT let inserted = 0; let updated = 0; let deleted = 0; @@ -1413,125 +1414,81 @@ class DataService { // 날짜 필드를 YYYY-MM-DD 형식으로 변환하는 헬퍼 함수 const normalizeDateValue = (value: any): any => { if (value == null) return value; - - // ISO 날짜 문자열 감지 (YYYY-MM-DDTHH:mm:ss.sssZ) if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T/.test(value)) { - return value.split("T")[0]; // YYYY-MM-DD 만 추출 + return value.split("T")[0]; } - return value; }; - // 새 레코드 처리 (INSERT or UPDATE) - for (const newRecord of records) { - console.log(`🔍 처리할 새 레코드:`, newRecord); + const existingIds = new Set(existingRecords.rows.map((r: any) => r[pkColumn])); + const processedIds = new Set(); // UPDATE 처리된 id 추적 + for (const newRecord of records) { // 날짜 필드 정규화 const normalizedRecord: Record = {}; for (const [key, value] of Object.entries(newRecord)) { normalizedRecord[key] = normalizeDateValue(value); } - console.log(`🔄 정규화된 레코드:`, normalizedRecord); + const recordId = normalizedRecord[pkColumn]; // 프론트에서 보낸 기존 레코드의 id - // 전체 레코드 데이터 (parentKeys + normalizedRecord) - const fullRecord = { ...parentKeys, ...normalizedRecord }; - - // 고유 키: parentKeys 제외한 나머지 필드들 - const uniqueFields = Object.keys(normalizedRecord); - - console.log(`🔑 고유 필드들:`, uniqueFields); - - // 기존 레코드에서 일치하는 것 찾기 - const existingRecord = existingRecords.rows.find((existing) => { - return uniqueFields.every((field) => { - const existingValue = existing[field]; - const newValue = normalizedRecord[field]; - - // null/undefined 처리 - if (existingValue == null && newValue == null) return true; - if (existingValue == null || newValue == null) return false; - - // Date 타입 처리 - if (existingValue instanceof Date && typeof newValue === "string") { - return ( - existingValue.toISOString().split("T")[0] === - newValue.split("T")[0] - ); - } - - // 문자열 비교 - return String(existingValue) === String(newValue); - }); - }); - - if (existingRecord) { - // UPDATE: 기존 레코드가 있으면 업데이트 + if (recordId && existingIds.has(recordId)) { + // ===== UPDATE: id(PK)가 DB에 존재 → 해당 레코드 업데이트 ===== + const fullRecord = { ...parentKeys, ...normalizedRecord }; const updateFields: string[] = []; const updateValues: any[] = []; - let updateParamIndex = 1; + let paramIdx = 1; for (const [key, value] of Object.entries(fullRecord)) { if (key !== pkColumn) { - // Primary Key는 업데이트하지 않음 - updateFields.push(`"${key}" = $${updateParamIndex}`); + updateFields.push(`"${key}" = $${paramIdx}`); updateValues.push(value); - updateParamIndex++; + paramIdx++; } } - updateValues.push(existingRecord[pkColumn]); // WHERE 조건용 - const updateQuery = ` - UPDATE "${tableName}" - SET ${updateFields.join(", ")}, updated_date = NOW() - WHERE "${pkColumn}" = $${updateParamIndex} - `; - - await pool.query(updateQuery, updateValues); - updated++; - - console.log(`✏️ UPDATE: ${pkColumn} = ${existingRecord[pkColumn]}`); + if (updateFields.length > 0) { + updateValues.push(recordId); + const updateQuery = ` + UPDATE "${tableName}" + SET ${updateFields.join(", ")}, updated_date = NOW() + WHERE "${pkColumn}" = $${paramIdx} + `; + await pool.query(updateQuery, updateValues); + updated++; + processedIds.add(recordId); + console.log(`✏️ UPDATE by id: ${pkColumn} = ${recordId}`); + } } else { - // INSERT: 기존 레코드가 없으면 삽입 - - // 🆕 자동 필드 추가 (company_code, writer, created_date, updated_date, id) - // created_date는 프론트엔드에서 전달된 값 무시하고 항상 현재 시간 설정 - const { created_date: _, ...recordWithoutCreatedDate } = fullRecord; + // ===== INSERT: id 없음 또는 DB에 없음 → 새 레코드 삽입 ===== + const { [pkColumn]: _removedId, created_date: _cd, ...cleanRecord } = normalizedRecord; + const fullRecord = { ...parentKeys, ...cleanRecord }; + const newId = uuidv4(); const recordWithMeta: Record = { - ...recordWithoutCreatedDate, - id: uuidv4(), // 새 ID 생성 + ...fullRecord, + [pkColumn]: newId, created_date: "NOW()", updated_date: "NOW()", }; - // company_code가 없으면 userCompany 사용 (단, userCompany가 "*"가 아닐 때만) - if ( - !recordWithMeta.company_code && - userCompany && - userCompany !== "*" - ) { + if (!recordWithMeta.company_code && userCompany && userCompany !== "*") { recordWithMeta.company_code = userCompany; } - - // writer가 없으면 userId 사용 if (!recordWithMeta.writer && userId) { recordWithMeta.writer = userId; } - const insertFields = Object.keys(recordWithMeta).filter( - (key) => recordWithMeta[key] !== "NOW()" - ); const insertPlaceholders: string[] = []; const insertValues: any[] = []; - let insertParamIndex = 1; + let paramIdx = 1; for (const field of Object.keys(recordWithMeta)) { if (recordWithMeta[field] === "NOW()") { insertPlaceholders.push("NOW()"); } else { - insertPlaceholders.push(`$${insertParamIndex}`); + insertPlaceholders.push(`$${paramIdx}`); insertValues.push(recordWithMeta[field]); - insertParamIndex++; + paramIdx++; } } @@ -1541,49 +1498,24 @@ class DataService { .join(", ")}) VALUES (${insertPlaceholders.join(", ")}) `; - - console.log(`➕ INSERT 쿼리:`, { - query: insertQuery, - values: insertValues, - }); - await pool.query(insertQuery, insertValues); inserted++; - - console.log(`➕ INSERT: 새 레코드`); + processedIds.add(newId); + console.log(`➕ INSERT: 새 레코드 ${pkColumn} = ${newId}`); } } - // 3. 삭제할 레코드 찾기 (기존 레코드 중 새 레코드에 없는 것) - for (const existingRecord of existingRecords.rows) { - const uniqueFields = Object.keys(records[0] || {}); - - const stillExists = records.some((newRecord) => { - return uniqueFields.every((field) => { - const existingValue = existingRecord[field]; - const newValue = newRecord[field]; - - if (existingValue == null && newValue == null) return true; - if (existingValue == null || newValue == null) return false; - - if (existingValue instanceof Date && typeof newValue === "string") { - return ( - existingValue.toISOString().split("T")[0] === - newValue.split("T")[0] - ); - } - - return String(existingValue) === String(newValue); - }); - }); - - if (!stillExists) { - // DELETE: 새 레코드에 없으면 삭제 - const deleteQuery = `DELETE FROM "${tableName}" WHERE "${pkColumn}" = $1`; - await pool.query(deleteQuery, [existingRecord[pkColumn]]); - deleted++; - - console.log(`🗑️ DELETE: ${pkColumn} = ${existingRecord[pkColumn]}`); + // 3. 고아 레코드 삭제: deleteOrphans=true일 때만 (EDIT 모드) + // CREATE 모드에서는 기존 레코드를 건드리지 않음 + if (deleteOrphans) { + for (const existingRow of existingRecords.rows) { + const existId = existingRow[pkColumn]; + if (!processedIds.has(existId)) { + const deleteQuery = `DELETE FROM "${tableName}" WHERE "${pkColumn}" = $1`; + await pool.query(deleteQuery, [existId]); + deleted++; + console.log(`🗑️ DELETE orphan: ${pkColumn} = ${existId}`); + } } } diff --git a/docs/DB_ARCHITECTURE_ANALYSIS.md b/docs/DB_ARCHITECTURE_ANALYSIS.md new file mode 100644 index 00000000..084c6940 --- /dev/null +++ b/docs/DB_ARCHITECTURE_ANALYSIS.md @@ -0,0 +1,2188 @@ +# WACE ERP 데이터베이스 아키텍처 분석 보고서 + +> 📅 작성일: 2026-01-20 +> 🎯 목적: WACE ERP 시스템 전체 워크플로우 문서화를 위한 DB 구조 분석 +> 📊 DB 엔진: PostgreSQL 16.8 + +--- + +## 📋 목차 + +1. [개요](#1-개요) +2. [전체 테이블 목록](#2-전체-테이블-목록) +3. [멀티테넌시 아키텍처](#3-멀티테넌시-아키텍처) +4. [핵심 시스템 테이블](#4-핵심-시스템-테이블) +5. [메타데이터 관리 시스템](#5-메타데이터-관리-시스템) +6. [화면 관리 시스템](#6-화면-관리-시스템) +7. [비즈니스 도메인별 테이블](#7-비즈니스-도메인별-테이블) +8. [플로우 및 데이터 통합](#8-플로우-및-데이터-통합) +9. [인덱스 전략](#9-인덱스-전략) +10. [동적 테이블 생성 패턴](#10-동적-테이블-생성-패턴) +11. [마이그레이션 히스토리](#11-마이그레이션-히스토리) + +--- + +## 1. 개요 + +### 1.1 데이터베이스 통계 + +``` +- 총 테이블 수: 약 280개 +- 총 함수 수: 약 50개 +- 총 트리거 수: 약 30개 +- 총 시퀀스 수: 약 100개 +- 뷰 수: 약 20개 +``` + +### 1.2 아키텍처 특징 + +- **멀티테넌시**: 모든 테이블에 `company_code` 컬럼으로 회사별 데이터 격리 +- **동적 스키마**: 런타임에 테이블 생성/수정 가능 +- **메타데이터 드리븐**: UI 컴포넌트가 메타데이터 테이블을 기반으로 동적 렌더링 +- **이력 관리**: 주요 테이블에 `_log` 테이블로 변경 이력 추적 +- **외부 연동**: 외부 DB 및 REST API 연결 지원 +- **플로우 기반**: 화면 간 데이터 흐름을 정의하고 실행 + +--- + +## 2. 전체 테이블 목록 + +### 2.1 테이블 분류 체계 + +``` +시스템 관리 (약 30개) +├── 사용자/권한 (10개) +├── 메뉴 관리 (5개) +├── 회사 관리 (3개) +└── 공통 코드 (5개) + +메타데이터 시스템 (약 20개) +├── 테이블/컬럼 정의 (8개) +├── 화면 정의 (10개) +└── 레이아웃/컴포넌트 (5개) + +비즈니스 도메인 (약 200개) +├── 영업/수주 (30개) +├── 구매/발주 (25개) +├── 재고/창고 (20개) +├── 생산/작업 (25개) +├── 품질/검사 (15개) +├── 물류/운송 (20개) +├── PLM/설계 (30개) +├── 회계/원가 (20개) +└── 기타 (15개) + +통합/플로우 (약 30개) +├── 데이터플로우 (10개) +├── 배치 작업 (8개) +└── 외부 연동 (12개) +``` + +### 2.2 주요 테이블 목록 (알파벳순) + +
+전체 테이블 목록 보기 (280개) + +``` +approval +attach_file_info +auth_tokens +authority_master +authority_master_history +authority_sub_user +batch_configs +batch_execution_logs +batch_job_executions +batch_job_parameters +batch_jobs +batch_mappings +batch_schedules +button_action_standards +carrier_contract_mng +carrier_contract_mng_log +carrier_mng +carrier_mng_log +carrier_vehicle_mng +carrier_vehicle_mng_log +cascading_auto_fill_group +cascading_auto_fill_mapping +cascading_condition +cascading_hierarchy_group +cascading_hierarchy_level +cascading_multi_parent +cascading_multi_parent_source +cascading_mutual_exclusion +cascading_relation +cascading_reverse_lookup +category_column_mapping +category_value_cascading_group +category_value_cascading_mapping +chartmgmt +check_report_mng +code_category +code_info +collection_batch_executions +collection_batch_management +column_labels +comm_code +comm_code_history +comm_exchange_rate +comments +company_code_sequence +company_mng +component_standards +contract_mgmt +contract_mgmt_option +counselingmgmt +customer_item +customer_item_alias +customer_item_mapping +customer_item_price +customer_mng +customer_service_mgmt +customer_service_part +customer_service_workingtime +dashboard_elements +dashboard_shares +dashboard_slider_items +dashboard_sliders +dashboards +data_collection_configs +data_collection_history +data_collection_jobs +data_relationship_bridge +dataflow_diagrams +dataflow_external_calls +ddl_execution_log +defect_standard_mng +defect_standard_mng_log +delivery_destination +delivery_history +delivery_history_defect +delivery_part_price +delivery_route_mng +delivery_route_mng_log +delivery_status +dept_info +dept_info_history +digital_twin_layout +digital_twin_layout_template +digital_twin_location_layout +digital_twin_objects +digital_twin_zone_layout +drivers +dtg_contracts +dtg_maintenance_history +dtg_management +dtg_management_log +dtg_monthly_settlements +dynamic_form_data +equipment_consumable +equipment_consumable_log +equipment_inspection_item +equipment_inspection_item_log +equipment_mng +equipment_mng_log +estimate_mgmt +excel_mapping_template +expense_detail +expense_master +external_call_configs +external_call_logs +external_connection_permission +external_db_connection +external_db_connections +external_rest_api_connections +external_work_review_info +facility_assembly_plan +file_down_log +flow_audit_log +flow_data_mapping +flow_data_status +flow_definition +flow_external_connection_permission +flow_external_db_connection +flow_integration_log +flow_step +flow_step_connection +fund_mgmt +grid_standards +inbound_mng +inboxtask +injection_cost +input_cost_goal +input_resource +inspection_equipment_mng +inspection_equipment_mng_log +inspection_standard +inventory_history +inventory_stock +item_info +item_inspection_info +item_routing_detail +item_routing_version +klbom_tbl +language_master +layout_instances +layout_standards +login_access_log +logistics_cost_mng +logistics_cost_mng_log +mail_log +maintenance_schedules +material_cost +material_detail_mgmt +material_master_mgmt +material_mng +material_release +menu_info +menu_screen_group_items +menu_screen_groups +mold_dev_request_info +multi_lang_category +multi_lang_key_master +multi_lang_text +node_flows +numbering_rule_parts +numbering_rules +oem_factory_mng +oem_milestone_mng +oem_mng +option_mng +option_price_history +order_mgmt +order_mng_master +order_mng_sub +order_plan_mgmt +order_plan_result_error +order_spec_mng +order_spec_mng_history +outbound_mng +part_bom_qty +part_bom_report +part_distribution_list +part_mgmt +part_mng +part_mng_history +planning_issue +pms_invest_cost_mng +pms_pjt_concept_info +pms_pjt_info +pms_pjt_year_goal +pms_rel_pjt_concept_milestone +pms_rel_pjt_concept_prod +pms_rel_pjt_prod +pms_rel_prod_ref_dept +pms_wbs_task +pms_wbs_task_confirm +pms_wbs_task_info +pms_wbs_task_standard +pms_wbs_template +problem_mng +process_equipment +process_mng +procurement_standard +product_group_mng +product_kind_spec +product_kind_spec_main +product_mgmt +product_mgmt_model +product_mgmt_price_history +product_mgmt_upg_detail +product_mgmt_upg_master +product_mng +product_spec +production_issue +production_record +production_task +profit_loss +profit_loss_coefficient +profit_loss_coolingtime +profit_loss_depth +profit_loss_lossrate +profit_loss_machine +profit_loss_pretime +profit_loss_srrate +profit_loss_total +profit_loss_total_addlist +profit_loss_weight +project +project_mgmt +purchase_detail +purchase_order +purchase_order_master +purchase_order_mng +purchase_order_multi +purchase_order_part +ratecal_mgmt +receive_history +receiving +rel_menu_auth +report_layout +report_master +report_menu_mapping +report_query +report_template +safety_budget_execution +safety_incidents +safety_inspections +safety_inspections_log +sales_bom_part_qty +sales_bom_report +sales_bom_report_part +sales_long_delivery +sales_long_delivery_input +sales_long_delivery_predict +sales_order_detail +sales_order_detail_log +sales_order_mng +sales_part_chg +sales_request_master +sales_request_part +sample_supply +screen_data_flows +screen_data_transfer +screen_definitions +screen_embedding +screen_field_joins +screen_group_members +screen_group_screens +screen_groups +screen_layouts +screen_menu_assignments +screen_split_panel +screen_table_relations +screen_templates +screen_widgets +shipment_detail +shipment_header +shipment_instruction +shipment_instruction_item +shipment_pallet +shipment_plan +standard_doc_info +structural_review_proposal +style_templates +supplier_item +supplier_item_alias +supplier_item_mapping +supplier_item_price +supplier_mng +supplier_mng_log +supply_charger_mng +supply_mng +supply_mng_history +table_column_category_values +table_labels +table_log_config +table_relationships +table_type_columns +tax_invoice +tax_invoice_item +time_sheet +transport_logs +transport_statistics +transport_vehicle_locations +used_mng +user_dept +user_dept_sub +user_info +user_info_history +vehicle_location_history +vehicle_locations +vehicle_trip_summary +vehicles +warehouse_info +warehouse_location +web_type_standards +work_instruction +work_instruction_detail +work_instruction_detail_log +work_instruction_log +work_order +work_orders +work_orders_detail +work_request +yard_layout +yard_material_placement +``` + +
+ +--- + +## 3. 멀티테넌시 아키텍처 + +### 3.1 company_code 패턴 + +**모든 테이블에 필수적으로 포함되는 컬럼:** + +```sql +company_code VARCHAR(20) NOT NULL +``` + +**의미:** +- 하나의 데이터베이스에서 여러 회사의 데이터를 격리 +- 모든 쿼리는 반드시 `company_code` 필터 포함 필요 + +### 3.2 특별한 company_code 값 + +#### `company_code = "*"` 의미 + +```sql +-- ❌ 잘못된 이해: 모든 회사가 공유하는 공통 데이터 +-- ✅ 올바른 이해: 슈퍼 관리자 전용 데이터 + +-- 일반 회사는 "*" 데이터를 볼 수 없음 +SELECT * FROM table_name +WHERE company_code = 'COMPANY_A' + AND company_code != '*'; -- 필수! +``` + +**용도:** +- 시스템 관리자용 메타데이터 +- 전역 설정 값 +- 기본 템플릿 + +### 3.3 멀티테넌시 쿼리 패턴 + +```sql +-- ✅ 표준 SELECT 패턴 +SELECT * FROM table_name +WHERE company_code = $1 + AND company_code != '*' +ORDER BY created_date DESC; + +-- ✅ JOIN 패턴 (company_code 매칭 필수!) +SELECT a.*, b.name +FROM table_a a +LEFT JOIN table_b b + ON a.ref_id = b.id + AND a.company_code = b.company_code -- 필수! +WHERE a.company_code = $1; + +-- ✅ 서브쿼리 패턴 +SELECT * +FROM orders o +WHERE company_code = $1 + AND product_id IN ( + SELECT id FROM products + WHERE company_code = $1 -- 서브쿼리에도 필수! + ); + +-- ✅ 집계 패턴 +SELECT + product_type, + COUNT(*) as total, + SUM(amount) as total_amount +FROM sales +WHERE company_code = $1 +GROUP BY product_type; +``` + +### 3.4 company_code 인덱스 전략 + +**모든 테이블에 필수 인덱스:** + +```sql +CREATE INDEX idx_{table_name}_company_code +ON {table_name}(company_code); + +-- 복합 인덱스 예시 +CREATE INDEX idx_sales_company_date +ON sales(company_code, sale_date DESC); +``` + +--- + +## 4. 핵심 시스템 테이블 + +### 4.1 사용자 관리 + +#### user_info (사용자 정보) + +```sql +CREATE TABLE user_info ( + sabun VARCHAR(1024), -- 사번 + user_id VARCHAR(1024) PRIMARY KEY,-- 사용자 ID + user_password VARCHAR(1024), -- 암호화된 비밀번호 + user_name VARCHAR(1024), -- 한글명 + user_name_eng VARCHAR(1024), -- 영문명 + user_name_cn VARCHAR(1024), -- 중문명 + dept_code VARCHAR(1024), -- 부서 코드 + dept_name VARCHAR(1024), -- 부서명 + position_code VARCHAR(1024), -- 직위 코드 + position_name VARCHAR(1024), -- 직위명 + email VARCHAR(1024), -- 이메일 + tel VARCHAR(1024), -- 전화번호 + cell_phone VARCHAR(1024), -- 휴대폰 + user_type VARCHAR(1024), -- 사용자 유형 코드 + user_type_name VARCHAR(1024), -- 사용자 유형명 + company_code VARCHAR(50), -- 회사 코드 (멀티테넌시) + status VARCHAR(32), -- active/inactive + license_number VARCHAR(50), -- 면허번호 + vehicle_number VARCHAR(50), -- 차량번호 + signup_type VARCHAR(20), -- 가입 유형 + branch_name VARCHAR(100), -- 지점명 + regdate TIMESTAMP, -- 등록일 + end_date TIMESTAMP -- 종료일 +); +``` + +**관련 테이블:** +- `user_info_history`: 사용자 정보 변경 이력 +- `user_dept`: 사용자-부서 관계 +- `user_dept_sub`: 사용자 하위 부서 + +#### auth_tokens (인증 토큰) + +```sql +CREATE TABLE auth_tokens ( + id SERIAL PRIMARY KEY, + user_id VARCHAR(255) NOT NULL, + token VARCHAR(500) NOT NULL, + refresh_token VARCHAR(500), + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP DEFAULT NOW(), + ip_address VARCHAR(50), + user_agent TEXT +); +``` + +### 4.2 권한 관리 + +#### authority_master (권한 그룹) + +```sql +CREATE TABLE authority_master ( + objid NUMERIC PRIMARY KEY, + auth_code VARCHAR(64), -- 권한 코드 + auth_name VARCHAR(64), -- 권한명 + company_code VARCHAR(50), -- 회사 코드 + status VARCHAR(32), -- 상태 + writer VARCHAR(32), -- 작성자 + regdate TIMESTAMP -- 등록일 +); +``` + +**관련 테이블:** +- `authority_master_history`: 권한 변경 이력 +- `authority_sub_user`: 권한-사용자 매핑 +- `rel_menu_auth`: 권한-메뉴 매핑 + +### 4.3 메뉴 관리 + +#### menu_info (메뉴 정보) + +```sql +CREATE TABLE menu_info ( + objid NUMERIC PRIMARY KEY, + menu_type NUMERIC, -- 0=일반, 1=시스템관리, 2=동적생성 + parent_obj_id NUMERIC, -- 부모 메뉴 ID + menu_name_kor VARCHAR(64), -- 한글 메뉴명 + menu_name_eng VARCHAR(64), -- 영문 메뉴명 + menu_code VARCHAR(50), -- 메뉴 코드 + menu_url VARCHAR(256), -- 메뉴 URL + seq NUMERIC, -- 순서 + screen_code VARCHAR(50), -- 화면 코드 (동적 생성 시) + screen_group_id INTEGER, -- 화면 그룹 ID + company_code VARCHAR(50), -- 회사 코드 + status VARCHAR(32), -- active/inactive + lang_key VARCHAR(100), -- 다국어 키 + source_menu_objid BIGINT, -- 원본 메뉴 ID (복사 시) + writer VARCHAR(32), + regdate TIMESTAMP +); +``` + +**특징:** +- `menu_type = 2`: 화면 생성 시 자동으로 생성되는 메뉴 +- 트리거: `auto_create_menu_for_screen()` - 화면 생성 시 자동 메뉴 추가 + +**관련 테이블:** +- `menu_screen_groups`: 메뉴 화면 그룹 +- `menu_screen_group_items`: 그룹-화면 연결 + +### 4.4 회사 관리 + +#### company_mng (회사 정보) + +```sql +CREATE TABLE company_mng ( + company_code VARCHAR(32) PRIMARY KEY, + company_name VARCHAR(64), + business_registration_number VARCHAR(20), -- 사업자등록번호 + representative_name VARCHAR(100), -- 대표자명 + representative_phone VARCHAR(20), -- 대표 연락처 + email VARCHAR(255), -- 회사 이메일 + website VARCHAR(500), -- 웹사이트 + address VARCHAR(500), -- 주소 + status VARCHAR(32), + writer VARCHAR(32), + regdate TIMESTAMP +); +``` + +**관련 테이블:** +- `company_code_sequence`: 회사별 시퀀스 관리 + +### 4.5 부서 관리 + +#### dept_info (부서 정보) + +```sql +CREATE TABLE dept_info ( + dept_code VARCHAR(1024) PRIMARY KEY, + dept_name VARCHAR(1024), + parent_dept_code VARCHAR(1024), -- 상위 부서 + company_code VARCHAR(50), + status VARCHAR(32), + writer VARCHAR(32), + regdate TIMESTAMP +); +``` + +**관련 테이블:** +- `dept_info_history`: 부서 정보 변경 이력 + +--- + +## 5. 메타데이터 관리 시스템 + +WACE ERP의 핵심 특징은 **메타데이터 드리븐 아키텍처**입니다. 화면, 테이블, 컬럼 정보를 메타데이터 테이블에서 관리하고, 프론트엔드가 이를 기반으로 동적 렌더링합니다. + +### 5.1 테이블 메타데이터 + +#### table_labels (테이블 정의) + +```sql +CREATE TABLE table_labels ( + table_name VARCHAR(100) PRIMARY KEY, -- 테이블명 (물리명) + table_label VARCHAR(200), -- 테이블 한글명 + description TEXT, -- 설명 + use_log_table VARCHAR(1) DEFAULT 'N', -- 이력 테이블 사용 여부 + created_date TIMESTAMP, + updated_date TIMESTAMP +); +``` + +**역할:** +- 동적으로 생성된 모든 테이블의 메타정보 저장 +- 화면 생성 시 테이블 선택 목록 제공 +- 데이터 딕셔너리로 활용 + +#### table_type_columns (컬럼 타입 정의) + +```sql +CREATE TABLE table_type_columns ( + id SERIAL PRIMARY KEY, + table_name VARCHAR(255) NOT NULL, + column_name VARCHAR(255) NOT NULL, + company_code VARCHAR(20) NOT NULL, -- 회사별 컬럼 설정 + input_type VARCHAR(50) DEFAULT 'text',-- 입력 타입 + detail_settings TEXT DEFAULT '{}', -- JSON 상세 설정 + is_nullable VARCHAR(10) DEFAULT 'Y', + display_order INTEGER DEFAULT 0, -- 표시 순서 + created_date TIMESTAMP, + updated_date TIMESTAMP, + + UNIQUE(table_name, column_name, company_code) +); +``` + +**input_type 종류:** +- `text`: 일반 텍스트 +- `number`: 숫자 +- `date`: 날짜 +- `select`: 드롭다운 (options 필요) +- `textarea`: 여러 줄 텍스트 +- `entity`: 참조 테이블 (referenceTable, referenceColumn 필요) +- `checkbox`: 체크박스 +- `radio`: 라디오 버튼 + +**detail_settings 예시:** + +```json +// select 타입 +{ + "options": [ + {"label": "일반", "value": "normal"}, + {"label": "긴급", "value": "urgent"} + ] +} + +// entity 타입 +{ + "referenceTable": "customer_mng", + "referenceColumn": "customer_code", + "displayColumn": "customer_name" +} +``` + +#### column_labels (컬럼 라벨 - 레거시) + +```sql +CREATE TABLE column_labels ( + table_name VARCHAR(100) NOT NULL, + column_name VARCHAR(100) NOT NULL, + column_label VARCHAR(200), -- 한글 라벨 + input_type VARCHAR(50), + detail_settings TEXT, + description TEXT, + display_order INTEGER, + is_visible BOOLEAN DEFAULT true, + created_date TIMESTAMP, + updated_date TIMESTAMP, + + PRIMARY KEY (table_name, column_name) +); +``` + +**참고:** +- 레거시 호환을 위해 유지 +- 새로운 컬럼은 `table_type_columns` 사용 권장 +- `table_type_columns`는 회사별 설정, `column_labels`는 전역 설정 + +### 5.2 카테고리 값 관리 + +#### table_column_category_values (컬럼 카테고리 값) + +```sql +CREATE TABLE table_column_category_values ( + id SERIAL PRIMARY KEY, + table_name VARCHAR(255) NOT NULL, + column_name VARCHAR(255) NOT NULL, + company_code VARCHAR(20) NOT NULL, + category_value VARCHAR(500) NOT NULL, -- 카테고리 값 + display_label VARCHAR(500), -- 표시 라벨 + display_order INTEGER DEFAULT 0, + is_active VARCHAR(1) DEFAULT 'Y', + parent_value VARCHAR(500), -- 부모 카테고리 (계층 구조) + created_date TIMESTAMP, + updated_date TIMESTAMP, + + UNIQUE(table_name, column_name, company_code, category_value) +); +``` + +**용도:** +- 동적 드롭다운 값 관리 +- 계층형 카테고리 지원 (parent_value) +- 회사별 카테고리 값 커스터마이징 + +**관련 테이블:** +- `category_column_mapping`: 카테고리-컬럼 매핑 +- `category_value_cascading_group`: 카테고리 캐스케이딩 그룹 +- `category_value_cascading_mapping`: 캐스케이딩 매핑 + +### 5.3 테이블 관계 관리 + +#### table_relationships (테이블 관계) + +```sql +CREATE TABLE table_relationships ( + id SERIAL PRIMARY KEY, + parent_table VARCHAR(100), -- 부모 테이블 + parent_column VARCHAR(100), -- 부모 컬럼 + child_table VARCHAR(100), -- 자식 테이블 + child_column VARCHAR(100), -- 자식 컬럼 + relationship_type VARCHAR(20), -- one-to-many, many-to-one 등 + created_date TIMESTAMP +); +``` + +--- + +## 6. 화면 관리 시스템 + +WACE ERP는 코드 작성 없이 화면을 동적으로 생성/수정할 수 있는 **Low-Code 플랫폼** 기능을 제공합니다. + +### 6.1 화면 정의 + +#### screen_definitions (화면 정의) + +```sql +CREATE TABLE screen_definitions ( + screen_id SERIAL PRIMARY KEY, + screen_name VARCHAR(100) NOT NULL, -- 화면명 + screen_code VARCHAR(50) NOT NULL, -- 화면 코드 (URL용) + table_name VARCHAR(100) NOT NULL, -- 메인 테이블 + company_code VARCHAR(50) NOT NULL, + description TEXT, + is_active CHAR(1) DEFAULT 'Y', -- Y=활성, N=비활성, D=삭제 + layout_metadata JSONB, -- 레이아웃 JSON + + -- 외부 데이터 소스 지원 + db_source_type VARCHAR(10) DEFAULT 'internal', -- internal/external + db_connection_id INTEGER, -- 외부 DB 연결 ID + data_source_type VARCHAR(20) DEFAULT 'database', -- database/rest_api + rest_api_connection_id INTEGER, -- REST API 연결 ID + rest_api_endpoint VARCHAR(500), -- API 엔드포인트 + rest_api_json_path VARCHAR(200) DEFAULT 'data', -- JSON 응답 경로 + + source_screen_id INTEGER, -- 원본 화면 ID (복사 시) + + created_date TIMESTAMP NOT NULL DEFAULT NOW(), + created_by VARCHAR(50), + updated_date TIMESTAMP NOT NULL DEFAULT NOW(), + updated_by VARCHAR(50), + deleted_date TIMESTAMP, -- 휴지통 이동 시점 + deleted_by VARCHAR(50), + delete_reason TEXT, + + UNIQUE(screen_code, company_code) +); +``` + +**화면 생성 플로우:** +1. 관리자가 화면 설정 페이지에서 테이블 선택 +2. `screen_definitions` 레코드 생성 +3. 트리거 `auto_create_menu_for_screen()` 실행 → `menu_info` 자동 생성 +4. 프론트엔드가 `/screen/{screen_code}` 경로로 접근 시 동적 렌더링 + +#### screen_layouts (화면 레이아웃 - 레거시) + +```sql +CREATE TABLE screen_layouts ( + layout_id SERIAL PRIMARY KEY, + screen_id INTEGER REFERENCES screen_definitions(screen_id), + layout_name VARCHAR(100), + layout_type VARCHAR(50), -- grid, form, split, tab 등 + layout_config JSONB, -- 레이아웃 설정 + display_order INTEGER, + is_active CHAR(1) DEFAULT 'Y', + company_code VARCHAR(50), + created_date TIMESTAMP, + updated_date TIMESTAMP +); +``` + +### 6.2 화면 그룹 관리 + +#### screen_groups (화면 그룹) + +```sql +CREATE TABLE screen_groups ( + id SERIAL PRIMARY KEY, + group_name VARCHAR(100) NOT NULL, -- 그룹명 + group_code VARCHAR(50) NOT NULL, -- 그룹 코드 + main_table_name VARCHAR(100), -- 메인 테이블 + description TEXT, + icon VARCHAR(100), -- 아이콘 + display_order INT DEFAULT 0, + is_active VARCHAR(1) DEFAULT 'Y', + company_code VARCHAR(20) NOT NULL, + + -- 계층 구조 지원 (037 마이그레이션에서 추가) + parent_group_id INTEGER REFERENCES screen_groups(id) ON DELETE CASCADE, + group_level INTEGER DEFAULT 0, -- 0=대, 1=중, 2=소 + hierarchy_path VARCHAR(500), -- 예: /1/3/5/ + + created_date TIMESTAMPTZ DEFAULT NOW(), + updated_date TIMESTAMPTZ DEFAULT NOW(), + writer VARCHAR(50), + + UNIQUE(company_code, group_code) +); + +CREATE INDEX idx_screen_groups_company_code ON screen_groups(company_code); +CREATE INDEX idx_screen_groups_parent_id ON screen_groups(parent_group_id); +CREATE INDEX idx_screen_groups_hierarchy_path ON screen_groups(hierarchy_path); +``` + +#### screen_group_screens (화면-그룹 연결) + +```sql +CREATE TABLE screen_group_screens ( + id SERIAL PRIMARY KEY, + group_id INT NOT NULL REFERENCES screen_groups(id) ON DELETE CASCADE, + screen_id INT NOT NULL REFERENCES screen_definitions(screen_id) ON DELETE CASCADE, + screen_role VARCHAR(50) DEFAULT 'main', -- main, register, list, detail 등 + display_order INT DEFAULT 0, + is_default VARCHAR(1) DEFAULT 'N', -- 기본 화면 여부 + company_code VARCHAR(20) NOT NULL, + created_date TIMESTAMPTZ DEFAULT NOW(), + updated_date TIMESTAMPTZ DEFAULT NOW(), + writer VARCHAR(50), + + UNIQUE(group_id, screen_id) +); +``` + +**용도:** +- 관련 화면들을 그룹으로 묶어 관리 +- 예: "영업 관리" 그룹 → 견적 화면, 수주 화면, 출하 화면 + +### 6.3 화면 필드 조인 + +#### screen_field_joins (화면 필드 조인 설정) + +```sql +CREATE TABLE screen_field_joins ( + id SERIAL PRIMARY KEY, + screen_id INT NOT NULL REFERENCES screen_definitions(screen_id) ON DELETE CASCADE, + layout_id INT, + component_id VARCHAR(500), + field_name VARCHAR(100), + + -- 저장 테이블 설정 + save_table VARCHAR(100) NOT NULL, + save_column VARCHAR(100) NOT NULL, + + -- 조인 테이블 설정 + join_table VARCHAR(100) NOT NULL, + join_column VARCHAR(100) NOT NULL, + display_column VARCHAR(100) NOT NULL, + + -- 조인 옵션 + join_type VARCHAR(20) DEFAULT 'LEFT', + filter_condition TEXT, + sort_column VARCHAR(100), + sort_direction VARCHAR(10) DEFAULT 'ASC', + + is_active VARCHAR(1) DEFAULT 'Y', + company_code VARCHAR(20) NOT NULL, + created_date TIMESTAMPTZ DEFAULT NOW(), + updated_date TIMESTAMPTZ DEFAULT NOW(), + writer VARCHAR(50) +); +``` + +**예시:** +```json +{ + "save_table": "sales_order", + "save_column": "customer_code", + "join_table": "customer_mng", + "join_column": "customer_code", + "display_column": "customer_name" +} +``` + +### 6.4 화면 간 데이터 흐름 + +#### screen_data_flows (화면 간 데이터 흐름) + +```sql +CREATE TABLE screen_data_flows ( + id SERIAL PRIMARY KEY, + group_id INT REFERENCES screen_groups(id) ON DELETE SET NULL, + + -- 소스 화면 + source_screen_id INT NOT NULL REFERENCES screen_definitions(screen_id) ON DELETE CASCADE, + source_action VARCHAR(50), -- click, submit, select 등 + + -- 타겟 화면 + target_screen_id INT NOT NULL REFERENCES screen_definitions(screen_id) ON DELETE CASCADE, + target_action VARCHAR(50), -- open, load, refresh 등 + + -- 데이터 매핑 설정 + data_mapping JSONB, -- 필드 매핑 정보 + + -- 흐름 설정 + flow_type VARCHAR(20) DEFAULT 'unidirectional', -- unidirectional/bidirectional + flow_label VARCHAR(100), -- 시각화 라벨 + condition_expression TEXT, -- 실행 조건식 + + is_active VARCHAR(1) DEFAULT 'Y', + company_code VARCHAR(20) NOT NULL, + created_date TIMESTAMPTZ DEFAULT NOW(), + updated_date TIMESTAMPTZ DEFAULT NOW(), + writer VARCHAR(50) +); +``` + +**data_mapping 예시:** + +```json +{ + "customer_code": "customer_code", + "customer_name": "customer_name", + "selected_date": "order_date" +} +``` + +#### screen_table_relations (화면-테이블 관계) + +```sql +CREATE TABLE screen_table_relations ( + id SERIAL PRIMARY KEY, + group_id INT REFERENCES screen_groups(id) ON DELETE SET NULL, + screen_id INT NOT NULL REFERENCES screen_definitions(screen_id) ON DELETE CASCADE, + table_name VARCHAR(100) NOT NULL, + + relation_type VARCHAR(20) DEFAULT 'main', -- main, join, lookup + crud_operations VARCHAR(20) DEFAULT 'CRUD',-- CRUD 조합 + description TEXT, + + is_active VARCHAR(1) DEFAULT 'Y', + company_code VARCHAR(20) NOT NULL, + created_date TIMESTAMPTZ DEFAULT NOW(), + updated_date TIMESTAMPTZ DEFAULT NOW(), + writer VARCHAR(50) +); +``` + +### 6.5 컴포넌트 표준 + +#### component_standards (컴포넌트 표준) + +```sql +CREATE TABLE component_standards ( + component_code VARCHAR(50) PRIMARY KEY, + component_name VARCHAR(100) NOT NULL, + component_name_eng VARCHAR(100), + description TEXT, + category VARCHAR(50) NOT NULL, -- input, layout, display 등 + icon_name VARCHAR(50), + default_size JSON, -- {width, height} + component_config JSON NOT NULL, -- 컴포넌트 설정 + preview_image VARCHAR(255), + sort_order INTEGER DEFAULT 0, + is_active CHAR(1) DEFAULT 'Y', + is_public CHAR(1) DEFAULT 'Y', + company_code VARCHAR(50) NOT NULL, + created_date TIMESTAMP, + updated_date TIMESTAMP +); +``` + +#### layout_standards (레이아웃 표준) + +```sql +CREATE TABLE layout_standards ( + layout_code VARCHAR(50) PRIMARY KEY, + layout_name VARCHAR(100) NOT NULL, + layout_type VARCHAR(50), -- grid, form, split, tab + default_config JSON, + is_active CHAR(1) DEFAULT 'Y', + company_code VARCHAR(50), + created_date TIMESTAMP, + updated_date TIMESTAMP +); +``` + +--- + +## 7. 비즈니스 도메인별 테이블 + +### 7.1 영업/수주 관리 + +#### 수주 관리 (Order Management) + +``` +sales_order_mng -- 수주 마스터 +├── sales_order_detail -- 수주 상세 +├── sales_order_detail_log -- 수주 상세 이력 +├── sales_request_master -- 영업 요청 마스터 +├── sales_request_part -- 영업 요청 부품 +└── sales_part_chg -- 영업 부품 변경 +``` + +**sales_order_mng:** +- 고객별 수주 정보 +- 납기, 금액, 상태 관리 + +**sales_order_detail:** +- 수주 라인 아이템 +- 품목, 수량, 단가 정보 + +#### 견적 관리 + +``` +estimate_mgmt -- 견적 관리 +contract_mgmt -- 계약 관리 +├── contract_mgmt_option -- 계약 옵션 +``` + +#### BOM 관리 + +``` +sales_bom_report -- 영업 BOM 리포트 +├── sales_bom_report_part -- 영업 BOM 부품 +└── sales_bom_part_qty -- 영업 BOM 부품 수량 +``` + +### 7.2 구매/발주 관리 + +``` +purchase_order_master -- 발주 마스터 +├── purchase_order -- 발주 상세 +├── purchase_order_part -- 발주 부품 +├── purchase_order_multi -- 다중 발주 +└── purchase_detail -- 구매 상세 + +supplier_mng -- 공급업체 관리 +├── supplier_mng_log -- 공급업체 이력 +├── supplier_item -- 공급업체 품목 +├── supplier_item_alias -- 공급업체 품목 별칭 +├── supplier_item_mapping -- 공급업체 품목 매핑 +└── supplier_item_price -- 공급업체 품목 가격 +``` + +### 7.3 재고/창고 관리 + +``` +inventory_stock -- 재고 현황 +inventory_history -- 재고 이력 +warehouse_info -- 창고 정보 +warehouse_location -- 창고 위치 +inbound_mng -- 입고 관리 +outbound_mng -- 출고 관리 +receiving -- 입하 +receive_history -- 입하 이력 +``` + +### 7.4 생산/작업 관리 + +``` +work_orders -- 작업지시 (신규) +├── work_orders_detail -- 작업지시 상세 +work_order -- 작업지시 (레거시) +work_instruction -- 작업 지시서 +├── work_instruction_detail -- 작업 지시서 상세 +├── work_instruction_detail_log +└── work_instruction_log + +production_record -- 생산 실적 +production_task -- 생산 작업 +production_issue -- 생산 이슈 +work_request -- 작업 요청 +``` + +#### work_orders (작업지시 - 050 마이그레이션) + +```sql +CREATE TABLE work_orders ( + id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + created_date TIMESTAMP DEFAULT NOW(), + updated_date TIMESTAMP DEFAULT NOW(), + writer VARCHAR(500), + company_code VARCHAR(500), + + -- 작업지시 정보 + wo_number VARCHAR(500), -- WO-20250130-001 + product_code VARCHAR(500), + product_name VARCHAR(500), + spec VARCHAR(500), + order_qty VARCHAR(500), + completed_qty VARCHAR(500), + start_date VARCHAR(500), + due_date VARCHAR(500), + status VARCHAR(500), -- normal/urgent + progress_status VARCHAR(500), -- pending/in-progress/completed + equipment VARCHAR(500), + routing VARCHAR(500), + work_team VARCHAR(500), -- DAY/NIGHT + worker VARCHAR(500), + shift VARCHAR(500), -- DAY/NIGHT + remark VARCHAR(500) +); + +CREATE INDEX idx_work_orders_company_code ON work_orders(company_code); +CREATE INDEX idx_work_orders_wo_number ON work_orders(wo_number); +``` + +### 7.5 품질/검사 관리 + +``` +inspection_standard -- 검사 기준 +item_inspection_info -- 품목 검사 정보 +inspection_equipment_mng -- 검사 설비 관리 +├── inspection_equipment_mng_log +defect_standard_mng -- 불량 기준 관리 +├── defect_standard_mng_log +check_report_mng -- 검사 성적서 관리 +safety_inspections -- 안전 점검 +└── safety_inspections_log +``` + +### 7.6 물류/운송 관리 + +``` +vehicles -- 차량 정보 +├── vehicle_locations -- 차량 위치 +├── vehicle_location_history -- 차량 위치 이력 +├── vehicle_trip_summary -- 차량 운행 요약 +drivers -- 운전자 정보 +transport_logs -- 운송 로그 +transport_statistics -- 운송 통계 +transport_vehicle_locations -- 차량 위치 + +carrier_mng -- 운송사 관리 +├── carrier_mng_log +├── carrier_contract_mng -- 운송사 계약 +├── carrier_contract_mng_log +├── carrier_vehicle_mng -- 운송사 차량 +└── carrier_vehicle_mng_log + +delivery_route_mng -- 배송 경로 관리 +├── delivery_route_mng_log +delivery_destination -- 배송지 +delivery_status -- 배송 상태 +delivery_history -- 배송 이력 +├── delivery_history_defect -- 배송 불량 +delivery_part_price -- 배송 부품 가격 +``` + +#### DTG 관리 (디지털 타코그래프) + +``` +dtg_management -- DTG 관리 +├── dtg_management_log +dtg_contracts -- DTG 계약 +dtg_maintenance_history -- DTG 정비 이력 +dtg_monthly_settlements -- DTG 월별 정산 +``` + +### 7.7 PLM/설계 관리 + +``` +part_mng -- 부품 관리 (메인) +├── part_mng_history +part_mgmt -- 부품 관리 (서브) +part_bom_qty -- 부품 BOM 수량 +part_bom_report -- 부품 BOM 리포트 +part_distribution_list -- 부품 배포 목록 + +item_info -- 품목 정보 +item_routing_version -- 품목 라우팅 버전 +item_routing_detail -- 품목 라우팅 상세 + +product_mng -- 제품 관리 +product_mgmt -- 제품 관리 (메인) +├── product_mgmt_model -- 제품 모델 +├── product_mgmt_price_history -- 제품 가격 이력 +├── product_mgmt_upg_master -- 제품 업그레이드 마스터 +└── product_mgmt_upg_detail -- 제품 업그레이드 상세 + +product_kind_spec -- 제품 종류 사양 +product_kind_spec_main -- 제품 종류 사양 메인 +product_spec -- 제품 사양 +product_group_mng -- 제품 그룹 관리 + +mold_dev_request_info -- 금형 개발 요청 +structural_review_proposal -- 구조 검토 제안 +``` + +### 7.8 프로젝트 관리 (PMS) + +``` +pms_pjt_info -- 프로젝트 정보 +├── pms_pjt_concept_info -- 프로젝트 개념 정보 +├── pms_pjt_year_goal -- 프로젝트 연도 목표 +pms_wbs_task -- WBS 작업 +├── pms_wbs_task_info -- WBS 작업 정보 +├── pms_wbs_task_confirm -- WBS 작업 확인 +├── pms_wbs_task_standard -- WBS 작업 표준 +└── pms_wbs_template -- WBS 템플릿 + +pms_rel_pjt_concept_milestone -- 프로젝트 개념-마일스톤 관계 +pms_rel_pjt_concept_prod -- 프로젝트 개념-제품 관계 +pms_rel_pjt_prod -- 프로젝트-제품 관계 +pms_rel_prod_ref_dept -- 제품-참조부서 관계 + +pms_invest_cost_mng -- 투자 비용 관리 +project_mgmt -- 프로젝트 관리 +problem_mng -- 문제 관리 +planning_issue -- 계획 이슈 +``` + +### 7.9 회계/원가 관리 + +``` +tax_invoice -- 세금계산서 +├── tax_invoice_item -- 세금계산서 항목 +fund_mgmt -- 자금 관리 +expense_master -- 비용 마스터 +├── expense_detail -- 비용 상세 + +profit_loss -- 손익 계산 +├── profit_loss_total -- 손익 합계 +├── profit_loss_coefficient -- 손익 계수 +├── profit_loss_machine -- 손익 기계 +├── profit_loss_weight -- 손익 무게 +├── profit_loss_depth -- 손익 깊이 +├── profit_loss_pretime -- 손익 사전 시간 +├── profit_loss_coolingtime -- 손익 냉각 시간 +├── profit_loss_lossrate -- 손익 손실률 +└── profit_loss_srrate -- 손익 SR률 + +material_cost -- 자재 비용 +injection_cost -- 사출 비용 +logistics_cost_mng -- 물류 비용 관리 +└── logistics_cost_mng_log + +input_cost_goal -- 투입 비용 목표 +input_resource -- 투입 자원 +``` + +### 7.10 고객/협력사 관리 + +``` +customer_mng -- 고객 관리 +customer_item -- 고객 품목 +├── customer_item_alias -- 고객 품목 별칭 +├── customer_item_mapping -- 고객 품목 매핑 +└── customer_item_price -- 고객 품목 가격 + +customer_service_mgmt -- 고객 서비스 관리 +├── customer_service_part -- 고객 서비스 부품 +└── customer_service_workingtime -- 고객 서비스 작업시간 + +oem_mng -- OEM 관리 +├── oem_factory_mng -- OEM 공장 관리 +└── oem_milestone_mng -- OEM 마일스톤 관리 +``` + +### 7.11 설비/장비 관리 + +``` +equipment_mng -- 설비 관리 +├── equipment_mng_log +equipment_consumable -- 설비 소모품 +├── equipment_consumable_log +equipment_inspection_item -- 설비 검사 항목 +└── equipment_inspection_item_log + +process_equipment -- 공정 설비 +process_mng -- 공정 관리 +maintenance_schedules -- 정비 일정 +``` + +### 7.12 기타 + +``` +approval -- 결재 +comments -- 댓글 +inboxtask -- 수신함 작업 +time_sheet -- 작업 시간 +attach_file_info -- 첨부 파일 +file_down_log -- 파일 다운로드 로그 +login_access_log -- 로그인 접근 로그 +``` + +--- + +## 8. 플로우 및 데이터 통합 + +### 8.1 플로우 정의 + +#### flow_definition (플로우 정의) + +```sql +CREATE TABLE flow_definition ( + flow_id SERIAL PRIMARY KEY, + flow_name VARCHAR(200) NOT NULL, + flow_code VARCHAR(100) NOT NULL, + flow_type VARCHAR(50), -- data_transfer, approval, batch 등 + description TEXT, + trigger_type VARCHAR(50), -- manual, schedule, event + trigger_config JSONB, + is_active VARCHAR(1) DEFAULT 'Y', + company_code VARCHAR(20), + created_date TIMESTAMPTZ DEFAULT NOW(), + updated_date TIMESTAMPTZ DEFAULT NOW(), + created_by VARCHAR(50), + updated_by VARCHAR(50), + + UNIQUE(flow_code, company_code) +); +``` + +#### flow_step (플로우 단계) + +```sql +CREATE TABLE flow_step ( + step_id SERIAL PRIMARY KEY, + flow_id INTEGER REFERENCES flow_definition(flow_id) ON DELETE CASCADE, + step_name VARCHAR(200) NOT NULL, + step_type VARCHAR(50) NOT NULL, -- query, transform, api_call, condition 등 + step_order INTEGER NOT NULL, + step_config JSONB NOT NULL, + error_handling_config JSONB, + is_active VARCHAR(1) DEFAULT 'Y', + company_code VARCHAR(20), + created_date TIMESTAMPTZ DEFAULT NOW(), + updated_date TIMESTAMPTZ DEFAULT NOW() +); +``` + +#### flow_step_connection (플로우 단계 연결) + +```sql +CREATE TABLE flow_step_connection ( + connection_id SERIAL PRIMARY KEY, + flow_id INTEGER REFERENCES flow_definition(flow_id) ON DELETE CASCADE, + source_step_id INTEGER REFERENCES flow_step(step_id) ON DELETE CASCADE, + target_step_id INTEGER REFERENCES flow_step(step_id) ON DELETE CASCADE, + condition_expression TEXT, -- 조건부 실행 + connection_type VARCHAR(20) DEFAULT 'sequential', -- sequential, parallel, conditional + company_code VARCHAR(20), + created_date TIMESTAMPTZ DEFAULT NOW() +); +``` + +### 8.2 데이터플로우 + +#### dataflow_diagrams (데이터플로우 다이어그램) + +```sql +CREATE TABLE dataflow_diagrams ( + diagram_id SERIAL PRIMARY KEY, + diagram_name VARCHAR(200) NOT NULL, + diagram_type VARCHAR(50), + diagram_json JSONB NOT NULL, -- 다이어그램 시각화 정보 + description TEXT, + is_active VARCHAR(1) DEFAULT 'Y', + company_code VARCHAR(20), + created_date TIMESTAMPTZ DEFAULT NOW(), + updated_date TIMESTAMPTZ DEFAULT NOW(), + created_by VARCHAR(50), + updated_by VARCHAR(50) +); +``` + +#### flow_data_mapping (플로우 데이터 매핑) + +```sql +CREATE TABLE flow_data_mapping ( + mapping_id SERIAL PRIMARY KEY, + flow_id INTEGER REFERENCES flow_definition(flow_id) ON DELETE CASCADE, + source_type VARCHAR(20), -- table, api, flow + source_identifier VARCHAR(200), -- 테이블명 또는 API 엔드포인트 + source_field VARCHAR(100), + target_type VARCHAR(20), + target_identifier VARCHAR(200), + target_field VARCHAR(100), + transformation_rule TEXT, -- 변환 규칙 (JavaScript 표현식) + company_code VARCHAR(20), + created_date TIMESTAMPTZ DEFAULT NOW() +); +``` + +#### flow_data_status (플로우 데이터 상태) + +```sql +CREATE TABLE flow_data_status ( + status_id SERIAL PRIMARY KEY, + flow_id INTEGER REFERENCES flow_definition(flow_id), + execution_id VARCHAR(100), + source_table VARCHAR(100), + source_record_id VARCHAR(500), + target_table VARCHAR(100), + target_record_id VARCHAR(500), + status VARCHAR(20), -- pending, processing, completed, failed + error_message TEXT, + processed_at TIMESTAMPTZ, + company_code VARCHAR(20), + created_date TIMESTAMPTZ DEFAULT NOW() +); +``` + +### 8.3 외부 연동 + +#### external_db_connections (외부 DB 연결) + +```sql +CREATE TABLE external_db_connections ( + id SERIAL PRIMARY KEY, + connection_name VARCHAR(200) NOT NULL, + connection_code VARCHAR(100) NOT NULL, + db_type VARCHAR(50) NOT NULL, -- postgresql, mysql, mssql, oracle + host VARCHAR(255) NOT NULL, + port INTEGER NOT NULL, + database_name VARCHAR(100) NOT NULL, + username VARCHAR(100), + password_encrypted TEXT, + ssl_enabled BOOLEAN DEFAULT false, + connection_options JSONB, + is_active VARCHAR(1) DEFAULT 'Y', + company_code VARCHAR(20), + created_date TIMESTAMPTZ DEFAULT NOW(), + updated_date TIMESTAMPTZ DEFAULT NOW(), + created_by VARCHAR(50), + + UNIQUE(connection_code, company_code) +); +``` + +#### external_rest_api_connections (외부 REST API 연결) + +```sql +CREATE TABLE external_rest_api_connections ( + id SERIAL PRIMARY KEY, + connection_name VARCHAR(200) NOT NULL, + connection_code VARCHAR(100) NOT NULL, + base_url VARCHAR(500) NOT NULL, + auth_type VARCHAR(50), -- none, basic, bearer, api_key + auth_config JSONB, + default_headers JSONB, + timeout_seconds INTEGER DEFAULT 30, + retry_count INTEGER DEFAULT 0, + is_active VARCHAR(1) DEFAULT 'Y', + company_code VARCHAR(20), + created_date TIMESTAMPTZ DEFAULT NOW(), + updated_date TIMESTAMPTZ DEFAULT NOW(), + + UNIQUE(connection_code, company_code) +); +``` + +#### external_call_configs (외부 호출 설정) + +```sql +CREATE TABLE external_call_configs ( + id SERIAL PRIMARY KEY, + config_name VARCHAR(200) NOT NULL, + config_code VARCHAR(100) NOT NULL, + connection_id INTEGER, -- external_rest_api_connections 참조 + http_method VARCHAR(10), -- GET, POST, PUT, DELETE + endpoint_path VARCHAR(500), + request_mapping JSONB, -- 요청 데이터 매핑 + response_mapping JSONB, -- 응답 데이터 매핑 + error_handling JSONB, + is_active VARCHAR(1) DEFAULT 'Y', + company_code VARCHAR(20), + created_date TIMESTAMPTZ DEFAULT NOW(), + updated_date TIMESTAMPTZ DEFAULT NOW() +); +``` + +### 8.4 배치 작업 + +#### batch_jobs (배치 작업 정의) + +```sql +CREATE TABLE batch_jobs ( + job_id SERIAL PRIMARY KEY, + job_name VARCHAR(200) NOT NULL, + job_code VARCHAR(100) NOT NULL, + job_type VARCHAR(50), -- data_collection, aggregation, sync 등 + source_type VARCHAR(50), -- database, api, file + source_config JSONB, + target_config JSONB, + schedule_config JSONB, + is_active VARCHAR(1) DEFAULT 'Y', + company_code VARCHAR(20), + created_date TIMESTAMPTZ DEFAULT NOW(), + updated_date TIMESTAMPTZ DEFAULT NOW(), + + UNIQUE(job_code, company_code) +); +``` + +#### batch_execution_logs (배치 실행 로그) + +```sql +CREATE TABLE batch_execution_logs ( + execution_id SERIAL PRIMARY KEY, + job_id INTEGER REFERENCES batch_jobs(job_id), + execution_status VARCHAR(20), -- running, completed, failed + start_time TIMESTAMPTZ, + end_time TIMESTAMPTZ, + records_processed INTEGER, + records_failed INTEGER, + error_message TEXT, + execution_details JSONB, + company_code VARCHAR(20), + created_date TIMESTAMPTZ DEFAULT NOW() +); +``` + +--- + +## 9. 인덱스 전략 + +### 9.1 필수 인덱스 + +**모든 테이블에 적용:** + +```sql +-- company_code 인덱스 (멀티테넌시 성능) +CREATE INDEX idx_{table_name}_company_code ON {table_name}(company_code); + +-- 복합 인덱스 (company_code + 주요 검색 컬럼) +CREATE INDEX idx_{table_name}_company_{column} +ON {table_name}(company_code, {column}); +``` + +### 9.2 화면 관련 인덱스 + +```sql +-- screen_definitions +CREATE INDEX idx_screen_definitions_company_code ON screen_definitions(company_code); +CREATE INDEX idx_screen_definitions_table_name ON screen_definitions(table_name); +CREATE INDEX idx_screen_definitions_is_active ON screen_definitions(is_active); +CREATE UNIQUE INDEX idx_screen_definitions_code_company +ON screen_definitions(screen_code, company_code); + +-- screen_groups +CREATE INDEX idx_screen_groups_company_code ON screen_groups(company_code); +CREATE INDEX idx_screen_groups_parent_id ON screen_groups(parent_group_id); +CREATE INDEX idx_screen_groups_hierarchy_path ON screen_groups(hierarchy_path); + +-- screen_field_joins +CREATE INDEX idx_screen_field_joins_screen_id ON screen_field_joins(screen_id); +CREATE INDEX idx_screen_field_joins_save_table ON screen_field_joins(save_table); +CREATE INDEX idx_screen_field_joins_join_table ON screen_field_joins(join_table); +``` + +### 9.3 메타데이터 인덱스 + +```sql +-- table_type_columns +CREATE UNIQUE INDEX idx_table_type_columns_unique +ON table_type_columns(table_name, column_name, company_code); + +-- column_labels +CREATE INDEX idx_column_labels_table ON column_labels(table_name); +``` + +### 9.4 비즈니스 테이블 인덱스 예시 + +```sql +-- sales_order_mng +CREATE INDEX idx_sales_order_company_code ON sales_order_mng(company_code); +CREATE INDEX idx_sales_order_customer ON sales_order_mng(customer_code); +CREATE INDEX idx_sales_order_date ON sales_order_mng(order_date DESC); +CREATE INDEX idx_sales_order_status ON sales_order_mng(order_status); + +-- work_orders +CREATE INDEX idx_work_orders_company_code ON work_orders(company_code); +CREATE INDEX idx_work_orders_wo_number ON work_orders(wo_number); +CREATE INDEX idx_work_orders_start_date ON work_orders(start_date); +CREATE INDEX idx_work_orders_product_code ON work_orders(product_code); +``` + +### 9.5 플로우 관련 인덱스 + +```sql +-- flow_definition +CREATE UNIQUE INDEX idx_flow_definition_code_company +ON flow_definition(flow_code, company_code); + +-- flow_step +CREATE INDEX idx_flow_step_flow_id ON flow_step(flow_id); +CREATE INDEX idx_flow_step_order ON flow_step(flow_id, step_order); + +-- flow_data_status +CREATE INDEX idx_flow_data_status_flow_execution +ON flow_data_status(flow_id, execution_id); +CREATE INDEX idx_flow_data_status_source +ON flow_data_status(source_table, source_record_id); +``` + +--- + +## 10. 동적 테이블 생성 패턴 + +WACE ERP의 핵심 기능 중 하나는 **런타임에 테이블을 동적으로 생성**할 수 있다는 것입니다. + +### 10.1 표준 컬럼 구조 + +**모든 동적 생성 테이블의 기본 컬럼:** + +```sql +CREATE TABLE {dynamic_table_name} ( + -- 시스템 기본 컬럼 (자동 포함) + id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + created_date TIMESTAMP DEFAULT NOW(), + updated_date TIMESTAMP DEFAULT NOW(), + writer VARCHAR(500), + company_code VARCHAR(500), + + -- 사용자 정의 컬럼 (모두 VARCHAR(500)) + {user_column_1} VARCHAR(500), + {user_column_2} VARCHAR(500), + ... +); + +-- 필수 인덱스 +CREATE INDEX idx_{table_name}_company_code ON {table_name}(company_code); +``` + +### 10.2 메타데이터 등록 프로세스 + +동적 테이블 생성 시 반드시 수행해야 하는 작업: + +#### 1단계: 테이블 생성 + +```sql +CREATE TABLE {table_name} ( + -- 위의 표준 구조 참조 +); +``` + +#### 2단계: table_labels 등록 + +```sql +INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date) +VALUES ('{table_name}', '{한글명}', '{설명}', NOW(), NOW()) +ON CONFLICT (table_name) +DO UPDATE SET + table_label = EXCLUDED.table_label, + description = EXCLUDED.description, + updated_date = NOW(); +``` + +#### 3단계: table_type_columns 등록 + +```sql +-- 기본 컬럼 등록 (display_order: -5 ~ -1) +INSERT INTO table_type_columns ( + table_name, column_name, company_code, input_type, detail_settings, + is_nullable, display_order, created_date, updated_date +) VALUES + ('{table_name}', 'id', '{company_code}', 'text', '{}', 'Y', -5, NOW(), NOW()), + ('{table_name}', 'created_date', '{company_code}', 'date', '{}', 'Y', -4, NOW(), NOW()), + ('{table_name}', 'updated_date', '{company_code}', 'date', '{}', 'Y', -3, NOW(), NOW()), + ('{table_name}', 'writer', '{company_code}', 'text', '{}', 'Y', -2, NOW(), NOW()), + ('{table_name}', 'company_code', '{company_code}', 'text', '{}', 'Y', -1, NOW(), NOW()) +ON CONFLICT (table_name, column_name, company_code) +DO UPDATE SET + input_type = EXCLUDED.input_type, + display_order = EXCLUDED.display_order, + updated_date = NOW(); + +-- 사용자 정의 컬럼 등록 (display_order: 0부터 시작) +INSERT INTO table_type_columns ( + table_name, column_name, company_code, input_type, detail_settings, + is_nullable, display_order, created_date, updated_date +) VALUES + ('{table_name}', '{column_1}', '{company_code}', 'text', '{}', 'Y', 0, NOW(), NOW()), + ('{table_name}', '{column_2}', '{company_code}', 'number', '{}', 'Y', 1, NOW(), NOW()), + ... +``` + +#### 4단계: column_labels 등록 (레거시 호환용) + +```sql +INSERT INTO column_labels ( + table_name, column_name, column_label, input_type, detail_settings, + description, display_order, is_visible, created_date, updated_date +) VALUES + ('{table_name}', 'id', 'ID', 'text', '{}', '기본키', -5, true, NOW(), NOW()), + ... +ON CONFLICT (table_name, column_name) +DO UPDATE SET + column_label = EXCLUDED.column_label, + input_type = EXCLUDED.input_type, + updated_date = NOW(); +``` + +### 10.3 마이그레이션 예시 + +**050_create_work_orders_table.sql:** + +```sql +-- ============================================ +-- 1. 테이블 생성 +-- ============================================ +CREATE TABLE work_orders ( + id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + created_date TIMESTAMP DEFAULT NOW(), + updated_date TIMESTAMP DEFAULT NOW(), + writer VARCHAR(500), + company_code VARCHAR(500), + + wo_number VARCHAR(500), + product_code VARCHAR(500), + product_name VARCHAR(500), + ... +); + +CREATE INDEX idx_work_orders_company_code ON work_orders(company_code); +CREATE INDEX idx_work_orders_wo_number ON work_orders(wo_number); + +-- ============================================ +-- 2. table_labels 등록 +-- ============================================ +INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date) +VALUES ('work_orders', '작업지시', '작업지시 관리 테이블', NOW(), NOW()) +ON CONFLICT (table_name) DO UPDATE SET ...; + +-- ============================================ +-- 3. table_type_columns 등록 +-- ============================================ +INSERT INTO table_type_columns (...) VALUES (...); + +-- ============================================ +-- 4. column_labels 등록 +-- ============================================ +INSERT INTO column_labels (...) VALUES (...); + +-- ============================================ +-- 5. 코멘트 추가 +-- ============================================ +COMMENT ON TABLE work_orders IS '작업지시 관리 테이블'; +COMMENT ON COLUMN work_orders.wo_number IS '작업지시번호 (WO-YYYYMMDD-XXX)'; +``` + +--- + +## 11. 마이그레이션 히스토리 + +### 11.1 마이그레이션 파일 목록 + +``` +db/migrations/ +├── 037_add_parent_group_to_screen_groups.sql -- 화면 그룹 계층 구조 +├── 050_create_work_orders_table.sql -- 작업지시 테이블 +├── 051_insert_work_order_screen_definition.sql -- 작업지시 화면 정의 +├── 052_insert_work_order_screen_layout.sql -- 작업지시 화면 레이아웃 +├── 054_create_screen_management_enhancement.sql -- 화면 관리 기능 확장 +└── plm_schema_20260120.sql -- 전체 스키마 덤프 +``` + +### 11.2 주요 마이그레이션 내용 + +#### 037: 화면 그룹 계층 구조 + +- `screen_groups`에 계층 구조 지원 추가 +- `parent_group_id`, `group_level`, `hierarchy_path` 컬럼 추가 +- 대/중/소 분류 지원 + +#### 050~052: 작업지시 시스템 + +- `work_orders` 테이블 생성 +- 메타데이터 등록 (table_labels, table_type_columns, column_labels) +- 화면 정의 및 레이아웃 생성 + +#### 054: 화면 관리 기능 확장 + +- `screen_groups`: 화면 그룹 +- `screen_group_screens`: 화면-그룹 연결 +- `screen_field_joins`: 화면 필드 조인 설정 +- `screen_data_flows`: 화면 간 데이터 흐름 +- `screen_table_relations`: 화면-테이블 관계 + +### 11.3 마이그레이션 실행 가이드 + +**마이그레이션 문서:** +- `RUN_027_MIGRATION.md` +- `RUN_043_MIGRATION.md` +- `RUN_044_MIGRATION.md` +- `RUN_046_MIGRATION.md` +- `RUN_063_064_MIGRATION.md` +- `RUN_065_MIGRATION.md` +- `RUN_078_MIGRATION.md` + +--- + +## 12. 데이터베이스 함수 및 트리거 + +### 12.1 주요 함수 + +#### 화면 관련 + +```sql +-- 화면 생성 시 메뉴 자동 생성 +CREATE FUNCTION auto_create_menu_for_screen() RETURNS TRIGGER; + +-- 화면 삭제 시 메뉴 비활성화 +CREATE FUNCTION auto_deactivate_menu_for_screen() RETURNS TRIGGER; +``` + +#### 통계 집계 + +```sql +-- 일일 운송 통계 집계 +CREATE FUNCTION aggregate_daily_transport_statistics(target_date DATE) RETURNS INTEGER; +``` + +#### 거리 계산 + +```sql +-- Haversine 거리 계산 +CREATE FUNCTION calculate_distance(lat1 NUMERIC, lng1 NUMERIC, lat2 NUMERIC, lng2 NUMERIC) +RETURNS NUMERIC; + +-- 이전 위치로부터 거리 계산 +CREATE FUNCTION calculate_distance_from_prev() RETURNS TRIGGER; +``` + +#### 비즈니스 로직 + +```sql +-- 수주 잔량 계산 +CREATE FUNCTION calculate_order_balance() RETURNS TRIGGER; + +-- 세금계산서 합계 계산 +CREATE FUNCTION calculate_tax_invoice_total() RETURNS TRIGGER; + +-- 영업에서 프로젝트 자동 생성 +CREATE FUNCTION auto_create_project_from_sales(p_sales_no VARCHAR) RETURNS VARCHAR; +``` + +#### 로그 관리 + +```sql +-- 테이블 변경 로그 트리거 함수 +CREATE FUNCTION carrier_contract_mng_log_trigger_func() RETURNS TRIGGER; +CREATE FUNCTION carrier_mng_log_trigger_func() RETURNS TRIGGER; +-- ... 각 테이블별 로그 트리거 함수 +``` + +### 12.2 주요 트리거 + +```sql +-- 화면 생성 시 메뉴 자동 생성 +CREATE TRIGGER trg_auto_create_menu_for_screen +AFTER INSERT ON screen_definitions +FOR EACH ROW +EXECUTE FUNCTION auto_create_menu_for_screen(); + +-- 화면 삭제 시 메뉴 비활성화 +CREATE TRIGGER trg_auto_deactivate_menu_for_screen +AFTER UPDATE ON screen_definitions +FOR EACH ROW +EXECUTE FUNCTION auto_deactivate_menu_for_screen(); + +-- 차량 위치 이력 거리 계산 +CREATE TRIGGER trg_calculate_distance_from_prev +BEFORE INSERT ON vehicle_location_history +FOR EACH ROW +EXECUTE FUNCTION calculate_distance_from_prev(); +``` + +--- + +## 13. 뷰 (Views) + +### 13.1 시스템 뷰 + +```sql +-- 권한 그룹 요약 +CREATE VIEW v_authority_group_summary AS +SELECT + am.objid, + am.auth_name, + am.auth_code, + am.company_code, + (SELECT COUNT(*) FROM authority_sub_user WHERE master_objid = am.objid) AS member_count, + (SELECT COUNT(*) FROM rel_menu_auth WHERE auth_objid = am.objid) AS menu_count +FROM authority_master am; +``` + +--- + +## 14. 데이터베이스 보안 및 암호화 + +### 14.1 암호화 컬럼 + +```sql +-- external_db_connections +password_encrypted TEXT -- AES 암호화된 비밀번호 + +-- external_rest_api_connections +auth_config JSONB -- 암호화된 인증 정보 +``` + +### 14.2 접근 제어 + +- PostgreSQL 롤 기반 접근 제어 +- 회사별 데이터 격리 (company_code) +- 사용자별 권한 관리 (authority_master, rel_menu_auth) + +--- + +## 15. 성능 최적화 전략 + +### 15.1 인덱스 최적화 + +- **company_code 인덱스**: 모든 테이블에 필수 +- **복합 인덱스**: 자주 함께 조회되는 컬럼 조합 +- **부분 인덱스**: 특정 조건의 데이터만 인덱싱 + +### 15.2 쿼리 최적화 + +- **서브쿼리 최소화**: JOIN으로 대체 +- **EXPLAIN ANALYZE** 활용 +- **인덱스 힌트** 사용 + +### 15.3 캐싱 전략 + +- **참조 데이터 캐싱**: referenceCacheService.ts +- **Redis 캐싱**: 자주 조회되는 메타데이터 + +### 15.4 파티셔닝 + +- 대용량 이력 테이블 파티셔닝 고려 +- 날짜 기반 파티셔닝 (vehicle_location_history 등) + +--- + +## 16. 백업 및 복구 + +### 16.1 백업 전략 + +```bash +# 전체 스키마 백업 +pg_dump -h host -U user -d database > plm_schema_YYYYMMDD.sql + +# 데이터 포함 백업 +pg_dump -h host -U user -d database --data-only > data_YYYYMMDD.sql + +# 특정 테이블 백업 +pg_dump -h host -U user -d database -t table_name > table_backup.sql +``` + +### 16.2 마이그레이션 롤백 + +- DDL 작업 전 백업 필수 +- 트랜잭션 기반 마이그레이션 +- 롤백 스크립트 준비 + +--- + +## 17. 모니터링 및 로깅 + +### 17.1 시스템 로그 테이블 + +``` +login_access_log -- 로그인 접근 로그 +ddl_execution_log -- DDL 실행 로그 +batch_execution_logs -- 배치 실행 로그 +flow_audit_log -- 플로우 감사 로그 +flow_integration_log -- 플로우 통합 로그 +external_call_logs -- 외부 호출 로그 +mail_log -- 메일 발송 로그 +file_down_log -- 파일 다운로드 로그 +``` + +### 17.2 변경 이력 테이블 + +**패턴:** `{원본테이블}_log` 또는 `{원본테이블}_history` + +``` +user_info_history +dept_info_history +authority_master_history +comm_code_history +carrier_mng_log +supplier_mng_log +equipment_mng_log +... +``` + +--- + +## 18. 결론 + +### 18.1 핵심 아키텍처 특징 + +1. **멀티테넌시**: company_code로 완벽한 데이터 격리 +2. **메타데이터 드리븐**: 동적 화면/테이블 생성 +3. **Low-Code 플랫폼**: 코드 없이 화면 구축 +4. **플로우 기반**: 시각적 데이터 흐름 설계 +5. **외부 연동**: DB/API 통합 지원 +6. **이력 관리**: 완벽한 변경 이력 추적 + +### 18.2 확장성 + +- **수평 확장**: 멀티테넌시로 무한한 회사 추가 가능 +- **수직 확장**: 동적 테이블/컬럼 추가 +- **기능 확장**: 플로우/배치 작업으로 비즈니스 로직 추가 + +### 18.3 유지보수성 + +- **표준화된 구조**: 모든 테이블이 동일한 패턴 +- **자동화**: 트리거/함수로 반복 작업 자동화 +- **문서화**: 메타데이터 테이블 자체가 문서 + +--- + +## 부록 A: 백엔드 서비스 매핑 + +### 주요 서비스와 테이블 매핑 + +```typescript +// backend-node/src/services/ + +screenManagementService.ts → screen_definitions, screen_layouts +tableManagementService.ts → table_labels, table_type_columns, column_labels +menuService.ts → menu_info, menu_screen_groups +categoryTreeService.ts → table_column_category_values +flowDefinitionService.ts → flow_definition, flow_step +flowExecutionService.ts → flow_data_status, flow_audit_log +dataflowService.ts → dataflow_diagrams, screen_data_flows +externalDbConnectionService.ts → external_db_connections +externalRestApiConnectionService.ts → external_rest_api_connections +batchService.ts → batch_jobs, batch_execution_logs +authService.ts → user_info, auth_tokens +roleService.ts → authority_master, rel_menu_auth +``` + +--- + +## 부록 B: SQL 쿼리 예시 + +### 멀티테넌시 표준 쿼리 + +```sql +-- ✅ 단일 테이블 조회 +SELECT * FROM sales_order_mng +WHERE company_code = $1 + AND company_code != '*' + AND order_date >= $2 +ORDER BY order_date DESC +LIMIT 100; + +-- ✅ JOIN 쿼리 +SELECT + so.order_no, + so.order_date, + c.customer_name, + p.product_name, + so.quantity, + so.unit_price +FROM sales_order_mng so +LEFT JOIN customer_mng c + ON so.customer_code = c.customer_code + AND so.company_code = c.company_code +LEFT JOIN product_mng p + ON so.product_code = p.product_code + AND so.company_code = p.company_code +WHERE so.company_code = $1 + AND so.company_code != '*' + AND so.order_date BETWEEN $2 AND $3; + +-- ✅ 집계 쿼리 +SELECT + customer_code, + COUNT(*) as order_count, + SUM(total_amount) as total_sales +FROM sales_order_mng +WHERE company_code = $1 + AND company_code != '*' + AND order_date >= DATE_TRUNC('month', CURRENT_DATE) +GROUP BY customer_code +HAVING COUNT(*) >= 5 +ORDER BY total_sales DESC; +``` + +--- + +## 부록 C: 참고 문서 + +``` +docs/ +├── DDD1542/ +│ ├── DB_STRUCTURE_DIAGRAM.md -- DB 구조 다이어그램 +│ ├── DB_INEFFICIENCY_ANALYSIS.md -- DB 비효율성 분석 +│ ├── COMPONENT_URL_SYSTEM_IMPLEMENTATION.md +│ ├── V2_마이그레이션_학습노트_DDD1542.md +│ └── 본서버_개발서버_마이그레이션_가이드.md +├── backend-architecture-analysis.md -- 백엔드 아키텍처 분석 +└── screen-implementation-guide/ -- 화면 구현 가이드 +``` + +--- + +**문서 작성자**: Cursor AI (DB Specialist Agent) +**문서 버전**: 1.0 +**마지막 업데이트**: 2026-01-20 +**스키마 버전**: plm_schema_20260120.sql + +--- diff --git a/docs/DB_WORKFLOW_ANALYSIS.md b/docs/DB_WORKFLOW_ANALYSIS.md new file mode 100644 index 00000000..d5cd069b --- /dev/null +++ b/docs/DB_WORKFLOW_ANALYSIS.md @@ -0,0 +1,1728 @@ +# WACE ERP 데이터베이스 워크플로우 분석 + +> 📅 작성일: 2026-02-06 +> 🎯 목적: 비즈니스 워크플로우 중심의 DB 구조 분석 +> 📊 DB 엔진: PostgreSQL 16.8 +> 📝 기반 스키마: plm_schema_20260120.sql + +--- + +## 📋 Executive Summary + +WACE ERP 시스템은 **멀티테넌트 SaaS 아키텍처**를 기반으로 한 제조업 특화 ERP입니다. +- **총 테이블 수**: 337개 +- **핵심 아키텍처**: Multi-tenancy (company_code 기반 데이터 격리) +- **특징**: 메타데이터 드리븐, 동적 스키마, 플로우 기반 통합 + +--- + +## 🏗️ 1. 데이터베이스 아키텍처 개요 + +### 1.1 계층 구조 + +``` +┌─────────────────────────────────────────────────────────┐ +│ Application Layer (React) │ +└─────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────┐ +│ Backend Layer (Node.js + TypeScript) │ +│ - API Routes │ +│ - Business Logic Services │ +│ - Raw SQL Query Execution │ +└─────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────┐ +│ Database Layer (PostgreSQL) │ +│ │ +│ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ System Core │ │ Metadata │ │ +│ │ - user_info │ │ - table_labels │ │ +│ │ - company_mng │ │ - screen_def │ │ +│ │ - menu_info │ │ - flow_def │ │ +│ └──────────────────┘ └──────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────┐ │ +│ │ Business Domain Tables │ │ +│ │ - Sales (30+) - Purchase (25+) │ │ +│ │ - Stock (20+) - Production (25+) │ │ +│ │ - Quality (15+) - Logistics (20+) │ │ +│ │ - PLM (30+) - Accounting (20+) │ │ +│ └─────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +### 1.2 멀티테넌시 핵심 원칙 + +**ABSOLUTE MUST: 모든 테이블에 company_code** + +```sql +-- ✅ 표준 테이블 구조 +CREATE TABLE {table_name} ( + id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + company_code VARCHAR(20) NOT NULL, -- 필수! + created_date TIMESTAMP DEFAULT NOW(), + updated_date TIMESTAMP DEFAULT NOW(), + writer VARCHAR(500), + -- ... 비즈니스 컬럼들 +); + +-- 필수 인덱스 +CREATE INDEX idx_{table_name}_company_code +ON {table_name}(company_code); +``` + +**company_code = "*" 의미** +- ❌ 잘못된 이해: 모든 회사 공통 데이터 +- ✅ 올바른 이해: 슈퍼 관리자 전용 데이터 +- 일반 회사 쿼리: `WHERE company_code = $1 AND company_code != '*'` + +--- + +## 🎯 2. 핵심 시스템 테이블 (System Core) + +### 2.1 사용자 및 인증 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `user_info` | 사용자 정보 | user_id, user_name, email, password_hash, company_code | +| `user_info_history` | 사용자 변경 이력 | history_id, user_id, change_type, changed_at | +| `auth_tokens` | 인증 토큰 | token_id, user_id, token, expires_at | +| `login_access_log` | 로그인 이력 | log_id, user_id, ip_address, login_at | +| `user_dept` | 사용자-부서 매핑 (겸직 지원) | user_id, dept_code, is_primary | +| `user_dept_sub` | 겸직 부서 정보 | user_id, sub_dept_code | + +**워크플로우:** +``` +로그인 → auth_tokens 생성 → login_access_log 기록 +회사별 데이터 격리 → company_code 필터링 +``` + +### 2.2 권한 관리 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `authority_master` | 권한 그룹 마스터 | objid, auth_name, auth_code, company_code | +| `authority_master_history` | 권한 그룹 이력 | objid, parent_objid, history_type | +| `authority_sub_user` | 권한 그룹 멤버 | objid, master_objid, user_id | +| `rel_menu_auth` | 메뉴별 권한 CRUD | objid, menu_objid, auth_objid, create_auth, read_auth, update_auth, delete_auth | + +**권한 체계:** +``` +사용자 → authority_sub_user → authority_master → rel_menu_auth → 메뉴별 CRUD 권한 +``` + +### 2.3 회사 및 부서 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `company_mng` | 회사 정보 | company_code (PK), company_name, business_registration_number | +| `company_code_sequence` | 회사 코드 시퀀스 | company_code, next_sequence | +| `dept_info` | 부서 정보 | dept_code, dept_name, parent_dept_code, company_code | +| `dept_info_history` | 부서 변경 이력 | dept_code, change_type, changed_at | + +**계층 구조:** +``` +company_mng (회사) + └─ dept_info (부서 - 계층 구조) + └─ user_dept (사용자 배정) +``` + +### 2.4 메뉴 시스템 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `menu_info` | 메뉴 정보 | objid, menu_type, parent_obj_id, menu_name_kor, menu_url, screen_code, company_code, source_menu_objid | +| `menu_screen_groups` | 통합 메뉴/화면 그룹 | group_id, group_name, parent_group_id, company_code | +| `menu_screen_group_items` | 그룹-화면 연결 | group_id, screen_code | +| `screen_menu_assignments` | 화면-메뉴 할당 | screen_code, menu_id | + +**메뉴 타입:** +- `menu_type = 0`: 일반 메뉴 +- `menu_type = 1`: 시스템 관리 메뉴 +- `menu_type = 2`: 동적 생성 메뉴 (screen_definitions에서 자동 생성) + +**메뉴 복사 메커니즘:** +``` +원본 메뉴 (회사A) → 복사 → 새 메뉴 (회사B) +source_menu_objid: 원본 메뉴의 objid 추적 +재복사 시: source_menu_objid로 기존 복사본 찾아서 덮어쓰기 +``` + +--- + +## 🗂️ 3. 메타데이터 시스템 (Metadata Layer) + +### 3.1 테이블 메타데이터 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `table_labels` | 테이블 논리명 | table_name (PK), table_label, description | +| `table_type_columns` | 컬럼 타입 정의 (회사별) | table_name, column_name, company_code, input_type, detail_settings, display_order | +| `column_labels` | 컬럼 논리명 (레거시) | table_name, column_name, column_label, input_type | +| `table_relationships` | 테이블 관계 정의 | parent_table, child_table, join_condition | +| `table_log_config` | 테이블 로그 설정 | table_name, log_enabled, log_table_name | + +**메타데이터 구조:** +``` +table_labels (테이블 논리명) + └─ table_type_columns (컬럼 타입 정의 - 회사별) + └─ category_column_mapping (카테고리 컬럼 매핑) + └─ table_column_category_values (카테고리 값) +``` + +**동적 테이블 생성 프로세스:** +1. `CREATE TABLE` 실행 +2. `table_labels` 등록 +3. `table_type_columns` 등록 (회사별) +4. `column_labels` 등록 (레거시 호환) +5. `ddl_execution_log`에 DDL 실행 이력 기록 + +### 3.2 화면 메타데이터 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `screen_definitions` | 화면 정의 | screen_code (PK), screen_name, table_name, screen_type, company_code | +| `screen_layouts` | 화면 레이아웃 | screen_code, layout_config (JSONB) | +| `screen_templates` | 화면 템플릿 | template_id, template_name, template_config | +| `screen_widgets` | 화면 위젯 | widget_id, screen_code, widget_type, widget_config | +| `screen_groups` | 화면 그룹 | group_id, group_name, parent_group_id | +| `screen_group_screens` | 화면-그룹 연결 | group_id, screen_code | + +**화면 생성 워크플로우:** +``` +screen_definitions 생성 + → 트리거 발동 → menu_info 자동 생성 (menu_type=2) + → screen_layouts 레이아웃 정의 + → screen_widgets 위젯 배치 + → screen_table_relations 테이블 관계 설정 +``` + +### 3.3 화면 고급 기능 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `screen_embedding` | 화면 임베딩 | parent_screen, child_screen, embed_config | +| `screen_split_panel` | 분할 패널 | screen_code, left_screen, right_screen, split_ratio | +| `screen_data_transfer` | 화면 간 데이터 전달 | source_screen, target_screen, mapping_config | +| `screen_data_flows` | 화면 간 데이터 흐름 | flow_id, source_screen, target_screen, flow_type | +| `screen_field_joins` | 화면 필드 조인 | source_table, target_table, join_condition | +| `screen_table_relations` | 화면-테이블 관계 | screen_code, table_name, relation_type | + +**화면 임베딩 패턴:** +``` +마스터 화면 (sales_order) + ├─ 좌측 패널: 수주 목록 + └─ 우측 패널: 상세 정보 (임베딩) + ├─ 수주 기본 정보 + ├─ 수주 품목 (임베딩) + └─ 배송 정보 (임베딩) +``` + +### 3.4 UI 컴포넌트 표준 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `component_standards` | UI 컴포넌트 표준 | component_type, standard_props (JSONB) | +| `button_action_standards` | 버튼 액션 기준 | action_type, action_config (JSONB) | +| `web_type_standards` | 웹 타입 기준 | web_type, type_config (JSONB) | +| `grid_standards` | 격자 시스템 기준 | grid_type, grid_config (JSONB) | +| `layout_standards` | 레이아웃 표준 | layout_type, layout_config (JSONB) | +| `layout_instances` | 레이아웃 인스턴스 | instance_id, layout_type, instance_config | +| `style_templates` | 스타일 템플릿 | template_id, template_name, style_config (JSONB) | + +--- + +## 🔄 4. 플로우 및 데이터 통합 시스템 + +### 4.1 플로우 정의 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `flow_definition` | 플로우 정의 | id, name, table_name, db_source_type, company_code | +| `flow_step` | 플로우 단계 | step_id, flow_id, step_name, step_type, step_order, action_config (JSONB) | +| `flow_step_connection` | 플로우 단계 연결 | connection_id, source_step_id, target_step_id, condition | +| `flow_data_mapping` | 플로우 데이터 매핑 | mapping_id, flow_id, source_table, target_table, mapping_config | + +**플로우 타입:** +- 승인 플로우: 결재 라인 정의 +- 상태 플로우: 데이터 상태 전환 +- 데이터 플로우: 테이블 간 데이터 이동 +- 통합 플로우: 외부 시스템 연동 + +### 4.2 플로우 실행 및 모니터링 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `flow_data_status` | 데이터 현재 상태 | data_id, flow_id, current_step_id, status | +| `flow_audit_log` | 플로우 상태 변경 이력 | log_id, flow_id, data_id, from_step, to_step, changed_at, changed_by | +| `flow_integration_log` | 플로우 외부 연동 로그 | log_id, flow_id, integration_type, request_data, response_data | + +**플로우 실행 예시: 수주 승인** +``` +수주 등록 (sales_order_mng) + → flow_data_status 생성 (status: 'PENDING') + → flow_step 1: 영업팀장 승인 + → flow_step 2: 재고 확인 + → flow_step 3: 생산계획 생성 + → flow_step 4: 최종 승인 + → flow_audit_log 각 단계 기록 +``` + +### 4.3 노드 기반 플로우 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `node_flows` | 노드 기반 플로우 | flow_id, flow_name, nodes (JSONB), edges (JSONB) | +| `dataflow_diagrams` | 데이터플로우 다이어그램 | diagram_id, diagram_name, diagram_data (JSONB) | +| `dataflow_external_calls` | 데이터플로우 외부 호출 | call_id, diagram_id, external_connection_id | + +**노드 타입:** +- Start/End Node +- Data Node (테이블 조회/저장) +- Logic Node (조건 분기, 계산) +- External Node (외부 API 호출) +- Transform Node (데이터 변환) + +--- + +## 🌐 5. 외부 연동 시스템 + +### 5.1 외부 데이터베이스 연결 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `external_db_connections` | 외부 DB 연결 정보 | connection_id, connection_name, db_type, host, port, database, username, password_encrypted | +| `external_db_connection` | 외부 DB 연결 (레거시) | connection_id, connection_config (JSONB) | +| `external_connection_permission` | 외부 연결 권한 | permission_id, connection_id, user_id, allowed_operations | +| `flow_external_db_connection` | 플로우 전용 외부 DB 연결 | connection_id, flow_id, db_config (JSONB) | +| `flow_external_connection_permission` | 플로우 외부 연결 권한 | permission_id, flow_id, connection_id | + +**지원 DB 타입:** +- PostgreSQL +- MySQL +- MS SQL Server +- Oracle +- MongoDB (NoSQL) + +### 5.2 외부 REST API 연결 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `external_rest_api_connections` | 외부 REST API 연결 | connection_id, api_name, base_url, auth_type, auth_config (JSONB) | +| `external_call_configs` | 외부 호출 설정 | config_id, connection_id, endpoint, method, headers (JSONB) | +| `external_call_logs` | 외부 호출 로그 | log_id, config_id, request_data, response_data, status_code, executed_at | + +**인증 방식:** +- API Key +- Bearer Token +- OAuth 2.0 +- Basic Auth + +### 5.3 데이터 수집 배치 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `data_collection_configs` | 데이터 수집 설정 | config_id, source_type, source_config (JSONB), schedule | +| `data_collection_jobs` | 데이터 수집 작업 | job_id, config_id, job_status, started_at, completed_at | +| `data_collection_history` | 데이터 수집 이력 | history_id, job_id, collected_count, error_count | +| `collection_batch_management` | 수집 배치 관리 | batch_id, batch_name, batch_config (JSONB) | +| `collection_batch_executions` | 배치 실행 이력 | execution_id, batch_id, execution_status, executed_at | + +--- + +## 📊 6. 비즈니스 도메인 테이블 (Business Domain) + +### 6.1 영업/수주 (Sales & Orders) - 30+ 테이블 + +#### 핵심 테이블 + +| 테이블 | 역할 | 주요 참조 | +|--------|------|-----------| +| `sales_order_mng` | 수주 관리 | customer_mng, product_mng | +| `sales_order_detail` | 수주 상세 | sales_order_mng | +| `sales_order_detail_log` | 수주 상세 이력 | sales_order_detail | +| `sales_request_master` | 구매요청서 마스터 | customer_mng | +| `sales_request_part` | 구매요청서 품목 | sales_request_master, part_mng | +| `estimate_mgmt` | 견적 관리 | customer_mng, product_mng | +| `contract_mgmt` | 계약 관리 | customer_mng | +| `contract_mgmt_option` | 계약 옵션 | contract_mgmt | + +#### 영업 지원 테이블 + +| 테이블 | 역할 | +|--------|------| +| `sales_bom_report` | 영업 BOM 보고서 | +| `sales_bom_part_qty` | 영업 BOM 수량 | +| `sales_bom_report_part` | 영업 BOM 품목 | +| `sales_long_delivery` | 장납기 부품 리스트 | +| `sales_long_delivery_input` | 장납기 자재 투입 이력 | +| `sales_long_delivery_predict` | 장납기 예측 | +| `sales_part_chg` | 설계 변경 리스트 | +| `sample_supply` | 샘플 공급 | + +#### 고객 관리 + +| 테이블 | 역할 | +|--------|------| +| `customer_mng` | 거래처 마스터 | +| `customer_item` | 거래처별 품번 관리 | +| `customer_item_alias` | 거래처별 품목 품번/품명 | +| `customer_item_mapping` | 거래처별 품목 매핑 | +| `customer_item_price` | 거래처별 품목 단가 이력 | +| `customer_service_mgmt` | 조치내역서 마스터 | +| `customer_service_part` | 조치내역서 사용 부품 | +| `customer_service_workingtime` | 조치내역서 작업 시간 | +| `counselingmgmt` | 상담 관리 | + +**워크플로우: 수주 프로세스** +``` +1. 견적 요청 (estimate_mgmt) +2. 견적 작성 및 발송 +3. 수주 등록 (sales_order_mng) +4. 수주 상세 입력 (sales_order_detail) +5. 계약 체결 (contract_mgmt) +6. 생산 계획 생성 (order_plan_mgmt) +7. 구매 요청 (sales_request_master) +``` + +### 6.2 구매/발주 (Purchase & Procurement) - 25+ 테이블 + +#### 핵심 테이블 + +| 테이블 | 역할 | 주요 참조 | +|--------|------|-----------| +| `purchase_order_master` | 발주 관리 마스터 | supplier_mng | +| `purchase_order_part` | 발주서 상세 목록 | purchase_order_master, part_mng | +| `purchase_order_multi` | 동시적용 프로젝트 발주 | purchase_order_master | +| `purchase_order_mng` | 발주 관리 | supplier_mng | +| `purchase_detail` | 구매 상세 | purchase_order | +| `purchase_order` | 구매 주문 | supplier_mng | + +#### 공급처 관리 + +| 테이블 | 역할 | +|--------|------| +| `supplier_mng` | 공급처 마스터 | +| `supplier_mng_log` | 공급처 변경 이력 | +| `supplier_item` | 공급처별 품목 정보 | +| `supplier_item_alias` | 공급처별 품목 품번/품명 | +| `supplier_item_mapping` | 공급처별 품목 매핑 | +| `supplier_item_price` | 공급처별 품목 단가 | +| `procurement_standard` | 구매 기준 정보 | + +#### 입고 관리 + +| 테이블 | 역할 | +|--------|------| +| `delivery_history` | 입고 관리 | +| `delivery_history_defect` | 입고 불량 품목 | +| `delivery_part_price` | 입고 품목 단가 | +| `receiving` | 입고 처리 | +| `receive_history` | 입고 이력 | +| `inbound_mng` | 입고 관리 | +| `check_report_mng` | 검수 관리 보고서 | + +**워크플로우: 구매 프로세스** +``` +1. 구매 요청 (sales_request_master) +2. 공급처 선정 (supplier_mng) +3. 발주서 작성 (purchase_order_master) +4. 발주서 상세 (purchase_order_part) +5. 입고 예정 (delivery_history) +6. 검수 (check_report_mng) +7. 입고 확정 (receiving) +8. 재고 반영 (inventory_stock) +``` + +### 6.3 재고/창고 (Inventory & Warehouse) - 20+ 테이블 + +#### 핵심 테이블 + +| 테이블 | 역할 | 주요 참조 | +|--------|------|-----------| +| `inventory_stock` | 재고 현황 | item_info, warehouse_location | +| `inventory_history` | 재고 이력 | inventory_stock | +| `warehouse_info` | 창고 정보 | - | +| `warehouse_location` | 창고 위치 | warehouse_info | +| `inbound_mng` | 입고 관리 | warehouse_location | +| `outbound_mng` | 출고 관리 | warehouse_location | +| `material_release` | 자재 출고 | - | + +#### 물류 관리 + +| 테이블 | 역할 | +|--------|------| +| `shipment_header` | 출하 헤더 | +| `shipment_detail` | 출하 상세 | +| `shipment_plan` | 출하 계획 | +| `shipment_instruction` | 출하 지시 | +| `shipment_instruction_item` | 출하 지시 품목 | +| `shipment_pallet` | 출하 파레트 | +| `delivery_destination` | 납품처 정보 | +| `delivery_route_mng` | 배송 경로 관리 | +| `delivery_route_mng_log` | 배송 경로 이력 | +| `delivery_status` | 배송 상태 | + +#### 디지털 트윈 (창고 레이아웃) + +| 테이블 | 역할 | +|--------|------| +| `digital_twin_layout` | 디지털 트윈 레이아웃 | +| `digital_twin_layout_template` | 레이아웃 템플릿 | +| `digital_twin_location_layout` | Location 배치 정보 | +| `digital_twin_zone_layout` | Zone 배치 정보 | +| `digital_twin_objects` | 디지털 트윈 객체 | +| `yard_layout` | 야드 레이아웃 | +| `yard_material_placement` | 야드 자재 배치 | + +**워크플로우: 재고 관리** +``` +1. 입고 (inbound_mng) + → inventory_stock 증가 + → inventory_history 기록 + +2. 출고 (outbound_mng) + → inventory_stock 감소 + → inventory_history 기록 + +3. 창고 위치 관리 + → warehouse_location + → digital_twin_location_layout (시각화) + +4. 재고 조사 + → inventory_stock 조회 + → 실사 vs 전산 비교 + → 차이 조정 +``` + +### 6.4 생산/작업 (Production & Work) - 25+ 테이블 + +#### 핵심 테이블 + +| 테이블 | 역할 | 주요 참조 | +|--------|------|-----------| +| `work_orders` | 작업지시 관리 | product_mng, sales_order_mng | +| `work_orders_detail` | 작업지시 상세 | work_orders | +| `work_instruction` | 작업지시 | - | +| `work_instruction_detail` | 작업지시 상세 | work_instruction | +| `work_instruction_log` | 작업지시 이력 | work_instruction | +| `work_instruction_detail_log` | 작업지시 상세 이력 | work_instruction_detail | +| `work_order` | 작업지시 (레거시) | - | +| `work_request` | 작업 요청 (워크플로우) | - | + +#### 생산 계획 + +| 테이블 | 역할 | +|--------|------| +| `order_plan_mgmt` | 생산 계획 관리 | +| `order_plan_result_error` | 생산 계획 오류 결과 | +| `production_task` | 생산 작업 | +| `production_record` | 생산 실적 | +| `production_issue` | 생산 이슈 | +| `facility_assembly_plan` | 설비 조립 계획 | + +#### 공정 관리 + +| 테이블 | 역할 | +|--------|------| +| `process_mng` | 공정 관리 | +| `process_equipment` | 공정 설비 | +| `item_routing_version` | 품목 라우팅 버전 | +| `item_routing_detail` | 품목 라우팅 상세 | +| `input_resource` | 투입 자원 | + +#### 설비 관리 + +| 테이블 | 역할 | +|--------|------| +| `equipment_mng` | 설비 관리 | +| `equipment_mng_log` | 설비 변경 이력 | +| `equipment_consumable` | 설비 소모품 | +| `equipment_consumable_log` | 소모품 이력 | +| `equipment_inspection_item` | 설비 점검 항목 | +| `equipment_inspection_item_log` | 점검 항목 이력 | +| `inspection_equipment_mng` | 검사 설비 관리 | +| `inspection_equipment_mng_log` | 검사 설비 이력 | + +**워크플로우: 생산 프로세스** +``` +1. 수주 확정 (sales_order_mng) +2. 생산 계획 생성 (order_plan_mgmt) +3. 자재 소요 계획 (MRP) +4. 작업지시 발행 (work_orders) +5. 자재 출고 (material_release) +6. 생산 실행 (production_record) +7. 품질 검사 (inspection_standard) +8. 완제품 입고 (inventory_stock) +``` + +### 6.5 품질/검사 (Quality & Inspection) - 15+ 테이블 + +| 테이블 | 역할 | 주요 참조 | +|--------|------|-----------| +| `inspection_standard` | 검사 기준 | - | +| `item_inspection_info` | 품목 검사 정보 | item_info | +| `defect_standard_mng` | 불량 기준 관리 | - | +| `defect_standard_mng_log` | 불량 기준 이력 | defect_standard_mng | +| `check_report_mng` | 검수 관리 보고서 | - | + +**품질 관리 워크플로우:** +``` +1. 입고 검사 (check_report_mng) +2. 공정 검사 (inspection_standard) +3. 최종 검사 (item_inspection_info) +4. 불량 처리 (defect_standard_mng) +5. 품질 데이터 분석 +``` + +### 6.6 물류/운송 (Logistics & Transport) - 20+ 테이블 + +#### 차량 관리 + +| 테이블 | 역할 | 주요 참조 | +|--------|------|-----------| +| `vehicles` | 차량 정보 | - | +| `drivers` | 운전자 정보 | - | +| `vehicle_locations` | 차량 현재 위치 | vehicles | +| `vehicle_location_history` | 차량 위치 이력 | vehicles | +| `transport_vehicle_locations` | 운행 관리 실시간 위치 | vehicles | + +#### 운송 관리 + +| 테이블 | 역할 | +|--------|------| +| `transport_logs` | 운행 이력 | +| `transport_statistics` | 일별 운행 통계 | +| `vehicle_trip_summary` | 차량 운행 요약 | +| `maintenance_schedules` | 차량 정비 일정 | + +#### 운송사 관리 + +| 테이블 | 역할 | +|--------|------| +| `carrier_mng` | 운송사 관리 | +| `carrier_mng_log` | 운송사 이력 | +| `carrier_contract_mng` | 운송사 계약 관리 | +| `carrier_contract_mng_log` | 계약 이력 | +| `carrier_vehicle_mng` | 운송사 차량 관리 | +| `carrier_vehicle_mng_log` | 차량 이력 | + +#### DTG (디지털운행기록계) + +| 테이블 | 역할 | +|--------|------| +| `dtg_management` | DTG 통합 관리 (구매/설치/점검/폐기/정산) | +| `dtg_management_log` | DTG 관리 이력 | +| `dtg_contracts` | DTG 차종별/운송사별 계약 | +| `dtg_monthly_settlements` | DTG 월별 정산 | +| `dtg_maintenance_history` | DTG 점검 이력 | + +#### 물류 비용 + +| 테이블 | 역할 | +|--------|------| +| `logistics_cost_mng` | 물류 비용 관리 | +| `logistics_cost_mng_log` | 물류 비용 이력 | + +**워크플로우: 운송 관리** +``` +1. 출하 계획 (shipment_plan) +2. 차량 배차 (vehicles) +3. 운행 시작 + → vehicle_location_history (GPS 추적) + → transport_logs 기록 +4. 배송 완료 +5. 운행 통계 집계 (transport_statistics) +6. 정산 (dtg_monthly_settlements) +``` + +### 6.7 PLM/설계 (Product Lifecycle Management) - 30+ 테이블 + +#### 제품 관리 + +| 테이블 | 역할 | 주요 참조 | +|--------|------|-----------| +| `product_mng` | 제품 마스터 | - | +| `product_mgmt` | 제품 관리 | - | +| `product_mgmt_model` | 제품 모델 | product_mgmt | +| `product_mgmt_price_history` | 제품 가격 이력 | product_mgmt | +| `product_mgmt_upg_master` | 제품 업그레이드 마스터 | product_mgmt | +| `product_mgmt_upg_detail` | 제품 업그레이드 상세 | product_mgmt_upg_master | +| `product_kind_spec` | 제품별 사양 관리 | - | +| `product_kind_spec_main` | 제품별 사양 메인 | - | +| `product_spec` | 제품 사양 | - | +| `product_group_mng` | 제품 그룹 관리 | - | + +#### 품목 관리 + +| 테이블 | 역할 | +|--------|------| +| `item_info` | 품목 정보 | +| `part_mng` | 부품 관리 (설계 정보 포함) | +| `part_mng_history` | 부품 변경 이력 | +| `part_mgmt` | 부품 관리 | +| `part_distribution_list` | 부품 배포 리스트 | + +#### BOM 관리 + +| 테이블 | 역할 | +|--------|------| +| `klbom_tbl` | KL BOM 테이블 | +| `part_bom_qty` | BOM 수량 관리 | +| `part_bom_report` | BOM 보고서 | +| `sales_bom_report` | 영업 BOM 보고서 | +| `sales_bom_report_part` | 영업 BOM 품목 | +| `sales_bom_part_qty` | 영업 BOM 수량 | + +#### 프로젝트 관리 + +| 테이블 | 역할 | +|--------|------| +| `pms_pjt_info` | 프로젝트 정보 | +| `pms_pjt_concept_info` | 프로젝트 개념 정보 | +| `pms_pjt_year_goal` | 프로젝트 연간 목표 | +| `pms_rel_pjt_prod` | 프로젝트-제품 관계 | +| `pms_rel_pjt_concept_prod` | 프로젝트 개념-제품 관계 | +| `pms_rel_pjt_concept_milestone` | 프로젝트 개념-마일스톤 | +| `pms_rel_prod_ref_dept` | 제품-참조부서 관계 | +| `project` | 프로젝트 (레거시) | +| `project_mgmt` | 프로젝트 관리 | +| `project_concept` | 프로젝트 개념 (레거시?) | + +#### WBS (Work Breakdown Structure) + +| 테이블 | 역할 | +|--------|------| +| `pms_wbs_task` | WBS 작업 | +| `pms_wbs_task_info` | WBS 작업 정보 | +| `pms_wbs_task_confirm` | WBS 작업 확인 | +| `pms_wbs_task_standard` | WBS 작업 표준 | +| `pms_wbs_task_standard2` | WBS 작업 표준2 | +| `pms_wbs_template` | WBS 템플릿 | + +#### 설계 관리 + +| 테이블 | 역할 | +|--------|------| +| `mold_dev_request_info` | 금형 개발 요청 | +| `structural_review_proposal` | 구조 검토 제안서 | +| `external_work_review_info` | 외주 작업 검토 정보 | +| `standard_doc_info` | 표준 문서 정보 | + +#### OEM 관리 + +| 테이블 | 역할 | +|--------|------| +| `oem_mng` | OEM 관리 | +| `oem_factory_mng` | OEM 공장 관리 | +| `oem_milestone_mng` | OEM 마일스톤 관리 | + +**워크플로우: PLM 프로세스** +``` +1. 제품 기획 (pms_pjt_concept_info) +2. 프로젝트 생성 (pms_pjt_info) +3. WBS 작업 분해 (pms_wbs_task) +4. 제품 설계 (product_mng) +5. BOM 작성 (part_bom_report) +6. 부품 관리 (part_mng) +7. 설계 검토 (structural_review_proposal) +8. 양산 전환 (production_task) +``` + +### 6.8 회계/원가 (Accounting & Costing) - 20+ 테이블 + +#### 원가 관리 + +| 테이블 | 역할 | 주요 참조 | +|--------|------|-----------| +| `material_cost` | 재료비 | - | +| `injection_cost` | 사출 원가 | - | +| `profit_loss` | 손익 관리 | - | +| `profit_loss_total` | 손익 합계 | - | +| `profit_loss_coefficient` | 손익 계수 | - | +| `profit_loss_coolingtime` | 손익 냉각 시간 | - | +| `profit_loss_depth` | 손익 깊이 | - | +| `profit_loss_lossrate` | 손익 손실률 | - | +| `profit_loss_machine` | 손익 기계 | - | +| `profit_loss_pretime` | 손익 사전 시간 | - | +| `profit_loss_srrate` | 손익 SR률 | - | +| `profit_loss_weight` | 손익 중량 | - | +| `profit_loss_total_addlist` | 손익 합계 추가 리스트 | - | +| `profit_loss_total_addlist2` | 손익 합계 추가 리스트2 | - | + +#### 자재/비용 관리 + +| 테이블 | 역할 | +|--------|------| +| `material_mng` | 자재 관리 | +| `material_master_mgmt` | 자재 마스터 관리 | +| `material_detail_mgmt` | 자재 상세 관리 | +| `input_cost_goal` | 투입 원가 목표 | + +#### 비용/투자 + +| 테이블 | 역할 | +|--------|------| +| `expense_master` | 비용 마스터 | +| `expense_detail` | 비용 상세 | +| `pms_invest_cost_mng` | 투자 비용 관리 | +| `fund_mgmt` | 자금 관리 | + +#### 세금계산서 + +| 테이블 | 역할 | +|--------|------| +| `tax_invoice` | 세금계산서 | +| `tax_invoice_item` | 세금계산서 항목 | + +#### 안전 예산 + +| 테이블 | 역할 | +|--------|------| +| `safety_budget_execution` | 안전 예산 집행 | +| `safety_incidents` | 안전 사고 | +| `safety_inspections` | 안전 점검 | +| `safety_inspections_log` | 안전 점검 이력 | + +**워크플로우: 원가 계산** +``` +1. BOM 기반 재료비 계산 (material_cost) +2. 공정별 가공비 계산 (injection_cost) +3. 간접비 배부 +4. 총 원가 집계 (profit_loss_total) +5. 판매가 대비 이익률 계산 (profit_loss) +``` + +### 6.9 기타 비즈니스 테이블 - 15+ 테이블 + +#### 공통 코드 + +| 테이블 | 역할 | +|--------|------| +| `comm_code` | 공통 코드 | +| `comm_code_history` | 공통 코드 이력 | +| `code_category` | 코드 카테고리 | +| `code_info` | 코드 정보 | + +#### 환율 + +| 테이블 | 역할 | +|--------|------| +| `comm_exchange_rate` | 환율 정보 | + +#### 게시판/댓글 + +| 테이블 | 역할 | +|--------|------| +| `chartmgmt` | 차트 관리 | +| `comments` | 댓글 | +| `inboxtask` | 받은편지함 작업 | + +#### 첨부파일 + +| 테이블 | 역할 | +|--------|------| +| `attach_file_info` | 첨부파일 정보 | +| `file_down_log` | 파일 다운로드 로그 | + +#### 승인 + +| 테이블 | 역할 | +|--------|------| +| `approval` | 승인 | + +#### 옵션 관리 + +| 테이블 | 역할 | +|--------|------| +| `option_mng` | 옵션 관리 | +| `option_price_history` | 옵션 가격 이력 | + +#### 수주 사양 + +| 테이블 | 역할 | +|--------|------| +| `order_spec_mng` | 수주 사양 관리 | +| `order_spec_mng_history` | 수주 사양 이력 | + +#### 기타 + +| 테이블 | 역할 | +|--------|------| +| `ratecal_mgmt` | 요율 계산 관리 | +| `time_sheet` | 타임시트 | +| `problem_mng` | 문제 관리 | +| `planning_issue` | 계획 이슈 | +| `used_mng` | 중고 관리 | + +--- + +## 📊 7. 대시보드 및 리포트 시스템 + +### 7.1 대시보드 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `dashboards` | 대시보드 정의 | dashboard_id, dashboard_name, company_code | +| `dashboard_elements` | 대시보드 요소 | element_id, dashboard_id, element_type, element_config (JSONB) | +| `dashboard_shares` | 대시보드 공유 | share_id, dashboard_id, shared_with_user_id | +| `dashboard_sliders` | 대시보드 슬라이더 | slider_id, slider_name, company_code | +| `dashboard_slider_items` | 슬라이더 내 대시보드 목록 | item_id, slider_id, dashboard_id, display_order | + +**대시보드 구조:** +``` +dashboard_sliders (슬라이더 그룹) + └─ dashboard_slider_items (슬라이더에 포함된 대시보드들) + └─ dashboards (개별 대시보드) + └─ dashboard_elements (차트, 위젯 등) +``` + +### 7.2 리포트 시스템 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `report_master` | 리포트 마스터 | report_id, report_name, report_type | +| `report_query` | 리포트 쿼리 | query_id, report_id, query_sql, query_params | +| `report_layout` | 리포트 레이아웃 | layout_id, report_id, layout_config (JSONB) | +| `report_template` | 리포트 템플릿 | template_id, template_name, template_config (JSONB) | +| `report_menu_mapping` | 리포트-메뉴 매핑 | mapping_id, report_id, menu_id | + +**리포트 생성 프로세스:** +``` +1. report_master 정의 +2. report_query SQL 작성 +3. report_layout 레이아웃 설정 +4. report_menu_mapping 메뉴 연결 +5. 사용자가 메뉴 클릭 +6. 동적 SQL 실행 +7. 결과를 레이아웃에 맞춰 렌더링 +``` + +--- + +## 🔧 8. 고급 기능 시스템 + +### 8.1 카테고리 시스템 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `category_column_mapping` | 카테고리 컬럼 매핑 | table_name, column_name, category_key | +| `table_column_category_values` | 카테고리 값 | table_name, column_name, company_code, category_values (JSONB) | +| `category_value_cascading_group` | 카테고리 연쇄 그룹 | group_id, group_name | +| `category_value_cascading_mapping` | 카테고리 연쇄 매핑 | mapping_id, parent_category, child_category | + +**카테고리 기능:** +- 컬럼별 드롭다운 옵션 관리 +- 회사별 독립적인 카테고리 값 +- 연쇄 드롭다운 (parent → child) + +### 8.2 연쇄 드롭다운 (Cascading Dropdown) + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `cascading_relation` | 연쇄 드롭다운 관계 | relation_id, parent_field, child_field, relation_config | +| `cascading_hierarchy_group` | 다단계 연쇄 그룹 | group_id, group_name, hierarchy_levels | +| `cascading_hierarchy_level` | 다단계 연쇄 레벨 | level_id, group_id, level_order, level_config | +| `cascading_condition` | 조건부 연쇄 | condition_id, parent_field, child_field, condition_config | +| `cascading_multi_parent` | 다중 부모 연쇄 | relation_id, child_field, parent_fields (ARRAY) | +| `cascading_multi_parent_source` | 다중 부모 소스 | source_id, relation_id, parent_field, source_config | +| `cascading_mutual_exclusion` | 상호 배제 | exclusion_id, field_1, field_2 | +| `cascading_reverse_lookup` | 역방향 연쇄 | lookup_id, child_field, parent_field | +| `cascading_auto_fill_group` | 자동 입력 그룹 | group_id, master_field, auto_fill_fields | +| `cascading_auto_fill_mapping` | 자동 입력 매핑 | mapping_id, group_id, source_field, target_field | + +**연쇄 드롭다운 패턴:** +``` +1. 단순 연쇄: 대분류 → 중분류 → 소분류 +2. 다단계 연쇄: 회사 → 부서 → 팀 → 직원 +3. 조건부 연쇄: 제품군에 따라 다른 옵션 표시 +4. 다중 부모: 부품은 여러 제품에 속할 수 있음 +5. 자동 입력: 거래처 선택 시 주소/연락처 자동 입력 +``` + +### 8.3 채번 규칙 (Numbering Rules) + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `numbering_rules` | 채번 규칙 마스터 | rule_id, rule_name, table_name, column_name, company_code | +| `numbering_rule_parts` | 채번 규칙 파트 | part_id, rule_id, part_order, part_type, part_config | + +**채번 규칙 예시:** +``` +수주번호: SO-20260206-001 + - SO: 고정 접두사 + - 20260206: 날짜 (YYYYMMDD) + - 001: 일련번호 (3자리) + +발주번호: PO-{회사코드}-{YYYYMM}-{시퀀스} + - PO: 고정 접두사 + - COMPANY_A: 회사 코드 + - 202602: 년월 + - 0001: 시퀀스 (4자리) +``` + +### 8.4 엑셀 업로드 매핑 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `excel_mapping_template` | 엑셀 매핑 템플릿 | template_id, template_name, table_name, column_mapping (JSONB) | + +**엑셀 업로드 프로세스:** +``` +1. 사용자가 엑셀 업로드 +2. 헤더 행 읽기 +3. excel_mapping_template에서 매칭 템플릿 조회 +4. 컬럼 자동 매핑 +5. 데이터 검증 +6. DB 삽입 +``` + +### 8.5 데이터 관계 브리지 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `data_relationship_bridge` | 테이블 간 데이터 관계 중계 | bridge_id, source_table, source_id, target_table, target_id, relation_type | + +**용도:** +- 여러 테이블에 걸친 데이터 연결 추적 +- M:N 관계 관리 +- 데이터 통합 조회 + +--- + +## ⚙️ 9. 배치 및 자동화 시스템 + +### 9.1 배치 작업 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `batch_jobs` | 배치 작업 정의 | job_id, job_name, job_type, job_config (JSONB) | +| `batch_schedules` | 배치 스케줄 | schedule_id, job_id, cron_expression | +| `batch_execution_logs` | 배치 실행 로그 | log_id, job_id, execution_status, started_at, completed_at | +| `batch_job_executions` | 배치 작업 실행 | execution_id, job_id, execution_params | +| `batch_job_parameters` | 배치 작업 파라미터 | param_id, job_id, param_name, param_value | +| `batch_configs` | 배치 설정 | config_id, job_id, config_key, config_value | +| `batch_mappings` | 배치 매핑 | mapping_id, source_config, target_config | + +**배치 작업 예시:** +- 일일 재고 실사 +- 월말 마감 처리 +- 통계 데이터 집계 +- 외부 시스템 동기화 + +### 9.2 동적 폼 데이터 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `dynamic_form_data` | 동적 폼 데이터 | form_id, table_name, form_data (JSONB) | + +**용도:** +- 런타임에 정의된 폼의 데이터 저장 +- 유연한 데이터 구조 + +--- + +## 🌍 10. 다국어 시스템 + +### 10.1 다국어 관리 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `multi_lang_category` | 다국어 키 카테고리 (2단계 계층) | category_id, category_name, parent_category_id | +| `multi_lang_key_master` | 다국어 키 마스터 | key_id, key_name, category_id, default_text | +| `multi_lang_text` | 다국어 텍스트 | text_id, key_id, language_code, translated_text | +| `language_master` | 언어 마스터 | language_code, language_name, is_active | + +**다국어 구조:** +``` +multi_lang_category (카테고리) + └─ multi_lang_key_master (키) + └─ multi_lang_text (언어별 번역) + ├─ ko: 한국어 + ├─ en: 영어 + ├─ ja: 일본어 + └─ zh: 중국어 +``` + +**사용 예시:** +```typescript +// menu_info.lang_key = "menu.sales.order" +// multi_lang_key_master: key_name = "menu.sales.order" +// multi_lang_text: +// - ko: "수주관리" +// - en: "Sales Order" +// - ja: "受注管理" +``` + +--- + +## 📧 11. 메일 및 로그 시스템 + +### 11.1 메일 시스템 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `mail_log` | 메일 발송 로그 | log_id, to_email, subject, body, sent_at, sent_status | + +### 11.2 로그 테이블 + +| 테이블 | 역할 | +|--------|------| +| `ddl_execution_log` | DDL 실행 로그 | +| `file_down_log` | 파일 다운로드 로그 | +| `login_access_log` | 로그인 접근 로그 | + +### 11.3 변경 이력 테이블 (Audit Log) + +모든 주요 테이블에는 `{table_name}_log` 테이블이 있습니다: + +| 원본 테이블 | 이력 테이블 | 트리거 함수 | +|-------------|-------------|-------------| +| `carrier_contract_mng` | `carrier_contract_mng_log` | `carrier_contract_mng_log_trigger_func()` | +| `carrier_mng` | `carrier_mng_log` | `carrier_mng_log_trigger_func()` | +| `carrier_vehicle_mng` | `carrier_vehicle_mng_log` | `carrier_vehicle_mng_log_trigger_func()` | +| `defect_standard_mng` | `defect_standard_mng_log` | - | +| `delivery_route_mng` | `delivery_route_mng_log` | - | +| `dtg_management` | `dtg_management_log` | - | +| `equipment_consumable` | `equipment_consumable_log` | - | +| `equipment_inspection_item` | `equipment_inspection_item_log` | - | +| `equipment_mng` | `equipment_mng_log` | - | +| `inspection_equipment_mng` | `inspection_equipment_mng_log` | - | +| `item_info_20251202` | `item_info_20251202_log` | - | +| `logistics_cost_mng` | `logistics_cost_mng_log` | - | +| `order_table` | `order_table_log` | - | +| `safety_inspections` | `safety_inspections_log` | - | +| `sales_order_detail` | `sales_order_detail_log` | - | +| `supplier_mng` | `supplier_mng_log` | - | +| `work_instruction` | `work_instruction_log` | - | +| `work_instruction_detail` | `work_instruction_detail_log` | - | + +**이력 테이블 구조:** +```sql +CREATE TABLE {table_name}_log ( + id SERIAL PRIMARY KEY, + operation_type VARCHAR(10), -- INSERT, UPDATE, DELETE + original_id VARCHAR(500), -- 원본 레코드 ID + changed_column VARCHAR(100), + old_value TEXT, + new_value TEXT, + changed_by VARCHAR(100), + ip_address VARCHAR(50), + changed_at TIMESTAMP DEFAULT NOW(), + full_row_before JSONB, -- 변경 전 전체 행 + full_row_after JSONB -- 변경 후 전체 행 +); +``` + +**트리거 자동 기록:** +```sql +CREATE TRIGGER trg_{table_name}_log +AFTER INSERT OR UPDATE OR DELETE ON {table_name} +FOR EACH ROW +EXECUTE FUNCTION {table_name}_log_trigger_func(); +``` + +--- + +## 🔍 12. 데이터베이스 함수 및 트리거 + +### 12.1 주요 함수 + +#### 화면 관련 + +```sql +-- 화면 생성 시 메뉴 자동 생성 +CREATE FUNCTION auto_create_menu_for_screen() RETURNS TRIGGER; + +-- 화면 삭제 시 메뉴 비활성화 +CREATE FUNCTION auto_deactivate_menu_for_screen() RETURNS TRIGGER; +``` + +#### 통계 집계 + +```sql +-- 일일 운송 통계 집계 함수 +CREATE FUNCTION aggregate_daily_transport_statistics(target_date DATE DEFAULT CURRENT_DATE - 1) +RETURNS INTEGER; +``` + +#### 거리 계산 + +```sql +-- Haversine 거리 계산 (GPS 좌표) +CREATE FUNCTION calculate_distance(lat1 NUMERIC, lng1 NUMERIC, lat2 NUMERIC, lng2 NUMERIC) +RETURNS NUMERIC; + +-- 차량 위치 이력의 이전 위치로부터 거리 계산 +CREATE FUNCTION calculate_distance_from_prev() RETURNS TRIGGER; +``` + +#### 비즈니스 로직 + +```sql +-- 수주 잔량 자동 계산 +CREATE FUNCTION calculate_order_balance() RETURNS TRIGGER; + +-- 세금계산서 합계 자동 계산 +CREATE FUNCTION calculate_tax_invoice_total() RETURNS TRIGGER; + +-- 영업에서 프로젝트 자동 생성 +CREATE FUNCTION auto_create_project_from_sales(p_sales_no VARCHAR) RETURNS VARCHAR; + +-- 차량 상태 변경 시 운행 통계 계산 +CREATE FUNCTION calculate_trip_on_status_change() RETURNS TRIGGER; +``` + +### 12.2 주요 트리거 + +```sql +-- 화면 생성 시 메뉴 자동 생성 +CREATE TRIGGER trg_auto_create_menu_for_screen +AFTER INSERT ON screen_definitions +FOR EACH ROW +EXECUTE FUNCTION auto_create_menu_for_screen(); + +-- 화면 삭제 시 메뉴 비활성화 +CREATE TRIGGER trg_auto_deactivate_menu_for_screen +AFTER UPDATE ON screen_definitions +FOR EACH ROW +EXECUTE FUNCTION auto_deactivate_menu_for_screen(); + +-- 차량 위치 이력 거리 자동 계산 +CREATE TRIGGER trg_calculate_distance_from_prev +BEFORE INSERT ON vehicle_location_history +FOR EACH ROW +EXECUTE FUNCTION calculate_distance_from_prev(); + +-- 수주 잔량 자동 계산 +CREATE TRIGGER trg_calculate_order_balance +BEFORE INSERT OR UPDATE ON orders +FOR EACH ROW +EXECUTE FUNCTION calculate_order_balance(); + +-- 세금계산서 합계 자동 계산 +CREATE TRIGGER trg_calculate_tax_invoice_total +BEFORE INSERT OR UPDATE ON tax_invoice +FOR EACH ROW +EXECUTE FUNCTION calculate_tax_invoice_total(); +``` + +--- + +## 📈 13. 인덱스 전략 + +### 13.1 필수 인덱스 + +**모든 테이블:** +```sql +-- company_code 인덱스 (멀티테넌시 필수) +CREATE INDEX idx_{table_name}_company_code ON {table_name}(company_code); +``` + +### 13.2 복합 인덱스 + +**자주 함께 조회되는 컬럼:** +```sql +-- 회사별, 날짜별 조회 +CREATE INDEX idx_sales_company_date ON sales_order_mng(company_code, order_date DESC); + +-- 회사별, 상태별 조회 +CREATE INDEX idx_work_orders_company_status ON work_orders(company_code, status); + +-- 회사별, 거래처별 조회 +CREATE INDEX idx_purchase_company_supplier ON purchase_order_master(company_code, partner_objid); +``` + +### 13.3 부분 인덱스 + +**특정 조건의 데이터만:** +```sql +-- 활성 상태의 메뉴만 인덱싱 +CREATE INDEX idx_menu_active ON menu_info(company_code, menu_type) +WHERE status = 'active'; + +-- 미완료 작업지시만 인덱싱 +CREATE INDEX idx_work_orders_pending ON work_orders(company_code, wo_number) +WHERE status IN ('PENDING', 'IN_PROGRESS'); +``` + +### 13.4 JSONB 인덱스 + +**JSONB 컬럼 검색:** +```sql +-- GIN 인덱스 (JSONB 전체 검색) +CREATE INDEX idx_screen_layouts_config ON screen_layouts USING GIN (layout_config); + +-- JSONB 특정 키 인덱스 +CREATE INDEX idx_flow_step_action_type ON flow_step ((action_config->>'action_type')); +``` + +--- + +## 🔒 14. 데이터베이스 보안 + +### 14.1 암호화 컬럼 + +```sql +-- 외부 DB 비밀번호 암호화 +external_db_connections.password_encrypted TEXT + +-- 외부 REST API 인증 정보 암호화 +external_rest_api_connections.auth_config JSONB +``` + +**암호화 방식:** +- AES-256-GCM +- 애플리케이션 레벨에서 암호화/복호화 +- DB에는 암호화된 값만 저장 + +### 14.2 접근 제어 + +**회사별 데이터 격리:** +```sql +-- 모든 쿼리에 company_code 필터 필수 +WHERE company_code = $1 AND company_code != '*' +``` + +**사용자별 권한 관리:** +``` +user_info + → authority_sub_user + → authority_master + → rel_menu_auth + → 메뉴별 CRUD 권한 +``` + +### 14.3 감사 로그 + +- 모든 변경 사항은 `{table_name}_log` 테이블에 기록 +- IP 주소, 사용자 ID, 변경 시각 추적 +- 변경 전후 전체 행 데이터 JSONB로 저장 + +--- + +## 🚀 15. 성능 최적화 전략 + +### 15.1 쿼리 최적화 + +**1. company_code 필터링 항상 포함** +```sql +-- ✅ Good +SELECT * FROM sales_order_mng +WHERE company_code = 'COMPANY_A' + AND order_date >= '2026-01-01'; + +-- ❌ Bad (전체 스캔) +SELECT * FROM sales_order_mng +WHERE order_date >= '2026-01-01'; +``` + +**2. JOIN 시 company_code 매칭** +```sql +-- ✅ Good +SELECT so.*, c.customer_name +FROM sales_order_mng so +LEFT JOIN customer_mng c + ON so.customer_code = c.customer_code + AND so.company_code = c.company_code -- 필수! +WHERE so.company_code = 'COMPANY_A'; + +-- ❌ Bad (크로스 조인 발생) +SELECT so.*, c.customer_name +FROM sales_order_mng so +LEFT JOIN customer_mng c + ON so.customer_code = c.customer_code +WHERE so.company_code = 'COMPANY_A'; +``` + +**3. 인덱스 활용** +```sql +-- 복합 인덱스 순서 중요 +CREATE INDEX idx_sales_company_date_status +ON sales_order_mng(company_code, order_date, status); + +-- ✅ Good (인덱스 활용) +WHERE company_code = 'COMPANY_A' + AND order_date >= '2026-01-01' + AND status = 'CONFIRMED'; + +-- ❌ Bad (인덱스 미활용) +WHERE status = 'CONFIRMED' + AND order_date >= '2026-01-01' + AND company_code = 'COMPANY_A'; +``` + +### 15.2 대용량 데이터 처리 + +**파티셔닝:** +```sql +-- 날짜 기반 파티셔닝 (예시) +CREATE TABLE vehicle_location_history ( + ... +) PARTITION BY RANGE (recorded_at); + +CREATE TABLE vehicle_location_history_2026_01 +PARTITION OF vehicle_location_history +FOR VALUES FROM ('2026-01-01') TO ('2026-02-01'); +``` + +**배치 처리:** +```sql +-- 대량 삽입 시 COPY 사용 +COPY table_name FROM '/path/to/file.csv' WITH (FORMAT csv, HEADER true); + +-- 대량 업데이트 시 배치 단위로 +UPDATE table_name +SET status = 'PROCESSED' +WHERE id IN ( + SELECT id FROM table_name + WHERE status = 'PENDING' + LIMIT 1000 +); +``` + +### 15.3 캐싱 전략 + +**애플리케이션 레벨 캐싱:** +- 메타데이터 (table_labels, column_labels) → Redis +- 공통 코드 (comm_code) → Redis +- 메뉴 정보 (menu_info) → Redis +- 사용자 권한 (rel_menu_auth) → Redis + +**쿼리 결과 캐싱:** +- 통계 데이터 +- 집계 데이터 +- 읽기 전용 마스터 데이터 + +--- + +## 📝 16. 마이그레이션 가이드 + +### 16.1 마이그레이션 파일 목록 + +``` +db/migrations/ +├── 037_add_parent_group_to_screen_groups.sql +├── 050_create_work_orders_table.sql +├── 051_insert_work_order_screen_definition.sql +├── 052_insert_work_order_screen_layout.sql +├── 054_create_screen_management_enhancement.sql +├── 055_create_customer_item_prices_table.sql +└── plm_schema_20260120.sql (전체 스키마 덤프) +``` + +### 16.2 마이그레이션 실행 순서 + +**1. 테이블 생성** +```sql +CREATE TABLE {table_name} ( + id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + company_code VARCHAR(20) NOT NULL, + created_date TIMESTAMP DEFAULT NOW(), + updated_date TIMESTAMP DEFAULT NOW(), + writer VARCHAR(500), + -- 비즈니스 컬럼들... +); +``` + +**2. 인덱스 생성** +```sql +CREATE INDEX idx_{table_name}_company_code ON {table_name}(company_code); +CREATE INDEX idx_{table_name}_created_date ON {table_name}(created_date DESC); +-- 기타 필요한 인덱스들... +``` + +**3. 메타데이터 등록** +```sql +-- table_labels +INSERT INTO table_labels (table_name, table_label, description) +VALUES ('{table_name}', '{한글명}', '{설명}') +ON CONFLICT (table_name) DO UPDATE SET ...; + +-- table_type_columns +INSERT INTO table_type_columns (table_name, column_name, company_code, input_type, ...) +VALUES ...; + +-- column_labels (레거시 호환) +INSERT INTO column_labels (table_name, column_name, column_label, ...) +VALUES ...; +``` + +**4. 코멘트 추가** +```sql +COMMENT ON TABLE {table_name} IS '{테이블 설명}'; +COMMENT ON COLUMN {table_name}.{column_name} IS '{컬럼 설명}'; +``` + +**5. 화면 정의 (선택)** +```sql +-- screen_definitions +INSERT INTO screen_definitions (screen_code, screen_name, table_name, ...) +VALUES ...; + +-- 트리거 자동 발동 → menu_info 자동 생성 +``` + +### 16.3 마이그레이션 롤백 + +```sql +-- 화면 정의 삭제 +DELETE FROM screen_definitions WHERE screen_code = '{screen_code}'; + +-- 메뉴 삭제 (자동 비활성화되었을 것) +DELETE FROM menu_info WHERE screen_code = '{screen_code}'; + +-- 메타데이터 삭제 +DELETE FROM column_labels WHERE table_name = '{table_name}'; +DELETE FROM table_type_columns WHERE table_name = '{table_name}'; +DELETE FROM table_labels WHERE table_name = '{table_name}'; + +-- 인덱스 삭제 +DROP INDEX IF EXISTS idx_{table_name}_company_code; + +-- 테이블 삭제 +DROP TABLE IF EXISTS {table_name}; +``` + +--- + +## 🎯 17. 데이터베이스 설계 원칙 요약 + +### 17.1 ABSOLUTE MUST (절대 필수) + +1. **모든 테이블에 company_code VARCHAR(20) NOT NULL** +2. **모든 쿼리에 company_code 필터 포함** +3. **JOIN 시 company_code 매칭 조건 포함** +4. **모든 테이블에 company_code 인덱스 생성** +5. **일반 회사는 company_code != '*' 필터 필수** + +### 17.2 표준 테이블 구조 + +```sql +CREATE TABLE {table_name} ( + -- 기본 컬럼 (표준 5종 세트) + id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + created_date TIMESTAMP DEFAULT NOW(), + updated_date TIMESTAMP DEFAULT NOW(), + writer VARCHAR(500), + company_code VARCHAR(20) NOT NULL, + + -- 비즈니스 컬럼들 + ... +); + +-- 필수 인덱스 +CREATE INDEX idx_{table_name}_company_code ON {table_name}(company_code); +``` + +### 17.3 메타데이터 등록 필수 + +동적 테이블 생성 시: +1. `table_labels` 등록 +2. `table_type_columns` 등록 (회사별) +3. `column_labels` 등록 (레거시 호환) +4. 코멘트 추가 + +### 17.4 쿼리 패턴 + +```sql +-- ✅ 표준 SELECT +SELECT * FROM table_name +WHERE company_code = $1 + AND company_code != '*' +ORDER BY created_date DESC; + +-- ✅ 표준 JOIN +SELECT a.*, b.name +FROM table_a a +LEFT JOIN table_b b + ON a.ref_id = b.id + AND a.company_code = b.company_code -- 필수! +WHERE a.company_code = $1; + +-- ✅ 표준 집계 +SELECT + category, + COUNT(*) as total, + SUM(amount) as total_amount +FROM sales +WHERE company_code = $1 +GROUP BY category; +``` + +--- + +## 📚 18. 참고 자료 + +### 18.1 관련 문서 + +``` +docs/ +├── DB_ARCHITECTURE_ANALYSIS.md -- 기존 상세 DB 분석 문서 +├── backend-architecture-analysis.md -- 백엔드 아키텍처 분석 +├── frontend-architecture-analysis.md -- 프론트엔드 아키텍처 분석 +└── kjs/ + ├── 멀티테넌시_구현_현황_분석_보고서.md + ├── 테이블_타입관리_성능최적화_결과.md + └── 카테고리_시스템_최종_완료_보고서.md +``` + +### 18.2 스키마 파일 + +``` +db/ +├── plm_schema_20260120.sql -- 전체 스키마 덤프 +└── migrations/ + ├── 037_add_parent_group_to_screen_groups.sql + ├── 050_create_work_orders_table.sql + ├── 051_insert_work_order_screen_definition.sql + ├── 052_insert_work_order_screen_layout.sql + ├── 054_create_screen_management_enhancement.sql + └── 055_create_customer_item_prices_table.sql +``` + +### 18.3 백엔드 서비스 매핑 + +```typescript +// backend-node/src/services/ + +// 화면 관리 +screenManagementService.ts → screen_definitions, screen_layouts + +// 테이블 관리 +tableManagementService.ts → table_labels, table_type_columns, column_labels + +// 메뉴 관리 +menuService.ts → menu_info, menu_screen_groups + +// 카테고리 관리 +categoryTreeService.ts → table_column_category_values + +// 플로우 관리 +flowDefinitionService.ts → flow_definition, flow_step +flowExecutionService.ts → flow_data_status, flow_audit_log + +// 데이터플로우 +dataflowService.ts → dataflow_diagrams, screen_data_flows + +// 외부 연동 +externalDbConnectionService.ts → external_db_connections +externalRestApiConnectionService.ts → external_rest_api_connections + +// 배치 +batchService.ts → batch_jobs, batch_execution_logs + +// 인증/권한 +authService.ts → user_info, auth_tokens +roleService.ts → authority_master, rel_menu_auth +``` + +--- + +## 🎬 19. 비즈니스 워크플로우 통합 예시 + +### 19.1 수주 → 생산 → 출하 전체 플로우 + +``` +[영업팀] +1. 견적 요청 접수 (estimate_mgmt) +2. 견적서 작성 및 발송 +3. 고객 승인 + +[영업팀] +4. 수주 등록 (sales_order_mng) + → sales_order_detail (품목별 상세) + → contract_mgmt (계약 체결) + +[생산관리팀] +5. 생산 계획 수립 (order_plan_mgmt) + → 자재 소요 계획 (MRP) + → 공정별 계획 + +[구매팀] +6. 구매 요청 (sales_request_master) + → 공급처 선정 (supplier_mng) + → 발주서 작성 (purchase_order_master) + → 발주서 상세 (purchase_order_part) + +[자재팀] +7. 입고 예정 (delivery_history) + → 검수 (check_report_mng) + → 입고 확정 (receiving) + → 재고 반영 (inventory_stock) + +[생산팀] +8. 작업지시 발행 (work_orders) + → 자재 출고 (material_release) + → 생산 실행 (production_record) + +[품질팀] +9. 품질 검사 (inspection_standard) + → 합격/불합격 판정 + → 완제품 입고 (inventory_stock) + +[물류팀] +10. 출하 계획 (shipment_plan) + → 출하 지시 (shipment_instruction) + → 차량 배차 (vehicles) + → 출고 처리 (outbound_mng) + +[운송팀] +11. 운행 시작 + → GPS 추적 (vehicle_location_history) + → 운행 로그 (transport_logs) + +[고객] +12. 납품 완료 + → 배송 상태 업데이트 (delivery_status) + → 세금계산서 발행 (tax_invoice) + +[재무팀] +13. 정산 + → 매출 인식 + → 원가 계산 (profit_loss) +``` + +### 19.2 데이터 흐름 추적 + +``` +플로우 정의 (flow_definition): "수주-생산-출하 플로우" + │ + ├─ Step 1: 수주 접수 (flow_step) + │ └─ 데이터: sales_order_mng + │ └─ flow_data_status: STEP_1_COMPLETED + │ + ├─ Step 2: 생산 계획 (flow_step) + │ └─ 데이터: order_plan_mgmt + │ └─ flow_data_status: STEP_2_COMPLETED + │ + ├─ Step 3: 발주 처리 (flow_step) + │ └─ 데이터: purchase_order_master + │ └─ flow_data_status: STEP_3_COMPLETED + │ + ├─ Step 4: 입고 처리 (flow_step) + │ └─ 데이터: receiving, inventory_stock + │ └─ flow_data_status: STEP_4_COMPLETED + │ + ├─ Step 5: 생산 실행 (flow_step) + │ └─ 데이터: work_orders, production_record + │ └─ flow_data_status: STEP_5_COMPLETED + │ + └─ Step 6: 출하 완료 (flow_step) + └─ 데이터: shipment_plan, outbound_mng + └─ flow_data_status: COMPLETED + +각 단계 변경 이력: flow_audit_log +외부 시스템 연동: flow_integration_log +``` + +--- + +**문서 작성자**: Cursor AI (DB Specialist Agent) +**문서 버전**: 2.0 +**작성일**: 2026-02-06 +**기반 스키마**: plm_schema_20260120.sql (337 테이블) +**목적**: WACE ERP 전체 워크플로우 문서화를 위한 DB 구조 분석 + +--- diff --git a/docs/WACE_SYSTEM_WORKFLOW.md b/docs/WACE_SYSTEM_WORKFLOW.md new file mode 100644 index 00000000..b9cd9f23 --- /dev/null +++ b/docs/WACE_SYSTEM_WORKFLOW.md @@ -0,0 +1,955 @@ +# WACE ERP 시스템 전체 워크플로우 문서 + +> 작성일: 2026-02-06 +> 분석 방법: Multi-Agent System (Backend + Frontend + DB 전문가 병렬 분석) + +--- + +## 목차 + +1. [시스템 개요](#1-시스템-개요) +2. [기술 스택](#2-기술-스택) +3. [전체 아키텍처](#3-전체-아키텍처) +4. [백엔드 아키텍처](#4-백엔드-아키텍처) +5. [프론트엔드 아키텍처](#5-프론트엔드-아키텍처) +6. [데이터베이스 구조](#6-데이터베이스-구조) +7. [인증/인가 워크플로우](#7-인증인가-워크플로우) +8. [화면 디자이너 워크플로우](#8-화면-디자이너-워크플로우) +9. [사용자 업무 워크플로우](#9-사용자-업무-워크플로우) +10. [플로우 엔진 워크플로우](#10-플로우-엔진-워크플로우) +11. [데이터플로우 시스템](#11-데이터플로우-시스템) +12. [대시보드 시스템](#12-대시보드-시스템) +13. [배치/스케줄 시스템](#13-배치스케줄-시스템) +14. [멀티테넌시 아키텍처](#14-멀티테넌시-아키텍처) +15. [외부 연동](#15-외부-연동) +16. [배포 환경](#16-배포-환경) + +--- + +## 1. 시스템 개요 + +WACE는 **로우코드(Low-Code) ERP 플랫폼**이다. 관리자가 코드 없이 드래그앤드롭으로 업무 화면을 설계하면, 사용자는 해당 화면으로 바로 업무를 처리할 수 있는 구조다. + +### 핵심 컨셉 + +``` +관리자 → 화면 디자이너로 화면 설계 → 메뉴에 연결 + ↓ +사용자 → 메뉴 클릭 → 화면 자동 렌더링 → 업무 수행 +``` + +### 주요 특징 + +- **드래그앤드롭 화면 디자이너**: 코드 없이 UI 구성 +- **동적 컴포넌트 시스템**: V2 통합 컴포넌트 10종으로 모든 UI 표현 +- **플로우 엔진**: 워크플로우(승인, 이동 등) 자동화 +- **데이터플로우**: 비즈니스 로직을 비주얼 다이어그램으로 설계 +- **멀티테넌시**: 회사별 완벽한 데이터 격리 +- **다국어 지원**: KR/EN/CN 다국어 라벨 관리 + +--- + +## 2. 기술 스택 + +| 영역 | 기술 | 비고 | +|------|------|------| +| **Frontend** | Next.js 15 (App Router) | React 19, TypeScript | +| **UI 라이브러리** | shadcn/ui + Radix UI | Tailwind CSS 4 | +| **상태 관리** | React Context + Zustand | React Query (서버 상태) | +| **Backend** | Node.js + Express | TypeScript | +| **Database** | PostgreSQL | Raw Query (ORM 미사용) | +| **인증** | JWT | 자동 갱신, 세션 관리 | +| **빌드/배포** | Docker | dev/prod 분리 | +| **포트** | FE: 9771(dev)/5555(prod) | BE: 8080 | + +--- + +## 3. 전체 아키텍처 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 사용자 브라우저 │ +│ Next.js App (React 19 + shadcn/ui + Tailwind CSS) │ +│ ├── 인증: JWT + Cookie + localStorage │ +│ ├── 상태: Context + Zustand + React Query │ +│ └── API: Axios Client (lib/api/) │ +└──────────────────────────┬──────────────────────────────────────┘ + │ HTTP/JSON (JWT Bearer Token) + ↓ +┌─────────────────────────────────────────────────────────────────┐ +│ Express Backend (Node.js) │ +│ ├── Middleware: Helmet → CORS → RateLimit → Auth → Permission │ +│ ├── Routes: 60+ 모듈 │ +│ ├── Controllers: 69개 │ +│ ├── Services: 87개 │ +│ └── Database: pg Pool (Raw Query) │ +└──────────────────────────┬──────────────────────────────────────┘ + │ TCP/SQL + ↓ +┌─────────────────────────────────────────────────────────────────┐ +│ PostgreSQL Database │ +│ ├── 시스템 테이블: 사용자, 회사, 메뉴, 권한, 화면 │ +│ ├── 메타데이터: 테이블/컬럼 정의, 코드, 카테고리 │ +│ ├── 비즈니스: 동적 생성 테이블 (화면별) │ +│ └── 멀티테넌시: 모든 테이블에 company_code │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 4. 백엔드 아키텍처 + +### 4.1 디렉토리 구조 + +``` +backend-node/src/ +├── app.ts # Express 앱 진입점 +├── config/ # 환경설정, Multer +├── controllers/ # 69개 컨트롤러 +├── services/ # 87개 서비스 +├── routes/ # 60+ 라우트 모듈 +├── middleware/ # 인증, 권한, 에러 처리 +│ ├── authMiddleware.ts # JWT 인증 +│ ├── permissionMiddleware.ts # 3단계 권한 체크 +│ ├── superAdminMiddleware.ts # 슈퍼관리자 전용 +│ └── errorHandler.ts # 전역 에러 처리 +├── database/ # DB 연결, 커넥터 팩토리 +│ ├── db.ts # PostgreSQL Pool +│ ├── DatabaseConnectorFactory.ts +│ ├── PostgreSQLConnector.ts +│ ├── MySQLConnector.ts +│ └── MariaDBConnector.ts +├── types/ # TypeScript 타입 (26개) +└── utils/ # 유틸리티 (16개) +``` + +### 4.2 미들웨어 스택 (실행 순서) + +``` +요청 → Helmet (보안 헤더) + → Compression (응답 압축) + → Body Parser (JSON/URLEncoded, 10MB) + → CORS (교차 출처 허용) + → Rate Limiter (10,000 req/min) + → Token Refresh (자동 갱신) + → Route Handlers (비즈니스 로직) + → Error Handler (전역 에러 처리) +``` + +### 4.3 API 라우트 도메인별 분류 + +#### 인증/사용자 관리 +| 라우트 | 역할 | +|--------|------| +| `/api/auth` | 로그인, 로그아웃, 토큰 갱신, 회사 전환 | +| `/api/admin/users` | 사용자 CRUD, 비밀번호 초기화, 상태 변경 | +| `/api/company-management` | 회사 CRUD | +| `/api/departments` | 부서 관리 | +| `/api/roles` | 권한 그룹 관리 | + +#### 화면/메뉴 관리 +| 라우트 | 역할 | +|--------|------| +| `/api/screen-management` | 화면 정의 CRUD, 그룹, 파일, 임베딩 | +| `/api/admin/menus` | 메뉴 트리 CRUD, 화면 할당 | +| `/api/table-management` | 테이블 CRUD, 엔티티 조인, 카테고리 | +| `/api/common-codes` | 공통 코드/카테고리 관리 | +| `/api/multilang` | 다국어 키/번역 관리 | + +#### 데이터 관리 +| 라우트 | 역할 | +|--------|------| +| `/api/data` | 동적 테이블 CRUD, 조인 쿼리 | +| `/api/data/:tableName` | 특정 테이블 데이터 조회 | +| `/api/data/join` | 조인 쿼리 실행 | +| `/api/dynamic-form` | 동적 폼 데이터 저장 | +| `/api/entity-search` | 엔티티 검색 | +| `/api/entity-reference` | 엔티티 참조 | +| `/api/numbering-rules` | 채번 규칙 관리 | +| `/api/cascading-*` | 연쇄 드롭다운 관계 | + +#### 자동화 +| 라우트 | 역할 | +|--------|------| +| `/api/flow` | 플로우 정의/단계/연결/실행 | +| `/api/dataflow` | 데이터플로우 다이어그램/실행 | +| `/api/batch-configs` | 배치 작업 설정 | +| `/api/batch-management` | 배치 작업 관리 | +| `/api/batch-execution-logs` | 배치 실행 로그 | + +#### 대시보드/리포트 +| 라우트 | 역할 | +|--------|------| +| `/api/dashboards` | 대시보드 CRUD, 쿼리 실행 | +| `/api/reports` | 리포트 생성 | + +#### 외부 연동 +| 라우트 | 역할 | +|--------|------| +| `/api/external-db-connections` | 외부 DB 연결 (PostgreSQL, MySQL, MariaDB, MSSQL, Oracle) | +| `/api/external-rest-api-connections` | 외부 REST API 연결 | +| `/api/mail` | 메일 발송/수신/템플릿 | +| `/api/tax-invoice` | 세금계산서 | + +#### 특수 도메인 +| 라우트 | 역할 | +|--------|------| +| `/api/delivery` | 배송/화물 관리 | +| `/api/risk-alerts` | 위험 알림 | +| `/api/todos` | 할일 관리 | +| `/api/bookings` | 예약 관리 | +| `/api/digital-twin` | 디지털 트윈 (야드 모니터링) | +| `/api/schedule` | 스케줄 자동 생성 | +| `/api/vehicle` | 차량 운행 | +| `/api/driver` | 운전자 관리 | +| `/api/files` | 파일 업로드/다운로드 | +| `/api/ddl` | DDL 실행 (슈퍼관리자 전용) | + +### 4.4 서비스 레이어 패턴 + +```typescript +// 표준 서비스 패턴 +class ExampleService { + // 목록 조회 (멀티테넌시 적용) + async findAll(companyCode: string, filters?: any) { + if (companyCode === "*") { + // 슈퍼관리자: 전체 데이터 + return await db.query("SELECT * FROM table ORDER BY company_code"); + } else { + // 일반 사용자: 자기 회사 데이터만 + return await db.query( + "SELECT * FROM table WHERE company_code = $1", + [companyCode] + ); + } + } +} +``` + +### 4.5 에러 처리 전략 + +```typescript +// 전역 에러 핸들러 (errorHandler.ts) +- PostgreSQL 에러: 중복키(23505), 외래키(23503), 널 제약(23502) 등 +- JWT 에러: 만료, 유효하지 않은 토큰 +- 일반 에러: 500 Internal Server Error +- 개발 환경: 상세 에러 스택 포함 +- 운영 환경: 일반적인 에러 메시지만 반환 +``` + +--- + +## 5. 프론트엔드 아키텍처 + +### 5.1 디렉토리 구조 + +``` +frontend/ +├── app/ # Next.js App Router +│ ├── (auth)/ # 인증 (로그인) +│ ├── (main)/ # 메인 앱 (인증 필요) +│ ├── (pop)/ # 모바일/팝업 +│ └── (admin)/ # 특수 관리자 +├── components/ # React 컴포넌트 +│ ├── screen/ # 화면 디자이너 & 뷰어 +│ ├── admin/ # 관리 기능 +│ ├── dashboard/ # 대시보드 위젯 +│ ├── dataflow/ # 데이터플로우 디자이너 +│ ├── v2/ # V2 통합 컴포넌트 +│ ├── ui/ # shadcn/ui 기본 컴포넌트 +│ └── report/ # 리포트 디자이너 +├── lib/ +│ ├── api/ # API 클라이언트 (57개 모듈) +│ ├── registry/ # 컴포넌트 레지스트리 (482개) +│ ├── utils/ # 유틸리티 +│ └── v2-core/ # V2 코어 로직 +├── contexts/ # React Context (인증, 메뉴, 화면 등) +├── hooks/ # Custom Hooks +├── stores/ # Zustand 상태관리 +└── middleware.ts # Next.js 인증 미들웨어 +``` + +### 5.2 페이지 라우팅 구조 + +``` +/login → 로그인 +/main → 메인 대시보드 +/screens/[screenId] → 동적 화면 뷰어 (사용자) + +/admin/screenMng/screenMngList → 화면 관리 +/admin/screenMng/dashboardList → 대시보드 관리 +/admin/screenMng/reportList → 리포트 관리 +/admin/systemMng/tableMngList → 테이블 관리 +/admin/systemMng/commonCodeList → 공통코드 관리 +/admin/systemMng/dataflow → 데이터플로우 관리 +/admin/systemMng/i18nList → 다국어 관리 +/admin/userMng/userMngList → 사용자 관리 +/admin/userMng/companyList → 회사 관리 +/admin/userMng/rolesList → 권한 관리 +/admin/automaticMng/flowMgmtList → 플로우 관리 +/admin/automaticMng/batchmngList → 배치 관리 +/admin/automaticMng/mail/* → 메일 시스템 +/admin/menu → 메뉴 관리 + +/dashboard/[dashboardId] → 대시보드 뷰어 +/pop/work → 모바일 작업 화면 +``` + +### 5.3 V2 통합 컴포넌트 시스템 + +**"하나의 컴포넌트, 여러 모드"** 철학으로 설계된 10개 통합 컴포넌트: + +| 컴포넌트 | 모드 | 역할 | +|----------|------|------| +| **V2Input** | text, number, password, slider, color | 텍스트/숫자 입력 | +| **V2Select** | dropdown, radio, checkbox, tag, toggle | 선택 입력 | +| **V2Date** | date, datetime, time, range | 날짜/시간 입력 | +| **V2List** | table, card, kanban, list | 데이터 목록 표시 | +| **V2Layout** | grid, split-panel, flex | 레이아웃 구성 | +| **V2Group** | tab, accordion, section, modal | 그룹 컨테이너 | +| **V2Media** | image, video, audio, file | 미디어 표시 | +| **V2Biz** | flow, rack, numbering-rule | 비즈니스 로직 | +| **V2Hierarchy** | tree, org-chart, BOM, cascading | 계층 구조 | +| **V2Repeater** | inline-table, modal, button | 반복 데이터 | + +### 5.4 API 클라이언트 규칙 + +```typescript +// 절대 금지: fetch 직접 사용 +const res = await fetch('/api/flow/definitions'); // ❌ + +// 반드시 사용: lib/api/ 클라이언트 +import { getFlowDefinitions } from '@/lib/api/flow'; +const res = await getFlowDefinitions(); // ✅ +``` + +환경별 URL 자동 처리: +| 환경 | 프론트엔드 | 백엔드 API | +|------|-----------|-----------| +| 로컬 개발 | localhost:9771 | localhost:8080/api | +| 운영 | v1.vexplor.com | api.vexplor.com/api | + +### 5.5 상태 관리 체계 + +``` +전역 상태 +├── AuthContext → 인증/세션/토큰 +├── MenuContext → 메뉴 트리/권한 +├── ScreenPreviewContext → 프리뷰 모드 +├── ScreenMultiLangContext → 다국어 라벨 +├── TableOptionsContext → 테이블 옵션 +└── ActiveTabContext → 활성 탭 + +로컬 상태 +├── Zustand Stores → 화면 디자이너 상태, 사용자 상태 +└── React Query → 서버 데이터 캐시 (5분 stale, 30분 GC) +``` + +### 5.6 레지스트리 시스템 + +```typescript +// 컴포넌트 등록 (482개 등록됨) +ComponentRegistry.registerComponent({ + id: "v2-input", + name: "통합 입력", + category: ComponentCategory.V2, + component: V2Input, + configPanel: V2InputConfigPanel, + defaultConfig: { inputType: "text" } +}); + +// 동적 렌더링 + +``` + +--- + +## 6. 데이터베이스 구조 + +### 6.1 테이블 도메인별 분류 + +#### 사용자/인증/회사 +| 테이블 | 역할 | +|--------|------| +| `company_mng` | 회사 마스터 | +| `user_info` | 사용자 정보 | +| `user_info_history` | 사용자 변경 이력 | +| `user_dept` | 사용자-부서 매핑 | +| `dept_info` | 부서 정보 | +| `authority_master` | 권한 그룹 마스터 | +| `authority_sub_user` | 사용자-권한 매핑 | +| `login_access_log` | 로그인 로그 | + +#### 메뉴/화면 +| 테이블 | 역할 | +|--------|------| +| `menu_info` | 메뉴 트리 구조 | +| `screen_definitions` | 화면 정의 (screenId, 테이블명 등) | +| `screen_layouts_v2` | V2 레이아웃 (JSON) | +| `screen_layouts` | V1 레이아웃 (레거시) | +| `screen_groups` | 화면 그룹 (계층구조) | +| `screen_group_screens` | 화면-그룹 매핑 | +| `screen_menu_assignments` | 화면-메뉴 할당 | +| `screen_field_joins` | 화면 필드 조인 설정 | +| `screen_data_flows` | 화면 데이터 플로우 | +| `screen_table_relations` | 화면-테이블 관계 | + +#### 메타데이터 +| 테이블 | 역할 | +|--------|------| +| `table_type_columns` | 테이블 타입별 컬럼 정의 (회사별) | +| `table_column_category_values` | 컬럼 카테고리 값 | +| `code_category` | 공통 코드 카테고리 | +| `code_info` | 공통 코드 값 | +| `category_column_mapping` | 카테고리-컬럼 매핑 | +| `cascading_relation` | 연쇄 드롭다운 관계 | +| `numbering_rules` | 채번 규칙 | +| `numbering_rule_parts` | 채번 규칙 파트 | + +#### 플로우/자동화 +| 테이블 | 역할 | +|--------|------| +| `flow_definition` | 플로우 정의 | +| `flow_step` | 플로우 단계 | +| `flow_step_connection` | 플로우 단계 연결 | +| `node_flows` | 노드 플로우 (버튼 액션) | +| `dataflow_diagrams` | 데이터플로우 다이어그램 | +| `batch_definitions` | 배치 작업 정의 | +| `batch_schedules` | 배치 스케줄 | +| `batch_execution_logs` | 배치 실행 로그 | + +#### 외부 연동 +| 테이블 | 역할 | +|--------|------| +| `external_db_connections` | 외부 DB 연결 정보 | +| `external_rest_api_connections` | 외부 REST API 연결 | + +#### 다국어 +| 테이블 | 역할 | +|--------|------| +| `multi_lang_key_master` | 다국어 키 마스터 | + +#### 기타 +| 테이블 | 역할 | +|--------|------| +| `work_history` | 작업 이력 | +| `todo_items` | 할일 목록 | +| `file_uploads` | 파일 업로드 | +| `ddl_audit_log` | DDL 감사 로그 | + +### 6.2 동적 테이블 생성 패턴 + +관리자가 화면 생성 시 비즈니스 테이블이 동적으로 생성된다: + +```sql +CREATE TABLE "dynamic_table_name" ( + "id" VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + "created_date" TIMESTAMP DEFAULT now(), + "updated_date" TIMESTAMP DEFAULT now(), + "writer" VARCHAR(500), + "company_code" VARCHAR(500), -- 멀티테넌시 필수! + -- 사용자 정의 컬럼들 (모두 VARCHAR(500)) + "product_name" VARCHAR(500), + "price" VARCHAR(500), + ... +); +CREATE INDEX idx_dynamic_company ON "dynamic_table_name"(company_code); +``` + +### 6.3 테이블 관계도 + +``` +company_mng (company_code PK) + │ + ├── user_info (company_code FK) + │ ├── authority_sub_user (user_id FK) + │ └── user_dept (user_id FK) + │ + ├── menu_info (company_code) + │ └── screen_menu_assignments (menu_objid FK) + │ + ├── screen_definitions (company_code) + │ ├── screen_layouts_v2 (screen_id FK) + │ ├── screen_groups → screen_group_screens (screen_id FK) + │ └── screen_field_joins (screen_id FK) + │ + ├── authority_master (company_code) + │ └── authority_sub_user (master_objid FK) + │ + ├── flow_definition (company_code) + │ ├── flow_step (flow_id FK) + │ └── flow_step_connection (flow_id FK) + │ + └── [동적 비즈니스 테이블들] (company_code) +``` + +--- + +## 7. 인증/인가 워크플로우 + +### 7.1 로그인 프로세스 + +``` +┌─── 사용자 ───┐ ┌─── 프론트엔드 ───┐ ┌─── 백엔드 ───┐ ┌─── DB ───┐ +│ │ │ │ │ │ │ │ +│ ID/PW 입력 │────→│ POST /auth/login │────→│ 비밀번호 검증 │────→│ user_info│ +│ │ │ │ │ │ │ 조회 │ +│ │ │ │ │ JWT 토큰 생성 │ │ │ +│ │ │ │←────│ 토큰 반환 │ │ │ +│ │ │ │ │ │ │ │ +│ │ │ localStorage 저장│ │ │ │ │ +│ │ │ Cookie 저장 │ │ │ │ │ +│ │ │ /main 리다이렉트 │ │ │ │ │ +└──────────────┘ └──────────────────┘ └──────────────┘ └──────────┘ +``` + +### 7.2 JWT 토큰 관리 + +``` +토큰 저장: localStorage (주 저장소) + Cookie (SSR 미들웨어용) + +자동 갱신: +├── 10분마다 만료 시간 체크 +├── 만료 30분 전: 백그라운드 자동 갱신 +├── 401 응답 시: 즉시 갱신 시도 +└── 갱신 실패 시: /login 리다이렉트 + +세션 관리: +├── 데스크톱: 30분 비활성 → 세션 만료 (5분 전 경고) +└── 모바일: 24시간 비활성 → 세션 만료 (1시간 전 경고) +``` + +### 7.3 권한 체계 (3단계) + +``` +SUPER_ADMIN (company_code = "*") +├── 모든 회사 데이터 접근 가능 +├── DDL 실행 가능 +├── 시스템 설정 변경 +└── 다른 회사로 전환 (switch-company) + +COMPANY_ADMIN (userType = "COMPANY_ADMIN") +├── 자기 회사 데이터만 접근 +├── 사용자 관리 가능 +└── 메뉴/화면 관리 가능 + +USER (일반 사용자) +├── 자기 회사 데이터만 접근 +├── 권한 그룹에 따른 메뉴 접근 +└── 할당된 화면만 사용 가능 +``` + +--- + +## 8. 화면 디자이너 워크플로우 + +### 8.1 관리자: 화면 설계 + +``` +Step 1: 화면 생성 + └→ /admin/screenMng/screenMngList + └→ "새 화면" 클릭 → 화면명, 설명, 메인 테이블 입력 + +Step 2: 화면 디자이너 진입 (ScreenDesigner.tsx) + ├── 좌측 패널: 컴포넌트 팔레트 (V2 컴포넌트 10종) + ├── 중앙 캔버스: 드래그앤드롭 영역 + └── 우측 패널: 선택된 컴포넌트 속성 설정 + +Step 3: 컴포넌트 배치 + └→ V2Input 드래그 → 캔버스 배치 → 속성 설정: + ├── 위치: x, y 좌표 + ├── 크기: width, height + ├── 데이터 바인딩: columnName = "product_name" + ├── 라벨: "제품명" + ├── 조건부 표시: 특정 조건에서만 보이기 + └── 플로우 연결: 버튼 클릭 시 실행할 플로우 + +Step 4: 레이아웃 저장 + └→ screen_layouts_v2 테이블에 JSON 형태로 저장 + └→ Zod 스키마 검증 → V2 형식 우선, V1 호환 저장 + +Step 5: 메뉴에 화면 할당 + └→ /admin/menu → 메뉴 트리에서 "제품 관리" 선택 + └→ 화면 연결 (screen_menu_assignments) +``` + +### 8.2 화면 레이아웃 저장 구조 (V2) + +```json +{ + "version": "v2", + "components": [ + { + "id": "comp-1", + "componentType": "v2-input", + "position": { "x": 100, "y": 50 }, + "size": { "width": 200, "height": 40 }, + "config": { + "inputType": "text", + "columnName": "product_name", + "label": "제품명", + "required": true + } + }, + { + "id": "comp-2", + "componentType": "v2-list", + "position": { "x": 100, "y": 150 }, + "size": { "width": 600, "height": 400 }, + "config": { + "listType": "table", + "tableName": "products", + "columns": ["product_name", "price", "quantity"] + } + } + ] +} +``` + +--- + +## 9. 사용자 업무 워크플로우 + +### 9.1 전체 흐름 + +``` +사용자 로그인 + ↓ +메인 대시보드 (/main) + ↓ +좌측 메뉴에서 "제품 관리" 클릭 + ↓ +/screens/[screenId] 라우팅 + ↓ +InteractiveScreenViewer 렌더링 + ├── screen_definitions에서 화면 정보 로드 + ├── screen_layouts_v2에서 레이아웃 JSON 로드 + ├── V2 → Legacy 변환 (호환성) + └── 메인 테이블 데이터 자동 로드 + ↓ +컴포넌트별 렌더링 + ├── V2Input → formData 바인딩 + ├── V2List → 테이블 데이터 표시 + ├── V2Select → 드롭다운/라디오 선택 + └── Button → 플로우/액션 연결 + ↓ +사용자 인터랙션 + ├── 폼 입력 → formData 업데이트 + ├── 테이블 행 선택 → selectedRowsData 업데이트 + └── 버튼 클릭 → 플로우 실행 + ↓ +플로우 실행 (nodeFlowButtonExecutor) + ├── Step 1: 데이터 검증 + ├── Step 2: API 호출 (INSERT/UPDATE/DELETE) + ├── Step 3: 성공/실패 처리 + └── Step 4: 테이블 자동 새로고침 +``` + +### 9.2 조건부 표시 워크플로우 + +``` +관리자 설정: + "특별 할인 입력" 컴포넌트 + └→ 조건: product_type === "PREMIUM" 일 때만 표시 + +사용자 사용: + 1. 화면 진입 → evaluateConditional() 실행 + 2. product_type ≠ "PREMIUM" → "특별 할인 입력" 숨김 + 3. 사용자가 product_type을 "PREMIUM"으로 변경 + 4. formData 업데이트 → evaluateConditional() 재평가 + 5. product_type === "PREMIUM" → "특별 할인 입력" 표시! +``` + +--- + +## 10. 플로우 엔진 워크플로우 + +### 10.1 플로우 정의 (관리자) + +``` +/admin/automaticMng/flowMgmtList + ↓ +플로우 생성: + ├── 이름: "제품 승인 플로우" + ├── 테이블: "products" + └── 단계 정의: + Step 1: "신청" (requester) + Step 2: "부서장 승인" (manager) + Step 3: "최종 승인" (director) + 연결: Step 1 → Step 2 → Step 3 +``` + +### 10.2 플로우 실행 (사용자) + +``` +1. 사용자: 제품 신청 + └→ "저장" 버튼 클릭 + └→ flowApi.startFlow() → 상태: "부서장 승인 대기" + +2. 부서장: 승인 화면 + └→ V2Biz (flow) 컴포넌트 → 현재 단계 표시 + └→ [승인] 클릭 → flowApi.approveStep() + └→ 상태: "최종 승인 대기" + +3. 이사: 최종 승인 + └→ [승인] 클릭 → flowApi.approveStep() + └→ 상태: "완료" + └→ products.approval_status = "APPROVED" +``` + +### 10.3 데이터 이동 (moveData) + +``` +플로우의 핵심 동작: 데이터를 한 스텝에서 다음 스텝으로 이동 + +Step 1 (접수) → Step 2 (검토) → Step 3 (완료) + ├── 단건 이동: moveData(flowId, dataId, fromStep, toStep) + └── 배치 이동: moveBatchData(flowId, dataIds[], fromStep, toStep) +``` + +--- + +## 11. 데이터플로우 시스템 + +### 11.1 개요 + +데이터플로우는 비즈니스 로직을 **비주얼 다이어그램**으로 설계하는 시스템이다. + +``` +/admin/systemMng/dataflow + ↓ +React Flow 기반 캔버스 + ├── InputNode: 데이터 입력 (폼 데이터, 테이블 데이터) + ├── TransformNode: 데이터 변환 (매핑, 필터링, 계산) + ├── DatabaseNode: DB 조회/저장 + ├── RestApiNode: 외부 API 호출 + ├── ConditionNode: 조건 분기 + ├── LoopNode: 반복 처리 + ├── MergeNode: 데이터 합치기 + └── OutputNode: 결과 출력 +``` + +### 11.2 데이터플로우 실행 + +``` +버튼 클릭 → 데이터플로우 트리거 + ↓ +InputNode: formData 수집 + ↓ +TransformNode: 데이터 가공 + ↓ +ConditionNode: 조건 분기 (가격 > 10000?) + ├── Yes → DatabaseNode: INSERT INTO premium_products + └── No → DatabaseNode: INSERT INTO standard_products + ↓ +OutputNode: 결과 반환 → toast.success("저장 완료") +``` + +--- + +## 12. 대시보드 시스템 + +### 12.1 구조 + +``` +관리자: /admin/screenMng/dashboardList + └→ 대시보드 생성 → 위젯 추가 → 레이아웃 저장 + +사용자: /dashboard/[dashboardId] + └→ 위젯 그리드 렌더링 → 실시간 데이터 표시 +``` + +### 12.2 위젯 종류 + +| 카테고리 | 위젯 | 역할 | +|----------|------|------| +| 시각화 | CustomMetricWidget | 커스텀 메트릭 표시 | +| | StatusSummaryWidget | 상태 요약 | +| 리스트 | CargoListWidget | 화물 목록 | +| | VehicleListWidget | 차량 목록 | +| 지도 | MapTestWidget | 지도 표시 | +| | WeatherMapWidget | 날씨 지도 | +| 작업 | TodoWidget | 할일 목록 | +| | WorkHistoryWidget | 작업 이력 | +| 알림 | BookingAlertWidget | 예약 알림 | +| | RiskAlertWidget | 위험 알림 | +| 기타 | ClockWidget | 시계 | +| | CalendarWidget | 캘린더 | + +--- + +## 13. 배치/스케줄 시스템 + +### 13.1 구조 + +``` +관리자: /admin/automaticMng/batchmngList + ↓ +배치 작업 생성: + ├── 이름: "일일 재고 집계" + ├── 실행 쿼리: SQL 또는 데이터플로우 ID + ├── 스케줄: Cron 표현식 ("0 0 * * *" = 매일 자정) + └── 활성화/비활성화 + ↓ +배치 스케줄러 (batch_schedules) + ↓ +자동 실행 → 실행 로그 (batch_execution_logs) +``` + +### 13.2 배치 실행 흐름 + +``` +Cron 트리거 → 배치 정의 조회 → SQL/데이터플로우 실행 + ↓ +성공: execution_log에 "SUCCESS" 기록 +실패: execution_log에 "FAILED" + 에러 메시지 기록 +``` + +--- + +## 14. 멀티테넌시 아키텍처 + +### 14.1 핵심 원칙 + +``` +모든 비즈니스 테이블: company_code 컬럼 필수 +모든 쿼리: WHERE company_code = $1 필수 +모든 JOIN: ON a.company_code = b.company_code 필수 +모든 집계: GROUP BY company_code 필수 +``` + +### 14.2 데이터 격리 + +``` +회사 A (company_code = "COMPANY_A"): + └→ 자기 데이터만 조회/수정/삭제 가능 + +회사 B (company_code = "COMPANY_B"): + └→ 자기 데이터만 조회/수정/삭제 가능 + +슈퍼관리자 (company_code = "*"): + └→ 모든 회사 데이터 조회 가능 + └→ 일반 회사는 "*" 데이터를 볼 수 없음 + +중요: company_code = "*"는 공통 데이터가 아니라 슈퍼관리자 전용 데이터! +``` + +### 14.3 코드 패턴 + +```typescript +// 백엔드 표준 패턴 +const companyCode = req.user!.companyCode; + +if (companyCode === "*") { + // 슈퍼관리자: 전체 데이터 + query = "SELECT * FROM table ORDER BY company_code"; +} else { + // 일반 사용자: 자기 회사만, "*" 제외 + query = "SELECT * FROM table WHERE company_code = $1 AND company_code != '*'"; + params = [companyCode]; +} +``` + +--- + +## 15. 외부 연동 + +### 15.1 외부 DB 연결 + +``` +지원 DB: PostgreSQL, MySQL, MariaDB, MSSQL, Oracle + +관리: /api/external-db-connections + ├── 연결 정보 등록 (host, port, database, credentials) + ├── 연결 테스트 + ├── 쿼리 실행 + └── 데이터플로우에서 DatabaseNode로 사용 +``` + +### 15.2 외부 REST API 연결 + +``` +관리: /api/external-rest-api-connections + ├── API 엔드포인트 등록 (URL, method, headers) + ├── 인증 설정 (Bearer, Basic, API Key) + ├── 테스트 호출 + └── 데이터플로우에서 RestApiNode로 사용 +``` + +### 15.3 메일 시스템 + +``` +관리: /admin/automaticMng/mail/* + ├── 메일 템플릿 관리 + ├── 메일 발송 (개별/대량) + ├── 수신 메일 확인 + └── 발송 이력 조회 +``` + +--- + +## 16. 배포 환경 + +### 16.1 Docker 구성 + +``` +개발 환경 (Mac): +├── docker/dev/docker-compose.backend.mac.yml (BE: 8080) +└── docker/dev/docker-compose.frontend.mac.yml (FE: 9771) + +운영 환경: +├── docker/prod/docker-compose.backend.prod.yml (BE: 8080) +└── docker/prod/docker-compose.frontend.prod.yml (FE: 5555) +``` + +### 16.2 서버 정보 + +| 환경 | 서버 | 포트 | DB | +|------|------|------|-----| +| 개발 | 39.117.244.52 | FE:9771, BE:8080 | 39.117.244.52:11132 | +| 운영 | 211.115.91.141 | FE:5555, BE:8080 | 211.115.91.141:11134 | + +### 16.3 백엔드 시작 시 자동 작업 + +``` +서버 시작 (app.ts) + ├── 마이그레이션 실행 (DB 스키마 업데이트) + ├── 배치 스케줄러 초기화 + ├── 위험 알림 캐시 로드 + └── 메일 정리 Cron 시작 +``` + +--- + +## 부록: 업무 진행 요약 + +### 새로운 업무 화면을 만드는 전체 프로세스 + +``` +1. [DB] 테이블 관리에서 비즈니스 테이블 생성 + └→ 컬럼 정의, 타입 설정 + +2. [화면] 화면 관리에서 새 화면 생성 + └→ 메인 테이블 지정 + +3. [디자인] 화면 디자이너에서 UI 구성 + └→ V2 컴포넌트 배치, 데이터 바인딩 + +4. [로직] 데이터플로우 설계 (필요시) + └→ 저장/수정/삭제 로직 다이어그램 + +5. [플로우] 플로우 정의 (승인 프로세스 필요시) + └→ 단계 정의, 연결 + +6. [메뉴] 메뉴에 화면 할당 + └→ 사용자가 접근할 수 있게 메뉴 트리 배치 + +7. [권한] 권한 그룹에 메뉴 할당 + └→ 특정 사용자 그룹만 접근 가능하게 + +8. [사용] 사용자가 메뉴 클릭 → 업무 시작! +``` diff --git a/docs/backend-analysis-README.md b/docs/backend-analysis-README.md new file mode 100644 index 00000000..27694d2e --- /dev/null +++ b/docs/backend-analysis-README.md @@ -0,0 +1,246 @@ +# WACE ERP Backend - 분석 문서 인덱스 + +> **분석 완료일**: 2026-02-06 +> **분석자**: Backend Specialist + +--- + +## 📚 문서 목록 + +### 1. 📖 상세 분석 문서 +**파일**: `backend-architecture-detailed-analysis.md` +**내용**: 백엔드 전체 아키텍처 상세 분석 (16개 섹션) + +- 전체 개요 및 기술 스택 +- 디렉토리 구조 +- 미들웨어 스택 구성 +- 인증/인가 시스템 (JWT, 3단계 권한) +- 멀티테넌시 구현 방식 +- API 라우트 전체 목록 +- 비즈니스 도메인별 모듈 (8개 도메인) +- 데이터베이스 접근 방식 (Raw Query) +- 외부 시스템 연동 (DB/REST API) +- 배치/스케줄 처리 (node-cron) +- 파일 처리 (multer) +- 에러 핸들링 +- 로깅 시스템 (Winston) +- 보안 및 권한 관리 +- 성능 최적화 + +**특징**: 워크플로우 문서에 통합하기 위한 완전한 아키텍처 분석 + +--- + +### 2. 📄 요약 문서 +**파일**: `backend-architecture-summary.md` +**내용**: 백엔드 아키텍처 핵심 요약 (16개 섹션 압축) + +- 기술 스택 요약 +- 계층 구조 다이어그램 +- 디렉토리 구조 +- 미들웨어 스택 순서 +- 인증/인가 흐름도 +- 멀티테넌시 핵심 원칙 +- API 라우트 카테고리별 정리 +- 비즈니스 도메인 8개 요약 +- 데이터베이스 접근 패턴 +- 외부 연동 아키텍처 +- 배치 스케줄러 시스템 +- 파일 처리 흐름 +- 보안 정책 +- 에러 핸들링 전략 +- 로깅 구조 +- 성능 최적화 전략 +- **핵심 체크리스트** (개발 시 필수 규칙 8개) + +**특징**: 빠른 참조를 위한 간결한 요약 + +--- + +### 3. 🔗 API 라우트 완전 매핑 +**파일**: `backend-api-route-mapping.md` +**내용**: 프론트엔드 개발자용 API 엔드포인트 전체 목록 (200+개) + +#### 포함된 API 카테고리 +1. 인증 API (7개) +2. 관리자 API (15개) +3. 테이블 관리 API (30개) +4. 화면 관리 API (10개) +5. 플로우 API (15개) +6. 데이터플로우 API (10개) +7. 외부 연동 API (15개) +8. 배치 API (10개) +9. 메일 API (5개) +10. 파일 API (5개) +11. 대시보드 API (5개) +12. 공통코드 API (3개) +13. 다국어 API (3개) +14. 회사 관리 API (4개) +15. 부서 API (2개) +16. 권한 그룹 API (2개) +17. DDL 실행 API (1개) +18. 외부 API 프록시 (2개) +19. 디지털 트윈 API (3개) +20. 3D 필드 API (2개) +21. 스케줄 API (1개) +22. 채번 규칙 API (3개) +23. 엔티티 검색 API (2개) +24. To-Do API (3개) +25. 예약 요청 API (2개) +26. 리스크/알림 API (2개) +27. 헬스 체크 (1개) + +#### 각 API 정보 포함 +- HTTP 메서드 +- 엔드포인트 경로 +- 필요 권한 (공개/인증/관리자/슈퍼관리자) +- 기능 설명 +- Request Body/Query Params +- Response 형식 + +#### 추가 정보 +- Base URL (개발/운영) +- 공통 헤더 (Authorization) +- 응답 형식 (성공/에러) +- 에러 코드 목록 + +**특징**: 프론트엔드에서 API 호출 시 즉시 참조 가능 + +--- + +### 4. 📊 JSON 응답 요약 +**파일**: `backend-analysis-response.json` +**내용**: 구조화된 JSON 형식의 분석 결과 + +```json +{ + "status": "success", + "confidence": "high", + "result": { + "summary": "...", + "details": "...", + "files_affected": [...], + "key_findings": { + "architecture_pattern": "...", + "tech_stack": {...}, + "middleware_stack": [...], + "authentication_flow": {...}, + "permission_levels": {...}, + "multi_tenancy": {...}, + "business_domains": {...}, + "database_access": {...}, + "security": {...}, + "performance_optimization": {...} + }, + "critical_rules": [...] + } +} +``` + +**특징**: 프로그래밍 방식으로 분석 결과 활용 가능 + +--- + +## 🎯 핵심 요약 + +### 아키텍처 +- **패턴**: Layered Architecture (Controller → Service → Database) +- **언어**: TypeScript (Strict Mode) +- **프레임워크**: Express.js +- **데이터베이스**: PostgreSQL (Raw Query, Connection Pool) +- **인증**: JWT (24시간 만료, 자동 갱신) + +### 멀티테넌시 +```typescript +// ✅ 핵심 원칙 +const companyCode = req.user!.companyCode; // JWT에서 추출 + +if (companyCode === "*") { + // 슈퍼관리자: 모든 데이터 + query = "SELECT * FROM table ORDER BY company_code"; +} else { + // 일반 사용자: 자기 회사만 + 슈퍼관리자 숨김 + query = "SELECT * FROM table WHERE company_code = $1 AND company_code != '*'"; + params = [companyCode]; +} +``` + +### 권한 체계 (3단계) +1. **SUPER_ADMIN** (`company_code = "*"`) + - 전체 회사 데이터 접근 + - DDL 실행, 회사 생성/삭제 + +2. **COMPANY_ADMIN** (`company_code = "ILSHIN"`) + - 자기 회사 데이터만 접근 + - 사용자/설정 관리 + +3. **USER** (`company_code = "ILSHIN"`) + - 자기 회사 데이터만 접근 + - 읽기/쓰기만 + +### 주요 도메인 (8개) +1. **관리자** - 사용자/메뉴/권한 +2. **테이블/화면** - 메타데이터, 동적 화면 +3. **플로우** - 워크플로우 엔진 +4. **데이터플로우** - ERD, 관계도 +5. **외부 연동** - 외부 DB/REST API +6. **배치** - Cron 스케줄러 +7. **메일** - 발송/수신 +8. **파일** - 업로드/다운로드 + +### API 통계 +- **총 라우트**: 70+개 +- **총 API**: 200+개 +- **컨트롤러**: 70+개 +- **서비스**: 80+개 +- **미들웨어**: 4개 + +--- + +## 🚨 개발 시 필수 규칙 + +✅ **모든 쿼리에 `company_code` 필터 추가** +✅ **JWT 토큰에서 `company_code` 추출 (클라이언트 신뢰 금지)** +✅ **Parameterized Query 사용 (SQL Injection 방지)** +✅ **슈퍼관리자 데이터 숨김 (`company_code != '*'`)** +✅ **비밀번호는 bcrypt, 민감정보는 AES-256** +✅ **에러 핸들링 try/catch 필수** +✅ **트랜잭션이 필요한 경우 `transaction()` 사용** +✅ **파일 업로드는 회사별 디렉토리 분리** + +--- + +## 📁 문서 위치 + +``` +ERP-node/docs/ +├── backend-architecture-detailed-analysis.md (상세 분석, 16개 섹션) +├── backend-architecture-summary.md (요약, 간결한 참조) +├── backend-api-route-mapping.md (API 200+개 전체 매핑) +└── backend-analysis-response.json (JSON 구조화 데이터) +``` + +--- + +## 🔍 문서 사용 가이드 + +### 처음 백엔드를 이해하려면 +→ `backend-architecture-summary.md` 읽기 (20분) + +### 특정 기능을 구현하려면 +→ `backend-architecture-detailed-analysis.md`에서 해당 도메인 섹션 참조 + +### API를 호출하려면 +→ `backend-api-route-mapping.md`에서 엔드포인트 검색 + +### 워크플로우 문서에 통합하려면 +→ `backend-architecture-detailed-analysis.md` 전체 복사 + +### 프로그래밍 방식으로 활용하려면 +→ `backend-analysis-response.json` 파싱 + +--- + +**문서 버전**: 1.0 +**마지막 업데이트**: 2026-02-06 +**다음 업데이트 예정**: 신규 API 추가 시 diff --git a/docs/backend-analysis-response.json b/docs/backend-analysis-response.json new file mode 100644 index 00000000..b6b11bb1 --- /dev/null +++ b/docs/backend-analysis-response.json @@ -0,0 +1,239 @@ +{ + "status": "success", + "confidence": "high", + "result": { + "summary": "WACE ERP 백엔드 전체 아키텍처 분석 완료", + "details": "Node.js + Express + TypeScript + PostgreSQL Raw Query 기반 멀티테넌시 시스템. 70+ 라우트, 70+ 컨트롤러, 80+ 서비스로 구성된 계층형 아키텍처. JWT 인증, 3단계 권한 체계(SUPER_ADMIN/COMPANY_ADMIN/USER), company_code 기반 완전한 데이터 격리 구현.", + "files_affected": [ + "docs/backend-architecture-detailed-analysis.md (상세 분석 문서)", + "docs/backend-architecture-summary.md (요약 문서)", + "docs/backend-api-route-mapping.md (API 라우트 전체 매핑)" + ], + "key_findings": { + "architecture_pattern": "Layered Architecture (Controller → Service → Database)", + "tech_stack": { + "language": "TypeScript", + "runtime": "Node.js 20.10.0+", + "framework": "Express.js", + "database": "PostgreSQL (pg 라이브러리, Raw Query)", + "authentication": "JWT (jsonwebtoken)", + "scheduler": "node-cron", + "external_db_support": ["PostgreSQL", "MySQL", "MSSQL", "Oracle"] + }, + "directory_structure": { + "controllers": "70+ 파일 (API 요청 수신, 응답 생성)", + "services": "80+ 파일 (비즈니스 로직, 트랜잭션 관리)", + "routes": "70+ 파일 (API 라우팅)", + "middleware": "4개 (인증, 권한, 슈퍼관리자, 에러핸들러)", + "types": "26개 (TypeScript 타입 정의)", + "utils": "유틸리티 함수 (JWT, 암호화, 로거)" + }, + "middleware_stack": [ + "1. Process Level Exception Handlers", + "2. Helmet (보안 헤더)", + "3. Compression (Gzip)", + "4. Body Parser (10MB limit)", + "5. Static Files (/uploads)", + "6. CORS (credentials: true)", + "7. Rate Limiting (1분 10000회)", + "8. Token Auto Refresh (1시간 이내 만료 시 갱신)", + "9. API Routes (70+개)", + "10. 404 Handler", + "11. Error Handler" + ], + "authentication_flow": { + "step1": "로그인 요청 → AuthController.login()", + "step2": "AuthService.processLogin() → loginPwdCheck() (bcrypt 검증)", + "step3": "getPersonBeanFromSession() → 사용자 정보 조회", + "step4": "insertLoginAccessLog() → 로그인 이력 저장", + "step5": "JwtUtils.generateToken() → JWT 토큰 생성", + "step6": "응답: { token, userInfo, firstMenuPath }" + }, + "jwt_payload": { + "userId": "사용자 ID", + "userName": "사용자명", + "companyCode": "회사 코드 (멀티테넌시 키)", + "userType": "권한 레벨 (SUPER_ADMIN/COMPANY_ADMIN/USER)", + "exp": "만료 시간 (24시간)" + }, + "permission_levels": { + "SUPER_ADMIN": { + "company_code": "*", + "userType": "SUPER_ADMIN", + "capabilities": [ + "전체 회사 데이터 접근", + "DDL 실행", + "회사 생성/삭제", + "시스템 설정 변경" + ] + }, + "COMPANY_ADMIN": { + "company_code": "특정 회사 (예: ILSHIN)", + "userType": "COMPANY_ADMIN", + "capabilities": [ + "자기 회사 데이터만 접근", + "자기 회사 사용자 관리", + "회사 설정 변경" + ] + }, + "USER": { + "company_code": "특정 회사", + "userType": "USER", + "capabilities": [ + "자기 회사 데이터만 접근", + "읽기/쓰기 권한만" + ] + } + }, + "multi_tenancy": { + "principle": "모든 쿼리에 company_code 필터 필수", + "pattern": "JWT 토큰에서 company_code 추출 (클라이언트 신뢰 금지)", + "super_admin_visibility": "일반 회사 사용자에게 슈퍼관리자(company_code='*') 숨김", + "correct_pattern": "WHERE company_code = $1 AND company_code != '*'", + "wrong_pattern": "req.body.companyCode 사용 (보안 위험!)" + }, + "api_routes": { + "total_count": "200+개", + "categories": { + "인증/관리자": "15개", + "테이블/화면": "40개", + "플로우": "15개", + "데이터플로우": "5개", + "외부 연동": "15개", + "배치": "10개", + "메일": "5개", + "파일": "5개", + "기타": "90개" + } + }, + "business_domains": { + "관리자": { + "controller": "adminController.ts", + "service": "adminService.ts", + "features": ["사용자 관리", "메뉴 관리", "권한 그룹 관리", "시스템 설정"] + }, + "테이블/화면": { + "controller": "tableManagementController.ts, screenManagementController.ts", + "service": "tableManagementService.ts, screenManagementService.ts", + "features": ["테이블 메타데이터", "화면 정의", "화면 그룹", "테이블 로그", "엔티티 관계"] + }, + "플로우": { + "controller": "flowController.ts", + "service": "flowExecutionService.ts, flowDefinitionService.ts", + "features": ["워크플로우 설계", "단계 관리", "데이터 이동", "조건부 이동", "오딧 로그"] + }, + "데이터플로우": { + "controller": "dataflowController.ts, dataflowDiagramController.ts", + "service": "dataflowService.ts, dataflowDiagramService.ts", + "features": ["테이블 관계 정의", "ERD", "다이어그램 시각화", "관계 실행"] + }, + "외부 연동": { + "controller": "externalDbConnectionController.ts, externalRestApiConnectionController.ts", + "service": "externalDbConnectionService.ts, dbConnectionManager.ts", + "features": ["외부 DB 연결", "Connection Pool 관리", "REST API 프록시"] + }, + "배치": { + "controller": "batchController.ts, batchManagementController.ts", + "service": "batchService.ts, batchSchedulerService.ts", + "features": ["Cron 스케줄러", "외부 DB → 내부 DB 동기화", "컬럼 매핑", "실행 이력"] + }, + "메일": { + "controller": "mailSendSimpleController.ts, mailReceiveBasicController.ts", + "service": "mailSendSimpleService.ts, mailReceiveBasicService.ts", + "features": ["메일 발송 (nodemailer)", "메일 수신 (IMAP)", "템플릿 관리", "첨부파일"] + }, + "파일": { + "controller": "fileController.ts, screenFileController.ts", + "service": "fileSystemManager.ts", + "features": ["파일 업로드 (multer)", "파일 다운로드", "화면별 파일 관리"] + } + }, + "database_access": { + "connection_pool": { + "min": "2~5 (환경별)", + "max": "10~20 (환경별)", + "connectionTimeout": "30000ms", + "idleTimeout": "600000ms", + "statementTimeout": "60000ms" + }, + "query_patterns": { + "multi_row": "query('SELECT ...', [params])", + "single_row": "queryOne('SELECT ...', [params])", + "transaction": "transaction(async (client) => { ... })" + }, + "sql_injection_prevention": "Parameterized Query 사용 (pg 라이브러리)" + }, + "external_integration": { + "supported_databases": ["PostgreSQL", "MySQL", "MSSQL", "Oracle"], + "connector_pattern": "Factory Pattern (DatabaseConnectorFactory)", + "rest_api": "axios 기반 프록시" + }, + "batch_scheduler": { + "library": "node-cron", + "timezone": "Asia/Seoul", + "cron_examples": { + "매일 새벽 2시": "0 2 * * *", + "5분마다": "*/5 * * * *", + "평일 오전 8시": "0 8 * * 1-5" + }, + "execution_flow": [ + "1. 소스 DB에서 데이터 조회", + "2. 컬럼 매핑 적용", + "3. 타겟 DB에 INSERT/UPDATE", + "4. 실행 로그 기록" + ] + }, + "file_handling": { + "upload_path": "uploads/{company_code}/{timestamp}-{uuid}-{filename}", + "max_file_size": "10MB", + "allowed_types": ["이미지", "PDF", "Office 문서"], + "library": "multer" + }, + "security": { + "password_encryption": "bcrypt (12 rounds)", + "sensitive_data_encryption": "AES-256-CBC (외부 DB 비밀번호)", + "jwt_secret": "환경변수 관리", + "security_headers": ["Helmet (CSP, X-Frame-Options)", "CORS (credentials: true)", "Rate Limiting (1분 10000회)"], + "sql_injection_prevention": "Parameterized Query" + }, + "error_handling": { + "postgres_error_codes": { + "23505": "중복된 데이터", + "23503": "참조 무결성 위반", + "23502": "필수 입력값 누락" + }, + "process_level": { + "unhandledRejection": "로깅 (서버 유지)", + "uncaughtException": "로깅 (서버 유지, 주의)", + "SIGTERM/SIGINT": "Graceful Shutdown" + } + }, + "logging": { + "library": "Winston", + "log_files": { + "error.log": "에러만 (10MB × 5파일)", + "combined.log": "전체 로그 (10MB × 10파일)" + }, + "log_levels": "error (0) → warn (1) → info (2) → debug (5)" + }, + "performance_optimization": { + "pool_monitoring": "5분마다 상태 체크, 대기 연결 5개 이상 시 경고", + "slow_query_detection": "1초 이상 걸린 쿼리 자동 경고", + "caching": "Redis (메뉴: 10분 TTL, 공통코드: 30분 TTL)", + "compression": "Gzip (1KB 이상 응답, 레벨 6)" + } + }, + "critical_rules": [ + "✅ 모든 쿼리에 company_code 필터 추가", + "✅ JWT 토큰에서 company_code 추출 (클라이언트 신뢰 금지)", + "✅ Parameterized Query 사용 (SQL Injection 방지)", + "✅ 슈퍼관리자 데이터 숨김 (company_code != '*')", + "✅ 비밀번호는 bcrypt, 민감정보는 AES-256", + "✅ 에러 핸들링 try/catch 필수", + "✅ 트랜잭션이 필요한 경우 transaction() 사용", + "✅ 파일 업로드는 회사별 디렉토리 분리" + ] + }, + "needs_from_others": [], + "questions": [] +} diff --git a/docs/backend-api-route-mapping.md b/docs/backend-api-route-mapping.md new file mode 100644 index 00000000..972f64b1 --- /dev/null +++ b/docs/backend-api-route-mapping.md @@ -0,0 +1,542 @@ +# WACE ERP Backend - API 라우트 완전 매핑 + +> **작성일**: 2026-02-06 +> **목적**: 프론트엔드 개발자용 API 엔드포인트 전체 목록 + +--- + +## 📌 공통 규칙 + +### Base URL +``` +개발: http://localhost:8080 +운영: http://39.117.244.52:8080 +``` + +### 헤더 +```http +Content-Type: application/json +Authorization: Bearer {JWT_TOKEN} +``` + +### 응답 형식 +```json +{ + "success": true, + "message": "성공 메시지", + "data": { ... } +} + +// 에러 시 +{ + "success": false, + "error": { + "code": "ERROR_CODE", + "details": "에러 상세" + } +} +``` + +--- + +## 1. 인증 API (`/api/auth`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| POST | `/auth/login` | 공개 | 로그인 | `{ userId, password }` | `{ token, userInfo, firstMenuPath }` | +| POST | `/auth/logout` | 인증 | 로그아웃 | - | `{ success: true }` | +| GET | `/auth/me` | 인증 | 현재 사용자 정보 | - | `{ userInfo }` | +| GET | `/auth/status` | 공개 | 인증 상태 확인 | - | `{ isLoggedIn, isAdmin }` | +| POST | `/auth/refresh` | 인증 | 토큰 갱신 | - | `{ token }` | +| POST | `/auth/signup` | 공개 | 회원가입 (공차중계) | `{ userId, password, userName, phoneNumber, licenseNumber, vehicleNumber }` | `{ success: true }` | +| POST | `/auth/switch-company` | 슈퍼관리자 | 회사 전환 | `{ companyCode }` | `{ token, companyCode }` | + +--- + +## 2. 관리자 API (`/api/admin`) + +### 2.1 사용자 관리 + +| 메서드 | 경로 | 권한 | 기능 | Query Params | Response | +|--------|------|------|------|--------------|----------| +| GET | `/admin/users` | 관리자 | 사용자 목록 | `page, limit, search` | `{ users[], total }` | +| POST | `/admin/users` | 관리자 | 사용자 생성 | - | `{ user }` | +| PUT | `/admin/users/:userId` | 관리자 | 사용자 수정 | - | `{ user }` | +| DELETE | `/admin/users/:userId` | 관리자 | 사용자 삭제 | - | `{ success: true }` | +| GET | `/admin/users/:userId/history` | 관리자 | 사용자 이력 | - | `{ history[] }` | + +### 2.2 메뉴 관리 + +| 메서드 | 경로 | 권한 | 기능 | Query Params | Response | +|--------|------|------|------|--------------|----------| +| GET | `/admin/menus` | 인증 | 메뉴 목록 (트리) | `userId, userLang` | `{ menus[] }` | +| POST | `/admin/menus` | 관리자 | 메뉴 생성 | - | `{ menu }` | +| PUT | `/admin/menus/:menuId` | 관리자 | 메뉴 수정 | - | `{ menu }` | +| DELETE | `/admin/menus/:menuId` | 관리자 | 메뉴 삭제 | - | `{ success: true }` | + +### 2.3 표준 관리 + +| 메서드 | 경로 | 권한 | 기능 | Response | +|--------|------|------|------|----------| +| GET | `/admin/web-types` | 인증 | 웹타입 표준 목록 | `{ webTypes[] }` | +| GET | `/admin/button-actions` | 인증 | 버튼 액션 표준 | `{ buttonActions[] }` | +| GET | `/admin/component-standards` | 인증 | 컴포넌트 표준 | `{ components[] }` | +| GET | `/admin/template-standards` | 인증 | 템플릿 표준 | `{ templates[] }` | +| GET | `/admin/reports` | 인증 | 리포트 목록 | `{ reports[] }` | + +--- + +## 3. 테이블 관리 API (`/api/table-management`) + +### 3.1 테이블 메타데이터 + +| 메서드 | 경로 | 권한 | 기능 | Response | +|--------|------|------|------|----------| +| GET | `/table-management/tables` | 인증 | 테이블 목록 | `{ tables[] }` | +| GET | `/table-management/tables/:table/columns` | 인증 | 컬럼 목록 | `{ columns[] }` | +| GET | `/table-management/tables/:table/schema` | 인증 | 테이블 스키마 | `{ schema }` | +| GET | `/table-management/tables/:table/exists` | 인증 | 테이블 존재 여부 | `{ exists: boolean }` | +| GET | `/table-management/tables/:table/web-types` | 인증 | 웹타입 정보 | `{ webTypes }` | + +### 3.2 컬럼 설정 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | +|--------|------|------|------|--------------| +| POST | `/table-management/tables/:table/columns/:column/settings` | 인증 | 컬럼 설정 업데이트 | `{ web_type, input_type, ... }` | +| POST | `/table-management/tables/:table/columns/settings` | 인증 | 전체 컬럼 일괄 업데이트 | `{ columns[] }` | +| PUT | `/table-management/tables/:table/label` | 인증 | 테이블 라벨 설정 | `{ label }` | + +### 3.3 데이터 CRUD + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| POST | `/table-management/tables/:table/data` | 인증 | 데이터 조회 (페이징) | `{ page, limit, filters, sort }` | `{ data[], total }` | +| POST | `/table-management/tables/:table/record` | 인증 | 단일 레코드 조회 | `{ conditions }` | `{ record }` | +| POST | `/table-management/tables/:table/add` | 인증 | 데이터 추가 | `{ data }` | `{ success: true, id }` | +| PUT | `/table-management/tables/:table/edit` | 인증 | 데이터 수정 | `{ conditions, data }` | `{ success: true }` | +| DELETE | `/table-management/tables/:table/delete` | 인증 | 데이터 삭제 | `{ conditions }` | `{ success: true }` | + +### 3.4 다중 테이블 저장 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | +|--------|------|------|------|--------------| +| POST | `/table-management/multi-table-save` | 인증 | 메인+서브 테이블 저장 | `{ mainTable, mainData, subTables: [{ table, data[] }] }` | + +### 3.5 로그 시스템 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | +|--------|------|------|------|--------------| +| POST | `/table-management/tables/:table/log` | 관리자 | 로그 테이블 생성 | - | +| GET | `/table-management/tables/:table/log/config` | 인증 | 로그 설정 조회 | - | +| GET | `/table-management/tables/:table/log` | 인증 | 로그 데이터 조회 | - | +| POST | `/table-management/tables/:table/log/toggle` | 관리자 | 로그 활성화/비활성화 | `{ is_active }` | + +### 3.6 엔티티 관계 + +| 메서드 | 경로 | 권한 | 기능 | Query Params | +|--------|------|------|------|--------------| +| GET | `/table-management/tables/entity-relations` | 인증 | 두 테이블 간 관계 조회 | `leftTable, rightTable` | +| GET | `/table-management/columns/:table/referenced-by` | 인증 | 현재 테이블 참조 목록 | - | + +### 3.7 카테고리 관리 + +| 메서드 | 경로 | 권한 | 기능 | Response | +|--------|------|------|------|----------| +| GET | `/table-management/category-columns` | 인증 | 회사별 카테고리 컬럼 | `{ categoryColumns[] }` | +| GET | `/table-management/menu/:menuObjid/category-columns` | 인증 | 메뉴별 카테고리 컬럼 | `{ categoryColumns[] }` | + +--- + +## 4. 화면 관리 API (`/api/screen-management`) + +| 메서드 | 경로 | 권한 | 기능 | Query Params | Response | +|--------|------|------|------|--------------|----------| +| GET | `/screen-management/screens` | 인증 | 화면 목록 | `page, limit` | `{ screens[], total }` | +| GET | `/screen-management/screens/:id` | 인증 | 화면 상세 | - | `{ screen }` | +| POST | `/screen-management/screens` | 관리자 | 화면 생성 | - | `{ screen }` | +| PUT | `/screen-management/screens/:id` | 관리자 | 화면 수정 | - | `{ screen }` | +| DELETE | `/screen-management/screens/:id` | 관리자 | 화면 삭제 | - | `{ success: true }` | + +### 화면 그룹 + +| 메서드 | 경로 | 권한 | 기능 | Response | +|--------|------|------|------|----------| +| GET | `/screen-groups` | 인증 | 화면 그룹 목록 | `{ screenGroups[] }` | +| POST | `/screen-groups` | 관리자 | 그룹 생성 | `{ group }` | + +### 화면 파일 + +| 메서드 | 경로 | 권한 | 기능 | Response | +|--------|------|------|------|----------| +| GET | `/screen-files` | 인증 | 화면 파일 목록 | `{ files[] }` | +| POST | `/screen-files` | 관리자 | 파일 업로드 | `{ file }` | + +--- + +## 5. 플로우 API (`/api/flow`) + +### 5.1 플로우 정의 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/flow/definitions` | 인증 | 플로우 목록 | - | `{ flows[] }` | +| GET | `/flow/definitions/:id` | 인증 | 플로우 상세 | - | `{ flow }` | +| POST | `/flow/definitions` | 인증 | 플로우 생성 | `{ name, description, targetTable }` | `{ flow }` | +| PUT | `/flow/definitions/:id` | 인증 | 플로우 수정 | `{ name, description }` | `{ flow }` | +| DELETE | `/flow/definitions/:id` | 인증 | 플로우 삭제 | - | `{ success: true }` | + +### 5.2 단계 관리 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/flow/definitions/:flowId/steps` | 인증 | 단계 목록 | - | `{ steps[] }` | +| POST | `/flow/definitions/:flowId/steps` | 인증 | 단계 생성 | `{ name, type, settings }` | `{ step }` | +| PUT | `/flow/steps/:stepId` | 인증 | 단계 수정 | `{ name, settings }` | `{ step }` | +| DELETE | `/flow/steps/:stepId` | 인증 | 단계 삭제 | - | `{ success: true }` | + +### 5.3 연결 관리 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/flow/connections/:flowId` | 인증 | 연결 목록 | - | `{ connections[] }` | +| POST | `/flow/connections` | 인증 | 연결 생성 | `{ fromStepId, toStepId, condition }` | `{ connection }` | +| DELETE | `/flow/connections/:connectionId` | 인증 | 연결 삭제 | - | `{ success: true }` | + +### 5.4 데이터 이동 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| POST | `/flow/move` | 인증 | 데이터 이동 (단건) | `{ flowId, fromStepId, toStepId, recordId }` | `{ success: true }` | +| POST | `/flow/move-batch` | 인증 | 데이터 이동 (다건) | `{ flowId, fromStepId, toStepId, recordIds[] }` | `{ success: true, movedCount }` | + +### 5.5 단계 데이터 조회 + +| 메서드 | 경로 | 권한 | 기능 | Query Params | Response | +|--------|------|------|------|--------------|----------| +| GET | `/flow/:flowId/step/:stepId/count` | 인증 | 단계 데이터 개수 | - | `{ count }` | +| GET | `/flow/:flowId/step/:stepId/list` | 인증 | 단계 데이터 목록 | `page, limit` | `{ data[], total }` | +| GET | `/flow/:flowId/step/:stepId/column-labels` | 인증 | 컬럼 라벨 조회 | - | `{ labels }` | +| GET | `/flow/:flowId/steps/counts` | 인증 | 모든 단계 카운트 | - | `{ counts[] }` | + +### 5.6 단계 데이터 수정 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| PUT | `/flow/:flowId/step/:stepId/data/:recordId` | 인증 | 인라인 편집 | `{ data }` | `{ success: true }` | + +### 5.7 오딧 로그 + +| 메서드 | 경로 | 권한 | 기능 | Response | +|--------|------|------|------|----------| +| GET | `/flow/audit/:flowId/:recordId` | 인증 | 레코드별 오딧 로그 | `{ auditLogs[] }` | +| GET | `/flow/audit/:flowId` | 인증 | 플로우 전체 오딧 로그 | `{ auditLogs[] }` | + +--- + +## 6. 데이터플로우 API (`/api/dataflow`) + +### 6.1 관계 관리 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/dataflow/relationships` | 인증 | 관계 목록 | - | `{ relationships[] }` | +| POST | `/dataflow/relationships` | 인증 | 관계 생성 | `{ fromTable, toTable, fromColumn, toColumn, type }` | `{ relationship }` | +| PUT | `/dataflow/relationships/:id` | 인증 | 관계 수정 | `{ name, type }` | `{ relationship }` | +| DELETE | `/dataflow/relationships/:id` | 인증 | 관계 삭제 | - | `{ success: true }` | + +### 6.2 다이어그램 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/dataflow-diagrams` | 인증 | 다이어그램 목록 | - | `{ diagrams[] }` | +| GET | `/dataflow-diagrams/:id` | 인증 | 다이어그램 상세 | - | `{ diagram }` | +| POST | `/dataflow-diagrams` | 인증 | 다이어그램 생성 | `{ name, description }` | `{ diagram }` | + +### 6.3 실행 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| POST | `/dataflow` | 인증 | 데이터플로우 실행 | `{ relationshipId, params }` | `{ result[] }` | + +--- + +## 7. 외부 연동 API + +### 7.1 외부 DB 연결 (`/api/external-db-connections`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/external-db-connections` | 인증 | 연결 목록 | - | `{ connections[] }` | +| GET | `/external-db-connections/:id` | 인증 | 연결 상세 | - | `{ connection }` | +| POST | `/external-db-connections` | 관리자 | 연결 생성 | `{ connectionName, dbType, host, port, database, username, password }` | `{ connection }` | +| PUT | `/external-db-connections/:id` | 관리자 | 연결 수정 | `{ connectionName, ... }` | `{ connection }` | +| DELETE | `/external-db-connections/:id` | 관리자 | 연결 삭제 | - | `{ success: true }` | +| POST | `/external-db-connections/:id/test` | 인증 | 연결 테스트 | - | `{ success: boolean, message }` | +| GET | `/external-db-connections/:id/tables` | 인증 | 테이블 목록 조회 | - | `{ tables[] }` | +| GET | `/external-db-connections/:id/tables/:table/columns` | 인증 | 컬럼 목록 조회 | - | `{ columns[] }` | + +### 7.2 외부 REST API (`/api/external-rest-api-connections`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/external-rest-api-connections` | 인증 | API 연결 목록 | - | `{ connections[] }` | +| POST | `/external-rest-api-connections` | 관리자 | API 연결 생성 | `{ name, baseUrl, authType, ... }` | `{ connection }` | +| POST | `/external-rest-api-connections/:id/test` | 인증 | API 테스트 | `{ endpoint, method }` | `{ response }` | + +### 7.3 멀티 커넥션 (`/api/multi-connection`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| POST | `/multi-connection/query` | 인증 | 멀티 DB 쿼리 | `{ connections: [{ connectionId, sql }] }` | `{ results[] }` | + +--- + +## 8. 배치 API + +### 8.1 배치 설정 (`/api/batch-configs`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/batch-configs` | 인증 | 배치 설정 목록 | - | `{ batchConfigs[] }` | +| GET | `/batch-configs/:id` | 인증 | 배치 설정 상세 | - | `{ batchConfig }` | +| POST | `/batch-configs` | 관리자 | 배치 설정 생성 | `{ batchName, cronSchedule, sourceConnection, targetTable, mappings }` | `{ batchConfig }` | +| PUT | `/batch-configs/:id` | 관리자 | 배치 설정 수정 | `{ batchName, ... }` | `{ batchConfig }` | +| DELETE | `/batch-configs/:id` | 관리자 | 배치 설정 삭제 | - | `{ success: true }` | +| GET | `/batch-configs/connections` | 관리자 | 사용 가능한 커넥션 목록 | - | `{ connections[] }` | +| GET | `/batch-configs/connections/:type/tables` | 관리자 | 테이블 목록 조회 | - | `{ tables[] }` | + +### 8.2 배치 실행 (`/api/batch-management`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| POST | `/batch-management/:id/execute` | 관리자 | 배치 즉시 실행 | - | `{ success: true, executionLogId }` | + +### 8.3 실행 이력 (`/api/batch-execution-logs`) + +| 메서드 | 경로 | 권한 | 기능 | Query Params | Response | +|--------|------|------|------|--------------|----------| +| GET | `/batch-execution-logs` | 인증 | 실행 이력 목록 | `batchConfigId, page, limit` | `{ logs[], total }` | +| GET | `/batch-execution-logs/:id` | 인증 | 실행 이력 상세 | - | `{ log }` | + +--- + +## 9. 메일 API (`/api/mail`) + +### 9.1 계정 관리 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/mail/accounts` | 인증 | 계정 목록 | - | `{ accounts[] }` | +| POST | `/mail/accounts` | 관리자 | 계정 추가 | `{ email, smtpHost, smtpPort, password }` | `{ account }` | + +### 9.2 템플릿 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/mail/templates-file` | 인증 | 템플릿 목록 | - | `{ templates[] }` | +| POST | `/mail/templates-file` | 관리자 | 템플릿 생성 | `{ name, subject, body }` | `{ template }` | + +### 9.3 발송/수신 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| POST | `/mail/send` | 인증 | 메일 발송 | `{ accountId, to, subject, body, attachments[] }` | `{ success: true, messageId }` | +| GET | `/mail/sent` | 인증 | 발송 이력 | `page, limit` | `{ mails[], total }` | +| POST | `/mail/receive` | 인증 | 메일 수신 | `{ accountId }` | `{ mails[] }` | + +--- + +## 10. 파일 API (`/api/files`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| POST | `/files/upload` | 인증 | 파일 업로드 (multipart) | `FormData { file }` | `{ fileId, fileName, filePath, fileSize }` | +| GET | `/files` | 인증 | 파일 목록 | `page, limit` | `{ files[], total }` | +| GET | `/files/:id` | 인증 | 파일 정보 조회 | - | `{ file }` | +| GET | `/files/download/:id` | 인증 | 파일 다운로드 | - | `(파일 스트림)` | +| DELETE | `/files/:id` | 인증 | 파일 삭제 | - | `{ success: true }` | +| GET | `/uploads/:filename` | 공개 | 정적 파일 서빙 | - | `(파일 스트림)` | + +--- + +## 11. 대시보드 API (`/api/dashboards`) + +| 메서드 | 경로 | 권한 | 기능 | Query Params | Response | +|--------|------|------|------|--------------|----------| +| GET | `/dashboards` | 인증 | 대시보드 목록 | - | `{ dashboards[] }` | +| GET | `/dashboards/:id` | 인증 | 대시보드 상세 | - | `{ dashboard }` | +| POST | `/dashboards` | 관리자 | 대시보드 생성 | - | `{ dashboard }` | +| GET | `/dashboards/:id/widgets` | 인증 | 위젯 데이터 조회 | - | `{ widgets[] }` | + +--- + +## 12. 공통코드 API (`/api/common-codes`) + +| 메서드 | 경로 | 권한 | 기능 | Query Params | Response | +|--------|------|------|------|--------------|----------| +| GET | `/common-codes` | 인증 | 공통코드 목록 | `codeGroup` | `{ codes[] }` | +| GET | `/common-codes/:codeGroup/:code` | 인증 | 공통코드 상세 | - | `{ code }` | +| POST | `/common-codes` | 관리자 | 공통코드 생성 | `{ codeGroup, code, name }` | `{ code }` | + +--- + +## 13. 다국어 API (`/api/multilang`) + +| 메서드 | 경로 | 권한 | 기능 | Query Params | Response | +|--------|------|------|------|--------------|----------| +| GET | `/multilang` | 인증 | 다국어 키 목록 | `lang` | `{ translations{} }` | +| GET | `/multilang/:key` | 인증 | 특정 키 조회 | `lang` | `{ key, value }` | +| POST | `/multilang` | 관리자 | 다국어 추가 | `{ key, ko, en, cn }` | `{ translation }` | + +--- + +## 14. 회사 관리 API (`/api/company-management`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/company-management` | 슈퍼관리자 | 회사 목록 | - | `{ companies[] }` | +| POST | `/company-management` | 슈퍼관리자 | 회사 생성 | `{ companyCode, companyName }` | `{ company }` | +| PUT | `/company-management/:code` | 슈퍼관리자 | 회사 수정 | `{ companyName }` | `{ company }` | +| DELETE | `/company-management/:code` | 슈퍼관리자 | 회사 삭제 | - | `{ success: true }` | + +--- + +## 15. 부서 API (`/api/departments`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/departments` | 인증 | 부서 목록 (트리) | - | `{ departments[] }` | +| POST | `/departments` | 관리자 | 부서 생성 | `{ deptCode, deptName, parentDeptCode }` | `{ department }` | + +--- + +## 16. 권한 그룹 API (`/api/roles`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/roles` | 인증 | 권한 그룹 목록 | - | `{ roles[] }` | +| POST | `/roles` | 관리자 | 권한 그룹 생성 | `{ roleName, permissions[] }` | `{ role }` | + +--- + +## 17. DDL 실행 API (`/api/ddl`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| POST | `/ddl` | 슈퍼관리자 | DDL 실행 | `{ sql }` | `{ success: true, result }` | + +--- + +## 18. 외부 API 프록시 (`/api/open-api`) + +| 메서드 | 경로 | 권한 | 기능 | Query Params | Response | +|--------|------|------|------|--------------|----------| +| GET | `/open-api/weather` | 인증 | 날씨 정보 조회 | `location` | `{ weather }` | +| GET | `/open-api/exchange` | 인증 | 환율 정보 조회 | `fromCurrency, toCurrency` | `{ rate }` | + +--- + +## 19. 디지털 트윈 API (`/api/digital-twin`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/digital-twin/layouts` | 인증 | 레이아웃 목록 | - | `{ layouts[] }` | +| GET | `/digital-twin/templates` | 인증 | 템플릿 목록 | - | `{ templates[] }` | +| GET | `/digital-twin/data` | 인증 | 실시간 데이터 | - | `{ data[] }` | + +--- + +## 20. 3D 필드 API (`/api/yard-layouts`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/yard-layouts` | 인증 | 필드 레이아웃 목록 | - | `{ yardLayouts[] }` | +| POST | `/yard-layouts` | 인증 | 레이아웃 저장 | `{ layout }` | `{ success: true }` | + +--- + +## 21. 스케줄 API (`/api/schedule`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| POST | `/schedule` | 인증 | 스케줄 자동 생성 | `{ params }` | `{ schedule }` | + +--- + +## 22. 채번 규칙 API (`/api/numbering-rules`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/numbering-rules` | 인증 | 채번 규칙 목록 | - | `{ rules[] }` | +| POST | `/numbering-rules` | 관리자 | 규칙 생성 | `{ ruleName, prefix, format }` | `{ rule }` | +| POST | `/numbering-rules/:id/generate` | 인증 | 번호 생성 | - | `{ number }` | + +--- + +## 23. 엔티티 검색 API (`/api/entity-search`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| POST | `/entity-search` | 인증 | 엔티티 검색 | `{ table, filters, page, limit }` | `{ results[], total }` | +| GET | `/entity/:table/options` | 인증 | V2Select용 옵션 | `search, limit` | `{ options[] }` | + +--- + +## 24. To-Do API (`/api/todos`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/todos` | 인증 | To-Do 목록 | `status, assignee` | `{ todos[] }` | +| POST | `/todos` | 인증 | To-Do 생성 | `{ title, description, dueDate }` | `{ todo }` | +| PUT | `/todos/:id` | 인증 | To-Do 수정 | `{ status }` | `{ todo }` | + +--- + +## 25. 예약 요청 API (`/api/bookings`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/bookings` | 인증 | 예약 목록 | - | `{ bookings[] }` | +| POST | `/bookings` | 인증 | 예약 생성 | `{ resourceId, startTime, endTime }` | `{ booking }` | + +--- + +## 26. 리스크/알림 API (`/api/risk-alerts`) + +| 메서드 | 경로 | 권한 | 기능 | Query Params | Response | +|--------|------|------|------|--------------|----------| +| GET | `/risk-alerts` | 인증 | 리스크/알림 목록 | `priority, status` | `{ alerts[] }` | +| POST | `/risk-alerts` | 인증 | 알림 생성 | `{ title, content, priority }` | `{ alert }` | + +--- + +## 27. 헬스 체크 + +| 메서드 | 경로 | 권한 | 기능 | Response | +|--------|------|------|------|----------| +| GET | `/health` | 공개 | 서버 상태 확인 | `{ status: "OK", timestamp, uptime, environment }` | + +--- + +## 🔐 에러 코드 목록 + +| 코드 | HTTP Status | 설명 | +|------|-------------|------| +| `TOKEN_MISSING` | 401 | 인증 토큰 누락 | +| `TOKEN_EXPIRED` | 401 | 토큰 만료 | +| `INVALID_TOKEN` | 401 | 유효하지 않은 토큰 | +| `AUTHENTICATION_REQUIRED` | 401 | 인증 필요 | +| `INSUFFICIENT_PERMISSION` | 403 | 권한 부족 | +| `SUPER_ADMIN_REQUIRED` | 403 | 슈퍼관리자 권한 필요 | +| `COMPANY_ACCESS_DENIED` | 403 | 회사 데이터 접근 거부 | +| `INVALID_INPUT` | 400 | 잘못된 입력 | +| `RESOURCE_NOT_FOUND` | 404 | 리소스 없음 | +| `DUPLICATE_ENTRY` | 400 | 중복 데이터 | +| `FOREIGN_KEY_VIOLATION` | 400 | 참조 무결성 위반 | +| `SERVER_ERROR` | 500 | 서버 오류 | + +--- + +**문서 버전**: 1.0 +**마지막 업데이트**: 2026-02-06 +**총 API 개수**: 200+개 diff --git a/docs/backend-architecture-analysis.md b/docs/backend-architecture-analysis.md new file mode 100644 index 00000000..c5c2b549 --- /dev/null +++ b/docs/backend-architecture-analysis.md @@ -0,0 +1,1424 @@ +# WACE ERP 백엔드 아키텍처 상세 분석 + +> **작성일**: 2026-02-06 +> **분석 대상**: ERP-node/backend-node +> **Stack**: Node.js + Express + TypeScript + PostgreSQL Raw Query + +--- + +## 📋 목차 + +1. [전체 디렉토리 구조](#1-전체-디렉토리-구조) +2. [API 라우트 목록 및 역할](#2-api-라우트-목록-및-역할) +3. [인증/인가 워크플로우](#3-인증인가-워크플로우) +4. [비즈니스 도메인별 모듈 분류](#4-비즈니스-도메인별-모듈-분류) +5. [미들웨어 스택 구성](#5-미들웨어-스택-구성) +6. [서비스 레이어 패턴](#6-서비스-레이어-패턴) +7. [멀티테넌시 구현 방식](#7-멀티테넌시-구현-방식) +8. [에러 핸들링 전략](#8-에러-핸들링-전략) +9. [파일 업로드/다운로드 처리](#9-파일-업로드다운로드-처리) +10. [외부 연동](#10-외부-연동) +11. [배치/스케줄 처리](#11-배치스케줄-처리) +12. [컨트롤러/서비스 상세 역할](#12-컨트롤러서비스-상세-역할) + +--- + +## 1. 전체 디렉토리 구조 + +``` +backend-node/ +├── src/ +│ ├── app.ts # Express 앱 진입점, 라우트 등록, 미들웨어 설정 +│ ├── config/ +│ │ └── environment.ts # 환경변수 관리 (PORT, DB, JWT, CORS 등) +│ ├── controllers/ # 69개 컨트롤러 (요청 처리 및 응답) +│ ├── services/ # 87개 서비스 (비즈니스 로직) +│ ├── routes/ # 77개 라우터 (엔드포인트 정의) +│ ├── middleware/ # 4개 미들웨어 (인증, 권한, 에러 핸들링) +│ ├── database/ # DB 연결 풀, 커넥터, 마이그레이션 +│ ├── utils/ # 16개 유틸리티 (JWT, 암호화, 로거 등) +│ ├── types/ # 26개 TypeScript 타입 정의 +│ ├── interfaces/ # 인터페이스 정의 +│ └── tests/ # 테스트 파일 +├── scripts/ # 배치 및 유틸리티 스크립트 +├── data/ # JSON 기반 설정 데이터 +├── uploads/ # 파일 업로드 디렉토리 +└── package.json # 의존성 관리 +``` + +### 주요 특징 +- **Layered Architecture**: Controller → Service → Database 3계층 구조 +- **TypeScript Strict Mode**: 타입 안전성 보장 +- **Raw Query 기반**: Prisma → PostgreSQL Raw Query 전환 완료 +- **Connection Pool**: pg 라이브러리 기반 연결 풀 관리 +- **마이크로서비스 지향**: 도메인별 명확한 분리 + +--- + +## 2. API 라우트 목록 및 역할 + +### 2.1 인증 및 관리자 (Auth & Admin) + +| 엔드포인트 | 메서드 | 역할 | 인증 | +|-----------|--------|------|------| +| `/api/auth/login` | POST | 로그인 (JWT 토큰 발급) | ❌ | +| `/api/auth/signup` | POST | 회원가입 (공차중계) | ❌ | +| `/api/auth/me` | GET | 현재 사용자 정보 조회 | ✅ | +| `/api/auth/logout` | POST | 로그아웃 | ✅ | +| `/api/auth/refresh` | POST | JWT 토큰 갱신 | ✅ | +| `/api/auth/switch-company` | POST | 관리자 전용: 회사 전환 | ✅ | +| `/api/admin/menus` | GET | 메뉴 목록 조회 | ✅ | +| `/api/admin/users` | GET/POST/PUT | 사용자 관리 (CRUD) | ✅ | +| `/api/admin/companies` | GET/POST/PUT/DELETE | 회사 관리 (CRUD) | ✅ | +| `/api/admin/departments` | GET | 부서 목록 조회 | ✅ | + +### 2.2 테이블 및 데이터 관리 + +| 엔드포인트 | 메서드 | 역할 | 인증 | +|-----------|--------|------|------| +| `/api/table-management/tables` | GET | 테이블 목록 조회 | ✅ | +| `/api/table-management/columns` | GET | 컬럼 정보 조회 | ✅ | +| `/api/table-management/entity-joins` | GET/POST | 테이블 조인 설정 | ✅ | +| `/api/data/*` | GET/POST/PUT/DELETE | 동적 테이블 데이터 CRUD | ✅ | +| `/api/ddl/*` | POST | DDL 실행 (테이블 생성/수정/삭제) | ✅ (Super Admin) | + +### 2.3 화면 및 폼 관리 + +| 엔드포인트 | 메서드 | 역할 | 인증 | +|-----------|--------|------|------| +| `/api/screen-management/*` | GET/POST/PUT/DELETE | 화면 메타데이터 관리 | ✅ | +| `/api/screen-groups/*` | GET/POST/PUT/DELETE | 화면 그룹 관리 | ✅ | +| `/api/dynamic-form/*` | GET/POST | 동적 폼 생성 및 렌더링 | ✅ | +| `/api/admin/web-types` | GET/POST | 웹 컴포넌트 타입 표준 관리 | ✅ | +| `/api/admin/button-actions` | GET/POST | 버튼 액션 표준 관리 | ✅ | +| `/api/admin/template-standards` | GET/POST | 템플릿 표준 관리 | ✅ | +| `/api/admin/component-standards` | GET/POST | 컴포넌트 표준 관리 | ✅ | + +### 2.4 플로우 및 데이터플로우 + +| 엔드포인트 | 메서드 | 역할 | 인증 | +|-----------|--------|------|------| +| `/api/flow/definitions` | GET/POST/PUT/DELETE | 플로우 정의 관리 | ✅ | +| `/api/flow/definitions/:id/steps` | GET/POST | 플로우 단계 관리 | ✅ | +| `/api/flow/connections` | GET/POST/DELETE | 플로우 연결 관리 | ✅ | +| `/api/flow/move` | POST | 데이터 이동 실행 | ✅ | +| `/api/flow/audit/:flowId` | GET | 플로우 오딧 로그 조회 | ✅ | +| `/api/dataflow/*` | GET/POST/PUT/DELETE | 데이터플로우 관계 관리 | ✅ | +| `/api/dataflow-diagrams/*` | GET/POST/PUT/DELETE | 데이터플로우 다이어그램 | ✅ | +| `/api/dataflow/execute` | POST | 데이터플로우 실행 | ✅ | + +### 2.5 배치 관리 + +| 엔드포인트 | 메서드 | 역할 | 인증 | +|-----------|--------|------|------| +| `/api/batch-configs` | GET/POST/PUT/DELETE | 배치 설정 관리 | ✅ | +| `/api/batch-configs/connections` | GET | 사용 가능한 커넥션 목록 | ✅ | +| `/api/batch-configs/:id/execute` | POST | 배치 수동 실행 | ✅ | +| `/api/batch-management/*` | GET/POST | 배치 실행 관리 | ✅ | +| `/api/batch-execution-logs` | GET | 배치 실행 이력 조회 | ✅ | + +### 2.6 외부 연동 + +| 엔드포인트 | 메서드 | 역할 | 인증 | +|-----------|--------|------|------| +| `/api/external-db-connections` | GET/POST/PUT/DELETE | 외부 DB 연결 관리 | ✅ | +| `/api/external-db-connections/:id/test` | POST | 외부 DB 연결 테스트 | ✅ | +| `/api/external-rest-api-connections` | GET/POST/PUT/DELETE | 외부 REST API 연결 | ✅ | +| `/api/external-calls/*` | GET/POST | 외부 API 호출 설정 | ✅ | +| `/api/multi-connection/query` | POST | 멀티 DB 통합 쿼리 | ✅ | + +### 2.7 메일 관리 + +| 엔드포인트 | 메서드 | 역할 | 인증 | +|-----------|--------|------|------| +| `/api/mail/accounts` | GET/POST/PUT/DELETE | 메일 계정 관리 | ✅ | +| `/api/mail/templates-file` | GET/POST/PUT/DELETE | 메일 템플릿 관리 | ✅ | +| `/api/mail/send` | POST | 메일 발송 (단일/대량) | ✅ | +| `/api/mail/sent` | GET | 발송 이력 조회 | ✅ | +| `/api/mail/receive` | GET | 메일 수신함 조회 | ✅ | + +### 2.8 기타 도메인 + +| 엔드포인트 | 메서드 | 역할 | 인증 | +|-----------|--------|------|------| +| `/api/dashboards/*` | GET/POST | 대시보드 관리 | ✅ | +| `/api/admin/reports/*` | GET/POST | 리포트 생성 및 조회 | ✅ | +| `/api/files/*` | POST | 파일 업로드/다운로드 | ✅ | +| `/api/delivery/*` | GET/POST | 배송/화물 관리 | ✅ | +| `/api/risk-alerts/*` | GET/POST | 리스크/알림 관리 | ✅ | +| `/api/todos/*` | GET/POST/PUT/DELETE | To-Do 관리 | ✅ | +| `/api/bookings/*` | GET/POST | 예약 관리 | ✅ | +| `/api/digital-twin/*` | GET/POST | 디지털 트윈 (야드 관제) | ✅ | +| `/api/schedule/*` | GET/POST | 스케줄 자동 생성 | ✅ | +| `/api/work-history/*` | GET | 작업 이력 조회 | ✅ | +| `/api/table-history/*` | GET | 테이블 변경 이력 조회 | ✅ | +| `/api/roles/*` | GET/POST | 권한 그룹 관리 | ✅ | +| `/api/numbering-rules/*` | GET/POST | 채번 규칙 관리 | ✅ | +| `/api/entity-search/*` | GET | 엔티티 검색 | ✅ | +| `/api/cascading-*` | GET/POST | 연쇄 드롭다운 관계 | ✅ | +| `/api/category-tree/*` | GET/POST | 카테고리 트리 | ✅ | +| `/api/vehicle/*` | GET/POST | 차량 운행 이력 | ✅ | +| `/api/tax-invoice/*` | GET/POST | 세금계산서 관리 | ✅ | + +**총 77개 라우터 파일, 200개 이상의 엔드포인트 제공** + +--- + +## 3. 인증/인가 워크플로우 + +### 3.1 인증 메커니즘 + +``` +로그인 요청 (userId, password) + ↓ +1. AuthController.login() + ↓ +2. AuthService.processLogin() + ├─ 비밀번호 검증 (BCrypt + 마스터 패스워드) + ├─ 사용자 정보 조회 (user_info 테이블) + ├─ 로그인 로그 기록 (LOGIN_ACCESS_LOG) + └─ JWT 토큰 생성 (JwtUtils.generateToken) + ↓ +3. JWT 토큰 응답 + ├─ accessToken (24시간 유효) + ├─ refreshToken (7일 유효) + └─ userInfo (userId, userName, companyCode, userType) +``` + +### 3.2 JWT 토큰 구조 + +```typescript +// JWT Payload +{ + userId: string; // 사용자 ID + userName: string; // 사용자 이름 + companyCode: string; // 회사 코드 (멀티테넌시 핵심) + userType: string; // 사용자 유형 (SUPER_ADMIN, COMPANY_ADMIN, USER) + userLang?: string; // 사용자 언어 + iat: number; // 발급 시간 + exp: number; // 만료 시간 +} +``` + +### 3.3 미들웨어 체인 + +``` +1. refreshTokenIfNeeded (자동 토큰 갱신) + ↓ +2. authenticateToken (JWT 검증 및 사용자 정보 설정) + ↓ +3. 권한 미들웨어 (선택적) + ├─ requireSuperAdmin (회사코드 '*' 필수) + ├─ requireAdmin (회사관리자 이상) + ├─ requireCompanyAccess (회사 데이터 접근 권한) + ├─ requireDDLPermission (DDL 실행 권한) + └─ requireUserManagement (사용자 관리 권한) + ↓ +4. Controller 실행 + ↓ +5. errorHandler (에러 발생 시) +``` + +### 3.4 권한 레벨 (3단계) + +| 레벨 | companyCode | userType | 권한 범위 | +|------|-------------|----------|----------| +| **Super Admin** | `*` | `SUPER_ADMIN` | 전체 시스템 접근, DDL 실행, 회사 생성/삭제 | +| **Company Admin** | 회사코드 | `COMPANY_ADMIN` | 자사 데이터 관리, 사용자 관리, 설정 변경 | +| **일반 사용자** | 회사코드 | `USER` | 자사 데이터 조회/수정 (권한 범위 내) | + +### 3.5 토큰 갱신 전략 + +- **자동 갱신**: 토큰이 1시간 이내 만료 시 응답 헤더(`X-New-Token`)에 새 토큰 포함 +- **명시적 갱신**: `/api/auth/refresh` 엔드포인트 호출 +- **만료 처리**: 만료된 토큰은 401 Unauthorized 응답 (`TOKEN_EXPIRED`) + +--- + +## 4. 비즈니스 도메인별 모듈 분류 + +### 4.1 관리자 영역 (Admin) + +**파일**: +- `adminController.ts`, `adminService.ts`, `adminRoutes.ts` + +**주요 기능**: +- 메뉴 관리 (CRUD, 복사, 상태 토글, 일괄 삭제) +- 사용자 관리 (등록, 수정, 상태 변경, 비밀번호 초기화) +- 회사 관리 (등록, 수정, 삭제, 조회) +- 부서 관리 (조회, 사용자-부서 통합 저장) +- 로케일 설정 (다국어 지원) +- 테이블 스키마 조회 (엑셀 매핑용) + +**특징**: +- 멀티테넌시 기반 회사별 데이터 격리 +- Super Admin만 회사 생성/삭제 가능 +- 사용자 변경 이력 추적 + +### 4.2 테이블 및 데이터 관리 (Table Management & Data) + +**파일**: +- `tableManagementController.ts`, `tableManagementService.ts` +- `dataController.ts`, `dataService.ts` +- `entityJoinController.ts`, `entityJoinService.ts` + +**주요 기능**: +- 테이블 목록 조회 (PostgreSQL information_schema 활용) +- 컬럼 정보 조회 (타입, 라벨, 제약조건, 참조 관계) +- 동적 테이블 데이터 CRUD (Raw Query 기반) +- 테이블 조인 설정 및 실행 (1:N, N:M 관계) +- 컬럼 라벨 및 설정 관리 (table_type_columns) + +**특징**: +- 캐시 기반 성능 최적화 (테이블/컬럼 정보) +- 멀티테넌시 자동 필터링 (`company_code` 조건) +- 코드 타입 컬럼 자동 처리 (공통 코드 연동) + +### 4.3 화면 관리 (Screen Management) + +**파일**: +- `screenManagementController.ts`, `screenManagementService.ts` +- `screenGroupController.ts`, `screenEmbeddingController.ts` + +**주요 기능**: +- 화면 메타데이터 관리 (테이블 연결, 컬럼 설정, 레이아웃) +- 화면 그룹 관리 (폴더 구조) +- 화면 임베딩 (부모-자식 화면 데이터 전달) +- 동적 폼 생성 (JSON 기반 폼 설정 → React 컴포넌트) + +**특징**: +- Low-Code 화면 구성 +- 웹 컴포넌트 타입 표준 기반 렌더링 +- 버튼 액션 표준 지원 (저장, 삭제, 조회, 커스텀) + +### 4.4 플로우 관리 (Flow Management) + +**파일**: +- `flowController.ts`, `flowService.ts` +- `flowExecutionService.ts`, `flowStepService.ts` +- `flowConnectionService.ts`, `flowDataMoveService.ts` + +**주요 기능**: +- 플로우 정의 관리 (작업 흐름 설계) +- 플로우 단계 관리 (스텝 생성, 수정, 삭제) +- 플로우 연결 관리 (스텝 간 조건부 연결) +- 데이터 이동 실행 (스텝 간 데이터 이동) +- 오딧 로그 조회 (변경 이력 추적) + +**특징**: +- 비주얼 워크플로우 엔진 +- 조건부 분기 지원 +- 배치 데이터 이동 지원 + +### 4.5 데이터플로우 (Dataflow) + +**파일**: +- `dataflowController.ts`, `dataflowService.ts` +- `dataflowDiagramController.ts`, `dataflowDiagramService.ts` +- `dataflowExecutionController.ts` + +**주요 기능**: +- 테이블 관계 정의 (1:1, 1:N, N:M) +- 데이터플로우 다이어그램 생성 (ERD 같은 시각화) +- 데이터플로우 실행 (자동 데이터 동기화) +- 관계 기반 데이터 조회 (조인 쿼리 자동 생성) + +**특징**: +- 그래프 기반 데이터 관계 모델링 +- 다이어그램별 관계 그룹화 + +### 4.6 배치 관리 (Batch Management) + +**파일**: +- `batchController.ts`, `batchService.ts` +- `batchSchedulerService.ts`, `batchExecutionLogService.ts` +- `batchExternalDbService.ts` + +**주요 기능**: +- 배치 설정 관리 (CRUD) +- Cron 기반 스케줄링 (node-cron) +- 배치 수동/자동 실행 +- 실행 이력 조회 (성공/실패 로그) +- 외부 DB 연동 배치 지원 + +**특징**: +- 실시간 스케줄러 업데이트 +- 다중 DB 간 데이터 동기화 +- 실행 시간 제한 및 오류 알림 + +### 4.7 외부 연동 (External Integration) + +**파일**: +- `externalDbConnectionController.ts`, `externalDbConnectionService.ts` +- `externalRestApiConnectionController.ts`, `externalRestApiConnectionService.ts` +- `externalCallController.ts`, `externalCallService.ts` +- `multiConnectionQueryService.ts` + +**주요 기능**: +- 외부 DB 연결 관리 (PostgreSQL, MySQL, MSSQL, Oracle, MariaDB) +- 외부 REST API 연결 관리 +- 멀티 DB 통합 쿼리 실행 +- 연결 테스트 및 상태 확인 +- 크레덴셜 암호화 저장 + +**특징**: +- 5종 DB 지원 (PostgreSQL, MySQL, MSSQL, Oracle, MariaDB) +- Connection Pool 기반 연결 관리 +- 비밀번호 암호화 (AES-256-CBC) + +### 4.8 메일 관리 (Mail Management) + +**파일**: +- `mailAccountFileController.ts`, `mailAccountFileService.ts` +- `mailTemplateFileController.ts`, `mailTemplateFileService.ts` +- `mailSendSimpleController.ts`, `mailSendSimpleService.ts` +- `mailSentHistoryController.ts`, `mailSentHistoryService.ts` +- `mailReceiveBasicController.ts`, `mailReceiveBasicService.ts` + +**주요 기능**: +- 메일 계정 관리 (SMTP/IMAP 설정) +- 메일 템플릿 관리 (JSON 기반 컴포넌트 조합) +- 메일 발송 (단일/대량, 첨부파일 지원) +- 발송 이력 조회 (30일 자동 삭제) +- 메일 수신함 조회 (IMAP) + +**특징**: +- Nodemailer 기반 발송 +- 템플릿 변수 치환 +- 대량 발송 지원 (100건/배치) +- 메일 예약 발송 + +### 4.9 대시보드 (Dashboard) + +**파일**: +- `DashboardController.ts`, `DashboardService.ts` + +**주요 기능**: +- 대시보드 위젯 관리 (차트, 테이블, 카드) +- 실시간 데이터 조회 (집계 쿼리) +- 사용자별 대시보드 설정 + +**특징**: +- JSON 기반 위젯 설정 +- 캐시 기반 성능 최적화 + +### 4.10 기타 도메인 + +**파일 및 주요 기능**: +- **리포트**: 리포트 생성 및 조회 (`reportController.ts`, `reportService.ts`) +- **파일**: 파일 업로드/다운로드 (`fileController.ts`, Multer) +- **배송/화물**: 배송 관리, 화물 추적 (`deliveryController.ts`) +- **리스크/알림**: 리스크 알림, 캐시 기반 자동 갱신 (`riskAlertController.ts`) +- **To-Do**: 할 일 관리 (`todoController.ts`) +- **예약**: 예약 요청 관리 (`bookingController.ts`) +- **디지털 트윈**: 야드 관제, 3D 레이아웃 (`digitalTwinController.ts`) +- **스케줄**: 스케줄 자동 생성 (`scheduleController.ts`) +- **작업 이력**: 작업 로그 조회 (`workHistoryController.ts`) +- **권한 그룹**: 권한 그룹 관리 (`roleController.ts`) +- **채번 규칙**: 자동 채번 규칙 (`numberingRuleController.ts`) +- **엔티티 검색**: 동적 엔티티 검색 (`entitySearchController.ts`) +- **연쇄 드롭다운**: 조건부 드롭다운 (`cascadingController.ts` 시리즈) + +--- + +## 5. 미들웨어 스택 구성 + +### 5.1 미들웨어 실행 순서 (app.ts 기준) + +``` +1. 프로세스 레벨 예외 처리 (unhandledRejection, uncaughtException) +2. 보안 헤더 (helmet) +3. 압축 (compression) +4. 바디 파싱 (express.json, express.urlencoded) +5. 정적 파일 서빙 (/uploads) +6. CORS (cors) +7. Rate Limiting (express-rate-limit) +8. 토큰 자동 갱신 (refreshTokenIfNeeded) +9. [라우트별 미들웨어] + ├─ authenticateToken (모든 /api/* 라우트) + ├─ 권한 미들웨어 (선택적) + └─ 컨트롤러 실행 +10. 404 핸들러 +11. 에러 핸들러 (errorHandler) +``` + +### 5.2 미들웨어 파일 + +#### `authMiddleware.ts` +- **authenticateToken**: JWT 토큰 검증 및 사용자 정보 설정 +- **optionalAuth**: 선택적 인증 (토큰 없어도 통과) +- **requireAdmin**: 관리자 권한 필수 (userId === 'plm_admin') +- **refreshTokenIfNeeded**: 토큰 자동 갱신 (1시간 이내 만료 시) +- **checkAuthStatus**: 인증 상태 확인 (유효성 검사만) + +#### `permissionMiddleware.ts` +- **requireSuperAdmin**: 슈퍼관리자 권한 필수 (companyCode === '*') +- **requireAdmin**: 관리자 이상 권한 필수 (Super Admin + Company Admin) +- **requireCompanyAccess**: 회사 데이터 접근 권한 체크 +- **requireUserManagement**: 사용자 관리 권한 체크 +- **requireCompanySettingsManagement**: 회사 설정 변경 권한 체크 +- **requireCompanyManagement**: 회사 생성/삭제 권한 체크 +- **requireDDLPermission**: DDL 실행 권한 체크 + +#### `superAdminMiddleware.ts` +- **requireSuperAdmin**: 슈퍼관리자 권한 확인 (DDL 전용) +- **validateDDLPermission**: DDL 실행 전 추가 보안 검증 (5초 간격 제한) +- **isSuperAdmin**: 슈퍼관리자 여부 확인 유틸 함수 +- **checkDDLPermission**: DDL 권한 체크 (미들웨어 없이 사용) + +#### `errorHandler.ts` +- **AppError**: 커스텀 에러 클래스 (statusCode, isOperational) +- **errorHandler**: 전역 에러 핸들러 (PostgreSQL, JWT 에러 처리) +- **notFoundHandler**: 404 에러 핸들러 + +### 5.3 보안 설정 + +```typescript +// helmet: 보안 헤더 설정 +helmet({ + contentSecurityPolicy: { + directives: { + 'frame-ancestors': ['self', 'http://localhost:9771', 'http://localhost:3000'] + } + } +}) + +// Rate Limiting: 1분당 10,000 요청 (개발), 100 요청 (운영) +rateLimit({ + windowMs: 1 * 60 * 1000, + max: process.env.NODE_ENV === 'development' ? 10000 : 100, + skip: (req) => { + // 헬스 체크, 자주 호출되는 API는 제외 + return req.path === '/health' + || req.path.includes('/table-management/') + || req.path.includes('/external-db-connections/') + } +}) + +// CORS: 환경별 origin 설정 +cors({ + origin: process.env.NODE_ENV === 'development' + ? true + : ['http://localhost:9771', 'http://39.117.244.52:5555'], + credentials: true +}) +``` + +--- + +## 6. 서비스 레이어 패턴 + +### 6.1 서비스 레이어 구조 + +``` +Controller (요청 처리) + ↓ +Service (비즈니스 로직) + ↓ +Database (Raw Query 실행) + ↓ +PostgreSQL (데이터 저장소) +``` + +### 6.2 데이터베이스 접근 방식 + +#### `db.ts` - Raw Query 매니저 + +```typescript +// 기본 쿼리 실행 +async function query(text: string, params?: any[]): Promise + +// 단일 행 조회 +async function queryOne(text: string, params?: any[]): Promise + +// 트랜잭션 +async function transaction(callback: (client: PoolClient) => Promise): Promise + +// 연결 풀 상태 +function getPoolStatus(): { totalCount, idleCount, waitingCount } +``` + +#### Connection Pool 설정 + +```typescript +new Pool({ + host: dbConfig.host, + port: dbConfig.port, + database: dbConfig.database, + user: dbConfig.user, + password: dbConfig.password, + + // 연결 풀 설정 + min: process.env.NODE_ENV === 'production' ? 5 : 2, + max: process.env.NODE_ENV === 'production' ? 20 : 10, + + // 타임아웃 설정 + connectionTimeoutMillis: 30000, // 30초 + idleTimeoutMillis: 600000, // 10분 + statement_timeout: 60000, // 60초 + query_timeout: 60000, + + application_name: 'WACE-PLM-Backend' +}) +``` + +### 6.3 서비스 패턴 예시 + +#### 멀티테넌시 쿼리 패턴 + +```typescript +// Super Admin: 모든 데이터 조회 +if (companyCode === '*') { + query = 'SELECT * FROM table_name ORDER BY company_code'; + params = []; +} +// 일반 사용자: 자사 데이터만 조회 (Super Admin 데이터 제외) +else { + query = ` + SELECT * FROM table_name + WHERE company_code = $1 AND company_code != '*' + ORDER BY created_at DESC + `; + params = [companyCode]; +} +``` + +#### 트랜잭션 패턴 + +```typescript +await transaction(async (client) => { + // 1. 부모 레코드 삽입 + const parent = await client.query( + 'INSERT INTO parent_table (...) VALUES (...) RETURNING *', + [...] + ); + + // 2. 자식 레코드 삽입 + await client.query( + 'INSERT INTO child_table (parent_id, ...) VALUES ($1, ...) RETURNING *', + [parent.rows[0].id, ...] + ); + + return { success: true }; +}); +``` + +#### 캐시 패턴 + +```typescript +// 캐시 조회 +const cachedData = cache.get(CacheKeys.TABLE_LIST); +if (cachedData) { + return cachedData; +} + +// DB 조회 +const data = await query('SELECT ...'); + +// 캐시 저장 (10분 TTL) +cache.set(CacheKeys.TABLE_LIST, data, 10 * 60 * 1000); + +return data; +``` + +### 6.4 외부 DB 커넥터 패턴 + +```typescript +// DatabaseConnectorFactory.ts +export class DatabaseConnectorFactory { + static createConnector(dbType: string, config: ConnectionConfig): DatabaseConnector { + switch (dbType) { + case 'postgresql': return new PostgreSQLConnector(config); + case 'mysql': return new MySQLConnector(config); + case 'mssql': return new MSSQLConnector(config); + case 'oracle': return new OracleConnector(config); + case 'mariadb': return new MariaDBConnector(config); + default: throw new Error(`Unsupported DB type: ${dbType}`); + } + } +} + +// 사용 예시 +const connector = DatabaseConnectorFactory.createConnector('mysql', config); +await connector.connect(); +const result = await connector.executeQuery('SELECT * FROM users'); +await connector.disconnect(); +``` + +--- + +## 7. 멀티테넌시 구현 방식 + +### 7.1 핵심 원칙 + +**CRITICAL PROJECT RULES**: +1. **모든 쿼리는 company_code 필터 필수** +2. **req.user!.companyCode 사용 (클라이언트 전송 값 신뢰 금지)** +3. **Super Admin (company_code = '*')만 전체 데이터 조회** +4. **일반 사용자는 company_code = '*' 데이터 조회 불가** + +### 7.2 쿼리 패턴 + +```typescript +const companyCode = req.user!.companyCode; + +// Super Admin: 모든 회사 데이터 조회 +if (companyCode === '*') { + query = 'SELECT * FROM users ORDER BY company_code'; + params = []; +} +// 일반 사용자: 자사 데이터만 조회 (Super Admin 제외) +else { + query = ` + SELECT * FROM users + WHERE company_code = $1 AND company_code != '*' + `; + params = [companyCode]; +} +``` + +### 7.3 테이블 설계 + +```sql +-- 모든 비즈니스 테이블에 company_code 컬럼 필수 +CREATE TABLE table_name ( + id SERIAL PRIMARY KEY, + company_code VARCHAR(50) NOT NULL, -- 회사 코드 + ... + INDEX idx_company_code (company_code) +); + +-- Super Admin 데이터: company_code = '*' +-- 회사별 데이터: company_code = '회사코드' +``` + +### 7.4 회사 전환 (Super Admin 전용) + +```typescript +// POST /api/auth/switch-company +{ + targetCompanyCode: "ILSHIN" // 전환할 회사 코드 +} + +// 응답 +{ + success: true, + token: "새로운 JWT 토큰", // companyCode가 변경된 토큰 + userInfo: { companyCode: "ILSHIN", ... } +} +``` + +### 7.5 권한 체크 + +```typescript +// 회사 데이터 접근 권한 확인 +export function canAccessCompanyData(user: PersonBean, targetCompanyCode: string): boolean { + // Super Admin: 모든 회사 접근 가능 + if (user.companyCode === '*') { + return true; + } + + // 일반 사용자: 자사만 접근 가능 + return user.companyCode === targetCompanyCode; +} +``` + +--- + +## 8. 에러 핸들링 전략 + +### 8.1 에러 핸들링 구조 + +``` +Controller (try-catch) + ↓ 에러 발생 +Service (throw error) + ↓ +errorHandler (미들웨어) + ├─ PostgreSQL 에러 처리 + ├─ JWT 에러 처리 + ├─ 커스텀 에러 처리 (AppError) + └─ 응답 전송 (JSON) +``` + +### 8.2 커스텀 에러 클래스 + +```typescript +export class AppError extends Error { + public statusCode: number; + public isOperational: boolean; + + constructor(message: string, statusCode: number = 500) { + super(message); + this.statusCode = statusCode; + this.isOperational = true; + } +} + +// 사용 예시 +throw new AppError('중복된 데이터가 존재합니다.', 400); +``` + +### 8.3 PostgreSQL 에러 처리 + +```typescript +// errorHandler.ts +if (pgError.code === '23505') { // unique_violation + error = new AppError('중복된 데이터가 존재합니다.', 400); +} else if (pgError.code === '23503') { // foreign_key_violation + error = new AppError('참조 무결성 제약 조건 위반입니다.', 400); +} else if (pgError.code === '23502') { // not_null_violation + error = new AppError('필수 입력값이 누락되었습니다.', 400); +} +``` + +### 8.4 에러 응답 형식 + +```json +{ + "success": false, + "error": { + "code": "UNIQUE_VIOLATION", + "message": "중복된 데이터가 존재합니다.", + "details": "사용자 ID가 이미 존재합니다.", + "stack": "..." // 개발 환경에서만 포함 + } +} +``` + +### 8.5 에러 로깅 + +```typescript +// logger.ts (Winston 기반) +logger.error({ + message: error.message, + stack: error.stack, + url: req.url, + method: req.method, + ip: req.ip, + userAgent: req.get('User-Agent') +}); +``` + +### 8.6 프로세스 레벨 예외 처리 + +```typescript +// app.ts +process.on('unhandledRejection', (reason, promise) => { + logger.error('⚠️ Unhandled Promise Rejection:', reason); + // 프로세스 종료하지 않고 로깅만 수행 +}); + +process.on('uncaughtException', (error) => { + logger.error('🔥 Uncaught Exception:', error); + // 심각한 에러 시 graceful shutdown 고려 +}); +``` + +--- + +## 9. 파일 업로드/다운로드 처리 + +### 9.1 파일 업로드 + +**파일**: `fileController.ts`, `fileRoutes.ts` + +```typescript +// Multer 설정 +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + cb(null, 'uploads/'); + }, + filename: (req, file, cb) => { + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); + cb(null, file.fieldname + '-' + uniqueSuffix + path.extname(file.originalname)); + } +}); + +const upload = multer({ + storage, + limits: { fileSize: 10 * 1024 * 1024 }, // 10MB + fileFilter: (req, file, cb) => { + // 허용된 확장자 체크 + const allowedTypes = /jpeg|jpg|png|gif|pdf|doc|docx|xls|xlsx/; + const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase()); + const mimetype = allowedTypes.test(file.mimetype); + + if (mimetype && extname) { + return cb(null, true); + } else { + cb(new Error('허용되지 않는 파일 형식입니다.')); + } + } +}); + +// 라우트 +router.post('/upload', authenticateToken, upload.single('file'), uploadFile); +router.post('/upload-multiple', authenticateToken, upload.array('files', 10), uploadMultipleFiles); +``` + +### 9.2 파일 다운로드 + +```typescript +// 정적 파일 서빙 (app.ts) +app.use('/uploads', + (req, res, next) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); + res.setHeader('Cache-Control', 'public, max-age=3600'); + next(); + }, + express.static(path.join(process.cwd(), 'uploads')) +); + +// 다운로드 엔드포인트 +router.get('/download/:filename', authenticateToken, async (req, res) => { + const filename = req.params.filename; + const filePath = path.join(process.cwd(), 'uploads', filename); + + if (!fs.existsSync(filePath)) { + return res.status(404).json({ error: '파일을 찾을 수 없습니다.' }); + } + + res.download(filePath); +}); +``` + +### 9.3 화면별 파일 관리 + +**파일**: `screenFileController.ts`, `screenFileService.ts` + +```typescript +// 화면별 파일 업로드 +router.post('/screens/:screenId/files', authenticateToken, upload.single('file'), uploadScreenFile); + +// 화면별 파일 목록 조회 +router.get('/screens/:screenId/files', authenticateToken, getScreenFiles); + +// 파일 삭제 +router.delete('/screens/:screenId/files/:fileId', authenticateToken, deleteScreenFile); +``` + +--- + +## 10. 외부 연동 + +### 10.1 외부 DB 연결 + +**지원 DB**: PostgreSQL, MySQL, MSSQL, Oracle, MariaDB + +**파일**: +- `externalDbConnectionService.ts` +- `PostgreSQLConnector.ts`, `MySQLConnector.ts`, `MSSQLConnector.ts`, `OracleConnector.ts`, `MariaDBConnector.ts` + +```typescript +// 외부 DB 연결 설정 +{ + connection_name: "외부 ERP DB", + db_type: "mysql", + host: "192.168.0.100", + port: 3306, + database: "erp_db", + username: "erp_user", + password: "encrypted_password", // AES-256-CBC 암호화 + company_code: "ILSHIN", + is_active: "Y" +} + +// 연결 테스트 +POST /api/external-db-connections/:id/test +{ + success: true, + message: "연결 테스트 성공" +} + +// 쿼리 실행 +POST /api/external-db-connections/:id/query +{ + query: "SELECT * FROM products WHERE category = ?", + params: ["전자제품"] +} +``` + +### 10.2 외부 REST API 연결 + +**파일**: `externalRestApiConnectionService.ts` + +```typescript +// 외부 REST API 연결 설정 +{ + connection_name: "날씨 API", + base_url: "https://api.weather.com", + auth_type: "bearer", // bearer, api-key, basic, oauth2 + auth_credentials: { + token: "encrypted_token" + }, + headers: { + "Content-Type": "application/json" + }, + company_code: "ILSHIN" +} + +// API 호출 +POST /api/external-rest-api-connections/:id/call +{ + method: "GET", + endpoint: "/weather", + params: { city: "Seoul" } +} +``` + +### 10.3 멀티 DB 통합 쿼리 + +**파일**: `multiConnectionQueryService.ts` + +```typescript +// 여러 DB에서 동시 쿼리 실행 +POST /api/multi-connection/query +{ + connections: [ + { + connectionId: 1, + query: "SELECT * FROM orders WHERE status = 'pending'" + }, + { + connectionId: 2, + query: "SELECT * FROM inventory WHERE quantity < 10" + } + ] +} + +// 응답 +{ + success: true, + results: [ + { connectionId: 1, data: [...], rowCount: 15 }, + { connectionId: 2, data: [...], rowCount: 8 } + ] +} +``` + +### 10.4 Open API Proxy + +**파일**: `openApiProxyController.ts` + +```typescript +// 날씨 API +GET /api/open-api/weather?city=Seoul + +// 환율 API +GET /api/open-api/exchange-rate?from=USD&to=KRW +``` + +--- + +## 11. 배치/스케줄 처리 + +### 11.1 배치 스케줄러 + +**파일**: `batchSchedulerService.ts` + +```typescript +// 배치 설정 +{ + batch_name: "일일 재고 동기화", + batch_type: "external_db", // external_db, rest_api, internal + cron_schedule: "0 2 * * *", // 매일 새벽 2시 + source_connection_id: 1, + source_query: "SELECT * FROM inventory", + target_table: "inventory_sync", + mapping: { + product_id: "item_id", + quantity: "stock_qty" + }, + is_active: "Y" +} + +// 스케줄러 초기화 (서버 시작 시) +await BatchSchedulerService.initializeScheduler(); + +// 배치 수동 실행 +POST /api/batch-configs/:id/execute +``` + +### 11.2 Cron 기반 자동 실행 + +```typescript +// node-cron 사용 +const task = cron.schedule( + config.cron_schedule, // "0 2 * * *" + async () => { + logger.info(`배치 실행 시작: ${config.batch_name}`); + await executeBatchConfig(config); + }, + { timezone: 'Asia/Seoul' } +); + +// 스케줄 업데이트 +await BatchSchedulerService.updateBatchSchedule(configId); + +// 스케줄 제거 +await BatchSchedulerService.removeBatchSchedule(configId); +``` + +### 11.3 배치 실행 로그 + +**파일**: `batchExecutionLogService.ts` + +```typescript +// 배치 실행 이력 +{ + batch_config_id: 1, + execution_status: "success", // success, failed, running + start_time: "2024-12-24T02:00:00Z", + end_time: "2024-12-24T02:05:23Z", + rows_processed: 1523, + rows_inserted: 1200, + rows_updated: 300, + rows_failed: 23, + error_message: null, + execution_log: "..." +} + +// 배치 이력 조회 +GET /api/batch-execution-logs?batch_config_id=1&page=1&limit=10 +``` + +### 11.4 자동 스케줄 작업 + +**파일**: `app.ts` + +```typescript +// 메일 자동 삭제 (매일 새벽 2시) +cron.schedule('0 2 * * *', async () => { + logger.info('🗑️ 30일 지난 삭제된 메일 자동 삭제 시작...'); + const deletedCount = await mailSentHistoryService.cleanupOldDeletedMails(); + logger.info(`✅ 30일 지난 메일 ${deletedCount}개 자동 삭제 완료`); +}); + +// 리스크/알림 자동 갱신 (10분 간격) +const cacheService = RiskAlertCacheService.getInstance(); +cacheService.startAutoRefresh(); +``` + +--- + +## 12. 컨트롤러/서비스 상세 역할 + +### 12.1 인증 및 관리자 + +#### `authController.ts` / `authService.ts` +- **로그인**: 비밀번호 검증, JWT 토큰 발급, 로그인 로그 기록 +- **회원가입**: 공차중계 사용자 등록 +- **토큰 갱신**: accessToken, refreshToken 갱신 +- **현재 사용자 정보**: JWT 기반 사용자 정보 조회 +- **로그아웃**: 토큰 무효화 (클라이언트 측 처리) +- **회사 전환**: Super Admin 전용 회사 전환 + +#### `adminController.ts` / `adminService.ts` +- **메뉴 관리**: 메뉴 트리 조회, 메뉴 CRUD, 메뉴 복사, 상태 토글, 일괄 삭제 +- **사용자 관리**: 사용자 목록, 사용자 CRUD, 상태 변경, 비밀번호 초기화, 변경 이력 +- **회사 관리**: 회사 목록, 회사 CRUD (Super Admin 전용) +- **부서 관리**: 부서 목록, 사용자-부서 통합 저장 +- **로케일 설정**: 사용자 언어 설정 (ko, en) +- **테이블 스키마**: 엑셀 업로드 컬럼 매핑용 스키마 조회 + +### 12.2 테이블 및 데이터 + +#### `tableManagementController.ts` / `tableManagementService.ts` +- **테이블 목록**: PostgreSQL information_schema 조회 +- **컬럼 정보**: 컬럼 타입, 제약조건, 라벨, 참조 관계 +- **컬럼 라벨 관리**: 다국어 라벨, 표시 순서, 표시 여부 +- **컬럼 설정**: 입력 타입, 코드 카테고리, 필수 여부, 기본값 +- **테이블 조회 설정**: 조회 컬럼, 정렬 순서, 필터 조건 +- **캐시 관리**: 테이블/컬럼 정보 캐시 (10분 TTL) + +#### `dataController.ts` / `dataService.ts` +- **동적 데이터 조회**: 테이블명 기반 데이터 조회 (페이지네이션, 필터, 정렬) +- **동적 데이터 생성**: INSERT 쿼리 자동 생성 +- **동적 데이터 수정**: UPDATE 쿼리 자동 생성 +- **동적 데이터 삭제**: DELETE 쿼리 자동 생성 (논리 삭제 지원) +- **멀티테넌시 자동 필터링**: company_code 자동 추가 + +#### `entityJoinController.ts` / `entityJoinService.ts` +- **조인 설정 관리**: 테이블 간 조인 관계 설정 (1:N, N:M) +- **조인 쿼리 실행**: 설정된 조인 관계 기반 데이터 조회 +- **참조 데이터 캐싱**: 자주 사용되는 참조 데이터 캐시 + +### 12.3 화면 관리 + +#### `screenManagementController.ts` / `screenManagementService.ts` +- **화면 메타데이터 관리**: 화면 설정 (테이블, 컬럼, 레이아웃) +- **화면 목록 조회**: 회사별, 화면 그룹별 필터링 +- **화면 복사**: 화면 설정 복제 +- **화면 삭제**: 논리 삭제 +- **화면 설정 조회**: 화면 메타데이터 상세 조회 + +#### `dynamicFormController.ts` / `dynamicFormService.ts` +- **동적 폼 생성**: JSON 기반 폼 설정 → React 컴포넌트 +- **폼 유효성 검사**: 필수 입력, 데이터 타입, 길이 제한 +- **폼 제출**: 데이터 저장 (INSERT/UPDATE) + +#### `buttonActionStandardController.ts` / `buttonActionStandardService.ts` +- **버튼 액션 표준**: 저장, 삭제, 조회, 엑셀 다운로드, 커스텀 액션 +- **버튼 액션 설정**: 액션 타입, 파라미터, 권한 설정 + +### 12.4 플로우 및 데이터플로우 + +#### `flowController.ts` / `flowService.ts` +- **플로우 정의**: 플로우 생성, 수정, 삭제, 조회 +- **플로우 단계**: 단계 생성, 수정, 삭제, 순서 변경 +- **플로우 연결**: 단계 간 연결 (조건부 분기) +- **데이터 이동**: 단계 간 데이터 이동 (단일/배치) +- **오딧 로그**: 플로우 실행 이력 조회 + +#### `dataflowController.ts` / `dataflowService.ts` +- **테이블 관계 정의**: 테이블 간 관계 설정 (1:1, 1:N, N:M) +- **데이터플로우 다이어그램**: ERD 같은 시각화 +- **데이터플로우 실행**: 관계 기반 데이터 동기화 +- **관계 조회**: 다이어그램별 관계 목록 + +### 12.5 배치 관리 + +#### `batchController.ts` / `batchService.ts` +- **배치 설정 관리**: 배치 CRUD +- **배치 실행**: 수동 실행, 스케줄 실행 +- **배치 이력**: 실행 로그, 성공/실패 통계 +- **커넥션 조회**: 사용 가능한 외부 DB/API 목록 + +#### `batchSchedulerService.ts` +- **스케줄러 초기화**: 서버 시작 시 활성 배치 등록 +- **스케줄 등록**: Cron 표현식 기반 스케줄 등록 +- **스케줄 업데이트**: 배치 설정 변경 시 스케줄 재등록 +- **스케줄 제거**: 배치 삭제 시 스케줄 제거 + +#### `batchExternalDbService.ts` +- **외부 DB 배치**: 외부 DB → 내부 DB 데이터 동기화 +- **컬럼 매핑**: 소스-타겟 컬럼 매핑 +- **데이터 변환**: 타입 변환, 값 변환 + +### 12.6 외부 연동 + +#### `externalDbConnectionController.ts` / `externalDbConnectionService.ts` +- **외부 DB 연결 관리**: 연결 CRUD +- **연결 테스트**: 연결 유효성 검증 +- **쿼리 실행**: 외부 DB 쿼리 실행 +- **테이블 목록**: 외부 DB 테이블 목록 조회 +- **컬럼 정보**: 외부 DB 컬럼 정보 조회 + +#### `externalRestApiConnectionController.ts` / `externalRestApiConnectionService.ts` +- **REST API 연결 관리**: API 연결 CRUD +- **API 호출**: 외부 API 호출 (GET, POST, PUT, DELETE) +- **인증 처리**: Bearer, API Key, Basic, OAuth2 +- **응답 캐싱**: API 응답 캐싱 (TTL 설정) + +#### `multiConnectionQueryService.ts` +- **멀티 DB 통합 쿼리**: 여러 DB에서 동시 쿼리 실행 +- **결과 병합**: 여러 DB 쿼리 결과 병합 +- **오류 처리**: 부분 실패 시 에러 로그 기록 + +### 12.7 메일 관리 + +#### `mailAccountFileController.ts` / `mailAccountFileService.ts` +- **메일 계정 관리**: SMTP/IMAP 계정 CRUD +- **계정 테스트**: 연결 유효성 검증 +- **계정 상태**: 활성/비활성 상태 관리 + +#### `mailTemplateFileController.ts` / `mailTemplateFileService.ts` +- **메일 템플릿 관리**: 템플릿 CRUD +- **템플릿 컴포넌트**: JSON 기반 컴포넌트 조합 (헤더, 본문, 버튼, 푸터) +- **변수 치환**: {변수명} 형태의 변수 치환 + +#### `mailSendSimpleController.ts` / `mailSendSimpleService.ts` +- **메일 발송**: 단일 발송, 대량 발송 (100건/배치) +- **첨부파일**: 다중 첨부파일 지원 +- **참조/숨은참조**: CC, BCC 지원 +- **발송 이력**: 발송 성공/실패 로그 기록 + +#### `mailSentHistoryController.ts` / `mailSentHistoryService.ts` +- **발송 이력 조회**: 페이지네이션, 필터링, 검색 +- **이력 삭제**: 논리 삭제 (30일 후 물리 삭제) +- **자동 삭제**: Cron 기반 자동 삭제 (매일 새벽 2시) + +#### `mailReceiveBasicController.ts` / `mailReceiveBasicService.ts` +- **메일 수신**: IMAP 기반 메일 수신 +- **메일 목록**: 수신함 목록 조회 +- **메일 읽기**: 메일 상세 조회, 첨부파일 다운로드 + +### 12.8 대시보드 및 리포트 + +#### `DashboardController.ts` / `DashboardService.ts` +- **대시보드 위젯**: 차트, 테이블, 카드 위젯 +- **실시간 데이터**: 집계 쿼리 기반 실시간 데이터 조회 +- **사용자 설정**: 사용자별 대시보드 레이아웃 저장 + +#### `reportController.ts` / `reportService.ts` +- **리포트 생성**: 동적 리포트 생성 (PDF, Excel, Word) +- **리포트 템플릿**: 템플릿 기반 리포트 생성 +- **리포트 스케줄**: 정기 리포트 자동 생성 및 메일 발송 + +### 12.9 기타 도메인 + +#### `deliveryController.ts` / `deliveryService.ts` +- **배송 관리**: 배송 정보 등록, 조회, 수정 +- **화물 추적**: 배송 상태 추적 + +#### `riskAlertController.ts` / `riskAlertService.ts` / `riskAlertCacheService.ts` +- **리스크 알림**: 리스크 기준 설정, 알림 발생 +- **자동 갱신**: 10분 간격 자동 갱신 (캐시 기반) + +#### `todoController.ts` / `todoService.ts` +- **To-Do 관리**: 할 일 CRUD, 상태 변경 (대기, 진행, 완료) + +#### `bookingController.ts` / `bookingService.ts` +- **예약 관리**: 예약 요청 CRUD, 승인/거부 + +#### `digitalTwinController.ts` / `digitalTwinLayoutController.ts` / `digitalTwinDataController.ts` +- **디지털 트윈**: 야드 관제, 3D 레이아웃, 실시간 데이터 시각화 + +#### `scheduleController.ts` / `scheduleService.ts` +- **스케줄 자동 생성**: 작업 스케줄 자동 생성 (규칙 기반) + +#### `workHistoryController.ts` / `workHistoryService.ts` +- **작업 이력**: 작업 로그 조회, 필터링, 검색 + +#### `roleController.ts` / `roleService.ts` +- **권한 그룹**: 권한 그룹 CRUD, 사용자-권한 매핑 + +#### `numberingRuleController.ts` / `numberingRuleService.ts` +- **채번 규칙**: 자동 채번 규칙 설정 (접두사, 연번, 접미사) + +#### `entitySearchController.ts` / `entitySearchService.ts` +- **엔티티 검색**: 동적 엔티티 검색 (테이블, 컬럼, 조건 기반) + +#### `cascadingController.ts` 시리즈 +- **연쇄 드롭다운**: 조건부 드롭다운 관계 설정 +- **자동 입력**: 연쇄 자동 입력 관계 설정 +- **상호 배제**: 상호 배타적 선택 관계 설정 +- **다단계 계층**: 계층 구조 관계 설정 + +--- + +## 📊 통계 요약 + +| 구분 | 개수 | +|------|------| +| **컨트롤러** | 69개 | +| **서비스** | 87개 | +| **라우터** | 77개 | +| **미들웨어** | 4개 | +| **엔드포인트** | 200개 이상 | +| **데이터베이스 커넥터** | 5종 (PostgreSQL, MySQL, MSSQL, Oracle, MariaDB) | +| **유틸리티** | 16개 | +| **타입 정의** | 26개 | + +--- + +## 🔧 기술 스택 + +```json +{ + "런타임": "Node.js 20.10.0+", + "언어": "TypeScript 5.3.3", + "프레임워크": "Express 4.18.2", + "데이터베이스": "PostgreSQL (pg 8.16.3)", + "인증": "JWT (jsonwebtoken 9.0.2)", + "암호화": "BCrypt (bcryptjs 2.4.3)", + "로깅": "Winston 3.11.0", + "스케줄링": "node-cron 4.2.1", + "메일": "Nodemailer 6.10.1 + IMAP 0.8.19", + "파일 업로드": "Multer 1.4.5", + "보안": "Helmet 7.1.0", + "외부 DB": "mysql2, mssql, oracledb", + "캐싱": "In-Memory Cache (Map 기반)", + "압축": "compression 1.7.4", + "Rate Limiting": "express-rate-limit 7.1.5" +} +``` + +--- + +## 📁 핵심 파일 경로 + +``` +backend-node/ +├── src/ +│ ├── app.ts # 앱 진입점 +│ ├── config/environment.ts # 환경변수 설정 +│ ├── database/ +│ │ ├── db.ts # Raw Query 매니저 +│ │ ├── DatabaseConnectorFactory.ts # DB 커넥터 팩토리 +│ │ └── [DB]Connector.ts # 각 DB별 커넥터 +│ ├── middleware/ +│ │ ├── authMiddleware.ts # JWT 인증 +│ │ ├── permissionMiddleware.ts # 권한 체크 +│ │ ├── superAdminMiddleware.ts # Super Admin 체크 +│ │ └── errorHandler.ts # 에러 핸들링 +│ ├── utils/ +│ │ ├── logger.ts # Winston 로거 +│ │ ├── jwtUtils.ts # JWT 유틸 +│ │ ├── encryptUtil.ts # BCrypt 암호화 +│ │ ├── passwordEncryption.ts # AES 암호화 +│ │ ├── cache.ts # 캐시 유틸 +│ │ └── permissionUtils.ts # 권한 유틸 +│ └── types/ +│ ├── auth.ts # 인증 타입 +│ ├── tableManagement.ts # 테이블 관리 타입 +│ └── ... +└── package.json # 의존성 관리 +``` + +--- + +## 🚀 서버 시작 프로세스 + +``` +1. dotenv 환경변수 로드 +2. Express 앱 생성 +3. 미들웨어 설정 (helmet, cors, compression, rate-limit) +4. 데이터베이스 연결 풀 초기화 +5. 라우터 등록 (77개 라우터) +6. 에러 핸들러 등록 +7. 서버 리스닝 (기본 포트: 8080) +8. 데이터베이스 마이그레이션 실행 +9. 배치 스케줄러 초기화 +10. 리스크/알림 자동 갱신 시작 +11. 메일 자동 삭제 스케줄러 시작 +``` + +--- + +## 🔒 보안 고려사항 + +1. **JWT 기반 인증**: 세션 없이 무상태(Stateless) 인증 +2. **비밀번호 암호화**: BCrypt (12 rounds) +3. **외부 DB 크레덴셜 암호화**: AES-256-CBC +4. **SQL Injection 방지**: Parameterized Query 필수 +5. **XSS 방지**: Helmet 보안 헤더 +6. **CSRF 방지**: CORS 설정 + JWT +7. **Rate Limiting**: 1분당 요청 수 제한 +8. **DDL 실행 제한**: Super Admin만 가능 + 5초 간격 제한 +9. **멀티테넌시 격리**: company_code 자동 필터링 +10. **에러 정보 노출 방지**: 운영 환경에서 스택 트레이스 숨김 + +--- + +## 📝 추천 개선 사항 + +1. **API 문서화**: Swagger/OpenAPI 자동 생성 +2. **단위 테스트**: Jest 기반 테스트 커버리지 확대 +3. **Redis 캐싱**: In-Memory 캐시 → Redis 전환 +4. **로그 중앙화**: Winston → ELK Stack 연동 +5. **성능 모니터링**: APM 도구 연동 (New Relic, Datadog) +6. **Docker 컨테이너화**: Dockerfile 및 docker-compose 개선 +7. **CI/CD 파이프라인**: GitHub Actions, Jenkins 연동 +8. **API Rate Limiting 세분화**: 엔드포인트별 제한 설정 +9. **WebSocket 지원**: 실시간 알림 및 데이터 업데이트 +10. **GraphQL API**: REST API + GraphQL 병행 지원 + +--- + +**문서 작성자**: WACE 백엔드 전문가 +**최종 수정일**: 2026-02-06 +**버전**: 1.0.0 + diff --git a/docs/backend-architecture-detailed-analysis.md b/docs/backend-architecture-detailed-analysis.md new file mode 100644 index 00000000..534cf7a3 --- /dev/null +++ b/docs/backend-architecture-detailed-analysis.md @@ -0,0 +1,1855 @@ +# WACE ERP Backend Architecture - 상세 분석 문서 + +> **작성일**: 2026-02-06 +> **작성자**: Backend Specialist +> **목적**: WACE ERP 시스템 백엔드 전체 아키텍처 분석 및 워크플로우 문서화 + +--- + +## 📑 목차 + +1. [전체 개요](#1-전체-개요) +2. [디렉토리 구조](#2-디렉토리-구조) +3. [기술 스택](#3-기술-스택) +4. [미들웨어 스택](#4-미들웨어-스택) +5. [인증/인가 시스템](#5-인증인가-시스템) +6. [멀티테넌시 구현](#6-멀티테넌시-구현) +7. [API 라우트 전체 목록](#7-api-라우트-전체-목록) +8. [비즈니스 도메인별 모듈](#8-비즈니스-도메인별-모듈) +9. [데이터베이스 접근 방식](#9-데이터베이스-접근-방식) +10. [외부 시스템 연동](#10-외부-시스템-연동) +11. [배치/스케줄 처리](#11-배치스케줄-처리) +12. [파일 처리](#12-파일-처리) +13. [에러 핸들링](#13-에러-핸들링) +14. [로깅 시스템](#14-로깅-시스템) +15. [보안 및 권한 관리](#15-보안-및-권한-관리) +16. [성능 최적화](#16-성능-최적화) + +--- + +## 1. 전체 개요 + +### 1.1 프로젝트 정보 +- **프로젝트명**: WACE ERP Backend (Node.js) +- **언어**: TypeScript (Strict Mode) +- **런타임**: Node.js 20.10.0+ +- **프레임워크**: Express.js +- **데이터베이스**: PostgreSQL (Raw Query 기반) +- **포트**: 8080 (기본값) + +### 1.2 아키텍처 특징 +1. **Layered Architecture**: Controller → Service → Database 3계층 구조 +2. **Multi-tenancy**: company_code 기반 완전한 데이터 격리 +3. **JWT 인증**: Stateless 토큰 기반 인증 시스템 +4. **Raw Query**: ORM 없이 PostgreSQL 직접 쿼리 (성능 최적화) +5. **Connection Pool**: pg 라이브러리 기반 안정적인 연결 관리 +6. **Type-Safe**: TypeScript 타입 시스템 적극 활용 + +### 1.3 주요 기능 +- 관리자 기능 (사용자/권한/메뉴 관리) +- 테이블/화면 메타데이터 관리 (동적 화면 생성) +- 플로우 관리 (워크플로우 엔진) +- 데이터플로우 다이어그램 (ERD/관계도) +- 외부 DB 연동 (PostgreSQL, MySQL, MSSQL, Oracle) +- 외부 REST API 연동 +- 배치 자동 실행 (Cron 스케줄러) +- 메일 발송/수신 +- 파일 업로드/다운로드 +- 다국어 지원 +- 대시보드/리포트 + +--- + +## 2. 디렉토리 구조 + +``` +backend-node/ +├── src/ +│ ├── app.ts # Express 앱 진입점 +│ ├── config/ # 환경 설정 +│ │ ├── environment.ts # 환경변수 관리 +│ │ └── multerConfig.ts # 파일 업로드 설정 +│ ├── controllers/ # 컨트롤러 (70+ 파일) +│ │ ├── authController.ts +│ │ ├── adminController.ts +│ │ ├── tableManagementController.ts +│ │ ├── flowController.ts +│ │ ├── dataflowController.ts +│ │ ├── batchController.ts +│ │ └── ... +│ ├── services/ # 비즈니스 로직 (80+ 파일) +│ │ ├── authService.ts +│ │ ├── adminService.ts +│ │ ├── tableManagementService.ts +│ │ ├── flowExecutionService.ts +│ │ ├── batchSchedulerService.ts +│ │ └── ... +│ ├── routes/ # API 라우터 (70+ 파일) +│ │ ├── authRoutes.ts +│ │ ├── adminRoutes.ts +│ │ ├── tableManagementRoutes.ts +│ │ ├── flowRoutes.ts +│ │ └── ... +│ ├── middleware/ # 미들웨어 (4개) +│ │ ├── authMiddleware.ts # JWT 인증 +│ │ ├── permissionMiddleware.ts # 권한 체크 +│ │ ├── superAdminMiddleware.ts # 슈퍼관리자 전용 +│ │ └── errorHandler.ts # 에러 핸들러 +│ ├── database/ # DB 연결 +│ │ ├── db.ts # PostgreSQL Pool 관리 +│ │ ├── DatabaseConnectorFactory.ts # 외부 DB 연결 +│ │ └── runMigration.ts # 마이그레이션 +│ ├── types/ # TypeScript 타입 정의 (26개) +│ │ ├── auth.ts +│ │ ├── batchTypes.ts +│ │ ├── flow.ts +│ │ └── ... +│ ├── utils/ # 유틸리티 함수 +│ │ ├── jwtUtils.ts # JWT 토큰 관리 +│ │ ├── permissionUtils.ts # 권한 체크 +│ │ ├── logger.ts # Winston 로거 +│ │ ├── encryptUtil.ts # 암호화/복호화 +│ │ ├── passwordEncryption.ts # 비밀번호 암호화 +│ │ └── ... +│ └── interfaces/ # 인터페이스 +│ └── DatabaseConnector.ts # DB 커넥터 인터페이스 +├── scripts/ # 스크립트 +│ ├── dev/ # 개발 환경 스크립트 +│ └── prod/ # 운영 환경 스크립트 +├── data/ # 정적 데이터 (JSON) +├── uploads/ # 업로드된 파일 +├── logs/ # 로그 파일 +├── package.json # NPM 의존성 +├── tsconfig.json # TypeScript 설정 +└── .env # 환경변수 +``` + +--- + +## 3. 기술 스택 + +### 3.1 핵심 라이브러리 + +```json +{ + "dependencies": { + "express": "^4.18.2", // 웹 프레임워크 + "pg": "^8.16.3", // PostgreSQL 클라이언트 + "jsonwebtoken": "^9.0.2", // JWT 토큰 + "bcryptjs": "^2.4.3", // 비밀번호 암호화 + "dotenv": "^16.3.1", // 환경변수 + "cors": "^2.8.5", // CORS 처리 + "helmet": "^7.1.0", // 보안 헤더 + "compression": "^1.7.4", // Gzip 압축 + "express-rate-limit": "^7.1.5", // Rate Limiting + "winston": "^3.11.0", // 로깅 + "multer": "^1.4.5-lts.1", // 파일 업로드 + "node-cron": "^4.2.1", // Cron 스케줄러 + "axios": "^1.11.0", // HTTP 클라이언트 + "nodemailer": "^6.10.1", // 메일 발송 + "imap": "^0.8.19", // 메일 수신 + "mysql2": "^3.15.0", // MySQL 연결 + "mssql": "^11.0.1", // MSSQL 연결 + "oracledb": "^6.9.0", // Oracle 연결 + "uuid": "^13.0.0", // UUID 생성 + "joi": "^17.11.0" // 데이터 검증 + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/pg": "^8.15.5", + "typescript": "^5.3.3", + "nodemon": "^3.1.10", + "ts-node": "^10.9.2", + "jest": "^29.7.0", + "prettier": "^3.1.0", + "eslint": "^8.55.0" + } +} +``` + +### 3.2 데이터베이스 연결 +- **메인 DB**: PostgreSQL (pg 라이브러리) +- **외부 DB 지원**: MySQL, MSSQL, Oracle, PostgreSQL +- **Connection Pool**: Min 2~5 / Max 10~20 +- **Timeout**: Connection 30s / Query 60s + +--- + +## 4. 미들웨어 스택 + +### 4.1 미들웨어 실행 순서 (app.ts) + +```typescript +// 1. 프로세스 레벨 예외 처리 +process.on('unhandledRejection', ...) +process.on('uncaughtException', ...) +process.on('SIGTERM', ...) +process.on('SIGINT', ...) + +// 2. 보안 미들웨어 +app.use(helmet({ + contentSecurityPolicy: { ... }, // CSP 설정 + frameguard: { ... } // Iframe 보호 +})) + +// 3. 압축 미들웨어 +app.use(compression()) + +// 4. Body Parser +app.use(express.json({ limit: '10mb' })) +app.use(express.urlencoded({ extended: true, limit: '10mb' })) + +// 5. 정적 파일 서빙 (/uploads) +app.use('/uploads', express.static(...)) + +// 6. CORS 설정 +app.use(cors({ + origin: [...], // 허용 도메인 + credentials: true, // 쿠키 포함 + methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'] +})) + +// 7. Rate Limiting (1분에 10000회) +app.use('/api/', limiter) + +// 8. 토큰 자동 갱신 (1시간 이내 만료 시 갱신) +app.use('/api/', refreshTokenIfNeeded) + +// 9. API 라우터 (70+개) +app.use('/api/auth', authRoutes) +app.use('/api/admin', adminRoutes) +// ... + +// 10. 404 핸들러 +app.use('*', notFoundHandler) + +// 11. 에러 핸들러 +app.use(errorHandler) +``` + +### 4.2 인증 미들웨어 체인 + +```typescript +// 기본 인증 +authenticateToken → Controller + +// 관리자 권한 +authenticateToken → requireAdmin → Controller + +// 슈퍼관리자 권한 +authenticateToken → requireSuperAdmin → Controller + +// 회사 데이터 접근 +authenticateToken → requireCompanyAccess → Controller + +// DDL 실행 권한 +authenticateToken → requireDDLPermission → Controller +``` + +--- + +## 5. 인증/인가 시스템 + +### 5.1 인증 플로우 + +``` +┌──────────┐ +│ 로그인 │ +│ 요청 │ +└────┬─────┘ + │ + ▼ +┌────────────────────┐ +│ AuthController │ +│ .login() │ +└────┬───────────────┘ + │ + ▼ +┌────────────────────┐ +│ AuthService │ +│ .processLogin() │ +└────┬───────────────┘ + │ + ├─► 1. loginPwdCheck() → DB에서 비밀번호 검증 + │ (마스터 패스워드: qlalfqjsgh11) + │ + ├─► 2. getPersonBeanFromSession() → 사용자 정보 조회 + │ (user_info, dept_info, company_mng JOIN) + │ + ├─► 3. insertLoginAccessLog() → 로그인 이력 저장 + │ + └─► 4. JwtUtils.generateToken() → JWT 토큰 생성 + (payload: userId, userName, companyCode, userType) +``` + +### 5.2 JWT 토큰 구조 + +```typescript +// Payload +{ + userId: "user123", // 사용자 ID + userName: "홍길동", // 사용자명 + deptName: "개발팀", // 부서명 + companyCode: "ILSHIN", // 회사 코드 (멀티테넌시 키) + companyName: "일신정공", // 회사명 + userType: "COMPANY_ADMIN", // 권한 레벨 + userTypeName: "회사관리자", // 권한명 + iat: 1234567890, // 발급 시간 + exp: 1234654290, // 만료 시간 (24시간) + iss: "PMS-System", // 발급자 + aud: "PMS-Users" // 대상 +} +``` + +### 5.3 권한 체계 (3단계) + +```typescript +// 1. SUPER_ADMIN (최고 관리자) +- company_code = "*" +- userType = "SUPER_ADMIN" +- 전체 회사 데이터 접근 가능 +- DDL 실행 가능 +- 회사 생성/삭제 가능 +- 시스템 설정 변경 가능 + +// 2. COMPANY_ADMIN (회사 관리자) +- company_code = "ILSHIN" (특정 회사) +- userType = "COMPANY_ADMIN" +- 자기 회사 데이터만 접근 +- 자기 회사 사용자 관리 가능 +- 회사 설정 변경 가능 + +// 3. USER (일반 사용자) +- company_code = "ILSHIN" +- userType = "USER" | "GUEST" | "PARTNER" +- 자기 회사 데이터만 접근 +- 읽기/쓰기 권한만 +``` + +### 5.4 토큰 갱신 메커니즘 + +```typescript +// refreshTokenIfNeeded 미들웨어 +// 1. 토큰 만료까지 1시간 미만 남은 경우 +// 2. 자동으로 새 토큰 발급 +// 3. 응답 헤더에 "X-New-Token" 추가 +// 4. 프론트엔드에서 자동으로 토큰 교체 +``` + +--- + +## 6. 멀티테넌시 구현 + +### 6.1 핵심 원칙 + +```typescript +// 🚨 절대 규칙: 모든 쿼리는 company_code 필터 필수 +const companyCode = req.user!.companyCode; + +if (companyCode === "*") { + // 슈퍼관리자: 모든 데이터 조회 가능 + query = "SELECT * FROM table ORDER BY company_code"; +} else { + // 일반 사용자: 자기 회사 데이터만 + query = "SELECT * FROM table WHERE company_code = $1 AND company_code != '*'"; + params = [companyCode]; +} +``` + +### 6.2 회사 데이터 격리 패턴 + +```typescript +// ✅ 올바른 패턴 +async function getDataList(req: AuthenticatedRequest) { + const companyCode = req.user!.companyCode; // JWT에서 추출 + + // 슈퍼관리자 체크 + if (companyCode === "*") { + // 모든 회사 데이터 조회 + return await query("SELECT * FROM data WHERE 1=1"); + } + + // 일반 사용자: 자기 회사 + 슈퍼관리자 데이터 제외 + return await query( + "SELECT * FROM data WHERE company_code = $1 AND company_code != '*'", + [companyCode] + ); +} + +// ❌ 잘못된 패턴 (절대 금지!) +async function getDataList(req: AuthenticatedRequest) { + const companyCode = req.body.companyCode; // 클라이언트에서 받음 (위험!) + return await query("SELECT * FROM data WHERE company_code = $1", [companyCode]); +} +``` + +### 6.3 슈퍼관리자 숨김 규칙 + +```sql +-- 슈퍼관리자 사용자 (company_code = '*')는 +-- 일반 회사 사용자에게 보이면 안 됨 + +-- ✅ 올바른 쿼리 +SELECT * FROM user_info +WHERE company_code = $1 + AND company_code != '*' -- 슈퍼관리자 숨김 + +-- ❌ 잘못된 쿼리 +SELECT * FROM user_info +WHERE company_code = $1 -- 슈퍼관리자 노출 위험 +``` + +### 6.4 회사 전환 기능 (SUPER_ADMIN 전용) + +```typescript +// POST /api/auth/switch-company +// WACE 관리자가 특정 회사로 컨텍스트 전환 +{ + companyCode: "ILSHIN" // 전환할 회사 코드 +} + +// 새로운 JWT 토큰 발급 (company_code만 변경) +// userType은 "SUPER_ADMIN" 유지 +``` + +--- + +## 7. API 라우트 전체 목록 + +### 7.1 인증/관리자 기능 + +| 경로 | 메서드 | 기능 | 권한 | +|------|--------|------|------| +| `/api/auth/login` | POST | 로그인 | 공개 | +| `/api/auth/logout` | POST | 로그아웃 | 인증 | +| `/api/auth/me` | GET | 현재 사용자 정보 | 인증 | +| `/api/auth/status` | GET | 인증 상태 확인 | 공개 | +| `/api/auth/refresh` | POST | 토큰 갱신 | 인증 | +| `/api/auth/signup` | POST | 회원가입 (공차중계) | 공개 | +| `/api/auth/switch-company` | POST | 회사 전환 | 슈퍼관리자 | +| `/api/admin/users` | GET | 사용자 목록 | 관리자 | +| `/api/admin/users` | POST | 사용자 생성 | 관리자 | +| `/api/admin/menus` | GET | 메뉴 목록 | 인증 | +| `/api/admin/web-types` | GET | 웹타입 표준 관리 | 인증 | +| `/api/admin/button-actions` | GET | 버튼 액션 표준 | 인증 | +| `/api/admin/component-standards` | GET | 컴포넌트 표준 | 인증 | +| `/api/admin/template-standards` | GET | 템플릿 표준 | 인증 | +| `/api/admin/reports` | GET | 리포트 관리 | 인증 | + +### 7.2 테이블/화면 관리 + +| 경로 | 메서드 | 기능 | 권한 | +|------|--------|------|------| +| `/api/table-management/tables` | GET | 테이블 목록 | 인증 | +| `/api/table-management/tables/:table/columns` | GET | 컬럼 목록 | 인증 | +| `/api/table-management/tables/:table/data` | POST | 데이터 조회 | 인증 | +| `/api/table-management/tables/:table/add` | POST | 데이터 추가 | 인증 | +| `/api/table-management/tables/:table/edit` | PUT | 데이터 수정 | 인증 | +| `/api/table-management/tables/:table/delete` | DELETE | 데이터 삭제 | 인증 | +| `/api/table-management/tables/:table/log` | POST | 로그 테이블 생성 | 관리자 | +| `/api/table-management/multi-table-save` | POST | 다중 테이블 저장 | 인증 | +| `/api/screen-management/screens` | GET | 화면 목록 | 인증 | +| `/api/screen-management/screens/:id` | GET | 화면 상세 | 인증 | +| `/api/screen-management/screens` | POST | 화면 생성 | 관리자 | +| `/api/screen-groups` | GET | 화면 그룹 관리 | 인증 | +| `/api/screen-files` | GET | 화면 파일 관리 | 인증 | + +### 7.3 플로우 관리 + +| 경로 | 메서드 | 기능 | 권한 | +|------|--------|------|------| +| `/api/flow/definitions` | GET | 플로우 정의 목록 | 인증 | +| `/api/flow/definitions` | POST | 플로우 생성 | 인증 | +| `/api/flow/definitions/:id/steps` | GET | 단계 목록 | 인증 | +| `/api/flow/definitions/:id/steps` | POST | 단계 생성 | 인증 | +| `/api/flow/connections/:flowId` | GET | 연결 목록 | 인증 | +| `/api/flow/connections` | POST | 연결 생성 | 인증 | +| `/api/flow/move` | POST | 데이터 이동 (단건) | 인증 | +| `/api/flow/move-batch` | POST | 데이터 이동 (다건) | 인증 | +| `/api/flow/:flowId/step/:stepId/count` | GET | 단계 데이터 개수 | 인증 | +| `/api/flow/:flowId/step/:stepId/list` | GET | 단계 데이터 목록 | 인증 | +| `/api/flow/audit/:flowId/:recordId` | GET | 오딧 로그 조회 | 인증 | + +### 7.4 데이터플로우/다이어그램 + +| 경로 | 메서드 | 기능 | 권한 | +|------|--------|------|------| +| `/api/dataflow/relationships` | GET | 관계 목록 | 인증 | +| `/api/dataflow/relationships` | POST | 관계 생성 | 인증 | +| `/api/dataflow-diagrams` | GET | 다이어그램 목록 | 인증 | +| `/api/dataflow-diagrams/:id` | GET | 다이어그램 상세 | 인증 | +| `/api/dataflow` | POST | 데이터플로우 실행 | 인증 | + +### 7.5 외부 연동 + +| 경로 | 메서드 | 기능 | 권한 | +|------|--------|------|------| +| `/api/external-db-connections` | GET | 외부 DB 연결 목록 | 인증 | +| `/api/external-db-connections` | POST | 외부 DB 연결 생성 | 관리자 | +| `/api/external-db-connections/:id/test` | POST | 연결 테스트 | 인증 | +| `/api/external-db-connections/:id/tables` | GET | 테이블 목록 | 인증 | +| `/api/external-rest-api-connections` | GET | REST API 연결 목록 | 인증 | +| `/api/external-rest-api-connections` | POST | REST API 연결 생성 | 관리자 | +| `/api/external-rest-api-connections/:id/test` | POST | API 테스트 | 인증 | +| `/api/multi-connection/query` | POST | 멀티 DB 쿼리 | 인증 | + +### 7.6 배치 관리 + +| 경로 | 메서드 | 기능 | 권한 | +|------|--------|------|------| +| `/api/batch-configs` | GET | 배치 설정 목록 | 인증 | +| `/api/batch-configs` | POST | 배치 설정 생성 | 관리자 | +| `/api/batch-configs/:id` | PUT | 배치 설정 수정 | 관리자 | +| `/api/batch-configs/:id` | DELETE | 배치 설정 삭제 | 관리자 | +| `/api/batch-management/:id/execute` | POST | 배치 즉시 실행 | 관리자 | +| `/api/batch-execution-logs` | GET | 실행 이력 | 인증 | + +### 7.7 메일 시스템 + +| 경로 | 메서드 | 기능 | 권한 | +|------|--------|------|------| +| `/api/mail/accounts` | GET | 계정 목록 | 인증 | +| `/api/mail/templates-file` | GET | 템플릿 목록 | 인증 | +| `/api/mail/send` | POST | 메일 발송 | 인증 | +| `/api/mail/sent` | GET | 발송 이력 | 인증 | +| `/api/mail/receive` | POST | 메일 수신 | 인증 | + +### 7.8 파일 관리 + +| 경로 | 메서드 | 기능 | 권한 | +|------|--------|------|------| +| `/api/files/upload` | POST | 파일 업로드 | 인증 | +| `/api/files/download/:id` | GET | 파일 다운로드 | 인증 | +| `/api/files` | GET | 파일 목록 | 인증 | +| `/api/files/:id` | DELETE | 파일 삭제 | 인증 | +| `/uploads/:filename` | GET | 정적 파일 서빙 | 공개 | + +### 7.9 기타 기능 + +| 경로 | 메서드 | 기능 | 권한 | +|------|--------|------|------| +| `/api/dashboards` | GET | 대시보드 데이터 | 인증 | +| `/api/common-codes` | GET | 공통코드 조회 | 인증 | +| `/api/multilang` | GET | 다국어 조회 | 인증 | +| `/api/company-management` | GET | 회사 목록 | 슈퍼관리자 | +| `/api/departments` | GET | 부서 목록 | 인증 | +| `/api/roles` | GET | 권한 그룹 관리 | 인증 | +| `/api/ddl` | POST | DDL 실행 | 슈퍼관리자 | +| `/api/open-api/weather` | GET | 날씨 정보 | 인증 | +| `/api/open-api/exchange` | GET | 환율 정보 | 인증 | +| `/api/digital-twin` | GET | 디지털 트윈 | 인증 | +| `/api/yard-layouts` | GET | 3D 필드 레이아웃 | 인증 | +| `/api/schedule` | POST | 스케줄 자동 생성 | 인증 | +| `/api/numbering-rules` | GET | 채번 규칙 관리 | 인증 | +| `/api/entity-search` | POST | 엔티티 검색 | 인증 | +| `/api/todos` | GET | To-Do 관리 | 인증 | +| `/api/bookings` | GET | 예약 요청 관리 | 인증 | +| `/api/risk-alerts` | GET | 리스크/알림 관리 | 인증 | +| `/health` | GET | 헬스 체크 | 공개 | + +--- + +## 8. 비즈니스 도메인별 모듈 + +### 8.1 관리자 도메인 (Admin) + +**컨트롤러**: `adminController.ts` +**서비스**: `adminService.ts` +**주요 기능**: +- 사용자 관리 (CRUD) +- 메뉴 관리 (트리 구조) +- 권한 그룹 관리 +- 시스템 설정 +- 사용자 이력 조회 + +**핵심 로직**: +```typescript +// 사용자 목록 조회 (멀티테넌시 적용) +async getUserList(params) { + const companyCode = params.userCompanyCode; + + // 슈퍼관리자: 모든 회사 사용자 조회 + if (companyCode === "*") { + return await query("SELECT * FROM user_info WHERE 1=1"); + } + + // 일반 관리자: 자기 회사 사용자만 + 슈퍼관리자 숨김 + return await query( + "SELECT * FROM user_info WHERE company_code = $1 AND company_code != '*'", + [companyCode] + ); +} +``` + +### 8.2 테이블/화면 관리 도메인 + +**컨트롤러**: `tableManagementController.ts`, `screenManagementController.ts` +**서비스**: `tableManagementService.ts`, `screenManagementService.ts` +**주요 기능**: +- 테이블 메타데이터 관리 (컬럼 설정) +- 화면 정의 (JSON 기반 동적 화면) +- 화면 그룹 관리 +- 테이블 로그 시스템 +- 엔티티 관계 관리 + +**핵심 로직**: +```typescript +// 테이블 데이터 조회 (페이징, 정렬, 필터) +async getTableData(tableName, filters, pagination) { + const companyCode = req.user!.companyCode; + + // 동적 WHERE 절 생성 + const whereClauses = [`company_code = '${companyCode}'`]; + + // 필터 조건 추가 + filters.forEach(filter => { + whereClauses.push(`${filter.column} ${filter.operator} '${filter.value}'`); + }); + + // 페이징 + 정렬 + const sql = ` + SELECT * FROM ${tableName} + WHERE ${whereClauses.join(' AND ')} + ORDER BY ${pagination.sortBy} ${pagination.sortOrder} + LIMIT ${pagination.limit} OFFSET ${pagination.offset} + `; + + return await query(sql); +} +``` + +### 8.3 플로우 도메인 (Flow) + +**컨트롤러**: `flowController.ts` +**서비스**: `flowExecutionService.ts`, `flowDefinitionService.ts` +**주요 기능**: +- 플로우 정의 (워크플로우 설계) +- 단계(Step) 관리 +- 단계 간 연결(Connection) 관리 +- 데이터 이동 (단건/다건) +- 조건부 이동 +- 오딧 로그 + +**핵심 로직**: +```typescript +// 데이터 이동 (단계 간 전환) +async moveData(flowId, fromStepId, toStepId, recordIds) { + return await transaction(async (client) => { + // 1. 연결 조건 확인 + const connection = await getConnection(fromStepId, toStepId); + + // 2. 조건 평가 (있으면) + if (connection.condition) { + const isValid = await evaluateCondition(connection.condition, recordIds); + if (!isValid) throw new Error("조건 불충족"); + } + + // 3. 데이터 이동 + await client.query( + `UPDATE ${connection.targetTable} + SET flow_step_id = $1, updated_at = now() + WHERE id = ANY($2) AND company_code = $3`, + [toStepId, recordIds, companyCode] + ); + + // 4. 오딧 로그 기록 + await insertAuditLog(flowId, recordIds, fromStepId, toStepId); + }); +} +``` + +### 8.4 데이터플로우 도메인 (Dataflow) + +**컨트롤러**: `dataflowController.ts`, `dataflowDiagramController.ts` +**서비스**: `dataflowService.ts`, `dataflowDiagramService.ts` +**주요 기능**: +- 테이블 관계 정의 (ERD) +- 다이어그램 관리 (시각화) +- 관계 실행 (조인 쿼리 자동 생성) +- 관계 검증 + +**핵심 로직**: +```typescript +// 테이블 관계 실행 (동적 조인) +async executeRelationship(relationshipId) { + const rel = await getRelationship(relationshipId); + + // 동적 조인 쿼리 생성 + const sql = ` + SELECT + a.*, + b.* + FROM ${rel.fromTableName} a + ${rel.relationshipType === '1:N' ? 'LEFT JOIN' : 'INNER JOIN'} + ${rel.toTableName} b + ON a.${rel.fromColumnName} = b.${rel.toColumnName} + WHERE a.company_code = $1 + `; + + return await query(sql, [companyCode]); +} +``` + +### 8.5 배치 도메인 (Batch) + +**컨트롤러**: `batchController.ts`, `batchManagementController.ts` +**서비스**: `batchService.ts`, `batchSchedulerService.ts` +**주요 기능**: +- 배치 설정 관리 +- Cron 스케줄링 (node-cron) +- 외부 DB → 내부 DB 데이터 동기화 +- 컬럼 매핑 +- 실행 이력 관리 + +**핵심 로직**: +```typescript +// 배치 스케줄러 초기화 (서버 시작 시) +async initializeScheduler() { + const activeBatches = await getBatchConfigs({ is_active: 'Y' }); + + for (const batch of activeBatches) { + // Cron 스케줄 등록 + const task = cron.schedule(batch.cron_schedule, async () => { + await executeBatchConfig(batch); + }, { + timezone: "Asia/Seoul" + }); + + scheduledTasks.set(batch.id, task); + } +} + +// 배치 실행 +async executeBatchConfig(config) { + // 1. 소스 DB에서 데이터 가져오기 + const sourceData = await getSourceData(config.source_connection); + + // 2. 컬럼 매핑 적용 + const mappedData = applyColumnMapping(sourceData, config.batch_mappings); + + // 3. 타겟 DB에 INSERT/UPDATE + await upsertToTarget(mappedData, config.target_table); + + // 4. 실행 로그 기록 + await logExecution(config.id, sourceData.length); +} +``` + +### 8.6 외부 연동 도메인 (External) + +**컨트롤러**: `externalDbConnectionController.ts`, `externalRestApiConnectionController.ts` +**서비스**: `externalDbConnectionService.ts`, `dbConnectionManager.ts` +**주요 기능**: +- 외부 DB 연결 설정 (PostgreSQL, MySQL, MSSQL, Oracle) +- Connection Pool 관리 +- 연결 테스트 +- 외부 REST API 연결 설정 +- API 프록시 + +**핵심 로직**: +```typescript +// 외부 DB 연결 (Factory 패턴) +class DatabaseConnectorFactory { + static getConnector(dbType: string, config: any) { + switch (dbType) { + case 'POSTGRESQL': + return new PostgreSQLConnector(config); + case 'MYSQL': + return new MySQLConnector(config); + case 'MSSQL': + return new MSSQLConnector(config); + case 'ORACLE': + return new OracleConnector(config); + default: + throw new Error(`지원하지 않는 DB 타입: ${dbType}`); + } + } +} + +// 외부 DB 쿼리 실행 +async executeExternalQuery(connectionId, sql) { + // 1. 연결 정보 조회 (암호화된 비밀번호 복호화) + const connection = await getConnection(connectionId); + connection.password = decrypt(connection.password); + + // 2. 커넥터 생성 + const connector = DatabaseConnectorFactory.getConnector( + connection.db_type, + connection + ); + + // 3. 연결 및 쿼리 실행 + await connector.connect(); + const result = await connector.query(sql); + await connector.disconnect(); + + return result; +} +``` + +### 8.7 메일 도메인 (Mail) + +**컨트롤러**: `mailSendSimpleController.ts`, `mailReceiveBasicController.ts` +**서비스**: `mailSendSimpleService.ts`, `mailReceiveBasicService.ts` +**주요 기능**: +- 메일 계정 관리 (파일 기반) +- 메일 템플릿 관리 +- 메일 발송 (nodemailer) +- 메일 수신 (IMAP) +- 발송 이력 관리 +- 첨부파일 처리 + +**핵심 로직**: +```typescript +// 메일 발송 +async sendEmail(params) { + // 1. 계정 정보 조회 + const account = await getMailAccount(params.accountId); + + // 2. 템플릿 적용 (있으면) + let emailBody = params.body; + if (params.templateId) { + const template = await getTemplate(params.templateId); + emailBody = renderTemplate(template.content, params.variables); + } + + // 3. nodemailer 전송 + const transporter = nodemailer.createTransport({ + host: account.smtp_host, + port: account.smtp_port, + secure: true, + auth: { + user: account.email, + pass: decrypt(account.password) + } + }); + + const result = await transporter.sendMail({ + from: account.email, + to: params.to, + subject: params.subject, + html: emailBody, + attachments: params.attachments + }); + + // 4. 발송 이력 저장 + await saveSentHistory(params, result); +} +``` + +### 8.8 파일 도메인 (File) + +**컨트롤러**: `fileController.ts`, `screenFileController.ts` +**서비스**: `fileSystemManager.ts` +**주요 기능**: +- 파일 업로드 (multer) +- 파일 다운로드 +- 파일 삭제 +- 화면별 파일 관리 + +**핵심 로직**: +```typescript +// 파일 업로드 +const upload = multer({ + storage: multer.diskStorage({ + destination: (req, file, cb) => { + const companyCode = req.user!.companyCode; + const dir = `uploads/${companyCode}`; + fs.mkdirSync(dir, { recursive: true }); + cb(null, dir); + }, + filename: (req, file, cb) => { + const uniqueName = `${Date.now()}-${uuidv4()}-${file.originalname}`; + cb(null, uniqueName); + } + }), + limits: { fileSize: 10 * 1024 * 1024 }, // 10MB + fileFilter: (req, file, cb) => { + // 파일 타입 검증 + const allowedTypes = /jpeg|jpg|png|gif|pdf|doc|docx|xls|xlsx/; + const isValid = allowedTypes.test(file.mimetype); + cb(null, isValid); + } +}); + +// 파일 메타데이터 저장 +async saveFileMetadata(file, userId, companyCode) { + return await query( + `INSERT INTO file_info ( + file_name, file_path, file_size, mime_type, + company_code, created_by + ) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, + [file.originalname, file.path, file.size, file.mimetype, companyCode, userId] + ); +} +``` + +### 8.9 대시보드 도메인 (Dashboard) + +**컨트롤러**: `DashboardController.ts` +**서비스**: `DashboardService.ts` +**주요 기능**: +- 대시보드 설정 관리 +- 위젯 관리 +- 통계 데이터 조회 +- 차트 데이터 생성 + +--- + +## 9. 데이터베이스 접근 방식 + +### 9.1 Connection Pool 설정 + +```typescript +// database/db.ts +const pool = new Pool({ + host: "localhost", + port: 5432, + database: "ilshin", + user: "postgres", + password: "postgres", + + // Pool 설정 + min: config.nodeEnv === "production" ? 5 : 2, // 최소 연결 수 + max: config.nodeEnv === "production" ? 20 : 10, // 최대 연결 수 + + // Timeout 설정 + connectionTimeoutMillis: 30000, // 연결 대기 30초 + idleTimeoutMillis: 600000, // 유휴 연결 유지 10분 + statement_timeout: 60000, // 쿼리 실행 60초 + query_timeout: 60000, + + // 연결 유지 + keepAlive: true, + keepAliveInitialDelayMillis: 10000, + + // Application Name + application_name: "WACE-PLM-Backend" +}); +``` + +### 9.2 쿼리 실행 패턴 + +```typescript +// 1. 기본 쿼리 (다중 행) +const users = await query( + 'SELECT * FROM user_info WHERE company_code = $1', + [companyCode] +); + +// 2. 단일 행 쿼리 +const user = await queryOne( + 'SELECT * FROM user_info WHERE user_id = $1', + ['user123'] +); + +// 3. 트랜잭션 +const result = await transaction(async (client) => { + await client.query('INSERT INTO table1 (...) VALUES (...)', [...]); + await client.query('INSERT INTO table2 (...) VALUES (...)', [...]); + return { success: true }; +}); +``` + +### 9.3 Parameterized Query (SQL Injection 방지) + +```typescript +// ✅ 올바른 방법 (Parameterized Query) +const users = await query( + 'SELECT * FROM user_info WHERE user_id = $1 AND dept_code = $2', + [userId, deptCode] +); + +// ❌ 잘못된 방법 (SQL Injection 위험!) +const users = await query( + `SELECT * FROM user_info WHERE user_id = '${userId}'` +); +``` + +### 9.4 동적 쿼리 빌더 패턴 + +```typescript +// utils/queryBuilder.ts +class QueryBuilder { + private table: string; + private whereClauses: string[] = []; + private params: any[] = []; + private paramIndex: number = 1; + + constructor(table: string) { + this.table = table; + } + + where(column: string, value: any) { + this.whereClauses.push(`${column} = $${this.paramIndex++}`); + this.params.push(value); + return this; + } + + whereIn(column: string, values: any[]) { + this.whereClauses.push(`${column} = ANY($${this.paramIndex++})`); + this.params.push(values); + return this; + } + + build() { + const where = this.whereClauses.length > 0 + ? `WHERE ${this.whereClauses.join(' AND ')}` + : ''; + return { + sql: `SELECT * FROM ${this.table} ${where}`, + params: this.params + }; + } +} + +// 사용 예시 +const { sql, params } = new QueryBuilder('user_info') + .where('company_code', companyCode) + .where('user_type', 'USER') + .whereIn('dept_code', ['D001', 'D002']) + .build(); + +const users = await query(sql, params); +``` + +--- + +## 10. 외부 시스템 연동 + +### 10.1 외부 DB 연결 아키텍처 + +``` +┌─────────────────────┐ +│ Backend Service │ +└──────────┬──────────┘ + │ + ▼ +┌─────────────────────┐ +│ DatabaseConnector │ +│ Factory │ +└──────────┬──────────┘ + │ + ┌─────┴─────┬─────────┬─────────┐ + ▼ ▼ ▼ ▼ +┌─────────┐ ┌─────────┐ ┌───────┐ ┌────────┐ +│PostgreSQL│ │ MySQL │ │ MSSQL │ │ Oracle │ +│Connector│ │Connector│ │Connect│ │Connect │ +└─────────┘ └─────────┘ └───────┘ └────────┘ + │ │ │ │ + ▼ ▼ ▼ ▼ +┌─────────┐ ┌─────────┐ ┌───────┐ ┌────────┐ +│External │ │External │ │External│ │External│ +│ PG DB │ │MySQL DB │ │MSSQL DB│ │Oracle │ +└─────────┘ └─────────┘ └───────┘ └────────┘ +``` + +### 10.2 외부 DB Connector 인터페이스 + +```typescript +// interfaces/DatabaseConnector.ts +interface DatabaseConnector { + connect(): Promise; + disconnect(): Promise; + query(sql: string, params?: any[]): Promise; + testConnection(): Promise; + getTables(): Promise; + getColumns(tableName: string): Promise; +} + +// 구현 예시: PostgreSQL +class PostgreSQLConnector implements DatabaseConnector { + private pool: Pool; + + constructor(config: ExternalDbConnection) { + this.pool = new Pool({ + host: config.host, + port: config.port, + database: config.database, + user: config.username, + password: decrypt(config.password), + ssl: config.use_ssl ? { rejectUnauthorized: false } : false + }); + } + + async connect() { + await this.pool.connect(); + } + + async query(sql: string, params?: any[]) { + const result = await this.pool.query(sql, params); + return result.rows; + } + + async getTables() { + const result = await this.query( + `SELECT tablename FROM pg_tables + WHERE schemaname = 'public' + ORDER BY tablename` + ); + return result.map(row => row.tablename); + } +} +``` + +### 10.3 외부 REST API 연동 + +```typescript +// services/externalRestApiConnectionService.ts +async callExternalApi(connectionId: number, endpoint: string, options: any) { + // 1. 연결 정보 조회 + const connection = await getApiConnection(connectionId); + + // 2. 인증 헤더 생성 + const headers: any = { + 'Content-Type': 'application/json', + ...connection.headers + }; + + if (connection.auth_type === 'BEARER') { + headers['Authorization'] = `Bearer ${decrypt(connection.auth_token)}`; + } else if (connection.auth_type === 'API_KEY') { + headers[connection.api_key_header] = decrypt(connection.api_key); + } + + // 3. Axios 요청 + const response = await axios({ + method: options.method || 'GET', + url: `${connection.base_url}${endpoint}`, + headers, + data: options.body, + timeout: connection.timeout || 30000 + }); + + return response.data; +} +``` + +--- + +## 11. 배치/스케줄 처리 + +### 11.1 배치 스케줄러 시스템 + +```typescript +// services/batchSchedulerService.ts +class BatchSchedulerService { + private static scheduledTasks: Map = new Map(); + + // 서버 시작 시 모든 활성 배치 스케줄링 + static async initializeScheduler() { + const activeBatches = await getBatchConfigs({ is_active: 'Y' }); + + for (const batch of activeBatches) { + this.scheduleBatch(batch); + } + } + + // 개별 배치 스케줄 등록 + static scheduleBatch(config: any) { + if (!cron.validate(config.cron_schedule)) { + throw new Error(`Invalid cron: ${config.cron_schedule}`); + } + + const task = cron.schedule( + config.cron_schedule, + async () => { + await this.executeBatchConfig(config); + }, + { timezone: "Asia/Seoul" } + ); + + this.scheduledTasks.set(config.id, task); + } + + // 배치 실행 + static async executeBatchConfig(config: any) { + const startTime = new Date(); + + try { + // 1. 소스 DB에서 데이터 조회 + const sourceData = await this.getSourceData(config); + + // 2. 컬럼 매핑 적용 + const mappedData = this.applyMapping(sourceData, config.batch_mappings); + + // 3. 타겟 DB에 저장 + await this.upsertToTarget(mappedData, config); + + // 4. 성공 로그 + await BatchExecutionLogService.updateExecutionLog(executionLogId, { + execution_status: 'SUCCESS', + end_time: new Date(), + success_records: mappedData.length + }); + } catch (error) { + // 5. 실패 로그 + await BatchExecutionLogService.updateExecutionLog(executionLogId, { + execution_status: 'FAILURE', + end_time: new Date(), + error_message: error.message + }); + } + } +} +``` + +### 11.2 Cron 표현식 예시 + +``` +# 형식: 초 분 시 일 월 요일 + +# 매 시간 정각 +0 * * * * + +# 매일 새벽 2시 +0 2 * * * + +# 매주 월요일 오전 9시 +0 9 * * 1 + +# 매월 1일 자정 +0 0 1 * * + +# 5분마다 +*/5 * * * * + +# 평일 오전 8시~오후 6시, 매 시간 +0 8-18 * * 1-5 +``` + +### 11.3 배치 실행 이력 관리 + +```typescript +// services/batchExecutionLogService.ts +interface ExecutionLog { + id: number; + batch_config_id: number; + execution_status: 'RUNNING' | 'SUCCESS' | 'FAILURE'; + start_time: Date; + end_time?: Date; + total_records: number; + success_records: number; + failure_records: number; + error_message?: string; + company_code: string; +} + +// 실행 로그 저장 +async createExecutionLog(data: Partial) { + return await query( + `INSERT INTO batch_execution_logs ( + batch_config_id, company_code, execution_status, + start_time, total_records + ) VALUES ($1, $2, $3, $4, $5) RETURNING *`, + [data.batch_config_id, data.company_code, data.execution_status, + data.start_time, data.total_records] + ); +} +``` + +--- + +## 12. 파일 처리 + +### 12.1 파일 업로드 흐름 + +``` +┌─────────────┐ +│ Frontend │ +└──────┬──────┘ + │ (FormData) + ▼ +┌─────────────────────┐ +│ Multer Middleware │ → 파일 저장 (uploads/COMPANY_CODE/) +└──────┬──────────────┘ + │ + ▼ +┌─────────────────────┐ +│ FileController │ → 메타데이터 저장 (file_info 테이블) +└──────┬──────────────┘ + │ + ▼ +┌─────────────────────┐ +│ Response │ → { fileId, fileName, filePath, fileSize } +└─────────────────────┘ +``` + +### 12.2 Multer 설정 + +```typescript +// config/multerConfig.ts +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + const companyCode = req.user!.companyCode; + const uploadDir = path.join(process.cwd(), 'uploads', companyCode); + + // 디렉토리 없으면 생성 + if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }); + } + + cb(null, uploadDir); + }, + + filename: (req, file, cb) => { + // 파일명 중복 방지: 타임스탬프 + UUID + 원본파일명 + const timestamp = Date.now(); + const uniqueId = uuidv4(); + const extension = path.extname(file.originalname); + const basename = path.basename(file.originalname, extension); + const uniqueName = `${timestamp}-${uniqueId}-${basename}${extension}`; + + cb(null, uniqueName); + } +}); + +export const upload = multer({ + storage, + limits: { + fileSize: 10 * 1024 * 1024 // 10MB + }, + fileFilter: (req, file, cb) => { + // 허용 확장자 검증 + const allowedMimeTypes = [ + 'image/jpeg', 'image/png', 'image/gif', + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ]; + + if (allowedMimeTypes.includes(file.mimetype)) { + cb(null, true); + } else { + cb(new Error('허용되지 않는 파일 형식입니다.')); + } + } +}); +``` + +### 12.3 파일 다운로드 + +```typescript +// controllers/fileController.ts +async downloadFile(req: AuthenticatedRequest, res: Response) { + const fileId = parseInt(req.params.id); + const companyCode = req.user!.companyCode; + + // 1. 파일 정보 조회 (권한 체크) + const file = await queryOne( + `SELECT * FROM file_info + WHERE id = $1 AND company_code = $2`, + [fileId, companyCode] + ); + + if (!file) { + return res.status(404).json({ error: '파일을 찾을 수 없습니다.' }); + } + + // 2. 파일 존재 확인 + if (!fs.existsSync(file.file_path)) { + return res.status(404).json({ error: '파일이 존재하지 않습니다.' }); + } + + // 3. 파일 다운로드 + res.download(file.file_path, file.file_name); +} +``` + +### 12.4 파일 삭제 (논리 삭제) + +```typescript +async deleteFile(req: AuthenticatedRequest, res: Response) { + const fileId = parseInt(req.params.id); + const companyCode = req.user!.companyCode; + + // 1. 논리 삭제 (is_active = 'N') + await query( + `UPDATE file_info + SET is_active = 'N', deleted_at = now(), deleted_by = $3 + WHERE id = $1 AND company_code = $2`, + [fileId, companyCode, req.user!.userId] + ); + + // 2. 물리 파일은 삭제하지 않음 (복구 가능) + // 주기적으로 삭제된 지 30일 지난 파일만 물리 삭제 +} +``` + +--- + +## 13. 에러 핸들링 + +### 13.1 에러 핸들러 미들웨어 + +```typescript +// middleware/errorHandler.ts +export const errorHandler = (err, req, res, next) => { + let error = { ...err }; + error.message = err.message; + + // 1. PostgreSQL 에러 처리 + if (err.code) { + switch (err.code) { + case '23505': // unique_violation + error = new AppError('중복된 데이터가 존재합니다.', 400); + break; + case '23503': // foreign_key_violation + error = new AppError('참조 무결성 제약 조건 위반입니다.', 400); + break; + case '23502': // not_null_violation + error = new AppError('필수 입력값이 누락되었습니다.', 400); + break; + default: + error = new AppError(`데이터베이스 오류: ${err.message}`, 500); + } + } + + // 2. JWT 에러 처리 + if (err.name === 'JsonWebTokenError') { + error = new AppError('유효하지 않은 토큰입니다.', 401); + } + if (err.name === 'TokenExpiredError') { + error = new AppError('토큰이 만료되었습니다.', 401); + } + + // 3. 에러 로깅 + logger.error({ + message: error.message, + stack: error.stack, + url: req.url, + method: req.method, + ip: req.ip + }); + + // 4. 응답 + const statusCode = error.statusCode || 500; + res.status(statusCode).json({ + success: false, + error: { + message: error.message, + ...(process.env.NODE_ENV === 'development' && { stack: error.stack }) + } + }); +}; +``` + +### 13.2 커스텀 에러 클래스 + +```typescript +export class AppError extends Error { + public statusCode: number; + public isOperational: boolean; + + constructor(message: string, statusCode: number = 500) { + super(message); + this.statusCode = statusCode; + this.isOperational = true; + Error.captureStackTrace(this, this.constructor); + } +} + +// 사용 예시 +if (!user) { + throw new AppError('사용자를 찾을 수 없습니다.', 404); +} +``` + +### 13.3 프로세스 레벨 예외 처리 + +```typescript +// app.ts +process.on('unhandledRejection', (reason, promise) => { + logger.error('⚠️ Unhandled Promise Rejection:', { + reason: reason?.message || reason, + stack: reason?.stack + }); + // 프로세스 종료하지 않고 로깅만 수행 +}); + +process.on('uncaughtException', (error) => { + logger.error('🔥 Uncaught Exception:', { + message: error.message, + stack: error.stack + }); + // 예외 발생 후에도 서버 유지 (주의: 불안정할 수 있음) +}); + +process.on('SIGTERM', () => { + logger.info('📴 SIGTERM 시그널 수신, graceful shutdown 시작...'); + // 연결 풀 정리, 진행 중인 요청 완료 대기 + process.exit(0); +}); +``` + +--- + +## 14. 로깅 시스템 + +### 14.1 Winston Logger 설정 + +```typescript +// utils/logger.ts +import winston from 'winston'; + +const logger = winston.createLogger({ + level: process.env.LOG_LEVEL || 'info', + format: winston.format.combine( + winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + winston.format.errors({ stack: true }), + winston.format.splat(), + winston.format.json() + ), + transports: [ + // 파일 로그 + new winston.transports.File({ + filename: 'logs/error.log', + level: 'error', + maxsize: 10485760, // 10MB + maxFiles: 5 + }), + new winston.transports.File({ + filename: 'logs/combined.log', + maxsize: 10485760, + maxFiles: 10 + }), + + // 콘솔 로그 (개발 환경) + ...(process.env.NODE_ENV === 'development' ? [ + new winston.transports.Console({ + format: winston.format.combine( + winston.format.colorize(), + winston.format.simple() + ) + }) + ] : []) + ] +}); + +export { logger }; +``` + +### 14.2 로깅 레벨 + +```typescript +// 로그 레벨 (우선순위 높음 → 낮음) +logger.error('에러 발생', { error }); // 0 +logger.warn('경고 메시지'); // 1 +logger.info('정보 메시지'); // 2 +logger.http('HTTP 요청'); // 3 +logger.verbose('상세 정보'); // 4 +logger.debug('디버그 정보', { data }); // 5 +logger.silly('매우 상세한 정보'); // 6 +``` + +### 14.3 로깅 패턴 + +```typescript +// 1. 인증 로그 +logger.info(`인증 성공: ${userInfo.userId} (${req.ip})`); +logger.warn(`인증 실패: ${errorMessage} (${req.ip})`); + +// 2. 쿼리 로그 (디버그 모드) +if (config.debug) { + logger.debug('쿼리 실행:', { + query: sql, + params, + rowCount: result.rowCount, + duration: `${duration}ms` + }); +} + +// 3. 에러 로그 +logger.error('배치 실행 실패:', { + batchId: config.id, + error: error.message, + stack: error.stack +}); + +// 4. 비즈니스 로그 +logger.info(`플로우 데이터 이동: ${fromStepId} → ${toStepId}`, { + flowId, + recordCount: recordIds.length +}); +``` + +--- + +## 15. 보안 및 권한 관리 + +### 15.1 비밀번호 암호화 + +```typescript +// utils/encryptUtil.ts +export class EncryptUtil { + // 비밀번호 해싱 (bcrypt) + static hash(password: string): string { + return bcrypt.hashSync(password, 12); + } + + // 비밀번호 검증 + static matches(plainPassword: string, hashedPassword: string): boolean { + return bcrypt.compareSync(plainPassword, hashedPassword); + } +} + +// 사용 예시 +const hashedPassword = EncryptUtil.hash('mypassword'); +const isValid = EncryptUtil.matches('mypassword', hashedPassword); +``` + +### 15.2 민감 정보 암호화 (AES-256) + +```typescript +// utils/credentialEncryption.ts +import crypto from 'crypto'; + +const ALGORITHM = 'aes-256-cbc'; +const SECRET_KEY = process.env.ENCRYPTION_KEY || 'default-32-char-secret-key!!!!'; +const IV_LENGTH = 16; + +// 암호화 +export function encrypt(text: string): string { + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(ALGORITHM, Buffer.from(SECRET_KEY), iv); + + let encrypted = cipher.update(text, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + + return iv.toString('hex') + ':' + encrypted; +} + +// 복호화 +export function decrypt(encryptedText: string): string { + const parts = encryptedText.split(':'); + const iv = Buffer.from(parts[0], 'hex'); + const encryptedData = parts[1]; + + const decipher = crypto.createDecipheriv(ALGORITHM, Buffer.from(SECRET_KEY), iv); + + let decrypted = decipher.update(encryptedData, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + + return decrypted; +} + +// 사용 예시: 외부 DB 비밀번호 저장 +const encryptedPassword = encrypt('db_password'); +await query( + 'INSERT INTO external_db_connections (password) VALUES ($1)', + [encryptedPassword] +); + +// 사용 시 복호화 +const connection = await queryOne('SELECT * FROM external_db_connections WHERE id = $1', [id]); +const plainPassword = decrypt(connection.password); +``` + +### 15.3 SQL Injection 방지 + +```typescript +// ✅ Parameterized Query (항상 사용) +const users = await query( + 'SELECT * FROM user_info WHERE user_id = $1 AND company_code = $2', + [userId, companyCode] +); + +// ❌ 문자열 연결 (절대 사용 금지!) +const users = await query( + `SELECT * FROM user_info WHERE user_id = '${userId}'` +); +``` + +### 15.4 Rate Limiting + +```typescript +// app.ts +const limiter = rateLimit({ + windowMs: 1 * 60 * 1000, // 1분 + max: 10000, // 최대 10000회 (개발: 완화, 운영: 100) + message: { + error: '너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해주세요.' + }, + skip: (req) => { + // 헬스 체크는 Rate Limiting 제외 + return req.path === '/health'; + } +}); + +app.use('/api/', limiter); +``` + +### 15.5 CORS 설정 + +```typescript +// config/environment.ts +const getCorsOrigin = () => { + // 개발 환경: 모든 origin 허용 + if (process.env.NODE_ENV === 'development') { + return true; + } + + // 운영 환경: 허용 도메인만 + return [ + 'http://localhost:9771', + 'http://39.117.244.52:5555', + 'https://v1.vexplor.com', + 'https://api.vexplor.com' + ]; +}; + +// app.ts +app.use(cors({ + origin: getCorsOrigin(), + credentials: true, // 쿠키 포함 + methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'] +})); +``` + +--- + +## 16. 성능 최적화 + +### 16.1 Connection Pool 모니터링 + +```typescript +// database/db.ts +// 5분마다 Pool 상태 체크 +setInterval(() => { + if (pool) { + const status = { + totalCount: pool.totalCount, // 전체 연결 수 + idleCount: pool.idleCount, // 유휴 연결 수 + waitingCount: pool.waitingCount // 대기 중인 연결 수 + }; + + // 대기 연결이 5개 이상이면 경고 + if (status.waitingCount > 5) { + console.warn('⚠️ PostgreSQL 연결 풀 대기열 증가:', status); + } + } +}, 5 * 60 * 1000); +``` + +### 16.2 쿼리 실행 시간 로깅 + +```typescript +// database/db.ts +export async function query(text: string, params?: any[]) { + const client = await pool.connect(); + + try { + const startTime = Date.now(); + const result = await client.query(text, params); + const duration = Date.now() - startTime; + + // 1초 이상 걸린 쿼리는 경고 + if (duration > 1000) { + logger.warn('⚠️ 느린 쿼리 감지:', { + query: text, + params, + duration: `${duration}ms` + }); + } + + return result.rows; + } finally { + client.release(); + } +} +``` + +### 16.3 캐싱 전략 + +```typescript +// utils/cache.ts (Redis 기반) +import Redis from 'redis'; + +const redis = Redis.createClient({ + url: process.env.REDIS_URL || 'redis://localhost:6379' +}); + +// 캐시 조회 (있으면 반환, 없으면 쿼리 후 캐싱) +export async function getCachedData(key: string, fetcher: () => Promise, ttl: number = 300) { + // 1. 캐시 확인 + const cached = await redis.get(key); + if (cached) { + return JSON.parse(cached); + } + + // 2. 캐시 미스 → DB 조회 + const data = await fetcher(); + + // 3. 캐시 저장 (TTL: 5분) + await redis.setEx(key, ttl, JSON.stringify(data)); + + return data; +} + +// 사용 예시: 메뉴 목록 캐싱 +const menuList = await getCachedData( + `menu:${companyCode}:${userId}`, + () => AdminService.getUserMenuList(params), + 600 // 10분 캐싱 +); +``` + +### 16.4 압축 (Gzip) + +```typescript +// app.ts +import compression from 'compression'; + +app.use(compression({ + level: 6, // 압축 레벨 (0~9) + threshold: 1024, // 1KB 이상만 압축 + filter: (req, res) => { + // JSON 응답만 압축 + return req.headers['x-no-compression'] ? false : compression.filter(req, res); + } +})); +``` + +--- + +## 🎯 핵심 요약 + +### 아키텍처 패턴 +- **Layered Architecture**: Controller → Service → Database +- **Multi-tenancy**: `company_code` 기반 완전한 데이터 격리 +- **JWT 인증**: Stateless 토큰 기반 (24시간 만료) +- **Raw Query**: ORM 없이 PostgreSQL 직접 쿼리 + +### 보안 원칙 +1. **모든 쿼리에 company_code 필터 필수** +2. **JWT 토큰에서 company_code 추출 (클라이언트 신뢰 금지)** +3. **Parameterized Query로 SQL Injection 방지** +4. **비밀번호 bcrypt, 민감정보 AES-256 암호화** +5. **Rate Limiting으로 DDoS 방지** + +### 주요 도메인 +- 관리자 (사용자/메뉴/권한) +- 테이블/화면 메타데이터 +- 플로우 (워크플로우 엔진) +- 데이터플로우 (ERD/관계도) +- 외부 연동 (DB/REST API) +- 배치 (Cron 스케줄러) +- 메일 (발송/수신) +- 파일 (업로드/다운로드) + +### API 개수 +- **총 70+ 라우트** +- **인증/관리자**: 15개 +- **테이블/화면**: 20개 +- **플로우**: 10개 +- **외부 연동**: 10개 +- **배치**: 6개 +- **메일**: 5개 +- **파일**: 4개 + +--- + +**문서 버전**: 1.0 +**마지막 업데이트**: 2026-02-06 diff --git a/docs/backend-architecture-summary.md b/docs/backend-architecture-summary.md new file mode 100644 index 00000000..d2155f24 --- /dev/null +++ b/docs/backend-architecture-summary.md @@ -0,0 +1,342 @@ +# WACE ERP Backend - 아키텍처 요약 + +> **작성일**: 2026-02-06 +> **목적**: 워크플로우 문서 통합용 백엔드 아키텍처 요약 + +--- + +## 1. 기술 스택 + +``` +언어: TypeScript (Node.js 20.10.0+) +프레임워크: Express.js +데이터베이스: PostgreSQL (pg 라이브러리, Raw Query) +인증: JWT (jsonwebtoken) +스케줄러: node-cron +메일: nodemailer + IMAP +파일업로드: multer +외부DB: MySQL, MSSQL, Oracle 지원 +``` + +## 2. 계층 구조 + +``` +┌─────────────────┐ +│ Controller │ ← API 요청 수신, 응답 생성 +└────────┬────────┘ + │ +┌────────▼────────┐ +│ Service │ ← 비즈니스 로직, 트랜잭션 관리 +└────────┬────────┘ + │ +┌────────▼────────┐ +│ Database │ ← PostgreSQL Raw Query +└─────────────────┘ +``` + +## 3. 디렉토리 구조 + +``` +backend-node/src/ +├── app.ts # Express 앱 진입점 +├── config/ # 환경설정 +├── controllers/ # 70+ 컨트롤러 +├── services/ # 80+ 서비스 +├── routes/ # 70+ 라우터 +├── middleware/ # 인증/권한/에러핸들러 +├── database/ # DB 연결 (pg Pool) +├── types/ # TypeScript 타입 (26개) +└── utils/ # 유틸리티 (JWT, 암호화, 로거) +``` + +## 4. 미들웨어 스택 순서 + +```typescript +1. Process Level Exception Handlers (unhandledRejection, uncaughtException) +2. Helmet (보안 헤더) +3. Compression (Gzip) +4. Body Parser (JSON, URL-encoded, 10MB limit) +5. Static Files (/uploads) +6. CORS (credentials: true) +7. Rate Limiting (1분 10000회) +8. Token Auto Refresh (1시간 이내 만료 시 갱신) +9. API Routes (70+개) +10. 404 Handler +11. Error Handler +``` + +## 5. 인증/인가 시스템 + +### 5.1 인증 흐름 + +``` +로그인 요청 + ↓ +AuthController.login() + ↓ +AuthService.processLogin() + ├─ loginPwdCheck() → 비밀번호 검증 (bcrypt) + ├─ getPersonBeanFromSession() → 사용자 정보 조회 + ├─ insertLoginAccessLog() → 로그인 이력 저장 + └─ JwtUtils.generateToken() → JWT 토큰 생성 + ↓ +응답: { token, userInfo, firstMenuPath } +``` + +### 5.2 JWT Payload + +```json +{ + "userId": "user123", + "userName": "홍길동", + "companyCode": "ILSHIN", + "userType": "COMPANY_ADMIN", + "iat": 1234567890, + "exp": 1234654290, + "iss": "PMS-System" +} +``` + +### 5.3 권한 체계 (3단계) + +| 권한 | company_code | userType | 권한 범위 | +|------|--------------|----------|-----------| +| **SUPER_ADMIN** | `*` | `SUPER_ADMIN` | 전체 회사, DDL 실행, 회사 생성/삭제 | +| **COMPANY_ADMIN** | `ILSHIN` | `COMPANY_ADMIN` | 자기 회사만, 사용자/설정 관리 | +| **USER** | `ILSHIN` | `USER` | 자기 회사만, 읽기/쓰기 | + +## 6. 멀티테넌시 구현 + +### 핵심 원칙 +```typescript +// ✅ 올바른 패턴 +const companyCode = req.user!.companyCode; // JWT에서 추출 + +if (companyCode === "*") { + // 슈퍼관리자: 모든 데이터 조회 + query = "SELECT * FROM table ORDER BY company_code"; +} else { + // 일반 사용자: 자기 회사 + 슈퍼관리자 데이터 제외 + query = "SELECT * FROM table WHERE company_code = $1 AND company_code != '*'"; + params = [companyCode]; +} + +// ❌ 잘못된 패턴 (보안 위험!) +const companyCode = req.body.companyCode; // 클라이언트에서 받음 +``` + +### 슈퍼관리자 숨김 규칙 +```sql +-- 일반 회사 사용자에게 슈퍼관리자(company_code='*')는 보이면 안 됨 +SELECT * FROM user_info +WHERE company_code = $1 + AND company_code != '*' -- 필수! +``` + +## 7. API 라우트 (70+개) + +### 7.1 인증/관리자 +- `POST /api/auth/login` - 로그인 +- `GET /api/auth/me` - 현재 사용자 정보 +- `POST /api/auth/switch-company` - 회사 전환 (슈퍼관리자) +- `GET /api/admin/users` - 사용자 목록 +- `GET /api/admin/menus` - 메뉴 목록 + +### 7.2 테이블/화면 +- `GET /api/table-management/tables` - 테이블 목록 +- `POST /api/table-management/tables/:table/data` - 데이터 조회 +- `POST /api/table-management/multi-table-save` - 다중 테이블 저장 +- `GET /api/screen-management/screens` - 화면 목록 + +### 7.3 플로우 +- `GET /api/flow/definitions` - 플로우 정의 목록 +- `POST /api/flow/move` - 데이터 이동 (단건) +- `POST /api/flow/move-batch` - 데이터 이동 (다건) + +### 7.4 외부 연동 +- `GET /api/external-db-connections` - 외부 DB 연결 목록 +- `POST /api/external-db-connections/:id/test` - 연결 테스트 +- `POST /api/multi-connection/query` - 멀티 DB 쿼리 + +### 7.5 배치 +- `GET /api/batch-configs` - 배치 설정 목록 +- `POST /api/batch-management/:id/execute` - 배치 즉시 실행 + +### 7.6 메일 +- `POST /api/mail/send` - 메일 발송 +- `GET /api/mail/sent` - 발송 이력 + +### 7.7 파일 +- `POST /api/files/upload` - 파일 업로드 +- `GET /uploads/:filename` - 정적 파일 서빙 + +## 8. 비즈니스 도메인 (8개) + +| 도메인 | 컨트롤러 | 주요 기능 | +|--------|----------|-----------| +| **관리자** | `adminController` | 사용자/메뉴/권한 관리 | +| **테이블/화면** | `tableManagementController` | 메타데이터, 동적 화면 생성 | +| **플로우** | `flowController` | 워크플로우 엔진, 데이터 이동 | +| **데이터플로우** | `dataflowController` | ERD, 관계도 | +| **외부 연동** | `externalDbConnectionController` | 외부 DB/REST API | +| **배치** | `batchController` | Cron 스케줄러, 데이터 동기화 | +| **메일** | `mailSendSimpleController` | 메일 발송/수신 | +| **파일** | `fileController` | 파일 업로드/다운로드 | + +## 9. 데이터베이스 접근 + +### Connection Pool 설정 +```typescript +{ + min: 2~5, // 최소 연결 수 + max: 10~20, // 최대 연결 수 + connectionTimeout: 30000, // 30초 + idleTimeout: 600000, // 10분 + statementTimeout: 60000 // 쿼리 실행 60초 +} +``` + +### Raw Query 패턴 +```typescript +// 1. 다중 행 +const users = await query('SELECT * FROM user_info WHERE company_code = $1', [companyCode]); + +// 2. 단일 행 +const user = await queryOne('SELECT * FROM user_info WHERE user_id = $1', [userId]); + +// 3. 트랜잭션 +await transaction(async (client) => { + await client.query('INSERT INTO table1 ...', [...]); + await client.query('INSERT INTO table2 ...', [...]); +}); +``` + +## 10. 외부 시스템 연동 + +### 지원 데이터베이스 +- PostgreSQL +- MySQL +- Microsoft SQL Server +- Oracle + +### Connector Factory Pattern +```typescript +DatabaseConnectorFactory + ├── PostgreSQLConnector + ├── MySQLConnector + ├── MSSQLConnector + └── OracleConnector +``` + +## 11. 배치/스케줄 시스템 + +### Cron 스케줄러 +```typescript +// node-cron 기반 +// 매일 새벽 2시: "0 2 * * *" +// 5분마다: "*/5 * * * *" +// 평일 오전 8시: "0 8 * * 1-5" + +// 서버 시작 시 자동 초기화 +BatchSchedulerService.initializeScheduler(); +``` + +### 배치 실행 흐름 +``` +1. 소스 DB에서 데이터 조회 + ↓ +2. 컬럼 매핑 적용 + ↓ +3. 타겟 DB에 INSERT/UPDATE + ↓ +4. 실행 로그 기록 (batch_execution_logs) +``` + +## 12. 파일 처리 + +### 업로드 경로 +``` +uploads/ + └── {company_code}/ + └── {timestamp}-{uuid}-{filename} +``` + +### Multer 설정 +- 최대 파일 크기: 10MB +- 허용 타입: 이미지, PDF, Office 문서 +- 파일명 중복 방지: 타임스탬프 + UUID + +## 13. 보안 + +### 암호화 +- **비밀번호**: bcrypt (12 rounds) +- **민감정보**: AES-256-CBC (외부 DB 비밀번호 등) +- **JWT Secret**: 환경변수 관리 + +### 보안 헤더 +- Helmet (CSP, X-Frame-Options) +- CORS (credentials: true) +- Rate Limiting (1분 10000회) + +### SQL Injection 방지 +- Parameterized Query 사용 (pg 라이브러리) +- 동적 쿼리 빌더 패턴 + +## 14. 에러 핸들링 + +### PostgreSQL 에러 코드 매핑 +- `23505` → "중복된 데이터" +- `23503` → "참조 무결성 위반" +- `23502` → "필수 입력값 누락" + +### 프로세스 레벨 +- `unhandledRejection` → 로깅 (서버 유지) +- `uncaughtException` → 로깅 (서버 유지, 주의) +- `SIGTERM/SIGINT` → Graceful Shutdown + +## 15. 로깅 (Winston) + +### 로그 파일 +- `logs/error.log` - 에러만 (10MB × 5파일) +- `logs/combined.log` - 전체 로그 (10MB × 10파일) + +### 로그 레벨 +``` +error (0) → warn (1) → info (2) → debug (5) +``` + +## 16. 성능 최적화 + +### Pool 모니터링 +- 5분마다 상태 체크 +- 대기 연결 5개 이상 시 경고 + +### 느린 쿼리 감지 +- 1초 이상 걸린 쿼리 자동 경고 + +### 캐싱 (Redis) +- 메뉴 목록: 10분 TTL +- 공통코드: 30분 TTL + +### Gzip 압축 +- 1KB 이상 응답만 압축 (레벨 6) + +--- + +## 🎯 핵심 체크리스트 + +### 개발 시 반드시 지켜야 할 규칙 + +✅ **모든 쿼리에 `company_code` 필터 추가** +✅ **JWT 토큰에서 `company_code` 추출 (클라이언트 신뢰 금지)** +✅ **Parameterized Query 사용 (SQL Injection 방지)** +✅ **슈퍼관리자 데이터 숨김 (`company_code != '*'`)** +✅ **비밀번호는 bcrypt, 민감정보는 AES-256** +✅ **에러 핸들링 try/catch 필수** +✅ **트랜잭션이 필요한 경우 `transaction()` 사용** +✅ **파일 업로드는 회사별 디렉토리 분리** + +--- + +**문서 버전**: 1.0 +**마지막 업데이트**: 2026-02-06 diff --git a/docs/frontend-architecture-analysis.md b/docs/frontend-architecture-analysis.md new file mode 100644 index 00000000..fb367585 --- /dev/null +++ b/docs/frontend-architecture-analysis.md @@ -0,0 +1,1920 @@ +# WACE ERP 프론트엔드 아키텍처 상세 분석 + +> 작성일: 2026-02-06 +> 작성자: AI Assistant +> 프로젝트: WACE ERP-node +> 목적: 시스템 전체 워크플로우 문서화를 위한 프론트엔드 구조 분석 + +--- + +## 목차 +1. [전체 디렉토리 구조](#1-전체-디렉토리-구조) +2. [Next.js App Router 구조](#2-nextjs-app-router-구조) +3. [컴포넌트 시스템](#3-컴포넌트-시스템) +4. [V2 컴포넌트 시스템](#4-v2-컴포넌트-시스템) +5. [화면 디자이너 워크플로우](#5-화면-디자이너-워크플로우) +6. [API 클라이언트 시스템](#6-api-클라이언트-시스템) +7. [상태 관리](#7-상태-관리) +8. [레지스트리 시스템](#8-레지스트리-시스템) +9. [대시보드 시스템](#9-대시보드-시스템) +10. [다국어 지원](#10-다국어-지원) +11. [인증 플로우](#11-인증-플로우) +12. [사용자 워크플로우](#12-사용자-워크플로우) + +--- + +## 1. 전체 디렉토리 구조 + +``` +frontend/ +├── app/ # Next.js 14 App Router +│ ├── (main)/ # 메인 레이아웃 그룹 +│ ├── (auth)/ # 인증 레이아웃 그룹 +│ ├── (admin)/ # 관리자 레이아웃 그룹 +│ ├── (pop)/ # 팝업 레이아웃 그룹 +│ ├── layout.tsx # 루트 레이아웃 +│ └── registry-provider.tsx # 레지스트리 초기화 프로바이더 +│ +├── components/ # React 컴포넌트 +│ ├── admin/ # 관리자 컴포넌트 (137개) +│ ├── screen/ # 화면 디자이너/뷰어 (139개) +│ ├── dashboard/ # 대시보드 컴포넌트 (32개) +│ ├── dataflow/ # 데이터플로우 관련 (90개) +│ ├── v2/ # V2 컴포넌트 시스템 (28개) +│ ├── common/ # 공통 컴포넌트 (19개) +│ ├── layout/ # 레이아웃 컴포넌트 (10개) +│ ├── ui/ # shadcn/ui 기반 UI (31개) +│ └── ... # 기타 도메인별 컴포넌트 +│ +├── lib/ # 유틸리티 & 라이브러리 +│ ├── api/ # API 클라이언트 (57개 파일) +│ │ ├── client.ts # Axios 기본 설정 +│ │ ├── screen.ts # 화면 관리 API +│ │ ├── user.ts # 사용자 관리 API +│ │ └── ... # 도메인별 API +│ │ +│ ├── registry/ # 컴포넌트 레지스트리 시스템 (540개) +│ │ ├── ComponentRegistry.ts # 컴포넌트 등록 관리 +│ │ ├── WebTypeRegistry.ts # 웹타입 등록 관리 +│ │ ├── LayoutRegistry.ts # 레이아웃 등록 관리 +│ │ ├── init.ts # 레지스트리 초기화 +│ │ ├── DynamicComponentRenderer.tsx # 동적 렌더링 +│ │ ├── components/ # 등록 가능한 컴포넌트들 (288 tsx, 205 ts) +│ │ │ ├── v2-input/ +│ │ │ ├── v2-select/ +│ │ │ ├── text-input/ +│ │ │ ├── entity-search-input/ +│ │ │ ├── modal-repeater-table/ +│ │ │ └── ... +│ │ └── layouts/ # 레이아웃 컴포넌트 +│ │ ├── accordion/ +│ │ ├── grid/ +│ │ ├── flexbox/ +│ │ ├── split/ +│ │ └── tabs/ +│ │ +│ ├── v2-core/ # V2 코어 시스템 +│ │ ├── events/ # 이벤트 버스 +│ │ ├── adapters/ # 레거시 어댑터 +│ │ ├── components/ # V2 공통 컴포넌트 +│ │ ├── services/ # V2 서비스 +│ │ └── init.ts # V2 초기화 +│ │ +│ ├── utils/ # 유틸리티 함수들 (40개+) +│ ├── services/ # 비즈니스 로직 서비스 +│ ├── stores/ # Zustand 스토어 +│ └── constants/ # 상수 정의 +│ +├── contexts/ # React Context (12개) +│ ├── AuthContext.tsx # 인증 컨텍스트 +│ ├── MenuContext.tsx # 메뉴 컨텍스트 +│ ├── ScreenContext.tsx # 화면 컨텍스트 +│ ├── DashboardContext.tsx # 대시보드 컨텍스트 +│ └── ... +│ +├── hooks/ # Custom Hooks (32개) +│ ├── useAuth.ts # 인증 훅 +│ ├── useMenu.ts # 메뉴 관리 훅 +│ ├── useFormValidation.ts # 폼 검증 훅 +│ └── ... +│ +├── types/ # TypeScript 타입 정의 (44개) +│ ├── screen.ts # 화면 관련 타입 +│ ├── component.ts # 컴포넌트 타입 +│ ├── user.ts # 사용자 타입 +│ └── ... +│ +├── providers/ # React Provider +│ └── QueryProvider.tsx # React Query 설정 +│ +├── stores/ # 전역 상태 관리 +│ ├── flowStepStore.ts # 플로우 단계 상태 +│ ├── modalDataStore.ts # 모달 데이터 상태 +│ └── tableDisplayStore.ts # 테이블 표시 상태 +│ +└── public/ # 정적 파일 + ├── favicon.ico + ├── logo.png + └── locales/ # 다국어 파일 +``` + +--- + +## 2. Next.js App Router 구조 + +### 2.1 라우팅 구조 + +WACE ERP는 **Next.js 14 App Router**를 사용하며, 레이아웃 그룹을 활용한 구조화된 라우팅을 제공합니다. + +#### 라우트 그룹 구조 + +``` +app/ +├── layout.tsx # 글로벌 레이아웃 +│ ├── QueryProvider # React Query 초기화 +│ ├── RegistryProvider # 컴포넌트 레지스트리 초기화 +│ ├── Toaster # 토스트 알림 +│ └── ScreenModal # 전역 화면 모달 +│ +├── (main)/ # 메인 애플리케이션 +│ ├── layout.tsx +│ │ ├── AuthProvider # 인증 컨텍스트 +│ │ ├── MenuProvider # 메뉴 컨텍스트 +│ │ └── AppLayout # 사이드바, 헤더 +│ │ +│ ├── main/page.tsx # 메인 페이지 +│ ├── dashboard/ # 대시보드 +│ │ ├── page.tsx # 대시보드 목록 +│ │ └── [dashboardId]/page.tsx # 대시보드 상세 +│ │ +│ ├── screens/[screenId]/page.tsx # 동적 화면 뷰어 +│ │ +│ └── admin/ # 관리자 페이지 +│ ├── page.tsx # 관리자 홈 +│ ├── menu/page.tsx # 메뉴 관리 +│ │ +│ ├── userMng/ # 사용자 관리 +│ │ ├── userMngList/page.tsx +│ │ ├── rolesList/page.tsx +│ │ ├── userAuthList/page.tsx +│ │ └── companyList/page.tsx +│ │ +│ ├── screenMng/ # 화면 관리 +│ │ ├── screenMngList/page.tsx +│ │ ├── dashboardList/page.tsx +│ │ └── reportList/page.tsx +│ │ +│ ├── systemMng/ # 시스템 관리 +│ │ ├── tableMngList/page.tsx +│ │ ├── commonCodeList/page.tsx +│ │ ├── i18nList/page.tsx +│ │ ├── dataflow/page.tsx +│ │ └── collection-managementList/page.tsx +│ │ +│ ├── automaticMng/ # 자동화 관리 +│ │ ├── flowMgmtList/page.tsx +│ │ ├── batchmngList/page.tsx +│ │ ├── exCallConfList/page.tsx +│ │ └── mail/ # 메일 관리 +│ │ ├── accounts/page.tsx +│ │ ├── send/page.tsx +│ │ ├── receive/page.tsx +│ │ └── templates/page.tsx +│ │ +│ └── [...slug]/page.tsx # 동적 관리자 페이지 +│ +├── (auth)/ # 인증 페이지 +│ ├── layout.tsx # 최소 레이아웃 +│ └── login/page.tsx # 로그인 페이지 +│ +├── (admin)/ # 관리자 전용 (별도 레이아웃) +│ └── admin/ +│ ├── vehicle-trips/page.tsx +│ └── vehicle-reports/page.tsx +│ +└── (pop)/ # 팝업 페이지 + ├── layout.tsx # 팝업 레이아웃 + ├── pop/page.tsx + └── work/page.tsx +``` + +### 2.2 페이지 목록 (총 76개) + +**메인 애플리케이션 (50개)** +- `/main` - 메인 페이지 +- `/dashboard` - 대시보드 목록 +- `/dashboard/[dashboardId]` - 대시보드 상세 +- `/screens/[screenId]` - 동적 화면 뷰어 ⭐ 핵심 +- `/multilang` - 다국어 관리 + +**관리자 페이지 (40개+)** +- 사용자 관리: 사용자, 역할, 권한, 회사, 부서 +- 화면 관리: 화면 목록, 대시보드, 리포트 +- 시스템 관리: 테이블, 공통코드, 다국어, 데이터플로우, 컬렉션 +- 자동화 관리: 플로우, 배치, 외부호출, 메일 +- 표준 관리: 웹타입 표준화 +- 캐스케이딩: 관계, 계층, 상호배타, 자동채움 + +**테스트 페이지 (5개)** +- `/test-autocomplete-mapping` +- `/test-entity-search` +- `/test-order-registration` +- `/test-type-safety` +- `/test-flow` + +**인증/기타 (2개)** +- `/login` - 로그인 +- `/pop` - 팝업 페이지 + +### 2.3 Next.js 설정 + +**next.config.mjs** +```javascript +{ + output: "standalone", // Docker 최적화 + eslint: { ignoreDuringBuilds: true }, + typescript: { ignoreBuildErrors: true }, + + // API 프록시: /api/* → http://localhost:8080/api/* + rewrites: [ + { source: "/api/:path*", destination: "http://localhost:8080/api/:path*" } + ], + + // CORS 헤더 + headers: [ + { source: "/api/:path*", headers: [...] } + ], + + // 환경 변수 + env: { + NEXT_PUBLIC_API_URL: "http://localhost:8080/api" + } +} +``` + +--- + +## 3. 컴포넌트 시스템 + +### 3.1 컴포넌트 분류 + +WACE ERP의 컴포넌트는 크게 **4가지 계층**으로 구분됩니다: + +#### 계층 구조 + +``` +1. UI 컴포넌트 (shadcn/ui 기반) - components/ui/ + └─ Button, Input, Select, Dialog, Card 등 기본 UI + +2. 공통 컴포넌트 - components/common/ + └─ ScreenModal, FormValidationIndicator 등 + +3. 도메인 컴포넌트 - components/{domain}/ + ├─ screen/ - 화면 디자이너/뷰어 + ├─ admin/ - 관리자 기능 + ├─ dashboard/ - 대시보드 + └─ dataflow/ - 데이터플로우 + +4. 레지스트리 컴포넌트 - lib/registry/components/ + └─ 동적으로 등록/렌더링되는 컴포넌트들 (90개+) +``` + +### 3.2 주요 컴포넌트 모듈 + +#### 📁 components/screen/ (139개 파일) + +**핵심 컴포넌트** + +| 컴포넌트 | 역할 | 주요 기능 | +|---------|------|----------| +| `ScreenDesigner.tsx` (7095줄) | 화면 디자이너 | - 드래그&드롭으로 컴포넌트 배치
- 그리드 시스템 (12컬럼)
- 실시간 미리보기
- 컴포넌트 설정 패널
- 그룹화/정렬/분산 도구 | +| `InteractiveScreenViewer.tsx` (2472줄) | 화면 뷰어 | - 실제 사용자 화면 렌더링
- 폼 데이터 바인딩
- 버튼 액션 실행
- 검증 처리 | +| `ScreenManagementList.tsx` | 화면 목록 | - 화면 CRUD
- 메뉴 할당
- 다국어 설정 | +| `DynamicWebTypeRenderer.tsx` | 웹타입 렌더러 | - WebTypeRegistry 기반 동적 렌더링 | + +**위젯 컴포넌트 (widgets/types/)** +- TextWidget, NumberWidget, DateWidget +- SelectWidget, CheckboxWidget, RadioWidget +- TextareaWidget, FileWidget, CodeWidget +- ButtonWidget, EntitySearchInputWrapper + +**설정 패널 (config-panels/)** +- 각 웹타입별 설정 패널 (TextConfigPanel, SelectConfigPanel 등) + +**모달 컴포넌트 (modals/)** +- 키보드 단축키, 다국어 설정, 메뉴 할당 등 + +#### 📁 components/admin/ (137개 파일) + +**관리자 기능 컴포넌트** +- 사용자 관리: UserManagementList, RolesManagementList, CompanyList +- 화면 관리: ScreenManagementList, DashboardManagementList +- 테이블 관리: TableManagementList, ColumnEditor +- 공통코드 관리: CommonCodeManagement +- 메뉴 관리: MenuManagement + +#### 📁 components/dashboard/ (32개 파일) + +**대시보드 컴포넌트** +- DashboardViewer: 대시보드 렌더링 +- DashboardWidgets: 차트, 테이블, 카드 등 위젯 +- ChartComponents: 라인, 바, 파이, 도넛 차트 + +#### 📁 components/dataflow/ (90개+ 파일) + +**데이터플로우 시스템** +- DataFlowList: 플로우 목록 +- node-editor/: 노드 편집기 +- connection/: 커넥션 관리 (38개 파일) +- condition/: 조건 설정 +- external-call/: 외부 호출 설정 + +--- + +## 4. V2 컴포넌트 시스템 + +V2는 **통합 컴포넌트 시스템**으로, 유사한 기능을 하나의 컴포넌트로 통합하여 개발 효율성을 높입니다. + +### 4.1 V2 컴포넌트 종류 (9개) + +| ID | 이름 | 설명 | 포함 기능 | +|----|------|------|----------| +| `v2-input` | 통합 입력 | 텍스트, 숫자, 비밀번호, 슬라이더, 컬러 등 | text, number, password, email, tel, textarea, slider, color | +| `v2-select` | 통합 선택 | 드롭다운, 라디오, 체크박스, 태그, 토글 등 | dropdown, radio, checkbox, tagbox, toggle, swap, combobox | +| `v2-date` | 통합 날짜 | 날짜, 시간, 날짜시간, 날짜 범위 | date, time, datetime, daterange | +| `v2-list` | 통합 목록 | 테이블, 카드, 칸반, 리스트 | table, card, kanban, list, grid | +| `v2-layout` | 통합 레이아웃 | 그리드, 분할 패널, 플렉스 | grid, split, flex, masonry | +| `v2-group` | 통합 그룹 | 탭, 아코디언, 섹션, 모달 | tabs, accordion, section, modal, drawer | +| `v2-media` | 통합 미디어 | 이미지, 비디오, 오디오, 파일 업로드 | image, video, audio, file | +| `v2-biz` | 통합 비즈니스 | 플로우, 랙, 채번규칙, 카테고리 | flow, rack, numbering, category | +| `v2-repeater` | 통합 반복 | 인라인 테이블, 모달, 버튼 | inline-table, modal, button, selected-items | + +### 4.2 V2 아키텍처 + +``` +components/v2/ # V2 UI 컴포넌트 +├── V2Input.tsx # 통합 입력 컴포넌트 +├── V2Select.tsx # 통합 선택 컴포넌트 +├── V2Date.tsx # 통합 날짜 컴포넌트 +├── V2List.tsx # 통합 목록 컴포넌트 +├── V2Layout.tsx # 통합 레이아웃 컴포넌트 +├── V2Group.tsx # 통합 그룹 컴포넌트 +├── V2Media.tsx # 통합 미디어 컴포넌트 +├── V2Biz.tsx # 통합 비즈니스 컴포넌트 +├── V2Hierarchy.tsx # 통합 계층 컴포넌트 +├── V2Repeater.tsx # 통합 반복 컴포넌트 +├── V2FormContext.tsx # V2 폼 컨텍스트 +├── V2ComponentRenderer.tsx # V2 렌더러 +├── registerV2Components.ts # V2 등록 함수 +└── config-panels/ # V2 설정 패널 + ├── V2InputConfigPanel.tsx + ├── V2SelectConfigPanel.tsx + └── ... + +lib/v2-core/ # V2 코어 라이브러리 +├── events/ # 이벤트 버스 +│ ├── EventBus.ts # 발행/구독 패턴 +│ └── types.ts # 이벤트 타입 +├── adapters/ # 레거시 어댑터 +│ └── LegacyEventAdapter.ts # 레거시 시스템 연동 +├── components/ # V2 공통 컴포넌트 +│ └── V2ErrorBoundary.tsx # 에러 경계 +├── services/ # V2 서비스 +│ ├── ScheduleGeneratorService.ts +│ └── ScheduleConfirmDialog.tsx +└── init.ts # V2 초기화 + +lib/registry/components/v2-*/ # V2 렌더러 (레지스트리용) +├── v2-input/ +│ ├── V2InputRenderer.tsx # 자동 등록 렌더러 +│ └── index.ts # 컴포넌트 정의 +├── v2-select/ +│ ├── V2SelectRenderer.tsx +│ └── index.ts +└── ... +``` + +### 4.3 V2 초기화 흐름 + +```typescript +// 1. app/registry-provider.tsx +useEffect(() => { + // 레거시 레지스트리 초기화 + initializeRegistries(); + + // V2 Core 초기화 + initV2Core({ + debug: false, + legacyBridge: { legacyToV2: true, v2ToLegacy: true } + }); +}, []); + +// 2. lib/registry/init.ts +export function initializeRegistries() { + // 웹타입 등록 (text, number, date 등) + initializeWebTypeRegistry(); + + // V2 컴포넌트 등록 + registerV2Components(); +} + +// 3. components/v2/registerV2Components.ts +export function registerV2Components() { + for (const definition of v2ComponentDefinitions) { + ComponentRegistry.registerComponent(definition); + } +} +``` + +### 4.4 V2 렌더링 흐름 + +``` +사용자 요청 (screens/[screenId]) + ↓ +InteractiveScreenViewer + ↓ +DynamicComponentRenderer + ↓ +┌─ componentType이 v2-* 인가? ─┐ +│ Yes │ No +↓ ↓ +ComponentRegistry.getComponent LegacyComponentRegistry.get + ↓ ↓ +V2*Renderer (클래스 기반) 레거시 렌더러 (함수) + ↓ ↓ +render() → V2* 컴포넌트 직접 렌더링 + ↓ +V2FormContext 연동 (선택) + ↓ +최종 렌더링 +``` + +### 4.5 V2 vs 레거시 비교 + +| 항목 | 레거시 시스템 | V2 시스템 | +|------|--------------|----------| +| 컴포넌트 수 | 90개+ (분산) | 9개 (통합) | +| 렌더러 패턴 | 함수형 렌더러 | 클래스 기반 자동 등록 | +| 폼 통합 | 개별 구현 | V2FormContext 통합 | +| 이벤트 시스템 | props drilling | V2 EventBus (발행/구독) | +| 에러 처리 | 개별 처리 | V2ErrorBoundary 통합 | +| 설정 패널 | 개별 패널 | 통합 설정 시스템 | +| 핫 리로드 | 미지원 | 개발 모드 지원 | + +--- + +## 5. 화면 디자이너 워크플로우 + +### 5.1 화면 디자이너 구성 + +**ScreenDesigner.tsx** (7095줄) - 핵심 컴포넌트 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Screen Designer │ +├─────────────────────────────────────────────────────────────────┤ +│ 탭1: 디자인 | 탭2: 프리뷰 | 탭3: 다국어 | 탭4: 메뉴할당 | 탭5: 스타일 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ [컴포넌트 팔레트] [캔버스] [속성 패널] │ +│ ┌──────────────┐ ┌────────────────┐ ┌──────────────┐ │ +│ │ 입력 컴포넌트 │ │ │ │ 기본 설정 │ │ +│ │ - Text │ │ 드래그&드롭 │ │ - ID │ │ +│ │ - Number │ │ 컴포넌트 │ │ - 라벨 │ │ +│ │ - Date │ ───▶ │ 배치 영역 │ ◀── │ - 위치/크기 │ │ +│ │ - Select │ │ │ │ - 데이터연결 │ │ +│ │ │ │ 그리드 기반 │ │ │ │ +│ │ 표시 컴포넌트 │ │ 12컬럼 │ │ 고급 설정 │ │ +│ │ - Table │ │ │ │ - 조건부표시 │ │ +│ │ - Card │ │ │ │ - 검증규칙 │ │ +│ │ │ │ │ │ - 버튼액션 │ │ +│ │ 레이아웃 │ └────────────────┘ └──────────────┘ │ +│ │ - Grid │ │ +│ │ - Tabs │ [하단 도구] │ +│ │ - Accordion │ ┌─────────────────────────────────────┐ │ +│ │ │ │ 그룹화 | 정렬 | 분산 | 라벨토글 │ │ +│ └──────────────┘ └─────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 5.2 주요 기능 + +#### 그리드 시스템 +```typescript +// 12컬럼 그리드 기반 +const GRID_COLUMNS = 12; +const COLUMN_WIDTH = (containerWidth - gaps) / 12; + +// 컴포넌트 너비 → 컬럼 스팬 변환 +function calculateColumnSpan(width: number): number { + return Math.max(1, Math.min(12, Math.round(width / COLUMN_WIDTH))); +} + +// 반응형 지원 +{ + default: { span: 6 }, // 기본 (50%) + sm: { span: 12 }, // 모바일 (100%) + md: { span: 6 }, // 태블릿 (50%) + lg: { span: 4 } // 데스크톱 (33%) +} +``` + +#### 컴포넌트 배치 흐름 + +``` +1. 컴포넌트 팔레트에서 드래그 시작 + ↓ +2. 캔버스에 드롭 + ↓ +3. 컴포넌트 생성 (generateComponentId) + ↓ +4. 위치 계산 (10px 단위 스냅) + ↓ +5. 그리드 정렬 (12컬럼 기준) + ↓ +6. 레이아웃 데이터 업데이트 + ↓ +7. 리렌더링 (RealtimePreview) +``` + +#### 컴포넌트 설정 + +``` +선택된 컴포넌트 → 우측 속성 패널 표시 + ↓ +┌─────────────────────────────────────┐ +│ 기본 설정 │ +│ - ID: comp_1234 │ +│ - 라벨: "제품명" │ +│ - 컬럼명: product_name │ +│ - 필수: ☑ │ +│ - 읽기전용: ☐ │ +│ │ +│ 크기 & 위치 │ +│ - X: 100px, Y: 200px │ +│ - 너비: 300px, 높이: 40px │ +│ - 컬럼 스팬: 6 │ +│ │ +│ 데이터 연결 │ +│ - 테이블: product_info │ +│ - 컬럼: product_name │ +│ - 웹타입: text │ +│ │ +│ 고급 설정 │ +│ - 조건부 표시: field === 'A' │ +│ - 버튼 액션: [등록], [수정], [삭제] │ +│ - 데이터플로우: flow_123 │ +└─────────────────────────────────────┘ +``` + +### 5.3 저장 구조 + +**ScreenDefinition (JSON)** + +```json +{ + "screenId": 123, + "screenCode": "PRODUCT_MGMT", + "screenName": "제품 관리", + "tableName": "product_info", + "screenType": "form", + "version": 2, + "layoutData": { + "components": [ + { + "id": "comp_text_1", + "type": "text-input", + "componentType": "text-input", + "label": "제품명", + "columnName": "product_name", + "position": { "x": 100, "y": 50, "z": 0 }, + "size": { "width": 300, "height": 40 }, + "componentConfig": { + "required": true, + "placeholder": "제품명을 입력하세요", + "maxLength": 100 + }, + "style": { + "labelDisplay": true, + "labelText": "제품명" + } + }, + { + "id": "comp_table_1", + "type": "table-list", + "componentType": "table-list", + "tableName": "product_info", + "position": { "x": 50, "y": 200, "z": 0 }, + "size": { "width": 800, "height": 400 }, + "componentConfig": { + "columns": ["product_code", "product_name", "price"], + "pagination": true, + "sortable": true + } + } + ], + "layouts": [ + { + "id": "layout_tabs_1", + "layoutType": "tabs", + "children": [ + { "tabId": "tab1", "title": "기본정보", "components": ["comp_text_1"] }, + { "tabId": "tab2", "title": "상세정보", "components": ["comp_table_1"] } + ] + } + ] + }, + "createdBy": "user123", + "createdDate": "2024-01-01T00:00:00Z" +} +``` + +--- + +## 6. API 클라이언트 시스템 + +### 6.1 API 클라이언트 구조 + +**lib/api/** (57개 파일) + +``` +lib/api/ +├── client.ts # Axios 기본 설정 & 인터셉터 +│ +├── 화면 관련 (3개) +│ ├── screen.ts # 화면 CRUD +│ ├── screenGroup.ts # 화면 그룹 +│ └── screenFile.ts # 화면 파일 +│ +├── 사용자 관련 (5개) +│ ├── user.ts # 사용자 관리 +│ ├── role.ts # 역할 관리 +│ ├── company.ts # 회사 관리 +│ └── department.ts # 부서 관리 +│ +├── 테이블 관련 (5개) +│ ├── tableManagement.ts # 테이블 관리 +│ ├── tableSchema.ts # 스키마 조회 +│ ├── tableHistory.ts # 테이블 이력 +│ └── tableCategoryValue.ts # 카테고리 값 +│ +├── 데이터플로우 (6개) +│ ├── dataflow.ts # 데이터플로우 정의 +│ ├── dataflowSave.ts # 저장 로직 +│ ├── nodeFlows.ts # 노드 플로우 +│ ├── nodeExternalConnections.ts # 외부 연결 +│ └── flowExternalDb.ts # 외부 DB 플로우 +│ +├── 자동화 (4개) +│ ├── batch.ts # 배치 작업 +│ ├── batchManagement.ts # 배치 관리 +│ ├── externalCall.ts # 외부 호출 +│ └── externalCallConfig.ts # 외부 호출 설정 +│ +├── 시스템 (8개) +│ ├── menu.ts # 메뉴 관리 +│ ├── commonCode.ts # 공통코드 +│ ├── multilang.ts # 다국어 +│ ├── layout.ts # 레이아웃 +│ ├── collection.ts # 컬렉션 +│ └── ... +│ +└── 기타 (26개) + ├── data.ts # 동적 데이터 CRUD + ├── dynamicForm.ts # 동적 폼 + ├── file.ts # 파일 업로드 + ├── dashboard.ts # 대시보드 + ├── mail.ts # 메일 + ├── reportApi.ts # 리포트 + └── ... +``` + +### 6.2 API 클라이언트 기본 설정 + +**lib/api/client.ts** + +```typescript +// 1. 동적 API URL 설정 +const getApiBaseUrl = (): string => { + // 환경변수 우선 + if (process.env.NEXT_PUBLIC_API_URL) return process.env.NEXT_PUBLIC_API_URL; + + // 프로덕션: v1.vexplor.com → api.vexplor.com + if (currentHost === "v1.vexplor.com") { + return "https://api.vexplor.com/api"; + } + + // 로컬: localhost:9771 → localhost:8080 + return "http://localhost:8080/api"; +}; + +export const API_BASE_URL = getApiBaseUrl(); + +// 2. Axios 인스턴스 생성 +export const apiClient = axios.create({ + baseURL: API_BASE_URL, + timeout: 30000, + headers: { "Content-Type": "application/json" }, + withCredentials: true +}); + +// 3. 요청 인터셉터 (JWT 토큰 자동 추가) +apiClient.interceptors.request.use((config) => { + const token = localStorage.getItem("authToken"); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + + // 다국어: GET 요청에 userLang 파라미터 추가 + if (config.method === "GET") { + config.params = { + ...config.params, + userLang: window.__GLOBAL_USER_LANG || "KR" + }; + } + + return config; +}); + +// 4. 응답 인터셉터 (토큰 갱신, 401 처리) +apiClient.interceptors.response.use( + (response) => { + // 서버에서 새 토큰 전송 시 자동 갱신 + const newToken = response.headers["x-new-token"]; + if (newToken) { + localStorage.setItem("authToken", newToken); + } + return response; + }, + async (error) => { + // 401 에러: 토큰 갱신 시도 → 실패 시 로그인 페이지 + if (error.response?.status === 401) { + const newToken = await refreshToken(); + if (newToken) { + error.config.headers.Authorization = `Bearer ${newToken}`; + return apiClient.request(error.config); // 재시도 + } + window.location.href = "/login"; + } + return Promise.reject(error); + } +); +``` + +### 6.3 API 사용 패턴 + +#### ✅ 올바른 사용법 (lib/api 클라이언트 사용) + +```typescript +import { screenApi } from "@/lib/api/screen"; +import { dataApi } from "@/lib/api/data"; + +// 화면 목록 조회 +const screens = await screenApi.getScreens({ page: 1, size: 20 }); + +// 데이터 생성 +const result = await dataApi.createData("product_info", { + product_name: "제품A", + price: 10000 +}); +``` + +#### ❌ 잘못된 사용법 (fetch 직접 사용 금지!) + +```typescript +// 🚫 금지! JWT 토큰, 다국어, 에러 처리 누락 +const res = await fetch('/api/screen-management/screens'); +``` + +### 6.4 주요 API 예시 + +#### 화면 관리 API (screen.ts) + +```typescript +export const screenApi = { + // 화면 목록 조회 + getScreens: async (params) => { + const response = await apiClient.get("/screen-management/screens", { params }); + return response.data; + }, + + // 화면 상세 조회 + getScreen: async (screenId: number) => { + const response = await apiClient.get(`/screen-management/screens/${screenId}`); + return response.data.data; + }, + + // 화면 생성 + createScreen: async (screen: CreateScreenRequest) => { + const response = await apiClient.post("/screen-management/screens", screen); + return response.data; + }, + + // 화면 수정 + updateScreen: async (screenId: number, screen: UpdateScreenRequest) => { + const response = await apiClient.put(`/screen-management/screens/${screenId}`, screen); + return response.data; + }, + + // 화면 삭제 + deleteScreen: async (screenId: number) => { + const response = await apiClient.delete(`/screen-management/screens/${screenId}`); + return response.data; + } +}; +``` + +#### 동적 데이터 API (data.ts) + +```typescript +export const dataApi = { + // 데이터 목록 조회 (페이징, 필터링, 정렬) + getDataList: async (tableName: string, params: { + page?: number; + size?: number; + filters?: Record; + sortBy?: string; + sortOrder?: "asc" | "desc"; + }) => { + const response = await apiClient.get(`/data/${tableName}`, { params }); + return response.data; + }, + + // 데이터 생성 + createData: async (tableName: string, data: Record) => { + const response = await apiClient.post(`/data/${tableName}`, data); + return response.data; + }, + + // 데이터 수정 + updateData: async (tableName: string, id: number, data: Record) => { + const response = await apiClient.put(`/data/${tableName}/${id}`, data); + return response.data; + }, + + // 데이터 삭제 + deleteData: async (tableName: string, id: number) => { + const response = await apiClient.delete(`/data/${tableName}/${id}`); + return response.data; + } +}; +``` + +--- + +## 7. 상태 관리 + +### 7.1 상태 관리 전략 + +WACE ERP는 **하이브리드 상태 관리 전략**을 사용합니다: + +| 관리 방식 | 사용 시나리오 | 예시 | +|----------|-------------|------| +| **React Query** | 서버 상태 (캐싱, 자동 갱신) | 화면 목록, 데이터 목록 | +| **React Context** | 전역 상태 (공유 데이터) | 인증, 메뉴, 화면 | +| **Zustand** | 클라이언트 상태 (간단한 전역) | 플로우 단계, 모달 데이터 | +| **Local State** | 컴포넌트 로컬 상태 | 폼 입력, UI 토글 | + +### 7.2 React Context (12개) + +``` +contexts/ +├── AuthContext.tsx # 인증 상태 & 세션 관리 +│ - 사용자 정보 +│ - 로그인/로그아웃 +│ - 세션 타이머 (30분) +│ +├── MenuContext.tsx # 메뉴 트리 & 네비게이션 +│ - 메뉴 구조 +│ - 현재 선택된 메뉴 +│ - 메뉴 접근 권한 +│ +├── ScreenContext.tsx # 화면 편집 상태 +│ - 현재 편집 중인 화면 +│ - 선택된 컴포넌트 +│ - 실행 취소/다시 실행 +│ +├── ScreenPreviewContext.tsx # 화면 미리보기 +│ - 반응형 모드 (데스크톱/태블릿/모바일) +│ - 미리보기 데이터 +│ +├── DashboardContext.tsx # 대시보드 상태 +│ - 대시보드 레이아웃 +│ - 위젯 설정 +│ +├── TableOptionsContext.tsx # 테이블 옵션 +│ - 컬럼 순서 +│ - 필터 +│ - 정렬 +│ +├── ActiveTabContext.tsx # 탭 활성화 상태 +├── LayerContext.tsx # 레이어 관리 +├── ReportDesignerContext.tsx # 리포트 디자이너 +├── ScreenMultiLangContext.tsx # 화면 다국어 +├── SplitPanelContext.tsx # 분할 패널 상태 +└── TableSearchWidgetHeightContext.tsx # 테이블 검색 높이 +``` + +### 7.3 React Query 설정 + +**providers/QueryProvider.tsx** + +```typescript +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, // 5분간 fresh + cacheTime: 10 * 60 * 1000, // 10분간 캐시 유지 + refetchOnWindowFocus: false, // 창 포커스 시 자동 갱신 비활성화 + retry: 1, // 실패 시 1회 재시도 + }, + }, +}); + +export function QueryProvider({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} +``` + +**사용 예시** + +```typescript +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { screenApi } from "@/lib/api/screen"; + +// 화면 목록 조회 (자동 캐싱) +const { data: screens, isLoading, error } = useQuery({ + queryKey: ["screens", { page: 1 }], + queryFn: () => screenApi.getScreens({ page: 1, size: 20 }) +}); + +// 화면 생성 (생성 후 목록 자동 갱신) +const queryClient = useQueryClient(); +const createMutation = useMutation({ + mutationFn: (screen: CreateScreenRequest) => screenApi.createScreen(screen), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["screens"] }); + } +}); +``` + +### 7.4 Zustand 스토어 (3개) + +``` +stores/ +├── flowStepStore.ts # 플로우 단계 상태 +│ - 현재 단계 +│ - 단계별 데이터 +│ - 진행률 +│ +├── modalDataStore.ts # 모달 데이터 공유 +│ - 모달 ID별 데이터 +│ - 모달 간 데이터 전달 +│ +└── tableDisplayStore.ts # 테이블 표시 상태 + - 선택된 행 + - 정렬 상태 + - 페이지네이션 +``` + +**사용 예시** + +```typescript +import { create } from "zustand"; + +interface ModalDataStore { + modalData: Record; + setModalData: (modalId: string, data: any) => void; + getModalData: (modalId: string) => any; +} + +export const useModalDataStore = create((set, get) => ({ + modalData: {}, + + setModalData: (modalId, data) => { + set((state) => ({ + modalData: { ...state.modalData, [modalId]: data } + })); + }, + + getModalData: (modalId) => { + return get().modalData[modalId]; + } +})); +``` + +--- + +## 8. 레지스트리 시스템 + +레지스트리 시스템은 **컴포넌트를 동적으로 등록하고 렌더링**하는 핵심 아키텍처입니다. + +### 8.1 레지스트리 종류 + +``` +lib/registry/ +├── ComponentRegistry.ts # 컴포넌트 레지스트리 (신규) +│ - 90개+ 컴포넌트 관리 +│ - 자동 등록 시스템 +│ - 핫 리로드 지원 +│ +├── WebTypeRegistry.ts # 웹타입 레지스트리 (레거시) +│ - text, number, date 등 기본 웹타입 +│ - 위젯 컴포넌트 + 설정 패널 +│ +└── LayoutRegistry.ts # 레이아웃 레지스트리 + - grid, tabs, accordion 등 레이아웃 +``` + +### 8.2 ComponentRegistry 상세 + +**등록 프로세스** + +```typescript +// 1. 컴포넌트 정의 (lib/registry/components/v2-input/index.ts) +export const V2InputDefinition: ComponentDefinition = { + id: "v2-input", + name: "통합 입력", + category: ComponentCategory.V2, + component: V2InputRenderer, + configPanel: V2InputConfigPanel, + defaultSize: { width: 200, height: 40 }, + defaultConfig: { inputType: "text", format: "none" } +}; + +// 2. 자동 등록 렌더러 (lib/registry/components/v2-input/V2InputRenderer.tsx) +export class V2InputRenderer extends AutoRegisteringComponentRenderer { + static componentDefinition = V2InputDefinition; + + render(): React.ReactElement { + return ; + } +} + +// 자동 등록 실행 +V2InputRenderer.registerSelf(); + +// 3. 레지스트리 조회 & 렌더링 (DynamicComponentRenderer.tsx) +const newComponent = ComponentRegistry.getComponent("v2-input"); +if (newComponent) { + const Renderer = newComponent.component; + return ; +} +``` + +### 8.3 등록된 컴포넌트 목록 (90개+) + +**입력 컴포넌트 (25개)** +- text-input, number-input, date-input +- select-basic, checkbox-basic, radio-basic, toggle-switch +- textarea-basic, slider-basic +- v2-input, v2-select, v2-date + +**표시 컴포넌트 (15개)** +- text-display, image-display, card-display +- table-list, v2-table-list, pivot-grid +- aggregation-widget, v2-aggregation-widget + +**레이아웃 컴포넌트 (10개)** +- grid-layout, flexbox-layout +- tabs-widget, accordion-basic +- split-panel-layout, v2-split-panel-layout +- section-card, section-paper +- v2-divider-line + +**비즈니스 컴포넌트 (20개)** +- entity-search-input, autocomplete-search-input +- modal-repeater-table, repeat-screen-modal +- selected-items-detail-input, simple-repeater-table +- repeater-field-group, repeat-container +- category-manager, v2-category-manager +- numbering-rule, v2-numbering-rule +- rack-structure, v2-rack-structure +- location-swap-selector, customer-item-mapping + +**특수 컴포넌트 (20개)** +- button-primary, v2-button-primary +- file-upload, v2-file-upload +- mail-recipient-selector +- conditional-container, universal-form-modal +- related-data-buttons +- flow-widget +- map + +### 8.4 DynamicComponentRenderer 동작 원리 + +```typescript +// DynamicComponentRenderer.tsx (770줄) + +export const DynamicComponentRenderer: React.FC = ({ component, ...props }) => { + // 1. 컴포넌트 타입 추출 + const componentType = component.componentType || component.type; + + // 2. 레거시 → V2 자동 매핑 + const v2Type = componentType.startsWith("v2-") + ? componentType + : ComponentRegistry.hasComponent(`v2-${componentType}`) + ? `v2-${componentType}` + : componentType; + + // 3. 조건부 렌더링 체크 + if (component.conditionalConfig?.enabled) { + const conditionMet = evaluateCondition(props.formData); + if (!conditionMet) return null; + } + + // 4. 카테고리 타입 우선 처리 + if (component.inputType === "category" || component.webType === "category") { + return ; + } + + // 5. 레이아웃 컴포넌트 + if (componentType === "layout") { + return ; + } + + // 6. ComponentRegistry에서 조회 (신규) + const newComponent = ComponentRegistry.getComponent(v2Type); + if (newComponent) { + const Renderer = newComponent.component; + // 클래스 기반: new Renderer(props).render() + // 함수형: + return isClass(Renderer) + ? new Renderer(props).render() + : ; + } + + // 7. LegacyComponentRegistry에서 조회 (레거시) + const legacyRenderer = legacyComponentRegistry.get(componentType); + if (legacyRenderer) { + return legacyRenderer({ component, ...props }); + } + + // 8. 폴백: 미등록 컴포넌트 플레이스홀더 + return ; +}; +``` + +--- + +## 9. 대시보드 시스템 + +### 9.1 대시보드 구조 + +``` +components/dashboard/ +├── DashboardViewer.tsx # 대시보드 렌더링 +├── DashboardGrid.tsx # 그리드 레이아웃 +├── DashboardWidget.tsx # 위젯 래퍼 +│ +├── widgets/ # 위젯 컴포넌트 +│ ├── ChartWidget.tsx # 차트 위젯 +│ ├── TableWidget.tsx # 테이블 위젯 +│ ├── CardWidget.tsx # 카드 위젯 +│ ├── StatWidget.tsx # 통계 위젯 +│ └── CustomWidget.tsx # 커스텀 위젯 +│ +└── charts/ # 차트 컴포넌트 + ├── LineChart.tsx + ├── BarChart.tsx + ├── PieChart.tsx + ├── DonutChart.tsx + └── AreaChart.tsx +``` + +### 9.2 대시보드 JSON 구조 + +```json +{ + "dashboardId": 1, + "dashboardName": "영업 대시보드", + "layout": { + "type": "grid", + "columns": 12, + "rows": 6, + "gap": 16 + }, + "widgets": [ + { + "widgetId": "widget_1", + "widgetType": "chart", + "chartType": "line", + "title": "월별 매출 추이", + "position": { "x": 0, "y": 0, "w": 6, "h": 3 }, + "dataSource": { + "type": "api", + "endpoint": "/api/data/sales_monthly", + "filters": { "year": 2024 } + }, + "chartConfig": { + "xAxis": "month", + "yAxis": "sales_amount", + "showLegend": true + } + }, + { + "widgetId": "widget_2", + "widgetType": "stat", + "title": "총 매출", + "position": { "x": 6, "y": 0, "w": 3, "h": 2 }, + "dataSource": { + "type": "sql", + "query": "SELECT SUM(amount) FROM sales WHERE year = 2024" + }, + "statConfig": { + "format": "currency", + "comparison": "lastYear" + } + } + ] +} +``` + +### 9.3 대시보드 라우팅 + +``` +/dashboard # 대시보드 목록 +/dashboard/[dashboardId] # 대시보드 보기 +/admin/screenMng/dashboardList # 대시보드 관리 (편집) +``` + +--- + +## 10. 다국어 지원 + +### 10.1 다국어 시스템 구조 + +WACE ERP는 **동적 다국어 시스템**을 제공합니다 (DB 기반): + +``` +다국어 흐름 + ↓ +1. 사용자 로그인 + ↓ +2. 사용자 로케일 조회 (GET /api/admin/user-locale) + → 결과: "KR" | "EN" | "CN" + ↓ +3. 전역 상태에 저장 + - window.__GLOBAL_USER_LANG + - localStorage.userLocale + ↓ +4. API 요청 시 자동 주입 + - GET 요청: ?userLang=KR (apiClient 인터셉터) + ↓ +5. 백엔드에서 다국어 데이터 반환 + - label_KR, label_EN, label_CN + ↓ +6. 프론트엔드에서 표시 + - extractMultilangLabel(label, "KR") +``` + +### 10.2 다국어 API + +**lib/api/multilang.ts** + +```typescript +export const multilangApi = { + // 다국어 데이터 조회 + getMultilangData: async (params: { + target_table: string; + target_pk: string; + target_lang: string; + }) => { + const response = await apiClient.get("/admin/multilang", { params }); + return response.data; + }, + + // 다국어 데이터 저장 + saveMultilangData: async (data: { + target_table: string; + target_pk: string; + target_field: string; + target_lang: string; + translated_text: string; + }) => { + const response = await apiClient.post("/admin/multilang", data); + return response.data; + } +}; +``` + +### 10.3 다국어 유틸리티 + +**lib/utils/multilang.ts** + +```typescript +// 다국어 라벨 추출 +export function extractMultilangLabel( + label: string | Record | undefined, + locale: string = "KR" +): string { + if (!label) return ""; + + // 문자열인 경우 그대로 반환 + if (typeof label === "string") return label; + + // 객체인 경우 로케일에 맞는 값 반환 + return label[locale] || label["KR"] || label["EN"] || ""; +} + +// 화면 컴포넌트 라벨 추출 +export function getComponentLabel(component: ComponentData, locale: string): string { + // 우선순위: multiLangLabel > label > columnName > id + if (component.multiLangLabel) { + return extractMultilangLabel(component.multiLangLabel, locale); + } + return component.label || component.columnName || component.id; +} +``` + +### 10.4 화면 디자이너 다국어 탭 + +``` +ScreenDesigner + ↓ +┌───────────────────────────────────────────────────┐ +│ 탭: 다국어 설정 │ +├───────────────────────────────────────────────────┤ +│ │ +│ 화면명 다국어 │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ 한국어(KR): 제품 관리 │ │ +│ │ 영어(EN): Product Management │ │ +│ │ 중국어(CN): 产品管理 │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ +│ 컴포넌트별 다국어 │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ [comp_text_1] 제품명 │ │ +│ │ 한국어(KR): 제품명 │ │ +│ │ 영어(EN): Product Name │ │ +│ │ 중국어(CN): 产品名称 │ │ +│ │ │ │ +│ │ [comp_text_2] 가격 │ │ +│ │ 한국어(KR): 가격 │ │ +│ │ 영어(EN): Price │ │ +│ │ 중국어(CN): 价格 │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ +│ [자동 번역] [일괄 적용] [저장] │ +└───────────────────────────────────────────────────┘ +``` + +--- + +## 11. 인증 플로우 + +### 11.1 인증 아키텍처 + +``` +┌─────────────────────────────────────────────────────┐ +│ Frontend │ +├─────────────────────────────────────────────────────┤ +│ │ +│ useAuth Hook │ +│ ├─ login(userId, password) │ +│ ├─ logout() │ +│ ├─ refreshUserData() │ +│ ├─ checkAuthStatus() │ +│ └─ switchCompany(companyCode) ⭐ 신규 │ +│ │ +│ AuthContext Provider │ +│ ├─ SessionManager (30분 타임아웃) │ +│ ├─ Session Warning (5분 전 알림) │ +│ └─ Auto Refresh (활동 감지) │ +│ │ +│ TokenManager │ +│ ├─ getToken(): localStorage.authToken │ +│ ├─ setToken(token): 저장 + 쿠키 설정 │ +│ ├─ removeToken(): 삭제 + 쿠키 삭제 │ +│ └─ isTokenExpired(token): JWT 검증 │ +│ │ +│ API Client Interceptor │ +│ ├─ Request: JWT 토큰 자동 추가 │ +│ └─ Response: 401 시 토큰 갱신 또는 로그아웃 │ +│ │ +└─────────────────────────────────────────────────────┘ + │ + │ HTTP Request + │ Authorization: Bearer + ↓ +┌─────────────────────────────────────────────────────┐ +│ Backend (8080) │ +├─────────────────────────────────────────────────────┤ +│ │ +│ POST /api/auth/login │ +│ → JWT 토큰 발급 (24시간) │ +│ │ +│ POST /api/auth/refresh │ +│ → JWT 토큰 갱신 │ +│ │ +│ POST /api/auth/logout │ +│ → 세션 무효화 │ +│ │ +│ GET /api/auth/me │ +│ → 현재 사용자 정보 │ +│ │ +│ GET /api/auth/status │ +│ → 인증 상태 확인 │ +│ │ +│ POST /api/auth/switch-company ⭐ 신규 │ +│ → 회사 전환 (WACE 관리자 전용) │ +│ │ +└─────────────────────────────────────────────────────┘ +``` + +### 11.2 로그인 흐름 + +``` +1. 사용자가 ID/PW 입력 → /login 페이지 + ↓ +2. POST /api/auth/login { userId, password } + ↓ +3. 백엔드 검증 (DB: user_info 테이블) + ├─ 성공: JWT 토큰 발급 (payload: userId, companyCode, isAdmin, exp) + └─ 실패: 401 에러 + ↓ +4. 프론트엔드: 토큰 저장 + - localStorage.setItem("authToken", token) + - document.cookie = "authToken=..." + ↓ +5. 사용자 정보 조회 + - GET /api/auth/me + - GET /api/admin/user-locale + ↓ +6. 전역 상태 업데이트 + - AuthContext.user + - window.__GLOBAL_USER_LANG + ↓ +7. 메인 페이지로 리다이렉트 (/main) +``` + +### 11.3 세션 관리 + +**SessionManager (lib/sessionManager.ts)** + +```typescript +// 설정 +{ + checkInterval: 60000, // 1분마다 체크 + maxInactiveTime: 1800000, // 30분 (데스크톱) + warningTime: 300000, // 5분 전 경고 +} + +// 이벤트 +{ + onWarning: (remainingTime) => { + // "세션이 5분 후 만료됩니다" 알림 표시 + }, + onExpiry: () => { + // 자동 로그아웃 → 로그인 페이지 + }, + onActivity: () => { + // 사용자 활동 감지 → 타이머 리셋 + } +} +``` + +### 11.4 토큰 갱신 전략 + +``` +자동 토큰 갱신 트리거: +1. 10분마다 토큰 상태 확인 (타이머) +2. 사용자 활동 감지 (클릭, 키보드, 스크롤) +3. API 401 응답 (토큰 만료) + +갱신 로직: + ↓ +1. 현재 토큰 만료까지 30분 미만? + ↓ Yes +2. POST /api/auth/refresh + ├─ 성공: 새 토큰 저장 + └─ 실패: 로그아웃 +``` + +### 11.5 회사 전환 (WACE 관리자 전용) ⭐ + +``` +1. WACE 관리자 로그인 + ↓ +2. 헤더에서 회사 선택 (CompanySwitcher) + ↓ +3. POST /api/auth/switch-company { companyCode: "AAA" } + ↓ +4. 백엔드: 새 JWT 발급 (payload.companyCode = "AAA") + ↓ +5. 프론트엔드: 새 토큰 저장 + ↓ +6. 페이지 새로고침 → 화면/데이터가 AAA 회사 기준으로 표시 +``` + +--- + +## 12. 사용자 워크플로우 + +### 12.1 전체 워크플로우 개요 + +``` +┌──────────────────────────────────────────────────────────────┐ +│ 관리자 (화면 생성) │ +└──────────────────────────────────────────────────────────────┘ + │ + ↓ + ┌─────────────────────────────┐ + │ 1. 로그인 (WACE 관리자) │ + └─────────────────────────────┘ + │ + ↓ + ┌─────────────────────────────┐ + │ 2. 화면 디자이너 접속 │ + │ /admin/screenMng/ │ + │ screenMngList │ + └─────────────────────────────┘ + │ + ↓ + ┌─────────────────────────────┐ + │ 3. 화면 생성 & 편집 │ + │ - 컴포넌트 배치 │ + │ - 데이터 연결 │ + │ - 버튼 액션 설정 │ + │ - 다국어 설정 │ + └─────────────────────────────┘ + │ + ↓ + ┌─────────────────────────────┐ + │ 4. 메뉴에 화면 할당 │ + │ /admin/menu │ + └─────────────────────────────┘ + │ + ↓ + ┌─────────────────────────────┐ + │ 5. 사용자에게 권한 부여 │ + │ /admin/userMng/ │ + │ userAuthList │ + └─────────────────────────────┘ + │ + ↓ +┌──────────────────────────────────────────────────────────────┐ +│ 사용자 (화면 사용) │ +└──────────────────────────────────────────────────────────────┘ + │ + ↓ + ┌─────────────────────────────┐ + │ 1. 로그인 (일반 사용자) │ + └─────────────────────────────┘ + │ + ↓ + ┌─────────────────────────────┐ + │ 2. 메뉴에서 화면 선택 │ + │ 사이드바 → 제품 관리 │ + └─────────────────────────────┘ + │ + ↓ + ┌─────────────────────────────┐ + │ 3. 화면 렌더링 │ + │ /screens/[screenId] │ + │ InteractiveScreenViewer │ + └─────────────────────────────┘ + │ + ↓ + ┌─────────────────────────────┐ + │ 4. 데이터 조회/등록/수정 │ + │ - 테이블에서 행 선택 │ + │ - 폼에 데이터 입력 │ + │ - [등록]/[수정]/[삭제] 버튼│ + └─────────────────────────────┘ +``` + +### 12.2 관리자 워크플로우 (화면 생성) + +#### 단계 1: 화면 디자이너 접속 + +``` +/admin/screenMng/screenMngList + ↓ +[새 화면 만들기] 버튼 클릭 + ↓ +ScreenDesigner 컴포넌트 로드 +``` + +#### 단계 2: 화면 디자인 + +**2-1. 기본 정보 설정** + +``` +화면 정보 입력: +- 화면 코드: PRODUCT_MGMT +- 화면명: 제품 관리 +- 테이블명: product_info +- 화면 타입: form (단일 폼) | list (목록) +``` + +**2-2. 컴포넌트 배치** + +``` +좌측 팔레트에서 컴포넌트 선택 + ↓ +캔버스에 드래그&드롭 + ↓ +위치/크기 조정 (10px 단위 스냅) + ↓ +우측 속성 패널에서 설정: + - 컬럼명: product_name + - 라벨: 제품명 + - 필수: ☑ + - 웹타입: text +``` + +**2-3. 버튼 액션 설정** + +``` +버튼 컴포넌트 추가 + ↓ +액션 타입 선택: + - 데이터 저장 (POST) + - 데이터 수정 (PUT) + - 데이터 삭제 (DELETE) + - 데이터플로우 실행 + - 외부 API 호출 + - 화면 이동 + ↓ +액션 설정: + - 대상 테이블: product_info + - 성공 시: 목록 새로고침 + - 실패 시: 에러 메시지 표시 +``` + +**2-4. 다국어 설정** + +``` +다국어 탭 클릭 + ↓ +컴포넌트별 다국어 입력: + - 한국어(KR): 제품명 + - 영어(EN): Product Name + - 중국어(CN): 产品名称 +``` + +**2-5. 저장** + +``` +[저장] 버튼 클릭 + ↓ +POST /api/screen-management/screens + ↓ +화면 정의 JSON 저장 (DB: screen_definition) +``` + +#### 단계 3: 메뉴에 화면 할당 + +``` +/admin/menu + ↓ +메뉴 트리에서 위치 선택 + ↓ +[화면 할당] 버튼 클릭 + ↓ +방금 생성한 화면 선택 (PRODUCT_MGMT) + ↓ +저장 → menu_screen 테이블에 연결 +``` + +#### 단계 4: 권한 부여 + +``` +/admin/userMng/userAuthList + ↓ +사용자 선택 + ↓ +메뉴 권한 설정 + ↓ +"제품 관리" 메뉴 체크 + ↓ +저장 → user_menu_auth 테이블 +``` + +### 12.3 사용자 워크플로우 (화면 사용) + +#### 단계 1: 로그인 + +``` +/login + ↓ +사용자 ID/PW 입력 + ↓ +POST /api/auth/login + ↓ +JWT 토큰 발급 + ↓ +메인 페이지 (/main) +``` + +#### 단계 2: 메뉴 선택 + +``` +좌측 사이드바 메뉴 트리 + ↓ +"제품 관리" 메뉴 클릭 + ↓ +MenuContext에서 메뉴 정보 조회: + - menuId, screenId, screenCode + ↓ +화면 뷰어로 이동 (/screens/[screenId]) +``` + +#### 단계 3: 화면 렌더링 + +``` +InteractiveScreenViewer 컴포넌트 로드 + ↓ +1. GET /api/screen-management/screens/[screenId] + → 화면 정의 JSON 조회 + ↓ +2. GET /api/data/product_info?page=1&size=20 + → 데이터 조회 + ↓ +3. DynamicComponentRenderer로 컴포넌트 렌더링 + ↓ +4. 폼 데이터 바인딩 (formData 상태) +``` + +#### 단계 4: 데이터 조작 + +**4-1. 신규 등록** + +``` +[등록] 버튼 클릭 + ↓ +빈 폼 표시 (ScreenModal 또는 EditModal) + ↓ +사용자가 데이터 입력: + - 제품명: "제품A" + - 가격: 10000 + ↓ +[저장] 버튼 클릭 + ↓ +버튼 액션 실행: + - POST /api/data/product_info + - Body: { product_name: "제품A", price: 10000 } + ↓ +성공 응답 + ↓ +목록 새로고침 (React Query invalidateQueries) +``` + +**4-2. 수정** + +``` +테이블에서 행 선택 (클릭) + ↓ +선택된 데이터 → selectedRowsData 상태 업데이트 + ↓ +[수정] 버튼 클릭 + ↓ +폼에 기존 데이터 표시 (EditModal) + ↓ +사용자가 데이터 수정: + - 가격: 10000 → 12000 + ↓ +[저장] 버튼 클릭 + ↓ +버튼 액션 실행: + - PUT /api/data/product_info/123 + - Body: { price: 12000 } + ↓ +목록 새로고침 +``` + +**4-3. 삭제** + +``` +테이블에서 행 선택 + ↓ +[삭제] 버튼 클릭 + ↓ +확인 다이얼로그 표시 + ↓ +확인 클릭 + ↓ +버튼 액션 실행: + - DELETE /api/data/product_info/123 + ↓ +목록 새로고침 +``` + +### 12.4 데이터플로우 실행 워크플로우 + +``` +1. 관리자가 데이터플로우 정의 + /admin/systemMng/dataflow + ↓ +2. 화면에 버튼 추가 & 플로우 연결 + 버튼 액션: "데이터플로우 실행" + 플로우 ID: flow_123 + ↓ +3. 사용자가 버튼 클릭 + ↓ +4. 프론트엔드: POST /api/dataflow/execute + Body: { flowId: 123, inputData: {...} } + ↓ +5. 백엔드: 플로우 실행 + - 노드 순회 + - 조건 분기 + - 외부 API 호출 + - 데이터 변환 + ↓ +6. 결과 반환 + ↓ +7. 프론트엔드: 결과 표시 (toast 또는 화면 갱신) +``` + +--- + +## 13. 요약 & 핵심 포인트 + +### 13.1 아키텍처 핵심 + +1. **Next.js 14 App Router** - 라우트 그룹 기반 구조화 +2. **컴포넌트 레지스트리** - 동적 등록/렌더링 시스템 +3. **V2 통합 시스템** - 9개 통합 컴포넌트로 단순화 +4. **화면 디자이너** - 노코드 화면 생성 도구 +5. **API 클라이언트** - Axios 기반 통일된 API 호출 +6. **다국어 지원** - DB 기반 동적 다국어 +7. **JWT 인증** - 토큰 기반 인증/세션 관리 + +### 13.2 파일 통계 + +| 항목 | 개수 | +|------|------| +| 총 파일 수 | 1,480개 | +| TypeScript/TSX | 1,395개 (946 tsx + 449 ts) | +| Markdown 문서 | 63개 | +| CSS | 22개 | +| 페이지 (라우트) | 76개 | +| 컴포넌트 | 500개+ | +| API 클라이언트 | 57개 | +| Context | 12개 | +| Custom Hooks | 32개 | +| 타입 정의 | 44개 | + +### 13.3 주요 경로 + +``` +화면 디자이너: /admin/screenMng/screenMngList +화면 뷰어: /screens/[screenId] +메뉴 관리: /admin/menu +사용자 관리: /admin/userMng/userMngList +테이블 관리: /admin/systemMng/tableMngList +다국어 관리: /admin/systemMng/i18nList +데이터플로우: /admin/systemMng/dataflow +대시보드: /dashboard/[dashboardId] +``` + +### 13.4 기술 스택 + +| 분류 | 기술 | +|------|------| +| 프레임워크 | Next.js 14 | +| UI 라이브러리 | React 18 | +| 언어 | TypeScript (strict mode) | +| 스타일링 | Tailwind CSS + shadcn/ui | +| 상태 관리 | React Query + Zustand + Context API | +| HTTP 클라이언트 | Axios | +| 폼 검증 | Zod | +| 날짜 처리 | date-fns | +| 아이콘 | lucide-react | +| 알림 | sonner | + +--- + +## 마무리 + +이 문서는 WACE ERP 프론트엔드의 전체 아키텍처를 분석한 결과입니다. + +**핵심 인사이트:** +- ✅ 높은 수준의 모듈화 (레지스트리 시스템) +- ✅ 노코드 화면 디자이너 (관리자가 직접 화면 생성) +- ✅ V2 통합 컴포넌트 시스템 (개발 효율성 향상) +- ✅ 동적 다국어 지원 (DB 기반) +- ✅ 완전한 TypeScript 타입 안정성 + +**개선 기회:** +- 일부 레거시 컴포넌트의 V2 마이그레이션 +- 테스트 코드 추가 (현재 거의 없음) +- 성능 최적화 (코드 스플리팅, 레이지 로딩) + +--- + +**작성자 노트** + +야... 진짜 엄청난 프로젝트네. 파일이 1,500개가 넘고 컴포넌트만 500개야. 특히 화면 디자이너가 7,000줄이 넘는 거 보고 놀랐어. 이 정도 규모면 엔터프라이즈급 ERP 시스템이라고 봐도 되겠어. + +가장 인상적이었던 건 **레지스트리 시스템**이랑 **V2 통합 아키텍처**야. 컴포넌트를 동적으로 등록하고 렌더링하는 구조가 꽤 잘 설계되어 있어. 다만 레거시 코드가 아직 많이 남아있어서 V2로 완전히 전환하면 코드 베이스가 훨씬 깔끔해질 것 같아. + +어쨌든 분석하느라 꽤 걸렸는데, 이 문서가 전체 워크플로우 문서 작성하는 데 도움이 되면 좋겠어! 🎉 diff --git a/frontend/app/globals.css b/frontend/app/globals.css index b8e7a178..7276f5b0 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -289,6 +289,20 @@ select { } } +/* ===== Sonner 토스트 애니메이션 완전 제거 ===== */ +[data-sonner-toaster] [data-sonner-toast] { + animation: none !important; + transition: none !important; + opacity: 1 !important; + transform: none !important; +} +[data-sonner-toaster] [data-sonner-toast][data-mounted="true"] { + animation: none !important; +} +[data-sonner-toaster] [data-sonner-toast][data-removed="true"] { + animation: none !important; +} + /* ===== Print Styles ===== */ @media print { * { diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 49fb3355..0add43d6 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -1,7 +1,17 @@ "use client"; -import React, { useState, useEffect, useRef } from "react"; +import React, { useState, useEffect, useRef, useCallback } from "react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogContent, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogCancel, + AlertDialogAction, +} from "@/components/ui/alert-dialog"; import { Checkbox } from "@/components/ui/checkbox"; import { Label } from "@/components/ui/label"; import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic"; @@ -67,6 +77,9 @@ export const ScreenModal: React.FC = ({ className }) => { // 화면 리셋 키 (컴포넌트 강제 리마운트용) const [resetKey, setResetKey] = useState(0); + // 모달 닫기 확인 다이얼로그 표시 상태 + const [showCloseConfirm, setShowCloseConfirm] = useState(false); + // localStorage에서 연속 모드 상태 복원 useEffect(() => { const savedMode = localStorage.getItem("screenModal_continuousMode"); @@ -218,10 +231,33 @@ export const ScreenModal: React.FC = ({ className }) => { const parentDataMapping = splitPanelContext?.parentDataMapping || []; // 부모 데이터 소스 - const rawParentData = - splitPanelParentData && Object.keys(splitPanelParentData).length > 0 - ? splitPanelParentData - : splitPanelContext?.selectedLeftData || {}; + // 🔧 수정: 여러 소스를 병합 (우선순위: splitPanelParentData > selectedLeftData > 기존 formData의 링크 필드) + // 예: screen 150→226→227 전환 시: + // - splitPanelParentData: item_info 데이터 (screen 226에서 전달) + // - selectedLeftData: customer_mng 데이터 (SplitPanel 좌측 선택) + // - 기존 formData: 이전 모달에서 설정된 link 필드 (customer_code 등) + const contextData = splitPanelContext?.selectedLeftData || {}; + const eventData = splitPanelParentData && Object.keys(splitPanelParentData).length > 0 + ? splitPanelParentData + : {}; + + // 🆕 기존 formData에서 link 필드(_code, _id)를 가져와 base로 사용 + // 모달 체인(226→227)에서 이전 모달의 연결 필드가 유지됨 + const previousLinkFields: Record = {}; + if (formData && typeof formData === "object" && !Array.isArray(formData)) { + const linkFieldPatterns = ["_code", "_id"]; + const excludeFields = ["id", "created_date", "updated_date", "created_at", "updated_at", "writer"]; + for (const [key, value] of Object.entries(formData)) { + if (excludeFields.includes(key)) continue; + if (value === undefined || value === null) continue; + const isLinkField = linkFieldPatterns.some((pattern) => key.endsWith(pattern)); + if (isLinkField) { + previousLinkFields[key] = value; + } + } + } + + const rawParentData = { ...previousLinkFields, ...contextData, ...eventData }; // 🔧 신규 등록 모드에서는 연결에 필요한 필드만 전달 const parentData: Record = {}; @@ -495,14 +531,31 @@ export const ScreenModal: React.FC = ({ className }) => { } }; - const handleClose = () => { - // 🔧 URL 파라미터 제거 (mode, editId, tableName 등) + // 사용자가 바깥 클릭/ESC/X 버튼으로 닫으려 할 때 확인 다이얼로그 표시 + const handleCloseAttempt = useCallback(() => { + setShowCloseConfirm(true); + }, []); + + // 확인 후 실제로 모달을 닫는 함수 + const handleConfirmClose = useCallback(() => { + setShowCloseConfirm(false); + handleCloseInternal(); + }, []); + + // 닫기 취소 (계속 작업) + const handleCancelClose = useCallback(() => { + setShowCloseConfirm(false); + }, []); + + const handleCloseInternal = () => { + // 🔧 URL 파라미터 제거 (mode, editId, tableName, groupByColumns, dataSourceId 등) if (typeof window !== "undefined") { const currentUrl = new URL(window.location.href); currentUrl.searchParams.delete("mode"); currentUrl.searchParams.delete("editId"); currentUrl.searchParams.delete("tableName"); currentUrl.searchParams.delete("groupByColumns"); + currentUrl.searchParams.delete("dataSourceId"); window.history.pushState({}, "", currentUrl.toString()); } @@ -514,8 +567,15 @@ export const ScreenModal: React.FC = ({ className }) => { }); setScreenData(null); setFormData({}); // 폼 데이터 초기화 + setOriginalData(null); // 원본 데이터 초기화 + setSelectedData([]); // 선택된 데이터 초기화 + setContinuousMode(false); + localStorage.setItem("screenModal_continuousMode", "false"); }; + // 기존 handleClose를 유지 (이벤트 핸들러 등에서 사용) + const handleClose = handleCloseInternal; + // 모달 크기 설정 - 화면관리 설정 크기 + 헤더/푸터 const getModalStyle = () => { if (!screenDimensions) { @@ -615,10 +675,28 @@ export const ScreenModal: React.FC = ({ className }) => { ]); return ( - + { + // X 버튼 클릭 시에도 확인 다이얼로그 표시 + if (!open) { + handleCloseAttempt(); + } + }} + > { + e.preventDefault(); + handleCloseAttempt(); + }} + // ESC 키 누를 때도 바로 닫히지 않도록 방지 + onEscapeKeyDown={(e) => { + e.preventDefault(); + handleCloseAttempt(); + }} >
@@ -838,6 +916,36 @@ export const ScreenModal: React.FC = ({ className }) => {
+ + {/* 모달 닫기 확인 다이얼로그 */} + + + + + 화면을 닫으시겠습니까? + + + 지금 나가시면 진행 중인 데이터가 저장되지 않습니다. +
+ 계속 작업하시려면 '계속 작업' 버튼을 눌러주세요. +
+
+ + + 계속 작업 + + + 나가기 + + +
+
); }; diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index e3e8d920..de2c5b61 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -449,6 +449,15 @@ function AppLayoutInner({ children }: AppLayoutProps) { ); }; + // 프리뷰 모드: 사이드바/헤더 없이 화면만 표시 (인증 대기 없이 즉시 렌더링) + if (isPreviewMode) { + return ( +
+ {children} +
+ ); + } + // 사용자 정보가 없으면 로딩 표시 if (!user) { return ( @@ -461,15 +470,6 @@ function AppLayoutInner({ children }: AppLayoutProps) { ); } - // 프리뷰 모드: 사이드바/헤더 없이 화면만 표시 - if (isPreviewMode) { - return ( -
- {children} -
- ); - } - // UI 변환된 메뉴 데이터 const uiMenus = convertMenuToUI(currentMenus, user as ExtendedUserInfo); diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 78f4d1d0..5f62ade4 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -1,7484 +1 @@ -"use client"; - -import { useState, useCallback, useEffect, useMemo, useRef } from "react"; -import { Database, Cog } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Button } from "@/components/ui/button"; -import { - ScreenDefinition, - ComponentData, - LayoutData, - GroupState, - TableInfo, - Position, - ColumnInfo, - GridSettings, - ScreenResolution, - SCREEN_RESOLUTIONS, -} from "@/types/screen"; -import { generateComponentId } from "@/lib/utils/generateId"; -import { - getComponentIdFromWebType, - createV2ConfigFromColumn, - getV2ConfigFromWebType, -} from "@/lib/utils/webTypeMapping"; -import { - createGroupComponent, - calculateBoundingBox, - calculateRelativePositions, - restoreAbsolutePositions, -} from "@/lib/utils/groupingUtils"; -import { - adjustGridColumnsFromSize, - updateSizeFromGridColumns, - calculateWidthFromColumns, - snapSizeToGrid, - snapToGrid, -} from "@/lib/utils/gridUtils"; -import { - alignComponents, - distributeComponents, - matchComponentSize, - toggleAllLabels, - nudgeComponents, - AlignMode, - DistributeDirection, - MatchSizeMode, -} from "@/lib/utils/alignmentUtils"; -import { KeyboardShortcutsModal } from "./modals/KeyboardShortcutsModal"; - -// 10px 단위 스냅 함수 -const snapTo10px = (value: number): number => { - return Math.round(value / 10) * 10; -}; - -const snapPositionTo10px = (position: Position): Position => { - return { - x: snapTo10px(position.x), - y: snapTo10px(position.y), - z: position.z, - }; -}; - -const snapSizeTo10px = (size: { width: number; height: number }): { width: number; height: number } => { - return { - width: snapTo10px(size.width), - height: snapTo10px(size.height), - }; -}; - -// calculateGridInfo 더미 함수 (하위 호환성을 위해 유지) -const calculateGridInfo = (width: number, height: number, settings: any) => { - return { - columnWidth: 10, - totalWidth: width, - totalHeight: height, - columns: settings.columns || 12, - gap: settings.gap || 0, - padding: settings.padding || 0, - }; -}; -import { GroupingToolbar } from "./GroupingToolbar"; -import { screenApi, tableTypeApi } from "@/lib/api/screen"; -import { tableManagementApi } from "@/lib/api/tableManagement"; -import { ExternalRestApiConnectionAPI } from "@/lib/api/externalRestApiConnection"; -import { toast } from "sonner"; -import { MenuAssignmentModal } from "./MenuAssignmentModal"; -import { FileAttachmentDetailModal } from "./FileAttachmentDetailModal"; -import { initializeComponents } from "@/lib/registry/components"; -import { ScreenFileAPI } from "@/lib/api/screenFile"; -import { safeMigrateLayout, needsMigration } from "@/lib/utils/widthToColumnSpan"; -import { convertV2ToLegacy, convertLegacyToV2, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; - -// V2 API 사용 플래그 (true: V2, false: 기존) -const USE_V2_API = true; - -import StyleEditor from "./StyleEditor"; -import { RealtimePreview } from "./RealtimePreviewDynamic"; -import FloatingPanel from "./FloatingPanel"; -import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; -import { MultilangSettingsModal } from "./modals/MultilangSettingsModal"; -import DesignerToolbar from "./DesignerToolbar"; -import TablesPanel from "./panels/TablesPanel"; -import { TemplatesPanel, TemplateComponent } from "./panels/TemplatesPanel"; -import { ComponentsPanel } from "./panels/ComponentsPanel"; -import PropertiesPanel from "./panels/PropertiesPanel"; -import DetailSettingsPanel from "./panels/DetailSettingsPanel"; -import ResolutionPanel from "./panels/ResolutionPanel"; -import { usePanelState, PanelConfig } from "@/hooks/usePanelState"; -import { FlowButtonGroup } from "./widgets/FlowButtonGroup"; -import { FlowVisibilityConfig } from "@/types/control-management"; -import { - areAllButtons, - generateGroupId, - groupButtons, - ungroupButtons, - findAllButtonGroups, -} from "@/lib/utils/flowButtonGroupUtils"; -import { FlowButtonGroupDialog } from "./dialogs/FlowButtonGroupDialog"; -import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext"; -import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; - -// 새로운 통합 UI 컴포넌트 -import { SlimToolbar } from "./toolbar/SlimToolbar"; -import { V2PropertiesPanel } from "./panels/V2PropertiesPanel"; - -// 컴포넌트 초기화 (새 시스템) -import "@/lib/registry/components"; -// 성능 최적화 도구 초기화 (필요시 사용) -import "@/lib/registry/utils/performanceOptimizer"; - -interface ScreenDesignerProps { - selectedScreen: ScreenDefinition | null; - onBackToList: () => void; - onScreenUpdate?: (updatedScreen: Partial) => void; - // POP 모드 지원 - isPop?: boolean; - defaultDevicePreview?: "mobile" | "tablet"; -} - -import { useLayerOptional, LayerProvider, createDefaultLayer } from "@/contexts/LayerContext"; -import { LayerManagerPanel } from "./LayerManagerPanel"; -import { LayerType, LayerDefinition } from "@/types/screen-management"; - -// 패널 설정 업데이트 -const panelConfigs: PanelConfig[] = [ - { - id: "v2", - title: "패널", - defaultPosition: "left", - defaultWidth: 240, - defaultHeight: 700, - shortcutKey: "p", - }, - { - id: "layer", - title: "레이어", - defaultPosition: "right", - defaultWidth: 240, - defaultHeight: 500, - shortcutKey: "l", - }, -]; - -export default function ScreenDesigner({ - selectedScreen, - onBackToList, - onScreenUpdate, - isPop = false, - defaultDevicePreview = "tablet" -}: ScreenDesignerProps) { - // POP 모드 여부에 따른 API 분기 - const USE_POP_API = isPop; - const [layout, setLayout] = useState({ - components: [], - gridSettings: { - columns: 12, - gap: 16, - padding: 0, - snapToGrid: true, - showGrid: false, // 기본값 false로 변경 - gridColor: "#d1d5db", - gridOpacity: 0.5, - }, - }); - const [isSaving, setIsSaving] = useState(false); - const [isGeneratingMultilang, setIsGeneratingMultilang] = useState(false); - const [showMultilangSettingsModal, setShowMultilangSettingsModal] = useState(false); - - // 🆕 화면에 할당된 메뉴 OBJID - const [menuObjid, setMenuObjid] = useState(undefined); - - // 메뉴 할당 모달 상태 - const [showMenuAssignmentModal, setShowMenuAssignmentModal] = useState(false); - - // 단축키 도움말 모달 상태 - const [showShortcutsModal, setShowShortcutsModal] = useState(false); - - // 파일첨부 상세 모달 상태 - const [showFileAttachmentModal, setShowFileAttachmentModal] = useState(false); - const [selectedFileComponent, setSelectedFileComponent] = useState(null); - - // 해상도 설정 상태 - const [screenResolution, setScreenResolution] = useState( - SCREEN_RESOLUTIONS[0], // 기본값: Full HD - ); - - // 🆕 패널 상태 관리 (usePanelState 훅) - const { panelStates, togglePanel, openPanel, closePanel, closeAllPanels, updatePanelPosition, updatePanelSize } = - usePanelState(panelConfigs); - - const [selectedComponent, setSelectedComponent] = useState(null); - - // 🆕 탭 내부 컴포넌트 선택 상태 (중첩 구조 지원) - const [selectedTabComponentInfo, setSelectedTabComponentInfo] = useState<{ - tabsComponentId: string; // 탭 컴포넌트 ID - tabId: string; // 탭 ID - componentId: string; // 탭 내부 컴포넌트 ID - component: any; // 탭 내부 컴포넌트 데이터 - // 🆕 중첩 구조용: 부모 분할 패널 정보 - parentSplitPanelId?: string | null; - parentPanelSide?: "left" | "right" | null; - } | null>(null); - - // 🆕 분할 패널 내부 컴포넌트 선택 상태 - const [selectedPanelComponentInfo, setSelectedPanelComponentInfo] = useState<{ - splitPanelId: string; // 분할 패널 컴포넌트 ID - panelSide: "left" | "right"; // 좌측/우측 패널 - componentId: string; // 패널 내부 컴포넌트 ID - component: any; // 패널 내부 컴포넌트 데이터 - } | null>(null); - - // 컴포넌트 선택 시 통합 패널 자동 열기 - const handleComponentSelect = useCallback( - (component: ComponentData | null) => { - setSelectedComponent(component); - // 일반 컴포넌트 선택 시 탭 내부 컴포넌트/분할 패널 내부 컴포넌트 선택 해제 - if (component) { - setSelectedTabComponentInfo(null); - setSelectedPanelComponentInfo(null); - } - - // 컴포넌트가 선택되면 통합 패널 자동 열기 - if (component) { - openPanel("v2"); - } - }, - [openPanel], - ); - - // 🆕 탭 내부 컴포넌트 선택 핸들러 (중첩 구조 지원) - const handleSelectTabComponent = useCallback( - ( - tabsComponentId: string, - tabId: string, - compId: string, - comp: any, - // 🆕 중첩 구조용: 부모 분할 패널 정보 (선택적) - parentSplitPanelId?: string | null, - parentPanelSide?: "left" | "right" | null, - ) => { - if (!compId) { - // 탭 영역 빈 공간 클릭 시 선택 해제 - setSelectedTabComponentInfo(null); - return; - } - - setSelectedTabComponentInfo({ - tabsComponentId, - tabId, - componentId: compId, - component: comp, - parentSplitPanelId: parentSplitPanelId || null, - parentPanelSide: parentPanelSide || null, - }); - // 탭 내부 컴포넌트 선택 시 일반 컴포넌트/분할 패널 내부 컴포넌트 선택 해제 - setSelectedComponent(null); - setSelectedPanelComponentInfo(null); - openPanel("v2"); - }, - [openPanel], - ); - - // 🆕 분할 패널 내부 컴포넌트 선택 핸들러 - const handleSelectPanelComponent = useCallback( - (splitPanelId: string, panelSide: "left" | "right", compId: string, comp: any) => { - // 🐛 디버깅: 전달받은 comp 확인 - console.log("🐛 [handleSelectPanelComponent] comp:", { - compId, - componentType: comp?.componentType, - selectedTable: comp?.componentConfig?.selectedTable, - fieldMapping: comp?.componentConfig?.fieldMapping, - fieldMappingKeys: comp?.componentConfig?.fieldMapping ? Object.keys(comp.componentConfig.fieldMapping) : [], - }); - - if (!compId) { - // 패널 영역 빈 공간 클릭 시 선택 해제 - setSelectedPanelComponentInfo(null); - return; - } - - setSelectedPanelComponentInfo({ - splitPanelId, - panelSide, - componentId: compId, - component: comp, - }); - // 분할 패널 내부 컴포넌트 선택 시 일반 컴포넌트/탭 내부 컴포넌트 선택 해제 - setSelectedComponent(null); - setSelectedTabComponentInfo(null); - openPanel("v2"); - }, - [openPanel], - ); - - // 🆕 중첩된 탭 컴포넌트 선택 이벤트 리스너 (분할 패널 안의 탭 안의 컴포넌트) - useEffect(() => { - const handleNestedTabComponentSelect = (event: CustomEvent) => { - const { tabsComponentId, tabId, componentId, component, parentSplitPanelId, parentPanelSide } = event.detail; - - if (!componentId) { - setSelectedTabComponentInfo(null); - return; - } - - console.log("🎯 중첩된 탭 컴포넌트 선택:", event.detail); - - setSelectedTabComponentInfo({ - tabsComponentId, - tabId, - componentId, - component, - parentSplitPanelId, - parentPanelSide, - }); - setSelectedComponent(null); - setSelectedPanelComponentInfo(null); - openPanel("v2"); - }; - - window.addEventListener("nested-tab-component-select", handleNestedTabComponentSelect as EventListener); - - return () => { - window.removeEventListener("nested-tab-component-select", handleNestedTabComponentSelect as EventListener); - }; - }, [openPanel]); - - // 클립보드 상태 - const [clipboard, setClipboard] = useState([]); - - // 실행취소/다시실행을 위한 히스토리 상태 - const [history, setHistory] = useState([]); - const [historyIndex, setHistoryIndex] = useState(-1); - - // 그룹 상태 - const [groupState, setGroupState] = useState({ - selectedComponents: [], - isGrouping: false, - }); - - // 드래그 상태 - const [dragState, setDragState] = useState({ - isDragging: false, - draggedComponent: null as ComponentData | null, - draggedComponents: [] as ComponentData[], // 다중 드래그를 위한 컴포넌트 배열 - originalPosition: { x: 0, y: 0, z: 1 }, - currentPosition: { x: 0, y: 0, z: 1 }, - grabOffset: { x: 0, y: 0 }, - justFinishedDrag: false, // 드래그 종료 직후 클릭 방지용 - }); - - // Pan 모드 상태 (스페이스바 + 드래그) - const [isPanMode, setIsPanMode] = useState(false); - const [panState, setPanState] = useState({ - isPanning: false, - startX: 0, - startY: 0, - outerScrollLeft: 0, - outerScrollTop: 0, - innerScrollLeft: 0, - innerScrollTop: 0, - }); - const canvasContainerRef = useRef(null); - - // Zoom 상태 - const [zoomLevel, setZoomLevel] = useState(1); // 1 = 100% - const MIN_ZOOM = 0.1; // 10% - const MAX_ZOOM = 3; // 300% - const zoomRafRef = useRef(null); // 줌 RAF throttle용 - - // 전역 파일 상태 변경 시 강제 리렌더링을 위한 상태 - const [forceRenderTrigger, setForceRenderTrigger] = useState(0); - - // 파일 컴포넌트 데이터 복원 함수 (실제 DB에서 조회) - const restoreFileComponentsData = useCallback( - async (components: ComponentData[]) => { - if (!selectedScreen?.screenId) return; - - // console.log("🔄 파일 컴포넌트 데이터 복원 시작:", components.length); - - try { - // 실제 DB에서 화면의 모든 파일 정보 조회 - const fileResponse = await ScreenFileAPI.getScreenComponentFiles(selectedScreen.screenId); - - if (!fileResponse.success) { - // console.warn("⚠️ 파일 정보 조회 실패:", fileResponse); - return; - } - - const { componentFiles } = fileResponse; - - if (typeof window !== "undefined") { - // 전역 파일 상태 초기화 - const globalFileState: { [key: string]: any[] } = {}; - let restoredCount = 0; - - // DB에서 조회한 파일 정보를 전역 상태로 복원 - Object.keys(componentFiles).forEach((componentId) => { - const files = componentFiles[componentId]; - if (files && files.length > 0) { - globalFileState[componentId] = files; - restoredCount++; - - // localStorage에도 백업 - const backupKey = `fileComponent_${componentId}_files`; - localStorage.setItem(backupKey, JSON.stringify(files)); - - console.log("📁 DB에서 파일 컴포넌트 데이터 복원:", { - componentId: componentId, - fileCount: files.length, - files: files.map((f) => ({ objid: f.objid, name: f.realFileName })), - }); - } - }); - - // 전역 상태 업데이트 - (window as any).globalFileState = globalFileState; - - // 모든 파일 컴포넌트에 복원 완료 이벤트 발생 - Object.keys(globalFileState).forEach((componentId) => { - const files = globalFileState[componentId]; - const syncEvent = new CustomEvent("globalFileStateChanged", { - detail: { - componentId: componentId, - files: files, - fileCount: files.length, - timestamp: Date.now(), - isRestore: true, - }, - }); - window.dispatchEvent(syncEvent); - }); - - if (restoredCount > 0) { - toast.success( - `${restoredCount}개 파일 컴포넌트 데이터가 DB에서 복원되었습니다. (총 ${fileResponse.totalFiles}개 파일)`, - ); - } - } - } catch (error) { - // console.error("❌ 파일 컴포넌트 데이터 복원 실패:", error); - toast.error("파일 데이터 복원 중 오류가 발생했습니다."); - } - }, - [selectedScreen?.screenId], - ); - - // 드래그 선택 상태 - const [selectionDrag, setSelectionDrag] = useState({ - isSelecting: false, - startPoint: { x: 0, y: 0, z: 1 }, - currentPoint: { x: 0, y: 0, z: 1 }, - wasSelecting: false, // 방금 전에 드래그 선택이 진행 중이었는지 추적 - }); - - // 테이블 데이터 - const [tables, setTables] = useState([]); - const [searchTerm, setSearchTerm] = useState(""); - - // 🆕 검색어로 필터링된 테이블 목록 - const filteredTables = useMemo(() => { - if (!searchTerm.trim()) return tables; - const term = searchTerm.toLowerCase(); - return tables.filter( - (table) => - table.tableName.toLowerCase().includes(term) || - table.columns?.some((col) => col.columnName.toLowerCase().includes(term)), - ); - }, [tables, searchTerm]); - - // 그룹 생성 다이얼로그 - const [showGroupCreateDialog, setShowGroupCreateDialog] = useState(false); - - const canvasRef = useRef(null); - - // 10px 격자 라인 생성 (시각적 가이드용) - const gridLines = useMemo(() => { - if (!layout.gridSettings?.showGrid) return []; - - const width = screenResolution.width; - const height = screenResolution.height; - const lines: Array<{ type: "vertical" | "horizontal"; position: number }> = []; - - // 10px 단위로 격자 라인 생성 - for (let x = 0; x <= width; x += 10) { - lines.push({ type: "vertical", position: x }); - } - for (let y = 0; y <= height; y += 10) { - lines.push({ type: "horizontal", position: y }); - } - - return lines; - }, [layout.gridSettings?.showGrid, screenResolution.width, screenResolution.height]); - - // 🆕 현재 편집 중인 레이어 ID (DB의 layer_id, 1 = 기본 레이어) - const [activeLayerId, setActiveLayerIdLocal] = useState(1); - const activeLayerIdRef = useRef(1); - const setActiveLayerIdWithRef = useCallback((id: number) => { - setActiveLayerIdLocal(id); - activeLayerIdRef.current = id; - }, []); - - // 🆕 좌측 패널 탭 상태 관리 - const [leftPanelTab, setLeftPanelTab] = useState("components"); - - // 🆕 레이어 영역 (기본 레이어에서 조건부 레이어들의 displayRegion 표시) - const [layerRegions, setLayerRegions] = useState>({}); - - // 🆕 조건부 영역 드래그 상태 (캔버스에서 드래그로 영역 설정) - const [regionDrag, setRegionDrag] = useState<{ - isDrawing: boolean; // 새 영역 그리기 모드 - isDragging: boolean; // 기존 영역 이동 모드 - isResizing: boolean; // 기존 영역 리사이즈 모드 - targetLayerId: string | null; // 대상 레이어 ID - startX: number; - startY: number; - currentX: number; - currentY: number; - resizeHandle: string | null; // 리사이즈 핸들 위치 - originalRegion: { x: number; y: number; width: number; height: number } | null; - }>({ - isDrawing: false, - isDragging: false, - isResizing: false, - targetLayerId: null, - startX: 0, - startY: 0, - currentX: 0, - currentY: 0, - resizeHandle: null, - originalRegion: null, - }); - - // 캔버스에 렌더링할 컴포넌트 (DB 기반 레이어: 각 레이어별로 별도 로드되므로 전체 표시) - const visibleComponents = useMemo(() => { - return layout.components; - }, [layout.components]); - - // 이미 배치된 컬럼 목록 계산 - const placedColumns = useMemo(() => { - const placed = new Set(); - // 🔧 화면의 메인 테이블명을 fallback으로 사용 - const screenTableName = selectedScreen?.tableName; - - const collectColumns = (components: ComponentData[]) => { - components.forEach((comp) => { - const anyComp = comp as any; - - // 🔧 tableName과 columnName을 여러 위치에서 찾기 (최상위, componentConfig, 또는 화면 테이블명) - const tableName = anyComp.tableName || anyComp.componentConfig?.tableName || screenTableName; - const columnName = anyComp.columnName || anyComp.componentConfig?.columnName; - - // widget 타입 또는 component 타입에서 columnName 확인 (tableName은 화면 테이블명으로 fallback) - if ((comp.type === "widget" || comp.type === "component") && tableName && columnName) { - const key = `${tableName}.${columnName}`; - placed.add(key); - } - - // 자식 컴포넌트도 확인 (재귀) - if (comp.children && comp.children.length > 0) { - collectColumns(comp.children); - } - }); - }; - - collectColumns(layout.components); - return placed; - }, [layout.components, selectedScreen?.tableName]); - - // 히스토리에 저장 - const saveToHistory = useCallback( - (newLayout: LayoutData) => { - setHistory((prev) => { - const newHistory = prev.slice(0, historyIndex + 1); - newHistory.push(newLayout); - return newHistory.slice(-50); // 최대 50개까지만 저장 - }); - setHistoryIndex((prev) => Math.min(prev + 1, 49)); - }, - [historyIndex], - ); - - // 🆕 탭 내부 컴포넌트 설정 업데이트 핸들러 (중첩 구조 지원) - const handleUpdateTabComponentConfig = useCallback( - (path: string, value: any) => { - if (!selectedTabComponentInfo) return; - - const { tabsComponentId, tabId, componentId, parentSplitPanelId, parentPanelSide } = selectedTabComponentInfo; - - // 탭 컴포넌트 업데이트 함수 (재사용) - const updateTabsComponent = (tabsComponent: any) => { - const currentConfig = tabsComponent.componentConfig || {}; - const tabs = currentConfig.tabs || []; - - const updatedTabs = tabs.map((tab: any) => { - if (tab.id === tabId) { - return { - ...tab, - components: (tab.components || []).map((comp: any) => { - if (comp.id === componentId) { - if (path.startsWith("componentConfig.")) { - const configPath = path.replace("componentConfig.", ""); - return { - ...comp, - componentConfig: { ...comp.componentConfig, [configPath]: value }, - }; - } else if (path.startsWith("style.")) { - const stylePath = path.replace("style.", ""); - return { ...comp, style: { ...comp.style, [stylePath]: value } }; - } else if (path.startsWith("size.")) { - const sizePath = path.replace("size.", ""); - return { ...comp, size: { ...comp.size, [sizePath]: value } }; - } else { - return { ...comp, [path]: value }; - } - } - return comp; - }), - }; - } - return tab; - }); - - return { ...tabsComponent, componentConfig: { ...currentConfig, tabs: updatedTabs } }; - }; - - setLayout((prevLayout) => { - let newLayout; - let updatedTabs; - - if (parentSplitPanelId && parentPanelSide) { - // 🆕 중첩 구조: 분할 패널 안의 탭 업데이트 - newLayout = { - ...prevLayout, - components: prevLayout.components.map((c) => { - if (c.id === parentSplitPanelId) { - const splitConfig = (c as any).componentConfig || {}; - const panelKey = parentPanelSide === "left" ? "leftPanel" : "rightPanel"; - const panelConfig = splitConfig[panelKey] || {}; - const panelComponents = panelConfig.components || []; - - const tabsComponent = panelComponents.find((pc: any) => pc.id === tabsComponentId); - if (!tabsComponent) return c; - - const updatedTabsComponent = updateTabsComponent(tabsComponent); - updatedTabs = updatedTabsComponent.componentConfig.tabs; - - return { - ...c, - componentConfig: { - ...splitConfig, - [panelKey]: { - ...panelConfig, - components: panelComponents.map((pc: any) => - pc.id === tabsComponentId ? updatedTabsComponent : pc, - ), - }, - }, - }; - } - return c; - }), - }; - } else { - // 일반 구조: 최상위 탭 업데이트 - const tabsComponent = prevLayout.components.find((c) => c.id === tabsComponentId); - if (!tabsComponent) return prevLayout; - - const updatedTabsComponent = updateTabsComponent(tabsComponent); - updatedTabs = updatedTabsComponent.componentConfig.tabs; - - newLayout = { - ...prevLayout, - components: prevLayout.components.map((c) => (c.id === tabsComponentId ? updatedTabsComponent : c)), - }; - } - - // 선택된 컴포넌트 정보도 업데이트 - if (updatedTabs) { - const updatedComp = updatedTabs - .find((t: any) => t.id === tabId) - ?.components?.find((c: any) => c.id === componentId); - if (updatedComp) { - setSelectedTabComponentInfo((prev) => (prev ? { ...prev, component: updatedComp } : null)); - } - } - - return newLayout; - }); - }, - [selectedTabComponentInfo], - ); - - // 실행취소 - const undo = useCallback(() => { - setHistoryIndex((prevIndex) => { - if (prevIndex > 0) { - const newIndex = prevIndex - 1; - setHistory((prevHistory) => { - if (prevHistory[newIndex]) { - setLayout(prevHistory[newIndex]); - } - return prevHistory; - }); - return newIndex; - } - return prevIndex; - }); - }, []); - - // 다시실행 - const redo = useCallback(() => { - setHistoryIndex((prevIndex) => { - let newIndex = prevIndex; - setHistory((prevHistory) => { - if (prevIndex < prevHistory.length - 1) { - newIndex = prevIndex + 1; - if (prevHistory[newIndex]) { - setLayout(prevHistory[newIndex]); - } - } - return prevHistory; - }); - return newIndex; - }); - }, []); - - // 컴포넌트 속성 업데이트 - const updateComponentProperty = useCallback( - (componentId: string, path: string, value: any) => { - // 🔥 함수형 업데이트로 변경하여 최신 layout 사용 - setLayout((prevLayout) => { - const targetComponent = prevLayout.components.find((comp) => comp.id === componentId); - const isLayoutComponent = targetComponent?.type === "layout"; - - // 🆕 그룹 설정 변경 시 같은 그룹의 모든 버튼에 일괄 적용 - const isGroupSetting = path === "webTypeConfig.flowVisibilityConfig.groupAlign"; - - let affectedComponents: string[] = [componentId]; // 기본적으로 현재 컴포넌트만 - - if (isGroupSetting && targetComponent) { - const flowConfig = (targetComponent as any).webTypeConfig?.flowVisibilityConfig; - const currentGroupId = flowConfig?.groupId; - - if (currentGroupId) { - // 같은 그룹의 모든 버튼 찾기 - affectedComponents = prevLayout.components - .filter((comp) => { - const compConfig = (comp as any).webTypeConfig?.flowVisibilityConfig; - return compConfig?.groupId === currentGroupId && compConfig?.enabled; - }) - .map((comp) => comp.id); - - console.log("🔄 그룹 설정 일괄 적용:", { - groupId: currentGroupId, - setting: path.split(".").pop(), - value, - affectedButtons: affectedComponents, - }); - } - } - - // 레이아웃 컴포넌트의 위치가 변경되는 경우 존에 속한 컴포넌트들도 함께 이동 - const positionDelta = { x: 0, y: 0 }; - if (isLayoutComponent && (path === "position.x" || path === "position.y" || path === "position")) { - const oldPosition = targetComponent.position; - let newPosition = { ...oldPosition }; - - if (path === "position.x") { - newPosition.x = value; - positionDelta.x = value - oldPosition.x; - } else if (path === "position.y") { - newPosition.y = value; - positionDelta.y = value - oldPosition.y; - } else if (path === "position") { - newPosition = value; - positionDelta.x = value.x - oldPosition.x; - positionDelta.y = value.y - oldPosition.y; - } - - console.log("📐 레이아웃 이동 감지:", { - layoutId: componentId, - oldPosition, - newPosition, - positionDelta, - }); - } - - const pathParts = path.split("."); - const updatedComponents = prevLayout.components.map((comp) => { - // 🆕 그룹 설정이면 같은 그룹의 모든 버튼에 적용 - const shouldUpdate = isGroupSetting ? affectedComponents.includes(comp.id) : comp.id === componentId; - - if (!shouldUpdate) { - // 레이아웃 이동 시 존에 속한 컴포넌트들도 함께 이동 - if (isLayoutComponent && (positionDelta.x !== 0 || positionDelta.y !== 0)) { - // 이 레이아웃의 존에 속한 컴포넌트인지 확인 - const isInLayoutZone = comp.parentId === componentId && comp.zoneId; - if (isInLayoutZone) { - console.log("🔄 존 컴포넌트 함께 이동:", { - componentId: comp.id, - zoneId: comp.zoneId, - oldPosition: comp.position, - delta: positionDelta, - }); - - return { - ...comp, - position: { - ...comp.position, - x: comp.position.x + positionDelta.x, - y: comp.position.y + positionDelta.y, - }, - }; - } - } - return comp; - } - - // 중첩 경로를 고려한 안전한 복사 - const newComp = { ...comp }; - - // 경로를 따라 내려가면서 각 레벨을 새 객체로 복사 - let current: any = newComp; - for (let i = 0; i < pathParts.length - 1; i++) { - const key = pathParts[i]; - - // 다음 레벨이 없거나 객체가 아니면 새 객체 생성 - if (!current[key] || typeof current[key] !== "object" || Array.isArray(current[key])) { - current[key] = {}; - } else { - // 기존 객체를 복사하여 불변성 유지 - current[key] = { ...current[key] }; - } - current = current[key]; - } - - // 최종 값 설정 - const finalKey = pathParts[pathParts.length - 1]; - current[finalKey] = value; - - // 🔧 style 관련 업데이트 디버그 로그 - if (path.includes("style") || path.includes("labelDisplay")) { - console.log("🎨 style 업데이트 제대로 렌더링된거다 내가바꿈:", { - componentId: comp.id, - path, - value, - updatedStyle: newComp.style, - pathIncludesLabelDisplay: path.includes("labelDisplay"), - }); - } - - // 🆕 labelDisplay 변경 시 강제 리렌더링 트리거 (조건문 밖으로 이동) - if (path === "style.labelDisplay") { - console.log("⏰⏰⏰ labelDisplay 변경 감지! forceRenderTrigger 실행 예정"); - } - - // 🆕 size 변경 시 style도 함께 업데이트 (파란 테두리와 실제 크기 동기화) - if (path === "size.width" || path === "size.height" || path === "size") { - // 🔧 style 객체를 새로 복사하여 불변성 유지 - newComp.style = { ...(newComp.style || {}) }; - - if (path === "size.width") { - newComp.style.width = `${value}px`; - } else if (path === "size.height") { - newComp.style.height = `${value}px`; - } else if (path === "size") { - // size 객체 전체가 변경된 경우 - if (value.width !== undefined) { - newComp.style.width = `${value.width}px`; - } - if (value.height !== undefined) { - newComp.style.height = `${value.height}px`; - } - } - - console.log("🔄 size 변경 → style 동기화:", { - componentId: newComp.id, - path, - value, - updatedStyle: newComp.style, - }); - } - - // gridColumns 변경 시 크기 자동 업데이트 제거 (격자 시스템 제거됨) - // if (path === "gridColumns" && prevLayout.gridSettings) { - // const updatedSize = updateSizeFromGridColumns(newComp, prevLayout.gridSettings as GridUtilSettings); - // newComp.size = updatedSize; - // } - - // 크기 변경 시 격자 스냅 적용 제거 (직접 입력 시 불필요) - // 드래그/리사이즈 시에는 별도 로직에서 처리됨 - // if ( - // (path === "size.width" || path === "size.height") && - // prevLayout.gridSettings?.snapToGrid && - // newComp.type !== "group" - // ) { - // const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, { - // columns: prevLayout.gridSettings.columns, - // gap: prevLayout.gridSettings.gap, - // padding: prevLayout.gridSettings.padding, - // snapToGrid: prevLayout.gridSettings.snapToGrid || false, - // }); - // const snappedSize = snapSizeToGrid( - // newComp.size, - // currentGridInfo, - // prevLayout.gridSettings as GridUtilSettings, - // ); - // newComp.size = snappedSize; - // - // const adjustedColumns = adjustGridColumnsFromSize( - // newComp, - // currentGridInfo, - // prevLayout.gridSettings as GridUtilSettings, - // ); - // if (newComp.gridColumns !== adjustedColumns) { - // newComp.gridColumns = adjustedColumns; - // } - // } - - // gridColumns 변경 시 크기를 격자에 맞게 자동 조정 제거 (격자 시스템 제거됨) - // if (path === "gridColumns" && prevLayout.gridSettings?.snapToGrid && newComp.type !== "group") { - // const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, { - // columns: prevLayout.gridSettings.columns, - // gap: prevLayout.gridSettings.gap, - // padding: prevLayout.gridSettings.padding, - // snapToGrid: prevLayout.gridSettings.snapToGrid || false, - // }); - // - // const newWidth = calculateWidthFromColumns( - // newComp.gridColumns, - // currentGridInfo, - // prevLayout.gridSettings as GridUtilSettings, - // ); - // newComp.size = { - // ...newComp.size, - // width: newWidth, - // }; - // } - - // 위치 변경 시 격자 스냅 적용 (그룹 내부 컴포넌트 포함) - if ( - (path === "position.x" || path === "position.y" || path === "position") && - layout.gridSettings?.snapToGrid - ) { - // 현재 해상도에 맞는 격자 정보 계산 - const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, { - columns: layout.gridSettings.columns, - gap: layout.gridSettings.gap, - padding: layout.gridSettings.padding, - snapToGrid: layout.gridSettings.snapToGrid || false, - }); - - // 그룹 내부 컴포넌트인 경우 패딩을 고려한 격자 스냅 적용 - if (newComp.parentId && currentGridInfo) { - const { columnWidth } = currentGridInfo; - const { gap } = layout.gridSettings; - - // 그룹 내부 패딩 고려한 격자 정렬 - const padding = 16; - const effectiveX = newComp.position.x - padding; - const columnIndex = Math.round(effectiveX / (columnWidth + (gap || 16))); - const snappedX = padding + columnIndex * (columnWidth + (gap || 16)); - - // Y 좌표는 10px 단위로 스냅 - const effectiveY = newComp.position.y - padding; - const rowIndex = Math.round(effectiveY / 10); - const snappedY = padding + rowIndex * 10; - - // 크기도 외부 격자와 동일하게 스냅 - const fullColumnWidth = columnWidth + (gap || 16); // 외부 격자와 동일한 크기 - const widthInColumns = Math.max(1, Math.round(newComp.size.width / fullColumnWidth)); - const snappedWidth = widthInColumns * fullColumnWidth - (gap || 16); // gap 제거하여 실제 컴포넌트 크기 - // 높이는 사용자가 입력한 값 그대로 사용 (스냅 제거) - const snappedHeight = Math.max(10, newComp.size.height); - - newComp.position = { - x: Math.max(padding, snappedX), // 패딩만큼 최소 여백 확보 - y: Math.max(padding, snappedY), - z: newComp.position.z || 1, - }; - - newComp.size = { - width: snappedWidth, - height: snappedHeight, - }; - } else if (newComp.type !== "group") { - // 그룹이 아닌 일반 컴포넌트만 격자 스냅 적용 - const snappedPosition = snapPositionTo10px( - newComp.position, - currentGridInfo, - layout.gridSettings as GridUtilSettings, - ); - newComp.position = snappedPosition; - } - } - - return newComp; - }); - - // 🔥 새로운 layout 생성 - const newLayout = { ...prevLayout, components: updatedComponents }; - - saveToHistory(newLayout); - - // selectedComponent가 업데이트된 컴포넌트와 같다면 selectedComponent도 업데이트 - setSelectedComponent((prevSelected) => { - if (prevSelected && prevSelected.id === componentId) { - const updatedSelectedComponent = updatedComponents.find((c) => c.id === componentId); - if (updatedSelectedComponent) { - // 🔧 완전히 새로운 객체를 만들어서 React가 변경을 감지하도록 함 - const newSelectedComponent = JSON.parse(JSON.stringify(updatedSelectedComponent)); - return newSelectedComponent; - } - } - return prevSelected; - }); - - // webTypeConfig 업데이트 후 레이아웃 상태 확인 - if (path === "webTypeConfig") { - const updatedComponent = newLayout.components.find((c) => c.id === componentId); - console.log("🔄 레이아웃 업데이트 후 컴포넌트 상태:", { - componentId, - updatedComponent: updatedComponent - ? { - id: updatedComponent.id, - type: updatedComponent.type, - webTypeConfig: updatedComponent.type === "widget" ? (updatedComponent as any).webTypeConfig : null, - } - : null, - layoutComponentsCount: newLayout.components.length, - timestamp: new Date().toISOString(), - }); - } - - return newLayout; - }); - }, - [saveToHistory], - ); - - // 컴포넌트 시스템 초기화 - useEffect(() => { - const initComponents = async () => { - try { - // console.log("🚀 컴포넌트 시스템 초기화 시작..."); - await initializeComponents(); - // console.log("✅ 컴포넌트 시스템 초기화 완료"); - } catch (error) { - // console.error("❌ 컴포넌트 시스템 초기화 실패:", error); - } - }; - - initComponents(); - }, []); - - // 화면 선택 시 파일 복원 - useEffect(() => { - if (selectedScreen?.screenId) { - restoreScreenFiles(); - } - }, [selectedScreen?.screenId]); - - // 화면의 모든 파일 컴포넌트 파일 복원 - const restoreScreenFiles = useCallback(async () => { - if (!selectedScreen?.screenId) return; - - try { - // console.log("🔄 화면 파일 복원 시작:", selectedScreen.screenId); - - // 해당 화면의 모든 파일 조회 - const response = await ScreenFileAPI.getScreenComponentFiles(selectedScreen.screenId); - - if (response.success && response.componentFiles) { - // console.log("📁 복원할 파일 데이터:", response.componentFiles); - - // 각 컴포넌트별로 파일 복원 (전역 상태와 localStorage 우선 적용) - Object.entries(response.componentFiles).forEach(([componentId, serverFiles]) => { - if (Array.isArray(serverFiles) && serverFiles.length > 0) { - // 🎯 전역 상태와 localStorage에서 현재 파일 상태 확인 - const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {}; - const currentGlobalFiles = globalFileState[componentId] || []; - - let currentLocalStorageFiles: any[] = []; - if (typeof window !== "undefined") { - try { - const storedFiles = localStorage.getItem(`fileComponent_${componentId}_files`); - if (storedFiles) { - currentLocalStorageFiles = JSON.parse(storedFiles); - } - } catch (e) { - // console.warn("localStorage 파일 파싱 실패:", e); - } - } - - // 🎯 우선순위: 전역 상태 > localStorage > 서버 데이터 - let finalFiles = serverFiles; - if (currentGlobalFiles.length > 0) { - finalFiles = currentGlobalFiles; - // console.log(`📂 컴포넌트 ${componentId} 전역 상태 우선 적용:`, finalFiles.length, "개"); - } else if (currentLocalStorageFiles.length > 0) { - finalFiles = currentLocalStorageFiles; - // console.log(`📂 컴포넌트 ${componentId} localStorage 우선 적용:`, finalFiles.length, "개"); - } else { - // console.log(`📂 컴포넌트 ${componentId} 서버 데이터 적용:`, finalFiles.length, "개"); - } - - // 전역 상태에 파일 저장 - globalFileState[componentId] = finalFiles; - if (typeof window !== "undefined") { - (window as any).globalFileState = globalFileState; - } - - // localStorage에도 백업 - if (typeof window !== "undefined") { - localStorage.setItem(`fileComponent_${componentId}_files`, JSON.stringify(finalFiles)); - } - } - }); - - // 레이아웃의 컴포넌트들에 파일 정보 적용 (전역 상태 우선) - setLayout((prevLayout) => { - const updatedComponents = prevLayout.components.map((comp) => { - // 🎯 전역 상태에서 최신 파일 정보 가져오기 - const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {}; - const finalFiles = globalFileState[comp.id] || []; - - if (finalFiles.length > 0) { - return { - ...comp, - uploadedFiles: finalFiles, - lastFileUpdate: Date.now(), - }; - } - return comp; - }); - - return { - ...prevLayout, - components: updatedComponents, - }; - }); - - // console.log("✅ 화면 파일 복원 완료"); - } - } catch (error) { - // console.error("❌ 화면 파일 복원 오류:", error); - } - }, [selectedScreen?.screenId]); - - // 전역 파일 상태 변경 이벤트 리스너 - useEffect(() => { - const handleGlobalFileStateChange = (event: CustomEvent) => { - // console.log("🔄 ScreenDesigner: 전역 파일 상태 변경 감지", event.detail); - setForceRenderTrigger((prev) => prev + 1); - }; - - if (typeof window !== "undefined") { - window.addEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener); - - return () => { - window.removeEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener); - }; - } - }, []); - - // 화면의 기본 테이블/REST API 정보 로드 - useEffect(() => { - const loadScreenDataSource = async () => { - console.log("🔍 [ScreenDesigner] 데이터 소스 로드 시작:", { - screenId: selectedScreen?.screenId, - screenName: selectedScreen?.screenName, - dataSourceType: selectedScreen?.dataSourceType, - tableName: selectedScreen?.tableName, - restApiConnectionId: selectedScreen?.restApiConnectionId, - restApiEndpoint: selectedScreen?.restApiEndpoint, - restApiJsonPath: selectedScreen?.restApiJsonPath, - // 전체 selectedScreen 객체도 출력 - fullScreen: selectedScreen, - }); - - // REST API 데이터 소스인 경우 - // 1. dataSourceType이 "restapi"인 경우 - // 2. tableName이 restapi_ 또는 _restapi_로 시작하는 경우 - // 3. restApiConnectionId가 있는 경우 - const isRestApi = - selectedScreen?.dataSourceType === "restapi" || - selectedScreen?.tableName?.startsWith("restapi_") || - selectedScreen?.tableName?.startsWith("_restapi_") || - !!selectedScreen?.restApiConnectionId; - - console.log("🔍 [ScreenDesigner] REST API 여부:", { isRestApi }); - - if (isRestApi && (selectedScreen?.restApiConnectionId || selectedScreen?.tableName)) { - try { - // 연결 ID 추출 (restApiConnectionId가 없으면 tableName에서 추출) - let connectionId = selectedScreen?.restApiConnectionId; - if (!connectionId && selectedScreen?.tableName) { - const match = selectedScreen.tableName.match(/restapi_(\d+)/); - connectionId = match ? parseInt(match[1]) : undefined; - } - - if (!connectionId) { - throw new Error("REST API 연결 ID를 찾을 수 없습니다."); - } - - console.log("🌐 [ScreenDesigner] REST API 데이터 로드:", { connectionId }); - - const restApiData = await ExternalRestApiConnectionAPI.fetchData( - connectionId, - selectedScreen?.restApiEndpoint, - selectedScreen?.restApiJsonPath || "response", // 기본값을 response로 변경 - ); - - // REST API 응답에서 컬럼 정보 생성 - const columns: ColumnInfo[] = restApiData.columns.map((col) => ({ - tableName: `restapi_${connectionId}`, - columnName: col.columnName, - columnLabel: col.columnLabel, - dataType: col.dataType === "string" ? "varchar" : col.dataType === "number" ? "numeric" : col.dataType, - webType: col.dataType === "number" ? "number" : "text", - input_type: "text", - widgetType: col.dataType === "number" ? "number" : "text", - isNullable: "YES", - required: false, - })); - - const tableInfo: TableInfo = { - tableName: `restapi_${connectionId}`, - tableLabel: restApiData.connectionInfo.connectionName || "REST API 데이터", - columns, - }; - - console.log("✅ [ScreenDesigner] REST API 컬럼 로드 완료:", { - tableName: tableInfo.tableName, - tableLabel: tableInfo.tableLabel, - columnsCount: columns.length, - columns: columns.map((c) => c.columnName), - }); - - setTables([tableInfo]); - console.log("REST API 데이터 소스 로드 완료:", { - connectionName: restApiData.connectionInfo.connectionName, - columnsCount: columns.length, - rowsCount: restApiData.total, - }); - } catch (error) { - console.error("REST API 데이터 소스 로드 실패:", error); - toast.error("REST API 데이터를 불러오는데 실패했습니다."); - setTables([]); - } - return; - } - - // 데이터베이스 데이터 소스인 경우 (기존 로직) - const tableName = selectedScreen?.tableName; - if (!tableName) { - setTables([]); - return; - } - - try { - // 테이블 라벨 조회 - const tableListResponse = await tableManagementApi.getTableList(); - const currentTable = - tableListResponse.success && tableListResponse.data - ? tableListResponse.data.find((t) => t.tableName === tableName) - : null; - const tableLabel = currentTable?.displayName || tableName; - - // 현재 화면의 테이블 컬럼 정보 조회 (캐시 버스팅으로 최신 데이터 가져오기) - const columnsResponse = await tableTypeApi.getColumns(tableName, true); - - const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => { - // widgetType 결정: inputType(entity 등) > webType > widget_type - const inputType = col.inputType || col.input_type; - const widgetType = inputType || col.widgetType || col.widget_type || col.webType || col.web_type; - - // detailSettings 파싱 (문자열이면 JSON 파싱) - let detailSettings = col.detailSettings || col.detail_settings; - if (typeof detailSettings === "string") { - // JSON 형식인 경우에만 파싱 시도 (중괄호로 시작하는 경우) - if (detailSettings.trim().startsWith("{")) { - try { - detailSettings = JSON.parse(detailSettings); - } catch (e) { - console.warn("detailSettings 파싱 실패:", e); - detailSettings = {}; - } - } else { - // JSON이 아닌 일반 문자열인 경우 빈 객체로 처리 - detailSettings = {}; - } - } - - // 엔티티 타입 디버깅 - if (inputType === "entity" || widgetType === "entity") { - console.log("🔍 엔티티 컬럼 감지:", { - columnName: col.columnName || col.column_name, - inputType, - widgetType, - detailSettings, - referenceTable: detailSettings?.referenceTable || col.referenceTable || col.reference_table, - }); - } - - return { - tableName: col.tableName || tableName, - columnName: col.columnName || col.column_name, - columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name, - dataType: col.dataType || col.data_type || col.dbType, - webType: col.webType || col.web_type, - input_type: inputType, - inputType: inputType, - widgetType, - isNullable: col.isNullable || col.is_nullable, - required: col.required !== undefined ? col.required : col.isNullable === "NO" || col.is_nullable === "NO", - columnDefault: col.columnDefault || col.column_default, - characterMaximumLength: col.characterMaximumLength || col.character_maximum_length, - codeCategory: col.codeCategory || col.code_category, - codeValue: col.codeValue || col.code_value, - // 엔티티 타입용 참조 테이블 정보 (detailSettings에서 추출) - referenceTable: detailSettings?.referenceTable || col.referenceTable || col.reference_table, - referenceColumn: detailSettings?.referenceColumn || col.referenceColumn || col.reference_column, - displayColumn: detailSettings?.displayColumn || col.displayColumn || col.display_column, - // detailSettings 전체 보존 (V2 컴포넌트용) - detailSettings, - }; - }); - - const tableInfo: TableInfo = { - tableName, - tableLabel, - columns, - }; - - setTables([tableInfo]); // 현재 화면의 테이블만 저장 (원래대로) - } catch (error) { - console.error("화면 테이블 정보 로드 실패:", error); - setTables([]); - } - }; - - loadScreenDataSource(); - }, [ - selectedScreen?.tableName, - selectedScreen?.screenName, - selectedScreen?.dataSourceType, - selectedScreen?.restApiConnectionId, - selectedScreen?.restApiEndpoint, - selectedScreen?.restApiJsonPath, - ]); - - // 테이블 선택 핸들러 - 사이드바에서 테이블 선택 시 호출 - const handleTableSelect = useCallback( - async (tableName: string) => { - console.log("📊 테이블 선택:", tableName); - - try { - // 테이블 라벨 조회 - const tableListResponse = await tableManagementApi.getTableList(); - const currentTable = - tableListResponse.success && tableListResponse.data - ? tableListResponse.data.find((t: any) => (t.tableName || t.table_name) === tableName) - : null; - const tableLabel = currentTable?.displayName || currentTable?.table_label || tableName; - - // 테이블 컬럼 정보 조회 - const columnsResponse = await tableTypeApi.getColumns(tableName, true); - - const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => { - const inputType = col.inputType || col.input_type; - const widgetType = inputType || col.widgetType || col.widget_type || col.webType || col.web_type; - - let detailSettings = col.detailSettings || col.detail_settings; - if (typeof detailSettings === "string") { - try { - detailSettings = JSON.parse(detailSettings); - } catch (e) { - detailSettings = {}; - } - } - - return { - tableName: col.tableName || tableName, - columnName: col.columnName || col.column_name, - columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name, - dataType: col.dataType || col.data_type || col.dbType, - webType: col.webType || col.web_type, - input_type: inputType, - inputType: inputType, - widgetType, - isNullable: col.isNullable || col.is_nullable, - required: col.required !== undefined ? col.required : col.isNullable === "NO" || col.is_nullable === "NO", - columnDefault: col.columnDefault || col.column_default, - characterMaximumLength: col.characterMaximumLength || col.character_maximum_length, - codeCategory: col.codeCategory || col.code_category, - codeValue: col.codeValue || col.code_value, - referenceTable: detailSettings?.referenceTable || col.referenceTable || col.reference_table, - referenceColumn: detailSettings?.referenceColumn || col.referenceColumn || col.reference_column, - displayColumn: detailSettings?.displayColumn || col.displayColumn || col.display_column, - detailSettings, - }; - }); - - const tableInfo: TableInfo = { - tableName, - tableLabel, - columns, - }; - - setTables([tableInfo]); - toast.success(`테이블 "${tableLabel}" 선택됨`); - - // 기존 테이블과 다른 테이블 선택 시, 기존 컴포넌트 중 다른 테이블 컬럼은 제거 - if (tables.length > 0 && tables[0].tableName !== tableName) { - setLayout((prev) => { - const newComponents = prev.components.filter((comp) => { - // 테이블 컬럼 기반 컴포넌트인지 확인 - if (comp.tableName && comp.tableName !== tableName) { - console.log("🗑️ 다른 테이블 컴포넌트 제거:", comp.tableName, comp.columnName); - return false; - } - return true; - }); - - if (newComponents.length < prev.components.length) { - toast.info( - `이전 테이블(${tables[0].tableName})의 컴포넌트가 ${prev.components.length - newComponents.length}개 제거되었습니다.`, - ); - } - - return { - ...prev, - components: newComponents, - }; - }); - } - } catch (error) { - console.error("테이블 정보 로드 실패:", error); - toast.error("테이블 정보를 불러오는데 실패했습니다."); - } - }, - [tables], - ); - - // 화면 레이아웃 로드 - useEffect(() => { - if (selectedScreen?.screenId) { - // 현재 화면 ID를 전역 변수로 설정 (파일 업로드 시 사용) - if (typeof window !== "undefined") { - (window as any).__CURRENT_SCREEN_ID__ = selectedScreen.screenId; - } - - const loadLayout = async () => { - try { - // 🆕 화면에 할당된 메뉴 조회 - const menuInfo = await screenApi.getScreenMenu(selectedScreen.screenId); - if (menuInfo) { - setMenuObjid(menuInfo.menuObjid); - console.log("🔗 화면에 할당된 메뉴:", menuInfo); - } else { - console.warn("⚠️ 화면에 할당된 메뉴가 없습니다"); - } - - // V2/POP API 사용 여부에 따라 분기 - let response: any; - if (USE_POP_API) { - // POP 모드: screen_layouts_pop 테이블 사용 - const popResponse = await screenApi.getLayoutPop(selectedScreen.screenId); - response = popResponse ? convertV2ToLegacy(popResponse) : null; - console.log("📱 POP 레이아웃 로드:", popResponse?.components?.length || 0, "개 컴포넌트"); - } else if (USE_V2_API) { - // 데스크톱 V2 모드: screen_layouts_v2 테이블 사용 - const v2Response = await screenApi.getLayoutV2(selectedScreen.screenId); - - // 🐛 디버깅: API 응답에서 fieldMapping.id 확인 - const splitPanelInV2 = v2Response?.components?.find((c: any) => c.url?.includes("v2-split-panel-layout")); - const finishedTimelineInV2 = splitPanelInV2?.overrides?.rightPanel?.components?.find( - (c: any) => c.id === "finished_timeline", - ); - console.log("🐛 [API 응답 RAW] finished_timeline:", JSON.stringify(finishedTimelineInV2, null, 2)); - console.log("🐛 [API 응답] finished_timeline fieldMapping:", { - fieldMapping: JSON.stringify(finishedTimelineInV2?.componentConfig?.fieldMapping), - fieldMappingKeys: finishedTimelineInV2?.componentConfig?.fieldMapping - ? Object.keys(finishedTimelineInV2?.componentConfig?.fieldMapping) - : [], - hasId: !!finishedTimelineInV2?.componentConfig?.fieldMapping?.id, - idValue: finishedTimelineInV2?.componentConfig?.fieldMapping?.id, - }); - - response = v2Response ? convertV2ToLegacy(v2Response) : null; - } else { - response = await screenApi.getLayout(selectedScreen.screenId); - } - - if (response) { - // 🔄 마이그레이션 필요 여부 확인 (V2는 스킵) - let layoutToUse = response; - - if (!USE_V2_API && needsMigration(response)) { - const canvasWidth = response.screenResolution?.width || 1920; - layoutToUse = safeMigrateLayout(response, canvasWidth); - } - - // 🔄 webTypeConfig를 autoGeneration으로 변환 - const { convertLayoutComponents } = await import("@/lib/utils/webTypeConfigConverter"); - const convertedComponents = convertLayoutComponents(layoutToUse.components); - - // 기본 격자 설정 보장 (격자 표시와 스냅 기본 활성화) - const layoutWithDefaultGrid = { - ...layoutToUse, - components: convertedComponents, // 변환된 컴포넌트 사용 - gridSettings: { - columns: layoutToUse.gridSettings?.columns || 12, // DB 값 우선, 없으면 기본값 12 - gap: layoutToUse.gridSettings?.gap ?? 16, // DB 값 우선, 없으면 기본값 16 - padding: 0, // padding은 항상 0으로 강제 - snapToGrid: layoutToUse.gridSettings?.snapToGrid ?? true, // DB 값 우선 - showGrid: layoutToUse.gridSettings?.showGrid ?? false, // DB 값 우선 - gridColor: layoutToUse.gridSettings?.gridColor || "#d1d5db", - gridOpacity: layoutToUse.gridSettings?.gridOpacity ?? 0.5, - }, - }; - - // 저장된 해상도 정보가 있으면 적용, 없으면 기본값 사용 - if (layoutToUse.screenResolution) { - setScreenResolution(layoutToUse.screenResolution); - // console.log("💾 저장된 해상도 불러옴:", layoutToUse.screenResolution); - } else { - // 기본 해상도 (Full HD) - const defaultResolution = - SCREEN_RESOLUTIONS.find((r) => r.name === "Full HD (1920×1080)") || SCREEN_RESOLUTIONS[0]; - setScreenResolution(defaultResolution); - // console.log("🔧 기본 해상도 적용:", defaultResolution); - } - - // 🔍 디버깅: 로드된 버튼 컴포넌트의 action 확인 - const buttonComponents = layoutWithDefaultGrid.components.filter((c: any) => - c.componentType?.startsWith("button"), - ); - console.log( - "🔍 [로드] 버튼 컴포넌트 action 확인:", - buttonComponents.map((c: any) => ({ - id: c.id, - type: c.componentType, - actionType: c.componentConfig?.action?.type, - fullAction: c.componentConfig?.action, - })), - ); - - setLayout(layoutWithDefaultGrid); - setHistory([layoutWithDefaultGrid]); - setHistoryIndex(0); - - // 파일 컴포넌트 데이터 복원 (비동기) - restoreFileComponentsData(layoutWithDefaultGrid.components); - - // 🆕 레이어 영역 로드 (조건부 레이어의 displayRegion) - try { - const layers = await screenApi.getScreenLayers(selectedScreen.screenId); - const regions: Record = {}; - for (const layer of layers) { - if (layer.layer_id > 1 && layer.condition_config?.displayRegion) { - regions[layer.layer_id] = { - ...layer.condition_config.displayRegion, - layerName: layer.layer_name, - }; - } - } - setLayerRegions(regions); - } catch { /* 레이어 로드 실패 무시 */ } - } - } catch (error) { - // console.error("레이아웃 로드 실패:", error); - toast.error("화면 레이아웃을 불러오는데 실패했습니다."); - } - }; - loadLayout(); - } - }, [selectedScreen?.screenId]); - - // 스페이스바 키 이벤트 처리 (Pan 모드) + 전역 마우스 이벤트 - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - // 입력 필드에서는 스페이스바 무시 (activeElement로 정확하게 체크) - const activeElement = document.activeElement; - if ( - activeElement instanceof HTMLInputElement || - activeElement instanceof HTMLTextAreaElement || - activeElement?.getAttribute("contenteditable") === "true" || - activeElement?.getAttribute("role") === "textbox" - ) { - return; - } - - // e.target도 함께 체크 (이중 방어) - if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { - return; - } - - if (e.code === "Space") { - e.preventDefault(); // 스페이스바 기본 스크롤 동작 차단 - if (!isPanMode) { - setIsPanMode(true); - // body에 커서 스타일 추가 - document.body.style.cursor = "grab"; - } - } - }; - - const handleKeyUp = (e: KeyboardEvent) => { - // 입력 필드에서는 스페이스바 무시 - const activeElement = document.activeElement; - if ( - activeElement instanceof HTMLInputElement || - activeElement instanceof HTMLTextAreaElement || - activeElement?.getAttribute("contenteditable") === "true" || - activeElement?.getAttribute("role") === "textbox" - ) { - return; - } - - if (e.code === "Space") { - e.preventDefault(); // 스페이스바 기본 스크롤 동작 차단 - setIsPanMode(false); - setPanState((prev) => ({ ...prev, isPanning: false })); - // body 커서 스타일 복원 - document.body.style.cursor = "default"; - } - }; - - const handleMouseDown = (e: MouseEvent) => { - if (isPanMode) { - e.preventDefault(); - // 외부와 내부 스크롤 컨테이너 모두 저장 - setPanState({ - isPanning: true, - startX: e.pageX, - startY: e.pageY, - outerScrollLeft: canvasContainerRef.current?.scrollLeft || 0, - outerScrollTop: canvasContainerRef.current?.scrollTop || 0, - innerScrollLeft: canvasRef.current?.scrollLeft || 0, - innerScrollTop: canvasRef.current?.scrollTop || 0, - }); - // 드래그 중 커서 변경 - document.body.style.cursor = "grabbing"; - } - }; - - const handleMouseMove = (e: MouseEvent) => { - if (isPanMode && panState.isPanning) { - e.preventDefault(); - const dx = e.pageX - panState.startX; - const dy = e.pageY - panState.startY; - - // 외부 컨테이너 스크롤 - if (canvasContainerRef.current) { - canvasContainerRef.current.scrollLeft = panState.outerScrollLeft - dx; - canvasContainerRef.current.scrollTop = panState.outerScrollTop - dy; - } - - // 내부 캔버스 스크롤 - if (canvasRef.current) { - canvasRef.current.scrollLeft = panState.innerScrollLeft - dx; - canvasRef.current.scrollTop = panState.innerScrollTop - dy; - } - } - }; - - const handleMouseUp = () => { - if (isPanMode) { - setPanState((prev) => ({ ...prev, isPanning: false })); - // 드래그 종료 시 커서 복원 - document.body.style.cursor = "grab"; - } - }; - - window.addEventListener("keydown", handleKeyDown); - window.addEventListener("keyup", handleKeyUp); - window.addEventListener("mousedown", handleMouseDown); - window.addEventListener("mousemove", handleMouseMove); - window.addEventListener("mouseup", handleMouseUp); - - return () => { - window.removeEventListener("keydown", handleKeyDown); - window.removeEventListener("keyup", handleKeyUp); - window.removeEventListener("mousedown", handleMouseDown); - window.removeEventListener("mousemove", handleMouseMove); - window.removeEventListener("mouseup", handleMouseUp); - }; - }, [ - isPanMode, - panState.isPanning, - panState.startX, - panState.startY, - panState.outerScrollLeft, - panState.outerScrollTop, - panState.innerScrollLeft, - panState.innerScrollTop, - ]); - - // 마우스 휠로 줌 제어 (RAF throttle 적용으로 깜빡임 방지) - useEffect(() => { - const handleWheel = (e: WheelEvent) => { - // 캔버스 컨테이너 내에서만 동작 - if (canvasContainerRef.current && canvasContainerRef.current.contains(e.target as Node)) { - // Shift 키를 누르지 않은 경우에만 줌 (Shift + 휠은 수평 스크롤용) - if (!e.shiftKey && !e.ctrlKey && !e.metaKey) { - // 기본 스크롤 동작 방지 - e.preventDefault(); - - const delta = e.deltaY; - const zoomFactor = 0.001; // 줌 속도 조절 - - // RAF throttle: 프레임당 한 번만 상태 업데이트 - if (zoomRafRef.current !== null) { - cancelAnimationFrame(zoomRafRef.current); - } - zoomRafRef.current = requestAnimationFrame(() => { - setZoomLevel((prevZoom) => { - const newZoom = prevZoom - delta * zoomFactor; - return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, newZoom)); - }); - zoomRafRef.current = null; - }); - } - } - }; - - // passive: false로 설정하여 preventDefault() 가능하게 함 - canvasContainerRef.current?.addEventListener("wheel", handleWheel, { passive: false }); - - const containerRef = canvasContainerRef.current; - return () => { - containerRef?.removeEventListener("wheel", handleWheel); - if (zoomRafRef.current !== null) { - cancelAnimationFrame(zoomRafRef.current); - } - }; - }, [MIN_ZOOM, MAX_ZOOM]); - - // 격자 설정 업데이트 (컴포넌트 자동 조정 제거됨) - const updateGridSettings = useCallback( - (newGridSettings: GridSettings) => { - const newLayout = { ...layout, gridSettings: newGridSettings }; - // 🆕 격자 설정 변경 시 컴포넌트 크기/위치 자동 조정 로직 제거됨 - // 사용자가 명시적으로 "격자 재조정" 버튼을 클릭해야만 조정됨 - setLayout(newLayout); - saveToHistory(newLayout); - }, - [layout, saveToHistory], - ); - - // 해상도 변경 핸들러 (컴포넌트 크기/위치 유지) - const handleResolutionChange = useCallback( - (newResolution: ScreenResolution) => { - const oldWidth = screenResolution.width; - const oldHeight = screenResolution.height; - const newWidth = newResolution.width; - const newHeight = newResolution.height; - - console.log("📱 해상도 변경:", { - from: `${oldWidth}x${oldHeight}`, - to: `${newWidth}x${newHeight}`, - componentsCount: layout.components.length, - }); - - setScreenResolution(newResolution); - - // 해상도만 변경하고 컴포넌트 크기/위치는 그대로 유지 - const updatedLayout = { - ...layout, - screenResolution: newResolution, - }; - - setLayout(updatedLayout); - saveToHistory(updatedLayout); - - toast.success("해상도가 변경되었습니다.", { - description: `${oldWidth}×${oldHeight} → ${newWidth}×${newHeight}`, - }); - - console.log("✅ 해상도 변경 완료 (컴포넌트 크기/위치 유지)"); - }, - [layout, saveToHistory, screenResolution], - ); - - // 강제 격자 재조정 핸들러 (해상도 변경 후 수동 격자 맞춤용) - const handleForceGridUpdate = useCallback(() => { - if (!layout.gridSettings?.snapToGrid || layout.components.length === 0) { - // console.log("격자 재조정 생략: 스냅 비활성화 또는 컴포넌트 없음"); - return; - } - - console.log("🔄 격자 강제 재조정 시작:", { - componentsCount: layout.components.length, - resolution: `${screenResolution.width}x${screenResolution.height}`, - gridSettings: layout.gridSettings, - }); - - // 현재 해상도로 격자 정보 계산 - const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, { - columns: layout.gridSettings.columns, - gap: layout.gridSettings.gap, - padding: layout.gridSettings.padding, - snapToGrid: layout.gridSettings.snapToGrid || false, - }); - - const gridUtilSettings = { - columns: layout.gridSettings.columns, - gap: layout.gridSettings.gap, - padding: layout.gridSettings.padding, - snapToGrid: true, - }; - - const adjustedComponents = layout.components.map((comp) => { - const snappedPosition = snapToGrid(comp.position, currentGridInfo, gridUtilSettings); - const snappedSize = snapSizeToGrid(comp.size, currentGridInfo, gridUtilSettings); - - // gridColumns가 없거나 범위를 벗어나면 자동 조정 - let adjustedGridColumns = comp.gridColumns; - if (!adjustedGridColumns || adjustedGridColumns < 1 || adjustedGridColumns > layout.gridSettings!.columns) { - adjustedGridColumns = adjustGridColumnsFromSize({ size: snappedSize }, currentGridInfo, gridUtilSettings); - } - - return { - ...comp, - position: snappedPosition, - size: snappedSize, - gridColumns: adjustedGridColumns, - }; - }); - - const newLayout = { ...layout, components: adjustedComponents }; - setLayout(newLayout); - saveToHistory(newLayout); - - console.log("✅ 격자 강제 재조정 완료:", { - adjustedComponents: adjustedComponents.length, - gridInfo: { - columnWidth: currentGridInfo.columnWidth.toFixed(2), - totalWidth: currentGridInfo.totalWidth, - columns: layout.gridSettings.columns, - }, - }); - - toast.success(`${adjustedComponents.length}개 컴포넌트가 격자에 맞게 재정렬되었습니다.`); - }, [layout, screenResolution, saveToHistory]); - - // === 정렬/배분/동일크기/라벨토글/Nudge 핸들러 === - - // 컴포넌트 정렬 - const handleGroupAlign = useCallback( - (mode: AlignMode) => { - if (groupState.selectedComponents.length < 2) { - toast.warning("2개 이상의 컴포넌트를 선택해주세요."); - return; - } - saveToHistory(layout); - const newComponents = alignComponents(layout.components, groupState.selectedComponents, mode); - setLayout((prev) => ({ ...prev, components: newComponents })); - - const modeNames: Record = { - left: "좌측", right: "우측", centerX: "가로 중앙", - top: "상단", bottom: "하단", centerY: "세로 중앙", - }; - toast.success(`${modeNames[mode]} 정렬 완료`); - }, - [groupState.selectedComponents, layout, saveToHistory] - ); - - // 컴포넌트 균등 배분 - const handleGroupDistribute = useCallback( - (direction: DistributeDirection) => { - if (groupState.selectedComponents.length < 3) { - toast.warning("3개 이상의 컴포넌트를 선택해주세요."); - return; - } - saveToHistory(layout); - const newComponents = distributeComponents(layout.components, groupState.selectedComponents, direction); - setLayout((prev) => ({ ...prev, components: newComponents })); - toast.success(`${direction === "horizontal" ? "가로" : "세로"} 균등 배분 완료`); - }, - [groupState.selectedComponents, layout, saveToHistory] - ); - - // 동일 크기 맞추기 - const handleMatchSize = useCallback( - (mode: MatchSizeMode) => { - if (groupState.selectedComponents.length < 2) { - toast.warning("2개 이상의 컴포넌트를 선택해주세요."); - return; - } - saveToHistory(layout); - const newComponents = matchComponentSize( - layout.components, - groupState.selectedComponents, - mode, - selectedComponent?.id - ); - setLayout((prev) => ({ ...prev, components: newComponents })); - - const modeNames: Record = { - width: "너비", height: "높이", both: "크기", - }; - toast.success(`${modeNames[mode]} 맞추기 완료`); - }, - [groupState.selectedComponents, layout, selectedComponent?.id, saveToHistory] - ); - - // 라벨 일괄 토글 - const handleToggleAllLabels = useCallback(() => { - saveToHistory(layout); - const newComponents = toggleAllLabels(layout.components); - setLayout((prev) => ({ ...prev, components: newComponents })); - - const hasHidden = layout.components.some( - (c) => c.type === "widget" && (c.style as any)?.labelDisplay === false - ); - toast.success(hasHidden ? "모든 라벨 표시" : "모든 라벨 숨기기"); - }, [layout, saveToHistory]); - - // Nudge (화살표 키 이동) - const handleNudge = useCallback( - (direction: "up" | "down" | "left" | "right", distance: number) => { - const targetIds = - groupState.selectedComponents.length > 0 - ? groupState.selectedComponents - : selectedComponent - ? [selectedComponent.id] - : []; - - if (targetIds.length === 0) return; - - const newComponents = nudgeComponents(layout.components, targetIds, direction, distance); - setLayout((prev) => ({ ...prev, components: newComponents })); - - // 선택된 컴포넌트 업데이트 - if (selectedComponent && targetIds.includes(selectedComponent.id)) { - const updated = newComponents.find((c) => c.id === selectedComponent.id); - if (updated) setSelectedComponent(updated); - } - }, - [groupState.selectedComponents, selectedComponent, layout.components] - ); - - // 저장 - const handleSave = useCallback(async () => { - if (!selectedScreen?.screenId) { - console.error("❌ 저장 실패: selectedScreen 또는 screenId가 없습니다.", selectedScreen); - toast.error("화면 정보가 없습니다."); - return; - } - - try { - setIsSaving(true); - - // 분할 패널 컴포넌트의 rightPanel.tableName 자동 설정 - const updatedComponents = layout.components.map((comp) => { - if (comp.type === "component" && comp.componentType === "split-panel-layout") { - const config = comp.componentConfig || {}; - const rightPanel = config.rightPanel || {}; - const leftPanel = config.leftPanel || {}; - const relationshipType = rightPanel.relation?.type || "detail"; - - // 관계 타입이 detail이면 rightPanel.tableName을 leftPanel.tableName과 동일하게 설정 - if (relationshipType === "detail" && leftPanel.tableName) { - console.log("🔧 분할 패널 자동 수정:", { - componentId: comp.id, - leftTableName: leftPanel.tableName, - rightTableName: leftPanel.tableName, - }); - - return { - ...comp, - componentConfig: { - ...config, - rightPanel: { - ...rightPanel, - tableName: leftPanel.tableName, - }, - }, - }; - } - } - return comp; - }); - - // 해상도 정보를 포함한 레이아웃 데이터 생성 - // 현재 선택된 테이블을 화면의 기본 테이블로 저장 - const currentMainTableName = tables.length > 0 ? tables[0].tableName : null; - - const layoutWithResolution = { - ...layout, - components: updatedComponents, - screenResolution: screenResolution, - mainTableName: currentMainTableName, // 화면의 기본 테이블 - }; - - // V2/POP API 사용 여부에 따라 분기 - const v2Layout = convertLegacyToV2(layoutWithResolution); - if (USE_POP_API) { - // POP 모드: screen_layouts_pop 테이블에 저장 - await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout); - } else if (USE_V2_API) { - // 레이어 기반 저장: 현재 활성 레이어의 layout만 저장 - const currentLayerId = activeLayerIdRef.current || 1; - await screenApi.saveLayoutV2(selectedScreen.screenId, { - ...v2Layout, - layerId: currentLayerId, - }); - } else { - await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution); - } - - // console.log("✅ 저장 성공!"); - toast.success("화면이 저장되었습니다."); - - // 저장 성공 후 부모에게 화면 정보 업데이트 알림 (테이블명 즉시 반영) - if (onScreenUpdate && currentMainTableName) { - onScreenUpdate({ tableName: currentMainTableName }); - } - - // 저장 성공 후 메뉴 할당 모달 열기 - setShowMenuAssignmentModal(true); - } catch (error) { - console.error("❌ 저장 실패:", error); - toast.error("저장 중 오류가 발생했습니다."); - } finally { - setIsSaving(false); - } - }, [selectedScreen, layout, screenResolution, tables, onScreenUpdate]); - - // POP 미리보기 핸들러 (새 창에서 열기) - const handlePopPreview = useCallback(() => { - if (!selectedScreen?.screenId) { - toast.error("화면 정보가 없습니다."); - return; - } - - const deviceType = defaultDevicePreview || "tablet"; - const previewUrl = `/pop/screens/${selectedScreen.screenId}?preview=true&device=${deviceType}`; - window.open(previewUrl, "_blank", "width=800,height=900"); - }, [selectedScreen, defaultDevicePreview]); - - // 다국어 자동 생성 핸들러 - const handleGenerateMultilang = useCallback(async () => { - if (!selectedScreen?.screenId) { - toast.error("화면 정보가 없습니다."); - return; - } - - setIsGeneratingMultilang(true); - - try { - // 공통 유틸 사용하여 라벨 추출 - const { extractMultilangLabels, extractTableNames, applyMultilangMappings } = await import( - "@/lib/utils/multilangLabelExtractor" - ); - const { apiClient } = await import("@/lib/api/client"); - - // 테이블별 컬럼 라벨 로드 - const tableNames = extractTableNames(layout.components); - const columnLabelMap: Record> = {}; - - for (const tableName of tableNames) { - try { - const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); - if (response.data?.success && response.data?.data) { - const columns = response.data.data.columns || response.data.data; - if (Array.isArray(columns)) { - columnLabelMap[tableName] = {}; - columns.forEach((col: any) => { - const colName = col.columnName || col.column_name || col.name; - const colLabel = col.displayName || col.columnLabel || col.column_label || colName; - if (colName) { - columnLabelMap[tableName][colName] = colLabel; - } - }); - } - } - } catch (error) { - console.error(`컬럼 라벨 조회 실패 (${tableName}):`, error); - } - } - - // 라벨 추출 (다국어 설정과 동일한 로직) - const extractedLabels = extractMultilangLabels(layout.components, columnLabelMap); - const labels = extractedLabels.map((l) => ({ - componentId: l.componentId, - label: l.label, - type: l.type, - })); - - if (labels.length === 0) { - toast.info("다국어로 변환할 라벨이 없습니다."); - setIsGeneratingMultilang(false); - return; - } - - // API 호출 - const { generateScreenLabelKeys } = await import("@/lib/api/multilang"); - const response = await generateScreenLabelKeys({ - screenId: selectedScreen.screenId, - menuObjId: menuObjid?.toString(), - labels, - }); - - if (response.success && response.data) { - // 자동 매핑 적용 - const updatedComponents = applyMultilangMappings(layout.components, response.data); - - // 레이아웃 업데이트 - const updatedLayout = { - ...layout, - components: updatedComponents, - screenResolution: screenResolution, - }; - - setLayout(updatedLayout); - - // 자동 저장 (매핑 정보가 손실되지 않도록) - try { - const v2Layout = convertLegacyToV2(updatedLayout); - if (USE_POP_API) { - await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout); - } else if (USE_V2_API) { - await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout); - } else { - await screenApi.saveLayout(selectedScreen.screenId, updatedLayout); - } - toast.success(`${response.data.length}개의 다국어 키가 생성되고 자동 저장되었습니다.`); - } catch (saveError) { - console.error("다국어 매핑 저장 실패:", saveError); - toast.warning(`${response.data.length}개의 다국어 키가 생성되었습니다. 저장 버튼을 눌러 매핑을 저장하세요.`); - } - } else { - toast.error(response.error?.details || "다국어 키 생성에 실패했습니다."); - } - } catch (error) { - console.error("다국어 생성 실패:", error); - toast.error("다국어 키 생성 중 오류가 발생했습니다."); - } finally { - setIsGeneratingMultilang(false); - } - }, [selectedScreen, layout, screenResolution, menuObjid]); - - // 템플릿 드래그 처리 - const handleTemplateDrop = useCallback( - (e: React.DragEvent, template: TemplateComponent) => { - const rect = canvasRef.current?.getBoundingClientRect(); - if (!rect) return; - - const dropX = e.clientX - rect.left; - const dropY = e.clientY - rect.top; - - // 현재 해상도에 맞는 격자 정보 계산 - const currentGridInfo = layout.gridSettings - ? calculateGridInfo(screenResolution.width, screenResolution.height, { - columns: layout.gridSettings.columns, - gap: layout.gridSettings.gap, - padding: layout.gridSettings.padding, - snapToGrid: layout.gridSettings.snapToGrid || false, - }) - : null; - - // 격자 스냅 적용 - const snappedPosition = - layout.gridSettings?.snapToGrid && currentGridInfo - ? snapToGrid({ x: dropX, y: dropY, z: 1 }, currentGridInfo, layout.gridSettings as GridUtilSettings) - : { x: dropX, y: dropY, z: 1 }; - - console.log("🎨 템플릿 드롭:", { - templateName: template.name, - componentsCount: template.components.length, - dropPosition: { x: dropX, y: dropY }, - snappedPosition, - }); - - // 템플릿의 모든 컴포넌트들을 생성 - // 먼저 ID 매핑을 생성 (parentId 참조를 위해) - const idMapping: Record = {}; - template.components.forEach((templateComp, index) => { - const newId = generateComponentId(); - if (index === 0) { - // 첫 번째 컴포넌트(컨테이너)는 "form-container"로 매핑 - idMapping["form-container"] = newId; - } - idMapping[templateComp.parentId || `temp_${index}`] = newId; - }); - - const newComponents: ComponentData[] = template.components.map((templateComp, index) => { - const componentId = index === 0 ? idMapping["form-container"] : generateComponentId(); - - // 템플릿 컴포넌트의 상대 위치를 드롭 위치 기준으로 조정 - const absoluteX = snappedPosition.x + templateComp.position.x; - const absoluteY = snappedPosition.y + templateComp.position.y; - - // 격자 스냅 적용 - const finalPosition = - layout.gridSettings?.snapToGrid && currentGridInfo - ? snapPositionTo10px( - { x: absoluteX, y: absoluteY, z: 1 }, - currentGridInfo, - layout.gridSettings as GridUtilSettings, - ) - : { x: absoluteX, y: absoluteY, z: 1 }; - - if (templateComp.type === "container") { - // 그리드 컬럼 기반 크기 계산 - const gridColumns = - typeof templateComp.size.width === "number" && templateComp.size.width <= 12 ? templateComp.size.width : 4; // 기본 4컬럼 - - const calculatedSize = - currentGridInfo && layout.gridSettings?.snapToGrid - ? (() => { - const newWidth = calculateWidthFromColumns( - gridColumns, - currentGridInfo, - layout.gridSettings as GridUtilSettings, - ); - return { - width: newWidth, - height: templateComp.size.height, - }; - })() - : { width: 400, height: templateComp.size.height }; // 폴백 크기 - - return { - id: componentId, - type: "container", - label: templateComp.label, - tableName: selectedScreen?.tableName || "", - title: templateComp.title || templateComp.label, - position: finalPosition, - size: calculatedSize, - gridColumns, - style: { - labelDisplay: true, - labelFontSize: "14px", - labelColor: "#212121", - labelFontWeight: "600", - labelMarginBottom: "8px", - ...templateComp.style, - }, - }; - } else if (templateComp.type === "datatable") { - // 데이터 테이블 컴포넌트 생성 - const gridColumns = 6; // 기본값: 6컬럼 (50% 너비) - - // gridColumns에 맞는 크기 계산 - const calculatedSize = - currentGridInfo && layout.gridSettings?.snapToGrid - ? (() => { - const newWidth = calculateWidthFromColumns( - gridColumns, - currentGridInfo, - layout.gridSettings as GridUtilSettings, - ); - return { - width: newWidth, - height: templateComp.size.height, // 높이는 템플릿 값 유지 - }; - })() - : templateComp.size; - - console.log("📊 데이터 테이블 생성 시 크기 계산:", { - gridColumns, - templateSize: templateComp.size, - calculatedSize, - hasGridInfo: !!currentGridInfo, - hasGridSettings: !!layout.gridSettings, - }); - - return { - id: componentId, - type: "datatable", - label: templateComp.label, - tableName: selectedScreen?.tableName || "", - position: finalPosition, - size: calculatedSize, - title: templateComp.label, - columns: [], // 초기에는 빈 배열, 나중에 설정 - filters: [], // 초기에는 빈 배열, 나중에 설정 - pagination: { - enabled: true, - pageSize: 10, - pageSizeOptions: [5, 10, 20, 50], - showPageSizeSelector: true, - showPageInfo: true, - showFirstLast: true, - }, - showSearchButton: true, - searchButtonText: "검색", - enableExport: true, - enableRefresh: true, - enableAdd: true, - enableEdit: true, - enableDelete: true, - addButtonText: "추가", - editButtonText: "수정", - deleteButtonText: "삭제", - addModalConfig: { - title: "새 데이터 추가", - description: `${templateComp.label}에 새로운 데이터를 추가합니다.`, - width: "lg", - layout: "two-column", - gridColumns: 2, - fieldOrder: [], // 초기에는 빈 배열, 나중에 컬럼 추가 시 설정 - requiredFields: [], - hiddenFields: [], - advancedFieldConfigs: {}, // 초기에는 빈 객체, 나중에 컬럼별 설정 - submitButtonText: "추가", - cancelButtonText: "취소", - }, - gridColumns, - style: { - labelDisplay: true, - labelFontSize: "14px", - labelColor: "#212121", - labelFontWeight: "600", - labelMarginBottom: "8px", - ...templateComp.style, - }, - } as ComponentData; - } else if (templateComp.type === "file") { - // 파일 첨부 컴포넌트 생성 - const gridColumns = 6; // 기본값: 6컬럼 - - const calculatedSize = - currentGridInfo && layout.gridSettings?.snapToGrid - ? (() => { - const newWidth = calculateWidthFromColumns( - gridColumns, - currentGridInfo, - layout.gridSettings as GridUtilSettings, - ); - return { - width: newWidth, - height: templateComp.size.height, - }; - })() - : templateComp.size; - - return { - id: componentId, - type: "file", - label: templateComp.label, - position: finalPosition, - size: calculatedSize, - gridColumns, - fileConfig: { - accept: ["image/*", ".pdf", ".doc", ".docx", ".xls", ".xlsx"], - multiple: true, - maxSize: 10, // 10MB - maxFiles: 5, - docType: "DOCUMENT", - docTypeName: "일반 문서", - targetObjid: selectedScreen?.screenId || "", - showPreview: true, - showProgress: true, - dragDropText: "파일을 드래그하여 업로드하세요", - uploadButtonText: "파일 선택", - autoUpload: true, - chunkedUpload: false, - }, - uploadedFiles: [], - style: { - labelDisplay: true, - labelFontSize: "14px", - labelColor: "#212121", - labelFontWeight: "600", - labelMarginBottom: "8px", - ...templateComp.style, - }, - } as ComponentData; - } else if (templateComp.type === "area") { - // 영역 컴포넌트 생성 - const gridColumns = 6; // 기본값: 6컬럼 (50% 너비) - - const calculatedSize = - currentGridInfo && layout.gridSettings?.snapToGrid - ? (() => { - const newWidth = calculateWidthFromColumns( - gridColumns, - currentGridInfo, - layout.gridSettings as GridUtilSettings, - ); - return { - width: newWidth, - height: templateComp.size.height, - }; - })() - : templateComp.size; - - return { - id: componentId, - type: "area", - label: templateComp.label, - position: finalPosition, - size: calculatedSize, - gridColumns, - layoutType: (templateComp as any).layoutType || "box", - title: (templateComp as any).title || templateComp.label, - description: (templateComp as any).description, - layoutConfig: (templateComp as any).layoutConfig || {}, - areaStyle: { - backgroundColor: "#ffffff", - borderWidth: 1, - borderStyle: "solid", - borderColor: "#e5e7eb", - borderRadius: 8, - padding: 0, - margin: 0, - shadow: "sm", - ...(templateComp as any).areaStyle, - }, - children: [], - style: { - labelDisplay: true, - labelFontSize: "14px", - labelColor: "#212121", - labelFontWeight: "600", - labelMarginBottom: "8px", - ...templateComp.style, - }, - } as ComponentData; - } else { - // 위젯 컴포넌트 - const widgetType = templateComp.widgetType || "text"; - - // 웹타입별 기본 그리드 컬럼 수 계산 - const getDefaultGridColumnsForTemplate = (wType: string): number => { - const widthMap: Record = { - text: 4, - email: 4, - tel: 3, - url: 4, - textarea: 6, - number: 2, - decimal: 2, - date: 3, - datetime: 3, - time: 2, - select: 3, - radio: 3, - checkbox: 2, - boolean: 2, - code: 3, - entity: 4, - file: 4, - image: 3, - button: 2, - label: 2, - }; - return widthMap[wType] || 3; - }; - - // 웹타입별 기본 설정 생성 - const getDefaultWebTypeConfig = (wType: string) => { - switch (wType) { - case "date": - return { - format: "YYYY-MM-DD" as const, - showTime: false, - placeholder: templateComp.placeholder || "날짜를 선택하세요", - }; - case "select": - case "dropdown": - return { - options: [ - { label: "옵션 1", value: "option1" }, - { label: "옵션 2", value: "option2" }, - { label: "옵션 3", value: "option3" }, - ], - multiple: false, - searchable: false, - placeholder: templateComp.placeholder || "옵션을 선택하세요", - }; - case "text": - return { - format: "none" as const, - placeholder: templateComp.placeholder || "텍스트를 입력하세요", - multiline: false, - }; - case "email": - return { - format: "email" as const, - placeholder: templateComp.placeholder || "이메일을 입력하세요", - multiline: false, - }; - case "tel": - return { - format: "phone" as const, - placeholder: templateComp.placeholder || "전화번호를 입력하세요", - multiline: false, - }; - case "textarea": - return { - rows: 3, - placeholder: templateComp.placeholder || "텍스트를 입력하세요", - resizable: true, - wordWrap: true, - }; - default: - return { - placeholder: templateComp.placeholder || "입력하세요", - }; - } - }; - - // 위젯 크기도 격자에 맞게 조정 - const widgetSize = - currentGridInfo && layout.gridSettings?.snapToGrid - ? { - width: calculateWidthFromColumns(1, currentGridInfo, layout.gridSettings as GridUtilSettings), - height: templateComp.size.height, - } - : templateComp.size; - - return { - id: componentId, - type: "widget", - widgetType: widgetType as any, - label: templateComp.label, - placeholder: templateComp.placeholder, - columnName: `field_${index + 1}`, - parentId: templateComp.parentId ? idMapping[templateComp.parentId] : undefined, - position: finalPosition, - size: widgetSize, - required: templateComp.required || false, - readonly: templateComp.readonly || false, - gridColumns: getDefaultGridColumnsForTemplate(widgetType), - webTypeConfig: getDefaultWebTypeConfig(widgetType), - style: { - labelDisplay: true, - labelFontSize: "14px", - labelColor: "#212121", - labelFontWeight: "600", - labelMarginBottom: "8px", - ...templateComp.style, - }, - } as ComponentData; - } - }); - - // 🆕 현재 활성 레이어에 컴포넌트 추가 (ref 사용으로 클로저 문제 방지) - const componentsWithLayerId = newComponents.map((comp) => ({ - ...comp, - layerId: activeLayerIdRef.current || 1, - })); - - // 레이아웃에 새 컴포넌트들 추가 - const newLayout = { - ...layout, - components: [...layout.components, ...componentsWithLayerId], - }; - - setLayout(newLayout); - saveToHistory(newLayout); - - // 첫 번째 컴포넌트 선택 - if (componentsWithLayerId.length > 0) { - setSelectedComponent(componentsWithLayerId[0]); - } - - toast.success(`${template.name} 템플릿이 추가되었습니다.`); - }, - [layout, selectedScreen, saveToHistory], - ); - - // 레이아웃 드래그 처리 - const handleLayoutDrop = useCallback( - (e: React.DragEvent, layoutData: any) => { - const rect = canvasRef.current?.getBoundingClientRect(); - if (!rect) return; - - // 🔥 중요: 줌 레벨을 고려한 마우스 위치 계산 - const dropX = (e.clientX - rect.left) / zoomLevel; - const dropY = (e.clientY - rect.top) / zoomLevel; - - // 현재 해상도에 맞는 격자 정보 계산 - const currentGridInfo = layout.gridSettings - ? calculateGridInfo(screenResolution.width, screenResolution.height, { - columns: layout.gridSettings.columns, - gap: layout.gridSettings.gap, - padding: layout.gridSettings.padding, - snapToGrid: layout.gridSettings.snapToGrid || false, - }) - : null; - - // 격자 스냅 적용 - const snappedPosition = - layout.gridSettings?.snapToGrid && currentGridInfo - ? snapToGrid({ x: dropX, y: dropY, z: 1 }, currentGridInfo, layout.gridSettings as GridUtilSettings) - : { x: dropX, y: dropY, z: 1 }; - - console.log("🏗️ 레이아웃 드롭 (줌 보정):", { - zoomLevel, - layoutType: layoutData.layoutType, - zonesCount: layoutData.zones.length, - mouseRaw: { x: e.clientX - rect.left, y: e.clientY - rect.top }, - dropPosition: { x: dropX, y: dropY }, - snappedPosition, - }); - - // 레이아웃 컴포넌트 생성 - const newLayoutComponent: ComponentData = { - id: layoutData.id, - type: "layout", - layoutType: layoutData.layoutType, - layoutConfig: layoutData.layoutConfig, - zones: layoutData.zones.map((zone: any) => ({ - ...zone, - id: `${layoutData.id}_${zone.id}`, // 레이아웃 ID를 접두사로 추가 - })), - children: [], - position: snappedPosition, - size: layoutData.size, - label: layoutData.label, - allowedComponentTypes: layoutData.allowedComponentTypes, - dropZoneConfig: layoutData.dropZoneConfig, - layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용) - } as ComponentData; - - // 레이아웃에 새 컴포넌트 추가 - const newLayout = { - ...layout, - components: [...layout.components, newLayoutComponent], - }; - - setLayout(newLayout); - saveToHistory(newLayout); - - // 레이아웃 컴포넌트 선택 - setSelectedComponent(newLayoutComponent); - - toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`); - }, - [layout, screenResolution, saveToHistory, zoomLevel], - ); - - // handleZoneComponentDrop은 handleComponentDrop으로 대체됨 - - // 존 클릭 핸들러 - const handleZoneClick = useCallback((zoneId: string) => { - // console.log("🎯 존 클릭:", zoneId); - // 필요시 존 선택 로직 추가 - }, []); - - // 웹타입별 기본 설정 생성 함수를 상위로 이동 - const getDefaultWebTypeConfig = useCallback((webType: string) => { - switch (webType) { - case "button": - return { - actionType: "custom", - variant: "default", - confirmationMessage: "", - popupTitle: "", - popupContent: "", - navigateUrl: "", - }; - case "date": - return { - format: "YYYY-MM-DD", - showTime: false, - placeholder: "날짜를 선택하세요", - }; - case "number": - return { - format: "integer", - placeholder: "숫자를 입력하세요", - }; - case "select": - return { - options: [ - { label: "옵션 1", value: "option1" }, - { label: "옵션 2", value: "option2" }, - { label: "옵션 3", value: "option3" }, - ], - multiple: false, - searchable: false, - placeholder: "옵션을 선택하세요", - }; - case "file": - return { - accept: ["*/*"], - maxSize: 10485760, // 10MB - multiple: false, - showPreview: true, - autoUpload: false, - }; - default: - return {}; - } - }, []); - - // 컴포넌트 드래그 처리 (캔버스 레벨 드롭) - const handleComponentDrop = useCallback( - (e: React.DragEvent, component?: any, zoneId?: string, layoutId?: string) => { - // 존별 드롭인 경우 dragData에서 컴포넌트 정보 추출 - if (!component) { - const dragData = e.dataTransfer.getData("application/json"); - if (!dragData) return; - - try { - const parsedData = JSON.parse(dragData); - if (parsedData.type === "component") { - component = parsedData.component; - } else { - return; - } - } catch (error) { - // console.error("드래그 데이터 파싱 오류:", error); - return; - } - } - - // 🎯 리피터 컨테이너 내부 드롭 처리 - const dropTarget = e.target as HTMLElement; - const repeatContainer = dropTarget.closest('[data-repeat-container="true"]'); - if (repeatContainer) { - const containerId = repeatContainer.getAttribute("data-component-id"); - if (containerId) { - // 해당 리피터 컨테이너 찾기 - const targetComponent = layout.components.find((c) => c.id === containerId); - const compType = (targetComponent as any)?.componentType; - // v2-repeat-container 또는 repeat-container 모두 지원 - if (targetComponent && (compType === "repeat-container" || compType === "v2-repeat-container")) { - const currentConfig = (targetComponent as any).componentConfig || {}; - const currentChildren = currentConfig.children || []; - - // 새 자식 컴포넌트 생성 - const newChild = { - id: `slot_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, - componentType: component.id || component.componentType || "text-display", - label: component.name || component.label || "새 컴포넌트", - fieldName: "", - position: { x: 0, y: currentChildren.length * 40 }, - size: component.defaultSize || { width: 200, height: 32 }, - componentConfig: component.defaultConfig || {}, - }; - - // 컴포넌트 업데이트 - const updatedComponent = { - ...targetComponent, - componentConfig: { - ...currentConfig, - children: [...currentChildren, newChild], - }, - }; - - const newLayout = { - ...layout, - components: layout.components.map((c) => (c.id === containerId ? updatedComponent : c)), - }; - - setLayout(newLayout); - saveToHistory(newLayout); - return; // 리피터 컨테이너 처리 완료 - } - } - } - - // 🎯 탭 컨테이너 내부 드롭 처리 (중첩 구조 지원) - const tabsContainer = dropTarget.closest('[data-tabs-container="true"]'); - if (tabsContainer) { - const containerId = tabsContainer.getAttribute("data-component-id"); - const activeTabId = tabsContainer.getAttribute("data-active-tab-id"); - if (containerId && activeTabId) { - // 1. 먼저 최상위 레이아웃에서 탭 컴포넌트 찾기 - let targetComponent = layout.components.find((c) => c.id === containerId); - let parentSplitPanelId: string | null = null; - let parentPanelSide: "left" | "right" | null = null; - - // 2. 최상위에 없으면 분할 패널 안에서 중첩된 탭 찾기 - if (!targetComponent) { - for (const comp of layout.components) { - const compType = (comp as any)?.componentType; - if (compType === "split-panel-layout" || compType === "v2-split-panel-layout") { - const config = (comp as any).componentConfig || {}; - - // 좌측 패널에서 찾기 - const leftComponents = config.leftPanel?.components || []; - const foundInLeft = leftComponents.find((c: any) => c.id === containerId); - if (foundInLeft) { - targetComponent = foundInLeft; - parentSplitPanelId = comp.id; - parentPanelSide = "left"; - break; - } - - // 우측 패널에서 찾기 - const rightComponents = config.rightPanel?.components || []; - const foundInRight = rightComponents.find((c: any) => c.id === containerId); - if (foundInRight) { - targetComponent = foundInRight; - parentSplitPanelId = comp.id; - parentPanelSide = "right"; - break; - } - } - } - } - - const compType = (targetComponent as any)?.componentType; - if (targetComponent && (compType === "tabs-widget" || compType === "v2-tabs-widget")) { - const currentConfig = (targetComponent as any).componentConfig || {}; - const tabs = currentConfig.tabs || []; - - // 활성 탭의 드롭 위치 계산 - const tabContentRect = tabsContainer.getBoundingClientRect(); - const dropX = (e.clientX - tabContentRect.left) / zoomLevel; - const dropY = (e.clientY - tabContentRect.top) / zoomLevel; - - // 새 컴포넌트 생성 - const componentType = component.id || component.componentType || "v2-text-display"; - - console.log("🎯 탭에 컴포넌트 드롭:", { - componentId: component.id, - componentType: componentType, - componentName: component.name, - isNested: !!parentSplitPanelId, - parentSplitPanelId, - parentPanelSide, - // 🆕 위치 디버깅 - clientX: e.clientX, - clientY: e.clientY, - tabContentRect: { left: tabContentRect.left, top: tabContentRect.top }, - zoomLevel, - calculatedPosition: { x: dropX, y: dropY }, - }); - - const newTabComponent = { - id: `tab_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, - componentType: componentType, - label: component.name || component.label || "새 컴포넌트", - position: { x: Math.max(0, dropX), y: Math.max(0, dropY) }, - size: component.defaultSize || { width: 200, height: 100 }, - componentConfig: component.defaultConfig || {}, - }; - - // 해당 탭에 컴포넌트 추가 - const updatedTabs = tabs.map((tab: any) => { - if (tab.id === activeTabId) { - return { - ...tab, - components: [...(tab.components || []), newTabComponent], - }; - } - return tab; - }); - - const updatedTabsComponent = { - ...targetComponent, - componentConfig: { - ...currentConfig, - tabs: updatedTabs, - }, - }; - - let newLayout; - - if (parentSplitPanelId && parentPanelSide) { - // 🆕 중첩 구조: 분할 패널 안의 탭 업데이트 - newLayout = { - ...layout, - components: layout.components.map((c) => { - if (c.id === parentSplitPanelId) { - const splitConfig = (c as any).componentConfig || {}; - const panelKey = parentPanelSide === "left" ? "leftPanel" : "rightPanel"; - const panelConfig = splitConfig[panelKey] || {}; - const panelComponents = panelConfig.components || []; - - return { - ...c, - componentConfig: { - ...splitConfig, - [panelKey]: { - ...panelConfig, - components: panelComponents.map((pc: any) => - pc.id === containerId ? updatedTabsComponent : pc, - ), - }, - }, - }; - } - return c; - }), - }; - toast.success("컴포넌트가 중첩된 탭에 추가되었습니다"); - } else { - // 일반 구조: 최상위 탭 업데이트 - newLayout = { - ...layout, - components: layout.components.map((c) => (c.id === containerId ? updatedTabsComponent : c)), - }; - toast.success("컴포넌트가 탭에 추가되었습니다"); - } - - setLayout(newLayout); - saveToHistory(newLayout); - return; // 탭 컨테이너 처리 완료 - } - } - } - - // 🎯 분할 패널 커스텀 모드 컨테이너 내부 드롭 처리 - const splitPanelContainer = dropTarget.closest('[data-split-panel-container="true"]'); - if (splitPanelContainer) { - const containerId = splitPanelContainer.getAttribute("data-component-id"); - const panelSide = splitPanelContainer.getAttribute("data-panel-side"); // "left" or "right" - if (containerId && panelSide) { - const targetComponent = layout.components.find((c) => c.id === containerId); - const compType = (targetComponent as any)?.componentType; - if (targetComponent && (compType === "split-panel-layout" || compType === "v2-split-panel-layout")) { - const currentConfig = (targetComponent as any).componentConfig || {}; - const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel"; - const panelConfig = currentConfig[panelKey] || {}; - const currentComponents = panelConfig.components || []; - - // 드롭 위치 계산 - const panelRect = splitPanelContainer.getBoundingClientRect(); - const dropX = (e.clientX - panelRect.left) / zoomLevel; - const dropY = (e.clientY - panelRect.top) / zoomLevel; - - // 새 컴포넌트 생성 - const componentType = component.id || component.componentType || "v2-text-display"; - - console.log("🎯 분할 패널에 컴포넌트 드롭:", { - componentId: component.id, - componentType: componentType, - panelSide: panelSide, - dropPosition: { x: dropX, y: dropY }, - }); - - const newPanelComponent = { - id: `panel_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, - componentType: componentType, - label: component.name || component.label || "새 컴포넌트", - position: { x: Math.max(0, dropX), y: Math.max(0, dropY) }, - size: component.defaultSize || { width: 200, height: 100 }, - componentConfig: component.defaultConfig || {}, - }; - - const updatedPanelConfig = { - ...panelConfig, - components: [...currentComponents, newPanelComponent], - }; - - const updatedComponent = { - ...targetComponent, - componentConfig: { - ...currentConfig, - [panelKey]: updatedPanelConfig, - }, - }; - - const newLayout = { - ...layout, - components: layout.components.map((c) => (c.id === containerId ? updatedComponent : c)), - }; - - setLayout(newLayout); - saveToHistory(newLayout); - toast.success(`컴포넌트가 ${panelSide === "left" ? "좌측" : "우측"} 패널에 추가되었습니다`); - return; // 분할 패널 처리 완료 - } - } - } - - const rect = canvasRef.current?.getBoundingClientRect(); - if (!rect) return; - - // 컴포넌트 크기 정보 - const componentWidth = component.defaultSize?.width || 120; - const componentHeight = component.defaultSize?.height || 36; - - // 🔥 중요: 줌 레벨과 transform-origin을 고려한 마우스 위치 계산 - // 1. 캔버스가 scale() 변환되어 있음 (transform-origin: top center) - // 2. 캔버스가 justify-center로 중앙 정렬되어 있음 - - // 실제 캔버스 논리적 크기 - const canvasLogicalWidth = screenResolution.width; - - // 화면상 캔버스 실제 크기 (스케일 적용 후) - const canvasVisualWidth = canvasLogicalWidth * zoomLevel; - - // 중앙 정렬로 인한 왼쪽 오프셋 계산 - // rect.left는 이미 중앙 정렬된 위치를 반영하고 있음 - - // 마우스의 캔버스 내 상대 위치 (스케일 보정) - const mouseXInCanvas = (e.clientX - rect.left) / zoomLevel; - const mouseYInCanvas = (e.clientY - rect.top) / zoomLevel; - - // 방법 1: 마우스 포인터를 컴포넌트 중심으로 - const dropX_centered = mouseXInCanvas - componentWidth / 2; - const dropY_centered = mouseYInCanvas - componentHeight / 2; - - // 방법 2: 마우스 포인터를 컴포넌트 좌상단으로 - const dropX_topleft = mouseXInCanvas; - const dropY_topleft = mouseYInCanvas; - - // 사용자가 원하는 방식으로 변경: 마우스 포인터가 좌상단에 오도록 - const dropX = dropX_topleft; - const dropY = dropY_topleft; - - console.log("🎯 위치 계산 디버깅 (줌 레벨 + 중앙정렬 반영):", { - "1. 줌 레벨": zoomLevel, - "2. 마우스 위치 (화면)": { clientX: e.clientX, clientY: e.clientY }, - "3. 캔버스 위치 (rect)": { left: rect.left, top: rect.top, width: rect.width, height: rect.height }, - "4. 캔버스 논리적 크기": { width: canvasLogicalWidth, height: screenResolution.height }, - "5. 캔버스 시각적 크기": { width: canvasVisualWidth, height: screenResolution.height * zoomLevel }, - "6. 마우스 캔버스 내 상대위치 (줌 전)": { x: e.clientX - rect.left, y: e.clientY - rect.top }, - "7. 마우스 캔버스 내 상대위치 (줌 보정)": { x: mouseXInCanvas, y: mouseYInCanvas }, - "8. 컴포넌트 크기": { width: componentWidth, height: componentHeight }, - "9a. 중심 방식": { x: dropX_centered, y: dropY_centered }, - "9b. 좌상단 방식": { x: dropX_topleft, y: dropY_topleft }, - "10. 최종 선택": { dropX, dropY }, - }); - - // 현재 해상도에 맞는 격자 정보 계산 - const currentGridInfo = layout.gridSettings - ? calculateGridInfo(screenResolution.width, screenResolution.height, { - columns: layout.gridSettings.columns, - gap: layout.gridSettings.gap, - padding: layout.gridSettings.padding, - snapToGrid: layout.gridSettings.snapToGrid || false, - }) - : null; - - // 캔버스 경계 내로 위치 제한 (조건부 레이어 편집 시 displayRegion 크기 기준) - const currentLayerId = activeLayerIdRef.current || 1; - const activeLayerRegion = currentLayerId > 1 ? layerRegions[currentLayerId] : null; - const canvasBoundW = activeLayerRegion ? activeLayerRegion.width : screenResolution.width; - const canvasBoundH = activeLayerRegion ? activeLayerRegion.height : screenResolution.height; - const boundedX = Math.max(0, Math.min(dropX, canvasBoundW - componentWidth)); - const boundedY = Math.max(0, Math.min(dropY, canvasBoundH - componentHeight)); - - // 격자 스냅 적용 - const snappedPosition = - layout.gridSettings?.snapToGrid && currentGridInfo - ? snapPositionTo10px( - { x: boundedX, y: boundedY, z: 1 }, - currentGridInfo, - layout.gridSettings as GridUtilSettings, - ) - : { x: boundedX, y: boundedY, z: 1 }; - - console.log("🧩 컴포넌트 드롭:", { - componentName: component.name, - webType: component.webType, - rawPosition: { x: dropX, y: dropY }, - boundedPosition: { x: boundedX, y: boundedY }, - snappedPosition, - }); - - // 새 컴포넌트 생성 (새 컴포넌트 시스템 지원) - console.log("🔍 ScreenDesigner handleComponentDrop:", { - componentName: component.name, - componentId: component.id, - webType: component.webType, - category: component.category, - defaultConfig: component.defaultConfig, - defaultSize: component.defaultSize, - }); - - // 컴포넌트별 gridColumns 설정 및 크기 계산 - let componentSize = component.defaultSize; - const isCardDisplay = component.id === "card-display"; - const isTableList = component.id === "table-list"; - - // 컴포넌트 타입별 기본 그리드 컬럼 수 설정 - const currentGridColumns = layout.gridSettings.columns; // 현재 격자 컬럼 수 - let gridColumns = 1; // 기본값 - - // 특수 컴포넌트 - if (isCardDisplay) { - gridColumns = Math.round(currentGridColumns * 0.667); // 약 66.67% - } else if (isTableList) { - gridColumns = currentGridColumns; // 테이블은 전체 너비 - } else { - // 웹타입별 적절한 그리드 컬럼 수 설정 - const webType = component.webType; - const componentId = component.id; - - // 웹타입별 기본 비율 매핑 (12컬럼 기준 비율) - const gridColumnsRatioMap: Record = { - // 입력 컴포넌트 (INPUT 카테고리) - "text-input": 4 / 12, // 텍스트 입력 (33%) - "number-input": 2 / 12, // 숫자 입력 (16.67%) - "email-input": 4 / 12, // 이메일 입력 (33%) - "tel-input": 3 / 12, // 전화번호 입력 (25%) - "date-input": 3 / 12, // 날짜 입력 (25%) - "datetime-input": 4 / 12, // 날짜시간 입력 (33%) - "time-input": 2 / 12, // 시간 입력 (16.67%) - "textarea-basic": 6 / 12, // 텍스트 영역 (50%) - "select-basic": 3 / 12, // 셀렉트 (25%) - "checkbox-basic": 2 / 12, // 체크박스 (16.67%) - "radio-basic": 3 / 12, // 라디오 (25%) - "file-basic": 4 / 12, // 파일 (33%) - "file-upload": 4 / 12, // 파일 업로드 (33%) - "slider-basic": 3 / 12, // 슬라이더 (25%) - "toggle-switch": 2 / 12, // 토글 스위치 (16.67%) - "repeater-field-group": 6 / 12, // 반복 필드 그룹 (50%) - - // 표시 컴포넌트 (DISPLAY 카테고리) - "label-basic": 2 / 12, // 라벨 (16.67%) - "text-display": 3 / 12, // 텍스트 표시 (25%) - "card-display": 8 / 12, // 카드 (66.67%) - "badge-basic": 1 / 12, // 배지 (8.33%) - "alert-basic": 6 / 12, // 알림 (50%) - "divider-basic": 1, // 구분선 (100%) - "divider-line": 1, // 구분선 (100%) - "accordion-basic": 1, // 아코디언 (100%) - "table-list": 1, // 테이블 리스트 (100%) - "image-display": 4 / 12, // 이미지 표시 (33%) - "split-panel-layout": 6 / 12, // 분할 패널 레이아웃 (50%) - "flow-widget": 1, // 플로우 위젯 (100%) - - // 액션 컴포넌트 (ACTION 카테고리) - "button-basic": 1 / 12, // 버튼 (8.33%) - "button-primary": 1 / 12, // 프라이머리 버튼 (8.33%) - "button-secondary": 1 / 12, // 세컨더리 버튼 (8.33%) - "icon-button": 1 / 12, // 아이콘 버튼 (8.33%) - - // 레이아웃 컴포넌트 - "container-basic": 6 / 12, // 컨테이너 (50%) - "section-basic": 1, // 섹션 (100%) - "panel-basic": 6 / 12, // 패널 (50%) - - // 기타 - "image-basic": 4 / 12, // 이미지 (33%) - "icon-basic": 1 / 12, // 아이콘 (8.33%) - "progress-bar": 4 / 12, // 프로그레스 바 (33%) - "chart-basic": 6 / 12, // 차트 (50%) - }; - - // defaultSize에 gridColumnSpan이 "full"이면 전체 컬럼 사용 - if (component.defaultSize?.gridColumnSpan === "full") { - gridColumns = currentGridColumns; - } else { - // componentId 또는 webType으로 비율 찾기, 없으면 기본값 25% - const ratio = gridColumnsRatioMap[componentId] || gridColumnsRatioMap[webType] || 0.25; - // 현재 격자 컬럼 수에 비율을 곱하여 계산 (최소 1, 최대 currentGridColumns) - gridColumns = Math.max(1, Math.min(currentGridColumns, Math.round(ratio * currentGridColumns))); - } - - console.log("🎯 컴포넌트 타입별 gridColumns 설정:", { - componentId, - webType, - gridColumns, - }); - } - - // 10px 단위로 너비 스냅 - if (layout.gridSettings?.snapToGrid) { - componentSize = { - ...component.defaultSize, - width: snapTo10px(component.defaultSize.width), - height: snapTo10px(component.defaultSize.height), - }; - } - - console.log("🎨 최종 컴포넌트 크기:", { - componentId: component.id, - componentName: component.name, - defaultSize: component.defaultSize, - finalSize: componentSize, - gridColumns, - }); - - // 반복 필드 그룹인 경우 테이블의 첫 번째 컬럼을 기본 필드로 추가 - let enhancedDefaultConfig = { ...component.defaultConfig }; - if ( - component.id === "repeater-field-group" && - tables && - tables.length > 0 && - tables[0].columns && - tables[0].columns.length > 0 - ) { - const firstColumn = tables[0].columns[0]; - enhancedDefaultConfig = { - ...enhancedDefaultConfig, - fields: [ - { - name: firstColumn.columnName, - label: firstColumn.columnLabel || firstColumn.columnName, - type: (firstColumn.widgetType as any) || "text", - required: firstColumn.required || false, - placeholder: `${firstColumn.columnLabel || firstColumn.columnName}을(를) 입력하세요`, - }, - ], - }; - } - - // gridColumns에 맞춰 width를 퍼센트로 계산 - const widthPercent = (gridColumns / currentGridColumns) * 100; - - console.log("🎨 [컴포넌트 생성] 너비 계산:", { - componentName: component.name, - componentId: component.id, - currentGridColumns, - gridColumns, - widthPercent: `${widthPercent}%`, - calculatedWidth: `${Math.round(widthPercent * 100) / 100}%`, - }); - - // 🆕 라벨을 기반으로 기본 columnName 생성 (한글 → 스네이크 케이스) - // 예: "창고코드" → "warehouse_code" 또는 그대로 유지 - const generateDefaultColumnName = (label: string): string => { - // 한글 라벨의 경우 그대로 사용 (나중에 사용자가 수정 가능) - // 영문의 경우 스네이크 케이스로 변환 - if (/[가-힣]/.test(label)) { - // 한글이 포함된 경우: 공백을 언더스코어로, 소문자로 변환 - return label.replace(/\s+/g, "_").toLowerCase(); - } - // 영문의 경우: 카멜케이스/파스칼케이스를 스네이크 케이스로 변환 - return label - .replace(/([a-z])([A-Z])/g, "$1_$2") - .replace(/\s+/g, "_") - .toLowerCase(); - }; - - const newComponent: ComponentData = { - id: generateComponentId(), - type: "component", // ✅ 새 컴포넌트 시스템 사용 - label: component.name, - columnName: generateDefaultColumnName(component.name), // 🆕 기본 columnName 자동 생성 - widgetType: component.webType, - componentType: component.id, // 새 컴포넌트 시스템의 ID (DynamicComponentRenderer용) - position: snappedPosition, - size: componentSize, - gridColumns: gridColumns, // 컴포넌트별 그리드 컬럼 수 적용 - layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용) - componentConfig: { - type: component.id, // 새 컴포넌트 시스템의 ID 사용 - webType: component.webType, // 웹타입 정보 추가 - ...enhancedDefaultConfig, - }, - webTypeConfig: getDefaultWebTypeConfig(component.webType), - style: { - labelDisplay: true, // 🆕 라벨 기본 표시 (사용자가 끄고 싶으면 체크 해제) - labelFontSize: "14px", - labelColor: "#212121", - labelFontWeight: "500", - labelMarginBottom: "4px", - width: `${componentSize.width}px`, // size와 동기화 (픽셀 단위) - height: `${componentSize.height}px`, // size와 동기화 (픽셀 단위) - }, - }; - - // 레이아웃에 컴포넌트 추가 - const newLayout: LayoutData = { - ...layout, - components: [...layout.components, newComponent], - }; - - setLayout(newLayout); - saveToHistory(newLayout); - - // 새 컴포넌트 선택 - setSelectedComponent(newComponent); - // 🔧 테이블 패널 유지를 위해 자동 속성 패널 열기 비활성화 - // openPanel("properties"); - - toast.success(`${component.name} 컴포넌트가 추가되었습니다.`); - }, - [layout, selectedScreen, saveToHistory], - ); - - // 드래그 앤 드롭 처리 - const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); - }, []); - - const handleDrop = useCallback( - async (e: React.DragEvent) => { - e.preventDefault(); - - const dragData = e.dataTransfer.getData("application/json"); - // console.log("🎯 드롭 이벤트:", { dragData }); - if (!dragData) { - // console.log("❌ 드래그 데이터가 없습니다"); - return; - } - - try { - const parsedData = JSON.parse(dragData); - // console.log("📋 파싱된 데이터:", parsedData); - - // 템플릿 드래그인 경우 - if (parsedData.type === "template") { - handleTemplateDrop(e, parsedData.template); - return; - } - - // 레이아웃 드래그인 경우 - if (parsedData.type === "layout") { - handleLayoutDrop(e, parsedData.layout); - return; - } - - // 컴포넌트 드래그인 경우 - if (parsedData.type === "component") { - handleComponentDrop(e, parsedData.component); - return; - } - - // 🆕 조건부 레이어 영역 드래그인 경우 → DB condition_config에 displayRegion 저장 - if (parsedData.type === "layer-region" && parsedData.layerId && selectedScreen?.screenId) { - const canvasRect = canvasRef.current?.getBoundingClientRect(); - if (!canvasRect) return; - const dropX = Math.round((e.clientX - canvasRect.left) / zoomLevel); - const dropY = Math.round((e.clientY - canvasRect.top) / zoomLevel); - const newRegion = { - x: Math.max(0, dropX - 400), - y: Math.max(0, dropY), - width: Math.min(800, screenResolution.width), - height: 200, - }; - // DB에 displayRegion 저장 (condition_config에 포함) - try { - // 기존 condition_config를 가져와서 displayRegion만 추가/업데이트 - const layerData = await screenApi.getLayerLayout(selectedScreen.screenId, parsedData.layerId); - const existingCondition = layerData?.conditionConfig || {}; - await screenApi.updateLayerCondition( - selectedScreen.screenId, - parsedData.layerId, - { ...existingCondition, displayRegion: newRegion } - ); - // 레이어 영역 state에 반영 (캔버스에 즉시 표시) - setLayerRegions((prev) => ({ - ...prev, - [parsedData.layerId]: { ...newRegion, layerName: parsedData.layerName }, - })); - toast.success(`"${parsedData.layerName}" 영역이 배치되었습니다.`); - } catch (error) { - console.error("레이어 영역 저장 실패:", error); - toast.error("레이어 영역 저장에 실패했습니다."); - } - return; - } - - // 기존 테이블/컬럼 드래그 처리 - const { type, table, column } = parsedData; - - // 드롭 대상이 폼 컨테이너인지 확인 - const dropTarget = e.target as HTMLElement; - const formContainer = dropTarget.closest('[data-form-container="true"]'); - - // 🎯 리피터 컨테이너 내부에 컬럼 드롭 시 처리 - const repeatContainer = dropTarget.closest('[data-repeat-container="true"]'); - if (repeatContainer && type === "column" && column) { - const containerId = repeatContainer.getAttribute("data-component-id"); - if (containerId) { - const targetComponent = layout.components.find((c) => c.id === containerId); - const rcType = (targetComponent as any)?.componentType; - if (targetComponent && (rcType === "repeat-container" || rcType === "v2-repeat-container")) { - const currentConfig = (targetComponent as any).componentConfig || {}; - const currentChildren = currentConfig.children || []; - - // 새 자식 컴포넌트 생성 (컬럼 기반) - const newChild = { - id: `slot_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, - componentType: column.widgetType || "text-display", - label: column.columnLabel || column.columnName, - fieldName: column.columnName, - position: { x: 0, y: currentChildren.length * 40 }, - size: { width: 200, height: 32 }, - componentConfig: {}, - }; - - const updatedComponent = { - ...targetComponent, - componentConfig: { - ...currentConfig, - children: [...currentChildren, newChild], - }, - }; - - const newLayout = { - ...layout, - components: layout.components.map((c) => (c.id === containerId ? updatedComponent : c)), - }; - - setLayout(newLayout); - saveToHistory(newLayout); - return; - } - } - } - - // 🎯 탭 컨테이너 내부에 컬럼 드롭 시 처리 (중첩 구조 지원) - const tabsContainer = dropTarget.closest('[data-tabs-container="true"]'); - if (tabsContainer && type === "column" && column) { - const containerId = tabsContainer.getAttribute("data-component-id"); - const activeTabId = tabsContainer.getAttribute("data-active-tab-id"); - if (containerId && activeTabId) { - // 1. 먼저 최상위 레이아웃에서 탭 컴포넌트 찾기 - let targetComponent = layout.components.find((c) => c.id === containerId); - let parentSplitPanelId: string | null = null; - let parentPanelSide: "left" | "right" | null = null; - - // 2. 최상위에 없으면 분할 패널 안에서 중첩된 탭 찾기 - if (!targetComponent) { - for (const comp of layout.components) { - const compType = (comp as any)?.componentType; - if (compType === "split-panel-layout" || compType === "v2-split-panel-layout") { - const config = (comp as any).componentConfig || {}; - - // 좌측 패널에서 찾기 - const leftComponents = config.leftPanel?.components || []; - const foundInLeft = leftComponents.find((c: any) => c.id === containerId); - if (foundInLeft) { - targetComponent = foundInLeft; - parentSplitPanelId = comp.id; - parentPanelSide = "left"; - break; - } - - // 우측 패널에서 찾기 - const rightComponents = config.rightPanel?.components || []; - const foundInRight = rightComponents.find((c: any) => c.id === containerId); - if (foundInRight) { - targetComponent = foundInRight; - parentSplitPanelId = comp.id; - parentPanelSide = "right"; - break; - } - } - } - } - - const compType = (targetComponent as any)?.componentType; - if (targetComponent && (compType === "tabs-widget" || compType === "v2-tabs-widget")) { - const currentConfig = (targetComponent as any).componentConfig || {}; - const tabs = currentConfig.tabs || []; - - // 드롭 위치 계산 - const tabContentRect = tabsContainer.getBoundingClientRect(); - const dropX = (e.clientX - tabContentRect.left) / zoomLevel; - const dropY = (e.clientY - tabContentRect.top) / zoomLevel; - - // 🆕 V2 컴포넌트 매핑 사용 (일반 캔버스와 동일) - const v2Mapping = createV2ConfigFromColumn({ - widgetType: column.widgetType, - columnName: column.columnName, - columnLabel: column.columnLabel, - codeCategory: column.codeCategory, - inputType: column.inputType, - required: column.required, - detailSettings: column.detailSettings, - referenceTable: column.referenceTable, - referenceColumn: column.referenceColumn, - displayColumn: column.displayColumn, - }); - - // 웹타입별 기본 크기 계산 - const getTabComponentSize = (widgetType: string) => { - const sizeMap: Record = { - text: { width: 200, height: 36 }, - number: { width: 150, height: 36 }, - decimal: { width: 150, height: 36 }, - date: { width: 180, height: 36 }, - datetime: { width: 200, height: 36 }, - select: { width: 200, height: 36 }, - category: { width: 200, height: 36 }, - code: { width: 200, height: 36 }, - entity: { width: 220, height: 36 }, - boolean: { width: 120, height: 36 }, - checkbox: { width: 120, height: 36 }, - textarea: { width: 300, height: 100 }, - file: { width: 250, height: 80 }, - }; - return sizeMap[widgetType] || { width: 200, height: 36 }; - }; - - const componentSize = getTabComponentSize(column.widgetType); - - const newTabComponent = { - id: `tab_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, - componentType: v2Mapping.componentType, - label: column.columnLabel || column.columnName, - position: { x: Math.max(0, dropX), y: Math.max(0, dropY) }, - size: componentSize, - inputType: column.inputType || column.widgetType, - widgetType: column.widgetType, - componentConfig: { - ...v2Mapping.componentConfig, - columnName: column.columnName, - tableName: column.tableName, - inputType: column.inputType || column.widgetType, - }, - }; - - // 해당 탭에 컴포넌트 추가 - const updatedTabs = tabs.map((tab: any) => { - if (tab.id === activeTabId) { - return { - ...tab, - components: [...(tab.components || []), newTabComponent], - }; - } - return tab; - }); - - const updatedTabsComponent = { - ...targetComponent, - componentConfig: { - ...currentConfig, - tabs: updatedTabs, - }, - }; - - let newLayout; - - if (parentSplitPanelId && parentPanelSide) { - // 🆕 중첩 구조: 분할 패널 안의 탭 업데이트 - newLayout = { - ...layout, - components: layout.components.map((c) => { - if (c.id === parentSplitPanelId) { - const splitConfig = (c as any).componentConfig || {}; - const panelKey = parentPanelSide === "left" ? "leftPanel" : "rightPanel"; - const panelConfig = splitConfig[panelKey] || {}; - const panelComponents = panelConfig.components || []; - - return { - ...c, - componentConfig: { - ...splitConfig, - [panelKey]: { - ...panelConfig, - components: panelComponents.map((pc: any) => - pc.id === containerId ? updatedTabsComponent : pc, - ), - }, - }, - }; - } - return c; - }), - }; - toast.success("컬럼이 중첩된 탭에 추가되었습니다"); - } else { - // 일반 구조: 최상위 탭 업데이트 - newLayout = { - ...layout, - components: layout.components.map((c) => (c.id === containerId ? updatedTabsComponent : c)), - }; - toast.success("컬럼이 탭에 추가되었습니다"); - } - - setLayout(newLayout); - saveToHistory(newLayout); - return; - } - } - } - - // 🎯 분할 패널 커스텀 모드 컨테이너 내부에 컬럼 드롭 시 처리 - const splitPanelContainer = dropTarget.closest('[data-split-panel-container="true"]'); - if (splitPanelContainer && type === "column" && column) { - const containerId = splitPanelContainer.getAttribute("data-component-id"); - const panelSide = splitPanelContainer.getAttribute("data-panel-side"); // "left" or "right" - if (containerId && panelSide) { - const targetComponent = layout.components.find((c) => c.id === containerId); - const compType = (targetComponent as any)?.componentType; - if (targetComponent && (compType === "split-panel-layout" || compType === "v2-split-panel-layout")) { - const currentConfig = (targetComponent as any).componentConfig || {}; - const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel"; - const panelConfig = currentConfig[panelKey] || {}; - const currentComponents = panelConfig.components || []; - - // 드롭 위치 계산 - const panelRect = splitPanelContainer.getBoundingClientRect(); - const dropX = (e.clientX - panelRect.left) / zoomLevel; - const dropY = (e.clientY - panelRect.top) / zoomLevel; - - // V2 컴포넌트 매핑 사용 - const v2Mapping = createV2ConfigFromColumn({ - widgetType: column.widgetType, - columnName: column.columnName, - columnLabel: column.columnLabel, - codeCategory: column.codeCategory, - inputType: column.inputType, - required: column.required, - detailSettings: column.detailSettings, - referenceTable: column.referenceTable, - referenceColumn: column.referenceColumn, - displayColumn: column.displayColumn, - }); - - // 웹타입별 기본 크기 계산 - const getPanelComponentSize = (widgetType: string) => { - const sizeMap: Record = { - text: { width: 200, height: 36 }, - number: { width: 150, height: 36 }, - decimal: { width: 150, height: 36 }, - date: { width: 180, height: 36 }, - datetime: { width: 200, height: 36 }, - select: { width: 200, height: 36 }, - category: { width: 200, height: 36 }, - code: { width: 200, height: 36 }, - entity: { width: 220, height: 36 }, - boolean: { width: 120, height: 36 }, - checkbox: { width: 120, height: 36 }, - textarea: { width: 300, height: 100 }, - file: { width: 250, height: 80 }, - }; - return sizeMap[widgetType] || { width: 200, height: 36 }; - }; - - const componentSize = getPanelComponentSize(column.widgetType); - - const newPanelComponent = { - id: `panel_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, - componentType: v2Mapping.componentType, - label: column.columnLabel || column.columnName, - position: { x: Math.max(0, dropX), y: Math.max(0, dropY) }, - size: componentSize, - inputType: column.inputType || column.widgetType, - widgetType: column.widgetType, - componentConfig: { - ...v2Mapping.componentConfig, - columnName: column.columnName, - tableName: column.tableName, - inputType: column.inputType || column.widgetType, - }, - }; - - const updatedPanelConfig = { - ...panelConfig, - components: [...currentComponents, newPanelComponent], - }; - - const updatedComponent = { - ...targetComponent, - componentConfig: { - ...currentConfig, - [panelKey]: updatedPanelConfig, - }, - }; - - const newLayout = { - ...layout, - components: layout.components.map((c) => (c.id === containerId ? updatedComponent : c)), - }; - - setLayout(newLayout); - saveToHistory(newLayout); - toast.success(`컬럼이 ${panelSide === "left" ? "좌측" : "우측"} 패널에 추가되었습니다`); - return; - } - } - } - - const rect = canvasRef.current?.getBoundingClientRect(); - if (!rect) return; - - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - - let newComponent: ComponentData; - - if (type === "table") { - // 테이블 컨테이너 생성 - newComponent = { - id: generateComponentId(), - type: "container", - label: table.tableLabel || table.tableName, // 테이블 라벨 우선, 없으면 테이블명 - tableName: table.tableName, - position: { x, y, z: 1 } as Position, - size: { width: 300, height: 200 }, - layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용) - style: { - labelDisplay: true, - labelFontSize: "14px", - labelColor: "#212121", - labelFontWeight: "600", - labelMarginBottom: "8px", - }, - }; - } else if (type === "column") { - // console.log("🔄 컬럼 드롭 처리:", { webType: column.widgetType, columnName: column.columnName }); - - // 웹타입별 기본 너비 계산 (10px 단위 고정) - const getDefaultWidth = (widgetType: string): number => { - const widthMap: Record = { - // 텍스트 입력 계열 - text: 200, - email: 200, - tel: 150, - url: 250, - textarea: 300, - - // 숫자/날짜 입력 - number: 120, - decimal: 120, - date: 150, - datetime: 180, - time: 120, - - // 선택 입력 - select: 180, - radio: 180, - checkbox: 120, - boolean: 120, - - // 코드/참조 - code: 180, - entity: 200, - - // 파일/이미지 - file: 250, - image: 200, - - // 기타 - button: 100, - label: 100, - }; - - return widthMap[widgetType] || 200; // 기본값 200px - }; - - // 웹타입별 기본 높이 계산 - const getDefaultHeight = (widgetType: string): number => { - const heightMap: Record = { - textarea: 120, // 텍스트 영역은 3줄 (40 * 3) - checkbox: 80, // 체크박스 그룹 (40 * 2) - radio: 80, // 라디오 버튼 (40 * 2) - file: 240, // 파일 업로드 (40 * 6) - }; - - return heightMap[widgetType] || 30; // 기본값 30px로 변경 - }; - - // 웹타입별 기본 설정 생성 - const getDefaultWebTypeConfig = (widgetType: string) => { - switch (widgetType) { - case "date": - return { - format: "YYYY-MM-DD" as const, - showTime: false, - placeholder: "날짜를 선택하세요", - }; - case "datetime": - return { - format: "YYYY-MM-DD HH:mm" as const, - showTime: true, - placeholder: "날짜와 시간을 선택하세요", - }; - case "number": - return { - format: "integer" as const, - placeholder: "숫자를 입력하세요", - }; - case "decimal": - return { - format: "decimal" as const, - step: 0.01, - decimalPlaces: 2, - placeholder: "소수를 입력하세요", - }; - case "select": - case "dropdown": - return { - options: [ - { label: "옵션 1", value: "option1" }, - { label: "옵션 2", value: "option2" }, - { label: "옵션 3", value: "option3" }, - ], - multiple: false, - searchable: false, - placeholder: "옵션을 선택하세요", - }; - case "text": - return { - format: "none" as const, - placeholder: "텍스트를 입력하세요", - multiline: false, - }; - case "email": - return { - format: "email" as const, - placeholder: "이메일을 입력하세요", - multiline: false, - }; - case "tel": - return { - format: "phone" as const, - placeholder: "전화번호를 입력하세요", - multiline: false, - }; - case "textarea": - return { - rows: 3, - placeholder: "텍스트를 입력하세요", - resizable: true, - autoResize: false, - wordWrap: true, - }; - case "checkbox": - case "boolean": - return { - defaultChecked: false, - labelPosition: "right" as const, - checkboxText: "", - trueValue: true, - falseValue: false, - indeterminate: false, - }; - case "radio": - return { - options: [ - { label: "옵션 1", value: "option1" }, - { label: "옵션 2", value: "option2" }, - ], - layout: "vertical" as const, - defaultValue: "", - allowNone: false, - }; - case "file": - return { - accept: "", - multiple: false, - maxSize: 10, - maxFiles: 1, - preview: true, - dragDrop: true, - allowedExtensions: [], - }; - case "code": - return { - codeCategory: "", // 기본값, 실제로는 컬럼 정보에서 가져옴 - placeholder: "선택하세요", - options: [], // 기본 빈 배열, 실제로는 API에서 로드 - }; - case "entity": - return { - entityName: "", - displayField: "name", - valueField: "id", - searchable: true, - multiple: false, - allowClear: true, - placeholder: "엔터티를 선택하세요", - apiEndpoint: "", - filters: [], - displayFormat: "simple" as const, - }; - case "table": - return { - tableName: "", - displayMode: "table" as const, - showHeader: true, - showFooter: true, - pagination: { - enabled: true, - pageSize: 10, - showPageSizeSelector: true, - showPageInfo: true, - showFirstLast: true, - }, - columns: [], - searchable: true, - sortable: true, - filterable: true, - exportable: true, - }; - default: - return undefined; - } - }; - - // 폼 컨테이너에 드롭한 경우 - if (formContainer) { - const formContainerId = formContainer.getAttribute("data-component-id"); - const formContainerComponent = layout.components.find((c) => c.id === formContainerId); - - if (formContainerComponent) { - // 폼 내부에서의 상대적 위치 계산 - const containerRect = formContainer.getBoundingClientRect(); - const relativeX = e.clientX - containerRect.left; - const relativeY = e.clientY - containerRect.top; - - // 🆕 V2 컴포넌트 매핑 사용 - const v2Mapping = createV2ConfigFromColumn({ - widgetType: column.widgetType, - columnName: column.columnName, - columnLabel: column.columnLabel, - codeCategory: column.codeCategory, - inputType: column.inputType, - required: column.required, - detailSettings: column.detailSettings, // 엔티티 참조 정보 전달 - // column_labels 직접 필드도 전달 - referenceTable: column.referenceTable, - referenceColumn: column.referenceColumn, - displayColumn: column.displayColumn, - }); - - // 웹타입별 기본 너비 계산 (10px 단위 고정) - const componentWidth = getDefaultWidth(column.widgetType); - - console.log("🎯 폼 컨테이너 V2 컴포넌트 생성:", { - widgetType: column.widgetType, - v2Type: v2Mapping.componentType, - componentWidth, - }); - - // 엔티티 조인 컬럼인 경우 읽기 전용으로 설정 - const isEntityJoinColumn = column.isEntityJoin === true; - - newComponent = { - id: generateComponentId(), - type: "component", // ✅ V2 컴포넌트 시스템 사용 - label: column.columnLabel || column.columnName, - tableName: table.tableName, - columnName: column.columnName, - required: isEntityJoinColumn ? false : column.required, // 조인 컬럼은 필수 아님 - readonly: isEntityJoinColumn, // 🆕 엔티티 조인 컬럼은 읽기 전용 - parentId: formContainerId, // 폼 컨테이너의 자식으로 설정 - componentType: v2Mapping.componentType, // v2-input, v2-select 등 - position: { x: relativeX, y: relativeY, z: 1 } as Position, - size: { width: componentWidth, height: getDefaultHeight(column.widgetType) }, - layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용) - // 코드 타입인 경우 코드 카테고리 정보 추가 - ...(column.widgetType === "code" && - column.codeCategory && { - codeCategory: column.codeCategory, - }), - // 엔티티 조인 정보 저장 - ...(isEntityJoinColumn && { - isEntityJoin: true, - entityJoinTable: column.entityJoinTable, - entityJoinColumn: column.entityJoinColumn, - }), - style: { - labelDisplay: true, // 🆕 라벨 기본 표시 - labelFontSize: "12px", - labelColor: "#212121", - labelFontWeight: "500", - labelMarginBottom: "6px", - }, - componentConfig: { - type: v2Mapping.componentType, // v2-input, v2-select 등 - ...v2Mapping.componentConfig, // V2 컴포넌트 기본 설정 - ...(isEntityJoinColumn && { disabled: true }), // 🆕 조인 컬럼은 비활성화 - }, - }; - } else { - return; // 폼 컨테이너를 찾을 수 없으면 드롭 취소 - } - } else { - // 일반 캔버스에 드롭한 경우 - 🆕 V2 컴포넌트 시스템 사용 - const v2Mapping = createV2ConfigFromColumn({ - widgetType: column.widgetType, - columnName: column.columnName, - columnLabel: column.columnLabel, - codeCategory: column.codeCategory, - inputType: column.inputType, - required: column.required, - detailSettings: column.detailSettings, // 엔티티 참조 정보 전달 - // column_labels 직접 필드도 전달 - referenceTable: column.referenceTable, - referenceColumn: column.referenceColumn, - displayColumn: column.displayColumn, - }); - - // 웹타입별 기본 너비 계산 (10px 단위 고정) - const componentWidth = getDefaultWidth(column.widgetType); - - console.log("🎯 캔버스 V2 컴포넌트 생성:", { - widgetType: column.widgetType, - v2Type: v2Mapping.componentType, - componentWidth, - }); - - // 엔티티 조인 컬럼인 경우 읽기 전용으로 설정 - const isEntityJoinColumn = column.isEntityJoin === true; - - newComponent = { - id: generateComponentId(), - type: "component", // ✅ V2 컴포넌트 시스템 사용 - label: column.columnLabel || column.columnName, // 컬럼 라벨 우선, 없으면 컬럼명 - tableName: table.tableName, - columnName: column.columnName, - required: isEntityJoinColumn ? false : column.required, // 조인 컬럼은 필수 아님 - readonly: isEntityJoinColumn, // 🆕 엔티티 조인 컬럼은 읽기 전용 - componentType: v2Mapping.componentType, // v2-input, v2-select 등 - position: { x, y, z: 1 } as Position, - size: { width: componentWidth, height: getDefaultHeight(column.widgetType) }, - layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용) - // 코드 타입인 경우 코드 카테고리 정보 추가 - ...(column.widgetType === "code" && - column.codeCategory && { - codeCategory: column.codeCategory, - }), - // 엔티티 조인 정보 저장 - ...(isEntityJoinColumn && { - isEntityJoin: true, - entityJoinTable: column.entityJoinTable, - entityJoinColumn: column.entityJoinColumn, - }), - style: { - labelDisplay: true, // 🆕 라벨 기본 표시 - labelFontSize: "14px", - labelColor: "#000000", // 순수한 검정 - labelFontWeight: "500", - labelMarginBottom: "8px", - }, - componentConfig: { - type: v2Mapping.componentType, // v2-input, v2-select 등 - ...v2Mapping.componentConfig, // V2 컴포넌트 기본 설정 - ...(isEntityJoinColumn && { disabled: true }), // 🆕 조인 컬럼은 비활성화 - }, - }; - } - } else { - return; - } - - // 10px 단위 스냅 적용 (그룹 컴포넌트 제외) - if (layout.gridSettings?.snapToGrid && newComponent.type !== "group") { - newComponent.position = snapPositionTo10px(newComponent.position); - newComponent.size = snapSizeTo10px(newComponent.size); - - console.log("🧲 새 컴포넌트 10px 스냅 적용:", { - type: newComponent.type, - snappedPosition: newComponent.position, - snappedSize: newComponent.size, - }); - } - - const newLayout = { - ...layout, - components: [...layout.components, newComponent], - }; - - setLayout(newLayout); - saveToHistory(newLayout); - setSelectedComponent(newComponent); - - // 🔧 테이블 패널 유지를 위해 자동 속성 패널 열기 비활성화 - // openPanel("properties"); - } catch (error) { - // console.error("드롭 처리 실패:", error); - } - }, - [layout, saveToHistory], - ); - - // 파일 컴포넌트 업데이트 처리 - const handleFileComponentUpdate = useCallback( - (updates: Partial) => { - if (!selectedFileComponent) return; - - const updatedComponents = layout.components.map((comp) => - comp.id === selectedFileComponent.id ? { ...comp, ...updates } : comp, - ); - - const newLayout = { ...layout, components: updatedComponents }; - setLayout(newLayout); - saveToHistory(newLayout); - - // selectedFileComponent도 업데이트 - setSelectedFileComponent((prev) => (prev ? { ...prev, ...updates } : null)); - - // selectedComponent가 같은 컴포넌트라면 업데이트 - if (selectedComponent?.id === selectedFileComponent.id) { - setSelectedComponent((prev) => (prev ? { ...prev, ...updates } : null)); - } - }, - [selectedFileComponent, layout, saveToHistory, selectedComponent], - ); - - // 파일첨부 모달 닫기 - const handleFileAttachmentModalClose = useCallback(() => { - setShowFileAttachmentModal(false); - setSelectedFileComponent(null); - }, []); - - // 컴포넌트 더블클릭 처리 - const handleComponentDoubleClick = useCallback((component: ComponentData, event?: React.MouseEvent) => { - event?.stopPropagation(); - - // 파일 컴포넌트인 경우 상세 모달 열기 - if (component.type === "file") { - setSelectedFileComponent(component); - setShowFileAttachmentModal(true); - return; - } - - // 다른 컴포넌트 타입의 더블클릭 처리는 여기에 추가 - // console.log("더블클릭된 컴포넌트:", component.type, component.id); - }, []); - - // 컴포넌트 클릭 처리 (다중선택 지원) - const handleComponentClick = useCallback( - (component: ComponentData, event?: React.MouseEvent) => { - event?.stopPropagation(); - - // 드래그가 끝난 직후라면 클릭을 무시 (다중 선택 유지) - if (dragState.justFinishedDrag) { - return; - } - - // 🔧 layout.components에서 최신 버전의 컴포넌트 찾기 - const latestComponent = layout.components.find((c) => c.id === component.id); - if (!latestComponent) { - console.warn("⚠️ 컴포넌트를 찾을 수 없습니다:", component.id); - return; - } - - const isShiftPressed = event?.shiftKey || false; - const isCtrlPressed = event?.ctrlKey || event?.metaKey || false; - const isGroupContainer = latestComponent.type === "group"; - - if (isShiftPressed || isCtrlPressed || groupState.isGrouping) { - // 다중 선택 모드 - if (isGroupContainer) { - // 그룹 컨테이너는 단일 선택으로 처리 - handleComponentSelect(latestComponent); // 🔧 최신 버전 사용 - setGroupState((prev) => ({ - ...prev, - selectedComponents: [latestComponent.id], - isGrouping: false, - })); - return; - } - - const isSelected = groupState.selectedComponents.includes(latestComponent.id); - setGroupState((prev) => ({ - ...prev, - selectedComponents: isSelected - ? prev.selectedComponents.filter((id) => id !== latestComponent.id) - : [...prev.selectedComponents, latestComponent.id], - })); - - // 마지막 선택된 컴포넌트를 selectedComponent로 설정 - if (!isSelected) { - // console.log("🎯 컴포넌트 선택 (다중 모드):", latestComponent.id); - handleComponentSelect(latestComponent); // 🔧 최신 버전 사용 - } - } else { - // 단일 선택 모드 - // console.log("🎯 컴포넌트 선택 (단일 모드):", latestComponent.id); - handleComponentSelect(latestComponent); // 🔧 최신 버전 사용 - setGroupState((prev) => ({ - ...prev, - selectedComponents: [latestComponent.id], - })); - } - }, - [ - handleComponentSelect, - groupState.isGrouping, - groupState.selectedComponents, - dragState.justFinishedDrag, - layout.components, - ], - ); - - // 컴포넌트 드래그 시작 - const startComponentDrag = useCallback( - (component: ComponentData, event: React.MouseEvent | React.DragEvent) => { - event.preventDefault(); - const rect = canvasRef.current?.getBoundingClientRect(); - if (!rect) return; - - // 새로운 드래그 시작 시 justFinishedDrag 플래그 해제 - if (dragState.justFinishedDrag) { - setDragState((prev) => ({ - ...prev, - justFinishedDrag: false, - })); - } - - // 🔥 중요: 줌 레벨을 고려한 마우스 위치 계산 - // 캔버스가 scale() 변환되어 있기 때문에 마우스 위치도 역변환 필요 - const relativeMouseX = (event.clientX - rect.left) / zoomLevel; - const relativeMouseY = (event.clientY - rect.top) / zoomLevel; - - // 다중 선택된 컴포넌트들 확인 - const isDraggedComponentSelected = groupState.selectedComponents.includes(component.id); - let componentsToMove = isDraggedComponentSelected - ? layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id)) - : [component]; - - // 레이아웃 컴포넌트인 경우 존에 속한 컴포넌트들도 함께 이동 - if (component.type === "layout") { - const zoneComponents = layout.components.filter((comp) => comp.parentId === component.id && comp.zoneId); - - console.log("🏗️ 레이아웃 드래그 - 존 컴포넌트들 포함:", { - layoutId: component.id, - zoneComponentsCount: zoneComponents.length, - zoneComponents: zoneComponents.map((c) => ({ id: c.id, zoneId: c.zoneId })), - }); - - // 중복 제거하여 추가 - const allComponentIds = new Set(componentsToMove.map((c) => c.id)); - const additionalComponents = zoneComponents.filter((c) => !allComponentIds.has(c.id)); - componentsToMove = [...componentsToMove, ...additionalComponents]; - } - - setDragState({ - isDragging: true, - draggedComponent: component, // 주 드래그 컴포넌트 (마우스 위치 기준) - draggedComponents: componentsToMove, // 함께 이동할 모든 컴포넌트들 - originalPosition: { - x: component.position.x, - y: component.position.y, - z: (component.position as Position).z || 1, - }, - currentPosition: { - x: component.position.x, - y: component.position.y, - z: (component.position as Position).z || 1, - }, - grabOffset: { - x: relativeMouseX - component.position.x, - y: relativeMouseY - component.position.y, - }, - justFinishedDrag: false, - }); - }, - [groupState.selectedComponents, layout.components, dragState.justFinishedDrag, zoomLevel], - ); - - // 드래그 중 위치 업데이트 (성능 최적화 + 실시간 업데이트) - const updateDragPosition = useCallback( - (event: MouseEvent) => { - if (!dragState.isDragging || !dragState.draggedComponent || !canvasRef.current) return; - - const rect = canvasRef.current.getBoundingClientRect(); - - // 🔥 중요: 줌 레벨을 고려한 마우스 위치 계산 - // 캔버스가 scale() 변환되어 있기 때문에 마우스 위치도 역변환 필요 - const relativeMouseX = (event.clientX - rect.left) / zoomLevel; - const relativeMouseY = (event.clientY - rect.top) / zoomLevel; - - // 컴포넌트 크기 가져오기 - const draggedComp = layout.components.find((c) => c.id === dragState.draggedComponent.id); - const componentWidth = draggedComp?.size?.width || 100; - const componentHeight = draggedComp?.size?.height || 40; - - // 경계 제한 적용 - const rawX = relativeMouseX - dragState.grabOffset.x; - const rawY = relativeMouseY - dragState.grabOffset.y; - - // 조건부 레이어 편집 시 displayRegion 크기 기준 경계 제한 - const dragLayerId = activeLayerIdRef.current || 1; - const dragLayerRegion = dragLayerId > 1 ? layerRegions[dragLayerId] : null; - const dragBoundW = dragLayerRegion ? dragLayerRegion.width : screenResolution.width; - const dragBoundH = dragLayerRegion ? dragLayerRegion.height : screenResolution.height; - - const newPosition = { - x: Math.max(0, Math.min(rawX, dragBoundW - componentWidth)), - y: Math.max(0, Math.min(rawY, dragBoundH - componentHeight)), - z: (dragState.draggedComponent.position as Position).z || 1, - }; - - // 드래그 상태 업데이트 - setDragState((prev) => { - const newState = { - ...prev, - currentPosition: { ...newPosition }, // 새로운 객체 생성 - }; - return newState; - }); - - // 성능 최적화: 드래그 중에는 상태 업데이트만 하고, - // 실제 레이아웃 업데이트는 endDrag에서 처리 - // 속성 패널에서는 dragState.currentPosition을 참조하여 실시간 표시 - }, - [dragState.isDragging, dragState.draggedComponent, dragState.grabOffset, zoomLevel], - ); - - // 드래그 종료 - const endDrag = useCallback( - (mouseEvent?: MouseEvent) => { - if (dragState.isDragging && dragState.draggedComponent) { - // 🎯 탭 컨테이너로의 드롭 처리 (기존 컴포넌트 이동, 중첩 구조 지원) - if (mouseEvent) { - const dropTarget = document.elementFromPoint(mouseEvent.clientX, mouseEvent.clientY) as HTMLElement; - const tabsContainer = dropTarget?.closest('[data-tabs-container="true"]'); - - if (tabsContainer) { - const containerId = tabsContainer.getAttribute("data-component-id"); - const activeTabId = tabsContainer.getAttribute("data-active-tab-id"); - - if (containerId && activeTabId) { - // 1. 먼저 최상위 레이아웃에서 탭 컴포넌트 찾기 - let targetComponent = layout.components.find((c) => c.id === containerId); - let parentSplitPanelId: string | null = null; - let parentPanelSide: "left" | "right" | null = null; - - // 2. 최상위에 없으면 분할 패널 안에서 중첩된 탭 찾기 - if (!targetComponent) { - for (const comp of layout.components) { - const compType = (comp as any)?.componentType; - if (compType === "split-panel-layout" || compType === "v2-split-panel-layout") { - const config = (comp as any).componentConfig || {}; - - // 좌측 패널에서 찾기 - const leftComponents = config.leftPanel?.components || []; - const foundInLeft = leftComponents.find((c: any) => c.id === containerId); - if (foundInLeft) { - targetComponent = foundInLeft; - parentSplitPanelId = comp.id; - parentPanelSide = "left"; - break; - } - - // 우측 패널에서 찾기 - const rightComponents = config.rightPanel?.components || []; - const foundInRight = rightComponents.find((c: any) => c.id === containerId); - if (foundInRight) { - targetComponent = foundInRight; - parentSplitPanelId = comp.id; - parentPanelSide = "right"; - break; - } - } - } - } - - const compType = (targetComponent as any)?.componentType; - - // 자기 자신을 자신에게 드롭하는 것 방지 - if ( - targetComponent && - (compType === "tabs-widget" || compType === "v2-tabs-widget") && - dragState.draggedComponent !== containerId - ) { - const draggedComponent = layout.components.find((c) => c.id === dragState.draggedComponent); - if (draggedComponent) { - const currentConfig = (targetComponent as any).componentConfig || {}; - const tabs = currentConfig.tabs || []; - - // 탭 컨텐츠 영역 기준 드롭 위치 계산 - const tabContentRect = tabsContainer.getBoundingClientRect(); - const dropX = (mouseEvent.clientX - tabContentRect.left) / zoomLevel; - const dropY = (mouseEvent.clientY - tabContentRect.top) / zoomLevel; - - // 기존 컴포넌트를 탭 내부 컴포넌트로 변환 - const newTabComponent = { - id: `tab_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, - componentType: (draggedComponent as any).componentType || draggedComponent.type, - label: (draggedComponent as any).label || "컴포넌트", - position: { x: Math.max(0, dropX), y: Math.max(0, dropY) }, - size: draggedComponent.size || { width: 200, height: 100 }, - componentConfig: (draggedComponent as any).componentConfig || {}, - style: (draggedComponent as any).style || {}, - }; - - // 해당 탭에 컴포넌트 추가 - const updatedTabs = tabs.map((tab: any) => { - if (tab.id === activeTabId) { - return { - ...tab, - components: [...(tab.components || []), newTabComponent], - }; - } - return tab; - }); - - const updatedTabsComponent = { - ...targetComponent, - componentConfig: { - ...currentConfig, - tabs: updatedTabs, - }, - }; - - let newLayout; - - if (parentSplitPanelId && parentPanelSide) { - // 🆕 중첩 구조: 분할 패널 안의 탭 업데이트 - newLayout = { - ...layout, - components: layout.components - .filter((c) => c.id !== dragState.draggedComponent) - .map((c) => { - if (c.id === parentSplitPanelId) { - const splitConfig = (c as any).componentConfig || {}; - const panelKey = parentPanelSide === "left" ? "leftPanel" : "rightPanel"; - const panelConfig = splitConfig[panelKey] || {}; - const panelComponents = panelConfig.components || []; - - return { - ...c, - componentConfig: { - ...splitConfig, - [panelKey]: { - ...panelConfig, - components: panelComponents.map((pc: any) => - pc.id === containerId ? updatedTabsComponent : pc, - ), - }, - }, - }; - } - return c; - }), - }; - toast.success("컴포넌트가 중첩된 탭으로 이동되었습니다"); - } else { - // 일반 구조: 최상위 탭 업데이트 - newLayout = { - ...layout, - components: layout.components - .filter((c) => c.id !== dragState.draggedComponent) - .map((c) => { - if (c.id === containerId) { - return updatedTabsComponent; - } - return c; - }), - }; - toast.success("컴포넌트가 탭으로 이동되었습니다"); - } - - setLayout(newLayout); - saveToHistory(newLayout); - setSelectedComponent(null); - - // 드래그 상태 초기화 후 종료 - setDragState({ - isDragging: false, - draggedComponent: null, - draggedComponents: [], - originalPosition: { x: 0, y: 0, z: 1 }, - currentPosition: { x: 0, y: 0, z: 1 }, - grabOffset: { x: 0, y: 0 }, - justFinishedDrag: true, - }); - - setTimeout(() => { - setDragState((prev) => ({ ...prev, justFinishedDrag: false })); - }, 100); - - return; // 탭으로 이동 완료, 일반 드래그 종료 로직 스킵 - } - } - } - } - } - - // 주 드래그 컴포넌트의 최종 위치 계산 - const draggedComponent = layout.components.find((c) => c.id === dragState.draggedComponent); - let finalPosition = dragState.currentPosition; - - // 현재 해상도에 맞는 격자 정보 계산 - const currentGridInfo = layout.gridSettings - ? calculateGridInfo(screenResolution.width, screenResolution.height, { - columns: layout.gridSettings.columns, - gap: layout.gridSettings.gap, - padding: layout.gridSettings.padding, - snapToGrid: layout.gridSettings.snapToGrid || false, - }) - : null; - - // 일반 컴포넌트 및 플로우 버튼 그룹에 격자 스냅 적용 (일반 그룹 제외) - if (draggedComponent?.type !== "group" && layout.gridSettings?.snapToGrid && currentGridInfo) { - finalPosition = snapPositionTo10px( - { - x: dragState.currentPosition.x, - y: dragState.currentPosition.y, - z: dragState.currentPosition.z ?? 1, - }, - currentGridInfo, - { - columns: layout.gridSettings.columns, - gap: layout.gridSettings.gap, - padding: layout.gridSettings.padding, - snapToGrid: layout.gridSettings.snapToGrid || false, - }, - ); - } - - // 스냅으로 인한 추가 이동 거리 계산 - const snapDeltaX = finalPosition.x - dragState.currentPosition.x; - const snapDeltaY = finalPosition.y - dragState.currentPosition.y; - - // 원래 이동 거리 + 스냅 조정 거리 - const totalDeltaX = dragState.currentPosition.x - dragState.originalPosition.x + snapDeltaX; - const totalDeltaY = dragState.currentPosition.y - dragState.originalPosition.y + snapDeltaY; - - // 다중 컴포넌트들의 최종 위치 업데이트 - const updatedComponents = layout.components.map((comp) => { - const isDraggedComponent = dragState.draggedComponents.some((dragComp) => dragComp.id === comp.id); - if (isDraggedComponent) { - const originalComponent = dragState.draggedComponents.find((dragComp) => dragComp.id === comp.id)!; - let newPosition = { - x: originalComponent.position.x + totalDeltaX, - y: originalComponent.position.y + totalDeltaY, - z: originalComponent.position.z || 1, - }; - - // 캔버스 경계 제한 (컴포넌트가 화면 밖으로 나가지 않도록) - const componentWidth = comp.size?.width || 100; - const componentHeight = comp.size?.height || 40; - - // 최소 위치: 0, 최대 위치: 캔버스 크기 - 컴포넌트 크기 - newPosition.x = Math.max(0, Math.min(newPosition.x, screenResolution.width - componentWidth)); - newPosition.y = Math.max(0, Math.min(newPosition.y, screenResolution.height - componentHeight)); - - // 그룹 내부 컴포넌트인 경우 패딩을 고려한 격자 스냅 적용 - if (comp.parentId && layout.gridSettings?.snapToGrid && gridInfo) { - const { columnWidth } = gridInfo; - const { gap } = layout.gridSettings; - - // 그룹 내부 패딩 고려한 격자 정렬 - const padding = 16; - const effectiveX = newPosition.x - padding; - const columnIndex = Math.round(effectiveX / (columnWidth + (gap || 16))); - const snappedX = padding + columnIndex * (columnWidth + (gap || 16)); - - // Y 좌표는 20px 단위로 스냅 - const effectiveY = newPosition.y - padding; - const rowIndex = Math.round(effectiveY / 20); - const snappedY = padding + rowIndex * 20; - - // 크기도 외부 격자와 동일하게 스냅 - const fullColumnWidth = columnWidth + (gap || 16); // 외부 격자와 동일한 크기 - const widthInColumns = Math.max(1, Math.round(comp.size.width / fullColumnWidth)); - const snappedWidth = widthInColumns * fullColumnWidth - (gap || 16); // gap 제거하여 실제 컴포넌트 크기 - // 높이는 사용자가 입력한 값 그대로 사용 (스냅 제거) - const snappedHeight = Math.max(40, comp.size.height); - - newPosition = { - x: Math.max(padding, snappedX), // 패딩만큼 최소 여백 확보 - y: Math.max(padding, snappedY), - z: newPosition.z, - }; - - // 크기도 업데이트 - const newSize = { - width: snappedWidth, - height: snappedHeight, - }; - - return { - ...comp, - position: newPosition as Position, - size: newSize, - }; - } - - return { - ...comp, - position: newPosition as Position, - }; - } - return comp; - }); - - const newLayout = { ...layout, components: updatedComponents }; - setLayout(newLayout); - - // 선택된 컴포넌트도 업데이트 (PropertiesPanel 동기화용) - if (selectedComponent && dragState.draggedComponents.some((c) => c.id === selectedComponent.id)) { - const updatedSelectedComponent = updatedComponents.find((c) => c.id === selectedComponent.id); - if (updatedSelectedComponent) { - console.log("🔄 ScreenDesigner: 선택된 컴포넌트 위치 업데이트", { - componentId: selectedComponent.id, - oldPosition: selectedComponent.position, - newPosition: updatedSelectedComponent.position, - }); - setSelectedComponent(updatedSelectedComponent); - } - } - - // 히스토리에 저장 - saveToHistory(newLayout); - } - - setDragState({ - isDragging: false, - draggedComponent: null, - draggedComponents: [], - originalPosition: { x: 0, y: 0, z: 1 }, - currentPosition: { x: 0, y: 0, z: 1 }, - grabOffset: { x: 0, y: 0 }, - justFinishedDrag: true, - }); - - // 짧은 시간 후 justFinishedDrag 플래그 해제 - setTimeout(() => { - setDragState((prev) => ({ - ...prev, - justFinishedDrag: false, - })); - }, 100); - }, - [dragState, layout, saveToHistory], - ); - - // 드래그 선택 시작 - const startSelectionDrag = useCallback( - (event: React.MouseEvent) => { - if (dragState.isDragging) return; // 컴포넌트 드래그 중이면 무시 - - const rect = canvasRef.current?.getBoundingClientRect(); - if (!rect) return; - - // zoom 스케일을 고려한 좌표 변환 - const startPoint = { - x: (event.clientX - rect.left) / zoomLevel, - y: (event.clientY - rect.top) / zoomLevel, - z: 1, - }; - - setSelectionDrag({ - isSelecting: true, - startPoint, - currentPoint: startPoint, - wasSelecting: false, - }); - }, - [dragState.isDragging, zoomLevel], - ); - - // 드래그 선택 업데이트 - const updateSelectionDrag = useCallback( - (event: MouseEvent) => { - if (!selectionDrag.isSelecting || !canvasRef.current) return; - - const rect = canvasRef.current.getBoundingClientRect(); - // zoom 스케일을 고려한 좌표 변환 - const currentPoint = { - x: (event.clientX - rect.left) / zoomLevel, - y: (event.clientY - rect.top) / zoomLevel, - z: 1, - }; - - setSelectionDrag((prev) => ({ - ...prev, - currentPoint, - })); - - // 선택 영역 내의 컴포넌트들 찾기 - const selectionRect = { - left: Math.min(selectionDrag.startPoint.x, currentPoint.x), - top: Math.min(selectionDrag.startPoint.y, currentPoint.y), - right: Math.max(selectionDrag.startPoint.x, currentPoint.x), - bottom: Math.max(selectionDrag.startPoint.y, currentPoint.y), - }; - - // 🆕 visibleComponents만 선택 대상으로 (현재 활성 레이어의 컴포넌트만) - const selectedIds = visibleComponents - .filter((comp) => { - const compRect = { - left: comp.position.x, - top: comp.position.y, - right: comp.position.x + comp.size.width, - bottom: comp.position.y + comp.size.height, - }; - - return ( - compRect.left < selectionRect.right && - compRect.right > selectionRect.left && - compRect.top < selectionRect.bottom && - compRect.bottom > selectionRect.top - ); - }) - .map((comp) => comp.id); - - setGroupState((prev) => ({ - ...prev, - selectedComponents: selectedIds, - })); - }, - [selectionDrag.isSelecting, selectionDrag.startPoint, visibleComponents, zoomLevel], - ); - - // 드래그 선택 종료 - const endSelectionDrag = useCallback(() => { - // 최소 드래그 거리 확인 (5픽셀) - const minDragDistance = 5; - const dragDistance = Math.sqrt( - Math.pow(selectionDrag.currentPoint.x - selectionDrag.startPoint.x, 2) + - Math.pow(selectionDrag.currentPoint.y - selectionDrag.startPoint.y, 2), - ); - - const wasActualDrag = dragDistance > minDragDistance; - - setSelectionDrag({ - isSelecting: false, - startPoint: { x: 0, y: 0, z: 1 }, - currentPoint: { x: 0, y: 0, z: 1 }, - wasSelecting: wasActualDrag, // 실제 드래그였을 때만 클릭 이벤트 무시 - }); - - // 짧은 시간 후 wasSelecting을 false로 리셋 - setTimeout(() => { - setSelectionDrag((prev) => ({ - ...prev, - wasSelecting: false, - })); - }, 100); - }, [selectionDrag.currentPoint, selectionDrag.startPoint]); - - // 컴포넌트 삭제 (단일/다중 선택 지원) - const deleteComponent = useCallback(() => { - // 다중 선택된 컴포넌트가 있는 경우 - if (groupState.selectedComponents.length > 0) { - // console.log("🗑️ 다중 컴포넌트 삭제:", groupState.selectedComponents.length, "개"); - - let newComponents = [...layout.components]; - - // 각 선택된 컴포넌트를 삭제 처리 - groupState.selectedComponents.forEach((componentId) => { - const component = layout.components.find((comp) => comp.id === componentId); - if (!component) return; - - if (component.type === "group") { - // 그룹 삭제 시: 자식 컴포넌트들의 절대 위치 복원 - const childComponents = newComponents.filter((comp) => comp.parentId === component.id); - const restoredChildren = restoreAbsolutePositions(childComponents, component.position); - - newComponents = newComponents - .map((comp) => { - if (comp.parentId === component.id) { - // 복원된 절대 위치로 업데이트 - const restoredChild = restoredChildren.find((restored) => restored.id === comp.id); - return restoredChild || { ...comp, parentId: undefined }; - } - return comp; - }) - .filter((comp) => comp.id !== component.id); // 그룹 컴포넌트 제거 - } else { - // 일반 컴포넌트 삭제 - newComponents = newComponents.filter((comp) => comp.id !== component.id); - } - }); - - const newLayout = { ...layout, components: newComponents }; - setLayout(newLayout); - saveToHistory(newLayout); - - // 선택 상태 초기화 - setSelectedComponent(null); - setGroupState((prev) => ({ ...prev, selectedComponents: [] })); - - toast.success(`${groupState.selectedComponents.length}개 컴포넌트가 삭제되었습니다.`); - return; - } - - // 단일 선택된 컴포넌트 삭제 - if (!selectedComponent) return; - - // console.log("🗑️ 단일 컴포넌트 삭제:", selectedComponent.id); - - let newComponents; - - if (selectedComponent.type === "group") { - // 그룹 삭제 시: 자식 컴포넌트들의 절대 위치 복원 후 그룹 삭제 - const childComponents = layout.components.filter((comp) => comp.parentId === selectedComponent.id); - const restoredChildren = restoreAbsolutePositions(childComponents, selectedComponent.position); - - newComponents = layout.components - .map((comp) => { - if (comp.parentId === selectedComponent.id) { - // 복원된 절대 위치로 업데이트 - const restoredChild = restoredChildren.find((restored) => restored.id === comp.id); - return restoredChild || { ...comp, parentId: undefined }; - } - return comp; - }) - .filter((comp) => comp.id !== selectedComponent.id); // 그룹 컴포넌트 제거 - } else { - // 일반 컴포넌트 삭제 - newComponents = layout.components.filter((comp) => comp.id !== selectedComponent.id); - } - - const newLayout = { ...layout, components: newComponents }; - - setLayout(newLayout); - saveToHistory(newLayout); - setSelectedComponent(null); - toast.success("컴포넌트가 삭제되었습니다."); - }, [selectedComponent, groupState.selectedComponents, layout, saveToHistory]); - - // 컴포넌트 복사 - const copyComponent = useCallback(() => { - if (groupState.selectedComponents.length > 0) { - // 다중 선택된 컴포넌트들 복사 - const componentsToCopy = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id)); - setClipboard(componentsToCopy); - // console.log("다중 컴포넌트 복사:", componentsToCopy.length, "개"); - toast.success(`${componentsToCopy.length}개 컴포넌트가 복사되었습니다.`); - } else if (selectedComponent) { - // 단일 컴포넌트 복사 - setClipboard([selectedComponent]); - // console.log("단일 컴포넌트 복사:", selectedComponent.id); - toast.success("컴포넌트가 복사되었습니다."); - } - }, [selectedComponent, groupState.selectedComponents, layout.components]); - - // 컴포넌트 붙여넣기 - const pasteComponent = useCallback(() => { - if (clipboard.length === 0) { - toast.warning("복사된 컴포넌트가 없습니다."); - return; - } - - const newComponents: ComponentData[] = []; - const offset = 20; // 붙여넣기 시 위치 오프셋 - - clipboard.forEach((clipComponent, index) => { - const newComponent: ComponentData = { - ...clipComponent, - id: generateComponentId(), - position: { - x: clipComponent.position.x + offset + index * 10, - y: clipComponent.position.y + offset + index * 10, - z: clipComponent.position.z || 1, - } as Position, - parentId: undefined, // 붙여넣기 시 부모 관계 해제 - layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 붙여넣기 (ref 사용) - }; - newComponents.push(newComponent); - }); - - const newLayout = { - ...layout, - components: [...layout.components, ...newComponents], - }; - - setLayout(newLayout); - saveToHistory(newLayout); - - // 붙여넣은 컴포넌트들을 선택 상태로 만들기 - setGroupState((prev) => ({ - ...prev, - selectedComponents: newComponents.map((comp) => comp.id), - })); - - // console.log("컴포넌트 붙여넣기 완료:", newComponents.length, "개"); - toast.success(`${newComponents.length}개 컴포넌트가 붙여넣어졌습니다.`); - }, [clipboard, layout, saveToHistory]); - - // 🆕 플로우 버튼 그룹 생성 (다중 선택된 버튼들을 한 번에 그룹으로) - // 🆕 플로우 버튼 그룹 다이얼로그 상태 - const [groupDialogOpen, setGroupDialogOpen] = useState(false); - - const handleFlowButtonGroup = useCallback(() => { - const selectedComponents = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id)); - - // 선택된 컴포넌트가 없거나 1개 이하면 그룹화 불가 - if (selectedComponents.length < 2) { - toast.error("그룹으로 묶을 버튼을 2개 이상 선택해주세요"); - return; - } - - // 모두 버튼인지 확인 - if (!areAllButtons(selectedComponents)) { - toast.error("버튼 컴포넌트만 그룹으로 묶을 수 있습니다"); - return; - } - - // 🆕 다이얼로그 열기 - setGroupDialogOpen(true); - }, [layout, groupState.selectedComponents]); - - // 🆕 그룹 생성 확인 핸들러 - const handleGroupConfirm = useCallback( - (settings: { - direction: "horizontal" | "vertical"; - gap: number; - align: "start" | "center" | "end" | "space-between" | "space-around"; - }) => { - const selectedComponents = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id)); - - // 고유한 그룹 ID 생성 - const newGroupId = generateGroupId(); - - // 🔧 그룹 위치 및 버튼 재배치 계산 - const align = settings.align; - const direction = settings.direction; - const gap = settings.gap; - - const groupY = Math.min(...selectedComponents.map((b) => b.position.y)); - let anchorButton; // 기준이 되는 버튼 - let groupX: number; - - // align에 따라 기준 버튼과 그룹 시작점 결정 - if (direction === "horizontal") { - if (align === "end") { - // 끝점 정렬: 가장 오른쪽 버튼이 기준 - anchorButton = selectedComponents.reduce((max, btn) => { - const rightEdge = btn.position.x + (btn.size?.width || 100); - const maxRightEdge = max.position.x + (max.size?.width || 100); - return rightEdge > maxRightEdge ? btn : max; - }); - - // 전체 그룹 너비 계산 - const totalWidth = selectedComponents.reduce((total, btn, index) => { - const buttonWidth = btn.size?.width || 100; - const gapWidth = index < selectedComponents.length - 1 ? gap : 0; - return total + buttonWidth + gapWidth; - }, 0); - - // 그룹 시작점 = 기준 버튼의 오른쪽 끝 - 전체 그룹 너비 - groupX = anchorButton.position.x + (anchorButton.size?.width || 100) - totalWidth; - } else if (align === "center") { - // 중앙 정렬: 버튼들의 중심점을 기준으로 - const minX = Math.min(...selectedComponents.map((b) => b.position.x)); - const maxX = Math.max(...selectedComponents.map((b) => b.position.x + (b.size?.width || 100))); - const centerX = (minX + maxX) / 2; - - const totalWidth = selectedComponents.reduce((total, btn, index) => { - const buttonWidth = btn.size?.width || 100; - const gapWidth = index < selectedComponents.length - 1 ? gap : 0; - return total + buttonWidth + gapWidth; - }, 0); - - groupX = centerX - totalWidth / 2; - anchorButton = selectedComponents[0]; // 중앙 정렬은 첫 번째 버튼 기준 - } else { - // 시작점 정렬: 가장 왼쪽 버튼이 기준 - anchorButton = selectedComponents.reduce((min, btn) => { - return btn.position.x < min.position.x ? btn : min; - }); - groupX = anchorButton.position.x; - } - } else { - // 세로 정렬: 가장 위쪽 버튼이 기준 - anchorButton = selectedComponents.reduce((min, btn) => { - return btn.position.y < min.position.y ? btn : min; - }); - groupX = Math.min(...selectedComponents.map((b) => b.position.x)); - } - - // 🔧 버튼들의 위치를 그룹 기준으로 재배치 - // 기준 버튼의 절대 위치를 유지하고, FlexBox가 나머지를 자동 정렬 - const groupedButtons = selectedComponents.map((button) => { - const currentConfig = (button as any).webTypeConfig?.flowVisibilityConfig || {}; - - // 모든 버튼을 그룹 시작점에 배치 - // FlexBox가 자동으로 정렬하여 기준 버튼의 위치가 유지됨 - const newPosition = { - x: groupX, - y: groupY, - z: button.position.z || 1, - }; - - return { - ...button, - position: newPosition, - webTypeConfig: { - ...(button as any).webTypeConfig, - flowVisibilityConfig: { - ...currentConfig, - enabled: true, - layoutBehavior: "auto-compact", - groupId: newGroupId, - groupDirection: settings.direction, - groupGap: settings.gap, - groupAlign: settings.align, - }, - }, - }; - }); - - // 레이아웃 업데이트 - const updatedComponents = layout.components.map((comp) => { - const grouped = groupedButtons.find((gb) => gb.id === comp.id); - return grouped || comp; - }); - - const newLayout = { - ...layout, - components: updatedComponents, - }; - - setLayout(newLayout); - saveToHistory(newLayout); - - toast.success(`${selectedComponents.length}개의 버튼이 플로우 그룹으로 묶였습니다`, { - description: `그룹 ID: ${newGroupId} / ${settings.direction === "horizontal" ? "가로" : "세로"} / ${settings.gap}px 간격`, - }); - - console.log("✅ 플로우 버튼 그룹 생성 완료:", { - groupId: newGroupId, - buttonCount: selectedComponents.length, - buttons: selectedComponents.map((b) => ({ id: b.id, position: b.position })), - groupPosition: { x: groupX, y: groupY }, - settings, - }); - }, - [layout, groupState.selectedComponents, saveToHistory], - ); - - // 🆕 플로우 버튼 그룹 해제 - const handleFlowButtonUngroup = useCallback(() => { - const selectedComponents = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id)); - - if (selectedComponents.length === 0) { - toast.error("그룹 해제할 버튼을 선택해주세요"); - return; - } - - // 버튼이 아닌 것 필터링 - const buttons = selectedComponents.filter((comp) => areAllButtons([comp])); - - if (buttons.length === 0) { - toast.error("선택된 버튼 중 그룹화된 버튼이 없습니다"); - return; - } - - // 그룹 해제 - const ungroupedButtons = ungroupButtons(buttons); - - // 레이아웃 업데이트 + 플로우 표시 제어 초기화 - const updatedComponents = layout.components.map((comp, index) => { - const ungrouped = ungroupedButtons.find((ub) => ub.id === comp.id); - - if (ungrouped) { - // 원래 위치 복원 또는 현재 위치 유지 + 간격 추가 - const buttonIndex = buttons.findIndex((b) => b.id === comp.id); - const basePosition = comp.position; - - // 버튼들을 오른쪽으로 조금씩 이동 (겹치지 않도록) - const offsetX = buttonIndex * 120; // 각 버튼당 120px 간격 - - // 그룹 해제된 버튼의 플로우 표시 제어를 끄고 설정 초기화 - return { - ...ungrouped, - position: { - x: basePosition.x + offsetX, - y: basePosition.y, - z: basePosition.z || 1, - }, - webTypeConfig: { - ...ungrouped.webTypeConfig, - flowVisibilityConfig: { - enabled: false, - targetFlowComponentId: null, - mode: "whitelist", - visibleSteps: [], - hiddenSteps: [], - layoutBehavior: "auto-compact", - groupId: null, - groupDirection: "horizontal", - groupGap: 8, - groupAlign: "start", - }, - }, - }; - } - - return comp; - }); - - const newLayout = { - ...layout, - components: updatedComponents, - }; - - setLayout(newLayout); - saveToHistory(newLayout); - - toast.success(`${buttons.length}개의 버튼 그룹이 해제되고 플로우 표시 제어가 비활성화되었습니다`); - }, [layout, groupState.selectedComponents, saveToHistory]); - - // 그룹 생성 (임시 비활성화) - const handleGroupCreate = useCallback( - (componentIds: string[], title: string, style?: any) => { - // console.log("그룹 생성 기능이 임시 비활성화되었습니다."); - toast.info("그룹 기능이 임시 비활성화되었습니다."); - return; - - // 격자 정보 계산 - const currentGridInfo = - gridInfo || - calculateGridInfo( - 1200, - 800, - layout.gridSettings || { - columns: 12, - gap: 16, - padding: 0, - snapToGrid: true, - showGrid: false, - gridColor: "#d1d5db", - gridOpacity: 0.5, - }, - ); - - console.log("🔧 그룹 생성 시작:", { - selectedCount: selectedComponents.length, - snapToGrid: true, - }); - - // 컴포넌트 크기 조정 기반 그룹 크기 계산 - const calculateOptimalGroupSize = () => { - if (!currentGridInfo || !layout.gridSettings?.snapToGrid) { - // 격자 스냅이 비활성화된 경우 기본 패딩 사용 - const boundingBox = calculateBoundingBox(selectedComponents); - const padding = 40; - return { - boundingBox, - groupPosition: { x: boundingBox.minX - padding, y: boundingBox.minY - padding, z: 1 }, - groupSize: { width: boundingBox.width + padding * 2, height: boundingBox.height + padding * 2 }, - gridColumns: 1, - scaledComponents: selectedComponents, // 크기 조정 없음 - padding: padding, - }; - } - - const { columnWidth } = currentGridInfo; - const gap = layout.gridSettings?.gap || 16; - const contentBoundingBox = calculateBoundingBox(selectedComponents); - - // 1. 간단한 접근: 컴포넌트들의 시작점에서 가장 가까운 격자 시작점 찾기 - const startColumn = Math.floor(contentBoundingBox.minX / (columnWidth + gap)); - - // 2. 컴포넌트들의 끝점까지 포함할 수 있는 컬럼 수 계산 - const groupStartX = startColumn * (columnWidth + gap); - const availableWidthFromStart = contentBoundingBox.maxX - groupStartX; - const currentWidthInColumns = Math.ceil(availableWidthFromStart / (columnWidth + gap)); - - // 2. 그룹은 격자에 정확히 맞게 위치와 크기 설정 - const padding = 20; - const groupX = startColumn * (columnWidth + gap); // 격자 시작점에 정확히 맞춤 - const groupY = contentBoundingBox.minY - padding; - const groupWidth = currentWidthInColumns * columnWidth + (currentWidthInColumns - 1) * gap; // 컬럼 크기 + gap - const groupHeight = contentBoundingBox.height + padding * 2; - - // 4. 내부 컴포넌트들을 그룹 크기에 맞게 스케일링 - const availableWidth = groupWidth - padding * 2; // 패딩 제외한 실제 사용 가능 너비 - const scaleFactorX = availableWidth / contentBoundingBox.width; - - const scaledComponents = selectedComponents.map((comp) => { - // 컴포넌트의 원래 위치에서 컨텐츠 영역 시작점까지의 상대 위치 계산 - const relativeX = comp.position.x - contentBoundingBox.minX; - const relativeY = comp.position.y - contentBoundingBox.minY; - - return { - ...comp, - position: { - x: padding + relativeX * scaleFactorX, // 패딩 + 스케일된 상대 위치 - y: padding + relativeY, // Y는 스케일링 없이 패딩만 적용 - z: comp.position.z || 1, - }, - size: { - width: comp.size.width * scaleFactorX, // X 방향 스케일링 - height: comp.size.height, // Y는 원본 크기 유지 - }, - }; - }); - - console.log("🎯 컴포넌트 크기 조정 기반 그룹 생성:", { - originalBoundingBox: contentBoundingBox, - gridCalculation: { - columnWidthPlusGap: columnWidth + gap, - startColumn: `Math.floor(${contentBoundingBox.minX} / ${columnWidth + gap}) = ${startColumn}`, - groupStartX: `${startColumn} * ${columnWidth + gap} = ${groupStartX}`, - availableWidthFromStart: `${contentBoundingBox.maxX} - ${groupStartX} = ${availableWidthFromStart}`, - currentWidthInColumns: `Math.ceil(${availableWidthFromStart} / ${columnWidth + gap}) = ${currentWidthInColumns}`, - finalGroupX: `${startColumn} * ${columnWidth + gap} = ${groupX}`, - actualGroupWidth: `${currentWidthInColumns}컬럼 * ${columnWidth}px + ${currentWidthInColumns - 1}gap * ${gap}px = ${groupWidth}px`, - }, - groupPosition: { x: groupX, y: groupY }, - groupSize: { width: groupWidth, height: groupHeight }, - scaleFactorX, - availableWidth, - padding, - scaledComponentsCount: scaledComponents.length, - scaledComponentsDetails: scaledComponents.map((comp) => { - const original = selectedComponents.find((c) => c.id === comp.id); - return { - id: comp.id, - originalPos: original?.position, - scaledPos: comp.position, - originalSize: original?.size, - scaledSize: comp.size, - deltaX: comp.position.x - (original?.position.x || 0), - deltaY: comp.position.y - (original?.position.y || 0), - }; - }), - }); - - return { - boundingBox: contentBoundingBox, - groupPosition: { x: groupX, y: groupY, z: 1 }, - groupSize: { width: groupWidth, height: groupHeight }, - gridColumns: currentWidthInColumns, - scaledComponents: scaledComponents, // 스케일된 컴포넌트들 - padding: padding, - }; - }; - - const { - boundingBox, - groupPosition, - groupSize: optimizedGroupSize, - gridColumns, - scaledComponents, - padding, - } = calculateOptimalGroupSize(); - - // 스케일된 컴포넌트들로 상대 위치 계산 (이미 최적화되어 추가 격자 정렬 불필요) - const relativeChildren = calculateRelativePositions( - scaledComponents, - groupPosition, - "temp", // 임시 그룹 ID - ); - - console.log("📏 최적화된 그룹 생성 (컴포넌트 스케일링):", { - gridColumns, - groupSize: optimizedGroupSize, - groupPosition, - scaledComponentsCount: scaledComponents.length, - padding, - strategy: "내부 컴포넌트 크기 조정으로 격자 정확 맞춤", - }); - - // 그룹 컴포넌트 생성 (gridColumns 속성 포함) - const groupComponent = createGroupComponent(componentIds, title, groupPosition, optimizedGroupSize, style); - - // 그룹에 계산된 gridColumns 속성 추가 - groupComponent.gridColumns = gridColumns; - - // 실제 그룹 ID로 자식들 업데이트 - const finalChildren = relativeChildren.map((child) => ({ - ...child, - parentId: groupComponent.id, - })); - - const newLayout = { - ...layout, - components: [ - ...layout.components.filter((comp) => !componentIds.includes(comp.id)), - groupComponent, - ...finalChildren, - ], - }; - - setLayout(newLayout); - saveToHistory(newLayout); - setGroupState((prev) => ({ - ...prev, - selectedComponents: [groupComponent.id], - isGrouping: false, - })); - setSelectedComponent(groupComponent); - - console.log("🎯 최적화된 그룹 생성 완료:", { - groupId: groupComponent.id, - childrenCount: finalChildren.length, - position: groupPosition, - size: optimizedGroupSize, - gridColumns: groupComponent.gridColumns, - componentsScaled: !!scaledComponents.length, - gridAligned: true, - }); - - toast.success(`그룹이 생성되었습니다 (${finalChildren.length}개 컴포넌트)`); - }, - [layout, saveToHistory], - ); - - // 그룹 생성 함수 (다이얼로그 표시) - const createGroup = useCallback(() => { - if (groupState.selectedComponents.length < 2) { - toast.warning("그룹을 만들려면 2개 이상의 컴포넌트를 선택해야 합니다."); - return; - } - - // console.log("🔄 그룹 생성 다이얼로그 표시"); - setShowGroupCreateDialog(true); - }, [groupState.selectedComponents]); - - // 그룹 해제 함수 (임시 비활성화) - const ungroupComponents = useCallback(() => { - // console.log("그룹 해제 기능이 임시 비활성화되었습니다."); - toast.info("그룹 해제 기능이 임시 비활성화되었습니다."); - return; - - const groupId = selectedComponent.id; - - // 자식 컴포넌트들의 절대 위치 복원 - const childComponents = layout.components.filter((comp) => comp.parentId === groupId); - const restoredChildren = restoreAbsolutePositions(childComponents, selectedComponent.position); - - // 자식 컴포넌트들의 위치 복원 및 parentId 제거 - const updatedComponents = layout.components - .map((comp) => { - if (comp.parentId === groupId) { - const restoredChild = restoredChildren.find((restored) => restored.id === comp.id); - return restoredChild || { ...comp, parentId: undefined }; - } - return comp; - }) - .filter((comp) => comp.id !== groupId); // 그룹 컴포넌트 제거 - - const newLayout = { ...layout, components: updatedComponents }; - setLayout(newLayout); - saveToHistory(newLayout); - - // 선택 상태 초기화 - setSelectedComponent(null); - setGroupState((prev) => ({ ...prev, selectedComponents: [] })); - }, [selectedComponent, layout, saveToHistory]); - - // 마우스 이벤트 처리 (드래그 및 선택) - 성능 최적화 - useEffect(() => { - let animationFrameId: number; - - const handleMouseMove = (e: MouseEvent) => { - if (dragState.isDragging) { - // requestAnimationFrame으로 부드러운 애니메이션 - if (animationFrameId) { - cancelAnimationFrame(animationFrameId); - } - animationFrameId = requestAnimationFrame(() => { - updateDragPosition(e); - }); - } else if (selectionDrag.isSelecting) { - updateSelectionDrag(e); - } - }; - - const handleMouseUp = (e: MouseEvent) => { - if (dragState.isDragging) { - if (animationFrameId) { - cancelAnimationFrame(animationFrameId); - } - endDrag(e); - } else if (selectionDrag.isSelecting) { - endSelectionDrag(); - } - }; - - if (dragState.isDragging || selectionDrag.isSelecting) { - document.addEventListener("mousemove", handleMouseMove, { passive: true }); - document.addEventListener("mouseup", handleMouseUp); - - return () => { - if (animationFrameId) { - cancelAnimationFrame(animationFrameId); - } - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - }; - } - }, [ - dragState.isDragging, - selectionDrag.isSelecting, - updateDragPosition, - endDrag, - updateSelectionDrag, - endSelectionDrag, - ]); - - // 키보드 이벤트 처리 (브라우저 기본 기능 완전 차단) - useEffect(() => { - const handleKeyDown = async (e: KeyboardEvent) => { - // console.log("🎯 키 입력 감지:", { key: e.key, ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, metaKey: e.metaKey }); - - // 🚫 브라우저 기본 단축키 완전 차단 목록 - const browserShortcuts = [ - // 검색 관련 - { ctrl: true, key: "f" }, // 페이지 내 검색 - { ctrl: true, key: "g" }, // 다음 검색 결과 - { ctrl: true, shift: true, key: "g" }, // 이전 검색 결과 - { ctrl: true, key: "h" }, // 검색 기록 - - // 탭/창 관리 - { ctrl: true, key: "t" }, // 새 탭 - { ctrl: true, key: "w" }, // 탭 닫기 - { ctrl: true, shift: true, key: "t" }, // 닫힌 탭 복원 - { ctrl: true, key: "n" }, // 새 창 - { ctrl: true, shift: true, key: "n" }, // 시크릿 창 - - // 페이지 관리 - { ctrl: true, key: "r" }, // 새로고침 - { ctrl: true, shift: true, key: "r" }, // 강제 새로고침 - { ctrl: true, key: "d" }, // 북마크 추가 - { ctrl: true, shift: true, key: "d" }, // 모든 탭 북마크 - - // 편집 관련 (필요시에만 허용) - { ctrl: true, key: "s" }, // 저장 (필요시 차단 해제) - { ctrl: true, key: "p" }, // 인쇄 - { ctrl: true, key: "o" }, // 파일 열기 - { ctrl: true, key: "v" }, // 붙여넣기 (브라우저 기본 동작 차단) - - // 개발자 도구 - { key: "F12" }, // 개발자 도구 - { ctrl: true, shift: true, key: "i" }, // 개발자 도구 - { ctrl: true, shift: true, key: "c" }, // 요소 검사 - { ctrl: true, shift: true, key: "j" }, // 콘솔 - { ctrl: true, key: "u" }, // 소스 보기 - - // 기타 - { ctrl: true, key: "j" }, // 다운로드 - { ctrl: true, shift: true, key: "delete" }, // 브라우징 데이터 삭제 - { ctrl: true, key: "+" }, // 확대 - { ctrl: true, key: "-" }, // 축소 - { ctrl: true, key: "0" }, // 확대/축소 초기화 - ]; - - // 브라우저 기본 단축키 체크 및 차단 - const isBrowserShortcut = browserShortcuts.some((shortcut) => { - const ctrlMatch = shortcut.ctrl ? e.ctrlKey || e.metaKey : true; - const shiftMatch = shortcut.shift ? e.shiftKey : !e.shiftKey; - const keyMatch = e.key?.toLowerCase() === shortcut.key?.toLowerCase(); - return ctrlMatch && shiftMatch && keyMatch; - }); - - if (isBrowserShortcut) { - // console.log("🚫 브라우저 기본 단축키 차단:", e.key); - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - } - - // ✅ 애플리케이션 전용 단축키 처리 - - // 1. 그룹 관련 단축키 - if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "g" && !e.shiftKey) { - // console.log("🔄 그룹 생성 단축키"); - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - - if (groupState.selectedComponents.length >= 2) { - // console.log("✅ 그룹 생성 실행"); - createGroup(); - } else { - // console.log("⚠️ 선택된 컴포넌트가 부족함 (2개 이상 필요)"); - } - return false; - } - - if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key?.toLowerCase() === "g") { - // console.log("🔄 그룹 해제 단축키"); - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - - if (selectedComponent && selectedComponent.type === "group") { - // console.log("✅ 그룹 해제 실행"); - ungroupComponents(); - } else { - // console.log("⚠️ 선택된 그룹이 없음"); - } - return false; - } - - // 2. 전체 선택 (애플리케이션 내에서만) - if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "a") { - // console.log("🔄 전체 선택 (애플리케이션 내)"); - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - const allComponentIds = layout.components.map((comp) => comp.id); - setGroupState((prev) => ({ ...prev, selectedComponents: allComponentIds })); - return false; - } - - // 3. 실행취소/다시실행 - if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "z" && !e.shiftKey) { - // console.log("🔄 실행취소"); - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - undo(); - return false; - } - - if ( - ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "y") || - ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key?.toLowerCase() === "z") - ) { - // console.log("🔄 다시실행"); - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - redo(); - return false; - } - - // 4. 복사 (컴포넌트 복사) - if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "c") { - // console.log("🔄 컴포넌트 복사"); - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - copyComponent(); - return false; - } - - // 5. 붙여넣기 (컴포넌트 붙여넣기) - if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "v") { - // console.log("🔄 컴포넌트 붙여넣기"); - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - pasteComponent(); - return false; - } - - // 6. 삭제 (단일/다중 선택 지원) - if (e.key === "Delete" && (selectedComponent || groupState.selectedComponents.length > 0)) { - // console.log("🗑️ 컴포넌트 삭제 (단축키)"); - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - deleteComponent(); - return false; - } - - // 7. 선택 해제 - if (e.key === "Escape") { - // console.log("🔄 선택 해제"); - setSelectedComponent(null); - setGroupState((prev) => ({ ...prev, selectedComponents: [], isGrouping: false })); - return false; - } - - // 8. 저장 (Ctrl+S는 레이아웃 저장용으로 사용) - if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "s") { - // console.log("💾 레이아웃 저장"); - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - - // 레이아웃 저장 실행 - if (layout.components.length > 0 && selectedScreen?.screenId) { - setIsSaving(true); - try { - // 해상도 정보를 포함한 레이아웃 데이터 생성 - const layoutWithResolution = { - ...layout, - screenResolution: screenResolution, - }; - console.log("⚡ 자동 저장할 레이아웃 데이터:", { - componentsCount: layoutWithResolution.components.length, - gridSettings: layoutWithResolution.gridSettings, - screenResolution: layoutWithResolution.screenResolution, - }); - // V2/POP API 사용 여부에 따라 분기 - const v2Layout = convertLegacyToV2(layoutWithResolution); - if (USE_POP_API) { - await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout); - } else if (USE_V2_API) { - await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout); - } else { - await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution); - } - toast.success("레이아웃이 저장되었습니다."); - } catch (error) { - // console.error("레이아웃 저장 실패:", error); - toast.error("레이아웃 저장에 실패했습니다."); - } finally { - setIsSaving(false); - } - } else { - // console.log("⚠️ 저장할 컴포넌트가 없습니다"); - toast.warning("저장할 컴포넌트가 없습니다."); - } - return false; - } - - // === 9. 화살표 키 Nudge (컴포넌트 미세 이동) === - if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) { - // 입력 필드에서는 무시 - const active = document.activeElement; - if ( - active instanceof HTMLInputElement || - active instanceof HTMLTextAreaElement || - active?.getAttribute("contenteditable") === "true" - ) { - return; - } - - if (selectedComponent || groupState.selectedComponents.length > 0) { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - const distance = e.shiftKey ? 10 : 1; // Shift 누르면 10px - const dirMap: Record = { - ArrowUp: "up", ArrowDown: "down", ArrowLeft: "left", ArrowRight: "right", - }; - handleNudge(dirMap[e.key], distance); - return false; - } - } - - // === 10. 정렬 단축키 (Alt + 키) - 다중 선택 시 === - if (e.altKey && !e.ctrlKey && !e.metaKey) { - const alignKey = e.key?.toLowerCase(); - const alignMap: Record = { - l: "left", r: "right", c: "centerX", - t: "top", b: "bottom", m: "centerY", - }; - - if (alignMap[alignKey] && groupState.selectedComponents.length >= 2) { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - handleGroupAlign(alignMap[alignKey]); - return false; - } - - // 균등 배분 (Alt+H: 가로, Alt+V: 세로) - if (alignKey === "h" && groupState.selectedComponents.length >= 3) { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - handleGroupDistribute("horizontal"); - return false; - } - if (alignKey === "v" && groupState.selectedComponents.length >= 3) { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - handleGroupDistribute("vertical"); - return false; - } - - // 동일 크기 맞추기 (Alt+W: 너비, Alt+E: 높이) - if (alignKey === "w" && groupState.selectedComponents.length >= 2) { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - handleMatchSize("width"); - return false; - } - if (alignKey === "e" && groupState.selectedComponents.length >= 2) { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - handleMatchSize("height"); - return false; - } - } - - // === 11. 라벨 일괄 토글 (Alt+Shift+L) === - if (e.altKey && e.shiftKey && e.key?.toLowerCase() === "l") { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - handleToggleAllLabels(); - return false; - } - - // === 12. 단축키 도움말 (? 키) === - if (e.key === "?" && !e.ctrlKey && !e.metaKey && !e.altKey) { - // 입력 필드에서는 무시 - const active = document.activeElement; - if ( - active instanceof HTMLInputElement || - active instanceof HTMLTextAreaElement || - active?.getAttribute("contenteditable") === "true" - ) { - return; - } - e.preventDefault(); - setShowShortcutsModal(true); - return false; - } - }; - - // window 레벨에서 캡처 단계에서 가장 먼저 처리 - window.addEventListener("keydown", handleKeyDown, { capture: true, passive: false }); - return () => window.removeEventListener("keydown", handleKeyDown, { capture: true }); - }, [ - selectedComponent, - deleteComponent, - copyComponent, - pasteComponent, - undo, - redo, - createGroup, - ungroupComponents, - groupState.selectedComponents, - layout, - selectedScreen, - handleNudge, - handleGroupAlign, - handleGroupDistribute, - handleMatchSize, - handleToggleAllLabels, - ]); - - // 플로우 위젯 높이 자동 업데이트 이벤트 리스너 - useEffect(() => { - const handleComponentSizeUpdate = (event: CustomEvent) => { - const { componentId, height } = event.detail; - - // 해당 컴포넌트 찾기 - const targetComponent = layout.components.find((c) => c.id === componentId); - if (!targetComponent) { - return; - } - - // 이미 같은 높이면 업데이트 안함 - if (targetComponent.size?.height === height) { - return; - } - - // 컴포넌트 높이 업데이트 - const updatedComponents = layout.components.map((comp) => { - if (comp.id === componentId) { - return { - ...comp, - size: { - ...comp.size, - width: comp.size?.width || 100, - height: height, - }, - }; - } - return comp; - }); - - const newLayout = { - ...layout, - components: updatedComponents, - }; - - setLayout(newLayout); - - // 선택된 컴포넌트도 업데이트 - if (selectedComponent?.id === componentId) { - const updatedComponent = updatedComponents.find((c) => c.id === componentId); - if (updatedComponent) { - setSelectedComponent(updatedComponent); - } - } - }; - - window.addEventListener("updateComponentSize", handleComponentSizeUpdate as EventListener); - return () => { - window.removeEventListener("updateComponentSize", handleComponentSizeUpdate as EventListener); - }; - }, [layout, selectedComponent]); - - // 🆕 조건부 영역 드래그 핸들러 (이동/리사이즈, DB 기반) - const handleRegionMouseDown = useCallback(( - e: React.MouseEvent, - layerId: string, - mode: "move" | "resize", - handle?: string, - ) => { - e.stopPropagation(); - e.preventDefault(); - const lid = Number(layerId); - const region = layerRegions[lid]; - if (!region) return; - - const canvasRect = canvasRef.current?.getBoundingClientRect(); - if (!canvasRect) return; - - const x = (e.clientX - canvasRect.left) / zoomLevel; - const y = (e.clientY - canvasRect.top) / zoomLevel; - - setRegionDrag({ - isDrawing: false, - isDragging: mode === "move", - isResizing: mode === "resize", - targetLayerId: layerId, - startX: x, - startY: y, - currentX: x, - currentY: y, - resizeHandle: handle || null, - originalRegion: { x: region.x, y: region.y, width: region.width, height: region.height }, - }); - }, [layerRegions, zoomLevel]); - - // 🆕 캔버스 마우스 이벤트 (영역 이동/리사이즈) - const handleRegionCanvasMouseMove = useCallback((e: React.MouseEvent) => { - if (!regionDrag.isDragging && !regionDrag.isResizing) return; - if (!regionDrag.targetLayerId) return; - - const canvasRect = canvasRef.current?.getBoundingClientRect(); - if (!canvasRect) return; - - const x = (e.clientX - canvasRect.left) / zoomLevel; - const y = (e.clientY - canvasRect.top) / zoomLevel; - - if (regionDrag.isDragging && regionDrag.originalRegion) { - const dx = x - regionDrag.startX; - const dy = y - regionDrag.startY; - const newRegion = { - x: Math.max(0, Math.round(regionDrag.originalRegion.x + dx)), - y: Math.max(0, Math.round(regionDrag.originalRegion.y + dy)), - width: regionDrag.originalRegion.width, - height: regionDrag.originalRegion.height, - }; - const lid = Number(regionDrag.targetLayerId); - setLayerRegions((prev) => ({ - ...prev, - [lid]: { ...prev[lid], ...newRegion }, - })); - } else if (regionDrag.isResizing && regionDrag.originalRegion) { - const dx = x - regionDrag.startX; - const dy = y - regionDrag.startY; - const orig = regionDrag.originalRegion; - const newRegion = { ...orig }; - - const handle = regionDrag.resizeHandle; - if (handle?.includes("e")) newRegion.width = Math.max(50, Math.round(orig.width + dx)); - if (handle?.includes("s")) newRegion.height = Math.max(30, Math.round(orig.height + dy)); - if (handle?.includes("w")) { - newRegion.x = Math.max(0, Math.round(orig.x + dx)); - newRegion.width = Math.max(50, Math.round(orig.width - dx)); - } - if (handle?.includes("n")) { - newRegion.y = Math.max(0, Math.round(orig.y + dy)); - newRegion.height = Math.max(30, Math.round(orig.height - dy)); - } - - const lid = Number(regionDrag.targetLayerId); - setLayerRegions((prev) => ({ - ...prev, - [lid]: { ...prev[lid], ...newRegion }, - })); - } - }, [regionDrag, zoomLevel]); - - const handleRegionCanvasMouseUp = useCallback(async () => { - // 드래그 완료 시 DB에 영역 저장 - if ((regionDrag.isDragging || regionDrag.isResizing) && regionDrag.targetLayerId && selectedScreen?.screenId) { - const lid = Number(regionDrag.targetLayerId); - const region = layerRegions[lid]; - if (region) { - try { - const layerData = await screenApi.getLayerLayout(selectedScreen.screenId, lid); - const existingCondition = layerData?.conditionConfig || {}; - await screenApi.updateLayerCondition( - selectedScreen.screenId, lid, - { ...existingCondition, displayRegion: { x: region.x, y: region.y, width: region.width, height: region.height } } - ); - } catch { - console.error("영역 저장 실패"); - } - } - } - // 드래그 상태 초기화 - setRegionDrag({ - isDrawing: false, - isDragging: false, - isResizing: false, - targetLayerId: null, - startX: 0, startY: 0, currentX: 0, currentY: 0, - resizeHandle: null, - originalRegion: null, - }); - }, [regionDrag, layerRegions, selectedScreen]); - - // 🆕 레이어 변경 핸들러 - 레이어 컨텍스트에서 레이어가 변경되면 layout에도 반영 - // 주의: layout.layers에 직접 설정된 displayRegion 등 메타데이터를 보존 - const handleLayersChange = useCallback((newLayers: LayerDefinition[]) => { - setLayout((prevLayout) => { - // 기존 layout.layers의 메타데이터(displayRegion 등)를 보존하며 병합 - const mergedLayers = newLayers.map((newLayer) => { - const existingLayer = prevLayout.layers?.find((l) => l.id === newLayer.id); - if (!existingLayer) return newLayer; - - // LayerContext에서 온 데이터(condition 등)를 우선하되, - // layout.layers에만 있는 데이터(캔버스에서 직접 수정한 displayRegion)도 보존 - return { - ...existingLayer, // 기존 메타데이터 보존 (displayRegion 등) - ...newLayer, // LayerContext 데이터 우선 (condition, name, isVisible 등) - // displayRegion: 양쪽 모두 있을 수 있으므로 최신 값 우선 - displayRegion: newLayer.displayRegion !== undefined - ? newLayer.displayRegion - : existingLayer.displayRegion, - }; - }); - - return { - ...prevLayout, - layers: mergedLayers, - }; - }); - }, []); - - // 🆕 활성 레이어 변경 핸들러 - const handleActiveLayerChange = useCallback((newActiveLayerId: number) => { - setActiveLayerIdWithRef(newActiveLayerId); - }, [setActiveLayerIdWithRef]); - - // 🆕 초기 레이어 계산 - layout에서 layers가 있으면 사용, 없으면 기본 레이어 생성 - // 주의: components는 layout.components에 layerId 속성으로 저장되므로, layer.components는 비워둠 - const initialLayers = useMemo(() => { - if (layout.layers && layout.layers.length > 0) { - // 기존 레이어 구조 사용 (layer.components는 무시하고 빈 배열로 설정) - return layout.layers.map(layer => ({ - ...layer, - components: [], // layout.components + layerId 방식 사용 - })); - } - // layers가 없으면 기본 레이어 생성 (components는 빈 배열) - return [createDefaultLayer()]; - }, [layout.layers]); - - if (!selectedScreen) { - return ( -
-
-
- -
-

화면을 선택하세요

-

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

-
-
- ); - } - - // 🔧 ScreenDesigner 렌더링 확인 (디버그 완료 - 주석 처리) - // console.log("🏠 ScreenDesigner 렌더!", Date.now()); - - return ( - - - -
- {/* 상단 슬림 툴바 */} - setShowMultilangSettingsModal(true)} - isPanelOpen={panelStates.v2?.isOpen || false} - onTogglePanel={() => togglePanel("v2")} - selectedCount={groupState.selectedComponents.length} - onAlign={handleGroupAlign} - onDistribute={handleGroupDistribute} - onMatchSize={handleMatchSize} - onToggleLabels={handleToggleAllLabels} - onShowShortcuts={() => setShowShortcutsModal(true)} - /> - {/* 메인 컨테이너 (패널들 + 캔버스) */} -
- {/* 통합 패널 - 좌측 사이드바 제거 후 너비 300px로 확장 */} - {panelStates.v2?.isOpen && ( -
-
-

패널

- -
-
- - - - 컴포넌트 - - - 레이어 - - - 편집 - - - - - { - const dragData = { - type: column ? "column" : "table", - table, - column, - }; - e.dataTransfer.setData("application/json", JSON.stringify(dragData)); - }} - selectedTableName={selectedScreen?.tableName} - placedColumns={placedColumns} - onTableSelect={handleTableSelect} - showTableSelector={true} - /> - - - {/* 🆕 레이어 관리 탭 (DB 기반) */} - - { - if (!selectedScreen?.screenId) return; - try { - // 1. 현재 레이어 저장 - const curId = Number(activeLayerIdRef.current) || 1; - const v2Layout = convertLegacyToV2({ ...layout, screenResolution }); - await screenApi.saveLayoutV2(selectedScreen.screenId, { ...v2Layout, layerId: curId }); - - // 2. 새 레이어 로드 - const data = await screenApi.getLayerLayout(selectedScreen.screenId, layerId); - if (data && data.components) { - const legacy = convertV2ToLegacy(data); - if (legacy) { - setLayout((prev) => ({ ...prev, components: legacy.components })); - } else { - setLayout((prev) => ({ ...prev, components: [] })); - } - } else { - setLayout((prev) => ({ ...prev, components: [] })); - } - - setActiveLayerIdWithRef(layerId); - setSelectedComponent(null); - } catch (error) { - console.error("레이어 전환 실패:", error); - toast.error("레이어 전환에 실패했습니다."); - } - }} - components={layout.components} - /> - - - - {/* 탭 내부 컴포넌트 선택 시에도 V2PropertiesPanel 사용 */} - {selectedTabComponentInfo ? ( - (() => { - const tabComp = selectedTabComponentInfo.component; - - // 탭 내부 컴포넌트를 ComponentData 형식으로 변환 - const tabComponentAsComponentData: ComponentData = { - id: tabComp.id, - type: "component", - componentType: tabComp.componentType, - label: tabComp.label, - position: tabComp.position || { x: 0, y: 0 }, - size: tabComp.size || { width: 200, height: 100 }, - componentConfig: tabComp.componentConfig || {}, - style: tabComp.style || {}, - } as ComponentData; - - // 탭 내부 컴포넌트용 속성 업데이트 핸들러 (중첩 구조 지원) - const updateTabComponentProperty = (componentId: string, path: string, value: any) => { - const { tabsComponentId, tabId, parentSplitPanelId, parentPanelSide } = - selectedTabComponentInfo; - - console.log("🔧 updateTabComponentProperty 호출:", { - componentId, - path, - value, - parentSplitPanelId, - parentPanelSide, - }); - - // 🆕 안전한 깊은 경로 업데이트 헬퍼 함수 - const setNestedValue = (obj: any, pathStr: string, val: any): any => { - // 깊은 복사로 시작 - const result = JSON.parse(JSON.stringify(obj)); - const parts = pathStr.split("."); - let current = result; - - for (let i = 0; i < parts.length - 1; i++) { - const part = parts[i]; - if (!current[part] || typeof current[part] !== "object") { - current[part] = {}; - } - current = current[part]; - } - current[parts[parts.length - 1]] = val; - return result; - }; - - // 탭 컴포넌트 업데이트 함수 - const updateTabsComponent = (tabsComponent: any) => { - const currentConfig = JSON.parse(JSON.stringify(tabsComponent.componentConfig || {})); - const tabs = currentConfig.tabs || []; - - const updatedTabs = tabs.map((tab: any) => { - if (tab.id === tabId) { - return { - ...tab, - components: (tab.components || []).map((comp: any) => { - if (comp.id !== componentId) return comp; - - // 🆕 안전한 깊은 경로 업데이트 사용 - const updatedComp = setNestedValue(comp, path, value); - console.log("🔧 컴포넌트 업데이트 결과:", updatedComp); - return updatedComp; - }), - }; - } - return tab; - }); - - return { - ...tabsComponent, - componentConfig: { ...currentConfig, tabs: updatedTabs }, - }; - }; - - setLayout((prevLayout) => { - let newLayout; - let updatedTabs; - - if (parentSplitPanelId && parentPanelSide) { - // 🆕 중첩 구조: 분할 패널 안의 탭 업데이트 - newLayout = { - ...prevLayout, - components: prevLayout.components.map((c) => { - if (c.id === parentSplitPanelId) { - const splitConfig = (c as any).componentConfig || {}; - const panelKey = parentPanelSide === "left" ? "leftPanel" : "rightPanel"; - const panelConfig = splitConfig[panelKey] || {}; - const panelComponents = panelConfig.components || []; - - const tabsComponent = panelComponents.find( - (pc: any) => pc.id === tabsComponentId, - ); - if (!tabsComponent) return c; - - const updatedTabsComponent = updateTabsComponent(tabsComponent); - updatedTabs = updatedTabsComponent.componentConfig.tabs; - - return { - ...c, - componentConfig: { - ...splitConfig, - [panelKey]: { - ...panelConfig, - components: panelComponents.map((pc: any) => - pc.id === tabsComponentId ? updatedTabsComponent : pc, - ), - }, - }, - }; - } - return c; - }), - }; - } else { - // 일반 구조: 최상위 탭 업데이트 - const tabsComponent = prevLayout.components.find((c) => c.id === tabsComponentId); - if (!tabsComponent) return prevLayout; - - const updatedTabsComponent = updateTabsComponent(tabsComponent); - updatedTabs = updatedTabsComponent.componentConfig.tabs; - - newLayout = { - ...prevLayout, - components: prevLayout.components.map((c) => - c.id === tabsComponentId ? updatedTabsComponent : c, - ), - }; - } - - // 선택된 컴포넌트 정보 업데이트 - if (updatedTabs) { - const updatedComp = updatedTabs - .find((t: any) => t.id === tabId) - ?.components?.find((c: any) => c.id === componentId); - if (updatedComp) { - setSelectedTabComponentInfo((prev) => - prev ? { ...prev, component: updatedComp } : null, - ); - } - } - - return newLayout; - }); - }; - - // 탭 내부 컴포넌트 삭제 핸들러 (중첩 구조 지원) - const deleteTabComponent = (componentId: string) => { - const { tabsComponentId, tabId, parentSplitPanelId, parentPanelSide } = - selectedTabComponentInfo; - - // 탭 컴포넌트에서 특정 컴포넌트 삭제 - const updateTabsComponentForDelete = (tabsComponent: any) => { - const currentConfig = tabsComponent.componentConfig || {}; - const tabs = currentConfig.tabs || []; - - const updatedTabs = tabs.map((tab: any) => { - if (tab.id === tabId) { - return { - ...tab, - components: (tab.components || []).filter((c: any) => c.id !== componentId), - }; - } - return tab; - }); - - return { - ...tabsComponent, - componentConfig: { ...currentConfig, tabs: updatedTabs }, - }; - }; - - setLayout((prevLayout) => { - let newLayout; - - if (parentSplitPanelId && parentPanelSide) { - // 🆕 중첩 구조: 분할 패널 안의 탭에서 삭제 - newLayout = { - ...prevLayout, - components: prevLayout.components.map((c) => { - if (c.id === parentSplitPanelId) { - const splitConfig = (c as any).componentConfig || {}; - const panelKey = parentPanelSide === "left" ? "leftPanel" : "rightPanel"; - const panelConfig = splitConfig[panelKey] || {}; - const panelComponents = panelConfig.components || []; - - const tabsComponent = panelComponents.find( - (pc: any) => pc.id === tabsComponentId, - ); - if (!tabsComponent) return c; - - const updatedTabsComponent = updateTabsComponentForDelete(tabsComponent); - - return { - ...c, - componentConfig: { - ...splitConfig, - [panelKey]: { - ...panelConfig, - components: panelComponents.map((pc: any) => - pc.id === tabsComponentId ? updatedTabsComponent : pc, - ), - }, - }, - }; - } - return c; - }), - }; - } else { - // 일반 구조: 최상위 탭에서 삭제 - const tabsComponent = prevLayout.components.find((c) => c.id === tabsComponentId); - if (!tabsComponent) return prevLayout; - - const updatedTabsComponent = updateTabsComponentForDelete(tabsComponent); - - newLayout = { - ...prevLayout, - components: prevLayout.components.map((c) => - c.id === tabsComponentId ? updatedTabsComponent : c, - ), - }; - } - - setSelectedTabComponentInfo(null); - return newLayout; - }); - }; - - return ( -
-
- 탭 내부 컴포넌트 - -
-
- 0 ? tables[0] : undefined} - currentTableName={selectedScreen?.tableName} - currentScreenCompanyCode={selectedScreen?.companyCode} - onStyleChange={(style) => { - updateTabComponentProperty(tabComp.id, "style", style); - }} - allComponents={layout.components} - menuObjid={menuObjid} - /> -
-
- ); - })() - ) : selectedPanelComponentInfo ? ( - // 🆕 분할 패널 내부 컴포넌트 선택 시 V2PropertiesPanel 사용 - (() => { - const panelComp = selectedPanelComponentInfo.component; - - // 분할 패널 내부 컴포넌트를 ComponentData 형식으로 변환 - const panelComponentAsComponentData: ComponentData = { - id: panelComp.id, - type: "component", - componentType: panelComp.componentType, - label: panelComp.label, - position: panelComp.position || { x: 0, y: 0 }, - size: panelComp.size || { width: 200, height: 100 }, - componentConfig: panelComp.componentConfig || {}, - style: panelComp.style || {}, - } as ComponentData; - - // 분할 패널 내부 컴포넌트용 속성 업데이트 핸들러 - const updatePanelComponentProperty = (componentId: string, path: string, value: any) => { - const { splitPanelId, panelSide } = selectedPanelComponentInfo; - const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel"; - - console.log("🔧 updatePanelComponentProperty 호출:", { - componentId, - path, - value, - splitPanelId, - panelSide, - }); - - // 🆕 안전한 깊은 경로 업데이트 헬퍼 함수 - const setNestedValue = (obj: any, pathStr: string, val: any): any => { - const result = JSON.parse(JSON.stringify(obj)); - const parts = pathStr.split("."); - let current = result; - - for (let i = 0; i < parts.length - 1; i++) { - const part = parts[i]; - if (!current[part] || typeof current[part] !== "object") { - current[part] = {}; - } - current = current[part]; - } - current[parts[parts.length - 1]] = val; - return result; - }; - - setLayout((prevLayout) => { - const splitPanelComponent = prevLayout.components.find((c) => c.id === splitPanelId); - if (!splitPanelComponent) return prevLayout; - - const currentConfig = (splitPanelComponent as any).componentConfig || {}; - const panelConfig = currentConfig[panelKey] || {}; - const components = panelConfig.components || []; - - // 해당 컴포넌트 찾기 - const targetCompIndex = components.findIndex((c: any) => c.id === componentId); - if (targetCompIndex === -1) return prevLayout; - - // 🆕 안전한 깊은 경로 업데이트 사용 - const targetComp = components[targetCompIndex]; - const updatedComp = - path === "style" - ? { ...targetComp, style: value } - : setNestedValue(targetComp, path, value); - - console.log("🔧 분할 패널 컴포넌트 업데이트 결과:", updatedComp); - - const updatedComponents = [ - ...components.slice(0, targetCompIndex), - updatedComp, - ...components.slice(targetCompIndex + 1), - ]; - - const updatedComponent = { - ...splitPanelComponent, - componentConfig: { - ...currentConfig, - [panelKey]: { - ...panelConfig, - components: updatedComponents, - }, - }, - }; - - // selectedPanelComponentInfo 업데이트 - setSelectedPanelComponentInfo((prev) => - prev ? { ...prev, component: updatedComp } : null, - ); - - return { - ...prevLayout, - components: prevLayout.components.map((c) => - c.id === splitPanelId ? updatedComponent : c, - ), - }; - }); - }; - - // 분할 패널 내부 컴포넌트 삭제 핸들러 - const deletePanelComponent = (componentId: string) => { - const { splitPanelId, panelSide } = selectedPanelComponentInfo; - const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel"; - - setLayout((prevLayout) => { - const splitPanelComponent = prevLayout.components.find((c) => c.id === splitPanelId); - if (!splitPanelComponent) return prevLayout; - - const currentConfig = (splitPanelComponent as any).componentConfig || {}; - const panelConfig = currentConfig[panelKey] || {}; - const components = panelConfig.components || []; - - const updatedComponents = components.filter((c: any) => c.id !== componentId); - - const updatedComponent = { - ...splitPanelComponent, - componentConfig: { - ...currentConfig, - [panelKey]: { - ...panelConfig, - components: updatedComponents, - }, - }, - }; - - setSelectedPanelComponentInfo(null); - - return { - ...prevLayout, - components: prevLayout.components.map((c) => - c.id === splitPanelId ? updatedComponent : c, - ), - }; - }); - }; - - return ( -
-
- - 분할 패널 ({selectedPanelComponentInfo.panelSide === "left" ? "좌측" : "우측"}) - 컴포넌트 - - -
-
- 0 ? tables[0] : undefined} - currentTableName={selectedScreen?.tableName} - currentScreenCompanyCode={selectedScreen?.companyCode} - onStyleChange={(style) => { - updatePanelComponentProperty(panelComp.id, "style", style); - }} - allComponents={layout.components} - menuObjid={menuObjid} - /> -
-
- ); - })() - ) : ( - 0 ? tables[0] : undefined} - currentTableName={selectedScreen?.tableName} - currentScreenCompanyCode={selectedScreen?.companyCode} - dragState={dragState} - onStyleChange={(style) => { - if (selectedComponent) { - updateComponentProperty(selectedComponent.id, "style", style); - } - }} - allComponents={layout.components} // 🆕 플로우 위젯 감지용 - menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 - /> - )} -
-
-
-
- )} - - {/* 메인 캔버스 영역 (스크롤 가능한 컨테이너) - GPU 가속 스크롤 적용 */} -
- {/* Pan 모드 안내 - 제거됨 */} - {/* 줌 레벨 표시 */} -
- 🔍 {Math.round(zoomLevel * 100)}% -
- {/* 🆕 플로우 버튼 그룹 제어 (버튼 선택 시 표시) */} - {(() => { - // 선택된 컴포넌트들 - const selectedComps = layout.components.filter((c) => groupState.selectedComponents.includes(c.id)); - - // 버튼 컴포넌트만 필터링 - const selectedButtons = selectedComps.filter((comp) => areAllButtons([comp])); - - // 플로우 그룹에 속한 버튼이 있는지 확인 - const hasFlowGroupButton = selectedButtons.some((btn) => { - const flowConfig = (btn as any).webTypeConfig?.flowVisibilityConfig; - return flowConfig?.enabled && flowConfig.layoutBehavior === "auto-compact" && flowConfig.groupId; - }); - - // 버튼이 선택되었거나 플로우 그룹 버튼이 있으면 표시 - const shouldShow = selectedButtons.length >= 1 && (selectedButtons.length >= 2 || hasFlowGroupButton); - - if (!shouldShow) return null; - - return ( -
-
-
- - - - - - {selectedButtons.length}개 버튼 선택됨 -
- - {/* 그룹 생성 버튼 (2개 이상 선택 시) */} - {selectedButtons.length >= 2 && ( - - )} - - {/* 그룹 해제 버튼 (플로우 그룹 버튼이 있으면 항상 표시) */} - {hasFlowGroupButton && ( - - )} - - {/* 상태 표시 */} - {hasFlowGroupButton &&

✓ 플로우 그룹 버튼

} -
-
- ); - })()} - {/* 🆕 활성 레이어 인디케이터 (기본 레이어가 아닌 경우 표시) */} - {activeLayerId > 1 && ( -
-
- - 레이어 {activeLayerId} 편집 중 - {layerRegions[activeLayerId] && ( - - (캔버스: {layerRegions[activeLayerId].width} x {layerRegions[activeLayerId].height}px) - - )} - {!layerRegions[activeLayerId] && ( - - (조건부 영역 미설정 - 기본 레이어에서 영역을 먼저 배치하세요) - - )} - -
- )} - - {/* 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬 + contain 최적화 */} - {(() => { - // 🆕 조건부 레이어 편집 시 캔버스 크기를 displayRegion에 맞춤 - const activeRegion = activeLayerId > 1 ? layerRegions[activeLayerId] : null; - const canvasW = activeRegion ? activeRegion.width : screenResolution.width; - const canvasH = activeRegion ? activeRegion.height : screenResolution.height; - - return ( -
- {/* 실제 작업 캔버스 (해상도 크기 또는 조건부 레이어 영역 크기) */} -
-
{ - if (e.target === e.currentTarget && !selectionDrag.wasSelecting && !isPanMode) { - setSelectedComponent(null); - setGroupState((prev) => ({ ...prev, selectedComponents: [] })); - } - }} - onMouseDown={(e) => { - // Pan 모드가 아닐 때만 다중 선택 시작 - if (e.target === e.currentTarget && !isPanMode) { - startSelectionDrag(e); - } - }} - onMouseMove={(e) => { - // 영역 이동/리사이즈 처리 - if (regionDrag.isDragging || regionDrag.isResizing) { - handleRegionCanvasMouseMove(e); - } - }} - onMouseUp={() => { - if (regionDrag.isDragging || regionDrag.isResizing) { - handleRegionCanvasMouseUp(); - } - }} - onMouseLeave={() => { - if (regionDrag.isDragging || regionDrag.isResizing) { - handleRegionCanvasMouseUp(); - } - }} - onDragOver={(e) => { - e.preventDefault(); - e.dataTransfer.dropEffect = "copy"; - }} - onDropCapture={(e) => { - // 캡처 단계에서 드롭 이벤트를 처리하여 자식 요소 드롭도 감지 - e.preventDefault(); - handleDrop(e); - }} - > - {/* 격자 라인 */} - {gridLines.map((line, index) => ( -
- ))} - - {/* 컴포넌트들 */} - {(() => { - // 🆕 플로우 버튼 그룹 감지 및 처리 - // visibleComponents를 사용하여 활성 레이어의 컴포넌트만 표시 - const topLevelComponents = visibleComponents.filter((component) => !component.parentId); - - // auto-compact 모드의 버튼들을 그룹별로 묶기 - const buttonGroups: Record = {}; - const processedButtonIds = new Set(); - - topLevelComponents.forEach((component) => { - const isButton = - component.type === "button" || - (component.type === "component" && - ["button-primary", "button-secondary"].includes((component as any).componentType)); - - if (isButton) { - const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig as - | FlowVisibilityConfig - | undefined; - - if ( - flowConfig?.enabled && - flowConfig.layoutBehavior === "auto-compact" && - flowConfig.groupId - ) { - if (!buttonGroups[flowConfig.groupId]) { - buttonGroups[flowConfig.groupId] = []; - } - buttonGroups[flowConfig.groupId].push(component); - processedButtonIds.add(component.id); - } - } - }); - - // 그룹에 속하지 않은 일반 컴포넌트들 - const regularComponents = topLevelComponents.filter((c) => !processedButtonIds.has(c.id)); - - // 🔧 렌더링 확인 로그 (디버그 완료 - 주석 처리) - // console.log("🔄 ScreenDesigner 렌더링:", { componentsCount: regularComponents.length, forceRenderTrigger, timestamp: Date.now() }); - - return ( - <> - {/* 조건부 레이어 영역 (기본 레이어에서만 표시, DB 기반) */} - {activeLayerId === 1 && Object.entries(layerRegions).map(([layerIdStr, region]) => { - const layerId = Number(layerIdStr); - const resizeHandles = ["nw", "ne", "sw", "se", "n", "s", "e", "w"]; - const handleCursors: Record = { - nw: "nwse-resize", ne: "nesw-resize", sw: "nesw-resize", se: "nwse-resize", - n: "ns-resize", s: "ns-resize", e: "ew-resize", w: "ew-resize", - }; - const handlePositions: Record = { - nw: { top: -4, left: -4 }, ne: { top: -4, right: -4 }, - sw: { bottom: -4, left: -4 }, se: { bottom: -4, right: -4 }, - n: { top: -4, left: "50%", transform: "translateX(-50%)" }, - s: { bottom: -4, left: "50%", transform: "translateX(-50%)" }, - e: { top: "50%", right: -4, transform: "translateY(-50%)" }, - w: { top: "50%", left: -4, transform: "translateY(-50%)" }, - }; - return ( -
handleRegionMouseDown(e, String(layerId), "move")} - > - - 레이어 {layerId} - {region.layerName} - - {/* 리사이즈 핸들 */} - {resizeHandles.map((handle) => ( -
handleRegionMouseDown(e, String(layerId), "resize", handle)} - /> - ))} - {/* 삭제 버튼 */} - -
- ); - })} - - - {/* 일반 컴포넌트들 */} - {regularComponents.map((component) => { - const children = - component.type === "group" - ? layout.components.filter((child) => child.parentId === component.id) - : []; - - // 드래그 중 시각적 피드백 (다중 선택 지원) - const isDraggingThis = - dragState.isDragging && dragState.draggedComponent?.id === component.id; - const isBeingDragged = - dragState.isDragging && - dragState.draggedComponents.some((dragComp) => dragComp.id === component.id); - - let displayComponent = component; - - if (isBeingDragged) { - if (isDraggingThis) { - // 주 드래그 컴포넌트: 마우스 위치 기반으로 실시간 위치 업데이트 - displayComponent = { - ...component, - position: dragState.currentPosition, - style: { - ...component.style, - opacity: 0.8, - transform: "scale(1.02)", - transition: "none", - zIndex: 50, - }, - }; - } else { - // 다른 선택된 컴포넌트들: 상대적 위치로 실시간 업데이트 - const originalComponent = dragState.draggedComponents.find( - (dragComp) => dragComp.id === component.id, - ); - if (originalComponent) { - const deltaX = dragState.currentPosition.x - dragState.originalPosition.x; - const deltaY = dragState.currentPosition.y - dragState.originalPosition.y; - - displayComponent = { - ...component, - position: { - x: originalComponent.position.x + deltaX, - y: originalComponent.position.y + deltaY, - z: originalComponent.position.z || 1, - } as Position, - style: { - ...component.style, - opacity: 0.8, - transition: "none", - zIndex: 40, // 주 컴포넌트보다 약간 낮게 - }, - }; - } - } - } - - // 전역 파일 상태도 key에 포함하여 실시간 리렌더링 - const globalFileState = - typeof window !== "undefined" ? (window as any).globalFileState || {} : {}; - const globalFiles = globalFileState[component.id] || []; - const componentFiles = (component as any).uploadedFiles || []; - const fileStateKey = `${globalFiles.length}-${JSON.stringify(globalFiles.map((f: any) => f.objid) || [])}-${componentFiles.length}`; - // 🆕 style 변경 시 리렌더링을 위한 key 추가 - const styleKey = - component.style?.labelDisplay !== undefined - ? `label-${component.style.labelDisplay}` - : ""; - const fullKey = `${component.id}-${fileStateKey}-${styleKey}-${(component as any).lastFileUpdate || 0}-${forceRenderTrigger}`; - - // 🔧 v2-input 계열 컴포넌트 key 변경 로그 (디버그 완료 - 주석 처리) - // if (component.id.includes("v2-") || component.widgetType?.includes("v2-")) { console.log("🔑 RealtimePreview key:", { id: component.id, styleKey, labelDisplay: component.style?.labelDisplay, forceRenderTrigger, fullKey }); } - - // 🆕 labelDisplay 변경 시 새 객체로 강제 변경 감지 - const componentWithLabel = { - ...displayComponent, - _labelDisplayKey: component.style?.labelDisplay, - }; - - return ( - handleComponentClick(component, e)} - onDoubleClick={(e) => handleComponentDoubleClick(component, e)} - onDragStart={(e) => startComponentDrag(component, e)} - onDragEnd={endDrag} - selectedScreen={selectedScreen} - tableName={selectedScreen?.tableName} // 🆕 디자인 모드에서도 옵션 로딩을 위해 전달 - menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 - // onZoneComponentDrop 제거 - onZoneClick={handleZoneClick} - // 설정 변경 핸들러 (테이블 페이지 크기 등 설정을 상세설정에 반영) - onConfigChange={(config) => { - // console.log("📤 테이블 설정 변경을 상세설정에 반영:", config); - - // 컴포넌트의 componentConfig 업데이트 - const updatedComponents = layout.components.map((comp) => { - if (comp.id === component.id) { - return { - ...comp, - componentConfig: { - ...comp.componentConfig, - ...config, - }, - }; - } - return comp; - }); - - const newLayout = { - ...layout, - components: updatedComponents, - }; - - setLayout(newLayout); - saveToHistory(newLayout); - - console.log("✅ 컴포넌트 설정 업데이트 완료:", { - componentId: component.id, - updatedConfig: config, - }); - }} - // 🆕 컴포넌트 전체 업데이트 핸들러 (탭 내부 컴포넌트 위치 조정 등) - onUpdateComponent={(updatedComponent) => { - const updatedComponents = layout.components.map((comp) => - comp.id === updatedComponent.id ? updatedComponent : comp, - ); - - const newLayout = { - ...layout, - components: updatedComponents, - }; - - setLayout(newLayout); - saveToHistory(newLayout); - }} - // 🆕 리사이즈 핸들러 (10px 스냅 적용됨) - onResize={(componentId, newSize) => { - setLayout((prevLayout) => { - const updatedComponents = prevLayout.components.map((comp) => - comp.id === componentId ? { ...comp, size: newSize } : comp, - ); - - const newLayout = { - ...prevLayout, - components: updatedComponents, - }; - - // saveToHistory는 별도로 호출 (prevLayout 기반) - setTimeout(() => saveToHistory(newLayout), 0); - return newLayout; - }); - }} - // 🆕 탭 내부 컴포넌트 선택 핸들러 - onSelectTabComponent={(tabId, compId, comp) => - handleSelectTabComponent(component.id, tabId, compId, comp) - } - selectedTabComponentId={ - selectedTabComponentInfo?.tabsComponentId === component.id - ? selectedTabComponentInfo.componentId - : undefined - } - // 🆕 분할 패널 내부 컴포넌트 선택 핸들러 - onSelectPanelComponent={(panelSide, compId, comp) => - handleSelectPanelComponent(component.id, panelSide, compId, comp) - } - selectedPanelComponentId={ - selectedPanelComponentInfo?.splitPanelId === component.id - ? selectedPanelComponentInfo.componentId - : undefined - } - > - {/* 컨테이너, 그룹, 영역, 컴포넌트의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */} - {(component.type === "group" || - component.type === "container" || - component.type === "area" || - component.type === "component") && - layout.components - .filter((child) => child.parentId === component.id) - .map((child) => { - // 자식 컴포넌트에도 드래그 피드백 적용 - const isChildDraggingThis = - dragState.isDragging && dragState.draggedComponent?.id === child.id; - const isChildBeingDragged = - dragState.isDragging && - dragState.draggedComponents.some((dragComp) => dragComp.id === child.id); - - let displayChild = child; - - if (isChildBeingDragged) { - if (isChildDraggingThis) { - // 주 드래그 자식 컴포넌트 - displayChild = { - ...child, - position: dragState.currentPosition, - style: { - ...child.style, - opacity: 0.8, - transform: "scale(1.02)", - transition: "none", - zIndex: 50, - }, - }; - } else { - // 다른 선택된 자식 컴포넌트들 - const originalChildComponent = dragState.draggedComponents.find( - (dragComp) => dragComp.id === child.id, - ); - if (originalChildComponent) { - const deltaX = dragState.currentPosition.x - dragState.originalPosition.x; - const deltaY = dragState.currentPosition.y - dragState.originalPosition.y; - - displayChild = { - ...child, - position: { - x: originalChildComponent.position.x + deltaX, - y: originalChildComponent.position.y + deltaY, - z: originalChildComponent.position.z || 1, - } as Position, - style: { - ...child.style, - opacity: 0.8, - transition: "none", - zIndex: 8888, - }, - }; - } - } - } - - // 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정 - const relativeChildComponent = { - ...displayChild, - position: { - x: displayChild.position.x - component.position.x, - y: displayChild.position.y - component.position.y, - z: displayChild.position.z || 1, - }, - }; - - return ( - f.objid) || [])}`} - component={relativeChildComponent} - isSelected={ - selectedComponent?.id === child.id || - groupState.selectedComponents.includes(child.id) - } - isDesignMode={true} // 편집 모드로 설정 - onClick={(e) => handleComponentClick(child, e)} - onDoubleClick={(e) => handleComponentDoubleClick(child, e)} - onDragStart={(e) => startComponentDrag(child, e)} - onDragEnd={endDrag} - selectedScreen={selectedScreen} - tableName={selectedScreen?.tableName} // 🆕 디자인 모드에서도 옵션 로딩을 위해 전달 - // onZoneComponentDrop 제거 - onZoneClick={handleZoneClick} - // 설정 변경 핸들러 (자식 컴포넌트용) - onConfigChange={(config) => { - // console.log("📤 자식 컴포넌트 설정 변경을 상세설정에 알림:", config); - // TODO: 실제 구현은 DetailSettingsPanel과의 연동 필요 - }} - // 🆕 자식 컴포넌트 리사이즈 핸들러 - onResize={(componentId, newSize) => { - setLayout((prevLayout) => { - const updatedComponents = prevLayout.components.map((comp) => - comp.id === componentId ? { ...comp, size: newSize } : comp, - ); - - const newLayout = { - ...prevLayout, - components: updatedComponents, - }; - - setTimeout(() => saveToHistory(newLayout), 0); - return newLayout; - }); - }} - /> - ); - })} - - ); - })} - - {/* 🆕 플로우 버튼 그룹들 */} - {Object.entries(buttonGroups).map(([groupId, buttons]) => { - if (buttons.length === 0) return null; - - const firstButton = buttons[0]; - const groupConfig = (firstButton as any).webTypeConfig - ?.flowVisibilityConfig as FlowVisibilityConfig; - - // 🔧 그룹의 위치 및 크기 계산 - // 모든 버튼이 같은 위치(groupX, groupY)에 배치되어 있으므로 - // 첫 번째 버튼의 위치를 그룹 시작점으로 사용 - const direction = groupConfig.groupDirection || "horizontal"; - const gap = groupConfig.groupGap ?? 8; - const align = groupConfig.groupAlign || "start"; - - const groupPosition = { - x: buttons[0].position.x, - y: buttons[0].position.y, - z: buttons[0].position.z || 2, - }; - - // 버튼들의 실제 크기 계산 - let groupWidth = 0; - let groupHeight = 0; - - if (direction === "horizontal") { - // 가로 정렬: 모든 버튼의 너비 + 간격 - groupWidth = buttons.reduce((total, button, index) => { - const buttonWidth = button.size?.width || 100; - const gapWidth = index < buttons.length - 1 ? gap : 0; - return total + buttonWidth + gapWidth; - }, 0); - groupHeight = Math.max(...buttons.map((b) => b.size?.height || 40)); - } else { - // 세로 정렬 - groupWidth = Math.max(...buttons.map((b) => b.size?.width || 100)); - groupHeight = buttons.reduce((total, button, index) => { - const buttonHeight = button.size?.height || 40; - const gapHeight = index < buttons.length - 1 ? gap : 0; - return total + buttonHeight + gapHeight; - }, 0); - } - - // 🆕 그룹 전체가 선택되었는지 확인 - const isGroupSelected = buttons.every( - (btn) => - selectedComponent?.id === btn.id || groupState.selectedComponents.includes(btn.id), - ); - const hasAnySelected = buttons.some( - (btn) => - selectedComponent?.id === btn.id || groupState.selectedComponents.includes(btn.id), - ); - - return ( -
- { - // 드래그 피드백 - const isDraggingThis = - dragState.isDragging && dragState.draggedComponent?.id === button.id; - const isBeingDragged = - dragState.isDragging && - dragState.draggedComponents.some((dragComp) => dragComp.id === button.id); - - let displayButton = button; - - if (isBeingDragged) { - if (isDraggingThis) { - displayButton = { - ...button, - position: dragState.currentPosition, - style: { - ...button.style, - opacity: 0.8, - transform: "scale(1.02)", - transition: "none", - zIndex: 50, - }, - }; - } - } - - // 🔧 그룹 내부에서는 상대 위치 사용 (wrapper로 처리) - const relativeButton = { - ...displayButton, - position: { - x: 0, - y: 0, - z: displayButton.position.z || 1, - }, - }; - - return ( -
{ - // 클릭이 아닌 드래그인 경우에만 드래그 시작 - e.preventDefault(); - e.stopPropagation(); - - const startX = e.clientX; - const startY = e.clientY; - let isDragging = false; - let dragStarted = false; - - const handleMouseMove = (moveEvent: MouseEvent) => { - const deltaX = Math.abs(moveEvent.clientX - startX); - const deltaY = Math.abs(moveEvent.clientY - startY); - - // 5픽셀 이상 움직이면 드래그로 간주 - if ((deltaX > 5 || deltaY > 5) && !dragStarted) { - isDragging = true; - dragStarted = true; - - // Shift 키를 누르지 않았으면 같은 그룹의 버튼들도 모두 선택 - if (!e.shiftKey) { - const buttonIds = buttons.map((b) => b.id); - setGroupState((prev) => ({ - ...prev, - selectedComponents: buttonIds, - })); - } - - // 드래그 시작 - startComponentDrag(button, e as any); - } - }; - - const handleMouseUp = () => { - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - - // 드래그가 아니면 클릭으로 처리 - if (!isDragging) { - // Shift 키를 누르지 않았으면 같은 그룹의 버튼들도 모두 선택 - if (!e.shiftKey) { - const buttonIds = buttons.map((b) => b.id); - setGroupState((prev) => ({ - ...prev, - selectedComponents: buttonIds, - })); - } - handleComponentClick(button, e); - } - }; - - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - }} - onDoubleClick={(e) => { - e.stopPropagation(); - handleComponentDoubleClick(button, e); - }} - className={ - selectedComponent?.id === button.id || - groupState.selectedComponents.includes(button.id) - ? "outline-1 outline-offset-1 outline-blue-400" - : "" - } - > - {/* 그룹 내부에서는 DynamicComponentRenderer로 직접 렌더링 */} -
- {}} - /> -
-
- ); - }} - /> -
- ); - })} - - ); - })()} - - {/* 드래그 선택 영역 */} - {selectionDrag.isSelecting && ( -
- )} - - {/* 빈 캔버스 안내 */} - {layout.components.length === 0 && ( -
-
-
- -
-

캔버스가 비어있습니다

-

- 좌측 패널에서 테이블/컬럼이나 템플릿을 드래그하여 화면을 설계하세요 -

-
-

- 단축키: T(테이블), M(템플릿), P(속성), S(스타일), - R(격자), D(상세설정), E(해상도) -

-

- 편집: Ctrl+C(복사), Ctrl+V(붙여넣기), Ctrl+S(저장), - Ctrl+Z(실행취소), Delete(삭제) -

-

- ⚠️ - 브라우저 기본 단축키가 차단되어 애플리케이션 기능만 작동합니다 -

-
-
-
- )} -
-
-
- ); /* 🔥 줌 래퍼 닫기 */ - })()} -
-
{" "} - {/* 메인 컨테이너 닫기 */} - {/* 🆕 플로우 버튼 그룹 생성 다이얼로그 */} - - {/* 모달들 */} - {/* 메뉴 할당 모달 */} - {showMenuAssignmentModal && selectedScreen && ( - setShowMenuAssignmentModal(false)} - onAssignmentComplete={() => { - // 모달을 즉시 닫지 않고, MenuAssignmentModal이 3초 후 자동으로 닫히도록 함 - // setShowMenuAssignmentModal(false); - // toast.success("메뉴에 화면이 할당되었습니다."); - }} - onBackToList={onBackToList} - /> - )} - {/* 파일첨부 상세 모달 */} - {showFileAttachmentModal && selectedFileComponent && ( - { - setShowFileAttachmentModal(false); - setSelectedFileComponent(null); - }} - component={selectedFileComponent} - screenId={selectedScreen.screenId} - /> - )} - {/* 다국어 설정 모달 */} - setShowMultilangSettingsModal(false)} - components={layout.components} - onSave={async (updates) => { - if (updates.length === 0) { - toast.info("저장할 변경사항이 없습니다."); - return; - } - - try { - // 공통 유틸 사용하여 매핑 적용 - const { applyMultilangMappings } = await import("@/lib/utils/multilangLabelExtractor"); - - // 매핑 형식 변환 - const mappings = updates.map((u) => ({ - componentId: u.componentId, - keyId: u.langKeyId, - langKey: u.langKey, - })); - - // 레이아웃 업데이트 - const updatedComponents = applyMultilangMappings(layout.components, mappings); - setLayout((prev) => ({ - ...prev, - components: updatedComponents, - })); - - toast.success(`${updates.length}개 항목의 다국어 설정이 저장되었습니다.`); - } catch (error) { - console.error("다국어 설정 저장 실패:", error); - toast.error("다국어 설정 저장 중 오류가 발생했습니다."); - } - }} - /> - {/* 단축키 도움말 모달 */} - setShowShortcutsModal(false)} - /> -
- - - - ); -} +서 \ No newline at end of file diff --git a/frontend/components/screen/ScreenSettingModal.tsx b/frontend/components/screen/ScreenSettingModal.tsx index 22c7af89..fa802893 100644 --- a/frontend/components/screen/ScreenSettingModal.tsx +++ b/frontend/components/screen/ScreenSettingModal.tsx @@ -134,7 +134,6 @@ interface ScreenSettingModalProps { fieldMappings?: FieldMappingInfo[]; componentCount?: number; onSaveSuccess?: () => void; - isPop?: boolean; // POP 화면 여부 } // 검색 가능한 Select 컴포넌트 @@ -240,7 +239,6 @@ export function ScreenSettingModal({ fieldMappings = [], componentCount = 0, onSaveSuccess, - isPop = false, }: ScreenSettingModalProps) { const [activeTab, setActiveTab] = useState("overview"); const [loading, setLoading] = useState(false); @@ -521,7 +519,6 @@ export function ScreenSettingModal({ iframeKey={iframeKey} canvasWidth={canvasSize.width} canvasHeight={canvasSize.height} - isPop={isPop} />
@@ -4634,10 +4631,9 @@ interface PreviewTabProps { iframeKey?: number; // iframe 새로고침용 키 canvasWidth?: number; // 화면 캔버스 너비 canvasHeight?: number; // 화면 캔버스 높이 - isPop?: boolean; // POP 화면 여부 } -function PreviewTab({ screenId, screenName, companyCode, iframeKey = 0, canvasWidth, canvasHeight, isPop = false }: PreviewTabProps) { +function PreviewTab({ screenId, screenName, companyCode, iframeKey = 0, canvasWidth, canvasHeight }: PreviewTabProps) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const containerRef = useRef(null); @@ -4691,18 +4687,12 @@ function PreviewTab({ screenId, screenName, companyCode, iframeKey = 0, canvasWi if (companyCode) { params.set("company_code", companyCode); } - // POP 화면일 경우 디바이스 타입 추가 - if (isPop) { - params.set("device", "tablet"); - } - // POP 화면과 데스크톱 화면 경로 분기 - const screenPath = isPop ? `/pop/screens/${screenId}` : `/screens/${screenId}`; if (typeof window !== "undefined") { const baseUrl = window.location.origin; - return `${baseUrl}${screenPath}?${params.toString()}`; + return `${baseUrl}/screens/${screenId}?${params.toString()}`; } - return `${screenPath}?${params.toString()}`; - }, [screenId, companyCode, isPop]); + return `/screens/${screenId}?${params.toString()}`; + }, [screenId, companyCode]); const handleIframeLoad = () => { setLoading(false); diff --git a/frontend/components/screen/panels/ComponentsPanel.tsx b/frontend/components/screen/panels/ComponentsPanel.tsx index 9464a204..98302169 100644 --- a/frontend/components/screen/panels/ComponentsPanel.tsx +++ b/frontend/components/screen/panels/ComponentsPanel.tsx @@ -109,8 +109,8 @@ export function ComponentsPanel({ "v2-media", // → 테이블 컬럼의 image/file 입력 타입으로 사용 // 플로우 위젯 숨김 처리 "flow-widget", - // 선택 항목 상세입력 - 기존 컴포넌트 조합으로 대체 가능 - "selected-items-detail-input", + // 선택 항목 상세입력 - 거래처 품목 추가 등에서 사용 + // "selected-items-detail-input", // 연관 데이터 버튼 - v2-repeater로 대체 가능 "related-data-buttons", // ===== V2로 대체된 기존 컴포넌트 (v2 버전만 사용) ===== diff --git a/frontend/components/ui/alert-dialog.tsx b/frontend/components/ui/alert-dialog.tsx index b4d0cae5..3c7a9239 100644 --- a/frontend/components/ui/alert-dialog.tsx +++ b/frontend/components/ui/alert-dialog.tsx @@ -18,7 +18,7 @@ const AlertDialogOverlay = React.forwardRef< >(({ className, ...props }, ref) => ( (({ className, ...props }, ref) => ( , - records: Array> + records: Array>, + options?: { deleteOrphans?: boolean } ): Promise<{ success: boolean; inserted?: number; updated?: number; deleted?: number; message?: string; error?: string }> => { try { console.log("📡 [dataApi.upsertGroupedRecords] 요청 데이터:", { @@ -251,6 +252,7 @@ export const dataApi = { tableName, parentKeys, records, + deleteOrphans: options?.deleteOrphans ?? true, // 기본값: true (기존 동작 유지) }; console.log("📦 [dataApi.upsertGroupedRecords] 요청 본문 (JSON):", JSON.stringify(requestBody, null, 2)); diff --git a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx index 285c655d..e73a78c6 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useMemo, useCallback } from "react"; +import React, { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { useSearchParams } from "next/navigation"; import { ComponentRendererProps } from "@/types/component"; import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition, ItemData, GroupEntry, DisplayItem } from "./types"; @@ -73,18 +73,14 @@ export const SelectedItemsDetailInputComponent: React.FC state.dataRegistry); const modalData = useMemo(() => dataRegistry[dataSourceId] || [], [dataRegistry, dataSourceId]); // 전체 dataRegistry를 사용 (모든 누적 데이터에 접근 가능) - console.log("📦 [SelectedItemsDetailInput] 사용 가능한 모든 데이터:", { - keys: Object.keys(dataRegistry), - counts: Object.entries(dataRegistry).map(([key, data]: [string, any]) => ({ - table: key, - count: data.length, - })), - }); const updateItemData = useModalDataStore((state) => state.updateItemData); @@ -94,113 +90,92 @@ export const SelectedItemsDetailInputComponent: React.FC(null); // 현재 편집 중인 품목 ID - const [editingGroupId, setEditingGroupId] = useState(null); // 현재 편집 중인 그룹 ID - const [editingDetailId, setEditingDetailId] = useState(null); // 현재 편집 중인 항목 ID + const [editingGroupId, setEditingGroupId] = useState(null); // 현재 편집 중인 그룹 ID (레거시 호환) + const [editingDetailId, setEditingDetailId] = useState(null); // 현재 편집 중인 항목 ID (레거시 호환) + + // 🆕 그룹별 독립 편집 상태: { [groupId]: entryId } + const [editingEntries, setEditingEntries] = useState>({}); // 🆕 코드 카테고리별 옵션 캐싱 const [codeOptions, setCodeOptions] = useState>>({}); - // 디버깅 로그 - useEffect(() => { - console.log("📍 [SelectedItemsDetailInput] 설정 확인:", { - inputMode: componentConfig.inputMode, - urlDataSourceId, - configDataSourceId: componentConfig.dataSourceId, - componentId: component.id, - finalDataSourceId: dataSourceId, - isEditing, - editingItemId, - }); - }, [ - urlDataSourceId, - componentConfig.dataSourceId, - component.id, - dataSourceId, - componentConfig.inputMode, - isEditing, - editingItemId, - ]); + // 디버깅 로그 (제거됨) // 🆕 필드에 codeCategory가 있으면 자동으로 옵션 로드 useEffect(() => { const loadCodeOptions = async () => { - console.log("🔄 [loadCodeOptions] 시작:", { - additionalFields: componentConfig.additionalFields, - targetTable: componentConfig.targetTable, - }); - - // 🆕 code/category 타입 필드 + codeCategory가 있는 필드 모두 처리 + // code/category 타입 필드 + codeCategory가 있는 필드 모두 처리 const codeFields = componentConfig.additionalFields?.filter( (field) => field.inputType === "code" || field.inputType === "category", ); - console.log("🔍 [loadCodeOptions] code/category 필드:", codeFields); - if (!codeFields || codeFields.length === 0) { - console.log("⚠️ [loadCodeOptions] code/category 타입 필드가 없습니다"); return; } const newOptions: Record> = { ...codeOptions }; - // 🆕 대상 테이블의 컬럼 메타데이터에서 codeCategory 가져오기 - const targetTable = componentConfig.targetTable; - let targetTableColumns: any[] = []; + // 🆕 그룹별 sourceTable 매핑 구성 + const groups = componentConfig.fieldGroups || []; + const groupSourceTableMap: Record = {}; + groups.forEach((g) => { + if (g.sourceTable) { + groupSourceTableMap[g.id] = g.sourceTable; + } + }); + const defaultTargetTable = componentConfig.targetTable; - if (targetTable) { + // 테이블별 컬럼 메타데이터 캐시 + const tableColumnsCache: Record = {}; + const getTableColumns = async (tableName: string) => { + if (tableColumnsCache[tableName]) return tableColumnsCache[tableName]; try { const { tableTypeApi } = await import("@/lib/api/screen"); - const columnsResponse = await tableTypeApi.getColumns(targetTable); - targetTableColumns = columnsResponse || []; + const columnsResponse = await tableTypeApi.getColumns(tableName); + tableColumnsCache[tableName] = columnsResponse || []; + return tableColumnsCache[tableName]; } catch (error) { - console.error("❌ 대상 테이블 컬럼 조회 실패:", error); + console.error(`❌ 테이블 컬럼 조회 실패 (${tableName}):`, error); + return []; } - } + }; for (const field of codeFields) { - // 이미 로드된 옵션이면 스킵 if (newOptions[field.name]) { - console.log(`⏭️ 이미 로드된 옵션 (${field.name})`); continue; } + // 🆕 필드의 그룹 sourceTable 결정 + const fieldSourceTable = (field.groupId && groupSourceTableMap[field.groupId]) || defaultTargetTable; + try { - // 🆕 category 타입이면 table_column_category_values에서 로드 - if (field.inputType === "category" && targetTable) { - console.log(`🔄 카테고리 옵션 로드 시도 (${targetTable}.${field.name})`); - + if (field.inputType === "category" && fieldSourceTable) { const { getCategoryValues } = await import("@/lib/api/tableCategoryValue"); - const response = await getCategoryValues(targetTable, field.name, false); + const response = await getCategoryValues(fieldSourceTable, field.name, false); - console.log("📥 getCategoryValues 응답:", response); - - if (response.success && response.data) { + if (response.success && response.data && response.data.length > 0) { newOptions[field.name] = response.data.map((item: any) => ({ label: item.value_label || item.valueLabel, value: item.value_code || item.valueCode, })); - console.log(`✅ 카테고리 옵션 로드 완료 (${field.name}):`, newOptions[field.name]); } else { - console.error(`❌ 카테고리 옵션 로드 실패 (${field.name}):`, response.error || "응답 없음"); } } else if (field.inputType === "code") { - // code 타입이면 기존대로 code_info에서 로드 - // 이미 codeCategory가 있으면 사용 let codeCategory = field.codeCategory; - // 🆕 codeCategory가 없으면 대상 테이블 컬럼에서 찾기 - if (!codeCategory && targetTableColumns.length > 0) { - const columnMeta = targetTableColumns.find( - (col: any) => (col.columnName || col.column_name) === field.name, - ); - if (columnMeta) { - codeCategory = columnMeta.codeCategory || columnMeta.code_category; - console.log(`🔍 필드 "${field.name}"의 codeCategory를 메타데이터에서 찾음:`, codeCategory); + if (!codeCategory && fieldSourceTable) { + const targetTableColumns = await getTableColumns(fieldSourceTable); + if (targetTableColumns.length > 0) { + const columnMeta = targetTableColumns.find( + (col: any) => (col.columnName || col.column_name) === field.name, + ); + if (columnMeta) { + codeCategory = columnMeta.codeCategory || columnMeta.code_category; + } } } if (!codeCategory) { - console.warn(`⚠️ 필드 "${field.name}"의 codeCategory를 찾을 수 없습니다`); continue; } @@ -210,7 +185,6 @@ export const SelectedItemsDetailInputComponent: React.FC { + const isArray = Array.isArray(sourceData); + const dataArray = isArray ? sourceData : [sourceData]; - if (dataArray.length === 0 || (dataArray.length === 1 && Object.keys(dataArray[0]).length === 0)) { - console.warn("⚠️ [SelectedItemsDetailInput] 데이터가 비어있음"); - return; - } - - console.log( - `📝 [SelectedItemsDetailInput] 수정 모드 - ${isArray ? "그룹 레코드" : "단일 레코드"} (${dataArray.length}개)`, - ); - console.log("📝 [SelectedItemsDetailInput] 데이터 소스:", { - fromGroupedData: groupedData && Array.isArray(groupedData) && groupedData.length > 0, - dataArray: JSON.stringify(dataArray, null, 2), - }); - - const groups = componentConfig.fieldGroups || []; - const additionalFields = componentConfig.additionalFields || []; - - // 🆕 첫 번째 레코드의 originalData를 기본 항목으로 설정 - const firstRecord = dataArray[0]; - const mainFieldGroups: Record = {}; - - // 🔧 각 그룹별로 고유한 엔트리만 수집 (중복 제거) - groups.forEach((group) => { - const groupFields = additionalFields.filter((field: any) => field.groupId === group.id); - - if (groupFields.length === 0) { - mainFieldGroups[group.id] = []; + if (dataArray.length === 0 || (dataArray.length === 1 && Object.keys(dataArray[0]).length === 0)) { return; } - // 🆕 각 레코드에서 그룹 데이터 추출 - const entriesMap = new Map(); + const groups = componentConfig.fieldGroups || []; + const additionalFields = componentConfig.additionalFields || []; + const firstRecord = dataArray[0]; - dataArray.forEach((record) => { - const entryData: Record = {}; + // 수정 모드: 모든 관련 테이블의 데이터를 API로 전체 로드 + // sourceData는 클릭한 1개 레코드만 포함할 수 있으므로, API로 전체를 다시 가져옴 + const editTableName = new URLSearchParams(window.location.search).get("tableName"); + const allTableData: Record[]> = {}; - groupFields.forEach((field: any) => { - let fieldValue = record[field.name]; + if (firstRecord.customer_id && firstRecord.item_id) { + try { + const { dataApi } = await import("@/lib/api/data"); + // 모든 sourceTable의 데이터를 API로 전체 로드 (중복 테이블 제거) + const allTables = groups + .map((g) => g.sourceTable || editTableName) + .filter((v, i, a) => v && a.indexOf(v) === i) as string[]; - // 🆕 값이 없으면 autoFillFrom 로직 적용 - if ((fieldValue === undefined || fieldValue === null) && field.autoFillFrom) { - let sourceData: any = null; + for (const table of allTables) { + const response = await dataApi.getTableData(table, { + filters: { + customer_id: firstRecord.customer_id, + item_id: firstRecord.item_id, + }, + sortBy: "created_date", + sortOrder: "desc", + }); + if (response.data && response.data.length > 0) { + allTableData[table] = response.data; + } + } + } catch (err) { + console.error("❌ 편집 데이터 전체 로드 실패:", err); + } + } - if (field.autoFillFromTable) { - // 특정 테이블에서 가져오기 - const tableData = dataRegistry[field.autoFillFromTable]; - if (tableData && tableData.length > 0) { - sourceData = tableData[0].originalData || tableData[0]; - console.log( - `✅ [수정모드 autoFill] ${field.name} ← ${field.autoFillFrom} (테이블: ${field.autoFillFromTable}):`, - sourceData?.[field.autoFillFrom], - ); - } else { - // 🆕 dataRegistry에 없으면 record에서 직접 찾기 (Entity Join된 경우) - sourceData = record; - console.log( - `⚠️ [수정모드 autoFill] dataRegistry에 ${field.autoFillFromTable} 없음, record에서 직접 찾기`, - ); + const mainFieldGroups: Record = {}; + + groups.forEach((group) => { + const groupFields = additionalFields.filter((field: any) => field.groupId === group.id); + + if (groupFields.length === 0) { + mainFieldGroups[group.id] = []; + return; + } + + // 이 그룹의 sourceTable 결정 → API에서 가져온 전체 데이터 사용 + const groupTable = group.sourceTable || editTableName || ""; + // 현재 테이블만 sourceData fallback 허용 (다른 테이블은 빈 배열 → id 크로스오염 방지) + const isCurrentTable = !group.sourceTable || group.sourceTable === editTableName; + const groupDataList = allTableData[groupTable] || (isCurrentTable ? dataArray : []); + + { + // 모든 테이블 그룹: API에서 가져온 전체 레코드를 entry로 변환 + const entriesMap = new Map(); + + groupDataList.forEach((record) => { + const entryData: Record = {}; + + groupFields.forEach((field: any) => { + let fieldValue = record[field.name]; + + // 값이 없으면 autoFillFrom 로직 적용 + if ((fieldValue === undefined || fieldValue === null) && field.autoFillFrom) { + let src: any = null; + + if (field.autoFillFromTable) { + const tableData = dataRegistry[field.autoFillFromTable]; + if (tableData && tableData.length > 0) { + src = tableData[0].originalData || tableData[0]; + } else { + src = record; + } + } else { + src = record; + } + + if (src && src[field.autoFillFrom] !== undefined) { + fieldValue = src[field.autoFillFrom]; + } else { + const possibleKeys = Object.keys(src || {}).filter((key) => + key.endsWith(`_${field.autoFillFrom}`), + ); + if (possibleKeys.length > 0) { + fieldValue = src[possibleKeys[0]]; + } + } } - } else { - // record 자체에서 가져오기 - sourceData = record; - console.log( - `✅ [수정모드 autoFill] ${field.name} ← ${field.autoFillFrom} (레코드):`, - sourceData?.[field.autoFillFrom], - ); - } - if (sourceData && sourceData[field.autoFillFrom] !== undefined) { - fieldValue = sourceData[field.autoFillFrom]; - console.log(`✅ [수정모드 autoFill] ${field.name} 값 설정:`, fieldValue); - } else { - // 🆕 Entity Join의 경우 sourceColumn_fieldName 형식으로도 찾기 - // 예: item_id_standard_price, customer_id_customer_name - // autoFillFromTable에서 어떤 sourceColumn인지 추론 - const possibleKeys = Object.keys(sourceData || {}).filter((key) => - key.endsWith(`_${field.autoFillFrom}`), - ); - - if (possibleKeys.length > 0) { - fieldValue = sourceData[possibleKeys[0]]; - console.log( - `✅ [수정모드 autoFill] ${field.name} Entity Join 키로 찾음 (${possibleKeys[0]}):`, - fieldValue, - ); - } else { - console.warn( - `⚠️ [수정모드 autoFill] ${field.name} ← ${field.autoFillFrom} 실패 (시도한 키들: ${field.autoFillFrom}, *_${field.autoFillFrom})`, - ); + if (fieldValue === undefined || fieldValue === null) { + if (field.defaultValue !== undefined) { + fieldValue = field.defaultValue; + } else if (field.type === "checkbox") { + fieldValue = false; + } else { + return; + } } + + // 날짜 타입이면 YYYY-MM-DD 형식으로 변환 + if (field.type === "date" || field.type === "datetime") { + const dateStr = String(fieldValue); + const match = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})/); + if (match) { + const [, year, month, day] = match; + fieldValue = `${year}-${month}-${day}`; + } + } + + entryData[field.name] = fieldValue; + }); + + const entryKey = JSON.stringify(entryData); + if (!entriesMap.has(entryKey)) { + entriesMap.set(entryKey, { + id: `${group.id}_entry_${entriesMap.size + 1}`, + // DB 레코드의 고유 id(UUID PK) 보존 → 수정 시 이 id로 UPDATE + _dbRecordId: record.id || null, + ...entryData, + }); } - } - - // 🔧 값이 없으면 기본값 사용 (false, 0, "" 등 falsy 값도 유효한 값으로 처리) - if (fieldValue === undefined || fieldValue === null) { - // 기본값이 있으면 사용, 없으면 필드 타입에 따라 기본값 설정 - if (field.defaultValue !== undefined) { - fieldValue = field.defaultValue; - } else if (field.type === "checkbox") { - fieldValue = false; // checkbox는 기본값 false - } else { - // 다른 타입은 null로 유지 (필수 필드가 아니면 표시 안 됨) - return; - } - } - - // 🔧 날짜 타입이면 YYYY-MM-DD 형식으로 변환 (타임존 제거) - if (field.type === "date" || field.type === "datetime") { - const dateStr = String(fieldValue); - const match = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})/); - if (match) { - const [, year, month, day] = match; - fieldValue = `${year}-${month}-${day}`; // ISO 형식 유지 (시간 제거) - } - } - - entryData[field.name] = fieldValue; - }); - - // 🔑 모든 필드 값을 합쳐서 고유 키 생성 (중복 제거 기준) - const entryKey = JSON.stringify(entryData); - - if (!entriesMap.has(entryKey)) { - entriesMap.set(entryKey, { - id: `${group.id}_entry_${entriesMap.size + 1}`, - ...entryData, }); + + mainFieldGroups[group.id] = Array.from(entriesMap.values()); } }); - mainFieldGroups[group.id] = Array.from(entriesMap.values()); - }); + if (groups.length === 0) { + mainFieldGroups["default"] = []; + } - // 그룹이 없으면 기본 그룹 생성 - if (groups.length === 0) { - mainFieldGroups["default"] = []; - } + const newItem: ItemData = { + // 수정 모드: item_id를 우선 사용 (id는 가격레코드의 PK일 수 있음) + id: String(firstRecord.item_id || firstRecord.id || "edit"), + originalData: firstRecord, + fieldGroups: mainFieldGroups, + }; - const newItem: ItemData = { - id: String(firstRecord.id || firstRecord.item_id || "edit"), - originalData: firstRecord, // 첫 번째 레코드를 대표 데이터로 사용 - fieldGroups: mainFieldGroups, + setItems([newItem]); }; - setItems([newItem]); - - console.log("✅ [SelectedItemsDetailInput] 수정 모드 데이터 로드 완료:", { - recordCount: dataArray.length, - item: newItem, - fieldGroupsKeys: Object.keys(mainFieldGroups), - firstGroupEntries: mainFieldGroups[groups[0]?.id]?.length || 0, - }); + loadEditData(); return; } // 생성 모드: modalData에서 데이터 로드 if (modalData && modalData.length > 0) { - console.log("📦 [SelectedItemsDetailInput] 데이터 수신:", modalData); // 🆕 각 품목마다 빈 fieldGroups 객체를 가진 ItemData 생성 const groups = componentConfig.fieldGroups || []; const newItems: ItemData[] = modalData.map((item) => { const fieldGroups: Record = {}; - // 각 그룹에 대해 빈 배열 초기화 + // 각 그룹에 대해 초기화 (maxEntries === 1이면 자동 1개 생성) groups.forEach((group) => { - fieldGroups[group.id] = []; + if (group.maxEntries === 1) { + // 1:1 관계: 빈 entry 1개 자동 생성 + fieldGroups[group.id] = [{ id: `${group.id}_auto_1` }]; + } else { + fieldGroups[group.id] = []; + } }); // 그룹이 없으면 기본 그룹 생성 @@ -425,12 +397,6 @@ export const SelectedItemsDetailInputComponent: React.FC g.id), - firstItem: newItems[0], - }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [modalData, component.id, componentConfig.fieldGroups, formData, groupedData]); // groupedData 의존성 추가 @@ -456,14 +422,6 @@ export const SelectedItemsDetailInputComponent: React.FC arr.length === 0); if (allGroupsEmpty) { - // 🔧 아이템이 1개뿐이면 기본 레코드 생성 (첫 저장 시) - // 아이템이 여러 개면 빈 아이템은 건너뛰기 (불필요한 NULL 레코드 방지) - if (itemsList.length === 1) { - console.log("📝 [generateCartesianProduct] 단일 아이템, 모든 그룹 비어있음 - 기본 레코드 생성", { - itemIndex, - itemId: item.id, - }); - // 빈 객체를 추가하면 parentKeys와 합쳐져서 기본 레코드가 됨 - allRecords.push({}); - } else { - console.log("⏭️ [generateCartesianProduct] 다중 아이템 중 빈 아이템 - 건너뜀", { - itemIndex, - itemId: item.id, - totalItems: itemsList.length, - }); - } + // 디테일 데이터가 없어도 기본 레코드 생성 (품목-거래처 매핑 유지) + // autoFillFrom 필드 (item_id 등)는 반드시 포함시켜야 나중에 식별 가능 + const baseRecord: Record = {}; + additionalFields.forEach((f) => { + if (f.autoFillFrom && item.originalData) { + const value = item.originalData[f.autoFillFrom]; + if (value !== undefined && value !== null) { + baseRecord[f.name] = value; + } + } + }); + + allRecords.push(baseRecord); return; } @@ -532,11 +487,6 @@ export const SelectedItemsDetailInputComponent: React.FC { const handleSaveRequest = async (event: Event) => { + // 중복 저장 방지 + // 항상 skipDefaultSave 설정 (buttonActions.ts의 이중 저장 방지) + if (event instanceof CustomEvent && event.detail) { + (event.detail as any).skipDefaultSave = true; + } + + if (isSavingRef.current) return; + isSavingRef.current = true; + // component.id를 문자열로 안전하게 변환 const componentKey = String(component.id || "selected_items"); - console.log("🔔 [SelectedItemsDetailInput] beforeFormSave 이벤트 수신!", { - itemsCount: items.length, - hasOnFormDataChange: !!onFormDataChange, - componentId: component.id, - componentIdType: typeof component.id, - componentKey, - }); - if (items.length === 0) { - console.warn("⚠️ [SelectedItemsDetailInput] 저장할 데이터 없음"); + isSavingRef.current = false; return; } - // 🆕 수정 모드인지 확인 (URL에 mode=edit이 있으면) - const urlParams = new URLSearchParams(window.location.search); - const mode = urlParams.get("mode"); - const isEditMode = mode === "edit"; + // parentDataMapping이 있으면 UPSERT API로 직접 저장 + const hasParentMapping = componentConfig.parentDataMapping && componentConfig.parentDataMapping.length > 0; - console.log("📝 [SelectedItemsDetailInput] 저장 모드:", { mode, isEditMode }); - - if (isEditMode && componentConfig.parentDataMapping && componentConfig.parentDataMapping.length > 0) { - // 🔄 수정 모드: UPSERT API 사용 + if (hasParentMapping) { try { - console.log("🔄 [SelectedItemsDetailInput] UPSERT 모드로 저장 시작"); - console.log("📋 [SelectedItemsDetailInput] componentConfig:", { - targetTable: componentConfig.targetTable, - parentDataMapping: componentConfig.parentDataMapping, - fieldGroups: componentConfig.fieldGroups, - additionalFields: componentConfig.additionalFields, - }); // 부모 키 추출 (parentDataMapping에서) const parentKeys: Record = {}; @@ -591,40 +530,34 @@ export const SelectedItemsDetailInputComponent: React.FC { - // 🆕 Entity Join 필드도 처리 (예: customer_code -> customer_id_customer_code) - const value = getFieldValue(sourceData, mapping.sourceField); + // 1차: formData(sourceData)에서 찾기 + let value = getFieldValue(sourceData, mapping.sourceField); + + // 2차: formData에 없으면 dataRegistry[sourceTable]에서 찾기 + // v2-split-panel-layout에서 좌측 항목 선택 시 dataRegistry에 저장한 데이터 활용 + if ((value === undefined || value === null) && mapping.sourceTable) { + const registryData = dataRegistry[mapping.sourceTable]; + if (registryData && registryData.length > 0) { + const registryItem = registryData[0].originalData || registryData[0]; + value = registryItem[mapping.sourceField]; + } + } + if (value !== undefined && value !== null) { parentKeys[mapping.targetField] = value; - console.log(`✅ [parentKeys] ${mapping.sourceField} → ${mapping.targetField}:`, value); } else { - console.warn( - `⚠️ [SelectedItemsDetailInput] 부모 키 누락: ${mapping.sourceField} → ${mapping.targetField}`, - ); + console.warn(`⚠️ 부모 키 누락: ${mapping.sourceField} → ${mapping.targetField}`); } }); - console.log("🔑 [SelectedItemsDetailInput] 부모 키:", parentKeys); - // 🔒 parentKeys 유효성 검증 - 빈 값이 있으면 저장 중단 const parentKeyValues = Object.values(parentKeys); const hasEmptyParentKey = parentKeyValues.length === 0 || parentKeyValues.some(v => v === null || v === undefined || v === ""); if (hasEmptyParentKey) { - console.error("❌ [SelectedItemsDetailInput] parentKeys가 비어있거나 유효하지 않습니다!", parentKeys); + console.error("❌ parentKeys 비어있음:", parentKeys); window.dispatchEvent( new CustomEvent("formSaveError", { detail: { message: "부모 키 값이 비어있어 저장할 수 없습니다. 먼저 상위 데이터를 선택해주세요." }, @@ -634,82 +567,244 @@ export const SelectedItemsDetailInputComponent: React.FC(); + groups.forEach((group) => { + const table = group.sourceTable || mainTable; + if (!groupsByTable.has(table)) { + groupsByTable.set(table, []); + } + groupsByTable.get(table)!.push(group); }); - // UPSERT API 호출 - const { dataApi } = await import("@/lib/api/data"); - const result = await dataApi.upsertGroupedRecords(componentConfig.targetTable, parentKeys, records); + // 디테일 테이블이 있는지 확인 (mainTable과 다른 sourceTable) + const detailTables = [...groupsByTable.keys()].filter((t) => t !== mainTable); + const hasDetailTable = detailTables.length > 0; - if (result.success) { - console.log("✅ [SelectedItemsDetailInput] UPSERT 성공:", { - inserted: result.inserted, - updated: result.updated, - deleted: result.deleted, - }); + if (hasDetailTable) { + // ============================================================ + // 2단계 저장: 메인 테이블 + 디테일 테이블 분리 저장 + // upsertGroupedRecords를 양쪽 모두 사용 (정확한 매칭 보장) + // ============================================================ + const mainGroups = groupsByTable.get(mainTable) || []; - // 저장 성공 이벤트 발생 + for (const item of items) { + // item_id 추출: originalData.item_id를 최우선 사용 + // (수정 모드에서 autoFillFrom:"id"가 가격 레코드 PK를 반환하는 문제 방지) + let itemId: string | null = null; + + // 1순위: originalData에 item_id가 직접 있으면 사용 (수정 모드에서 정확한 값) + if (item.originalData && item.originalData.item_id) { + itemId = item.originalData.item_id; + } + + // 2순위: autoFillFrom 로직 (신규 등록 모드에서 사용) + if (!itemId) { + mainGroups.forEach((group) => { + const groupFields = additionalFields.filter((f) => f.groupId === group.id); + groupFields.forEach((field) => { + if (field.name === "item_id" && field.autoFillFrom && item.originalData) { + itemId = item.originalData[field.autoFillFrom] || null; + } + }); + }); + } + + // 3순위: fallback (최후의 수단) + if (!itemId && item.originalData) { + itemId = item.originalData.id || null; + } + + if (!itemId) { + console.error("❌ [2단계 저장] item_id를 찾을 수 없음:", item); + continue; + } + + // upsert 공통 parentKeys: customer_id + item_id (정확한 매칭) + const itemParentKeys = { ...parentKeys, item_id: itemId }; + + // === Step 1: 메인 테이블(customer_item_mapping) 저장 === + // 여러 개의 매핑 레코드 지원 (거래처 품번/품명이 다중일 수 있음) + const mappingRecords: Record[] = []; + mainGroups.forEach((group) => { + const entries = item.fieldGroups[group.id] || []; + const groupFields = additionalFields.filter((f) => f.groupId === group.id); + + entries.forEach((entry) => { + const record: Record = {}; + groupFields.forEach((field) => { + const val = entry[field.name]; + if (val !== undefined && val !== null && val !== "") { + record[field.name] = val; + } + }); + // 기존 DB 레코드의 고유 id(PK)가 있으면 포함 → 백엔드에서 이 id로 UPDATE + if (entry._dbRecordId) { + record.id = entry._dbRecordId; + } + // item_id는 정확한 itemId 변수 사용 (autoFillFrom:"id" 오작동 방지) + record.item_id = itemId; + // 나머지 autoFillFrom 필드 처리 + groupFields.forEach((field) => { + if (field.name !== "item_id" && field.autoFillFrom && item.originalData) { + const value = item.originalData[field.autoFillFrom]; + if (value !== undefined && value !== null && !record[field.name]) { + record[field.name] = value; + } + } + }); + mappingRecords.push(record); + }); + }); + + // 레코드에 id(기존 DB PK)가 있으면 EDIT 모드 → 고아 삭제 + // id 없으면 CREATE 모드 → 기존 레코드 건드리지 않음 + const mappingHasDbIds = mappingRecords.some((r) => !!r.id); + try { + const mappingResult = await dataApi.upsertGroupedRecords( + mainTable, + itemParentKeys, + mappingRecords, + { deleteOrphans: mappingHasDbIds }, + ); + } catch (err) { + console.error(`❌ ${mainTable} 저장 실패:`, err); + } + + // === Step 2: 디테일 테이블(customer_item_prices) 저장 === + for (const detailTable of detailTables) { + const detailGroups = groupsByTable.get(detailTable) || []; + const priceRecords: Record[] = []; + + detailGroups.forEach((group) => { + const entries = item.fieldGroups[group.id] || []; + const groupFields = additionalFields.filter((f) => f.groupId === group.id); + + entries.forEach((entry) => { + // 사용자가 실제 입력한 값이 있는지 확인 + // select/category 필드는 항상 기본값이 있으므로 제외하고 판별 + const hasUserInput = groupFields.some((field) => { + // 셀렉트/카테고리 필드는 기본값이 자동 설정되므로 무시 + if (field.type === "select" || field.inputType === "code" || field.inputType === "category") { + return false; + } + const value = entry[field.name]; + if (value === undefined || value === null || value === "") return false; + if (value === 0 || value === "0" || value === "0.00") return false; + return true; + }); + + if (hasUserInput) { + const priceRecord: Record = {}; + groupFields.forEach((field) => { + const val = entry[field.name]; + if (val !== undefined && val !== null) { + priceRecord[field.name] = val; + } + }); + // 기존 DB 레코드의 고유 id(PK)가 있으면 포함 → 백엔드에서 이 id로 UPDATE + if (entry._dbRecordId) { + priceRecord.id = entry._dbRecordId; + } + priceRecords.push(priceRecord); + } + }); + }); + + // 빈 항목이라도 최소 레코드 생성 (우측 패널에 표시되도록) + if (priceRecords.length === 0) { + // select/category 필드를 명시적 null로 설정 (DB DEFAULT 'KRW' 등 방지) + const emptyRecord: Record = {}; + const detailGroupFields = additionalFields.filter((f) => + detailGroups.some((g) => g.id === f.groupId), + ); + detailGroupFields.forEach((field) => { + if (field.type === "select" || field.inputType === "code" || field.inputType === "category") { + emptyRecord[field.name] = null; + } + }); + priceRecords.push(emptyRecord); + } + + const priceHasDbIds = priceRecords.some((r) => !!r.id); + try { + const detailResult = await dataApi.upsertGroupedRecords( + detailTable, + itemParentKeys, + priceRecords, + { deleteOrphans: priceHasDbIds }, + ); + + if (!detailResult.success) { + console.error(`❌ ${detailTable} 저장 실패:`, detailResult.error); + } + } catch (err) { + console.error(`❌ ${detailTable} 오류:`, err); + } + } + } + + // 저장 성공 이벤트 + 테이블 새로고침 (모든 아이템 저장 완료 후) window.dispatchEvent( new CustomEvent("formSaveSuccess", { detail: { message: "데이터가 저장되었습니다." }, }), ); + // 분할 패널 우측 데이터 새로고침 + window.dispatchEvent(new CustomEvent("refreshTable")); } else { - console.error("❌ [SelectedItemsDetailInput] UPSERT 실패:", result.error); - window.dispatchEvent( - new CustomEvent("formSaveError", { - detail: { message: result.error || "데이터 저장 실패" }, - }), - ); + // ============================================================ + // 단일 테이블 저장 (기존 로직 - detailTable 없는 경우) + // ============================================================ + const records = generateCartesianProduct(items); + + const result = await dataApi.upsertGroupedRecords(mainTable, parentKeys, records); + + if (result.success) { + window.dispatchEvent( + new CustomEvent("formSaveSuccess", { + detail: { message: "데이터가 저장되었습니다." }, + }), + ); + } else { + window.dispatchEvent( + new CustomEvent("formSaveError", { + detail: { message: result.error || "데이터 저장 실패" }, + }), + ); + } } } catch (error) { - console.error("❌ [SelectedItemsDetailInput] UPSERT 오류:", error); + console.error("❌ UPSERT 오류:", error); window.dispatchEvent( new CustomEvent("formSaveError", { detail: { message: "데이터 저장 중 오류가 발생했습니다." }, @@ -720,28 +815,21 @@ export const SelectedItemsDetailInputComponent: React.FC { window.removeEventListener("beforeFormSave", handleSaveRequest as EventListener); }; - }, [items, component.id, onFormDataChange, componentConfig, formData, generateCartesianProduct]); + }, [items, component.id, onFormDataChange, componentConfig, formData, generateCartesianProduct, dataRegistry]); // 스타일 계산 const componentStyle: React.CSSProperties = { width: "100%", height: "100%", + overflowY: "auto", // 항목이 많을 때 스크롤 지원 ...component.style, ...style, }; @@ -775,13 +864,22 @@ export const SelectedItemsDetailInputComponent: React.FC { + const options = codeOptions[fieldName] || []; + const matched = options.find((opt) => opt.value === valueCode); + return matched?.label || valueCode || ""; + }, + [codeOptions], + ); + + // 🆕 실시간 단가 계산 함수 (라벨명 기반 - 회사별 코드 무관) const calculatePrice = useCallback( (entry: GroupEntry): number => { - // 자동 계산 설정이 없으면 계산하지 않음 if (!componentConfig.autoCalculation) return 0; - const { inputFields, valueMapping } = componentConfig.autoCalculation; + const { inputFields } = componentConfig.autoCalculation; // 기본 단가 const basePrice = parseFloat(entry[inputFields.basePrice] || "0"); @@ -789,51 +887,55 @@ export const SelectedItemsDetailInputComponent: React.FC { - console.log("📝 [handleFieldChange] 필드 값 변경:", { - itemId, - groupId, - entryId, - fieldName, - value, - }); - setItems((prevItems) => { return prevItems.map((item) => { if (item.id !== itemId) return item; @@ -842,20 +944,30 @@ export const SelectedItemsDetailInputComponent: React.FC e.id === entryId); if (existingEntryIndex >= 0) { - // 기존 entry 업데이트 (항상 이 경로로만 진입) + const currentEntry = groupEntries[existingEntryIndex]; + + // 날짜 검증: 종료일이 시작일보다 앞서면 차단 + if (fieldName === "end_date" && value && currentEntry.start_date) { + if (new Date(value) < new Date(currentEntry.start_date as string)) { + alert("종료일은 시작일보다 이후여야 합니다."); + return item; // 변경 취소 + } + } + if (fieldName === "start_date" && value && currentEntry.end_date) { + if (new Date(value) > new Date(currentEntry.end_date as string)) { + alert("시작일은 종료일보다 이전이어야 합니다."); + return item; // 변경 취소 + } + } + + // 기존 entry 업데이트 const updatedEntries = [...groupEntries]; const updatedEntry = { ...updatedEntries[existingEntryIndex], [fieldName]: value, }; - console.log("✅ [handleFieldChange] Entry 업데이트:", { - beforeKeys: Object.keys(updatedEntries[existingEntryIndex]), - afterKeys: Object.keys(updatedEntry), - updatedEntry, - }); - - // 🆕 가격 관련 필드가 변경되면 자동 계산 + // 가격 관련 필드가 변경되면 자동 계산 if (componentConfig.autoCalculation) { const { inputFields, targetField } = componentConfig.autoCalculation; const priceRelatedFields = [ @@ -869,15 +981,6 @@ export const SelectedItemsDetailInputComponent: React.FC 0) { // 첫 번째 항목 사용 (또는 매칭 로직 추가 가능) sourceData = tableData[0].originalData || tableData[0]; - console.log( - `✅ [autoFill 추가] ${field.name} ← ${field.autoFillFrom} (테이블: ${field.autoFillFromTable}):`, - sourceData?.[field.autoFillFrom], - ); } else { - // 🆕 dataRegistry에 없으면 item.originalData에서 찾기 (수정 모드) sourceData = item.originalData; - console.log(`⚠️ [autoFill 추가] dataRegistry에 ${field.autoFillFromTable} 없음, originalData에서 찾기`); } } else { - // 주 데이터 소스 (item.originalData) 사용 sourceData = item.originalData; - console.log( - `✅ [autoFill 추가] ${field.name} ← ${field.autoFillFrom} (주 소스):`, - sourceData?.[field.autoFillFrom], - ); } // 🆕 getFieldValue 사용하여 Entity Join 필드도 찾기 @@ -955,9 +1046,6 @@ export const SelectedItemsDetailInputComponent: React.FC ({ ...prev, [groupId]: newEntryId })); }; // 🆕 그룹 항목 제거 핸들러 @@ -992,14 +1082,34 @@ export const SelectedItemsDetailInputComponent: React.FC { + if (prev[groupId] === entryId) { + const next = { ...prev }; + delete next[groupId]; + return next; + } + return prev; + }); }; - // 🆕 그룹 항목 편집 핸들러 (클릭하면 수정 가능) + // 🆕 그룹 항목 편집 핸들러 (클릭하면 수정 가능) - 독립 편집 const handleEditGroupEntry = (itemId: string, groupId: string, entryId: string) => { setIsEditing(true); setEditingItemId(itemId); setEditingGroupId(groupId); setEditingDetailId(entryId); + // 그룹별 독립 편집: 해당 그룹만 토글 (다른 그룹은 유지) + setEditingEntries((prev) => ({ ...prev, [groupId]: entryId })); + }; + + // 🆕 특정 그룹의 편집 닫기 (다른 그룹은 유지) + const closeGroupEditing = (groupId: string) => { + setEditingEntries((prev) => { + const next = { ...prev }; + delete next[groupId]; + return next; + }); }; // 🆕 다음 품목으로 이동 @@ -1059,7 +1169,7 @@ export const SelectedItemsDetailInputComponent: React.FC handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)} maxLength={field.validation?.maxLength} - className="h-10 text-sm" + className="h-7 text-xs" /> ); @@ -1068,39 +1178,48 @@ export const SelectedItemsDetailInputComponent: React.FC -
자동 계산
+
자동 계산
); } return ( handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)} - min={field.validation?.min} - max={field.validation?.max} - className="h-10 text-sm" + value={displayNum} + placeholder={field.placeholder} + disabled={componentConfig.disabled || componentConfig.readonly} + type="text" + inputMode="numeric" + onChange={(e) => { + // 콤마 제거 후 숫자만 저장 + const cleaned = e.target.value.replace(/,/g, "").replace(/[^0-9.\-]/g, ""); + handleFieldChange(itemId, groupId, entryId, field.name, cleaned); + }} + className="h-7 text-xs" /> ); + } case "date": case "timestamp": @@ -1117,7 +1236,7 @@ export const SelectedItemsDetailInputComponent: React.FC ); @@ -1137,8 +1256,8 @@ export const SelectedItemsDetailInputComponent: React.FC handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)} - rows={2} - className="resize-none text-xs sm:text-sm" + rows={1} + className="min-h-[28px] resize-none text-xs" /> ); @@ -1163,7 +1282,7 @@ export const SelectedItemsDetailInputComponent: React.FC handleFieldChange(itemId, groupId, entryId, field.name, val)} disabled={componentConfig.disabled || componentConfig.readonly} > - + @@ -1189,7 +1308,7 @@ export const SelectedItemsDetailInputComponent: React.FC handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)} - className="h-8 text-xs sm:h-10 sm:text-sm" + className="h-7 text-xs" /> ); @@ -1200,7 +1319,7 @@ export const SelectedItemsDetailInputComponent: React.FC handleFieldChange(itemId, groupId, entryId, field.name, val)} disabled={componentConfig.disabled || componentConfig.readonly} > - + @@ -1223,7 +1342,7 @@ export const SelectedItemsDetailInputComponent: React.FC handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)} maxLength={field.validation?.maxLength} - className="h-8 text-xs sm:h-10 sm:text-sm" + className="h-7 text-xs" /> ); } @@ -1242,17 +1361,13 @@ export const SelectedItemsDetailInputComponent: React.FC key.includes("_id_")) || possibleKeys[0]; - console.log(`🔍 [getFieldValue] "${fieldName}" → "${entityJoinKey}" =`, data[entityJoinKey]); return data[entityJoinKey]; } // 2. 직접 필드명으로 찾기 (Entity Join이 없을 때만) if (data[fieldName] !== undefined) { - console.log(`🔍 [getFieldValue] "${fieldName}" → 직접 =`, data[fieldName]); return data[fieldName]; } - - console.warn(`⚠️ [getFieldValue] "${fieldName}" 못 찾음`); return null; }, []); @@ -1264,28 +1379,91 @@ export const SelectedItemsDetailInputComponent: React.FC - componentConfig.fieldGroups && componentConfig.fieldGroups.length > 0 ? f.groupId === groupId : true, - ); - return fields + const fields = (componentConfig.additionalFields || []).filter((f) => { + const matchGroup = componentConfig.fieldGroups && componentConfig.fieldGroups.length > 0 + ? f.groupId === groupId + : true; + const isVisible = f.width !== "0px"; + return matchGroup && isVisible; + }); + + // 헬퍼: 값을 사람이 읽기 좋은 형태로 변환 + const formatValue = (f: any, value: any): string => { + if (!value && value !== 0) return ""; + const strValue = String(value); + + // 날짜 포맷 + const isoDateMatch = strValue.match(/^(\d{4})-(\d{2})-(\d{2})(T|\s|$)/); + if (isoDateMatch) { + const [, year, month, day] = isoDateMatch; + return `${year}.${month}.${day}`; + } + + // 카테고리/코드 -> 라벨명 + const renderType = f.inputType || f.type; + if (renderType === "category" || renderType === "code" || renderType === "select") { + const options = codeOptions[f.name] || f.options || []; + const matched = options.find((opt: any) => opt.value === strValue); + if (matched) return matched.label; + } + + // 숫자는 천 단위 구분 + if (renderType === "number" && !isNaN(Number(strValue))) { + return new Intl.NumberFormat("ko-KR").format(Number(strValue)); + } + + return strValue; + }; + + // 간결한 요약 생성 (그룹별 핵심 정보만) + const hasAnyValue = fields.some((f) => { + const v = entry[f.name]; + return v !== undefined && v !== null && v !== ""; + }); + + if (!hasAnyValue) { + const fieldLabels = fields.slice(0, 2).map(f => f.label).join("/"); + return `신규 ${fieldLabels} 입력`; + } + + // 날짜 범위가 있으면 우선 표시 + const startDate = entry["start_date"] ? formatValue({ inputType: "date" }, entry["start_date"]) : ""; + const endDate = entry["end_date"] ? formatValue({ inputType: "date" }, entry["end_date"]) : ""; + + // 기준단가(calculated_price) 또는 기준가(base_price) 표시 + const calcPrice = entry["calculated_price"] ? formatValue({ inputType: "number" }, entry["calculated_price"]) : ""; + const basePrice = entry["base_price"] ? formatValue({ inputType: "number" }, entry["base_price"]) : ""; + + // 통화코드 + const currencyCode = entry["currency_code"] ? formatValue( + fields.find(f => f.name === "currency_code") || { inputType: "category", name: "currency_code" }, + entry["currency_code"] + ) : ""; + + if (startDate || calcPrice || basePrice) { + // 날짜 + 단가 간결 표시 + const parts: string[] = []; + if (startDate) { + parts.push(endDate ? `${startDate} ~ ${endDate}` : `${startDate} ~`); + } + if (calcPrice) { + parts.push(`${currencyCode || ""} ${calcPrice}`.trim()); + } else if (basePrice) { + parts.push(`${currencyCode || ""} ${basePrice}`.trim()); + } + return parts.join(" | "); + } + + // 그 외 그룹 (거래처 품번 등): 첫 2개 필드만 표시 + const summaryParts = fields + .slice(0, 3) .map((f) => { const value = entry[f.name]; - if (!value) return "-"; - - const strValue = String(value); - - // 🔧 ISO 날짜 형식 자동 감지 및 포맷팅 (필드 타입 무관) - // ISO 8601 형식: YYYY-MM-DDTHH:mm:ss.sssZ 또는 YYYY-MM-DD - const isoDateMatch = strValue.match(/^(\d{4})-(\d{2})-(\d{2})(T|\s|$)/); - if (isoDateMatch) { - const [, year, month, day] = isoDateMatch; - return `${year}.${month}.${day}`; - } - - return strValue; + if (!value && value !== 0) return null; + return `${f.label}: ${formatValue(f, value)}`; }) - .join(" / "); + .filter(Boolean); + return summaryParts.join(" "); } // displayItems 설정대로 렌더링 @@ -1454,20 +1632,122 @@ export const SelectedItemsDetailInputComponent: React.FC ); }, - [componentConfig.fieldGroups, componentConfig.additionalFields], + [componentConfig.fieldGroups, componentConfig.additionalFields, codeOptions], ); // 빈 상태 렌더링 if (items.length === 0) { + // 디자인 모드: 샘플 데이터로 미리보기 표시 + if (isDesignMode) { + const sampleDisplayCols = componentConfig.displayColumns || []; + const sampleFields = (componentConfig.additionalFields || []).filter(f => f.name !== "item_id" && f.width !== "0px"); + const sampleGroups = componentConfig.fieldGroups || [{ id: "default", title: "입력 정보", order: 0 }]; + const gridCols = sampleGroups.length === 1 ? "grid-cols-1" : "grid-cols-2"; + + return ( +
+
+ {/* 미리보기 안내 배너 */} +
+ [미리보기] + 실제 데이터가 전달되면 아래와 같은 형태로 표시됩니다 +
+ + {/* 샘플 품목 카드 2개 */} + {[1, 2].map((idx) => ( + + + + + {idx}. {sampleDisplayCols.length > 0 ? `샘플 ${sampleDisplayCols[0]?.label || "품목"} ${idx}` : `샘플 항목 ${idx}`} + + + + {sampleDisplayCols.length > 0 && ( +
+ {sampleDisplayCols.map((col, i) => ( + + {i > 0 && " | "} + {col.label}: 샘플값 + + ))} +
+ )} +
+ +
+ {sampleGroups.map((group) => { + const groupFields = sampleFields.filter(f => + sampleGroups.length <= 1 || f.groupId === group.id + ); + if (groupFields.length === 0) return null; + const isSingle = group.maxEntries === 1; + + return ( + + + + {group.title} + {!isSingle && ( + + )} + + + + {isSingle ? ( + /* 1:1 그룹: 인라인 폼 미리보기 */ +
+ {groupFields.slice(0, 4).map(f => ( +
+ {f.label} +
샘플
+
+ ))} + {groupFields.length > 4 && ( +
외 {groupFields.length - 4}개 필드
+ )} +
+ ) : ( + /* 1:N 그룹: 다중 항목 미리보기 */ + <> +
+ + 1. {groupFields.slice(0, 2).map(f => `${f.label}: 샘플`).join(" / ")} + + +
+ {idx === 1 && ( +
+ + 2. {groupFields.slice(0, 2).map(f => `${f.label}: 샘플`).join(" / ")} + + +
+ )} + + )} +
+
+ ); + })} +
+
+
+ ))} +
+
+ ); + } + + // 런타임 빈 상태 return (

{componentConfig.emptyMessage}

- {isDesignMode && ( -

- 💡 이전 모달에서 "다음" 버튼으로 데이터를 전달하면 여기에 표시됩니다. -

- )}
); @@ -1483,141 +1763,147 @@ export const SelectedItemsDetailInputComponent: React.FC (a.order || 0) - (b.order || 0)); + // 그룹 수에 따라 grid 열 수 결정 + const gridCols = sortedGroups.length === 1 ? "grid-cols-1" : "grid-cols-2"; + return ( -
+
{sortedGroups.map((group) => { const groupFields = fields.filter((f) => (groups.length === 0 ? true : f.groupId === group.id)); if (groupFields.length === 0) return null; const groupEntries = item.fieldGroups[group.id] || []; - const isEditingThisGroup = isEditing && editingItemId === item.id && editingGroupId === group.id; + // 그룹별 독립 편집 상태 사용 + const editingEntryIdForGroup = editingEntries[group.id] || null; + + // 1:1 관계 그룹 (maxEntries === 1): 인라인 폼으로 바로 표시 + const isSingleEntry = group.maxEntries === 1; + const singleEntry = isSingleEntry ? (groupEntries[0] || { id: `${group.id}_auto_1` }) : null; + // hidden 필드 제외 (width: "0px"인 필드) + const visibleFields = groupFields.filter((f) => f.width !== "0px"); return ( - + {group.title} - + {/* 1:N 그룹만 + 추가 버튼 표시 */} + {!isSingleEntry && ( + + )} - {group.description &&

{group.description}

} + {group.description &&

{group.description}

}
- - {/* 이미 입력된 항목들 */} - {groupEntries.length > 0 ? ( -
- {groupEntries.map((entry, idx) => { - const isEditingThisEntry = isEditingThisGroup && editingDetailId === entry.id; - - if (isEditingThisEntry) { - // 편집 모드: 입력 필드 표시 (가로 배치) - return ( - - -
- 수정 중 - -
- {/* 🆕 가로 Grid 배치 (2~3열) */} -
- {groupFields.map((field) => ( -
- - {renderField(field, item.id, group.id, entry.id, entry)} -
- ))} -
-
-
- ); - } else { - // 읽기 모드: 텍스트 표시 (클릭하면 수정) - return ( -
handleEditGroupEntry(item.id, group.id, entry.id)} - > - - {idx + 1}. {renderDisplayItems(entry, item, group.id)} - - -
- ); - } - })} + + {/* === 1:1 그룹: 인라인 폼 (항상 편집 모드) - 컴팩트 === */} + {isSingleEntry && singleEntry && ( +
+ {visibleFields.map((field) => ( +
+ + {renderField(field, item.id, group.id, singleEntry.id, singleEntry)} +
+ ))}
- ) : ( -

아직 입력된 항목이 없습니다.

)} - {/* 새 항목 입력 중 */} - {isEditingThisGroup && editingDetailId && !groupEntries.find((e) => e.id === editingDetailId) && ( - - -
- 새 항목 - + {/* === 1:N 그룹: 다중 입력 (독립 편집) === */} + {!isSingleEntry && ( + <> + {groupEntries.length > 0 ? ( +
+ {groupEntries.map((entry, idx) => { + // 그룹별 독립 편집 상태 확인 + const isEditingThisEntry = editingEntryIdForGroup === entry.id; + + return ( +
+ {/* 헤더 (항상 표시) */} +
{ + if (isEditingThisEntry) { + // 이 그룹만 닫기 (다른 그룹은 유지) + closeGroupEditing(group.id); + } else { + handleEditGroupEntry(item.id, group.id, entry.id); + } + }} + > + + {idx + 1}. {renderDisplayItems(entry, item, group.id)} + +
+ +
+
+ + {/* 폼 영역 (편집 시에만 아래로 펼침) - 컴팩트 */} + {isEditingThisEntry && ( +
+
+ {visibleFields.map((field) => ( +
+ + {renderField(field, item.id, group.id, entry.id, entry)} +
+ ))} +
+
+ +
+
+ )} +
+ ); + })}
- {groupFields.map((field) => ( -
- - {renderField(field, item.id, group.id, editingDetailId, {})} -
- ))} - - + ) : ( +

아직 입력된 항목이 없습니다.

+ )} + + {/* 새 항목은 handleAddGroupEntry에서 아코디언 항목으로 직접 추가됨 */} + )} @@ -1629,12 +1915,6 @@ export const SelectedItemsDetailInputComponent: React.FC { - console.log("🎨 [renderGridLayout] 렌더링:", { - itemsLength: items.length, - displayColumns: componentConfig.displayColumns, - firstItemOriginalData: items[0]?.originalData, - }); - return (
{items.map((item, index) => { @@ -1648,15 +1928,6 @@ export const SelectedItemsDetailInputComponent: React.FC getFieldValue(item.originalData, col.name)) .filter(Boolean); - console.log("🔍 [renderGridLayout] 항목 렌더링:", { - index, - titleValue, - summaryValues, - displayColumns: componentConfig.displayColumns, - originalData: item.originalData, - "displayColumns[0]": componentConfig.displayColumns?.[0], - "originalData keys": Object.keys(item.originalData), - }); return ( @@ -1711,15 +1982,7 @@ export const SelectedItemsDetailInputComponent: React.FC { const editingItem = items.find((item) => item.id === editingItemId); - console.log("🔍 [Modal Mode] 편집 항목 찾기:", { - editingItemId, - itemsLength: items.length, - itemIds: items.map((i) => i.id), - editingItem: editingItem ? "찾음" : "못 찾음", - editingDetailId, - }); if (!editingItem) { - console.warn("⚠️ [Modal Mode] 편집할 항목을 찾을 수 없습니다!"); return null; } @@ -2054,14 +2317,6 @@ export const SelectedItemsDetailInputComponent: React.FC { const isModalMode = componentConfig.inputMode === "modal"; - console.log("🎨 [renderCardLayout] 렌더링 모드:", { - inputMode: componentConfig.inputMode, - isModalMode, - isEditing, - editingItemId, - itemsLength: items.length, - }); - return (
{/* Modal 모드: 추가 버튼 */} @@ -2080,15 +2335,7 @@ export const SelectedItemsDetailInputComponent: React.FC { const editingItem = items.find((item) => item.id === editingItemId); - console.log("🔍 [Modal Mode - Card] 편집 항목 찾기:", { - editingItemId, - itemsLength: items.length, - itemIds: items.map((i) => i.id), - editingItem: editingItem ? "찾음" : "못 찾음", - editingDetailId, - }); if (!editingItem) { - console.warn("⚠️ [Modal Mode - Card] 편집할 항목을 찾을 수 없습니다!"); return null; } @@ -2321,12 +2568,6 @@ export const SelectedItemsDetailInputComponent: React.FC {/* 레이아웃에 따라 렌더링 */} diff --git a/frontend/lib/registry/components/selected-items-detail-input/types.ts b/frontend/lib/registry/components/selected-items-detail-input/types.ts index 88d02c8e..a2d5de34 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/types.ts +++ b/frontend/lib/registry/components/selected-items-detail-input/types.ts @@ -54,6 +54,10 @@ export interface FieldGroup { description?: string; /** 그룹 표시 순서 */ order?: number; + /** 🆕 최대 항목 수 (1이면 1:1 관계 - 자동 생성, + 추가 버튼 숨김) */ + maxEntries?: number; + /** 🆕 이 그룹의 소스 테이블 (카테고리 옵션 로드 시 사용) */ + sourceTable?: string; /** 🆕 이 그룹의 항목 표시 설정 (그룹별로 다른 표시 형식 가능) */ displayItems?: DisplayItem[]; } diff --git a/frontend/lib/utils/alignmentUtils.ts b/frontend/lib/utils/alignmentUtils.ts index e2af866e..c914defc 100644 --- a/frontend/lib/utils/alignmentUtils.ts +++ b/frontend/lib/utils/alignmentUtils.ts @@ -214,19 +214,53 @@ export function matchComponentSize( * 모든 컴포넌트의 라벨 표시/숨기기를 토글합니다. * 숨겨진 라벨이 하나라도 있으면 모두 표시, 모두 표시되어 있으면 모두 숨기기 */ -export function toggleAllLabels(components: ComponentData[], forceShow?: boolean): ComponentData[] { - // 현재 라벨이 숨겨진(labelDisplay === false) 컴포넌트가 있는지 확인 - const hasHiddenLabel = components.some( - (c) => c.type === "widget" && (c.style as any)?.labelDisplay === false +/** + * 라벨 토글 대상 타입 판별 + * label 속성이 있고, style.labelDisplay를 지원하는 컴포넌트인지 확인 + */ +function hasLabelSupport(component: ComponentData): boolean { + // 라벨이 없는 컴포넌트는 제외 + if (!component.label) return false; + + // 그룹, datatable 등은 라벨 토글 대상에서 제외 + const excludedTypes = ["group", "datatable"]; + if (excludedTypes.includes(component.type)) return false; + + // 나머지 (widget, component, container, file, flow 등)는 대상 + return true; +} + +/** + * @param components - 전체 컴포넌트 배열 + * @param selectedIds - 선택된 컴포넌트 ID 목록 (빈 배열이면 전체 대상) + * @param forceShow - 강제 표시/숨기기 (지정하지 않으면 자동 토글) + */ +export function toggleAllLabels( + components: ComponentData[], + selectedIds: string[] = [], + forceShow?: boolean +): ComponentData[] { + // 대상 컴포넌트 필터: selectedIds가 있으면 선택된 것만, 없으면 전체 + const targetComponents = components.filter((c) => { + if (!hasLabelSupport(c)) return false; + if (selectedIds.length > 0) return selectedIds.includes(c.id); + return true; + }); + + // 대상 중 라벨이 숨겨진 컴포넌트가 있는지 확인 + const hasHiddenLabel = targetComponents.some( + (c) => (c.style as any)?.labelDisplay === false ); // forceShow가 지정되면 그 값 사용, 아니면 자동 판단 - // 숨겨진 라벨이 있으면 모두 표시, 아니면 모두 숨기기 const shouldShow = forceShow !== undefined ? forceShow : hasHiddenLabel; + // 대상 ID Set (빠른 조회용) + const targetIdSet = new Set(targetComponents.map((c) => c.id)); + return components.map((c) => { - // 위젯 타입만 라벨 토글 대상 - if (c.type !== "widget") return c; + // 대상이 아닌 컴포넌트는 건드리지 않음 + if (!targetIdSet.has(c.id)) return c; return { ...c, diff --git a/mcp-agent-orchestrator/src/index.ts b/mcp-agent-orchestrator/src/index.ts index 3634cf70..3494ce00 100644 --- a/mcp-agent-orchestrator/src/index.ts +++ b/mcp-agent-orchestrator/src/index.ts @@ -16,15 +16,12 @@ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; -import { exec } from "child_process"; -import { promisify } from "util"; +import { spawn } from "child_process"; import { platform } from "os"; import { AGENT_CONFIGS } from "./agents/prompts.js"; import { AgentType, ParallelResult } from "./agents/types.js"; import { logger } from "./utils/logger.js"; -const execAsync = promisify(exec); - // OS 감지 const isWindows = platform() === "win32"; logger.info(`Platform detected: ${platform()} (isWindows: ${isWindows})`); @@ -43,12 +40,97 @@ const server = new Server( ); /** - * Cursor Agent CLI를 통해 에이전트 호출 - * Cursor Team Plan 사용 - API 키 불필요! + * 유틸: ms만큼 대기 + */ +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Cursor Agent CLI 단일 호출 (내부용) + * spawn + stdin 직접 전달 + */ +function spawnAgentOnce( + agentType: AgentType, + fullPrompt: string, + model: string +): Promise { + const agentPath = isWindows ? 'agent' : `${process.env.HOME}/.local/bin/agent`; + + return new Promise((resolve, reject) => { + let stdout = ''; + let stderr = ''; + let settled = false; + + const child = spawn(agentPath, ['--model', model, '--print'], { + cwd: process.cwd(), + env: { + ...process.env, + PATH: `${process.env.HOME}/.local/bin:${process.env.PATH}`, + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + child.stdout.on('data', (data: Buffer) => { + stdout += data.toString(); + }); + + child.stderr.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + + child.on('error', (err: Error) => { + if (!settled) { + settled = true; + reject(err); + } + }); + + child.on('close', (code: number | null) => { + if (settled) return; + settled = true; + + if (stderr) { + const significantStderr = stderr + .split('\n') + .filter((line: string) => line && !line.includes('warning') && !line.includes('info') && !line.includes('debug')) + .join('\n'); + if (significantStderr) { + logger.warn(`${agentType} agent stderr`, { stderr: significantStderr.substring(0, 500) }); + } + } + + if (code === 0 || stdout.trim().length > 0) { + resolve(stdout.trim()); + } else { + reject(new Error( + `Agent exited with code ${code}. stderr: ${stderr.substring(0, 1000)}` + )); + } + }); + + // 타임아웃 (5분) + const timeout = setTimeout(() => { + if (!settled) { + settled = true; + child.kill('SIGTERM'); + reject(new Error(`${agentType} agent timed out after 5 minutes`)); + } + }, 300000); + + child.on('close', () => clearTimeout(timeout)); + + // stdin으로 프롬프트 직접 전달 + child.stdin.write(fullPrompt); + child.stdin.end(); + }); +} + +/** + * Cursor Agent CLI를 통해 에이전트 호출 (재시도 포함) * - * 크로스 플랫폼 지원: - * - Windows: cmd /c "echo. | agent ..." (stdin 닫기 위해) - * - Mac/Linux: ~/.local/bin/agent 사용 + * - 최대 2회 재시도 (총 3회 시도) + * - 재시도 간 2초 대기 (Cursor CLI 동시 실행 제한 대응) */ async function callAgentCLI( agentType: AgentType, @@ -56,60 +138,43 @@ async function callAgentCLI( context?: string ): Promise { const config = AGENT_CONFIGS[agentType]; - - // 모델 선택: PM은 opus, 나머지는 sonnet const model = agentType === 'pm' ? 'opus-4.5' : 'sonnet-4.5'; - - logger.info(`Calling ${agentType} agent via CLI`, { model, task: task.substring(0, 100) }); + const maxRetries = 2; - try { - const userMessage = context - ? `${task}\n\n배경 정보:\n${context}` - : task; - - // 프롬프트를 임시 파일에 저장하여 쉘 이스케이프 문제 회피 - const fullPrompt = `${config.systemPrompt}\n\n---\n\n${userMessage}`; - - // Base64 인코딩으로 특수문자 문제 해결 - const encodedPrompt = Buffer.from(fullPrompt).toString('base64'); - - let cmd: string; - let shell: string; - const agentPath = isWindows ? 'agent' : `${process.env.HOME}/.local/bin/agent`; - - if (isWindows) { - // Windows: PowerShell을 통해 Base64 디코딩 후 실행 - cmd = `powershell -Command "$prompt = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${encodedPrompt}')); echo $prompt | ${agentPath} --model ${model} --print"`; - shell = 'powershell.exe'; - } else { - // Mac/Linux: echo로 base64 디코딩 후 파이프 - cmd = `echo "${encodedPrompt}" | base64 -d | ${agentPath} --model ${model} --print`; - shell = '/bin/bash'; + logger.info(`Calling ${agentType} agent via CLI (spawn+retry)`, { + model, + task: task.substring(0, 100), + }); + + const userMessage = context + ? `${task}\n\n배경 정보:\n${context}` + : task; + const fullPrompt = `${config.systemPrompt}\n\n---\n\n${userMessage}`; + + let lastError: Error | null = null; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + if (attempt > 0) { + const delay = attempt * 2000; // 2초, 4초 + logger.info(`${agentType} agent retry ${attempt}/${maxRetries} (waiting ${delay}ms)`); + await sleep(delay); + } + + const result = await spawnAgentOnce(agentType, fullPrompt, model); + logger.info(`${agentType} agent completed (attempt ${attempt + 1})`); + return result; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + logger.warn(`${agentType} agent attempt ${attempt + 1} failed`, { + error: lastError.message.substring(0, 200), + }); } - - logger.debug(`Executing: ${agentPath} --model ${model} --print`); - - const { stdout, stderr } = await execAsync(cmd, { - cwd: process.cwd(), - maxBuffer: 10 * 1024 * 1024, // 10MB buffer - timeout: 300000, // 5분 타임아웃 - shell, - env: { - ...process.env, - PATH: `${process.env.HOME}/.local/bin:${process.env.PATH}`, - }, - }); - - if (stderr && !stderr.includes('warning') && !stderr.includes('info')) { - logger.warn(`${agentType} agent stderr`, { stderr: stderr.substring(0, 500) }); - } - - logger.info(`${agentType} agent completed via CLI`); - return stdout.trim(); - } catch (error) { - logger.error(`${agentType} agent CLI error`, error); - throw error; } + + // 모든 재시도 실패 + logger.error(`${agentType} agent failed after ${maxRetries + 1} attempts`); + throw lastError!; } /** @@ -277,12 +342,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { }>; }; - logger.info(`Parallel ask to ${requests.length} agents (TRUE PARALLEL!)`); + logger.info(`Parallel ask to ${requests.length} agents (STAGGERED PARALLEL)`); + + // 시차 병렬 실행: 각 에이전트를 500ms 간격으로 시작 + // Cursor Agent CLI 동시 실행 제한 대응 + const STAGGER_DELAY = 500; // ms - // 진짜 병렬 실행! 모든 에이전트가 동시에 작업 const results: ParallelResult[] = await Promise.all( - requests.map(async (req) => { + requests.map(async (req, index) => { try { + // 시차 적용 (첫 번째는 즉시, 이후 500ms 간격) + if (index > 0) { + await sleep(index * STAGGER_DELAY); + } const result = await callAgentCLI(req.agent, req.task, req.context); return { agent: req.agent, result }; } catch (error) {