From 08dde416b1ca3281e0f5d57dccc3e4875b6205a4 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Fri, 6 Feb 2026 16:00:43 +0900 Subject: [PATCH 01/44] 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 02/44] 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 03/44] 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 04/44] 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 7dc0bbb329d2f6233534c8a726e49712510a1c22 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 9 Feb 2026 15:02:53 +0900 Subject: [PATCH 05/44] =?UTF-8?q?feat:=20=EC=A1=B0=EA=B1=B4=EB=B6=80=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=96=B4=20=EA=B4=80=EB=A6=AC=20=EB=B0=8F=20?= =?UTF-8?q?=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4=EC=85=98=20=EC=B5=9C?= =?UTF-8?q?=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 레이어 저장 로직을 개선하여 conditionConfig의 명시적 전달 여부에 따라 저장 방식을 다르게 처리하도록 변경했습니다. - 조건부 레이어 로드 및 조건 평가 기능을 추가하여 레이어의 가시성을 동적으로 조정할 수 있도록 했습니다. - 컴포넌트 위치 변경 시 모든 애니메이션을 제거하여 사용자 경험을 개선했습니다. - LayerConditionPanel에서 조건 설정 시 기존 displayRegion을 보존하도록 업데이트했습니다. - RealtimePreview 및 ScreenDesigner에서 조건부 레이어의 크기를 적절히 조정하도록 수정했습니다. --- .../src/services/screenManagementService.ts | 32 ++- .../app/(main)/screens/[screenId]/page.tsx | 221 +++++++++++++++++- frontend/app/globals.css | 10 +- .../components/screen/LayerConditionPanel.tsx | 93 ++++++-- .../components/screen/LayerManagerPanel.tsx | 18 +- .../components/screen/RealtimePreview.tsx | 7 +- frontend/components/screen/ScreenDesigner.tsx | 65 ++++-- .../ConditionalSectionViewer.tsx | 2 +- 8 files changed, 389 insertions(+), 59 deletions(-) diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 244f2b2a..551fba91 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -5158,11 +5158,14 @@ export class ScreenManagementService { ): Promise { const layerId = layoutData.layerId || 1; const layerName = layoutData.layerName || (layerId === 1 ? '기본 레이어' : `레이어 ${layerId}`); + // conditionConfig가 명시적으로 전달되었는지 확인 (undefined = 미전달, null/object = 명시적 전달) + const hasConditionConfig = 'conditionConfig' in layoutData; const conditionConfig = layoutData.conditionConfig || null; console.log(`=== V2 레이아웃 저장 시작 ===`); console.log(`화면 ID: ${screenId}, 회사: ${companyCode}, 레이어: ${layerId} (${layerName})`); console.log(`컴포넌트 수: ${layoutData.components?.length || 0}`); + console.log(`조건 설정 포함 여부: ${hasConditionConfig}`); // 권한 확인 const screens = await query<{ company_code: string | null }>( @@ -5187,16 +5190,27 @@ export class ScreenManagementService { ...pureLayoutData, }; - // UPSERT (레이어별 저장) - await query( - `INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, condition_config, layout_data, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW()) - ON CONFLICT (screen_id, company_code, layer_id) - DO UPDATE SET layout_data = $6, layer_name = $4, condition_config = $5, updated_at = NOW()`, - [screenId, companyCode, layerId, layerName, conditionConfig ? JSON.stringify(conditionConfig) : null, JSON.stringify(dataToSave)], - ); + if (hasConditionConfig) { + // conditionConfig가 명시적으로 전달된 경우: condition_config도 함께 저장 + await query( + `INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, condition_config, layout_data, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW()) + ON CONFLICT (screen_id, company_code, layer_id) + DO UPDATE SET layout_data = $6, layer_name = $4, condition_config = $5, updated_at = NOW()`, + [screenId, companyCode, layerId, layerName, conditionConfig ? JSON.stringify(conditionConfig) : null, JSON.stringify(dataToSave)], + ); + } else { + // conditionConfig가 전달되지 않은 경우: 기존 condition_config 유지, layout_data만 업데이트 + await query( + `INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, NOW(), NOW()) + ON CONFLICT (screen_id, company_code, layer_id) + DO UPDATE SET layout_data = $5, layer_name = $4, updated_at = NOW()`, + [screenId, companyCode, layerId, layerName, JSON.stringify(dataToSave)], + ); + } - console.log(`V2 레이아웃 저장 완료 (레이어 ${layerId})`); + console.log(`V2 레이아웃 저장 완료 (레이어 ${layerId}, 조건설정 ${hasConditionConfig ? '포함' : '유지'})`); } /** diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 9f043adf..1b13e29a 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { Loader2 } from "lucide-react"; import { screenApi } from "@/lib/api/screen"; import { ScreenDefinition, LayoutData, ComponentData } from "@/types/screen"; +import { LayerDefinition } from "@/types/screen-management"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { initializeComponents } from "@/lib/registry/components"; @@ -86,6 +87,9 @@ function ScreenViewPage() { // 🆕 조건부 컨테이너 높이 추적 (컴포넌트 ID → 높이) const [conditionalContainerHeights, setConditionalContainerHeights] = useState>({}); + // 🆕 레이어 시스템 지원 + const [conditionalLayers, setConditionalLayers] = useState([]); + // 편집 모달 상태 const [editModalOpen, setEditModalOpen] = useState(false); const [editModalConfig, setEditModalConfig] = useState<{ @@ -204,6 +208,131 @@ function ScreenViewPage() { } }, [screenId]); + // 🆕 조건부 레이어 로드 (기본 레이어 외 모든 레이어 로드) + useEffect(() => { + const loadConditionalLayers = async () => { + if (!screenId || !layout) return; + + try { + // 1. 모든 레이어 목록 조회 + const allLayers = await screenApi.getScreenLayers(screenId); + // layer_id > 1인 레이어만 (기본 레이어 제외) + const nonBaseLayers = allLayers.filter((l: any) => l.layer_id > 1); + + if (nonBaseLayers.length === 0) { + setConditionalLayers([]); + return; + } + + // 2. 각 레이어의 레이아웃 데이터 로드 + const layerDefinitions: LayerDefinition[] = []; + + for (const layerInfo of nonBaseLayers) { + try { + const layerData = await screenApi.getLayerLayout(screenId, layerInfo.layer_id); + const condConfig = layerInfo.condition_config || layerData?.conditionConfig || {}; + + // 레이어 컴포넌트 변환 (V2 → Legacy) + // getLayerLayout 응답: { ...layout_data, layerId, layerName, conditionConfig } + // layout_data가 spread 되므로 components는 최상위에 있음 + let layerComponents: any[] = []; + const rawComponents = layerData?.components; + if (rawComponents && Array.isArray(rawComponents) && rawComponents.length > 0) { + // V2 컴포넌트를 Legacy 형식으로 변환 + const tempV2 = { + version: "2.0" as const, + components: rawComponents, + gridSettings: layerData.gridSettings, + screenResolution: layerData.screenResolution, + }; + if (isValidV2Layout(tempV2)) { + const converted = convertV2ToLegacy(tempV2); + if (converted) { + layerComponents = converted.components || []; + } + } + } + + // LayerDefinition 생성 + const layerDef: LayerDefinition = { + id: String(layerInfo.layer_id), + name: layerInfo.layer_name || `레이어 ${layerInfo.layer_id}`, + type: "conditional", + zIndex: layerInfo.layer_id * 10, + isVisible: false, // 조건 충족 시에만 표시 + isLocked: false, + condition: condConfig.targetComponentId ? { + targetComponentId: condConfig.targetComponentId, + operator: condConfig.operator || "eq", + value: condConfig.value, + } : undefined, + displayRegion: condConfig.displayRegion || undefined, + components: layerComponents, + }; + + layerDefinitions.push(layerDef); + } catch (layerError) { + console.warn(`레이어 ${layerInfo.layer_id} 로드 실패:`, layerError); + } + } + + console.log("🔄 조건부 레이어 로드 완료:", layerDefinitions.length, "개", layerDefinitions.map(l => ({ + id: l.id, name: l.name, condition: l.condition, displayRegion: l.displayRegion, + componentCount: l.components.length, + }))); + setConditionalLayers(layerDefinitions); + } catch (error) { + console.error("레이어 로드 실패:", error); + } + }; + + loadConditionalLayers(); + }, [screenId, layout]); + + // 🆕 조건부 레이어 조건 평가 (formData 변경 시 동기적으로 즉시 계산) + const activeLayerIds = useMemo(() => { + if (conditionalLayers.length === 0 || !layout) return [] as string[]; + + const allComponents = layout.components || []; + const newActiveIds: string[] = []; + + conditionalLayers.forEach((layer) => { + if (layer.condition) { + const { targetComponentId, operator, value } = layer.condition; + + // 트리거 컴포넌트 찾기 (기본 레이어에서) + const targetComponent = allComponents.find((c) => c.id === targetComponentId); + + // columnName으로 formData에서 값 조회 + const fieldKey = + (targetComponent as any)?.columnName || + (targetComponent as any)?.componentConfig?.columnName || + targetComponentId; + + const targetValue = formData[fieldKey]; + + let isMatch = false; + switch (operator) { + case "eq": + isMatch = targetValue == value; + break; + case "neq": + isMatch = targetValue != value; + break; + case "in": + isMatch = Array.isArray(value) && value.includes(targetValue); + break; + } + + if (isMatch) { + newActiveIds.push(layer.id); + } + } + }); + + return newActiveIds; + }, [formData, conditionalLayers, layout]); + // 🆕 메인 테이블 데이터 자동 로드 (단일 레코드 폼) // 화면의 메인 테이블에서 사용자 회사 코드로 데이터를 조회하여 폼에 자동 채움 useEffect(() => { @@ -513,6 +642,7 @@ function ScreenViewPage() { {layoutReady && layout && layout.components.length > 0 ? (
0) { + // 🆕 조건부 레이어 displayRegion 기반 높이 조정 + // 기본 레이어 컴포넌트는 displayRegion 포함한 위치에 배치되므로, + // 비활성(빈 영역) 시 아래 컴포넌트를 위로 당겨 빈 공간 제거 + for (const layer of conditionalLayers) { + if (!layer.displayRegion) continue; + const region = layer.displayRegion; + const regionBottom = region.y + region.height; + const isActive = activeLayerIds.includes(layer.id); + + // 컴포넌트가 조건부 영역 하단보다 아래에 있는 경우 + if (component.position.y >= regionBottom) { + if (!isActive) { + // 비활성: 영역 높이만큼 위로 당김 (빈 공간 제거) + totalHeightAdjustment -= region.height; + } + } + } + + if (totalHeightAdjustment !== 0) { return { ...component, position: { @@ -950,6 +1098,77 @@ function ScreenViewPage() {
); })} + + {/* 🆕 조건부 레이어 컴포넌트 렌더링 */} + {conditionalLayers.map((layer) => { + const isActive = activeLayerIds.includes(layer.id); + if (!isActive || !layer.components || layer.components.length === 0) return null; + + const region = layer.displayRegion; + + return ( +
+ {layer.components + .filter((comp) => !comp.parentId) + .map((comp) => ( + {}} + menuObjid={menuObjid} + screenId={screenId} + tableName={screen?.tableName} + userId={user?.userId} + userName={userName} + companyCode={companyCode} + selectedRowsData={selectedRowsData} + sortBy={tableSortBy} + sortOrder={tableSortOrder} + columnOrder={tableColumnOrder} + tableDisplayData={tableDisplayData} + onSelectedRowsChange={( + _, + selectedData, + sortBy, + sortOrder, + columnOrder, + tableDisplayData, + ) => { + setSelectedRowsData(selectedData); + setTableSortBy(sortBy); + setTableSortOrder(sortOrder || "asc"); + setTableColumnOrder(columnOrder); + setTableDisplayData(tableDisplayData || []); + }} + refreshKey={tableRefreshKey} + onRefresh={() => { + setTableRefreshKey((prev) => prev + 1); + setSelectedRowsData([]); + }} + formData={formData} + onFormDataChange={(fieldName, value) => { + setFormData((prev) => ({ ...prev, [fieldName]: value })); + }} + /> + ))} +
+ ); + })} ); })()} diff --git a/frontend/app/globals.css b/frontend/app/globals.css index a252eaff..b8e7a178 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -263,12 +263,20 @@ input, textarea, select { transition-property: - color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, + color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, filter, backdrop-filter; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; } +/* 런타임 화면에서 컴포넌트 위치 변경 시 모든 애니메이션/트랜지션 완전 제거 */ +[data-screen-runtime] [id^="component-"] { + transition: none !important; +} +[data-screen-runtime] [data-conditional-layer] { + transition: none !important; +} + /* Disable animations for users who prefer reduced motion */ @media (prefers-reduced-motion: reduce) { *, diff --git a/frontend/components/screen/LayerConditionPanel.tsx b/frontend/components/screen/LayerConditionPanel.tsx index e74cc4a0..6a640ffc 100644 --- a/frontend/components/screen/LayerConditionPanel.tsx +++ b/frontend/components/screen/LayerConditionPanel.tsx @@ -81,16 +81,18 @@ export const LayerConditionPanel: React.FC = ({ const isTriggerComponent = (comp: ComponentData): boolean => { const componentType = (comp.componentType || "").toLowerCase(); const widgetType = ((comp as any).widgetType || "").toLowerCase(); - const webType = ((comp as any).webType || "").toLowerCase(); - const inputType = ((comp as any).componentConfig?.inputType || "").toLowerCase(); + const webType = ((comp as any).webType || comp.componentConfig?.webType || "").toLowerCase(); + const inputType = ((comp as any).inputType || comp.componentConfig?.inputType || "").toLowerCase(); + const source = ((comp as any).source || comp.componentConfig?.source || "").toLowerCase(); - // 셀렉트, 라디오, 코드 타입 컴포넌트만 허용 - const triggerTypes = ["select", "radio", "code", "checkbox", "toggle", "entity"]; + // 셀렉트, 라디오, 코드, 카테고리, 엔티티 타입 컴포넌트 허용 + const triggerTypes = ["select", "radio", "code", "checkbox", "toggle", "entity", "category"]; return triggerTypes.some((type) => componentType.includes(type) || widgetType.includes(type) || webType.includes(type) || - inputType.includes(type) + inputType.includes(type) || + source.includes(type) ); }; @@ -112,9 +114,21 @@ export const LayerConditionPanel: React.FC = ({ }, [components, baseLayerComponents]); // 선택된 컴포넌트 정보 + // 기본 레이어 + 현재 레이어 통합 컴포넌트 목록 (트리거 컴포넌트 검색용) + const allAvailableComponents = useMemo(() => { + const merged = [...(baseLayerComponents || []), ...components]; + // 중복 제거 (id 기준) + const seen = new Set(); + return merged.filter((c) => { + if (seen.has(c.id)) return false; + seen.add(c.id); + return true; + }); + }, [components, baseLayerComponents]); + const selectedComponent = useMemo(() => { - return components.find((c) => c.id === targetComponentId); - }, [components, targetComponentId]); + return allAvailableComponents.find((c) => c.id === targetComponentId); + }, [allAvailableComponents, targetComponentId]); // 선택된 컴포넌트의 데이터 소스 정보 추출 const dataSourceInfo = useMemo<{ @@ -136,8 +150,17 @@ export const LayerConditionPanel: React.FC = ({ const config = comp.componentConfig || comp.webTypeConfig || {}; const detailSettings = comp.detailSettings || {}; - // V2 컴포넌트: config.source 확인 - const source = config.source; + // V2 컴포넌트: source 확인 (componentConfig, 상위 레벨, inputType 모두 체크) + const source = config.source || comp.source; + const inputType = config.inputType || comp.inputType; + const webType = config.webType || comp.webType; + + // inputType/webType이 category면 카테고리로 판단 + if (inputType === "category" || webType === "category") { + const categoryTable = config.categoryTable || comp.tableName || config.tableName; + const categoryColumn = config.categoryColumn || comp.columnName || config.columnName; + return { type: "category", categoryTable, categoryColumn }; + } // 1. 카테고리 소스 (V2: source === "category", category_values 테이블) if (source === "category") { @@ -188,8 +211,17 @@ export const LayerConditionPanel: React.FC = ({ return { type: "none" }; }, [selectedComponent]); + // 의존성 안정화를 위한 직렬화 키 + const dataSourceKey = useMemo(() => { + const { type, categoryTable, categoryColumn, codeCategory, originTable, originColumn, referenceTable, referenceColumn } = dataSourceInfo; + return `${type}|${categoryTable || ""}|${categoryColumn || ""}|${codeCategory || ""}|${originTable || ""}|${originColumn || ""}|${referenceTable || ""}|${referenceColumn || ""}`; + }, [dataSourceInfo]); + // 컴포넌트 선택 시 옵션 목록 로드 (카테고리, 코드, 엔티티, 정적) useEffect(() => { + // race condition 방지 + let cancelled = false; + if (dataSourceInfo.type === "none") { setOptions([]); return; @@ -212,10 +244,13 @@ export const LayerConditionPanel: React.FC = ({ try { if (dataSourceInfo.type === "category" && dataSourceInfo.categoryTable && dataSourceInfo.categoryColumn) { // 카테고리 값에서 옵션 로드 (category_values 테이블) + console.log("[LayerCondition] 카테고리 옵션 로드:", dataSourceInfo.categoryTable, dataSourceInfo.categoryColumn); const response = await apiClient.get( `/table-categories/${dataSourceInfo.categoryTable}/${dataSourceInfo.categoryColumn}/values` ); + if (cancelled) return; const data = response.data; + console.log("[LayerCondition] 카테고리 API 응답:", data?.success, "항목수:", Array.isArray(data?.data) ? data.data.length : 0); if (data.success && data.data) { // 트리 구조를 평탄화 const flattenTree = (items: any[], depth = 0): ConditionOption[] => { @@ -232,22 +267,22 @@ export const LayerConditionPanel: React.FC = ({ } return result; }; - setOptions(flattenTree(Array.isArray(data.data) ? data.data : [])); + const loadedOptions = flattenTree(Array.isArray(data.data) ? data.data : []); + console.log("[LayerCondition] 카테고리 옵션 설정:", loadedOptions.length, "개"); + setOptions(loadedOptions); } else { setOptions([]); } } else if (dataSourceInfo.type === "code" && dataSourceInfo.codeCategory) { // 코드 카테고리에서 옵션 로드 const codes = await getCodesByCategory(dataSourceInfo.codeCategory); + if (cancelled) return; setOptions(codes.map((code) => ({ value: code.code, label: code.name, }))); } else if (dataSourceInfo.type === "entity") { // 엔티티 참조에서 옵션 로드 - // 방법 1: 원본 테이블.컬럼으로 entity-reference API 호출 - // (백엔드에서 table_type_columns를 통해 참조 테이블/컬럼을 자동 매핑) - // 방법 2: 직접 참조 테이블로 폴백 let entityLoaded = false; if (dataSourceInfo.originTable && dataSourceInfo.originColumn) { @@ -257,13 +292,13 @@ export const LayerConditionPanel: React.FC = ({ dataSourceInfo.originColumn, { limit: 100 } ); + if (cancelled) return; setOptions(entityData.options.map((opt) => ({ value: opt.value, label: opt.label, }))); entityLoaded = true; } catch { - // 원본 테이블.컬럼으로 실패 시 폴백 console.warn("원본 테이블.컬럼으로 엔티티 조회 실패, 직접 참조로 폴백"); } } @@ -277,6 +312,7 @@ export const LayerConditionPanel: React.FC = ({ refColumn, { limit: 100 } ); + if (cancelled) return; setOptions(entityData.options.map((opt) => ({ value: opt.value, label: opt.label, @@ -287,25 +323,32 @@ export const LayerConditionPanel: React.FC = ({ } } - // 모든 방법 실패 시 빈 옵션으로 설정하고 에러 표시하지 않음 - if (!entityLoaded) { - // 엔티티 소스이지만 테이블 조회 불가 시, 직접 입력 모드로 전환 + if (!entityLoaded && !cancelled) { setOptions([]); } } else { - setOptions([]); + if (!cancelled) setOptions([]); } } catch (error: any) { - console.error("옵션 목록 로드 실패:", error); - setLoadError(error.message || "옵션 목록을 불러올 수 없습니다."); - setOptions([]); + if (!cancelled) { + console.error("옵션 목록 로드 실패:", error); + setLoadError(error.message || "옵션 목록을 불러올 수 없습니다."); + setOptions([]); + } } finally { - setIsLoadingOptions(false); + if (!cancelled) { + setIsLoadingOptions(false); + } } }; loadOptions(); - }, [dataSourceInfo]); + + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dataSourceKey]); // 조건 저장 const handleSave = useCallback(() => { @@ -574,11 +617,11 @@ export const LayerConditionPanel: React.FC = ({ )} {/* 현재 조건 요약 */} - {targetComponentId && (value || multiValues.length > 0) && ( + {targetComponentId && selectedComponent && (value || multiValues.length > 0) && (
요약: - "{getComponentLabel(selectedComponent!)}" 값이{" "} + "{getComponentLabel(selectedComponent)}" 값이{" "} {operator === "eq" && `"${options.find(o => o.value === value)?.label || value}"와 같으면`} {operator === "neq" && `"${options.find(o => o.value === value)?.label || value}"와 다르면`} {operator === "in" && `[${multiValues.map(v => options.find(o => o.value === v)?.label || v).join(", ")}] 중 하나이면`} diff --git a/frontend/components/screen/LayerManagerPanel.tsx b/frontend/components/screen/LayerManagerPanel.tsx index 114723a1..c45ca18d 100644 --- a/frontend/components/screen/LayerManagerPanel.tsx +++ b/frontend/components/screen/LayerManagerPanel.tsx @@ -134,11 +134,25 @@ export const LayerManagerPanel: React.FC = ({ } }, [screenId, activeLayerId, loadLayers, onLayerChange]); - // 조건 업데이트 + // 조건 업데이트 (기존 condition_config의 displayRegion 보존) const handleUpdateCondition = useCallback(async (layerId: number, condition: LayerCondition | undefined) => { if (!screenId) return; try { - await screenApi.updateLayerCondition(screenId, layerId, condition || null); + // 기존 condition_config를 가져와서 displayRegion 보존 + const layerData = await screenApi.getLayerLayout(screenId, layerId); + const existingCondition = layerData?.conditionConfig || {}; + const displayRegion = existingCondition.displayRegion; + + let mergedCondition: any; + if (condition) { + // 조건 설정: 새 조건 + 기존 displayRegion 보존 + mergedCondition = { ...condition, ...(displayRegion ? { displayRegion } : {}) }; + } else { + // 조건 삭제: displayRegion만 남기거나, 없으면 null + mergedCondition = displayRegion ? { displayRegion } : null; + } + + await screenApi.updateLayerCondition(screenId, layerId, mergedCondition); toast.success("조건이 저장되었습니다."); await loadLayers(); } catch (error) { diff --git a/frontend/components/screen/RealtimePreview.tsx b/frontend/components/screen/RealtimePreview.tsx index 5a786616..4efcb696 100644 --- a/frontend/components/screen/RealtimePreview.tsx +++ b/frontend/components/screen/RealtimePreview.tsx @@ -561,9 +561,8 @@ export const RealtimePreviewDynamic: React.FC = ({ zIndex: position?.z || 1, // right 속성 강제 제거 right: undefined, - // 🆕 분할 패널 드래그 중에는 트랜지션 없이 즉시 이동 - transition: - isOnSplitPanel && isButtonComponent ? (isDraggingSplitPanel ? "none" : "left 0.1s ease-out") : undefined, + // 모든 컴포넌트에서 transition 완전 제거 (위치 변경 시 애니메이션 방지) + transition: "none", }; // 선택된 컴포넌트 스타일 @@ -594,7 +593,7 @@ export const RealtimePreviewDynamic: React.FC = ({ return (
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 = @@ -4265,9 +4269,15 @@ export default function ScreenDesigner({ 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, screenResolution.width - componentWidth)), - y: Math.max(0, Math.min(rawY, screenResolution.height - componentHeight)), + 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, }; @@ -6623,28 +6633,50 @@ export default function ScreenDesigner({ {activeLayerId > 1 && (
- 레이어 {activeLayerId} 편집 중 + + 레이어 {activeLayerId} 편집 중 + {layerRegions[activeLayerId] && ( + + (캔버스: {layerRegions[activeLayerId].width} x {layerRegions[activeLayerId].height}px) + + )} + {!layerRegions[activeLayerId] && ( + + (조건부 영역 미설정 - 기본 레이어에서 영역을 먼저 배치하세요) + + )} +
)} {/* 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬 + contain 최적화 */} + {(() => { + // 🆕 조건부 레이어 편집 시 캔버스 크기를 displayRegion에 맞춤 + const activeRegion = activeLayerId > 1 ? layerRegions[activeLayerId] : null; + const canvasW = activeRegion ? activeRegion.width : screenResolution.width; + const canvasH = activeRegion ? activeRegion.height : screenResolution.height; + + return (
- {/* 실제 작업 캔버스 (해상도 크기) - 고정 크기 + 줌 적용 */} + {/* 실제 작업 캔버스 (해상도 크기 또는 조건부 레이어 영역 크기) */}
-
{" "} - {/* 🔥 줌 래퍼 닫기 */} +
+ ); /* 🔥 줌 래퍼 닫기 */ + })()}
{" "} {/* 메인 컨테이너 닫기 */} diff --git a/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx b/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx index 1338f40b..d55e12d0 100644 --- a/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx +++ b/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx @@ -105,7 +105,7 @@ export function ConditionalSectionViewer({ return (
Date: Mon, 9 Feb 2026 15:03:29 +0900 Subject: [PATCH 06/44] 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 b4d216b7c8946aa4132458cbf1ea7d2e559d7e82 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 9 Feb 2026 15:07:50 +0900 Subject: [PATCH 07/44] Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node From d7f900d8aec118baca5e022b6da34cf328115ea7 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Mon, 9 Feb 2026 15:37:28 +0900 Subject: [PATCH 08/44] 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 09/44] 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 Date: Mon, 9 Feb 2026 16:03:27 +0900 Subject: [PATCH 10/44] Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node --- .../src/controllers/adminController.ts | 8 +- .../controllers/screenManagementController.ts | 72 + .../src/routes/screenManagementRoutes.ts | 12 + .../src/services/screenManagementService.ts | 164 + .../app/(main)/screens/[screenId]/page.tsx | 81 +- .../screen/InteractiveScreenViewer.tsx | 123 +- .../components/screen/LayerManagerPanel.tsx | 504 +- frontend/components/screen/ScreenDesigner.tsx | 7494 ++++++++++++++++- frontend/lib/api/screen.ts | 51 + frontend/types/screen-management.ts | 31 +- 10 files changed, 8264 insertions(+), 276 deletions(-) diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index a89e50d1..bcfff1d2 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -1443,13 +1443,7 @@ async function collectAllChildMenuIds(parentObjid: number): Promise { * 메뉴 및 관련 데이터 정리 헬퍼 함수 */ async function cleanupMenuRelatedData(menuObjid: number): Promise { - // 1. category_column_mapping에서 menu_objid를 NULL로 설정 - await query( - `UPDATE category_column_mapping SET menu_objid = NULL WHERE menu_objid = $1`, - [menuObjid] - ); - - // 2. code_category에서 menu_objid를 NULL로 설정 + // 1. code_category에서 menu_objid를 NULL로 설정 await query( `UPDATE code_category SET menu_objid = NULL WHERE menu_objid = $1`, [menuObjid] diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts index a0521eec..3e624c40 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -787,6 +787,78 @@ export const updateLayerCondition = async (req: AuthenticatedRequest, res: Respo } }; +// ======================================== +// 조건부 영역(Zone) 관리 +// ======================================== + +// Zone 목록 조회 +export const getScreenZones = async (req: AuthenticatedRequest, res: Response) => { + try { + const { screenId } = req.params; + const { companyCode } = req.user as any; + const zones = await screenManagementService.getScreenZones(parseInt(screenId), companyCode); + res.json({ success: true, data: zones }); + } catch (error) { + console.error("Zone 목록 조회 실패:", error); + res.status(500).json({ success: false, message: "Zone 목록 조회에 실패했습니다." }); + } +}; + +// Zone 생성 +export const createZone = async (req: AuthenticatedRequest, res: Response) => { + try { + const { screenId } = req.params; + const { companyCode } = req.user as any; + const zone = await screenManagementService.createZone(parseInt(screenId), companyCode, req.body); + res.json({ success: true, data: zone }); + } catch (error) { + console.error("Zone 생성 실패:", error); + res.status(500).json({ success: false, message: "Zone 생성에 실패했습니다." }); + } +}; + +// Zone 업데이트 (위치/크기/트리거) +export const updateZone = async (req: AuthenticatedRequest, res: Response) => { + try { + const { zoneId } = req.params; + const { companyCode } = req.user as any; + await screenManagementService.updateZone(parseInt(zoneId), companyCode, req.body); + res.json({ success: true, message: "Zone이 업데이트되었습니다." }); + } catch (error) { + console.error("Zone 업데이트 실패:", error); + res.status(500).json({ success: false, message: "Zone 업데이트에 실패했습니다." }); + } +}; + +// Zone 삭제 +export const deleteZone = async (req: AuthenticatedRequest, res: Response) => { + try { + const { zoneId } = req.params; + const { companyCode } = req.user as any; + await screenManagementService.deleteZone(parseInt(zoneId), companyCode); + res.json({ success: true, message: "Zone이 삭제되었습니다." }); + } catch (error) { + console.error("Zone 삭제 실패:", error); + res.status(500).json({ success: false, message: "Zone 삭제에 실패했습니다." }); + } +}; + +// Zone에 레이어 추가 +export const addLayerToZone = async (req: AuthenticatedRequest, res: Response) => { + try { + const { screenId, zoneId } = req.params; + const { companyCode } = req.user as any; + const { conditionValue, layerName } = req.body; + const result = await screenManagementService.addLayerToZone( + parseInt(screenId), companyCode, parseInt(zoneId), conditionValue, layerName + ); + res.json({ success: true, data: result }); + } catch (error) { + console.error("Zone 레이어 추가 실패:", error); + res.status(500).json({ success: false, message: "Zone에 레이어를 추가하지 못했습니다." }); + } +}; + // ======================================== // POP 레이아웃 관리 (모바일/태블릿) // ======================================== diff --git a/backend-node/src/routes/screenManagementRoutes.ts b/backend-node/src/routes/screenManagementRoutes.ts index 08bf57f6..824bee71 100644 --- a/backend-node/src/routes/screenManagementRoutes.ts +++ b/backend-node/src/routes/screenManagementRoutes.ts @@ -46,6 +46,11 @@ import { getLayerLayout, deleteLayer, updateLayerCondition, + getScreenZones, + createZone, + updateZone, + deleteZone, + addLayerToZone, } from "../controllers/screenManagementController"; const router = express.Router(); @@ -98,6 +103,13 @@ router.get("/screens/:screenId/layers/:layerId/layout", getLayerLayout); // 특 router.delete("/screens/:screenId/layers/:layerId", deleteLayer); // 레이어 삭제 router.put("/screens/:screenId/layers/:layerId/condition", updateLayerCondition); // 레이어 조건 설정 +// 조건부 영역(Zone) 관리 +router.get("/screens/:screenId/zones", getScreenZones); // Zone 목록 +router.post("/screens/:screenId/zones", createZone); // Zone 생성 +router.put("/zones/:zoneId", updateZone); // Zone 업데이트 +router.delete("/zones/:zoneId", deleteZone); // Zone 삭제 +router.post("/screens/:screenId/zones/:zoneId/layers", addLayerToZone); // Zone에 레이어 추가 + // POP 레이아웃 관리 (모바일/태블릿) router.get("/screens/:screenId/layout-pop", getLayoutPop); // POP: 모바일/태블릿용 레이아웃 조회 router.post("/screens/:screenId/layout-pop", saveLayoutPop); // POP: 모바일/태블릿용 레이아웃 저장 diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 551fba91..7dc3b2a6 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -5363,6 +5363,170 @@ export class ScreenManagementService { ); } + // ======================================== + // 조건부 영역(Zone) 관리 + // ======================================== + + /** + * 화면의 조건부 영역(Zone) 목록 조회 + */ + async getScreenZones(screenId: number, companyCode: string): Promise { + let zones; + if (companyCode === "*") { + // 최고 관리자: 모든 회사 Zone 조회 가능 + zones = await query( + `SELECT * FROM screen_conditional_zones WHERE screen_id = $1 ORDER BY zone_id`, + [screenId], + ); + } else { + // 일반 회사: 자사 Zone만 조회 (company_code = '*' 제외) + zones = await query( + `SELECT * FROM screen_conditional_zones WHERE screen_id = $1 AND company_code = $2 ORDER BY zone_id`, + [screenId, companyCode], + ); + } + return zones; + } + + /** + * 조건부 영역(Zone) 생성 + */ + async createZone( + screenId: number, + companyCode: string, + zoneData: { + zone_name?: string; + x: number; + y: number; + width: number; + height: number; + trigger_component_id?: string; + trigger_operator?: string; + }, + ): Promise { + const result = await queryOne( + `INSERT INTO screen_conditional_zones + (screen_id, company_code, zone_name, x, y, width, height, trigger_component_id, trigger_operator) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING *`, + [ + screenId, + companyCode, + zoneData.zone_name || '조건부 영역', + zoneData.x, + zoneData.y, + zoneData.width, + zoneData.height, + zoneData.trigger_component_id || null, + zoneData.trigger_operator || 'eq', + ], + ); + return result; + } + + /** + * 조건부 영역(Zone) 업데이트 (위치/크기/트리거) + */ + async updateZone( + zoneId: number, + companyCode: string, + updates: { + zone_name?: string; + x?: number; + y?: number; + width?: number; + height?: number; + trigger_component_id?: string; + trigger_operator?: string; + }, + ): Promise { + const setClauses: string[] = ['updated_at = NOW()']; + const params: any[] = [zoneId, companyCode]; + let paramIdx = 3; + + for (const [key, value] of Object.entries(updates)) { + if (value !== undefined) { + setClauses.push(`${key} = $${paramIdx}`); + params.push(value); + paramIdx++; + } + } + + await query( + `UPDATE screen_conditional_zones SET ${setClauses.join(', ')} + WHERE zone_id = $1 AND company_code = $2`, + params, + ); + } + + /** + * 조건부 영역(Zone) 삭제 + 소속 레이어들의 condition_config 정리 + */ + async deleteZone(zoneId: number, companyCode: string): Promise { + // Zone에 소속된 레이어들의 condition_config에서 zone_id 제거 + await query( + `UPDATE screen_layouts_v2 SET condition_config = NULL, updated_at = NOW() + WHERE company_code = $1 AND condition_config->>'zone_id' = $2::text`, + [companyCode, String(zoneId)], + ); + + await query( + `DELETE FROM screen_conditional_zones WHERE zone_id = $1 AND company_code = $2`, + [zoneId, companyCode], + ); + } + + /** + * Zone에 레이어 추가 (빈 레이아웃으로 새 레이어 생성 + zone_id 할당) + */ + async addLayerToZone( + screenId: number, + companyCode: string, + zoneId: number, + conditionValue: string, + layerName?: string, + ): Promise<{ layerId: number }> { + // 다음 layer_id 계산 + const maxResult = await queryOne<{ max_id: number }>( + `SELECT COALESCE(MAX(layer_id), 1) as max_id FROM screen_layouts_v2 + WHERE screen_id = $1 AND company_code = $2`, + [screenId, companyCode], + ); + const newLayerId = (maxResult?.max_id || 1) + 1; + + // Zone 정보로 캔버스 크기 결정 (company_code 필터링 필수) + const zone = await queryOne( + `SELECT * FROM screen_conditional_zones WHERE zone_id = $1 AND company_code = $2`, + [zoneId, companyCode], + ); + + const layoutData = { + version: "2.1", + components: [], + screenResolution: zone + ? { width: zone.width, height: zone.height } + : { width: 800, height: 200 }, + }; + + const conditionConfig = { + zone_id: zoneId, + condition_value: conditionValue, + }; + + await query( + `INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, layer_id, layer_name, condition_config) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (screen_id, company_code, layer_id) DO UPDATE + SET layout_data = EXCLUDED.layout_data, + layer_name = EXCLUDED.layer_name, + condition_config = EXCLUDED.condition_config, + updated_at = NOW()`, + [screenId, companyCode, JSON.stringify(layoutData), newLayerId, layerName || `레이어 ${newLayerId}`, JSON.stringify(conditionConfig)], + ); + + return { layerId: newLayerId }; + } + // ======================================== // POP 레이아웃 관리 (모바일/태블릿) // v2.0: 4모드 레이아웃 지원 (태블릿 가로/세로, 모바일 가로/세로) diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 1b13e29a..92904e73 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -89,6 +89,8 @@ function ScreenViewPage() { // 🆕 레이어 시스템 지원 const [conditionalLayers, setConditionalLayers] = useState([]); + // 🆕 조건부 영역(Zone) 목록 + const [zones, setZones] = useState([]); // 편집 모달 상태 const [editModalOpen, setEditModalOpen] = useState(false); @@ -208,15 +210,18 @@ function ScreenViewPage() { } }, [screenId]); - // 🆕 조건부 레이어 로드 (기본 레이어 외 모든 레이어 로드) + // 🆕 조건부 레이어 + Zone 로드 useEffect(() => { - const loadConditionalLayers = async () => { + const loadConditionalLayersAndZones = async () => { if (!screenId || !layout) return; try { - // 1. 모든 레이어 목록 조회 + // 1. Zone 로드 + const loadedZones = await screenApi.getScreenZones(screenId); + setZones(loadedZones); + + // 2. 모든 레이어 목록 조회 const allLayers = await screenApi.getScreenLayers(screenId); - // layer_id > 1인 레이어만 (기본 레이어 제외) const nonBaseLayers = allLayers.filter((l: any) => l.layer_id > 1); if (nonBaseLayers.length === 0) { @@ -224,7 +229,7 @@ function ScreenViewPage() { return; } - // 2. 각 레이어의 레이아웃 데이터 로드 + // 3. 각 레이어의 레이아웃 데이터 로드 const layerDefinitions: LayerDefinition[] = []; for (const layerInfo of nonBaseLayers) { @@ -233,12 +238,9 @@ function ScreenViewPage() { const condConfig = layerInfo.condition_config || layerData?.conditionConfig || {}; // 레이어 컴포넌트 변환 (V2 → Legacy) - // getLayerLayout 응답: { ...layout_data, layerId, layerName, conditionConfig } - // layout_data가 spread 되므로 components는 최상위에 있음 let layerComponents: any[] = []; const rawComponents = layerData?.components; if (rawComponents && Array.isArray(rawComponents) && rawComponents.length > 0) { - // V2 컴포넌트를 Legacy 형식으로 변환 const tempV2 = { version: "2.0" as const, components: rawComponents, @@ -253,20 +255,33 @@ function ScreenViewPage() { } } + // Zone 기반 condition_config 처리 + const zoneId = condConfig.zone_id; + const conditionValue = condConfig.condition_value; + const zone = zoneId ? loadedZones.find((z: any) => z.zone_id === zoneId) : null; + // LayerDefinition 생성 const layerDef: LayerDefinition = { id: String(layerInfo.layer_id), name: layerInfo.layer_name || `레이어 ${layerInfo.layer_id}`, type: "conditional", zIndex: layerInfo.layer_id * 10, - isVisible: false, // 조건 충족 시에만 표시 + isVisible: false, isLocked: false, - condition: condConfig.targetComponentId ? { + // Zone 기반 조건 (Zone에서 트리거 정보를 가져옴) + condition: zone ? { + targetComponentId: zone.trigger_component_id || "", + operator: (zone.trigger_operator as "eq" | "neq" | "in") || "eq", + value: conditionValue, + } : condConfig.targetComponentId ? { targetComponentId: condConfig.targetComponentId, operator: condConfig.operator || "eq", value: condConfig.value, } : undefined, - displayRegion: condConfig.displayRegion || undefined, + // Zone 기반: displayRegion은 Zone에서 가져옴 + zoneId: zoneId || undefined, + conditionValue: conditionValue || undefined, + displayRegion: zone ? { x: zone.x, y: zone.y, width: zone.width, height: zone.height } : condConfig.displayRegion || undefined, components: layerComponents, }; @@ -277,16 +292,16 @@ function ScreenViewPage() { } console.log("🔄 조건부 레이어 로드 완료:", layerDefinitions.length, "개", layerDefinitions.map(l => ({ - id: l.id, name: l.name, condition: l.condition, displayRegion: l.displayRegion, + id: l.id, name: l.name, zoneId: l.zoneId, conditionValue: l.conditionValue, componentCount: l.components.length, }))); setConditionalLayers(layerDefinitions); } catch (error) { - console.error("레이어 로드 실패:", error); + console.error("레이어/Zone 로드 실패:", error); } }; - loadConditionalLayers(); + loadConditionalLayersAndZones(); }, [screenId, layout]); // 🆕 조건부 레이어 조건 평가 (formData 변경 시 동기적으로 즉시 계산) @@ -760,20 +775,20 @@ function ScreenViewPage() { } } - // 🆕 조건부 레이어 displayRegion 기반 높이 조정 - // 기본 레이어 컴포넌트는 displayRegion 포함한 위치에 배치되므로, - // 비활성(빈 영역) 시 아래 컴포넌트를 위로 당겨 빈 공간 제거 - for (const layer of conditionalLayers) { - if (!layer.displayRegion) continue; - const region = layer.displayRegion; - const regionBottom = region.y + region.height; - const isActive = activeLayerIds.includes(layer.id); - - // 컴포넌트가 조건부 영역 하단보다 아래에 있는 경우 - if (component.position.y >= regionBottom) { - if (!isActive) { - // 비활성: 영역 높이만큼 위로 당김 (빈 공간 제거) - totalHeightAdjustment -= region.height; + // 🆕 Zone 기반 높이 조정 + // Zone 단위로 활성 여부를 판단하여 Y 오프셋 계산 + // Zone은 겹치지 않으므로 merge 로직이 불필요 (단순 boolean 판단) + for (const zone of zones) { + const zoneBottom = zone.y + zone.height; + // 컴포넌트가 Zone 하단보다 아래에 있는 경우 + if (component.position.y >= zoneBottom) { + // Zone에 매칭되는 활성 레이어가 있는지 확인 + const hasActiveLayer = conditionalLayers.some( + l => l.zoneId === zone.zone_id && activeLayerIds.includes(l.id) + ); + if (!hasActiveLayer) { + // Zone에 활성 레이어 없음: Zone 높이만큼 위로 당김 (빈 공간 제거) + totalHeightAdjustment -= zone.height; } } } @@ -1099,12 +1114,16 @@ function ScreenViewPage() { ); })} - {/* 🆕 조건부 레이어 컴포넌트 렌더링 */} + {/* 🆕 조건부 레이어 컴포넌트 렌더링 (Zone 기반) */} {conditionalLayers.map((layer) => { const isActive = activeLayerIds.includes(layer.id); if (!isActive || !layer.components || layer.components.length === 0) return null; - const region = layer.displayRegion; + // Zone 기반: zoneId로 Zone 찾아서 위치/크기 결정 + const zone = layer.zoneId ? zones.find(z => z.zone_id === layer.zoneId) : null; + const region = zone + ? { x: zone.x, y: zone.y, width: zone.width, height: zone.height } + : layer.displayRegion; return (
diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index 4f295878..05d8bdc9 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -284,59 +284,38 @@ export const InteractiveScreenViewer: React.FC = ( }); }, [finalFormData, layers, allComponents, handleLayerAction]); - // 🆕 모든 조건부 레이어의 displayRegion 정보 (활성/비활성 모두) - const conditionalRegionInfos = useMemo(() => { - return layers - .filter((layer) => layer.type === "conditional" && layer.displayRegion) - .map((layer) => ({ - layerId: layer.id, - region: layer.displayRegion!, - isActive: activeLayerIds.includes(layer.id), - })) - .sort((a, b) => a.region.y - b.region.y); // Y 좌표 기준 정렬 - }, [layers, activeLayerIds]); - - // 🆕 접힌 조건부 영역 (비활성 상태인 것만) - const collapsedRegions = useMemo(() => { - return conditionalRegionInfos - .filter((info) => !info.isActive) - .map((info) => info.region); - }, [conditionalRegionInfos]); - - // 🆕 Y 오프셋 계산 함수 (다중 조건부 영역 지원) - // 컴포넌트의 원래 Y 좌표보다 위에 있는 접힌 영역들의 높이를 누적하여 빼줌 - // 겹치는 영역은 중복 계산하지 않도록 병합(merge) 처리 + // 🆕 Zone 기반 Y 오프셋 계산 (단순화) + // Zone 단위로 활성 여부만 판단 → merge 로직 불필요 const calculateYOffset = useCallback((componentY: number): number => { - if (collapsedRegions.length === 0) return 0; - - // 컴포넌트보다 위에 있는 접힌 영역만 필터링 - const relevantRegions = collapsedRegions.filter( - (region) => region.y + region.height <= componentY - ); - - if (relevantRegions.length === 0) return 0; - - // 겹치는 영역 병합 (다중 조건부 영역이 겹치는 경우 중복 높이 제거) - const mergedRegions: { y: number; bottom: number }[] = []; - for (const region of relevantRegions) { - const bottom = region.y + region.height; - if (mergedRegions.length === 0) { - mergedRegions.push({ y: region.y, bottom }); - } else { - const last = mergedRegions[mergedRegions.length - 1]; - if (region.y <= last.bottom) { - // 겹치는 영역 - 병합 (더 큰 하단으로 확장) - last.bottom = Math.max(last.bottom, bottom); - } else { - // 겹치지 않는 영역 - 새로 추가 - mergedRegions.push({ y: region.y, bottom }); - } + // layers에서 Zone 정보 추출 (displayRegion이 있는 레이어들을 zone 단위로 그룹핑) + const zoneMap = new Map(); + + for (const layer of layers) { + if (layer.type !== "conditional" || !layer.zoneId || !layer.displayRegion) continue; + const zid = layer.zoneId; + if (!zoneMap.has(zid)) { + zoneMap.set(zid, { + y: layer.displayRegion.y, + height: layer.displayRegion.height, + hasActive: false, + }); + } + if (activeLayerIds.includes(layer.id)) { + zoneMap.get(zid)!.hasActive = true; } } - // 병합된 영역들의 높이 합산 - return mergedRegions.reduce((offset, merged) => offset + (merged.bottom - merged.y), 0); - }, [collapsedRegions]); + let totalOffset = 0; + for (const [, zone] of zoneMap) { + const zoneBottom = zone.y + zone.height; + // 컴포넌트가 Zone 하단보다 아래에 있고, Zone에 활성 레이어가 없으면 접힘 + if (componentY >= zoneBottom && !zone.hasActive) { + totalOffset += zone.height; + } + } + + return totalOffset; + }, [layers, activeLayerIds]); // 개선된 검증 시스템 (선택적 활성화) const enhancedValidation = enableEnhancedValidation && screenInfo && tableColumns.length > 0 @@ -2378,7 +2357,48 @@ export const InteractiveScreenViewer: React.FC = ( ); } - // 일반/조건부 레이어 (base, conditional) + // 조건부 레이어: Zone 기반 영역 내에 컴포넌트 렌더링 + if (layer.type === "conditional" && layer.displayRegion) { + const region = layer.displayRegion; + return ( +
+ {layer.components.map((comp) => ( +
+ +
+ ))} +
+ ); + } + + // 기본/기타 레이어 (base) return (
= ( style={{ zIndex: layer.zIndex }} > {layer.components.map((comp) => { - // 기본 레이어 컴포넌트만 Y 오프셋 적용 (조건부 레이어 컴포넌트는 자체 영역 내 표시) const yOffset = layer.type === "base" ? calculateYOffset(comp.position.y) : 0; const adjustedY = comp.position.y - yOffset; @@ -2414,7 +2433,7 @@ export const InteractiveScreenViewer: React.FC = ( })}
); - }, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo, calculateYOffset, allLayerComponents]); + }, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo, calculateYOffset, allLayerComponents, layers]); return ( diff --git a/frontend/components/screen/LayerManagerPanel.tsx b/frontend/components/screen/LayerManagerPanel.tsx index c45ca18d..9179afbe 100644 --- a/frontend/components/screen/LayerManagerPanel.tsx +++ b/frontend/components/screen/LayerManagerPanel.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useCallback } from "react"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Badge } from "@/components/ui/badge"; import { @@ -12,13 +13,13 @@ import { ChevronRight, Zap, Loader2, + Box, + Settings2, } from "lucide-react"; import { cn } from "@/lib/utils"; import { screenApi } from "@/lib/api/screen"; -import { convertV2ToLegacy } from "@/lib/utils/layoutV2Converter"; import { toast } from "sonner"; -import { LayerConditionPanel } from "./LayerConditionPanel"; -import { ComponentData, LayerCondition, DisplayRegion } from "@/types/screen-management"; +import { ComponentData, ConditionalZone } from "@/types/screen-management"; // DB 레이어 타입 interface DBLayer { @@ -34,6 +35,8 @@ interface LayerManagerPanelProps { activeLayerId: number; // 현재 활성 레이어 ID (DB layer_id) onLayerChange: (layerId: number) => void; // 레이어 전환 components?: ComponentData[]; // 현재 활성 레이어의 컴포넌트 (폴백용) + zones?: ConditionalZone[]; // Zone 목록 (ScreenDesigner에서 전달) + onZonesChange?: (zones: ConditionalZone[]) => void; // Zone 목록 변경 콜백 } export const LayerManagerPanel: React.FC = ({ @@ -41,13 +44,23 @@ export const LayerManagerPanel: React.FC = ({ activeLayerId, onLayerChange, components = [], + zones: externalZones, + onZonesChange, }) => { const [layers, setLayers] = useState([]); const [isLoading, setIsLoading] = useState(false); - const [conditionOpenLayerId, setConditionOpenLayerId] = useState(null); - // 기본 레이어(layer_id=1)의 컴포넌트 (조건 설정 시 트리거 대상) + // 펼침/접힘 상태: zone_id별 + const [expandedZones, setExpandedZones] = useState>(new Set()); + // Zone에 레이어 추가 시 조건값 입력 상태 + const [addingToZoneId, setAddingToZoneId] = useState(null); + const [newConditionValue, setNewConditionValue] = useState(""); + // Zone 트리거 설정 열기 상태 + const [triggerEditZoneId, setTriggerEditZoneId] = useState(null); + // 기본 레이어 컴포넌트 (트리거 선택용) const [baseLayerComponents, setBaseLayerComponents] = useState([]); + const zones = externalZones || []; + // 레이어 목록 로드 const loadLayers = useCallback(async () => { if (!screenId) return; @@ -62,60 +75,60 @@ export const LayerManagerPanel: React.FC = ({ } }, [screenId]); - // 기본 레이어 컴포넌트 로드 (조건 설정 패널에서 트리거 컴포넌트 선택용) + // 기본 레이어 컴포넌트 로드 const loadBaseLayerComponents = useCallback(async () => { if (!screenId) return; try { const data = await screenApi.getLayerLayout(screenId, 1); - if (data && data.components) { - const legacy = convertV2ToLegacy(data); - if (legacy) { - setBaseLayerComponents(legacy.components as ComponentData[]); - return; - } + if (data?.components) { + setBaseLayerComponents(data.components as ComponentData[]); } - setBaseLayerComponents([]); } catch { - // 기본 레이어가 없거나 로드 실패 시 현재 컴포넌트 사용 setBaseLayerComponents(components); } }, [screenId, components]); useEffect(() => { loadLayers(); - }, [loadLayers]); + loadBaseLayerComponents(); + }, [loadLayers, loadBaseLayerComponents]); - // 조건 설정 패널이 열릴 때 기본 레이어 컴포넌트 로드 - useEffect(() => { - if (conditionOpenLayerId !== null) { - loadBaseLayerComponents(); - } - }, [conditionOpenLayerId, loadBaseLayerComponents]); + // Zone별 레이어 그룹핑 + const getLayersForZone = useCallback((zoneId: number): DBLayer[] => { + return layers.filter(l => { + const cc = l.condition_config; + return cc && cc.zone_id === zoneId; + }); + }, [layers]); - // 새 레이어 추가 - const handleAddLayer = useCallback(async () => { - if (!screenId) return; - // 다음 layer_id 계산 - const maxLayerId = layers.length > 0 ? Math.max(...layers.map((l) => l.layer_id)) : 0; - const newLayerId = maxLayerId + 1; + // Zone에 속하지 않는 조건부 레이어 (레거시) + const orphanLayers = layers.filter(l => { + if (l.layer_id === 1) return false; + const cc = l.condition_config; + return !cc || !cc.zone_id; + }); + // 기본 레이어 + const baseLayer = layers.find(l => l.layer_id === 1); + + // Zone에 레이어 추가 + const handleAddLayerToZone = useCallback(async (zoneId: number) => { + if (!screenId || !newConditionValue.trim()) return; try { - // 빈 레이아웃으로 새 레이어 저장 - await screenApi.saveLayoutV2(screenId, { - version: "2.0", - components: [], - layerId: newLayerId, - layerName: `조건부 레이어 ${newLayerId}`, - }); - toast.success(`조건부 레이어 ${newLayerId}가 생성되었습니다.`); + const result = await screenApi.addLayerToZone( + screenId, zoneId, newConditionValue.trim(), + `레이어 (${newConditionValue.trim()})`, + ); + toast.success(`레이어가 Zone에 추가되었습니다. (ID: ${result.layerId})`); + setAddingToZoneId(null); + setNewConditionValue(""); await loadLayers(); - // 새 레이어로 전환 - onLayerChange(newLayerId); + onLayerChange(result.layerId); } catch (error) { - console.error("레이어 추가 실패:", error); + console.error("Zone 레이어 추가 실패:", error); toast.error("레이어 추가에 실패했습니다."); } - }, [screenId, layers, loadLayers, onLayerChange]); + }, [screenId, newConditionValue, loadLayers, onLayerChange]); // 레이어 삭제 const handleDeleteLayer = useCallback(async (layerId: number) => { @@ -124,42 +137,59 @@ export const LayerManagerPanel: React.FC = ({ await screenApi.deleteLayer(screenId, layerId); toast.success("레이어가 삭제되었습니다."); await loadLayers(); - // 기본 레이어로 전환 - if (activeLayerId === layerId) { - onLayerChange(1); - } + if (activeLayerId === layerId) onLayerChange(1); } catch (error) { console.error("레이어 삭제 실패:", error); toast.error("레이어 삭제에 실패했습니다."); } }, [screenId, activeLayerId, loadLayers, onLayerChange]); - // 조건 업데이트 (기존 condition_config의 displayRegion 보존) - const handleUpdateCondition = useCallback(async (layerId: number, condition: LayerCondition | undefined) => { + // Zone 삭제 + const handleDeleteZone = useCallback(async (zoneId: number) => { if (!screenId) return; try { - // 기존 condition_config를 가져와서 displayRegion 보존 - const layerData = await screenApi.getLayerLayout(screenId, layerId); - const existingCondition = layerData?.conditionConfig || {}; - const displayRegion = existingCondition.displayRegion; - - let mergedCondition: any; - if (condition) { - // 조건 설정: 새 조건 + 기존 displayRegion 보존 - mergedCondition = { ...condition, ...(displayRegion ? { displayRegion } : {}) }; - } else { - // 조건 삭제: displayRegion만 남기거나, 없으면 null - mergedCondition = displayRegion ? { displayRegion } : null; - } - - await screenApi.updateLayerCondition(screenId, layerId, mergedCondition); - toast.success("조건이 저장되었습니다."); + await screenApi.deleteZone(zoneId); + toast.success("조건부 영역이 삭제되었습니다."); + // Zone 목록 새로고침 + const loadedZones = await screenApi.getScreenZones(screenId); + onZonesChange?.(loadedZones); await loadLayers(); + onLayerChange(1); } catch (error) { - console.error("조건 업데이트 실패:", error); - toast.error("조건 저장에 실패했습니다."); + console.error("Zone 삭제 실패:", error); + toast.error("Zone 삭제에 실패했습니다."); } - }, [screenId, loadLayers]); + }, [screenId, loadLayers, onLayerChange, onZonesChange]); + + // Zone 트리거 컴포넌트 업데이트 + const handleUpdateZoneTrigger = useCallback(async (zoneId: number, triggerComponentId: string, operator: string = "eq") => { + try { + await screenApi.updateZone(zoneId, { + trigger_component_id: triggerComponentId, + trigger_operator: operator, + }); + const loadedZones = await screenApi.getScreenZones(screenId!); + onZonesChange?.(loadedZones); + toast.success("트리거가 설정되었습니다."); + } catch (error) { + console.error("Zone 트리거 업데이트 실패:", error); + toast.error("트리거 설정에 실패했습니다."); + } + }, [screenId, onZonesChange]); + + // Zone 접힘/펼침 토글 + const toggleZone = (zoneId: number) => { + setExpandedZones(prev => { + const next = new Set(prev); + next.has(zoneId) ? next.delete(zoneId) : next.add(zoneId); + return next; + }); + }; + + // 트리거로 사용 가능한 컴포넌트 (select, combobox 등) + const triggerableComponents = baseLayerComponents.filter(c => + ["select", "combobox", "radio-group"].some(t => c.componentType?.includes(t)) + ); return (
@@ -172,19 +202,9 @@ export const LayerManagerPanel: React.FC = ({ {layers.length}
- -
- {/* 레이어 목록 */} + {/* 레이어 + Zone 목록 */}
{isLoading ? ( @@ -192,146 +212,266 @@ export const LayerManagerPanel: React.FC = ({ 로딩 중...
- ) : layers.length === 0 ? ( -
-

레이어를 로드하는 중...

-

먼저 화면을 저장하면 기본 레이어가 생성됩니다.

-
) : ( - layers - .slice() - .reverse() - .map((layer) => { - const isActive = activeLayerId === layer.layer_id; - const isBase = layer.layer_id === 1; - const hasCondition = !!layer.condition_config; - const isConditionOpen = conditionOpenLayerId === layer.layer_id; + <> + {/* 기본 레이어 */} + {baseLayer && ( +
onLayerChange(1)} + > + + + +
+ {baseLayer.layer_name} +
+ 기본 + {baseLayer.component_count}개 컴포넌트 +
+
+
+ )} + + {/* 조건부 영역(Zone) 목록 */} + {zones.map((zone) => { + const zoneLayers = getLayersForZone(zone.zone_id); + const isExpanded = expandedZones.has(zone.zone_id); + const isTriggerEdit = triggerEditZoneId === zone.zone_id; return ( -
+
+ {/* Zone 헤더 */}
onLayerChange(layer.layer_id)} - // 조건부 레이어를 캔버스로 드래그 (영역 배치용) - draggable={!isBase} - onDragStart={(e) => { - if (isBase) return; - e.dataTransfer.setData("application/json", JSON.stringify({ - type: "layer-region", - layerId: layer.layer_id, - layerName: layer.layer_name, - })); - e.dataTransfer.effectAllowed = "copy"; - }} + onClick={() => toggleZone(zone.zone_id)} > - - + {isExpanded ? ( + + ) : ( + + )} +
+ {zone.zone_name}
- - {isBase ? : } - - {layer.layer_name} -
-
- - {isBase ? "기본" : "조건부"} + + Zone - {layer.component_count}개 컴포넌트 + {zoneLayers.length}개 레이어 | {zone.width}x{zone.height} - {hasCondition && ( + {zone.trigger_component_id && ( - - 조건 + 트리거 )}
- {/* 액션 버튼 */} + {/* Zone 액션 버튼 */}
- {!isBase && ( - - )} - {!isBase && ( - - )} + +
- {/* 조건 설정 패널 */} - {!isBase && isConditionOpen && ( + {/* 펼쳐진 Zone 내용 */} + {isExpanded && (
- handleUpdateCondition(layer.layer_id, condition)} - onUpdateDisplayRegion={() => {}} - onClose={() => setConditionOpenLayerId(null)} - /> + {/* 트리거 설정 패널 */} + {isTriggerEdit && ( +
+

트리거 컴포넌트 선택

+ {triggerableComponents.length === 0 ? ( +

기본 레이어에 Select/Combobox/Radio 컴포넌트가 없습니다.

+ ) : ( +
+ {triggerableComponents.map(c => ( + + ))} +
+ )} +
+ )} + + {/* Zone 소속 레이어 목록 */} + {zoneLayers.map((layer) => { + const isActive = activeLayerId === layer.layer_id; + return ( +
onLayerChange(layer.layer_id)} + > + +
+ {layer.layer_name} +
+ + 조건값: {layer.condition_config?.condition_value || "미설정"} + + + | {layer.component_count}개 + +
+
+ +
+ ); + })} + + {/* 레이어 추가 */} + {addingToZoneId === zone.zone_id ? ( +
+ setNewConditionValue(e.target.value)} + placeholder="조건값 입력 (예: 옵션1)" + className="h-6 text-[11px] flex-1" + autoFocus + onKeyDown={(e) => { if (e.key === "Enter") handleAddLayerToZone(zone.zone_id); }} + /> + + +
+ ) : ( + + )}
)}
); - }) + })} + + {/* 고아 레이어 (Zone에 소속되지 않은 조건부 레이어) */} + {orphanLayers.length > 0 && ( +
+

Zone 미할당 레이어

+ {orphanLayers.map((layer) => { + const isActive = activeLayerId === layer.layer_id; + return ( +
onLayerChange(layer.layer_id)} + > + +
+ {layer.layer_name} +
+ 조건부 + {layer.component_count}개 +
+
+ +
+ ); + })} +
+ )} + )}
- {/* 도움말 */} -
-

레이어를 클릭하여 편집 | 조건부 레이어를 캔버스에 드래그하여 영역 설정

+ {/* Zone 생성 드래그 영역 */} +
+
{ + e.dataTransfer.setData("application/json", JSON.stringify({ type: "create-zone" })); + e.dataTransfer.effectAllowed = "copy"; + }} + > + + + 조건부 영역 추가 (캔버스로 드래그) +
+

+ 기본 레이어에서 Zone을 배치한 후, Zone 내에 레이어를 추가하세요 +

); diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 5f62ade4..df2244e0 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -1 +1,7493 @@ -서 \ No newline at end of file +"use client"; + +import { useState, useCallback, useEffect, useMemo, useRef } from "react"; +import { Database, Cog } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { + ScreenDefinition, + ComponentData, + LayoutData, + GroupState, + TableInfo, + Position, + ColumnInfo, + GridSettings, + ScreenResolution, + SCREEN_RESOLUTIONS, +} from "@/types/screen"; +import { generateComponentId } from "@/lib/utils/generateId"; +import { + getComponentIdFromWebType, + createV2ConfigFromColumn, + getV2ConfigFromWebType, +} from "@/lib/utils/webTypeMapping"; +import { + createGroupComponent, + calculateBoundingBox, + calculateRelativePositions, + restoreAbsolutePositions, +} from "@/lib/utils/groupingUtils"; +import { + adjustGridColumnsFromSize, + updateSizeFromGridColumns, + calculateWidthFromColumns, + snapSizeToGrid, + snapToGrid, +} from "@/lib/utils/gridUtils"; +import { + alignComponents, + distributeComponents, + matchComponentSize, + toggleAllLabels, + nudgeComponents, + AlignMode, + DistributeDirection, + MatchSizeMode, +} from "@/lib/utils/alignmentUtils"; +import { KeyboardShortcutsModal } from "./modals/KeyboardShortcutsModal"; + +// 10px 단위 스냅 함수 +const snapTo10px = (value: number): number => { + return Math.round(value / 10) * 10; +}; + +const snapPositionTo10px = (position: Position): Position => { + return { + x: snapTo10px(position.x), + y: snapTo10px(position.y), + z: position.z, + }; +}; + +const snapSizeTo10px = (size: { width: number; height: number }): { width: number; height: number } => { + return { + width: snapTo10px(size.width), + height: snapTo10px(size.height), + }; +}; + +// calculateGridInfo 더미 함수 (하위 호환성을 위해 유지) +const calculateGridInfo = (width: number, height: number, settings: any) => { + return { + columnWidth: 10, + totalWidth: width, + totalHeight: height, + columns: settings.columns || 12, + gap: settings.gap || 0, + padding: settings.padding || 0, + }; +}; +import { GroupingToolbar } from "./GroupingToolbar"; +import { screenApi, tableTypeApi } from "@/lib/api/screen"; +import { tableManagementApi } from "@/lib/api/tableManagement"; +import { ExternalRestApiConnectionAPI } from "@/lib/api/externalRestApiConnection"; +import { toast } from "sonner"; +import { MenuAssignmentModal } from "./MenuAssignmentModal"; +import { FileAttachmentDetailModal } from "./FileAttachmentDetailModal"; +import { initializeComponents } from "@/lib/registry/components"; +import { ScreenFileAPI } from "@/lib/api/screenFile"; +import { safeMigrateLayout, needsMigration } from "@/lib/utils/widthToColumnSpan"; +import { convertV2ToLegacy, convertLegacyToV2, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; + +// V2 API 사용 플래그 (true: V2, false: 기존) +const USE_V2_API = true; + +import StyleEditor from "./StyleEditor"; +import { RealtimePreview } from "./RealtimePreviewDynamic"; +import FloatingPanel from "./FloatingPanel"; +import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; +import { MultilangSettingsModal } from "./modals/MultilangSettingsModal"; +import DesignerToolbar from "./DesignerToolbar"; +import TablesPanel from "./panels/TablesPanel"; +import { TemplatesPanel, TemplateComponent } from "./panels/TemplatesPanel"; +import { ComponentsPanel } from "./panels/ComponentsPanel"; +import PropertiesPanel from "./panels/PropertiesPanel"; +import DetailSettingsPanel from "./panels/DetailSettingsPanel"; +import ResolutionPanel from "./panels/ResolutionPanel"; +import { usePanelState, PanelConfig } from "@/hooks/usePanelState"; +import { FlowButtonGroup } from "./widgets/FlowButtonGroup"; +import { FlowVisibilityConfig } from "@/types/control-management"; +import { + areAllButtons, + generateGroupId, + groupButtons, + ungroupButtons, + findAllButtonGroups, +} from "@/lib/utils/flowButtonGroupUtils"; +import { FlowButtonGroupDialog } from "./dialogs/FlowButtonGroupDialog"; +import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext"; +import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; + +// 새로운 통합 UI 컴포넌트 +import { SlimToolbar } from "./toolbar/SlimToolbar"; +import { V2PropertiesPanel } from "./panels/V2PropertiesPanel"; + +// 컴포넌트 초기화 (새 시스템) +import "@/lib/registry/components"; +// 성능 최적화 도구 초기화 (필요시 사용) +import "@/lib/registry/utils/performanceOptimizer"; + +interface ScreenDesignerProps { + selectedScreen: ScreenDefinition | null; + onBackToList: () => void; + onScreenUpdate?: (updatedScreen: Partial) => void; + // POP 모드 지원 + isPop?: boolean; + defaultDevicePreview?: "mobile" | "tablet"; +} + +import { useLayerOptional, LayerProvider, createDefaultLayer } from "@/contexts/LayerContext"; +import { LayerManagerPanel } from "./LayerManagerPanel"; +import { LayerType, LayerDefinition } from "@/types/screen-management"; + +// 패널 설정 업데이트 +const panelConfigs: PanelConfig[] = [ + { + id: "v2", + title: "패널", + defaultPosition: "left", + defaultWidth: 240, + defaultHeight: 700, + shortcutKey: "p", + }, + { + id: "layer", + title: "레이어", + defaultPosition: "right", + defaultWidth: 240, + defaultHeight: 500, + shortcutKey: "l", + }, +]; + +export default function ScreenDesigner({ + selectedScreen, + onBackToList, + onScreenUpdate, + isPop = false, + defaultDevicePreview = "tablet" +}: ScreenDesignerProps) { + // POP 모드 여부에 따른 API 분기 + const USE_POP_API = isPop; + const [layout, setLayout] = useState({ + components: [], + gridSettings: { + columns: 12, + gap: 16, + padding: 0, + snapToGrid: true, + showGrid: false, // 기본값 false로 변경 + gridColor: "#d1d5db", + gridOpacity: 0.5, + }, + }); + const [isSaving, setIsSaving] = useState(false); + const [isGeneratingMultilang, setIsGeneratingMultilang] = useState(false); + const [showMultilangSettingsModal, setShowMultilangSettingsModal] = useState(false); + + // 🆕 화면에 할당된 메뉴 OBJID + const [menuObjid, setMenuObjid] = useState(undefined); + + // 메뉴 할당 모달 상태 + const [showMenuAssignmentModal, setShowMenuAssignmentModal] = useState(false); + + // 단축키 도움말 모달 상태 + const [showShortcutsModal, setShowShortcutsModal] = useState(false); + + // 파일첨부 상세 모달 상태 + const [showFileAttachmentModal, setShowFileAttachmentModal] = useState(false); + const [selectedFileComponent, setSelectedFileComponent] = useState(null); + + // 해상도 설정 상태 + const [screenResolution, setScreenResolution] = useState( + SCREEN_RESOLUTIONS[0], // 기본값: Full HD + ); + + // 🆕 패널 상태 관리 (usePanelState 훅) + const { panelStates, togglePanel, openPanel, closePanel, closeAllPanels, updatePanelPosition, updatePanelSize } = + usePanelState(panelConfigs); + + const [selectedComponent, setSelectedComponent] = useState(null); + + // 🆕 탭 내부 컴포넌트 선택 상태 (중첩 구조 지원) + const [selectedTabComponentInfo, setSelectedTabComponentInfo] = useState<{ + tabsComponentId: string; // 탭 컴포넌트 ID + tabId: string; // 탭 ID + componentId: string; // 탭 내부 컴포넌트 ID + component: any; // 탭 내부 컴포넌트 데이터 + // 🆕 중첩 구조용: 부모 분할 패널 정보 + parentSplitPanelId?: string | null; + parentPanelSide?: "left" | "right" | null; + } | null>(null); + + // 🆕 분할 패널 내부 컴포넌트 선택 상태 + const [selectedPanelComponentInfo, setSelectedPanelComponentInfo] = useState<{ + splitPanelId: string; // 분할 패널 컴포넌트 ID + panelSide: "left" | "right"; // 좌측/우측 패널 + componentId: string; // 패널 내부 컴포넌트 ID + component: any; // 패널 내부 컴포넌트 데이터 + } | null>(null); + + // 컴포넌트 선택 시 통합 패널 자동 열기 + const handleComponentSelect = useCallback( + (component: ComponentData | null) => { + setSelectedComponent(component); + // 일반 컴포넌트 선택 시 탭 내부 컴포넌트/분할 패널 내부 컴포넌트 선택 해제 + if (component) { + setSelectedTabComponentInfo(null); + setSelectedPanelComponentInfo(null); + } + + // 컴포넌트가 선택되면 통합 패널 자동 열기 + if (component) { + openPanel("v2"); + } + }, + [openPanel], + ); + + // 🆕 탭 내부 컴포넌트 선택 핸들러 (중첩 구조 지원) + const handleSelectTabComponent = useCallback( + ( + tabsComponentId: string, + tabId: string, + compId: string, + comp: any, + // 🆕 중첩 구조용: 부모 분할 패널 정보 (선택적) + parentSplitPanelId?: string | null, + parentPanelSide?: "left" | "right" | null, + ) => { + if (!compId) { + // 탭 영역 빈 공간 클릭 시 선택 해제 + setSelectedTabComponentInfo(null); + return; + } + + setSelectedTabComponentInfo({ + tabsComponentId, + tabId, + componentId: compId, + component: comp, + parentSplitPanelId: parentSplitPanelId || null, + parentPanelSide: parentPanelSide || null, + }); + // 탭 내부 컴포넌트 선택 시 일반 컴포넌트/분할 패널 내부 컴포넌트 선택 해제 + setSelectedComponent(null); + setSelectedPanelComponentInfo(null); + openPanel("v2"); + }, + [openPanel], + ); + + // 🆕 분할 패널 내부 컴포넌트 선택 핸들러 + const handleSelectPanelComponent = useCallback( + (splitPanelId: string, panelSide: "left" | "right", compId: string, comp: any) => { + // 🐛 디버깅: 전달받은 comp 확인 + console.log("🐛 [handleSelectPanelComponent] comp:", { + compId, + componentType: comp?.componentType, + selectedTable: comp?.componentConfig?.selectedTable, + fieldMapping: comp?.componentConfig?.fieldMapping, + fieldMappingKeys: comp?.componentConfig?.fieldMapping ? Object.keys(comp.componentConfig.fieldMapping) : [], + }); + + if (!compId) { + // 패널 영역 빈 공간 클릭 시 선택 해제 + setSelectedPanelComponentInfo(null); + return; + } + + setSelectedPanelComponentInfo({ + splitPanelId, + panelSide, + componentId: compId, + component: comp, + }); + // 분할 패널 내부 컴포넌트 선택 시 일반 컴포넌트/탭 내부 컴포넌트 선택 해제 + setSelectedComponent(null); + setSelectedTabComponentInfo(null); + openPanel("v2"); + }, + [openPanel], + ); + + // 🆕 중첩된 탭 컴포넌트 선택 이벤트 리스너 (분할 패널 안의 탭 안의 컴포넌트) + useEffect(() => { + const handleNestedTabComponentSelect = (event: CustomEvent) => { + const { tabsComponentId, tabId, componentId, component, parentSplitPanelId, parentPanelSide } = event.detail; + + if (!componentId) { + setSelectedTabComponentInfo(null); + return; + } + + console.log("🎯 중첩된 탭 컴포넌트 선택:", event.detail); + + setSelectedTabComponentInfo({ + tabsComponentId, + tabId, + componentId, + component, + parentSplitPanelId, + parentPanelSide, + }); + setSelectedComponent(null); + setSelectedPanelComponentInfo(null); + openPanel("v2"); + }; + + window.addEventListener("nested-tab-component-select", handleNestedTabComponentSelect as EventListener); + + return () => { + window.removeEventListener("nested-tab-component-select", handleNestedTabComponentSelect as EventListener); + }; + }, [openPanel]); + + // 클립보드 상태 + const [clipboard, setClipboard] = useState([]); + + // 실행취소/다시실행을 위한 히스토리 상태 + const [history, setHistory] = useState([]); + const [historyIndex, setHistoryIndex] = useState(-1); + + // 그룹 상태 + const [groupState, setGroupState] = useState({ + selectedComponents: [], + isGrouping: false, + }); + + // 드래그 상태 + const [dragState, setDragState] = useState({ + isDragging: false, + draggedComponent: null as ComponentData | null, + draggedComponents: [] as ComponentData[], // 다중 드래그를 위한 컴포넌트 배열 + originalPosition: { x: 0, y: 0, z: 1 }, + currentPosition: { x: 0, y: 0, z: 1 }, + grabOffset: { x: 0, y: 0 }, + justFinishedDrag: false, // 드래그 종료 직후 클릭 방지용 + }); + + // Pan 모드 상태 (스페이스바 + 드래그) + const [isPanMode, setIsPanMode] = useState(false); + const [panState, setPanState] = useState({ + isPanning: false, + startX: 0, + startY: 0, + outerScrollLeft: 0, + outerScrollTop: 0, + innerScrollLeft: 0, + innerScrollTop: 0, + }); + const canvasContainerRef = useRef(null); + + // Zoom 상태 + const [zoomLevel, setZoomLevel] = useState(1); // 1 = 100% + const MIN_ZOOM = 0.1; // 10% + const MAX_ZOOM = 3; // 300% + const zoomRafRef = useRef(null); // 줌 RAF throttle용 + + // 전역 파일 상태 변경 시 강제 리렌더링을 위한 상태 + const [forceRenderTrigger, setForceRenderTrigger] = useState(0); + + // 파일 컴포넌트 데이터 복원 함수 (실제 DB에서 조회) + const restoreFileComponentsData = useCallback( + async (components: ComponentData[]) => { + if (!selectedScreen?.screenId) return; + + // console.log("🔄 파일 컴포넌트 데이터 복원 시작:", components.length); + + try { + // 실제 DB에서 화면의 모든 파일 정보 조회 + const fileResponse = await ScreenFileAPI.getScreenComponentFiles(selectedScreen.screenId); + + if (!fileResponse.success) { + // console.warn("⚠️ 파일 정보 조회 실패:", fileResponse); + return; + } + + const { componentFiles } = fileResponse; + + if (typeof window !== "undefined") { + // 전역 파일 상태 초기화 + const globalFileState: { [key: string]: any[] } = {}; + let restoredCount = 0; + + // DB에서 조회한 파일 정보를 전역 상태로 복원 + Object.keys(componentFiles).forEach((componentId) => { + const files = componentFiles[componentId]; + if (files && files.length > 0) { + globalFileState[componentId] = files; + restoredCount++; + + // localStorage에도 백업 + const backupKey = `fileComponent_${componentId}_files`; + localStorage.setItem(backupKey, JSON.stringify(files)); + + console.log("📁 DB에서 파일 컴포넌트 데이터 복원:", { + componentId: componentId, + fileCount: files.length, + files: files.map((f) => ({ objid: f.objid, name: f.realFileName })), + }); + } + }); + + // 전역 상태 업데이트 + (window as any).globalFileState = globalFileState; + + // 모든 파일 컴포넌트에 복원 완료 이벤트 발생 + Object.keys(globalFileState).forEach((componentId) => { + const files = globalFileState[componentId]; + const syncEvent = new CustomEvent("globalFileStateChanged", { + detail: { + componentId: componentId, + files: files, + fileCount: files.length, + timestamp: Date.now(), + isRestore: true, + }, + }); + window.dispatchEvent(syncEvent); + }); + + if (restoredCount > 0) { + toast.success( + `${restoredCount}개 파일 컴포넌트 데이터가 DB에서 복원되었습니다. (총 ${fileResponse.totalFiles}개 파일)`, + ); + } + } + } catch (error) { + // console.error("❌ 파일 컴포넌트 데이터 복원 실패:", error); + toast.error("파일 데이터 복원 중 오류가 발생했습니다."); + } + }, + [selectedScreen?.screenId], + ); + + // 드래그 선택 상태 + const [selectionDrag, setSelectionDrag] = useState({ + isSelecting: false, + startPoint: { x: 0, y: 0, z: 1 }, + currentPoint: { x: 0, y: 0, z: 1 }, + wasSelecting: false, // 방금 전에 드래그 선택이 진행 중이었는지 추적 + }); + + // 테이블 데이터 + const [tables, setTables] = useState([]); + const [searchTerm, setSearchTerm] = useState(""); + + // 🆕 검색어로 필터링된 테이블 목록 + const filteredTables = useMemo(() => { + if (!searchTerm.trim()) return tables; + const term = searchTerm.toLowerCase(); + return tables.filter( + (table) => + table.tableName.toLowerCase().includes(term) || + table.columns?.some((col) => col.columnName.toLowerCase().includes(term)), + ); + }, [tables, searchTerm]); + + // 그룹 생성 다이얼로그 + const [showGroupCreateDialog, setShowGroupCreateDialog] = useState(false); + + const canvasRef = useRef(null); + + // 10px 격자 라인 생성 (시각적 가이드용) + const gridLines = useMemo(() => { + if (!layout.gridSettings?.showGrid) return []; + + const width = screenResolution.width; + const height = screenResolution.height; + const lines: Array<{ type: "vertical" | "horizontal"; position: number }> = []; + + // 10px 단위로 격자 라인 생성 + for (let x = 0; x <= width; x += 10) { + lines.push({ type: "vertical", position: x }); + } + for (let y = 0; y <= height; y += 10) { + lines.push({ type: "horizontal", position: y }); + } + + return lines; + }, [layout.gridSettings?.showGrid, screenResolution.width, screenResolution.height]); + + // 🆕 현재 편집 중인 레이어 ID (DB의 layer_id, 1 = 기본 레이어) + const [activeLayerId, setActiveLayerIdLocal] = useState(1); + const activeLayerIdRef = useRef(1); + const setActiveLayerIdWithRef = useCallback((id: number) => { + setActiveLayerIdLocal(id); + activeLayerIdRef.current = id; + }, []); + + // 🆕 좌측 패널 탭 상태 관리 + const [leftPanelTab, setLeftPanelTab] = useState("components"); + + // 🆕 조건부 영역(Zone) 목록 (DB screen_conditional_zones 기반) + const [zones, setZones] = useState([]); + + // 🆕 조건부 영역 드래그 상태 (캔버스에서 드래그로 영역 설정) + const [regionDrag, setRegionDrag] = useState<{ + isDrawing: boolean; // 새 영역 그리기 모드 + isDragging: boolean; // 기존 영역 이동 모드 + isResizing: boolean; // 기존 영역 리사이즈 모드 + targetLayerId: string | null; // 대상 Zone 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, + }); + + // 🆕 현재 활성 레이어의 Zone 정보 (캔버스 크기 결정용) + const [activeLayerZone, setActiveLayerZone] = useState(null); + + // 🆕 activeLayerId 변경 시 해당 레이어의 Zone 찾기 + useEffect(() => { + if (activeLayerId <= 1 || !selectedScreen?.screenId) { + setActiveLayerZone(null); + return; + } + // 레이어의 condition_config에서 zone_id를 가져와서 zones에서 찾기 + const findZone = async () => { + try { + const layerData = await screenApi.getLayerLayout(selectedScreen.screenId, activeLayerId); + const zoneId = layerData?.conditionConfig?.zone_id; + if (zoneId) { + const zone = zones.find(z => z.zone_id === zoneId); + setActiveLayerZone(zone || null); + } else { + setActiveLayerZone(null); + } + } catch { + setActiveLayerZone(null); + } + }; + findZone(); + }, [activeLayerId, selectedScreen?.screenId, zones]); + + // 캔버스에 렌더링할 컴포넌트 (DB 기반 레이어: 각 레이어별로 별도 로드되므로 전체 표시) + const visibleComponents = useMemo(() => { + return layout.components; + }, [layout.components]); + + // 이미 배치된 컬럼 목록 계산 + const placedColumns = useMemo(() => { + const placed = new Set(); + // 🔧 화면의 메인 테이블명을 fallback으로 사용 + const screenTableName = selectedScreen?.tableName; + + const collectColumns = (components: ComponentData[]) => { + components.forEach((comp) => { + const anyComp = comp as any; + + // 🔧 tableName과 columnName을 여러 위치에서 찾기 (최상위, componentConfig, 또는 화면 테이블명) + const tableName = anyComp.tableName || anyComp.componentConfig?.tableName || screenTableName; + const columnName = anyComp.columnName || anyComp.componentConfig?.columnName; + + // widget 타입 또는 component 타입에서 columnName 확인 (tableName은 화면 테이블명으로 fallback) + if ((comp.type === "widget" || comp.type === "component") && tableName && columnName) { + const key = `${tableName}.${columnName}`; + placed.add(key); + } + + // 자식 컴포넌트도 확인 (재귀) + if (comp.children && comp.children.length > 0) { + collectColumns(comp.children); + } + }); + }; + + collectColumns(layout.components); + return placed; + }, [layout.components, selectedScreen?.tableName]); + + // 히스토리에 저장 + const saveToHistory = useCallback( + (newLayout: LayoutData) => { + setHistory((prev) => { + const newHistory = prev.slice(0, historyIndex + 1); + newHistory.push(newLayout); + return newHistory.slice(-50); // 최대 50개까지만 저장 + }); + setHistoryIndex((prev) => Math.min(prev + 1, 49)); + }, + [historyIndex], + ); + + // 🆕 탭 내부 컴포넌트 설정 업데이트 핸들러 (중첩 구조 지원) + const handleUpdateTabComponentConfig = useCallback( + (path: string, value: any) => { + if (!selectedTabComponentInfo) return; + + const { tabsComponentId, tabId, componentId, parentSplitPanelId, parentPanelSide } = selectedTabComponentInfo; + + // 탭 컴포넌트 업데이트 함수 (재사용) + const updateTabsComponent = (tabsComponent: any) => { + const currentConfig = tabsComponent.componentConfig || {}; + const tabs = currentConfig.tabs || []; + + const updatedTabs = tabs.map((tab: any) => { + if (tab.id === tabId) { + return { + ...tab, + components: (tab.components || []).map((comp: any) => { + if (comp.id === componentId) { + if (path.startsWith("componentConfig.")) { + const configPath = path.replace("componentConfig.", ""); + return { + ...comp, + componentConfig: { ...comp.componentConfig, [configPath]: value }, + }; + } else if (path.startsWith("style.")) { + const stylePath = path.replace("style.", ""); + return { ...comp, style: { ...comp.style, [stylePath]: value } }; + } else if (path.startsWith("size.")) { + const sizePath = path.replace("size.", ""); + return { ...comp, size: { ...comp.size, [sizePath]: value } }; + } else { + return { ...comp, [path]: value }; + } + } + return comp; + }), + }; + } + return tab; + }); + + return { ...tabsComponent, componentConfig: { ...currentConfig, tabs: updatedTabs } }; + }; + + setLayout((prevLayout) => { + let newLayout; + let updatedTabs; + + if (parentSplitPanelId && parentPanelSide) { + // 🆕 중첩 구조: 분할 패널 안의 탭 업데이트 + newLayout = { + ...prevLayout, + components: prevLayout.components.map((c) => { + if (c.id === parentSplitPanelId) { + const splitConfig = (c as any).componentConfig || {}; + const panelKey = parentPanelSide === "left" ? "leftPanel" : "rightPanel"; + const panelConfig = splitConfig[panelKey] || {}; + const panelComponents = panelConfig.components || []; + + const tabsComponent = panelComponents.find((pc: any) => pc.id === tabsComponentId); + if (!tabsComponent) return c; + + const updatedTabsComponent = updateTabsComponent(tabsComponent); + updatedTabs = updatedTabsComponent.componentConfig.tabs; + + return { + ...c, + componentConfig: { + ...splitConfig, + [panelKey]: { + ...panelConfig, + components: panelComponents.map((pc: any) => + pc.id === tabsComponentId ? updatedTabsComponent : pc, + ), + }, + }, + }; + } + return c; + }), + }; + } else { + // 일반 구조: 최상위 탭 업데이트 + const tabsComponent = prevLayout.components.find((c) => c.id === tabsComponentId); + if (!tabsComponent) return prevLayout; + + const updatedTabsComponent = updateTabsComponent(tabsComponent); + updatedTabs = updatedTabsComponent.componentConfig.tabs; + + newLayout = { + ...prevLayout, + components: prevLayout.components.map((c) => (c.id === tabsComponentId ? updatedTabsComponent : c)), + }; + } + + // 선택된 컴포넌트 정보도 업데이트 + if (updatedTabs) { + const updatedComp = updatedTabs + .find((t: any) => t.id === tabId) + ?.components?.find((c: any) => c.id === componentId); + if (updatedComp) { + setSelectedTabComponentInfo((prev) => (prev ? { ...prev, component: updatedComp } : null)); + } + } + + return newLayout; + }); + }, + [selectedTabComponentInfo], + ); + + // 실행취소 + const undo = useCallback(() => { + setHistoryIndex((prevIndex) => { + if (prevIndex > 0) { + const newIndex = prevIndex - 1; + setHistory((prevHistory) => { + if (prevHistory[newIndex]) { + setLayout(prevHistory[newIndex]); + } + return prevHistory; + }); + return newIndex; + } + return prevIndex; + }); + }, []); + + // 다시실행 + const redo = useCallback(() => { + setHistoryIndex((prevIndex) => { + let newIndex = prevIndex; + setHistory((prevHistory) => { + if (prevIndex < prevHistory.length - 1) { + newIndex = prevIndex + 1; + if (prevHistory[newIndex]) { + setLayout(prevHistory[newIndex]); + } + } + return prevHistory; + }); + return newIndex; + }); + }, []); + + // 컴포넌트 속성 업데이트 + const updateComponentProperty = useCallback( + (componentId: string, path: string, value: any) => { + // 🔥 함수형 업데이트로 변경하여 최신 layout 사용 + setLayout((prevLayout) => { + const targetComponent = prevLayout.components.find((comp) => comp.id === componentId); + const isLayoutComponent = targetComponent?.type === "layout"; + + // 🆕 그룹 설정 변경 시 같은 그룹의 모든 버튼에 일괄 적용 + const isGroupSetting = path === "webTypeConfig.flowVisibilityConfig.groupAlign"; + + let affectedComponents: string[] = [componentId]; // 기본적으로 현재 컴포넌트만 + + if (isGroupSetting && targetComponent) { + const flowConfig = (targetComponent as any).webTypeConfig?.flowVisibilityConfig; + const currentGroupId = flowConfig?.groupId; + + if (currentGroupId) { + // 같은 그룹의 모든 버튼 찾기 + affectedComponents = prevLayout.components + .filter((comp) => { + const compConfig = (comp as any).webTypeConfig?.flowVisibilityConfig; + return compConfig?.groupId === currentGroupId && compConfig?.enabled; + }) + .map((comp) => comp.id); + + console.log("🔄 그룹 설정 일괄 적용:", { + groupId: currentGroupId, + setting: path.split(".").pop(), + value, + affectedButtons: affectedComponents, + }); + } + } + + // 레이아웃 컴포넌트의 위치가 변경되는 경우 존에 속한 컴포넌트들도 함께 이동 + const positionDelta = { x: 0, y: 0 }; + if (isLayoutComponent && (path === "position.x" || path === "position.y" || path === "position")) { + const oldPosition = targetComponent.position; + let newPosition = { ...oldPosition }; + + if (path === "position.x") { + newPosition.x = value; + positionDelta.x = value - oldPosition.x; + } else if (path === "position.y") { + newPosition.y = value; + positionDelta.y = value - oldPosition.y; + } else if (path === "position") { + newPosition = value; + positionDelta.x = value.x - oldPosition.x; + positionDelta.y = value.y - oldPosition.y; + } + + console.log("📐 레이아웃 이동 감지:", { + layoutId: componentId, + oldPosition, + newPosition, + positionDelta, + }); + } + + const pathParts = path.split("."); + const updatedComponents = prevLayout.components.map((comp) => { + // 🆕 그룹 설정이면 같은 그룹의 모든 버튼에 적용 + const shouldUpdate = isGroupSetting ? affectedComponents.includes(comp.id) : comp.id === componentId; + + if (!shouldUpdate) { + // 레이아웃 이동 시 존에 속한 컴포넌트들도 함께 이동 + if (isLayoutComponent && (positionDelta.x !== 0 || positionDelta.y !== 0)) { + // 이 레이아웃의 존에 속한 컴포넌트인지 확인 + const isInLayoutZone = comp.parentId === componentId && comp.zoneId; + if (isInLayoutZone) { + console.log("🔄 존 컴포넌트 함께 이동:", { + componentId: comp.id, + zoneId: comp.zoneId, + oldPosition: comp.position, + delta: positionDelta, + }); + + return { + ...comp, + position: { + ...comp.position, + x: comp.position.x + positionDelta.x, + y: comp.position.y + positionDelta.y, + }, + }; + } + } + return comp; + } + + // 중첩 경로를 고려한 안전한 복사 + const newComp = { ...comp }; + + // 경로를 따라 내려가면서 각 레벨을 새 객체로 복사 + let current: any = newComp; + for (let i = 0; i < pathParts.length - 1; i++) { + const key = pathParts[i]; + + // 다음 레벨이 없거나 객체가 아니면 새 객체 생성 + if (!current[key] || typeof current[key] !== "object" || Array.isArray(current[key])) { + current[key] = {}; + } else { + // 기존 객체를 복사하여 불변성 유지 + current[key] = { ...current[key] }; + } + current = current[key]; + } + + // 최종 값 설정 + const finalKey = pathParts[pathParts.length - 1]; + current[finalKey] = value; + + // 🔧 style 관련 업데이트 디버그 로그 + if (path.includes("style") || path.includes("labelDisplay")) { + console.log("🎨 style 업데이트 제대로 렌더링된거다 내가바꿈:", { + componentId: comp.id, + path, + value, + updatedStyle: newComp.style, + pathIncludesLabelDisplay: path.includes("labelDisplay"), + }); + } + + // 🆕 labelDisplay 변경 시 강제 리렌더링 트리거 (조건문 밖으로 이동) + if (path === "style.labelDisplay") { + console.log("⏰⏰⏰ labelDisplay 변경 감지! forceRenderTrigger 실행 예정"); + } + + // 🆕 size 변경 시 style도 함께 업데이트 (파란 테두리와 실제 크기 동기화) + if (path === "size.width" || path === "size.height" || path === "size") { + // 🔧 style 객체를 새로 복사하여 불변성 유지 + newComp.style = { ...(newComp.style || {}) }; + + if (path === "size.width") { + newComp.style.width = `${value}px`; + } else if (path === "size.height") { + newComp.style.height = `${value}px`; + } else if (path === "size") { + // size 객체 전체가 변경된 경우 + if (value.width !== undefined) { + newComp.style.width = `${value.width}px`; + } + if (value.height !== undefined) { + newComp.style.height = `${value.height}px`; + } + } + + console.log("🔄 size 변경 → style 동기화:", { + componentId: newComp.id, + path, + value, + updatedStyle: newComp.style, + }); + } + + // gridColumns 변경 시 크기 자동 업데이트 제거 (격자 시스템 제거됨) + // if (path === "gridColumns" && prevLayout.gridSettings) { + // const updatedSize = updateSizeFromGridColumns(newComp, prevLayout.gridSettings as GridUtilSettings); + // newComp.size = updatedSize; + // } + + // 크기 변경 시 격자 스냅 적용 제거 (직접 입력 시 불필요) + // 드래그/리사이즈 시에는 별도 로직에서 처리됨 + // if ( + // (path === "size.width" || path === "size.height") && + // prevLayout.gridSettings?.snapToGrid && + // newComp.type !== "group" + // ) { + // const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, { + // columns: prevLayout.gridSettings.columns, + // gap: prevLayout.gridSettings.gap, + // padding: prevLayout.gridSettings.padding, + // snapToGrid: prevLayout.gridSettings.snapToGrid || false, + // }); + // const snappedSize = snapSizeToGrid( + // newComp.size, + // currentGridInfo, + // prevLayout.gridSettings as GridUtilSettings, + // ); + // newComp.size = snappedSize; + // + // const adjustedColumns = adjustGridColumnsFromSize( + // newComp, + // currentGridInfo, + // prevLayout.gridSettings as GridUtilSettings, + // ); + // if (newComp.gridColumns !== adjustedColumns) { + // newComp.gridColumns = adjustedColumns; + // } + // } + + // gridColumns 변경 시 크기를 격자에 맞게 자동 조정 제거 (격자 시스템 제거됨) + // if (path === "gridColumns" && prevLayout.gridSettings?.snapToGrid && newComp.type !== "group") { + // const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, { + // columns: prevLayout.gridSettings.columns, + // gap: prevLayout.gridSettings.gap, + // padding: prevLayout.gridSettings.padding, + // snapToGrid: prevLayout.gridSettings.snapToGrid || false, + // }); + // + // const newWidth = calculateWidthFromColumns( + // newComp.gridColumns, + // currentGridInfo, + // prevLayout.gridSettings as GridUtilSettings, + // ); + // newComp.size = { + // ...newComp.size, + // width: newWidth, + // }; + // } + + // 위치 변경 시 격자 스냅 적용 (그룹 내부 컴포넌트 포함) + if ( + (path === "position.x" || path === "position.y" || path === "position") && + layout.gridSettings?.snapToGrid + ) { + // 현재 해상도에 맞는 격자 정보 계산 + const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, { + columns: layout.gridSettings.columns, + gap: layout.gridSettings.gap, + padding: layout.gridSettings.padding, + snapToGrid: layout.gridSettings.snapToGrid || false, + }); + + // 그룹 내부 컴포넌트인 경우 패딩을 고려한 격자 스냅 적용 + if (newComp.parentId && currentGridInfo) { + const { columnWidth } = currentGridInfo; + const { gap } = layout.gridSettings; + + // 그룹 내부 패딩 고려한 격자 정렬 + const padding = 16; + const effectiveX = newComp.position.x - padding; + const columnIndex = Math.round(effectiveX / (columnWidth + (gap || 16))); + const snappedX = padding + columnIndex * (columnWidth + (gap || 16)); + + // Y 좌표는 10px 단위로 스냅 + const effectiveY = newComp.position.y - padding; + const rowIndex = Math.round(effectiveY / 10); + const snappedY = padding + rowIndex * 10; + + // 크기도 외부 격자와 동일하게 스냅 + const fullColumnWidth = columnWidth + (gap || 16); // 외부 격자와 동일한 크기 + const widthInColumns = Math.max(1, Math.round(newComp.size.width / fullColumnWidth)); + const snappedWidth = widthInColumns * fullColumnWidth - (gap || 16); // gap 제거하여 실제 컴포넌트 크기 + // 높이는 사용자가 입력한 값 그대로 사용 (스냅 제거) + const snappedHeight = Math.max(10, newComp.size.height); + + newComp.position = { + x: Math.max(padding, snappedX), // 패딩만큼 최소 여백 확보 + y: Math.max(padding, snappedY), + z: newComp.position.z || 1, + }; + + newComp.size = { + width: snappedWidth, + height: snappedHeight, + }; + } else if (newComp.type !== "group") { + // 그룹이 아닌 일반 컴포넌트만 격자 스냅 적용 + const snappedPosition = snapPositionTo10px( + newComp.position, + currentGridInfo, + layout.gridSettings as GridUtilSettings, + ); + newComp.position = snappedPosition; + } + } + + return newComp; + }); + + // 🔥 새로운 layout 생성 + const newLayout = { ...prevLayout, components: updatedComponents }; + + saveToHistory(newLayout); + + // selectedComponent가 업데이트된 컴포넌트와 같다면 selectedComponent도 업데이트 + setSelectedComponent((prevSelected) => { + if (prevSelected && prevSelected.id === componentId) { + const updatedSelectedComponent = updatedComponents.find((c) => c.id === componentId); + if (updatedSelectedComponent) { + // 🔧 완전히 새로운 객체를 만들어서 React가 변경을 감지하도록 함 + const newSelectedComponent = JSON.parse(JSON.stringify(updatedSelectedComponent)); + return newSelectedComponent; + } + } + return prevSelected; + }); + + // webTypeConfig 업데이트 후 레이아웃 상태 확인 + if (path === "webTypeConfig") { + const updatedComponent = newLayout.components.find((c) => c.id === componentId); + console.log("🔄 레이아웃 업데이트 후 컴포넌트 상태:", { + componentId, + updatedComponent: updatedComponent + ? { + id: updatedComponent.id, + type: updatedComponent.type, + webTypeConfig: updatedComponent.type === "widget" ? (updatedComponent as any).webTypeConfig : null, + } + : null, + layoutComponentsCount: newLayout.components.length, + timestamp: new Date().toISOString(), + }); + } + + return newLayout; + }); + }, + [saveToHistory], + ); + + // 컴포넌트 시스템 초기화 + useEffect(() => { + const initComponents = async () => { + try { + // console.log("🚀 컴포넌트 시스템 초기화 시작..."); + await initializeComponents(); + // console.log("✅ 컴포넌트 시스템 초기화 완료"); + } catch (error) { + // console.error("❌ 컴포넌트 시스템 초기화 실패:", error); + } + }; + + initComponents(); + }, []); + + // 화면 선택 시 파일 복원 + useEffect(() => { + if (selectedScreen?.screenId) { + restoreScreenFiles(); + } + }, [selectedScreen?.screenId]); + + // 화면의 모든 파일 컴포넌트 파일 복원 + const restoreScreenFiles = useCallback(async () => { + if (!selectedScreen?.screenId) return; + + try { + // console.log("🔄 화면 파일 복원 시작:", selectedScreen.screenId); + + // 해당 화면의 모든 파일 조회 + const response = await ScreenFileAPI.getScreenComponentFiles(selectedScreen.screenId); + + if (response.success && response.componentFiles) { + // console.log("📁 복원할 파일 데이터:", response.componentFiles); + + // 각 컴포넌트별로 파일 복원 (전역 상태와 localStorage 우선 적용) + Object.entries(response.componentFiles).forEach(([componentId, serverFiles]) => { + if (Array.isArray(serverFiles) && serverFiles.length > 0) { + // 🎯 전역 상태와 localStorage에서 현재 파일 상태 확인 + const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {}; + const currentGlobalFiles = globalFileState[componentId] || []; + + let currentLocalStorageFiles: any[] = []; + if (typeof window !== "undefined") { + try { + const storedFiles = localStorage.getItem(`fileComponent_${componentId}_files`); + if (storedFiles) { + currentLocalStorageFiles = JSON.parse(storedFiles); + } + } catch (e) { + // console.warn("localStorage 파일 파싱 실패:", e); + } + } + + // 🎯 우선순위: 전역 상태 > localStorage > 서버 데이터 + let finalFiles = serverFiles; + if (currentGlobalFiles.length > 0) { + finalFiles = currentGlobalFiles; + // console.log(`📂 컴포넌트 ${componentId} 전역 상태 우선 적용:`, finalFiles.length, "개"); + } else if (currentLocalStorageFiles.length > 0) { + finalFiles = currentLocalStorageFiles; + // console.log(`📂 컴포넌트 ${componentId} localStorage 우선 적용:`, finalFiles.length, "개"); + } else { + // console.log(`📂 컴포넌트 ${componentId} 서버 데이터 적용:`, finalFiles.length, "개"); + } + + // 전역 상태에 파일 저장 + globalFileState[componentId] = finalFiles; + if (typeof window !== "undefined") { + (window as any).globalFileState = globalFileState; + } + + // localStorage에도 백업 + if (typeof window !== "undefined") { + localStorage.setItem(`fileComponent_${componentId}_files`, JSON.stringify(finalFiles)); + } + } + }); + + // 레이아웃의 컴포넌트들에 파일 정보 적용 (전역 상태 우선) + setLayout((prevLayout) => { + const updatedComponents = prevLayout.components.map((comp) => { + // 🎯 전역 상태에서 최신 파일 정보 가져오기 + const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {}; + const finalFiles = globalFileState[comp.id] || []; + + if (finalFiles.length > 0) { + return { + ...comp, + uploadedFiles: finalFiles, + lastFileUpdate: Date.now(), + }; + } + return comp; + }); + + return { + ...prevLayout, + components: updatedComponents, + }; + }); + + // console.log("✅ 화면 파일 복원 완료"); + } + } catch (error) { + // console.error("❌ 화면 파일 복원 오류:", error); + } + }, [selectedScreen?.screenId]); + + // 전역 파일 상태 변경 이벤트 리스너 + useEffect(() => { + const handleGlobalFileStateChange = (event: CustomEvent) => { + // console.log("🔄 ScreenDesigner: 전역 파일 상태 변경 감지", event.detail); + setForceRenderTrigger((prev) => prev + 1); + }; + + if (typeof window !== "undefined") { + window.addEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener); + + return () => { + window.removeEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener); + }; + } + }, []); + + // 화면의 기본 테이블/REST API 정보 로드 + useEffect(() => { + const loadScreenDataSource = async () => { + console.log("🔍 [ScreenDesigner] 데이터 소스 로드 시작:", { + screenId: selectedScreen?.screenId, + screenName: selectedScreen?.screenName, + dataSourceType: selectedScreen?.dataSourceType, + tableName: selectedScreen?.tableName, + restApiConnectionId: selectedScreen?.restApiConnectionId, + restApiEndpoint: selectedScreen?.restApiEndpoint, + restApiJsonPath: selectedScreen?.restApiJsonPath, + // 전체 selectedScreen 객체도 출력 + fullScreen: selectedScreen, + }); + + // REST API 데이터 소스인 경우 + // 1. dataSourceType이 "restapi"인 경우 + // 2. tableName이 restapi_ 또는 _restapi_로 시작하는 경우 + // 3. restApiConnectionId가 있는 경우 + const isRestApi = + selectedScreen?.dataSourceType === "restapi" || + selectedScreen?.tableName?.startsWith("restapi_") || + selectedScreen?.tableName?.startsWith("_restapi_") || + !!selectedScreen?.restApiConnectionId; + + console.log("🔍 [ScreenDesigner] REST API 여부:", { isRestApi }); + + if (isRestApi && (selectedScreen?.restApiConnectionId || selectedScreen?.tableName)) { + try { + // 연결 ID 추출 (restApiConnectionId가 없으면 tableName에서 추출) + let connectionId = selectedScreen?.restApiConnectionId; + if (!connectionId && selectedScreen?.tableName) { + const match = selectedScreen.tableName.match(/restapi_(\d+)/); + connectionId = match ? parseInt(match[1]) : undefined; + } + + if (!connectionId) { + throw new Error("REST API 연결 ID를 찾을 수 없습니다."); + } + + console.log("🌐 [ScreenDesigner] REST API 데이터 로드:", { connectionId }); + + const restApiData = await ExternalRestApiConnectionAPI.fetchData( + connectionId, + selectedScreen?.restApiEndpoint, + selectedScreen?.restApiJsonPath || "response", // 기본값을 response로 변경 + ); + + // REST API 응답에서 컬럼 정보 생성 + const columns: ColumnInfo[] = restApiData.columns.map((col) => ({ + tableName: `restapi_${connectionId}`, + columnName: col.columnName, + columnLabel: col.columnLabel, + dataType: col.dataType === "string" ? "varchar" : col.dataType === "number" ? "numeric" : col.dataType, + webType: col.dataType === "number" ? "number" : "text", + input_type: "text", + widgetType: col.dataType === "number" ? "number" : "text", + isNullable: "YES", + required: false, + })); + + const tableInfo: TableInfo = { + tableName: `restapi_${connectionId}`, + tableLabel: restApiData.connectionInfo.connectionName || "REST API 데이터", + columns, + }; + + console.log("✅ [ScreenDesigner] REST API 컬럼 로드 완료:", { + tableName: tableInfo.tableName, + tableLabel: tableInfo.tableLabel, + columnsCount: columns.length, + columns: columns.map((c) => c.columnName), + }); + + setTables([tableInfo]); + console.log("REST API 데이터 소스 로드 완료:", { + connectionName: restApiData.connectionInfo.connectionName, + columnsCount: columns.length, + rowsCount: restApiData.total, + }); + } catch (error) { + console.error("REST API 데이터 소스 로드 실패:", error); + toast.error("REST API 데이터를 불러오는데 실패했습니다."); + setTables([]); + } + return; + } + + // 데이터베이스 데이터 소스인 경우 (기존 로직) + const tableName = selectedScreen?.tableName; + if (!tableName) { + setTables([]); + return; + } + + try { + // 테이블 라벨 조회 + const tableListResponse = await tableManagementApi.getTableList(); + const currentTable = + tableListResponse.success && tableListResponse.data + ? tableListResponse.data.find((t) => t.tableName === tableName) + : null; + const tableLabel = currentTable?.displayName || tableName; + + // 현재 화면의 테이블 컬럼 정보 조회 (캐시 버스팅으로 최신 데이터 가져오기) + const columnsResponse = await tableTypeApi.getColumns(tableName, true); + + const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => { + // widgetType 결정: inputType(entity 등) > webType > widget_type + const inputType = col.inputType || col.input_type; + const widgetType = inputType || col.widgetType || col.widget_type || col.webType || col.web_type; + + // detailSettings 파싱 (문자열이면 JSON 파싱) + let detailSettings = col.detailSettings || col.detail_settings; + if (typeof detailSettings === "string") { + // JSON 형식인 경우에만 파싱 시도 (중괄호로 시작하는 경우) + if (detailSettings.trim().startsWith("{")) { + try { + detailSettings = JSON.parse(detailSettings); + } catch (e) { + console.warn("detailSettings 파싱 실패:", e); + detailSettings = {}; + } + } else { + // JSON이 아닌 일반 문자열인 경우 빈 객체로 처리 + detailSettings = {}; + } + } + + // 엔티티 타입 디버깅 + if (inputType === "entity" || widgetType === "entity") { + console.log("🔍 엔티티 컬럼 감지:", { + columnName: col.columnName || col.column_name, + inputType, + widgetType, + detailSettings, + referenceTable: detailSettings?.referenceTable || col.referenceTable || col.reference_table, + }); + } + + return { + tableName: col.tableName || tableName, + columnName: col.columnName || col.column_name, + columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name, + dataType: col.dataType || col.data_type || col.dbType, + webType: col.webType || col.web_type, + input_type: inputType, + inputType: inputType, + widgetType, + isNullable: col.isNullable || col.is_nullable, + required: col.required !== undefined ? col.required : col.isNullable === "NO" || col.is_nullable === "NO", + columnDefault: col.columnDefault || col.column_default, + characterMaximumLength: col.characterMaximumLength || col.character_maximum_length, + codeCategory: col.codeCategory || col.code_category, + codeValue: col.codeValue || col.code_value, + // 엔티티 타입용 참조 테이블 정보 (detailSettings에서 추출) + referenceTable: detailSettings?.referenceTable || col.referenceTable || col.reference_table, + referenceColumn: detailSettings?.referenceColumn || col.referenceColumn || col.reference_column, + displayColumn: detailSettings?.displayColumn || col.displayColumn || col.display_column, + // detailSettings 전체 보존 (V2 컴포넌트용) + detailSettings, + }; + }); + + const tableInfo: TableInfo = { + tableName, + tableLabel, + columns, + }; + + setTables([tableInfo]); // 현재 화면의 테이블만 저장 (원래대로) + } catch (error) { + console.error("화면 테이블 정보 로드 실패:", error); + setTables([]); + } + }; + + loadScreenDataSource(); + }, [ + selectedScreen?.tableName, + selectedScreen?.screenName, + selectedScreen?.dataSourceType, + selectedScreen?.restApiConnectionId, + selectedScreen?.restApiEndpoint, + selectedScreen?.restApiJsonPath, + ]); + + // 테이블 선택 핸들러 - 사이드바에서 테이블 선택 시 호출 + const handleTableSelect = useCallback( + async (tableName: string) => { + console.log("📊 테이블 선택:", tableName); + + try { + // 테이블 라벨 조회 + const tableListResponse = await tableManagementApi.getTableList(); + const currentTable = + tableListResponse.success && tableListResponse.data + ? tableListResponse.data.find((t: any) => (t.tableName || t.table_name) === tableName) + : null; + const tableLabel = currentTable?.displayName || currentTable?.table_label || tableName; + + // 테이블 컬럼 정보 조회 + const columnsResponse = await tableTypeApi.getColumns(tableName, true); + + const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => { + const inputType = col.inputType || col.input_type; + const widgetType = inputType || col.widgetType || col.widget_type || col.webType || col.web_type; + + let detailSettings = col.detailSettings || col.detail_settings; + if (typeof detailSettings === "string") { + try { + detailSettings = JSON.parse(detailSettings); + } catch (e) { + detailSettings = {}; + } + } + + return { + tableName: col.tableName || tableName, + columnName: col.columnName || col.column_name, + columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name, + dataType: col.dataType || col.data_type || col.dbType, + webType: col.webType || col.web_type, + input_type: inputType, + inputType: inputType, + widgetType, + isNullable: col.isNullable || col.is_nullable, + required: col.required !== undefined ? col.required : col.isNullable === "NO" || col.is_nullable === "NO", + columnDefault: col.columnDefault || col.column_default, + characterMaximumLength: col.characterMaximumLength || col.character_maximum_length, + codeCategory: col.codeCategory || col.code_category, + codeValue: col.codeValue || col.code_value, + referenceTable: detailSettings?.referenceTable || col.referenceTable || col.reference_table, + referenceColumn: detailSettings?.referenceColumn || col.referenceColumn || col.reference_column, + displayColumn: detailSettings?.displayColumn || col.displayColumn || col.display_column, + detailSettings, + }; + }); + + const tableInfo: TableInfo = { + tableName, + tableLabel, + columns, + }; + + setTables([tableInfo]); + toast.success(`테이블 "${tableLabel}" 선택됨`); + + // 기존 테이블과 다른 테이블 선택 시, 기존 컴포넌트 중 다른 테이블 컬럼은 제거 + if (tables.length > 0 && tables[0].tableName !== tableName) { + setLayout((prev) => { + const newComponents = prev.components.filter((comp) => { + // 테이블 컬럼 기반 컴포넌트인지 확인 + if (comp.tableName && comp.tableName !== tableName) { + console.log("🗑️ 다른 테이블 컴포넌트 제거:", comp.tableName, comp.columnName); + return false; + } + return true; + }); + + if (newComponents.length < prev.components.length) { + toast.info( + `이전 테이블(${tables[0].tableName})의 컴포넌트가 ${prev.components.length - newComponents.length}개 제거되었습니다.`, + ); + } + + return { + ...prev, + components: newComponents, + }; + }); + } + } catch (error) { + console.error("테이블 정보 로드 실패:", error); + toast.error("테이블 정보를 불러오는데 실패했습니다."); + } + }, + [tables], + ); + + // 화면 레이아웃 로드 + useEffect(() => { + if (selectedScreen?.screenId) { + // 현재 화면 ID를 전역 변수로 설정 (파일 업로드 시 사용) + if (typeof window !== "undefined") { + (window as any).__CURRENT_SCREEN_ID__ = selectedScreen.screenId; + } + + const loadLayout = async () => { + try { + // 🆕 화면에 할당된 메뉴 조회 + const menuInfo = await screenApi.getScreenMenu(selectedScreen.screenId); + if (menuInfo) { + setMenuObjid(menuInfo.menuObjid); + console.log("🔗 화면에 할당된 메뉴:", menuInfo); + } else { + console.warn("⚠️ 화면에 할당된 메뉴가 없습니다"); + } + + // V2/POP API 사용 여부에 따라 분기 + let response: any; + if (USE_POP_API) { + // POP 모드: screen_layouts_pop 테이블 사용 + const popResponse = await screenApi.getLayoutPop(selectedScreen.screenId); + response = popResponse ? convertV2ToLegacy(popResponse) : null; + console.log("📱 POP 레이아웃 로드:", popResponse?.components?.length || 0, "개 컴포넌트"); + } else if (USE_V2_API) { + // 데스크톱 V2 모드: screen_layouts_v2 테이블 사용 + const v2Response = await screenApi.getLayoutV2(selectedScreen.screenId); + + // 🐛 디버깅: API 응답에서 fieldMapping.id 확인 + const splitPanelInV2 = v2Response?.components?.find((c: any) => c.url?.includes("v2-split-panel-layout")); + const finishedTimelineInV2 = splitPanelInV2?.overrides?.rightPanel?.components?.find( + (c: any) => c.id === "finished_timeline", + ); + console.log("🐛 [API 응답 RAW] finished_timeline:", JSON.stringify(finishedTimelineInV2, null, 2)); + console.log("🐛 [API 응답] finished_timeline fieldMapping:", { + fieldMapping: JSON.stringify(finishedTimelineInV2?.componentConfig?.fieldMapping), + fieldMappingKeys: finishedTimelineInV2?.componentConfig?.fieldMapping + ? Object.keys(finishedTimelineInV2?.componentConfig?.fieldMapping) + : [], + hasId: !!finishedTimelineInV2?.componentConfig?.fieldMapping?.id, + idValue: finishedTimelineInV2?.componentConfig?.fieldMapping?.id, + }); + + response = v2Response ? convertV2ToLegacy(v2Response) : null; + } else { + response = await screenApi.getLayout(selectedScreen.screenId); + } + + if (response) { + // 🔄 마이그레이션 필요 여부 확인 (V2는 스킵) + let layoutToUse = response; + + if (!USE_V2_API && needsMigration(response)) { + const canvasWidth = response.screenResolution?.width || 1920; + layoutToUse = safeMigrateLayout(response, canvasWidth); + } + + // 🔄 webTypeConfig를 autoGeneration으로 변환 + const { convertLayoutComponents } = await import("@/lib/utils/webTypeConfigConverter"); + const convertedComponents = convertLayoutComponents(layoutToUse.components); + + // 기본 격자 설정 보장 (격자 표시와 스냅 기본 활성화) + const layoutWithDefaultGrid = { + ...layoutToUse, + components: convertedComponents, // 변환된 컴포넌트 사용 + gridSettings: { + columns: layoutToUse.gridSettings?.columns || 12, // DB 값 우선, 없으면 기본값 12 + gap: layoutToUse.gridSettings?.gap ?? 16, // DB 값 우선, 없으면 기본값 16 + padding: 0, // padding은 항상 0으로 강제 + snapToGrid: layoutToUse.gridSettings?.snapToGrid ?? true, // DB 값 우선 + showGrid: layoutToUse.gridSettings?.showGrid ?? false, // DB 값 우선 + gridColor: layoutToUse.gridSettings?.gridColor || "#d1d5db", + gridOpacity: layoutToUse.gridSettings?.gridOpacity ?? 0.5, + }, + }; + + // 저장된 해상도 정보가 있으면 적용, 없으면 기본값 사용 + if (layoutToUse.screenResolution) { + setScreenResolution(layoutToUse.screenResolution); + // console.log("💾 저장된 해상도 불러옴:", layoutToUse.screenResolution); + } else { + // 기본 해상도 (Full HD) + const defaultResolution = + SCREEN_RESOLUTIONS.find((r) => r.name === "Full HD (1920×1080)") || SCREEN_RESOLUTIONS[0]; + setScreenResolution(defaultResolution); + // console.log("🔧 기본 해상도 적용:", defaultResolution); + } + + // 🔍 디버깅: 로드된 버튼 컴포넌트의 action 확인 + const buttonComponents = layoutWithDefaultGrid.components.filter((c: any) => + c.componentType?.startsWith("button"), + ); + console.log( + "🔍 [로드] 버튼 컴포넌트 action 확인:", + buttonComponents.map((c: any) => ({ + id: c.id, + type: c.componentType, + actionType: c.componentConfig?.action?.type, + fullAction: c.componentConfig?.action, + })), + ); + + setLayout(layoutWithDefaultGrid); + setHistory([layoutWithDefaultGrid]); + setHistoryIndex(0); + + // 파일 컴포넌트 데이터 복원 (비동기) + restoreFileComponentsData(layoutWithDefaultGrid.components); + + // 🆕 조건부 영역(Zone) 로드 + try { + const loadedZones = await screenApi.getScreenZones(selectedScreen.screenId); + setZones(loadedZones); + } catch { /* Zone 로드 실패 무시 */ } + } + } catch (error) { + // console.error("레이아웃 로드 실패:", error); + toast.error("화면 레이아웃을 불러오는데 실패했습니다."); + } + }; + loadLayout(); + } + }, [selectedScreen?.screenId]); + + // 스페이스바 키 이벤트 처리 (Pan 모드) + 전역 마우스 이벤트 + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // 입력 필드에서는 스페이스바 무시 (activeElement로 정확하게 체크) + const activeElement = document.activeElement; + if ( + activeElement instanceof HTMLInputElement || + activeElement instanceof HTMLTextAreaElement || + activeElement?.getAttribute("contenteditable") === "true" || + activeElement?.getAttribute("role") === "textbox" + ) { + return; + } + + // e.target도 함께 체크 (이중 방어) + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { + return; + } + + if (e.code === "Space") { + e.preventDefault(); // 스페이스바 기본 스크롤 동작 차단 + if (!isPanMode) { + setIsPanMode(true); + // body에 커서 스타일 추가 + document.body.style.cursor = "grab"; + } + } + }; + + const handleKeyUp = (e: KeyboardEvent) => { + // 입력 필드에서는 스페이스바 무시 + const activeElement = document.activeElement; + if ( + activeElement instanceof HTMLInputElement || + activeElement instanceof HTMLTextAreaElement || + activeElement?.getAttribute("contenteditable") === "true" || + activeElement?.getAttribute("role") === "textbox" + ) { + return; + } + + if (e.code === "Space") { + e.preventDefault(); // 스페이스바 기본 스크롤 동작 차단 + setIsPanMode(false); + setPanState((prev) => ({ ...prev, isPanning: false })); + // body 커서 스타일 복원 + document.body.style.cursor = "default"; + } + }; + + const handleMouseDown = (e: MouseEvent) => { + if (isPanMode) { + e.preventDefault(); + // 외부와 내부 스크롤 컨테이너 모두 저장 + setPanState({ + isPanning: true, + startX: e.pageX, + startY: e.pageY, + outerScrollLeft: canvasContainerRef.current?.scrollLeft || 0, + outerScrollTop: canvasContainerRef.current?.scrollTop || 0, + innerScrollLeft: canvasRef.current?.scrollLeft || 0, + innerScrollTop: canvasRef.current?.scrollTop || 0, + }); + // 드래그 중 커서 변경 + document.body.style.cursor = "grabbing"; + } + }; + + const handleMouseMove = (e: MouseEvent) => { + if (isPanMode && panState.isPanning) { + e.preventDefault(); + const dx = e.pageX - panState.startX; + const dy = e.pageY - panState.startY; + + // 외부 컨테이너 스크롤 + if (canvasContainerRef.current) { + canvasContainerRef.current.scrollLeft = panState.outerScrollLeft - dx; + canvasContainerRef.current.scrollTop = panState.outerScrollTop - dy; + } + + // 내부 캔버스 스크롤 + if (canvasRef.current) { + canvasRef.current.scrollLeft = panState.innerScrollLeft - dx; + canvasRef.current.scrollTop = panState.innerScrollTop - dy; + } + } + }; + + const handleMouseUp = () => { + if (isPanMode) { + setPanState((prev) => ({ ...prev, isPanning: false })); + // 드래그 종료 시 커서 복원 + document.body.style.cursor = "grab"; + } + }; + + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keyup", handleKeyUp); + window.addEventListener("mousedown", handleMouseDown); + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keyup", handleKeyUp); + window.removeEventListener("mousedown", handleMouseDown); + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + }, [ + isPanMode, + panState.isPanning, + panState.startX, + panState.startY, + panState.outerScrollLeft, + panState.outerScrollTop, + panState.innerScrollLeft, + panState.innerScrollTop, + ]); + + // 마우스 휠로 줌 제어 (RAF throttle 적용으로 깜빡임 방지) + useEffect(() => { + const handleWheel = (e: WheelEvent) => { + // 캔버스 컨테이너 내에서만 동작 + if (canvasContainerRef.current && canvasContainerRef.current.contains(e.target as Node)) { + // Shift 키를 누르지 않은 경우에만 줌 (Shift + 휠은 수평 스크롤용) + if (!e.shiftKey && !e.ctrlKey && !e.metaKey) { + // 기본 스크롤 동작 방지 + e.preventDefault(); + + const delta = e.deltaY; + const zoomFactor = 0.001; // 줌 속도 조절 + + // RAF throttle: 프레임당 한 번만 상태 업데이트 + if (zoomRafRef.current !== null) { + cancelAnimationFrame(zoomRafRef.current); + } + zoomRafRef.current = requestAnimationFrame(() => { + setZoomLevel((prevZoom) => { + const newZoom = prevZoom - delta * zoomFactor; + return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, newZoom)); + }); + zoomRafRef.current = null; + }); + } + } + }; + + // passive: false로 설정하여 preventDefault() 가능하게 함 + canvasContainerRef.current?.addEventListener("wheel", handleWheel, { passive: false }); + + const containerRef = canvasContainerRef.current; + return () => { + containerRef?.removeEventListener("wheel", handleWheel); + if (zoomRafRef.current !== null) { + cancelAnimationFrame(zoomRafRef.current); + } + }; + }, [MIN_ZOOM, MAX_ZOOM]); + + // 격자 설정 업데이트 (컴포넌트 자동 조정 제거됨) + const updateGridSettings = useCallback( + (newGridSettings: GridSettings) => { + const newLayout = { ...layout, gridSettings: newGridSettings }; + // 🆕 격자 설정 변경 시 컴포넌트 크기/위치 자동 조정 로직 제거됨 + // 사용자가 명시적으로 "격자 재조정" 버튼을 클릭해야만 조정됨 + setLayout(newLayout); + saveToHistory(newLayout); + }, + [layout, saveToHistory], + ); + + // 해상도 변경 핸들러 (컴포넌트 크기/위치 유지) + const handleResolutionChange = useCallback( + (newResolution: ScreenResolution) => { + const oldWidth = screenResolution.width; + const oldHeight = screenResolution.height; + const newWidth = newResolution.width; + const newHeight = newResolution.height; + + console.log("📱 해상도 변경:", { + from: `${oldWidth}x${oldHeight}`, + to: `${newWidth}x${newHeight}`, + componentsCount: layout.components.length, + }); + + setScreenResolution(newResolution); + + // 해상도만 변경하고 컴포넌트 크기/위치는 그대로 유지 + const updatedLayout = { + ...layout, + screenResolution: newResolution, + }; + + setLayout(updatedLayout); + saveToHistory(updatedLayout); + + toast.success("해상도가 변경되었습니다.", { + description: `${oldWidth}×${oldHeight} → ${newWidth}×${newHeight}`, + }); + + console.log("✅ 해상도 변경 완료 (컴포넌트 크기/위치 유지)"); + }, + [layout, saveToHistory, screenResolution], + ); + + // 강제 격자 재조정 핸들러 (해상도 변경 후 수동 격자 맞춤용) + const handleForceGridUpdate = useCallback(() => { + if (!layout.gridSettings?.snapToGrid || layout.components.length === 0) { + // console.log("격자 재조정 생략: 스냅 비활성화 또는 컴포넌트 없음"); + return; + } + + console.log("🔄 격자 강제 재조정 시작:", { + componentsCount: layout.components.length, + resolution: `${screenResolution.width}x${screenResolution.height}`, + gridSettings: layout.gridSettings, + }); + + // 현재 해상도로 격자 정보 계산 + const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, { + columns: layout.gridSettings.columns, + gap: layout.gridSettings.gap, + padding: layout.gridSettings.padding, + snapToGrid: layout.gridSettings.snapToGrid || false, + }); + + const gridUtilSettings = { + columns: layout.gridSettings.columns, + gap: layout.gridSettings.gap, + padding: layout.gridSettings.padding, + snapToGrid: true, + }; + + const adjustedComponents = layout.components.map((comp) => { + const snappedPosition = snapToGrid(comp.position, currentGridInfo, gridUtilSettings); + const snappedSize = snapSizeToGrid(comp.size, currentGridInfo, gridUtilSettings); + + // gridColumns가 없거나 범위를 벗어나면 자동 조정 + let adjustedGridColumns = comp.gridColumns; + if (!adjustedGridColumns || adjustedGridColumns < 1 || adjustedGridColumns > layout.gridSettings!.columns) { + adjustedGridColumns = adjustGridColumnsFromSize({ size: snappedSize }, currentGridInfo, gridUtilSettings); + } + + return { + ...comp, + position: snappedPosition, + size: snappedSize, + gridColumns: adjustedGridColumns, + }; + }); + + const newLayout = { ...layout, components: adjustedComponents }; + setLayout(newLayout); + saveToHistory(newLayout); + + console.log("✅ 격자 강제 재조정 완료:", { + adjustedComponents: adjustedComponents.length, + gridInfo: { + columnWidth: currentGridInfo.columnWidth.toFixed(2), + totalWidth: currentGridInfo.totalWidth, + columns: layout.gridSettings.columns, + }, + }); + + toast.success(`${adjustedComponents.length}개 컴포넌트가 격자에 맞게 재정렬되었습니다.`); + }, [layout, screenResolution, saveToHistory]); + + // === 정렬/배분/동일크기/라벨토글/Nudge 핸들러 === + + // 컴포넌트 정렬 + const handleGroupAlign = useCallback( + (mode: AlignMode) => { + if (groupState.selectedComponents.length < 2) { + toast.warning("2개 이상의 컴포넌트를 선택해주세요."); + return; + } + saveToHistory(layout); + const newComponents = alignComponents(layout.components, groupState.selectedComponents, mode); + setLayout((prev) => ({ ...prev, components: newComponents })); + + const modeNames: Record = { + left: "좌측", right: "우측", centerX: "가로 중앙", + top: "상단", bottom: "하단", centerY: "세로 중앙", + }; + toast.success(`${modeNames[mode]} 정렬 완료`); + }, + [groupState.selectedComponents, layout, saveToHistory] + ); + + // 컴포넌트 균등 배분 + const handleGroupDistribute = useCallback( + (direction: DistributeDirection) => { + if (groupState.selectedComponents.length < 3) { + toast.warning("3개 이상의 컴포넌트를 선택해주세요."); + return; + } + saveToHistory(layout); + const newComponents = distributeComponents(layout.components, groupState.selectedComponents, direction); + setLayout((prev) => ({ ...prev, components: newComponents })); + toast.success(`${direction === "horizontal" ? "가로" : "세로"} 균등 배분 완료`); + }, + [groupState.selectedComponents, layout, saveToHistory] + ); + + // 동일 크기 맞추기 + const handleMatchSize = useCallback( + (mode: MatchSizeMode) => { + if (groupState.selectedComponents.length < 2) { + toast.warning("2개 이상의 컴포넌트를 선택해주세요."); + return; + } + saveToHistory(layout); + const newComponents = matchComponentSize( + layout.components, + groupState.selectedComponents, + mode, + selectedComponent?.id + ); + setLayout((prev) => ({ ...prev, components: newComponents })); + + const modeNames: Record = { + width: "너비", height: "높이", both: "크기", + }; + toast.success(`${modeNames[mode]} 맞추기 완료`); + }, + [groupState.selectedComponents, layout, selectedComponent?.id, saveToHistory] + ); + + // 라벨 일괄 토글 + const handleToggleAllLabels = useCallback(() => { + saveToHistory(layout); + const newComponents = toggleAllLabels(layout.components); + setLayout((prev) => ({ ...prev, components: newComponents })); + + const hasHidden = layout.components.some( + (c) => c.type === "widget" && (c.style as any)?.labelDisplay === false + ); + toast.success(hasHidden ? "모든 라벨 표시" : "모든 라벨 숨기기"); + }, [layout, saveToHistory]); + + // Nudge (화살표 키 이동) + const handleNudge = useCallback( + (direction: "up" | "down" | "left" | "right", distance: number) => { + const targetIds = + groupState.selectedComponents.length > 0 + ? groupState.selectedComponents + : selectedComponent + ? [selectedComponent.id] + : []; + + if (targetIds.length === 0) return; + + const newComponents = nudgeComponents(layout.components, targetIds, direction, distance); + setLayout((prev) => ({ ...prev, components: newComponents })); + + // 선택된 컴포넌트 업데이트 + if (selectedComponent && targetIds.includes(selectedComponent.id)) { + const updated = newComponents.find((c) => c.id === selectedComponent.id); + if (updated) setSelectedComponent(updated); + } + }, + [groupState.selectedComponents, selectedComponent, layout.components] + ); + + // 저장 + const handleSave = useCallback(async () => { + if (!selectedScreen?.screenId) { + console.error("❌ 저장 실패: selectedScreen 또는 screenId가 없습니다.", selectedScreen); + toast.error("화면 정보가 없습니다."); + return; + } + + try { + setIsSaving(true); + + // 분할 패널 컴포넌트의 rightPanel.tableName 자동 설정 + const updatedComponents = layout.components.map((comp) => { + if (comp.type === "component" && comp.componentType === "split-panel-layout") { + const config = comp.componentConfig || {}; + const rightPanel = config.rightPanel || {}; + const leftPanel = config.leftPanel || {}; + const relationshipType = rightPanel.relation?.type || "detail"; + + // 관계 타입이 detail이면 rightPanel.tableName을 leftPanel.tableName과 동일하게 설정 + if (relationshipType === "detail" && leftPanel.tableName) { + console.log("🔧 분할 패널 자동 수정:", { + componentId: comp.id, + leftTableName: leftPanel.tableName, + rightTableName: leftPanel.tableName, + }); + + return { + ...comp, + componentConfig: { + ...config, + rightPanel: { + ...rightPanel, + tableName: leftPanel.tableName, + }, + }, + }; + } + } + return comp; + }); + + // 해상도 정보를 포함한 레이아웃 데이터 생성 + // 현재 선택된 테이블을 화면의 기본 테이블로 저장 + const currentMainTableName = tables.length > 0 ? tables[0].tableName : null; + + const layoutWithResolution = { + ...layout, + components: updatedComponents, + screenResolution: screenResolution, + mainTableName: currentMainTableName, // 화면의 기본 테이블 + }; + + // V2/POP API 사용 여부에 따라 분기 + const v2Layout = convertLegacyToV2(layoutWithResolution); + if (USE_POP_API) { + // POP 모드: screen_layouts_pop 테이블에 저장 + await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout); + } else if (USE_V2_API) { + // 레이어 기반 저장: 현재 활성 레이어의 layout만 저장 + const currentLayerId = activeLayerIdRef.current || 1; + await screenApi.saveLayoutV2(selectedScreen.screenId, { + ...v2Layout, + layerId: currentLayerId, + }); + } else { + await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution); + } + + // console.log("✅ 저장 성공!"); + toast.success("화면이 저장되었습니다."); + + // 저장 성공 후 부모에게 화면 정보 업데이트 알림 (테이블명 즉시 반영) + if (onScreenUpdate && currentMainTableName) { + onScreenUpdate({ tableName: currentMainTableName }); + } + + // 저장 성공 후 메뉴 할당 모달 열기 + setShowMenuAssignmentModal(true); + } catch (error) { + console.error("❌ 저장 실패:", error); + toast.error("저장 중 오류가 발생했습니다."); + } finally { + setIsSaving(false); + } + }, [selectedScreen, layout, screenResolution, tables, onScreenUpdate]); + + // POP 미리보기 핸들러 (새 창에서 열기) + const handlePopPreview = useCallback(() => { + if (!selectedScreen?.screenId) { + toast.error("화면 정보가 없습니다."); + return; + } + + const deviceType = defaultDevicePreview || "tablet"; + const previewUrl = `/pop/screens/${selectedScreen.screenId}?preview=true&device=${deviceType}`; + window.open(previewUrl, "_blank", "width=800,height=900"); + }, [selectedScreen, defaultDevicePreview]); + + // 다국어 자동 생성 핸들러 + const handleGenerateMultilang = useCallback(async () => { + if (!selectedScreen?.screenId) { + toast.error("화면 정보가 없습니다."); + return; + } + + setIsGeneratingMultilang(true); + + try { + // 공통 유틸 사용하여 라벨 추출 + const { extractMultilangLabels, extractTableNames, applyMultilangMappings } = await import( + "@/lib/utils/multilangLabelExtractor" + ); + const { apiClient } = await import("@/lib/api/client"); + + // 테이블별 컬럼 라벨 로드 + const tableNames = extractTableNames(layout.components); + const columnLabelMap: Record> = {}; + + for (const tableName of tableNames) { + try { + const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); + if (response.data?.success && response.data?.data) { + const columns = response.data.data.columns || response.data.data; + if (Array.isArray(columns)) { + columnLabelMap[tableName] = {}; + columns.forEach((col: any) => { + const colName = col.columnName || col.column_name || col.name; + const colLabel = col.displayName || col.columnLabel || col.column_label || colName; + if (colName) { + columnLabelMap[tableName][colName] = colLabel; + } + }); + } + } + } catch (error) { + console.error(`컬럼 라벨 조회 실패 (${tableName}):`, error); + } + } + + // 라벨 추출 (다국어 설정과 동일한 로직) + const extractedLabels = extractMultilangLabels(layout.components, columnLabelMap); + const labels = extractedLabels.map((l) => ({ + componentId: l.componentId, + label: l.label, + type: l.type, + })); + + if (labels.length === 0) { + toast.info("다국어로 변환할 라벨이 없습니다."); + setIsGeneratingMultilang(false); + return; + } + + // API 호출 + const { generateScreenLabelKeys } = await import("@/lib/api/multilang"); + const response = await generateScreenLabelKeys({ + screenId: selectedScreen.screenId, + menuObjId: menuObjid?.toString(), + labels, + }); + + if (response.success && response.data) { + // 자동 매핑 적용 + const updatedComponents = applyMultilangMappings(layout.components, response.data); + + // 레이아웃 업데이트 + const updatedLayout = { + ...layout, + components: updatedComponents, + screenResolution: screenResolution, + }; + + setLayout(updatedLayout); + + // 자동 저장 (매핑 정보가 손실되지 않도록) + try { + const v2Layout = convertLegacyToV2(updatedLayout); + if (USE_POP_API) { + await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout); + } else if (USE_V2_API) { + await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout); + } else { + await screenApi.saveLayout(selectedScreen.screenId, updatedLayout); + } + toast.success(`${response.data.length}개의 다국어 키가 생성되고 자동 저장되었습니다.`); + } catch (saveError) { + console.error("다국어 매핑 저장 실패:", saveError); + toast.warning(`${response.data.length}개의 다국어 키가 생성되었습니다. 저장 버튼을 눌러 매핑을 저장하세요.`); + } + } else { + toast.error(response.error?.details || "다국어 키 생성에 실패했습니다."); + } + } catch (error) { + console.error("다국어 생성 실패:", error); + toast.error("다국어 키 생성 중 오류가 발생했습니다."); + } finally { + setIsGeneratingMultilang(false); + } + }, [selectedScreen, layout, screenResolution, menuObjid]); + + // 템플릿 드래그 처리 + const handleTemplateDrop = useCallback( + (e: React.DragEvent, template: TemplateComponent) => { + const rect = canvasRef.current?.getBoundingClientRect(); + if (!rect) return; + + const dropX = e.clientX - rect.left; + const dropY = e.clientY - rect.top; + + // 현재 해상도에 맞는 격자 정보 계산 + const currentGridInfo = layout.gridSettings + ? calculateGridInfo(screenResolution.width, screenResolution.height, { + columns: layout.gridSettings.columns, + gap: layout.gridSettings.gap, + padding: layout.gridSettings.padding, + snapToGrid: layout.gridSettings.snapToGrid || false, + }) + : null; + + // 격자 스냅 적용 + const snappedPosition = + layout.gridSettings?.snapToGrid && currentGridInfo + ? snapToGrid({ x: dropX, y: dropY, z: 1 }, currentGridInfo, layout.gridSettings as GridUtilSettings) + : { x: dropX, y: dropY, z: 1 }; + + console.log("🎨 템플릿 드롭:", { + templateName: template.name, + componentsCount: template.components.length, + dropPosition: { x: dropX, y: dropY }, + snappedPosition, + }); + + // 템플릿의 모든 컴포넌트들을 생성 + // 먼저 ID 매핑을 생성 (parentId 참조를 위해) + const idMapping: Record = {}; + template.components.forEach((templateComp, index) => { + const newId = generateComponentId(); + if (index === 0) { + // 첫 번째 컴포넌트(컨테이너)는 "form-container"로 매핑 + idMapping["form-container"] = newId; + } + idMapping[templateComp.parentId || `temp_${index}`] = newId; + }); + + const newComponents: ComponentData[] = template.components.map((templateComp, index) => { + const componentId = index === 0 ? idMapping["form-container"] : generateComponentId(); + + // 템플릿 컴포넌트의 상대 위치를 드롭 위치 기준으로 조정 + const absoluteX = snappedPosition.x + templateComp.position.x; + const absoluteY = snappedPosition.y + templateComp.position.y; + + // 격자 스냅 적용 + const finalPosition = + layout.gridSettings?.snapToGrid && currentGridInfo + ? snapPositionTo10px( + { x: absoluteX, y: absoluteY, z: 1 }, + currentGridInfo, + layout.gridSettings as GridUtilSettings, + ) + : { x: absoluteX, y: absoluteY, z: 1 }; + + if (templateComp.type === "container") { + // 그리드 컬럼 기반 크기 계산 + const gridColumns = + typeof templateComp.size.width === "number" && templateComp.size.width <= 12 ? templateComp.size.width : 4; // 기본 4컬럼 + + const calculatedSize = + currentGridInfo && layout.gridSettings?.snapToGrid + ? (() => { + const newWidth = calculateWidthFromColumns( + gridColumns, + currentGridInfo, + layout.gridSettings as GridUtilSettings, + ); + return { + width: newWidth, + height: templateComp.size.height, + }; + })() + : { width: 400, height: templateComp.size.height }; // 폴백 크기 + + return { + id: componentId, + type: "container", + label: templateComp.label, + tableName: selectedScreen?.tableName || "", + title: templateComp.title || templateComp.label, + position: finalPosition, + size: calculatedSize, + gridColumns, + style: { + labelDisplay: true, + labelFontSize: "14px", + labelColor: "#212121", + labelFontWeight: "600", + labelMarginBottom: "8px", + ...templateComp.style, + }, + }; + } else if (templateComp.type === "datatable") { + // 데이터 테이블 컴포넌트 생성 + const gridColumns = 6; // 기본값: 6컬럼 (50% 너비) + + // gridColumns에 맞는 크기 계산 + const calculatedSize = + currentGridInfo && layout.gridSettings?.snapToGrid + ? (() => { + const newWidth = calculateWidthFromColumns( + gridColumns, + currentGridInfo, + layout.gridSettings as GridUtilSettings, + ); + return { + width: newWidth, + height: templateComp.size.height, // 높이는 템플릿 값 유지 + }; + })() + : templateComp.size; + + console.log("📊 데이터 테이블 생성 시 크기 계산:", { + gridColumns, + templateSize: templateComp.size, + calculatedSize, + hasGridInfo: !!currentGridInfo, + hasGridSettings: !!layout.gridSettings, + }); + + return { + id: componentId, + type: "datatable", + label: templateComp.label, + tableName: selectedScreen?.tableName || "", + position: finalPosition, + size: calculatedSize, + title: templateComp.label, + columns: [], // 초기에는 빈 배열, 나중에 설정 + filters: [], // 초기에는 빈 배열, 나중에 설정 + pagination: { + enabled: true, + pageSize: 10, + pageSizeOptions: [5, 10, 20, 50], + showPageSizeSelector: true, + showPageInfo: true, + showFirstLast: true, + }, + showSearchButton: true, + searchButtonText: "검색", + enableExport: true, + enableRefresh: true, + enableAdd: true, + enableEdit: true, + enableDelete: true, + addButtonText: "추가", + editButtonText: "수정", + deleteButtonText: "삭제", + addModalConfig: { + title: "새 데이터 추가", + description: `${templateComp.label}에 새로운 데이터를 추가합니다.`, + width: "lg", + layout: "two-column", + gridColumns: 2, + fieldOrder: [], // 초기에는 빈 배열, 나중에 컬럼 추가 시 설정 + requiredFields: [], + hiddenFields: [], + advancedFieldConfigs: {}, // 초기에는 빈 객체, 나중에 컬럼별 설정 + submitButtonText: "추가", + cancelButtonText: "취소", + }, + gridColumns, + style: { + labelDisplay: true, + labelFontSize: "14px", + labelColor: "#212121", + labelFontWeight: "600", + labelMarginBottom: "8px", + ...templateComp.style, + }, + } as ComponentData; + } else if (templateComp.type === "file") { + // 파일 첨부 컴포넌트 생성 + const gridColumns = 6; // 기본값: 6컬럼 + + const calculatedSize = + currentGridInfo && layout.gridSettings?.snapToGrid + ? (() => { + const newWidth = calculateWidthFromColumns( + gridColumns, + currentGridInfo, + layout.gridSettings as GridUtilSettings, + ); + return { + width: newWidth, + height: templateComp.size.height, + }; + })() + : templateComp.size; + + return { + id: componentId, + type: "file", + label: templateComp.label, + position: finalPosition, + size: calculatedSize, + gridColumns, + fileConfig: { + accept: ["image/*", ".pdf", ".doc", ".docx", ".xls", ".xlsx"], + multiple: true, + maxSize: 10, // 10MB + maxFiles: 5, + docType: "DOCUMENT", + docTypeName: "일반 문서", + targetObjid: selectedScreen?.screenId || "", + showPreview: true, + showProgress: true, + dragDropText: "파일을 드래그하여 업로드하세요", + uploadButtonText: "파일 선택", + autoUpload: true, + chunkedUpload: false, + }, + uploadedFiles: [], + style: { + labelDisplay: true, + labelFontSize: "14px", + labelColor: "#212121", + labelFontWeight: "600", + labelMarginBottom: "8px", + ...templateComp.style, + }, + } as ComponentData; + } else if (templateComp.type === "area") { + // 영역 컴포넌트 생성 + const gridColumns = 6; // 기본값: 6컬럼 (50% 너비) + + const calculatedSize = + currentGridInfo && layout.gridSettings?.snapToGrid + ? (() => { + const newWidth = calculateWidthFromColumns( + gridColumns, + currentGridInfo, + layout.gridSettings as GridUtilSettings, + ); + return { + width: newWidth, + height: templateComp.size.height, + }; + })() + : templateComp.size; + + return { + id: componentId, + type: "area", + label: templateComp.label, + position: finalPosition, + size: calculatedSize, + gridColumns, + layoutType: (templateComp as any).layoutType || "box", + title: (templateComp as any).title || templateComp.label, + description: (templateComp as any).description, + layoutConfig: (templateComp as any).layoutConfig || {}, + areaStyle: { + backgroundColor: "#ffffff", + borderWidth: 1, + borderStyle: "solid", + borderColor: "#e5e7eb", + borderRadius: 8, + padding: 0, + margin: 0, + shadow: "sm", + ...(templateComp as any).areaStyle, + }, + children: [], + style: { + labelDisplay: true, + labelFontSize: "14px", + labelColor: "#212121", + labelFontWeight: "600", + labelMarginBottom: "8px", + ...templateComp.style, + }, + } as ComponentData; + } else { + // 위젯 컴포넌트 + const widgetType = templateComp.widgetType || "text"; + + // 웹타입별 기본 그리드 컬럼 수 계산 + const getDefaultGridColumnsForTemplate = (wType: string): number => { + const widthMap: Record = { + text: 4, + email: 4, + tel: 3, + url: 4, + textarea: 6, + number: 2, + decimal: 2, + date: 3, + datetime: 3, + time: 2, + select: 3, + radio: 3, + checkbox: 2, + boolean: 2, + code: 3, + entity: 4, + file: 4, + image: 3, + button: 2, + label: 2, + }; + return widthMap[wType] || 3; + }; + + // 웹타입별 기본 설정 생성 + const getDefaultWebTypeConfig = (wType: string) => { + switch (wType) { + case "date": + return { + format: "YYYY-MM-DD" as const, + showTime: false, + placeholder: templateComp.placeholder || "날짜를 선택하세요", + }; + case "select": + case "dropdown": + return { + options: [ + { label: "옵션 1", value: "option1" }, + { label: "옵션 2", value: "option2" }, + { label: "옵션 3", value: "option3" }, + ], + multiple: false, + searchable: false, + placeholder: templateComp.placeholder || "옵션을 선택하세요", + }; + case "text": + return { + format: "none" as const, + placeholder: templateComp.placeholder || "텍스트를 입력하세요", + multiline: false, + }; + case "email": + return { + format: "email" as const, + placeholder: templateComp.placeholder || "이메일을 입력하세요", + multiline: false, + }; + case "tel": + return { + format: "phone" as const, + placeholder: templateComp.placeholder || "전화번호를 입력하세요", + multiline: false, + }; + case "textarea": + return { + rows: 3, + placeholder: templateComp.placeholder || "텍스트를 입력하세요", + resizable: true, + wordWrap: true, + }; + default: + return { + placeholder: templateComp.placeholder || "입력하세요", + }; + } + }; + + // 위젯 크기도 격자에 맞게 조정 + const widgetSize = + currentGridInfo && layout.gridSettings?.snapToGrid + ? { + width: calculateWidthFromColumns(1, currentGridInfo, layout.gridSettings as GridUtilSettings), + height: templateComp.size.height, + } + : templateComp.size; + + return { + id: componentId, + type: "widget", + widgetType: widgetType as any, + label: templateComp.label, + placeholder: templateComp.placeholder, + columnName: `field_${index + 1}`, + parentId: templateComp.parentId ? idMapping[templateComp.parentId] : undefined, + position: finalPosition, + size: widgetSize, + required: templateComp.required || false, + readonly: templateComp.readonly || false, + gridColumns: getDefaultGridColumnsForTemplate(widgetType), + webTypeConfig: getDefaultWebTypeConfig(widgetType), + style: { + labelDisplay: true, + labelFontSize: "14px", + labelColor: "#212121", + labelFontWeight: "600", + labelMarginBottom: "8px", + ...templateComp.style, + }, + } as ComponentData; + } + }); + + // 🆕 현재 활성 레이어에 컴포넌트 추가 (ref 사용으로 클로저 문제 방지) + const componentsWithLayerId = newComponents.map((comp) => ({ + ...comp, + layerId: activeLayerIdRef.current || 1, + })); + + // 레이아웃에 새 컴포넌트들 추가 + const newLayout = { + ...layout, + components: [...layout.components, ...componentsWithLayerId], + }; + + setLayout(newLayout); + saveToHistory(newLayout); + + // 첫 번째 컴포넌트 선택 + if (componentsWithLayerId.length > 0) { + setSelectedComponent(componentsWithLayerId[0]); + } + + toast.success(`${template.name} 템플릿이 추가되었습니다.`); + }, + [layout, selectedScreen, saveToHistory], + ); + + // 레이아웃 드래그 처리 + const handleLayoutDrop = useCallback( + (e: React.DragEvent, layoutData: any) => { + const rect = canvasRef.current?.getBoundingClientRect(); + if (!rect) return; + + // 🔥 중요: 줌 레벨을 고려한 마우스 위치 계산 + const dropX = (e.clientX - rect.left) / zoomLevel; + const dropY = (e.clientY - rect.top) / zoomLevel; + + // 현재 해상도에 맞는 격자 정보 계산 + const currentGridInfo = layout.gridSettings + ? calculateGridInfo(screenResolution.width, screenResolution.height, { + columns: layout.gridSettings.columns, + gap: layout.gridSettings.gap, + padding: layout.gridSettings.padding, + snapToGrid: layout.gridSettings.snapToGrid || false, + }) + : null; + + // 격자 스냅 적용 + const snappedPosition = + layout.gridSettings?.snapToGrid && currentGridInfo + ? snapToGrid({ x: dropX, y: dropY, z: 1 }, currentGridInfo, layout.gridSettings as GridUtilSettings) + : { x: dropX, y: dropY, z: 1 }; + + console.log("🏗️ 레이아웃 드롭 (줌 보정):", { + zoomLevel, + layoutType: layoutData.layoutType, + zonesCount: layoutData.zones.length, + mouseRaw: { x: e.clientX - rect.left, y: e.clientY - rect.top }, + dropPosition: { x: dropX, y: dropY }, + snappedPosition, + }); + + // 레이아웃 컴포넌트 생성 + const newLayoutComponent: ComponentData = { + id: layoutData.id, + type: "layout", + layoutType: layoutData.layoutType, + layoutConfig: layoutData.layoutConfig, + zones: layoutData.zones.map((zone: any) => ({ + ...zone, + id: `${layoutData.id}_${zone.id}`, // 레이아웃 ID를 접두사로 추가 + })), + children: [], + position: snappedPosition, + size: layoutData.size, + label: layoutData.label, + allowedComponentTypes: layoutData.allowedComponentTypes, + dropZoneConfig: layoutData.dropZoneConfig, + layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용) + } as ComponentData; + + // 레이아웃에 새 컴포넌트 추가 + const newLayout = { + ...layout, + components: [...layout.components, newLayoutComponent], + }; + + setLayout(newLayout); + saveToHistory(newLayout); + + // 레이아웃 컴포넌트 선택 + setSelectedComponent(newLayoutComponent); + + toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`); + }, + [layout, screenResolution, saveToHistory, zoomLevel], + ); + + // handleZoneComponentDrop은 handleComponentDrop으로 대체됨 + + // 존 클릭 핸들러 + const handleZoneClick = useCallback((zoneId: string) => { + // console.log("🎯 존 클릭:", zoneId); + // 필요시 존 선택 로직 추가 + }, []); + + // 웹타입별 기본 설정 생성 함수를 상위로 이동 + const getDefaultWebTypeConfig = useCallback((webType: string) => { + switch (webType) { + case "button": + return { + actionType: "custom", + variant: "default", + confirmationMessage: "", + popupTitle: "", + popupContent: "", + navigateUrl: "", + }; + case "date": + return { + format: "YYYY-MM-DD", + showTime: false, + placeholder: "날짜를 선택하세요", + }; + case "number": + return { + format: "integer", + placeholder: "숫자를 입력하세요", + }; + case "select": + return { + options: [ + { label: "옵션 1", value: "option1" }, + { label: "옵션 2", value: "option2" }, + { label: "옵션 3", value: "option3" }, + ], + multiple: false, + searchable: false, + placeholder: "옵션을 선택하세요", + }; + case "file": + return { + accept: ["*/*"], + maxSize: 10485760, // 10MB + multiple: false, + showPreview: true, + autoUpload: false, + }; + default: + return {}; + } + }, []); + + // 컴포넌트 드래그 처리 (캔버스 레벨 드롭) + const handleComponentDrop = useCallback( + (e: React.DragEvent, component?: any, zoneId?: string, layoutId?: string) => { + // 존별 드롭인 경우 dragData에서 컴포넌트 정보 추출 + if (!component) { + const dragData = e.dataTransfer.getData("application/json"); + if (!dragData) return; + + try { + const parsedData = JSON.parse(dragData); + if (parsedData.type === "component") { + component = parsedData.component; + } else { + return; + } + } catch (error) { + // console.error("드래그 데이터 파싱 오류:", error); + return; + } + } + + // 🎯 리피터 컨테이너 내부 드롭 처리 + const dropTarget = e.target as HTMLElement; + const repeatContainer = dropTarget.closest('[data-repeat-container="true"]'); + if (repeatContainer) { + const containerId = repeatContainer.getAttribute("data-component-id"); + if (containerId) { + // 해당 리피터 컨테이너 찾기 + const targetComponent = layout.components.find((c) => c.id === containerId); + const compType = (targetComponent as any)?.componentType; + // v2-repeat-container 또는 repeat-container 모두 지원 + if (targetComponent && (compType === "repeat-container" || compType === "v2-repeat-container")) { + const currentConfig = (targetComponent as any).componentConfig || {}; + const currentChildren = currentConfig.children || []; + + // 새 자식 컴포넌트 생성 + const newChild = { + id: `slot_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + componentType: component.id || component.componentType || "text-display", + label: component.name || component.label || "새 컴포넌트", + fieldName: "", + position: { x: 0, y: currentChildren.length * 40 }, + size: component.defaultSize || { width: 200, height: 32 }, + componentConfig: component.defaultConfig || {}, + }; + + // 컴포넌트 업데이트 + const updatedComponent = { + ...targetComponent, + componentConfig: { + ...currentConfig, + children: [...currentChildren, newChild], + }, + }; + + const newLayout = { + ...layout, + components: layout.components.map((c) => (c.id === containerId ? updatedComponent : c)), + }; + + setLayout(newLayout); + saveToHistory(newLayout); + return; // 리피터 컨테이너 처리 완료 + } + } + } + + // 🎯 탭 컨테이너 내부 드롭 처리 (중첩 구조 지원) + const tabsContainer = dropTarget.closest('[data-tabs-container="true"]'); + if (tabsContainer) { + const containerId = tabsContainer.getAttribute("data-component-id"); + const activeTabId = tabsContainer.getAttribute("data-active-tab-id"); + if (containerId && activeTabId) { + // 1. 먼저 최상위 레이아웃에서 탭 컴포넌트 찾기 + let targetComponent = layout.components.find((c) => c.id === containerId); + let parentSplitPanelId: string | null = null; + let parentPanelSide: "left" | "right" | null = null; + + // 2. 최상위에 없으면 분할 패널 안에서 중첩된 탭 찾기 + if (!targetComponent) { + for (const comp of layout.components) { + const compType = (comp as any)?.componentType; + if (compType === "split-panel-layout" || compType === "v2-split-panel-layout") { + const config = (comp as any).componentConfig || {}; + + // 좌측 패널에서 찾기 + const leftComponents = config.leftPanel?.components || []; + const foundInLeft = leftComponents.find((c: any) => c.id === containerId); + if (foundInLeft) { + targetComponent = foundInLeft; + parentSplitPanelId = comp.id; + parentPanelSide = "left"; + break; + } + + // 우측 패널에서 찾기 + const rightComponents = config.rightPanel?.components || []; + const foundInRight = rightComponents.find((c: any) => c.id === containerId); + if (foundInRight) { + targetComponent = foundInRight; + parentSplitPanelId = comp.id; + parentPanelSide = "right"; + break; + } + } + } + } + + const compType = (targetComponent as any)?.componentType; + if (targetComponent && (compType === "tabs-widget" || compType === "v2-tabs-widget")) { + const currentConfig = (targetComponent as any).componentConfig || {}; + const tabs = currentConfig.tabs || []; + + // 활성 탭의 드롭 위치 계산 + const tabContentRect = tabsContainer.getBoundingClientRect(); + const dropX = (e.clientX - tabContentRect.left) / zoomLevel; + const dropY = (e.clientY - tabContentRect.top) / zoomLevel; + + // 새 컴포넌트 생성 + const componentType = component.id || component.componentType || "v2-text-display"; + + console.log("🎯 탭에 컴포넌트 드롭:", { + componentId: component.id, + componentType: componentType, + componentName: component.name, + isNested: !!parentSplitPanelId, + parentSplitPanelId, + parentPanelSide, + // 🆕 위치 디버깅 + clientX: e.clientX, + clientY: e.clientY, + tabContentRect: { left: tabContentRect.left, top: tabContentRect.top }, + zoomLevel, + calculatedPosition: { x: dropX, y: dropY }, + }); + + const newTabComponent = { + id: `tab_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + componentType: componentType, + label: component.name || component.label || "새 컴포넌트", + position: { x: Math.max(0, dropX), y: Math.max(0, dropY) }, + size: component.defaultSize || { width: 200, height: 100 }, + componentConfig: component.defaultConfig || {}, + }; + + // 해당 탭에 컴포넌트 추가 + const updatedTabs = tabs.map((tab: any) => { + if (tab.id === activeTabId) { + return { + ...tab, + components: [...(tab.components || []), newTabComponent], + }; + } + return tab; + }); + + const updatedTabsComponent = { + ...targetComponent, + componentConfig: { + ...currentConfig, + tabs: updatedTabs, + }, + }; + + let newLayout; + + if (parentSplitPanelId && parentPanelSide) { + // 🆕 중첩 구조: 분할 패널 안의 탭 업데이트 + newLayout = { + ...layout, + components: layout.components.map((c) => { + if (c.id === parentSplitPanelId) { + const splitConfig = (c as any).componentConfig || {}; + const panelKey = parentPanelSide === "left" ? "leftPanel" : "rightPanel"; + const panelConfig = splitConfig[panelKey] || {}; + const panelComponents = panelConfig.components || []; + + return { + ...c, + componentConfig: { + ...splitConfig, + [panelKey]: { + ...panelConfig, + components: panelComponents.map((pc: any) => + pc.id === containerId ? updatedTabsComponent : pc, + ), + }, + }, + }; + } + return c; + }), + }; + toast.success("컴포넌트가 중첩된 탭에 추가되었습니다"); + } else { + // 일반 구조: 최상위 탭 업데이트 + newLayout = { + ...layout, + components: layout.components.map((c) => (c.id === containerId ? updatedTabsComponent : c)), + }; + toast.success("컴포넌트가 탭에 추가되었습니다"); + } + + setLayout(newLayout); + saveToHistory(newLayout); + return; // 탭 컨테이너 처리 완료 + } + } + } + + // 🎯 분할 패널 커스텀 모드 컨테이너 내부 드롭 처리 + const splitPanelContainer = dropTarget.closest('[data-split-panel-container="true"]'); + if (splitPanelContainer) { + const containerId = splitPanelContainer.getAttribute("data-component-id"); + const panelSide = splitPanelContainer.getAttribute("data-panel-side"); // "left" or "right" + if (containerId && panelSide) { + const targetComponent = layout.components.find((c) => c.id === containerId); + const compType = (targetComponent as any)?.componentType; + if (targetComponent && (compType === "split-panel-layout" || compType === "v2-split-panel-layout")) { + const currentConfig = (targetComponent as any).componentConfig || {}; + const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel"; + const panelConfig = currentConfig[panelKey] || {}; + const currentComponents = panelConfig.components || []; + + // 드롭 위치 계산 + const panelRect = splitPanelContainer.getBoundingClientRect(); + const dropX = (e.clientX - panelRect.left) / zoomLevel; + const dropY = (e.clientY - panelRect.top) / zoomLevel; + + // 새 컴포넌트 생성 + const componentType = component.id || component.componentType || "v2-text-display"; + + console.log("🎯 분할 패널에 컴포넌트 드롭:", { + componentId: component.id, + componentType: componentType, + panelSide: panelSide, + dropPosition: { x: dropX, y: dropY }, + }); + + const newPanelComponent = { + id: `panel_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + componentType: componentType, + label: component.name || component.label || "새 컴포넌트", + position: { x: Math.max(0, dropX), y: Math.max(0, dropY) }, + size: component.defaultSize || { width: 200, height: 100 }, + componentConfig: component.defaultConfig || {}, + }; + + const updatedPanelConfig = { + ...panelConfig, + components: [...currentComponents, newPanelComponent], + }; + + const updatedComponent = { + ...targetComponent, + componentConfig: { + ...currentConfig, + [panelKey]: updatedPanelConfig, + }, + }; + + const newLayout = { + ...layout, + components: layout.components.map((c) => (c.id === containerId ? updatedComponent : c)), + }; + + setLayout(newLayout); + saveToHistory(newLayout); + toast.success(`컴포넌트가 ${panelSide === "left" ? "좌측" : "우측"} 패널에 추가되었습니다`); + return; // 분할 패널 처리 완료 + } + } + } + + const rect = canvasRef.current?.getBoundingClientRect(); + if (!rect) return; + + // 컴포넌트 크기 정보 + const componentWidth = component.defaultSize?.width || 120; + const componentHeight = component.defaultSize?.height || 36; + + // 🔥 중요: 줌 레벨과 transform-origin을 고려한 마우스 위치 계산 + // 1. 캔버스가 scale() 변환되어 있음 (transform-origin: top center) + // 2. 캔버스가 justify-center로 중앙 정렬되어 있음 + + // 실제 캔버스 논리적 크기 + const canvasLogicalWidth = screenResolution.width; + + // 화면상 캔버스 실제 크기 (스케일 적용 후) + const canvasVisualWidth = canvasLogicalWidth * zoomLevel; + + // 중앙 정렬로 인한 왼쪽 오프셋 계산 + // rect.left는 이미 중앙 정렬된 위치를 반영하고 있음 + + // 마우스의 캔버스 내 상대 위치 (스케일 보정) + const mouseXInCanvas = (e.clientX - rect.left) / zoomLevel; + const mouseYInCanvas = (e.clientY - rect.top) / zoomLevel; + + // 방법 1: 마우스 포인터를 컴포넌트 중심으로 + const dropX_centered = mouseXInCanvas - componentWidth / 2; + const dropY_centered = mouseYInCanvas - componentHeight / 2; + + // 방법 2: 마우스 포인터를 컴포넌트 좌상단으로 + const dropX_topleft = mouseXInCanvas; + const dropY_topleft = mouseYInCanvas; + + // 사용자가 원하는 방식으로 변경: 마우스 포인터가 좌상단에 오도록 + const dropX = dropX_topleft; + const dropY = dropY_topleft; + + console.log("🎯 위치 계산 디버깅 (줌 레벨 + 중앙정렬 반영):", { + "1. 줌 레벨": zoomLevel, + "2. 마우스 위치 (화면)": { clientX: e.clientX, clientY: e.clientY }, + "3. 캔버스 위치 (rect)": { left: rect.left, top: rect.top, width: rect.width, height: rect.height }, + "4. 캔버스 논리적 크기": { width: canvasLogicalWidth, height: screenResolution.height }, + "5. 캔버스 시각적 크기": { width: canvasVisualWidth, height: screenResolution.height * zoomLevel }, + "6. 마우스 캔버스 내 상대위치 (줌 전)": { x: e.clientX - rect.left, y: e.clientY - rect.top }, + "7. 마우스 캔버스 내 상대위치 (줌 보정)": { x: mouseXInCanvas, y: mouseYInCanvas }, + "8. 컴포넌트 크기": { width: componentWidth, height: componentHeight }, + "9a. 중심 방식": { x: dropX_centered, y: dropY_centered }, + "9b. 좌상단 방식": { x: dropX_topleft, y: dropY_topleft }, + "10. 최종 선택": { dropX, dropY }, + }); + + // 현재 해상도에 맞는 격자 정보 계산 + const currentGridInfo = layout.gridSettings + ? calculateGridInfo(screenResolution.width, screenResolution.height, { + columns: layout.gridSettings.columns, + gap: layout.gridSettings.gap, + padding: layout.gridSettings.padding, + snapToGrid: layout.gridSettings.snapToGrid || false, + }) + : null; + + // 캔버스 경계 내로 위치 제한 (조건부 레이어 편집 시 Zone 크기 기준) + const currentLayerId = activeLayerIdRef.current || 1; + const activeLayerRegion = currentLayerId > 1 ? activeLayerZone : null; + const canvasBoundW = activeLayerRegion ? activeLayerRegion.width : screenResolution.width; + const canvasBoundH = activeLayerRegion ? activeLayerRegion.height : screenResolution.height; + const boundedX = Math.max(0, Math.min(dropX, canvasBoundW - componentWidth)); + const boundedY = Math.max(0, Math.min(dropY, canvasBoundH - componentHeight)); + + // 격자 스냅 적용 + const snappedPosition = + layout.gridSettings?.snapToGrid && currentGridInfo + ? snapPositionTo10px( + { x: boundedX, y: boundedY, z: 1 }, + currentGridInfo, + layout.gridSettings as GridUtilSettings, + ) + : { x: boundedX, y: boundedY, z: 1 }; + + console.log("🧩 컴포넌트 드롭:", { + componentName: component.name, + webType: component.webType, + rawPosition: { x: dropX, y: dropY }, + boundedPosition: { x: boundedX, y: boundedY }, + snappedPosition, + }); + + // 새 컴포넌트 생성 (새 컴포넌트 시스템 지원) + console.log("🔍 ScreenDesigner handleComponentDrop:", { + componentName: component.name, + componentId: component.id, + webType: component.webType, + category: component.category, + defaultConfig: component.defaultConfig, + defaultSize: component.defaultSize, + }); + + // 컴포넌트별 gridColumns 설정 및 크기 계산 + let componentSize = component.defaultSize; + const isCardDisplay = component.id === "card-display"; + const isTableList = component.id === "table-list"; + + // 컴포넌트 타입별 기본 그리드 컬럼 수 설정 + const currentGridColumns = layout.gridSettings.columns; // 현재 격자 컬럼 수 + let gridColumns = 1; // 기본값 + + // 특수 컴포넌트 + if (isCardDisplay) { + gridColumns = Math.round(currentGridColumns * 0.667); // 약 66.67% + } else if (isTableList) { + gridColumns = currentGridColumns; // 테이블은 전체 너비 + } else { + // 웹타입별 적절한 그리드 컬럼 수 설정 + const webType = component.webType; + const componentId = component.id; + + // 웹타입별 기본 비율 매핑 (12컬럼 기준 비율) + const gridColumnsRatioMap: Record = { + // 입력 컴포넌트 (INPUT 카테고리) + "text-input": 4 / 12, // 텍스트 입력 (33%) + "number-input": 2 / 12, // 숫자 입력 (16.67%) + "email-input": 4 / 12, // 이메일 입력 (33%) + "tel-input": 3 / 12, // 전화번호 입력 (25%) + "date-input": 3 / 12, // 날짜 입력 (25%) + "datetime-input": 4 / 12, // 날짜시간 입력 (33%) + "time-input": 2 / 12, // 시간 입력 (16.67%) + "textarea-basic": 6 / 12, // 텍스트 영역 (50%) + "select-basic": 3 / 12, // 셀렉트 (25%) + "checkbox-basic": 2 / 12, // 체크박스 (16.67%) + "radio-basic": 3 / 12, // 라디오 (25%) + "file-basic": 4 / 12, // 파일 (33%) + "file-upload": 4 / 12, // 파일 업로드 (33%) + "slider-basic": 3 / 12, // 슬라이더 (25%) + "toggle-switch": 2 / 12, // 토글 스위치 (16.67%) + "repeater-field-group": 6 / 12, // 반복 필드 그룹 (50%) + + // 표시 컴포넌트 (DISPLAY 카테고리) + "label-basic": 2 / 12, // 라벨 (16.67%) + "text-display": 3 / 12, // 텍스트 표시 (25%) + "card-display": 8 / 12, // 카드 (66.67%) + "badge-basic": 1 / 12, // 배지 (8.33%) + "alert-basic": 6 / 12, // 알림 (50%) + "divider-basic": 1, // 구분선 (100%) + "divider-line": 1, // 구분선 (100%) + "accordion-basic": 1, // 아코디언 (100%) + "table-list": 1, // 테이블 리스트 (100%) + "image-display": 4 / 12, // 이미지 표시 (33%) + "split-panel-layout": 6 / 12, // 분할 패널 레이아웃 (50%) + "flow-widget": 1, // 플로우 위젯 (100%) + + // 액션 컴포넌트 (ACTION 카테고리) + "button-basic": 1 / 12, // 버튼 (8.33%) + "button-primary": 1 / 12, // 프라이머리 버튼 (8.33%) + "button-secondary": 1 / 12, // 세컨더리 버튼 (8.33%) + "icon-button": 1 / 12, // 아이콘 버튼 (8.33%) + + // 레이아웃 컴포넌트 + "container-basic": 6 / 12, // 컨테이너 (50%) + "section-basic": 1, // 섹션 (100%) + "panel-basic": 6 / 12, // 패널 (50%) + + // 기타 + "image-basic": 4 / 12, // 이미지 (33%) + "icon-basic": 1 / 12, // 아이콘 (8.33%) + "progress-bar": 4 / 12, // 프로그레스 바 (33%) + "chart-basic": 6 / 12, // 차트 (50%) + }; + + // defaultSize에 gridColumnSpan이 "full"이면 전체 컬럼 사용 + if (component.defaultSize?.gridColumnSpan === "full") { + gridColumns = currentGridColumns; + } else { + // componentId 또는 webType으로 비율 찾기, 없으면 기본값 25% + const ratio = gridColumnsRatioMap[componentId] || gridColumnsRatioMap[webType] || 0.25; + // 현재 격자 컬럼 수에 비율을 곱하여 계산 (최소 1, 최대 currentGridColumns) + gridColumns = Math.max(1, Math.min(currentGridColumns, Math.round(ratio * currentGridColumns))); + } + + console.log("🎯 컴포넌트 타입별 gridColumns 설정:", { + componentId, + webType, + gridColumns, + }); + } + + // 10px 단위로 너비 스냅 + if (layout.gridSettings?.snapToGrid) { + componentSize = { + ...component.defaultSize, + width: snapTo10px(component.defaultSize.width), + height: snapTo10px(component.defaultSize.height), + }; + } + + console.log("🎨 최종 컴포넌트 크기:", { + componentId: component.id, + componentName: component.name, + defaultSize: component.defaultSize, + finalSize: componentSize, + gridColumns, + }); + + // 반복 필드 그룹인 경우 테이블의 첫 번째 컬럼을 기본 필드로 추가 + let enhancedDefaultConfig = { ...component.defaultConfig }; + if ( + component.id === "repeater-field-group" && + tables && + tables.length > 0 && + tables[0].columns && + tables[0].columns.length > 0 + ) { + const firstColumn = tables[0].columns[0]; + enhancedDefaultConfig = { + ...enhancedDefaultConfig, + fields: [ + { + name: firstColumn.columnName, + label: firstColumn.columnLabel || firstColumn.columnName, + type: (firstColumn.widgetType as any) || "text", + required: firstColumn.required || false, + placeholder: `${firstColumn.columnLabel || firstColumn.columnName}을(를) 입력하세요`, + }, + ], + }; + } + + // gridColumns에 맞춰 width를 퍼센트로 계산 + const widthPercent = (gridColumns / currentGridColumns) * 100; + + console.log("🎨 [컴포넌트 생성] 너비 계산:", { + componentName: component.name, + componentId: component.id, + currentGridColumns, + gridColumns, + widthPercent: `${widthPercent}%`, + calculatedWidth: `${Math.round(widthPercent * 100) / 100}%`, + }); + + // 🆕 라벨을 기반으로 기본 columnName 생성 (한글 → 스네이크 케이스) + // 예: "창고코드" → "warehouse_code" 또는 그대로 유지 + const generateDefaultColumnName = (label: string): string => { + // 한글 라벨의 경우 그대로 사용 (나중에 사용자가 수정 가능) + // 영문의 경우 스네이크 케이스로 변환 + if (/[가-힣]/.test(label)) { + // 한글이 포함된 경우: 공백을 언더스코어로, 소문자로 변환 + return label.replace(/\s+/g, "_").toLowerCase(); + } + // 영문의 경우: 카멜케이스/파스칼케이스를 스네이크 케이스로 변환 + return label + .replace(/([a-z])([A-Z])/g, "$1_$2") + .replace(/\s+/g, "_") + .toLowerCase(); + }; + + const newComponent: ComponentData = { + id: generateComponentId(), + type: "component", // ✅ 새 컴포넌트 시스템 사용 + label: component.name, + columnName: generateDefaultColumnName(component.name), // 🆕 기본 columnName 자동 생성 + widgetType: component.webType, + componentType: component.id, // 새 컴포넌트 시스템의 ID (DynamicComponentRenderer용) + position: snappedPosition, + size: componentSize, + gridColumns: gridColumns, // 컴포넌트별 그리드 컬럼 수 적용 + layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용) + componentConfig: { + type: component.id, // 새 컴포넌트 시스템의 ID 사용 + webType: component.webType, // 웹타입 정보 추가 + ...enhancedDefaultConfig, + }, + webTypeConfig: getDefaultWebTypeConfig(component.webType), + style: { + labelDisplay: true, // 🆕 라벨 기본 표시 (사용자가 끄고 싶으면 체크 해제) + labelFontSize: "14px", + labelColor: "#212121", + labelFontWeight: "500", + labelMarginBottom: "4px", + width: `${componentSize.width}px`, // size와 동기화 (픽셀 단위) + height: `${componentSize.height}px`, // size와 동기화 (픽셀 단위) + }, + }; + + // 레이아웃에 컴포넌트 추가 + const newLayout: LayoutData = { + ...layout, + components: [...layout.components, newComponent], + }; + + setLayout(newLayout); + saveToHistory(newLayout); + + // 새 컴포넌트 선택 + setSelectedComponent(newComponent); + // 🔧 테이블 패널 유지를 위해 자동 속성 패널 열기 비활성화 + // openPanel("properties"); + + toast.success(`${component.name} 컴포넌트가 추가되었습니다.`); + }, + [layout, selectedScreen, saveToHistory], + ); + + // 드래그 앤 드롭 처리 + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + }, []); + + const handleDrop = useCallback( + async (e: React.DragEvent) => { + e.preventDefault(); + + const dragData = e.dataTransfer.getData("application/json"); + // console.log("🎯 드롭 이벤트:", { dragData }); + if (!dragData) { + // console.log("❌ 드래그 데이터가 없습니다"); + return; + } + + try { + const parsedData = JSON.parse(dragData); + // console.log("📋 파싱된 데이터:", parsedData); + + // 템플릿 드래그인 경우 + if (parsedData.type === "template") { + handleTemplateDrop(e, parsedData.template); + return; + } + + // 레이아웃 드래그인 경우 + if (parsedData.type === "layout") { + handleLayoutDrop(e, parsedData.layout); + return; + } + + // 컴포넌트 드래그인 경우 + if (parsedData.type === "component") { + handleComponentDrop(e, parsedData.component); + return; + } + + // 🆕 조건부 영역(Zone) 생성 드래그인 경우 → DB screen_conditional_zones에 저장 + if (parsedData.type === "create-zone" && 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); + try { + await screenApi.createZone(selectedScreen.screenId, { + zone_name: "조건부 영역", + x: Math.max(0, dropX - 400), + y: Math.max(0, dropY), + width: Math.min(800, screenResolution.width), + height: 200, + }); + // Zone 목록 새로고침 + const loadedZones = await screenApi.getScreenZones(selectedScreen.screenId); + setZones(loadedZones); + toast.success("조건부 영역이 생성되었습니다."); + } catch (error) { + console.error("Zone 생성 실패:", error); + toast.error("조건부 영역 생성에 실패했습니다."); + } + return; + } + + // 기존 테이블/컬럼 드래그 처리 + const { type, table, column } = parsedData; + + // 드롭 대상이 폼 컨테이너인지 확인 + const dropTarget = e.target as HTMLElement; + const formContainer = dropTarget.closest('[data-form-container="true"]'); + + // 🎯 리피터 컨테이너 내부에 컬럼 드롭 시 처리 + const repeatContainer = dropTarget.closest('[data-repeat-container="true"]'); + if (repeatContainer && type === "column" && column) { + const containerId = repeatContainer.getAttribute("data-component-id"); + if (containerId) { + const targetComponent = layout.components.find((c) => c.id === containerId); + const rcType = (targetComponent as any)?.componentType; + if (targetComponent && (rcType === "repeat-container" || rcType === "v2-repeat-container")) { + const currentConfig = (targetComponent as any).componentConfig || {}; + const currentChildren = currentConfig.children || []; + + // 새 자식 컴포넌트 생성 (컬럼 기반) + const newChild = { + id: `slot_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + componentType: column.widgetType || "text-display", + label: column.columnLabel || column.columnName, + fieldName: column.columnName, + position: { x: 0, y: currentChildren.length * 40 }, + size: { width: 200, height: 32 }, + componentConfig: {}, + }; + + const updatedComponent = { + ...targetComponent, + componentConfig: { + ...currentConfig, + children: [...currentChildren, newChild], + }, + }; + + const newLayout = { + ...layout, + components: layout.components.map((c) => (c.id === containerId ? updatedComponent : c)), + }; + + setLayout(newLayout); + saveToHistory(newLayout); + return; + } + } + } + + // 🎯 탭 컨테이너 내부에 컬럼 드롭 시 처리 (중첩 구조 지원) + const tabsContainer = dropTarget.closest('[data-tabs-container="true"]'); + if (tabsContainer && type === "column" && column) { + const containerId = tabsContainer.getAttribute("data-component-id"); + const activeTabId = tabsContainer.getAttribute("data-active-tab-id"); + if (containerId && activeTabId) { + // 1. 먼저 최상위 레이아웃에서 탭 컴포넌트 찾기 + let targetComponent = layout.components.find((c) => c.id === containerId); + let parentSplitPanelId: string | null = null; + let parentPanelSide: "left" | "right" | null = null; + + // 2. 최상위에 없으면 분할 패널 안에서 중첩된 탭 찾기 + if (!targetComponent) { + for (const comp of layout.components) { + const compType = (comp as any)?.componentType; + if (compType === "split-panel-layout" || compType === "v2-split-panel-layout") { + const config = (comp as any).componentConfig || {}; + + // 좌측 패널에서 찾기 + const leftComponents = config.leftPanel?.components || []; + const foundInLeft = leftComponents.find((c: any) => c.id === containerId); + if (foundInLeft) { + targetComponent = foundInLeft; + parentSplitPanelId = comp.id; + parentPanelSide = "left"; + break; + } + + // 우측 패널에서 찾기 + const rightComponents = config.rightPanel?.components || []; + const foundInRight = rightComponents.find((c: any) => c.id === containerId); + if (foundInRight) { + targetComponent = foundInRight; + parentSplitPanelId = comp.id; + parentPanelSide = "right"; + break; + } + } + } + } + + const compType = (targetComponent as any)?.componentType; + if (targetComponent && (compType === "tabs-widget" || compType === "v2-tabs-widget")) { + const currentConfig = (targetComponent as any).componentConfig || {}; + const tabs = currentConfig.tabs || []; + + // 드롭 위치 계산 + const tabContentRect = tabsContainer.getBoundingClientRect(); + const dropX = (e.clientX - tabContentRect.left) / zoomLevel; + const dropY = (e.clientY - tabContentRect.top) / zoomLevel; + + // 🆕 V2 컴포넌트 매핑 사용 (일반 캔버스와 동일) + const v2Mapping = createV2ConfigFromColumn({ + widgetType: column.widgetType, + columnName: column.columnName, + columnLabel: column.columnLabel, + codeCategory: column.codeCategory, + inputType: column.inputType, + required: column.required, + detailSettings: column.detailSettings, + referenceTable: column.referenceTable, + referenceColumn: column.referenceColumn, + displayColumn: column.displayColumn, + }); + + // 웹타입별 기본 크기 계산 + const getTabComponentSize = (widgetType: string) => { + const sizeMap: Record = { + text: { width: 200, height: 36 }, + number: { width: 150, height: 36 }, + decimal: { width: 150, height: 36 }, + date: { width: 180, height: 36 }, + datetime: { width: 200, height: 36 }, + select: { width: 200, height: 36 }, + category: { width: 200, height: 36 }, + code: { width: 200, height: 36 }, + entity: { width: 220, height: 36 }, + boolean: { width: 120, height: 36 }, + checkbox: { width: 120, height: 36 }, + textarea: { width: 300, height: 100 }, + file: { width: 250, height: 80 }, + }; + return sizeMap[widgetType] || { width: 200, height: 36 }; + }; + + const componentSize = getTabComponentSize(column.widgetType); + + const newTabComponent = { + id: `tab_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + componentType: v2Mapping.componentType, + label: column.columnLabel || column.columnName, + position: { x: Math.max(0, dropX), y: Math.max(0, dropY) }, + size: componentSize, + inputType: column.inputType || column.widgetType, + widgetType: column.widgetType, + componentConfig: { + ...v2Mapping.componentConfig, + columnName: column.columnName, + tableName: column.tableName, + inputType: column.inputType || column.widgetType, + }, + }; + + // 해당 탭에 컴포넌트 추가 + const updatedTabs = tabs.map((tab: any) => { + if (tab.id === activeTabId) { + return { + ...tab, + components: [...(tab.components || []), newTabComponent], + }; + } + return tab; + }); + + const updatedTabsComponent = { + ...targetComponent, + componentConfig: { + ...currentConfig, + tabs: updatedTabs, + }, + }; + + let newLayout; + + if (parentSplitPanelId && parentPanelSide) { + // 🆕 중첩 구조: 분할 패널 안의 탭 업데이트 + newLayout = { + ...layout, + components: layout.components.map((c) => { + if (c.id === parentSplitPanelId) { + const splitConfig = (c as any).componentConfig || {}; + const panelKey = parentPanelSide === "left" ? "leftPanel" : "rightPanel"; + const panelConfig = splitConfig[panelKey] || {}; + const panelComponents = panelConfig.components || []; + + return { + ...c, + componentConfig: { + ...splitConfig, + [panelKey]: { + ...panelConfig, + components: panelComponents.map((pc: any) => + pc.id === containerId ? updatedTabsComponent : pc, + ), + }, + }, + }; + } + return c; + }), + }; + toast.success("컬럼이 중첩된 탭에 추가되었습니다"); + } else { + // 일반 구조: 최상위 탭 업데이트 + newLayout = { + ...layout, + components: layout.components.map((c) => (c.id === containerId ? updatedTabsComponent : c)), + }; + toast.success("컬럼이 탭에 추가되었습니다"); + } + + setLayout(newLayout); + saveToHistory(newLayout); + return; + } + } + } + + // 🎯 분할 패널 커스텀 모드 컨테이너 내부에 컬럼 드롭 시 처리 + const splitPanelContainer = dropTarget.closest('[data-split-panel-container="true"]'); + if (splitPanelContainer && type === "column" && column) { + const containerId = splitPanelContainer.getAttribute("data-component-id"); + const panelSide = splitPanelContainer.getAttribute("data-panel-side"); // "left" or "right" + if (containerId && panelSide) { + const targetComponent = layout.components.find((c) => c.id === containerId); + const compType = (targetComponent as any)?.componentType; + if (targetComponent && (compType === "split-panel-layout" || compType === "v2-split-panel-layout")) { + const currentConfig = (targetComponent as any).componentConfig || {}; + const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel"; + const panelConfig = currentConfig[panelKey] || {}; + const currentComponents = panelConfig.components || []; + + // 드롭 위치 계산 + const panelRect = splitPanelContainer.getBoundingClientRect(); + const dropX = (e.clientX - panelRect.left) / zoomLevel; + const dropY = (e.clientY - panelRect.top) / zoomLevel; + + // V2 컴포넌트 매핑 사용 + const v2Mapping = createV2ConfigFromColumn({ + widgetType: column.widgetType, + columnName: column.columnName, + columnLabel: column.columnLabel, + codeCategory: column.codeCategory, + inputType: column.inputType, + required: column.required, + detailSettings: column.detailSettings, + referenceTable: column.referenceTable, + referenceColumn: column.referenceColumn, + displayColumn: column.displayColumn, + }); + + // 웹타입별 기본 크기 계산 + const getPanelComponentSize = (widgetType: string) => { + const sizeMap: Record = { + text: { width: 200, height: 36 }, + number: { width: 150, height: 36 }, + decimal: { width: 150, height: 36 }, + date: { width: 180, height: 36 }, + datetime: { width: 200, height: 36 }, + select: { width: 200, height: 36 }, + category: { width: 200, height: 36 }, + code: { width: 200, height: 36 }, + entity: { width: 220, height: 36 }, + boolean: { width: 120, height: 36 }, + checkbox: { width: 120, height: 36 }, + textarea: { width: 300, height: 100 }, + file: { width: 250, height: 80 }, + }; + return sizeMap[widgetType] || { width: 200, height: 36 }; + }; + + const componentSize = getPanelComponentSize(column.widgetType); + + const newPanelComponent = { + id: `panel_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + componentType: v2Mapping.componentType, + label: column.columnLabel || column.columnName, + position: { x: Math.max(0, dropX), y: Math.max(0, dropY) }, + size: componentSize, + inputType: column.inputType || column.widgetType, + widgetType: column.widgetType, + componentConfig: { + ...v2Mapping.componentConfig, + columnName: column.columnName, + tableName: column.tableName, + inputType: column.inputType || column.widgetType, + }, + }; + + const updatedPanelConfig = { + ...panelConfig, + components: [...currentComponents, newPanelComponent], + }; + + const updatedComponent = { + ...targetComponent, + componentConfig: { + ...currentConfig, + [panelKey]: updatedPanelConfig, + }, + }; + + const newLayout = { + ...layout, + components: layout.components.map((c) => (c.id === containerId ? updatedComponent : c)), + }; + + setLayout(newLayout); + saveToHistory(newLayout); + toast.success(`컬럼이 ${panelSide === "left" ? "좌측" : "우측"} 패널에 추가되었습니다`); + return; + } + } + } + + const rect = canvasRef.current?.getBoundingClientRect(); + if (!rect) return; + + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + let newComponent: ComponentData; + + if (type === "table") { + // 테이블 컨테이너 생성 + newComponent = { + id: generateComponentId(), + type: "container", + label: table.tableLabel || table.tableName, // 테이블 라벨 우선, 없으면 테이블명 + tableName: table.tableName, + position: { x, y, z: 1 } as Position, + size: { width: 300, height: 200 }, + layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용) + style: { + labelDisplay: true, + labelFontSize: "14px", + labelColor: "#212121", + labelFontWeight: "600", + labelMarginBottom: "8px", + }, + }; + } else if (type === "column") { + // console.log("🔄 컬럼 드롭 처리:", { webType: column.widgetType, columnName: column.columnName }); + + // 웹타입별 기본 너비 계산 (10px 단위 고정) + const getDefaultWidth = (widgetType: string): number => { + const widthMap: Record = { + // 텍스트 입력 계열 + text: 200, + email: 200, + tel: 150, + url: 250, + textarea: 300, + + // 숫자/날짜 입력 + number: 120, + decimal: 120, + date: 150, + datetime: 180, + time: 120, + + // 선택 입력 + select: 180, + radio: 180, + checkbox: 120, + boolean: 120, + + // 코드/참조 + code: 180, + entity: 200, + + // 파일/이미지 + file: 250, + image: 200, + + // 기타 + button: 100, + label: 100, + }; + + return widthMap[widgetType] || 200; // 기본값 200px + }; + + // 웹타입별 기본 높이 계산 + const getDefaultHeight = (widgetType: string): number => { + const heightMap: Record = { + textarea: 120, // 텍스트 영역은 3줄 (40 * 3) + checkbox: 80, // 체크박스 그룹 (40 * 2) + radio: 80, // 라디오 버튼 (40 * 2) + file: 240, // 파일 업로드 (40 * 6) + }; + + return heightMap[widgetType] || 30; // 기본값 30px로 변경 + }; + + // 웹타입별 기본 설정 생성 + const getDefaultWebTypeConfig = (widgetType: string) => { + switch (widgetType) { + case "date": + return { + format: "YYYY-MM-DD" as const, + showTime: false, + placeholder: "날짜를 선택하세요", + }; + case "datetime": + return { + format: "YYYY-MM-DD HH:mm" as const, + showTime: true, + placeholder: "날짜와 시간을 선택하세요", + }; + case "number": + return { + format: "integer" as const, + placeholder: "숫자를 입력하세요", + }; + case "decimal": + return { + format: "decimal" as const, + step: 0.01, + decimalPlaces: 2, + placeholder: "소수를 입력하세요", + }; + case "select": + case "dropdown": + return { + options: [ + { label: "옵션 1", value: "option1" }, + { label: "옵션 2", value: "option2" }, + { label: "옵션 3", value: "option3" }, + ], + multiple: false, + searchable: false, + placeholder: "옵션을 선택하세요", + }; + case "text": + return { + format: "none" as const, + placeholder: "텍스트를 입력하세요", + multiline: false, + }; + case "email": + return { + format: "email" as const, + placeholder: "이메일을 입력하세요", + multiline: false, + }; + case "tel": + return { + format: "phone" as const, + placeholder: "전화번호를 입력하세요", + multiline: false, + }; + case "textarea": + return { + rows: 3, + placeholder: "텍스트를 입력하세요", + resizable: true, + autoResize: false, + wordWrap: true, + }; + case "checkbox": + case "boolean": + return { + defaultChecked: false, + labelPosition: "right" as const, + checkboxText: "", + trueValue: true, + falseValue: false, + indeterminate: false, + }; + case "radio": + return { + options: [ + { label: "옵션 1", value: "option1" }, + { label: "옵션 2", value: "option2" }, + ], + layout: "vertical" as const, + defaultValue: "", + allowNone: false, + }; + case "file": + return { + accept: "", + multiple: false, + maxSize: 10, + maxFiles: 1, + preview: true, + dragDrop: true, + allowedExtensions: [], + }; + case "code": + return { + codeCategory: "", // 기본값, 실제로는 컬럼 정보에서 가져옴 + placeholder: "선택하세요", + options: [], // 기본 빈 배열, 실제로는 API에서 로드 + }; + case "entity": + return { + entityName: "", + displayField: "name", + valueField: "id", + searchable: true, + multiple: false, + allowClear: true, + placeholder: "엔터티를 선택하세요", + apiEndpoint: "", + filters: [], + displayFormat: "simple" as const, + }; + case "table": + return { + tableName: "", + displayMode: "table" as const, + showHeader: true, + showFooter: true, + pagination: { + enabled: true, + pageSize: 10, + showPageSizeSelector: true, + showPageInfo: true, + showFirstLast: true, + }, + columns: [], + searchable: true, + sortable: true, + filterable: true, + exportable: true, + }; + default: + return undefined; + } + }; + + // 폼 컨테이너에 드롭한 경우 + if (formContainer) { + const formContainerId = formContainer.getAttribute("data-component-id"); + const formContainerComponent = layout.components.find((c) => c.id === formContainerId); + + if (formContainerComponent) { + // 폼 내부에서의 상대적 위치 계산 + const containerRect = formContainer.getBoundingClientRect(); + const relativeX = e.clientX - containerRect.left; + const relativeY = e.clientY - containerRect.top; + + // 🆕 V2 컴포넌트 매핑 사용 + const v2Mapping = createV2ConfigFromColumn({ + widgetType: column.widgetType, + columnName: column.columnName, + columnLabel: column.columnLabel, + codeCategory: column.codeCategory, + inputType: column.inputType, + required: column.required, + detailSettings: column.detailSettings, // 엔티티 참조 정보 전달 + // column_labels 직접 필드도 전달 + referenceTable: column.referenceTable, + referenceColumn: column.referenceColumn, + displayColumn: column.displayColumn, + }); + + // 웹타입별 기본 너비 계산 (10px 단위 고정) + const componentWidth = getDefaultWidth(column.widgetType); + + console.log("🎯 폼 컨테이너 V2 컴포넌트 생성:", { + widgetType: column.widgetType, + v2Type: v2Mapping.componentType, + componentWidth, + }); + + // 엔티티 조인 컬럼인 경우 읽기 전용으로 설정 + const isEntityJoinColumn = column.isEntityJoin === true; + + newComponent = { + id: generateComponentId(), + type: "component", // ✅ V2 컴포넌트 시스템 사용 + label: column.columnLabel || column.columnName, + tableName: table.tableName, + columnName: column.columnName, + required: isEntityJoinColumn ? false : column.required, // 조인 컬럼은 필수 아님 + readonly: isEntityJoinColumn, // 🆕 엔티티 조인 컬럼은 읽기 전용 + parentId: formContainerId, // 폼 컨테이너의 자식으로 설정 + componentType: v2Mapping.componentType, // v2-input, v2-select 등 + position: { x: relativeX, y: relativeY, z: 1 } as Position, + size: { width: componentWidth, height: getDefaultHeight(column.widgetType) }, + layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용) + // 코드 타입인 경우 코드 카테고리 정보 추가 + ...(column.widgetType === "code" && + column.codeCategory && { + codeCategory: column.codeCategory, + }), + // 엔티티 조인 정보 저장 + ...(isEntityJoinColumn && { + isEntityJoin: true, + entityJoinTable: column.entityJoinTable, + entityJoinColumn: column.entityJoinColumn, + }), + style: { + labelDisplay: true, // 🆕 라벨 기본 표시 + labelFontSize: "12px", + labelColor: "#212121", + labelFontWeight: "500", + labelMarginBottom: "6px", + }, + componentConfig: { + type: v2Mapping.componentType, // v2-input, v2-select 등 + ...v2Mapping.componentConfig, // V2 컴포넌트 기본 설정 + ...(isEntityJoinColumn && { disabled: true }), // 🆕 조인 컬럼은 비활성화 + }, + }; + } else { + return; // 폼 컨테이너를 찾을 수 없으면 드롭 취소 + } + } else { + // 일반 캔버스에 드롭한 경우 - 🆕 V2 컴포넌트 시스템 사용 + const v2Mapping = createV2ConfigFromColumn({ + widgetType: column.widgetType, + columnName: column.columnName, + columnLabel: column.columnLabel, + codeCategory: column.codeCategory, + inputType: column.inputType, + required: column.required, + detailSettings: column.detailSettings, // 엔티티 참조 정보 전달 + // column_labels 직접 필드도 전달 + referenceTable: column.referenceTable, + referenceColumn: column.referenceColumn, + displayColumn: column.displayColumn, + }); + + // 웹타입별 기본 너비 계산 (10px 단위 고정) + const componentWidth = getDefaultWidth(column.widgetType); + + console.log("🎯 캔버스 V2 컴포넌트 생성:", { + widgetType: column.widgetType, + v2Type: v2Mapping.componentType, + componentWidth, + }); + + // 엔티티 조인 컬럼인 경우 읽기 전용으로 설정 + const isEntityJoinColumn = column.isEntityJoin === true; + + newComponent = { + id: generateComponentId(), + type: "component", // ✅ V2 컴포넌트 시스템 사용 + label: column.columnLabel || column.columnName, // 컬럼 라벨 우선, 없으면 컬럼명 + tableName: table.tableName, + columnName: column.columnName, + required: isEntityJoinColumn ? false : column.required, // 조인 컬럼은 필수 아님 + readonly: isEntityJoinColumn, // 🆕 엔티티 조인 컬럼은 읽기 전용 + componentType: v2Mapping.componentType, // v2-input, v2-select 등 + position: { x, y, z: 1 } as Position, + size: { width: componentWidth, height: getDefaultHeight(column.widgetType) }, + layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용) + // 코드 타입인 경우 코드 카테고리 정보 추가 + ...(column.widgetType === "code" && + column.codeCategory && { + codeCategory: column.codeCategory, + }), + // 엔티티 조인 정보 저장 + ...(isEntityJoinColumn && { + isEntityJoin: true, + entityJoinTable: column.entityJoinTable, + entityJoinColumn: column.entityJoinColumn, + }), + style: { + labelDisplay: true, // 🆕 라벨 기본 표시 + labelFontSize: "14px", + labelColor: "#000000", // 순수한 검정 + labelFontWeight: "500", + labelMarginBottom: "8px", + }, + componentConfig: { + type: v2Mapping.componentType, // v2-input, v2-select 등 + ...v2Mapping.componentConfig, // V2 컴포넌트 기본 설정 + ...(isEntityJoinColumn && { disabled: true }), // 🆕 조인 컬럼은 비활성화 + }, + }; + } + } else { + return; + } + + // 10px 단위 스냅 적용 (그룹 컴포넌트 제외) + if (layout.gridSettings?.snapToGrid && newComponent.type !== "group") { + newComponent.position = snapPositionTo10px(newComponent.position); + newComponent.size = snapSizeTo10px(newComponent.size); + + console.log("🧲 새 컴포넌트 10px 스냅 적용:", { + type: newComponent.type, + snappedPosition: newComponent.position, + snappedSize: newComponent.size, + }); + } + + const newLayout = { + ...layout, + components: [...layout.components, newComponent], + }; + + setLayout(newLayout); + saveToHistory(newLayout); + setSelectedComponent(newComponent); + + // 🔧 테이블 패널 유지를 위해 자동 속성 패널 열기 비활성화 + // openPanel("properties"); + } catch (error) { + // console.error("드롭 처리 실패:", error); + } + }, + [layout, saveToHistory], + ); + + // 파일 컴포넌트 업데이트 처리 + const handleFileComponentUpdate = useCallback( + (updates: Partial) => { + if (!selectedFileComponent) return; + + const updatedComponents = layout.components.map((comp) => + comp.id === selectedFileComponent.id ? { ...comp, ...updates } : comp, + ); + + const newLayout = { ...layout, components: updatedComponents }; + setLayout(newLayout); + saveToHistory(newLayout); + + // selectedFileComponent도 업데이트 + setSelectedFileComponent((prev) => (prev ? { ...prev, ...updates } : null)); + + // selectedComponent가 같은 컴포넌트라면 업데이트 + if (selectedComponent?.id === selectedFileComponent.id) { + setSelectedComponent((prev) => (prev ? { ...prev, ...updates } : null)); + } + }, + [selectedFileComponent, layout, saveToHistory, selectedComponent], + ); + + // 파일첨부 모달 닫기 + const handleFileAttachmentModalClose = useCallback(() => { + setShowFileAttachmentModal(false); + setSelectedFileComponent(null); + }, []); + + // 컴포넌트 더블클릭 처리 + const handleComponentDoubleClick = useCallback((component: ComponentData, event?: React.MouseEvent) => { + event?.stopPropagation(); + + // 파일 컴포넌트인 경우 상세 모달 열기 + if (component.type === "file") { + setSelectedFileComponent(component); + setShowFileAttachmentModal(true); + return; + } + + // 다른 컴포넌트 타입의 더블클릭 처리는 여기에 추가 + // console.log("더블클릭된 컴포넌트:", component.type, component.id); + }, []); + + // 컴포넌트 클릭 처리 (다중선택 지원) + const handleComponentClick = useCallback( + (component: ComponentData, event?: React.MouseEvent) => { + event?.stopPropagation(); + + // 드래그가 끝난 직후라면 클릭을 무시 (다중 선택 유지) + if (dragState.justFinishedDrag) { + return; + } + + // 🔧 layout.components에서 최신 버전의 컴포넌트 찾기 + const latestComponent = layout.components.find((c) => c.id === component.id); + if (!latestComponent) { + console.warn("⚠️ 컴포넌트를 찾을 수 없습니다:", component.id); + return; + } + + const isShiftPressed = event?.shiftKey || false; + const isCtrlPressed = event?.ctrlKey || event?.metaKey || false; + const isGroupContainer = latestComponent.type === "group"; + + if (isShiftPressed || isCtrlPressed || groupState.isGrouping) { + // 다중 선택 모드 + if (isGroupContainer) { + // 그룹 컨테이너는 단일 선택으로 처리 + handleComponentSelect(latestComponent); // 🔧 최신 버전 사용 + setGroupState((prev) => ({ + ...prev, + selectedComponents: [latestComponent.id], + isGrouping: false, + })); + return; + } + + const isSelected = groupState.selectedComponents.includes(latestComponent.id); + setGroupState((prev) => ({ + ...prev, + selectedComponents: isSelected + ? prev.selectedComponents.filter((id) => id !== latestComponent.id) + : [...prev.selectedComponents, latestComponent.id], + })); + + // 마지막 선택된 컴포넌트를 selectedComponent로 설정 + if (!isSelected) { + // console.log("🎯 컴포넌트 선택 (다중 모드):", latestComponent.id); + handleComponentSelect(latestComponent); // 🔧 최신 버전 사용 + } + } else { + // 단일 선택 모드 + // console.log("🎯 컴포넌트 선택 (단일 모드):", latestComponent.id); + handleComponentSelect(latestComponent); // 🔧 최신 버전 사용 + setGroupState((prev) => ({ + ...prev, + selectedComponents: [latestComponent.id], + })); + } + }, + [ + handleComponentSelect, + groupState.isGrouping, + groupState.selectedComponents, + dragState.justFinishedDrag, + layout.components, + ], + ); + + // 컴포넌트 드래그 시작 + const startComponentDrag = useCallback( + (component: ComponentData, event: React.MouseEvent | React.DragEvent) => { + event.preventDefault(); + const rect = canvasRef.current?.getBoundingClientRect(); + if (!rect) return; + + // 새로운 드래그 시작 시 justFinishedDrag 플래그 해제 + if (dragState.justFinishedDrag) { + setDragState((prev) => ({ + ...prev, + justFinishedDrag: false, + })); + } + + // 🔥 중요: 줌 레벨을 고려한 마우스 위치 계산 + // 캔버스가 scale() 변환되어 있기 때문에 마우스 위치도 역변환 필요 + const relativeMouseX = (event.clientX - rect.left) / zoomLevel; + const relativeMouseY = (event.clientY - rect.top) / zoomLevel; + + // 다중 선택된 컴포넌트들 확인 + const isDraggedComponentSelected = groupState.selectedComponents.includes(component.id); + let componentsToMove = isDraggedComponentSelected + ? layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id)) + : [component]; + + // 레이아웃 컴포넌트인 경우 존에 속한 컴포넌트들도 함께 이동 + if (component.type === "layout") { + const zoneComponents = layout.components.filter((comp) => comp.parentId === component.id && comp.zoneId); + + console.log("🏗️ 레이아웃 드래그 - 존 컴포넌트들 포함:", { + layoutId: component.id, + zoneComponentsCount: zoneComponents.length, + zoneComponents: zoneComponents.map((c) => ({ id: c.id, zoneId: c.zoneId })), + }); + + // 중복 제거하여 추가 + const allComponentIds = new Set(componentsToMove.map((c) => c.id)); + const additionalComponents = zoneComponents.filter((c) => !allComponentIds.has(c.id)); + componentsToMove = [...componentsToMove, ...additionalComponents]; + } + + setDragState({ + isDragging: true, + draggedComponent: component, // 주 드래그 컴포넌트 (마우스 위치 기준) + draggedComponents: componentsToMove, // 함께 이동할 모든 컴포넌트들 + originalPosition: { + x: component.position.x, + y: component.position.y, + z: (component.position as Position).z || 1, + }, + currentPosition: { + x: component.position.x, + y: component.position.y, + z: (component.position as Position).z || 1, + }, + grabOffset: { + x: relativeMouseX - component.position.x, + y: relativeMouseY - component.position.y, + }, + justFinishedDrag: false, + }); + }, + [groupState.selectedComponents, layout.components, dragState.justFinishedDrag, zoomLevel], + ); + + // 드래그 중 위치 업데이트 (성능 최적화 + 실시간 업데이트) + const updateDragPosition = useCallback( + (event: MouseEvent) => { + if (!dragState.isDragging || !dragState.draggedComponent || !canvasRef.current) return; + + const rect = canvasRef.current.getBoundingClientRect(); + + // 🔥 중요: 줌 레벨을 고려한 마우스 위치 계산 + // 캔버스가 scale() 변환되어 있기 때문에 마우스 위치도 역변환 필요 + const relativeMouseX = (event.clientX - rect.left) / zoomLevel; + const relativeMouseY = (event.clientY - rect.top) / zoomLevel; + + // 컴포넌트 크기 가져오기 + const draggedComp = layout.components.find((c) => c.id === dragState.draggedComponent.id); + const componentWidth = draggedComp?.size?.width || 100; + const componentHeight = draggedComp?.size?.height || 40; + + // 경계 제한 적용 + const rawX = relativeMouseX - dragState.grabOffset.x; + const rawY = relativeMouseY - dragState.grabOffset.y; + + // 조건부 레이어 편집 시 Zone 크기 기준 경계 제한 + const dragLayerId = activeLayerIdRef.current || 1; + const dragLayerRegion = dragLayerId > 1 ? activeLayerZone : null; + const dragBoundW = dragLayerRegion ? dragLayerRegion.width : screenResolution.width; + const dragBoundH = dragLayerRegion ? dragLayerRegion.height : screenResolution.height; + + const newPosition = { + x: Math.max(0, Math.min(rawX, dragBoundW - componentWidth)), + y: Math.max(0, Math.min(rawY, dragBoundH - componentHeight)), + z: (dragState.draggedComponent.position as Position).z || 1, + }; + + // 드래그 상태 업데이트 + setDragState((prev) => { + const newState = { + ...prev, + currentPosition: { ...newPosition }, // 새로운 객체 생성 + }; + return newState; + }); + + // 성능 최적화: 드래그 중에는 상태 업데이트만 하고, + // 실제 레이아웃 업데이트는 endDrag에서 처리 + // 속성 패널에서는 dragState.currentPosition을 참조하여 실시간 표시 + }, + [dragState.isDragging, dragState.draggedComponent, dragState.grabOffset, zoomLevel], + ); + + // 드래그 종료 + const endDrag = useCallback( + (mouseEvent?: MouseEvent) => { + if (dragState.isDragging && dragState.draggedComponent) { + // 🎯 탭 컨테이너로의 드롭 처리 (기존 컴포넌트 이동, 중첩 구조 지원) + if (mouseEvent) { + const dropTarget = document.elementFromPoint(mouseEvent.clientX, mouseEvent.clientY) as HTMLElement; + const tabsContainer = dropTarget?.closest('[data-tabs-container="true"]'); + + if (tabsContainer) { + const containerId = tabsContainer.getAttribute("data-component-id"); + const activeTabId = tabsContainer.getAttribute("data-active-tab-id"); + + if (containerId && activeTabId) { + // 1. 먼저 최상위 레이아웃에서 탭 컴포넌트 찾기 + let targetComponent = layout.components.find((c) => c.id === containerId); + let parentSplitPanelId: string | null = null; + let parentPanelSide: "left" | "right" | null = null; + + // 2. 최상위에 없으면 분할 패널 안에서 중첩된 탭 찾기 + if (!targetComponent) { + for (const comp of layout.components) { + const compType = (comp as any)?.componentType; + if (compType === "split-panel-layout" || compType === "v2-split-panel-layout") { + const config = (comp as any).componentConfig || {}; + + // 좌측 패널에서 찾기 + const leftComponents = config.leftPanel?.components || []; + const foundInLeft = leftComponents.find((c: any) => c.id === containerId); + if (foundInLeft) { + targetComponent = foundInLeft; + parentSplitPanelId = comp.id; + parentPanelSide = "left"; + break; + } + + // 우측 패널에서 찾기 + const rightComponents = config.rightPanel?.components || []; + const foundInRight = rightComponents.find((c: any) => c.id === containerId); + if (foundInRight) { + targetComponent = foundInRight; + parentSplitPanelId = comp.id; + parentPanelSide = "right"; + break; + } + } + } + } + + const compType = (targetComponent as any)?.componentType; + + // 자기 자신을 자신에게 드롭하는 것 방지 + if ( + targetComponent && + (compType === "tabs-widget" || compType === "v2-tabs-widget") && + dragState.draggedComponent !== containerId + ) { + const draggedComponent = layout.components.find((c) => c.id === dragState.draggedComponent); + if (draggedComponent) { + const currentConfig = (targetComponent as any).componentConfig || {}; + const tabs = currentConfig.tabs || []; + + // 탭 컨텐츠 영역 기준 드롭 위치 계산 + const tabContentRect = tabsContainer.getBoundingClientRect(); + const dropX = (mouseEvent.clientX - tabContentRect.left) / zoomLevel; + const dropY = (mouseEvent.clientY - tabContentRect.top) / zoomLevel; + + // 기존 컴포넌트를 탭 내부 컴포넌트로 변환 + const newTabComponent = { + id: `tab_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + componentType: (draggedComponent as any).componentType || draggedComponent.type, + label: (draggedComponent as any).label || "컴포넌트", + position: { x: Math.max(0, dropX), y: Math.max(0, dropY) }, + size: draggedComponent.size || { width: 200, height: 100 }, + componentConfig: (draggedComponent as any).componentConfig || {}, + style: (draggedComponent as any).style || {}, + }; + + // 해당 탭에 컴포넌트 추가 + const updatedTabs = tabs.map((tab: any) => { + if (tab.id === activeTabId) { + return { + ...tab, + components: [...(tab.components || []), newTabComponent], + }; + } + return tab; + }); + + const updatedTabsComponent = { + ...targetComponent, + componentConfig: { + ...currentConfig, + tabs: updatedTabs, + }, + }; + + let newLayout; + + if (parentSplitPanelId && parentPanelSide) { + // 🆕 중첩 구조: 분할 패널 안의 탭 업데이트 + newLayout = { + ...layout, + components: layout.components + .filter((c) => c.id !== dragState.draggedComponent) + .map((c) => { + if (c.id === parentSplitPanelId) { + const splitConfig = (c as any).componentConfig || {}; + const panelKey = parentPanelSide === "left" ? "leftPanel" : "rightPanel"; + const panelConfig = splitConfig[panelKey] || {}; + const panelComponents = panelConfig.components || []; + + return { + ...c, + componentConfig: { + ...splitConfig, + [panelKey]: { + ...panelConfig, + components: panelComponents.map((pc: any) => + pc.id === containerId ? updatedTabsComponent : pc, + ), + }, + }, + }; + } + return c; + }), + }; + toast.success("컴포넌트가 중첩된 탭으로 이동되었습니다"); + } else { + // 일반 구조: 최상위 탭 업데이트 + newLayout = { + ...layout, + components: layout.components + .filter((c) => c.id !== dragState.draggedComponent) + .map((c) => { + if (c.id === containerId) { + return updatedTabsComponent; + } + return c; + }), + }; + toast.success("컴포넌트가 탭으로 이동되었습니다"); + } + + setLayout(newLayout); + saveToHistory(newLayout); + setSelectedComponent(null); + + // 드래그 상태 초기화 후 종료 + setDragState({ + isDragging: false, + draggedComponent: null, + draggedComponents: [], + originalPosition: { x: 0, y: 0, z: 1 }, + currentPosition: { x: 0, y: 0, z: 1 }, + grabOffset: { x: 0, y: 0 }, + justFinishedDrag: true, + }); + + setTimeout(() => { + setDragState((prev) => ({ ...prev, justFinishedDrag: false })); + }, 100); + + return; // 탭으로 이동 완료, 일반 드래그 종료 로직 스킵 + } + } + } + } + } + + // 주 드래그 컴포넌트의 최종 위치 계산 + const draggedComponent = layout.components.find((c) => c.id === dragState.draggedComponent); + let finalPosition = dragState.currentPosition; + + // 현재 해상도에 맞는 격자 정보 계산 + const currentGridInfo = layout.gridSettings + ? calculateGridInfo(screenResolution.width, screenResolution.height, { + columns: layout.gridSettings.columns, + gap: layout.gridSettings.gap, + padding: layout.gridSettings.padding, + snapToGrid: layout.gridSettings.snapToGrid || false, + }) + : null; + + // 일반 컴포넌트 및 플로우 버튼 그룹에 격자 스냅 적용 (일반 그룹 제외) + if (draggedComponent?.type !== "group" && layout.gridSettings?.snapToGrid && currentGridInfo) { + finalPosition = snapPositionTo10px( + { + x: dragState.currentPosition.x, + y: dragState.currentPosition.y, + z: dragState.currentPosition.z ?? 1, + }, + currentGridInfo, + { + columns: layout.gridSettings.columns, + gap: layout.gridSettings.gap, + padding: layout.gridSettings.padding, + snapToGrid: layout.gridSettings.snapToGrid || false, + }, + ); + } + + // 스냅으로 인한 추가 이동 거리 계산 + const snapDeltaX = finalPosition.x - dragState.currentPosition.x; + const snapDeltaY = finalPosition.y - dragState.currentPosition.y; + + // 원래 이동 거리 + 스냅 조정 거리 + const totalDeltaX = dragState.currentPosition.x - dragState.originalPosition.x + snapDeltaX; + const totalDeltaY = dragState.currentPosition.y - dragState.originalPosition.y + snapDeltaY; + + // 다중 컴포넌트들의 최종 위치 업데이트 + const updatedComponents = layout.components.map((comp) => { + const isDraggedComponent = dragState.draggedComponents.some((dragComp) => dragComp.id === comp.id); + if (isDraggedComponent) { + const originalComponent = dragState.draggedComponents.find((dragComp) => dragComp.id === comp.id)!; + let newPosition = { + x: originalComponent.position.x + totalDeltaX, + y: originalComponent.position.y + totalDeltaY, + z: originalComponent.position.z || 1, + }; + + // 캔버스 경계 제한 (컴포넌트가 화면 밖으로 나가지 않도록) + const componentWidth = comp.size?.width || 100; + const componentHeight = comp.size?.height || 40; + + // 최소 위치: 0, 최대 위치: 캔버스 크기 - 컴포넌트 크기 + newPosition.x = Math.max(0, Math.min(newPosition.x, screenResolution.width - componentWidth)); + newPosition.y = Math.max(0, Math.min(newPosition.y, screenResolution.height - componentHeight)); + + // 그룹 내부 컴포넌트인 경우 패딩을 고려한 격자 스냅 적용 + if (comp.parentId && layout.gridSettings?.snapToGrid && gridInfo) { + const { columnWidth } = gridInfo; + const { gap } = layout.gridSettings; + + // 그룹 내부 패딩 고려한 격자 정렬 + const padding = 16; + const effectiveX = newPosition.x - padding; + const columnIndex = Math.round(effectiveX / (columnWidth + (gap || 16))); + const snappedX = padding + columnIndex * (columnWidth + (gap || 16)); + + // Y 좌표는 20px 단위로 스냅 + const effectiveY = newPosition.y - padding; + const rowIndex = Math.round(effectiveY / 20); + const snappedY = padding + rowIndex * 20; + + // 크기도 외부 격자와 동일하게 스냅 + const fullColumnWidth = columnWidth + (gap || 16); // 외부 격자와 동일한 크기 + const widthInColumns = Math.max(1, Math.round(comp.size.width / fullColumnWidth)); + const snappedWidth = widthInColumns * fullColumnWidth - (gap || 16); // gap 제거하여 실제 컴포넌트 크기 + // 높이는 사용자가 입력한 값 그대로 사용 (스냅 제거) + const snappedHeight = Math.max(40, comp.size.height); + + newPosition = { + x: Math.max(padding, snappedX), // 패딩만큼 최소 여백 확보 + y: Math.max(padding, snappedY), + z: newPosition.z, + }; + + // 크기도 업데이트 + const newSize = { + width: snappedWidth, + height: snappedHeight, + }; + + return { + ...comp, + position: newPosition as Position, + size: newSize, + }; + } + + return { + ...comp, + position: newPosition as Position, + }; + } + return comp; + }); + + const newLayout = { ...layout, components: updatedComponents }; + setLayout(newLayout); + + // 선택된 컴포넌트도 업데이트 (PropertiesPanel 동기화용) + if (selectedComponent && dragState.draggedComponents.some((c) => c.id === selectedComponent.id)) { + const updatedSelectedComponent = updatedComponents.find((c) => c.id === selectedComponent.id); + if (updatedSelectedComponent) { + console.log("🔄 ScreenDesigner: 선택된 컴포넌트 위치 업데이트", { + componentId: selectedComponent.id, + oldPosition: selectedComponent.position, + newPosition: updatedSelectedComponent.position, + }); + setSelectedComponent(updatedSelectedComponent); + } + } + + // 히스토리에 저장 + saveToHistory(newLayout); + } + + setDragState({ + isDragging: false, + draggedComponent: null, + draggedComponents: [], + originalPosition: { x: 0, y: 0, z: 1 }, + currentPosition: { x: 0, y: 0, z: 1 }, + grabOffset: { x: 0, y: 0 }, + justFinishedDrag: true, + }); + + // 짧은 시간 후 justFinishedDrag 플래그 해제 + setTimeout(() => { + setDragState((prev) => ({ + ...prev, + justFinishedDrag: false, + })); + }, 100); + }, + [dragState, layout, saveToHistory], + ); + + // 드래그 선택 시작 + const startSelectionDrag = useCallback( + (event: React.MouseEvent) => { + if (dragState.isDragging) return; // 컴포넌트 드래그 중이면 무시 + + const rect = canvasRef.current?.getBoundingClientRect(); + if (!rect) return; + + // zoom 스케일을 고려한 좌표 변환 + const startPoint = { + x: (event.clientX - rect.left) / zoomLevel, + y: (event.clientY - rect.top) / zoomLevel, + z: 1, + }; + + setSelectionDrag({ + isSelecting: true, + startPoint, + currentPoint: startPoint, + wasSelecting: false, + }); + }, + [dragState.isDragging, zoomLevel], + ); + + // 드래그 선택 업데이트 + const updateSelectionDrag = useCallback( + (event: MouseEvent) => { + if (!selectionDrag.isSelecting || !canvasRef.current) return; + + const rect = canvasRef.current.getBoundingClientRect(); + // zoom 스케일을 고려한 좌표 변환 + const currentPoint = { + x: (event.clientX - rect.left) / zoomLevel, + y: (event.clientY - rect.top) / zoomLevel, + z: 1, + }; + + setSelectionDrag((prev) => ({ + ...prev, + currentPoint, + })); + + // 선택 영역 내의 컴포넌트들 찾기 + const selectionRect = { + left: Math.min(selectionDrag.startPoint.x, currentPoint.x), + top: Math.min(selectionDrag.startPoint.y, currentPoint.y), + right: Math.max(selectionDrag.startPoint.x, currentPoint.x), + bottom: Math.max(selectionDrag.startPoint.y, currentPoint.y), + }; + + // 🆕 visibleComponents만 선택 대상으로 (현재 활성 레이어의 컴포넌트만) + const selectedIds = visibleComponents + .filter((comp) => { + const compRect = { + left: comp.position.x, + top: comp.position.y, + right: comp.position.x + comp.size.width, + bottom: comp.position.y + comp.size.height, + }; + + return ( + compRect.left < selectionRect.right && + compRect.right > selectionRect.left && + compRect.top < selectionRect.bottom && + compRect.bottom > selectionRect.top + ); + }) + .map((comp) => comp.id); + + setGroupState((prev) => ({ + ...prev, + selectedComponents: selectedIds, + })); + }, + [selectionDrag.isSelecting, selectionDrag.startPoint, visibleComponents, zoomLevel], + ); + + // 드래그 선택 종료 + const endSelectionDrag = useCallback(() => { + // 최소 드래그 거리 확인 (5픽셀) + const minDragDistance = 5; + const dragDistance = Math.sqrt( + Math.pow(selectionDrag.currentPoint.x - selectionDrag.startPoint.x, 2) + + Math.pow(selectionDrag.currentPoint.y - selectionDrag.startPoint.y, 2), + ); + + const wasActualDrag = dragDistance > minDragDistance; + + setSelectionDrag({ + isSelecting: false, + startPoint: { x: 0, y: 0, z: 1 }, + currentPoint: { x: 0, y: 0, z: 1 }, + wasSelecting: wasActualDrag, // 실제 드래그였을 때만 클릭 이벤트 무시 + }); + + // 짧은 시간 후 wasSelecting을 false로 리셋 + setTimeout(() => { + setSelectionDrag((prev) => ({ + ...prev, + wasSelecting: false, + })); + }, 100); + }, [selectionDrag.currentPoint, selectionDrag.startPoint]); + + // 컴포넌트 삭제 (단일/다중 선택 지원) + const deleteComponent = useCallback(() => { + // 다중 선택된 컴포넌트가 있는 경우 + if (groupState.selectedComponents.length > 0) { + // console.log("🗑️ 다중 컴포넌트 삭제:", groupState.selectedComponents.length, "개"); + + let newComponents = [...layout.components]; + + // 각 선택된 컴포넌트를 삭제 처리 + groupState.selectedComponents.forEach((componentId) => { + const component = layout.components.find((comp) => comp.id === componentId); + if (!component) return; + + if (component.type === "group") { + // 그룹 삭제 시: 자식 컴포넌트들의 절대 위치 복원 + const childComponents = newComponents.filter((comp) => comp.parentId === component.id); + const restoredChildren = restoreAbsolutePositions(childComponents, component.position); + + newComponents = newComponents + .map((comp) => { + if (comp.parentId === component.id) { + // 복원된 절대 위치로 업데이트 + const restoredChild = restoredChildren.find((restored) => restored.id === comp.id); + return restoredChild || { ...comp, parentId: undefined }; + } + return comp; + }) + .filter((comp) => comp.id !== component.id); // 그룹 컴포넌트 제거 + } else { + // 일반 컴포넌트 삭제 + newComponents = newComponents.filter((comp) => comp.id !== component.id); + } + }); + + const newLayout = { ...layout, components: newComponents }; + setLayout(newLayout); + saveToHistory(newLayout); + + // 선택 상태 초기화 + setSelectedComponent(null); + setGroupState((prev) => ({ ...prev, selectedComponents: [] })); + + toast.success(`${groupState.selectedComponents.length}개 컴포넌트가 삭제되었습니다.`); + return; + } + + // 단일 선택된 컴포넌트 삭제 + if (!selectedComponent) return; + + // console.log("🗑️ 단일 컴포넌트 삭제:", selectedComponent.id); + + let newComponents; + + if (selectedComponent.type === "group") { + // 그룹 삭제 시: 자식 컴포넌트들의 절대 위치 복원 후 그룹 삭제 + const childComponents = layout.components.filter((comp) => comp.parentId === selectedComponent.id); + const restoredChildren = restoreAbsolutePositions(childComponents, selectedComponent.position); + + newComponents = layout.components + .map((comp) => { + if (comp.parentId === selectedComponent.id) { + // 복원된 절대 위치로 업데이트 + const restoredChild = restoredChildren.find((restored) => restored.id === comp.id); + return restoredChild || { ...comp, parentId: undefined }; + } + return comp; + }) + .filter((comp) => comp.id !== selectedComponent.id); // 그룹 컴포넌트 제거 + } else { + // 일반 컴포넌트 삭제 + newComponents = layout.components.filter((comp) => comp.id !== selectedComponent.id); + } + + const newLayout = { ...layout, components: newComponents }; + + setLayout(newLayout); + saveToHistory(newLayout); + setSelectedComponent(null); + toast.success("컴포넌트가 삭제되었습니다."); + }, [selectedComponent, groupState.selectedComponents, layout, saveToHistory]); + + // 컴포넌트 복사 + const copyComponent = useCallback(() => { + if (groupState.selectedComponents.length > 0) { + // 다중 선택된 컴포넌트들 복사 + const componentsToCopy = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id)); + setClipboard(componentsToCopy); + // console.log("다중 컴포넌트 복사:", componentsToCopy.length, "개"); + toast.success(`${componentsToCopy.length}개 컴포넌트가 복사되었습니다.`); + } else if (selectedComponent) { + // 단일 컴포넌트 복사 + setClipboard([selectedComponent]); + // console.log("단일 컴포넌트 복사:", selectedComponent.id); + toast.success("컴포넌트가 복사되었습니다."); + } + }, [selectedComponent, groupState.selectedComponents, layout.components]); + + // 컴포넌트 붙여넣기 + const pasteComponent = useCallback(() => { + if (clipboard.length === 0) { + toast.warning("복사된 컴포넌트가 없습니다."); + return; + } + + const newComponents: ComponentData[] = []; + const offset = 20; // 붙여넣기 시 위치 오프셋 + + clipboard.forEach((clipComponent, index) => { + const newComponent: ComponentData = { + ...clipComponent, + id: generateComponentId(), + position: { + x: clipComponent.position.x + offset + index * 10, + y: clipComponent.position.y + offset + index * 10, + z: clipComponent.position.z || 1, + } as Position, + parentId: undefined, // 붙여넣기 시 부모 관계 해제 + layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 붙여넣기 (ref 사용) + }; + newComponents.push(newComponent); + }); + + const newLayout = { + ...layout, + components: [...layout.components, ...newComponents], + }; + + setLayout(newLayout); + saveToHistory(newLayout); + + // 붙여넣은 컴포넌트들을 선택 상태로 만들기 + setGroupState((prev) => ({ + ...prev, + selectedComponents: newComponents.map((comp) => comp.id), + })); + + // console.log("컴포넌트 붙여넣기 완료:", newComponents.length, "개"); + toast.success(`${newComponents.length}개 컴포넌트가 붙여넣어졌습니다.`); + }, [clipboard, layout, saveToHistory]); + + // 🆕 플로우 버튼 그룹 생성 (다중 선택된 버튼들을 한 번에 그룹으로) + // 🆕 플로우 버튼 그룹 다이얼로그 상태 + const [groupDialogOpen, setGroupDialogOpen] = useState(false); + + const handleFlowButtonGroup = useCallback(() => { + const selectedComponents = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id)); + + // 선택된 컴포넌트가 없거나 1개 이하면 그룹화 불가 + if (selectedComponents.length < 2) { + toast.error("그룹으로 묶을 버튼을 2개 이상 선택해주세요"); + return; + } + + // 모두 버튼인지 확인 + if (!areAllButtons(selectedComponents)) { + toast.error("버튼 컴포넌트만 그룹으로 묶을 수 있습니다"); + return; + } + + // 🆕 다이얼로그 열기 + setGroupDialogOpen(true); + }, [layout, groupState.selectedComponents]); + + // 🆕 그룹 생성 확인 핸들러 + const handleGroupConfirm = useCallback( + (settings: { + direction: "horizontal" | "vertical"; + gap: number; + align: "start" | "center" | "end" | "space-between" | "space-around"; + }) => { + const selectedComponents = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id)); + + // 고유한 그룹 ID 생성 + const newGroupId = generateGroupId(); + + // 🔧 그룹 위치 및 버튼 재배치 계산 + const align = settings.align; + const direction = settings.direction; + const gap = settings.gap; + + const groupY = Math.min(...selectedComponents.map((b) => b.position.y)); + let anchorButton; // 기준이 되는 버튼 + let groupX: number; + + // align에 따라 기준 버튼과 그룹 시작점 결정 + if (direction === "horizontal") { + if (align === "end") { + // 끝점 정렬: 가장 오른쪽 버튼이 기준 + anchorButton = selectedComponents.reduce((max, btn) => { + const rightEdge = btn.position.x + (btn.size?.width || 100); + const maxRightEdge = max.position.x + (max.size?.width || 100); + return rightEdge > maxRightEdge ? btn : max; + }); + + // 전체 그룹 너비 계산 + const totalWidth = selectedComponents.reduce((total, btn, index) => { + const buttonWidth = btn.size?.width || 100; + const gapWidth = index < selectedComponents.length - 1 ? gap : 0; + return total + buttonWidth + gapWidth; + }, 0); + + // 그룹 시작점 = 기준 버튼의 오른쪽 끝 - 전체 그룹 너비 + groupX = anchorButton.position.x + (anchorButton.size?.width || 100) - totalWidth; + } else if (align === "center") { + // 중앙 정렬: 버튼들의 중심점을 기준으로 + const minX = Math.min(...selectedComponents.map((b) => b.position.x)); + const maxX = Math.max(...selectedComponents.map((b) => b.position.x + (b.size?.width || 100))); + const centerX = (minX + maxX) / 2; + + const totalWidth = selectedComponents.reduce((total, btn, index) => { + const buttonWidth = btn.size?.width || 100; + const gapWidth = index < selectedComponents.length - 1 ? gap : 0; + return total + buttonWidth + gapWidth; + }, 0); + + groupX = centerX - totalWidth / 2; + anchorButton = selectedComponents[0]; // 중앙 정렬은 첫 번째 버튼 기준 + } else { + // 시작점 정렬: 가장 왼쪽 버튼이 기준 + anchorButton = selectedComponents.reduce((min, btn) => { + return btn.position.x < min.position.x ? btn : min; + }); + groupX = anchorButton.position.x; + } + } else { + // 세로 정렬: 가장 위쪽 버튼이 기준 + anchorButton = selectedComponents.reduce((min, btn) => { + return btn.position.y < min.position.y ? btn : min; + }); + groupX = Math.min(...selectedComponents.map((b) => b.position.x)); + } + + // 🔧 버튼들의 위치를 그룹 기준으로 재배치 + // 기준 버튼의 절대 위치를 유지하고, FlexBox가 나머지를 자동 정렬 + const groupedButtons = selectedComponents.map((button) => { + const currentConfig = (button as any).webTypeConfig?.flowVisibilityConfig || {}; + + // 모든 버튼을 그룹 시작점에 배치 + // FlexBox가 자동으로 정렬하여 기준 버튼의 위치가 유지됨 + const newPosition = { + x: groupX, + y: groupY, + z: button.position.z || 1, + }; + + return { + ...button, + position: newPosition, + webTypeConfig: { + ...(button as any).webTypeConfig, + flowVisibilityConfig: { + ...currentConfig, + enabled: true, + layoutBehavior: "auto-compact", + groupId: newGroupId, + groupDirection: settings.direction, + groupGap: settings.gap, + groupAlign: settings.align, + }, + }, + }; + }); + + // 레이아웃 업데이트 + const updatedComponents = layout.components.map((comp) => { + const grouped = groupedButtons.find((gb) => gb.id === comp.id); + return grouped || comp; + }); + + const newLayout = { + ...layout, + components: updatedComponents, + }; + + setLayout(newLayout); + saveToHistory(newLayout); + + toast.success(`${selectedComponents.length}개의 버튼이 플로우 그룹으로 묶였습니다`, { + description: `그룹 ID: ${newGroupId} / ${settings.direction === "horizontal" ? "가로" : "세로"} / ${settings.gap}px 간격`, + }); + + console.log("✅ 플로우 버튼 그룹 생성 완료:", { + groupId: newGroupId, + buttonCount: selectedComponents.length, + buttons: selectedComponents.map((b) => ({ id: b.id, position: b.position })), + groupPosition: { x: groupX, y: groupY }, + settings, + }); + }, + [layout, groupState.selectedComponents, saveToHistory], + ); + + // 🆕 플로우 버튼 그룹 해제 + const handleFlowButtonUngroup = useCallback(() => { + const selectedComponents = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id)); + + if (selectedComponents.length === 0) { + toast.error("그룹 해제할 버튼을 선택해주세요"); + return; + } + + // 버튼이 아닌 것 필터링 + const buttons = selectedComponents.filter((comp) => areAllButtons([comp])); + + if (buttons.length === 0) { + toast.error("선택된 버튼 중 그룹화된 버튼이 없습니다"); + return; + } + + // 그룹 해제 + const ungroupedButtons = ungroupButtons(buttons); + + // 레이아웃 업데이트 + 플로우 표시 제어 초기화 + const updatedComponents = layout.components.map((comp, index) => { + const ungrouped = ungroupedButtons.find((ub) => ub.id === comp.id); + + if (ungrouped) { + // 원래 위치 복원 또는 현재 위치 유지 + 간격 추가 + const buttonIndex = buttons.findIndex((b) => b.id === comp.id); + const basePosition = comp.position; + + // 버튼들을 오른쪽으로 조금씩 이동 (겹치지 않도록) + const offsetX = buttonIndex * 120; // 각 버튼당 120px 간격 + + // 그룹 해제된 버튼의 플로우 표시 제어를 끄고 설정 초기화 + return { + ...ungrouped, + position: { + x: basePosition.x + offsetX, + y: basePosition.y, + z: basePosition.z || 1, + }, + webTypeConfig: { + ...ungrouped.webTypeConfig, + flowVisibilityConfig: { + enabled: false, + targetFlowComponentId: null, + mode: "whitelist", + visibleSteps: [], + hiddenSteps: [], + layoutBehavior: "auto-compact", + groupId: null, + groupDirection: "horizontal", + groupGap: 8, + groupAlign: "start", + }, + }, + }; + } + + return comp; + }); + + const newLayout = { + ...layout, + components: updatedComponents, + }; + + setLayout(newLayout); + saveToHistory(newLayout); + + toast.success(`${buttons.length}개의 버튼 그룹이 해제되고 플로우 표시 제어가 비활성화되었습니다`); + }, [layout, groupState.selectedComponents, saveToHistory]); + + // 그룹 생성 (임시 비활성화) + const handleGroupCreate = useCallback( + (componentIds: string[], title: string, style?: any) => { + // console.log("그룹 생성 기능이 임시 비활성화되었습니다."); + toast.info("그룹 기능이 임시 비활성화되었습니다."); + return; + + // 격자 정보 계산 + const currentGridInfo = + gridInfo || + calculateGridInfo( + 1200, + 800, + layout.gridSettings || { + columns: 12, + gap: 16, + padding: 0, + snapToGrid: true, + showGrid: false, + gridColor: "#d1d5db", + gridOpacity: 0.5, + }, + ); + + console.log("🔧 그룹 생성 시작:", { + selectedCount: selectedComponents.length, + snapToGrid: true, + }); + + // 컴포넌트 크기 조정 기반 그룹 크기 계산 + const calculateOptimalGroupSize = () => { + if (!currentGridInfo || !layout.gridSettings?.snapToGrid) { + // 격자 스냅이 비활성화된 경우 기본 패딩 사용 + const boundingBox = calculateBoundingBox(selectedComponents); + const padding = 40; + return { + boundingBox, + groupPosition: { x: boundingBox.minX - padding, y: boundingBox.minY - padding, z: 1 }, + groupSize: { width: boundingBox.width + padding * 2, height: boundingBox.height + padding * 2 }, + gridColumns: 1, + scaledComponents: selectedComponents, // 크기 조정 없음 + padding: padding, + }; + } + + const { columnWidth } = currentGridInfo; + const gap = layout.gridSettings?.gap || 16; + const contentBoundingBox = calculateBoundingBox(selectedComponents); + + // 1. 간단한 접근: 컴포넌트들의 시작점에서 가장 가까운 격자 시작점 찾기 + const startColumn = Math.floor(contentBoundingBox.minX / (columnWidth + gap)); + + // 2. 컴포넌트들의 끝점까지 포함할 수 있는 컬럼 수 계산 + const groupStartX = startColumn * (columnWidth + gap); + const availableWidthFromStart = contentBoundingBox.maxX - groupStartX; + const currentWidthInColumns = Math.ceil(availableWidthFromStart / (columnWidth + gap)); + + // 2. 그룹은 격자에 정확히 맞게 위치와 크기 설정 + const padding = 20; + const groupX = startColumn * (columnWidth + gap); // 격자 시작점에 정확히 맞춤 + const groupY = contentBoundingBox.minY - padding; + const groupWidth = currentWidthInColumns * columnWidth + (currentWidthInColumns - 1) * gap; // 컬럼 크기 + gap + const groupHeight = contentBoundingBox.height + padding * 2; + + // 4. 내부 컴포넌트들을 그룹 크기에 맞게 스케일링 + const availableWidth = groupWidth - padding * 2; // 패딩 제외한 실제 사용 가능 너비 + const scaleFactorX = availableWidth / contentBoundingBox.width; + + const scaledComponents = selectedComponents.map((comp) => { + // 컴포넌트의 원래 위치에서 컨텐츠 영역 시작점까지의 상대 위치 계산 + const relativeX = comp.position.x - contentBoundingBox.minX; + const relativeY = comp.position.y - contentBoundingBox.minY; + + return { + ...comp, + position: { + x: padding + relativeX * scaleFactorX, // 패딩 + 스케일된 상대 위치 + y: padding + relativeY, // Y는 스케일링 없이 패딩만 적용 + z: comp.position.z || 1, + }, + size: { + width: comp.size.width * scaleFactorX, // X 방향 스케일링 + height: comp.size.height, // Y는 원본 크기 유지 + }, + }; + }); + + console.log("🎯 컴포넌트 크기 조정 기반 그룹 생성:", { + originalBoundingBox: contentBoundingBox, + gridCalculation: { + columnWidthPlusGap: columnWidth + gap, + startColumn: `Math.floor(${contentBoundingBox.minX} / ${columnWidth + gap}) = ${startColumn}`, + groupStartX: `${startColumn} * ${columnWidth + gap} = ${groupStartX}`, + availableWidthFromStart: `${contentBoundingBox.maxX} - ${groupStartX} = ${availableWidthFromStart}`, + currentWidthInColumns: `Math.ceil(${availableWidthFromStart} / ${columnWidth + gap}) = ${currentWidthInColumns}`, + finalGroupX: `${startColumn} * ${columnWidth + gap} = ${groupX}`, + actualGroupWidth: `${currentWidthInColumns}컬럼 * ${columnWidth}px + ${currentWidthInColumns - 1}gap * ${gap}px = ${groupWidth}px`, + }, + groupPosition: { x: groupX, y: groupY }, + groupSize: { width: groupWidth, height: groupHeight }, + scaleFactorX, + availableWidth, + padding, + scaledComponentsCount: scaledComponents.length, + scaledComponentsDetails: scaledComponents.map((comp) => { + const original = selectedComponents.find((c) => c.id === comp.id); + return { + id: comp.id, + originalPos: original?.position, + scaledPos: comp.position, + originalSize: original?.size, + scaledSize: comp.size, + deltaX: comp.position.x - (original?.position.x || 0), + deltaY: comp.position.y - (original?.position.y || 0), + }; + }), + }); + + return { + boundingBox: contentBoundingBox, + groupPosition: { x: groupX, y: groupY, z: 1 }, + groupSize: { width: groupWidth, height: groupHeight }, + gridColumns: currentWidthInColumns, + scaledComponents: scaledComponents, // 스케일된 컴포넌트들 + padding: padding, + }; + }; + + const { + boundingBox, + groupPosition, + groupSize: optimizedGroupSize, + gridColumns, + scaledComponents, + padding, + } = calculateOptimalGroupSize(); + + // 스케일된 컴포넌트들로 상대 위치 계산 (이미 최적화되어 추가 격자 정렬 불필요) + const relativeChildren = calculateRelativePositions( + scaledComponents, + groupPosition, + "temp", // 임시 그룹 ID + ); + + console.log("📏 최적화된 그룹 생성 (컴포넌트 스케일링):", { + gridColumns, + groupSize: optimizedGroupSize, + groupPosition, + scaledComponentsCount: scaledComponents.length, + padding, + strategy: "내부 컴포넌트 크기 조정으로 격자 정확 맞춤", + }); + + // 그룹 컴포넌트 생성 (gridColumns 속성 포함) + const groupComponent = createGroupComponent(componentIds, title, groupPosition, optimizedGroupSize, style); + + // 그룹에 계산된 gridColumns 속성 추가 + groupComponent.gridColumns = gridColumns; + + // 실제 그룹 ID로 자식들 업데이트 + const finalChildren = relativeChildren.map((child) => ({ + ...child, + parentId: groupComponent.id, + })); + + const newLayout = { + ...layout, + components: [ + ...layout.components.filter((comp) => !componentIds.includes(comp.id)), + groupComponent, + ...finalChildren, + ], + }; + + setLayout(newLayout); + saveToHistory(newLayout); + setGroupState((prev) => ({ + ...prev, + selectedComponents: [groupComponent.id], + isGrouping: false, + })); + setSelectedComponent(groupComponent); + + console.log("🎯 최적화된 그룹 생성 완료:", { + groupId: groupComponent.id, + childrenCount: finalChildren.length, + position: groupPosition, + size: optimizedGroupSize, + gridColumns: groupComponent.gridColumns, + componentsScaled: !!scaledComponents.length, + gridAligned: true, + }); + + toast.success(`그룹이 생성되었습니다 (${finalChildren.length}개 컴포넌트)`); + }, + [layout, saveToHistory], + ); + + // 그룹 생성 함수 (다이얼로그 표시) + const createGroup = useCallback(() => { + if (groupState.selectedComponents.length < 2) { + toast.warning("그룹을 만들려면 2개 이상의 컴포넌트를 선택해야 합니다."); + return; + } + + // console.log("🔄 그룹 생성 다이얼로그 표시"); + setShowGroupCreateDialog(true); + }, [groupState.selectedComponents]); + + // 그룹 해제 함수 (임시 비활성화) + const ungroupComponents = useCallback(() => { + // console.log("그룹 해제 기능이 임시 비활성화되었습니다."); + toast.info("그룹 해제 기능이 임시 비활성화되었습니다."); + return; + + const groupId = selectedComponent.id; + + // 자식 컴포넌트들의 절대 위치 복원 + const childComponents = layout.components.filter((comp) => comp.parentId === groupId); + const restoredChildren = restoreAbsolutePositions(childComponents, selectedComponent.position); + + // 자식 컴포넌트들의 위치 복원 및 parentId 제거 + const updatedComponents = layout.components + .map((comp) => { + if (comp.parentId === groupId) { + const restoredChild = restoredChildren.find((restored) => restored.id === comp.id); + return restoredChild || { ...comp, parentId: undefined }; + } + return comp; + }) + .filter((comp) => comp.id !== groupId); // 그룹 컴포넌트 제거 + + const newLayout = { ...layout, components: updatedComponents }; + setLayout(newLayout); + saveToHistory(newLayout); + + // 선택 상태 초기화 + setSelectedComponent(null); + setGroupState((prev) => ({ ...prev, selectedComponents: [] })); + }, [selectedComponent, layout, saveToHistory]); + + // 마우스 이벤트 처리 (드래그 및 선택) - 성능 최적화 + useEffect(() => { + let animationFrameId: number; + + const handleMouseMove = (e: MouseEvent) => { + if (dragState.isDragging) { + // requestAnimationFrame으로 부드러운 애니메이션 + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + } + animationFrameId = requestAnimationFrame(() => { + updateDragPosition(e); + }); + } else if (selectionDrag.isSelecting) { + updateSelectionDrag(e); + } + }; + + const handleMouseUp = (e: MouseEvent) => { + if (dragState.isDragging) { + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + } + endDrag(e); + } else if (selectionDrag.isSelecting) { + endSelectionDrag(); + } + }; + + if (dragState.isDragging || selectionDrag.isSelecting) { + document.addEventListener("mousemove", handleMouseMove, { passive: true }); + document.addEventListener("mouseup", handleMouseUp); + + return () => { + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + } + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + } + }, [ + dragState.isDragging, + selectionDrag.isSelecting, + updateDragPosition, + endDrag, + updateSelectionDrag, + endSelectionDrag, + ]); + + // 키보드 이벤트 처리 (브라우저 기본 기능 완전 차단) + useEffect(() => { + const handleKeyDown = async (e: KeyboardEvent) => { + // console.log("🎯 키 입력 감지:", { key: e.key, ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, metaKey: e.metaKey }); + + // 🚫 브라우저 기본 단축키 완전 차단 목록 + const browserShortcuts = [ + // 검색 관련 + { ctrl: true, key: "f" }, // 페이지 내 검색 + { ctrl: true, key: "g" }, // 다음 검색 결과 + { ctrl: true, shift: true, key: "g" }, // 이전 검색 결과 + { ctrl: true, key: "h" }, // 검색 기록 + + // 탭/창 관리 + { ctrl: true, key: "t" }, // 새 탭 + { ctrl: true, key: "w" }, // 탭 닫기 + { ctrl: true, shift: true, key: "t" }, // 닫힌 탭 복원 + { ctrl: true, key: "n" }, // 새 창 + { ctrl: true, shift: true, key: "n" }, // 시크릿 창 + + // 페이지 관리 + { ctrl: true, key: "r" }, // 새로고침 + { ctrl: true, shift: true, key: "r" }, // 강제 새로고침 + { ctrl: true, key: "d" }, // 북마크 추가 + { ctrl: true, shift: true, key: "d" }, // 모든 탭 북마크 + + // 편집 관련 (필요시에만 허용) + { ctrl: true, key: "s" }, // 저장 (필요시 차단 해제) + { ctrl: true, key: "p" }, // 인쇄 + { ctrl: true, key: "o" }, // 파일 열기 + { ctrl: true, key: "v" }, // 붙여넣기 (브라우저 기본 동작 차단) + + // 개발자 도구 + { key: "F12" }, // 개발자 도구 + { ctrl: true, shift: true, key: "i" }, // 개발자 도구 + { ctrl: true, shift: true, key: "c" }, // 요소 검사 + { ctrl: true, shift: true, key: "j" }, // 콘솔 + { ctrl: true, key: "u" }, // 소스 보기 + + // 기타 + { ctrl: true, key: "j" }, // 다운로드 + { ctrl: true, shift: true, key: "delete" }, // 브라우징 데이터 삭제 + { ctrl: true, key: "+" }, // 확대 + { ctrl: true, key: "-" }, // 축소 + { ctrl: true, key: "0" }, // 확대/축소 초기화 + ]; + + // 브라우저 기본 단축키 체크 및 차단 + const isBrowserShortcut = browserShortcuts.some((shortcut) => { + const ctrlMatch = shortcut.ctrl ? e.ctrlKey || e.metaKey : true; + const shiftMatch = shortcut.shift ? e.shiftKey : !e.shiftKey; + const keyMatch = e.key?.toLowerCase() === shortcut.key?.toLowerCase(); + return ctrlMatch && shiftMatch && keyMatch; + }); + + if (isBrowserShortcut) { + // console.log("🚫 브라우저 기본 단축키 차단:", e.key); + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + } + + // ✅ 애플리케이션 전용 단축키 처리 + + // 1. 그룹 관련 단축키 + if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "g" && !e.shiftKey) { + // console.log("🔄 그룹 생성 단축키"); + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + + if (groupState.selectedComponents.length >= 2) { + // console.log("✅ 그룹 생성 실행"); + createGroup(); + } else { + // console.log("⚠️ 선택된 컴포넌트가 부족함 (2개 이상 필요)"); + } + return false; + } + + if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key?.toLowerCase() === "g") { + // console.log("🔄 그룹 해제 단축키"); + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + + if (selectedComponent && selectedComponent.type === "group") { + // console.log("✅ 그룹 해제 실행"); + ungroupComponents(); + } else { + // console.log("⚠️ 선택된 그룹이 없음"); + } + return false; + } + + // 2. 전체 선택 (애플리케이션 내에서만) + if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "a") { + // console.log("🔄 전체 선택 (애플리케이션 내)"); + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + const allComponentIds = layout.components.map((comp) => comp.id); + setGroupState((prev) => ({ ...prev, selectedComponents: allComponentIds })); + return false; + } + + // 3. 실행취소/다시실행 + if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "z" && !e.shiftKey) { + // console.log("🔄 실행취소"); + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + undo(); + return false; + } + + if ( + ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "y") || + ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key?.toLowerCase() === "z") + ) { + // console.log("🔄 다시실행"); + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + redo(); + return false; + } + + // 4. 복사 (컴포넌트 복사) + if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "c") { + // console.log("🔄 컴포넌트 복사"); + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + copyComponent(); + return false; + } + + // 5. 붙여넣기 (컴포넌트 붙여넣기) + if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "v") { + // console.log("🔄 컴포넌트 붙여넣기"); + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + pasteComponent(); + return false; + } + + // 6. 삭제 (단일/다중 선택 지원) + if (e.key === "Delete" && (selectedComponent || groupState.selectedComponents.length > 0)) { + // console.log("🗑️ 컴포넌트 삭제 (단축키)"); + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + deleteComponent(); + return false; + } + + // 7. 선택 해제 + if (e.key === "Escape") { + // console.log("🔄 선택 해제"); + setSelectedComponent(null); + setGroupState((prev) => ({ ...prev, selectedComponents: [], isGrouping: false })); + return false; + } + + // 8. 저장 (Ctrl+S는 레이아웃 저장용으로 사용) + if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "s") { + // console.log("💾 레이아웃 저장"); + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + + // 레이아웃 저장 실행 + if (layout.components.length > 0 && selectedScreen?.screenId) { + setIsSaving(true); + try { + // 해상도 정보를 포함한 레이아웃 데이터 생성 + const layoutWithResolution = { + ...layout, + screenResolution: screenResolution, + }; + console.log("⚡ 자동 저장할 레이아웃 데이터:", { + componentsCount: layoutWithResolution.components.length, + gridSettings: layoutWithResolution.gridSettings, + screenResolution: layoutWithResolution.screenResolution, + }); + // V2/POP API 사용 여부에 따라 분기 + const v2Layout = convertLegacyToV2(layoutWithResolution); + if (USE_POP_API) { + await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout); + } else if (USE_V2_API) { + await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout); + } else { + await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution); + } + toast.success("레이아웃이 저장되었습니다."); + } catch (error) { + // console.error("레이아웃 저장 실패:", error); + toast.error("레이아웃 저장에 실패했습니다."); + } finally { + setIsSaving(false); + } + } else { + // console.log("⚠️ 저장할 컴포넌트가 없습니다"); + toast.warning("저장할 컴포넌트가 없습니다."); + } + return false; + } + + // === 9. 화살표 키 Nudge (컴포넌트 미세 이동) === + if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) { + // 입력 필드에서는 무시 + const active = document.activeElement; + if ( + active instanceof HTMLInputElement || + active instanceof HTMLTextAreaElement || + active?.getAttribute("contenteditable") === "true" + ) { + return; + } + + if (selectedComponent || groupState.selectedComponents.length > 0) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + const distance = e.shiftKey ? 10 : 1; // Shift 누르면 10px + const dirMap: Record = { + ArrowUp: "up", ArrowDown: "down", ArrowLeft: "left", ArrowRight: "right", + }; + handleNudge(dirMap[e.key], distance); + return false; + } + } + + // === 10. 정렬 단축키 (Alt + 키) - 다중 선택 시 === + if (e.altKey && !e.ctrlKey && !e.metaKey) { + const alignKey = e.key?.toLowerCase(); + const alignMap: Record = { + l: "left", r: "right", c: "centerX", + t: "top", b: "bottom", m: "centerY", + }; + + if (alignMap[alignKey] && groupState.selectedComponents.length >= 2) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + handleGroupAlign(alignMap[alignKey]); + return false; + } + + // 균등 배분 (Alt+H: 가로, Alt+V: 세로) + if (alignKey === "h" && groupState.selectedComponents.length >= 3) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + handleGroupDistribute("horizontal"); + return false; + } + if (alignKey === "v" && groupState.selectedComponents.length >= 3) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + handleGroupDistribute("vertical"); + return false; + } + + // 동일 크기 맞추기 (Alt+W: 너비, Alt+E: 높이) + if (alignKey === "w" && groupState.selectedComponents.length >= 2) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + handleMatchSize("width"); + return false; + } + if (alignKey === "e" && groupState.selectedComponents.length >= 2) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + handleMatchSize("height"); + return false; + } + } + + // === 11. 라벨 일괄 토글 (Alt+Shift+L) === + if (e.altKey && e.shiftKey && e.key?.toLowerCase() === "l") { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + handleToggleAllLabels(); + return false; + } + + // === 12. 단축키 도움말 (? 키) === + if (e.key === "?" && !e.ctrlKey && !e.metaKey && !e.altKey) { + // 입력 필드에서는 무시 + const active = document.activeElement; + if ( + active instanceof HTMLInputElement || + active instanceof HTMLTextAreaElement || + active?.getAttribute("contenteditable") === "true" + ) { + return; + } + e.preventDefault(); + setShowShortcutsModal(true); + return false; + } + }; + + // window 레벨에서 캡처 단계에서 가장 먼저 처리 + window.addEventListener("keydown", handleKeyDown, { capture: true, passive: false }); + return () => window.removeEventListener("keydown", handleKeyDown, { capture: true }); + }, [ + selectedComponent, + deleteComponent, + copyComponent, + pasteComponent, + undo, + redo, + createGroup, + ungroupComponents, + groupState.selectedComponents, + layout, + selectedScreen, + handleNudge, + handleGroupAlign, + handleGroupDistribute, + handleMatchSize, + handleToggleAllLabels, + ]); + + // 플로우 위젯 높이 자동 업데이트 이벤트 리스너 + useEffect(() => { + const handleComponentSizeUpdate = (event: CustomEvent) => { + const { componentId, height } = event.detail; + + // 해당 컴포넌트 찾기 + const targetComponent = layout.components.find((c) => c.id === componentId); + if (!targetComponent) { + return; + } + + // 이미 같은 높이면 업데이트 안함 + if (targetComponent.size?.height === height) { + return; + } + + // 컴포넌트 높이 업데이트 + const updatedComponents = layout.components.map((comp) => { + if (comp.id === componentId) { + return { + ...comp, + size: { + ...comp.size, + width: comp.size?.width || 100, + height: height, + }, + }; + } + return comp; + }); + + const newLayout = { + ...layout, + components: updatedComponents, + }; + + setLayout(newLayout); + + // 선택된 컴포넌트도 업데이트 + if (selectedComponent?.id === componentId) { + const updatedComponent = updatedComponents.find((c) => c.id === componentId); + if (updatedComponent) { + setSelectedComponent(updatedComponent); + } + } + }; + + window.addEventListener("updateComponentSize", handleComponentSizeUpdate as EventListener); + return () => { + window.removeEventListener("updateComponentSize", handleComponentSizeUpdate as EventListener); + }; + }, [layout, selectedComponent]); + + // 🆕 조건부 영역 드래그 핸들러 (이동/리사이즈, DB 기반) + const handleRegionMouseDown = useCallback(( + e: React.MouseEvent, + layerId: string, + mode: "move" | "resize", + handle?: string, + ) => { + e.stopPropagation(); + e.preventDefault(); + const zoneId = Number(layerId); // layerId는 실제로 zoneId + const zone = zones.find(z => z.zone_id === zoneId); + if (!zone) 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: String(zoneId), + startX: x, + startY: y, + currentX: x, + currentY: y, + resizeHandle: handle || null, + originalRegion: { x: zone.x, y: zone.y, width: zone.width, height: zone.height }, + }); + }, [zones, 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 zoneId = Number(regionDrag.targetLayerId); + setZones((prev) => prev.map(z => z.zone_id === zoneId ? { ...z, ...newRegion } : z)); + } 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 zoneId = Number(regionDrag.targetLayerId); + setZones((prev) => prev.map(z => z.zone_id === zoneId ? { ...z, ...newRegion } : z)); + } + }, [regionDrag, zoomLevel]); + + const handleRegionCanvasMouseUp = useCallback(async () => { + // 드래그 완료 시 DB에 Zone 저장 + if ((regionDrag.isDragging || regionDrag.isResizing) && regionDrag.targetLayerId) { + const zoneId = Number(regionDrag.targetLayerId); + const zone = zones.find(z => z.zone_id === zoneId); + if (zone) { + try { + await screenApi.updateZone(zoneId, { + x: zone.x, y: zone.y, width: zone.width, height: zone.height, + }); + } catch { + console.error("Zone 저장 실패"); + } + } + } + // 드래그 상태 초기화 + setRegionDrag({ + isDrawing: false, + isDragging: false, + isResizing: false, + targetLayerId: null, + startX: 0, startY: 0, currentX: 0, currentY: 0, + resizeHandle: null, + originalRegion: null, + }); + }, [regionDrag, zones]); + + // 🆕 레이어 변경 핸들러 - 레이어 컨텍스트에서 레이어가 변경되면 layout에도 반영 + // Zone 기반이므로 displayRegion 보존 불필요 + const handleLayersChange = useCallback((newLayers: LayerDefinition[]) => { + setLayout((prevLayout) => ({ + ...prevLayout, + layers: newLayers, + })); + }, []); + + // 🆕 활성 레이어 변경 핸들러 + const handleActiveLayerChange = useCallback((newActiveLayerId: number) => { + setActiveLayerIdWithRef(newActiveLayerId); + }, [setActiveLayerIdWithRef]); + + // 🆕 초기 레이어 계산 - layout에서 layers가 있으면 사용, 없으면 기본 레이어 생성 + // 주의: components는 layout.components에 layerId 속성으로 저장되므로, layer.components는 비워둠 + const initialLayers = useMemo(() => { + if (layout.layers && layout.layers.length > 0) { + // 기존 레이어 구조 사용 (layer.components는 무시하고 빈 배열로 설정) + return layout.layers.map(layer => ({ + ...layer, + components: [], // layout.components + layerId 방식 사용 + })); + } + // layers가 없으면 기본 레이어 생성 (components는 빈 배열) + return [createDefaultLayer()]; + }, [layout.layers]); + + if (!selectedScreen) { + return ( +
+
+
+ +
+

화면을 선택하세요

+

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

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

패널

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

캔버스가 비어있습니다

+

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

+
+

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

+

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

+

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

+
+
+
+ )} +
+
+
+ ); /* 🔥 줌 래퍼 닫기 */ + })()} +
+
{" "} + {/* 메인 컨테이너 닫기 */} + {/* 🆕 플로우 버튼 그룹 생성 다이얼로그 */} + + {/* 모달들 */} + {/* 메뉴 할당 모달 */} + {showMenuAssignmentModal && selectedScreen && ( + setShowMenuAssignmentModal(false)} + onAssignmentComplete={() => { + // 모달을 즉시 닫지 않고, MenuAssignmentModal이 3초 후 자동으로 닫히도록 함 + // setShowMenuAssignmentModal(false); + // toast.success("메뉴에 화면이 할당되었습니다."); + }} + onBackToList={onBackToList} + /> + )} + {/* 파일첨부 상세 모달 */} + {showFileAttachmentModal && selectedFileComponent && ( + { + setShowFileAttachmentModal(false); + setSelectedFileComponent(null); + }} + component={selectedFileComponent} + screenId={selectedScreen.screenId} + /> + )} + {/* 다국어 설정 모달 */} + setShowMultilangSettingsModal(false)} + components={layout.components} + onSave={async (updates) => { + if (updates.length === 0) { + toast.info("저장할 변경사항이 없습니다."); + return; + } + + try { + // 공통 유틸 사용하여 매핑 적용 + const { applyMultilangMappings } = await import("@/lib/utils/multilangLabelExtractor"); + + // 매핑 형식 변환 + const mappings = updates.map((u) => ({ + componentId: u.componentId, + keyId: u.langKeyId, + langKey: u.langKey, + })); + + // 레이아웃 업데이트 + const updatedComponents = applyMultilangMappings(layout.components, mappings); + setLayout((prev) => ({ + ...prev, + components: updatedComponents, + })); + + toast.success(`${updates.length}개 항목의 다국어 설정이 저장되었습니다.`); + } catch (error) { + console.error("다국어 설정 저장 실패:", error); + toast.error("다국어 설정 저장 중 오류가 발생했습니다."); + } + }} + /> + {/* 단축키 도움말 모달 */} + setShowShortcutsModal(false)} + /> +
+ + + + ); +} \ No newline at end of file diff --git a/frontend/lib/api/screen.ts b/frontend/lib/api/screen.ts index f6aad934..96766816 100644 --- a/frontend/lib/api/screen.ts +++ b/frontend/lib/api/screen.ts @@ -235,6 +235,57 @@ export const screenApi = { await apiClient.put(`/screen-management/screens/${screenId}/layers/${layerId}/condition`, { conditionConfig, layerName }); }, + // ======================================== + // 조건부 영역(Zone) 관리 + // ======================================== + + // Zone 목록 조회 + getScreenZones: async (screenId: number): Promise => { + const response = await apiClient.get(`/screen-management/screens/${screenId}/zones`); + return response.data.data || []; + }, + + // Zone 생성 + createZone: async (screenId: number, zoneData: { + zone_name?: string; + x: number; + y: number; + width: number; + height: number; + trigger_component_id?: string; + trigger_operator?: string; + }): Promise => { + const response = await apiClient.post(`/screen-management/screens/${screenId}/zones`, zoneData); + return response.data.data; + }, + + // Zone 업데이트 (위치/크기/트리거) + updateZone: async (zoneId: number, updates: { + zone_name?: string; + x?: number; + y?: number; + width?: number; + height?: number; + trigger_component_id?: string; + trigger_operator?: string; + }): Promise => { + await apiClient.put(`/screen-management/zones/${zoneId}`, updates); + }, + + // Zone 삭제 + deleteZone: async (zoneId: number): Promise => { + await apiClient.delete(`/screen-management/zones/${zoneId}`); + }, + + // Zone에 레이어 추가 + addLayerToZone: async (screenId: number, zoneId: number, conditionValue: string, layerName?: string): Promise<{ layerId: number }> => { + const response = await apiClient.post(`/screen-management/screens/${screenId}/zones/${zoneId}/layers`, { + conditionValue, + layerName, + }); + return response.data.data; + }, + // ======================================== // POP 레이아웃 관리 (모바일/태블릿) // ======================================== diff --git a/frontend/types/screen-management.ts b/frontend/types/screen-management.ts index 0687e8d3..91c61146 100644 --- a/frontend/types/screen-management.ts +++ b/frontend/types/screen-management.ts @@ -878,7 +878,7 @@ export interface LayerOverlayConfig { /** * 조건부 레이어 표시 영역 - * 조건 미충족 시 이 영역이 사라지고, 아래 컴포넌트들이 위로 이동 + * @deprecated Zone 기반으로 전환 - ConditionalZone.x/y/width/height 사용 */ export interface DisplayRegion { x: number; @@ -887,6 +887,27 @@ export interface DisplayRegion { height: number; } +/** + * 조건부 영역(Zone) + * - 기본 레이어 캔버스에서 영역을 정의하고, 여러 레이어를 할당 + * - Zone 내에서는 항상 1개 레이어만 활성 (exclusive) + * - Zone 단위로 접힘/펼침 판단 (Y 오프셋 계산 단순화) + */ +export interface ConditionalZone { + zone_id: number; + screen_id: number; + company_code: string; + zone_name: string; + x: number; + y: number; + width: number; + height: number; + trigger_component_id: string | null; // 기본 레이어의 트리거 컴포넌트 ID + trigger_operator: string; // eq, neq, in + created_at?: string; + updated_at?: string; +} + /** * 레이어 정의 */ @@ -898,10 +919,14 @@ export interface LayerDefinition { isVisible: boolean; // 초기 표시 여부 isLocked: boolean; // 편집 잠금 여부 - // 조건부 표시 로직 + // 조건부 표시 로직 (레거시 - Zone 미사용 레이어용) condition?: LayerCondition; - // 조건부 레이어 표시 영역 (조건 미충족 시 이 영역이 사라짐) + // Zone 기반 조건부 설정 (신규) + zoneId?: number; // 소속 조건부 영역 ID + conditionValue?: string; // Zone 트리거 매칭 값 + + // 조건부 레이어 표시 영역 (레거시 호환 - Zone으로 대체됨) displayRegion?: DisplayRegion; // 모달/드로어 전용 설정 From 1aacd829f2d07894aad01a2bb4815909811de742 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Mon, 9 Feb 2026 16:46:50 +0900 Subject: [PATCH 11/44] 123 --- frontend/components/screen/ScreenDesigner.tsx | 7141 ++++++++++++++++- 1 file changed, 7140 insertions(+), 1 deletion(-) diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 5f62ade4..a530c024 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -1 +1,7140 @@ -서 \ No newline at end of file +"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)} + /> +
+ + + + ); +} From c65f436009aa6c9131b000e611c3b4be10d51e06 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 9 Feb 2026 17:13:26 +0900 Subject: [PATCH 12/44] feat: Enhance LayerManagerPanel with dynamic trigger options and improved condition handling - Added a new Select component to allow users to choose condition values dynamically based on the selected zone's trigger component. - Implemented logic to fetch trigger options from the base layer components, ensuring only relevant options are displayed. - Updated the LayerManagerPanel to handle condition values more effectively, including the ability to set new condition values and manage used values. - Refactored the ComponentsPanel to include the new V2 select component with appropriate configuration options. - Improved the V2SelectConfigPanel to streamline option management and enhance user experience. --- .../components/screen/LayerManagerPanel.tsx | 116 +- frontend/components/screen/ScreenDesigner.tsx | 7494 +---------------- .../screen/panels/ComponentsPanel.tsx | 21 +- frontend/components/v2/V2Select.tsx | 12 +- .../v2/config-panels/V2SelectConfigPanel.tsx | 22 +- 5 files changed, 137 insertions(+), 7528 deletions(-) diff --git a/frontend/components/screen/LayerManagerPanel.tsx b/frontend/components/screen/LayerManagerPanel.tsx index 9179afbe..8d7e6dc0 100644 --- a/frontend/components/screen/LayerManagerPanel.tsx +++ b/frontend/components/screen/LayerManagerPanel.tsx @@ -1,6 +1,7 @@ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Badge } from "@/components/ui/badge"; import { @@ -78,6 +79,11 @@ export const LayerManagerPanel: React.FC = ({ // 기본 레이어 컴포넌트 로드 const loadBaseLayerComponents = useCallback(async () => { if (!screenId) return; + // 현재 활성 레이어가 기본 레이어(1)이면 props의 실시간 컴포넌트 사용 + if (activeLayerId === 1 && components.length > 0) { + setBaseLayerComponents(components); + return; + } try { const data = await screenApi.getLayerLayout(screenId, 1); if (data?.components) { @@ -86,7 +92,7 @@ export const LayerManagerPanel: React.FC = ({ } catch { setBaseLayerComponents(components); } - }, [screenId, components]); + }, [screenId, components, activeLayerId]); useEffect(() => { loadLayers(); @@ -191,6 +197,22 @@ export const LayerManagerPanel: React.FC = ({ ["select", "combobox", "radio-group"].some(t => c.componentType?.includes(t)) ); + // Zone의 트리거 컴포넌트에서 옵션 목록 가져오기 + const getTriggerOptions = useCallback((zone: ConditionalZone): { value: string; label: string }[] => { + if (!zone.trigger_component_id) return []; + const triggerComp = baseLayerComponents.find(c => c.id === zone.trigger_component_id); + if (!triggerComp) return []; + + const config = triggerComp.componentConfig || {}; + // 정적 옵션 (v2-select static source) + if (config.options && Array.isArray(config.options)) { + return config.options + .filter((opt: any) => opt.value) + .map((opt: any) => ({ value: opt.value, label: opt.label || opt.value })); + } + return []; + }, [baseLayerComponents]); + return (
{/* 헤더 */} @@ -335,6 +357,8 @@ export const LayerManagerPanel: React.FC = ({ {/* Zone 소속 레이어 목록 */} {zoneLayers.map((layer) => { const isActive = activeLayerId === layer.layer_id; + const triggerOpts = getTriggerOptions(zone); + const currentCondValue = layer.condition_config?.condition_value || ""; return (
= ({
{layer.layer_name} -
- - 조건값: {layer.condition_config?.condition_value || "미설정"} - +
+ {triggerOpts.length > 0 ? ( + + ) : ( + + 조건값: {currentCondValue || "미설정"} + + )} | {layer.component_count}개 @@ -373,14 +434,41 @@ export const LayerManagerPanel: React.FC = ({ {/* 레이어 추가 */} {addingToZoneId === zone.zone_id ? (
- setNewConditionValue(e.target.value)} - placeholder="조건값 입력 (예: 옵션1)" - className="h-6 text-[11px] flex-1" - autoFocus - onKeyDown={(e) => { if (e.key === "Enter") handleAddLayerToZone(zone.zone_id); }} - /> + {(() => { + const triggerOpts = getTriggerOptions(zone); + // 이미 사용된 조건값 제외 + const usedValues = new Set( + zoneLayers.map(l => l.condition_config?.condition_value).filter(Boolean) + ); + const availableOpts = triggerOpts.filter(o => !usedValues.has(o.value)); + + if (availableOpts.length > 0) { + return ( + + ); + } + return ( + setNewConditionValue(e.target.value)} + placeholder="조건값 입력" + className="h-6 text-[11px] flex-1" + autoFocus + onKeyDown={(e) => { if (e.key === "Enter") handleAddLayerToZone(zone.zone_id); }} + /> + ); + })()} -
-
- - - - 컴포넌트 - - - 레이어 - - - 편집 - - - - - { - const dragData = { - type: column ? "column" : "table", - table, - column, - }; - e.dataTransfer.setData("application/json", JSON.stringify(dragData)); - }} - selectedTableName={selectedScreen?.tableName} - placedColumns={placedColumns} - onTableSelect={handleTableSelect} - showTableSelector={true} - /> - - - {/* 🆕 레이어 관리 탭 (DB 기반) */} - - { - if (!selectedScreen?.screenId) return; - try { - // 1. 현재 레이어 저장 - const curId = Number(activeLayerIdRef.current) || 1; - const v2Layout = convertLegacyToV2({ ...layout, screenResolution }); - await screenApi.saveLayoutV2(selectedScreen.screenId, { ...v2Layout, layerId: curId }); - - // 2. 새 레이어 로드 - const data = await screenApi.getLayerLayout(selectedScreen.screenId, layerId); - if (data && data.components) { - const legacy = convertV2ToLegacy(data); - if (legacy) { - setLayout((prev) => ({ ...prev, components: legacy.components })); - } else { - setLayout((prev) => ({ ...prev, components: [] })); - } - } else { - setLayout((prev) => ({ ...prev, components: [] })); - } - - setActiveLayerIdWithRef(layerId); - setSelectedComponent(null); - } catch (error) { - console.error("레이어 전환 실패:", error); - toast.error("레이어 전환에 실패했습니다."); - } - }} - components={layout.components} - zones={zones} - onZonesChange={setZones} - /> - - - - {/* 탭 내부 컴포넌트 선택 시에도 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} 편집 중 - {activeLayerZone && ( - - (캔버스: {activeLayerZone.width} x {activeLayerZone.height}px - {activeLayerZone.zone_name}) - - )} - {!activeLayerZone && ( - - (조건부 영역 미설정 - 기본 레이어에서 Zone을 먼저 생성하세요) - - )} - -
- )} - - {/* 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬 + contain 최적화 */} - {(() => { - // 🆕 조건부 레이어 편집 시 캔버스 크기를 Zone에 맞춤 - const activeRegion = activeLayerId > 1 ? activeLayerZone : null; - const canvasW = activeRegion ? activeRegion.width : screenResolution.width; - const canvasH = activeRegion ? activeRegion.height : screenResolution.height; - - return ( -
- {/* 실제 작업 캔버스 (해상도 크기 또는 조건부 레이어 영역 크기) */} -
-
{ - if (e.target === e.currentTarget && !selectionDrag.wasSelecting && !isPanMode) { - setSelectedComponent(null); - setGroupState((prev) => ({ ...prev, selectedComponents: [] })); - } - }} - onMouseDown={(e) => { - // Pan 모드가 아닐 때만 다중 선택 시작 - if (e.target === e.currentTarget && !isPanMode) { - startSelectionDrag(e); - } - }} - onMouseMove={(e) => { - // 영역 이동/리사이즈 처리 - if (regionDrag.isDragging || regionDrag.isResizing) { - handleRegionCanvasMouseMove(e); - } - }} - onMouseUp={() => { - if (regionDrag.isDragging || regionDrag.isResizing) { - handleRegionCanvasMouseUp(); - } - }} - onMouseLeave={() => { - if (regionDrag.isDragging || regionDrag.isResizing) { - handleRegionCanvasMouseUp(); - } - }} - onDragOver={(e) => { - e.preventDefault(); - e.dataTransfer.dropEffect = "copy"; - }} - onDropCapture={(e) => { - // 캡처 단계에서 드롭 이벤트를 처리하여 자식 요소 드롭도 감지 - e.preventDefault(); - handleDrop(e); - }} - > - {/* 격자 라인 */} - {gridLines.map((line, index) => ( -
- ))} - - {/* 컴포넌트들 */} - {(() => { - // 🆕 플로우 버튼 그룹 감지 및 처리 - // visibleComponents를 사용하여 활성 레이어의 컴포넌트만 표시 - const topLevelComponents = visibleComponents.filter((component) => !component.parentId); - - // auto-compact 모드의 버튼들을 그룹별로 묶기 - const buttonGroups: Record = {}; - const processedButtonIds = new Set(); - - topLevelComponents.forEach((component) => { - const isButton = - component.type === "button" || - (component.type === "component" && - ["button-primary", "button-secondary"].includes((component as any).componentType)); - - if (isButton) { - const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig as - | FlowVisibilityConfig - | undefined; - - if ( - flowConfig?.enabled && - flowConfig.layoutBehavior === "auto-compact" && - flowConfig.groupId - ) { - if (!buttonGroups[flowConfig.groupId]) { - buttonGroups[flowConfig.groupId] = []; - } - buttonGroups[flowConfig.groupId].push(component); - processedButtonIds.add(component.id); - } - } - }); - - // 그룹에 속하지 않은 일반 컴포넌트들 - const regularComponents = topLevelComponents.filter((c) => !processedButtonIds.has(c.id)); - - // 🔧 렌더링 확인 로그 (디버그 완료 - 주석 처리) - // console.log("🔄 ScreenDesigner 렌더링:", { componentsCount: regularComponents.length, forceRenderTrigger, timestamp: Date.now() }); - - return ( - <> - {/* 조건부 영역(Zone) (기본 레이어에서만 표시, DB 기반) */} - {/* 내부는 pointerEvents: none으로 아래 컴포넌트 클릭/드래그 통과 */} - {activeLayerId === 1 && zones.map((zone) => { - const layerId = zone.zone_id; // 렌더링용 ID - const region = zone; - 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%)" }, - }; - // 테두리 두께 (이동 핸들 영역) - const borderWidth = 6; - return ( -
- {/* 테두리 이동 핸들: 상/하/좌/우 얇은 영역만 pointerEvents 활성 */} - {/* 상단 */} -
handleRegionMouseDown(e, String(layerId), "move")} - /> - {/* 하단 */} -
handleRegionMouseDown(e, String(layerId), "move")} - /> - {/* 좌측 */} -
handleRegionMouseDown(e, String(layerId), "move")} - /> - {/* 우측 */} -
handleRegionMouseDown(e, String(layerId), "move")} - /> - {/* 라벨 */} - handleRegionMouseDown(e, String(layerId), "move")} - > - Zone {zone.zone_id} - {zone.zone_name} - - {/* 리사이즈 핸들 */} - {resizeHandles.map((handle) => ( -
handleRegionMouseDown(e, String(layerId), "resize", handle)} - /> - ))} - {/* 삭제 버튼 */} - -
- ); - })} - - - {/* 일반 컴포넌트들 */} - {regularComponents.map((component) => { - const children = - component.type === "group" - ? layout.components.filter((child) => child.parentId === component.id) - : []; - - // 드래그 중 시각적 피드백 (다중 선택 지원) - const isDraggingThis = - dragState.isDragging && dragState.draggedComponent?.id === component.id; - const isBeingDragged = - dragState.isDragging && - dragState.draggedComponents.some((dragComp) => dragComp.id === component.id); - - let displayComponent = component; - - if (isBeingDragged) { - if (isDraggingThis) { - // 주 드래그 컴포넌트: 마우스 위치 기반으로 실시간 위치 업데이트 - displayComponent = { - ...component, - position: dragState.currentPosition, - style: { - ...component.style, - opacity: 0.8, - transform: "scale(1.02)", - transition: "none", - zIndex: 50, - }, - }; - } else { - // 다른 선택된 컴포넌트들: 상대적 위치로 실시간 업데이트 - const originalComponent = dragState.draggedComponents.find( - (dragComp) => dragComp.id === component.id, - ); - if (originalComponent) { - const deltaX = dragState.currentPosition.x - dragState.originalPosition.x; - const deltaY = dragState.currentPosition.y - dragState.originalPosition.y; - - displayComponent = { - ...component, - position: { - x: originalComponent.position.x + deltaX, - y: originalComponent.position.y + deltaY, - z: originalComponent.position.z || 1, - } as Position, - style: { - ...component.style, - opacity: 0.8, - transition: "none", - zIndex: 40, // 주 컴포넌트보다 약간 낮게 - }, - }; - } - } - } - - // 전역 파일 상태도 key에 포함하여 실시간 리렌더링 - const globalFileState = - typeof window !== "undefined" ? (window as any).globalFileState || {} : {}; - const globalFiles = globalFileState[component.id] || []; - const componentFiles = (component as any).uploadedFiles || []; - const fileStateKey = `${globalFiles.length}-${JSON.stringify(globalFiles.map((f: any) => f.objid) || [])}-${componentFiles.length}`; - // 🆕 style 변경 시 리렌더링을 위한 key 추가 - const styleKey = - component.style?.labelDisplay !== undefined - ? `label-${component.style.labelDisplay}` - : ""; - const fullKey = `${component.id}-${fileStateKey}-${styleKey}-${(component as any).lastFileUpdate || 0}-${forceRenderTrigger}`; - - // 🔧 v2-input 계열 컴포넌트 key 변경 로그 (디버그 완료 - 주석 처리) - // if (component.id.includes("v2-") || component.widgetType?.includes("v2-")) { console.log("🔑 RealtimePreview key:", { id: component.id, styleKey, labelDisplay: component.style?.labelDisplay, forceRenderTrigger, fullKey }); } - - // 🆕 labelDisplay 변경 시 새 객체로 강제 변경 감지 - const componentWithLabel = { - ...displayComponent, - _labelDisplayKey: component.style?.labelDisplay, - }; - - return ( - handleComponentClick(component, e)} - onDoubleClick={(e) => handleComponentDoubleClick(component, e)} - onDragStart={(e) => startComponentDrag(component, e)} - onDragEnd={endDrag} - selectedScreen={selectedScreen} - tableName={selectedScreen?.tableName} // 🆕 디자인 모드에서도 옵션 로딩을 위해 전달 - menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 - // onZoneComponentDrop 제거 - onZoneClick={handleZoneClick} - // 설정 변경 핸들러 (테이블 페이지 크기 등 설정을 상세설정에 반영) - onConfigChange={(config) => { - // console.log("📤 테이블 설정 변경을 상세설정에 반영:", config); - - // 컴포넌트의 componentConfig 업데이트 - const updatedComponents = layout.components.map((comp) => { - if (comp.id === component.id) { - return { - ...comp, - componentConfig: { - ...comp.componentConfig, - ...config, - }, - }; - } - return comp; - }); - - const newLayout = { - ...layout, - components: updatedComponents, - }; - - setLayout(newLayout); - saveToHistory(newLayout); - - console.log("✅ 컴포넌트 설정 업데이트 완료:", { - componentId: component.id, - updatedConfig: config, - }); - }} - // 🆕 컴포넌트 전체 업데이트 핸들러 (탭 내부 컴포넌트 위치 조정 등) - onUpdateComponent={(updatedComponent) => { - const updatedComponents = layout.components.map((comp) => - comp.id === updatedComponent.id ? updatedComponent : comp, - ); - - const newLayout = { - ...layout, - components: updatedComponents, - }; - - setLayout(newLayout); - saveToHistory(newLayout); - }} - // 🆕 리사이즈 핸들러 (10px 스냅 적용됨) - onResize={(componentId, newSize) => { - setLayout((prevLayout) => { - const updatedComponents = prevLayout.components.map((comp) => - comp.id === componentId ? { ...comp, size: newSize } : comp, - ); - - const newLayout = { - ...prevLayout, - components: updatedComponents, - }; - - // saveToHistory는 별도로 호출 (prevLayout 기반) - setTimeout(() => saveToHistory(newLayout), 0); - return newLayout; - }); - }} - // 🆕 탭 내부 컴포넌트 선택 핸들러 - onSelectTabComponent={(tabId, compId, comp) => - handleSelectTabComponent(component.id, tabId, compId, comp) - } - selectedTabComponentId={ - selectedTabComponentInfo?.tabsComponentId === component.id - ? selectedTabComponentInfo.componentId - : undefined - } - // 🆕 분할 패널 내부 컴포넌트 선택 핸들러 - onSelectPanelComponent={(panelSide, compId, comp) => - handleSelectPanelComponent(component.id, panelSide, compId, comp) - } - selectedPanelComponentId={ - selectedPanelComponentInfo?.splitPanelId === component.id - ? selectedPanelComponentInfo.componentId - : undefined - } - > - {/* 컨테이너, 그룹, 영역, 컴포넌트의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */} - {(component.type === "group" || - component.type === "container" || - component.type === "area" || - component.type === "component") && - layout.components - .filter((child) => child.parentId === component.id) - .map((child) => { - // 자식 컴포넌트에도 드래그 피드백 적용 - const isChildDraggingThis = - dragState.isDragging && dragState.draggedComponent?.id === child.id; - const isChildBeingDragged = - dragState.isDragging && - dragState.draggedComponents.some((dragComp) => dragComp.id === child.id); - - let displayChild = child; - - if (isChildBeingDragged) { - if (isChildDraggingThis) { - // 주 드래그 자식 컴포넌트 - displayChild = { - ...child, - position: dragState.currentPosition, - style: { - ...child.style, - opacity: 0.8, - transform: "scale(1.02)", - transition: "none", - zIndex: 50, - }, - }; - } else { - // 다른 선택된 자식 컴포넌트들 - const originalChildComponent = dragState.draggedComponents.find( - (dragComp) => dragComp.id === child.id, - ); - if (originalChildComponent) { - const deltaX = dragState.currentPosition.x - dragState.originalPosition.x; - const deltaY = dragState.currentPosition.y - dragState.originalPosition.y; - - displayChild = { - ...child, - position: { - x: originalChildComponent.position.x + deltaX, - y: originalChildComponent.position.y + deltaY, - z: originalChildComponent.position.z || 1, - } as Position, - style: { - ...child.style, - opacity: 0.8, - transition: "none", - zIndex: 8888, - }, - }; - } - } - } - - // 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정 - const relativeChildComponent = { - ...displayChild, - position: { - x: displayChild.position.x - component.position.x, - y: displayChild.position.y - component.position.y, - z: displayChild.position.z || 1, - }, - }; - - return ( - f.objid) || [])}`} - component={relativeChildComponent} - isSelected={ - selectedComponent?.id === child.id || - groupState.selectedComponents.includes(child.id) - } - isDesignMode={true} // 편집 모드로 설정 - onClick={(e) => handleComponentClick(child, e)} - onDoubleClick={(e) => handleComponentDoubleClick(child, e)} - onDragStart={(e) => startComponentDrag(child, e)} - onDragEnd={endDrag} - selectedScreen={selectedScreen} - tableName={selectedScreen?.tableName} // 🆕 디자인 모드에서도 옵션 로딩을 위해 전달 - // onZoneComponentDrop 제거 - onZoneClick={handleZoneClick} - // 설정 변경 핸들러 (자식 컴포넌트용) - onConfigChange={(config) => { - // console.log("📤 자식 컴포넌트 설정 변경을 상세설정에 알림:", config); - // TODO: 실제 구현은 DetailSettingsPanel과의 연동 필요 - }} - // 🆕 자식 컴포넌트 리사이즈 핸들러 - onResize={(componentId, newSize) => { - setLayout((prevLayout) => { - const updatedComponents = prevLayout.components.map((comp) => - comp.id === componentId ? { ...comp, size: newSize } : comp, - ); - - const newLayout = { - ...prevLayout, - components: updatedComponents, - }; - - setTimeout(() => saveToHistory(newLayout), 0); - return newLayout; - }); - }} - /> - ); - })} - - ); - })} - - {/* 🆕 플로우 버튼 그룹들 */} - {Object.entries(buttonGroups).map(([groupId, buttons]) => { - if (buttons.length === 0) return null; - - const firstButton = buttons[0]; - const groupConfig = (firstButton as any).webTypeConfig - ?.flowVisibilityConfig as FlowVisibilityConfig; - - // 🔧 그룹의 위치 및 크기 계산 - // 모든 버튼이 같은 위치(groupX, groupY)에 배치되어 있으므로 - // 첫 번째 버튼의 위치를 그룹 시작점으로 사용 - const direction = groupConfig.groupDirection || "horizontal"; - const gap = groupConfig.groupGap ?? 8; - const align = groupConfig.groupAlign || "start"; - - const groupPosition = { - x: buttons[0].position.x, - y: buttons[0].position.y, - z: buttons[0].position.z || 2, - }; - - // 버튼들의 실제 크기 계산 - let groupWidth = 0; - let groupHeight = 0; - - if (direction === "horizontal") { - // 가로 정렬: 모든 버튼의 너비 + 간격 - groupWidth = buttons.reduce((total, button, index) => { - const buttonWidth = button.size?.width || 100; - const gapWidth = index < buttons.length - 1 ? gap : 0; - return total + buttonWidth + gapWidth; - }, 0); - groupHeight = Math.max(...buttons.map((b) => b.size?.height || 40)); - } else { - // 세로 정렬 - groupWidth = Math.max(...buttons.map((b) => b.size?.width || 100)); - groupHeight = buttons.reduce((total, button, index) => { - const buttonHeight = button.size?.height || 40; - const gapHeight = index < buttons.length - 1 ? gap : 0; - return total + buttonHeight + gapHeight; - }, 0); - } - - // 🆕 그룹 전체가 선택되었는지 확인 - const isGroupSelected = buttons.every( - (btn) => - selectedComponent?.id === btn.id || groupState.selectedComponents.includes(btn.id), - ); - const hasAnySelected = buttons.some( - (btn) => - selectedComponent?.id === btn.id || groupState.selectedComponents.includes(btn.id), - ); - - return ( -
- { - // 드래그 피드백 - const isDraggingThis = - dragState.isDragging && dragState.draggedComponent?.id === button.id; - const isBeingDragged = - dragState.isDragging && - dragState.draggedComponents.some((dragComp) => dragComp.id === button.id); - - let displayButton = button; - - if (isBeingDragged) { - if (isDraggingThis) { - displayButton = { - ...button, - position: dragState.currentPosition, - style: { - ...button.style, - opacity: 0.8, - transform: "scale(1.02)", - transition: "none", - zIndex: 50, - }, - }; - } - } - - // 🔧 그룹 내부에서는 상대 위치 사용 (wrapper로 처리) - const relativeButton = { - ...displayButton, - position: { - x: 0, - y: 0, - z: displayButton.position.z || 1, - }, - }; - - return ( -
{ - // 클릭이 아닌 드래그인 경우에만 드래그 시작 - e.preventDefault(); - e.stopPropagation(); - - const startX = e.clientX; - const startY = e.clientY; - let isDragging = false; - let dragStarted = false; - - const handleMouseMove = (moveEvent: MouseEvent) => { - const deltaX = Math.abs(moveEvent.clientX - startX); - const deltaY = Math.abs(moveEvent.clientY - startY); - - // 5픽셀 이상 움직이면 드래그로 간주 - if ((deltaX > 5 || deltaY > 5) && !dragStarted) { - isDragging = true; - dragStarted = true; - - // Shift 키를 누르지 않았으면 같은 그룹의 버튼들도 모두 선택 - if (!e.shiftKey) { - const buttonIds = buttons.map((b) => b.id); - setGroupState((prev) => ({ - ...prev, - selectedComponents: buttonIds, - })); - } - - // 드래그 시작 - startComponentDrag(button, e as any); - } - }; - - const handleMouseUp = () => { - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - - // 드래그가 아니면 클릭으로 처리 - if (!isDragging) { - // Shift 키를 누르지 않았으면 같은 그룹의 버튼들도 모두 선택 - if (!e.shiftKey) { - const buttonIds = buttons.map((b) => b.id); - setGroupState((prev) => ({ - ...prev, - selectedComponents: buttonIds, - })); - } - handleComponentClick(button, e); - } - }; - - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - }} - onDoubleClick={(e) => { - e.stopPropagation(); - handleComponentDoubleClick(button, e); - }} - className={ - selectedComponent?.id === button.id || - groupState.selectedComponents.includes(button.id) - ? "outline-1 outline-offset-1 outline-blue-400" - : "" - } - > - {/* 그룹 내부에서는 DynamicComponentRenderer로 직접 렌더링 */} -
- {}} - /> -
-
- ); - }} - /> -
- ); - })} - - ); - })()} - - {/* 드래그 선택 영역 */} - {selectionDrag.isSelecting && ( -
- )} - - {/* 빈 캔버스 안내 */} - {layout.components.length === 0 && ( -
-
-
- -
-

캔버스가 비어있습니다

-

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

-
-

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

-

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

-

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

-
-
-
- )} -
-
-
- ); /* 🔥 줌 래퍼 닫기 */ - })()} -
-
{" "} - {/* 메인 컨테이너 닫기 */} - {/* 🆕 플로우 버튼 그룹 생성 다이얼로그 */} - - {/* 모달들 */} - {/* 메뉴 할당 모달 */} - {showMenuAssignmentModal && selectedScreen && ( - setShowMenuAssignmentModal(false)} - onAssignmentComplete={() => { - // 모달을 즉시 닫지 않고, MenuAssignmentModal이 3초 후 자동으로 닫히도록 함 - // setShowMenuAssignmentModal(false); - // toast.success("메뉴에 화면이 할당되었습니다."); - }} - onBackToList={onBackToList} - /> - )} - {/* 파일첨부 상세 모달 */} - {showFileAttachmentModal && selectedFileComponent && ( - { - setShowFileAttachmentModal(false); - setSelectedFileComponent(null); - }} - component={selectedFileComponent} - screenId={selectedScreen.screenId} - /> - )} - {/* 다국어 설정 모달 */} - setShowMultilangSettingsModal(false)} - components={layout.components} - onSave={async (updates) => { - if (updates.length === 0) { - toast.info("저장할 변경사항이 없습니다."); - return; - } - - try { - // 공통 유틸 사용하여 매핑 적용 - const { applyMultilangMappings } = await import("@/lib/utils/multilangLabelExtractor"); - - // 매핑 형식 변환 - const mappings = updates.map((u) => ({ - componentId: u.componentId, - keyId: u.langKeyId, - langKey: u.langKey, - })); - - // 레이아웃 업데이트 - const updatedComponents = applyMultilangMappings(layout.components, mappings); - setLayout((prev) => ({ - ...prev, - components: updatedComponents, - })); - - toast.success(`${updates.length}개 항목의 다국어 설정이 저장되었습니다.`); - } catch (error) { - console.error("다국어 설정 저장 실패:", error); - toast.error("다국어 설정 저장 중 오류가 발생했습니다."); - } - }} - /> - {/* 단축키 도움말 모달 */} - setShowShortcutsModal(false)} - /> -
- - - - ); -} \ No newline at end of file +서; diff --git a/frontend/components/screen/panels/ComponentsPanel.tsx b/frontend/components/screen/panels/ComponentsPanel.tsx index 98302169..a076b867 100644 --- a/frontend/components/screen/panels/ComponentsPanel.tsx +++ b/frontend/components/screen/panels/ComponentsPanel.tsx @@ -49,7 +49,6 @@ export function ComponentsPanel({ () => [ // v2-input: 테이블 컬럼 드래그 시 자동 생성되므로 숨김 처리 - // v2-select: 테이블 컬럼 드래그 시 자동 생성되므로 숨김 처리 // v2-date: 테이블 컬럼 드래그 시 자동 생성되므로 숨김 처리 // v2-layout: 중첩 드래그앤드롭 기능 미구현으로 숨김 처리 // v2-group: 중첩 드래그앤드롭 기능 미구현으로 숨김 처리 @@ -57,6 +56,23 @@ export function ComponentsPanel({ // v2-media 제거 - 테이블 컬럼의 image/file 입력 타입으로 사용 // v2-biz 제거 - 개별 컴포넌트(flow-widget, rack-structure, numbering-rule)로 직접 표시 // v2-hierarchy 제거 - 현재 미사용 + { + id: "v2-select", + name: "V2 선택", + description: "드롭다운, 콤보박스, 라디오, 체크박스 등 다양한 선택 모드 지원", + category: "input" as ComponentCategory, + tags: ["select", "dropdown", "combobox", "v2"], + defaultSize: { width: 300, height: 40 }, + defaultConfig: { + mode: "dropdown", + source: "static", + multiple: false, + searchable: false, + placeholder: "선택하세요", + options: [], + allowClear: true, + }, + }, { id: "v2-repeater", name: "리피터 그리드", @@ -65,7 +81,7 @@ export function ComponentsPanel({ tags: ["repeater", "table", "modal", "button", "v2", "v2"], defaultSize: { width: 600, height: 300 }, }, - ] as ComponentDefinition[], + ] as unknown as ComponentDefinition[], [], ); @@ -126,6 +142,7 @@ export function ComponentsPanel({ "section-card", // → v2-section-card "location-swap-selector", // → v2-location-swap-selector "rack-structure", // → v2-rack-structure + "v2-select", // → v2-select (아래 v2Components에서 별도 처리) "v2-repeater", // → v2-repeater (아래 v2Components에서 별도 처리) "repeat-container", // → v2-repeat-container "repeat-screen-modal", // → v2-repeat-screen-modal diff --git a/frontend/components/v2/V2Select.tsx b/frontend/components/v2/V2Select.tsx index 832f2ddb..c4bd0925 100644 --- a/frontend/components/v2/V2Select.tsx +++ b/frontend/components/v2/V2Select.tsx @@ -71,11 +71,13 @@ const DropdownSelect = forwardRef - {options.map((option) => ( - - {option.label} - - ))} + {options + .filter((option) => option.value !== "") + .map((option) => ( + + {option.label} + + ))} ); diff --git a/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx b/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx index e67accee..ce3b3dbd 100644 --- a/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx @@ -87,9 +87,9 @@ export const V2SelectConfigPanel: React.FC = ({ config updateConfig("options", newOptions); }; - const updateOption = (index: number, field: "value" | "label", value: string) => { + const updateOptionValue = (index: number, value: string) => { const newOptions = [...options]; - newOptions[index] = { ...newOptions[index], [field]: value }; + newOptions[index] = { ...newOptions[index], value, label: value }; updateConfig("options", newOptions); }; @@ -139,7 +139,7 @@ export const V2SelectConfigPanel: React.FC = ({ config
{/* 정적 옵션 관리 */} - {config.source === "static" && ( + {(config.source || "static") === "static" && (
@@ -148,19 +148,13 @@ export const V2SelectConfigPanel: React.FC = ({ config 추가
-
+
{options.map((option: any, index: number) => ( -
+
updateOption(index, "value", e.target.value)} - placeholder="값" - className="h-7 flex-1 text-xs" - /> - updateOption(index, "label", e.target.value)} - placeholder="표시 텍스트" + onChange={(e) => updateOptionValue(index, e.target.value)} + placeholder={`옵션 ${index + 1}`} className="h-7 flex-1 text-xs" /> From 30ee36f881f77637ab4d4817984c5e456c553964 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 9 Feb 2026 17:24:50 +0900 Subject: [PATCH 13/44] refactor: Update UserFormModal to remove department code requirement - Modified the user form validation logic to make the department code optional in edit mode. - Removed the department code from the required fields check and adjusted the UI label accordingly. - Ensured that the form validation still checks for email format when provided. --- frontend/components/admin/UserFormModal.tsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/frontend/components/admin/UserFormModal.tsx b/frontend/components/admin/UserFormModal.tsx index a70e82b9..39bbefbc 100644 --- a/frontend/components/admin/UserFormModal.tsx +++ b/frontend/components/admin/UserFormModal.tsx @@ -145,13 +145,12 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF const isFormValid = useMemo(() => { // 수정 모드에서는 비밀번호 선택 사항 (변경할 경우만 입력) const requiredFields = isEditMode - ? [formData.userId.trim(), formData.userName.trim(), formData.companyCode, formData.deptCode] + ? [formData.userId.trim(), formData.userName.trim(), formData.companyCode] : [ formData.userId.trim(), formData.userPassword.trim(), formData.userName.trim(), formData.companyCode, - formData.deptCode, ]; // 모든 필수 필드가 입력되었는지 확인 @@ -327,11 +326,6 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF return false; } - if (!formData.deptCode) { - showAlert("입력 오류", "부서를 선택해주세요.", "error"); - return false; - } - // 이메일 형식 검사 (입력된 경우만) if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) { showAlert("입력 오류", "올바른 이메일 형식을 입력해주세요.", "error"); @@ -581,7 +575,7 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF
onLabelChange(e.target.value)} + placeholder="라벨" + className="h-6 min-w-0 flex-1 text-xs" + /> + onWidthChange(parseInt(e.target.value) || 100)} + placeholder="너비" + className="h-6 w-14 shrink-0 text-xs" + /> + {isNumeric && ( + + )} + +
+ ); +} interface SplitPanelLayoutConfigPanelProps { config: SplitPanelLayoutConfig; @@ -206,6 +271,11 @@ interface AdditionalTabConfigPanelProps { loadedTableColumns: Record; loadTableColumns: (tableName: string) => Promise; loadingColumns: Record; + // Entity 조인 컬럼 (테이블별) + entityJoinColumns?: Record; + joinTables: Array<{ tableName: string; currentDisplayColumn: string; joinConfig?: any; availableColumns: Array<{ columnName: string; columnLabel: string; dataType: string; inputType?: string; description?: string }> }>; + }>; } const AdditionalTabConfigPanel: React.FC = ({ @@ -219,6 +289,7 @@ const AdditionalTabConfigPanel: React.FC = ({ loadedTableColumns, loadTableColumns, loadingColumns, + entityJoinColumns: entityJoinColumnsMap, }) => { // 탭 테이블 변경 시 컬럼 로드 useEffect(() => { @@ -768,6 +839,73 @@ const AdditionalTabConfigPanel: React.FC = ({
)} + {/* ===== 7.5 Entity 조인 컬럼 ===== */} + {(() => { + const joinData = tab.tableName ? entityJoinColumnsMap?.[tab.tableName] : null; + if (!joinData || joinData.joinTables.length === 0) return null; + + return ( +
+ +

연관 테이블의 컬럼을 추가합니다

+ {joinData.joinTables.map((joinTable, tableIndex) => ( +
+
+ + {joinTable.tableName} + {joinTable.currentDisplayColumn} +
+
+ {joinTable.availableColumns.map((column, colIndex) => { + const matchingJoinColumn = joinData.availableColumns.find( + (jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName, + ); + if (!matchingJoinColumn) return null; + const tabColumns2 = tab.columns || []; + const isAdded = tabColumns2.some((c) => c.name === matchingJoinColumn.joinAlias); + + return ( +
{ + if (isAdded) { + updateTab({ columns: tabColumns2.filter((c) => c.name !== matchingJoinColumn.joinAlias) }); + } else { + updateTab({ + columns: [...tabColumns2, { + name: matchingJoinColumn.joinAlias, + label: matchingJoinColumn.suggestedLabel || matchingJoinColumn.columnLabel, + width: 100, + isEntityJoin: true, + joinInfo: { + sourceTable: tab.tableName!, + sourceColumn: (joinTable as any).joinConfig?.sourceColumn || "", + referenceTable: matchingJoinColumn.tableName, + joinAlias: matchingJoinColumn.joinAlias, + }, + }], + }); + } + }} + > + + + {column.columnLabel} + {column.dataType} +
+ ); + })} +
+
+ ))} +
+ ); + })()} + {/* ===== 8. 데이터 필터링 ===== */}
@@ -1068,6 +1206,7 @@ export const SplitPanelLayoutConfigPanel: React.FC { + const [activeModal, setActiveModal] = useState(null); // 설정 모달 상태 const [leftTableOpen, setLeftTableOpen] = useState(false); // 🆕 좌측 테이블 Combobox 상태 const [rightTableOpen, setRightTableOpen] = useState(false); const [loadedTableColumns, setLoadedTableColumns] = useState>({}); @@ -1077,6 +1216,36 @@ export const SplitPanelLayoutConfigPanel: React.FC>({}); + // Entity 조인 컬럼 (테이블별) + const [entityJoinColumns, setEntityJoinColumns] = useState< + Record< + string, + { + availableColumns: Array<{ + tableName: string; + columnName: string; + columnLabel: string; + dataType: string; + joinAlias: string; + suggestedLabel: string; + }>; + joinTables: Array<{ + tableName: string; + currentDisplayColumn: string; + joinConfig?: any; + availableColumns: Array<{ + columnName: string; + columnLabel: string; + dataType: string; + inputType?: string; + description?: string; + }>; + }>; + } + > + >({}); + const [loadingEntityJoins, setLoadingEntityJoins] = useState>({}); + // 🆕 입력 필드용 로컬 상태 const [isUserEditing, setIsUserEditing] = useState(false); const [localTitles, setLocalTitles] = useState({ @@ -1159,6 +1328,9 @@ export const SplitPanelLayoutConfigPanel: React.FC ({ ...prev, [tableName]: [] })); @@ -1167,6 +1339,32 @@ export const SplitPanelLayoutConfigPanel: React.FC { + if (entityJoinColumns[tableName] || loadingEntityJoins[tableName]) return; + + setLoadingEntityJoins((prev) => ({ ...prev, [tableName]: true })); + try { + const result = await entityJoinApi.getEntityJoinColumns(tableName); + console.log(`🔗 Entity 조인 컬럼 (${tableName}):`, result); + setEntityJoinColumns((prev) => ({ + ...prev, + [tableName]: { + availableColumns: result.availableColumns || [], + joinTables: result.joinTables || [], + }, + })); + } catch (error) { + console.error(`❌ Entity 조인 컬럼 조회 실패 (${tableName}):`, error); + setEntityJoinColumns((prev) => ({ + ...prev, + [tableName]: { availableColumns: [], joinTables: [] }, + })); + } finally { + setLoadingEntityJoins((prev) => ({ ...prev, [tableName]: false })); + } + }; + // 🆕 엔티티 참조 테이블의 컬럼 로드 const loadEntityReferenceColumns = async (sourceTableName: string, columns: ColumnInfo[]) => { const entityColumns = columns.filter( @@ -1373,10 +1571,64 @@ export const SplitPanelLayoutConfigPanel: React.FC +
+ {/* ===== 간소화된 설정 메뉴 카드 ===== */} +

분할 패널 설정

+
+ {[ + { + id: "basic", + title: "기본 설정", + desc: `${relationshipType === "detail" ? "상세" : "조건 필터"} | 비율 ${config.splitRatio || 30}%`, + icon: Settings2, + }, + { + id: "left", + title: "좌측 패널", + desc: config.leftPanel?.tableName || screenTableName || "미설정", + icon: PanelLeft, + }, + { + id: "right", + title: "우측 패널", + desc: config.rightPanel?.tableName || "미설정", + icon: PanelRight, + }, + { + id: "tabs", + title: "추가 탭", + desc: `${config.rightPanel?.additionalTabs?.length || 0}개 탭`, + icon: Layers, + }, + ].map((item) => ( + + ))} +
+ + {/* ===== 기본 설정 모달 ===== */} + !open && setActiveModal(null)}> + + + 기본 설정 + 패널 관계 타입 및 레이아웃을 설정합니다 + +
{/* 관계 타입 선택 */} -
-

패널 관계 타입

+
+

패널 관계 타입

{ - const newColumns = [...(config.leftPanel?.columns || [])]; - newColumns[index] = { ...newColumns[index], label: e.target.value }; - updateLeftPanel({ columns: newColumns }); - }} - placeholder="제목" - className="h-6 flex-1 text-xs" - /> - { - const newColumns = [...(config.leftPanel?.columns || [])]; - newColumns[index] = { ...newColumns[index], width: parseInt(e.target.value) || 100 }; - updateLeftPanel({ columns: newColumns }); - }} - placeholder="너비" - className="h-6 w-14 text-xs" - /> - {/* 숫자 타입: 천단위 구분자 체크박스 */} - {isNumeric && ( -
- ); - })} + + )}
- )} -
- )} + ); + })()}
+ {/* 좌측 패널 Entity 조인 컬럼 */} + {(() => { + const leftTable = config.leftPanel?.tableName || screenTableName; + const joinData = leftTable ? entityJoinColumns[leftTable] : null; + if (!joinData || joinData.joinTables.length === 0) return null; + const selectedColumns = config.leftPanel?.columns || []; + + return ( +
+

Entity 조인 컬럼

+

연관 테이블의 컬럼을 표시 컬럼에 추가합니다

+
+ {joinData.joinTables.map((joinTable, tableIndex) => ( +
+
+ + {joinTable.tableName} + {joinTable.currentDisplayColumn} +
+
+ {joinTable.availableColumns.map((column, colIndex) => { + const matchingJoinColumn = joinData.availableColumns.find( + (jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName, + ); + if (!matchingJoinColumn) return null; + const isAdded = selectedColumns.some((c) => c.name === matchingJoinColumn.joinAlias); + + return ( +
{ + if (isAdded) { + updateLeftPanel({ columns: selectedColumns.filter((c) => c.name !== matchingJoinColumn.joinAlias) }); + } else { + updateLeftPanel({ + columns: [...selectedColumns, { + name: matchingJoinColumn.joinAlias, + label: matchingJoinColumn.suggestedLabel || matchingJoinColumn.columnLabel, + width: 100, + isEntityJoin: true, + joinInfo: { + sourceTable: leftTable!, + sourceColumn: (joinTable as any).joinConfig?.sourceColumn || "", + referenceTable: matchingJoinColumn.tableName, + joinAlias: matchingJoinColumn.joinAlias, + }, + }], + }); + } + }} + > + + + {column.columnLabel} + {column.dataType} +
+ ); + })} +
+
+ ))} +
+
+ ); + })()} + {/* 좌측 패널 데이터 필터링 */} -
-

좌측 패널 데이터 필터링

+
+

좌측 패널 데이터 필터링

특정 컬럼 값으로 좌측 패널 데이터를 필터링합니다

+
+ + + {/* ===== 우측 패널 모달 ===== */} + !open && setActiveModal(null)}> + + + 우측 패널 설정 + 상세/필터 데이터 표시, 버튼, 중복 제거를 설정합니다 + +
{/* 우측 패널 설정 */} -
-

우측 패널 설정 ({relationshipType === "detail" ? "상세" : "조건 필터"})

+
+

우측 패널 설정 ({relationshipType === "detail" ? "상세" : "조건 필터"})

@@ -1919,25 +2230,27 @@ export const SplitPanelLayoutConfigPanel: React.FC updateRightPanel({ displayMode: value })} > - - + + + {(config.rightPanel?.displayMode || "list") === "list" ? "목록 (LIST)" : (config.rightPanel?.displayMode || "list") === "table" ? "테이블 (TABLE)" : "커스텀 (CUSTOM)"} + -
- 목록 (LIST) +
+ 목록 (LIST) 클릭 가능한 항목 목록 (기본)
-
- 테이블 (TABLE) +
+ 테이블 (TABLE) 컬럼 헤더가 있는 테이블 형식
-
- 커스텀 (CUSTOM) +
+ 커스텀 (CUSTOM) 패널 안에 컴포넌트 자유 배치
@@ -2097,170 +2410,176 @@ export const SplitPanelLayoutConfigPanel: React.FC )} - {/* 우측 패널 표시 컬럼 설정 - 체크박스 방식 */} -
- + {/* 우측 패널 표시 컬럼 설정 - 드래그앤드롭 */} + {(() => { + const selectedColumns = config.rightPanel?.columns || []; + const filteredTableColumns = rightTableColumns.filter((c) => !["company_code", "company_name"].includes(c.columnName)); + const unselectedColumns = filteredTableColumns.filter((c) => !selectedColumns.some((sc) => sc.name === c.columnName)); + const dbNumericTypes = ["numeric", "decimal", "integer", "bigint", "double precision", "real", "smallint", "int4", "int8", "float4", "float8"]; + const inputNumericTypes = ["number", "decimal", "currency", "integer"]; - {/* 컬럼 체크박스 목록 */} -
- {rightTableColumns.length === 0 ? ( -

테이블을 선택해주세요

- ) : ( - rightTableColumns - .filter((column) => !["company_code", "company_name"].includes(column.columnName)) - .map((column) => { - const isSelected = (config.rightPanel?.columns || []).some((c) => c.name === column.columnName); - return ( -
{ - const currentColumns = config.rightPanel?.columns || []; - if (isSelected) { - const newColumns = currentColumns.filter((c) => c.name !== column.columnName); - updateRightPanel({ columns: newColumns }); - } else { - const newColumn = { - name: column.columnName, - label: column.columnLabel || column.columnName, - width: 100, - }; - updateRightPanel({ columns: [...currentColumns, newColumn] }); - } - }} - > - { - const currentColumns = config.rightPanel?.columns || []; - if (isSelected) { - const newColumns = currentColumns.filter((c) => c.name !== column.columnName); - updateRightPanel({ columns: newColumns }); - } else { - const newColumn = { - name: column.columnName, - label: column.columnLabel || column.columnName, - width: 100, - }; - updateRightPanel({ columns: [...currentColumns, newColumn] }); - } - }} - className="pointer-events-none h-3.5 w-3.5 shrink-0" - /> - - {column.columnLabel || column.columnName} -
- ); - }) - )} -
+ const handleRightDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (over && active.id !== over.id) { + const oldIndex = selectedColumns.findIndex((c) => c.name === active.id); + const newIndex = selectedColumns.findIndex((c) => c.name === over.id); + if (oldIndex !== -1 && newIndex !== -1) { + updateRightPanel({ columns: arrayMove([...selectedColumns], oldIndex, newIndex) }); + } + } + }; - {/* 선택된 컬럼 상세 설정 */} - {(config.rightPanel?.columns || []).length > 0 && ( + return (
- -
- {(config.rightPanel?.columns || []).map((col, index) => { - const column = rightTableColumns.find((c) => c.columnName === col.name); + +
+ {rightTableColumns.length === 0 ? ( +

테이블을 선택해주세요

+ ) : ( + <> + {selectedColumns.length > 0 && ( + + c.name)} strategy={verticalListSortingStrategy}> +
+ {selectedColumns.map((col, index) => { + const colInfo = rightTableColumns.find((c) => c.columnName === col.name); + const isNumeric = colInfo && ( + dbNumericTypes.includes(colInfo.dataType?.toLowerCase() || "") || + inputNumericTypes.includes(colInfo.input_type?.toLowerCase() || "") || + inputNumericTypes.includes(colInfo.webType?.toLowerCase() || "") + ); + return ( + { + const newColumns = [...selectedColumns]; + newColumns[index] = { ...newColumns[index], label: value }; + updateRightPanel({ columns: newColumns }); + }} + onWidthChange={(value) => { + const newColumns = [...selectedColumns]; + newColumns[index] = { ...newColumns[index], width: value }; + updateRightPanel({ columns: newColumns }); + }} + onFormatChange={(checked) => { + const newColumns = [...selectedColumns]; + newColumns[index] = { ...newColumns[index], format: { ...newColumns[index].format, type: "number", thousandSeparator: checked } }; + updateRightPanel({ columns: newColumns }); + }} + onRemove={() => updateRightPanel({ columns: selectedColumns.filter((_, i) => i !== index) })} + /> + ); + })} +
+
+
+ )} - // 숫자 타입 판별 - const dbNumericTypes = [ - "numeric", - "decimal", - "integer", - "bigint", - "double precision", - "real", - "smallint", - "int4", - "int8", - "float4", - "float8", - ]; - const inputNumericTypes = ["number", "decimal", "currency", "integer"]; - const isNumeric = - column && - (dbNumericTypes.includes(column.dataType?.toLowerCase() || "") || - inputNumericTypes.includes(column.input_type?.toLowerCase() || "") || - inputNumericTypes.includes(column.webType?.toLowerCase() || "")); + {selectedColumns.length > 0 && unselectedColumns.length > 0 && ( +
+ 미선택 컬럼 +
+ )} - return ( -
- - - { - const newColumns = [...(config.rightPanel?.columns || [])]; - newColumns[index] = { ...newColumns[index], label: e.target.value }; - updateRightPanel({ columns: newColumns }); - }} - placeholder="제목" - className="h-6 flex-1 text-xs" - /> - { - const newColumns = [...(config.rightPanel?.columns || [])]; - newColumns[index] = { ...newColumns[index], width: parseInt(e.target.value) || 100 }; - updateRightPanel({ columns: newColumns }); - }} - placeholder="너비" - className="h-6 w-14 text-xs" - /> - {/* 숫자 타입: 천단위 구분자 체크박스 */} - {isNumeric && ( -
- ); - })} + + )}
- )} -
+ ); + })()}
+ {/* 우측 패널 Entity 조인 컬럼 */} + {(() => { + const rightTable = config.rightPanel?.tableName; + const joinData = rightTable ? entityJoinColumns[rightTable] : null; + if (!joinData || joinData.joinTables.length === 0) return null; + const selectedColumns = config.rightPanel?.columns || []; + + return ( +
+

Entity 조인 컬럼

+

연관 테이블의 컬럼을 표시 컬럼에 추가합니다

+
+ {joinData.joinTables.map((joinTable, tableIndex) => ( +
+
+ + {joinTable.tableName} + {joinTable.currentDisplayColumn} +
+
+ {joinTable.availableColumns.map((column, colIndex) => { + const matchingJoinColumn = joinData.availableColumns.find( + (jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName, + ); + if (!matchingJoinColumn) return null; + const isAdded = selectedColumns.some((c) => c.name === matchingJoinColumn.joinAlias); + + return ( +
{ + if (isAdded) { + updateRightPanel({ columns: selectedColumns.filter((c) => c.name !== matchingJoinColumn.joinAlias) }); + } else { + updateRightPanel({ + columns: [...selectedColumns, { + name: matchingJoinColumn.joinAlias, + label: matchingJoinColumn.suggestedLabel || matchingJoinColumn.columnLabel, + width: 100, + isEntityJoin: true, + joinInfo: { + sourceTable: rightTable!, + sourceColumn: (joinTable as any).joinConfig?.sourceColumn || "", + referenceTable: matchingJoinColumn.tableName, + joinAlias: matchingJoinColumn.joinAlias, + }, + }], + }); + } + }} + > + + + {column.columnLabel} + {column.dataType} +
+ ); + })} +
+
+ ))} +
+
+ ); + })()} + {/* 우측 패널 데이터 필터링 */} -
-

우측 패널 데이터 필터링

+
+

우측 패널 데이터 필터링

특정 컬럼 값으로 우측 패널 데이터를 필터링합니다

{/* 우측 패널 중복 제거 */} -
+
-

중복 데이터 제거

+

중복 데이터 제거

같은 값을 가진 데이터를 하나로 통합하여 표시

{/* 🆕 우측 패널 수정 버튼 설정 */} -
+
-

수정 버튼 설정

+

수정 버튼 설정

우측 리스트의 수정 버튼 동작 방식 설정

{/* 🆕 우측 패널 삭제 버튼 설정 */} -
+
-

삭제 버튼 설정

+

삭제 버튼 설정

)}
+
+ +
- {/* ======================================== */} - {/* 추가 탭 설정 (우측 패널과 동일한 구조) */} - {/* ======================================== */} -
+ {/* ===== 추가 탭 모달 ===== */} + !open && setActiveModal(null)}> + + + 추가 탭 설정 + 우측 패널에 다른 테이블 데이터를 탭으로 추가합니다 + +
+
-

추가 탭

+

추가 탭

우측 패널에 다른 테이블 데이터를 탭으로 추가합니다

@@ -2692,6 +3019,7 @@ export const SplitPanelLayoutConfigPanel: React.FC ))} @@ -2703,36 +3031,9 @@ export const SplitPanelLayoutConfigPanel: React.FC )}
- - {/* 레이아웃 설정 */} -
-
- - updateConfig({ splitRatio: value[0] })} - min={20} - max={80} - step={5} - /> -
- -
- - updateConfig({ resizable: checked })} - /> -
- -
- - updateConfig({ autoLoad: checked })} - /> -
-
+
+ +
); }; diff --git a/frontend/lib/registry/components/v2-split-panel-layout/types.ts b/frontend/lib/registry/components/v2-split-panel-layout/types.ts index 7ab0dbcb..9a3672bb 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/types.ts +++ b/frontend/lib/registry/components/v2-split-panel-layout/types.ts @@ -50,6 +50,14 @@ export interface AdditionalTabConfig { suffix?: string; dateFormat?: string; }; + // Entity 조인 컬럼 정보 + isEntityJoin?: boolean; + joinInfo?: { + sourceTable: string; + sourceColumn: string; + referenceTable: string; + joinAlias: string; + }; }>; addModalColumns?: Array<{ @@ -145,6 +153,14 @@ export interface SplitPanelLayoutConfig { suffix?: string; // 접미사 (예: "원", "개") dateFormat?: string; // 날짜 포맷 (type: "date") }; + // Entity 조인 컬럼 정보 + isEntityJoin?: boolean; + joinInfo?: { + sourceTable: string; + sourceColumn: string; + referenceTable: string; + joinAlias: string; + }; }>; // 추가 모달에서 입력받을 컬럼 설정 addModalColumns?: Array<{ @@ -217,6 +233,14 @@ export interface SplitPanelLayoutConfig { suffix?: string; // 접미사 (예: "원", "개") dateFormat?: string; // 날짜 포맷 (type: "date") }; + // Entity 조인 컬럼 정보 + isEntityJoin?: boolean; + joinInfo?: { + sourceTable: string; + sourceColumn: string; + referenceTable: string; + joinAlias: string; + }; }>; // 추가 모달에서 입력받을 컬럼 설정 addModalColumns?: Array<{ From 9e1a54c7386b7643eba062b7aecb82dcc8f4ab4b Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 10 Feb 2026 10:06:53 +0900 Subject: [PATCH 15/44] feat: Add savedIds to UPSERT response and update related components - Enhanced the UPSERT process in DataService to include savedIds in the response, allowing tracking of newly saved record IDs. - Updated the dataApi to reflect the new savedIds field in the Promise return type. - Modified the SelectedItemsDetailInputComponent to handle and inject saved mapping IDs into detail records, improving data integrity and management during the save process. - Added logging for savedIds to facilitate debugging and tracking of saved records. --- backend-node/src/routes/dataRoutes.ts | 1 + backend-node/src/services/dataService.ts | 5 +++-- frontend/lib/api/data.ts | 2 +- .../SelectedItemsDetailInputComponent.tsx | 19 +++++++++++++++++++ 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/backend-node/src/routes/dataRoutes.ts b/backend-node/src/routes/dataRoutes.ts index 0abc6793..02dfc1e8 100644 --- a/backend-node/src/routes/dataRoutes.ts +++ b/backend-node/src/routes/dataRoutes.ts @@ -742,6 +742,7 @@ router.post( inserted: result.data?.inserted || 0, updated: result.data?.updated || 0, deleted: result.data?.deleted || 0, + savedIds: result.data?.savedIds || [], }); } catch (error) { console.error("그룹화된 데이터 UPSERT 오류:", error); diff --git a/backend-node/src/services/dataService.ts b/backend-node/src/services/dataService.ts index 8c837697..2150a4af 100644 --- a/backend-node/src/services/dataService.ts +++ b/backend-node/src/services/dataService.ts @@ -1519,11 +1519,12 @@ class DataService { } } - console.log(`✅ UPSERT 완료:`, { inserted, updated, deleted }); + const savedIds = Array.from(processedIds); + console.log(`✅ UPSERT 완료:`, { inserted, updated, deleted, savedIds }); return { success: true, - data: { inserted, updated, deleted }, + data: { inserted, updated, deleted, savedIds }, }; } catch (error) { console.error(`UPSERT 오류 (${tableName}):`, error); diff --git a/frontend/lib/api/data.ts b/frontend/lib/api/data.ts index 270c59f9..14aad709 100644 --- a/frontend/lib/api/data.ts +++ b/frontend/lib/api/data.ts @@ -238,7 +238,7 @@ export const dataApi = { parentKeys: Record, records: Array>, options?: { deleteOrphans?: boolean } - ): Promise<{ success: boolean; inserted?: number; updated?: number; deleted?: number; message?: string; error?: string }> => { + ): Promise<{ success: boolean; inserted?: number; updated?: number; deleted?: number; savedIds?: string[]; message?: string; error?: string }> => { try { console.log("📡 [dataApi.upsertGroupedRecords] 요청 데이터:", { tableName, 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 e73a78c6..ffe2875d 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx @@ -689,6 +689,8 @@ export const SelectedItemsDetailInputComponent: React.FC !!r.id); + // 저장된 매핑 ID를 추적 (디테일 테이블에 mapping_id 주입용) + let savedMappingIds: string[] = []; try { const mappingResult = await dataApi.upsertGroupedRecords( mainTable, @@ -696,6 +698,11 @@ export const SelectedItemsDetailInputComponent: React.FC 0) { + const mappingId = savedMappingIds[0]; // 일반적으로 1:N (매핑 1개 : 단가 N개) + priceRecords.forEach((record) => { + if (!record.mapping_id) { + record.mapping_id = mappingId; + } + }); + console.log(`🔗 디테일 레코드에 mapping_id 주입: ${mappingId}`); + } + const priceHasDbIds = priceRecords.some((r) => !!r.id); try { const detailResult = await dataApi.upsertGroupedRecords( From 3c8c2ebcf4856e0ee0dc9bf70614dcd06b785aad Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 10 Feb 2026 10:51:23 +0900 Subject: [PATCH 16/44] feat: Enhance entity join functionality with company code support - Updated the EntityJoinController to log the company code during entity join configuration retrieval. - Modified the entityJoinService to accept company code as a parameter, allowing for company-specific entity join detection. - Enhanced the TableManagementService to pass the company code when detecting entity joins and retrieving reference table columns. - Implemented a helper function in the SplitPanelLayoutComponent to extract additional join columns based on the entity join configuration. - Improved the SplitPanelLayoutConfigPanel to display entity join columns dynamically, enhancing user experience and functionality. --- .../src/controllers/entityJoinController.ts | 17 +- .../src/services/entityJoinService.ts | 174 +++++---- .../src/services/tableManagementService.ts | 47 ++- .../SplitPanelLayoutComponent.tsx | 173 +++++---- .../SplitPanelLayoutConfigPanel.tsx | 355 ++++++++---------- 5 files changed, 392 insertions(+), 374 deletions(-) diff --git a/backend-node/src/controllers/entityJoinController.ts b/backend-node/src/controllers/entityJoinController.ts index f4f89d25..15e05473 100644 --- a/backend-node/src/controllers/entityJoinController.ts +++ b/backend-node/src/controllers/entityJoinController.ts @@ -193,10 +193,11 @@ export class EntityJoinController { async getEntityJoinConfigs(req: Request, res: Response): Promise { try { const { tableName } = req.params; + const companyCode = (req as any).user?.companyCode; - logger.info(`Entity 조인 설정 조회: ${tableName}`); + logger.info(`Entity 조인 설정 조회: ${tableName} (companyCode: ${companyCode})`); - const joinConfigs = await entityJoinService.detectEntityJoins(tableName); + const joinConfigs = await entityJoinService.detectEntityJoins(tableName, undefined, companyCode); res.status(200).json({ success: true, @@ -224,11 +225,12 @@ export class EntityJoinController { async getReferenceTableColumns(req: Request, res: Response): Promise { try { const { tableName } = req.params; + const companyCode = (req as any).user?.companyCode; - logger.info(`참조 테이블 컬럼 조회: ${tableName}`); + logger.info(`참조 테이블 컬럼 조회: ${tableName} (companyCode: ${companyCode})`); const columns = - await tableManagementService.getReferenceTableColumns(tableName); + await tableManagementService.getReferenceTableColumns(tableName, companyCode); res.status(200).json({ success: true, @@ -408,11 +410,12 @@ export class EntityJoinController { async getEntityJoinColumns(req: Request, res: Response): Promise { try { const { tableName } = req.params; + const companyCode = (req as any).user?.companyCode; - logger.info(`Entity 조인 컬럼 조회: ${tableName}`); + logger.info(`Entity 조인 컬럼 조회: ${tableName} (companyCode: ${companyCode})`); // 1. 현재 테이블의 Entity 조인 설정 조회 - const allJoinConfigs = await entityJoinService.detectEntityJoins(tableName); + const allJoinConfigs = await entityJoinService.detectEntityJoins(tableName, undefined, companyCode); // 🆕 화면 디자이너용: table_column_category_values는 카테고리 드롭다운용이므로 제외 // 카테고리 값은 엔티티 조인 컬럼이 아니라 셀렉트박스 옵션으로 사용됨 @@ -439,7 +442,7 @@ export class EntityJoinController { try { const columns = await tableManagementService.getReferenceTableColumns( - config.referenceTable + config.referenceTable, companyCode ); // 현재 display_column 정보 (참고용으로만 사용, 필터링하지 않음) diff --git a/backend-node/src/services/entityJoinService.ts b/backend-node/src/services/entityJoinService.ts index 4441a636..13f757fd 100644 --- a/backend-node/src/services/entityJoinService.ts +++ b/backend-node/src/services/entityJoinService.ts @@ -16,16 +16,18 @@ export class EntityJoinService { * 테이블의 Entity 컬럼들을 감지하여 조인 설정 생성 * @param tableName 테이블명 * @param screenEntityConfigs 화면별 엔티티 설정 (선택사항) + * @param companyCode 회사코드 (회사별 설정 우선, 없으면 전체 조회) */ async detectEntityJoins( tableName: string, - screenEntityConfigs?: Record + screenEntityConfigs?: Record, + companyCode?: string ): Promise { try { - logger.info(`Entity 컬럼 감지 시작: ${tableName}`); + logger.info(`Entity 컬럼 감지 시작: ${tableName} (companyCode: ${companyCode || 'all'})`); // table_type_columns에서 entity 및 category 타입인 컬럼들 조회 - // company_code = '*' (공통 설정) 우선 조회 + // 회사코드가 있으면 해당 회사 + '*' 만 조회, 회사별 우선 const entityColumns = await query<{ column_name: string; input_type: string; @@ -33,14 +35,17 @@ export class EntityJoinService { reference_column: string; display_column: string | null; }>( - `SELECT column_name, input_type, reference_table, reference_column, display_column + `SELECT DISTINCT ON (column_name) + column_name, input_type, reference_table, reference_column, display_column FROM table_type_columns WHERE table_name = $1 AND input_type IN ('entity', 'category') - AND company_code = '*' AND reference_table IS NOT NULL - AND reference_table != ''`, - [tableName] + AND reference_table != '' + ${companyCode ? `AND company_code IN ($2, '*')` : ''} + ORDER BY column_name, + CASE WHEN company_code = '*' THEN 1 ELSE 0 END`, + companyCode ? [tableName, companyCode] : [tableName] ); logger.info(`🔍 Entity 컬럼 조회 결과: ${entityColumns.length}개 발견`); @@ -272,7 +277,8 @@ export class EntityJoinService { orderBy: string = "", limit?: number, offset?: number, - columnTypes?: Map // 컬럼명 → 데이터 타입 매핑 + columnTypes?: Map, // 컬럼명 → 데이터 타입 매핑 + referenceTableColumns?: Map // 🆕 참조 테이블별 전체 컬럼 목록 ): { query: string; aliasMap: Map } { try { // 기본 SELECT 컬럼들 (날짜는 YYYY-MM-DD 형식, 나머지는 TEXT 캐스팅) @@ -338,115 +344,100 @@ export class EntityJoinService { ); }); - // 🔧 _label 별칭 중복 방지를 위한 Set - // 같은 sourceColumn에서 여러 조인 설정이 있을 때 _label은 첫 번째만 생성 - const generatedLabelAliases = new Set(); + // 🔧 생성된 별칭 중복 방지를 위한 Set + const generatedAliases = new Set(); - const joinColumns = joinConfigs + const joinColumns = uniqueReferenceTableConfigs .map((config) => { const aliasKey = `${config.referenceTable}:${config.sourceColumn}`; const alias = aliasMap.get(aliasKey); - const displayColumns = config.displayColumns || [ - config.displayColumn, - ]; - const separator = config.separator || " - "; - - // 결과 컬럼 배열 (aliasColumn + _label 필드) const resultColumns: string[] = []; - if (displayColumns.length === 0 || !displayColumns[0]) { - // displayColumns가 빈 배열이거나 첫 번째 값이 null/undefined인 경우 - // 조인 테이블의 referenceColumn을 기본값으로 사용 - resultColumns.push( - `COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}` - ); - } else if (displayColumns.length === 1) { - // 단일 컬럼인 경우 - const col = displayColumns[0]; + // 🆕 참조 테이블의 전체 컬럼 목록이 있으면 모든 컬럼을 SELECT + const refTableCols = referenceTableColumns?.get( + `${config.referenceTable}:${config.sourceColumn}` + ) || referenceTableColumns?.get(config.referenceTable); - // ✅ 개선: referenceTable이 설정되어 있으면 조인 테이블에서 가져옴 - // 이렇게 하면 item_info.size, item_info.material 등 모든 조인 테이블 컬럼 지원 - const isJoinTableColumn = - config.referenceTable && config.referenceTable !== tableName; + if (refTableCols && refTableCols.length > 0) { + // 메타 컬럼은 제외 (메인 테이블과 중복되거나 불필요) + const skipColumns = new Set(["company_code", "created_date", "updated_date", "writer"]); + + for (const col of refTableCols) { + if (skipColumns.has(col)) continue; + + const colAlias = `${config.sourceColumn}_${col}`; + if (generatedAliases.has(colAlias)) continue; - if (isJoinTableColumn) { resultColumns.push( - `COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}` + `COALESCE(${alias}."${col}"::TEXT, '') AS "${colAlias}"` ); + generatedAliases.add(colAlias); + } - // _label 필드도 함께 SELECT (프론트엔드 getColumnUniqueValues용) - // sourceColumn_label 형식으로 추가 - // 🔧 중복 방지: 같은 sourceColumn에서 _label은 첫 번째만 생성 - const labelAlias = `${config.sourceColumn}_label`; - if (!generatedLabelAliases.has(labelAlias)) { - resultColumns.push( - `COALESCE(${alias}.${col}::TEXT, '') AS ${labelAlias}` - ); - generatedLabelAliases.add(labelAlias); - } - - // 🆕 referenceColumn (PK)도 항상 SELECT (parentDataMapping용) - // 예: customer_code, item_number 등 - // col과 동일해도 별도의 alias로 추가 (customer_code as customer_code) - // 🔧 중복 방지: referenceColumn도 한 번만 추가 - const refColAlias = config.referenceColumn; - if (!generatedLabelAliases.has(refColAlias)) { - resultColumns.push( - `COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${refColAlias}` - ); - generatedLabelAliases.add(refColAlias); - } - } else { + // _label 필드도 추가 (기존 호환성) + const labelAlias = `${config.sourceColumn}_label`; + if (!generatedAliases.has(labelAlias)) { + // 표시용 컬럼 자동 감지: *_name > name > label > referenceColumn + const nameCol = refTableCols.find((c) => c.endsWith("_name") && c !== "company_name"); + const displayCol = nameCol || refTableCols.find((c) => c === "name") || config.referenceColumn; resultColumns.push( - `COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}` + `COALESCE(${alias}."${displayCol}"::TEXT, '') AS "${labelAlias}"` ); + generatedAliases.add(labelAlias); } } else { - // 🆕 여러 컬럼인 경우 각 컬럼을 개별 alias로 반환 (합치지 않음) - // 예: item_info.standard_price → sourceColumn_standard_price (item_id_standard_price) - displayColumns.forEach((col) => { + // 🔄 기존 로직 (참조 테이블 컬럼 목록이 없는 경우 - fallback) + const displayColumns = config.displayColumns || [config.displayColumn]; + + if (displayColumns.length === 0 || !displayColumns[0]) { + resultColumns.push( + `COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}` + ); + } else if (displayColumns.length === 1) { + const col = displayColumns[0]; const isJoinTableColumn = config.referenceTable && config.referenceTable !== tableName; - const individualAlias = `${config.sourceColumn}_${col}`; - - // 🔧 중복 방지: 같은 alias가 이미 생성되었으면 스킵 - if (generatedLabelAliases.has(individualAlias)) { - return; - } - if (isJoinTableColumn) { - // 조인 테이블 컬럼은 조인 별칭 사용 resultColumns.push( - `COALESCE(${alias}.${col}::TEXT, '') AS ${individualAlias}` + `COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}` ); + const labelAlias = `${config.sourceColumn}_label`; + if (!generatedAliases.has(labelAlias)) { + resultColumns.push( + `COALESCE(${alias}.${col}::TEXT, '') AS ${labelAlias}` + ); + generatedAliases.add(labelAlias); + } } else { - // 기본 테이블 컬럼은 main 별칭 사용 resultColumns.push( - `COALESCE(main.${col}::TEXT, '') AS ${individualAlias}` + `COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}` ); } - generatedLabelAliases.add(individualAlias); - }); + } else { + displayColumns.forEach((col) => { + const isJoinTableColumn = + config.referenceTable && config.referenceTable !== tableName; + const individualAlias = `${config.sourceColumn}_${col}`; + if (generatedAliases.has(individualAlias)) return; - // 🆕 referenceColumn (PK)도 함께 SELECT (parentDataMapping용) - const isJoinTableColumn = - config.referenceTable && config.referenceTable !== tableName; - if ( - isJoinTableColumn && - !displayColumns.includes(config.referenceColumn) && - !generatedLabelAliases.has(config.referenceColumn) // 🔧 중복 방지 - ) { - resultColumns.push( - `COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.referenceColumn}` - ); - generatedLabelAliases.add(config.referenceColumn); + if (isJoinTableColumn) { + resultColumns.push( + `COALESCE(${alias}.${col}::TEXT, '') AS ${individualAlias}` + ); + } else { + resultColumns.push( + `COALESCE(main.${col}::TEXT, '') AS ${individualAlias}` + ); + } + generatedAliases.add(individualAlias); + }); } } - // 모든 resultColumns를 반환 return resultColumns.join(", "); }) + .filter(Boolean) .join(", "); // SELECT 절 구성 @@ -725,7 +716,7 @@ export class EntityJoinService { /** * 참조 테이블의 컬럼 목록 조회 (UI용) */ - async getReferenceTableColumns(tableName: string): Promise< + async getReferenceTableColumns(tableName: string, companyCode?: string): Promise< Array<{ columnName: string; displayName: string; @@ -750,16 +741,19 @@ export class EntityJoinService { ); // 2. table_type_columns 테이블에서 라벨과 input_type 정보 조회 + // 회사코드가 있으면 해당 회사 + '*' 만, 회사별 우선 const columnLabels = await query<{ column_name: string; column_label: string | null; input_type: string | null; }>( - `SELECT column_name, column_label, input_type + `SELECT DISTINCT ON (column_name) column_name, column_label, input_type FROM table_type_columns WHERE table_name = $1 - AND company_code = '*'`, - [tableName] + ${companyCode ? `AND company_code IN ($2, '*')` : ''} + ORDER BY column_name, + CASE WHEN company_code = '*' THEN 1 ELSE 0 END`, + companyCode ? [tableName, companyCode] : [tableName] ); // 3. 라벨 및 inputType 정보를 맵으로 변환 diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index db5f32ed..27f713fc 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -2875,10 +2875,11 @@ export class TableManagementService { }; } - // Entity 조인 설정 감지 (화면별 엔티티 설정 전달) + // Entity 조인 설정 감지 (화면별 엔티티 설정 + 회사코드 전달) let joinConfigs = await entityJoinService.detectEntityJoins( tableName, - options.screenEntityConfigs + options.screenEntityConfigs, + options.companyCode ); logger.info( @@ -3258,6 +3259,28 @@ export class TableManagementService { startTime: number ): Promise { try { + // 🆕 참조 테이블별 전체 컬럼 목록 미리 조회 + const referenceTableColumns = new Map(); + const uniqueRefTables = new Set( + joinConfigs + .filter((c) => c.referenceTable !== "table_column_category_values") // 카테고리는 제외 + .map((c) => `${c.referenceTable}:${c.sourceColumn}`) + ); + + for (const key of uniqueRefTables) { + const refTable = key.split(":")[0]; + if (!referenceTableColumns.has(key)) { + const cols = await query<{ column_name: string }>( + `SELECT column_name FROM information_schema.columns + WHERE table_name = $1 AND table_schema = 'public' + ORDER BY ordinal_position`, + [refTable] + ); + referenceTableColumns.set(key, cols.map((c) => c.column_name)); + logger.info(`🔍 참조 테이블 컬럼 조회: ${refTable} → ${cols.length}개`); + } + } + // 데이터 조회 쿼리 const dataQuery = entityJoinService.buildJoinQuery( tableName, @@ -3266,7 +3289,9 @@ export class TableManagementService { whereClause, orderBy, limit, - offset + offset, + undefined, + referenceTableColumns // 🆕 참조 테이블 전체 컬럼 전달 ).query; // 카운트 쿼리 @@ -3767,12 +3792,12 @@ export class TableManagementService { reference_table: string; reference_column: string; }>( - `SELECT column_name, reference_table, reference_column + `SELECT DISTINCT ON (column_name) column_name, reference_table, reference_column FROM table_type_columns WHERE table_name = $1 AND input_type = 'entity' AND reference_table = $2 - AND company_code = '*' + ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END LIMIT 1`, [tableName, refTable] ); @@ -3883,7 +3908,7 @@ export class TableManagementService { /** * 참조 테이블의 표시 컬럼 목록 조회 */ - async getReferenceTableColumns(tableName: string): Promise< + async getReferenceTableColumns(tableName: string, companyCode?: string): Promise< Array<{ columnName: string; displayName: string; @@ -3891,7 +3916,7 @@ export class TableManagementService { inputType?: string; }> > { - return await entityJoinService.getReferenceTableColumns(tableName); + return await entityJoinService.getReferenceTableColumns(tableName, companyCode); } /** @@ -5005,14 +5030,14 @@ export class TableManagementService { input_type: string; display_column: string | null; }>( - `SELECT column_name, reference_column, input_type, display_column + `SELECT DISTINCT ON (column_name) column_name, reference_column, input_type, display_column FROM table_type_columns WHERE table_name = $1 AND input_type IN ('entity', 'category') AND reference_table = $2 AND reference_column IS NOT NULL AND reference_column != '' - AND company_code = '*'`, + ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`, [rightTable, leftTable] ); @@ -5034,14 +5059,14 @@ export class TableManagementService { input_type: string; display_column: string | null; }>( - `SELECT column_name, reference_column, input_type, display_column + `SELECT DISTINCT ON (column_name) column_name, reference_column, input_type, display_column FROM table_type_columns WHERE table_name = $1 AND input_type IN ('entity', 'category') AND reference_table = $2 AND reference_column IS NOT NULL AND reference_column != '' - AND company_code = '*'`, + ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`, [leftTable, rightTable] ); diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index 0a69843c..74b4add0 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -957,6 +957,67 @@ export const SplitPanelLayoutComponent: React.FC [formatDateValue, formatNumberValue], ); + // 🆕 패널 config의 columns에서 additionalJoinColumns 추출하는 헬퍼 + const extractAdditionalJoinColumns = useCallback((columns: any[] | undefined, tableName: string) => { + if (!columns || columns.length === 0) return undefined; + + const joinColumns: Array<{ + sourceTable: string; + sourceColumn: string; + referenceTable: string; + joinAlias: string; + }> = []; + + columns.forEach((col: any) => { + // 방법 1: isEntityJoin 플래그가 있는 경우 (설정 패널에서 Entity 조인 컬럼으로 추가한 경우) + if (col.isEntityJoin && col.joinInfo) { + const existing = joinColumns.find( + (j) => j.referenceTable === col.joinInfo.referenceTable && j.joinAlias === col.joinInfo.joinAlias + ); + if (!existing) { + joinColumns.push({ + sourceTable: col.joinInfo.sourceTable || tableName, + sourceColumn: col.joinInfo.sourceColumn, + referenceTable: col.joinInfo.referenceTable, + joinAlias: col.joinInfo.joinAlias, + }); + } + return; + } + + // 방법 2: "테이블명.컬럼명" 형식 (기존 좌측 패널 방식) + const colName = typeof col === "string" ? col : col.name || col.columnName; + if (colName && colName.includes(".")) { + const [refTable, refColumn] = colName.split("."); + const inferredSourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id"); + const existing = joinColumns.find( + (j) => j.referenceTable === refTable && j.sourceColumn === inferredSourceColumn + ); + if (!existing) { + joinColumns.push({ + sourceTable: tableName, + sourceColumn: inferredSourceColumn, + referenceTable: refTable, + joinAlias: `${inferredSourceColumn}_${refColumn}`, + }); + } else { + // 이미 추가된 테이블이면 별칭만 추가 + const newAlias = `${inferredSourceColumn}_${refColumn}`; + if (!joinColumns.find((j) => j.joinAlias === newAlias)) { + joinColumns.push({ + sourceTable: tableName, + sourceColumn: inferredSourceColumn, + referenceTable: refTable, + joinAlias: newAlias, + }); + } + } + } + }); + + return joinColumns.length > 0 ? joinColumns : undefined; + }, []); + // 좌측 데이터 로드 const loadLeftData = useCallback(async () => { const leftTableName = componentConfig.leftPanel?.tableName; @@ -967,74 +1028,22 @@ export const SplitPanelLayoutComponent: React.FC // 🎯 필터 조건을 API에 전달 (entityJoinApi 사용) const filters = Object.keys(searchValues).length > 0 ? searchValues : undefined; - // 🆕 "테이블명.컬럼명" 형식의 조인 컬럼들을 additionalJoinColumns로 변환 - const configuredColumns = componentConfig.leftPanel?.columns || []; - const additionalJoinColumns: Array<{ - sourceTable: string; - sourceColumn: string; - referenceTable: string; - joinAlias: string; - }> = []; + // 🆕 좌측 패널 config의 Entity 조인 컬럼 추출 (헬퍼 함수 사용) + const leftJoinColumns = extractAdditionalJoinColumns( + componentConfig.leftPanel?.columns, + leftTableName, + ); - // 소스 컬럼 매핑 (item_info → item_code, warehouse_info → warehouse_id 등) - const sourceColumnMap: Record = {}; - - configuredColumns.forEach((col: any) => { - const colName = typeof col === "string" ? col : col.name || col.columnName; - if (colName && colName.includes(".")) { - const [refTable, refColumn] = colName.split("."); - // 소스 컬럼 추론 (item_info → item_code 또는 warehouse_info → warehouse_id) - // 기본: _info → _code, 백업: _info → _id - const primarySourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id"); - const secondarySourceColumn = refTable.replace("_info", "_id").replace("_mng", "_id"); - // 실제 존재하는 소스 컬럼은 백엔드에서 결정 (프론트엔드는 두 패턴 모두 전달) - const inferredSourceColumn = primarySourceColumn; - - // 이미 추가된 조인인지 확인 (동일 테이블, 동일 소스컬럼) - const existingJoin = additionalJoinColumns.find( - (j) => j.referenceTable === refTable && j.sourceColumn === inferredSourceColumn, - ); - - if (!existingJoin) { - // 새로운 조인 추가 (첫 번째 컬럼) - additionalJoinColumns.push({ - sourceTable: leftTableName, - sourceColumn: inferredSourceColumn, - referenceTable: refTable, - joinAlias: `${inferredSourceColumn}_${refColumn}`, - }); - sourceColumnMap[refTable] = inferredSourceColumn; - } - - // 추가 컬럼도 별도로 요청 (item_code_standard, item_code_unit 등) - // 단, 첫 번째 컬럼과 다른 경우만 - const existingAliases = additionalJoinColumns - .filter((j) => j.referenceTable === refTable) - .map((j) => j.joinAlias); - const newAlias = `${sourceColumnMap[refTable] || inferredSourceColumn}_${refColumn}`; - - if (!existingAliases.includes(newAlias)) { - additionalJoinColumns.push({ - sourceTable: leftTableName, - sourceColumn: sourceColumnMap[refTable] || inferredSourceColumn, - referenceTable: refTable, - joinAlias: newAlias, - }); - } - } - }); - - console.log("🔗 [분할패널] additionalJoinColumns:", additionalJoinColumns); - console.log("🔗 [분할패널] configuredColumns:", configuredColumns); + console.log("🔗 [분할패널] 좌측 additionalJoinColumns:", leftJoinColumns); const result = await entityJoinApi.getTableDataWithJoins(leftTableName, { page: 1, size: 100, - search: filters, // 필터 조건 전달 - enableEntityJoin: true, // 엔티티 조인 활성화 - dataFilter: componentConfig.leftPanel?.dataFilter, // 🆕 데이터 필터 전달 - additionalJoinColumns: additionalJoinColumns.length > 0 ? additionalJoinColumns : undefined, // 🆕 추가 조인 컬럼 - companyCodeOverride: companyCode, // 🆕 프리뷰용 회사 코드 오버라이드 + search: filters, + enableEntityJoin: true, + dataFilter: componentConfig.leftPanel?.dataFilter, + additionalJoinColumns: leftJoinColumns, + companyCodeOverride: companyCode, }); // 🔍 디버깅: API 응답 데이터의 키 확인 @@ -1093,11 +1102,16 @@ export const SplitPanelLayoutComponent: React.FC // 🆕 엔티티 조인 API 사용 const { entityJoinApi } = await import("@/lib/api/entityJoin"); + const rightDetailJoinColumns = extractAdditionalJoinColumns( + componentConfig.rightPanel?.columns, + rightTableName, + ); const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { search: { id: primaryKey }, - enableEntityJoin: true, // 엔티티 조인 활성화 + enableEntityJoin: true, size: 1, - companyCodeOverride: companyCode, // 🆕 프리뷰용 회사 코드 오버라이드 + companyCodeOverride: companyCode, + additionalJoinColumns: rightDetailJoinColumns, // 🆕 Entity 조인 컬럼 전달 }); const detail = result.items && result.items.length > 0 ? result.items[0] : null; @@ -1141,6 +1155,12 @@ export const SplitPanelLayoutComponent: React.FC const { entityJoinApi } = await import("@/lib/api/entityJoin"); const allResults: any[] = []; + // 🆕 우측 패널 Entity 조인 컬럼 추출 (그룹 합산용) + const rightJoinColumnsForGroup = extractAdditionalJoinColumns( + componentConfig.rightPanel?.columns, + rightTableName, + ); + // 각 원본 항목에 대해 조회 for (const originalItem of leftItem._originalItems) { const searchConditions: Record = {}; @@ -1155,7 +1175,8 @@ export const SplitPanelLayoutComponent: React.FC search: searchConditions, enableEntityJoin: true, size: 1000, - companyCodeOverride: companyCode, // 🆕 프리뷰용 회사 코드 오버라이드 + companyCodeOverride: companyCode, + additionalJoinColumns: rightJoinColumnsForGroup, // 🆕 Entity 조인 컬럼 전달 }); if (result.data) { allResults.push(...result.data); @@ -1185,12 +1206,22 @@ export const SplitPanelLayoutComponent: React.FC console.log("🔗 [분할패널] 복합키 조건:", searchConditions); + // 🆕 우측 패널 config의 Entity 조인 컬럼 추출 + const rightJoinColumns = extractAdditionalJoinColumns( + componentConfig.rightPanel?.columns, + rightTableName, + ); + if (rightJoinColumns) { + console.log("🔗 [분할패널] 우측 패널 additionalJoinColumns:", rightJoinColumns); + } + // 엔티티 조인 API로 데이터 조회 const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { search: searchConditions, enableEntityJoin: true, size: 1000, - companyCodeOverride: companyCode, // 🆕 프리뷰용 회사 코드 오버라이드 + companyCodeOverride: companyCode, + additionalJoinColumns: rightJoinColumns, // 🆕 Entity 조인 컬럼 전달 }); console.log("🔗 [분할패널] 복합키 조회 결과:", result); @@ -1275,6 +1306,12 @@ export const SplitPanelLayoutComponent: React.FC const leftColumn = tabConfig.relation?.leftColumn || keys?.[0]?.leftColumn; const rightColumn = tabConfig.relation?.foreignKey || keys?.[0]?.rightColumn; + // 🆕 탭 config의 Entity 조인 컬럼 추출 + const tabJoinColumns = extractAdditionalJoinColumns(tabConfig.columns, tabTableName); + if (tabJoinColumns) { + console.log(`🔗 [분할패널] 탭 ${tabIndex} additionalJoinColumns:`, tabJoinColumns); + } + let resultData: any[] = []; if (leftColumn && rightColumn) { @@ -1303,12 +1340,14 @@ export const SplitPanelLayoutComponent: React.FC search: searchConditions, enableEntityJoin: true, size: 1000, + additionalJoinColumns: tabJoinColumns, // 🆕 Entity 조인 컬럼 전달 }); resultData = result.data || []; } else { const result = await entityJoinApi.getTableDataWithJoins(tabTableName, { enableEntityJoin: true, size: 1000, + additionalJoinColumns: tabJoinColumns, // 🆕 Entity 조인 컬럼 전달 }); resultData = result.data || []; } diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx index dd7638bc..b2fff2cd 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx @@ -28,12 +28,13 @@ import { CSS } from "@dnd-kit/utilities"; // 드래그 가능한 컬럼 아이템 function SortableColumnRow({ - id, col, index, isNumeric, onLabelChange, onWidthChange, onFormatChange, onRemove, + id, col, index, isNumeric, isEntityJoin, onLabelChange, onWidthChange, onFormatChange, onRemove, }: { id: string; col: { name: string; label: string; width?: number; format?: any }; index: number; isNumeric: boolean; + isEntityJoin?: boolean; onLabelChange: (value: string) => void; onWidthChange: (value: number) => void; onFormatChange: (checked: boolean) => void; @@ -49,12 +50,17 @@ function SortableColumnRow({ className={cn( "flex items-center gap-1.5 rounded-md border bg-card px-2 py-1.5", isDragging && "z-50 opacity-50 shadow-md", + isEntityJoin && "border-blue-200 bg-blue-50/30", )} >
- #{index + 1} + {isEntityJoin ? ( + + ) : ( + #{index + 1} + )} onLabelChange(e.target.value)} @@ -1975,6 +1981,7 @@ export const SplitPanelLayoutConfigPanel: React.FC { const newColumns = [...selectedColumns]; newColumns[index] = { ...newColumns[index], label: value }; @@ -2021,6 +2028,78 @@ export const SplitPanelLayoutConfigPanel: React.FC ))}
+ + {/* 좌측 패널 - Entity 조인 컬럼 아코디언 */} + {(() => { + const leftTable = config.leftPanel?.tableName || screenTableName; + const joinData = leftTable ? entityJoinColumns[leftTable] : null; + if (!joinData || joinData.joinTables.length === 0) return null; + + return joinData.joinTables.map((joinTable, tableIndex) => { + const joinColumnsToShow = joinTable.availableColumns.filter((column) => { + const matchingJoinColumn = joinData.availableColumns.find( + (jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName, + ); + if (!matchingJoinColumn) return false; + return !selectedColumns.some((c) => c.name === matchingJoinColumn.joinAlias); + }); + const addedCount = joinTable.availableColumns.length - joinColumnsToShow.length; + + if (joinColumnsToShow.length === 0 && addedCount === 0) return null; + + return ( +
+ + + + {joinTable.tableName} + {addedCount > 0 && ( + {addedCount}개 선택 + )} + {joinColumnsToShow.length}개 남음 + +
+ {joinColumnsToShow.map((column, colIndex) => { + const matchingJoinColumn = joinData.availableColumns.find( + (jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName, + ); + if (!matchingJoinColumn) return null; + + return ( +
{ + updateLeftPanel({ + columns: [...selectedColumns, { + name: matchingJoinColumn.joinAlias, + label: matchingJoinColumn.suggestedLabel || matchingJoinColumn.columnLabel, + width: 100, + isEntityJoin: true, + joinInfo: { + sourceTable: leftTable!, + sourceColumn: (joinTable as any).joinConfig?.sourceColumn || "", + referenceTable: matchingJoinColumn.tableName, + joinAlias: matchingJoinColumn.joinAlias, + }, + }], + }); + }} + > + + + {column.columnLabel || column.columnName} +
+ ); + })} + {joinColumnsToShow.length === 0 && ( +

모든 컬럼이 이미 추가되었습니다

+ )} +
+
+ ); + }); + })()} )}
@@ -2029,76 +2108,6 @@ export const SplitPanelLayoutConfigPanel: React.FC - {/* 좌측 패널 Entity 조인 컬럼 */} - {(() => { - const leftTable = config.leftPanel?.tableName || screenTableName; - const joinData = leftTable ? entityJoinColumns[leftTable] : null; - if (!joinData || joinData.joinTables.length === 0) return null; - const selectedColumns = config.leftPanel?.columns || []; - - return ( -
-

Entity 조인 컬럼

-

연관 테이블의 컬럼을 표시 컬럼에 추가합니다

-
- {joinData.joinTables.map((joinTable, tableIndex) => ( -
-
- - {joinTable.tableName} - {joinTable.currentDisplayColumn} -
-
- {joinTable.availableColumns.map((column, colIndex) => { - const matchingJoinColumn = joinData.availableColumns.find( - (jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName, - ); - if (!matchingJoinColumn) return null; - const isAdded = selectedColumns.some((c) => c.name === matchingJoinColumn.joinAlias); - - return ( -
{ - if (isAdded) { - updateLeftPanel({ columns: selectedColumns.filter((c) => c.name !== matchingJoinColumn.joinAlias) }); - } else { - updateLeftPanel({ - columns: [...selectedColumns, { - name: matchingJoinColumn.joinAlias, - label: matchingJoinColumn.suggestedLabel || matchingJoinColumn.columnLabel, - width: 100, - isEntityJoin: true, - joinInfo: { - sourceTable: leftTable!, - sourceColumn: (joinTable as any).joinConfig?.sourceColumn || "", - referenceTable: matchingJoinColumn.tableName, - joinAlias: matchingJoinColumn.joinAlias, - }, - }], - }); - } - }} - > - - - {column.columnLabel} - {column.dataType} -
- ); - })} -
-
- ))} -
-
- ); - })()} - {/* 좌측 패널 데이터 필터링 */}

좌측 패널 데이터 필터링

@@ -2351,64 +2360,7 @@ export const SplitPanelLayoutConfigPanel: React.FC )} - {/* 엔티티 설정 선택 - 조건 필터 모드에서만 표시 */} - {relationshipType !== "detail" && ( -
- -

- 우측 테이블에서 좌측 테이블을 참조하는 컬럼을 선택하세요 -

- - {config.rightPanel?.relation?.foreignKey && ( -

선택된 컬럼의 엔티티 설정이 자동으로 적용됩니다.

- )} -
- )} + {/* 필터 연결 컬럼 제거됨 - Entity 조인이 자동으로 관계를 처리 */} {/* 우측 패널 표시 컬럼 설정 - 드래그앤드롭 */} {(() => { @@ -2455,6 +2407,7 @@ export const SplitPanelLayoutConfigPanel: React.FC { const newColumns = [...selectedColumns]; newColumns[index] = { ...newColumns[index], label: value }; @@ -2499,6 +2452,78 @@ export const SplitPanelLayoutConfigPanel: React.FC ))}
+ + {/* Entity 조인 컬럼 - 아코디언 (접기/펼치기) */} + {(() => { + const rightTable = config.rightPanel?.tableName; + const joinData = rightTable ? entityJoinColumns[rightTable] : null; + if (!joinData || joinData.joinTables.length === 0) return null; + + return joinData.joinTables.map((joinTable, tableIndex) => { + const joinColumnsToShow = joinTable.availableColumns.filter((column) => { + const matchingJoinColumn = joinData.availableColumns.find( + (jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName, + ); + if (!matchingJoinColumn) return false; + return !selectedColumns.some((c) => c.name === matchingJoinColumn.joinAlias); + }); + const addedCount = joinTable.availableColumns.length - joinColumnsToShow.length; + + if (joinColumnsToShow.length === 0 && addedCount === 0) return null; + + return ( +
+ + + + {joinTable.tableName} + {addedCount > 0 && ( + {addedCount}개 선택 + )} + {joinColumnsToShow.length}개 남음 + +
+ {joinColumnsToShow.map((column, colIndex) => { + const matchingJoinColumn = joinData.availableColumns.find( + (jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName, + ); + if (!matchingJoinColumn) return null; + + return ( +
{ + updateRightPanel({ + columns: [...selectedColumns, { + name: matchingJoinColumn.joinAlias, + label: matchingJoinColumn.suggestedLabel || matchingJoinColumn.columnLabel, + width: 100, + isEntityJoin: true, + joinInfo: { + sourceTable: rightTable!, + sourceColumn: (joinTable as any).joinConfig?.sourceColumn || "", + referenceTable: matchingJoinColumn.tableName, + joinAlias: matchingJoinColumn.joinAlias, + }, + }], + }); + }} + > + + + {column.columnLabel || column.columnName} +
+ ); + })} + {joinColumnsToShow.length === 0 && ( +

모든 컬럼이 이미 추가되었습니다

+ )} +
+
+ ); + }); + })()} )}
@@ -2507,75 +2532,7 @@ export const SplitPanelLayoutConfigPanel: React.FC - {/* 우측 패널 Entity 조인 컬럼 */} - {(() => { - const rightTable = config.rightPanel?.tableName; - const joinData = rightTable ? entityJoinColumns[rightTable] : null; - if (!joinData || joinData.joinTables.length === 0) return null; - const selectedColumns = config.rightPanel?.columns || []; - - return ( -
-

Entity 조인 컬럼

-

연관 테이블의 컬럼을 표시 컬럼에 추가합니다

-
- {joinData.joinTables.map((joinTable, tableIndex) => ( -
-
- - {joinTable.tableName} - {joinTable.currentDisplayColumn} -
-
- {joinTable.availableColumns.map((column, colIndex) => { - const matchingJoinColumn = joinData.availableColumns.find( - (jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName, - ); - if (!matchingJoinColumn) return null; - const isAdded = selectedColumns.some((c) => c.name === matchingJoinColumn.joinAlias); - - return ( -
{ - if (isAdded) { - updateRightPanel({ columns: selectedColumns.filter((c) => c.name !== matchingJoinColumn.joinAlias) }); - } else { - updateRightPanel({ - columns: [...selectedColumns, { - name: matchingJoinColumn.joinAlias, - label: matchingJoinColumn.suggestedLabel || matchingJoinColumn.columnLabel, - width: 100, - isEntityJoin: true, - joinInfo: { - sourceTable: rightTable!, - sourceColumn: (joinTable as any).joinConfig?.sourceColumn || "", - referenceTable: matchingJoinColumn.tableName, - joinAlias: matchingJoinColumn.joinAlias, - }, - }], - }); - } - }} - > - - - {column.columnLabel} - {column.dataType} -
- ); - })} -
-
- ))} -
-
- ); - })()} + {/* 우측 패널 Entity 조인 컬럼은 표시 컬럼 목록에 통합됨 */} {/* 우측 패널 데이터 필터링 */}
From 219f7724e7d11bc27bdd9f8c794ad90ab613d770 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 10 Feb 2026 11:38:02 +0900 Subject: [PATCH 17/44] feat: Enhance MasterDetailExcelService with table alias for JOIN operations - Added a new property `tableAlias` to distinguish between master ("m") and detail ("d") tables during JOIN operations. - Updated the SELECT clause to include the appropriate table alias for master and detail tables. - Improved the entity join clause construction to utilize the new table alias, ensuring clarity in SQL queries. --- .../src/services/masterDetailExcelService.ts | 7 +- .../src/services/tableCategoryValueService.ts | 61 ++++++--- frontend/components/common/ScreenModal.tsx | 48 ++++++- frontend/components/v2/V2Input.tsx | 129 +++++++++++++++--- .../components/v2-select/V2SelectRenderer.tsx | 4 +- frontend/lib/utils/buttonActions.ts | 19 +++ 6 files changed, 226 insertions(+), 42 deletions(-) diff --git a/backend-node/src/services/masterDetailExcelService.ts b/backend-node/src/services/masterDetailExcelService.ts index 87d56694..a3eecb61 100644 --- a/backend-node/src/services/masterDetailExcelService.ts +++ b/backend-node/src/services/masterDetailExcelService.ts @@ -310,6 +310,7 @@ class MasterDetailExcelService { sourceColumn: string; alias: string; displayColumn: string; + tableAlias: string; // "m" (마스터) 또는 "d" (디테일) - JOIN 시 소스 테이블 구분 }> = []; // SELECT 절 구성 @@ -332,6 +333,7 @@ class MasterDetailExcelService { sourceColumn: fkColumn.sourceColumn, alias, displayColumn, + tableAlias: "m", // 마스터 테이블에서 조인 }); selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`); } else { @@ -360,6 +362,7 @@ class MasterDetailExcelService { sourceColumn: fkColumn.sourceColumn, alias, displayColumn, + tableAlias: "d", // 디테일 테이블에서 조인 }); selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`); } else { @@ -373,9 +376,9 @@ class MasterDetailExcelService { const selectClause = selectParts.join(", "); - // 엔티티 조인 절 구성 + // 엔티티 조인 절 구성 (마스터/디테일 테이블 alias 구분) const entityJoinClauses = entityJoins.map(ej => - `LEFT JOIN "${ej.refTable}" ${ej.alias} ON m."${ej.sourceColumn}" = ${ej.alias}."${ej.refColumn}"` + `LEFT JOIN "${ej.refTable}" ${ej.alias} ON ${ej.tableAlias}."${ej.sourceColumn}" = ${ej.alias}."${ej.refColumn}"` ).join("\n "); // WHERE 절 구성 diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts index 2eb35f64..dd2f73a9 100644 --- a/backend-node/src/services/tableCategoryValueService.ts +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -1371,39 +1371,66 @@ class TableCategoryValueService { const pool = getPool(); - // 동적으로 파라미터 플레이스홀더 생성 - const placeholders = valueCodes.map((_, i) => `$${i + 1}`).join(", "); + const n = valueCodes.length; + + // 첫 번째 쿼리용 플레이스홀더: $1 ~ $n + const placeholders1 = valueCodes.map((_, i) => `$${i + 1}`).join(", "); let query: string; let params: any[]; if (companyCode === "*") { - // 최고 관리자: 모든 카테고리 값 조회 + // 최고 관리자: 두 테이블 모두에서 조회 (UNION으로 병합) + // 두 번째 쿼리용 플레이스홀더: $n+1 ~ $2n + const placeholders2 = valueCodes.map((_, i) => `$${n + i + 1}`).join(", "); query = ` - SELECT value_code, value_label - FROM table_column_category_values - WHERE value_code IN (${placeholders}) - AND is_active = true + SELECT value_code, value_label FROM ( + SELECT value_code, value_label + FROM table_column_category_values + WHERE value_code IN (${placeholders1}) + AND is_active = true + UNION ALL + SELECT value_code, value_label + FROM category_values + WHERE value_code IN (${placeholders2}) + AND is_active = true + ) combined `; - params = valueCodes; + params = [...valueCodes, ...valueCodes]; } else { - // 일반 회사: 자신의 카테고리 값 + 공통 카테고리 값 조회 + // 일반 회사: 두 테이블에서 자신의 카테고리 값 + 공통 카테고리 값 조회 + // 첫 번째: $1~$n (valueCodes), $n+1 (companyCode) + // 두 번째: $n+2~$2n+1 (valueCodes), $2n+2 (companyCode) + const companyIdx1 = n + 1; + const placeholders2 = valueCodes.map((_, i) => `$${n + 1 + i + 1}`).join(", "); + const companyIdx2 = 2 * n + 2; + query = ` - SELECT value_code, value_label - FROM table_column_category_values - WHERE value_code IN (${placeholders}) - AND is_active = true - AND (company_code = $${valueCodes.length + 1} OR company_code = '*') + SELECT value_code, value_label FROM ( + SELECT value_code, value_label + FROM table_column_category_values + WHERE value_code IN (${placeholders1}) + AND is_active = true + AND (company_code = $${companyIdx1} OR company_code = '*') + UNION ALL + SELECT value_code, value_label + FROM category_values + WHERE value_code IN (${placeholders2}) + AND is_active = true + AND (company_code = $${companyIdx2} OR company_code = '*') + ) combined `; - params = [...valueCodes, companyCode]; + params = [...valueCodes, companyCode, ...valueCodes, companyCode]; } const result = await pool.query(query, params); - // { [code]: label } 형태로 변환 + // { [code]: label } 형태로 변환 (중복 시 첫 번째 결과 우선) const labels: Record = {}; for (const row of result.rows) { - labels[row.value_code] = row.value_label; + if (!labels[row.value_code]) { + labels[row.value_code] = row.value_label; + } } logger.info(`카테고리 라벨 ${Object.keys(labels).length}개 조회 완료`, { companyCode }); diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 0add43d6..c2d8bcbc 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -172,6 +172,7 @@ export const ScreenModal: React.FC = ({ className }) => { selectedData: eventSelectedData, selectedIds, isCreateMode, // 🆕 복사 모드 플래그 (true면 editData가 있어도 originalData 설정 안 함) + fieldMappings, // 🆕 필드 매핑 정보 (명시적 매핑이 있으면 모든 매핑된 필드 전달) } = event.detail; // 🆕 모달 열린 시간 기록 @@ -267,6 +268,17 @@ export const ScreenModal: React.FC = ({ className }) => { parentData.company_code = rawParentData.company_code; } + // 🆕 명시적 필드 매핑이 있으면 매핑된 타겟 필드를 모두 보존 + // (버튼 설정에서 fieldMappings로 지정한 필드는 link 필드가 아니어도 전달) + const mappedTargetFields = new Set(); + if (fieldMappings && Array.isArray(fieldMappings)) { + for (const mapping of fieldMappings) { + if (mapping.targetField) { + mappedTargetFields.add(mapping.targetField); + } + } + } + // parentDataMapping에 정의된 필드만 전달 for (const mapping of parentDataMapping) { const sourceValue = rawParentData[mapping.sourceColumn]; @@ -275,8 +287,17 @@ export const ScreenModal: React.FC = ({ className }) => { } } - // parentDataMapping이 비어있으면 연결 필드 자동 감지 (equipment_code, xxx_code, xxx_id 패턴) - if (parentDataMapping.length === 0) { + // 🆕 명시적 필드 매핑이 있으면 해당 필드를 모두 전달 + if (mappedTargetFields.size > 0) { + for (const [key, value] of Object.entries(rawParentData)) { + if (mappedTargetFields.has(key) && value !== undefined && value !== null) { + parentData[key] = value; + } + } + } + + // parentDataMapping이 비어있고 명시적 필드 매핑도 없으면 연결 필드 자동 감지 + if (parentDataMapping.length === 0 && mappedTargetFields.size === 0) { const linkFieldPatterns = ["_code", "_id"]; const excludeFields = [ "id", @@ -293,6 +314,29 @@ export const ScreenModal: React.FC = ({ className }) => { if (value === undefined || value === null) continue; // 연결 필드 패턴 확인 + const isLinkField = linkFieldPatterns.some((pattern) => key.endsWith(pattern)); + if (isLinkField) { + parentData[key] = value; + } + } + } else if (parentDataMapping.length === 0 && mappedTargetFields.size > 0) { + // 🆕 명시적 매핑이 있어도 연결 필드(_code, _id)는 추가로 전달 + const linkFieldPatterns = ["_code", "_id"]; + const excludeFields = [ + "id", + "company_code", + "created_date", + "updated_date", + "created_at", + "updated_at", + "writer", + ]; + + for (const [key, value] of Object.entries(rawParentData)) { + if (excludeFields.includes(key)) continue; + if (parentData[key] !== undefined) continue; // 이미 매핑된 필드는 스킵 + if (value === undefined || value === null) continue; + const isLinkField = linkFieldPatterns.some((pattern) => key.endsWith(pattern)); if (isLinkField) { parentData[key] = value; diff --git a/frontend/components/v2/V2Input.tsx b/frontend/components/v2/V2Input.tsx index a284f26e..d8457adb 100644 --- a/frontend/components/v2/V2Input.tsx +++ b/frontend/components/v2/V2Input.tsx @@ -23,15 +23,26 @@ import { AutoGenerationConfig } from "@/types/screen"; import { previewNumberingCode } from "@/lib/api/numberingRule"; // 형식별 입력 마스크 및 검증 패턴 -const FORMAT_PATTERNS: Record = { - none: { pattern: /.*/, placeholder: "" }, - email: { pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, placeholder: "example@email.com" }, - tel: { pattern: /^\d{2,3}-\d{3,4}-\d{4}$/, placeholder: "010-1234-5678" }, - url: { pattern: /^https?:\/\/.+/, placeholder: "https://example.com" }, - currency: { pattern: /^[\d,]+$/, placeholder: "1,000,000" }, - biz_no: { pattern: /^\d{3}-\d{2}-\d{5}$/, placeholder: "123-45-67890" }, +const FORMAT_PATTERNS: Record = { + none: { pattern: /.*/, placeholder: "", errorMessage: "" }, + email: { pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, placeholder: "example@email.com", errorMessage: "올바른 이메일 형식이 아닙니다" }, + tel: { pattern: /^\d{2,3}-\d{3,4}-\d{4}$/, placeholder: "010-1234-5678", errorMessage: "올바른 전화번호 형식이 아닙니다" }, + url: { pattern: /^https?:\/\/.+/, placeholder: "https://example.com", errorMessage: "올바른 URL 형식이 아닙니다 (https://로 시작)" }, + currency: { pattern: /^[\d,]+$/, placeholder: "1,000,000", errorMessage: "숫자만 입력 가능합니다" }, + biz_no: { pattern: /^\d{3}-\d{2}-\d{5}$/, placeholder: "123-45-67890", errorMessage: "올바른 사업자번호 형식이 아닙니다" }, }; +// 형식 검증 함수 (외부에서도 사용 가능) +export function validateInputFormat(value: string, format: V2InputFormat): { isValid: boolean; errorMessage: string } { + if (!value || value.trim() === "" || format === "none") { + return { isValid: true, errorMessage: "" }; + } + const formatConfig = FORMAT_PATTERNS[format]; + if (!formatConfig) return { isValid: true, errorMessage: "" }; + const isValid = formatConfig.pattern.test(value); + return { isValid, errorMessage: isValid ? "" : formatConfig.errorMessage }; +} + // 통화 형식 변환 function formatCurrency(value: string | number): string { const num = typeof value === "string" ? parseFloat(value.replace(/,/g, "")) : value; @@ -70,8 +81,13 @@ const TextInput = forwardRef< readonly?: boolean; disabled?: boolean; className?: string; + columnName?: string; } ->(({ value, onChange, format = "none", placeholder, readonly, disabled, className }, ref) => { +>(({ value, onChange, format = "none", placeholder, readonly, disabled, className, columnName }, ref) => { + // 검증 상태 + const [hasBlurred, setHasBlurred] = useState(false); + const [validationError, setValidationError] = useState(""); + // 형식에 따른 값 포맷팅 const formatValue = useCallback( (val: string): string => { @@ -104,29 +120,101 @@ const TextInput = forwardRef< newValue = formatTel(newValue); } + // 입력 중 에러 표시 해제 (입력 중에는 관대하게) + if (hasBlurred && validationError) { + const { isValid } = validateInputFormat(newValue, format); + if (isValid) { + setValidationError(""); + } + } + onChange?.(newValue); }, - [format, onChange], + [format, onChange, hasBlurred, validationError], ); + // blur 시 형식 검증 + const handleBlur = useCallback(() => { + setHasBlurred(true); + const currentValue = value !== undefined && value !== null ? String(value) : ""; + if (currentValue && format !== "none") { + const { isValid, errorMessage } = validateInputFormat(currentValue, format); + setValidationError(isValid ? "" : errorMessage); + } else { + setValidationError(""); + } + }, [value, format]); + + // 값 변경 시 검증 상태 업데이트 + useEffect(() => { + if (hasBlurred) { + const currentValue = value !== undefined && value !== null ? String(value) : ""; + if (currentValue && format !== "none") { + const { isValid, errorMessage } = validateInputFormat(currentValue, format); + setValidationError(isValid ? "" : errorMessage); + } else { + setValidationError(""); + } + } + }, [value, format, hasBlurred]); + + // 글로벌 폼 검증 이벤트 리스너 (저장 시 호출) + useEffect(() => { + if (format === "none" || !columnName) return; + + const handleValidateForm = (event: CustomEvent) => { + const currentValue = value !== undefined && value !== null ? String(value) : ""; + if (currentValue) { + const { isValid, errorMessage } = validateInputFormat(currentValue, format); + if (!isValid) { + setHasBlurred(true); + setValidationError(errorMessage); + // 검증 결과를 이벤트에 기록 + if (event.detail?.errors) { + event.detail.errors.push({ + columnName, + message: errorMessage, + }); + } + } + } + }; + + window.addEventListener("validateFormInputs", handleValidateForm as EventListener); + return () => { + window.removeEventListener("validateFormInputs", handleValidateForm as EventListener); + }; + }, [format, value, columnName]); + const displayValue = useMemo(() => { if (value === undefined || value === null) return ""; return formatValue(String(value)); }, [value, formatValue]); const inputPlaceholder = placeholder || FORMAT_PATTERNS[format].placeholder; + const hasError = hasBlurred && !!validationError; return ( - +
+ + {hasError && ( +

{validationError}

+ )} +
); }); TextInput.displayName = "TextInput"; @@ -678,6 +766,7 @@ export const V2Input = forwardRef((props, ref) => placeholder={config.placeholder} readonly={readonly || (autoGeneration.enabled && hasGeneratedRef.current)} disabled={disabled} + columnName={columnName} /> ); @@ -835,9 +924,11 @@ export const V2Input = forwardRef((props, ref) => setAutoGeneratedValue(null); onChange?.(v); }} + format={config.format} placeholder={config.placeholder} readonly={readonly} disabled={disabled} + columnName={columnName} /> ); } diff --git a/frontend/lib/registry/components/v2-select/V2SelectRenderer.tsx b/frontend/lib/registry/components/v2-select/V2SelectRenderer.tsx index be5a6c84..5ab010f2 100644 --- a/frontend/lib/registry/components/v2-select/V2SelectRenderer.tsx +++ b/frontend/lib/registry/components/v2-select/V2SelectRenderer.tsx @@ -121,8 +121,8 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer { onChange={handleChange} config={{ mode: config.mode || "dropdown", - // 🔧 카테고리 타입이면 source를 "category"로 설정 - source: config.source || (isCategoryType ? "category" : "distinct"), + // 🔧 카테고리 타입이면 source를 무조건 "category"로 강제 (테이블 타입 관리 설정 우선) + source: isCategoryType ? "category" : (config.source || "distinct"), multiple: config.multiple || false, searchable: config.searchable ?? true, placeholder: config.placeholder || "선택하세요", diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 71a23472..e1abcb25 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -541,6 +541,23 @@ export class ButtonActionExecutor { return false; } + // ✅ 입력 형식 검증 (이메일, 전화번호, URL 등) + const formatValidationDetail = { errors: [] as Array<{ columnName: string; message: string }> }; + window.dispatchEvent( + new CustomEvent("validateFormInputs", { + detail: formatValidationDetail, + }), + ); + // 약간의 대기 (이벤트 핸들러가 동기적으로 실행되지만 안전을 위해) + await new Promise((resolve) => setTimeout(resolve, 50)); + + if (formatValidationDetail.errors.length > 0) { + const errorMessages = formatValidationDetail.errors.map((e) => e.message); + console.log("❌ [handleSave] 입력 형식 검증 실패:", formatValidationDetail.errors); + toast.error(`입력 형식을 확인해주세요: ${errorMessages.join(", ")}`); + return false; + } + // 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용 if (onSave) { try { @@ -3144,6 +3161,8 @@ export class ButtonActionExecutor { editData: useAsEditData && isPassDataMode ? parentData : undefined, splitPanelParentData: isPassDataMode ? parentData : undefined, urlParams: dataSourceId ? { dataSourceId } : undefined, + // 🆕 필드 매핑 정보 전달 - ScreenModal에서 매핑된 필드를 필터링하지 않도록 + fieldMappings: config.fieldMappings, }, }); From 8894216ee86cb91e20807227925eb089f8819845 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 10 Feb 2026 12:07:25 +0900 Subject: [PATCH 18/44] feat: Improve entity join handling with enhanced column validation and support for complex keys - Updated the entityJoinService to include type casting for source and reference columns, ensuring compatibility during joins. - Implemented validation for reference columns in the TableManagementService, allowing automatic fallback to 'id' if the specified reference column does not exist. - Enhanced logging for join configurations to provide better insights during the join setup process. - Transitioned the SplitPanelLayoutComponent to utilize the entityJoinApi for handling single key to composite key transformations, improving data retrieval efficiency. - Added support for displaying null or empty values as "-" in the SplitPanelLayout, enhancing user experience. --- .../src/services/entityJoinService.ts | 52 +++++++++-- .../src/services/tableManagementService.ts | 66 +++++++++----- .../SplitPanelLayoutComponent.tsx | 90 +++++++++++++------ 3 files changed, 149 insertions(+), 59 deletions(-) diff --git a/backend-node/src/services/entityJoinService.ts b/backend-node/src/services/entityJoinService.ts index 13f757fd..059dad4a 100644 --- a/backend-node/src/services/entityJoinService.ts +++ b/backend-node/src/services/entityJoinService.ts @@ -457,17 +457,18 @@ export class EntityJoinService { // table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링) if (config.referenceTable === "table_column_category_values") { - return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`; + return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`; } // user_info는 전역 테이블이므로 company_code 조건 없이 조인 if (config.referenceTable === "user_info") { - return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`; + return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT`; } // 일반 테이블: company_code가 있으면 같은 회사 데이터만 조인 (멀티테넌시) // supplier_mng, customer_mng, item_info 등 회사별 데이터 테이블 - return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.company_code = main.company_code`; + // ::TEXT 캐스팅으로 varchar/integer 등 타입 불일치 방지 + return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.company_code = main.company_code`; }) .join("\n"); @@ -580,6 +581,7 @@ export class EntityJoinService { logger.info("🔍 조인 설정 검증 상세:", { sourceColumn: config.sourceColumn, referenceTable: config.referenceTable, + referenceColumn: config.referenceColumn, displayColumns: config.displayColumns, displayColumn: config.displayColumn, aliasColumn: config.aliasColumn, @@ -598,7 +600,45 @@ export class EntityJoinService { return false; } - // 참조 컬럼 존재 확인 (displayColumns[0] 사용) + // 참조 컬럼(JOIN 키) 존재 확인 - 참조 테이블에 reference_column이 실제로 있는지 검증 + if (config.referenceColumn) { + const refColExists = await query<{ exists: number }>( + `SELECT 1 as exists FROM information_schema.columns + WHERE table_name = $1 + AND column_name = $2 + LIMIT 1`, + [config.referenceTable, config.referenceColumn] + ); + + if (refColExists.length === 0) { + // reference_column이 없으면 'id' 컬럼으로 자동 대체 시도 + const idColExists = await query<{ exists: number }>( + `SELECT 1 as exists FROM information_schema.columns + WHERE table_name = $1 + AND column_name = 'id' + LIMIT 1`, + [config.referenceTable] + ); + + if (idColExists.length > 0) { + logger.warn( + `⚠️ 참조 컬럼 ${config.referenceTable}.${config.referenceColumn}이 존재하지 않음 → 'id'로 자동 대체` + ); + config.referenceColumn = "id"; + } else { + logger.warn( + `❌ 참조 컬럼 ${config.referenceTable}.${config.referenceColumn}이 존재하지 않고 'id' 컬럼도 없음 → 스킵` + ); + return false; + } + } else { + logger.info( + `✅ 참조 컬럼 확인 완료: ${config.referenceTable}.${config.referenceColumn}` + ); + } + } + + // 표시 컬럼 존재 확인 (displayColumns[0] 사용) const displayColumn = config.displayColumns?.[0] || config.displayColumn; logger.info( `🔍 표시 컬럼 확인: ${displayColumn} (from displayColumns: ${config.displayColumns}, displayColumn: ${config.displayColumn})` @@ -686,10 +726,10 @@ export class EntityJoinService { // table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만) if (config.referenceTable === "table_column_category_values") { // 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외) - return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`; + return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`; } - return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`; + return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT`; }) .join("\n"); diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 27f713fc..6e0f3944 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -2979,31 +2979,49 @@ export class TableManagementService { continue; // 기본 Entity 조인과 중복되면 추가하지 않음 } - // 추가 조인 컬럼 설정 생성 - const additionalJoinConfig: EntityJoinConfig = { - sourceTable: tableName, - sourceColumn: sourceColumn, // 실제 소스 컬럼 (partner_id) - referenceTable: - (additionalColumn as any).referenceTable || - baseJoinConfig.referenceTable, // 참조 테이블 (customer_mng) - referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (customer_code) - displayColumns: [actualColumnName], // 표시할 컬럼들 (customer_name) - displayColumn: actualColumnName, // 하위 호환성 - aliasColumn: correctedJoinAlias, // 수정된 별칭 (partner_id_customer_name) - separator: " - ", // 기본 구분자 - }; - - joinConfigs.push(additionalJoinConfig); - logger.info( - `✅ 추가 조인 컬럼 설정 추가: ${additionalJoinConfig.aliasColumn} -> ${actualColumnName}` + // 🆕 같은 sourceColumn + referenceTable 조합의 기존 config가 있으면 displayColumns에 병합 + const existingConfig = joinConfigs.find( + (config) => + config.sourceColumn === sourceColumn && + config.referenceTable === ((additionalColumn as any).referenceTable || baseJoinConfig.referenceTable) ); - logger.info(`🔍 추가된 조인 설정 상세:`, { - sourceTable: additionalJoinConfig.sourceTable, - sourceColumn: additionalJoinConfig.sourceColumn, - referenceTable: additionalJoinConfig.referenceTable, - displayColumns: additionalJoinConfig.displayColumns, - aliasColumn: additionalJoinConfig.aliasColumn, - }); + + if (existingConfig) { + // 기존 config에 display column 추가 (중복 방지) + if (!existingConfig.displayColumns?.includes(actualColumnName)) { + existingConfig.displayColumns = existingConfig.displayColumns || []; + existingConfig.displayColumns.push(actualColumnName); + logger.info( + `🔄 기존 조인 설정에 컬럼 병합: ${existingConfig.aliasColumn} ← ${actualColumnName} (총 ${existingConfig.displayColumns.length}개)` + ); + } + } else { + // 새 조인 설정 생성 + const additionalJoinConfig: EntityJoinConfig = { + sourceTable: tableName, + sourceColumn: sourceColumn, // 실제 소스 컬럼 (partner_id) + referenceTable: + (additionalColumn as any).referenceTable || + baseJoinConfig.referenceTable, // 참조 테이블 (customer_mng) + referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (customer_code) + displayColumns: [actualColumnName], // 표시할 컬럼들 (customer_name) + displayColumn: actualColumnName, // 하위 호환성 + aliasColumn: correctedJoinAlias, // 수정된 별칭 (partner_id_customer_name) + separator: " - ", // 기본 구분자 + }; + + joinConfigs.push(additionalJoinConfig); + logger.info( + `✅ 추가 조인 컬럼 설정 추가: ${additionalJoinConfig.aliasColumn} -> ${actualColumnName}` + ); + logger.info(`🔍 추가된 조인 설정 상세:`, { + sourceTable: additionalJoinConfig.sourceTable, + sourceColumn: additionalJoinConfig.sourceColumn, + referenceTable: additionalJoinConfig.referenceTable, + displayColumns: additionalJoinConfig.displayColumns, + aliasColumn: additionalJoinConfig.aliasColumn, + }); + } } } } diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index 74b4add0..9328df8c 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -1250,24 +1250,59 @@ export const SplitPanelLayoutComponent: React.FC setRightData(filteredData); } else { - // 단일키 (하위 호환성) + // 단일키 (하위 호환성) → entityJoinApi 사용으로 전환 (entity 조인 컬럼 지원) const leftColumn = componentConfig.rightPanel?.relation?.leftColumn; const rightColumn = componentConfig.rightPanel?.relation?.foreignKey; if (leftColumn && rightColumn && leftTable) { const leftValue = leftItem[leftColumn]; - const joinedData = await dataApi.getJoinedData( - leftTable, + const { entityJoinApi } = await import("@/lib/api/entityJoin"); + + // 단일키를 복합키 형식으로 변환 + const searchConditions: Record = {}; + searchConditions[rightColumn] = leftValue; + + // Entity 조인 컬럼 추출 + const rightJoinColumnsLegacy = extractAdditionalJoinColumns( + componentConfig.rightPanel?.columns, rightTableName, - leftColumn, - rightColumn, - leftValue, - componentConfig.rightPanel?.dataFilter, // 🆕 데이터 필터 전달 - true, // 🆕 Entity 조인 활성화 - componentConfig.rightPanel?.columns, // 🆕 표시 컬럼 전달 (item_info.item_name 등) - componentConfig.rightPanel?.deduplication, // 🆕 중복 제거 설정 전달 ); - setRightData(joinedData || []); // 모든 관련 레코드 (배열) + if (rightJoinColumnsLegacy) { + console.log("🔗 [분할패널] 단일키 모드 additionalJoinColumns:", rightJoinColumnsLegacy); + } + + const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { + search: searchConditions, + enableEntityJoin: true, + size: 1000, + companyCodeOverride: companyCode, + additionalJoinColumns: rightJoinColumnsLegacy, + }); + + let filteredDataLegacy = result.data || []; + + // 데이터 필터 적용 + const dataFilterLegacy = componentConfig.rightPanel?.dataFilter; + if (dataFilterLegacy?.enabled && dataFilterLegacy.conditions?.length > 0) { + filteredDataLegacy = filteredDataLegacy.filter((item: any) => { + return dataFilterLegacy.conditions.every((cond: any) => { + const value = item[cond.column]; + const condValue = cond.value; + switch (cond.operator) { + case "equals": + return value === condValue; + case "notEquals": + return value !== condValue; + case "contains": + return String(value).includes(String(condValue)); + default: + return true; + } + }); + }); + } + + setRightData(filteredDataLegacy || []); } } } @@ -3802,23 +3837,20 @@ export const SplitPanelLayoutComponent: React.FC if (rightColumns && rightColumns.length > 0) { // 설정된 컬럼만 표시 (엔티티 조인 컬럼 처리) + // 설정된 컬럼은 null/empty여도 항상 표시 (사용자가 명시적으로 설정한 컬럼이므로) const summaryCount = componentConfig.rightPanel?.summaryColumnCount ?? 3; firstValues = rightColumns .slice(0, summaryCount) .map((col) => { - // 🆕 엔티티 조인 컬럼 처리 (getEntityJoinValue 사용) const value = getEntityJoinValue(item, col.name); return [col.name, value, col.label] as [string, any, string]; - }) - .filter(([_, value]) => value !== null && value !== undefined && value !== ""); + }); allValues = rightColumns .map((col) => { - // 🆕 엔티티 조인 컬럼 처리 (getEntityJoinValue 사용) const value = getEntityJoinValue(item, col.name); return [col.name, value, col.label] as [string, any, string]; - }) - .filter(([_, value]) => value !== null && value !== undefined && value !== ""); + }); } else { // 설정 없으면 모든 컬럼 표시 (기존 로직) const summaryCount = componentConfig.rightPanel?.summaryColumnCount ?? 3; @@ -3851,8 +3883,10 @@ export const SplitPanelLayoutComponent: React.FC const format = colConfig?.format; const boldValue = colConfig?.bold ?? false; - // 🆕 포맷 적용 (날짜/숫자/카테고리) - const displayValue = formatCellValue(key, value, rightCategoryMappings, format); + // 🆕 포맷 적용 (날짜/숫자/카테고리) - null/empty는 "-"로 표시 + const displayValue = (value === null || value === undefined || value === "") + ? "-" + : formatCellValue(key, value, rightCategoryMappings, format); const showLabel = componentConfig.rightPanel?.summaryShowLabel ?? true; @@ -3929,8 +3963,10 @@ export const SplitPanelLayoutComponent: React.FC const colConfig = rightColumns?.find((c) => c.name === key); const format = colConfig?.format; - // 🆕 포맷 적용 (날짜/숫자/카테고리) - const displayValue = formatCellValue(key, value, rightCategoryMappings, format); + // 🆕 포맷 적용 (날짜/숫자/카테고리) - null/empty는 "-"로 표시 + const displayValue = (value === null || value === undefined || value === "") + ? "-" + : formatCellValue(key, value, rightCategoryMappings, format); return ( @@ -3993,13 +4029,7 @@ export const SplitPanelLayoutComponent: React.FC return [col.name, value, col.label] as [string, any, string]; }) - .filter(([key, value]) => { - const filtered = value === null || value === undefined || value === ""; - if (filtered) { - console.log(` ❌ 필터링됨: "${key}" (값: ${value})`); - } - return !filtered; - }); +; // 설정된 컬럼은 null/empty여도 항상 표시 console.log(" ✅ 최종 표시할 항목:", displayEntries.length, "개"); } else { @@ -4017,7 +4047,9 @@ export const SplitPanelLayoutComponent: React.FC
{label || getColumnLabel(key)}
-
{String(value)}
+
+ {(value === null || value === undefined || value === "") ? - : String(value)} +
))}
From a8432b83ba3132c561dea7344feb3cf8b082a244 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 10 Feb 2026 12:17:21 +0900 Subject: [PATCH 19/44] Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node From e97fd05e75f1a739392ece5dd7c0e23e9ab27e1c Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 10 Feb 2026 14:01:43 +0900 Subject: [PATCH 20/44] feat: Enhance CardDisplay and SplitPanelLayout components with improved table name handling and custom selection data - Updated CardDisplayComponent to streamline table name retrieval from props or component configuration. - Introduced custom selection data management in SplitPanelLayoutComponent, allowing for better handling of selected items in custom mode. - Enhanced form data handling in SplitPanelLayoutComponent to utilize selected data from the left panel, improving data flow and user experience. --- .../v2-card-display/CardDisplayComponent.tsx | 4 ++-- .../SplitPanelLayoutComponent.tsx | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/frontend/lib/registry/components/v2-card-display/CardDisplayComponent.tsx b/frontend/lib/registry/components/v2-card-display/CardDisplayComponent.tsx index e8afb3b3..ad6c88db 100644 --- a/frontend/lib/registry/components/v2-card-display/CardDisplayComponent.tsx +++ b/frontend/lib/registry/components/v2-card-display/CardDisplayComponent.tsx @@ -233,8 +233,8 @@ export const CardDisplayComponent: React.FC = ({ return; } - // tableName 확인 (props에서 전달받은 tableName 사용) - const tableNameToUse = tableName || component.componentConfig?.tableName || 'user_info'; // 기본 테이블명 설정 + // tableName 확인 (props에서 전달받은 tableName 또는 componentConfig에서 추출) + const tableNameToUse = tableName || component.componentConfig?.tableName; if (!tableNameToUse) { setLoading(false); diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index 9328df8c..5857e88e 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -193,6 +193,7 @@ export const SplitPanelLayoutComponent: React.FC const [rightData, setRightData] = useState(null); // 조인 모드는 배열, 상세 모드는 객체 const [selectedLeftItem, setSelectedLeftItem] = useState(null); const [expandedRightItems, setExpandedRightItems] = useState>(new Set()); // 확장된 우측 아이템 + const [customLeftSelectedData, setCustomLeftSelectedData] = useState>({}); // 커스텀 모드: 좌측 선택 데이터 const [leftSearchQuery, setLeftSearchQuery] = useState(""); const [rightSearchQuery, setRightSearchQuery] = useState(""); const [isLoadingLeft, setIsLoadingLeft] = useState(false); @@ -2747,6 +2748,17 @@ export const SplitPanelLayoutComponent: React.FC component={componentData as any} isDesignMode={false} formData={{}} + tableName={componentConfig.leftPanel?.tableName} + onFormDataChange={(data: any) => { + // 커스텀 모드: 좌측 카드/테이블 선택 시 데이터 캡처 + if (data?.selectedRowsData && data.selectedRowsData.length > 0) { + setCustomLeftSelectedData(data.selectedRowsData[0]); + setSelectedLeftItem(data.selectedRowsData[0]); + } else if (data?.selectedRowsData && data.selectedRowsData.length === 0) { + setCustomLeftSelectedData({}); + setSelectedLeftItem(null); + } + }} />
); @@ -3617,7 +3629,8 @@ export const SplitPanelLayoutComponent: React.FC
); From 403b3da36deb50a102602af681b4945cb7eadd66 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 10 Feb 2026 14:31:48 +0900 Subject: [PATCH 21/44] 123 --- frontend/components/common/ScreenModal.tsx | 85 ++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 3d61df9d..957b6eac 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -686,6 +686,91 @@ export const ScreenModal: React.FC = ({ className }) => { return activeComps; }, [formData, conditionalLayers, screenData?.components]); + // 🆕 이전 활성 레이어 ID 추적 (레이어 전환 감지용) + const prevActiveLayerIdsRef = useRef([]); + + // 🆕 레이어 전환 시 비활성화된 레이어의 필드값을 formData에서 제거 + // (품목우선 → 공급업체우선 전환 시, 품목우선 레이어의 데이터가 남지 않도록) + useEffect(() => { + if (conditionalLayers.length === 0) return; + + // 현재 활성 레이어 ID 목록 + const currentActiveLayerIds = conditionalLayers + .filter((layer) => { + if (!layer.condition) return false; + const { targetComponentId, operator, value } = layer.condition; + if (!targetComponentId) return false; + + const allComponents = screenData?.components || []; + const comp = allComponents.find((c: any) => c.id === targetComponentId); + const fieldKey = + (comp as any)?.overrides?.columnName || + (comp as any)?.columnName || + (comp as any)?.componentConfig?.columnName || + targetComponentId; + + const targetValue = formData[fieldKey]; + switch (operator) { + case "eq": + return String(targetValue ?? "") === String(value ?? ""); + case "neq": + return String(targetValue ?? "") !== String(value ?? ""); + case "in": + if (Array.isArray(value)) { + return value.some((v) => String(v) === String(targetValue ?? "")); + } else if (typeof value === "string" && value.includes(",")) { + return value.split(",").map((v) => v.trim()).includes(String(targetValue ?? "")); + } + return false; + default: + return false; + } + }) + .map((l) => l.id); + + const prevIds = prevActiveLayerIdsRef.current; + + // 이전에 활성이었는데 이번에 비활성이 된 레이어 찾기 + const deactivatedLayerIds = prevIds.filter((id) => !currentActiveLayerIds.includes(id)); + + if (deactivatedLayerIds.length > 0) { + // 비활성화된 레이어의 컴포넌트 필드명 수집 + const fieldsToRemove: string[] = []; + deactivatedLayerIds.forEach((layerId) => { + const layer = conditionalLayers.find((l) => l.id === layerId); + if (!layer) return; + + layer.components.forEach((comp: any) => { + const fieldName = + comp?.overrides?.columnName || + comp?.columnName || + comp?.componentConfig?.columnName; + if (fieldName) { + fieldsToRemove.push(fieldName); + } + }); + }); + + if (fieldsToRemove.length > 0) { + console.log("[ScreenModal] 레이어 전환 감지 - 비활성 레이어 필드 제거:", { + deactivatedLayerIds, + fieldsToRemove, + }); + + setFormData((prev) => { + const cleaned = { ...prev }; + fieldsToRemove.forEach((field) => { + delete cleaned[field]; + }); + return cleaned; + }); + } + } + + // 현재 상태 저장 + prevActiveLayerIdsRef.current = currentActiveLayerIds; + }, [formData, conditionalLayers, screenData?.components]); + // 사용자가 바깥 클릭/ESC/X 버튼으로 닫으려 할 때 // 폼 데이터 변경이 있으면 확인 다이얼로그, 없으면 바로 닫기 const handleCloseAttempt = useCallback(() => { From 86a73267cb0265b5cbbf6625aead5b7fe615e4d9 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 10 Feb 2026 16:23:27 +0900 Subject: [PATCH 22/44] Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node --- .../SplitPanelLayoutComponent.tsx | 32 +++-- .../v2-table-list/TableListConfigPanel.tsx | 115 +++++++++++++++++- .../v2-tabs-widget/tabs-component.tsx | 24 ++-- 3 files changed, 145 insertions(+), 26 deletions(-) diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index 5857e88e..923c1aa3 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -221,6 +221,8 @@ export const SplitPanelLayoutComponent: React.FC const [resizeSize, setResizeSize] = useState<{ width: number; height: number } | null>(null); // 🆕 외부에서 전달받은 선택 상태 사용 (탭 컴포넌트와 동일 구조) const selectedPanelComponentId = externalSelectedPanelComponentId || null; + // 🆕 커스텀 모드: 분할패널 내 탭 컴포넌트의 선택 상태 관리 + const [nestedTabSelectedCompId, setNestedTabSelectedCompId] = useState(undefined); const rafRef = useRef(null); // 🆕 10px 단위 스냅 함수 @@ -300,8 +302,9 @@ export const SplitPanelLayoutComponent: React.FC rafRef.current = requestAnimationFrame(() => { const deltaX = moveEvent.clientX - startMouseX; const deltaY = moveEvent.clientY - startMouseY; - const newX = Math.max(0, startLeft + deltaX); - const newY = Math.max(0, startTop + deltaY); + // 10px 단위 스냅 적용 + const newX = snapTo10(Math.max(0, startLeft + deltaX)); + const newY = snapTo10(Math.max(0, startTop + deltaY)); setDragPosition({ x: newX, y: newY }); }); }; @@ -317,8 +320,9 @@ export const SplitPanelLayoutComponent: React.FC const deltaX = upEvent.clientX - startMouseX; const deltaY = upEvent.clientY - startMouseY; - const newX = Math.max(0, startLeft + deltaX); - const newY = Math.max(0, startTop + deltaY); + // 10px 단위 스냅 적용 + const newX = snapTo10(Math.max(0, startLeft + deltaX)); + const newY = snapTo10(Math.max(0, startTop + deltaY)); setDraggingCompId(null); setDragPosition(null); @@ -328,7 +332,7 @@ export const SplitPanelLayoutComponent: React.FC const panelConfig = componentConfig[panelKey] || {}; const updatedComponents = (panelConfig.components || []).map((c: PanelInlineComponent) => c.id === comp.id - ? { ...c, position: { x: Math.round(newX), y: Math.round(newY) } } + ? { ...c, position: { x: newX, y: newY } } : c ); @@ -348,7 +352,7 @@ export const SplitPanelLayoutComponent: React.FC document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); }, - [component, componentConfig, onUpdateComponent] + [component, componentConfig, onUpdateComponent, snapTo10] ); // 🆕 커스텀 모드: 리사이즈 시작 핸들러 @@ -2601,6 +2605,10 @@ export const SplitPanelLayoutComponent: React.FC }} onClick={(e) => { e.stopPropagation(); + // 패널 컴포넌트 선택 시 탭 내 선택 해제 + if (comp.componentType !== "v2-tabs-widget") { + setNestedTabSelectedCompId(undefined); + } onSelectPanelComponent?.("left", comp.id, comp); }} > @@ -2680,6 +2688,8 @@ export const SplitPanelLayoutComponent: React.FC // 🆕 중첩된 탭 내부 컴포넌트 선택 핸들러 - 부모 분할 패널 정보 포함 onSelectTabComponent={(tabId: string, compId: string, tabComp: any) => { console.log("🔍 [SplitPanel-Left] onSelectTabComponent 호출:", { tabId, compId, tabComp, parentSplitPanelId: component.id }); + // 탭 내 컴포넌트 선택 상태 업데이트 + setNestedTabSelectedCompId(compId); // 부모 분할 패널 정보와 함께 전역 이벤트 발생 const event = new CustomEvent("nested-tab-component-select", { detail: { @@ -2693,7 +2703,7 @@ export const SplitPanelLayoutComponent: React.FC }); window.dispatchEvent(event); }} - selectedTabComponentId={undefined} + selectedTabComponentId={nestedTabSelectedCompId} />
@@ -3494,6 +3504,10 @@ export const SplitPanelLayoutComponent: React.FC }} onClick={(e) => { e.stopPropagation(); + // 패널 컴포넌트 선택 시 탭 내 선택 해제 + if (comp.componentType !== "v2-tabs-widget") { + setNestedTabSelectedCompId(undefined); + } onSelectPanelComponent?.("right", comp.id, comp); }} > @@ -3573,6 +3587,8 @@ export const SplitPanelLayoutComponent: React.FC // 🆕 중첩된 탭 내부 컴포넌트 선택 핸들러 - 부모 분할 패널 정보 포함 onSelectTabComponent={(tabId: string, compId: string, tabComp: any) => { console.log("🔍 [SplitPanel-Right] onSelectTabComponent 호출:", { tabId, compId, tabComp, parentSplitPanelId: component.id }); + // 탭 내 컴포넌트 선택 상태 업데이트 + setNestedTabSelectedCompId(compId); // 부모 분할 패널 정보와 함께 전역 이벤트 발생 const event = new CustomEvent("nested-tab-component-select", { detail: { @@ -3586,7 +3602,7 @@ export const SplitPanelLayoutComponent: React.FC }); window.dispatchEvent(event); }} - selectedTabComponentId={undefined} + selectedTabComponentId={nestedTabSelectedCompId} />
diff --git a/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx b/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx index 8f14a250..becd3c34 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx @@ -186,9 +186,10 @@ export const TableListConfigPanel: React.FC = ({ return; } - // 🆕 customTableName이 설정된 경우 반드시 API에서 가져오기 - // tableColumns prop은 화면의 기본 테이블 컬럼이므로, customTableName 사용 시 무시 - const shouldUseTableColumnsProp = !config.useCustomTable && tableColumns && tableColumns.length > 0; + // tableColumns prop은 화면의 기본 테이블 컬럼이므로, + // 다른 테이블을 선택한 경우 반드시 API에서 가져오기 + const isUsingDifferentTable = config.selectedTable && screenTableName && config.selectedTable !== screenTableName; + const shouldUseTableColumnsProp = !config.useCustomTable && !isUsingDifferentTable && tableColumns && tableColumns.length > 0; if (shouldUseTableColumnsProp) { const mappedColumns = tableColumns.map((column: any) => ({ @@ -772,11 +773,113 @@ export const TableListConfigPanel: React.FC = ({ handleChange("columns", columns); }; + // 테이블 변경 핸들러 - 테이블 변경 시 컬럼 설정 초기화 + const handleTableChange = (newTableName: string) => { + if (newTableName === targetTableName) return; + + const updatedConfig = { + ...config, + selectedTable: newTableName, + // 테이블이 변경되면 컬럼 설정 초기화 + columns: [], + }; + onChange(updatedConfig); + setTableComboboxOpen(false); + }; + return (
테이블 리스트 설정
+ {/* 테이블 선택 */} +
+
+

데이터 소스

+

+ 테이블을 선택하세요. 미선택 시 화면 메인 테이블을 사용합니다. +

+
+
+
+ + + + + + + + + + 테이블을 찾을 수 없습니다. + + {availableTables.map((table) => ( + handleTableChange(table.tableName)} + className="text-xs" + > + +
+ {table.displayName} + {table.displayName !== table.tableName && ( + {table.tableName} + )} +
+
+ ))} +
+
+
+
+
+ {screenTableName && targetTableName !== screenTableName && ( +
+ + 화면 기본 테이블({screenTableName})과 다른 테이블을 사용 중 + + +
+ )} +
+
+ {/* 툴바 버튼 설정 */}
@@ -1167,11 +1270,11 @@ export const TableListConfigPanel: React.FC = ({
)} - {!screenTableName ? ( + {!targetTableName ? (
-

테이블이 연결되지 않았습니다.

-

화면에 테이블을 연결한 후 컬럼을 설정할 수 있습니다.

+

테이블이 선택되지 않았습니다.

+

위 데이터 소스에서 테이블을 선택하세요.

) : availableColumns.length === 0 ? ( diff --git a/frontend/lib/registry/components/v2-tabs-widget/tabs-component.tsx b/frontend/lib/registry/components/v2-tabs-widget/tabs-component.tsx index 8645fed9..0a04f76d 100644 --- a/frontend/lib/registry/components/v2-tabs-widget/tabs-component.tsx +++ b/frontend/lib/registry/components/v2-tabs-widget/tabs-component.tsx @@ -78,6 +78,9 @@ const TabsDesignEditor: React.FC<{ [activeTabId, component, onUpdateComponent, tabs] ); + // 10px 단위 스냅 함수 + const snapTo10 = useCallback((value: number) => Math.round(value / 10) * 10, []); + // 컴포넌트 드래그 시작 const handleDragStart = useCallback( (e: React.MouseEvent, comp: TabInlineComponent) => { @@ -104,9 +107,9 @@ const TabsDesignEditor: React.FC<{ const deltaX = moveEvent.clientX - startMouseX; const deltaY = moveEvent.clientY - startMouseY; - // 새 위치 = 시작 위치 + 이동량 - const newX = Math.max(0, startLeft + deltaX); - const newY = Math.max(0, startTop + deltaY); + // 새 위치 = 시작 위치 + 이동량 (10px 단위 스냅 적용) + const newX = snapTo10(Math.max(0, startLeft + deltaX)); + const newY = snapTo10(Math.max(0, startTop + deltaY)); // React 상태로 위치 업데이트 (리렌더링 트리거) setDragPosition({ x: newX, y: newY }); @@ -126,9 +129,9 @@ const TabsDesignEditor: React.FC<{ const deltaX = upEvent.clientX - startMouseX; const deltaY = upEvent.clientY - startMouseY; - // 새 위치 = 시작 위치 + 이동량 - const newX = Math.max(0, startLeft + deltaX); - const newY = Math.max(0, startTop + deltaY); + // 새 위치 = 시작 위치 + 이동량 (10px 단위 스냅 적용) + const newX = snapTo10(Math.max(0, startLeft + deltaX)); + const newY = snapTo10(Math.max(0, startTop + deltaY)); setDraggingCompId(null); setDragPosition(null); @@ -144,8 +147,8 @@ const TabsDesignEditor: React.FC<{ ? { ...c, position: { - x: Math.max(0, Math.round(newX)), - y: Math.max(0, Math.round(newY)), + x: newX, + y: newY, }, } : c @@ -172,12 +175,9 @@ const TabsDesignEditor: React.FC<{ document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); }, - [activeTabId, component, onUpdateComponent, tabs] + [activeTabId, component, onUpdateComponent, tabs, snapTo10] ); - // 10px 단위 스냅 함수 - const snapTo10 = useCallback((value: number) => Math.round(value / 10) * 10, []); - // 리사이즈 시작 핸들러 const handleResizeStart = useCallback( (e: React.MouseEvent, comp: TabInlineComponent, direction: "e" | "s" | "se") => { From 9785f098d84bcc8f06eeecd804ed5981f7beefda Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 10 Feb 2026 18:30:15 +0900 Subject: [PATCH 23/44] feat: Enhance image handling in table components with improved loading and error states - Introduced a new TableCellImage component for rendering images in table cells, supporting both object IDs and direct URLs. - Implemented loading and error states for images, providing a better user experience when images fail to load. - Updated CardModeRenderer and SingleTableWithSticky components to utilize the new image handling logic, ensuring consistent image rendering across the application. - Enhanced formatCellValue function to return React nodes, allowing for more flexible cell content rendering. --- .../v2-table-list/CardModeRenderer.tsx | 17 ++- .../v2-table-list/SingleTableWithSticky.tsx | 42 +++--- .../v2-table-list/TableListComponent.tsx | 128 ++++++++++++++---- 3 files changed, 140 insertions(+), 47 deletions(-) diff --git a/frontend/lib/registry/components/v2-table-list/CardModeRenderer.tsx b/frontend/lib/registry/components/v2-table-list/CardModeRenderer.tsx index 2676bed6..7623c1b5 100644 --- a/frontend/lib/registry/components/v2-table-list/CardModeRenderer.tsx +++ b/frontend/lib/registry/components/v2-table-list/CardModeRenderer.tsx @@ -6,6 +6,8 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Eye, Edit, Trash2, MoreHorizontal } from "lucide-react"; import { CardDisplayConfig, ColumnConfig } from "./types"; +import { getFullImageUrl } from "@/lib/api/client"; +import { getFilePreviewUrl } from "@/lib/api/file"; interface CardModeRendererProps { data: Record[]; @@ -168,12 +170,25 @@ export const CardModeRenderer: React.FC = ({ {imageValue && (
{ + const strValue = String(imageValue); + const isObjid = /^\d+$/.test(strValue); + return isObjid ? getFilePreviewUrl(strValue) : getFullImageUrl(strValue); + })()} alt={titleValue} className="h-24 w-full rounded-md bg-gray-100 object-cover" onError={(e) => { const target = e.target as HTMLImageElement; + // 이미지 로드 실패 시 폴백 표시 target.style.display = "none"; + const parent = target.parentElement; + if (parent && !parent.querySelector("[data-image-fallback]")) { + const fallback = document.createElement("div"); + fallback.setAttribute("data-image-fallback", "true"); + fallback.className = "flex items-center justify-center h-24 w-full rounded-md bg-muted text-muted-foreground"; + fallback.innerHTML = ``; + parent.appendChild(fallback); + } }} />
diff --git a/frontend/lib/registry/components/v2-table-list/SingleTableWithSticky.tsx b/frontend/lib/registry/components/v2-table-list/SingleTableWithSticky.tsx index e127bdbb..36bec277 100644 --- a/frontend/lib/registry/components/v2-table-list/SingleTableWithSticky.tsx +++ b/frontend/lib/registry/components/v2-table-list/SingleTableWithSticky.tsx @@ -24,7 +24,7 @@ interface SingleTableWithStickyProps { handleRowClick?: (row: any, index: number, e: React.MouseEvent) => void; renderCheckboxCell?: (row: any, index: number) => React.ReactNode; renderCheckboxHeader?: () => React.ReactNode; - formatCellValue: (value: any, format?: string, columnName?: string, rowData?: Record) => string; + formatCellValue: (value: any, format?: string, columnName?: string, rowData?: Record) => React.ReactNode; getColumnWidth: (column: ColumnConfig) => number; containerWidth?: string; // 컨테이너 너비 설정 loading?: boolean; @@ -264,25 +264,34 @@ export const SingleTableWithSticky: React.FC = ({ currentSearchIndex < highlightArray.length && highlightArray[currentSearchIndex] === cellKey; + // formatCellValue 결과 (이미지 등 JSX 반환 가능) + const rawCellValue = + formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0"; + // 이미지 등 JSX 반환 여부 확인 + const isReactElement = typeof rawCellValue === "object" && React.isValidElement(rawCellValue); + // 셀 값에서 검색어 하이라이트 렌더링 const renderCellContent = () => { - const cellValue = - formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0"; - - if (!isHighlighted || !searchTerm || column.columnName === "__checkbox__") { - return cellValue; + // ReactNode(JSX)가 반환된 경우 (이미지 등) 그대로 렌더링 + if (isReactElement) { + return rawCellValue; } - // 검색어 하이라이트 처리 - const lowerValue = String(cellValue).toLowerCase(); + if (!isHighlighted || !searchTerm || column.columnName === "__checkbox__") { + return rawCellValue; + } + + // 검색어 하이라이트 처리 (문자열만) + const strValue = String(rawCellValue); + const lowerValue = strValue.toLowerCase(); const lowerTerm = searchTerm.toLowerCase(); const startIndex = lowerValue.indexOf(lowerTerm); - if (startIndex === -1) return cellValue; + if (startIndex === -1) return rawCellValue; - const before = String(cellValue).slice(0, startIndex); - const match = String(cellValue).slice(startIndex, startIndex + searchTerm.length); - const after = String(cellValue).slice(startIndex + searchTerm.length); + const before = strValue.slice(0, startIndex); + const match = strValue.slice(startIndex, startIndex + searchTerm.length); + const after = strValue.slice(startIndex + searchTerm.length); return ( <> @@ -307,7 +316,9 @@ export const SingleTableWithSticky: React.FC = ({ key={`cell-${column.columnName}`} id={isCurrentSearchResult ? "current-search-result" : undefined} className={cn( - "text-foreground h-10 px-3 py-1.5 align-middle text-xs whitespace-nowrap transition-colors sm:px-4 sm:py-2 sm:text-sm", + "text-foreground h-10 px-3 py-1.5 align-middle text-xs transition-colors sm:px-4 sm:py-2 sm:text-sm", + // 이미지 셀은 overflow/ellipsis 제외 (이미지 잘림 방지) + !isReactElement && "whitespace-nowrap", `text-${column.align}`, // 고정 컬럼 스타일 column.fixed === "left" && @@ -322,9 +333,8 @@ export const SingleTableWithSticky: React.FC = ({ minWidth: "100px", // 최소 너비 보장 maxWidth: "300px", // 최대 너비 제한 boxSizing: "border-box", - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", + // 이미지 셀은 overflow 허용 + ...(isReactElement ? {} : { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }), // sticky 위치 설정 ...(column.fixed === "left" && { left: leftFixedWidth }), ...(column.fixed === "right" && { right: rightFixedWidth }), diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 6e529ab9..59cb47fa 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -12,6 +12,96 @@ import { getFilePreviewUrl } from "@/lib/api/file"; import { Button } from "@/components/ui/button"; import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core"; +// 🖼️ 테이블 셀 이미지 썸네일 컴포넌트 +// objid인 경우 인증된 API로 blob URL 생성, 경로인 경우 직접 URL 사용 +const TableCellImage: React.FC<{ value: string }> = React.memo(({ value }) => { + const [imgSrc, setImgSrc] = React.useState(null); + const [error, setError] = React.useState(false); + const [loading, setLoading] = React.useState(true); + + React.useEffect(() => { + let mounted = true; + const strValue = String(value); + const isObjid = /^\d+$/.test(strValue); + + if (isObjid) { + // objid인 경우: 인증된 API로 blob 다운로드 + const loadImage = async () => { + try { + const { apiClient } = await import("@/lib/api/client"); + const response = await apiClient.get(`/files/preview/${strValue}`, { + responseType: "blob", + }); + if (mounted) { + const blob = new Blob([response.data]); + const url = window.URL.createObjectURL(blob); + setImgSrc(url); + setLoading(false); + } + } catch { + if (mounted) { + setError(true); + setLoading(false); + } + } + }; + loadImage(); + } else { + // 경로인 경우: 직접 URL 사용 + setImgSrc(getFullImageUrl(strValue)); + setLoading(false); + } + + return () => { + mounted = false; + // blob URL 해제 + if (imgSrc && imgSrc.startsWith("blob:")) { + window.URL.revokeObjectURL(imgSrc); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]); + + if (loading) { + return ( +
+
+
+ ); + } + + if (error || !imgSrc) { + return ( +
+
+ +
+
+ ); + } + + return ( +
+ 이미지 { + e.stopPropagation(); + // objid인 경우 preview URL로 열기, 아니면 full URL로 열기 + const strValue = String(value); + const isObjid = /^\d+$/.test(strValue); + const openUrl = isObjid ? getFilePreviewUrl(strValue) : getFullImageUrl(strValue); + window.open(openUrl, "_blank"); + }} + onError={() => setError(true)} + /> +
+ ); +}); +TableCellImage.displayName = "TableCellImage"; + // 🆕 RelatedDataButtons 전역 레지스트리 타입 선언 declare global { interface Window { @@ -4061,35 +4151,9 @@ export const TableListComponent: React.FC = ({ // inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선) const inputType = meta?.inputType || column.inputType; - // 🖼️ 이미지 타입: 작은 썸네일 표시 + // 🖼️ 이미지 타입: 작은 썸네일 표시 (TableCellImage 컴포넌트 사용) if (inputType === "image" && value) { - // value가 objid (숫자 또는 숫자 문자열)인 경우 파일 API URL 사용 - // 🔑 download 대신 preview 사용 (공개 접근 허용) - const strValue = String(value); - const isObjid = /^\d+$/.test(strValue); - // 🔑 상대 경로(/api/...) 대신 전체 URL 사용 (Docker 환경에서 Next.js rewrite 의존 방지) - const imageUrl = isObjid - ? getFilePreviewUrl(strValue) - : getFullImageUrl(strValue); - return ( -
- 이미지 { - e.stopPropagation(); - // 이미지 클릭 시 새 탭에서 크게 보기 - window.open(imageUrl, "_blank"); - }} - onError={(e) => { - // 이미지 로드 실패 시 기본 아이콘 표시 - (e.target as HTMLImageElement).style.display = "none"; - }} - /> -
- ); + return ; } // 📎 첨부파일 타입: 파일 아이콘과 개수 표시 @@ -5945,7 +6009,9 @@ export const TableListComponent: React.FC = ({ = ({ data-row={index} data-col={colIndex} className={cn( - "text-foreground overflow-hidden text-xs font-normal text-ellipsis whitespace-nowrap sm:text-sm", + "text-foreground text-xs font-normal sm:text-sm", + // 이미지 컬럼은 overflow/ellipsis 제외 (이미지 잘림 방지) + inputType !== "image" && "overflow-hidden text-ellipsis whitespace-nowrap", column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-1.5", isFrozen && "sticky z-20 shadow-[2px_0_4px_rgba(0,0,0,0.08)]", // 🆕 포커스된 셀 스타일 From 225fd50ca10dad52974b287d9539bb5ba7b21f07 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 11 Feb 2026 09:39:56 +0900 Subject: [PATCH 24/44] Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node From 308f05ca076758426850660025866e3db5ff205e Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 11 Feb 2026 09:47:59 +0900 Subject: [PATCH 25/44] fix: Correct file upload configuration handling in FileUploadComponent - Updated the file upload configuration to ensure that the safeComponentConfig is properly merged into fileConfig. - This change enhances the reliability of file upload settings by ensuring that default values are applied correctly, improving the overall functionality of the file upload feature. --- .../components/v2-file-upload/FileUploadComponent.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx b/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx index 9af1a58a..42b81edd 100644 --- a/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx +++ b/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx @@ -492,11 +492,11 @@ const FileUploadComponent: React.FC = ({ // 파일 업로드 설정 - componentConfig가 undefined일 수 있으므로 안전하게 처리 const safeComponentConfig = componentConfig || {}; const fileConfig = { + ...safeComponentConfig, accept: safeComponentConfig.accept || "*/*", multiple: safeComponentConfig.multiple || false, maxSize: safeComponentConfig.maxSize || 10 * 1024 * 1024, // 10MB maxFiles: safeComponentConfig.maxFiles || 5, - ...safeComponentConfig, } as FileUploadConfig; // 파일 선택 핸들러 @@ -1117,7 +1117,7 @@ const FileUploadComponent: React.FC = ({ onFileDelete={handleFileDelete} onFileView={handleFileView} onSetRepresentative={handleSetRepresentative} - config={safeComponentConfig} + config={fileConfig} isDesignMode={isDesignMode} />
From ced25c9a545af85e8be8f92597a284da5f881e52 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Wed, 11 Feb 2026 10:46:47 +0900 Subject: [PATCH 26/44] feat: Enhance SplitPanelLayoutComponent with improved data loading and filtering logic - Updated loadRightData function to support loading all data when no leftItem is selected, applying data filters as needed. - Enhanced loadTabData function to handle data loading for tabs, including support for data filters and entity joins. - Improved comments for clarity on data loading behavior based on leftItem selection. - Refactored UI components in SplitPanelLayoutConfigPanel for better styling and organization, including updates to table selection and display settings. --- .../SplitPanelLayoutComponent.tsx | 244 +++++-- .../SplitPanelLayoutConfigPanel.tsx | 620 ++++++++---------- 2 files changed, 461 insertions(+), 403 deletions(-) diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index 923c1aa3..f47da1aa 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -1091,7 +1091,7 @@ export const SplitPanelLayoutComponent: React.FC searchValues, ]); - // 우측 데이터 로드 + // 우측 데이터 로드 (leftItem이 null이면 전체 데이터 로드) const loadRightData = useCallback( async (leftItem: any) => { const relationshipType = componentConfig.rightPanel?.relation?.type || "detail"; @@ -1099,10 +1099,84 @@ export const SplitPanelLayoutComponent: React.FC if (!rightTableName || isDesignMode) return; + // 좌측 미선택 시: 전체 데이터 로드 (dataFilter 적용) + if (!leftItem && relationshipType === "join") { + setIsLoadingRight(true); + try { + const rightJoinColumns = extractAdditionalJoinColumns( + componentConfig.rightPanel?.columns, + rightTableName, + ); + + const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { + enableEntityJoin: true, + size: 1000, + companyCodeOverride: companyCode, + additionalJoinColumns: rightJoinColumns, + dataFilter: componentConfig.rightPanel?.dataFilter, + }); + + // dataFilter 적용 + let filteredData = result.data || []; + const dataFilter = componentConfig.rightPanel?.dataFilter; + if (dataFilter?.enabled && dataFilter.filters?.length > 0) { + filteredData = filteredData.filter((item: any) => { + return dataFilter.filters.every((cond: any) => { + const value = item[cond.columnName]; + switch (cond.operator) { + case "equals": + return value === cond.value; + case "notEquals": + return value !== cond.value; + case "contains": + return String(value || "").includes(String(cond.value)); + case "is_null": + return value === null || value === undefined || value === ""; + case "is_not_null": + return value !== null && value !== undefined && value !== ""; + default: + return true; + } + }); + }); + } + + // conditions 형식 dataFilter도 지원 (하위 호환성) + const dataFilterConditions = componentConfig.rightPanel?.dataFilter; + if (dataFilterConditions?.enabled && dataFilterConditions.conditions?.length > 0) { + filteredData = filteredData.filter((item: any) => { + return dataFilterConditions.conditions.every((cond: any) => { + const value = item[cond.column]; + switch (cond.operator) { + case "equals": + return value === cond.value; + case "notEquals": + return value !== cond.value; + case "contains": + return String(value || "").includes(String(cond.value)); + default: + return true; + } + }); + }); + } + + setRightData(filteredData); + } catch (error) { + console.error("우측 전체 데이터 로드 실패:", error); + } finally { + setIsLoadingRight(false); + } + return; + } + + // leftItem이 null이면 join 모드 이외에는 데이터 로드 불가 + if (!leftItem) return; + setIsLoadingRight(true); try { if (relationshipType === "detail") { - // 상세 모드: 동일 테이블의 상세 정보 (🆕 엔티티 조인 활성화) + // 상세 모드: 동일 테이블의 상세 정보 (엔티티 조인 활성화) const primaryKey = leftItem.id || leftItem.ID || Object.values(leftItem)[0]; // 🆕 엔티티 조인 API 사용 @@ -1331,11 +1405,11 @@ export const SplitPanelLayoutComponent: React.FC ], ); - // 추가 탭 데이터 로딩 함수 + // 추가 탭 데이터 로딩 함수 (leftItem이 null이면 전체 데이터 로드) const loadTabData = useCallback( async (tabIndex: number, leftItem: any) => { const tabConfig = componentConfig.rightPanel?.additionalTabs?.[tabIndex - 1]; - if (!tabConfig || !leftItem || isDesignMode) return; + if (!tabConfig || isDesignMode) return; const tabTableName = tabConfig.tableName; if (!tabTableName) return; @@ -1346,7 +1420,7 @@ export const SplitPanelLayoutComponent: React.FC const leftColumn = tabConfig.relation?.leftColumn || keys?.[0]?.leftColumn; const rightColumn = tabConfig.relation?.foreignKey || keys?.[0]?.rightColumn; - // 🆕 탭 config의 Entity 조인 컬럼 추출 + // 탭 config의 Entity 조인 컬럼 추출 const tabJoinColumns = extractAdditionalJoinColumns(tabConfig.columns, tabTableName); if (tabJoinColumns) { console.log(`🔗 [분할패널] 탭 ${tabIndex} additionalJoinColumns:`, tabJoinColumns); @@ -1354,7 +1428,20 @@ export const SplitPanelLayoutComponent: React.FC let resultData: any[] = []; - if (leftColumn && rightColumn) { + // 탭의 dataFilter (API 전달용) + const tabDataFilterForApi = (tabConfig as any).dataFilter; + + if (!leftItem) { + // 좌측 미선택: 전체 데이터 로드 (dataFilter는 API에 전달) + const result = await entityJoinApi.getTableDataWithJoins(tabTableName, { + enableEntityJoin: true, + size: 1000, + companyCodeOverride: companyCode, + additionalJoinColumns: tabJoinColumns, + dataFilter: tabDataFilterForApi, + }); + resultData = result.data || []; + } else if (leftColumn && rightColumn) { const searchConditions: Record = {}; if (keys && keys.length > 0) { @@ -1380,18 +1467,46 @@ export const SplitPanelLayoutComponent: React.FC search: searchConditions, enableEntityJoin: true, size: 1000, - additionalJoinColumns: tabJoinColumns, // 🆕 Entity 조인 컬럼 전달 + companyCodeOverride: companyCode, + additionalJoinColumns: tabJoinColumns, + dataFilter: tabDataFilterForApi, }); resultData = result.data || []; } else { const result = await entityJoinApi.getTableDataWithJoins(tabTableName, { enableEntityJoin: true, size: 1000, - additionalJoinColumns: tabJoinColumns, // 🆕 Entity 조인 컬럼 전달 + companyCodeOverride: companyCode, + additionalJoinColumns: tabJoinColumns, + dataFilter: tabDataFilterForApi, }); resultData = result.data || []; } + // 탭별 dataFilter 적용 + const tabDataFilter = (tabConfig as any).dataFilter; + if (tabDataFilter?.enabled && tabDataFilter.filters?.length > 0) { + resultData = resultData.filter((item: any) => { + return tabDataFilter.filters.every((cond: any) => { + const value = item[cond.columnName]; + switch (cond.operator) { + case "equals": + return value === cond.value; + case "notEquals": + return value !== cond.value; + case "contains": + return String(value || "").includes(String(cond.value)); + case "is_null": + return value === null || value === undefined || value === ""; + case "is_not_null": + return value !== null && value !== undefined && value !== ""; + default: + return true; + } + }); + }); + } + setTabsData((prev) => ({ ...prev, [tabIndex]: resultData })); } catch (error) { console.error(`추가탭 ${tabIndex} 데이터 로드 실패:`, error); @@ -1407,29 +1522,55 @@ export const SplitPanelLayoutComponent: React.FC [componentConfig.rightPanel?.additionalTabs, isDesignMode, toast], ); - // 탭 변경 핸들러 + // 탭 변경 핸들러 (좌측 미선택 시에도 전체 데이터 로드) const handleTabChange = useCallback( (newTabIndex: number) => { setActiveTabIndex(newTabIndex); - if (selectedLeftItem) { - if (newTabIndex === 0) { - if (!rightData || (Array.isArray(rightData) && rightData.length === 0)) { - loadRightData(selectedLeftItem); - } - } else { - if (!tabsData[newTabIndex]) { - loadTabData(newTabIndex, selectedLeftItem); - } + if (newTabIndex === 0) { + if (!rightData || (Array.isArray(rightData) && rightData.length === 0)) { + loadRightData(selectedLeftItem); + } + } else { + if (!tabsData[newTabIndex]) { + loadTabData(newTabIndex, selectedLeftItem); } } }, [selectedLeftItem, rightData, tabsData, loadRightData, loadTabData], ); - // 좌측 항목 선택 핸들러 + // 좌측 항목 선택 핸들러 (동일 항목 재클릭 시 선택 해제 → 전체 데이터 표시) const handleLeftItemSelect = useCallback( (item: any) => { + // 동일 항목 클릭 시 선택 해제 (전체 보기로 복귀) + const leftPk = componentConfig.rightPanel?.relation?.leftColumn || + componentConfig.rightPanel?.relation?.keys?.[0]?.leftColumn; + const isSameItem = selectedLeftItem && leftPk && + selectedLeftItem[leftPk] === item[leftPk]; + + if (isSameItem) { + // 선택 해제 → 전체 데이터 로드 + setSelectedLeftItem(null); + setExpandedRightItems(new Set()); + setTabsData({}); + if (activeTabIndex === 0) { + loadRightData(null); + } else { + loadTabData(activeTabIndex, null); + } + // 추가 탭들도 전체 데이터 로드 + const tabs = componentConfig.rightPanel?.additionalTabs; + if (tabs && tabs.length > 0) { + tabs.forEach((_: any, idx: number) => { + if (idx + 1 !== activeTabIndex) { + loadTabData(idx + 1, null); + } + }); + } + return; + } + setSelectedLeftItem(item); setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화 setTabsData({}); // 모든 탭 데이터 초기화 @@ -1450,7 +1591,7 @@ export const SplitPanelLayoutComponent: React.FC }); } }, - [loadRightData, loadTabData, activeTabIndex, componentConfig.leftPanel?.tableName, isDesignMode], + [loadRightData, loadTabData, activeTabIndex, componentConfig.leftPanel?.tableName, componentConfig.rightPanel?.relation, componentConfig.rightPanel?.additionalTabs, isDesignMode, selectedLeftItem], ); // 우측 항목 확장/축소 토글 @@ -2026,10 +2167,8 @@ export const SplitPanelLayoutComponent: React.FC if (editModalPanel === "left") { loadLeftData(); // 우측 패널도 새로고침 (FK가 변경되었을 수 있음) - if (selectedLeftItem) { - loadRightData(selectedLeftItem); - } - } else if (editModalPanel === "right" && selectedLeftItem) { + loadRightData(selectedLeftItem); + } else if (editModalPanel === "right") { loadRightData(selectedLeftItem); } } else { @@ -2160,7 +2299,7 @@ export const SplitPanelLayoutComponent: React.FC setSelectedLeftItem(null); setRightData(null); } - } else if (deleteModalPanel === "right" && selectedLeftItem) { + } else if (deleteModalPanel === "right") { loadRightData(selectedLeftItem); } } else { @@ -2317,7 +2456,7 @@ export const SplitPanelLayoutComponent: React.FC if (addModalPanel === "left" || addModalPanel === "left-item") { // 좌측 패널 데이터 새로고침 (일반 추가 또는 하위 항목 추가) loadLeftData(); - } else if (addModalPanel === "right" && selectedLeftItem) { + } else if (addModalPanel === "right") { // 우측 패널 데이터 새로고침 loadRightData(selectedLeftItem); } @@ -2405,10 +2544,22 @@ export const SplitPanelLayoutComponent: React.FC } }, [leftColumnVisibility, componentConfig.leftPanel?.tableName, currentUserId]); - // 초기 데이터 로드 + // 초기 데이터 로드 (좌측 + 우측 전체 데이터) useEffect(() => { if (!isDesignMode && componentConfig.autoLoad !== false) { loadLeftData(); + // 좌측 미선택 상태에서 우측 전체 데이터 기본 로드 + const relationshipType = componentConfig.rightPanel?.relation?.type || "detail"; + if (relationshipType === "join") { + loadRightData(null); + // 추가 탭도 전체 데이터 로드 + const tabs = componentConfig.rightPanel?.additionalTabs; + if (tabs && tabs.length > 0) { + tabs.forEach((_: any, idx: number) => { + loadTabData(idx + 1, null); + }); + } + } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isDesignMode, componentConfig.autoLoad]); @@ -2421,19 +2572,17 @@ export const SplitPanelLayoutComponent: React.FC // eslint-disable-next-line react-hooks/exhaustive-deps }, [leftFilters]); - // 🆕 전역 테이블 새로고침 이벤트 리스너 + // 전역 테이블 새로고침 이벤트 리스너 useEffect(() => { const handleRefreshTable = () => { if (!isDesignMode) { console.log("🔄 [SplitPanel] refreshTable 이벤트 수신 - 데이터 새로고침"); loadLeftData(); - // 선택된 항목이 있으면 현재 활성 탭 데이터 새로고침 - if (selectedLeftItem) { - if (activeTabIndex === 0) { - loadRightData(selectedLeftItem); - } else { - loadTabData(activeTabIndex, selectedLeftItem); - } + // 현재 활성 탭 데이터 새로고침 (좌측 미선택 시에도 전체 데이터 로드) + if (activeTabIndex === 0) { + loadRightData(selectedLeftItem); + } else { + loadTabData(activeTabIndex, selectedLeftItem); } } }; @@ -3339,15 +3488,7 @@ export const SplitPanelLayoutComponent: React.FC ); } - if (!selectedLeftItem) { - return ( -
-

좌측에서 항목을 선택하세요

-
- ); - } - - if (currentTabData.length === 0) { + if (currentTabData.length === 0 && !isTabLoading) { return (

관련 데이터가 없습니다.

@@ -4107,11 +4248,20 @@ export const SplitPanelLayoutComponent: React.FC
) : ( - // 선택 없음 + // 데이터 없음 또는 초기 로딩 대기
-

좌측에서 항목을 선택하세요

-

선택한 항목의 상세 정보가 여기에 표시됩니다

+ {componentConfig.rightPanel?.relation?.type === "join" ? ( + <> + +

데이터를 불러오는 중...

+ + ) : ( + <> +

좌측에서 항목을 선택하세요

+

선택한 항목의 상세 정보가 여기에 표시됩니다

+ + )}
)} diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx index b2fff2cd..25a57448 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx @@ -328,7 +328,7 @@ const AdditionalTabConfigPanel: React.FC = ({
@@ -341,11 +341,11 @@ const AdditionalTabConfigPanel: React.FC = ({ )}
- -
+ + {/* ===== 1. 기본 정보 ===== */} -
- +
+

기본 정보

@@ -366,123 +366,120 @@ const AdditionalTabConfigPanel: React.FC = ({ />
-
- - updateTab({ panelHeaderHeight: parseInt(e.target.value) || 48 })} - placeholder="48" - className="h-8 w-24 text-xs" - /> -
{/* ===== 2. 테이블 선택 ===== */} -
- -
- - - - - - - - - 테이블을 찾을 수 없습니다. - - {availableRightTables.map((table) => ( - updateTab({ tableName: table.tableName, columns: [] })} - > - - {table.displayName || table.tableName} - - ))} - - - - -
+
+

테이블 설정

+ + + + + + + + 테이블을 찾을 수 없습니다. + + {availableRightTables.map((table) => ( + updateTab({ tableName: table.tableName, columns: [] })} + > + + {table.displayName || table.tableName} + {table.displayName && ({table.tableName})} + + ))} + + + +
- {/* ===== 3. 표시 모드 ===== */} -
- -
+ {/* ===== 3. 표시 모드 + 요약 설정 ===== */} +
+

표시 설정

+
{/* 요약 설정 (목록 모드) */} - {tab.displayMode === "list" && ( -
-
- + {(tab.displayMode || "list") === "list" && ( +
+ +
+ updateTab({ summaryColumnCount: parseInt(e.target.value) || 3 })} - min={1} - max={10} - className="h-8 text-xs" + className="bg-white" /> +

접기 전에 표시할 컬럼 개수 (기본: 3개)

-
+
+
+ +

컬럼명 표시 여부

+
updateTab({ summaryShowLabel: !!checked })} + onCheckedChange={(checked) => updateTab({ summaryShowLabel: checked as boolean })} /> -
)}
{/* ===== 4. 컬럼 매핑 (연결 키) ===== */} -
- -

- 좌측 패널 선택 시 관련 데이터만 표시합니다 -

-
+
+

컬럼 매핑 (연결 키)

+

좌측 패널 선택 시 관련 데이터만 표시합니다

+
@@ -515,10 +508,7 @@ const AdditionalTabConfigPanel: React.FC = ({ value={tab.relation?.keys?.[0]?.rightColumn || tab.relation?.foreignKey || "__none__"} onValueChange={(value) => { if (value === "__none__") { - // 선택 안 함 - 조인 키 제거 - updateTab({ - relation: undefined, - }); + updateTab({ relation: undefined }); } else { updateTab({ relation: { @@ -530,17 +520,13 @@ const AdditionalTabConfigPanel: React.FC = ({ } }} > - + - - 선택 안 함 (전체 데이터) - + 선택 안 함 (전체 데이터) {tabColumns.map((col) => ( - - {col.columnLabel || col.columnName} - + {col.columnLabel || col.columnName} ))} @@ -549,215 +535,202 @@ const AdditionalTabConfigPanel: React.FC = ({
{/* ===== 5. 기능 버튼 ===== */} -
- +
+

기능 버튼

- updateTab({ showSearch: !!checked })} - /> + updateTab({ showSearch: !!checked })} />
- updateTab({ showAdd: !!checked })} - /> + updateTab({ showAdd: !!checked })} />
- updateTab({ showEdit: !!checked })} - /> + updateTab({ showEdit: !!checked })} />
- updateTab({ showDelete: !!checked })} - /> + updateTab({ showDelete: !!checked })} />
- {/* ===== 6. 표시 컬럼 설정 ===== */} -
-
- - -
-

- 표시할 컬럼을 선택하세요. 선택하지 않으면 모든 컬럼이 표시됩니다. -

+ {/* ===== 6. 표시할 컬럼 - DnD + Entity 조인 통합 ===== */} + {(() => { + const selectedColumns = tab.columns || []; + const filteredTabCols = tabColumns.filter((c) => !["company_code", "company_name"].includes(c.columnName)); + const unselectedCols = filteredTabCols.filter((c) => !selectedColumns.some((sc) => sc.name === c.columnName)); + const dbNumericTypes = ["numeric", "decimal", "integer", "bigint", "double precision", "real", "smallint", "int4", "int8", "float4", "float8"]; + const inputNumericTypes = ["number", "decimal", "currency", "integer"]; - {/* 테이블 미선택 상태 */} - {!tab.tableName && ( -
-

먼저 테이블을 선택하세요

-
- )} + const handleTabDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (over && active.id !== over.id) { + const oldIndex = selectedColumns.findIndex((c) => c.name === active.id); + const newIndex = selectedColumns.findIndex((c) => c.name === over.id); + if (oldIndex !== -1 && newIndex !== -1) { + updateTab({ columns: arrayMove([...selectedColumns], oldIndex, newIndex) }); + } + } + }; - {/* 테이블 선택됨 - 컬럼 목록 */} - {tab.tableName && ( -
- {/* 로딩 상태 */} - {loadingTabColumns && ( -
-

컬럼을 불러오는 중...

-
- )} + return ( +
+

표시할 컬럼 ({selectedColumns.length}개 선택)

+
+ {!tab.tableName ? ( +

테이블을 선택해주세요

+ ) : loadingTabColumns ? ( +

컬럼을 불러오는 중...

+ ) : ( + <> + {selectedColumns.length > 0 && ( + + c.name)} strategy={verticalListSortingStrategy}> +
+ {selectedColumns.map((col, index) => { + const colInfo = tabColumns.find((c) => c.columnName === col.name); + const isNumeric = colInfo && ( + dbNumericTypes.includes(colInfo.dataType?.toLowerCase() || "") || + inputNumericTypes.includes(colInfo.input_type?.toLowerCase() || "") || + inputNumericTypes.includes(colInfo.webType?.toLowerCase() || "") + ); + return ( + { + const newColumns = [...selectedColumns]; + newColumns[index] = { ...newColumns[index], label: value }; + updateTab({ columns: newColumns }); + }} + onWidthChange={(value) => { + const newColumns = [...selectedColumns]; + newColumns[index] = { ...newColumns[index], width: value }; + updateTab({ columns: newColumns }); + }} + onFormatChange={(checked) => { + const newColumns = [...selectedColumns]; + newColumns[index] = { ...newColumns[index], format: { ...newColumns[index].format, type: "number", thousandSeparator: checked } }; + updateTab({ columns: newColumns }); + }} + onRemove={() => updateTab({ columns: selectedColumns.filter((_, i) => i !== index) })} + /> + ); + })} +
+
+
+ )} - {/* 설정된 컬럼이 없을 때 */} - {!loadingTabColumns && (tab.columns || []).length === 0 && ( -
-

설정된 컬럼이 없습니다

-

컬럼을 추가하지 않으면 모든 컬럼이 표시됩니다

-
- )} + {selectedColumns.length > 0 && unselectedCols.length > 0 && ( +
+ 미선택 컬럼 +
+ )} - {/* 설정된 컬럼 목록 */} - {!loadingTabColumns && (tab.columns || []).length > 0 && ( - (tab.columns || []).map((col, colIndex) => ( -
- {/* 상단: 순서 변경 + 삭제 버튼 */} -
-
- - - #{colIndex + 1} -
- + + {column.columnLabel || column.columnName} +
+ ))}
- {/* 컬럼 선택 */} -
- - -
+ {/* Entity 조인 컬럼 - 아코디언 (접기/펼치기) */} + {(() => { + const joinData = tab.tableName ? entityJoinColumnsMap?.[tab.tableName] : null; + if (!joinData || joinData.joinTables.length === 0) return null; - {/* 라벨 + 너비 */} -
-
- - { - const newColumns = [...(tab.columns || [])]; - newColumns[colIndex] = { ...col, label: e.target.value }; - updateTab({ columns: newColumns }); - }} - placeholder="표시 라벨" - className="h-8 text-xs" - /> -
-
- - { - const newColumns = [...(tab.columns || [])]; - newColumns[colIndex] = { ...col, width: parseInt(e.target.value) || 100 }; - updateTab({ columns: newColumns }); - }} - placeholder="100" - className="h-8 text-xs" - /> -
-
-
- )) - )} + return joinData.joinTables.map((joinTable, tableIndex) => { + const joinColumnsToShow = joinTable.availableColumns.filter((column) => { + const matchingJoinColumn = joinData.availableColumns.find( + (jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName, + ); + if (!matchingJoinColumn) return false; + return !selectedColumns.some((c) => c.name === matchingJoinColumn.joinAlias); + }); + const addedCount = joinTable.availableColumns.length - joinColumnsToShow.length; + if (joinColumnsToShow.length === 0 && addedCount === 0) return null; + + return ( +
+ + + + {joinTable.tableName} + {addedCount > 0 && ( + {addedCount}개 선택 + )} + {joinColumnsToShow.length}개 남음 + +
+ {joinColumnsToShow.map((column, colIndex) => { + const matchingJoinColumn = joinData.availableColumns.find( + (jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName, + ); + if (!matchingJoinColumn) return null; + + return ( +
{ + updateTab({ + columns: [...selectedColumns, { + name: matchingJoinColumn.joinAlias, + label: matchingJoinColumn.suggestedLabel || matchingJoinColumn.columnLabel, + width: 100, + isEntityJoin: true, + joinInfo: { + sourceTable: tab.tableName!, + sourceColumn: (joinTable as any).joinConfig?.sourceColumn || "", + referenceTable: matchingJoinColumn.tableName, + joinAlias: matchingJoinColumn.joinAlias, + }, + }], + }); + }} + > + + + {column.columnLabel || column.columnName} +
+ ); + })} + {joinColumnsToShow.length === 0 && ( +

모든 컬럼이 이미 추가되었습니다

+ )} +
+
+ ); + }); + })()} + + )} +
- )} -
+ ); + })()} {/* ===== 7. 추가 모달 컬럼 설정 (showAdd일 때) ===== */} {tab.showAdd && ( -
+
- +

추가 모달 컬럼 설정

)} - {/* ===== 7.5 Entity 조인 컬럼 ===== */} - {(() => { - const joinData = tab.tableName ? entityJoinColumnsMap?.[tab.tableName] : null; - if (!joinData || joinData.joinTables.length === 0) return null; - - return ( -
- -

연관 테이블의 컬럼을 추가합니다

- {joinData.joinTables.map((joinTable, tableIndex) => ( -
-
- - {joinTable.tableName} - {joinTable.currentDisplayColumn} -
-
- {joinTable.availableColumns.map((column, colIndex) => { - const matchingJoinColumn = joinData.availableColumns.find( - (jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName, - ); - if (!matchingJoinColumn) return null; - const tabColumns2 = tab.columns || []; - const isAdded = tabColumns2.some((c) => c.name === matchingJoinColumn.joinAlias); - - return ( -
{ - if (isAdded) { - updateTab({ columns: tabColumns2.filter((c) => c.name !== matchingJoinColumn.joinAlias) }); - } else { - updateTab({ - columns: [...tabColumns2, { - name: matchingJoinColumn.joinAlias, - label: matchingJoinColumn.suggestedLabel || matchingJoinColumn.columnLabel, - width: 100, - isEntityJoin: true, - joinInfo: { - sourceTable: tab.tableName!, - sourceColumn: (joinTable as any).joinConfig?.sourceColumn || "", - referenceTable: matchingJoinColumn.tableName, - joinAlias: matchingJoinColumn.joinAlias, - }, - }], - }); - } - }} - > - - - {column.columnLabel} - {column.dataType} -
- ); - })} -
-
- ))} -
- ); - })()} + {/* Entity 조인 컬럼은 표시 컬럼 목록에 통합됨 */} {/* ===== 8. 데이터 필터링 ===== */} -
- +
+

데이터 필터링

= ({
{/* ===== 9. 중복 데이터 제거 ===== */} -
+
- +

중복 데이터 제거

{ @@ -1019,8 +927,8 @@ const AdditionalTabConfigPanel: React.FC = ({ {/* ===== 10. 수정 버튼 설정 ===== */} {tab.showEdit && ( -
- +
+

수정 버튼 설정

@@ -1125,8 +1033,8 @@ const AdditionalTabConfigPanel: React.FC = ({ {/* ===== 11. 삭제 버튼 설정 ===== */} {tab.showDelete && ( -
- +
+

삭제 버튼 설정

@@ -1196,7 +1104,7 @@ const AdditionalTabConfigPanel: React.FC = ({ 탭 삭제
-
+ ); From eac2fa63b16df05c9862b3b98ff05df35005b8ea Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 11 Feb 2026 14:45:23 +0900 Subject: [PATCH 27/44] feat: Enhance input and select components with custom styling support - Added support for custom border, background, and text styles in V2Input and V2Select components, allowing for greater flexibility in styling based on user-defined configurations. - Updated the input and select components to conditionally apply styles based on the presence of custom properties, improving the overall user experience and visual consistency. - Refactored the FileUploadComponent to handle chunked file uploads, enhancing the file upload process by allowing multiple files to be uploaded in batches, improving performance and user feedback during uploads. --- frontend/components/ui/input.tsx | 2 +- frontend/components/v2/V2Input.tsx | 28 +- frontend/components/v2/V2Select.tsx | 26 +- .../ButtonPrimaryComponent.tsx | 16 +- .../v2-file-upload/FileUploadComponent.tsx | 336 ++++++++++-------- .../v2-text-display/TextDisplayComponent.tsx | 17 +- 6 files changed, 263 insertions(+), 162 deletions(-) diff --git a/frontend/components/ui/input.tsx b/frontend/components/ui/input.tsx index fcfd16cf..f6c5e4ea 100644 --- a/frontend/components/ui/input.tsx +++ b/frontend/components/ui/input.tsx @@ -55,7 +55,7 @@ const Input = React.forwardRef( type={type} data-slot="input" className={cn( - "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-10 w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50", + "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-10 w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-sm transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", className, diff --git a/frontend/components/v2/V2Input.tsx b/frontend/components/v2/V2Input.tsx index d8457adb..17183050 100644 --- a/frontend/components/v2/V2Input.tsx +++ b/frontend/components/v2/V2Input.tsx @@ -947,6 +947,21 @@ export const V2Input = forwardRef((props, ref) => const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4; const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2; // 라벨 높이 + 여백 + // 🔧 커스텀 스타일 감지 (StyleEditor에서 설정한 테두리/배경/텍스트 스타일) + // RealtimePreview 래퍼가 외부 div에 스타일을 적용하지만, + // 내부 input/textarea가 자체 Tailwind 테두리를 가지므로 이를 제거하여 외부 스타일이 보이도록 함 + const hasCustomBorder = !!(style?.borderWidth || style?.borderColor || style?.borderStyle || style?.border); + const hasCustomBackground = !!style?.backgroundColor; + const hasCustomRadius = !!style?.borderRadius; + + // 텍스트 스타일 오버라이드 (CSS 상속으로 내부 input에 전달) + const customTextStyle: React.CSSProperties = {}; + if (style?.color) customTextStyle.color = style.color; + if (style?.fontSize) customTextStyle.fontSize = style.fontSize; + if (style?.fontWeight) customTextStyle.fontWeight = style.fontWeight; + if (style?.textAlign) customTextStyle.textAlign = style.textAlign as React.CSSProperties["textAlign"]; + const hasCustomText = Object.keys(customTextStyle).length > 0; + return (
((props, ref) => {required && *} )} -
+
{renderInput()}
diff --git a/frontend/components/v2/V2Select.tsx b/frontend/components/v2/V2Select.tsx index c4bd0925..c7ea8c94 100644 --- a/frontend/components/v2/V2Select.tsx +++ b/frontend/components/v2/V2Select.tsx @@ -947,6 +947,19 @@ export const V2Select = forwardRef( const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4; const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2; + // 🔧 커스텀 스타일 감지 (StyleEditor에서 설정한 테두리/배경/텍스트 스타일) + const hasCustomBorder = !!(style?.borderWidth || style?.borderColor || style?.borderStyle || style?.border); + const hasCustomBackground = !!style?.backgroundColor; + const hasCustomRadius = !!style?.borderRadius; + + // 텍스트 스타일 오버라이드 (CSS 상속) + const customTextStyle: React.CSSProperties = {}; + if (style?.color) customTextStyle.color = style.color; + if (style?.fontSize) customTextStyle.fontSize = style.fontSize; + if (style?.fontWeight) customTextStyle.fontWeight = style.fontWeight; + if (style?.textAlign) customTextStyle.textAlign = style.textAlign as React.CSSProperties["textAlign"]; + const hasCustomText = Object.keys(customTextStyle).length > 0; + return (
( {required && *} )} -
+
{renderSelect()}
diff --git a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx index f8b154d6..5516a4bf 100644 --- a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx @@ -1283,13 +1283,17 @@ export const ButtonPrimaryComponent: React.FC = ({ width: buttonWidth, height: buttonHeight, minHeight: "32px", // 🔧 최소 높이를 32px로 줄임 - border: "none", - borderRadius: "0.5rem", + // 🔧 커스텀 테두리 스타일 (StyleEditor에서 설정한 값 우선) + border: style?.border || (style?.borderWidth ? undefined : "none"), + borderWidth: style?.borderWidth || undefined, + borderStyle: (style?.borderStyle as React.CSSProperties["borderStyle"]) || undefined, + borderColor: style?.borderColor || undefined, + borderRadius: style?.borderRadius || "0.5rem", backgroundColor: finalDisabled ? "#e5e7eb" : buttonColor, - color: finalDisabled ? "#9ca3af" : buttonTextColor, // 🔧 webTypeConfig.textColor 지원 - // 🔧 크기 설정 적용 (sm/md/lg) - fontSize: componentConfig.size === "sm" ? "0.75rem" : componentConfig.size === "lg" ? "1rem" : "0.875rem", - fontWeight: "600", + color: finalDisabled ? "#9ca3af" : (style?.color || buttonTextColor), // 🔧 StyleEditor 텍스트 색상도 지원 + // 🔧 크기 설정 적용 (sm/md/lg), StyleEditor fontSize 우선 + fontSize: style?.fontSize || (componentConfig.size === "sm" ? "0.75rem" : componentConfig.size === "lg" ? "1rem" : "0.875rem"), + fontWeight: style?.fontWeight || "600", cursor: finalDisabled ? "not-allowed" : "pointer", outline: "none", boxSizing: "border-box", diff --git a/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx b/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx index 42b81edd..fc39458a 100644 --- a/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx +++ b/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx @@ -5,7 +5,7 @@ import { Badge } from "@/components/ui/badge"; import { toast } from "sonner"; import { uploadFiles, downloadFile, deleteFile, getComponentFiles, getFileInfoByObjid, getFilePreviewUrl } from "@/lib/api/file"; import { GlobalFileManager } from "@/lib/api/globalFile"; -import { formatFileSize } from "@/lib/utils"; +import { formatFileSize, cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; import { FileViewerModal } from "./FileViewerModal"; import { FileManagerModal } from "./FileManagerModal"; @@ -513,7 +513,10 @@ const FileUploadComponent: React.FC = ({ } }, []); - // 파일 업로드 처리 + // 백엔드 multer 제한에 맞춘 1회 요청당 최대 파일 수 + const CHUNK_SIZE = 10; + + // 파일 업로드 처리 (10개 초과 시 자동 분할 업로드) const handleFileUpload = useCallback( async (files: File[]) => { if (!files.length) return; @@ -548,7 +551,17 @@ const FileUploadComponent: React.FC = ({ const filesToUpload = uniqueFiles.length > 0 ? uniqueFiles : files; setUploadStatus("uploading"); - toast.loading("파일을 업로드하는 중...", { id: "file-upload" }); + + // 분할 업로드 여부 판단 + const totalFiles = filesToUpload.length; + const totalChunks = Math.ceil(totalFiles / CHUNK_SIZE); + const isChunked = totalChunks > 1; + + if (isChunked) { + toast.loading(`파일 업로드 준비 중... (총 ${totalFiles}개, ${totalChunks}회 분할)`, { id: "file-upload" }); + } else { + toast.loading("파일을 업로드하는 중...", { id: "file-upload" }); + } try { // 🔑 레코드 모드 우선 사용 @@ -585,13 +598,11 @@ const FileUploadComponent: React.FC = ({ const userCompanyCode = user?.companyCode || (window as any).__user__?.companyCode; // 🔑 레코드 모드일 때는 effectiveTableName을 우선 사용 - // formData.linkedTable이 'screen_files' 같은 기본값일 수 있으므로 레코드 모드에서는 무시 const finalLinkedTable = effectiveIsRecordMode ? effectiveTableName : (formData?.linkedTable || effectiveTableName); const uploadData = { - // 🎯 formData에서 백엔드 API 설정 가져오기 autoLink: formData?.autoLink || true, linkedTable: finalLinkedTable, recordId: effectiveRecordId || `temp_${component.id}`, @@ -599,143 +610,163 @@ const FileUploadComponent: React.FC = ({ isVirtualFileColumn: formData?.isVirtualFileColumn || true, docType: component.fileConfig?.docType || "DOCUMENT", docTypeName: component.fileConfig?.docTypeName || "일반 문서", - companyCode: userCompanyCode, // 🔒 멀티테넌시: 회사 코드 명시적 전달 - // 호환성을 위한 기존 필드들 + companyCode: userCompanyCode, tableName: effectiveTableName, fieldName: effectiveColumnName, - targetObjid: targetObjid, // InteractiveDataTable 호환을 위한 targetObjid 추가 - // 🆕 레코드 모드 플래그 + targetObjid: targetObjid, isRecordMode: effectiveIsRecordMode, }; - - const response = await uploadFiles({ - files: filesToUpload, - ...uploadData, - }); - if (response.success) { - // FileUploadResponse 타입에 맞게 files 배열 사용 - const fileData = response.files || (response as any).data || []; + // 🔄 파일을 CHUNK_SIZE(10개)씩 나눠서 순차 업로드 + const allNewFiles: any[] = []; + let failedChunks = 0; - if (fileData.length === 0) { - throw new Error("업로드된 파일 데이터를 받지 못했습니다."); + for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { + const start = chunkIndex * CHUNK_SIZE; + const end = Math.min(start + CHUNK_SIZE, totalFiles); + const chunk = filesToUpload.slice(start, end); + + // 분할 업로드 시 진행 상태 토스트 업데이트 + if (isChunked) { + toast.loading( + `업로드 중... ${chunkIndex + 1}/${totalChunks} 배치 (${start + 1}~${end}번째 파일)`, + { id: "file-upload" } + ); } - const newFiles = fileData.map((file: any) => ({ - objid: file.objid || file.id, - savedFileName: file.saved_file_name || file.savedFileName, - realFileName: file.real_file_name || file.realFileName || file.name, - fileSize: file.file_size || file.fileSize || file.size, - fileExt: file.file_ext || file.fileExt || file.extension, - filePath: file.file_path || file.filePath || file.path, - docType: file.doc_type || file.docType, - docTypeName: file.doc_type_name || file.docTypeName, - targetObjid: file.target_objid || file.targetObjid, - parentTargetObjid: file.parent_target_objid || file.parentTargetObjid, - companyCode: file.company_code || file.companyCode, - writer: file.writer, - regdate: file.regdate, - status: file.status || "ACTIVE", - uploadedAt: new Date().toISOString(), - ...file, - })); - - - const updatedFiles = [...uploadedFiles, ...newFiles]; - - setUploadedFiles(updatedFiles); - setUploadStatus("success"); - - // localStorage 백업 (레코드별 고유 키 사용) try { - const backupKey = getUniqueKey(); - localStorage.setItem(backupKey, JSON.stringify(updatedFiles)); - } catch (e) { - console.warn("localStorage 백업 실패:", e); + const response = await uploadFiles({ + files: chunk, + ...uploadData, + }); + + if (response.success) { + const fileData = response.files || (response as any).data || []; + const chunkFiles = fileData.map((file: any) => ({ + objid: file.objid || file.id, + savedFileName: file.saved_file_name || file.savedFileName, + realFileName: file.real_file_name || file.realFileName || file.name, + fileSize: file.file_size || file.fileSize || file.size, + fileExt: file.file_ext || file.fileExt || file.extension, + filePath: file.file_path || file.filePath || file.path, + docType: file.doc_type || file.docType, + docTypeName: file.doc_type_name || file.docTypeName, + targetObjid: file.target_objid || file.targetObjid, + parentTargetObjid: file.parent_target_objid || file.parentTargetObjid, + companyCode: file.company_code || file.companyCode, + writer: file.writer, + regdate: file.regdate, + status: file.status || "ACTIVE", + uploadedAt: new Date().toISOString(), + ...file, + })); + allNewFiles.push(...chunkFiles); + } else { + console.error(`❌ ${chunkIndex + 1}번째 배치 업로드 실패:`, response); + failedChunks++; + } + } catch (chunkError) { + console.error(`❌ ${chunkIndex + 1}번째 배치 업로드 오류:`, chunkError); + failedChunks++; } + } - // 전역 상태 업데이트 (모든 파일 컴포넌트 동기화) - if (typeof window !== "undefined") { - // 전역 파일 상태 업데이트 (레코드별 고유 키 사용) - const globalFileState = (window as any).globalFileState || {}; - const uniqueKey = getUniqueKey(); - globalFileState[uniqueKey] = updatedFiles; - (window as any).globalFileState = globalFileState; + // 모든 배치 처리 완료 후 결과 처리 + if (allNewFiles.length === 0) { + throw new Error("업로드된 파일 데이터를 받지 못했습니다."); + } - // 🌐 전역 파일 저장소에 새 파일 등록 (페이지 간 공유용) - GlobalFileManager.registerFiles(newFiles, { - uploadPage: window.location.pathname, + const updatedFiles = [...uploadedFiles, ...allNewFiles]; + + setUploadedFiles(updatedFiles); + setUploadStatus("success"); + + // localStorage 백업 (레코드별 고유 키 사용) + try { + const backupKey = getUniqueKey(); + localStorage.setItem(backupKey, JSON.stringify(updatedFiles)); + } catch (e) { + console.warn("localStorage 백업 실패:", e); + } + + // 전역 상태 업데이트 (모든 파일 컴포넌트 동기화) + if (typeof window !== "undefined") { + const globalFileState = (window as any).globalFileState || {}; + const uniqueKey = getUniqueKey(); + globalFileState[uniqueKey] = updatedFiles; + (window as any).globalFileState = globalFileState; + + GlobalFileManager.registerFiles(allNewFiles, { + uploadPage: window.location.pathname, + componentId: component.id, + screenId: formData?.screenId, + recordId: recordId, + }); + + const syncEvent = new CustomEvent("globalFileStateChanged", { + detail: { componentId: component.id, - screenId: formData?.screenId, - recordId: recordId, // 🆕 레코드 ID 추가 - }); + eventColumnName: columnName, + uniqueKey: uniqueKey, + recordId: recordId, + files: updatedFiles, + fileCount: updatedFiles.length, + timestamp: Date.now(), + }, + }); + window.dispatchEvent(syncEvent); + } - // 모든 파일 컴포넌트에 동기화 이벤트 발생 - // 🆕 columnName 추가하여 같은 화면의 다른 파일 업로드 컴포넌트와 구분 - const syncEvent = new CustomEvent("globalFileStateChanged", { - detail: { - componentId: component.id, - eventColumnName: columnName, // 🆕 컬럼명 추가 - uniqueKey: uniqueKey, // 🆕 고유 키 추가 - recordId: recordId, // 🆕 레코드 ID 추가 - files: updatedFiles, - fileCount: updatedFiles.length, - timestamp: Date.now(), - }, - }); - window.dispatchEvent(syncEvent); - } - - // 컴포넌트 업데이트 - if (onUpdate) { - const timestamp = Date.now(); - onUpdate({ - uploadedFiles: updatedFiles, - lastFileUpdate: timestamp, - }); - } else { - console.warn("⚠️ onUpdate 콜백이 없습니다!"); - } - - // 🆕 이미지/파일 컬럼에 objid 저장 (formData 업데이트) - if (onFormDataChange && effectiveColumnName) { - // 🎯 이미지/파일 타입 컬럼: 첫 번째 파일의 objid를 저장 (그리드에서 표시용) - // 단일 파일인 경우 단일 값, 복수 파일인 경우 콤마 구분 문자열 - const fileObjids = updatedFiles.map(file => file.objid); - const columnValue = fileConfig.multiple - ? fileObjids.join(',') // 복수 파일: 콤마 구분 - : (fileObjids[0] || ''); // 단일 파일: 첫 번째 파일 ID - - // onFormDataChange를 (fieldName, value) 형태로 호출 (SaveModal 호환) - onFormDataChange(effectiveColumnName, columnValue); - } - - // 그리드 파일 상태 새로고침 이벤트 발생 - if (typeof window !== "undefined") { - const refreshEvent = new CustomEvent("refreshFileStatus", { - detail: { - tableName: effectiveTableName, - recordId: effectiveRecordId, - columnName: effectiveColumnName, - targetObjid: targetObjid, - fileCount: updatedFiles.length, - }, - }); - window.dispatchEvent(refreshEvent); - } - - // 컴포넌트 설정 콜백 - if (safeComponentConfig.onFileUpload) { - safeComponentConfig.onFileUpload(newFiles); - } - - // 성공 시 토스트 처리 - setUploadStatus("idle"); - toast.dismiss("file-upload"); - toast.success(`${newFiles.length}개 파일 업로드 완료`); + // 컴포넌트 업데이트 + if (onUpdate) { + const timestamp = Date.now(); + onUpdate({ + uploadedFiles: updatedFiles, + lastFileUpdate: timestamp, + }); } else { - console.error("❌ 파일 업로드 실패:", response); - throw new Error(response.message || (response as any).error || "파일 업로드에 실패했습니다."); + console.warn("⚠️ onUpdate 콜백이 없습니다!"); + } + + // 이미지/파일 컬럼에 objid 저장 (formData 업데이트) + if (onFormDataChange && effectiveColumnName) { + const fileObjids = updatedFiles.map(file => file.objid); + const columnValue = fileConfig.multiple + ? fileObjids.join(',') + : (fileObjids[0] || ''); + onFormDataChange(effectiveColumnName, columnValue); + } + + // 그리드 파일 상태 새로고침 이벤트 발생 + if (typeof window !== "undefined") { + const refreshEvent = new CustomEvent("refreshFileStatus", { + detail: { + tableName: effectiveTableName, + recordId: effectiveRecordId, + columnName: effectiveColumnName, + targetObjid: targetObjid, + fileCount: updatedFiles.length, + }, + }); + window.dispatchEvent(refreshEvent); + } + + // 컴포넌트 설정 콜백 + if (safeComponentConfig.onFileUpload) { + safeComponentConfig.onFileUpload(allNewFiles); + } + + // 성공/부분 성공 토스트 처리 + setUploadStatus("idle"); + toast.dismiss("file-upload"); + + if (failedChunks > 0) { + toast.warning( + `${allNewFiles.length}개 업로드 완료, 일부 파일 실패`, + { description: "일부 파일이 업로드되지 않았습니다. 다시 시도해주세요." } + ); + } else { + toast.success(`${allNewFiles.length}개 파일 업로드 완료`); } } catch (error) { console.error("파일 업로드 오류:", error); @@ -991,19 +1022,26 @@ const FileUploadComponent: React.FC = ({ [safeComponentConfig.readonly, safeComponentConfig.disabled, handleFileSelect, onClick], ); + // 🔧 커스텀 스타일 감지 (StyleEditor에서 설정한 값) + const customStyle = component.style || {}; + const hasCustomBorder = !!(customStyle.borderWidth || customStyle.borderColor || customStyle.borderStyle || customStyle.border); + const hasCustomBackground = !!customStyle.backgroundColor; + const hasCustomRadius = !!customStyle.borderRadius; + return (
@@ -1014,15 +1052,15 @@ const FileUploadComponent: React.FC = ({ position: "absolute", top: "-20px", left: "0px", - fontSize: "12px", - color: "rgb(107, 114, 128)", - fontWeight: "400", - background: "transparent !important", - border: "none !important", - boxShadow: "none !important", - outline: "none !important", - padding: "0px !important", - margin: "0px !important" + fontSize: customStyle.labelFontSize || "12px", + color: customStyle.labelColor || "rgb(107, 114, 128)", + fontWeight: customStyle.labelFontWeight || "400", + background: "transparent", + border: "none", + boxShadow: "none", + outline: "none", + padding: "0px", + margin: "0px" }} > {component.label} @@ -1033,7 +1071,13 @@ const FileUploadComponent: React.FC = ({ )}
{/* 대표 이미지 전체 화면 표시 */} {uploadedFiles.length > 0 ? (() => { diff --git a/frontend/lib/registry/components/v2-text-display/TextDisplayComponent.tsx b/frontend/lib/registry/components/v2-text-display/TextDisplayComponent.tsx index 78157d3e..fe66b458 100644 --- a/frontend/lib/registry/components/v2-text-display/TextDisplayComponent.tsx +++ b/frontend/lib/registry/components/v2-text-display/TextDisplayComponent.tsx @@ -56,16 +56,19 @@ export const TextDisplayComponent: React.FC = ({ // DOM props 필터링 (React 관련 props 제거) const domProps = filterDOMProps(props); + // 🔧 StyleEditor(component.style) 값 우선, 없으면 componentConfig 폴백 + const customStyle = component.style || {}; + // 텍스트 스타일 계산 const textStyle: React.CSSProperties = { - fontSize: componentConfig.fontSize || "14px", - fontWeight: componentConfig.fontWeight || "normal", - color: componentConfig.color || "#212121", - textAlign: componentConfig.textAlign || "left", - backgroundColor: componentConfig.backgroundColor || "transparent", + fontSize: customStyle.fontSize || componentConfig.fontSize || "14px", + fontWeight: customStyle.fontWeight || componentConfig.fontWeight || "normal", + color: customStyle.color || componentConfig.color || "#212121", + textAlign: (customStyle.textAlign || componentConfig.textAlign || "left") as React.CSSProperties["textAlign"], + backgroundColor: customStyle.backgroundColor || componentConfig.backgroundColor || "transparent", padding: componentConfig.padding || "0", - borderRadius: componentConfig.borderRadius || "0", - border: componentConfig.border || "none", + borderRadius: customStyle.borderRadius || componentConfig.borderRadius || "0", + border: customStyle.border || (customStyle.borderWidth ? `${customStyle.borderWidth} ${customStyle.borderStyle || "solid"} ${customStyle.borderColor || "transparent"}` : componentConfig.border || "none"), width: "100%", height: "100%", display: "flex", From 2bbb5d701342f335faf83e388c2fda533acd232a Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 11 Feb 2026 15:43:50 +0900 Subject: [PATCH 28/44] feat: Enhance Excel upload functionality with automatic numbering column detection - Implemented automatic detection of numbering columns in the Excel upload modal, improving user experience by streamlining the upload process. - Updated the master-detail Excel upload configuration to reflect changes in how numbering rules are applied, ensuring consistency across uploads. - Refactored related components to remove deprecated properties and improve clarity in the configuration settings. - Enhanced error handling and logging for better debugging during the upload process. --- .../src/services/masterDetailExcelService.ts | 288 +++++++++++++----- .../components/common/ExcelUploadModal.tsx | 90 ++++-- .../config-panels/ButtonConfigPanel.tsx | 193 +----------- frontend/lib/utils/buttonActions.ts | 96 ++++-- 4 files changed, 362 insertions(+), 305 deletions(-) diff --git a/backend-node/src/services/masterDetailExcelService.ts b/backend-node/src/services/masterDetailExcelService.ts index a3eecb61..fa19c0a0 100644 --- a/backend-node/src/services/masterDetailExcelService.ts +++ b/backend-node/src/services/masterDetailExcelService.ts @@ -413,6 +413,16 @@ class MasterDetailExcelService { ? `WHERE ${whereConditions.join(" AND ")}` : ""; + // 디테일 테이블의 id 컬럼 존재 여부 확인 (user_info 등 id가 없는 테이블 대응) + const detailIdCheck = await queryOne<{ exists: boolean }>( + `SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = $1 AND column_name = 'id' + ) as exists`, + [detailTable] + ); + const detailOrderColumn = detailIdCheck?.exists ? `d."id"` : `d."${detailFkColumn}"`; + // JOIN 쿼리 실행 const sql = ` SELECT ${selectClause} @@ -422,7 +432,7 @@ class MasterDetailExcelService { AND m.company_code = d.company_code ${entityJoinClauses} ${whereClause} - ORDER BY m."${masterKeyColumn}", d.id + ORDER BY m."${masterKeyColumn}", ${detailOrderColumn} `; logger.info(`마스터-디테일 JOIN 쿼리:`, { sql, params }); @@ -481,14 +491,67 @@ class MasterDetailExcelService { } } + /** + * 특정 테이블의 특정 컬럼이 채번 타입인지 확인하고, 채번 규칙 ID를 반환 + * 회사별 설정을 우선 조회하고, 없으면 공통(*) 설정으로 fallback + */ + private async detectNumberingRuleForColumn( + tableName: string, + columnName: string, + companyCode?: string + ): Promise<{ numberingRuleId: string } | null> { + try { + // 회사별 설정 우선, 공통 설정 fallback (company_code DESC로 회사별이 먼저) + const companyCondition = companyCode && companyCode !== "*" + ? `AND company_code IN ($3, '*')` + : `AND company_code = '*'`; + const params = companyCode && companyCode !== "*" + ? [tableName, columnName, companyCode] + : [tableName, columnName]; + + const result = await query( + `SELECT input_type, detail_settings, company_code + FROM table_type_columns + WHERE table_name = $1 AND column_name = $2 ${companyCondition} + ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END`, + params + ); + + logger.info(`채번 컬럼 조회 결과: ${tableName}.${columnName}`, { + rowCount: result.length, + rows: result.map((r: any) => ({ input_type: r.input_type, company_code: r.company_code })), + }); + + // 채번 타입인 행 찾기 (회사별 우선) + for (const row of result) { + if (row.input_type === "numbering") { + const settings = typeof row.detail_settings === "string" + ? JSON.parse(row.detail_settings || "{}") + : row.detail_settings; + + if (settings?.numberingRuleId) { + logger.info(`채번 컬럼 감지: ${tableName}.${columnName} → 규칙 ID: ${settings.numberingRuleId} (company: ${row.company_code})`); + return { numberingRuleId: settings.numberingRuleId }; + } + } + } + + logger.info(`채번 컬럼 아님: ${tableName}.${columnName}`); + return null; + } catch (error) { + logger.error(`채번 컬럼 감지 실패: ${tableName}.${columnName}`, error); + return null; + } + } + /** * 마스터-디테일 데이터 업로드 (엑셀 업로드용) * * 처리 로직: - * 1. 엑셀 데이터를 마스터 키로 그룹화 - * 2. 각 그룹의 첫 번째 행에서 마스터 데이터 추출 → UPSERT - * 3. 해당 마스터 키의 기존 디테일 삭제 - * 4. 새 디테일 데이터 INSERT + * 1. 마스터 키 컬럼이 채번 타입인지 확인 + * 2-A. 채번인 경우: 다른 마스터 컬럼 값으로 그룹화 → 키 자동 생성 → INSERT + * 2-B. 채번 아닌 경우: 마스터 키 값으로 그룹화 → UPSERT + * 3. 디테일 데이터 INSERT */ async uploadJoinedData( relation: MasterDetailRelation, @@ -513,94 +576,164 @@ class MasterDetailExcelService { const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation; - // 1. 데이터를 마스터 키로 그룹화 - const groupedData = new Map[]>(); - - for (const row of data) { - const masterKey = row[masterKeyColumn]; - if (!masterKey) { - result.errors.push(`마스터 키(${masterKeyColumn}) 값이 없는 행이 있습니다.`); - continue; - } + // 마스터/디테일 테이블의 실제 컬럼 존재 여부 확인 (writer, created_date 등 하드코딩 방지) + const masterColsResult = await client.query( + `SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1`, + [masterTable] + ); + const masterExistingCols = new Set(masterColsResult.rows.map((r: any) => r.column_name)); - if (!groupedData.has(masterKey)) { - groupedData.set(masterKey, []); + const detailColsResult = await client.query( + `SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1`, + [detailTable] + ); + const detailExistingCols = new Set(detailColsResult.rows.map((r: any) => r.column_name)); + + // 마스터 키 컬럼의 채번 규칙 자동 감지 (회사별 설정 우선) + const numberingInfo = await this.detectNumberingRuleForColumn(masterTable, masterKeyColumn, companyCode); + const isAutoNumbering = !!numberingInfo; + + logger.info(`마스터 키 채번 감지:`, { + masterKeyColumn, + isAutoNumbering, + numberingRuleId: numberingInfo?.numberingRuleId + }); + + // 데이터 그룹화 + const groupedData = new Map[]>(); + + if (isAutoNumbering) { + // 채번 모드: 마스터 키 제외한 다른 마스터 컬럼 값으로 그룹화 + const otherMasterCols = masterColumns.filter(c => c.name !== masterKeyColumn).map(c => c.name); + + for (const row of data) { + // 다른 마스터 컬럼 값들을 조합해 그룹 키 생성 + const groupKey = otherMasterCols.map(col => row[col] ?? "").join("|||"); + if (!groupedData.has(groupKey)) { + groupedData.set(groupKey, []); + } + groupedData.get(groupKey)!.push(row); } - groupedData.get(masterKey)!.push(row); + + logger.info(`채번 모드 그룹화 완료: ${groupedData.size}개 그룹 (기준: ${otherMasterCols.join(", ")})`); + } else { + // 일반 모드: 마스터 키 값으로 그룹화 + for (const row of data) { + const masterKey = row[masterKeyColumn]; + if (!masterKey) { + result.errors.push(`마스터 키(${masterKeyColumn}) 값이 없는 행이 있습니다.`); + continue; + } + if (!groupedData.has(masterKey)) { + groupedData.set(masterKey, []); + } + groupedData.get(masterKey)!.push(row); + } + + logger.info(`일반 모드 그룹화 완료: ${groupedData.size}개 마스터 그룹`); } - logger.info(`데이터 그룹화 완료: ${groupedData.size}개 마스터 그룹`); - - // 2. 각 그룹 처리 - for (const [masterKey, rows] of groupedData.entries()) { + // 각 그룹 처리 + for (const [groupKey, rows] of groupedData.entries()) { try { - // 2a. 마스터 데이터 추출 (첫 번째 행에서) + // 마스터 키 결정 (채번이면 자동 생성, 아니면 그룹 키 자체가 마스터 키) + let masterKey: string; + + if (isAutoNumbering) { + // 채번 규칙으로 마스터 키 자동 생성 + masterKey = await this.generateNumberWithRule(client, numberingInfo!.numberingRuleId, companyCode); + logger.info(`채번 생성: ${masterKey}`); + } else { + masterKey = groupKey; + } + + // 마스터 데이터 추출 (첫 번째 행에서) const masterData: Record = {}; + // 마스터 키 컬럼은 항상 설정 (분할패널 컬럼 목록에 없어도) + masterData[masterKeyColumn] = masterKey; for (const col of masterColumns) { + if (col.name === masterKeyColumn) continue; // 이미 위에서 설정 if (rows[0][col.name] !== undefined) { masterData[col.name] = rows[0][col.name]; } } - // 회사 코드, 작성자 추가 - masterData.company_code = companyCode; - if (userId) { + // 회사 코드, 작성자 추가 (테이블에 해당 컬럼이 있을 때만) + if (masterExistingCols.has("company_code")) { + masterData.company_code = companyCode; + } + if (userId && masterExistingCols.has("writer")) { masterData.writer = userId; } - // 2b. 마스터 UPSERT - const existingMaster = await client.query( - `SELECT id FROM "${masterTable}" WHERE "${masterKeyColumn}" = $1 AND company_code = $2`, - [masterKey, companyCode] - ); + // INSERT SQL 생성 헬퍼 (created_date 존재 시만 추가) + const buildInsertSQL = (table: string, data: Record, existingCols: Set) => { + const cols = Object.keys(data); + const hasCreatedDate = existingCols.has("created_date"); + const colList = hasCreatedDate ? [...cols, "created_date"] : cols; + const placeholders = cols.map((_, i) => `$${i + 1}`); + const valList = hasCreatedDate ? [...placeholders, "NOW()"] : placeholders; + const values = cols.map(k => data[k]); + return { + sql: `INSERT INTO "${table}" (${colList.map(c => `"${c}"`).join(", ")}) VALUES (${valList.join(", ")})`, + values, + }; + }; - if (existingMaster.rows.length > 0) { - // UPDATE - const updateCols = Object.keys(masterData) - .filter(k => k !== masterKeyColumn && k !== "id") - .map((k, i) => `"${k}" = $${i + 1}`); - const updateValues = Object.keys(masterData) - .filter(k => k !== masterKeyColumn && k !== "id") - .map(k => masterData[k]); - - if (updateCols.length > 0) { - await client.query( - `UPDATE "${masterTable}" - SET ${updateCols.join(", ")}, updated_date = NOW() - WHERE "${masterKeyColumn}" = $${updateValues.length + 1} AND company_code = $${updateValues.length + 2}`, - [...updateValues, masterKey, companyCode] - ); - } - result.masterUpdated++; - } else { - // INSERT - const insertCols = Object.keys(masterData); - const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`); - const insertValues = insertCols.map(k => masterData[k]); - - await client.query( - `INSERT INTO "${masterTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date) - VALUES (${insertPlaceholders.join(", ")}, NOW())`, - insertValues - ); + if (isAutoNumbering) { + // 채번 모드: 항상 INSERT (새 마스터 생성) + const { sql, values } = buildInsertSQL(masterTable, masterData, masterExistingCols); + await client.query(sql, values); result.masterInserted++; + } else { + // 일반 모드: UPSERT (있으면 UPDATE, 없으면 INSERT) + const existingMaster = await client.query( + `SELECT 1 FROM "${masterTable}" WHERE "${masterKeyColumn}" = $1 AND company_code = $2`, + [masterKey, companyCode] + ); + + if (existingMaster.rows.length > 0) { + const updateCols = Object.keys(masterData) + .filter(k => k !== masterKeyColumn && k !== "id") + .map((k, i) => `"${k}" = $${i + 1}`); + const updateValues = Object.keys(masterData) + .filter(k => k !== masterKeyColumn && k !== "id") + .map(k => masterData[k]); + + if (updateCols.length > 0) { + const updatedDateClause = masterExistingCols.has("updated_date") ? `, updated_date = NOW()` : ""; + await client.query( + `UPDATE "${masterTable}" + SET ${updateCols.join(", ")}${updatedDateClause} + WHERE "${masterKeyColumn}" = $${updateValues.length + 1} AND company_code = $${updateValues.length + 2}`, + [...updateValues, masterKey, companyCode] + ); + } + result.masterUpdated++; + } else { + const { sql, values } = buildInsertSQL(masterTable, masterData, masterExistingCols); + await client.query(sql, values); + result.masterInserted++; + } + + // 일반 모드에서만 기존 디테일 삭제 (채번 모드는 새 마스터이므로 삭제할 디테일 없음) + const deleteResult = await client.query( + `DELETE FROM "${detailTable}" WHERE "${detailFkColumn}" = $1 AND company_code = $2`, + [masterKey, companyCode] + ); + result.detailDeleted += deleteResult.rowCount || 0; } - // 2c. 기존 디테일 삭제 - const deleteResult = await client.query( - `DELETE FROM "${detailTable}" WHERE "${detailFkColumn}" = $1 AND company_code = $2`, - [masterKey, companyCode] - ); - result.detailDeleted += deleteResult.rowCount || 0; - - // 2d. 새 디테일 INSERT + // 디테일 INSERT for (const row of rows) { const detailData: Record = {}; - // FK 컬럼 추가 + // FK 컬럼에 마스터 키 주입 detailData[detailFkColumn] = masterKey; - detailData.company_code = companyCode; - if (userId) { + if (detailExistingCols.has("company_code")) { + detailData.company_code = companyCode; + } + if (userId && detailExistingCols.has("writer")) { detailData.writer = userId; } @@ -611,20 +744,13 @@ class MasterDetailExcelService { } } - const insertCols = Object.keys(detailData); - const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`); - const insertValues = insertCols.map(k => detailData[k]); - - await client.query( - `INSERT INTO "${detailTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date) - VALUES (${insertPlaceholders.join(", ")}, NOW())`, - insertValues - ); + const { sql, values } = buildInsertSQL(detailTable, detailData, detailExistingCols); + await client.query(sql, values); result.detailInserted++; } } catch (error: any) { - result.errors.push(`마스터 키 ${masterKey} 처리 실패: ${error.message}`); - logger.error(`마스터 키 ${masterKey} 처리 실패:`, error); + result.errors.push(`그룹 처리 실패: ${error.message}`); + logger.error(`그룹 처리 실패:`, error); } } diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx index f6429a09..067d3a45 100644 --- a/frontend/components/common/ExcelUploadModal.tsx +++ b/frontend/components/common/ExcelUploadModal.tsx @@ -84,12 +84,9 @@ export interface ExcelUploadModalProps { masterColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>; detailColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>; }; - // 🆕 마스터-디테일 엑셀 업로드 설정 + // 마스터-디테일 엑셀 업로드 설정 masterDetailExcelConfig?: MasterDetailExcelConfig; - // 🆕 단일 테이블 채번 설정 - numberingRuleId?: string; - numberingTargetColumn?: string; - // 🆕 업로드 후 제어 실행 설정 + // 업로드 후 제어 실행 설정 afterUploadFlows?: Array<{ flowId: string; order: number }>; } @@ -112,9 +109,6 @@ export const ExcelUploadModal: React.FC = ({ isMasterDetail = false, masterDetailRelation, masterDetailExcelConfig, - // 단일 테이블 채번 설정 - numberingRuleId, - numberingTargetColumn, // 업로드 후 제어 실행 설정 afterUploadFlows, }) => { @@ -627,6 +621,44 @@ export const ExcelUploadModal: React.FC = ({ setCurrentStep((prev) => Math.max(prev - 1, 1)); }; + // 테이블 타입 관리에서 채번 컬럼 자동 감지 + const detectNumberingColumn = async ( + targetTableName: string + ): Promise<{ columnName: string; numberingRuleId: string } | null> => { + try { + const { getTableColumns } = await import("@/lib/api/tableManagement"); + const response = await getTableColumns(targetTableName); + + if (response.success && response.data?.columns) { + for (const col of response.data.columns) { + if (col.inputType === "numbering") { + try { + const settings = + typeof col.detailSettings === "string" + ? JSON.parse(col.detailSettings) + : col.detailSettings; + if (settings?.numberingRuleId) { + console.log( + `✅ 채번 컬럼 자동 감지: ${col.columnName} → 규칙 ID: ${settings.numberingRuleId}` + ); + return { + columnName: col.columnName, + numberingRuleId: settings.numberingRuleId, + }; + } + } catch { + // detailSettings 파싱 실패 시 무시 + } + } + } + } + return null; + } catch (error) { + console.error("채번 컬럼 감지 실패:", error); + return null; + } + }; + // 업로드 핸들러 const handleUpload = async () => { if (!file || !tableName) { @@ -667,19 +699,24 @@ export const ExcelUploadModal: React.FC = ({ `📊 엑셀 업로드: 전체 ${mappedData.length}행 중 유효한 ${filteredData.length}행` ); - // 🆕 마스터-디테일 간단 모드 처리 (마스터 필드 선택 + 채번) + // 마스터-디테일 간단 모드 처리 (마스터 필드 선택 + 채번 자동 감지) if (isSimpleMasterDetailMode && screenId && masterDetailRelation) { + // 마스터 테이블에서 채번 컬럼 자동 감지 + const masterNumberingInfo = await detectNumberingColumn(masterDetailRelation.masterTable); + const detectedNumberingRuleId = masterNumberingInfo?.numberingRuleId || masterDetailExcelConfig?.numberingRuleId; + console.log("📊 마스터-디테일 간단 모드 업로드:", { masterDetailRelation, masterFieldValues, - numberingRuleId: masterDetailExcelConfig?.numberingRuleId, + detectedNumberingRuleId, + autoDetected: !!masterNumberingInfo, }); const uploadResult = await DynamicFormApi.uploadMasterDetailSimple( screenId, filteredData, masterFieldValues, - masterDetailExcelConfig?.numberingRuleId || undefined, + detectedNumberingRuleId || undefined, masterDetailExcelConfig?.afterUploadFlowId || undefined, // 하위 호환성 masterDetailExcelConfig?.afterUploadFlows || undefined // 다중 제어 ); @@ -704,6 +741,24 @@ export const ExcelUploadModal: React.FC = ({ else if (isMasterDetail && screenId && masterDetailRelation) { console.log("📊 마스터-디테일 업로드 모드:", masterDetailRelation); + // 마스터 키 컬럼 매핑 검증 (채번 타입이면 자동 생성되므로 검증 생략) + const masterKeyCol = masterDetailRelation.masterKeyColumn; + const hasMasterKey = filteredData.length > 0 && filteredData[0][masterKeyCol] !== undefined && filteredData[0][masterKeyCol] !== null && filteredData[0][masterKeyCol] !== ""; + if (!hasMasterKey) { + // 채번 여부 확인 - 채번이면 백엔드에서 자동 생성하므로 통과 + const numberingInfo = await detectNumberingColumn(masterDetailRelation.masterTable); + const isMasterKeyAutoNumbering = numberingInfo && numberingInfo.columnName === masterKeyCol; + + if (!isMasterKeyAutoNumbering) { + toast.error( + `마스터 키 컬럼(${masterKeyCol})이 매핑되지 않았습니다. 컬럼 매핑에서 [마스터] 항목을 확인해주세요.` + ); + setIsUploading(false); + return; + } + console.log(`✅ 마스터 키(${masterKeyCol})는 채번 타입 → 백엔드에서 자동 생성`); + } + const uploadResult = await DynamicFormApi.uploadMasterDetailData( screenId, filteredData @@ -731,8 +786,9 @@ export const ExcelUploadModal: React.FC = ({ let skipCount = 0; let overwriteCount = 0; - // 단일 테이블 채번 설정 확인 - const hasNumbering = numberingRuleId && numberingTargetColumn; + // 단일 테이블 채번 자동 감지 (테이블 타입 관리에서 input_type = 'numbering' 컬럼) + const numberingInfo = await detectNumberingColumn(tableName); + const hasNumbering = !!numberingInfo; // 중복 체크 설정 확인 const duplicateCheckMappings = columnMappings.filter( @@ -816,14 +872,14 @@ export const ExcelUploadModal: React.FC = ({ continue; } - // 채번 적용: 각 행마다 채번 API 호출 (신규 등록 시에만) - if (hasNumbering && uploadMode === "insert" && !shouldUpdate) { + // 채번 적용: 각 행마다 채번 API 호출 (신규 등록 시에만, 자동 감지된 채번 규칙 사용) + if (hasNumbering && numberingInfo && uploadMode === "insert" && !shouldUpdate) { try { const { apiClient } = await import("@/lib/api/client"); - const numberingResponse = await apiClient.post(`/numbering-rules/${numberingRuleId}/allocate`); + const numberingResponse = await apiClient.post(`/numbering-rules/${numberingInfo.numberingRuleId}/allocate`); const generatedCode = numberingResponse.data?.data?.generatedCode || numberingResponse.data?.data?.code; if (numberingResponse.data?.success && generatedCode) { - dataToSave[numberingTargetColumn] = generatedCode; + dataToSave[numberingInfo.columnName] = generatedCode; } } catch (numError) { console.error("채번 오류:", numError); diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index 8d6df989..ea2febb1 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -3777,7 +3777,7 @@ export const ButtonConfigPanel: React.FC = ({ /** * 마스터-디테일 엑셀 업로드 설정 컴포넌트 - * 분할 패널 + column_labels에서 관계를 자동 감지하고, 사용자는 채번 규칙만 선택 + * 분할 패널 + column_labels에서 관계를 자동 감지 (채번은 테이블 타입 관리에서 자동 감지) */ const MasterDetailExcelUploadConfig: React.FC<{ config: any; @@ -4005,7 +4005,7 @@ const MasterDetailExcelUploadConfig: React.FC<{ {/* 마스터 키 자동 생성 안내 */} {relationInfo && (

- 마스터 테이블의 {relationInfo.masterKeyColumn} 값은 위에서 설정한 채번 규칙으로 자동 + 마스터 테이블의 {relationInfo.masterKeyColumn} 값은 테이블 타입 관리에서 설정된 채번 규칙으로 자동 생성됩니다.

)} @@ -4114,165 +4114,15 @@ const MasterDetailExcelUploadConfig: React.FC<{ }; /** - * 엑셀 업로드 채번 규칙 설정 (단일 테이블/마스터-디테일 모두 사용 가능) + * 엑셀 업로드 채번 규칙 안내 (테이블 타입 관리에서 자동 감지) */ -const ExcelNumberingRuleConfig: React.FC<{ - config: { numberingRuleId?: string; numberingTargetColumn?: string }; - updateConfig: (updates: { numberingRuleId?: string; numberingTargetColumn?: string }) => void; - tableName?: string; // 단일 테이블인 경우 테이블명 - hasSplitPanel?: boolean; // 분할 패널 여부 (마스터-디테일) -}> = ({ config, updateConfig, tableName, hasSplitPanel }) => { - const [numberingRules, setNumberingRules] = useState([]); - const [ruleSelectOpen, setRuleSelectOpen] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [tableColumns, setTableColumns] = useState>([]); - const [columnsLoading, setColumnsLoading] = useState(false); - - // 채번 규칙 목록 로드 - useEffect(() => { - const loadNumberingRules = async () => { - setIsLoading(true); - try { - const { apiClient } = await import("@/lib/api/client"); - const response = await apiClient.get("/numbering-rules"); - if (response.data?.success && response.data?.data) { - setNumberingRules(response.data.data); - } - } catch (error) { - console.error("채번 규칙 목록 로드 실패:", error); - } finally { - setIsLoading(false); - } - }; - - loadNumberingRules(); - }, []); - - // 단일 테이블인 경우 컬럼 목록 로드 - useEffect(() => { - if (!tableName || hasSplitPanel) { - setTableColumns([]); - return; - } - - const loadColumns = async () => { - setColumnsLoading(true); - try { - const { apiClient } = await import("@/lib/api/client"); - const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); - if (response.data?.success && response.data?.data?.columns) { - const cols = response.data.data.columns.map((col: any) => ({ - columnName: col.columnName || col.column_name, - columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name, - })); - setTableColumns(cols); - } - } catch (error) { - console.error("컬럼 목록 로드 실패:", error); - } finally { - setColumnsLoading(false); - } - }; - - loadColumns(); - }, [tableName, hasSplitPanel]); - - const selectedRule = numberingRules.find((r) => String(r.rule_id || r.ruleId) === String(config.numberingRuleId)); - +const ExcelNumberingRuleInfo: React.FC = () => { return (
-

- 업로드 시 자동으로 생성할 코드/번호의 채번 규칙을 선택하세요. +

+ 테이블 타입 관리에서 "채번" 타입으로 설정된 컬럼의 채번 규칙이 업로드 시 자동으로 적용됩니다.

- - - - - - - - - - 검색 결과 없음 - - { - updateConfig({ numberingRuleId: undefined, numberingTargetColumn: undefined }); - setRuleSelectOpen(false); - }} - className="text-xs" - > - - 채번 없음 - - {numberingRules.map((rule, idx) => { - const ruleId = String(rule.rule_id || rule.ruleId || `rule-${idx}`); - const ruleName = rule.rule_name || rule.ruleName || "(이름 없음)"; - return ( - { - updateConfig({ numberingRuleId: ruleId }); - setRuleSelectOpen(false); - }} - className="text-xs" - > - - {ruleName} - - ); - })} - - - - - - - {/* 단일 테이블이고 채번 규칙이 선택된 경우, 적용할 컬럼 선택 */} - {config.numberingRuleId && !hasSplitPanel && tableName && ( -
- - -

채번 값이 입력될 컬럼을 선택하세요.

-
- )} - - {/* 분할 패널인 경우 안내 메시지 */} - {config.numberingRuleId && hasSplitPanel && ( -

마스터-디테일 구조에서는 마스터 키 컬럼에 자동 적용됩니다.

- )}
); }; @@ -4440,14 +4290,10 @@ const ExcelUploadConfigSection: React.FC<{ allComponents: ComponentData[]; currentTableName?: string; // 현재 화면의 테이블명 (ButtonConfigPanel에서 전달) }> = ({ config, onUpdateProperty, allComponents, currentTableName: propTableName }) => { - // 엑셀 업로드 설정 상태 관리 + // 엑셀 업로드 설정 상태 관리 (채번은 테이블 타입 관리에서 자동 감지) const [excelUploadConfig, setExcelUploadConfig] = useState<{ - numberingRuleId?: string; - numberingTargetColumn?: string; afterUploadFlows?: Array<{ flowId: string; order: number }>; }>({ - numberingRuleId: config.action?.excelNumberingRuleId, - numberingTargetColumn: config.action?.excelNumberingTargetColumn, afterUploadFlows: config.action?.excelAfterUploadFlows || [], }); @@ -4529,17 +4375,11 @@ const ExcelUploadConfigSection: React.FC<{ ); }, [hasSplitPanel, singleTableName, propTableName]); - // 설정 업데이트 함수 + // 설정 업데이트 함수 (채번은 테이블 타입 관리에서 자동 감지되므로 제어 실행만 관리) const updateExcelUploadConfig = (updates: Partial) => { const newConfig = { ...excelUploadConfig, ...updates }; setExcelUploadConfig(newConfig); - if (updates.numberingRuleId !== undefined) { - onUpdateProperty("componentConfig.action.excelNumberingRuleId", updates.numberingRuleId); - } - if (updates.numberingTargetColumn !== undefined) { - onUpdateProperty("componentConfig.action.excelNumberingTargetColumn", updates.numberingTargetColumn); - } if (updates.afterUploadFlows !== undefined) { onUpdateProperty("componentConfig.action.excelAfterUploadFlows", updates.afterUploadFlows); } @@ -4548,15 +4388,9 @@ const ExcelUploadConfigSection: React.FC<{ // config 변경 시 로컬 상태 동기화 useEffect(() => { setExcelUploadConfig({ - numberingRuleId: config.action?.excelNumberingRuleId, - numberingTargetColumn: config.action?.excelNumberingTargetColumn, afterUploadFlows: config.action?.excelAfterUploadFlows || [], }); - }, [ - config.action?.excelNumberingRuleId, - config.action?.excelNumberingTargetColumn, - config.action?.excelAfterUploadFlows, - ]); + }, [config.action?.excelAfterUploadFlows]); return (
@@ -4595,13 +4429,8 @@ const ExcelUploadConfigSection: React.FC<{
)} - {/* 채번 규칙 설정 (항상 표시) */} - + {/* 채번 규칙 안내 (테이블 타입 관리에서 자동 감지) */} + {/* 업로드 후 제어 실행 (항상 표시) */} diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index e1abcb25..358b0df1 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -4984,7 +4984,7 @@ export class ButtonActionExecutor { // visible이 true인 컬럼만 추출 visibleColumns = columns.filter((col: any) => col.visible !== false).map((col: any) => col.columnName); - // 🎯 column_labels 테이블에서 실제 라벨 가져오기 + // column_labels 테이블에서 실제 라벨 가져오기 try { const columnsResponse = await apiClient.get(`/table-management/tables/${context.tableName}/columns`, { params: { page: 1, size: 9999 }, @@ -5021,19 +5021,77 @@ export class ButtonActionExecutor { } }); } - } else { - console.warn("⚠️ 화면 레이아웃에서 테이블 리스트 컴포넌트를 찾을 수 없습니다."); } } } catch (error) { console.error("❌ 화면 레이아웃 조회 실패:", error); } - // 🎨 카테고리 값들 조회 (한 번만) + // Fallback: 레이아웃에서 컬럼 정보를 못 가져온 경우, table_type_columns에서 직접 조회 + // 시스템 컬럼 제외 + 라벨 적용으로 raw 컬럼명 노출 방지 + const SYSTEM_COLUMNS = ["id", "company_code", "created_date", "updated_date", "writer"]; + if ((!visibleColumns || visibleColumns.length === 0) && context.tableName && dataToExport.length > 0) { + console.log("⚠️ 레이아웃에서 컬럼 설정을 찾지 못함 → table_type_columns에서 fallback 조회"); + try { + const { apiClient } = await import("@/lib/api/client"); + const columnsResponse = await apiClient.get(`/table-management/tables/${context.tableName}/columns`, { + params: { page: 1, size: 9999 }, + }); + + if (columnsResponse.data?.success && columnsResponse.data?.data) { + let columnData = columnsResponse.data.data; + if (columnData.columns && Array.isArray(columnData.columns)) { + columnData = columnData.columns; + } + + if (Array.isArray(columnData) && columnData.length > 0) { + // visible이 false가 아닌 컬럼만 + 시스템 컬럼 제외 + const filteredCols = columnData.filter((col: any) => { + const colName = (col.column_name || col.columnName || "").toLowerCase(); + if (SYSTEM_COLUMNS.includes(colName)) return false; + if (col.isVisible === false || col.is_visible === false) return false; + return true; + }); + + visibleColumns = filteredCols.map((col: any) => col.column_name || col.columnName); + columnLabels = {}; + filteredCols.forEach((col: any) => { + const colName = col.column_name || col.columnName; + const labelValue = col.column_label || col.label || col.displayName || colName; + if (colName) { + columnLabels![colName] = labelValue; + } + }); + + console.log(`✅ Fallback 컬럼 ${visibleColumns.length}개 로드 완료`); + } + } + } catch (fallbackError) { + console.error("❌ Fallback 컬럼 조회 실패:", fallbackError); + } + } + + // 최종 안전장치: 여전히 컬럼 정보가 없으면 데이터의 키에서 시스템 컬럼만 제외 + if ((!visibleColumns || visibleColumns.length === 0) && dataToExport.length > 0) { + console.log("⚠️ 최종 fallback: 데이터 키에서 시스템 컬럼 제외"); + const allKeys = Object.keys(dataToExport[0]); + visibleColumns = allKeys.filter((key) => { + const lowerKey = key.toLowerCase(); + // 시스템 컬럼 제외 + if (SYSTEM_COLUMNS.includes(lowerKey)) return false; + // _name, _label 등 조인된 보조 필드 제외 + if (lowerKey.endsWith("_name") || lowerKey.endsWith("_label") || lowerKey.endsWith("_value_label")) return false; + return true; + }); + // 라벨이 없으므로 최소한 column_labels 비워두지 않음 (컬럼명 그대로 표시되지만 시스템 컬럼은 제외됨) + if (!columnLabels) { + columnLabels = {}; + } + } + + // 카테고리 값들 조회 (한 번만) const categoryMap: Record> = {}; let categoryColumns: string[] = []; - - // 백엔드에서 카테고리 컬럼 정보 가져오기 if (context.tableName) { try { const { getCategoryColumns, getCategoryValues } = await import("@/lib/api/tableCategoryValue"); @@ -5072,7 +5130,7 @@ export class ButtonActionExecutor { } } - // 🎨 컬럼 필터링 및 라벨 적용 (항상 실행) + // 컬럼 필터링 및 라벨 적용 if (visibleColumns && visibleColumns.length > 0 && dataToExport.length > 0) { dataToExport = dataToExport.map((row: any) => { const filteredRow: Record = {}; @@ -5165,6 +5223,8 @@ export class ButtonActionExecutor { ? config.excelAfterUploadFlows : config.masterDetailExcel?.afterUploadFlows; + // masterDetailExcel 설정이 명시적으로 있을 때만 간단 모드 (디테일만 업로드) + // 설정이 없으면 기본 모드 (마스터+디테일 둘 다 업로드) if (config.masterDetailExcel) { masterDetailExcelConfig = { ...config.masterDetailExcel, @@ -5173,25 +5233,13 @@ export class ButtonActionExecutor { detailTable: relationResponse.data.detailTable, masterKeyColumn: relationResponse.data.masterKeyColumn, detailFkColumn: relationResponse.data.detailFkColumn, - // 채번 규칙 ID 추가 (excelNumberingRuleId를 numberingRuleId로 매핑) - numberingRuleId: config.masterDetailExcel.numberingRuleId || config.excelNumberingRuleId, - // 업로드 후 제어 설정 (통합: excelAfterUploadFlows 우선) - afterUploadFlows, - }; - } else { - // 버튼 설정이 없으면 분할 패널 정보만 사용 - masterDetailExcelConfig = { - masterTable: relationResponse.data.masterTable, - detailTable: relationResponse.data.detailTable, - masterKeyColumn: relationResponse.data.masterKeyColumn, - detailFkColumn: relationResponse.data.detailFkColumn, - simpleMode: true, // 기본값으로 간단 모드 사용 - // 채번 규칙 ID 추가 (excelNumberingRuleId 사용) - numberingRuleId: config.excelNumberingRuleId, + // 채번은 ExcelUploadModal에서 마스터 테이블 기반 자동 감지 // 업로드 후 제어 설정 (통합: excelAfterUploadFlows 우선) afterUploadFlows, }; } + // masterDetailExcel 설정 없으면 masterDetailExcelConfig는 undefined 유지 + // → ExcelUploadModal에서 기본 모드로 동작 (마스터+디테일 둘 다 매핑/업로드) } } @@ -5233,9 +5281,7 @@ export class ButtonActionExecutor { isMasterDetail, masterDetailRelation, masterDetailExcelConfig, - // 🆕 단일 테이블 채번 설정 - numberingRuleId: config.excelNumberingRuleId, - numberingTargetColumn: config.excelNumberingTargetColumn, + // 채번은 ExcelUploadModal에서 테이블 타입 관리 기반 자동 감지 // 🆕 업로드 후 제어 실행 설정 afterUploadFlows: config.excelAfterUploadFlows, onSuccess: () => { From e065835c4dc7a6c0b4bdef8382cb39e7ccf79d1f Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 11 Feb 2026 16:07:44 +0900 Subject: [PATCH 29/44] feat: Add PK and index management APIs for table management - Implemented new API endpoints for managing primary keys and indexes in the table management system. - Added functionality to retrieve table constraints, set primary keys, toggle indexes, and manage NOT NULL constraints. - Enhanced the frontend to support PK and index management, including loading constraints and handling user interactions for toggling indexes and setting primary keys. - Improved error handling and logging for better debugging and user feedback during these operations. --- .../controllers/tableManagementController.ts | 257 ++++++++++++ .../src/routes/tableManagementRoutes.ts | 28 ++ .../admin/systemMng/tableMngList/page.tsx | 386 +++++++++++------- 3 files changed, 529 insertions(+), 142 deletions(-) diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index a494ae3d..320ab74b 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -2447,3 +2447,260 @@ export async function getReferencedByTables( res.status(500).json(response); } } + +// ======================================== +// PK / 인덱스 관리 API +// ======================================== + +/** + * PK/인덱스 상태 조회 + * GET /api/table-management/tables/:tableName/constraints + */ +export async function getTableConstraints( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + + if (!tableName) { + res.status(400).json({ success: false, message: "테이블명이 필요합니다." }); + return; + } + + // PK 조회 + const pkResult = await query( + `SELECT tc.conname AS constraint_name, + array_agg(a.attname ORDER BY x.n) AS columns + FROM pg_constraint tc + JOIN pg_class c ON tc.conrelid = c.oid + JOIN pg_namespace ns ON c.relnamespace = ns.oid + CROSS JOIN LATERAL unnest(tc.conkey) WITH ORDINALITY AS x(attnum, n) + JOIN pg_attribute a ON a.attrelid = tc.conrelid AND a.attnum = x.attnum + WHERE ns.nspname = 'public' AND c.relname = $1 AND tc.contype = 'p' + GROUP BY tc.conname`, + [tableName] + ); + + // array_agg 결과가 문자열로 올 수 있으므로 안전하게 배열로 변환 + const parseColumns = (cols: any): string[] => { + if (Array.isArray(cols)) return cols; + if (typeof cols === "string") { + // PostgreSQL 배열 형식: {col1,col2} + return cols.replace(/[{}]/g, "").split(",").filter(Boolean); + } + return []; + }; + + const primaryKey = pkResult.length > 0 + ? { name: pkResult[0].constraint_name, columns: parseColumns(pkResult[0].columns) } + : { name: "", columns: [] }; + + // 인덱스 조회 (PK 인덱스 제외) + const indexResult = await query( + `SELECT i.relname AS index_name, + ix.indisunique AS is_unique, + array_agg(a.attname ORDER BY x.n) AS columns + FROM pg_index ix + JOIN pg_class t ON ix.indrelid = t.oid + JOIN pg_class i ON ix.indexrelid = i.oid + JOIN pg_namespace ns ON t.relnamespace = ns.oid + CROSS JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS x(attnum, n) + JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = x.attnum + WHERE ns.nspname = 'public' AND t.relname = $1 + AND ix.indisprimary = false + GROUP BY i.relname, ix.indisunique + ORDER BY i.relname`, + [tableName] + ); + + const indexes = indexResult.map((row: any) => ({ + name: row.index_name, + columns: parseColumns(row.columns), + isUnique: row.is_unique, + })); + + logger.info(`제약조건 조회: ${tableName} - PK: ${primaryKey.columns.join(",")}, 인덱스: ${indexes.length}개`); + + res.status(200).json({ + success: true, + data: { primaryKey, indexes }, + }); + } catch (error) { + logger.error("제약조건 조회 오류:", error); + res.status(500).json({ + success: false, + message: "제약조건 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +} + +/** + * PK 설정 + * PUT /api/table-management/tables/:tableName/primary-key + */ +export async function setTablePrimaryKey( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + const { columns } = req.body; + + if (!tableName || !columns || !Array.isArray(columns) || columns.length === 0) { + res.status(400).json({ success: false, message: "테이블명과 PK 컬럼 배열이 필요합니다." }); + return; + } + + logger.info(`PK 설정: ${tableName} → [${columns.join(", ")}]`); + + // 기존 PK 제약조건 이름 조회 + const existingPk = await query( + `SELECT conname FROM pg_constraint tc + JOIN pg_class c ON tc.conrelid = c.oid + JOIN pg_namespace ns ON c.relnamespace = ns.oid + WHERE ns.nspname = 'public' AND c.relname = $1 AND tc.contype = 'p'`, + [tableName] + ); + + // 기존 PK 삭제 + if (existingPk.length > 0) { + const dropSql = `ALTER TABLE "public"."${tableName}" DROP CONSTRAINT "${existingPk[0].conname}"`; + logger.info(`기존 PK 삭제: ${dropSql}`); + await query(dropSql); + } + + // 새 PK 추가 + const colList = columns.map((c: string) => `"${c}"`).join(", "); + const addSql = `ALTER TABLE "public"."${tableName}" ADD PRIMARY KEY (${colList})`; + logger.info(`새 PK 추가: ${addSql}`); + await query(addSql); + + res.status(200).json({ + success: true, + message: `PK가 설정되었습니다: ${columns.join(", ")}`, + }); + } catch (error) { + logger.error("PK 설정 오류:", error); + res.status(500).json({ + success: false, + message: "PK 설정 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +} + +/** + * 인덱스 토글 (생성/삭제) + * POST /api/table-management/tables/:tableName/indexes + */ +export async function toggleTableIndex( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + const { columnName, indexType, action } = req.body; + + if (!tableName || !columnName || !indexType || !action) { + res.status(400).json({ + success: false, + message: "tableName, columnName, indexType(index|unique), action(create|drop)이 필요합니다.", + }); + return; + } + + const indexName = `idx_${tableName}_${columnName}${indexType === "unique" ? "_uq" : ""}`; + + logger.info(`인덱스 ${action}: ${indexName} (${indexType})`); + + if (action === "create") { + const uniqueClause = indexType === "unique" ? "UNIQUE " : ""; + const sql = `CREATE ${uniqueClause}INDEX "${indexName}" ON "public"."${tableName}" ("${columnName}")`; + logger.info(`인덱스 생성: ${sql}`); + await query(sql); + } else if (action === "drop") { + const sql = `DROP INDEX IF EXISTS "public"."${indexName}"`; + logger.info(`인덱스 삭제: ${sql}`); + await query(sql); + } else { + res.status(400).json({ success: false, message: "action은 create 또는 drop이어야 합니다." }); + return; + } + + res.status(200).json({ + success: true, + message: action === "create" + ? `인덱스가 생성되었습니다: ${indexName}` + : `인덱스가 삭제되었습니다: ${indexName}`, + }); + } catch (error: any) { + logger.error("인덱스 토글 오류:", error); + + // 중복 데이터로 인한 UNIQUE 인덱스 생성 실패 안내 + const errorMsg = error.message?.includes("duplicate key") + ? "중복 데이터가 있어 UNIQUE 인덱스를 생성할 수 없습니다. 중복 데이터를 먼저 정리해주세요." + : "인덱스 설정 중 오류가 발생했습니다."; + + res.status(500).json({ + success: false, + message: errorMsg, + error: error instanceof Error ? error.message : "Unknown error", + }); + } +} + +/** + * NOT NULL 토글 + * PUT /api/table-management/tables/:tableName/columns/:columnName/nullable + */ +export async function toggleColumnNullable( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName, columnName } = req.params; + const { nullable } = req.body; + + if (!tableName || !columnName || typeof nullable !== "boolean") { + res.status(400).json({ + success: false, + message: "tableName, columnName, nullable(boolean)이 필요합니다.", + }); + return; + } + + if (nullable) { + // NOT NULL 해제 + const sql = `ALTER TABLE "public"."${tableName}" ALTER COLUMN "${columnName}" DROP NOT NULL`; + logger.info(`NOT NULL 해제: ${sql}`); + await query(sql); + } else { + // NOT NULL 설정 + const sql = `ALTER TABLE "public"."${tableName}" ALTER COLUMN "${columnName}" SET NOT NULL`; + logger.info(`NOT NULL 설정: ${sql}`); + await query(sql); + } + + res.status(200).json({ + success: true, + message: nullable + ? `${columnName} 컬럼의 NOT NULL 제약이 해제되었습니다.` + : `${columnName} 컬럼이 NOT NULL로 설정되었습니다.`, + }); + } catch (error: any) { + logger.error("NOT NULL 토글 오류:", error); + + // NULL 데이터가 있는 컬럼에 NOT NULL 설정 시 안내 + const errorMsg = error.message?.includes("contains null values") + ? "해당 컬럼에 NULL 값이 있어 NOT NULL 설정이 불가합니다. NULL 데이터를 먼저 정리해주세요." + : "NOT NULL 설정 중 오류가 발생했습니다."; + + res.status(500).json({ + success: false, + message: errorMsg, + error: error instanceof Error ? error.message : "Unknown error", + }); + } +} diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index b9cf43c5..d02a5615 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -28,6 +28,10 @@ import { multiTableSave, // 🆕 범용 다중 테이블 저장 getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회 getReferencedByTables, // 🆕 현재 테이블을 참조하는 테이블 조회 + getTableConstraints, // 🆕 PK/인덱스 상태 조회 + setTablePrimaryKey, // 🆕 PK 설정 + toggleTableIndex, // 🆕 인덱스 토글 + toggleColumnNullable, // 🆕 NOT NULL 토글 } from "../controllers/tableManagementController"; const router = express.Router(); @@ -133,6 +137,30 @@ router.put("/tables/:tableName/columns/batch", updateAllColumnSettings); */ router.get("/tables/:tableName/schema", getTableSchema); +/** + * PK/인덱스 제약조건 상태 조회 + * GET /api/table-management/tables/:tableName/constraints + */ +router.get("/tables/:tableName/constraints", getTableConstraints); + +/** + * PK 설정 (기존 PK DROP 후 재생성) + * PUT /api/table-management/tables/:tableName/primary-key + */ +router.put("/tables/:tableName/primary-key", setTablePrimaryKey); + +/** + * 인덱스 토글 (생성/삭제) + * POST /api/table-management/tables/:tableName/indexes + */ +router.post("/tables/:tableName/indexes", toggleTableIndex); + +/** + * NOT NULL 토글 + * PUT /api/table-management/tables/:tableName/columns/:columnName/nullable + */ +router.put("/tables/:tableName/columns/:columnName/nullable", toggleColumnNullable); + /** * 테이블 존재 여부 확인 * GET /api/table-management/tables/:tableName/exists diff --git a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx index 17c52897..3a159700 100644 --- a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx @@ -145,6 +145,14 @@ export default function TableManagementPage() { const [tableToDelete, setTableToDelete] = useState(""); const [isDeleting, setIsDeleting] = useState(false); + // PK/인덱스 관리 상태 + const [constraints, setConstraints] = useState<{ + primaryKey: { name: string; columns: string[] }; + indexes: Array<{ name: string; columns: string[]; isUnique: boolean }>; + }>({ primaryKey: { name: "", columns: [] }, indexes: [] }); + const [pkDialogOpen, setPkDialogOpen] = useState(false); + const [pendingPkColumns, setPendingPkColumns] = useState([]); + // 선택된 테이블 목록 (체크박스) const [selectedTableIds, setSelectedTableIds] = useState>(new Set()); @@ -397,6 +405,19 @@ export default function TableManagementPage() { } }, []); + // PK/인덱스 제약조건 로드 + const loadConstraints = useCallback(async (tableName: string) => { + try { + const response = await apiClient.get(`/table-management/tables/${tableName}/constraints`); + if (response.data.success) { + setConstraints(response.data.data); + } + } catch (error) { + console.error("제약조건 로드 실패:", error); + setConstraints({ primaryKey: { name: "", columns: [] }, indexes: [] }); + } + }, []); + // 테이블 선택 const handleTableSelect = useCallback( (tableName: string) => { @@ -410,8 +431,9 @@ export default function TableManagementPage() { setTableDescription(tableInfo?.description || ""); loadColumnTypes(tableName, 1, pageSize); + loadConstraints(tableName); }, - [loadColumnTypes, pageSize, tables], + [loadColumnTypes, loadConstraints, pageSize, tables], ); // 입력 타입 변경 @@ -1000,6 +1022,123 @@ export default function TableManagementPage() { } }, [selectedTable, columns.length, totalColumns, columnsLoading, pageSize, loadColumnTypes]); + // PK 체크박스 변경 핸들러 + const handlePkToggle = useCallback( + (columnName: string, checked: boolean) => { + const currentPkCols = [...constraints.primaryKey.columns]; + let newPkCols: string[]; + if (checked) { + newPkCols = [...currentPkCols, columnName]; + } else { + newPkCols = currentPkCols.filter((c) => c !== columnName); + } + // PK 변경은 확인 다이얼로그 표시 + setPendingPkColumns(newPkCols); + setPkDialogOpen(true); + }, + [constraints.primaryKey.columns], + ); + + // PK 변경 확인 + const handlePkConfirm = async () => { + if (!selectedTable) return; + try { + if (pendingPkColumns.length === 0) { + toast.error("PK 컬럼을 최소 1개 이상 선택해야 합니다."); + setPkDialogOpen(false); + return; + } + const response = await apiClient.put(`/table-management/tables/${selectedTable}/primary-key`, { + columns: pendingPkColumns, + }); + if (response.data.success) { + toast.success(response.data.message); + await loadConstraints(selectedTable); + } else { + toast.error(response.data.message || "PK 설정 실패"); + } + } catch (error: any) { + toast.error(error?.response?.data?.message || "PK 설정 중 오류가 발생했습니다."); + } finally { + setPkDialogOpen(false); + } + }; + + // 인덱스 토글 핸들러 + const handleIndexToggle = useCallback( + async (columnName: string, indexType: "index" | "unique", checked: boolean) => { + if (!selectedTable) return; + const action = checked ? "create" : "drop"; + try { + const response = await apiClient.post(`/table-management/tables/${selectedTable}/indexes`, { + columnName, + indexType, + action, + }); + if (response.data.success) { + toast.success(response.data.message); + await loadConstraints(selectedTable); + } else { + toast.error(response.data.message || "인덱스 설정 실패"); + } + } catch (error: any) { + toast.error(error?.response?.data?.message || error?.response?.data?.error || "인덱스 설정 중 오류가 발생했습니다."); + } + }, + [selectedTable, loadConstraints], + ); + + // 컬럼별 인덱스 상태 헬퍼 + const getColumnIndexState = useCallback( + (columnName: string) => { + const isPk = constraints.primaryKey.columns.includes(columnName); + const hasIndex = constraints.indexes.some( + (idx) => !idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName, + ); + const hasUnique = constraints.indexes.some( + (idx) => idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName, + ); + return { isPk, hasIndex, hasUnique }; + }, + [constraints], + ); + + // NOT NULL 토글 핸들러 + const handleNullableToggle = useCallback( + async (columnName: string, currentIsNullable: string) => { + if (!selectedTable) return; + // isNullable이 "YES"면 nullable, "NO"면 NOT NULL + // 체크박스 체크 = NOT NULL 설정 (nullable: false) + // 체크박스 해제 = NOT NULL 해제 (nullable: true) + const isCurrentlyNotNull = currentIsNullable === "NO"; + const newNullable = isCurrentlyNotNull; // NOT NULL이면 해제, NULL이면 설정 + try { + const response = await apiClient.put( + `/table-management/tables/${selectedTable}/columns/${columnName}/nullable`, + { nullable: newNullable }, + ); + if (response.data.success) { + toast.success(response.data.message); + // 컬럼 상태 로컬 업데이트 + setColumns((prev) => + prev.map((col) => + col.columnName === columnName + ? { ...col, isNullable: newNullable ? "YES" : "NO" } + : col, + ), + ); + } else { + toast.error(response.data.message || "NOT NULL 설정 실패"); + } + } catch (error: any) { + toast.error( + error?.response?.data?.message || "NOT NULL 설정 중 오류가 발생했습니다.", + ); + } + }, + [selectedTable], + ); + // 테이블 삭제 확인 const handleDeleteTableClick = (tableName: string) => { setTableToDelete(tableName); @@ -1391,12 +1530,16 @@ export default function TableManagementPage() { {/* 컬럼 헤더 (고정) */}
-
컬럼명
-
라벨
+
라벨
+
컬럼명
입력 타입
설명
+
Primary
+
NotNull
+
Index
+
Unique
{/* 컬럼 리스트 (스크롤 영역) */} @@ -1410,16 +1553,15 @@ export default function TableManagementPage() { } }} > - {columns.map((column, index) => ( + {columns.map((column, index) => { + const idxState = getColumnIndexState(column.columnName); + return (
-
-
{column.columnName}
-
-
+
handleLabelChange(column.columnName, e.target.value)} @@ -1427,6 +1569,9 @@ export default function TableManagementPage() { className="h-8 text-xs" />
+
+
{column.columnName}
+
{/* 입력 타입 선택 */} @@ -1689,141 +1834,11 @@ export default function TableManagementPage() {
)} - {/* 표시 컬럼 - 검색 가능한 Combobox */} - {column.referenceTable && - column.referenceTable !== "none" && - column.referenceColumn && - column.referenceColumn !== "none" && ( -
- - - setEntityComboboxOpen((prev) => ({ - ...prev, - [column.columnName]: { - ...prev[column.columnName], - displayColumn: open, - }, - })) - } - > - - - - - - - - - 컬럼을 찾을 수 없습니다. - - - { - handleDetailSettingsChange( - column.columnName, - "entity_display_column", - "none", - ); - setEntityComboboxOpen((prev) => ({ - ...prev, - [column.columnName]: { - ...prev[column.columnName], - displayColumn: false, - }, - })); - }} - className="text-xs" - > - - -- 선택 안함 -- - - {referenceTableColumns[column.referenceTable]?.map((refCol) => ( - { - handleDetailSettingsChange( - column.columnName, - "entity_display_column", - refCol.columnName, - ); - setEntityComboboxOpen((prev) => ({ - ...prev, - [column.columnName]: { - ...prev[column.columnName], - displayColumn: false, - }, - })); - }} - className="text-xs" - > - -
- {refCol.columnName} - {refCol.columnLabel && ( - - {refCol.columnLabel} - - )} -
-
- ))} -
-
-
-
-
-
- )} - {/* 설정 완료 표시 */} {column.referenceTable && column.referenceTable !== "none" && column.referenceColumn && - column.referenceColumn !== "none" && - column.displayColumn && - column.displayColumn !== "none" && ( + column.referenceColumn !== "none" && (
설정 완료 @@ -1953,8 +1968,49 @@ export default function TableManagementPage() { className="h-8 w-full text-xs" />
+ {/* PK 체크박스 */} +
+ + handlePkToggle(column.columnName, checked as boolean) + } + aria-label={`${column.columnName} PK 설정`} + /> +
+ {/* NN (NOT NULL) 체크박스 */} +
+ + handleNullableToggle(column.columnName, column.isNullable) + } + aria-label={`${column.columnName} NOT NULL 설정`} + /> +
+ {/* IDX 체크박스 */} +
+ + handleIndexToggle(column.columnName, "index", checked as boolean) + } + aria-label={`${column.columnName} 인덱스 설정`} + /> +
+ {/* UQ 체크박스 */} +
+ + handleIndexToggle(column.columnName, "unique", checked as boolean) + } + aria-label={`${column.columnName} 유니크 설정`} + /> +
- ))} + ); + })} {/* 로딩 표시 */} {columnsLoading && ( @@ -2120,6 +2176,52 @@ export default function TableManagementPage() { )} + {/* PK 변경 확인 다이얼로그 */} + + + + PK 변경 확인 + + PK를 변경하면 기존 제약조건이 삭제되고 새로 생성됩니다. +
데이터 무결성에 영향을 줄 수 있습니다. +
+
+ +
+
+

변경될 PK 컬럼:

+ {pendingPkColumns.length > 0 ? ( +
+ {pendingPkColumns.map((col) => ( + + {col} + + ))} +
+ ) : ( +

PK가 모두 제거됩니다

+ )} +
+
+ + + + + +
+
+ {/* Scroll to Top 버튼 */}
From 4e12f93da4bbc02dd9010ae6a0fc2268837665f0 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Wed, 11 Feb 2026 17:45:43 +0900 Subject: [PATCH 30/44] feat: Enhance SplitPanelLayoutComponent with delete modal improvements - Added a new state to manage the table name for the delete modal, allowing for more specific deletion handling based on the context of the item being deleted. - Updated the delete button handler to accept an optional table name parameter, improving the flexibility of the delete functionality. - Enhanced the delete confirmation logic to prioritize the specified table name when available, ensuring accurate deletion operations. - Refactored related logic to maintain clarity and improve the overall user experience during item deletion in the split panel layout. --- .../SplitPanelLayoutComponent.tsx | 50 ++++++++++++------- 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index f47da1aa..9a952bc0 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -462,6 +462,7 @@ export const SplitPanelLayoutComponent: React.FC const [showDeleteModal, setShowDeleteModal] = useState(false); const [deleteModalPanel, setDeleteModalPanel] = useState<"left" | "right" | null>(null); const [deleteModalItem, setDeleteModalItem] = useState(null); + const [deleteModalTableName, setDeleteModalTableName] = useState(null); // 추가 탭 삭제 시 테이블명 // 리사이저 드래그 상태 const [isDragging, setIsDragging] = useState(false); @@ -2197,32 +2198,39 @@ export const SplitPanelLayoutComponent: React.FC loadRightData, ]); - // 삭제 버튼 핸들러 - const handleDeleteClick = useCallback((panel: "left" | "right", item: any) => { + // 삭제 버튼 핸들러 (tableName: 추가 탭 등 특정 테이블 지정 시 사용) + const handleDeleteClick = useCallback((panel: "left" | "right", item: any, tableName?: string) => { setDeleteModalPanel(panel); setDeleteModalItem(item); + setDeleteModalTableName(tableName || null); setShowDeleteModal(true); }, []); // 삭제 확인 const handleDeleteConfirm = useCallback(async () => { - // 우측 패널 삭제 시 중계 테이블 확인 - let tableName = - deleteModalPanel === "left" ? componentConfig.leftPanel?.tableName : componentConfig.rightPanel?.tableName; + // 1. 테이블명 결정: deleteModalTableName이 있으면 우선 사용 (추가 탭 등) + let tableName = deleteModalTableName; - // 우측 패널 + 중계 테이블 모드인 경우 - if (deleteModalPanel === "right" && componentConfig.rightPanel?.addConfig?.targetTable) { - tableName = componentConfig.rightPanel.addConfig.targetTable; - console.log("🔗 중계 테이블 모드: 삭제 대상 테이블 =", tableName); + if (!tableName) { + tableName = + deleteModalPanel === "left" ? componentConfig.leftPanel?.tableName : componentConfig.rightPanel?.tableName; + + // 우측 패널 + 중계 테이블 모드인 경우 + if (deleteModalPanel === "right" && componentConfig.rightPanel?.addConfig?.targetTable) { + tableName = componentConfig.rightPanel.addConfig.targetTable; + console.log("🔗 중계 테이블 모드: 삭제 대상 테이블 =", tableName); + } } - const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id"; - let primaryKey: any = deleteModalItem[sourceColumn] || deleteModalItem.id || deleteModalItem.ID; + // 2. Primary Key 추출: id 필드를 우선 사용, 없으면 전체 객체 전달 (복합키) + let primaryKey: any = deleteModalItem?.id || deleteModalItem?.ID; - // 복합키 처리: deleteModalItem 전체를 전달 (백엔드에서 복합키 자동 처리) - if (deleteModalItem && typeof deleteModalItem === "object") { + if (!primaryKey && deleteModalItem && typeof deleteModalItem === "object") { + // id가 없는 경우에만 전체 객체 전달 (복합키 테이블) primaryKey = deleteModalItem; - console.log("🔑 복합키 가능성: 전체 객체 전달", primaryKey); + console.log("🔑 복합키: 전체 객체 전달", Object.keys(primaryKey)); + } else { + console.log("🔑 단일키 삭제: id =", primaryKey, "테이블 =", tableName); } if (!tableName || !primaryKey) { @@ -2290,6 +2298,7 @@ export const SplitPanelLayoutComponent: React.FC // 모달 닫기 setShowDeleteModal(false); setDeleteModalItem(null); + setDeleteModalTableName(null); // 데이터 새로고침 if (deleteModalPanel === "left") { @@ -2300,7 +2309,12 @@ export const SplitPanelLayoutComponent: React.FC setRightData(null); } } else if (deleteModalPanel === "right") { - loadRightData(selectedLeftItem); + // 추가 탭에서 삭제한 경우 해당 탭 데이터 리로드 + if (deleteModalTableName && activeTabIndex > 0) { + loadTabData(activeTabIndex, selectedLeftItem); + } else { + loadRightData(selectedLeftItem); + } } } else { toast({ @@ -2324,7 +2338,7 @@ export const SplitPanelLayoutComponent: React.FC variant: "destructive", }); } - }, [deleteModalPanel, componentConfig, deleteModalItem, toast, selectedLeftItem, loadLeftData, loadRightData]); + }, [deleteModalPanel, deleteModalTableName, componentConfig, deleteModalItem, toast, selectedLeftItem, loadLeftData, loadRightData, loadTabData, activeTabIndex]); // 항목별 추가 버튼 핸들러 (좌측 항목의 + 버튼 - 하위 항목 추가) const handleItemAddClick = useCallback( @@ -3541,7 +3555,7 @@ export const SplitPanelLayoutComponent: React.FC )} {currentTabConfig?.showDelete && ( @@ -3585,7 +3599,7 @@ export const SplitPanelLayoutComponent: React.FC )} {currentTabConfig?.showDelete && ( From 0512a3214c259c125d2ca47ee2cba0b39b4a726d Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Wed, 11 Feb 2026 18:05:32 +0900 Subject: [PATCH 31/44] Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node --- .../src/services/nodeFlowExecutionService.ts | 85 ++++++- .../panels/properties/ConditionProperties.tsx | 209 +++++++++++++++++- frontend/types/node-editor.ts | 12 +- 3 files changed, 297 insertions(+), 9 deletions(-) diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts index 9bc59d97..a2a8aef1 100644 --- a/backend-node/src/services/nodeFlowExecutionService.ts +++ b/backend-node/src/services/nodeFlowExecutionService.ts @@ -2830,12 +2830,12 @@ export class NodeFlowExecutionService { inputData: any, context: ExecutionContext ): Promise { - const { conditions, logic } = node.data; + const { conditions, logic, targetLookup } = node.data; logger.info( `🔍 조건 노드 실행 - inputData 타입: ${typeof inputData}, 배열 여부: ${Array.isArray(inputData)}, 길이: ${Array.isArray(inputData) ? inputData.length : "N/A"}` ); - logger.info(`🔍 조건 개수: ${conditions?.length || 0}, 로직: ${logic}`); + logger.info(`🔍 조건 개수: ${conditions?.length || 0}, 로직: ${logic}, 타겟조회: ${targetLookup ? targetLookup.tableName : "없음"}`); if (inputData) { console.log( @@ -2865,6 +2865,9 @@ export class NodeFlowExecutionService { // 배열의 각 항목에 대해 조건 평가 (EXISTS 조건은 비동기) for (const item of inputData) { + // 타겟 테이블 조회 (DB 기존값 비교용) + const targetRow = await this.lookupTargetRow(targetLookup, item, context); + const results: boolean[] = []; for (const condition of conditions) { @@ -2887,9 +2890,14 @@ export class NodeFlowExecutionService { `🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}` ); } else { - // 일반 연산자 처리 + // 비교값 결정: static(고정값) / field(같은 데이터 내 필드) / target(DB 기존값) let compareValue = condition.value; - if (condition.valueType === "field") { + if (condition.valueType === "target" && targetRow) { + compareValue = targetRow[condition.value]; + logger.info( + `🎯 타겟(DB) 비교: ${condition.field} (${fieldValue}) vs DB.${condition.value} (${compareValue})` + ); + } else if (condition.valueType === "field") { compareValue = item[condition.value]; logger.info( `🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})` @@ -2931,6 +2939,9 @@ export class NodeFlowExecutionService { } // 단일 객체인 경우 + // 타겟 테이블 조회 (DB 기존값 비교용) + const targetRow = await this.lookupTargetRow(targetLookup, inputData, context); + const results: boolean[] = []; for (const condition of conditions) { @@ -2953,9 +2964,14 @@ export class NodeFlowExecutionService { `🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}` ); } else { - // 일반 연산자 처리 + // 비교값 결정: static(고정값) / field(같은 데이터 내 필드) / target(DB 기존값) let compareValue = condition.value; - if (condition.valueType === "field") { + if (condition.valueType === "target" && targetRow) { + compareValue = targetRow[condition.value]; + logger.info( + `🎯 타겟(DB) 비교: ${condition.field} (${fieldValue}) vs DB.${condition.value} (${compareValue})` + ); + } else if (condition.valueType === "field") { compareValue = inputData[condition.value]; logger.info( `🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})` @@ -2990,6 +3006,63 @@ export class NodeFlowExecutionService { }; } + /** + * 조건 노드의 타겟 테이블 조회 (DB 기존값 비교용) + * targetLookup 설정이 있을 때, 소스 데이터의 키값으로 DB에서 기존 레코드를 조회 + */ + private static async lookupTargetRow( + targetLookup: any, + sourceRow: any, + context: ExecutionContext + ): Promise { + if (!targetLookup?.tableName || !targetLookup?.lookupKeys?.length) { + return null; + } + + try { + const whereConditions = targetLookup.lookupKeys + .map((key: any, idx: number) => `"${key.targetField}" = $${idx + 1}`) + .join(" AND "); + + const lookupValues = targetLookup.lookupKeys.map( + (key: any) => sourceRow[key.sourceField] + ); + + // 키값이 비어있으면 조회 불필요 + if (lookupValues.some((v: any) => v === null || v === undefined || v === "")) { + logger.info(`⚠️ 조건 노드 타겟 조회: 키값이 비어있어 스킵`); + return null; + } + + // company_code 필터링 (멀티테넌시) + const companyCode = context.buttonContext?.companyCode || sourceRow.company_code; + let sql = `SELECT * FROM "${targetLookup.tableName}" WHERE ${whereConditions}`; + const params = [...lookupValues]; + + if (companyCode && companyCode !== "*") { + sql += ` AND company_code = $${params.length + 1}`; + params.push(companyCode); + } + + sql += " LIMIT 1"; + + logger.info(`🎯 조건 노드 타겟 조회: ${targetLookup.tableName}, 조건: ${whereConditions}, 값: ${JSON.stringify(lookupValues)}`); + + const targetRow = await queryOne(sql, params); + + if (targetRow) { + logger.info(`🎯 타겟 데이터 조회 성공`); + } else { + logger.info(`🎯 타겟 데이터 없음 (신규 레코드)`); + } + + return targetRow; + } catch (error: any) { + logger.warn(`⚠️ 조건 노드 타겟 조회 실패: ${error.message}`); + return null; + } + } + /** * EXISTS_IN / NOT_EXISTS_IN 조건 평가 * 다른 테이블에 값이 존재하는지 확인 diff --git a/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx index a2d060d4..76354925 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx @@ -251,6 +251,14 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) const [logic, setLogic] = useState<"AND" | "OR">(data.logic || "AND"); const [availableFields, setAvailableFields] = useState([]); + // 타겟 조회 설정 (DB 기존값 비교용) + const [targetLookup, setTargetLookup] = useState<{ + tableName: string; + tableLabel?: string; + lookupKeys: Array<{ sourceField: string; targetField: string; sourceFieldLabel?: string }>; + } | undefined>(data.targetLookup); + const [targetLookupColumns, setTargetLookupColumns] = useState([]); + // EXISTS 연산자용 상태 const [allTables, setAllTables] = useState([]); const [tableColumnsCache, setTableColumnsCache] = useState>({}); @@ -262,8 +270,20 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) setDisplayName(data.displayName || "조건 분기"); setConditions(data.conditions || []); setLogic(data.logic || "AND"); + setTargetLookup(data.targetLookup); }, [data]); + // targetLookup 테이블 변경 시 컬럼 목록 로드 + useEffect(() => { + if (targetLookup?.tableName) { + loadTableColumns(targetLookup.tableName).then((cols) => { + setTargetLookupColumns(cols); + }); + } else { + setTargetLookupColumns([]); + } + }, [targetLookup?.tableName]); + // 전체 테이블 목록 로드 (EXISTS 연산자용) useEffect(() => { const loadAllTables = async () => { @@ -559,6 +579,47 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) }); }; + // 타겟 조회 테이블 변경 + const handleTargetLookupTableChange = async (tableName: string) => { + await ensureTablesLoaded(); + const tableInfo = allTables.find((t) => t.tableName === tableName); + const newLookup = { + tableName, + tableLabel: tableInfo?.tableLabel || tableName, + lookupKeys: targetLookup?.lookupKeys || [], + }; + setTargetLookup(newLookup); + updateNode(nodeId, { targetLookup: newLookup }); + + // 컬럼 로드 + const cols = await loadTableColumns(tableName); + setTargetLookupColumns(cols); + }; + + // 타겟 조회 키 필드 변경 + const handleTargetLookupKeyChange = (sourceField: string, targetField: string) => { + if (!targetLookup) return; + const sourceFieldInfo = availableFields.find((f) => f.name === sourceField); + const newLookup = { + ...targetLookup, + lookupKeys: [{ sourceField, targetField, sourceFieldLabel: sourceFieldInfo?.label || sourceField }], + }; + setTargetLookup(newLookup); + updateNode(nodeId, { targetLookup: newLookup }); + }; + + // 타겟 조회 제거 + const handleRemoveTargetLookup = () => { + setTargetLookup(undefined); + updateNode(nodeId, { targetLookup: undefined }); + // target 타입 조건들을 field로 변경 + const newConditions = conditions.map((c) => + (c as any).valueType === "target" ? { ...c, valueType: "field" } : c + ); + setConditions(newConditions); + updateNode(nodeId, { conditions: newConditions }); + }; + return (
@@ -597,6 +658,119 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
+ {/* 타겟 조회 (DB 기존값 비교) */} +
+
+

+ + 타겟 조회 (DB 기존값) +

+
+ + {!targetLookup ? ( +
+
+ DB의 기존값과 비교하려면 타겟 테이블을 설정하세요. +
+ +
+ ) : ( +
+
+ 타겟 테이블 + +
+ + {/* 테이블 선택 */} + {allTables.length > 0 ? ( + + ) : ( +
+ 테이블 로딩 중... +
+ )} + + {/* 키 필드 매핑 */} + {targetLookup.tableName && ( +
+ +
+ + = + {targetLookupColumns.length > 0 ? ( + + ) : ( +
+ 컬럼 로딩 중... +
+ )} +
+
+ 비교 값 타입에서 "타겟 필드 (DB 기존값)"을 선택하면 이 테이블의 기존값과 비교합니다. +
+
+ )} +
+ )} +
+ {/* 조건식 */}
@@ -738,15 +912,46 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) 고정값 필드 참조 + {targetLookup?.tableName && ( + 타겟 필드 (DB 기존값) + )}
- {(condition as any).valueType === "field" ? ( + {(condition as any).valueType === "target" ? ( + // 타겟 필드 (DB 기존값): 타겟 테이블 컬럼에서 선택 + targetLookupColumns.length > 0 ? ( + + ) : ( +
+ 타겟 조회를 먼저 설정하세요 +
+ ) + ) : (condition as any).valueType === "field" ? ( // 필드 참조: 드롭다운으로 선택 availableFields.length > 0 ? ( {/* 중복 체크 체크박스 */} @@ -1427,6 +1501,38 @@ export const ExcelUploadModal: React.FC = ({
+ {/* 미매핑 필수(NOT NULL) 컬럼 경고 */} + {(() => { + const mappedCols = new Set(); + columnMappings.filter((m) => m.systemColumn).forEach((m) => { + const n = m.systemColumn!; + mappedCols.add(n); + if (n.includes(".")) mappedCols.add(n.split(".")[1]); + }); + const missing = systemColumns.filter((col) => { + const rawName = col.name.includes(".") ? col.name.split(".")[1] : col.name; + if (AUTO_GENERATED_COLUMNS.includes(rawName.toLowerCase())) return false; + if (col.nullable) return false; + if (mappedCols.has(col.name) || mappedCols.has(rawName)) return false; + if ((col as any).isNumbering) return false; + return true; + }); + if (missing.length === 0) return null; + return ( +
+
+ +
+

필수(NOT NULL) 컬럼이 매핑되지 않았습니다:

+

+ {missing.map((c) => c.label || c.name).join(", ")} +

+
+
+
+ ); + })()} + {/* 중복 체크 안내 */} {duplicateCheckCount > 0 ? (
diff --git a/frontend/components/dataflow/node-editor/FlowToolbar.tsx b/frontend/components/dataflow/node-editor/FlowToolbar.tsx index f136d216..607886f3 100644 --- a/frontend/components/dataflow/node-editor/FlowToolbar.tsx +++ b/frontend/components/dataflow/node-editor/FlowToolbar.tsx @@ -4,7 +4,7 @@ * 플로우 에디터 상단 툴바 */ -import { useState } from "react"; +import { useState, useEffect, useRef } from "react"; import { Save, Undo2, Redo2, ZoomIn, ZoomOut, Download, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -42,6 +42,27 @@ export function FlowToolbar({ validations = [], onSaveComplete }: FlowToolbarPro const [showSaveDialog, setShowSaveDialog] = useState(false); + // Ctrl+S 단축키: 플로우 저장 + const handleSaveRef = useRef<() => void>(); + + useEffect(() => { + handleSaveRef.current = handleSave; + }); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === "s") { + e.preventDefault(); + if (!isSaving) { + handleSaveRef.current?.(); + } + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isSaving]); + const handleSave = async () => { // 검증 수행 const currentValidations = validations.length > 0 ? validations : validateFlow(nodes, edges); From 5c6efa861dd0a1a01eaaddedbbfa4a2ae66aa92b Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 12 Feb 2026 10:30:37 +0900 Subject: [PATCH 33/44] feat: Add support for selected rows data handling in TabsWidget - Introduced new props for managing selected rows data, enabling better interaction with tab components. - Added `selectedRowsData` and `onSelectedRowsChange` callbacks to facilitate row selection and updates. - Enhanced the TabsWidget functionality to improve user experience when interacting with tabbed content. --- frontend/components/screen/widgets/TabsWidget.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/frontend/components/screen/widgets/TabsWidget.tsx b/frontend/components/screen/widgets/TabsWidget.tsx index 6c770e48..2dd0899c 100644 --- a/frontend/components/screen/widgets/TabsWidget.tsx +++ b/frontend/components/screen/widgets/TabsWidget.tsx @@ -26,6 +26,15 @@ interface TabsWidgetProps { isDesignMode?: boolean; onComponentSelect?: (tabId: string, componentId: string) => void; selectedComponentId?: string; + // 테이블 선택된 행 데이터 (버튼 활성화 및 수정/삭제 동작에 필요) + selectedRowsData?: any[]; + onSelectedRowsChange?: ( + selectedRows: any[], + selectedRowsData: any[], + sortBy?: string, + sortOrder?: "asc" | "desc", + columnOrder?: string[], + ) => void; } export function TabsWidget({ @@ -38,6 +47,8 @@ export function TabsWidget({ isDesignMode = false, onComponentSelect, selectedComponentId, + selectedRowsData, + onSelectedRowsChange, }: TabsWidgetProps) { const { setActiveTab, removeTabsComponent } = useActiveTab(); const { @@ -345,6 +356,8 @@ export function TabsWidget({ menuObjid={menuObjid} isDesignMode={isDesignMode} isInteractive={!isDesignMode} + selectedRowsData={selectedRowsData} + onSelectedRowsChange={onSelectedRowsChange} />
); From 14d6406a61a7183075abff9378168340a74c35af Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 12 Feb 2026 11:42:32 +0900 Subject: [PATCH 34/44] feat: Improve selected rows data management in TabsWidget and SplitPanelLayoutComponent - Refactored TabsWidget to manage local selected rows data, enhancing responsiveness to user interactions. - Introduced a new callback for handling selected rows changes, ensuring updates are reflected in both local and parent states. - Updated SplitPanelLayoutComponent to share selected rows data between tabs and buttons, improving data consistency across components. - Enhanced overall user experience by ensuring immediate recognition of selection changes within the tabbed interface. --- .../components/screen/widgets/TabsWidget.tsx | 38 ++++++++++++++++--- .../SplitPanelLayoutComponent.tsx | 29 ++++++++++++++ 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/frontend/components/screen/widgets/TabsWidget.tsx b/frontend/components/screen/widgets/TabsWidget.tsx index 2dd0899c..6b0d0864 100644 --- a/frontend/components/screen/widgets/TabsWidget.tsx +++ b/frontend/components/screen/widgets/TabsWidget.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useMemo } from "react"; +import React, { useState, useEffect, useCallback } from "react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; import { X, Loader2 } from "lucide-react"; @@ -35,6 +35,8 @@ interface TabsWidgetProps { sortOrder?: "asc" | "desc", columnOrder?: string[], ) => void; + // 추가 props (부모에서 전달받은 나머지 props) + [key: string]: any; } export function TabsWidget({ @@ -47,8 +49,9 @@ export function TabsWidget({ isDesignMode = false, onComponentSelect, selectedComponentId, - selectedRowsData, - onSelectedRowsChange, + selectedRowsData: _externalSelectedRowsData, + onSelectedRowsChange: externalOnSelectedRowsChange, + ...restProps }: TabsWidgetProps) { const { setActiveTab, removeTabsComponent } = useActiveTab(); const { @@ -62,6 +65,30 @@ export function TabsWidget({ const storageKey = `tabs-${component.id}-selected`; + // 탭 내부 자체 selectedRowsData 상태 관리 (항상 로컬 상태 사용) + // 부모에서 빈 배열 []이 전달되어도 로컬 상태를 우선하여 탭 내부 버튼이 즉시 인식 + const [localSelectedRowsData, setLocalSelectedRowsData] = useState([]); + + // 선택 변경 핸들러: 로컬 상태 업데이트 + 부모 콜백 호출 + const handleSelectedRowsChange = useCallback( + ( + selectedRows: any[], + selectedRowsDataNew: any[], + sortBy?: string, + sortOrder?: "asc" | "desc", + columnOrder?: string[], + ) => { + // 로컬 상태 업데이트 (탭 내부 버튼이 즉시 인식) + setLocalSelectedRowsData(selectedRowsDataNew); + + // 부모 콜백 호출 (부모 상태도 업데이트) + if (externalOnSelectedRowsChange) { + externalOnSelectedRowsChange(selectedRows, selectedRowsDataNew, sortBy, sortOrder, columnOrder); + } + }, + [externalOnSelectedRowsChange], + ); + // 초기 선택 탭 결정 const getInitialTab = () => { if (persistSelection && typeof window !== "undefined") { @@ -342,6 +369,7 @@ export function TabsWidget({ }} >
); diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index 923c1aa3..83aabc85 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -194,6 +194,17 @@ export const SplitPanelLayoutComponent: React.FC const [selectedLeftItem, setSelectedLeftItem] = useState(null); const [expandedRightItems, setExpandedRightItems] = useState>(new Set()); // 확장된 우측 아이템 const [customLeftSelectedData, setCustomLeftSelectedData] = useState>({}); // 커스텀 모드: 좌측 선택 데이터 + // 커스텀 모드: 탭/버튼 간 공유할 selectedRowsData 자체 관리 (항상 로컬 상태 사용) + const [localSelectedRowsData, setLocalSelectedRowsData] = useState([]); + const handleLocalSelectedRowsChange = useCallback( + (selectedRows: any[], selectedRowsDataNew: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[]) => { + setLocalSelectedRowsData(selectedRowsDataNew); + if ((props as any).onSelectedRowsChange) { + (props as any).onSelectedRowsChange(selectedRows, selectedRowsDataNew, sortBy, sortOrder, columnOrder); + } + }, + [(props as any).onSelectedRowsChange], + ); const [leftSearchQuery, setLeftSearchQuery] = useState(""); const [rightSearchQuery, setRightSearchQuery] = useState(""); const [isLoadingLeft, setIsLoadingLeft] = useState(false); @@ -2757,8 +2768,17 @@ export const SplitPanelLayoutComponent: React.FC { // 커스텀 모드: 좌측 카드/테이블 선택 시 데이터 캡처 if (data?.selectedRowsData && data.selectedRowsData.length > 0) { @@ -3645,8 +3665,17 @@ export const SplitPanelLayoutComponent: React.FC
); From 4294e6206b3dfcabd0ea96ca17b272187c5842b2 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Thu, 12 Feb 2026 11:42:52 +0900 Subject: [PATCH 35/44] feat: Add express-async-errors for improved error handling - Integrated express-async-errors to automatically handle errors in async route handlers, enhancing the overall error management in the application. - Updated app.ts to include the express-async-errors import for global error handling. - Removed redundant logging statements in admin and user menu retrieval functions to streamline the code and improve readability. - Adjusted logging levels from info to debug for less critical logs, ensuring that important information is logged appropriately without cluttering the logs. --- backend-node/package-lock.json | 11 + backend-node/package.json | 1 + backend-node/src/app.ts | 1 + .../src/controllers/adminController.ts | 48 +- .../src/controllers/authController.ts | 26 +- .../src/controllers/entitySearchController.ts | 26 +- .../src/controllers/flowController.ts | 50 +- backend-node/src/middleware/authMiddleware.ts | 4 +- backend-node/src/services/adminService.ts | 38 +- backend-node/src/services/authService.ts | 11 +- .../src/services/flowExecutionService.ts | 22 - backend-node/src/services/flowStepService.ts | 15 - .../src/services/nodeFlowExecutionService.ts | 16 + .../src/services/screenManagementService.ts | 22 +- frontend/components/v2/V2Repeater.tsx | 40 +- .../modal-repeater-table/RepeaterTable.tsx | 4 +- .../SplitPanelLayoutComponent.tsx | 632 ++++++++++-------- .../SplitPanelLayoutConfigPanel.tsx | 49 +- .../components/v2-split-panel-layout/types.ts | 4 + frontend/lib/utils/buttonActions.ts | 16 +- 20 files changed, 555 insertions(+), 481 deletions(-) diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index 43b698d2..c365a102 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -18,6 +18,7 @@ "docx": "^9.5.1", "dotenv": "^16.3.1", "express": "^4.18.2", + "express-async-errors": "^3.1.1", "express-rate-limit": "^7.1.5", "helmet": "^7.1.0", "html-to-docx": "^1.8.0", @@ -5948,6 +5949,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -5989,6 +5991,15 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-async-errors": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/express-async-errors/-/express-async-errors-3.1.1.tgz", + "integrity": "sha512-h6aK1da4tpqWSbyCa3FxB/V6Ehd4EEB15zyQq9qe75OZBp0krinNKuH4rAY+S/U/2I36vdLAUFSjQJ+TFmODng==", + "license": "ISC", + "peerDependencies": { + "express": "^4.16.2" + } + }, "node_modules/express-rate-limit": { "version": "7.5.1", "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", diff --git a/backend-node/package.json b/backend-node/package.json index b1bfa319..310ab401 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -32,6 +32,7 @@ "docx": "^9.5.1", "dotenv": "^16.3.1", "express": "^4.18.2", + "express-async-errors": "^3.1.1", "express-rate-limit": "^7.1.5", "helmet": "^7.1.0", "html-to-docx": "^1.8.0", diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 1fbefea5..30e684d5 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -1,4 +1,5 @@ import "dotenv/config"; +import "express-async-errors"; // async 라우트 핸들러의 에러를 Express 에러 핸들러로 자동 전달 import express from "express"; import cors from "cors"; import helmet from "helmet"; diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index bcfff1d2..7b3b1033 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -19,8 +19,6 @@ export async function getAdminMenus( res: Response ): Promise { try { - logger.info("=== 관리자 메뉴 목록 조회 시작 ==="); - // 현재 로그인한 사용자의 정보 가져오기 const userId = req.user?.userId; const userCompanyCode = req.user?.companyCode || "ILSHIN"; @@ -29,13 +27,6 @@ export async function getAdminMenus( const menuType = req.query.menuType as string | undefined; // menuType 파라미터 추가 const includeInactive = req.query.includeInactive === "true"; // includeInactive 파라미터 추가 - logger.info(`사용자 ID: ${userId}`); - logger.info(`사용자 회사 코드: ${userCompanyCode}`); - logger.info(`사용자 유형: ${userType}`); - logger.info(`사용자 로케일: ${userLang}`); - logger.info(`메뉴 타입: ${menuType || "전체"}`); - logger.info(`비활성 메뉴 포함: ${includeInactive}`); - const paramMap = { userId, userCompanyCode, @@ -47,13 +38,6 @@ export async function getAdminMenus( const menuList = await AdminService.getAdminMenuList(paramMap); - logger.info( - `관리자 메뉴 조회 결과: ${menuList.length}개 (타입: ${menuType || "전체"}, 회사: ${userCompanyCode})` - ); - if (menuList.length > 0) { - logger.info("첫 번째 메뉴:", menuList[0]); - } - const response: ApiResponse = { success: true, message: "관리자 메뉴 목록 조회 성공", @@ -85,19 +69,12 @@ export async function getUserMenus( res: Response ): Promise { try { - logger.info("=== 사용자 메뉴 목록 조회 시작 ==="); - // 현재 로그인한 사용자의 정보 가져오기 const userId = req.user?.userId; const userCompanyCode = req.user?.companyCode || "ILSHIN"; const userType = req.user?.userType; const userLang = (req.query.userLang as string) || "ko"; - logger.info(`사용자 ID: ${userId}`); - logger.info(`사용자 회사 코드: ${userCompanyCode}`); - logger.info(`사용자 유형: ${userType}`); - logger.info(`사용자 로케일: ${userLang}`); - const paramMap = { userId, userCompanyCode, @@ -107,13 +84,6 @@ export async function getUserMenus( const menuList = await AdminService.getUserMenuList(paramMap); - logger.info( - `사용자 메뉴 조회 결과: ${menuList.length}개 (회사: ${userCompanyCode})` - ); - if (menuList.length > 0) { - logger.info("첫 번째 메뉴:", menuList[0]); - } - const response: ApiResponse = { success: true, message: "사용자 메뉴 목록 조회 성공", @@ -473,7 +443,7 @@ export const getUserLocale = async ( res: Response ): Promise => { try { - logger.info("사용자 로케일 조회 요청", { + logger.debug("사용자 로케일 조회 요청", { query: req.query, user: req.user, }); @@ -496,7 +466,7 @@ export const getUserLocale = async ( if (userInfo?.locale) { userLocale = userInfo.locale; - logger.info("데이터베이스에서 사용자 로케일 조회 성공", { + logger.debug("데이터베이스에서 사용자 로케일 조회 성공", { userId: req.user.userId, locale: userLocale, }); @@ -513,7 +483,7 @@ export const getUserLocale = async ( message: "사용자 로케일 조회 성공", }; - logger.info("사용자 로케일 조회 성공", { + logger.debug("사용자 로케일 조회 성공", { userLocale, userId: req.user.userId, fromDatabase: !!userInfo?.locale, @@ -618,7 +588,7 @@ export const getCompanyList = async ( res: Response ) => { try { - logger.info("회사 목록 조회 요청", { + logger.debug("회사 목록 조회 요청", { query: req.query, user: req.user, }); @@ -658,12 +628,8 @@ export const getCompanyList = async ( message: "회사 목록 조회 성공", }; - logger.info("회사 목록 조회 성공", { + logger.debug("회사 목록 조회 성공", { totalCount: companies.length, - companies: companies.map((c) => ({ - code: c.company_code, - name: c.company_name, - })), }); res.status(200).json(response); @@ -1864,7 +1830,7 @@ export async function getCompanyListFromDB( res: Response ): Promise { try { - logger.info("회사 목록 조회 요청 (Raw Query)", { user: req.user }); + logger.debug("회사 목록 조회 요청 (Raw Query)", { user: req.user }); // Raw Query로 회사 목록 조회 const companies = await query( @@ -1884,7 +1850,7 @@ export async function getCompanyListFromDB( ORDER BY regdate DESC` ); - logger.info("회사 목록 조회 성공 (Raw Query)", { count: companies.length }); + logger.debug("회사 목록 조회 성공 (Raw Query)", { count: companies.length }); const response: ApiResponse = { success: true, diff --git a/backend-node/src/controllers/authController.ts b/backend-node/src/controllers/authController.ts index 1903d397..ebf3e8f5 100644 --- a/backend-node/src/controllers/authController.ts +++ b/backend-node/src/controllers/authController.ts @@ -17,9 +17,7 @@ export class AuthController { const { userId, password }: LoginRequest = req.body; const remoteAddr = req.ip || req.connection.remoteAddress || "unknown"; - logger.info(`=== API 로그인 호출됨 ===`); - logger.info(`userId: ${userId}`); - logger.info(`password: ${password ? "***" : "null"}`); + logger.debug(`로그인 요청: ${userId}`); // 입력값 검증 if (!userId || !password) { @@ -50,14 +48,7 @@ export class AuthController { companyCode: loginResult.userInfo.companyCode || "ILSHIN", }; - logger.info(`=== API 로그인 사용자 정보 디버그 ===`); - logger.info( - `PersonBean companyCode: ${loginResult.userInfo.companyCode}` - ); - logger.info(`반환할 사용자 정보:`); - logger.info(`- userId: ${userInfo.userId}`); - logger.info(`- userName: ${userInfo.userName}`); - logger.info(`- companyCode: ${userInfo.companyCode}`); + logger.debug(`로그인 사용자 정보: ${userInfo.userId} (${userInfo.companyCode})`); // 사용자의 첫 번째 접근 가능한 메뉴 조회 let firstMenuPath: string | null = null; @@ -71,7 +62,7 @@ export class AuthController { }; const menuList = await AdminService.getUserMenuList(paramMap); - logger.info(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`); + logger.debug(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`); // 접근 가능한 첫 번째 메뉴 찾기 // 조건: @@ -87,16 +78,9 @@ export class AuthController { if (firstMenu) { firstMenuPath = firstMenu.menu_url || firstMenu.url; - logger.info(`✅ 첫 번째 접근 가능한 메뉴 발견:`, { - name: firstMenu.menu_name_kor || firstMenu.translated_name, - url: firstMenuPath, - level: firstMenu.lev || firstMenu.level, - seq: firstMenu.seq, - }); + logger.debug(`첫 번째 메뉴: ${firstMenuPath}`); } else { - logger.info( - "⚠️ 접근 가능한 메뉴가 없습니다. 메인 페이지로 이동합니다." - ); + logger.debug("접근 가능한 메뉴 없음, 메인 페이지로 이동"); } } catch (menuError) { logger.warn("메뉴 조회 중 오류 발생 (무시하고 계속):", menuError); diff --git a/backend-node/src/controllers/entitySearchController.ts b/backend-node/src/controllers/entitySearchController.ts index 2e850a03..bbc42568 100644 --- a/backend-node/src/controllers/entitySearchController.ts +++ b/backend-node/src/controllers/entitySearchController.ts @@ -395,11 +395,35 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) { ? `WHERE ${whereConditions.join(" AND ")}` : ""; + // 정렬 컬럼 결정: id가 있으면 id, 없으면 첫 번째 컬럼 사용 + let orderByColumn = "1"; // 기본: 첫 번째 컬럼 + if (existingColumns.has("id")) { + orderByColumn = '"id"'; + } else { + // PK 컬럼 조회 시도 + try { + const pkResult = await pool.query( + `SELECT a.attname + FROM pg_index i + JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) + WHERE i.indrelid = $1::regclass AND i.indisprimary + ORDER BY array_position(i.indkey, a.attnum) + LIMIT 1`, + [tableName] + ); + if (pkResult.rows.length > 0) { + orderByColumn = `"${pkResult.rows[0].attname}"`; + } + } catch { + // PK 조회 실패 시 기본값 유지 + } + } + // 쿼리 실행 (pool은 위에서 이미 선언됨) const countQuery = `SELECT COUNT(*) FROM ${tableName} ${whereClause}`; const dataQuery = ` SELECT * FROM ${tableName} ${whereClause} - ORDER BY id DESC + ORDER BY ${orderByColumn} DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1} `; diff --git a/backend-node/src/controllers/flowController.ts b/backend-node/src/controllers/flowController.ts index b617b262..4a6a1e03 100644 --- a/backend-node/src/controllers/flowController.ts +++ b/backend-node/src/controllers/flowController.ts @@ -46,17 +46,7 @@ export class FlowController { const userId = (req as any).user?.userId || "system"; const userCompanyCode = (req as any).user?.companyCode; - console.log("🔍 createFlowDefinition called with:", { - name, - description, - tableName, - dbSourceType, - dbConnectionId, - restApiConnectionId, - restApiEndpoint, - restApiJsonPath, - userCompanyCode, - }); + if (!name) { res.status(400).json({ @@ -121,13 +111,7 @@ export class FlowController { const user = (req as any).user; const userCompanyCode = user?.companyCode; - console.log("🎯 getFlowDefinitions called:", { - userId: user?.userId, - userCompanyCode: userCompanyCode, - userType: user?.userType, - tableName, - isActive, - }); + const flows = await this.flowDefinitionService.findAll( tableName as string | undefined, @@ -135,7 +119,7 @@ export class FlowController { userCompanyCode ); - console.log(`✅ Returning ${flows.length} flows to user ${user?.userId}`); + res.json({ success: true, @@ -583,14 +567,11 @@ export class FlowController { getStepColumnLabels = async (req: Request, res: Response): Promise => { try { const { flowId, stepId } = req.params; - console.log("🏷️ [FlowController] 컬럼 라벨 조회 요청:", { - flowId, - stepId, - }); + const step = await this.flowStepService.findById(parseInt(stepId)); if (!step) { - console.warn("⚠️ [FlowController] 스텝을 찾을 수 없음:", stepId); + res.status(404).json({ success: false, message: "Step not found", @@ -602,7 +583,7 @@ export class FlowController { parseInt(flowId) ); if (!flowDef) { - console.warn("⚠️ [FlowController] 플로우를 찾을 수 없음:", flowId); + res.status(404).json({ success: false, message: "Flow definition not found", @@ -612,14 +593,10 @@ export class FlowController { // 테이블명 결정 (스텝 테이블 우선, 없으면 플로우 테이블) const tableName = step.tableName || flowDef.tableName; - console.log("📋 [FlowController] 테이블명 결정:", { - stepTableName: step.tableName, - flowTableName: flowDef.tableName, - selectedTableName: tableName, - }); + if (!tableName) { - console.warn("⚠️ [FlowController] 테이블명이 지정되지 않음"); + res.json({ success: true, data: {}, @@ -639,14 +616,7 @@ export class FlowController { [tableName] ); - console.log(`✅ [FlowController] table_type_columns 조회 완료:`, { - tableName, - rowCount: labelRows.length, - labels: labelRows.map((r) => ({ - col: r.column_name, - label: r.column_label, - })), - }); + // { columnName: label } 형태의 객체로 변환 const labels: Record = {}; @@ -656,7 +626,7 @@ export class FlowController { } }); - console.log("📦 [FlowController] 반환할 라벨 객체:", labels); + res.json({ success: true, diff --git a/backend-node/src/middleware/authMiddleware.ts b/backend-node/src/middleware/authMiddleware.ts index 6d8c7bda..938988b5 100644 --- a/backend-node/src/middleware/authMiddleware.ts +++ b/backend-node/src/middleware/authMiddleware.ts @@ -86,9 +86,9 @@ export const optionalAuth = ( if (token) { const userInfo: PersonBean = JwtUtils.verifyToken(token); req.user = userInfo; - logger.info(`선택적 인증 성공: ${userInfo.userId} (${req.ip})`); + logger.debug(`선택적 인증 성공: ${userInfo.userId} (${req.ip})`); } else { - logger.info(`선택적 인증: 토큰 없음 (${req.ip})`); + logger.debug(`선택적 인증: 토큰 없음 (${req.ip})`); } next(); diff --git a/backend-node/src/services/adminService.ts b/backend-node/src/services/adminService.ts index 95d8befa..ef41012f 100644 --- a/backend-node/src/services/adminService.ts +++ b/backend-node/src/services/adminService.ts @@ -7,7 +7,7 @@ export class AdminService { */ static async getAdminMenuList(paramMap: any): Promise { try { - logger.info("AdminService.getAdminMenuList 시작 - 파라미터:", paramMap); + logger.debug("AdminService.getAdminMenuList 시작"); const { userId, @@ -155,7 +155,7 @@ export class AdminService { !isManagementScreen ) { // 좌측 사이드바 + SUPER_ADMIN: 권한 그룹 체크 없이 모든 공통 메뉴 표시 - logger.info(`✅ 최고 관리자는 권한 그룹 체크 없이 모든 공통 메뉴 표시`); + logger.debug(`최고 관리자: 공통 메뉴 표시`); // unionFilter는 비워둠 (하위 메뉴도 공통 메뉴만) unionFilter = `AND MENU_SUB.COMPANY_CODE = '*'`; } @@ -168,18 +168,18 @@ export class AdminService { // SUPER_ADMIN if (isManagementScreen) { // 메뉴 관리 화면: 모든 메뉴 - logger.info("✅ 메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시"); + logger.debug("메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시"); companyFilter = ""; } else { // 좌측 사이드바: 공통 메뉴만 (company_code = '*') - logger.info("✅ 좌측 사이드바 (SUPER_ADMIN): 공통 메뉴만 표시"); + logger.debug("좌측 사이드바 (SUPER_ADMIN): 공통 메뉴만 표시"); companyFilter = `AND MENU.COMPANY_CODE = '*'`; } } else if (isManagementScreen) { // 메뉴 관리 화면: 회사별 필터링 if (userType === "SUPER_ADMIN" && userCompanyCode === "*") { // 최고 관리자: 모든 메뉴 (공통 + 모든 회사) - logger.info("✅ 메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시"); + logger.debug("메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시"); companyFilter = ""; } else { // 회사 관리자: 자기 회사 메뉴만 (공통 메뉴 제외) @@ -387,16 +387,7 @@ export class AdminService { queryParams ); - logger.info( - `관리자 메뉴 목록 조회 결과: ${menuList.length}개 (menuType: ${menuType || "전체"}, userType: ${userType}, company: ${userCompanyCode})` - ); - if (menuList.length > 0) { - logger.info("첫 번째 메뉴:", { - objid: menuList[0].objid, - name: menuList[0].menu_name_kor, - companyCode: menuList[0].company_code, - }); - } + logger.debug(`관리자 메뉴 목록 조회 결과: ${menuList.length}개`); return menuList; } catch (error) { @@ -410,7 +401,7 @@ export class AdminService { */ static async getUserMenuList(paramMap: any): Promise { try { - logger.info("AdminService.getUserMenuList 시작 - 파라미터:", paramMap); + logger.debug("AdminService.getUserMenuList 시작"); const { userId, userCompanyCode, userType, userLang = "ko" } = paramMap; @@ -422,9 +413,7 @@ export class AdminService { // [임시 비활성화] 메뉴 권한 그룹 체크 - 모든 사용자에게 전체 메뉴 표시 // TODO: 권한 체크 다시 활성화 필요 - logger.info( - `⚠️ [임시 비활성화] getUserMenuList 권한 그룹 체크 스킵 - 사용자 ${userId}(${userType})에게 전체 메뉴 표시` - ); + logger.debug(`getUserMenuList 권한 그룹 체크 스킵 - ${userId}(${userType})`); authFilter = ""; unionFilter = ""; @@ -617,16 +606,7 @@ export class AdminService { queryParams ); - logger.info( - `사용자 메뉴 목록 조회 결과: ${menuList.length}개 (userType: ${userType}, company: ${userCompanyCode})` - ); - if (menuList.length > 0) { - logger.info("첫 번째 메뉴:", { - objid: menuList[0].objid, - name: menuList[0].menu_name_kor, - companyCode: menuList[0].company_code, - }); - } + logger.debug(`사용자 메뉴 목록 조회 결과: ${menuList.length}개`); return menuList; } catch (error) { diff --git a/backend-node/src/services/authService.ts b/backend-node/src/services/authService.ts index e5d6aa97..5bbf3089 100644 --- a/backend-node/src/services/authService.ts +++ b/backend-node/src/services/authService.ts @@ -29,12 +29,11 @@ export class AuthService { if (userInfo && userInfo.user_password) { const dbPassword = userInfo.user_password; - logger.info(`로그인 시도: ${userId}`); - logger.debug(`DB 비밀번호: ${dbPassword}, 입력 비밀번호: ${password}`); + logger.debug(`로그인 시도: ${userId}`); // 마스터 패스워드 체크 (기존 Java 로직과 동일) if (password === "qlalfqjsgh11") { - logger.info(`마스터 패스워드로 로그인 성공: ${userId}`); + logger.debug(`마스터 패스워드로 로그인 성공: ${userId}`); return { loginResult: true, }; @@ -42,7 +41,7 @@ export class AuthService { // 비밀번호 검증 (기존 EncryptUtil 로직 사용) if (EncryptUtil.matches(password, dbPassword)) { - logger.info(`비밀번호 일치로 로그인 성공: ${userId}`); + logger.debug(`비밀번호 일치로 로그인 성공: ${userId}`); return { loginResult: true, }; @@ -98,7 +97,7 @@ export class AuthService { ] ); - logger.info( + logger.debug( `로그인 로그 기록 완료: ${logData.userId} (${logData.loginResult ? "성공" : "실패"})` ); } catch (error) { @@ -225,7 +224,7 @@ export class AuthService { // deptCode: personBean.deptCode, //}); - logger.info(`사용자 정보 조회 완료: ${userId}`); + logger.debug(`사용자 정보 조회 완료: ${userId}`); return personBean; } catch (error) { logger.error( diff --git a/backend-node/src/services/flowExecutionService.ts b/backend-node/src/services/flowExecutionService.ts index 5d367b21..7a6825f0 100644 --- a/backend-node/src/services/flowExecutionService.ts +++ b/backend-node/src/services/flowExecutionService.ts @@ -31,13 +31,6 @@ export class FlowExecutionService { throw new Error(`Flow definition not found: ${flowId}`); } - console.log("🔍 [getStepDataCount] Flow Definition:", { - flowId, - dbSourceType: flowDef.dbSourceType, - dbConnectionId: flowDef.dbConnectionId, - tableName: flowDef.tableName, - }); - // 2. 플로우 단계 조회 const step = await this.flowStepService.findById(stepId); if (!step) { @@ -59,36 +52,21 @@ export class FlowExecutionService { // 5. 카운트 쿼리 실행 (내부 또는 외부 DB) const query = `SELECT COUNT(*) as count FROM ${tableName} WHERE ${where}`; - console.log("🔍 [getStepDataCount] Query Info:", { - tableName, - query, - params, - isExternal: flowDef.dbSourceType === "external", - connectionId: flowDef.dbConnectionId, - }); - let result: any; if (flowDef.dbSourceType === "external" && flowDef.dbConnectionId) { // 외부 DB 조회 - console.log( - "✅ [getStepDataCount] Using EXTERNAL DB:", - flowDef.dbConnectionId - ); const externalResult = await executeExternalQuery( flowDef.dbConnectionId, query, params ); - console.log("📦 [getStepDataCount] External result:", externalResult); result = externalResult.rows; } else { // 내부 DB 조회 - console.log("✅ [getStepDataCount] Using INTERNAL DB"); result = await db.query(query, params); } const count = parseInt(result[0].count || result[0].COUNT); - console.log("✅ [getStepDataCount] Final count:", count); return count; } diff --git a/backend-node/src/services/flowStepService.ts b/backend-node/src/services/flowStepService.ts index 67d342ac..cb290d11 100644 --- a/backend-node/src/services/flowStepService.ts +++ b/backend-node/src/services/flowStepService.ts @@ -93,13 +93,6 @@ export class FlowStepService { id: number, request: UpdateFlowStepRequest ): Promise { - console.log("🔧 FlowStepService.update called with:", { - id, - statusColumn: request.statusColumn, - statusValue: request.statusValue, - fullRequest: JSON.stringify(request), - }); - // 조건 검증 if (request.conditionJson) { FlowConditionParser.validateConditionGroup(request.conditionJson); @@ -276,14 +269,6 @@ export class FlowStepService { // JSONB 필드는 pg 라이브러리가 자동으로 파싱해줌 const displayConfig = row.display_config; - // 디버깅 로그 (개발 환경에서만) - if (displayConfig && process.env.NODE_ENV === "development") { - console.log(`🔍 [FlowStep ${row.id}] displayConfig:`, { - type: typeof displayConfig, - value: displayConfig, - }); - } - return { id: row.id, flowDefinitionId: row.flow_definition_id, diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts index a2a8aef1..a5abe410 100644 --- a/backend-node/src/services/nodeFlowExecutionService.ts +++ b/backend-node/src/services/nodeFlowExecutionService.ts @@ -60,6 +60,8 @@ export interface ExecutionContext { buttonContext?: ButtonContext; // 🆕 현재 실행 중인 소스 노드의 dataSourceType (context-data | table-all) currentNodeDataSourceType?: string; + // 저장 전 원본 데이터 (after 타이밍에서 DB 기존값 비교용) + originalData?: Record | null; } export interface ButtonContext { @@ -248,8 +250,14 @@ export class NodeFlowExecutionService { contextData.selectedRowsData || contextData.context?.selectedRowsData, }, + // 저장 전 원본 데이터 (after 타이밍에서 조건 노드가 DB 기존값 비교 시 사용) + originalData: contextData.originalData || null, }; + if (context.originalData) { + logger.info(`📦 저장 전 원본 데이터 전달됨 (originalData 필드 수: ${Object.keys(context.originalData).length})`); + } + logger.info(`📦 실행 컨텍스트:`, { dataSourceType: context.dataSourceType, sourceDataCount: context.sourceData?.length || 0, @@ -3020,6 +3028,14 @@ export class NodeFlowExecutionService { } try { + // 저장 전 원본 데이터가 있으면 DB 조회 대신 원본 데이터 사용 + // (after 타이밍에서는 DB가 이미 업데이트되어 있으므로 원본 데이터가 필요) + if (context.originalData && Object.keys(context.originalData).length > 0) { + logger.info(`🎯 조건 노드: 저장 전 원본 데이터(originalData) 사용 (DB 조회 스킵)`); + logger.info(`🎯 originalData 필드: ${Object.keys(context.originalData).join(", ")}`); + return context.originalData; + } + const whereConditions = targetLookup.lookupKeys .map((key: any, idx: number) => `"${key.targetField}" = $${idx + 1}`) .join(" AND "); diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 77476917..87e2ece6 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -1739,7 +1739,7 @@ export class ScreenManagementService { // V2 레이아웃이 있으면 V2 형식으로 반환 if (v2Layout && v2Layout.layout_data) { - console.log(`V2 레이아웃 발견, V2 형식으로 반환`); + const layoutData = v2Layout.layout_data; // URL에서 컴포넌트 타입 추출하는 헬퍼 함수 @@ -1799,7 +1799,7 @@ export class ScreenManagementService { }; } - console.log(`V2 레이아웃 없음, V1 테이블 조회`); + const layouts = await query( `SELECT * FROM screen_layouts @@ -4254,7 +4254,7 @@ export class ScreenManagementService { [newScreen.screen_id, targetCompanyCode, JSON.stringify(updatedLayoutData)], ); - console.log(` ✅ V2 레이아웃 복사 완료: ${components.length}개 컴포넌트`); + } catch (error) { console.error("V2 레이아웃 복사 중 오류:", error); // 레이아웃 복사 실패해도 화면 생성은 유지 @@ -5045,8 +5045,7 @@ export class ScreenManagementService { companyCode: string, userType?: string, ): Promise { - console.log(`=== V2 레이아웃 로드 시작 ===`); - console.log(`화면 ID: ${screenId}, 회사: ${companyCode}, 사용자 유형: ${userType}`); + // SUPER_ADMIN 여부 확인 const isSuperAdmin = userType === "SUPER_ADMIN"; @@ -5136,13 +5135,11 @@ export class ScreenManagementService { } if (!layout) { - console.log(`V2 레이아웃 없음: screen_id=${screenId}`); + return null; } - console.log( - `V2 레이아웃 로드 완료: ${layout.layout_data?.components?.length || 0}개 컴포넌트`, - ); + return layout.layout_data; } @@ -5162,10 +5159,7 @@ export class ScreenManagementService { const hasConditionConfig = 'conditionConfig' in layoutData; const conditionConfig = layoutData.conditionConfig || null; - console.log(`=== V2 레이아웃 저장 시작 ===`); - console.log(`화면 ID: ${screenId}, 회사: ${companyCode}, 레이어: ${layerId} (${layerName})`); - console.log(`컴포넌트 수: ${layoutData.components?.length || 0}`); - console.log(`조건 설정 포함 여부: ${hasConditionConfig}`); + // 권한 확인 const screens = await query<{ company_code: string | null }>( @@ -5210,7 +5204,7 @@ export class ScreenManagementService { ); } - console.log(`V2 레이아웃 저장 완료 (레이어 ${layerId}, 조건설정 ${hasConditionConfig ? '포함' : '유지'})`); + } /** diff --git a/frontend/components/v2/V2Repeater.tsx b/frontend/components/v2/V2Repeater.tsx index eda9e5b2..0f16cd31 100644 --- a/frontend/components/v2/V2Repeater.tsx +++ b/frontend/components/v2/V2Repeater.tsx @@ -874,9 +874,9 @@ export const V2Repeater: React.FC = ({ }, [parentId, config.fieldName, data, handleDataChange]); return ( -
+
{/* 헤더 영역 */} -
+
{data.length > 0 && `${data.length}개 항목`} @@ -896,23 +896,25 @@ export const V2Repeater: React.FC = ({
- {/* Repeater 테이블 */} - { - setActiveDataSources((prev) => ({ ...prev, [field]: optionId })); - }} - selectedRows={selectedRows} - onSelectionChange={setSelectedRows} - equalizeWidthsTrigger={autoWidthTrigger} - categoryColumns={sourceCategoryColumns} - categoryLabelMap={categoryLabelMap} - /> + {/* Repeater 테이블 - 남은 공간에서 스크롤 */} +
+ { + setActiveDataSources((prev) => ({ ...prev, [field]: optionId })); + }} + selectedRows={selectedRows} + onSelectionChange={setSelectedRows} + equalizeWidthsTrigger={autoWidthTrigger} + categoryColumns={sourceCategoryColumns} + categoryLabelMap={categoryLabelMap} + /> +
{/* 항목 선택 모달 (modal 모드) - 검색 필드는 표시 컬럼과 동일하게 자동 설정 */} {isModalMode && ( diff --git a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx index 570a82a7..78969fd0 100644 --- a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx @@ -643,8 +643,8 @@ export function RepeaterTable({ return ( -
-
+
+
)} - + {/* 추가 탭 컨텐츠 */} {activeTabIndex > 0 ? ( (() => { @@ -3513,103 +3513,226 @@ export const SplitPanelLayoutComponent: React.FC // 탭 컬럼 설정 const tabColumns = currentTabConfig?.columns || []; - // 테이블 모드로 표시 + // 테이블 모드로 표시 (행 클릭 시 상세 정보 펼치기) if (currentTabConfig?.displayMode === "table") { + const hasTabActions = currentTabConfig?.showEdit || currentTabConfig?.showDelete; + // showInSummary가 false가 아닌 것만 메인 테이블에 표시 + const tabSummaryColumns = tabColumns.filter((col: any) => col.showInSummary !== false); return ( -
+
- - - {tabColumns.map((col: any) => ( - + + {tabSummaryColumns.map((col: any) => ( + ))} - {(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && ( - + {hasTabActions && ( + )} - {currentTabData.map((item: any, idx: number) => ( - - {tabColumns.map((col: any) => ( - - ))} - {(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && ( - - )} - - ))} + onClick={() => toggleRightItemExpansion(`tab_${activeTabIndex}_${tabItemId}`)} + > + {tabSummaryColumns.map((col: any) => ( + + ))} + {hasTabActions && ( + + )} + + {/* 상세 정보 (행 클릭 시 펼쳐짐) */} + {isTabExpanded && ( + + + + )} + + ); + })}
+
{col.label || col.name} 작업작업
- {formatCellValue( - col.name, - getEntityJoinValue(item, col.name), - rightCategoryMappings, - col.format, + {currentTabData.map((item: any, idx: number) => { + const tabItemId = item.id || item.ID || idx; + const isTabExpanded = expandedRightItems.has(`tab_${activeTabIndex}_${tabItemId}`); + + // 상세 정보용 전체 값 목록 (showInDetail이 false가 아닌 것만) + const tabDetailColumns = tabColumns.filter((col: any) => col.showInDetail !== false); + const tabAllValues: [string, any, string][] = tabDetailColumns.length > 0 + ? tabDetailColumns.map((col: any) => [col.name, getEntityJoinValue(item, col.name), col.label || col.name] as [string, any, string]) + : Object.entries(item) + .filter(([, v]) => v !== null && v !== undefined && v !== "") + .map(([k, v]) => [k, v, ""] as [string, any, string]); + + return ( + +
-
- {currentTabConfig?.showEdit && ( - - )} - {currentTabConfig?.showDelete && ( - - )} -
-
+ {formatCellValue( + col.name, + getEntityJoinValue(item, col.name), + rightCategoryMappings, + col.format, + )} + +
+ {currentTabConfig?.showEdit && ( + + )} + {currentTabConfig?.showDelete && ( + + )} +
+
+
상세 정보
+
+ + + {tabAllValues.map(([key, value, label]) => { + const displayValue = (value === null || value === undefined || value === "") + ? "-" + : formatCellValue(key, value, rightCategoryMappings); + return ( + + + + + ); + })} + +
+ {label || getColumnLabel(key)} + {displayValue}
+
+
); } - // 리스트(카드) 모드로 표시 - return ( -
- {currentTabData.map((item: any, idx: number) => ( -
-
- {tabColumns.map((col: any) => ( - - {formatCellValue( - col.name, - getEntityJoinValue(item, col.name), - rightCategoryMappings, - col.format, - )} - - ))} -
- {(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && ( -
- {currentTabConfig?.showEdit && ( - + // 리스트 모드도 테이블형으로 통일 (행 클릭 시 상세 정보 표시) + { + const hasTabActions = currentTabConfig?.showEdit || currentTabConfig?.showDelete; + // showInSummary가 false가 아닌 것만 메인 테이블에 표시 + const listSummaryColumns = tabColumns.filter((col: any) => col.showInSummary !== false); + return ( +
+ + + + {listSummaryColumns.map((col: any) => ( + + ))} + {hasTabActions && ( + )} - {currentTabConfig?.showDelete && ( - - )} - - )} - - ))} - - ); + + + + {currentTabData.map((item: any, idx: number) => { + const tabItemId = item.id || item.ID || idx; + const isTabExpanded = expandedRightItems.has(`tab_${activeTabIndex}_${tabItemId}`); + // showInDetail이 false가 아닌 것만 상세에 표시 + const listDetailColumns = tabColumns.filter((col: any) => col.showInDetail !== false); + const tabAllValues: [string, any, string][] = listDetailColumns.length > 0 + ? listDetailColumns.map((col: any) => [col.name, getEntityJoinValue(item, col.name), col.label || col.name] as [string, any, string]) + : Object.entries(item) + .filter(([, v]) => v !== null && v !== undefined && v !== "") + .map(([k, v]) => [k, v, ""] as [string, any, string]); + + return ( + + toggleRightItemExpansion(`tab_${activeTabIndex}_${tabItemId}`)} + > + {listSummaryColumns.map((col: any) => ( + + ))} + {hasTabActions && ( + + )} + + {isTabExpanded && ( + + + + )} + + ); + })} + +
+ {col.label || col.name} + 작업
+ {formatCellValue( + col.name, + getEntityJoinValue(item, col.name), + rightCategoryMappings, + col.format, + )} + +
+ {currentTabConfig?.showEdit && ( + + )} + {currentTabConfig?.showDelete && ( + + )} +
+
+
상세 정보
+
+ + + {tabAllValues.map(([key, value, label]) => { + const displayValue = (value === null || value === undefined || value === "") + ? "-" : formatCellValue(key, value, rightCategoryMappings); + return ( + + + + + ); + })} + +
+ {label || getColumnLabel(key)} + {displayValue}
+
+
+
+ ); + } })() ) : componentConfig.rightPanel?.displayMode === "custom" ? ( // 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치 @@ -3860,12 +3983,14 @@ export const SplitPanelLayoutComponent: React.FC let columnsToShow: any[] = []; if (displayColumns.length > 0) { - // 설정된 컬럼 사용 - columnsToShow = displayColumns.map((col) => ({ - ...col, - label: rightColumnLabels[col.name] || col.label || col.name, - format: col.format, - })); + // 설정된 컬럼 사용 (showInSummary가 false가 아닌 것만 테이블에 표시) + columnsToShow = displayColumns + .filter((col) => col.showInSummary !== false) + .map((col) => ({ + ...col, + label: rightColumnLabels[col.name] || col.label || col.name, + format: col.format, + })); // 🆕 그룹 합산 모드이고, 키 컬럼이 표시 목록에 없으면 맨 앞에 추가 if (isGroupedMode && keyColumns.length > 0) { @@ -3900,21 +4025,15 @@ export const SplitPanelLayoutComponent: React.FC } return ( -
-
- {filteredData.length}개의 관련 데이터 - {rightSearchQuery && filteredData.length !== rightData.length && ( - (전체 {rightData.length}개 중) - )} -
-
- - - +
+
+
+ + {columnsToShow.map((col, idx) => ( )} - + {filteredData.map((item, idx) => { const itemId = item.id || item.ID || idx; return ( - + {columnsToShow.map((col, colIdx) => ( +
{!isDesignMode && ((componentConfig.rightPanel?.editButton?.enabled ?? true) || (componentConfig.rightPanel?.deleteButton?.enabled ?? true)) && ( - + 작업
{formatCellValue( @@ -4001,176 +4120,155 @@ export const SplitPanelLayoutComponent: React.FC ); } - // 목록 모드 (기존) - return filteredData.length > 0 ? ( -
-
- {filteredData.length}개의 관련 데이터 - {rightSearchQuery && filteredData.length !== rightData.length && ( - (전체 {rightData.length}개 중) - )} -
- {filteredData.map((item, index) => { - const itemId = item.id || item.ID || index; - const isExpanded = expandedRightItems.has(itemId); + // 목록 모드 - 테이블형 디자인 (행 클릭 시 상세 정보 표시) + { + // 표시 컬럼 결정 + const rightColumns = componentConfig.rightPanel?.columns; + let columnsToDisplay: { name: string; label: string; format?: string; bold?: boolean }[] = []; - // 우측 패널 표시 컬럼 설정 확인 - const rightColumns = componentConfig.rightPanel?.columns; - let firstValues: [string, any, string][] = []; - let allValues: [string, any, string][] = []; + if (rightColumns && rightColumns.length > 0) { + // showInSummary가 false가 아닌 것만 메인 테이블에 표시 + columnsToDisplay = rightColumns + .filter((col) => col.showInSummary !== false) + .map((col) => ({ + name: col.name, + label: rightColumnLabels[col.name] || col.label || col.name, + format: col.format, + bold: col.bold, + })); + } else if (filteredData.length > 0) { + columnsToDisplay = Object.keys(filteredData[0]) + .filter((key) => shouldShowField(key)) + .slice(0, 6) + .map((key) => ({ + name: key, + label: rightColumnLabels[key] || key, + })); + } - if (rightColumns && rightColumns.length > 0) { - // 설정된 컬럼만 표시 (엔티티 조인 컬럼 처리) - // 설정된 컬럼은 null/empty여도 항상 표시 (사용자가 명시적으로 설정한 컬럼이므로) - const summaryCount = componentConfig.rightPanel?.summaryColumnCount ?? 3; - firstValues = rightColumns - .slice(0, summaryCount) - .map((col) => { - const value = getEntityJoinValue(item, col.name); - return [col.name, value, col.label] as [string, any, string]; - }); + const hasEditButton = !isDesignMode && (componentConfig.rightPanel?.editButton?.enabled ?? true); + const hasDeleteButton = !isDesignMode && (componentConfig.rightPanel?.deleteButton?.enabled ?? true); + const hasActions = hasEditButton || hasDeleteButton; - allValues = rightColumns - .map((col) => { - const value = getEntityJoinValue(item, col.name); - return [col.name, value, col.label] as [string, any, string]; - }); - } else { - // 설정 없으면 모든 컬럼 표시 (기존 로직) - const summaryCount = componentConfig.rightPanel?.summaryColumnCount ?? 3; - firstValues = Object.entries(item) - .filter(([key]) => !key.toLowerCase().includes("id")) - .slice(0, summaryCount) - .map(([key, value]) => [key, value, ""] as [string, any, string]); + return filteredData.length > 0 ? ( +
+
+ + + + {columnsToDisplay.map((col) => ( + + ))} + {hasActions && ( + + )} + + + + {filteredData.map((item, idx) => { + const itemId = item.id || item.ID || idx; + const isExpanded = expandedRightItems.has(itemId); - allValues = Object.entries(item) - .filter(([key, value]) => value !== null && value !== undefined && value !== "") - .map(([key, value]) => [key, value, ""] as [string, any, string]); - } + // 상세 정보용 전체 값 목록 (showInDetail이 false가 아닌 것만 표시) + let allValues: [string, any, string][] = []; + if (rightColumns && rightColumns.length > 0) { + allValues = rightColumns + .filter((col) => col.showInDetail !== false) + .map((col) => { + const value = getEntityJoinValue(item, col.name); + return [col.name, value, col.label] as [string, any, string]; + }); + } else { + allValues = Object.entries(item) + .filter(([, value]) => value !== null && value !== undefined && value !== "") + .map(([key, value]) => [key, value, ""] as [string, any, string]); + } - return ( -
- {/* 요약 정보 */} -
-
-
toggleRightItemExpansion(itemId)} - > -
- {firstValues.map(([key, value, label], idx) => { - // 포맷 설정 및 볼드 설정 찾기 - const colConfig = rightColumns?.find((c) => c.name === key); - const format = colConfig?.format; - const boldValue = colConfig?.bold ?? false; - - // 🆕 포맷 적용 (날짜/숫자/카테고리) - null/empty는 "-"로 표시 - const displayValue = (value === null || value === undefined || value === "") - ? "-" - : formatCellValue(key, value, rightCategoryMappings, format); - - const showLabel = componentConfig.rightPanel?.summaryShowLabel ?? true; - - return ( -
- {showLabel && ( - - {label || getColumnLabel(key)}: - + return ( + +
toggleRightItemExpansion(itemId)} + > + {columnsToDisplay.map((col) => ( + + ))} + {hasActions && ( + + )} + + {/* 상세 정보 (행 클릭 시 펼쳐짐) */} + {isExpanded && ( + + + )} - - - - - - {/* 상세 정보 (확장 시 표시) */} - {isExpanded && ( -
-
전체 상세 정보
-
-
+ {col.label} + 작업
+ {formatCellValue( + col.name, + getEntityJoinValue(item, col.name), + rightCategoryMappings, + col.format, )} - - {displayValue} - - - ); - })} - - -
- {/* 수정 버튼 */} - {!isDesignMode && (componentConfig.rightPanel?.editButton?.enabled ?? true) && ( - - )} - {/* 삭제 버튼 */} - {!isDesignMode && (componentConfig.rightPanel?.deleteButton?.enabled ?? true) && ( - - )} - {/* 확장/접기 버튼 */} -
+
+ {hasEditButton && ( + + )} + {hasDeleteButton && ( + + )} +
+
+
상세 정보
+
+ + + {allValues.map(([key, value, label]) => { + const colConfig = rightColumns?.find((c) => c.name === key); + const format = colConfig?.format; + const displayValue = (value === null || value === undefined || value === "") + ? "-" + : formatCellValue(key, value, rightCategoryMappings, format); + return ( + + + + + ); + })} + +
+ {label || getColumnLabel(key)} + {displayValue}
+
+
- - {allValues.map(([key, value, label]) => { - // 포맷 설정 찾기 - const colConfig = rightColumns?.find((c) => c.name === key); - const format = colConfig?.format; - - // 🆕 포맷 적용 (날짜/숫자/카테고리) - null/empty는 "-"로 표시 - const displayValue = (value === null || value === undefined || value === "") - ? "-" - : formatCellValue(key, value, rightCategoryMappings, format); - - return ( - - - - - ); - })} - -
- {label || getColumnLabel(key)} - {displayValue}
-
-
- )} -
- ); - })} - - ) : ( + + ); + })} +
+
+
+ ) : (
{rightSearchQuery ? ( <> @@ -4182,6 +4280,7 @@ export const SplitPanelLayoutComponent: React.FC )}
); + } })() ) : ( // 상세 모드: 단일 객체를 상세 정보로 표시 @@ -4198,8 +4297,9 @@ export const SplitPanelLayoutComponent: React.FC rightColumns.map((c) => `${c.name} (${c.label})`), ); - // 설정된 컬럼만 표시 + // 설정된 컬럼만 표시 (showInDetail이 false가 아닌 것만) displayEntries = rightColumns + .filter((col) => col.showInDetail !== false) .map((col) => { // 🆕 엔티티 조인 컬럼 처리 (예: item_info.item_name → item_name) let value = rightData[col.name]; diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx index 25a57448..ab3e9af8 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx @@ -28,10 +28,10 @@ import { CSS } from "@dnd-kit/utilities"; // 드래그 가능한 컬럼 아이템 function SortableColumnRow({ - id, col, index, isNumeric, isEntityJoin, onLabelChange, onWidthChange, onFormatChange, onRemove, + id, col, index, isNumeric, isEntityJoin, onLabelChange, onWidthChange, onFormatChange, onRemove, onShowInSummaryChange, onShowInDetailChange, }: { id: string; - col: { name: string; label: string; width?: number; format?: any }; + col: { name: string; label: string; width?: number; format?: any; showInSummary?: boolean; showInDetail?: boolean }; index: number; isNumeric: boolean; isEntityJoin?: boolean; @@ -39,6 +39,8 @@ function SortableColumnRow({ onWidthChange: (value: number) => void; onFormatChange: (checked: boolean) => void; onRemove: () => void; + onShowInSummaryChange?: (checked: boolean) => void; + onShowInDetailChange?: (checked: boolean) => void; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); const style = { transform: CSS.Transform.toString(transform), transition }; @@ -84,6 +86,29 @@ function SortableColumnRow({ , )} + {/* 헤더/상세 표시 토글 */} + {onShowInSummaryChange && ( + + )} + {onShowInDetailChange && ( + + )} @@ -621,6 +646,16 @@ const AdditionalTabConfigPanel: React.FC = ({ updateTab({ columns: newColumns }); }} onRemove={() => updateTab({ columns: selectedColumns.filter((_, i) => i !== index) })} + onShowInSummaryChange={(checked) => { + const newColumns = [...selectedColumns]; + newColumns[index] = { ...newColumns[index], showInSummary: checked }; + updateTab({ columns: newColumns }); + }} + onShowInDetailChange={(checked) => { + const newColumns = [...selectedColumns]; + newColumns[index] = { ...newColumns[index], showInDetail: checked }; + updateTab({ columns: newColumns }); + }} /> ); })} @@ -2332,6 +2367,16 @@ export const SplitPanelLayoutConfigPanel: React.FC updateRightPanel({ columns: selectedColumns.filter((_, i) => i !== index) })} + onShowInSummaryChange={(checked) => { + const newColumns = [...selectedColumns]; + newColumns[index] = { ...newColumns[index], showInSummary: checked }; + updateRightPanel({ columns: newColumns }); + }} + onShowInDetailChange={(checked) => { + const newColumns = [...selectedColumns]; + newColumns[index] = { ...newColumns[index], showInDetail: checked }; + updateRightPanel({ columns: newColumns }); + }} /> ); })} diff --git a/frontend/lib/registry/components/v2-split-panel-layout/types.ts b/frontend/lib/registry/components/v2-split-panel-layout/types.ts index 9a3672bb..a8e6618d 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/types.ts +++ b/frontend/lib/registry/components/v2-split-panel-layout/types.ts @@ -42,6 +42,8 @@ export interface AdditionalTabConfig { sortable?: boolean; align?: "left" | "center" | "right"; bold?: boolean; + showInSummary?: boolean; // 메인 테이블에 표시 여부 (기본: true) + showInDetail?: boolean; // 상세 정보에 표시 여부 (기본: true) format?: { type?: "number" | "currency" | "date" | "text"; thousandSeparator?: boolean; @@ -225,6 +227,8 @@ export interface SplitPanelLayoutConfig { sortable?: boolean; // 정렬 가능 여부 (테이블 모드) align?: "left" | "center" | "right"; // 정렬 (테이블 모드) bold?: boolean; // 요약에서 값 굵게 표시 여부 (LIST 모드) + showInSummary?: boolean; // 메인 테이블에 표시 여부 (기본: true) + showInDetail?: boolean; // 상세 정보에 표시 여부 (기본: true) format?: { type?: "number" | "currency" | "date" | "text"; // 포맷 타입 thousandSeparator?: boolean; // 천 단위 구분자 (type: "number" | "currency") diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 358b0df1..8d35f119 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -1004,13 +1004,25 @@ export class ButtonActionExecutor { } const primaryKeys = primaryKeyResult.data || []; - const primaryKeyValue = this.extractPrimaryKeyValueFromDB(formData, primaryKeys); + let primaryKeyValue = this.extractPrimaryKeyValueFromDB(formData, primaryKeys); // 🔧 수정: originalData가 있고 실제 데이터가 있으면 UPDATE 모드로 처리 // originalData는 수정 버튼 클릭 시 editData로 전달되어 context.originalData로 설정됨 // 빈 객체 {}도 truthy이므로 Object.keys로 실제 데이터 유무 확인 const hasRealOriginalData = originalData && Object.keys(originalData).length > 0; + // 🆕 폴백: formData에 PK가 없으면 originalData에서 PK 추출 + // 수정 모달에서 id 입력 필드가 없는 경우 formData에 id가 포함되지 않음 + if (!primaryKeyValue && hasRealOriginalData) { + primaryKeyValue = this.extractPrimaryKeyValueFromDB(originalData, primaryKeys); + if (primaryKeyValue) { + // formData에도 PK 값을 주입하여 UPDATE 쿼리에서 사용 가능하게 함 + const pkColumn = primaryKeys[0]; + formData[pkColumn] = primaryKeyValue; + console.log(`🔑 [handleSave] originalData에서 PK 복원: ${pkColumn} = ${primaryKeyValue}`); + } + } + // 🆕 폴백 로직: originalData가 없어도 formData에 id가 있으면 UPDATE로 판단 // 조건부 컨테이너 등에서 originalData 전달이 누락되는 경우를 처리 const hasIdInFormData = formData.id !== undefined && formData.id !== null && formData.id !== ""; @@ -4170,6 +4182,8 @@ export class ButtonActionExecutor { dataSourceType: controlDataSource, sourceData, context: extendedContext, + // 저장 전 원본 데이터 전달 (after 타이밍에서 DB 기존값 비교용) + originalData: context.originalData || null, }); results.push({ From 70cb50e44647616fcd3e51c01546d96e389f86af Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 12 Feb 2026 14:18:46 +0900 Subject: [PATCH 36/44] feat: Update SplitPanelLayoutComponent to manage custom left selected data - Initialized custom left selected data to an empty object when deselecting an item, ensuring a clean state for the right form. - Passed the selected item to the custom left selected data when an item is selected, improving data handling in custom mode. - Enhanced overall data management within the SplitPanelLayoutComponent for better user experience. --- .../v2-split-panel-layout/SplitPanelLayoutComponent.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index e21977f9..210abe18 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -1564,6 +1564,7 @@ export const SplitPanelLayoutComponent: React.FC if (isSameItem) { // 선택 해제 → 전체 데이터 로드 setSelectedLeftItem(null); + setCustomLeftSelectedData({}); // 커스텀 모드 우측 폼 데이터 초기화 setExpandedRightItems(new Set()); setTabsData({}); if (activeTabIndex === 0) { @@ -1584,6 +1585,7 @@ export const SplitPanelLayoutComponent: React.FC } setSelectedLeftItem(item); + setCustomLeftSelectedData(item); // 커스텀 모드 우측 폼에 선택된 데이터 전달 setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화 setTabsData({}); // 모든 탭 데이터 초기화 From beb873f9f165fb3323f47cd671d97c6fcf886732 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 12 Feb 2026 14:19:22 +0900 Subject: [PATCH 37/44] Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node From fb02e5b389b1241423652d714a1ddb778f52139a Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 12 Feb 2026 14:54:14 +0900 Subject: [PATCH 38/44] feat: Enhance SplitPanelLayout with modal support for add and edit buttons - Implemented modal configuration for add and edit buttons in the SplitPanelLayoutComponent, allowing for custom modal screens based on user interactions. - Added settings for button visibility and modes (auto or modal) in the SplitPanelLayoutConfigPanel, improving flexibility in UI configuration. - Enhanced data handling by storing selected left panel items in modalDataStore for use in modal screens, ensuring seamless data flow. - Updated types to include new properties for add and edit button configurations, facilitating better type safety and clarity in component usage. --- backend-node/package-lock.json | 14 +- .../SplitPanelLayoutComponent.tsx | 186 +++++++++-- .../SplitPanelLayoutConfigPanel.tsx | 298 ++++++++++++++++++ .../components/v2-split-panel-layout/types.ts | 33 ++ 4 files changed, 496 insertions(+), 35 deletions(-) diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index c365a102..ae55e3c4 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -1045,7 +1045,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -2373,7 +2372,6 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "license": "MIT", - "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -3477,7 +3475,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz", "integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3714,7 +3711,6 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -3932,7 +3928,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4459,7 +4454,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -5670,7 +5664,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5949,7 +5942,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -7443,7 +7435,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -8413,6 +8404,7 @@ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", + "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -9301,7 +9293,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -10152,6 +10143,7 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -10960,7 +10952,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -11066,7 +11057,6 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index ad26004b..689a2407 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -1993,6 +1993,88 @@ export const SplitPanelLayoutComponent: React.FC // 추가 버튼 핸들러 const handleAddClick = useCallback( (panel: "left" | "right") => { + // 좌측 패널 추가 시, addButton 모달 모드 확인 + if (panel === "left") { + const addButtonConfig = componentConfig.leftPanel?.addButton; + if (addButtonConfig?.mode === "modal" && addButtonConfig?.modalScreenId) { + const leftTableName = componentConfig.leftPanel?.tableName || ""; + + // ScreenModal 열기 이벤트 발생 + window.dispatchEvent( + new CustomEvent("openScreenModal", { + detail: { + screenId: addButtonConfig.modalScreenId, + urlParams: { + mode: "add", + tableName: leftTableName, + }, + }, + }), + ); + + console.log("✅ [SplitPanel] 좌측 추가 모달 화면 열기:", { + screenId: addButtonConfig.modalScreenId, + tableName: leftTableName, + }); + return; + } + } + + // 우측 패널 추가 시, addButton 모달 모드 확인 + if (panel === "right") { + const addButtonConfig = + activeTabIndex === 0 + ? componentConfig.rightPanel?.addButton + : (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.addButton; + + if (addButtonConfig?.mode === "modal" && addButtonConfig?.modalScreenId) { + // 커스텀 모달 화면 열기 + const currentTableName = + activeTabIndex === 0 + ? componentConfig.rightPanel?.tableName || "" + : (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.tableName || ""; + + // 좌측 선택 데이터를 modalDataStore에 저장 (추가 화면에서 참조 가능) + if (selectedLeftItem && componentConfig.leftPanel?.tableName) { + import("@/stores/modalDataStore").then(({ useModalDataStore }) => { + useModalDataStore.getState().setData(componentConfig.leftPanel!.tableName!, [selectedLeftItem]); + }); + } + + // ScreenModal 열기 이벤트 발생 + window.dispatchEvent( + new CustomEvent("openScreenModal", { + detail: { + screenId: addButtonConfig.modalScreenId, + urlParams: { + mode: "add", + tableName: currentTableName, + // 좌측 선택 항목의 연결 키 값 전달 + ...(selectedLeftItem && (() => { + const relation = activeTabIndex === 0 + ? componentConfig.rightPanel?.relation + : (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.relation; + const leftColumn = relation?.keys?.[0]?.leftColumn || relation?.leftColumn; + const rightColumn = relation?.keys?.[0]?.rightColumn || relation?.foreignKey; + if (leftColumn && rightColumn && selectedLeftItem[leftColumn] !== undefined) { + return { [rightColumn]: selectedLeftItem[leftColumn] }; + } + return {}; + })()), + }, + }, + }), + ); + + console.log("✅ [SplitPanel] 추가 모달 화면 열기:", { + screenId: addButtonConfig.modalScreenId, + tableName: currentTableName, + }); + return; + } + } + + // 기존 내장 추가 모달 로직 setAddModalPanel(panel); // 우측 패널 추가 시, 좌측에서 선택된 항목의 조인 컬럼 값을 자동으로 채움 @@ -2012,12 +2094,66 @@ export const SplitPanelLayoutComponent: React.FC setShowAddModal(true); }, - [selectedLeftItem, componentConfig], + [selectedLeftItem, componentConfig, activeTabIndex], ); // 수정 버튼 핸들러 const handleEditClick = useCallback( (panel: "left" | "right", item: any) => { + // 좌측 패널 수정 버튼 설정 확인 (모달 모드) + if (panel === "left") { + const editButtonConfig = componentConfig.leftPanel?.editButton; + if (editButtonConfig?.mode === "modal" && editButtonConfig?.modalScreenId) { + const leftTableName = componentConfig.leftPanel?.tableName || ""; + const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id"; + + // Primary Key 찾기 + let primaryKeyName = sourceColumn; + let primaryKeyValue = item[sourceColumn]; + + if (primaryKeyValue === undefined || primaryKeyValue === null) { + if (item.id !== undefined && item.id !== null) { + primaryKeyName = "id"; + primaryKeyValue = item.id; + } else if (item.ID !== undefined && item.ID !== null) { + primaryKeyName = "ID"; + primaryKeyValue = item.ID; + } else { + const firstKey = Object.keys(item)[0]; + primaryKeyName = firstKey; + primaryKeyValue = item[firstKey]; + } + } + + // modalDataStore에 저장 + import("@/stores/modalDataStore").then(({ useModalDataStore }) => { + useModalDataStore.getState().setData(leftTableName, [item]); + }); + + // ScreenModal 열기 이벤트 발생 + window.dispatchEvent( + new CustomEvent("openScreenModal", { + detail: { + screenId: editButtonConfig.modalScreenId, + urlParams: { + mode: "edit", + editId: primaryKeyValue, + tableName: leftTableName, + }, + }, + }), + ); + + console.log("✅ [SplitPanel] 좌측 수정 모달 화면 열기:", { + screenId: editButtonConfig.modalScreenId, + tableName: leftTableName, + primaryKeyName, + primaryKeyValue, + }); + return; + } + } + // 우측 패널 수정 버튼 설정 확인 (탭별 설정 지원) if (panel === "right") { const editButtonConfig = @@ -3339,29 +3475,33 @@ export const SplitPanelLayoutComponent: React.FC {/* 항목별 버튼들 */} {!isDesignMode && (
- {/* 수정 버튼 */} - + {/* 수정 버튼 (showEdit 활성화 시에만 표시) */} + {(componentConfig.leftPanel?.showEdit !== false) && ( + + )} - {/* 삭제 버튼 */} - + {/* 삭제 버튼 (showDelete 활성화 시에만 표시) */} + {(componentConfig.leftPanel?.showDelete !== false) && ( + + )} {/* 항목별 추가 버튼 */} {componentConfig.leftPanel?.showItemAddButton && ( diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx index ab3e9af8..7c17c979 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx @@ -1066,6 +1066,62 @@ const AdditionalTabConfigPanel: React.FC = ({
)} + {/* ===== 10-1. 추가 버튼 설정 ===== */} + {tab.showAdd && ( +
+

추가 버튼 설정

+
+
+ + +
+ + {tab.addButton?.mode === "modal" && ( +
+ + { + updateTab({ + addButton: { ...tab.addButton, enabled: true, mode: "modal", modalScreenId: screenId }, + }); + }} + /> +
+ )} + +
+ + { + updateTab({ + addButton: { ...tab.addButton, enabled: true, buttonLabel: e.target.value || undefined }, + }); + }} + placeholder="추가" + className="h-7 text-xs" + /> +
+
+
+ )} + {/* ===== 11. 삭제 버튼 설정 ===== */} {tab.showDelete && (
@@ -2071,6 +2127,169 @@ export const SplitPanelLayoutConfigPanel: React.FC
+ + {/* 좌측 패널 버튼 설정 */} +
+

좌측 패널 버튼 설정

+ + {/* 버튼 표시 체크박스 */} +
+
+ updateLeftPanel({ showSearch: !!checked })} + /> + +
+
+ updateLeftPanel({ showAdd: !!checked })} + /> + +
+
+ updateLeftPanel({ showEdit: !!checked })} + /> + +
+
+ updateLeftPanel({ showDelete: !!checked })} + /> + +
+
+ + {/* 추가 버튼 상세 설정 */} + {config.leftPanel?.showAdd && ( +
+

추가 버튼 설정

+
+
+ + +
+ + {config.leftPanel?.addButton?.mode === "modal" && ( +
+ + + updateLeftPanel({ + addButton: { ...config.leftPanel?.addButton, enabled: true, mode: "modal", modalScreenId: screenId }, + }) + } + /> +
+ )} + +
+ + + updateLeftPanel({ + addButton: { + ...config.leftPanel?.addButton, + enabled: true, + mode: config.leftPanel?.addButton?.mode || "auto", + buttonLabel: e.target.value || undefined, + }, + }) + } + placeholder="추가" + className="h-7 text-xs" + /> +
+
+
+ )} + + {/* 수정 버튼 상세 설정 */} + {(config.leftPanel?.showEdit ?? true) && ( +
+

수정 버튼 설정

+
+
+ + +
+ + {config.leftPanel?.editButton?.mode === "modal" && ( +
+ + + updateLeftPanel({ + editButton: { ...config.leftPanel?.editButton, enabled: true, mode: "modal", modalScreenId: screenId }, + }) + } + /> +
+ )} + +
+ + + updateLeftPanel({ + editButton: { + ...config.leftPanel?.editButton, + enabled: true, + mode: config.leftPanel?.editButton?.mode || "auto", + buttonLabel: e.target.value || undefined, + }, + }) + } + placeholder="수정" + className="h-7 text-xs" + /> +
+
+
+ )} +
@@ -2775,6 +2994,85 @@ export const SplitPanelLayoutConfigPanel: React.FC + {/* 🆕 우측 패널 추가 버튼 설정 */} + {config.rightPanel?.showAdd && ( +
+
+
+

추가 버튼 설정

+

우측 리스트의 추가 버튼 동작 방식 설정

+
+
+ +
+
+ + +

+ {config.rightPanel?.addButton?.mode === "modal" + ? "지정한 화면을 모달로 열어 데이터를 추가합니다" + : "내장 폼으로 데이터를 추가합니다"} +

+
+ + {config.rightPanel?.addButton?.mode === "modal" && ( +
+ + + updateRightPanel({ + addButton: { + ...config.rightPanel?.addButton!, + modalScreenId: screenId, + }, + }) + } + /> +
+ )} + +
+ + + updateRightPanel({ + addButton: { + ...config.rightPanel?.addButton!, + buttonLabel: e.target.value, + enabled: true, + mode: config.rightPanel?.addButton?.mode || "auto", + }, + }) + } + placeholder="추가" + className="h-8 text-xs" + /> +
+
+
+ )} + {/* 🆕 우측 패널 삭제 버튼 설정 */}
diff --git a/frontend/lib/registry/components/v2-split-panel-layout/types.ts b/frontend/lib/registry/components/v2-split-panel-layout/types.ts index a8e6618d..b738d317 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/types.ts +++ b/frontend/lib/registry/components/v2-split-panel-layout/types.ts @@ -115,6 +115,14 @@ export interface AdditionalTabConfig { groupByColumns?: string[]; }; + // 추가 버튼 설정 (모달 화면 연결 지원) + addButton?: { + enabled: boolean; + mode: "auto" | "modal"; // auto: 내장 폼, modal: 커스텀 모달 화면 + modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때) + buttonLabel?: string; // 버튼 라벨 (기본: "추가") + }; + deleteButton?: { enabled: boolean; buttonLabel?: string; @@ -141,6 +149,23 @@ export interface SplitPanelLayoutConfig { showAdd?: boolean; showEdit?: boolean; // 수정 버튼 showDelete?: boolean; // 삭제 버튼 + + // 수정 버튼 설정 (모달 화면 연결 지원) + editButton?: { + enabled: boolean; + mode: "auto" | "modal"; // auto: 내장 편집, modal: 커스텀 모달 화면 + modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때) + buttonLabel?: string; // 버튼 라벨 (기본: "수정") + }; + + // 추가 버튼 설정 (모달 화면 연결 지원) + addButton?: { + enabled: boolean; + mode: "auto" | "modal"; // auto: 내장 폼, modal: 커스텀 모달 화면 + modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때) + buttonLabel?: string; // 버튼 라벨 (기본: "추가") + }; + columns?: Array<{ name: string; label: string; @@ -307,6 +332,14 @@ export interface SplitPanelLayoutConfig { groupByColumns?: string[]; // 🆕 그룹핑 기준 컬럼들 (예: ["customer_id", "item_id"]) }; + // 🆕 추가 버튼 설정 (모달 화면 연결 지원) + addButton?: { + enabled: boolean; // 추가 버튼 표시 여부 (기본: true) + mode: "auto" | "modal"; // auto: 내장 폼, modal: 커스텀 모달 화면 + modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때) + buttonLabel?: string; // 버튼 라벨 (기본: "추가") + }; + // 🆕 삭제 버튼 설정 deleteButton?: { enabled: boolean; // 삭제 버튼 표시 여부 (기본: true) From 505930b3ecc1da57ee086026bada7d4d761a35a8 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 12 Feb 2026 15:03:56 +0900 Subject: [PATCH 39/44] feat: Implement custom right panel save functionality in SplitPanelLayoutComponent - Added a new save handler for the custom right panel, allowing users to save inline edit data. - Implemented validation checks to ensure data integrity before saving, including checks for selected items and primary keys. - Enhanced user feedback with toast notifications for success and error states during the save process. - Integrated company_code automatically into the saved data to maintain multi-tenancy compliance. - Updated the UI to include a save button in the custom mode, improving user interaction and data management. --- .../SplitPanelLayoutComponent.tsx | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index 689a2407..05c16943 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -2261,6 +2261,82 @@ export const SplitPanelLayoutComponent: React.FC [componentConfig, activeTabIndex], ); + // 커스텀 모드 우측 패널 저장 (인라인 편집 데이터) + const handleCustomRightSave = useCallback(async () => { + if (!selectedLeftItem || !customLeftSelectedData || Object.keys(customLeftSelectedData).length === 0) { + toast({ + title: "저장 실패", + description: "저장할 데이터가 없습니다. 좌측에서 항목을 선택해주세요.", + variant: "destructive", + }); + return; + } + + const tableName = componentConfig.rightPanel?.tableName || componentConfig.leftPanel?.tableName; + if (!tableName) { + toast({ + title: "저장 실패", + description: "테이블 정보가 없습니다.", + variant: "destructive", + }); + return; + } + + // Primary Key 찾기 + const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id"; + const primaryKey = selectedLeftItem[sourceColumn] || selectedLeftItem.id || selectedLeftItem.ID; + + if (!primaryKey) { + toast({ + title: "저장 실패", + description: "Primary Key를 찾을 수 없습니다.", + variant: "destructive", + }); + return; + } + + try { + // 프론트엔드 전용 필드 제거 + const cleanData = { ...customLeftSelectedData }; + delete cleanData.children; + delete cleanData.level; + delete cleanData._originalItems; + + // company_code 자동 추가 + if (companyCode && !cleanData.company_code) { + cleanData.company_code = companyCode; + } + + console.log("📝 [SplitPanel] 커스텀 우측 패널 저장:", { tableName, sourceColumn, primaryKey, data: cleanData }); + + const response = await dataApi.updateRecord(tableName, primaryKey, cleanData); + + if (response.success) { + toast({ + title: "저장 완료", + description: "데이터가 저장되었습니다.", + }); + // 좌측 데이터 새로고침 (변경된 항목 반영) + loadLeftData(); + // selectedLeftItem도 업데이트 + setSelectedLeftItem(customLeftSelectedData); + } else { + toast({ + title: "저장 실패", + description: response.error || "데이터 저장에 실패했습니다.", + variant: "destructive", + }); + } + } catch (error) { + console.error("커스텀 우측 패널 저장 오류:", error); + toast({ + title: "저장 오류", + description: "데이터 저장 중 오류가 발생했습니다.", + variant: "destructive", + }); + } + }, [selectedLeftItem, customLeftSelectedData, componentConfig, companyCode, toast, loadLeftData]); + // 수정 모달 저장 const handleEditModalSave = useCallback(async () => { const tableName = @@ -3618,6 +3694,13 @@ export const SplitPanelLayoutComponent: React.FC
{!isDesignMode && (
+ {/* 커스텀 모드 기본정보 탭: 저장 버튼 */} + {activeTabIndex === 0 && componentConfig.rightPanel?.displayMode === "custom" && selectedLeftItem && ( + + )} {activeTabIndex === 0 ? componentConfig.rightPanel?.showAdd && (
+ ) ) : isLoadingRight ? ( // 로딩 중
From df04afa5deec022fe4d5c031ed099e2edd69d0ab Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Thu, 12 Feb 2026 16:20:26 +0900 Subject: [PATCH 41/44] feat: Refactor EditModal for improved INSERT/UPDATE handling - Introduced a new state flag `isCreateModeFlag` to determine the mode (INSERT or UPDATE) directly from the event, enhancing clarity in the modal's behavior. - Updated the logic for initializing `originalData` and determining the mode, ensuring that the modal correctly identifies whether to create or update based on the provided data. - Refactored the update logic to send the entire `formData` without relying on `originalData`, streamlining the update process. - Enhanced logging for better debugging and understanding of the modal's state during operations. --- .../src/controllers/screenGroupController.ts | 18 +-- frontend/components/screen/EditModal.tsx | 151 ++++++++++-------- frontend/components/screen/StyleEditor.tsx | 12 ++ frontend/components/v2/V2Input.tsx | 27 +++- .../date-input/DateInputComponent.tsx | 7 + .../number-input/NumberInputComponent.tsx | 6 + .../SelectedItemsDetailInputComponent.tsx | 19 ++- .../text-input/TextInputComponent.tsx | 10 ++ .../textarea-basic/TextareaBasicComponent.tsx | 2 +- .../SplitPanelLayoutComponent.tsx | 6 +- .../SplitPanelLayoutConfigPanel.tsx | 19 +-- 11 files changed, 180 insertions(+), 97 deletions(-) diff --git a/backend-node/src/controllers/screenGroupController.ts b/backend-node/src/controllers/screenGroupController.ts index b53454b9..0e97e2e2 100644 --- a/backend-node/src/controllers/screenGroupController.ts +++ b/backend-node/src/controllers/screenGroupController.ts @@ -1488,13 +1488,13 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons SELECT sd.screen_id, sd.screen_name, - sd.table_name as main_table, - jsonb_array_elements_text( + sd.table_name::text as main_table, + jsonb_array_elements( COALESCE( sl.properties->'componentConfig'->'columns', '[]'::jsonb ) - )::jsonb->>'columnName' as column_name + )->>'columnName' as column_name FROM screen_definitions sd JOIN screen_layouts sl ON sd.screen_id = sl.screen_id WHERE sd.screen_id = ANY($1) @@ -1507,7 +1507,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons SELECT sd.screen_id, sd.screen_name, - sd.table_name as main_table, + sd.table_name::text as main_table, COALESCE( sl.properties->'componentConfig'->>'bindField', sl.properties->>'bindField', @@ -1530,7 +1530,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons SELECT sd.screen_id, sd.screen_name, - sd.table_name as main_table, + sd.table_name::text as main_table, sl.properties->'componentConfig'->>'valueField' as column_name FROM screen_definitions sd JOIN screen_layouts sl ON sd.screen_id = sl.screen_id @@ -1543,7 +1543,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons SELECT sd.screen_id, sd.screen_name, - sd.table_name as main_table, + sd.table_name::text as main_table, sl.properties->'componentConfig'->>'parentFieldId' as column_name FROM screen_definitions sd JOIN screen_layouts sl ON sd.screen_id = sl.screen_id @@ -1556,7 +1556,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons SELECT sd.screen_id, sd.screen_name, - sd.table_name as main_table, + sd.table_name::text as main_table, sl.properties->'componentConfig'->>'cascadingParentField' as column_name FROM screen_definitions sd JOIN screen_layouts sl ON sd.screen_id = sl.screen_id @@ -1569,7 +1569,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons SELECT sd.screen_id, sd.screen_name, - sd.table_name as main_table, + sd.table_name::text as main_table, sl.properties->'componentConfig'->>'controlField' as column_name FROM screen_definitions sd JOIN screen_layouts sl ON sd.screen_id = sl.screen_id @@ -1750,7 +1750,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons sd.table_name as main_table, sl.properties->>'componentType' as component_type, sl.properties->'componentConfig'->'rightPanel'->'relation' as right_panel_relation, - sl.properties->'componentConfig'->'rightPanel'->'tableName' as right_panel_table, + sl.properties->'componentConfig'->'rightPanel'->>'tableName' as right_panel_table, sl.properties->'componentConfig'->'rightPanel'->'columns' as right_panel_columns FROM screen_definitions sd JOIN screen_layouts sl ON sd.screen_id = sl.screen_id diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 38b6da5a..d8ce8e7a 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -113,6 +113,10 @@ export const EditModal: React.FC = ({ className }) => { // 폼 데이터 상태 (편집 데이터로 초기화됨) const [formData, setFormData] = useState>({}); const [originalData, setOriginalData] = useState>({}); + // INSERT/UPDATE 판단용 플래그 (이벤트에서 명시적으로 전달받음) + // true = INSERT (등록/복사), false = UPDATE (수정) + // originalData 상태에 의존하지 않고 이벤트의 isCreateMode 값을 직접 사용 + const [isCreateModeFlag, setIsCreateModeFlag] = useState(true); // 🆕 그룹 데이터 상태 (같은 order_no의 모든 품목) const [groupData, setGroupData] = useState[]>([]); @@ -271,13 +275,19 @@ export const EditModal: React.FC = ({ className }) => { // 편집 데이터로 폼 데이터 초기화 setFormData(editData || {}); - // 🆕 isCreateMode가 true이면 originalData를 빈 객체로 설정 (INSERT 모드) - // originalData가 비어있으면 INSERT, 있으면 UPDATE로 처리됨 + // originalData: changedData 계산(PATCH)에만 사용 + // INSERT/UPDATE 판단에는 사용하지 않음 setOriginalData(isCreateMode ? {} : editData || {}); + // INSERT/UPDATE 판단: 이벤트의 isCreateMode 플래그를 직접 저장 + // isCreateMode=true(복사/등록) → INSERT, false/undefined(수정) → UPDATE + setIsCreateModeFlag(!!isCreateMode); - if (isCreateMode) { - console.log("[EditModal] 생성 모드로 열림, 초기값:", editData); - } + console.log("[EditModal] 모달 열림:", { + mode: isCreateMode ? "INSERT (생성/복사)" : "UPDATE (수정)", + hasEditData: !!editData, + editDataId: editData?.id, + isCreateMode, + }); }; const handleCloseEditModal = () => { @@ -579,6 +589,7 @@ export const EditModal: React.FC = ({ className }) => { setZones([]); setConditionalLayers([]); setOriginalData({}); + setIsCreateModeFlag(true); // 기본값은 INSERT (안전 방향) setGroupData([]); // 🆕 setOriginalGroupData([]); // 🆕 }; @@ -942,8 +953,31 @@ export const EditModal: React.FC = ({ className }) => { return; } - // originalData가 비어있으면 INSERT, 있으면 UPDATE - const isCreateMode = Object.keys(originalData).length === 0; + // ======================================== + // INSERT/UPDATE 판단 (재설계) + // ======================================== + // 판단 기준: + // 1. isCreateModeFlag === true → 무조건 INSERT (복사/등록 모드 보호) + // 2. isCreateModeFlag === false → formData.id 있으면 UPDATE, 없으면 INSERT + // originalData는 INSERT/UPDATE 판단에 사용하지 않음 (changedData 계산에만 사용) + // ======================================== + let isCreateMode: boolean; + + if (isCreateModeFlag) { + // 이벤트에서 명시적으로 INSERT 모드로 지정됨 (등록/복사) + isCreateMode = true; + } else { + // 수정 모드: formData에 id가 있으면 UPDATE, 없으면 INSERT + isCreateMode = !formData.id; + } + + console.log("[EditModal] 저장 모드 판단:", { + isCreateMode, + isCreateModeFlag, + formDataId: formData.id, + originalDataLength: Object.keys(originalData).length, + tableName: screenData.screenInfo.tableName, + }); if (isCreateMode) { // INSERT 모드 @@ -1134,70 +1168,57 @@ export const EditModal: React.FC = ({ className }) => { throw new Error(response.message || "생성에 실패했습니다."); } } else { - // UPDATE 모드 - 기존 로직 - const changedData: Record = {}; - Object.keys(formData).forEach((key) => { - if (formData[key] !== originalData[key]) { - let value = formData[key]; - - // 🔧 배열이면 쉼표 구분 문자열로 변환 (리피터 데이터 제외) - if (Array.isArray(value)) { - // 리피터 데이터인지 확인 (객체 배열이고 _targetTable 또는 _isNewItem이 있으면 리피터) - const isRepeaterData = value.length > 0 && - typeof value[0] === "object" && - value[0] !== null && - ("_targetTable" in value[0] || "_isNewItem" in value[0] || "_existingRecord" in value[0]); - - if (!isRepeaterData) { - // 🔧 손상된 값 필터링 헬퍼 (중괄호, 따옴표, 백슬래시 포함 시 무효) - const isValidValue = (v: any): boolean => { - if (typeof v === "number" && !isNaN(v)) return true; - if (typeof v !== "string") return false; - if (!v || v.trim() === "") return false; - // 손상된 PostgreSQL 배열 형식 감지 - if (v.includes("{") || v.includes("}") || v.includes('"') || v.includes("\\")) return false; - return true; - }; - - // 🔧 다중 선택 배열 → 쉼표 구분 문자열로 변환 (손상된 값 필터링) - const validValues = value - .map((v: any) => typeof v === "number" ? String(v) : v) - .filter(isValidValue); - - if (validValues.length !== value.length) { - console.warn(`⚠️ [EditModal UPDATE] 손상된 값 필터링: ${key}`, { - before: value.length, - after: validValues.length, - removed: value.filter((v: any) => !isValidValue(v)) - }); - } - - const stringValue = validValues.join(","); - console.log(`🔧 [EditModal UPDATE] 배열→문자열 변환: ${key}`, { original: value.length, valid: validValues.length, converted: stringValue }); - value = stringValue; - } - } - - changedData[key] = value; - } - }); + // UPDATE 모드 - PUT (전체 업데이트) + // originalData 비교 없이 formData 전체를 보냄 + const recordId = formData.id; - if (Object.keys(changedData).length === 0) { - toast.info("변경된 내용이 없습니다."); - handleClose(); + if (!recordId) { + console.error("[EditModal] UPDATE 실패: formData에 id가 없습니다.", { + formDataKeys: Object.keys(formData), + }); + toast.error("수정할 레코드의 ID를 찾을 수 없습니다."); return; } - // 기본키 확인 (id 또는 첫 번째 키) - const recordId = originalData.id || Object.values(originalData)[0]; + // 배열 값 → 쉼표 구분 문자열 변환 (리피터 데이터 제외) + const dataToSave: Record = {}; + Object.entries(formData).forEach(([key, value]) => { + if (Array.isArray(value)) { + const isRepeaterData = value.length > 0 && + typeof value[0] === "object" && + value[0] !== null && + ("_targetTable" in value[0] || "_isNewItem" in value[0] || "_existingRecord" in value[0]); + + if (isRepeaterData) { + // 리피터 데이터는 제외 (별도 저장) + return; + } + // 다중 선택 배열 → 쉼표 구분 문자열 + const validValues = value + .map((v: any) => typeof v === "number" ? String(v) : v) + .filter((v: any) => { + if (typeof v === "number") return true; + if (typeof v !== "string") return false; + if (!v || v.trim() === "") return false; + if (v.includes("{") || v.includes("}") || v.includes('"') || v.includes("\\")) return false; + return true; + }); + dataToSave[key] = validValues.join(","); + } else { + dataToSave[key] = value; + } + }); - // UPDATE 액션 실행 - const response = await dynamicFormApi.updateFormDataPartial( + console.log("[EditModal] UPDATE(PUT) 실행:", { recordId, - originalData, - changedData, - screenData.screenInfo.tableName, - ); + fieldCount: Object.keys(dataToSave).length, + tableName: screenData.screenInfo.tableName, + }); + + const response = await dynamicFormApi.updateFormData(recordId, { + tableName: screenData.screenInfo.tableName, + data: dataToSave, + }); if (response.success) { toast.success("데이터가 수정되었습니다."); diff --git a/frontend/components/screen/StyleEditor.tsx b/frontend/components/screen/StyleEditor.tsx index f265115b..3add842c 100644 --- a/frontend/components/screen/StyleEditor.tsx +++ b/frontend/components/screen/StyleEditor.tsx @@ -33,6 +33,15 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd onStyleChange(newStyle); }; + // 숫자만 입력했을 때 자동으로 px 붙여주는 핸들러 + const autoPxProperties: (keyof ComponentStyle)[] = ["fontSize", "borderWidth", "borderRadius"]; + const handlePxBlur = (property: keyof ComponentStyle) => { + const val = localStyle[property]; + if (val && /^\d+(\.\d+)?$/.test(String(val))) { + handleStyleChange(property, `${val}px`); + } + }; + const toggleSection = (section: string) => { setOpenSections((prev) => ({ ...prev, @@ -66,6 +75,7 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd placeholder="1px" value={localStyle.borderWidth || ""} onChange={(e) => handleStyleChange("borderWidth", e.target.value)} + onBlur={() => handlePxBlur("borderWidth")} className="h-6 w-full px-2 py-0 text-xs" />
@@ -121,6 +131,7 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd placeholder="5px" value={localStyle.borderRadius || ""} onChange={(e) => handleStyleChange("borderRadius", e.target.value)} + onBlur={() => handlePxBlur("borderRadius")} className="h-6 w-full px-2 py-0 text-xs" />
@@ -209,6 +220,7 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd placeholder="14px" value={localStyle.fontSize || ""} onChange={(e) => handleStyleChange("fontSize", e.target.value)} + onBlur={() => handlePxBlur("fontSize")} className="h-6 w-full px-2 py-0 text-xs" />
diff --git a/frontend/components/v2/V2Input.tsx b/frontend/components/v2/V2Input.tsx index 17183050..78732ab1 100644 --- a/frontend/components/v2/V2Input.tsx +++ b/frontend/components/v2/V2Input.tsx @@ -82,8 +82,9 @@ const TextInput = forwardRef< disabled?: boolean; className?: string; columnName?: string; + inputStyle?: React.CSSProperties; } ->(({ value, onChange, format = "none", placeholder, readonly, disabled, className, columnName }, ref) => { +>(({ value, onChange, format = "none", placeholder, readonly, disabled, className, columnName, inputStyle }, ref) => { // 검증 상태 const [hasBlurred, setHasBlurred] = useState(false); const [validationError, setValidationError] = useState(""); @@ -210,6 +211,7 @@ const TextInput = forwardRef< hasError && "border-destructive focus-visible:ring-destructive", className, )} + style={inputStyle} /> {hasError && (

{validationError}

@@ -234,8 +236,9 @@ const NumberInput = forwardRef< readonly?: boolean; disabled?: boolean; className?: string; + inputStyle?: React.CSSProperties; } ->(({ value, onChange, min, max, step = 1, placeholder, readonly, disabled, className }, ref) => { +>(({ value, onChange, min, max, step = 1, placeholder, readonly, disabled, className, inputStyle }, ref) => { const handleChange = useCallback( (e: React.ChangeEvent) => { const val = e.target.value; @@ -268,6 +271,7 @@ const NumberInput = forwardRef< readOnly={readonly} disabled={disabled} className={cn("h-full w-full", className)} + style={inputStyle} /> ); }); @@ -285,8 +289,9 @@ const PasswordInput = forwardRef< readonly?: boolean; disabled?: boolean; className?: string; + inputStyle?: React.CSSProperties; } ->(({ value, onChange, placeholder, readonly, disabled, className }, ref) => { +>(({ value, onChange, placeholder, readonly, disabled, className, inputStyle }, ref) => { const [showPassword, setShowPassword] = useState(false); return ( @@ -300,6 +305,7 @@ const PasswordInput = forwardRef< readOnly={readonly} disabled={disabled} className={cn("h-full w-full pr-10", className)} + style={inputStyle} />