From 08dde416b1ca3281e0f5d57dccc3e4875b6205a4 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Fri, 6 Feb 2026 16:00:43 +0900 Subject: [PATCH 1/7] docs: Add detailed backend, database, and frontend architecture analysis documents - Created a comprehensive analysis document for the backend architecture, detailing the directory structure, API routes, authentication workflows, and more. - Added a database architecture analysis report outlining the database structure, multi-tenancy architecture, and key system tables. - Introduced a frontend architecture analysis document that covers the directory structure, component systems, and Next.js App Router structure. These documents aim to enhance the understanding of the WACE ERP system's architecture and facilitate better workflow documentation. --- docs/DB_ARCHITECTURE_ANALYSIS.md | 2188 ++++++++++++++++++++++++ docs/backend-architecture-analysis.md | 1424 +++++++++++++++ docs/frontend-architecture-analysis.md | 1920 +++++++++++++++++++++ mcp-agent-orchestrator/src/index.ts | 127 +- 4 files changed, 5612 insertions(+), 47 deletions(-) create mode 100644 docs/DB_ARCHITECTURE_ANALYSIS.md create mode 100644 docs/backend-architecture-analysis.md create mode 100644 docs/frontend-architecture-analysis.md 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/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/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/mcp-agent-orchestrator/src/index.ts b/mcp-agent-orchestrator/src/index.ts index 3634cf70..51b372cd 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})`); @@ -46,9 +43,11 @@ const server = new Server( * Cursor Agent CLI를 통해 에이전트 호출 * Cursor Team Plan 사용 - API 키 불필요! * + * spawn + stdin 직접 전달 방식으로 쉘 이스케이프 문제 완전 해결 + * * 크로스 플랫폼 지원: - * - Windows: cmd /c "echo. | agent ..." (stdin 닫기 위해) - * - Mac/Linux: ~/.local/bin/agent 사용 + * - Windows: agent (PATH에서 검색) + * - Mac/Linux: ~/.local/bin/agent */ async function callAgentCLI( agentType: AgentType, @@ -60,56 +59,90 @@ async function callAgentCLI( // 모델 선택: 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) }); + logger.info(`Calling ${agentType} agent via CLI (spawn)`, { model, task: task.substring(0, 100) }); - 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.debug(`Executing: ${agentPath} --model ${model} --print`); - - const { stdout, stderr } = await execAsync(cmd, { + const userMessage = context + ? `${task}\n\n배경 정보:\n${context}` + : task; + + const fullPrompt = `${config.systemPrompt}\n\n---\n\n${userMessage}`; + 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(), - maxBuffer: 10 * 1024 * 1024, // 10MB buffer - timeout: 300000, // 5분 타임아웃 - shell, env: { ...process.env, PATH: `${process.env.HOME}/.local/bin:${process.env.PATH}`, }, + stdio: ['pipe', 'pipe', 'pipe'], }); - if (stderr && !stderr.includes('warning') && !stderr.includes('info')) { - logger.warn(`${agentType} agent stderr`, { stderr: stderr.substring(0, 500) }); - } + child.stdout.on('data', (data: Buffer) => { + stdout += data.toString(); + }); - logger.info(`${agentType} agent completed via CLI`); - return stdout.trim(); - } catch (error) { - logger.error(`${agentType} agent CLI error`, error); - throw error; - } + child.stderr.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + + child.on('error', (err: Error) => { + if (!settled) { + settled = true; + logger.error(`${agentType} agent spawn error`, err); + reject(err); + } + }); + + child.on('close', (code: number | null) => { + if (settled) return; + settled = true; + + if (stderr) { + // 경고/정보 레벨 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) { + // 정상 종료이거나, 에러 코드여도 stdout에 결과가 있으면 성공 처리 + logger.info(`${agentType} agent completed via CLI (exit code: ${code})`); + resolve(stdout.trim()); + } else { + const errorMsg = `Agent exited with code ${code}. stderr: ${stderr.substring(0, 1000)}`; + logger.error(`${agentType} agent CLI error`, { code, stderr: stderr.substring(0, 1000) }); + reject(new Error(errorMsg)); + } + }); + + // 타임아웃 (5분) + const timeout = setTimeout(() => { + if (!settled) { + settled = true; + child.kill('SIGTERM'); + logger.error(`${agentType} agent timed out after 5 minutes`); + reject(new Error(`${agentType} agent timed out after 5 minutes`)); + } + }, 300000); + + // 프로세스 종료 시 타이머 클리어 + child.on('close', () => clearTimeout(timeout)); + + // stdin으로 프롬프트 직접 전달 (쉘 이스케이프 문제 없음!) + child.stdin.write(fullPrompt); + child.stdin.end(); + + logger.debug(`Prompt sent to ${agentType} agent via stdin (${fullPrompt.length} chars)`); + }); } /** From 79d8f0b160a8c7423fd15c5ed7a82fc2d60aa189 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Sat, 7 Feb 2026 17:45:44 +0900 Subject: [PATCH 2/7] refactor: Update ComponentsPanel and SelectedItemsDetailInputComponent for improved functionality - Updated ComponentsPanel to clarify the usage of the "selected-items-detail-input" component, indicating its application in the context of adding items for clients. - Enhanced SelectedItemsDetailInputComponent by introducing independent editing states for group entries, allowing for better management of item edits within groups. - Adjusted input field heights and styles for consistency and improved user experience. - Added a new property `maxEntries` to the FieldGroup interface to support 1:1 relationships and automatic entry generation. - Implemented overflow support for the component to handle cases with many items, ensuring a smoother user interface. --- docs/DB_WORKFLOW_ANALYSIS.md | 1728 +++++++++++++++ docs/WACE_SYSTEM_WORKFLOW.md | 955 +++++++++ docs/backend-analysis-README.md | 246 +++ docs/backend-analysis-response.json | 239 +++ docs/backend-api-route-mapping.md | 542 +++++ .../backend-architecture-detailed-analysis.md | 1855 +++++++++++++++++ docs/backend-architecture-summary.md | 342 +++ .../screen/panels/ComponentsPanel.tsx | 4 +- .../SelectedItemsDetailInputComponent.tsx | 445 ++-- .../selected-items-detail-input/types.ts | 2 + 10 files changed, 6210 insertions(+), 148 deletions(-) create mode 100644 docs/DB_WORKFLOW_ANALYSIS.md create mode 100644 docs/WACE_SYSTEM_WORKFLOW.md create mode 100644 docs/backend-analysis-README.md create mode 100644 docs/backend-analysis-response.json create mode 100644 docs/backend-api-route-mapping.md create mode 100644 docs/backend-architecture-detailed-analysis.md create mode 100644 docs/backend-architecture-summary.md 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-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/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/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx index 285c655d..023002c0 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx @@ -94,8 +94,11 @@ 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>>({}); @@ -404,9 +407,14 @@ export const SelectedItemsDetailInputComponent: React.FC { 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] = []; + } }); // 그룹이 없으면 기본 그룹 생성 @@ -757,6 +765,7 @@ export const SelectedItemsDetailInputComponent: React.FC ({ ...prev, [groupId]: newEntryId })); }; // 🆕 그룹 항목 제거 핸들러 @@ -992,14 +1003,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 +1090,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" /> ); @@ -1081,12 +1112,12 @@ export const SelectedItemsDetailInputComponent: React.FC -
자동 계산
+
자동 계산
); } @@ -1098,7 +1129,7 @@ export const SelectedItemsDetailInputComponent: React.FC handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)} min={field.validation?.min} max={field.validation?.max} - className="h-10 text-sm" + className="h-7 text-xs" /> ); @@ -1117,7 +1148,7 @@ export const SelectedItemsDetailInputComponent: React.FC ); @@ -1137,8 +1168,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 +1194,7 @@ export const SelectedItemsDetailInputComponent: React.FC handleFieldChange(itemId, groupId, entryId, field.name, val)} disabled={componentConfig.disabled || componentConfig.readonly} > - + @@ -1264,28 +1295,42 @@ export const SelectedItemsDetailInputComponent: React.FC - componentConfig.fieldGroups && componentConfig.fieldGroups.length > 0 ? f.groupId === groupId : true, - ); - return fields + // displayItems가 없으면 기본 방식 (해당 그룹의 visible 필드만 나열) + const fields = (componentConfig.additionalFields || []).filter((f) => { + // 그룹 필터 + const matchGroup = componentConfig.fieldGroups && componentConfig.fieldGroups.length > 0 + ? f.groupId === groupId + : true; + // hidden 필드 제외 (width: "0px"인 필드) + const isVisible = f.width !== "0px"; + return matchGroup && isVisible; + }); + + // 값이 있는 필드만 "라벨: 값" 형식으로 표시 + const displayParts = fields .map((f) => { const value = entry[f.name]; - if (!value) return "-"; + if (!value && value !== 0) return null; const strValue = String(value); - // 🔧 ISO 날짜 형식 자동 감지 및 포맷팅 (필드 타입 무관) - // ISO 8601 형식: YYYY-MM-DDTHH:mm:ss.sssZ 또는 YYYY-MM-DD + // ISO 날짜 형식 자동 포맷팅 const isoDateMatch = strValue.match(/^(\d{4})-(\d{2})-(\d{2})(T|\s|$)/); if (isoDateMatch) { const [, year, month, day] = isoDateMatch; - return `${year}.${month}.${day}`; + return `${f.label}: ${year}.${month}.${day}`; } - return strValue; + return `${f.label}: ${strValue}`; }) - .join(" / "); + .filter(Boolean); + + // 빈 항목 표시: 그룹 필드명으로 안내 메시지 생성 + if (displayParts.length === 0) { + const fieldLabels = fields.slice(0, 2).map(f => f.label).join("/"); + return `신규 ${fieldLabels} 입력`; + } + return displayParts.join(" "); } // displayItems 설정대로 렌더링 @@ -1459,15 +1504,117 @@ export const SelectedItemsDetailInputComponent: React.FC 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 +1630,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에서 아코디언 항목으로 직접 추가됨 */} + )} 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..d1778074 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,8 @@ export interface FieldGroup { description?: string; /** 그룹 표시 순서 */ order?: number; + /** 🆕 최대 항목 수 (1이면 1:1 관계 - 자동 생성, + 추가 버튼 숨김) */ + maxEntries?: number; /** 🆕 이 그룹의 항목 표시 설정 (그룹별로 다른 표시 형식 가능) */ displayItems?: DisplayItem[]; } From bb4d90fd58f5ab75fab7c575793867b7c6ef9c07 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Mon, 9 Feb 2026 10:07:07 +0900 Subject: [PATCH 3/7] refactor: Improve label toggling functionality in ScreenDesigner and enhance SelectedItemsDetailInputComponent - Updated the label toggling logic in ScreenDesigner to allow toggling of labels for selected components or all components based on the current selection. - Enhanced the SelectedItemsDetailInputComponent by implementing a caching mechanism for table columns and refining the logic for loading category options based on field groups. - Introduced a new helper function to convert category codes to labels, improving the clarity and maintainability of the price calculation logic. - Added support for determining the source table for field groups, facilitating better data management and retrieval. --- frontend/components/screen/ScreenDesigner.tsx | 30 +- .../SelectedItemsDetailInputComponent.tsx | 271 ++++++++++++------ .../selected-items-detail-input/types.ts | 2 + frontend/lib/utils/alignmentUtils.ts | 48 +++- 4 files changed, 252 insertions(+), 99 deletions(-) diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 429f91f8..9e724a3f 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -1871,17 +1871,33 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU [groupState.selectedComponents, layout, selectedComponent?.id, saveToHistory] ); - // 라벨 일괄 토글 + // 라벨 일괄 토글 (선택된 컴포넌트가 있으면 선택된 것만, 없으면 전체) const handleToggleAllLabels = useCallback(() => { saveToHistory(layout); - const newComponents = toggleAllLabels(layout.components); + + const selectedIds = groupState.selectedComponents; + const isPartial = selectedIds.length > 0; + + // 토글 대상 컴포넌트 필터 + const targetComponents = layout.components.filter((c) => { + if (!c.label || ["group", "datatable"].includes(c.type)) return false; + if (isPartial) return selectedIds.includes(c.id); + return true; + }); + + const hadHidden = targetComponents.some( + (c) => (c.style as any)?.labelDisplay === false + ); + + const newComponents = toggleAllLabels(layout.components, selectedIds); 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]); + // 강제 리렌더링 트리거 + setForceRenderTrigger((prev) => prev + 1); + + const scope = isPartial ? `선택된 ${targetComponents.length}개` : "모든"; + toast.success(hadHidden ? `${scope} 라벨 표시` : `${scope} 라벨 숨기기`); + }, [layout, saveToHistory, groupState.selectedComponents]); // Nudge (화살표 키 이동) const handleNudge = useCallback( 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 023002c0..27feafe2 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx @@ -146,59 +146,69 @@ export const SelectedItemsDetailInputComponent: React.FC> = { ...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) { + console.log(`🔄 카테고리 옵션 로드 (${fieldSourceTable}.${field.name})`); 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]); + console.log(`✅ 카테고리 옵션 로드 완료 (${fieldSourceTable}.${field.name}):`, newOptions[field.name]); } else { - console.error(`❌ 카테고리 옵션 로드 실패 (${field.name}):`, response.error || "응답 없음"); + console.warn(`⚠️ 카테고리 옵션 없음 (${fieldSourceTable}.${field.name})`); } } 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; + console.log(`🔍 필드 "${field.name}"의 codeCategory를 메타데이터에서 찾음:`, codeCategory); + } } } @@ -784,13 +794,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"); @@ -798,38 +817,46 @@ 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], @@ -1099,16 +1142,19 @@ export const SelectedItemsDetailInputComponent: React.FC handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)} - min={field.validation?.min} - max={field.validation?.max} + 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": @@ -1194,7 +1246,7 @@ export const SelectedItemsDetailInputComponent: React.FC handleFieldChange(itemId, groupId, entryId, field.name, val)} disabled={componentConfig.disabled || componentConfig.readonly} > - + @@ -1220,7 +1272,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" /> ); @@ -1231,7 +1283,7 @@ export const SelectedItemsDetailInputComponent: React.FC handleFieldChange(itemId, groupId, entryId, field.name, val)} disabled={componentConfig.disabled || componentConfig.readonly} > - + @@ -1254,7 +1306,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" /> ); } @@ -1295,42 +1347,91 @@ export const SelectedItemsDetailInputComponent: React.FC { - // 그룹 필터 const matchGroup = componentConfig.fieldGroups && componentConfig.fieldGroups.length > 0 ? f.groupId === groupId : true; - // hidden 필드 제외 (width: "0px"인 필드) const isVisible = f.width !== "0px"; return matchGroup && isVisible; }); - // 값이 있는 필드만 "라벨: 값" 형식으로 표시 - const displayParts = fields - .map((f) => { - const value = entry[f.name]; - if (!value && value !== 0) return null; + // 헬퍼: 값을 사람이 읽기 좋은 형태로 변환 + const formatValue = (f: any, value: any): string => { + if (!value && value !== 0) return ""; + const strValue = String(value); - 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}`; + } - // ISO 날짜 형식 자동 포맷팅 - const isoDateMatch = strValue.match(/^(\d{4})-(\d{2})-(\d{2})(T|\s|$)/); - if (isoDateMatch) { - const [, year, month, day] = isoDateMatch; - return `${f.label}: ${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; + } - return `${f.label}: ${strValue}`; - }) - .filter(Boolean); + // 숫자는 천 단위 구분 + if (renderType === "number" && !isNaN(Number(strValue))) { + return new Intl.NumberFormat("ko-KR").format(Number(strValue)); + } - // 빈 항목 표시: 그룹 필드명으로 안내 메시지 생성 - if (displayParts.length === 0) { + 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} 입력`; } - return displayParts.join(" "); + + // 날짜 범위가 있으면 우선 표시 + 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 && value !== 0) return null; + return `${f.label}: ${formatValue(f, value)}`; + }) + .filter(Boolean); + return summaryParts.join(" "); } // displayItems 설정대로 렌더링 @@ -1499,7 +1600,7 @@ export const SelectedItemsDetailInputComponent: React.FC ); }, - [componentConfig.fieldGroups, componentConfig.additionalFields], + [componentConfig.fieldGroups, componentConfig.additionalFields, codeOptions], ); // 빈 상태 렌더링 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 d1778074..a2d5de34 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/types.ts +++ b/frontend/lib/registry/components/selected-items-detail-input/types.ts @@ -56,6 +56,8 @@ export interface FieldGroup { 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, From 2e500f066f25d7ece2d8791cae3d695e1a0fb3cd Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Mon, 9 Feb 2026 13:22:48 +0900 Subject: [PATCH 4/7] feat: Add close confirmation dialog to ScreenModal and enhance SelectedItemsDetailInputComponent - Implemented a confirmation dialog in ScreenModal to prevent accidental closure, allowing users to confirm before exiting and potentially losing unsaved data. - Enhanced SelectedItemsDetailInputComponent by ensuring that base records are created even when detail data is absent, maintaining item-client mapping. - Improved logging for better traceability during the UPSERT process and refined the handling of parent data mappings for more robust data management. --- frontend/components/common/ScreenModal.tsx | 124 ++++++- .../SelectedItemsDetailInputComponent.tsx | 329 ++++++++++++++---- mcp-agent-orchestrator/src/index.ts | 117 ++++--- 3 files changed, 454 insertions(+), 116 deletions(-) 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/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx index 27feafe2..e99fd0e5 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx @@ -490,22 +490,25 @@ 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; + } + } + }); + + console.log("📝 [generateCartesianProduct] 모든 그룹 비어있음 - 기본 레코드 생성 (매핑 유지)", { + itemIndex, + itemId: item.id, + baseRecord, + totalItems: itemsList.length, + }); + allRecords.push(baseRecord); return; } @@ -579,17 +582,15 @@ export const SelectedItemsDetailInputComponent: React.FC 0; - console.log("📝 [SelectedItemsDetailInput] 저장 모드:", { mode, isEditMode }); + console.log("📝 [SelectedItemsDetailInput] 저장 모드:", { hasParentMapping }); - if (isEditMode && componentConfig.parentDataMapping && componentConfig.parentDataMapping.length > 0) { - // 🔄 수정 모드: UPSERT API 사용 + if (hasParentMapping) { + // UPSERT API로 직접 DB 저장 try { - console.log("🔄 [SelectedItemsDetailInput] UPSERT 모드로 저장 시작"); + console.log("🔄 [SelectedItemsDetailInput] UPSERT 저장 시작"); console.log("📋 [SelectedItemsDetailInput] componentConfig:", { targetTable: componentConfig.targetTable, parentDataMapping: componentConfig.parentDataMapping, @@ -622,14 +623,30 @@ 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]; + console.log( + `🔄 [parentKeys] dataRegistry["${mapping.sourceTable}"]에서 찾음: ${mapping.sourceField} =`, + value, + ); + } + } + 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}`, + `(sourceData, dataRegistry["${mapping.sourceTable}"] 모두 확인)`, ); } }); @@ -657,15 +674,6 @@ 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, + console.log("🏗️ [SelectedItemsDetailInput] 저장 구조:", { + mainTable, + detailTables, + hasDetailTable, + groupsByTable: Object.fromEntries(groupsByTable), + }); + + if (hasDetailTable) { + // ============================================================ + // 🆕 2단계 저장: 메인 테이블 + 디테일 테이블 분리 저장 + // 예: customer_item_mapping (매핑) + customer_item_prices (가격) + // ============================================================ + const mainGroups = groupsByTable.get(mainTable) || []; + let totalInserted = 0; + let totalUpdated = 0; + + for (const item of items) { + // Step 1: 메인 테이블 매핑 레코드 생성/갱신 + const mappingData: Record = { ...parentKeys }; + + // 메인 그룹 필드 추출 (customer_item_code, customer_item_name 등) + mainGroups.forEach((group) => { + const entries = item.fieldGroups[group.id] || []; + const groupFields = additionalFields.filter((f) => f.groupId === group.id); + + if (entries.length > 0) { + groupFields.forEach((field) => { + if (entries[0][field.name] !== undefined) { + mappingData[field.name] = entries[0][field.name]; + } + }); + } + + // autoFillFrom 필드 처리 (item_id 등) + groupFields.forEach((field) => { + if (field.autoFillFrom && item.originalData) { + const value = item.originalData[field.autoFillFrom]; + if (value !== undefined && value !== null) { + mappingData[field.name] = value; + } + } + }); + }); + + console.log("📋 [2단계 저장] Step 1 - 매핑 데이터:", mappingData); + + // 기존 매핑 레코드 찾기 + let mappingId: string | null = null; + const searchFilters: Record = {}; + + // parentKeys + item_id로 검색 + Object.entries(parentKeys).forEach(([key, value]) => { + searchFilters[key] = value; + }); + if (mappingData.item_id) { + searchFilters.item_id = mappingData.item_id; + } + + try { + const searchResult = await dataApi.getTableData(mainTable, { + filters: searchFilters, + size: 1, + }); + + if (searchResult.data && searchResult.data.length > 0) { + // 기존 매핑 업데이트 + mappingId = searchResult.data[0].id; + console.log("📌 [2단계 저장] 기존 매핑 발견:", mappingId); + await dataApi.updateRecord(mainTable, mappingId, mappingData); + totalUpdated++; + } else { + // 새 매핑 생성 + const createResult = await dataApi.createRecord(mainTable, mappingData); + if (createResult.success && createResult.data) { + mappingId = createResult.data.id; + console.log("✨ [2단계 저장] 새 매핑 생성:", mappingId); + totalInserted++; + } + } + } catch (err) { + console.error("❌ [2단계 저장] 매핑 저장 실패:", err); + continue; + } + + if (!mappingId) { + console.error("❌ [2단계 저장] mapping_id 획득 실패 - item:", mappingData.item_id); + continue; + } + + // Step 2: 디테일 테이블에 가격 레코드 저장 + 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) => { + // 실제 값이 있는 엔트리만 저장 + const hasValues = groupFields.some((field) => { + const value = entry[field.name]; + return value !== undefined && value !== null && value !== ""; + }); + + if (hasValues) { + const priceRecord: Record = { + mapping_id: mappingId, + // 비정규화: 직접 필터링을 위해 customer_id, item_id 포함 + ...parentKeys, + item_id: mappingData.item_id, + }; + groupFields.forEach((field) => { + if (entry[field.name] !== undefined) { + priceRecord[field.name] = entry[field.name]; + } + }); + priceRecords.push(priceRecord); + } + }); + }); + + if (priceRecords.length > 0) { + console.log(`📋 [2단계 저장] Step 2 - ${detailTable} 레코드:`, { + mappingId, + count: priceRecords.length, + records: priceRecords, + }); + + const detailResult = await dataApi.upsertGroupedRecords( + detailTable, + { mapping_id: mappingId }, + priceRecords, + ); + + if (detailResult.success) { + console.log(`✅ [2단계 저장] ${detailTable} 저장 성공:`, detailResult); + } else { + console.error(`❌ [2단계 저장] ${detailTable} 저장 실패:`, detailResult.error); + } + } else { + console.log(`⏭️ [2단계 저장] ${detailTable} - 가격 레코드 없음 (빈 항목)`); + } + } + } + + console.log("✅ [SelectedItemsDetailInput] 2단계 저장 완료:", { + inserted: totalInserted, + updated: totalUpdated, }); - // 저장 성공 이벤트 발생 + // 저장 성공 이벤트 window.dispatchEvent( new CustomEvent("formSaveSuccess", { detail: { message: "데이터가 저장되었습니다." }, }), ); } else { - console.error("❌ [SelectedItemsDetailInput] UPSERT 실패:", result.error); - window.dispatchEvent( - new CustomEvent("formSaveError", { - detail: { message: result.error || "데이터 저장 실패" }, - }), - ); + // ============================================================ + // 단일 테이블 저장 (기존 로직 - detailTable 없는 경우) + // ============================================================ + const records = generateCartesianProduct(items); + + console.log("📦 [SelectedItemsDetailInput] 단일 테이블 UPSERT:", { + tableName: mainTable, + parentKeys, + recordCount: records.length, + }); + + const result = await dataApi.upsertGroupedRecords(mainTable, parentKeys, records); + + if (result.success) { + console.log("✅ [SelectedItemsDetailInput] UPSERT 성공:", { + inserted: result.inserted, + updated: result.updated, + deleted: result.deleted, + }); + + window.dispatchEvent( + new CustomEvent("formSaveSuccess", { + detail: { message: "데이터가 저장되었습니다." }, + }), + ); + } else { + console.error("❌ [SelectedItemsDetailInput] UPSERT 실패:", result.error); + window.dispatchEvent( + new CustomEvent("formSaveError", { + detail: { message: result.error || "데이터 저장 실패" }, + }), + ); + } } } catch (error) { console.error("❌ [SelectedItemsDetailInput] UPSERT 오류:", error); @@ -769,7 +948,7 @@ 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 = { @@ -844,15 +1023,27 @@ export const SelectedItemsDetailInputComponent: React.FC { - 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 (spawn)`, { model, task: task.substring(0, 100) }); +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} - const userMessage = context - ? `${task}\n\n배경 정보:\n${context}` - : task; - - const fullPrompt = `${config.systemPrompt}\n\n---\n\n${userMessage}`; +/** + * 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) => { @@ -93,7 +82,6 @@ async function callAgentCLI( child.on('error', (err: Error) => { if (!settled) { settled = true; - logger.error(`${agentType} agent spawn error`, err); reject(err); } }); @@ -103,7 +91,6 @@ async function callAgentCLI( settled = true; if (stderr) { - // 경고/정보 레벨 stderr는 무시 const significantStderr = stderr .split('\n') .filter((line: string) => line && !line.includes('warning') && !line.includes('info') && !line.includes('debug')) @@ -114,13 +101,11 @@ async function callAgentCLI( } if (code === 0 || stdout.trim().length > 0) { - // 정상 종료이거나, 에러 코드여도 stdout에 결과가 있으면 성공 처리 - logger.info(`${agentType} agent completed via CLI (exit code: ${code})`); resolve(stdout.trim()); } else { - const errorMsg = `Agent exited with code ${code}. stderr: ${stderr.substring(0, 1000)}`; - logger.error(`${agentType} agent CLI error`, { code, stderr: stderr.substring(0, 1000) }); - reject(new Error(errorMsg)); + reject(new Error( + `Agent exited with code ${code}. stderr: ${stderr.substring(0, 1000)}` + )); } }); @@ -129,22 +114,69 @@ async function callAgentCLI( if (!settled) { settled = true; child.kill('SIGTERM'); - logger.error(`${agentType} agent timed out after 5 minutes`); reject(new Error(`${agentType} agent timed out after 5 minutes`)); } }, 300000); - // 프로세스 종료 시 타이머 클리어 child.on('close', () => clearTimeout(timeout)); - // stdin으로 프롬프트 직접 전달 (쉘 이스케이프 문제 없음!) + // stdin으로 프롬프트 직접 전달 child.stdin.write(fullPrompt); child.stdin.end(); - - logger.debug(`Prompt sent to ${agentType} agent via stdin (${fullPrompt.length} chars)`); }); } +/** + * Cursor Agent CLI를 통해 에이전트 호출 (재시도 포함) + * + * - 최대 2회 재시도 (총 3회 시도) + * - 재시도 간 2초 대기 (Cursor CLI 동시 실행 제한 대응) + */ +async function callAgentCLI( + agentType: AgentType, + task: string, + context?: string +): Promise { + const config = AGENT_CONFIGS[agentType]; + const model = agentType === 'pm' ? 'opus-4.5' : 'sonnet-4.5'; + const maxRetries = 2; + + 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.error(`${agentType} agent failed after ${maxRetries + 1} attempts`); + throw lastError!; +} + /** * 도구 목록 핸들러 */ @@ -310,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) { From 2b035ce6e14a53fa70789f10e813f80bc22e9cc1 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Mon, 9 Feb 2026 15:03:29 +0900 Subject: [PATCH 5/7] Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node --- .../src/controllers/screenGroupController.ts | 2 +- backend-node/src/services/dataService.ts | 161 ++-- frontend/components/screen/ScreenDesigner.tsx | 507 ++---------- .../components/screen/ScreenSettingModal.tsx | 18 +- .../SelectedItemsDetailInputComponent.tsx | 742 +++++++----------- 5 files changed, 406 insertions(+), 1024 deletions(-) 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/services/dataService.ts b/backend-node/src/services/dataService.ts index 9623d976..ff3b502a 100644 --- a/backend-node/src/services/dataService.ts +++ b/backend-node/src/services/dataService.ts @@ -1405,7 +1405,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 +1413,86 @@ 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 추적 + // DEBUG: 수신된 레코드와 기존 레코드 id 확인 + console.log(`🔑 [UPSERT DEBUG] pkColumn: ${pkColumn}`); + console.log(`🔑 [UPSERT DEBUG] existingIds:`, Array.from(existingIds)); + console.log(`🔑 [UPSERT DEBUG] records received:`, records.map((r: any) => ({ id: r[pkColumn], keys: Object.keys(r) }))); + + 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 +1502,21 @@ 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: 새 레코드에 없으면 삭제 + // 3. 고아 레코드 삭제: 기존 레코드 중 이번에 처리되지 않은 것 삭제 + 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, [existingRecord[pkColumn]]); + await pool.query(deleteQuery, [existId]); deleted++; - - console.log(`🗑️ DELETE: ${pkColumn} = ${existingRecord[pkColumn]}`); + console.log(`🗑️ DELETE orphan: ${pkColumn} = ${existId}`); } } diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 27e96050..9e724a3f 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -2,7 +2,6 @@ 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 { @@ -133,9 +132,6 @@ interface ScreenDesignerProps { selectedScreen: ScreenDefinition | null; onBackToList: () => void; onScreenUpdate?: (updatedScreen: Partial) => void; - // POP 모드 지원 - isPop?: boolean; - defaultDevicePreview?: "mobile" | "tablet"; } import { useLayerOptional, LayerProvider, createDefaultLayer } from "@/contexts/LayerContext"; @@ -162,15 +158,7 @@ const panelConfigs: PanelConfig[] = [ }, ]; -export default function ScreenDesigner({ - selectedScreen, - onBackToList, - onScreenUpdate, - isPop = false, - defaultDevicePreview = "tablet" -}: ScreenDesignerProps) { - // POP 모드 여부에 따른 API 분기 - const USE_POP_API = isPop; +export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenUpdate }: ScreenDesignerProps) { const [layout, setLayout] = useState({ components: [], gridSettings: { @@ -512,49 +500,25 @@ export default function ScreenDesigner({ 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; - }, []); + // 🆕 레이어 활성 상태 관리 (LayerProvider 외부에서 관리) + const [activeLayerId, setActiveLayerIdLocal] = useState("default-layer"); - // 🆕 좌측 패널 탭 상태 관리 - 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 기반 레이어: 각 레이어별로 별도 로드되므로 전체 표시) + // 캔버스에 렌더링할 컴포넌트 필터링 (레이어 기반) + // 활성 레이어가 있으면 해당 레이어의 컴포넌트만 표시 + // layerId가 없는 컴포넌트는 기본 레이어("default-layer")에 속한 것으로 처리 const visibleComponents = useMemo(() => { - return layout.components; - }, [layout.components]); + // 레이어 시스템이 활성화되지 않았거나 활성 레이어가 없으면 모든 컴포넌트 표시 + if (!activeLayerId) { + return layout.components; + } + + // 활성 레이어에 속한 컴포넌트만 필터링 + return layout.components.filter((comp) => { + // layerId가 없는 컴포넌트는 기본 레이어("default-layer")에 속한 것으로 처리 + const compLayerId = comp.layerId || "default-layer"; + return compLayerId === activeLayerId; + }); + }, [layout.components, activeLayerId]); // 이미 배치된 컬럼 목록 계산 const placedColumns = useMemo(() => { @@ -1483,15 +1447,9 @@ export default function ScreenDesigner({ console.warn("⚠️ 화면에 할당된 메뉴가 없습니다"); } - // V2/POP API 사용 여부에 따라 분기 + // V2 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 테이블 사용 + if (USE_V2_API) { const v2Response = await screenApi.getLayoutV2(selectedScreen.screenId); // 🐛 디버깅: API 응답에서 fieldMapping.id 확인 @@ -1574,21 +1532,6 @@ export default function ScreenDesigner({ // 파일 컴포넌트 데이터 복원 (비동기) 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); @@ -2026,25 +1969,37 @@ export default function ScreenDesigner({ // 현재 선택된 테이블을 화면의 기본 테이블로 저장 const currentMainTableName = tables.length > 0 ? tables[0].tableName : null; + // 🆕 레이어 정보도 함께 저장 (레이어가 있으면 레이어의 컴포넌트로 업데이트) + const updatedLayers = layout.layers?.map((layer) => ({ + ...layer, + components: layer.components.map((comp) => { + // 분할 패널 업데이트 로직 적용 + const updatedComp = updatedComponents.find((uc) => uc.id === comp.id); + return updatedComp || comp; + }), + })); + const layoutWithResolution = { ...layout, components: updatedComponents, + layers: updatedLayers, // 🆕 레이어 정보 포함 screenResolution: screenResolution, mainTableName: currentMainTableName, // 화면의 기본 테이블 }; + // 🔍 버튼 컴포넌트들의 action.type 확인 + const buttonComponents = layoutWithResolution.components.filter( + (c: any) => c.componentType?.startsWith("button") || c.type === "button" || c.type === "button-primary", + ); + // 💾 저장 로그 (디버그 완료 - 간소화) + // console.log("💾 저장 시작:", { screenId: selectedScreen.screenId, componentsCount: layoutWithResolution.components.length }); + // 분할 패널 디버그 로그 (주석 처리) - // 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, - }); + // V2 API 사용 여부에 따라 분기 + if (USE_V2_API) { + // 🔧 V2 레이아웃 저장 (디버그 로그 주석 처리) + const v2Layout = convertLegacyToV2(layoutWithResolution); + await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout); + // console.log("📦 V2 레이아웃 저장:", v2Layout.components.length, "개 컴포넌트"); } else { await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution); } @@ -2067,18 +2022,6 @@ export default function ScreenDesigner({ } }, [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) { @@ -2157,10 +2100,8 @@ export default function ScreenDesigner({ // 자동 저장 (매핑 정보가 손실되지 않도록) try { - const v2Layout = convertLegacyToV2(updatedLayout); - if (USE_POP_API) { - await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout); - } else if (USE_V2_API) { + if (USE_V2_API) { + const v2Layout = convertLegacyToV2(updatedLayout); await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout); } else { await screenApi.saveLayout(selectedScreen.screenId, updatedLayout); @@ -2580,10 +2521,10 @@ export default function ScreenDesigner({ } }); - // 🆕 현재 활성 레이어에 컴포넌트 추가 (ref 사용으로 클로저 문제 방지) + // 🆕 현재 활성 레이어에 컴포넌트 추가 const componentsWithLayerId = newComponents.map((comp) => ({ ...comp, - layerId: activeLayerIdRef.current || 1, + layerId: activeLayerId || "default-layer", })); // 레이아웃에 새 컴포넌트들 추가 @@ -2602,7 +2543,7 @@ export default function ScreenDesigner({ toast.success(`${template.name} 템플릿이 추가되었습니다.`); }, - [layout, selectedScreen, saveToHistory], + [layout, selectedScreen, saveToHistory, activeLayerId], ); // 레이아웃 드래그 처리 @@ -2656,7 +2597,7 @@ export default function ScreenDesigner({ label: layoutData.label, allowedComponentTypes: layoutData.allowedComponentTypes, dropZoneConfig: layoutData.dropZoneConfig, - layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용) + layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가 } as ComponentData; // 레이아웃에 새 컴포넌트 추가 @@ -2673,7 +2614,7 @@ export default function ScreenDesigner({ toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`); }, - [layout, screenResolution, saveToHistory, zoomLevel], + [layout, screenResolution, saveToHistory, zoomLevel, activeLayerId], ); // handleZoneComponentDrop은 handleComponentDrop으로 대체됨 @@ -3264,7 +3205,7 @@ export default function ScreenDesigner({ position: snappedPosition, size: componentSize, gridColumns: gridColumns, // 컴포넌트별 그리드 컬럼 수 적용 - layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용) + layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가 componentConfig: { type: component.id, // 새 컴포넌트 시스템의 ID 사용 webType: component.webType, // 웹타입 정보 추가 @@ -3298,7 +3239,7 @@ export default function ScreenDesigner({ toast.success(`${component.name} 컴포넌트가 추가되었습니다.`); }, - [layout, selectedScreen, saveToHistory], + [layout, selectedScreen, saveToHistory, activeLayerId], ); // 드래그 앤 드롭 처리 @@ -3307,7 +3248,7 @@ export default function ScreenDesigner({ }, []); const handleDrop = useCallback( - async (e: React.DragEvent) => { + (e: React.DragEvent) => { e.preventDefault(); const dragData = e.dataTransfer.getData("application/json"); @@ -3339,41 +3280,6 @@ export default function ScreenDesigner({ 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; @@ -3705,7 +3611,7 @@ export default function ScreenDesigner({ tableName: table.tableName, position: { x, y, z: 1 } as Position, size: { width: 300, height: 200 }, - layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용) + layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가 style: { labelDisplay: true, labelFontSize: "14px", @@ -3956,7 +3862,7 @@ export default function ScreenDesigner({ 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 사용) + layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가 // 코드 타입인 경우 코드 카테고리 정보 추가 ...(column.widgetType === "code" && column.codeCategory && { @@ -4023,7 +3929,7 @@ export default function ScreenDesigner({ 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 사용) + layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가 // 코드 타입인 경우 코드 카테고리 정보 추가 ...(column.widgetType === "code" && column.codeCategory && { @@ -4846,7 +4752,7 @@ export default function ScreenDesigner({ z: clipComponent.position.z || 1, } as Position, parentId: undefined, // 붙여넣기 시 부모 관계 해제 - layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 붙여넣기 (ref 사용) + layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 붙여넣기 }; newComponents.push(newComponent); }); @@ -4867,7 +4773,7 @@ export default function ScreenDesigner({ // console.log("컴포넌트 붙여넣기 완료:", newComponents.length, "개"); toast.success(`${newComponents.length}개 컴포넌트가 붙여넣어졌습니다.`); - }, [clipboard, layout, saveToHistory]); + }, [clipboard, layout, saveToHistory, activeLayerId]); // 🆕 플로우 버튼 그룹 생성 (다중 선택된 버튼들을 한 번에 그룹으로) // 🆕 플로우 버튼 그룹 다이얼로그 상태 @@ -5571,11 +5477,9 @@ export default function ScreenDesigner({ 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) { + // V2 API 사용 여부에 따라 분기 + if (USE_V2_API) { + const v2Layout = convertLegacyToV2(layoutWithResolution); await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout); } else { await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution); @@ -5769,152 +5673,21 @@ export default function ScreenDesigner({ }; }, [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 등 메타데이터를 보존 + // 주의: layout.components는 layerId 속성으로 레이어를 구분하므로, 여기서 덮어쓰지 않음 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, - }; - }); + setLayout((prevLayout) => ({ + ...prevLayout, + layers: newLayers, + // components는 그대로 유지 - layerId 속성으로 레이어 구분 + // components: prevLayout.components (기본값으로 유지됨) + })); }, []); // 🆕 활성 레이어 변경 핸들러 - const handleActiveLayerChange = useCallback((newActiveLayerId: number) => { - setActiveLayerIdWithRef(newActiveLayerId); - }, [setActiveLayerIdWithRef]); + const handleActiveLayerChange = useCallback((newActiveLayerId: string | null) => { + setActiveLayerIdLocal(newActiveLayerId); + }, []); // 🆕 초기 레이어 계산 - layout에서 layers가 있으면 사용, 없으면 기본 레이어 생성 // 주의: components는 layout.components에 layerId 속성으로 저장되므로, layer.components는 비워둠 @@ -5964,7 +5737,6 @@ export default function ScreenDesigner({ onBack={onBackToList} onSave={handleSave} isSaving={isSaving} - onPreview={isPop ? handlePopPreview : undefined} onResolutionChange={setScreenResolution} gridSettings={layout.gridSettings} onGridSettingsChange={updateGridSettings} @@ -5995,7 +5767,7 @@ export default function ScreenDesigner({
- + 컴포넌트 @@ -6028,41 +5800,9 @@ export default function ScreenDesigner({ /> - {/* 🆕 레이어 관리 탭 (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} - /> + @@ -6635,14 +6375,6 @@ export default function ScreenDesigner({
); })()} - {/* 🆕 활성 레이어 인디케이터 (기본 레이어가 아닌 경우 표시) */} - {activeLayerId > 1 && ( -
-
- 레이어 {activeLayerId} 편집 중 -
- )} - {/* 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬 + contain 최적화 */}
{ - // 영역 이동/리사이즈 처리 - 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"; @@ -6767,79 +6483,6 @@ export default function ScreenDesigner({ 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 = 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/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx index e99fd0e5..034c3b41 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); @@ -103,44 +99,17 @@ export const SelectedItemsDetailInputComponent: React.FC>>({}); - // 디버깅 로그 - 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; } @@ -173,7 +142,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 = {}; + // 수정 모드: 다른 sourceTable의 데이터도 추가 로드 (예: customer_item_mapping) + let mappingData: Record | null = null; - groupFields.forEach((field: any) => { - let fieldValue = record[field.name]; + // URL의 tableName = 이미 데이터가 로드된 테이블. 그 외 sourceTable은 추가 조회 필요 + const editTableName = new URLSearchParams(window.location.search).get("tableName"); + const otherTables = groups + .filter((g) => g.sourceTable && g.sourceTable !== editTableName) + .map((g) => g.sourceTable!) + .filter((v, i, a) => a.indexOf(v) === i); // 중복 제거 - // 🆕 값이 없으면 autoFillFrom 로직 적용 - if ((fieldValue === undefined || fieldValue === null) && field.autoFillFrom) { - let sourceData: any = null; - - 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에서 직접 찾기`, - ); - } - } 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 (otherTables.length > 0 && firstRecord.customer_id && firstRecord.item_id) { + try { + const { dataApi } = await import("@/lib/api/data"); + for (const otherTable of otherTables) { + // getTableData 반환: { data: any[], total, page, size } (success 필드 없음) + const response = await dataApi.getTableData(otherTable, { + filters: { + customer_id: firstRecord.customer_id, + item_id: firstRecord.item_id, + }, + }); + if (response.data && response.data.length > 0) { + mappingData = response.data[0]; } } + } catch (err) { + console.error("❌ 매핑 데이터 로드 실패:", err); + } + } - // 🔧 값이 없으면 기본값 사용 (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; + 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에 따라 데이터 소스 결정 + const editTableName = new URLSearchParams(window.location.search).get("tableName"); + const isOtherTable = group.sourceTable && group.sourceTable !== editTableName; + + if (isOtherTable && mappingData) { + // 다른 테이블 그룹 (예: customer_item_mapping) → mappingData에서 로드 + const entryData: Record = {}; + groupFields.forEach((field: any) => { + let fieldValue = mappingData![field.name]; + + // autoFillFrom 로직 + if ((fieldValue === undefined || fieldValue === null) && field.autoFillFrom) { + fieldValue = firstRecord[field.autoFillFrom] || firstRecord.item_id; } - } - // 🔧 날짜 타입이면 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 형식 유지 (시간 제거) + if (fieldValue !== undefined && fieldValue !== null) { + entryData[field.name] = fieldValue; } - } - - entryData[field.name] = fieldValue; - }); - - // 🔑 모든 필드 값을 합쳐서 고유 키 생성 (중복 제거 기준) - const entryKey = JSON.stringify(entryData); - - if (!entriesMap.has(entryKey)) { - entriesMap.set(entryKey, { - id: `${group.id}_entry_${entriesMap.size + 1}`, - ...entryData, }); + + if (Object.keys(entryData).length > 0) { + mainFieldGroups[group.id] = [{ + id: `${group.id}_entry_1`, + // DB 레코드의 고유 id(UUID PK) 보존 → 수정 시 이 id로 UPDATE + _dbRecordId: mappingData!.id || null, + ...entryData, + }]; + } else { + mainFieldGroups[group.id] = []; + } + } else { + // 현재 테이블 그룹 (예: customer_item_prices) → dataArray에서 로드 + const entriesMap = new Map(); + + dataArray.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]]; + } + } + } + + 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)) { + // DEBUG: record.id 확인 (추후 삭제) + console.log("🔑 [LOAD] record.id:", record.id, "record keys:", Object.keys(record)); + entriesMap.set(entryKey, { + id: `${group.id}_entry_${entriesMap.size + 1}`, + // DB 레코드의 고유 id(UUID PK) 보존 → 수정 시 이 id로 UPDATE + _dbRecordId: record.id || null, + ...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 || []; @@ -443,12 +422,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 의존성 추가 @@ -474,14 +447,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; } - // parentDataMapping이 있으면 UPSERT API로 직접 저장 (생성/수정 모드 무관) + // parentDataMapping이 있으면 UPSERT API로 직접 저장 const hasParentMapping = componentConfig.parentDataMapping && componentConfig.parentDataMapping.length > 0; - console.log("📝 [SelectedItemsDetailInput] 저장 모드:", { hasParentMapping }); - if (hasParentMapping) { - // UPSERT API로 직접 DB 저장 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 = {}; @@ -610,18 +555,6 @@ export const SelectedItemsDetailInputComponent: React.FC { // 1차: formData(sourceData)에서 찾기 let value = getFieldValue(sourceData, mapping.sourceField); @@ -633,33 +566,23 @@ export const SelectedItemsDetailInputComponent: React.FC 0) { const registryItem = registryData[0].originalData || registryData[0]; value = registryItem[mapping.sourceField]; - console.log( - `🔄 [parentKeys] dataRegistry["${mapping.sourceTable}"]에서 찾음: ${mapping.sourceField} =`, - value, - ); } } 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}`, - `(sourceData, dataRegistry["${mapping.sourceTable}"] 모두 확인)`, - ); + 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: "부모 키 값이 비어있어 저장할 수 없습니다. 먼저 상위 데이터를 선택해주세요." }, @@ -669,14 +592,13 @@ export const SelectedItemsDetailInputComponent: React.FC t !== mainTable); const hasDetailTable = detailTables.length > 0; - console.log("🏗️ [SelectedItemsDetailInput] 저장 구조:", { - mainTable, - detailTables, - hasDetailTable, - groupsByTable: Object.fromEntries(groupsByTable), - }); - if (hasDetailTable) { // ============================================================ - // 🆕 2단계 저장: 메인 테이블 + 디테일 테이블 분리 저장 - // 예: customer_item_mapping (매핑) + customer_item_prices (가격) + // 2단계 저장: 메인 테이블 + 디테일 테이블 분리 저장 + // upsertGroupedRecords를 양쪽 모두 사용 (정확한 매칭 보장) // ============================================================ const mainGroups = groupsByTable.get(mainTable) || []; - let totalInserted = 0; - let totalUpdated = 0; for (const item of items) { - // Step 1: 메인 테이블 매핑 레코드 생성/갱신 - const mappingData: Record = { ...parentKeys }; + // item_id 추출: originalData.item_id를 최우선 사용 + // (수정 모드에서 autoFillFrom:"id"가 가격 레코드 PK를 반환하는 문제 방지) + let itemId: string | null = null; - // 메인 그룹 필드 추출 (customer_item_code, customer_item_name 등) + // 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 mappingRecord: Record = {}; mainGroups.forEach((group) => { const entries = item.fieldGroups[group.id] || []; const groupFields = additionalFields.filter((f) => f.groupId === group.id); if (entries.length > 0) { groupFields.forEach((field) => { - if (entries[0][field.name] !== undefined) { - mappingData[field.name] = entries[0][field.name]; + const val = entries[0][field.name]; + // 사용자가 실제 입력한 값만 포함 (빈 문자열, null 제외) + if (val !== undefined && val !== null && val !== "") { + mappingRecord[field.name] = val; } }); + // 기존 DB 레코드의 고유 id(PK)가 있으면 포함 → 백엔드에서 이 id로 UPDATE + if (entries[0]._dbRecordId) { + mappingRecord.id = entries[0]._dbRecordId; + } } // autoFillFrom 필드 처리 (item_id 등) + // 단, item_id는 이미 정확한 itemId 변수를 사용 (autoFillFrom:"id"가 수정 모드에서 오작동 방지) groupFields.forEach((field) => { - if (field.autoFillFrom && item.originalData) { + if (field.name === "item_id") { + // item_id는 위에서 계산된 정확한 itemId 사용 + mappingRecord.item_id = itemId; + } else if (field.autoFillFrom && item.originalData) { const value = item.originalData[field.autoFillFrom]; if (value !== undefined && value !== null) { - mappingData[field.name] = value; + mappingRecord[field.name] = value; } } }); }); - console.log("📋 [2단계 저장] Step 1 - 매핑 데이터:", mappingData); - - // 기존 매핑 레코드 찾기 - let mappingId: string | null = null; - const searchFilters: Record = {}; - - // parentKeys + item_id로 검색 - Object.entries(parentKeys).forEach(([key, value]) => { - searchFilters[key] = value; - }); - if (mappingData.item_id) { - searchFilters.item_id = mappingData.item_id; - } - try { - const searchResult = await dataApi.getTableData(mainTable, { - filters: searchFilters, - size: 1, - }); - - if (searchResult.data && searchResult.data.length > 0) { - // 기존 매핑 업데이트 - mappingId = searchResult.data[0].id; - console.log("📌 [2단계 저장] 기존 매핑 발견:", mappingId); - await dataApi.updateRecord(mainTable, mappingId, mappingData); - totalUpdated++; - } else { - // 새 매핑 생성 - const createResult = await dataApi.createRecord(mainTable, mappingData); - if (createResult.success && createResult.data) { - mappingId = createResult.data.id; - console.log("✨ [2단계 저장] 새 매핑 생성:", mappingId); - totalInserted++; - } - } + const mappingResult = await dataApi.upsertGroupedRecords( + mainTable, + itemParentKeys, + [mappingRecord], + ); } catch (err) { - console.error("❌ [2단계 저장] 매핑 저장 실패:", err); - continue; + console.error(`❌ ${mainTable} 저장 실패:`, err); } - if (!mappingId) { - console.error("❌ [2단계 저장] mapping_id 획득 실패 - item:", mappingData.item_id); - continue; - } - - // Step 2: 디테일 테이블에 가격 레코드 저장 + // === Step 2: 디테일 테이블(customer_item_prices) 저장 === for (const detailTable of detailTables) { const detailGroups = groupsByTable.get(detailTable) || []; const priceRecords: Record[] = []; @@ -812,58 +732,69 @@ export const SelectedItemsDetailInputComponent: React.FC f.groupId === group.id); entries.forEach((entry) => { - // 실제 값이 있는 엔트리만 저장 - const hasValues = groupFields.some((field) => { + // 사용자가 실제 입력한 값이 있는지 확인 + // select/category 필드는 항상 기본값이 있으므로 제외하고 판별 + const hasUserInput = groupFields.some((field) => { + // 셀렉트/카테고리 필드는 기본값이 자동 설정되므로 무시 + if (field.type === "select" || field.inputType === "code" || field.inputType === "category") { + return false; + } const value = entry[field.name]; - return value !== undefined && value !== null && value !== ""; + if (value === undefined || value === null || value === "") return false; + if (value === 0 || value === "0" || value === "0.00") return false; + return true; }); - if (hasValues) { - const priceRecord: Record = { - mapping_id: mappingId, - // 비정규화: 직접 필터링을 위해 customer_id, item_id 포함 - ...parentKeys, - item_id: mappingData.item_id, - }; + if (hasUserInput) { + const priceRecord: Record = {}; groupFields.forEach((field) => { - if (entry[field.name] !== undefined) { - priceRecord[field.name] = entry[field.name]; + 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; + } + // DEBUG: id 전달 확인용 (추후 삭제) + console.log("🔑 [SAVE] entry._dbRecordId:", entry._dbRecordId, "→ priceRecord.id:", priceRecord.id, "entry keys:", Object.keys(entry)); priceRecords.push(priceRecord); } }); }); - if (priceRecords.length > 0) { - console.log(`📋 [2단계 저장] Step 2 - ${detailTable} 레코드:`, { - mappingId, - count: priceRecords.length, - records: priceRecords, + // 빈 항목이라도 최소 레코드 생성 (우측 패널에 표시되도록) + 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); + } + try { const detailResult = await dataApi.upsertGroupedRecords( detailTable, - { mapping_id: mappingId }, + itemParentKeys, priceRecords, ); - if (detailResult.success) { - console.log(`✅ [2단계 저장] ${detailTable} 저장 성공:`, detailResult); - } else { - console.error(`❌ [2단계 저장] ${detailTable} 저장 실패:`, detailResult.error); + if (!detailResult.success) { + console.error(`❌ ${detailTable} 저장 실패:`, detailResult.error); } - } else { - console.log(`⏭️ [2단계 저장] ${detailTable} - 가격 레코드 없음 (빈 항목)`); + } catch (err) { + console.error(`❌ ${detailTable} 오류:`, err); } } } - console.log("✅ [SelectedItemsDetailInput] 2단계 저장 완료:", { - inserted: totalInserted, - updated: totalUpdated, - }); - // 저장 성공 이벤트 window.dispatchEvent( new CustomEvent("formSaveSuccess", { @@ -876,28 +807,15 @@ 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; @@ -1092,13 +987,7 @@ 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 필드도 찾기 @@ -1198,9 +1066,6 @@ 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; }, []); @@ -2074,12 +1935,6 @@ export const SelectedItemsDetailInputComponent: React.FC { - console.log("🎨 [renderGridLayout] 렌더링:", { - itemsLength: items.length, - displayColumns: componentConfig.displayColumns, - firstItemOriginalData: items[0]?.originalData, - }); - return (
{items.map((item, index) => { @@ -2093,15 +1948,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 ( @@ -2156,15 +2002,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; } @@ -2499,14 +2337,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 모드: 추가 버튼 */} @@ -2525,15 +2355,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; } @@ -2766,12 +2588,6 @@ export const SelectedItemsDetailInputComponent: React.FC {/* 레이아웃에 따라 렌더링 */} From d7f900d8aec118baca5e022b6da34cf328115ea7 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Mon, 9 Feb 2026 15:37:28 +0900 Subject: [PATCH 6/7] refactor: Remove debug logs and optimize toast animations - Removed debug console logs from the UPSERT process in the DataService to clean up the code. - Disabled animations for Sonner toast notifications to enhance performance and user experience. - Simplified the alert and dialog components by removing unnecessary animation classes, ensuring a smoother transition. - Updated the SelectedItemsDetailInputComponent to load all related table data in edit mode, improving data management and consistency. --- backend-node/src/routes/dataRoutes.ts | 5 +- backend-node/src/services/dataService.ts | 8 +- frontend/app/globals.css | 14 +++ frontend/components/screen/ScreenDesigner.tsx | 15 +-- frontend/components/ui/alert-dialog.tsx | 4 +- frontend/components/ui/dialog.tsx | 4 +- frontend/components/ui/dropdown-menu.tsx | 4 +- frontend/components/ui/popover.tsx | 2 +- frontend/components/ui/sheet.tsx | 14 +-- frontend/lib/api/data.ts | 4 +- .../SelectedItemsDetailInputComponent.tsx | 114 +++++++----------- 11 files changed, 80 insertions(+), 108 deletions(-) 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 ff3b502a..2c7e7865 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 }> > { @@ -1422,11 +1423,6 @@ class DataService { const existingIds = new Set(existingRecords.rows.map((r: any) => r[pkColumn])); const processedIds = new Set(); // UPDATE 처리된 id 추적 - // DEBUG: 수신된 레코드와 기존 레코드 id 확인 - console.log(`🔑 [UPSERT DEBUG] pkColumn: ${pkColumn}`); - console.log(`🔑 [UPSERT DEBUG] existingIds:`, Array.from(existingIds)); - console.log(`🔑 [UPSERT DEBUG] records received:`, records.map((r: any) => ({ id: r[pkColumn], keys: Object.keys(r) }))); - for (const newRecord of records) { // 날짜 필드 정규화 const normalizedRecord: Record = {}; 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/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 442b51fd..a530c024 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useCallback, useEffect, useMemo, useRef } from "react"; +import { cn } from "@/lib/utils"; import { Database, Cog } from "lucide-react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; @@ -6389,19 +6390,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU {activeLayerId > 1 && (
- - 레이어 {activeLayerId} 편집 중 - {layerRegions[activeLayerId] && ( - - (캔버스: {layerRegions[activeLayerId].width} x {layerRegions[activeLayerId].height}px) - - )} - {!layerRegions[activeLayerId] && ( - - (조건부 영역 미설정 - 기본 레이어에서 영역을 먼저 배치하세요) - - )} - + 레이어 {activeLayerId} 편집 중
)} 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 034c3b41..3a2b83f9 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx @@ -223,33 +223,34 @@ export const SelectedItemsDetailInputComponent: React.FC | null = null; - - // URL의 tableName = 이미 데이터가 로드된 테이블. 그 외 sourceTable은 추가 조회 필요 + // 수정 모드: 모든 관련 테이블의 데이터를 API로 전체 로드 + // sourceData는 클릭한 1개 레코드만 포함할 수 있으므로, API로 전체를 다시 가져옴 const editTableName = new URLSearchParams(window.location.search).get("tableName"); - const otherTables = groups - .filter((g) => g.sourceTable && g.sourceTable !== editTableName) - .map((g) => g.sourceTable!) - .filter((v, i, a) => a.indexOf(v) === i); // 중복 제거 + const allTableData: Record[]> = {}; - if (otherTables.length > 0 && firstRecord.customer_id && firstRecord.item_id) { + if (firstRecord.customer_id && firstRecord.item_id) { try { const { dataApi } = await import("@/lib/api/data"); - for (const otherTable of otherTables) { - // getTableData 반환: { data: any[], total, page, size } (success 필드 없음) - const response = await dataApi.getTableData(otherTable, { + // 모든 sourceTable의 데이터를 API로 전체 로드 (중복 테이블 제거) + const allTables = groups + .map((g) => g.sourceTable || editTableName) + .filter((v, i, a) => v && a.indexOf(v) === i) as string[]; + + 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) { - mappingData = response.data[0]; + allTableData[table] = response.data; } } } catch (err) { - console.error("❌ 매핑 데이터 로드 실패:", err); + console.error("❌ 편집 데이터 전체 로드 실패:", err); } } @@ -263,41 +264,17 @@ export const SelectedItemsDetailInputComponent: React.FC = {}; - groupFields.forEach((field: any) => { - let fieldValue = mappingData![field.name]; - - // autoFillFrom 로직 - if ((fieldValue === undefined || fieldValue === null) && field.autoFillFrom) { - fieldValue = firstRecord[field.autoFillFrom] || firstRecord.item_id; - } - - if (fieldValue !== undefined && fieldValue !== null) { - entryData[field.name] = fieldValue; - } - }); - - if (Object.keys(entryData).length > 0) { - mainFieldGroups[group.id] = [{ - id: `${group.id}_entry_1`, - // DB 레코드의 고유 id(UUID PK) 보존 → 수정 시 이 id로 UPDATE - _dbRecordId: mappingData!.id || null, - ...entryData, - }]; - } else { - mainFieldGroups[group.id] = []; - } - } else { - // 현재 테이블 그룹 (예: customer_item_prices) → dataArray에서 로드 + { + // 모든 테이블 그룹: API에서 가져온 전체 레코드를 entry로 변환 const entriesMap = new Map(); - dataArray.forEach((record) => { + groupDataList.forEach((record) => { const entryData: Record = {}; groupFields.forEach((field: any) => { @@ -355,8 +332,6 @@ export const SelectedItemsDetailInputComponent: React.FC = {}; + // 여러 개의 매핑 레코드 지원 (거래처 품번/품명이 다중일 수 있음) + const mappingRecords: Record[] = []; mainGroups.forEach((group) => { const entries = item.fieldGroups[group.id] || []; const groupFields = additionalFields.filter((f) => f.groupId === group.id); - if (entries.length > 0) { + entries.forEach((entry) => { + const record: Record = {}; groupFields.forEach((field) => { - const val = entries[0][field.name]; - // 사용자가 실제 입력한 값만 포함 (빈 문자열, null 제외) + const val = entry[field.name]; if (val !== undefined && val !== null && val !== "") { - mappingRecord[field.name] = val; + record[field.name] = val; } }); // 기존 DB 레코드의 고유 id(PK)가 있으면 포함 → 백엔드에서 이 id로 UPDATE - if (entries[0]._dbRecordId) { - mappingRecord.id = entries[0]._dbRecordId; + if (entry._dbRecordId) { + record.id = entry._dbRecordId; } - } - - // autoFillFrom 필드 처리 (item_id 등) - // 단, item_id는 이미 정확한 itemId 변수를 사용 (autoFillFrom:"id"가 수정 모드에서 오작동 방지) - groupFields.forEach((field) => { - if (field.name === "item_id") { - // item_id는 위에서 계산된 정확한 itemId 사용 - mappingRecord.item_id = itemId; - } else if (field.autoFillFrom && item.originalData) { - const value = item.originalData[field.autoFillFrom]; - if (value !== undefined && value !== null) { - mappingRecord[field.name] = value; + // 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); }); }); @@ -716,7 +690,7 @@ export const SelectedItemsDetailInputComponent: React.FC Date: Mon, 9 Feb 2026 15:50:53 +0900 Subject: [PATCH 7/7] feat: Implement orphan record deletion logic based on edit mode - Updated the DataService to conditionally delete orphan records only when in EDIT mode, controlled by the deleteOrphans flag. - Enhanced the SelectedItemsDetailInputComponent to determine the mode (EDIT or CREATE) based on the presence of existing database IDs, ensuring that orphan records are only deleted when necessary. - Improved data integrity by preventing unintended deletions during the CREATE process. --- backend-node/src/services/dataService.ts | 19 +- frontend/components/layout/AppLayout.tsx | 18 +- frontend/components/screen/ScreenDesigner.tsx | 7141 +---------------- .../SelectedItemsDetailInputComponent.tsx | 10 +- 4 files changed, 30 insertions(+), 7158 deletions(-) diff --git a/backend-node/src/services/dataService.ts b/backend-node/src/services/dataService.ts index 2c7e7865..8c837697 100644 --- a/backend-node/src/services/dataService.ts +++ b/backend-node/src/services/dataService.ts @@ -1505,14 +1505,17 @@ class DataService { } } - // 3. 고아 레코드 삭제: 기존 레코드 중 이번에 처리되지 않은 것 삭제 - 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}`); + // 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/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 a530c024..5f62ade4 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -1,7140 +1 @@ -"use client"; - -import { useState, useCallback, useEffect, useMemo, useRef } from "react"; -import { cn } from "@/lib/utils"; -import { Database, Cog } from "lucide-react"; -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; -} - -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 }: ScreenDesignerProps) { - 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]); - - // 🆕 레이어 활성 상태 관리 (LayerProvider 외부에서 관리) - const [activeLayerId, setActiveLayerIdLocal] = useState("default-layer"); - - // 캔버스에 렌더링할 컴포넌트 필터링 (레이어 기반) - // 활성 레이어가 있으면 해당 레이어의 컴포넌트만 표시 - // layerId가 없는 컴포넌트는 기본 레이어("default-layer")에 속한 것으로 처리 - const visibleComponents = useMemo(() => { - // 레이어 시스템이 활성화되지 않았거나 활성 레이어가 없으면 모든 컴포넌트 표시 - if (!activeLayerId) { - return layout.components; - } - - // 활성 레이어에 속한 컴포넌트만 필터링 - return layout.components.filter((comp) => { - // layerId가 없는 컴포넌트는 기본 레이어("default-layer")에 속한 것으로 처리 - const compLayerId = comp.layerId || "default-layer"; - return compLayerId === activeLayerId; - }); - }, [layout.components, activeLayerId]); - - // 이미 배치된 컬럼 목록 계산 - 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 API 사용 여부에 따라 분기 - let response: any; - if (USE_V2_API) { - 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); - } - } 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 selectedIds = groupState.selectedComponents; - const isPartial = selectedIds.length > 0; - - // 토글 대상 컴포넌트 필터 - const targetComponents = layout.components.filter((c) => { - if (!c.label || ["group", "datatable"].includes(c.type)) return false; - if (isPartial) return selectedIds.includes(c.id); - return true; - }); - - const hadHidden = targetComponents.some( - (c) => (c.style as any)?.labelDisplay === false - ); - - const newComponents = toggleAllLabels(layout.components, selectedIds); - setLayout((prev) => ({ ...prev, components: newComponents })); - - // 강제 리렌더링 트리거 - setForceRenderTrigger((prev) => prev + 1); - - const scope = isPartial ? `선택된 ${targetComponents.length}개` : "모든"; - toast.success(hadHidden ? `${scope} 라벨 표시` : `${scope} 라벨 숨기기`); - }, [layout, saveToHistory, groupState.selectedComponents]); - - // 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 updatedLayers = layout.layers?.map((layer) => ({ - ...layer, - components: layer.components.map((comp) => { - // 분할 패널 업데이트 로직 적용 - const updatedComp = updatedComponents.find((uc) => uc.id === comp.id); - return updatedComp || comp; - }), - })); - - const layoutWithResolution = { - ...layout, - components: updatedComponents, - layers: updatedLayers, // 🆕 레이어 정보 포함 - screenResolution: screenResolution, - mainTableName: currentMainTableName, // 화면의 기본 테이블 - }; - // 🔍 버튼 컴포넌트들의 action.type 확인 - const buttonComponents = layoutWithResolution.components.filter( - (c: any) => c.componentType?.startsWith("button") || c.type === "button" || c.type === "button-primary", - ); - // 💾 저장 로그 (디버그 완료 - 간소화) - // console.log("💾 저장 시작:", { screenId: selectedScreen.screenId, componentsCount: layoutWithResolution.components.length }); - // 분할 패널 디버그 로그 (주석 처리) - - // V2 API 사용 여부에 따라 분기 - if (USE_V2_API) { - // 🔧 V2 레이아웃 저장 (디버그 로그 주석 처리) - const v2Layout = convertLegacyToV2(layoutWithResolution); - await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout); - // console.log("📦 V2 레이아웃 저장:", v2Layout.components.length, "개 컴포넌트"); - } 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]); - - // 다국어 자동 생성 핸들러 - 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 { - if (USE_V2_API) { - const v2Layout = convertLegacyToV2(updatedLayout); - 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; - } - }); - - // 🆕 현재 활성 레이어에 컴포넌트 추가 - const componentsWithLayerId = newComponents.map((comp) => ({ - ...comp, - layerId: activeLayerId || "default-layer", - })); - - // 레이아웃에 새 컴포넌트들 추가 - 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, activeLayerId], - ); - - // 레이아웃 드래그 처리 - 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: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가 - } as ComponentData; - - // 레이아웃에 새 컴포넌트 추가 - const newLayout = { - ...layout, - components: [...layout.components, newLayoutComponent], - }; - - setLayout(newLayout); - saveToHistory(newLayout); - - // 레이아웃 컴포넌트 선택 - setSelectedComponent(newLayoutComponent); - - toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`); - }, - [layout, screenResolution, saveToHistory, zoomLevel, activeLayerId], - ); - - // 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: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가 - 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, activeLayerId], - ); - - // 드래그 앤 드롭 처리 - const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); - }, []); - - const handleDrop = useCallback( - (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; - } - - // 기존 테이블/컬럼 드래그 처리 - 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: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가 - 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: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가 - // 코드 타입인 경우 코드 카테고리 정보 추가 - ...(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: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가 - // 코드 타입인 경우 코드 카테고리 정보 추가 - ...(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: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 붙여넣기 - }; - 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, activeLayerId]); - - // 🆕 플로우 버튼 그룹 생성 (다중 선택된 버튼들을 한 번에 그룹으로) - // 🆕 플로우 버튼 그룹 다이얼로그 상태 - 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 API 사용 여부에 따라 분기 - if (USE_V2_API) { - const v2Layout = convertLegacyToV2(layoutWithResolution); - 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]); - - // 🆕 레이어 변경 핸들러 - 레이어 컨텍스트에서 레이어가 변경되면 layout에도 반영 - // 주의: layout.components는 layerId 속성으로 레이어를 구분하므로, 여기서 덮어쓰지 않음 - const handleLayersChange = useCallback((newLayers: LayerDefinition[]) => { - setLayout((prevLayout) => ({ - ...prevLayout, - layers: newLayers, - // components는 그대로 유지 - layerId 속성으로 레이어 구분 - // components: prevLayout.components (기본값으로 유지됨) - })); - }, []); - - // 🆕 활성 레이어 변경 핸들러 - const handleActiveLayerChange = useCallback((newActiveLayerId: string | null) => { - setActiveLayerIdLocal(newActiveLayerId); - }, []); - - // 🆕 초기 레이어 계산 - 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} - /> - - - {/* 🆕 레이어 관리 탭 */} - - - - - - {/* 탭 내부 컴포넌트 선택 시에도 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} 편집 중 -
- )} - - {/* 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬 + 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); - } - }} - 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 ( - <> - {/* 일반 컴포넌트들 */} - {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/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx index 3a2b83f9..e73a78c6 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx @@ -686,11 +686,15 @@ export const SelectedItemsDetailInputComponent: React.FC !!r.id); try { const mappingResult = await dataApi.upsertGroupedRecords( mainTable, itemParentKeys, mappingRecords, + { deleteOrphans: mappingHasDbIds }, ); } catch (err) { console.error(`❌ ${mainTable} 저장 실패:`, err); @@ -751,11 +755,13 @@ export const SelectedItemsDetailInputComponent: React.FC !!r.id); try { const detailResult = await dataApi.upsertGroupedRecords( detailTable, itemParentKeys, priceRecords, + { deleteOrphans: priceHasDbIds }, ); if (!detailResult.success) { @@ -767,12 +773,14 @@ export const SelectedItemsDetailInputComponent: React.FC