diff --git a/Development/client/.env b/Development/client/.env deleted file mode 100644 index 1645943..0000000 --- a/Development/client/.env +++ /dev/null @@ -1,6 +0,0 @@ -CURRENT_FILE_PATH=src/locale/messages.xlf -TRANSLATED_FILE_PATH_ES=src/locale/messages.es.xlf -TRANSLATED_FILE_PATH_PT=src/locale/messages.pt.xlf -GOOGLE_LOCATION=global -GOOGLE_PROJECT_ID=predictive-fx-392018 -GOOGLE_APPLICATION_CREDENTIALS=google-cloud.json \ No newline at end of file diff --git a/Development/client/.vscode/settings.json b/Development/client/.vscode/settings.json index 056830b..a571aea 100644 --- a/Development/client/.vscode/settings.json +++ b/Development/client/.vscode/settings.json @@ -6,6 +6,5 @@ "formate.alignColon": true, "formate.verticalAlignProperties": true, "formate.enable": true, - "formate.additionalSpaces": 0, - "specstory.cloudSync.enabled": "never" + "formate.additionalSpaces": 0 } \ No newline at end of file diff --git a/Development/client/AgMission-BigQuery-Analytics-Mapping.md b/Development/client/AgMission-BigQuery-Analytics-Mapping.md deleted file mode 100644 index c83cde6..0000000 --- a/Development/client/AgMission-BigQuery-Analytics-Mapping.md +++ /dev/null @@ -1,1038 +0,0 @@ -# AgMission BigQuery Analytics Mapping -**Comprehensive Mapping of Analytics Questions to BigQuery Parameters** - -## Overview - -This document maps specific analytics questions from the AgMission role-based analytics framework to their corresponding BigQuery queries and parameters. It identifies which parameters are already available in the current GA4 events interface and specifies how to collect any missing parameters. - -This document is based on: -- **Unified Event Reference**: `/client/AgMission-GA4-Complete-Reference.csv` -- **Interface Definition**: `/src/app/shared/types/ga4-events.interface.ts` -- **Implementation Status**: Focus on E-commerce subscription tracking with correct package names - -## Table of Contents - -1. [Available Parameters Inventory](#available-parameters-inventory) -2. [Event Structure Reference](#event-structure-reference) -3. [Role-Based Analytics Mapping](#role-based-analytics-mapping) -4. [E-commerce Analytics](#e-commerce-analytics) -5. [Missing Parameters Analysis](#missing-parameters-analysis) -6. [Implementation Recommendations](#implementation-recommendations) - ---- - -## Available Parameters Inventory - -### Current GA4 Event Parameters (From Interface) - -#### **Base Context (Available on All Events)** -- `user_id` - User identifier -- `session_id` - Session identifier -- `user_role` - User role (admin, applicator, office_admin, client, officer, pilot, inspector, aircraft) -- `subscription_tier` - Subscription tier level (string: "1", "2", "3", "4", "5", "addon", "unknown") -- `app_version` - Application version -- `platform` - Platform type (web) - -#### **Job Management Parameters** -- `job_type` - Type of job (spraying, seeding, fertilizing, harvesting, soil_testing) -- `field_size_acres` - Field size in acres -- `crop_type` - Crop type -- `equipment_type` - Equipment used -- `priority` - Job priority (low, medium, high, urgent) -- `client_id` - Client identifier -- `weather_dependency` - Weather dependency boolean -- `creation_method` - Job creation method (manual, template, duplicate) -- `estimated_duration_hours` - Expected job duration in hours -- `job_status` - Job status (new, ready, downloaded, sprayed, archived, invoiced) -- `efficiency_score` - Job efficiency score -- `assignee_id` - Assigned user ID -- `assignee_role` - Assigned user role (pilot, applicator, officer, admin) -- `assignment_method` - Assignment method (manual, auto, bulk) -- `assignment_lead_time_hours` - Lead time in hours -- `fields_modified` - List of fields changed in updates -- `change_magnitude` - Magnitude of changes (minor, major) -- `edit_session_duration` - Time spent editing in minutes -- `save_method` - How update was saved (manual, auto_save) -- `deletion_reason` - Reason for job deletion -- `deletion_method` - How deletion was triggered -- `time_since_creation` - Time between creation and deletion -- `old_status` - Previous status before change -- `new_status` - New status after change -- `status_change_reason` - Reason for status change -- `completion_time` - Time to complete job - -#### **Job List Operation Parameters** -- `view_type` - Type of list view (table, grid, map, calendar) -- `total_jobs` - Total jobs available -- `displayed_jobs` - Number of jobs shown -- `sort_by` - Sort criteria -- `filter_count` - Number of active filters -- `load_time_ms` - List load time -- `client_filter_applied` - Whether client filter is active -- `reload_interval` - Auto-reload interval -- `filter_type` - Type of filter applied -- `filter_value` - Value of applied filter -- `results_before` - Results count before filter -- `results_after` - Results count after filter -- `filter_effectiveness` - Filter effectiveness percentage -- `date_filter_type` - Type of date filter -- `custom_date_range` - Custom date range -- `selection_method` - Method used to select job -- `position_in_list` - Position of job in list -- `action_type` - Type of bulk action -- `job_count` - Number of jobs affected -- `job_ids` - List of job IDs affected -- `execution_time` - Time to execute action -- `success_rate` - Success rate of action - -#### **File Management Parameters** -- `file_type` - File type (field_boundary, prescription_map, application_report, soil_map) -- `file_size_mb` - File size in MB -- `related_job_id` - Related job identifier -- `upload_source` - Upload source (manual, drag_drop, bulk) -- `processing_time_seconds` - Processing time -- `validation_status` - Validation status (passed, failed, warning) -- `data_quality_score` - Data quality score -- `automation_enabled` - Automation status -- `error_type` - Error type for failed uploads -- `error_message` - Error message -- `retry_attempted` - Whether retry was attempted -- `deletion_reason` - File deletion reason -- `file_age_days` - File age in days -- `confirmation_required` - Whether confirmation was required -- `download_method` - Download method -- `file_format` - File format (original, converted) -- `download_source` - Download source - -#### **Library Upload Parameters** -- `upload_type` - Type of library upload (field_areas, boundary_data, geographic_data) -- `file_count` - Number of files uploaded -- `total_areas_uploaded` - Total areas uploaded -- `duplicate_areas_found` - Number of duplicates found -- `failed_files` - Number of failed files -- `file_types` - Array of file types -- `total_file_size_mb` - Total file size -- `processing_method` - Processing method (bulk, individual) - -#### **Report Parameters** -- `report_type` - Type of report (job_summary, financial_analysis, field_report, performance_dashboard) -- `report_id` - Report identifier -- `date_range_days` - Date range in days -- `jobs_included` - Number of jobs included -- `generation_time_seconds` - Report generation time -- `data_completeness` - Data completeness percentage -- `view_duration_seconds` - View duration -- `pages_viewed` - Number of pages viewed -- `engagement_quality` - Engagement quality (low, medium, high) -- `export_format` - Export format (pdf, excel, csv) -- `render_duration_ms` - Render duration -- `data_size_mb` - Data size -- `complexity_score` - Report complexity score -- `design_session_duration` - Design session duration -- `modifications_made` - Number of modifications -- `scroll_depth` - Scroll depth percentage - -#### **Invoice Management Parameters** -- `invoice_id` - Invoice identifier -- `client_id` - Client identifier -- `total_amount` - Invoice amount -- `currency` - Currency type (USD, CAD, EUR) -- `job_count` - Number of jobs in invoice -- `creation_method` - Creation method (manual, auto_generated, template, recurring) -- `due_date_days` - Due date in days -- `payment_terms` - Payment terms -- `fields_modified` - List of modified fields -- `amount_change` - Amount change -- `previous_status` - Previous invoice status -- `current_status` - Current invoice status -- `modification_type` - Type of modification -- `invoice_status` - Invoice status (new, draft, open, paid, void, uncollectible) -- `deletion_reason` - Deletion reason -- `days_since_creation` - Days since creation -- `had_payments` - Whether invoice had payments -- `old_status` - Previous status -- `new_status` - New status -- `status_change_reason` - Status change reason -- `days_in_previous_status` - Days in previous status -- `payment_amount` - Payment amount -- `payment_method` - Payment method (cash, check, credit_card, bank_transfer, other) -- `payment_date` - Payment date -- `remaining_balance` - Remaining balance -- `payment_reference` - Payment reference -- `days_to_payment` - Days to payment -- `total_invoices` - Total number of invoices -- `displayed_invoices` - Number of displayed invoices -- `date_range_applied` - Whether date range filter applied -- `status_filter_applied` - Whether status filter applied -- `multiple_filters_active` - Whether multiple filters active -- `invoice_amount` - Invoice amount -- `total_amount_affected` - Total amount affected by bulk action -- `view_source` - View source (list, direct_link, search, navigation) -- `export_method` - Export method (single, bulk) -- `file_size_kb` - Export file size -- `includes_job_details` - Whether export includes job details -- `settings_modified` - List of modified settings -- `automation_enabled` - Whether automation enabled -- `payment_terms_changed` - Whether payment terms changed -- `billing_preferences_updated` - Whether billing preferences updated -- `item_id` - Item identifier -- `item_type` - Item type (service, material, equipment, labor) -- `unit_type` - Unit type (per_acre, per_hour, flat_rate, per_unit) -- `base_rate` - Base rate -- `affects_existing_invoices` - Whether affects existing invoices - -#### **Authentication Parameters** -- `method` - Login/signup method (email, google, microsoft, sso) -- `last_login_days_ago` - Days since last login -- `session_duration_minutes` - Session duration -- `logout_method` - Logout method (manual, timeout, forced) -- `page_location` - Page location at logout -- `signup_method` - Signup method -- `user_type` - User type (client, applicator, admin, office_admin) -- `source` - Signup source (landing_page, referral, advertisement, direct) -- `invitation_code` - Invitation code -- `company_name` - Company name -- `signup_duration_minutes` - Signup duration -- `profile_completed` - Whether profile completed -- `verification_required` - Whether verification required -- `email_address_hash` - Hashed email address -- `request_method` - Request method for password/email actions -- `user_exists` - Whether user exists -- `reset_token_age_minutes` - Reset token age -- `success` - Whether action was successful -- `failure_reason` - Failure reason -- `verification_token_age_minutes` - Verification token age - -#### **Error Tracking Parameters** -- `error_type` - Error type (network_error, server_error, client_error, timeout, unknown_error) -- `http_status_code` - HTTP status code -- `error_message` - Error message -- `request_method` - Request method (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS) -- `request_url` - Request URL -- `request_endpoint` - Request endpoint -- `response_time_ms` - Response time -- `affected_feature` - Affected feature - -#### **Session & Performance Parameters** -- `entry_page` - Entry page -- `referrer` - Referrer URL -- `page_title` - Page title -- `load_time_ms` - Page load time -- `connection_type` - Connection type (wifi, cellular, ethernet, unknown) -- `device_type` - Device type (desktop, mobile, tablet) -- `api_endpoint` - API endpoint -- `response_time_ms` - API response time -- `payload_size` - Payload size -- `cache_hit` - Whether cache hit - -#### **E-commerce Parameters** -- `subscription_type` - AgMission package name (e.g., "AgMission Essentials 1", "AgMission Enterprise 3") -- `subscription_duration` - Duration (monthly, quarterly, annual) -- `subscription_price` - Subscription price -- `previous_subscription_type` - Previous subscription type -- `payment_method` - Payment method (credit_card, bank_transfer, paypal, invoice) -- `billing_frequency` - Billing frequency (monthly, quarterly, annual) -- `promo_code` - Promotional code -- `discount_amount` - Discount amount -- `subscription_start_date` - Start date -- `auto_renewal` - Auto renewal setting -- `upgrade_from` - Upgrade from package -- `upgrade_to` - Upgrade to package -- `trial_conversion` - Whether trial conversion -- `subscription_value` - Subscription value -- `user_tenure_days` - User tenure in days -- `service_type` - Service category (essential, enterprise, addon) -- `is_trial` - Whether trial subscription ---- - -## Event Structure Reference - -### Event Categories and Names (From Unified Reference) - -#### **Job Management Events** -1. `job_created` - User creates a new agricultural job -2. `job_updated` - User modifies an existing job -3. `job_deleted` - User removes a job from system -4. `job_assigned` - Job assigned to pilot or operator -5. `job_status_changed` - Job status transitions - -#### **Job List Operations** -1. `job_list_viewed` - User accesses the jobs list interface -2. `job_list_filtered` - User applies filters to narrow job results -3. `job_selected` - User clicks/selects a specific job -4. `job_bulk_action` - User performs action on multiple jobs - -#### **File Management Events** -1. `file_upload_started` - User initiates file upload -2. `file_upload_completed` - File upload completes successfully -3. `file_upload_failed` - File upload fails -4. `file_deleted` - User deletes a file -5. `file_downloaded` - User downloads a file - -#### **Library Upload Events** -1. `library_upload_started` - User starts library upload -2. `library_upload_completed` - Library upload completes - -#### **Report Events** -1. `report_generated` - System generates a report -2. `report_viewed` - User views a report -3. `report_exported` - User exports a report -4. `report_filtered` - User applies filters to report -5. `report_rendered` - Report rendering completes -6. `report_design_mode_entered` - User enters design mode -7. `report_view_duration` - Report view duration tracking - -#### **Invoice Management Events** -1. `invoice_created` - User creates new invoice -2. `invoice_updated` - User modifies existing invoice -3. `invoice_deleted` - User deletes invoice -4. `invoice_status_changed` - Invoice status transitions -5. `invoice_payment_logged` - Payment logged for invoice - -#### **Invoice List Operations** -1. `invoice_list_viewed` - User accesses invoice list -2. `invoice_list_filtered` - User filters invoice list -3. `invoice_selected` - User selects specific invoice -4. `invoice_bulk_action` - User performs bulk action on invoices -5. `invoice_viewed` - User views invoice details -6. `invoice_exported` - User exports invoice - -#### **Invoice Settings & Configuration** -1. `customer_invoice_settings_updated` - Customer invoice settings changed -2. `invoice_costing_item_managed` - Invoice costing item managed - -#### **Authentication Events** -1. `login` - User logs into system -2. `logout` - User logs out of system -3. `signup` - User signs up for account -4. `signup_completed` - User completes signup process -5. `password_reset_requested` - User requests password reset -6. `password_reset_completed` - User completes password reset -7. `email_verification_requested` - User requests email verification -8. `email_verification_completed` - User completes email verification - -#### **Error Tracking Events** -1. `http_error` - HTTP error occurs - -#### **Session & Performance Events** -1. `session_start` - User session starts -2. `slow_page_load` - Page loads slowly -3. `api_response_slow` - API response is slow - -#### **E-commerce Events** -1. `subscription_purchased` - User purchases subscription - - ---- - -## Role-Based Analytics Mapping - -### 🔧 ADMIN Role Analytics - -#### **Question**: "Which user roles generate the most system activity?" -**BigQuery Parameters Available:** -- `user_role` (available) -- `event_name` (available) -- `event_timestamp` (available) -- `session_duration_minutes` (available) - -**SQL Query:** -```sql -SELECT - (SELECT value.string_value FROM UNNEST(user_properties) WHERE key = 'user_role') as user_role, - COUNT(*) as total_events, - COUNT(DISTINCT user_id) as unique_users, - AVG(CAST((SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'session_duration_minutes') AS NUMERIC)) as avg_session_duration -FROM `agmission-analytics.analytics_12345678.events_*` -WHERE _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY)) - AND FORMAT_DATE('%Y%m%d', CURRENT_DATE()) -GROUP BY user_role -ORDER BY total_events DESC; -``` - -#### **Question**: "What's the subscription purchase conversion rate by user role?" -**BigQuery Parameters Available:** -- `user_role` (available) -- `subscription_type` (available) -- `subscription_price` (available) -- `trial_conversion` (available) -- `service_type` (available) - -**SQL Query:** -```sql -WITH signup_users AS ( - SELECT DISTINCT - user_id, - (SELECT value.string_value FROM UNNEST(user_properties) WHERE key = 'user_role') as user_role, - MIN(TIMESTAMP_MICROS(event_timestamp)) as signup_time - FROM `agmission-analytics.analytics_12345678.events_*` - WHERE event_name = 'signup_completed' - AND _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 90 DAY)) - AND FORMAT_DATE('%Y%m%d', CURRENT_DATE()) -), -subscription_purchases AS ( - SELECT DISTINCT - user_id, - (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'subscription_type') as subscription_type, - (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'service_type') as service_type, - CAST((SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'subscription_price') AS NUMERIC) as subscription_price, - (SELECT value.bool_value FROM UNNEST(event_params) WHERE key = 'trial_conversion') as trial_conversion - FROM `agmission-analytics.analytics_12345678.events_*` - WHERE event_name = 'subscription_purchased' - AND _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 90 DAY)) - AND FORMAT_DATE('%Y%m%d', CURRENT_DATE()) -) -SELECT - s.user_role, - COUNT(s.user_id) as total_signups, - COUNT(p.user_id) as total_purchases, - ROUND(COUNT(p.user_id) / COUNT(s.user_id) * 100, 2) as conversion_rate_percent, - COUNT(CASE WHEN p.trial_conversion = true THEN 1 END) as trial_conversions, - ROUND(AVG(p.subscription_price), 2) as avg_subscription_price, - COUNT(CASE WHEN p.service_type = 'essential' THEN 1 END) as essential_purchases, - COUNT(CASE WHEN p.service_type = 'enterprise' THEN 1 END) as enterprise_purchases -FROM signup_users s -LEFT JOIN subscription_purchases p ON s.user_id = p.user_id -GROUP BY s.user_role -ORDER BY conversion_rate_percent DESC; -``` - -#### **Question**: "Which roles have the highest system adoption rates?" -**BigQuery Parameters Available:** -- `user_role` (available) -- `signup_completed` event (available) -- `job_created` event (available) -- `file_upload_started` event (available) - -**SQL Query:** -```sql -WITH user_adoption AS ( - SELECT - user_id, - (SELECT value.string_value FROM UNNEST(user_properties) WHERE key = 'user_role') as user_role, - MIN(CASE WHEN event_name = 'signup_completed' THEN TIMESTAMP_MICROS(event_timestamp) END) as signup_time, - MIN(CASE WHEN event_name = 'job_created' THEN TIMESTAMP_MICROS(event_timestamp) END) as first_job_time, - MIN(CASE WHEN event_name = 'file_upload_started' THEN TIMESTAMP_MICROS(event_timestamp) END) as first_upload_time - FROM `agmission-analytics.analytics_12345678.events_*` - WHERE _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 90 DAY)) - AND FORMAT_DATE('%Y%m%d', CURRENT_DATE()) - GROUP BY user_id, user_role -) -SELECT - user_role, - COUNT(*) as total_signups, - COUNT(first_job_time) as users_created_jobs, - COUNT(first_upload_time) as users_uploaded_files, - ROUND(COUNT(first_job_time) / COUNT(*) * 100, 2) as job_adoption_rate_percent, - ROUND(COUNT(first_upload_time) / COUNT(*) * 100, 2) as upload_adoption_rate_percent -FROM user_adoption -WHERE signup_time IS NOT NULL -GROUP BY user_role -ORDER BY job_adoption_rate_percent DESC; -``` - ---- - -## E-commerce Analytics - -### Subscription Purchase Tracking - -#### **Question**: "What's the distribution of subscription purchases by package type and tier?" -**BigQuery Parameters Available:** -- `subscription_type` - AgMission package name (e.g., "AgMission Essentials 1", "AgMission Enterprise 3") -- `service_type` - Service category (essential, enterprise, addon) -- `subscription_tier` - Tier level (string: "1", "2", "3", "4", "5") -- `subscription_price` - Subscription price -- `subscription_duration` - Duration (monthly, quarterly, annual) - -**SQL Query:** -```sql -SELECT - (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'subscription_type') as subscription_package, - (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'service_type') as service_type, - (SELECT value.string_value FROM UNNEST(user_properties) WHERE key = 'subscription_tier') as tier, - (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'subscription_duration') as duration, - COUNT(*) as purchase_count, - COUNT(DISTINCT user_id) as unique_customers, - SUM(CAST((SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'subscription_price') AS NUMERIC)) as total_revenue, - ROUND(AVG(CAST((SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'subscription_price') AS NUMERIC)), 2) as avg_price -FROM `agmission-analytics.analytics_12345678.events_*` -WHERE event_name = 'subscription_purchased' - AND _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 90 DAY)) - AND FORMAT_DATE('%Y%m%d', CURRENT_DATE()) -GROUP BY subscription_package, service_type, tier, duration -ORDER BY total_revenue DESC; -``` - -#### **Question**: "What's the trial to paid conversion rate by subscription type?" -**BigQuery Parameters Available:** -- `trial_conversion` - Whether this is a trial conversion -- `subscription_type` - Package name -- `is_trial` - Whether this is a trial subscription -- `user_tenure_days` - User tenure in days - -**SQL Query:** -```sql -WITH trial_tracking AS ( - SELECT - user_id, - (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'subscription_type') as subscription_type, - (SELECT value.bool_value FROM UNNEST(event_params) WHERE key = 'trial_conversion') as trial_conversion, - (SELECT value.bool_value FROM UNNEST(event_params) WHERE key = 'is_trial') as is_trial, - CAST((SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'user_tenure_days') AS NUMERIC) as user_tenure_days, - TIMESTAMP_MICROS(event_timestamp) as purchase_time - FROM `agmission-analytics.analytics_12345678.events_*` - WHERE event_name = 'subscription_purchased' - AND _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 180 DAY)) - AND FORMAT_DATE('%Y%m%d', CURRENT_DATE()) -) -SELECT - subscription_type, - COUNT(CASE WHEN is_trial = true THEN 1 END) as trial_subscriptions, - COUNT(CASE WHEN trial_conversion = true THEN 1 END) as trial_conversions, - COUNT(CASE WHEN is_trial = false AND trial_conversion = false THEN 1 END) as direct_purchases, - ROUND( - CASE - WHEN COUNT(CASE WHEN is_trial = true THEN 1 END) > 0 - THEN COUNT(CASE WHEN trial_conversion = true THEN 1 END) / COUNT(CASE WHEN is_trial = true THEN 1 END) * 100 - ELSE 0 - END, 2 - ) as trial_conversion_rate_percent, - ROUND(AVG(CASE WHEN trial_conversion = true THEN user_tenure_days END), 1) as avg_trial_duration_days -FROM trial_tracking -GROUP BY subscription_type -ORDER BY trial_conversion_rate_percent DESC; -``` - -#### **Question**: "What's the upgrade/downgrade pattern analysis?" -**BigQuery Parameters Available:** -- `upgrade_from` - Previous package -- `upgrade_to` - New package -- `previous_subscription_type` - Previous subscription -- `subscription_type` - Current subscription -- `subscription_price` - Price - -**SQL Query:** -```sql -SELECT - (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'previous_subscription_type') as previous_package, - (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'subscription_type') as new_package, - (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'upgrade_from') as upgrade_from, - (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'upgrade_to') as upgrade_to, - COUNT(*) as change_count, - COUNT(DISTINCT user_id) as unique_users, - AVG(CAST((SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'subscription_price') AS NUMERIC)) as avg_new_price, - CASE - WHEN (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'upgrade_from') IS NOT NULL - THEN 'UPGRADE' - WHEN (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'previous_subscription_type') != 'none' - THEN 'CHANGE' - ELSE 'NEW' - END as change_type -FROM `agmission-analytics.analytics_12345678.events_*` -WHERE event_name = 'subscription_purchased' - AND _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 90 DAY)) - AND FORMAT_DATE('%Y%m%d', CURRENT_DATE()) -GROUP BY previous_package, new_package, upgrade_from, upgrade_to -ORDER BY change_count DESC; -``` - -#### **Question**: "What's the revenue impact by payment method and billing frequency?" -**BigQuery Parameters Available:** -- `payment_method` - Payment method (credit_card, bank_transfer, paypal, invoice) -- `billing_frequency` - Billing frequency (monthly, quarterly, annual) -- `subscription_price` - Subscription price -- `discount_amount` - Discount amount -- `promo_code` - Promotional code used - -**SQL Query:** -```sql -SELECT - (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'payment_method') as payment_method, - (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'billing_frequency') as billing_frequency, - COUNT(*) as transaction_count, - COUNT(DISTINCT user_id) as unique_customers, - SUM(CAST((SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'subscription_price') AS NUMERIC)) as gross_revenue, - SUM(COALESCE(CAST((SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'discount_amount') AS NUMERIC), 0)) as total_discounts, - SUM(CAST((SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'subscription_price') AS NUMERIC)) - - SUM(COALESCE(CAST((SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'discount_amount') AS NUMERIC), 0)) as net_revenue, - COUNT(CASE WHEN (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'promo_code') IS NOT NULL THEN 1 END) as promo_usage_count, - ROUND(AVG(CAST((SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'subscription_price') AS NUMERIC)), 2) as avg_transaction_value -FROM `agmission-analytics.analytics_12345678.events_*` -WHERE event_name = 'subscription_purchased' - AND _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 90 DAY)) - AND FORMAT_DATE('%Y%m%d', CURRENT_DATE()) -GROUP BY payment_method, billing_frequency -ORDER BY net_revenue DESC; -``` - -### 📊 OFFICER Role Analytics - -#### **Question**: "What's the optimal job assignment lead time?" -**BigQuery Parameters Available:** -- `assignment_lead_time_hours` (available) -- `job_type` (available) -- `efficiency_score` (available) -- `assignee_role` (available) - -**SQL Query:** -```sql -SELECT - (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'job_type') as job_type, - (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'assignee_role') as assignee_role, - ROUND(AVG(CAST((SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'assignment_lead_time_hours') AS NUMERIC)), 2) as avg_lead_time_hours, - ROUND(AVG(CAST((SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'efficiency_score') AS NUMERIC)), 2) as avg_efficiency_score, - COUNT(*) as total_assignments -FROM `agmission-analytics.analytics_12345678.events_*` -WHERE event_name = 'job_assigned' - AND _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 60 DAY)) - AND FORMAT_DATE('%Y%m%d', CURRENT_DATE()) -GROUP BY job_type, assignee_role -HAVING COUNT(*) >= 10 -ORDER BY avg_efficiency_score DESC, avg_lead_time_hours; -``` - -#### **Question**: "Which pilots/applicators have the highest efficiency scores?" -**BigQuery Parameters Available:** -- `assignee_id` (available) -- `assignee_role` (available) -- `efficiency_score` (available) -- `job_type` (available) - -**SQL Query:** -```sql -SELECT - (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'assignee_id') as assignee_id, - (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'assignee_role') as assignee_role, - (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'job_type') as job_type, - COUNT(*) as total_jobs, - ROUND(AVG(CAST((SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'efficiency_score') AS NUMERIC)), 2) as avg_efficiency_score, - MIN(CAST((SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'efficiency_score') AS NUMERIC)) as min_efficiency, - MAX(CAST((SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'efficiency_score') AS NUMERIC)) as max_efficiency -FROM `agmission-analytics.analytics_12345678.events_*` -WHERE event_name = 'job_status_changed' - AND (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'new_status') = 'sprayed' - AND (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'assignee_role') IN ('pilot', 'applicator') - AND _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 90 DAY)) - AND FORMAT_DATE('%Y%m%d', CURRENT_DATE()) -GROUP BY assignee_id, assignee_role, job_type -HAVING COUNT(*) >= 5 -ORDER BY avg_efficiency_score DESC; -``` - -### ✈️ PILOT Role Analytics - -#### **Question**: "What's my personal efficiency score compared to team average?" -**BigQuery Parameters Available:** -- `user_id` (available) -- `efficiency_score` (available) -- `job_type` (available) -- `user_role` (available) - -**SQL Query:** -```sql -WITH pilot_performance AS ( - SELECT - user_id, - (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'job_type') as job_type, - AVG(CAST((SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'efficiency_score') AS NUMERIC)) as avg_efficiency_score - FROM `agmission-analytics.analytics_12345678.events_*` - WHERE event_name = 'job_status_changed' - AND (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'new_status') = 'sprayed' - AND (SELECT value.string_value FROM UNNEST(user_properties) WHERE key = 'user_role') = 'pilot' - AND _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 90 DAY)) - AND FORMAT_DATE('%Y%m%d', CURRENT_DATE()) - GROUP BY user_id, job_type -), -team_averages AS ( - SELECT - job_type, - AVG(avg_efficiency_score) as team_avg_efficiency - FROM pilot_performance - GROUP BY job_type -) -SELECT - p.user_id, - p.job_type, - p.avg_efficiency_score as personal_efficiency, - t.team_avg_efficiency, - ROUND(((p.avg_efficiency_score - t.team_avg_efficiency) / t.team_avg_efficiency) * 100, 2) as performance_vs_team_percent -FROM pilot_performance p -JOIN team_averages t ON p.job_type = t.job_type -WHERE p.user_id = 'CURRENT_USER_ID' -- Replace with actual user ID -ORDER BY p.job_type; -``` - -#### **Question**: "How does weather dependency affect my job completion rates?" -**BigQuery Parameters Available:** -- `weather_dependency` (available) -- `job_status` (available) -- `user_id` (available) - -**SQL Query:** -```sql -SELECT - (SELECT value.bool_value FROM UNNEST(event_params) WHERE key = 'weather_dependency') as weather_dependent, - COUNT(*) as total_jobs, - COUNT(CASE WHEN (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'new_status') = 'sprayed' THEN 1 END) as completed_jobs, - ROUND(COUNT(CASE WHEN (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'new_status') = 'sprayed' THEN 1 END) / COUNT(*) * 100, 2) as completion_rate_percent -FROM `agmission-analytics.analytics_12345678.events_*` -WHERE event_name = 'job_status_changed' - AND user_id = 'CURRENT_USER_ID' -- Replace with actual user ID - AND _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 90 DAY)) - AND FORMAT_DATE('%Y%m%d', CURRENT_DATE()) -GROUP BY weather_dependent -ORDER BY completion_rate_percent DESC; -``` - -### 🚁 APPLICATOR Role Analytics - -#### **Question**: "Which file types do I upload most frequently?" -**BigQuery Parameters Available:** -- `file_type` (available) -- `user_id` (available) -- `file_size_mb` (available) -- `processing_time_seconds` (available) - -**SQL Query:** -```sql -SELECT - (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'file_type') as file_type, - COUNT(*) as upload_count, - ROUND(AVG(CAST((SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'file_size_mb') AS NUMERIC)), 2) as avg_file_size_mb, - ROUND(AVG(CAST((SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'processing_time_seconds') AS NUMERIC)), 2) as avg_processing_time_seconds, - COUNT(CASE WHEN (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'validation_status') = 'passed' THEN 1 END) as successful_uploads, - ROUND(COUNT(CASE WHEN (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'validation_status') = 'passed' THEN 1 END) / COUNT(*) * 100, 2) as success_rate_percent -FROM `agmission-analytics.analytics_12345678.events_*` -WHERE event_name = 'file_upload_completed' - AND user_id = 'CURRENT_USER_ID' -- Replace with actual user ID - AND _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 90 DAY)) - AND FORMAT_DATE('%Y%m%d', CURRENT_DATE()) -GROUP BY file_type -ORDER BY upload_count DESC; -``` - -#### **Question**: "What's my file upload success rate compared to team average?" -**BigQuery Parameters Available:** -- `user_id` (available) -- `validation_status` (available) -- `file_type` (available) -- `user_role` (available) - -**SQL Query:** -```sql -WITH user_upload_success AS ( - SELECT - user_id, - (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'file_type') as file_type, - COUNT(*) as total_uploads, - COUNT(CASE WHEN (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'validation_status') = 'passed' THEN 1 END) as successful_uploads, - ROUND(COUNT(CASE WHEN (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'validation_status') = 'passed' THEN 1 END) / COUNT(*) * 100, 2) as success_rate - FROM `agmission-analytics.analytics_12345678.events_*` - WHERE event_name = 'file_upload_completed' - AND (SELECT value.string_value FROM UNNEST(user_properties) WHERE key = 'user_role') = 'applicator' - AND _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 90 DAY)) - AND FORMAT_DATE('%Y%m%d', CURRENT_DATE()) - GROUP BY user_id, file_type -), -team_averages AS ( - SELECT - file_type, - AVG(success_rate) as team_avg_success_rate, - COUNT(DISTINCT user_id) as total_team_members - FROM user_upload_success - GROUP BY file_type -) -SELECT - u.file_type, - u.total_uploads as my_uploads, - u.success_rate as my_success_rate, - t.team_avg_success_rate, - t.total_team_members, - ROUND(u.success_rate - t.team_avg_success_rate, 2) as performance_vs_team -FROM user_upload_success u -JOIN team_averages t ON u.file_type = t.file_type -WHERE u.user_id = 'CURRENT_USER_ID' -- Replace with actual user ID -ORDER BY u.file_type; -``` - -### 🌾 CLIENT Role Analytics - -#### **Question**: "What's the completion rate for my jobs?" -**BigQuery Parameters Available:** -- `client_id` (available) -- `job_status` (available) -- `job_type` (available) - -**SQL Query:** -```sql -SELECT - (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'job_type') as job_type, - COUNT(*) as total_jobs, - COUNT(CASE WHEN (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'new_status') = 'sprayed' THEN 1 END) as completed_jobs, - COUNT(CASE WHEN (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'new_status') = 'archived' THEN 1 END) as archived_jobs, - ROUND(COUNT(CASE WHEN (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'new_status') = 'sprayed' THEN 1 END) / COUNT(*) * 100, 2) as completion_rate_percent -FROM `agmission-analytics.analytics_12345678.events_*` -WHERE event_name = 'job_status_changed' - AND (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'client_id') = 'CURRENT_CLIENT_ID' -- Replace with actual client ID - AND _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 90 DAY)) - AND FORMAT_DATE('%Y%m%d', CURRENT_DATE()) -GROUP BY job_type -ORDER BY completion_rate_percent DESC; -``` - -### 🔍 INSPECTOR Role Analytics - -#### **Question**: "What's the compliance rate across all jobs?" -**BigQuery Parameters Required:** -- `job_type` (available) -- `efficiency_score` (available) -- **MISSING**: `compliance_score`, `inspection_result`, `regulatory_standard` - -**Collection Method for Missing Parameters:** -- Add `compliance_score` to job completion events -- Track `inspection_result` when inspectors review jobs -- Include `regulatory_standard` for compliance tracking - ---- - -## Missing Parameters Analysis - -### Critical Missing Parameters - -#### **1. Weather & Environmental Data** -**Missing Parameters:** -- `weather_conditions` - Current weather conditions -- `temperature` - Temperature at job time -- `wind_speed` - Wind speed during operation -- `humidity` - Humidity levels -- `precipitation` - Precipitation data - -**Collection Method:** -- Integrate with weather API (OpenWeatherMap, WeatherAPI) -- Capture at job creation and completion -- Store in job events as additional parameters - -#### **2. Financial & Business Metrics** -**Missing Parameters:** -- `profit_margin` - Profit margin per job -- `cost_per_acre` - Cost per acre calculation -- `revenue_per_job` - Revenue generated per job -- `customer_lifetime_value` - CLV calculation -- `account_creation_cost` - Cost to create account -- `support_cost_per_user` - Support cost per user -- `feature_usage_cost` - Feature usage cost - -**Collection Method:** -- Calculate from invoice and cost data -- Add to invoice and job completion events -- Integrate with accounting system -- Track support system costs -- Add to signup and subscription events - -#### **3. Performance & Quality Metrics** -**Missing Parameters:** -- `application_accuracy` - Application accuracy percentage -- `coverage_quality` - Coverage quality score -- `rework_required` - Whether rework was needed -- `customer_satisfaction` - Customer satisfaction score -- `compliance_score` - Compliance score -- `inspection_result` - Inspection outcomes -- `regulatory_standard` - Regulatory compliance standard - -**Collection Method:** -- GPS tracking integration for accuracy -- Post-job quality assessment -- Customer feedback collection -- Compliance system integration -- Add to job completion events - -#### **4. Equipment & Maintenance Data** -**Missing Parameters:** -- `equipment_id` - Specific equipment identifier -- `maintenance_status` - Equipment maintenance status -- `fuel_consumption` - Fuel consumption data -- `operating_hours` - Equipment operating hours - -**Collection Method:** -- Equipment tracking integration -- Maintenance system integration -- Add to job assignment and completion events - -#### **5. Compliance & Safety Data** -**Missing Parameters:** -- `safety_incidents` - Safety incident reports -- `regulatory_compliance` - Compliance status -- `certification_status` - Certification validity - -**Collection Method:** -- Safety system integration -- Regulatory compliance tracking -- Inspection result recording -- Add to job and user events - -#### **6. Enhanced E-commerce Metrics** -**Missing Parameters:** -- `churn_risk_score` - Calculated churn risk -- `subscription_health_score` - Subscription health metric -- `feature_usage_frequency` - Feature usage patterns -- `support_ticket_count` - Number of support tickets -- `user_engagement_score` - Overall engagement metric - -**Collection Method:** -- Calculate from usage patterns -- Track support interactions -- Add to subscription and user events -- Implement engagement scoring system - ---- - -## Implementation Recommendations - -### Phase 1: Core Parameter Enhancement (Immediate) -1. **Add Weather Data Integration** - - Implement weather API integration - - Add weather parameters to job events - - Create weather-based analytics - -2. **Enhance Financial Tracking** - - Add profit margin calculations - - Include cost tracking parameters - - Implement revenue analytics - -3. **Improve Performance Metrics** - - Add quality scoring system - - Implement accuracy tracking - - Create performance dashboards - -4. **Complete E-commerce Implementation** - - Ensure all subscription purchase tracking is active - - Validate string-based package names in tracking - - Add missing trial conversion tracking - - Implement addon purchase tracking (currently placeholder) - -### Phase 2: Advanced Analytics (Next 3 months) -1. **Equipment Integration** - - Connect with equipment systems - - Add maintenance tracking - - Implement utilization analytics - -2. **Compliance System** - - Add regulatory tracking - - Implement safety monitoring - - Create compliance dashboards - -3. **Customer Experience** - - Add satisfaction tracking - - Implement feedback collection - - Create customer analytics - -4. **Enhanced E-commerce Analytics** - - Implement churn prediction - - Add subscription health scoring - - Create customer lifetime value tracking - -### Phase 3: Predictive Analytics (Next 6 months) -1. **Machine Learning Integration** - - Predictive maintenance - - Weather-based scheduling - - Demand forecasting - - Churn prediction models - -2. **Advanced Reporting** - - Custom dashboard creation - - Automated insights - - Real-time monitoring - -3. **Subscription Optimization** - - Package recommendation engine - - Pricing optimization - - Retention improvement analytics - -### BigQuery Schema Enhancements - -#### **Recommended Event Parameter Additions** -```typescript -// Add to existing interfaces -export interface EnhancedJobParams extends JobCreatedParams { - weather_conditions?: string; - temperature?: number; - wind_speed?: number; - humidity?: number; - equipment_id?: string; - maintenance_status?: string; - profit_margin?: number; - cost_per_acre?: number; -} - -export interface QualityMetricsParams extends AgMissionBaseContext { - application_accuracy?: number; - coverage_quality?: number; - rework_required?: boolean; - customer_satisfaction?: number; - compliance_score?: number; -} - -export interface EnhancedSubscriptionParams extends SubscriptionPurchasedParams { - churn_risk_score?: number; - subscription_health_score?: number; - feature_usage_frequency?: number; - support_ticket_count?: number; - user_engagement_score?: number; -} -``` - -### **Current Implementation Status** - -#### **✅ Completed Events & Parameters** -- **Job Management**: All events implemented with comprehensive parameters -- **File Management**: Full upload/download tracking with validation metrics -- **Invoice Management**: Complete invoice lifecycle tracking -- **Authentication**: Full signup/login tracking with verification flows -- **E-commerce**: Subscription purchase tracking with correct package names -- **Performance**: Page load and API response tracking -- **Error Tracking**: HTTP error monitoring - -#### **🔄 In Progress** -- **E-commerce Addon Tracking**: Currently placeholder implementation -- **Trial Conversion Optimization**: Basic tracking in place, needs enhancement -- **Customer Lifecycle Analytics**: Partial implementation - -#### **❌ Missing Implementation** -- **Weather Integration**: API integration needed -- **Equipment Tracking**: System integration required -- **Compliance Monitoring**: Regulatory system integration -- **Quality Scoring**: Post-job assessment system -- **Customer Satisfaction**: Feedback collection system - -### **Data Quality Validation Queries** - -#### **Verify E-commerce Data Integrity** -```sql --- Check subscription purchase data completeness -SELECT - COUNT(*) as total_purchases, - COUNT(CASE WHEN (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'subscription_type') IS NOT NULL THEN 1 END) as with_package_name, - COUNT(CASE WHEN (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'service_type') IS NOT NULL THEN 1 END) as with_service_type, - COUNT(CASE WHEN (SELECT value.string_value FROM UNNEST(user_properties) WHERE key = 'subscription_tier') IS NOT NULL THEN 1 END) as with_tier -FROM `agmission-analytics.analytics_12345678.events_*` -WHERE event_name = 'subscription_purchased' - AND _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY)) - AND FORMAT_DATE('%Y%m%d', CURRENT_DATE()); -``` - -#### **Validate Parameter Consistency** -```sql --- Check for consistent parameter usage across events -SELECT - event_name, - COUNT(*) as event_count, - COUNT(DISTINCT user_id) as unique_users, - COUNT(CASE WHEN (SELECT value.string_value FROM UNNEST(user_properties) WHERE key = 'user_role') IS NOT NULL THEN 1 END) as with_user_role, - COUNT(CASE WHEN (SELECT value.string_value FROM UNNEST(user_properties) WHERE key = 'subscription_tier') IS NOT NULL THEN 1 END) as with_subscription_tier -FROM `agmission-analytics.analytics_12345678.events_*` -WHERE _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY)) - AND FORMAT_DATE('%Y%m%d', CURRENT_DATE()) -GROUP BY event_name -ORDER BY event_count DESC; -``` diff --git a/Development/client/AgMission-GA4-Complete-Reference.csv b/Development/client/AgMission-GA4-Complete-Reference.csv deleted file mode 100644 index ec47617..0000000 --- a/Development/client/AgMission-GA4-Complete-Reference.csv +++ /dev/null @@ -1,221 +0,0 @@ -Event Category,Event Name,Event Description,Component Location,Parameter Name,Parameter Type,Parameter Description,Allowed Values,Example Value,Required/Optional,Business Purpose,Validation Rules -Job Management,job_created,User creates a new agricultural job,job.effects.ts,job_type,String,Type of agricultural job being performed,"spraying, seeding, fertilizing, harvesting, soil_testing",spraying,Required,Categorize jobs for operational insights,Must be from predefined list -Job Management,job_created,User creates a new agricultural job,job.effects.ts,field_size_acres,Number,Size of the field in acres,Positive numbers up to 10000,150.5,Required,Track job scale and pricing,Must be > 0 and <= 10000 -Job Management,job_created,User creates a new agricultural job,job.effects.ts,crop_type,String,Type of crop being worked on,"corn, soybeans, wheat, cotton, alfalfa, other",corn,Required,Analyze crop-specific patterns,Required for all job events -Job Management,job_created,User creates a new agricultural job,job.effects.ts,client_id,String,Unique identifier for the client,Alphanumeric string,CLIENT_12345,Required,Track client relationships and revenue,Must be valid client ID -Job Management,job_created,User creates a new agricultural job,job.effects.ts,priority,String,Job priority level,"low, medium, high, urgent",high,Required,Optimize job scheduling,Must be from predefined list -Job Management,job_created,User creates a new agricultural job,job.effects.ts,equipment_type,String,Type of equipment used,"drone, ground_rig, aerial, manual, tractor",drone,Optional,Track equipment utilization,Must be from equipment catalog -Job Management,job_created,User creates a new agricultural job,job.effects.ts,weather_dependency,Boolean,Whether job depends on weather conditions,true or false,true,Optional,Plan weather-sensitive operations,Boolean validation -Job Management,job_created,User creates a new agricultural job,job.effects.ts,estimated_duration_hours,Number,Expected job duration in hours,Positive numbers,4.5,Optional,Resource planning and scheduling,Must be > 0 and <= 24 -Job Management,job_updated,User modifies an existing job,job.effects.ts,job_id,String,Unique job identifier,Alphanumeric string,JOB_001,Required,Track individual job lifecycle,Must be valid job ID -Job Management,job_updated,User modifies an existing job,job.effects.ts,fields_modified,Array,List of fields changed in update,Array of strings,"[""priority"", ""crop_type""]",Required,Monitor update patterns,Must be valid field names -Job Management,job_updated,User modifies an existing job,job.effects.ts,change_magnitude,String,Magnitude of the changes made,"minor, major",minor,Optional,Track update impact,Must be from predefined levels -Job Management,job_updated,User modifies an existing job,job.effects.ts,edit_session_duration,Number,Time spent editing in minutes,Positive numbers,15.5,Optional,Monitor user efficiency,Must be > 0 -Job Management,job_updated,User modifies an existing job,job.effects.ts,save_method,String,How the update was saved,"manual, auto_save",manual,Optional,Track save behavior,Must be from valid save methods -Job Management,job_deleted,User removes a job from system,job.effects.ts,job_id,String,Unique job identifier,Alphanumeric string,JOB_001,Required,Track individual job lifecycle,Must be valid job ID -Job Management,job_deleted,User removes a job from system,job.effects.ts,job_type,String,Type of agricultural job being performed,"spraying, seeding, fertilizing, harvesting, soil_testing",spraying,Required,Categorize jobs for operational insights,Must be from predefined list -Job Management,job_deleted,User removes a job from system,job.effects.ts,job_status,String,Current status of the job,"new, ready, downloaded, sprayed, invoiced",new,Required,Track job lifecycle states,Must be from valid status list -Job Management,job_deleted,User removes a job from system,job.effects.ts,deletion_reason,String,Reason for job deletion,"cancelled, duplicate, error, user_action",user_action,Optional,Track deletion patterns and causes,Must be from predefined reasons -Job Management,job_deleted,User removes a job from system,job.effects.ts,time_since_creation,Number,Time between creation and deletion in hours,Non-negative numbers,48.5,Optional,Monitor job lifecycle timing,Must be >= 0 -Job Management,job_deleted,User removes a job from system,job.effects.ts,deletion_method,String,How job deletion was triggered,"button_click, bulk_action, api_call",button_click,Optional,Optimize deletion workflows,Must be from valid methods -Job Management,job_assigned,Job assigned to pilot or operator,job.effects.ts,job_id,String,Unique job identifier,Alphanumeric string,JOB_001,Required,Track individual job lifecycle,Must be valid job ID -Job Management,job_assigned,Job assigned to pilot or operator,job.effects.ts,assignee_id,String,ID of person assigned to job,Alphanumeric string,USER_456,Required,Track assignment patterns,Must be valid user ID -Job Management,job_assigned,Job assigned to pilot or operator,job.effects.ts,assignee_role,String,Role of assigned person,"pilot, operator, supervisor, manager",pilot,Required,Optimize role assignments,Must be from predefined roles -Job Management,job_assigned,Job assigned to pilot or operator,job.effects.ts,assignment_method,String,How assignment was made,"manual, auto, bulk",manual,Required,Track assignment efficiency,Must be from predefined methods -Job Management,job_assigned,Job assigned to pilot or operator,job.effects.ts,assignment_lead_time_hours,Number,Hours between assignment and scheduled start,Non-negative numbers,24.0,Optional,Track planning efficiency,Must be >= 0 -Job Management,job_status_changed,Job status transitions,job-edit.component.ts,job_id,String,Unique job identifier,Alphanumeric string,JOB_001,Required,Track individual job lifecycle,Must be valid job ID -Job Management,job_status_changed,Job status transitions,job-edit.component.ts,old_status,String,Previous status before change,"new, ready, downloaded, sprayed, invoiced",new,Required,Track status transition patterns,Must be from valid status list -Job Management,job_status_changed,Job status transitions,job-edit.component.ts,new_status,String,New status after change,"new, ready, downloaded, sprayed, invoiced",sprayed,Required,Track status transition patterns,Must be from valid status list -Job Management,job_status_changed,Job status transitions,job-edit.component.ts,status_change_reason,String,Reason for status change,"user_action, system_update, api_call, automation",user_action,Required,Understand status change drivers,Must be from valid reason types -Job Management,job_status_changed,Job status transitions,job-edit.component.ts,completion_time,Number,Time to complete job in hours,Positive numbers,4.2,Optional,Track job completion efficiency,Must be > 0 when status changed to completed -Job Management,job_status_changed,Job status transitions,job-edit.component.ts,efficiency_score,Number,Calculated efficiency percentage,Number 0-100,85.5,Optional,Monitor operational efficiency,Must be between 0 and 100 -Job List Operations,job_list_viewed,User accesses the jobs list interface,job-list.component.ts,view_type,String,Type of list view used,"table, grid, map, calendar",table,Required,Optimize UI preferences,Must be from available views -Job List Operations,job_list_viewed,User accesses the jobs list interface,job-list.component.ts,total_jobs,Number,Total jobs available in system,Non-negative integer,45,Required,Monitor system usage,Must be >= 0 -Job List Operations,job_list_viewed,User accesses the jobs list interface,job-list.component.ts,displayed_jobs,Number,Number of jobs shown to user,Non-negative integer,20,Required,Track filtering effectiveness,Must be >= 0 and <= total_jobs -Job List Operations,job_list_viewed,User accesses the jobs list interface,job-list.component.ts,client_filter_applied,Boolean,Whether client filter is active,true or false,true,Optional,Track client-specific viewing patterns,Boolean validation -Job List Operations,job_list_viewed,User accesses the jobs list interface,job-list.component.ts,reload_interval,Number,Auto-reload interval in minutes,Non-negative integer,5,Optional,Track user preference for data freshness,Must be >= 0 -Job List Operations,job_list_filtered,User applies filters to narrow job results,job-list.component.ts,filter_type,String,Type of filter applied,"status, date_range, client, crop_type, priority",status,Required,Improve filter functionality,Must be valid filter type -Job List Operations,job_list_filtered,User applies filters to narrow job results,job-list.component.ts,results_before,Number,Results count before filter,Non-negative integer,45,Required,Measure filter effectiveness,Must be >= 0 -Job List Operations,job_list_filtered,User applies filters to narrow job results,job-list.component.ts,results_after,Number,Results count after filter,Non-negative integer,12,Required,Measure filter effectiveness,Must be >= 0 and <= results_before -Job List Operations,job_list_filtered,User applies filters to narrow job results,job-list.component.ts,filter_value,String,Value of the applied filter,String,new,Optional,Track specific filter usage,Must be valid for filter type -Job List Operations,job_list_filtered,User applies filters to narrow job results,job-list.component.ts,date_filter_type,String,Type of date filter applied,"today, week, month, quarter, custom",month,Optional,Track temporal filtering patterns,Must be from valid date types -Job List Operations,job_list_filtered,User applies filters to narrow job results,job-list.component.ts,custom_date_range,Array,Custom date range selected,Array of dates,"[""2024-01-01"", ""2024-01-31""]",Optional,Track custom date usage,Must be valid date range -Job List Operations,job_selected,User clicks/selects a specific job,job-list.component.ts,job_id,String,Unique job identifier,Alphanumeric string,JOB_001,Required,Track individual job lifecycle,Must be valid job ID -Job List Operations,job_selected,User clicks/selects a specific job,job-list.component.ts,selection_method,String,Method used to select job,"row_click, search_result, link_navigation",row_click,Required,Optimize selection UX,Must be from valid selection methods -Job List Operations,job_selected,User clicks/selects a specific job,job-list.component.ts,position_in_list,Number,Position of job in list when selected,Positive integer,3,Optional,Track selection patterns,Must be > 0 -Job List Operations,job_selected,User clicks/selects a specific job,job-list.component.ts,job_type,String,Type of agricultural job being performed,"spraying, seeding, fertilizing, harvesting, soil_testing",spraying,Optional,Categorize jobs for operational insights,Must be from predefined list -Job List Operations,job_selected,User clicks/selects a specific job,job-list.component.ts,job_status,String,Current status of the job,"new, ready, downloaded, sprayed, invoiced",new,Optional,Track job lifecycle states,Must be from valid status list -Job List Operations,job_bulk_action,User performs action on multiple jobs,job-list.component.ts,action_type,String,Type of bulk action performed,"duplicate, delete, assign, status_change, export",duplicate,Required,Track bulk operation patterns,Must be from valid action types -Job List Operations,job_bulk_action,User performs action on multiple jobs,job-list.component.ts,job_count,Number,Number of jobs affected by bulk action,Positive integer,1,Required,Monitor bulk operation scale,Must be > 0 -Job List Operations,job_bulk_action,User performs action on multiple jobs,job-list.component.ts,job_ids,Array,List of job IDs affected by action,Array of strings,"[""JOB_001""]",Required,Track specific jobs in bulk operations,Must be valid job IDs -Job List Operations,job_bulk_action,User performs action on multiple jobs,job-list.component.ts,execution_time,Number,Time taken to complete bulk action in seconds,Positive numbers,2.5,Optional,Monitor bulk operation performance,Must be > 0 -Job List Operations,job_bulk_action,User performs action on multiple jobs,job-list.component.ts,success_rate,Number,Percentage of successful operations in bulk action,Number 0-100,100,Optional,Track bulk operation reliability,Must be between 0 and 100 -File Upload Operations,file_upload_started,User initiates file upload process,"upload.component.ts, job-edit.component.ts",file_type,String,Type of file being uploaded,"field_boundary, prescription_map, application_report, soil_map, shape, geojson, kml, other",field_boundary,Required,Track file usage patterns,Must be from supported file types -File Upload Operations,file_upload_started,User initiates file upload process,"upload.component.ts, job-edit.component.ts",file_size_mb,Number,File size in megabytes,Positive numbers,2.3,Required,Monitor upload performance,Must be > 0 and <= 100 -File Upload Operations,file_upload_started,User initiates file upload process,"upload.component.ts, job-edit.component.ts",related_job_id,String,Job ID associated with file upload,Alphanumeric string,JOB_001,Optional,Track file-job relationships,Must be valid job ID when provided -File Upload Operations,file_upload_started,User initiates file upload process,"upload.component.ts, job-edit.component.ts",upload_source,String,Source of file upload,"drag_drop, file_picker, api",drag_drop,Optional,Track upload method preferences,Must be from valid upload sources -File Upload Operations,file_upload_completed,File upload finishes successfully,"upload.component.ts, job-edit.component.ts, areas.component.ts",file_type,String,Type of file being uploaded,"field_boundary, prescription_map, application_report, soil_map, shape, geojson, kml, other",field_boundary,Required,Track file usage patterns,Must be from supported file types -File Upload Operations,file_upload_completed,File upload finishes successfully,"upload.component.ts, job-edit.component.ts, areas.component.ts",processing_time_seconds,Number,Time to process file in seconds,Positive numbers,15.2,Required,Optimize processing performance,Must be > 0 -File Upload Operations,file_upload_completed,File upload finishes successfully,"upload.component.ts, job-edit.component.ts, areas.component.ts",validation_status,String,File validation result,"passed, failed, warning",passed,Required,Monitor file quality,Must be from validation states -File Upload Operations,file_upload_completed,File upload finishes successfully,"upload.component.ts, job-edit.component.ts, areas.component.ts",data_quality_score,Number,Quality score of uploaded file data,Number 0-100,87.5,Optional,Monitor data quality trends,Must be between 0 and 100 -File Upload Operations,file_upload_completed,File upload finishes successfully,"upload.component.ts, job-edit.component.ts, areas.component.ts",automation_enabled,Boolean,Whether automated processing was used,true or false,true,Optional,Track automation usage,Boolean validation -File Upload Operations,file_upload_failed,File upload encounters error,"upload.component.ts, job-edit.component.ts",file_type,String,Type of file being uploaded,"field_boundary, prescription_map, application_report, soil_map, shape, geojson, kml, other",field_boundary,Required,Track file usage patterns,Must be from supported file types -File Upload Operations,file_upload_failed,File upload encounters error,"upload.component.ts, job-edit.component.ts",error_type,String,Type of upload error,"network_error, file_too_large, invalid_format, timeout, server_error",invalid_format,Required,Improve error handling,Must be from error catalog -File Upload Operations,file_upload_failed,File upload encounters error,"upload.component.ts, job-edit.component.ts",file_size_mb,Number,File size in megabytes,Positive numbers,2.3,Required,Monitor upload performance,Must be > 0 and <= 100 -File Upload Operations,file_upload_failed,File upload encounters error,"upload.component.ts, job-edit.component.ts",retry_attempted,Boolean,Whether user attempted to retry upload,true or false,true,Optional,Monitor retry patterns,Boolean validation -File Upload Operations,file_validation_error,File validation fails with specific errors,"upload.component.ts, job-edit.component.ts",validation_error_type,String,Type of file validation error,"missing_coordinates, invalid_geometry, unsupported_format, file_corruption, size_limit_exceeded",missing_coordinates,Required,Categorize validation failures,Must be from validation error types -File Upload Operations,file_validation_error,File validation fails with specific errors,"upload.component.ts, job-edit.component.ts",error_details,String,Detailed error information,String,Invalid coordinate system detected,Required,Improve error messaging,Required for validation errors -File Upload Operations,file_validation_error,File validation fails with specific errors,"upload.component.ts, job-edit.component.ts",user_action,String,User action after validation error,"retry, cancel, ignore, edit",retry,Required,Track user response to errors,Must be from valid actions -File Management Operations,file_deleted,User deletes an uploaded file,job-edit.component.ts,file_type,String,Type of file being uploaded,"field_boundary, prescription_map, application_report, soil_map, shape, geojson, kml, other",field_boundary,Required,Track file usage patterns,Must be from supported file types -File Management Operations,file_deleted,User deletes an uploaded file,job-edit.component.ts,file_size_mb,Number,File size in megabytes,Positive numbers,2.3,Required,Monitor upload performance,Must be > 0 and <= 100 -File Management Operations,file_deleted,User deletes an uploaded file,job-edit.component.ts,deletion_reason,String,Reason for file deletion,"user_action, cleanup, replacement, error_correction",user_action,Required,Track file management patterns,Must be from predefined reasons -File Management Operations,file_deleted,User deletes an uploaded file,job-edit.component.ts,confirmation_required,Boolean,Whether deletion required user confirmation,true or false,true,Required,Track UX patterns for file operations,Boolean validation -File Management Operations,file_deleted,User deletes an uploaded file,job-edit.component.ts,related_job_id,String,Job ID associated with file upload,Alphanumeric string,JOB_001,Optional,Track file-job relationships,Must be valid job ID when provided -File Management Operations,file_deleted,User deletes an uploaded file,job-edit.component.ts,file_age_days,Number,Age of file in days when deleted,Non-negative integer,7,Optional,Monitor file lifecycle patterns,Must be >= 0 -File Management Operations,file_downloaded,User downloads a file,job-edit.component.ts,file_type,String,Type of file being uploaded,"field_boundary, prescription_map, application_report, soil_map, shape, geojson, kml, other",field_boundary,Required,Track file usage patterns,Must be from supported file types -File Management Operations,file_downloaded,User downloads a file,job-edit.component.ts,file_size_mb,Number,File size in megabytes,Positive numbers,2.3,Required,Monitor upload performance,Must be > 0 and <= 100 -File Management Operations,file_downloaded,User downloads a file,job-edit.component.ts,download_method,String,Method used to download file,"direct_link, button_click, bulk_export",button_click,Required,Optimize download UX,Must be from valid download methods -File Management Operations,file_downloaded,User downloads a file,job-edit.component.ts,download_source,String,Source location of download action,"job_edit, file_manager, report_export",job_edit,Required,Track download context,Must be from valid source locations -File Management Operations,file_downloaded,User downloads a file,job-edit.component.ts,related_job_id,String,Job ID associated with file upload,Alphanumeric string,JOB_001,Optional,Track file-job relationships,Must be valid job ID when provided -File Management Operations,file_downloaded,User downloads a file,job-edit.component.ts,file_format,String,Format of downloaded file,"original, converted",original,Optional,Track format preferences,Must be valid format type -Library Upload Operations,library_upload_completed,Areas/fields uploaded to library successfully,"areas.component.ts, track.component.ts",upload_type,String,Type of library upload,"field_areas, tracked_areas",field_areas,Required,Track library content additions,Must be from valid upload types -Library Upload Operations,library_upload_completed,Areas/fields uploaded to library successfully,"areas.component.ts, track.component.ts",file_count,Number,Number of files uploaded,Positive integer,3,Required,Monitor upload volume,Must be > 0 -Library Upload Operations,library_upload_completed,Areas/fields uploaded to library successfully,"areas.component.ts, track.component.ts",total_areas_uploaded,Number,Total number of areas added to library,Non-negative integer,12,Required,Track library growth,Must be >= 0 -Library Upload Operations,library_upload_completed,Areas/fields uploaded to library successfully,"areas.component.ts, track.component.ts",duplicate_areas_found,Number,Number of duplicate areas detected,Non-negative integer,2,Optional,Monitor data quality,Must be >= 0 -Library Upload Operations,library_upload_completed,Areas/fields uploaded to library successfully,"areas.component.ts, track.component.ts",failed_files,Number,Number of files that failed to process,Non-negative integer,0,Optional,Track processing reliability,Must be >= 0 -Library Upload Operations,library_upload_completed,Areas/fields uploaded to library successfully,"areas.component.ts, track.component.ts",processing_method,String,Method used for processing,"automatic, manual_review",automatic,Optional,Track processing approaches,Must be from valid processing methods -E-commerce,subscription_purchased,User purchases or upgrades subscription,"subscription.effects.ts (updateSubscription$, checkoutTrial$)",subscription_type,String,Type of subscription purchased,AgMission package names (e.g. AgMission Essentials 1-5 or AgMission Enterprise 1-5),AgMission Essentials 3,Required,Track subscription tier adoption and revenue,Must be valid SUB_NAME value from common.ts -E-commerce,subscription_purchased,User purchases or upgrades subscription,"subscription.effects.ts (updateSubscription$, checkoutTrial$)",subscription_duration,String,Duration of subscription,"monthly, quarterly, annual",monthly,Required,Monitor subscription length preferences,Must be from valid duration options -E-commerce,subscription_purchased,User purchases or upgrades subscription,"subscription.effects.ts (updateSubscription$, checkoutTrial$)",subscription_price,Number,Price of subscription in USD,Positive numbers,99.99,Required,Track revenue per subscription,Must be > 0 -E-commerce,subscription_purchased,User purchases or upgrades subscription,"subscription.effects.ts (updateSubscription$, checkoutTrial$)",previous_subscription_type,String,Previous subscription type before change,AgMission package names or none,AgMission Essentials 1,Optional,Track subscription transitions,Must be valid SUB_NAME value or none -E-commerce,subscription_purchased,User purchases or upgrades subscription,"subscription.effects.ts (updateSubscription$, checkoutTrial$)",payment_method,String,Method used for payment,"credit_card, bank_transfer, paypal, invoice",credit_card,Required,Optimize payment options,Must be from supported payment methods -E-commerce,subscription_purchased,User purchases or upgrades subscription,"subscription.effects.ts (updateSubscription$, checkoutTrial$)",billing_frequency,String,How often billing occurs,"monthly, quarterly, annual",monthly,Required,Track billing preferences,Must match subscription duration -E-commerce,subscription_purchased,User purchases or upgrades subscription,"subscription.effects.ts (updateSubscription$, checkoutTrial$)",promo_code,String,Promotional code used,Alphanumeric string,SAVE20,Optional,Track promotion effectiveness,Optional promotional code -E-commerce,subscription_purchased,User purchases or upgrades subscription,"subscription.effects.ts (updateSubscription$, checkoutTrial$)",discount_amount,Number,Discount applied in USD,Non-negative numbers,19.99,Optional,Monitor discount impact,Must be >= 0 -E-commerce,subscription_purchased,User purchases or upgrades subscription,"subscription.effects.ts (updateSubscription$, checkoutTrial$)",subscription_start_date,String,Start date of subscription,ISO date string,2024-01-15,Required,Track subscription lifecycle,Must be valid ISO date -E-commerce,subscription_purchased,User purchases or upgrades subscription,"subscription.effects.ts (updateSubscription$, checkoutTrial$)",auto_renewal,Boolean,Whether subscription auto-renews,true or false,true,Required,Monitor auto-renewal adoption,Boolean validation -E-commerce,subscription_purchased,User purchases or upgrades subscription,"subscription.effects.ts (updateSubscription$, checkoutTrial$)",upgrade_from,String,Previous subscription tier when upgrading,AgMission package names,AgMission Essentials 1,Optional,Track upgrade patterns,Required when transaction is an upgrade -E-commerce,subscription_purchased,User purchases or upgrades subscription,"subscription.effects.ts (updateSubscription$, checkoutTrial$)",upgrade_to,String,New subscription tier when upgrading,AgMission package names,AgMission Essentials 3,Optional,Track upgrade patterns,Required when transaction is an upgrade -E-commerce,subscription_purchased,User purchases or upgrades subscription,"subscription.effects.ts (updateSubscription$, checkoutTrial$)",trial_conversion,Boolean,Whether purchase is converting from trial,true or false,true,Required,Monitor trial conversion rate,Boolean validation -E-commerce,subscription_purchased,User purchases or upgrades subscription,"subscription.effects.ts (updateSubscription$, checkoutTrial$)",subscription_value,Number,Annual contract value in USD,Positive numbers,1199.88,Required,Track customer lifetime value,Must be > 0 -E-commerce,subscription_purchased,User purchases or upgrades subscription,"subscription.effects.ts (updateSubscription$, checkoutTrial$)",user_tenure_days,Number,Days since user first registered,Non-negative integer,45,Required,Analyze subscription timing patterns,Must be >= 0 -E-commerce,subscription_purchased,User purchases or upgrades subscription,"subscription.effects.ts (updateSubscription$, checkoutTrial$)",service_type,String,Service category of subscription,"essential, enterprise, addon",essential,Optional,Categorize subscription types by service level,Must be from SERVICE_TYPE enum -E-commerce,subscription_purchased,User purchases or upgrades subscription,"subscription.effects.ts (updateSubscription$, checkoutTrial$)",is_trial,Boolean,Whether this is a trial subscription,true or false,false,Optional,Track trial vs paid subscriptions,Boolean validation -Performance,slow_page_load,Page loads slower than threshold,app.component.ts,page_url,String,URL of the page with slow load,String,/dashboard/jobs,Required,Identify performance bottlenecks,Must be valid URL path -Performance,slow_page_load,Page loads slower than threshold,app.component.ts,load_time,Number,Page load time in seconds,Positive numbers,8.5,Required,Monitor page performance,Must be > 0 -Performance,slow_page_load,Page loads slower than threshold,app.component.ts,device_type,String,Type of device experiencing slow load,"desktop, mobile, tablet",desktop,Optional,Optimize for different devices,Must be from device categories -Performance,slow_page_load,Page loads slower than threshold,app.component.ts,connection_type,String,User's connection type,"wifi, cellular, ethernet, unknown",wifi,Optional,Understand connection impact,Must be from connection types -Performance,api_response_slow,API calls exceed performance threshold,global-error.interceptor.ts,api_endpoint,String,API endpoint with slow response,String,/api/v1/jobs,Required,Identify slow API endpoints,Must be valid API path -Performance,api_response_slow,API calls exceed performance threshold,global-error.interceptor.ts,response_time,Number,API response time in milliseconds,Positive numbers,3500,Required,Monitor API performance,Must be > 0 -Performance,api_response_slow,API calls exceed performance threshold,global-error.interceptor.ts,request_size,Number,Size of API request in bytes,Non-negative integer,1024,Optional,Analyze request impact on performance,Must be >= 0 -Performance,api_response_slow,API calls exceed performance threshold,global-error.interceptor.ts,response_size,Number,Size of API response in bytes,Non-negative integer,5120,Optional,Analyze response impact on performance,Must be >= 0 -Performance,api_response_slow,API calls exceed performance threshold,global-error.interceptor.ts,cache_hit,Boolean,Whether response was served from cache,true or false,false,Optional,Monitor caching effectiveness,Boolean validation -Performance,api_response_slow,API calls exceed performance threshold,global-error.interceptor.ts,http_status,Number,HTTP status code of response,Valid HTTP status codes,200,Optional,Track response success patterns,Must be valid HTTP status code -Error Tracking,http_error,HTTP request errors automatically tracked by interceptor,global-error.interceptor.ts,error_type,String,Type of HTTP error that occurred,"network_error, server_error, client_error, timeout, unknown_error",server_error,Required,Categorize HTTP errors for debugging and monitoring,Must be from predefined error types -Error Tracking,http_error,HTTP request errors automatically tracked by interceptor,global-error.interceptor.ts,http_status_code,Number,HTTP status code returned by server,Integer 0-599,500,Required,Track specific HTTP error codes for debugging,Must be valid HTTP status code (0-599) -Error Tracking,http_error,HTTP request errors automatically tracked by interceptor,global-error.interceptor.ts,request_method,String,HTTP method used for the request,"GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS",GET,Required,Track which HTTP methods encounter errors,Must be valid HTTP method -Error Tracking,http_error,HTTP request errors automatically tracked by interceptor,global-error.interceptor.ts,request_url,String,Full URL of the failed request,Valid URL string,https://api.agmission.com/api/jobs,Required,Track specific endpoints experiencing errors,Must be valid URL format -Error Tracking,http_error,HTTP request errors automatically tracked by interceptor,global-error.interceptor.ts,request_endpoint,String,API endpoint that failed,String,jobs,Required,Track which API endpoints have the most errors,Must be valid endpoint identifier -Error Tracking,http_error,HTTP request errors automatically tracked by interceptor,global-error.interceptor.ts,response_time_ms,Number,Time taken for the request to fail in milliseconds,Non-negative integer,5000,Optional,Monitor request timing patterns for failed requests,Must be >= 0 -Error Tracking,http_error,HTTP request errors automatically tracked by interceptor,global-error.interceptor.ts,affected_feature,String,Application feature affected by the error,"job_management, billing, reporting, file_management, user_management, authentication, customer_management, equipment_management, unknown",job_management,Optional,Track which features are most impacted by HTTP errors,Must be from predefined feature list -User Authentication,login,User logs into AgMission system,auth.service.ts,method,String,Authentication method used,"email, google, microsoft, sso",email,Required,Track authentication preferences and security,Must be from supported authentication methods -User Authentication,login,User logs into AgMission system,auth.service.ts,user_role,String,Role of the user performing the action,"admin, applicator, office_admin, client, officer, pilot, inspector, aircraft",applicator,Required,Segment analytics by user type and permissions,Must be from predefined role list -User Authentication,login,User logs into AgMission system,auth.service.ts,last_login_days_ago,Number,Days since user's last login,Non-negative numbers,7,Optional,Track user return patterns,Must be >= 0 -User Authentication,logout,User logs out of system,auth.service.ts,session_duration_minutes,Number,Duration of user session in minutes,Positive integers,45,Required,Monitor user engagement and session patterns,Must be > 0 -User Authentication,logout,User logs out of system,auth.service.ts,user_role,String,Role of the user performing the action,"admin, applicator, office_admin, client, officer, pilot, inspector, aircraft",applicator,Required,Segment analytics by user type and permissions,Must be from predefined role list -User Authentication,logout,User logs out of system,auth.service.ts,logout_method,String,How user logged out,"manual, timeout, forced",manual,Optional,Understand logout patterns and session management,Must be from predefined logout types -User Authentication,signup,User begins signup process,"signup-form.component.ts, signup-verify.component.ts",signup_method,String,Method used for account signup,"email, google, microsoft, invitation",email,Required,Track signup channel effectiveness,Must be from supported signup methods -User Authentication,signup,User begins signup process,"signup-form.component.ts, signup-verify.component.ts",user_type,String,Type of user signing up,"client, applicator, admin, office_admin",applicator,Required,Segment new user acquisition,Must be from predefined user types -User Authentication,signup,User begins signup process,"signup-form.component.ts, signup-verify.component.ts",source,String,Source of signup traffic,"landing_page, referral, advertisement, direct",landing_page,Optional,Track marketing channel effectiveness,Must be from valid traffic sources -User Authentication,signup,User begins signup process,"signup-form.component.ts, signup-verify.component.ts",company_name,String,Name of company during signup,String,AgriCorp Inc,Optional,Identify business customers,Required for business signups -User Authentication,signup_completed,User completes signup process,signup-form.component.ts,signup_duration_minutes,Number,Time taken to complete signup in minutes,Positive numbers,15.5,Required,Monitor signup flow efficiency,Must be > 0 -User Authentication,signup_completed,User completes signup process,signup-form.component.ts,signup_method,String,Method used for account signup,"email, google, microsoft, invitation",email,Required,Track signup channel effectiveness,Must be from supported signup methods -User Authentication,signup_completed,User completes signup process,signup-form.component.ts,user_type,String,Type of user signing up,"client, applicator, admin, office_admin",applicator,Required,Segment new user acquisition,Must be from predefined user types -User Authentication,signup_completed,User completes signup process,signup-form.component.ts,verification_required,Boolean,Whether email verification was required,true or false,true,Required,Track verification requirements,Boolean validation -User Authentication,signup_completed,User completes signup process,signup-form.component.ts,profile_completed,Boolean,Whether user completed full profile setup,true or false,false,Optional,Monitor onboarding completion,Boolean validation -User Authentication,password_reset_requested,User requests password reset,auth.service.ts,request_method,String,How password reset was requested,"forgot_password_page, login_page, profile_page",forgot_password_page,Required,Track reset request patterns,Must be from valid request sources -User Authentication,password_reset_requested,User requests password reset,auth.service.ts,user_exists,Boolean,Whether user account exists for reset request,true or false,true,Required,Monitor reset request validity,Boolean validation -User Authentication,password_reset_requested,User requests password reset,auth.service.ts,email_address_hash,String,Hashed email address for privacy,String,abc123def456,Optional,Track verification requests while maintaining privacy,Must be valid hash when provided -User Authentication,password_reset_completed,Password reset process completed,"auth.service.ts, app.password-reset.component.ts",success,Boolean,Whether password reset was successful,true or false,true,Required,Track reset success rates,Boolean validation -User Authentication,password_reset_completed,Password reset process completed,"auth.service.ts, app.password-reset.component.ts",reset_token_age_minutes,Number,Age of reset token when used in minutes,Non-negative numbers,5,Required,Monitor token validity and timing,Must be >= 0 -User Authentication,password_reset_completed,Password reset process completed,"auth.service.ts, app.password-reset.component.ts",failure_reason,String,Reason for password reset failure,"expired_token, invalid_token, weak_password, other",expired_token,Optional,Categorize reset failures for improvement,Must be from predefined failure reasons -User Authentication,email_verification_requested,User requests email verification,signup-verify.component.ts,request_method,String,How email verification was requested,"signup_form, verification_page, resend_request",verification_page,Required,Track verification request patterns,Must be from valid request sources -User Authentication,email_verification_requested,User requests email verification,signup-verify.component.ts,user_exists,Boolean,Whether user account exists for reset request,true or false,true,Required,Monitor reset request validity,Boolean validation -User Authentication,email_verification_requested,User requests email verification,signup-verify.component.ts,email_address_hash,String,Hashed email address for privacy,String,abc123def456,Optional,Track verification requests while maintaining privacy,Must be valid hash when provided -User Authentication,email_verification_completed,Email verification process completed,signup-verify.component.ts,success,Boolean,Whether password reset was successful,true or false,true,Required,Track reset success rates,Boolean validation -User Authentication,email_verification_completed,Email verification process completed,signup-verify.component.ts,verification_token_age_minutes,Number,Age of verification token when used in minutes,Non-negative numbers,30,Required,Monitor verification token validity and timing,Must be >= 0 -User Authentication,email_verification_completed,Email verification process completed,signup-verify.component.ts,failure_reason,String,Reason for password reset failure,"expired_token, invalid_token, weak_password, other",expired_token,Optional,Categorize reset failures for improvement,Must be from predefined failure reasons -Invoice Management,invoice_created,User creates a new invoice,invoice-edit.component.ts,invoice_id,String,Unique identifier for invoice,Alphanumeric string,INV_001,Required,Track individual invoice lifecycle,Must be valid invoice ID -Invoice Management,invoice_created,User creates a new invoice,invoice-edit.component.ts,total_amount,Number,Total invoice amount,Positive numbers,2500.00,Required,Track revenue and financial metrics,Must be > 0 -Invoice Management,invoice_created,User creates a new invoice,invoice-edit.component.ts,currency,String,Currency code for invoice amount,"USD, CAD, EUR",USD,Required,Track multi-currency operations,Must be valid ISO currency code -Invoice Management,invoice_created,User creates a new invoice,invoice-edit.component.ts,creation_method,String,Method used to create invoice,"manual, auto_generated, template, recurring",manual,Required,Track invoice creation patterns,Must be from predefined methods -Invoice Management,invoice_created,User creates a new invoice,invoice-edit.component.ts,due_date_days,Number,Days until invoice due date,Integer,30,Required,Track payment terms and cash flow,Must be >= 0 -Invoice Management,invoice_created,User creates a new invoice,invoice-edit.component.ts,payment_terms,String,Payment terms for invoice,String,net_30,Optional,Analyze payment term preferences,Free text or predefined terms -Invoice Management,invoice_updated,User modifies an existing invoice,invoice-edit.component.ts,invoice_id,String,Unique identifier for invoice,Alphanumeric string,INV_001,Required,Track individual invoice lifecycle,Must be valid invoice ID -Invoice Management,invoice_updated,User modifies an existing invoice,invoice-edit.component.ts,fields_modified,Array,List of fields changed in invoice update,Array of strings,"[""amount"", ""due_date""]",Required,Monitor invoice modification patterns,Must be valid field names -Invoice Management,invoice_updated,User modifies an existing invoice,invoice-edit.component.ts,modification_type,String,Primary type of modification made,"amount, due_date, jobs, customer, payment_terms",amount,Required,Categorize modification patterns,Must be from predefined types -Invoice Management,invoice_updated,User modifies an existing invoice,invoice-edit.component.ts,amount_change,Number,Change in invoice amount,Number (can be negative),-150.00,Optional,Track invoice adjustments,Can be positive or negative -Invoice Management,invoice_updated,User modifies an existing invoice,invoice-edit.component.ts,previous_status,String,Previous invoice status before update,"new, draft, open, paid, void, uncollectible",draft,Optional,Track status progression,Must be from valid status list -Invoice Management,invoice_updated,User modifies an existing invoice,invoice-edit.component.ts,current_status,String,Current invoice status after update,"new, draft, open, paid, void, uncollectible",open,Optional,Track status progression,Must be from valid status list -Invoice Management,invoice_updated,User modifies an existing invoice,invoice-edit.component.ts,edit_session_duration,Number,Time spent editing invoice in minutes,Positive numbers,15.5,Optional,Monitor user efficiency,Must be > 0 -Invoice Management,invoice_deleted,User removes an invoice from system,invoice-edit.component.ts,invoice_id,String,Unique identifier for invoice,Alphanumeric string,INV_001,Required,Track individual invoice lifecycle,Must be valid invoice ID -Invoice Management,invoice_deleted,User removes an invoice from system,invoice-edit.component.ts,invoice_status,String,Current status of invoice,"new, draft, open, paid, void, uncollectible",paid,Required,Track invoice lifecycle states,Must be from valid status list -Invoice Management,invoice_deleted,User removes an invoice from system,invoice-edit.component.ts,total_amount,Number,Total invoice amount,Positive numbers,2500.00,Required,Track revenue and financial metrics,Must be > 0 -Invoice Management,invoice_deleted,User removes an invoice from system,invoice-edit.component.ts,deletion_reason,String,Reason for invoice deletion,"cancelled, duplicate, error, customer_request",cancelled,Required,Track deletion patterns and causes,Must be from predefined reasons -Invoice Management,invoice_deleted,User removes an invoice from system,invoice-edit.component.ts,days_since_creation,Number,Days between creation and deletion,Non-negative integer,7,Required,Monitor invoice lifecycle timing,Must be >= 0 -Invoice Management,invoice_deleted,User removes an invoice from system,invoice-edit.component.ts,had_payments,Boolean,Whether invoice had any payments before deletion,true or false,false,Required,Track payment impact on deletions,Boolean validation -Invoice Management,invoice_status_changed,Invoice status transitions,invoice-detail.component.ts,invoice_id,String,Unique identifier for invoice,Alphanumeric string,INV_001,Required,Track individual invoice lifecycle,Must be valid invoice ID -Invoice Management,invoice_status_changed,Invoice status transitions,invoice-detail.component.ts,old_status,String,Previous invoice status,"new, draft, open, paid, void, uncollectible",draft,Required,Track status transitions,Must be from valid status list -Invoice Management,invoice_status_changed,Invoice status transitions,invoice-detail.component.ts,new_status,String,New invoice status,"new, draft, open, paid, void, uncollectible",open,Required,Track status transitions,Must be from valid status list -Invoice Management,invoice_status_changed,Invoice status transitions,invoice-detail.component.ts,status_change_reason,String,Reason for status change,"user_action, payment_received, due_date_passed, automated",user_action,Required,Understand status change drivers,Must be from valid reason types -Invoice Management,invoice_status_changed,Invoice status transitions,invoice-detail.component.ts,total_amount,Number,Total invoice amount,Positive numbers,2500.00,Required,Track revenue and financial metrics,Must be > 0 -Invoice Management,invoice_status_changed,Invoice status transitions,invoice-detail.component.ts,days_in_previous_status,Number,Days spent in previous status,Non-negative integer,5,Optional,Track status duration patterns,Must be >= 0 -Invoice Management,invoice_payment_logged,Payment recorded against invoice,"invoice-detail.component.ts, invoice-edit.component.ts",invoice_id,String,Unique identifier for invoice,Alphanumeric string,INV_001,Required,Track individual invoice lifecycle,Must be valid invoice ID -Invoice Management,invoice_payment_logged,Payment recorded against invoice,"invoice-detail.component.ts, invoice-edit.component.ts",payment_amount,Number,Amount of payment logged,Positive numbers,2500.00,Required,Track payment patterns,Must be > 0 -Invoice Management,invoice_payment_logged,Payment recorded against invoice,"invoice-detail.component.ts, invoice-edit.component.ts",payment_method,String,Method used for payment,"cash, check, credit_card, bank_transfer, other",check,Required,Analyze payment preferences,Must be from valid payment methods -Invoice Management,invoice_payment_logged,Payment recorded against invoice,"invoice-detail.component.ts, invoice-edit.component.ts",payment_date,String,Date payment was received,ISO date string,2024-01-15,Required,Track payment timing,Must be valid date format -Invoice Management,invoice_payment_logged,Payment recorded against invoice,"invoice-detail.component.ts, invoice-edit.component.ts",remaining_balance,Number,Invoice balance after payment,Non-negative numbers,0.00,Required,Monitor collection completion,Must be >= 0 -Invoice Management,invoice_payment_logged,Payment recorded against invoice,"invoice-detail.component.ts, invoice-edit.component.ts",days_to_payment,Number,Days from invoice creation to payment,Non-negative integer,15,Required,Analyze collection efficiency,Must be >= 0 -Invoice Management,invoice_payment_logged,Payment recorded against invoice,"invoice-detail.component.ts, invoice-edit.component.ts",payment_reference,String,Reference number for payment,String,CHK_001,Optional,Track payment reconciliation,Optional reference identifier -Invoice List Operations,invoice_list_viewed,User accesses the invoices list interface,invoices-list.component.ts,view_type,String,Type of list view used,"table, grid, map, calendar",table,Required,Optimize UI preferences,Must be from available views -Invoice List Operations,invoice_list_viewed,User accesses the invoices list interface,invoices-list.component.ts,total_invoices,Number,Total number of invoices in system,Non-negative integer,120,Required,Monitor system usage and scale,Must be >= 0 -Invoice List Operations,invoice_list_viewed,User accesses the invoices list interface,invoices-list.component.ts,displayed_invoices,Number,Number of invoices shown to user,Non-negative integer,25,Required,Track pagination and filtering,Must be >= 0 and <= total_invoices -Invoice List Operations,invoice_list_viewed,User accesses the invoices list interface,invoices-list.component.ts,date_range_applied,Boolean,Whether date range filter is active,true or false,true,Optional,Track temporal filtering usage,Boolean validation -Invoice List Operations,invoice_list_viewed,User accesses the invoices list interface,invoices-list.component.ts,status_filter_applied,Boolean,Whether status filter is active,true or false,false,Optional,Track status filtering usage,Boolean validation -Invoice List Operations,invoice_list_filtered,User applies filters to narrow invoice results,invoices-list.component.ts,filter_type,String,Type of filter applied to invoice list,"status, date_range, client, amount_range, overdue",status,Required,Improve filter functionality,Must be valid filter type -Invoice List Operations,invoice_list_filtered,User applies filters to narrow invoice results,invoices-list.component.ts,filter_value,String,Value of the applied filter,String,new,Required,Track specific filter usage,Must be valid for filter type -Invoice List Operations,invoice_list_filtered,User applies filters to narrow invoice results,invoices-list.component.ts,results_before,Number,Results count before filter,Non-negative integer,45,Required,Measure filter effectiveness,Must be >= 0 -Invoice List Operations,invoice_list_filtered,User applies filters to narrow invoice results,invoices-list.component.ts,results_after,Number,Results count after filter,Non-negative integer,12,Required,Measure filter effectiveness,Must be >= 0 and <= results_before -Invoice List Operations,invoice_list_filtered,User applies filters to narrow invoice results,invoices-list.component.ts,multiple_filters_active,Boolean,Whether multiple filters are applied simultaneously,true or false,true,Optional,Track complex filtering patterns,Boolean validation -Invoice List Operations,invoice_selected,User clicks/selects a specific invoice,invoices-list.component.ts,invoice_id,String,Unique identifier for invoice,Alphanumeric string,INV_001,Required,Track individual invoice lifecycle,Must be valid invoice ID -Invoice List Operations,invoice_selected,User clicks/selects a specific invoice,invoices-list.component.ts,selection_method,String,Method used to select invoice,"row_click, search_result, link_navigation, edit_button, view_button",edit_button,Required,Optimize selection UX,Must be from valid selection methods -Invoice List Operations,invoice_selected,User clicks/selects a specific invoice,invoices-list.component.ts,invoice_status,String,Current status of invoice,"new, draft, open, paid, void, uncollectible",paid,Required,Track invoice lifecycle states,Must be from valid status list -Invoice List Operations,invoice_selected,User clicks/selects a specific invoice,invoices-list.component.ts,invoice_amount,Number,Amount of selected/viewed invoice,Positive numbers,2500.00,Required,Track amount-based patterns,Must be > 0 -Invoice List Operations,invoice_selected,User clicks/selects a specific invoice,invoices-list.component.ts,position_in_list,Number,Position of invoice in list when selected,Positive integer,3,Optional,Track selection patterns,Must be > 0 -Invoice List Operations,invoice_bulk_action,User performs action on multiple invoices,invoices-list.component.ts,action_type,String,Type of bulk action performed,"delete, mark_sent, mark_paid, export, print",export,Required,Track bulk operation patterns,Must be from valid action types -Invoice List Operations,invoice_bulk_action,User performs action on multiple invoices,invoices-list.component.ts,invoice_count,Number,Number of invoices affected by bulk action,Positive integer,5,Required,Monitor bulk operation scale,Must be > 0 -Invoice List Operations,invoice_bulk_action,User performs action on multiple invoices,invoices-list.component.ts,invoice_ids,Array,List of invoice IDs affected by action,Array of strings,"[""INV_001"", ""INV_002""]",Required,Track specific invoices in bulk operations,Must be valid invoice IDs -Invoice List Operations,invoice_bulk_action,User performs action on multiple invoices,invoices-list.component.ts,total_amount_affected,Number,Total amount of invoices affected by bulk action,Positive numbers,12500.00,Required,Track financial impact of bulk operations,Must be > 0 -Invoice List Operations,invoice_bulk_action,User performs action on multiple invoices,invoices-list.component.ts,execution_time,Number,Time taken to complete bulk action in seconds,Positive numbers,2.5,Optional,Monitor bulk operation performance,Must be > 0 -Invoice List Operations,invoice_bulk_action,User performs action on multiple invoices,invoices-list.component.ts,success_rate,Number,Percentage of successful operations in bulk action,Number 0-100,100,Optional,Track bulk operation reliability,Must be between 0 and 100 -Invoice Detail Operations,invoice_viewed,User opens and views invoice details,invoice-detail.component.ts,invoice_id,String,Unique identifier for invoice,Alphanumeric string,INV_001,Required,Track individual invoice lifecycle,Must be valid invoice ID -Invoice Detail Operations,invoice_viewed,User opens and views invoice details,invoice-detail.component.ts,invoice_status,String,Current status of invoice,"new, draft, open, paid, void, uncollectible",paid,Required,Track invoice lifecycle states,Must be from valid status list -Invoice Detail Operations,invoice_viewed,User opens and views invoice details,invoice-detail.component.ts,invoice_amount,Number,Amount of selected/viewed invoice,Positive numbers,2500.00,Required,Track amount-based patterns,Must be > 0 -Invoice Detail Operations,invoice_viewed,User opens and views invoice details,invoice-detail.component.ts,view_source,String,Source of invoice view navigation,"list, direct_link, search, navigation",list,Required,Track navigation patterns,Must be from valid view sources -Invoice Detail Operations,invoice_exported,User exports/prints invoice,invoice-detail.component.ts,invoice_id,String,Unique identifier for invoice,Alphanumeric string,INV_001,Required,Track individual invoice lifecycle,Must be valid invoice ID -Invoice Detail Operations,invoice_exported,User exports/prints invoice,invoice-detail.component.ts,export_format,String,Format used for invoice export,"pdf, excel, csv, print, iif",csv,Required,Analyze export format preferences,Must be from supported formats -Invoice Detail Operations,invoice_exported,User exports/prints invoice,invoice-detail.component.ts,invoice_amount,Number,Amount of selected/viewed invoice,Positive numbers,2500.00,Required,Track amount-based patterns,Must be > 0 -Invoice Detail Operations,invoice_exported,User exports/prints invoice,invoice-detail.component.ts,export_method,String,Method of export operation,"single, bulk",single,Required,Track export operation patterns,Must be from valid export methods -Invoice Detail Operations,invoice_exported,User exports/prints invoice,invoice-detail.component.ts,includes_job_details,Boolean,Whether export includes detailed job information,true or false,true,Required,Track export content preferences,Boolean validation -Invoice Detail Operations,invoice_exported,User exports/prints invoice,invoice-detail.component.ts,file_size_kb,Number,Size of exported file in kilobytes,Positive numbers,150.5,Optional,Monitor export performance,Must be > 0 -Invoice Settings Operations,customer_invoice_settings_updated,Customer invoice settings modified,customer-settings.component.ts,client_id,String,Unique identifier for the client,Alphanumeric string,CLIENT_12345,Required,Track client relationships and revenue,Must be valid client ID -Invoice Settings Operations,customer_invoice_settings_updated,Customer invoice settings modified,customer-settings.component.ts,settings_modified,Array,List of customer settings changed,Array of strings,"[""payment_terms"", ""automation""]",Required,Monitor settings usage patterns,Must be valid setting names -Invoice Settings Operations,customer_invoice_settings_updated,Customer invoice settings modified,customer-settings.component.ts,automation_enabled,Boolean,Whether automation was enabled in settings,true or false,true,Optional,Track automation adoption,Boolean validation -Invoice Settings Operations,customer_invoice_settings_updated,Customer invoice settings modified,customer-settings.component.ts,payment_terms_changed,Boolean,Whether payment terms were modified,true or false,false,Optional,Track payment term adjustments,Boolean validation -Invoice Settings Operations,customer_invoice_settings_updated,Customer invoice settings modified,customer-settings.component.ts,billing_preferences_updated,Boolean,Whether billing preferences were changed,true or false,true,Optional,Track billing customization,Boolean validation -Invoice Settings Operations,invoice_costing_item_managed,Costing items created/updated/deleted,costing-item.component.ts,item_type,String,Type of costing item,"service, material, equipment, labor",service,Required,Categorize costing structures,Must be from predefined types -Invoice Settings Operations,invoice_costing_item_managed,Costing items created/updated/deleted,costing-item.component.ts,unit_type,String,Unit basis for costing,"per_acre, per_hour, flat_rate, per_unit",per_acre,Required,Track pricing models,Must be from valid unit types -Invoice Settings Operations,invoice_costing_item_managed,Costing items created/updated/deleted,costing-item.component.ts,base_rate,Number,Base rate for costing item,Positive numbers,25.00,Required,Monitor pricing strategies,Must be > 0 -Invoice Settings Operations,invoice_costing_item_managed,Costing items created/updated/deleted,costing-item.component.ts,action_type,String,Type of action performed on costing item,"created, updated, deleted",created,Required,Track costing item lifecycle,Must be from valid action types -Invoice Settings Operations,invoice_costing_item_managed,Costing items created/updated/deleted,costing-item.component.ts,item_id,String,Unique identifier for costing item,Alphanumeric string,ITEM_001,Optional,Track individual costing items,Must be valid item ID when provided -Invoice Settings Operations,invoice_costing_item_managed,Costing items created/updated/deleted,costing-item.component.ts,affects_existing_invoices,Boolean,Whether change affects existing invoices,true or false,false,Optional,Track retroactive pricing impacts,Boolean validation diff --git a/Development/client/angular.json b/Development/client/angular.json index c0831f7..b020731 100644 --- a/Development/client/angular.json +++ b/Development/client/angular.json @@ -103,8 +103,7 @@ }, { "type": "anyComponentStyle", - "maximumWarning": "12kb", - "maximumError": "18kb" + "maximumWarning": "6kb" } ] }, @@ -165,34 +164,29 @@ "tsConfig": "src/tsconfig.spec.json", "karmaConfig": "src/karma.conf.js", "scripts": [ - "node_modules/rbush/rbush.min.js", - "src/assets/js/turf.min.js" + "node_modules/leaflet/dist/leaflet.js", + "src/assets/js/leaflet-draw/leaflet.draw-src.js", + "src/assets/js/L.Path.Drag.js", + "src/assets/js/Leaflet.draw.drag-src.js", + "src/assets/js/leaflet-measure/leaflet-measure-path.js", + "src/assets/js/leaflet.circle.topolygon.js", + "src/assets/js/turf.min.js", + "src/assets/js/leaflet-corridor.js", + "src/assets/js/utm.js", + "src/assets/js/L.Control.MapCenterCoord.js", + "src/assets/js/leaflet.polylineDecorator.js" ], "styles": [ "node_modules/leaflet/dist/leaflet.css", "src/assets/js/leaflet-draw/leaflet.draw.css", "src/assets/js/leaflet-measure/leaflet-measure-path.css", + "node_modules/primeng/resources/primeng.min.css", + "node_modules/nanoscroller/bin/css/nanoscroller.css", "src/assets/js/L.Control.MapCenterCoord.css", - "src/assets/js/Leaflet.AgmIcon.css", - "src/assets/js/Leaflet.AgmACIcon.css", - "node_modules/primeng-lts/resources/primeng.min.css", - "node_modules/@fullcalendar/core/main.min.css", - "node_modules/@fullcalendar/daygrid/main.min.css", - "node_modules/@fullcalendar/timegrid/main.min.css", - "node_modules/quill/dist/quill.snow.css", "src/styles.scss" ], "assets": [ - "src/assets/js/L.Control.MapCenterCoord.css", - "src/assets/js/L.Control.MapCenterCoord.js", - "src/assets/js/utm.js", - "src/assets/js/Leaflet.GoogleMutant.js", - "src/assets/js/leaflet-corridor.js", - "src/assets/js/sti-rpt/", - "src/assets/theme/theme-green.min.css", - "src/assets/layout/css/layout-green.min.css", - "src/assets/layout/fonts/", - "src/assets/images/", + "src/assets", { "glob": "**/*", "input": "node_modules/leaflet/dist/images", @@ -248,4 +242,4 @@ "cli": { "analytics": "a39ac155-50fa-4441-b491-60e55b83e6ff" } -} \ No newline at end of file +} diff --git a/Development/client/docs/MANAGE_SERVICES_PROMO_DISPLAY.md b/Development/client/docs/MANAGE_SERVICES_PROMO_DISPLAY.md deleted file mode 100644 index 98c2376..0000000 --- a/Development/client/docs/MANAGE_SERVICES_PROMO_DISPLAY.md +++ /dev/null @@ -1,382 +0,0 @@ -# Promo Display Logic — services Screen - -**Component**: `src/app/profile/manage-services/manage-services.component.ts` -**Route**: `/profile/services` -**Last Updated**: March 18, 2026 - ---- - -## Table of Contents - -- [Overview](#overview) -- [Data Flow](#data-flow) -- [Key State](#key-state) -- [activePromos Map Construction](#activepromos-map-construction) -- [Button Labels and confirmServices Flow](#button-labels-and-confirmservices-flow) - - [Create New Subscription Plan](#create-new-subscription-plan) - - [Create Trial Subscription Plan](#create-trial-subscription-plan) - - [Checkout Promo Display after Each Flow](#checkout-promo-display-after-each-flow) -- [getPromoForLookupKey the Core Gate](#getpromoforlookupkey-the-core-gate) -- [isAllPackagesPromo Package-Wide Banner Logic](#isallpackagespromo-package-wide-banner-logic) -- [Template Rendering Logic](#template-rendering-logic) -- [Promo Price Calculation](#promo-price-calculation) -- [ESS_1 Legacy Special Handling](#ess_1-legacy-special-handling) -- [Promo Display Components](#promo-display-components) -- [Quick Reference Table](#quick-reference-table) - ---- - -## Overview - -The `/services` screen ("Choose Your Plan") shows packages and addons with promotional pricing when applicable. Promo data comes from the authenticated `GET /api/activePromos` endpoint (v3.0+), which already filters by customer eligibility server-side. The client only needs to apply display-mode gating (available vs. subscribed) on top. - ---- - -## Data Flow - -```mermaid -flowchart TD - A([ngOnInit]) --> B[dispatch FetchSubPlans] - A --> C[loadActivePromos] - A --> D[loadPromoMode] - - B --> E["populates essPkgs, addons via Redux store"] - - C --> F["GET /api/activePromos
Auth required - v3.0"] - F --> G["Server filters by customer eligibility
eligibility: all / new_only / renew_only"] - G --> H["Returns only eligible promos"] - H --> I[buildActivePromosMap] - I --> J[(activePromos Map)] - - D --> F2["getCurrentMode()"] - F2 --> K["promoMode: enabled or disabled"] - - J --> L[Template rendering] - K --> L - E --> L -``` - -> **v3.0 (Jan 2026):** `/api/activePromos` requires authentication and returns **only the promos the current customer is eligible for**. The client does **not** need to re-check eligibility — the returned list is already filtered. - ---- - -## Key State - -| Property | Source | Purpose | -|---|---|---| -| `activePromos` | `GET /api/activePromos` | Map of eligible promos for current user | -| `promoMode` | `currentMode.mode` from same response | Global kill switch (`'enabled'` or `'disabled'`) | -| `subs` | Redux `getSubscriptions` | Current Stripe subscriptions for this user | -| `isTrial` | Redux `getSubIntentState` — `subIntent.mode === Mode.TRIALING` | Whether checkout intent is a trial sign-up | - ---- - -## activePromos Map Construction - -`loadActivePromos()` builds a flat `Map` using three key patterns based on what fields each promo has: - -```mermaid -flowchart TD - A([Promo from /api/activePromos]) --> B{Has priceKey?} - - B -->|Yes| C["activePromos.set(priceKey, promo)
e.g. 'ess_1_1' -> promo"] - - B -->|No| D{Has type?} - - D -->|Yes - package or addon| E["activePromos.set('package_all' or 'addon_all', promo)"] - - D -->|No - universal promo| F["activePromos.set('package_all', promo)
activePromos.set('addon_all', promo)"] -``` - -**Lookup at render time:** - -```mermaid -flowchart LR - A["getPromoForLookupKey('ess_1_1', 'package')"] - A --> B["activePromos.get('ess_1_1')"] - B -->|found| C([return exact promo]) - B -->|not found| D["activePromos.get('package_all')"] - D -->|found| E([return type-wide promo]) - D -->|not found| F([return null]) -``` - ---- - -## Button Labels and confirmServices Flow - -The confirm button in `#btnSection` uses a computed `confirmLabel` getter: - -``` -isNewSub = !originalSel.selPkg && !(originalSel.selAddons.length > 0) - -confirmLabel = - isNewSub && isTrial → "Create Trial Subscription Plan" - isNewSub && !isTrial → "Create New Subscription Plan" - otherwise → "Confirm" -``` - -`isNewSub` is true when the user has **no** existing package and no existing addon subscriptions — i.e. they are subscribing for the first time. `isTrial` comes from Redux `subIntent.mode === Mode.TRIALING`. - -### Create New Subscription Plan - -Triggered when `isNewSub=true` and `isTrial=false`. Full flow from button click through promo display in checkout: - -```mermaid -flowchart TD - BTN([Click Create New Subscription Plan]) --> CS[confirmServices] - CS --> ISNEW{isNewSub?} - ISNEW -->|Yes| REGDIRECT[dispatchStartBillingInfo
mode = Mode.REGULAR] - ISNEW -->|No - existing sub change| CONFIRM[Confirm dialog
then dispatchStartBillingInfo
mode = Mode.REGULAR] - REGDIRECT --> SBI[StartBillingInfo dispatched
prorateTS = DateUtils.currUTC] - CONFIRM --> SBI - SBI --> NAV([Navigate to /checkout]) - - NAV --> INIT[initPage] - INIT --> ISTRIALCK{isTrial?} - ISTRIALCK -->|No - regular| INVOICES[Fetch upcoming invoices
calcChkoutPayment] - INVOICES --> CAP[checkApplicablePromos] - CAP --> GATE["getPromoForLookupKey
hasAnyPackageSub? NO
exact or type-wide match?"] - GATE -->|promo found| PROMODISPLAY([Show promo badge + discounted price]) - GATE -->|no match| NORMALPRICE([Regular price]) - - NAV --> LAP[loadActivePromos async] - LAP --> CAP2[checkApplicablePromos again
with real activePromos] - CAP2 --> PROMODISPLAY2([Promo display updated if match]) -``` - -### Create Trial Subscription Plan - -Triggered when `isNewSub=true` and `isTrial=true`. The trial flow adds `trialEnd` timestamps to the selected package and addons, then navigates to checkout: - -```mermaid -flowchart TD - BTN([Click Create Trial Subscription Plan]) --> CS[confirmServices] - CS --> TRIALS[Read membership.trials
Calculate trialEndDate] - TRIALS --> PKGTRIALEND["selPkg = { ...currSel.selPkg,
trialEnd: trialEndDate }"] - PKGTRIALEND --> ADDONTRIALEND["selAddons = addons.map
addon.trialEnd = trialEndDate"] - ADDONTRIALEND --> ISNEW{isNewSub?} - ISNEW -->|Yes| TRIALDIRECT[dispatchStartBillingInfo
mode = Mode.TRIALING
prorateTS = null] - ISNEW -->|No - existing trial change| TRIALCONFIRM[Confirm dialog
then dispatchStartBillingInfo
mode = Mode.TRIALING] - TRIALDIRECT --> SBI[StartBillingInfo dispatched] - TRIALCONFIRM --> SBI - SBI --> NAV([Navigate to /checkout]) - - NAV --> INIT[initPage] - INIT --> ISTRIALCK{isTrial = true} - ISTRIALCK --> TRIALITEMS["createTrialItems
from selPkg + selAddons"] - TRIALITEMS --> CTIP1["checkTrialItemPromos
activePromos EMPTY at this point
totalPromoSavings = 0"] - CTIP1 --> AMOUNT1["amount.total = grossTotal - 0
STALE - full price"] - - NAV --> LAP[loadActivePromos async] - LAP --> PROMOMAP[activePromos Map built
from /api/activePromos response] - PROMOMAP --> CTIP2["checkTrialItemPromos
activePromos NOW loaded"] - CTIP2 --> PROMOFOUND{promo in activePromos
for this lookupKey?} - PROMOFOUND -->|Yes - e.g. ess_1_1 eligibility=all| SAVINGS["totalPromoSavings recalculated
paymentPromos populated"] - SAVINGS --> AMOUNTFIX["amount.total = grossTotal - totalPromoSavings
UpdateAmount dispatched"] - AMOUNTFIX --> PROMODISPLAY([Show promo badge + discounted price]) - PROMOFOUND -->|No match| NORMALPRICE([Full trial price - no promo]) -``` - -### Checkout Promo Display after Each Flow - -```mermaid -flowchart LR - REG([Regular flow
Mode.REGULAR]) --> REGPATH["checkApplicablePromos
uses chkoutPmt.lineItems
gate: hasAnyPackageSub"] - REGPATH --> REGPROMO([paymentPromos map
promo badges + savings]) - - TRIAL([Trial flow
Mode.TRIALING]) --> TRIALPATH["checkTrialItemPromos
uses trialItems
no subscription gate
looks up activePromos directly"] - TRIALPATH --> TRIALPROMO(["paymentPromos map
promo badges + discounted trial total
eligibility=all promos shown"]) -``` - -**Key difference**: `checkApplicablePromos` gates on `hasAnyPackageSubscription` because it works with real invoice line items that could include existing subscriptions. `checkTrialItemPromos` skips that gate — the items are the trial package/addons only, and the server already applied eligibility filtering to `/activePromos`. - ---- - -## getPromoForLookupKey the Core Gate - -This is the single method called by the template for every package and addon row. It returns an `ActivePromo` to display or `null` to show nothing. - -Signature: `getPromoForLookupKey(lookupKey, type, mode = 'available')` - -```mermaid -flowchart TD - START([getPromoForLookupKey]) --> A{promoMode === disabled?} - A -->|Yes| NULL1([return null - global kill switch]) - A -->|No| B[getUserSubscriptionForLookupKey] - - B --> C{User has sub for this item
AND status === trialing?} - C -->|Yes| NULL2([return null - trial IS the promo]) - C -->|No| D{mode === available
AND userHasThis?} - - D -->|Yes| NULL3([return null - item already subscribed]) - D -->|No| E{mode === subscribed
AND NOT userHasThis?} - - E -->|Yes| NULL4([return null - item not subscribed]) - E -->|No| F{mode === subscribed?} - - F -->|Yes| G{promoDetails.hasPromo === true?} - G -->|Yes| CONV([return convertPromoDetailsToActivePromo]) - G -->|No| NULL5([return null - no fallback for subscribed mode]) - - F -->|No - mode is available| H["activePromos.get(lookupKey)"] - H -->|found| EXACT([return exact-match promo]) - H -->|not found| I["activePromos.get(type_all)"] - I -->|found| TYPE([return type-wide promo]) - I -->|not found| NULL6([return null]) -``` - -### Why isTrial does NOT suppress promos - -Prior to v3.0, the flag `isTrial` (set when checkout intent mode is `TRIALING`) blocked all available promos. This was removed because: - -- Since v3.0, the server already evaluates `eligibility` before returning promos. If a promo with `eligibility: 'all'` is returned (e.g. `ess_1_1` with a `$400 OFF` offer), it means the server has confirmed this user qualifies. -- A trial user looking at `/services` to decide whether to subscribe with auto-renewal **should** see that promo — it is the incentive to convert. -- The existing guard `status === 'trialing'` (step above) still correctly hides promos on any subscription row where the user is actively in a trial. - ---- - -## isAllPackagesPromo Package-Wide Banner Logic - -Controls whether the green promo banner above the packages table is shown. - -```mermaid -flowchart TD - START([isAllPackagesPromo]) --> A{essPkgs empty?} - A -->|Yes| NULL1([return null]) - A -->|No| B{User has ANY existing
package subscription?} - - B -->|Yes| NULL2([return null - banner only for new subscribers]) - B -->|No| C["activePromos.get('package_all')"] - - C -->|found| RET1([return type-wide promo]) - C -->|not found| D[Map each pkg to its activePromo] - - D --> E{All packages have
an individual promo?} - E -->|No| NULL3([return null]) - E -->|Yes| F{All promos share same
discountType + discountValue?} - - F -->|No| NULL4([return null]) - F -->|Yes| RET2([return the shared promo]) -``` - -> The banner is intentionally shown **only** to brand-new subscribers (no existing package subscription). Returning subscribers see promo state per-row instead. - ---- - -## Template Rendering Logic - -### Package Row Structure - -```mermaid -flowchart TD - ROW([Package row rendered]) --> LEGACY{isLegacyEss1?} - - LEGACY -->|Yes| LEGACYLABEL[Show legacy notice label
Promo display suppressed] - LEGACY -->|No| SUB{isUserSubscribed?} - - SUB -->|Yes - subscribed| ACTIVE["getPromoForLookupKey(key, package, subscribed)"] - ACTIVE -->|promo found| ACTIVELABEL["agm-active-promo-label
Active Promo: DISCOUNT"] - ACTIVE -->|null| NOTHING1[No promo label] - - SUB -->|No - not subscribed| AVAIL["getPromoForLookupKey(key, package, available)"] - AVAIL -->|promo found| AVAILABLELABEL["Promo name text
e.g. AgMission Essentials 1 Plus"] - AVAIL -->|null| NOTHING2[No promo label] - - ROW --> PRICECOL[Price column] - PRICECOL --> PROMOCHECK["getPromoForLookupKey(key, package)
mode defaults to available"] - PROMOCHECK -->|promo found| CROSSEDPRICE["original-price crossed out
promo-price shown
Valid until date below"] - PROMOCHECK -->|null| REGULARPRICE[Regular price only] -``` - -### Addon Row Structure - -Same dual-mode structure as packages, with **no banner** at the top (per P2-D wireframe). Both Unit Price and Total Price columns independently call `getPromoForLookupKey` in `available` mode. - -```mermaid -flowchart TD - ADDONROW([Addon row rendered]) --> SUBSCHECK{isUserSubscribed?} - - SUBSCHECK -->|Yes| A2["getPromoForLookupKey(key, addon, subscribed)"] - A2 -->|promo found| A2L[agm-active-promo-label] - A2 -->|null| A2N[No promo label] - - SUBSCHECK -->|No| A3["getPromoForLookupKey(key, addon, available)"] - A3 -->|promo found| A3L[Promo name text below addon name] - A3 -->|null| A3N[No promo label] - - ADDONROW --> UNITPRICE[Unit Price column] - UNITPRICE --> UP["getPromoForLookupKey(key, addon)"] - UP -->|promo| UPPROMO[crossed price + promo price] - UP -->|null| UPREGULAR[Regular unit price] - - ADDONROW --> TOTALPRICE[Total Price column] - TOTALPRICE --> TP["getPromoForLookupKey(key, addon)"] - TP -->|promo| TPPROMO["crossed total + promo total
Valid until date below"] - TP -->|null| TPREGULAR[Regular total price] -``` - ---- - -## Promo Price Calculation - -All math is delegated to `SubscriptionService.calculateDiscountedAmount(originalCents, promo)`: - -```mermaid -flowchart TD - CALC([calculateDiscountedAmount]) --> A{discountType} - - A -->|free OR discountValue === 100| ZERO([return 0]) - A -->|percent| PCT["return round(original x 1 - value/100)"] - A -->|fixed| FIXED["return max(0, original - value)
value is already in cents"] -``` - -For addons: `calculatePromoTotal(addon, promo)` = `calculatePromoPrice(addon.price, promo) × quantity`. - ---- - -## ESS_1 Legacy Special Handling - -```mermaid -flowchart TD - PKG([Package item]) --> SHOW{shouldShowPackage} - - SHOW --> ESS1{lookupKey === ess_1?} - ESS1 -->|Yes| HASLEGACY{hasLegacyEss1Subscription?} - HASLEGACY -->|Yes - user has active or trialing ESS_1| VISIBLE([Show row]) - HASLEGACY -->|No| HIDDEN([Hide row]) - - ESS1 -->|No - ess_1_1 or others| VISIBLE2([Always show row]) - - VISIBLE --> PROMOCHECK{isLegacyEss1?} - PROMOCHECK -->|Yes - ess_1 row| LEGACYNOTICE[Show legacy notice
Promo display suppressed] - PROMOCHECK -->|No| NORMALPROMO[Normal dual-mode promo display] -``` - -`isLegacyEss1(lookupKey)`: returns `true` only when `lookupKey === 'ess_1'` AND the user has a legacy ESS_1 subscription. Promo display is suppressed on ESS_1 rows even if a matching global promo exists. - ---- - -## Promo Display Components - -| Component | Selector | Used for | Shows | -|---|---|---|---| -| `ActivePromoLabelComponent` | `agm-active-promo-label` | Subscribed users with `promoDetails.hasPromo` | Active Promo: DISCOUNT | -| `ConstraintMessageComponent` | `agm-constraint-message severity="promo"` | Package-wide banner via `isAllPackagesPromo()` | Green info box with promo message and valid-until date | -| Raw template | `div.available-promo` | Available promo name below package or addon name | Promo name text | -| Raw template | `div.price-with-promo` | Price column when promo available | Crossed-out price, promo price, valid-until date | - ---- - -## Quick Reference Table - -| User type | Item state | mode arg | Result | -|---|---|---|---| -| Any, `PROMO_MODE=disabled` | any | any | No promo shown | -| Any | `status=trialing` subscription | any | No promo shown | -| Not subscribed | available item | `available` | Promo shown if in `activePromos` | -| Already subscribed | own subscription | `subscribed` | Promo shown if `promoDetails.hasPromo` | -| Trial user (`isTrial=true`) | not yet subscribed | `available` | Promo shown if in `activePromos` | -| Legacy ESS_1 subscriber | `ess_1` row | any | Promo suppressed, legacy notice shown | -| User with existing package sub | package-wide banner | `isAllPackagesPromo` | Banner hidden | - -> **Trial users CAN see available promos.** Server-side eligibility filtering (v3.0) ensures only qualifying promos are returned. The `isTrial` flag from Redux intent no longer suppresses promo display — only active `status='trialing'` subscriptions suppress promos on their own row. diff --git a/Development/client/docs/NOTIFICATION-DEEP-LINKS.md b/Development/client/docs/NOTIFICATION-DEEP-LINKS.md deleted file mode 100644 index 8f8367b..0000000 --- a/Development/client/docs/NOTIFICATION-DEEP-LINKS.md +++ /dev/null @@ -1,333 +0,0 @@ -# Notification Deep-Links - -Reference for handling external deep-link URLs sent in customer notification emails -(e.g. "Manage your subscription", "Update payment method"). - ---- - -## Table of Contents - -1. [Overview](#1-overview) -2. [Registered Routes](#2-registered-routes) -3. [How It Works — Full Flow](#3-how-it-works--full-flow) - - [Authenticated user](#authenticated-user) - - [Unauthenticated user](#unauthenticated-user) -4. [Key Files](#4-key-files) -5. [Adding a New Notification URL](#5-adding-a-new-notification-url) -6. [Design Decisions](#6-design-decisions) - ---- - -## 1. Overview - -Notification emails link customers to top-level URLs such as `/#/manage-subscription`. -These URLs must: - -- **Not require authentication themselves** — the customer may be logged out -- **Skip the shell layout** — they live outside `AppMainComponent` -- **Redirect instantly** — no blank-page flash, no component rendered -- **Show a contextual notice** on the login screen when authentication is required - -All of this is handled by a single `NotificationRedirectGuard` combined with route `data`. -Adding a new notification URL requires **one route entry and zero new files**. - ---- - -## 2. Registered Routes - -All notification routes are declared at the top level in `app-routing.module.ts`, -outside the `AppMainComponent` shell: - -``` -/#/manage-subscription → /profile/myservices (or /profile/services if no subs) -/#/update-pm → /profile/payment-method-list -/#/update-bill-address → /profile/billing-address -``` - -Each route in source: - -```typescript -// app-routing.module.ts - -{ - path: 'manage-subscription', - component: PageNotFoundComponent, // never rendered — guard always redirects - canActivate: [NotificationRedirectGuard], - data: { - redirectTo: ['profile', 'myservices'], - redirectToNoSubs: ['profile', 'services'], - loginNotice: $localize`@@manageSubLoginNotice:Please log in with your Master account to manage subscriptions.` - } -}, -{ - path: 'update-pm', - component: PageNotFoundComponent, - canActivate: [NotificationRedirectGuard], - data: { - redirectTo: ['profile', 'payment-method-list'], - loginNotice: $localize`@@updatePmLoginNotice:Please log in with your Master account to update your payment method.` - } -}, -{ - path: 'update-bill-address', - component: PageNotFoundComponent, - canActivate: [NotificationRedirectGuard], - data: { - redirectTo: ['profile', 'billing-address'], - loginNotice: $localize`@@updateBillAddrLoginNotice:Please log in with your Master account to update your billing address.` - } -}, -``` - -> `PageNotFoundComponent` is the placeholder — it is already declared in `AppModule` -> and is never displayed because the guard always returns a `UrlTree` before any -> component activates. - ---- - -## 3. How It Works — Full Flow - -### Authenticated user - -``` -User clicks link in email - │ - ▼ -Browser opens /#/manage-subscription - │ - ▼ -Angular router matches route - │ - ▼ -NotificationRedirectGuard.canActivate() - │ - ├─ authSvc.loggedIn = true - │ - ├─[redirectToNoSubs defined AND master AND no subs] - │ └──→ UrlTree: /profile/services - │ - └─[all other authenticated cases] - └──→ UrlTree: /profile/myservices - │ - ▼ - AppMainComponent loads normally - /profile/myservices renders -``` - -The guard returns a `UrlTree` **synchronously** (auth state is rehydrated from -`sessionStorage` before any guard runs). Angular processes the redirect before -deactivating the current route or activating any component — no blank page, no -shell teardown. - ---- - -### Unauthenticated user - -``` -User clicks link in email (not logged in) - │ - ▼ -Browser opens /#/manage-subscription - │ - ▼ -NotificationRedirectGuard.canActivate() - │ - ├─ authSvc.loggedIn = false - │ - └──→ UrlTree: /login?returnUrl=manage-subscription - &loginNotice= - │ - ▼ - LoginComponent constructor reads queryParams - nav.extractedUrl.queryParams['loginNotice'] - │ - ▼ - Pushes { severity: 'info', detail: loginNotice } - into this.msgs → rendered by - │ - ▼ - ┌──────────────────────────────────────────┐ - │ [AgMission logo] │ - │ │ - │ ℹ Please log in with your Master │ - │ account to manage subscriptions. │ - │ │ - │ Username ________________________ │ - │ Password ________________________ │ - │ [ LOGIN ] │ - └──────────────────────────────────────────┘ - │ - User logs in - │ - ▼ - authActions.LoginSuccess dispatched - │ - ▼ - AuthEffects.navigateDefault() - router.parseUrl(router.url) - .queryParams['returnUrl'] - → 'manage-subscription' - │ - ▼ - window.location.replace('/#/manage-subscription') - │ - ▼ - NotificationRedirectGuard runs again - (now authenticated) - │ - ▼ - Redirects to /profile/myservices -``` - ---- - -## 4. Key Files - -### `src/app/domain/guards/notification-redirect.guard.ts` - -The single guard that handles all notification deep-links. - -**Route `data` contract:** - -| Field | Type | Required | Description | -|---|---|---|---| -| `redirectTo` | `string[]` | Yes | Router path segments for authenticated users | -| `redirectToNoSubs` | `string[]` | No | Alternate path when master account has no subscriptions | -| `loginNotice` | `string` | No | Message shown in the `` bar on the login screen | - -```typescript -canActivate(route: ActivatedRouteSnapshot): UrlTree { - const { redirectTo, redirectToNoSubs } = route.data; - - if (!this.authSvc.loggedIn) { - const { loginNotice } = route.data; - return this.router.createUrlTree(['/login'], { - queryParams: { - returnUrl: route.url.map(s => s.path).join('/'), - ...(loginNotice ? { loginNotice } : {}) - } - }); - } - - const isMaster = !this.authSvc.user?.parent; - if (redirectToNoSubs && isMaster && !this.authSvc.hasSubs()) { - return this.router.createUrlTree(redirectToNoSubs); - } - return this.router.createUrlTree(redirectTo); -} -``` - ---- - -### `src/app/auth/effects/auth.effects.ts` — `navigateDefault()` - -After a successful login, reads `returnUrl` from the current router URL and -replaces the browser location to trigger the notification route again -(now authenticated): - -```typescript -private navigateDefault(lang) { - const hash = (this.router.url.indexOf('#') == -1) ? '/#/' : '/'; - const returnUrl = this.router.parseUrl(this.router.url).queryParams['returnUrl'] || 'home'; - window.location.replace((lang === 'en' ? hash : `/${lang}${hash}`) + returnUrl); -} -``` - -Uses `router.parseUrl()` instead of string splitting — correctly handles all -URL encodings. - -If no `returnUrl` is present (normal login), falls back to `'home'` as before. - ---- - -### `src/app/auth/login/login.component.ts` — constructor - -Generic `loginNotice` handling — no hardcoded route names: - -```typescript -const nav = this.router.getCurrentNavigation(); -if (nav) { - const msgs: any[] = []; - if (nav.extras?.state?.changedPwd) { - msgs.push({ severity: 'info', summary: '', detail: globals.pwdChangedOk }); - } - const loginNotice = - nav.finalUrl?.queryParams?.['loginNotice'] ?? - nav.extractedUrl?.queryParams?.['loginNotice']; - if (loginNotice) { - msgs.push({ severity: 'info', summary: '', detail: loginNotice }); - } - if (msgs.length) this.msgs = msgs; -} -``` - -Reads from `getCurrentNavigation()` — the only safe place to read query params -on a login redirect since the router replaces the URL before `ngOnInit` runs. - ---- - -## 5. Adding a New Notification URL - -**Zero new files required.** Add one entry to `app-routing.module.ts`: - -```typescript -{ - path: 'renew-subscription', // URL path: /#/renew-subscription - component: PageNotFoundComponent, // never rendered - canActivate: [NotificationRedirectGuard], - data: { - redirectTo: ['profile', 'checkout'], // authenticated destination - // redirectToNoSubs: ['profile', 'services'], // optional alternate - loginNotice: $localize`:@@renewSubLoginNotice:Please log in with your Master account to renew your subscription.` - } -}, -``` - -That's it. The guard, the login notice, and the post-login redirect all work -automatically. - -**Checklist:** -- [ ] Add route entry with `redirectTo` (required) and optionally `redirectToNoSubs` / `loginNotice` -- [ ] Add the `loginNotice` i18n key to all locale `.xlf` translation files if the app is translated -- [ ] Provide the deep-link URL to the notifications/email team: `https://app.agmission.com/#/renew-subscription` - ---- - -## 6. Design Decisions - -### Why a guard returning `UrlTree` instead of a redirect component? - -A component that calls `router.navigate()` in `ngOnInit` causes: -- The current shell (`AppMainComponent`) to deactivate and re-activate — visible flicker -- A blank template to render briefly while `ngOnInit` executes - -A guard returning a `UrlTree` is processed by Angular **before any component is -activated or deactivated**. The redirect is invisible and instantaneous. - -### Why route `data` instead of a guard per URL? - -One guard file per URL creates N files for N routes with identical logic. -Putting the routing targets in `data` makes the guard a pure engine -and the route definition the configuration — consistent with Angular's -`canActivate`+`data` idiom used throughout the app (e.g. `RoleGuard` + `data.roles`). - -### Why `PageNotFoundComponent` as the placeholder? - -Angular requires a `component` on every non-lazy route. The component is never -rendered (the guard always redirects), so any already-declared component works. -`PageNotFoundComponent` is the most semantically appropriate fallback if the guard -ever fails to redirect for an unexpected reason. - -### Why `window.location.replace()` instead of `router.navigate()` in `navigateDefault`? - -This was the pre-existing pattern to prevent the login page from appearing in the -browser's Back history after authentication. `location.replace` replaces the -current history entry rather than pushing a new one. - -### Sub-accounts vs. Master accounts - -Subscription management (`/profile/myservices`) is only meaningful for master -accounts, but sub-accounts (users with `user.parent` set) are redirected there -too — the manage-subscription view enforces its own access rules once loaded. -`redirectToNoSubs` is only evaluated for master accounts with zero subscriptions, -sending them to the `/profile/services` plan picker instead. diff --git a/Development/client/docs/SUBSCRIPTION-DISPLAY.md b/Development/client/docs/SUBSCRIPTION-DISPLAY.md deleted file mode 100644 index e3ab06d..0000000 --- a/Development/client/docs/SUBSCRIPTION-DISPLAY.md +++ /dev/null @@ -1,576 +0,0 @@ -# Subscription Display Reference - -Quick overview of how subscriptions, pricing, and promos are displayed across the entire flow — from checkout through confirmation and the account management page. - ---- - -## Table of Contents - -1. [At a Glance — Full User Journey](#1-at-a-glance--full-user-journey) -2. [Page Structure Overview](#2-page-structure-overview) -3. [Checkout Flow (3 Stages)](#3-checkout-flow--3-stages) - - [Stage 1 — Enter Payment](#stage-1--enter-payment-checkoutcomponent) - - [Stage 2 — Review & Submit](#stage-2--review--submit-checkout-reviewcomponent) - - [Stage 3 — Confirmation](#stage-3--confirmation-checkout-confirmcomponent) -4. [Manage Subscription Page](#4-manage-subscription-page-myservices) - - [Subscription State Decision Tree](#subscription-state-decision-tree) - - [Case-by-Case Pricing Display](#case-by-case-pricing-display) -5. [Shared Pricing Components](#5-shared-pricing-components) - - [`` Template Guide](#payment-amount-template-guide) - - [`` Mode Guide](#payment-summary-mode-guide) -6. [Canada Tax Logic](#6-canada-tax-logic) -7. [Conditional Label Reference](#7-conditional-label-reference) - ---- - -## 1. At a Glance — Full User Journey - -``` -User selects a plan - │ - ▼ -┌───────────────────┐ ┌─────────────────────┐ -│ Regular Purchase │ │ Trial Signup │ -│ /checkout │ │ /checkout (isTrial) │ -└────────┬──────────┘ └──────────┬───────────┘ - │ │ - │ ┌─────────────┴─────────────┐ - │ │ │ - │ Trial only Trial + "continue - │ (no card yet) after trial" checked - │ │ │ - ▼ ▼ ▼ - Stage 1: Stage 1: Stage 1: - Payment form $0.00 total After-trial price - (Templates 1) (Template 5) (Template 7) - │ │ │ - └──────────────┴───────────────────────────┘ - │ - ▼ - Stage 2: Review - payment-summary REGULAR - (Template 2 — tax/discount/total) - │ - ▼ - ┌───────────────┼───────────────┐ - │ │ │ - TRIALING CONTINUE_TRIAL REGULAR - Stage 3: Stage 3: Stage 3: - Template 5 Template 7 Template 2 - ($0 confirm) (after-trial (full receipt) - confirm) - │ - ▼ - /myservices (manage-subscription) - Displays live subscription state - for all owned packages/addons -``` - ---- - -## 2. Page Structure Overview - -### `/checkout` — Stage 1 - -``` -┌──────────────────────────────────────────────────────┐ -│ CHECKOUT │ -│ │ -│ ┌────────────────────────────────────────────────┐ │ -│ │ [Regular purchase — no refund] │ │ -│ │ │ │ -│ │ payment-info (new line items) │ │ -│ │ ┌──────────────────────────────────────────┐ │ │ -│ │ │ [IF promo active] → Template 1 │ │ │ -│ │ │ [ELSE] → coupon input field │ │ │ -│ │ └──────────────────────────────────────────┘ │ │ -│ │ ── Credit card form ── │ │ -│ └────────────────────────────────────────────────┘ │ -│ │ -│ ┌──────────────────┬─────────────────────────────┐ │ -│ │ [With refund] │ │ │ -│ │ Payment column │ Refund column │ │ -│ │ payment-info │ payment-info (refund items)│ │ -│ │ Template 1 / │ Template 1 │ │ -│ │ coupon input │ │ │ -│ └──────────────────┴─────────────────────────────┘ │ -│ ── Credit card form ── │ -└──────────────────────────────────────────────────────┘ - -┌──────────────────────────────────────────────────────┐ -│ CHECKOUT (trial — start only, isTrial=true) │ -│ │ -│ Trial Information │ -│ payment-info (trial items) │ -│ │ -│ [IF promo]: 🎁 Total Promo Savings: -$X.XX │ -│ After Trial Total: $X.XX ← * │ -│ │ -│ ┌──────────────────────────────────────────────┐ │ -│ │ Template 5: "Free trial until DATE" │ │ -│ │ Total: $0.00 US │ │ -│ └──────────────────────────────────────────────┘ │ -│ │ -│ ☐ I want to continue the service after trial end │ -│ └─(checked)→ credit card form appears │ -└──────────────────────────────────────────────────────┘ - -┌──────────────────────────────────────────────────────┐ -│ CHECKOUT (trial → continuing, isContAftTrialEnd) │ -│ │ -│ ⚠ Your trial is active until [DATE]. You will be │ -│ charged on that date. │ -│ │ -│ Your Subscription After Trial Ends │ -│ payment-info (trial items) │ -│ │ -│ [IF promo]: 🎁 Total Promo Savings: -$X.XX │ -│ After Trial Total *: $X.XX │ -│ │ -│ Total *: $X.XX │ -│ [Canada only]: Plus Applicable Tax │ -│ │ -│ ── Credit card form ── │ -│ │ -│ * label changes in Canada (see §6) │ -└──────────────────────────────────────────────────────┘ -``` - -### `/checkout-review` — Stage 2 - -``` -┌──────────────────────────────────────────────────────┐ -│ REVIEW AND SUBMIT │ -│ │ -│ ✓ (success icon) │ -│ │ -│ payment-summary [mode]="REGULAR" │ -│ ┌────────────────────────┬───────────────────────┐ │ -│ │ Payment Information │ Card Info │ │ -│ │ ───────────────── │ ───────────────── │ │ -│ │ Template 2: │ •••• 4242 │ │ -│ │ Total Excl. Tax $X.XX │ Visa Exp 12/27 │ │ -│ │ Tax $X.XX │ │ │ -│ │ [Discount] -$X.XX │ [Edit button] │ │ -│ │ [Plan Refund]-$X.XX │ │ │ -│ │ ────── │ │ │ -│ │ Total $X.XX │ │ │ -│ └────────────────────────┴───────────────────────┘ │ -│ │ -│ [Error states: PAST_DUE / CARD_DECLINED / etc. │ -│ → error banner above payment-summary] │ -│ │ -│ [ SUBMIT ] │ -└──────────────────────────────────────────────────────┘ -``` - -### `/checkout-confirm` — Stage 3 - -``` - ┌──────────────────────────┐ - │ mode = TRIALING │ - ├──────────────────────────┤ - │ ✓ Trial subscription │ - │ is active │ - │ │ - │ payment-summary TRIALING │ - │ Trial Information │ - │ payment-info (items) │ - │ Template 5: $0.00 │ - └──────────────────────────┘ - - ┌──────────────────────────┐ - │ mode = CONTINUE_TRIAL │ - ├──────────────────────────┤ - │ ✓ Continuation setup │ - │ complete │ - │ │ - │ payment-summary │ - │ CONTINUE_TRIAL │ - │ [showApplicableTax= │ - │ authSvc.isCanada] │ - │ ───────────────────── │ - │ Trial Information │ - │ ⚠ constraint-message │ - │ payment-info (items) │ - │ Template 7: │ - │ 🎁 Promo Savings $X │ - │ Total (Before Tax) * │ - │ Plus Applicable Tax * │ - │ Card: •••• 4242 │ - └──────────────────────────┘ - - ┌──────────────────────────┐ - │ mode = REGULAR │ - ├──────────────────────────┤ - │ ✓ Subscription active │ - │ [promo note if promo] │ - │ │ - │ Template 2: │ - │ Total Excl. Tax $X.XX │ - │ Tax $X.XX │ - │ [Discount] -$X.XX │ - │ [Plan Refund] -$X.XX │ - │ Total $X.XX │ - │ │ - │ Card: •••• 4242 Visa │ - └──────────────────────────┘ - - * label changes in Canada (see §6) -``` - ---- - -## 3. Checkout Flow — 3 Stages - -### Stage 1 — Enter Payment (`checkout.component`) - -#### Decision tree - -``` -checkout.component - │ - ├─[isTrial = false]────────────────── Regular purchase - │ │ - │ ├─[hasRefund = false]─────── Single column - │ │ payment-info (items) - │ │ [promo?] Template 1 : coupon input - │ │ credit card form - │ │ - │ └─[hasRefund = true]──────── Two columns - │ Payment col │ Refund col - │ items+T1 │ items+T1 - │ credit card form below - │ - └─[isTrial = true]─────────────────── Trial purchase - │ - ├─[isContAftTrialEnd = false]── Trial-start only - │ Trial info + items - │ [promo?] 🎁 savings + After Trial Total * - │ Template 5 ($0.00) - │ Checkbox: continue after trial? - │ └─(checked) → isContAftTrialEnd = true - │ - └─[isContAftTrialEnd = true]─── Trial + continue - Constraint banner - After-trial items - [promo?] 🎁 savings + After Trial Total * - Total * / Plus Applicable Tax (CA) - Credit card form - - * = "After Trial Total (Before Tax)" / "Total (Before Tax)" in Canada -``` - ---- - -### Stage 2 — Review & Submit (`checkout-review.component`) - -Always renders `` → **Template 2** inside. - -``` -Template 2 layout: - - ┌─[IF promoSavings > 0]────────────────────────────────┐ - │ 🎁 Total Promo Savings: -$X.XX │ - │ [IF creditAmount > 0] │ - │ 🔄 Plan Refund: -$X.XX │ - │ Tax: $X.XX │ - │ Total: $X.XX │ - └──────────────────────────────────────────────────────┘ - - ┌─[ELSE — no promo]────────────────────────────────────┐ - │ Total Excluding Tax: $X.XX │ - │ Tax: $X.XX │ - │ [IF discount.amountOff] │ - │ (Discount): -$X.XX │ - │ [IF creditAmount > 0] │ - │ 🔄 Plan Refund: -$X.XX │ - │ Total: $X.XX │ - └──────────────────────────────────────────────────────┘ -``` - ---- - -### Stage 3 — Confirmation (`checkout-confirm.component`) - -``` -Mode selection: - - TRIALING ──────────────────→ payment-summary TRIALING - → Template 5 ($0.00 + trial msg) - - CONTINUE_TRIAL ────────────→ payment-summary CONTINUE_TRIAL - [showApplicableTax]="isCanada" - → Template 7 (after-trial totals) - - REGULAR (default) ─────────→ Template 2 (full tax + total receipt) -``` - ---- - -## 4. Manage Subscription Page (`/myservices`) - -### Subscription State Decision Tree - -``` -manage-subscription: for each pkg in subscriptions - │ - ├─[TRIALING]─────────────────────────────────────────────────── - │ Trial Ends: [DATE] - │ │ - │ ├─[No promo — Case 2A] - │ │ After Trial: $X.XX/year - │ │ - │ ├─[Promo + will continue — Case 2C] - │ │ Regular Price: $X.XX/year ← context - │ │ Paid Price: $X.XX + (save $X.XX) - │ │ [IF time-limited] After Promo Ends: $X.XX/year - │ │ - │ └─[Promo + cancel at end — Case 2D] - │ Regular Price: $X.XX/year - │ (no paid price shown — trial will cancel) - │ - ├─[ACTIVE + hasActivePromo(pkg)]───────────────────────────── - │ │ - │ ├─[isRenewalPromo — Case 2B] - │ │ 🏷 Discount badge - │ │ getRenewalPromoMessage() (e.g. "Renew by X and save!") - │ │ - │ └─[existingPromo — Case 3] - │ Regular Price: $X.XX/year ← context - │ Paid Price: $X.XX + (save $X.XX) - │ [IF time-limited] After Promo Ends: $X.XX/year - │ - ├─[ACTIVE — no promo]──────────────────────────────────────── - │ Paid Price: $X.XX/year - │ - ├─[CANCELED]───────────────────────────────────────────────── - │ Ended On: [DATE] - │ Previous Price: $X.XX/year - │ - └─[PAST_DUE / INCOMPLETE]──────────────────────────────────── - Paid Price: $X.XX/year - Next Bill Date: [DATE] -``` - -### Case-by-Case Pricing Display - -Each subscription card shows a **Pricing Section** and a **Details Section**: - -``` -┌─────────────────────────────────────────────────┐ -│ 📦 Package Name [STATUS BADGE] │ -│ ───────────────────────────────────────────── │ -│ PRICING SECTION (varies by case — see below) │ -│ ───────────────────────────────────────────── │ -│ DETAILS SECTION (always shown): │ -│ Max Vehicles: N Aircraft │ -│ Max Acres: N,000 / Unlimited │ -│ Billing Cycle: Yearly │ -│ Payment Method: Visa •••• 4242 │ -│ Next Bill Date: Jan 1, 2027 ─┐ ACTIVE / │ -│ Next Bill Amt: $X.XX ┘ TRIALING │ -│ ───────────────────────────────────────────── │ -│ [Promo section if applicable — see below] │ -│ ───────────────────────────────────────────── │ -│ [ MANAGE ] [ CANCEL / REACTIVATE ] │ -└─────────────────────────────────────────────────┘ -``` - -#### Pricing section — by case - -``` -CASE 2A — Trial, no promo - Trial Ends: Jan 10, 2026 - After Trial: $995.00/year - -CASE 2B — Active, renewal incentive promo (cancel_at_period_end) - 🏷 [badge] "Renew by Jan 10, 2026 and save 50%!" - -CASE 2C — Trial, promo applied, will continue after trial - Trial Ends: Jan 10, 2026 - Regular Price: $995.00/year ← full price for context - Paid Price: $497.50/year (save $497.50) - After Promo Ends: $995.00/year ← only if time-limited promo - -CASE 2D — Trial, promo applied, cancel at end - Trial Ends: Jan 10, 2026 - Regular Price: $995.00/year - -CASE 3 — Active, promo applied on subscription - Regular Price: $995.00/year ← full price for context - Paid Price: $497.50/year (save $497.50) - After Promo Ends: $995.00/year ← only if time-limited promo - -ACTIVE (no promo) - Paid Price: $995.00/year - -CANCELED - Ended On: Dec 31, 2025 - Previous Price: $995.00/year - -PAST_DUE / INCOMPLETE - Paid Price: $995.00/year - Next Bill Date: Jan 10, 2026 -``` - -#### Promo details block (ACTIVE non-renewal promos) - -``` - ───────────────────────────────────────────────── - 🏷 Percentage Off | Amount Off | Forever - Discount: 50% off or $497.50 off - Duration: For N months | One time | Forever - [IF expires]: Promo Expires: Dec 31, 2026 (N days left) - ───────────────────────────────────────────────── - [IF pendingPromo]: - ⏳ Pending Promo (from next billing cycle): - Discount / Duration / Savings - ───────────────────────────────────────────────── -``` - ---- - -## 5. Shared Pricing Components - -### `` Template Guide - -The component is template-switched via `[template]="N"`. - -``` -Template Used In Renders -───────── ─────────────────────────────────── ────────────────────────────────────────────── -1 checkout (with active promo) Tax + discount + promo savings + Total -2 checkout-review, checkout-confirm Full grid: Excl.Tax / Tax / Discount / Total -3 (reserved) Subtotal ─── Tax ─── Discount ─── Total -4 (reserved) Coupon/discount line only -5 trial start confirm, TRIALING mode Trial msg + Total: $0.00 -6 (reserved) "Will be charged after trial" note + Total -7 CONTINUE_TRIAL confirm, isContAftTrial Promo savings + Total (Before Tax)* + Tax note -``` - -#### Template 1 layout -``` - Tax: $X.XX US - [IF %off] 50% off: ($X.XX) US - [IF $off] ($ off): ($X.XX) US - [IF promo] 🎁 Total Promo Savings: -$X.XX US - Total: $X.XX US -``` - -#### Template 2 layout -``` - ┌─[IF promoSavings > 0]───────────────────────────────┐ - │ 🎁 Total Promo Savings: -$X.XX │ - │ [Plan Refund]: -$X.XX (if > 0) │ - │ Tax: $X.XX │ - │ Total: $X.XX │ - └─────────────────────────────────────────────────────┘ - ┌─[ELSE]──────────────────────────────────────────────┐ - │ Total Excluding Tax: $X.XX │ - │ Tax: $X.XX │ - │ [(Discount)]: ($X.XX) (if $off) │ - │ [Plan Refund]: -$X.XX (if > 0) │ - │ Total: $X.XX │ - └─────────────────────────────────────────────────────┘ -``` - -#### Template 5 layout -``` - [msg] "Free trial until Jan 10, 2026" - Total: $0.00 US -``` - -#### Template 7 layout -``` - [IF promoSavings > 0] - 🎁 Total Promo Savings: -$X.XX US - - [!Canada] Total: $X.XX US - [Canada] Total (Before Tax): $X.XX US - Plus Applicable Tax ← only when totalAmount > 0 -``` - ---- - -### `` Mode Guide - -A mode-driven wrapper that picks the layout and calls `` with the right template. - -``` -Mode Template Used Shows Card? showApplicableTax driven by -─────────────── ───────────── ─────────── ────────────────────────────── -REGULAR 2 yes N/A (Template 2 has no tax toggle) -TRIALING 5 no N/A -CONTINUE_TRIAL 7 yes [showApplicableTax] input → isCanada -``` - -**Inputs:** - -| Input | Type | Purpose | -|---|---|---| -| `mode` | `Mode` | `REGULAR`, `TRIALING`, or `CONTINUE_TRIAL` | -| `card` | `Card` | Credit card info for display | -| `payment` | `PaidAmount` | `{ total, totalTax, totalExcludingTax, discount, refundAmount }` | -| `trialItems` | `TrialItem[]` | Line items for trial subscriptions | -| `promoSavings` | `number` | Total promo discount | -| `showApplicableTax` | `boolean` | Passed down to `` (Template 7) | -| `editable` | `boolean` | Shows Edit button in REGULAR mode | -| `promos` | `Map` | Promo badge data for `` | - ---- - -## 6. Canada Tax Logic - -```typescript -// auth.service.ts -get isCanada(): boolean { - return this.user?.country === 'CA'; // populated from login response -} -``` - -### Where `isCanada` propagates - -``` -AuthService.isCanada - │ - ├── checkout.component (readonly authSvc exposed to template) - │ ├── "Total (Before Tax):" label [isContAftTrialEnd block] - │ ├── "After Trial Total (Before Tax):" label - │ └── "Plus Applicable Tax" div - │ - └── checkout-confirm.component (readonly authSvc) - └── - └── - └── Template 7 tax toggle + "Plus Applicable Tax" note -``` - -### Label changes by country - -``` - Non-Canada Canada - ───────────────────── ───────────────────────────── -Total label Total: Total (Before Tax): -After-trial total label After Trial Total: After Trial Total (Before Tax): -Tax line (hidden) Plus Applicable Tax -``` - ---- - -## 7. Conditional Label Reference - -| Label | Component | Renders when | -|---|---|---| -| **Total:** | All templates, default | `!showApplicableTax` | -| **Total (Before Tax):** | Template 7, `checkout.html` | Canada (`showApplicableTax = true`) | -| **Total Excluding Tax:** | Template 2, no-promo branch | Non-promo path (always) | -| **Tax:** | Templates 1, 2, 3 | Tax data available | -| **Plus Applicable Tax** | Template 7, `checkout.html` | Canada + `totalAmount > 0` | -| **After Trial Total:** | `payment-summary #trial`, `checkout.html` | `!showApplicableTax` + `promoSavings > 0` | -| **After Trial Total (Before Tax):** | `payment-summary #trial`, `checkout.html` | Canada + `promoSavings > 0` | -| **🎁 Total Promo Savings:** | Templates 2, 7; inline in checkout | `promoSavings > 0` | -| **🔄 Plan Refund:** | Template 2 | `creditAmount > 0` | -| **Regular Price:** | manage-subscription Cases 2C, 2D, 3 | Has promo applied | -| **Paid Price:** | manage-subscription Cases 2C, 3, ACTIVE, PAST_DUE | Active promo or non-promo active | -| **After Promo Ends:** | manage-subscription Cases 2C, 3 | `showAfterPromoEnds(pkg)` — time-limited promo | -| **Next Period Amount:** | manage-subscription | `nextBillAmounts[key]` loaded | diff --git a/Development/client/package.json b/Development/client/package.json index fc87927..2fe9567 100644 --- a/Development/client/package.json +++ b/Development/client/package.json @@ -5,8 +5,7 @@ "angular-cli": {}, "scripts": { "ng": "ng", - "start-cert": "ng serve --ssl true --sslKey ~/ssl/server.key --sslCert ~/ssl/server.crt --proxy-config proxy.config.json --host 0.0.0.0 --disableHostCheck", - "start": "CHOKIDAR_USEPOLLING=true ng serve --ssl true --proxy-config proxy.config.json --host 0.0.0.0 --disableHostCheck", + "start": "ng serve --ssl true --proxy-config proxy.config.json --host 0.0.0.0 --disableHostCheck", "start-es": "ng serve --ssl true --proxy-config proxy.config.json --host 0.0.0.0 --disableHostCheck --configuration=es", "start-pt": "ng serve --ssl true --proxy-config proxy.config.json --host 0.0.0.0 --disableHostCheck --configuration=pt", "build": "ng build", @@ -21,7 +20,6 @@ "i18n-merge-w": "(for %i in (pt es) do (xliffmerge --profile xliffmerge.json en %i))", "sync-i18n": "npm run build-prep && npm run i18n-extract && npm run i18n-merge", "sync-i18n-w": "npm run build-prep && npm run i18n-extract-w && npm run i18n-merge-w", - "pre-translate": "npx translation start && npm run sync-i18n && npx translation translate && npx translation cleanup", "build-prod": "ng build --prod --localize && cp -R dist/en/* dist/ && rm -R dist/en", "build-prod-window": "ng build --prod --localize && xcopy /E /Y dist\\en\\* dist\\ && rmdir /S /Q dist\\en" }, @@ -47,13 +45,12 @@ "@ngrx/entity": "^9.2.0", "@ngrx/store": "^9.2.0", "@ngrx/store-devtools": "^9.2.0", - "@stripe/stripe-js": "1.46.0", "angular-resizable-element": "^3.3.2", "angular-svg-icon": "^7.2.1", "chart.js": "^2.9.3", "classlist.js": "^1.1.20150312", "clone-deep": "^4.0.0", - "esri-leaflet": "3.0.10", + "esri-leaflet": "^3.0.1", "file-saver": "^1.3.8", "geodesy": "^1.1.3", "intl": "^1.2.5", @@ -73,41 +70,32 @@ "@angular/compiler-cli": "9.1.13", "@angular/language-service": "9.1.13", "@locl/cli": "^1.0.0", - "@types/esri-leaflet": "2.1.9", + "@types/esri-leaflet": "^2.1.6", "@types/file-saver": "^1.3.1", "@types/geodesy": "^1.1.3", "@types/jasmine": "^2.8.16", "@types/jasminewd2": "2.0.3", - "@types/leaflet": "1.9.4", + "@types/leaflet": "^1.5.17", "@types/leaflet-draw": "^0.4.14", - "@types/node": "12.12.29", + "@types/node": "12.11.1", "ajv": "6.12.2", - "codelyzer": "5.2.1", - "jasmine-core": "4.6.0", + "codelyzer": "5.1.2", + "jasmine-core": "3.5.0", "jasmine-spec-reporter": "4.2.1", "karma": "4.4.1", - "karma-chrome-launcher": "3.1.0", - "karma-cli": "2.0.0", - "karma-coverage-istanbul-reporter": "2.1.1", + "karma-chrome-launcher": "2.2.0", + "karma-cli": "1.0.1", + "karma-coverage-istanbul-reporter": "2.0.4", "karma-jasmine": "2.0.1", - "karma-jasmine-html-reporter": "1.5.2", - "ngx-i18nsupport": "^0.17.1", - "protractor": "5.4.3", + "karma-jasmine-html-reporter": "1.4.2", + "protractor": "5.4.1", "rxjs-tslint": "0.1.8", - "ts-node": "8.3.0", + "ts-node": "7.0.1", "tslint": "5.20.1", "typescript": "3.8.3" }, "resolutions": { "serialize-javascript": "^2.1.1", "tree-kill": "^1.2.2" - }, - "overrides": { - "@locl/cli": { - "@angular/compiler": "9.1.13", - "@angular/core": "9.1.13", - "@angular/localize": "9.1.13" - }, - "websocket-driver": "0.7.3" } -} \ No newline at end of file +} diff --git a/Development/client/proxy.config.json b/Development/client/proxy.config.json index 441f674..42dee98 100644 --- a/Development/client/proxy.config.json +++ b/Development/client/proxy.config.json @@ -1,18 +1,18 @@ { "/api/*": { - "target": "https://127.0.0.1:4100", + "target": "https://127.0.0.1:4000", "secure": false, "changeOrigin": false, "logLevel": "debug" }, "/uploads/*": { - "target": "https://127.0.0.1:4100", + "target": "https://127.0.0.1:4000", "secure": false, "changeOrigin": false, "logLevel": "debug" }, "/es/uploads/*": { - "target": "https://127.0.0.1:4100", + "target": "https://127.0.0.1:4000", "pathRewrite": { "/es/uploads/": "/uploads/" }, @@ -21,7 +21,7 @@ "logLevel": "debug" }, "/pt/uploads/*": { - "target": "https://127.0.0.1:4100", + "target": "https://127.0.0.1:4000", "pathRewrite": { "/pt/uploads/": "/uploads/" }, diff --git a/Development/client/src/app/accounts/account-edit/account-edit.component.css b/Development/client/src/app/accounts/account-edit/account-edit.component.css index 3a93c9e..e69de29 100644 --- a/Development/client/src/app/accounts/account-edit/account-edit.component.css +++ b/Development/client/src/app/accounts/account-edit/account-edit.component.css @@ -1,410 +0,0 @@ -/* Satloc Integration Styles */ -.satloc-integration-fields { - margin-top: 15px; - padding: 15px; - border: 1px solid #dee2e6; - border-radius: 4px; - background-color: #f8f9fa; -} - -.satloc-integration-fields .ui-g { - width: 100%; -} - -.satloc-integration-fields .form-row { - margin-bottom: 15px; - min-height: 60px; - /* Prevent jumping when validation messages appear */ -} - -.satloc-integration-fields .form-row input { - width: 100%; - box-sizing: border-box; -} - -.satloc-integration-fields .form-row label { - display: block; - margin-bottom: 5px; - font-weight: 500; - color: #495057; - min-height: 20px; - /* Consistent label height */ -} - -.satloc-integration-fields .ui-message { - margin-top: 5px; - min-height: 20px; - /* Consistent error message height */ -} - -.satloc-connection-status { - margin-top: 15px; - padding: 10px; - border-radius: 4px; - background-color: #ffffff; - border: 1px solid #dee2e6; -} - -.connection-loading { - display: flex; - align-items: center; - color: #0c5460; - margin-bottom: 10px; -} - -.connection-error { - margin-bottom: 10px; -} - -.connection-success { - margin-bottom: 10px; -} - -.connection-details { - margin-top: 5px; -} - -.connection-details small { - color: #6c757d; - font-size: 0.875rem; -} - -.status-badge { - display: inline-flex; - align-items: center; - padding: 4px 8px; - border-radius: 4px; - font-weight: 500; - font-size: 0.875rem; -} - -.status-badge i { - margin-right: 5px; -} - -.status-active { - background-color: #d4edda; - color: #155724; - border: 1px solid #c3e6cb; -} - -.status-inactive { - background-color: #f8d7da; - color: #721c24; - border: 1px solid #f5c6cb; -} - -.status-error { - background-color: #f8d7da; - color: #721c24; - border: 1px solid #f5c6cb; -} - - -/* ============================================================================ - FORM FIELD SPACING AND LAYOUT - UX AUDIT COMPLIANCE - ============================================================================ */ - -/* Consistent form field structure */ -.form-row { - margin-bottom: 24px; - /* Increased from 15px for better visual separation */ - display: flex; - flex-direction: column; -} - -/* Standardized field labels */ -.field-label { - display: flex; - align-items: center; - margin-bottom: 8px; - /* Consistent spacing between label and input */ - font-weight: 500; - color: #495057; - font-size: 14px; - line-height: 1.4; - /* UX audit recommendation for readability */ -} - -/* Inline constraint message (beside label) */ -.field-label .inline-constraint { - margin-left: 6px; - /* Small gap between label text and icon */ -} - -/* Override constraint wrapper width for inline display */ -.field-label .inline-constraint ::ng-deep .agm-constraint-wrapper { - display: inline-block; - width: auto; - /* Override default 100% width */ - vertical-align: middle; -} - -/* Standardized field input containers */ -.field-input { - margin-bottom: 8px; - /* Space between input and any messages */ -} - -/* Standardized message spacing */ -.field-message { - margin-top: 8px !important; - /* Override inline styles for consistency */ -} - -/* Test Connection Section Specific Styling */ -.test-connection-section { - padding-top: 16px; - border-top: 1px solid #e9ecef; - /* Visual separator */ -} - -.test-connection-controls { - display: flex; - align-items: center; - gap: 12px; - /* Consistent spacing between button and status indicators */ - margin-bottom: 8px; -} - -/* Loading indicator standardization */ -.loading-indicator { - margin-top: 8px; - font-size: 12px; - color: #666; - display: flex; - align-items: center; - gap: 6px; -} - -/* Responsive spacing adjustments */ -@media (max-width: 768px) { - .form-row { - margin-bottom: 20px; - /* Slightly reduced for mobile */ - } - - .test-connection-section { - margin-top: 24px; - padding-top: 12px; - } - - .test-connection-controls { - flex-direction: column; - align-items: flex-start; - gap: 8px; - } -} - -/* Button spacing */ -.p-button { - margin-right: 8px; -} - -.p-button:last-child { - margin-right: 0; -} - -/* Error message styling */ -.p-error { - display: block; - margin-top: 5px; - color: #dc3545; - font-size: 0.875rem; -} - -/* Loading spinner center alignment */ -.text-center { - text-align: center; -} - -/* Responsive adjustments */ -@media (max-width: 768px) { - .satloc-integration-fields { - padding: 10px; - } - - .satloc-connection-status { - padding: 8px; - } -} - -/* Connection Status Badge - Circular design similar to topbar-badge */ -.connection-status-badge { - display: inline-block; - width: 24px; - height: 24px; - border-radius: 50%; - text-align: center; - line-height: 24px; - font-size: 12px; - border: 2px solid; - cursor: pointer; - transition: transform 0.2s ease, box-shadow 0.2s ease; -} - -.connection-status-badge:hover { - transform: scale(1.1); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); -} - -.connection-status-badge.success { - background-color: #28a745; - color: white; - border-color: #1e7e34; -} - -.connection-status-badge.error { - background-color: #dc3545; - color: white; - border-color: #c82333; -} - -.connection-status-badge.warning { - background-color: #ffc107; - color: #212529; - border-color: #e0a800; -} - -/* ============================================================================ */ -/* PHASE 3: SAVE CREDENTIALS DIALOG STYLES */ -/* ============================================================================ */ - -/* Dialog content container */ -.dialog-content { - padding: 1rem 0; - font-family: "Roboto", "Helvetica Neue", sans-serif; - color: #212121; -} - -/* Success message styling */ -.success-message { - display: flex; - align-items: center; - margin-bottom: 1.5rem; - padding: 1rem; - background-color: #E8F5E9; - border-left: 4px solid #4CAF50; - border-radius: 3px; - font-size: 1rem; - color: #2E7D32; -} - -.success-message i { - font-size: 1.5rem; - margin-right: 0.75rem; - color: #4CAF50; -} - -/* Save prompt paragraph */ -.dialog-content>p { - margin: 0 0 1.5rem 0; - font-size: 1rem; - line-height: 1.5; - color: #212121; -} - -/* Button styling overrides for dialog footer */ -::ng-deep .ui-dialog-footer .ui-button-secondary { - background-color: #757575; - border-color: #757575; - color: #ffffff; - transition: all 0.2s ease; -} - -::ng-deep .ui-dialog-footer .ui-button-secondary:hover { - background-color: #616161; - border-color: #616161; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); -} - -::ng-deep .ui-dialog-footer .ui-button-success { - background-color: #4CAF50; - border-color: #4CAF50; - color: #ffffff; - transition: all 0.2s ease; -} - -::ng-deep .ui-dialog-footer .ui-button-success:hover { - background-color: #2E7D32; - border-color: #2E7D32; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); -} - -/* Responsive adjustments for mobile */ -@media (max-width: 768px) { - .dialog-content { - padding: 0.75rem 0; - } - - .success-message { - padding: 0.75rem; - font-size: 0.9rem; - } - - .success-message i { - font-size: 1.25rem; - margin-right: 0.5rem; - } - - .dialog-content>p { - font-size: 0.9rem; - } -} - -/* ============================================================================ - * PHASE 4: POST-SAVE VALIDATION STYLING - * ============================================================================ */ - -/* Post-save validation message spacing */ -.post-save-message { - margin-top: 16px; - margin-bottom: 12px; -} - -/* Post-save validation progress indicator */ -.validation-progress { - display: flex; - align-items: center; - gap: 12px; - padding: 12px 16px; - margin-top: 16px; - background-color: #f5f5f5; - border-radius: 3px; - border-left: 4px solid #4CAF50; - /* AgMission primary green */ -} - -.validation-progress i { - font-size: 1.125rem; - color: #4CAF50; - /* AgMission primary green */ -} - -.validation-progress span { - font-size: 0.95rem; - color: #212121; - /* AgMission text color */ - font-family: "Roboto", "Helvetica Neue", sans-serif; -} - -/* Mobile responsive adjustments for post-save validation */ -@media (max-width: 768px) { - .post-save-message { - margin-top: 12px; - margin-bottom: 8px; - } - - .validation-progress { - padding: 10px 12px; - margin-top: 12px; - } - - .validation-progress i { - font-size: 1rem; - } - - .validation-progress span { - font-size: 0.875rem; - } -} \ No newline at end of file diff --git a/Development/client/src/app/accounts/account-edit/account-edit.component.html b/Development/client/src/app/accounts/account-edit/account-edit.component.html index 3f376d5..40e71aa 100644 --- a/Development/client/src/app/accounts/account-edit/account-edit.component.html +++ b/Development/client/src/app/accounts/account-edit/account-edit.component.html @@ -8,169 +8,24 @@
- -
- - - - {{ type.label }} - - - -
- - -
- -
-
- - -
- - - - -
- - -
- - -
-
- Partner System - selection is required for partner system accounts
-
- - -
- -
-
- - -
- {{ Labels.LOADING_VENDOR_OPTIONS }} -
- - - - -
- - -
-
- - - -
- - - + + Account Type: + + + + + {{ type.label }} - - - - - -
-
- - - -
- -

{{ Labels.SAVE_BEFORE_TEST_MESSAGE }}

- - - - -
- - - - - -
- - - - -
- - {{ Labels.VALIDATING_CREDENTIALS }} -
- - - - -
- - Processing request... -
+ +
- -
- - +
+
- - -
- -
-
- + [icon]="isNew ? 'ui-icon-plus' : 'ui-icon-save'" [label]="isNew ? globals.create : globals.save" (click)="saveAccount(); false"> +
diff --git a/Development/client/src/app/accounts/account-edit/account-edit.component.ts b/Development/client/src/app/accounts/account-edit/account-edit.component.ts index c57ca1a..dee8f20 100644 --- a/Development/client/src/app/accounts/account-edit/account-edit.component.ts +++ b/Development/client/src/app/accounts/account-edit/account-edit.component.ts @@ -1,27 +1,14 @@ -import { Component, OnInit, OnDestroy, ChangeDetectorRef, ViewChild } from '@angular/core'; +import { Component, OnInit, OnDestroy } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; + import { SelectItem } from 'primeng/api'; -import { FormBuilder, FormGroup, Validators } from '@angular/forms'; -import { HttpErrorResponse } from '@angular/common/http'; -import { of } from 'rxjs'; -import { catchError, finalize, take } from 'rxjs/operators'; -import { User, PartnerSystemUser, SatlocIntegration } from '../models/user.model'; -import { PartnerService } from '@app/partners/services/partner.service'; -import { Partner } from '@app/partners/models/partner.model'; +import { User } from '../models/user.model'; import * as userActions from '../actions/account.actions'; -import * as fromUsers from '../reducers'; -import { RoleIds, Roles, globals, OperationalStatus, Labels, KnownPartnerCodes } from '@app/shared/global'; +import { RoleIds, Roles, globals } from '@app/shared/global'; import { BaseComp } from '@app/shared/base/base.component'; -import { ConstraintMessageComponent } from '@app/shared/constraint-message/constraint-message.component'; -import { AccountEditorComponent } from '@app/shared/account-editor/account-editor.component'; -import { PartnerUtilsService } from '@app/shared/services/partner-utils.service'; -import { handlePartnerErr } from '@app/profile/common'; - -// Partner Integration Constants -export const VENDOR_SYSTEM_FIELD = 'vendorSystem'; -export const SATLOC_VENDOR = KnownPartnerCodes.SATLOC; +import { FormGroup, FormBuilder } from '@angular/forms'; @Component({ selector: 'agm-account-edit', @@ -29,1128 +16,81 @@ export const SATLOC_VENDOR = KnownPartnerCodes.SATLOC; styleUrls: ['./account-edit.component.css'] }) export class AccountEditComponent extends BaseComp implements OnInit, OnDestroy { - readonly globals = globals; - readonly Labels = Labels; - readonly VENDOR_SYSTEM_FIELD = VENDOR_SYSTEM_FIELD; - readonly SATLOC_VENDOR = SATLOC_VENDOR; form: FormGroup; selectedItem: User; kinds: SelectItem[]; - // ============================================================================ - // VIEW CHILDREN - // ============================================================================ - - @ViewChild('accountTypeConstraint') accountTypeConstraint: ConstraintMessageComponent; - @ViewChild('vendorSystemConstraint') vendorSystemConstraint: ConstraintMessageComponent; - @ViewChild('accountEditor') accountEditor: AccountEditorComponent; - - // ============================================================================ - // PARTNER INTEGRATION PROPERTIES - // ============================================================================ - - // Partner system integration state - showVendorOptions: boolean = false; - selectedVendor: string = ''; - - // Partner system user data for dual-model approach - partnerSystemUser: PartnerSystemUser | null = null; - existingPartnerSystemUsers: PartnerSystemUser[] = []; - - // Dynamic partner loading from API - partners: Partner[] = []; - vendorsLoading: boolean = false; - vendorOptions: SelectItem[] = [ - { label: $localize`:Select vendor dropdown option@@selectVendor:Select Partner System`, value: '' } - ]; - availableVendorOptions: SelectItem[] = []; - - // Satloc integration properties - satlocLoading: boolean = false; - satlocError: string | null = null; - satlocIntegration: SatlocIntegration = { - enabled: false, - status: OperationalStatus.ERROR, - account_info: null, - credentials_stored: false, - last_error: null - }; - - // Save before test dialog state - showSaveBeforeTestDialog: boolean = false; - pendingTestAfterSave: boolean = false; - - // Credential change tracking - credentialsChanged: boolean = false; - originalUsername: string = ''; - originalPassword: string = ''; - - // Vendor change tracking for soft-lock confirmation - originalVendorSystem: string = ''; - - // Account Does Not Exist flow tracking - isAccountDoesNotExistFlow: boolean = false; - - // Return navigation properties for vehicle-edit flow - returnTo: string | null = null; - vehicleId: string | null = null; - partnerId: string | null = null; - partnerCode: string | null = null; - customerId: string | null = null; - - // Post-save validation state - postSaveValidationInProgress: boolean = false; - postSaveValidationSuccess: boolean = false; - postSaveValidationError: boolean = false; - postSaveErrorMessage: string | null = null; - - private _account: User | PartnerSystemUser; - private _isNew: boolean; - - get account(): User | PartnerSystemUser { - return this._account; - } - - set account(account: User | PartnerSystemUser) { + private _account: User; + get account(): User { return this._account; } + set account(account: User) { this._account = account; - this.selectedItem = Object.assign({}, account); - - const isPartnerSystemUser = this.isPartnerSystemUser(account); - const isPartnerAccount = account.kind === RoleIds.PARTNER; - const accountType = account.kind; - + this.selectedItem = Object.assign({}, account); // create a clone object to work on the editor this.form.patchValue({ profile: this.selectedItem, account: { active: this.selectedItem.active, username: this.selectedItem.username, password: this.selectedItem.password }, - kind: accountType, - [VENDOR_SYSTEM_FIELD]: this.getVendorFromAccount(account) + kind: this.selectedItem.kind, }); - - if (this.isAccountDoesNotExistFlow && this.isNew) { - const partnerLabel = this.partnerCode || (this.partnerId ? this.getPartnerLabelFromId(this.partnerId) : null); - - if (partnerLabel) { - const matchingVendor = this.vendorOptions.find(vendor => - vendor.label.toLowerCase() === partnerLabel.toLowerCase() - ); - - if (matchingVendor) { - this.selectedVendor = matchingVendor.label; - this.form.patchValue({ - [VENDOR_SYSTEM_FIELD]: matchingVendor.value - }); - } else { - this.selectedVendor = partnerLabel; - this.form.patchValue({ - [VENDOR_SYSTEM_FIELD]: partnerLabel - }); - } - } - } - - this.showVendorOptions = isPartnerAccount || isPartnerSystemUser || this.isAccountDoesNotExistFlow; - this.selectedVendor = this.getVendorFromAccount(account); - - if (this.isPartnerSystemUser(account)) { - const partnerSystemUser = account as PartnerSystemUser; - // Use getVendorFromAccount which now prefers partner ObjectId → partnerCode - const vendorType = this.getVendorFromAccount(account) || SATLOC_VENDOR; - this.selectedVendor = vendorType; - - if (this.partnerUtils.isSatlocPartner(vendorType)) { - this.satlocIntegration = { - enabled: true, - status: partnerSystemUser.syncStatus === OperationalStatus.ACTIVE ? OperationalStatus.ACTIVE : OperationalStatus.ERROR, - account_info: null, - credentials_stored: true, - last_error: partnerSystemUser.syncStatus === OperationalStatus.ERROR ? $localize`:Connection error message@@connectionError:Connection error` : null - }; - } - } - - if (isPartnerAccount) { - const vendorType = this.getVendorFromAccount(account); - this.selectedVendor = vendorType; - - if (this.partnerUtils.isSatlocPartner(vendorType)) { - this.satlocIntegration = { - enabled: true, - status: OperationalStatus.ERROR, - account_info: null, - credentials_stored: true, - last_error: null - }; - } - } - - this.updateAvailableVendorOptions(); - - // Store original vendor for soft-lock confirmation (WI-1) - if (!this.isNew) { - this.originalVendorSystem = this.selectedVendor; - } - - if (isPartnerAccount || isPartnerSystemUser) { - this.form.get(this.VENDOR_SYSTEM_FIELD)?.setValidators([Validators.required]); - this.form.get(this.VENDOR_SYSTEM_FIELD)?.updateValueAndValidity(); - - } - - // WI-1: Soft lock - Only disable for special flow, enable for existing accounts - if (!this.isNew || (this.isAccountDoesNotExistFlow && this.isNew)) { - this.form.get('kind')?.disable(); - // Only disable vendor for special flow, not for regular existing accounts - if (this.isAccountDoesNotExistFlow) { - this.form.get(this.VENDOR_SYSTEM_FIELD)?.disable(); - } else { - this.form.get(this.VENDOR_SYSTEM_FIELD)?.enable(); - } - } else { - this.form.get('kind')?.enable(); - this.form.get(this.VENDOR_SYSTEM_FIELD)?.enable(); - } - } - - private updateVendorFieldState(): void { - // WI-1: Soft lock - Enable vendor field for existing accounts with confirmation dialog - // Previously: disabled for existing accounts to prevent changes - // Now: enabled with confirmation dialog when changed (see onVendorChange) - if (this.isAccountDoesNotExistFlow) { - // Special flow: vendor is pre-configured, keep disabled - this.form.get(this.VENDOR_SYSTEM_FIELD)?.disable(); - } else { - // All other cases: enable the field (new accounts OR existing accounts) - this.form.get(this.VENDOR_SYSTEM_FIELD)?.enable(); - } } + private _isNew: boolean; get isNew(): boolean { return this._isNew; } - get isCurrentAccountPartnerSystemUser(): boolean { - return this.form?.get('kind')?.value === RoleIds.PARTNER_SYSTEM_USER; - } - - // ============================================================================ - // DISABLED STATES FEEDBACK - // ============================================================================ - - get shouldShowAccountTypeDisabledMessage(): boolean { - return ((!this.isNew) || (this.isAccountDoesNotExistFlow && this.isNew)) && this.form?.get('kind')?.disabled; - } - - get shouldShowVendorSystemDisabledMessage(): boolean { - // WI-4: Only show disabled message for special flow (accountDoesNotExist) - // Regular existing accounts now use soft-lock with confirmation dialog - return this.isAccountDoesNotExistFlow && this.form?.get(this.VENDOR_SYSTEM_FIELD)?.disabled && this.showVendorOptions; - } - - /** - * Get context-aware constraint message for account type field - */ - get accountTypeConstraintMessage(): string { - if (this.isAccountDoesNotExistFlow && this.isNew) { - return Labels.ACCOUNT_TYPE_FLOW_DISABLED_MESSAGE; - } - return Labels.ACCOUNT_TYPE_DISABLED_MESSAGE; - } - - /** - * Get context-aware constraint message for vendor system field - */ - get vendorSystemConstraintMessage(): string { - if (this.isAccountDoesNotExistFlow && this.isNew) { - return Labels.VENDOR_SYSTEM_FLOW_DISABLED_MESSAGE; - } - return Labels.VENDOR_SYSTEM_DISABLED_MESSAGE; - } - - /** - * Get context-aware constraint title for account type field - */ - get accountTypeConstraintTitle(): string { - if (this.isAccountDoesNotExistFlow && this.isNew) { - return Labels.ACCOUNT_TYPE_FLOW_DISABLED_TITLE; - } - return Labels.ACCOUNT_TYPE_DISABLED_TITLE; - } - - /** - * Get context-aware constraint title for vendor system field - */ - get vendorSystemConstraintTitle(): string { - if (this.isAccountDoesNotExistFlow && this.isNew) { - return Labels.VENDOR_SYSTEM_FLOW_DISABLED_TITLE; - } - return Labels.VENDOR_SYSTEM_DISABLED_TITLE; - } - constructor( private readonly route: ActivatedRoute, - private readonly fb: FormBuilder, - private readonly partnerService: PartnerService, - public readonly partnerUtils: PartnerUtilsService, - private readonly cdr: ChangeDetectorRef + private readonly fb: FormBuilder ) { super(); this.form = this.fb.group({ profile: [], account: [], - kind: [], - [VENDOR_SYSTEM_FIELD]: [''] + kind: [] }); - this.kinds = [ - { label: Roles[RoleIds.APP_ADM], value: RoleIds.APP_ADM }, - { label: Roles[RoleIds.OFFICER], value: RoleIds.OFFICER }, - { label: Roles[RoleIds.INSPECTOR], value: RoleIds.INSPECTOR } - ]; - - this.availableVendorOptions = [...this.vendorOptions]; - } - - // Type guard for PartnerSystemUser - private isPartnerSystemUser(account: User | PartnerSystemUser): account is PartnerSystemUser { - return account.kind === RoleIds.PARTNER_SYSTEM_USER; - } - - /** - * Look up a loaded partner by ObjectId and return its partnerCode. - * Returns null if partners are not yet loaded or the partner is not found. - */ - private getPartnerCodeFromPartnerId(partnerId: string | { _id: string } | null | undefined): string | null { - if (!partnerId || !this.partners?.length) return null; - const id = typeof partnerId === 'string' ? partnerId : (partnerId as any)._id; - const partner = this.partners.find(p => p._id === id); - return partner?.partnerCode || null; - } - - // Helper to safely get vendor from account - private getVendorFromAccount(account: User | PartnerSystemUser): string { - if (this.isPartnerSystemUser(account)) { - const partnerSystemUser = account as PartnerSystemUser; - - // Prefer partner ObjectId → partnerCode (authoritative canonical value) - if (partnerSystemUser.partner) { - const rawPartnerId = typeof partnerSystemUser.partner === 'string' - ? partnerSystemUser.partner - : (partnerSystemUser.partner as any)._id; - const partnerCode = this.getPartnerCodeFromPartnerId(rawPartnerId); - if (partnerCode) return partnerCode; - } - - // If partner is already a populated object with partnerCode - if (typeof partnerSystemUser.partner === 'object' && (partnerSystemUser.partner as any).partnerCode) { - return (partnerSystemUser.partner as any).partnerCode; - } - - return ''; - } else if (account.kind === RoleIds.PARTNER) { - return SATLOC_VENDOR; - } else { - return ''; - } - } - - ngOnInit() { - this.sub$ = this.route.queryParams.subscribe(params => { - this.returnTo = params['returnTo'] || null; - this.vehicleId = params['vehicleId'] || null; - this.partnerId = params['partner'] || null; - this.partnerCode = params['partnerCode'] || null; - this.customerId = params['customerId'] || null; - this.isAccountDoesNotExistFlow = params['accountDoesNotExist'] === 'true' || params['accountDoesNotExist'] === true; - - if (this.isAccountDoesNotExistFlow) { - const hasPartnerSystemUser = this.kinds.some(kind => kind.value === RoleIds.PARTNER_SYSTEM_USER); - if (!hasPartnerSystemUser) { - this.kinds.push({ - label: Labels.PARTNER_SYSTEM_LABEL, - value: RoleIds.PARTNER_SYSTEM_USER - }); - } - } - }); - - this.sub$ = this.route.data.subscribe((data) => { - const account = data[0] as User || null; - - if (account) { - this._isNew = (account._id === '0'); - if (this.isNew) { - if (!account.kind) { - account.kind = this.kinds[0].value; - } - account.parent = this.authSvc.byPUserId; - - if (this.isAccountDoesNotExistFlow) { - account.kind = RoleIds.PARTNER_SYSTEM_USER; - } - - // Ensure all partner system users default to active - if (account.kind === RoleIds.PARTNER_SYSTEM_USER) { - account.active = true; - } - } - this.account = account; - - if (!this.isNew && account) { - this.originalUsername = account.username || ''; - this.originalPassword = account.password || ''; - - // testAuth is triggered once by updateSelectedVendorAfterLoading() after vendor options load - } - } - }); - - this.loadVendorOptions(); - - this.sub$.add(this.appActions.ofTypes([ - userActions.CREATE_SUCCESS, - userActions.UPDATE_SUCCESS - ]) - .subscribe((action) => { - const savedAccount = action['payload']; - - if (this.shouldPerformPostSaveValidation(savedAccount)) { - this.performPostSaveValidation(savedAccount); - } else { - this.handleNormalSaveSuccess(action); - } - })); - - // Form change detection - track credential modifications - const accountControl = this.form.get('account'); - - if (accountControl) { - this.sub$.add( - accountControl.valueChanges.subscribe((accountValue) => { - if (accountValue && !this.isNew) { - const currentUsername = accountValue.username || ''; - const currentPassword = accountValue.password || ''; - - this.credentialsChanged = - (currentUsername !== this.originalUsername) || - (currentPassword !== this.originalPassword); - - if (!this.postSaveValidationError) { - this.clearPostSaveValidation(); - } - } - }) - ); - } - } - - ngOnDestroy() { - super.ngOnDestroy(); - } - - onAccountTypeChange(selectedType: any): void { - if (!this.isNew && this.account && - (this.account.kind === RoleIds.PARTNER || this.account.kind === RoleIds.PARTNER_SYSTEM_USER)) { - return; - } - - this.showVendorOptions = (selectedType === RoleIds.PARTNER || selectedType === RoleIds.PARTNER_SYSTEM_USER); - - // Ensure partner system users are always active by default - if (selectedType === RoleIds.PARTNER_SYSTEM_USER && this.account) { - this.account.active = true; - } - - if (!this.showVendorOptions) { - this.form.patchValue({ - [VENDOR_SYSTEM_FIELD]: '' - }); - this.selectedVendor = null; - this.updateAvailableVendorOptions(); - } else { - this.form.get(VENDOR_SYSTEM_FIELD)?.setValidators([Validators.required]); - this.form.get(VENDOR_SYSTEM_FIELD)?.updateValueAndValidity(); - this.updateAvailableVendorOptions(); - } - } - - onVendorChange(selectedVendor: any): void { - // WI-1: Soft lock - Show confirmation dialog when changing vendor for existing accounts - if (!this.isNew && this.originalVendorSystem && this.originalVendorSystem !== selectedVendor) { - this.confirmSvc.confirm({ - header: Labels.VENDOR_CHANGE_CONFIRM_TITLE, - message: Labels.VENDOR_CHANGE_CONFIRM_MESSAGE, - acceptLabel: globals.yes, - rejectLabel: globals.no, - accept: () => { - // Allow the change - this.selectedVendor = selectedVendor; - this.form.get(VENDOR_SYSTEM_FIELD)?.updateValueAndValidity(); - }, - reject: () => { - // Revert to original value - this.form.get(VENDOR_SYSTEM_FIELD)?.setValue(this.originalVendorSystem); - this.selectedVendor = this.originalVendorSystem; - } - }); - return; - } - - this.selectedVendor = selectedVendor; - this.form.get(VENDOR_SYSTEM_FIELD)?.updateValueAndValidity(); - } - - // ============================================================================ - // PARTNER CONNECTION TESTING - // ============================================================================ - - /** - * Test partner connection for partner system user accounts. - */ - onTestPartnerConnection(): void { - if (this.isNew) { - return; - } - - // Check if credentials have changed but not saved - if (this.credentialsChanged && !this.isNew) { - this.showSaveBeforeTestDialog = true; - return; - } - - if (!this.isPartnerSystemUser(this.account)) { - const errorMsg = Labels.CONNECTION_TEST_ONLY_AVAILABLE_FOR_PARTNER_USERS; - this.satlocError = errorMsg; - this.satlocIntegration.status = OperationalStatus.ERROR; - return; - } - - const formAccount = this.form.get('account')?.value; - const username = formAccount?.username; - const password = formAccount?.password; - - if (!username || !password) { - const errorMsg = Labels.MISSING_USERNAME_PASSWORD_FOR_CONNECTION_TEST; - this.satlocError = errorMsg; - this.satlocIntegration.status = OperationalStatus.ERROR; - return; - } - - const account = this.account; - // 'customer' is a Mongoose virtual stripped by .lean() — fall back to 'parent' (the real DB field) - const customerId = (typeof account?.customer === 'string' ? account.customer : (account?.customer as any)?._id) - ?? (typeof (account as any)?.parent === 'string' ? (account as any).parent : ((account as any)?.parent as any)?._id); - const partnerId = typeof account?.partner === 'string' ? account.partner : (account?.partner as any)?._id; - - if (!customerId || !partnerId) { - const errorMsg = Labels.MISSING_CUSTOMER_PARTNER_ID_FOR_CONNECTION_TEST; - this.satlocError = errorMsg; - this.satlocIntegration.status = OperationalStatus.ERROR; - return; - } - - this.satlocLoading = true; - this.satlocError = null; - - const testAuthObservable = this.partnerService.testPartnerAuth(customerId, partnerId, username, password); - - testAuthObservable - .pipe( - finalize(() => { - this.satlocLoading = false; - }) - ) - .subscribe({ - next: (result: any) => { - const isSuccess = this.partnerService.isAuthenticationSuccessful(result); - - if (isSuccess) { - this.satlocIntegration = { - enabled: true, - status: OperationalStatus.ACTIVE, - account_info: null, - credentials_stored: true, - last_error: null - }; - this.satlocError = null; - } else { - // Use centralized error handler to extract .tag and map to localized message - const errorResult = handlePartnerErr(result); - this.satlocIntegration.status = OperationalStatus.ERROR; - this.satlocError = errorResult.message; - } - }, - error: (error) => { - // Use centralized error handler for HTTP errors - const errorResult = handlePartnerErr(error); - this.satlocIntegration.status = OperationalStatus.ERROR; - this.satlocError = errorResult.message; - } - }); - } - - /** - * User confirmed saving and testing with modified credentials. - */ - onConfirmSaveBeforeTest(): void { - this.showSaveBeforeTestDialog = false; - this.pendingTestAfterSave = true; - this.saveAccount(); - } - - /** - * User cancelled save before test dialog. - */ - onCancelSaveBeforeTest(): void { - this.showSaveBeforeTestDialog = false; - this.pendingTestAfterSave = false; - } - - /** - * Determine if post-save validation is required for this account. - */ - private shouldPerformPostSaveValidation(savedAccount: User | PartnerSystemUser): boolean { - if (!this.isPartnerSystemUser(savedAccount)) { - return false; - } - - if (!this.isNew && !this.credentialsChanged) { - return false; - } - - const partnerSystemUser = savedAccount as PartnerSystemUser; - // 'customer' is a Mongoose virtual stripped by .lean() — fall back to 'parent' (the real DB field) - const customerId = (typeof partnerSystemUser?.customer === 'string' ? partnerSystemUser.customer : (partnerSystemUser?.customer as any)?._id) - ?? (typeof (partnerSystemUser as any)?.parent === 'string' ? (partnerSystemUser as any).parent : ((partnerSystemUser as any)?.parent as any)?._id); - const partnerId = typeof partnerSystemUser?.partner === 'string' - ? partnerSystemUser.partner - : (partnerSystemUser?.partner as any)?._id; - - const hasRequiredData = !!(customerId && partnerId && partnerSystemUser.username && partnerSystemUser.password); - - return hasRequiredData; - } - - /** - * Perform post-save credential validation. - */ - private performPostSaveValidation(savedAccount: PartnerSystemUser): void { - this.postSaveValidationInProgress = true; - this.postSaveValidationSuccess = false; - this.postSaveValidationError = false; - this.postSaveErrorMessage = null; - - // � Capture original "new" state before modifying it - const wasNewAccount = this.isNew; - - // �🔄 CRITICAL: Transition from "new" mode to "edit" mode after account creation - // This ensures that if post-save validation fails, the user can: - // 1. Test credentials again (test connection button works) - // 2. See save dialog when testing with modified credentials (Phase 1-3) - // 3. Re-save the account with corrected credentials - if (wasNewAccount) { - this.account = savedAccount; - this._isNew = false; - this.originalUsername = savedAccount.username || ''; - this.originalPassword = savedAccount.password || ''; - this.credentialsChanged = false; - - // Mark username as saved in account editor to prevent "username taken" error - if (this.accountEditor && savedAccount.username) { - this.accountEditor.markUsernameAsSaved(savedAccount.username); - } - } - - // 'customer' is a Mongoose virtual stripped by .lean() — fall back to 'parent' (the real DB field) - const customerId = (typeof savedAccount?.customer === 'string' ? savedAccount.customer : (savedAccount?.customer as any)?._id) - ?? (typeof (savedAccount as any)?.parent === 'string' ? (savedAccount as any).parent : ((savedAccount as any)?.parent as any)?._id); - const partnerId = typeof savedAccount?.partner === 'string' - ? savedAccount.partner - : (savedAccount?.partner as any)?._id; - - if (!customerId || !partnerId || !savedAccount.username || !savedAccount.password) { - this.postSaveValidationInProgress = false; - - if (wasNewAccount) { - this.postSaveValidationError = true; - this.postSaveErrorMessage = 'Unable to validate credentials: missing customer or partner information.'; - this.account = savedAccount; - this._isNew = false; - } else { - this.handleNormalSaveSuccess({ payload: savedAccount }); - } - return; - } - - const testAuthObservable = this.partnerService.testPartnerAuth( - customerId, - partnerId, - savedAccount.username, - savedAccount.password - ); - - testAuthObservable - .pipe( - catchError(error => { - return of({ - authSuccess: false, - success: false, - error: error - }); - }), - finalize(() => { - this.cdr.detectChanges(); - }) - ) - .subscribe({ - next: (result: any) => { - const isSuccess = this.partnerService.isAuthenticationSuccessful(result); - - if (isSuccess) { - this.postSaveValidationInProgress = false; - this.postSaveValidationSuccess = true; - this.postSaveValidationError = false; - this.postSaveErrorMessage = null; - - this.credentialsChanged = false; - this.originalUsername = savedAccount.username || ''; - this.originalPassword = savedAccount.password || ''; - - // Update satlocIntegration status to show success icon - this.satlocIntegration = { - enabled: true, - status: OperationalStatus.ACTIVE, - account_info: null, - credentials_stored: true, - last_error: null - }; - this.satlocError = null; - - // Navigation behavior depends on whether this was a new account or existing account - if (wasNewAccount) { - // New account created from vehicle-edit flow: Navigate back immediately - // This provides seamless flow when creating missing partner accounts - if (this.returnTo === 'vehicle-edit') { - this.account = savedAccount; - this.handleVehicleEditReturnFlow(userActions.CREATE_SUCCESS); - } else { - // New account from normal flow: Stay on page to show success status - // User can see validation succeeded before navigating away manually - this.store.dispatch(new userActions.Select(savedAccount)); - } - } else { - // Existing account: Navigate back to account list (normal save flow) - // Credentials were changed and validated successfully, so navigate away - this.handleNormalSaveSuccess({ payload: savedAccount }); - } - } else { - this.handlePostSaveValidationFailure(result, savedAccount); - } - }, - error: (error) => { - this.handlePostSaveValidationFailure(error, savedAccount); - } - }); - } - - /** - * Handle post-save validation failure. - * Unwraps error structure if needed and extracts localized error message. - */ - private handlePostSaveValidationFailure(error: any, savedAccount?: PartnerSystemUser): void { - // Unwrap error if it was wrapped by catchError operator - // catchError wraps as: { authSuccess: false, success: false, error: HttpErrorResponse } - const actualError = error?.error instanceof HttpErrorResponse ? error.error : error; - - const errorResult = handlePartnerErr(actualError); - - this.postSaveValidationInProgress = false; - this.postSaveValidationSuccess = false; - this.postSaveValidationError = true; - this.postSaveErrorMessage = errorResult.message; - - // Update satlocIntegration status to show error icon - this.satlocIntegration.status = OperationalStatus.ERROR; - this.satlocError = errorResult.message; - - if (savedAccount) { - this.account = savedAccount; - this.originalUsername = savedAccount.username || ''; - this.originalPassword = savedAccount.password || ''; - this.credentialsChanged = false; - } - } - - /** - * Normal save success handling. - */ - private handleNormalSaveSuccess(action: any): void { - const savedAccount = action.payload; - - // If save was triggered by "Save and Test" dialog, run test connection - if (this.pendingTestAfterSave) { - this.pendingTestAfterSave = false; - this.account = savedAccount; - this._isNew = false; - this.originalUsername = savedAccount.username || ''; - this.originalPassword = savedAccount.password || ''; - this.credentialsChanged = false; - - // Mark username as saved in account editor to prevent "username taken" error - if (this.accountEditor && savedAccount.username) { - this.accountEditor.markUsernameAsSaved(savedAccount.username); - } - - // Trigger test connection after brief delay to ensure state is updated - setTimeout(() => { - this.onTestPartnerConnection(); - }, 200); - return; - } - - if (this.returnTo === 'vehicle-edit') { - this.account = savedAccount; - // Mark username as saved in account editor to prevent "username taken" error - if (this.accountEditor && savedAccount.username) { - this.accountEditor.markUsernameAsSaved(savedAccount.username); - } - this.handleVehicleEditReturnFlow(action.type); - } else { - this.store.dispatch(new userActions.Select(savedAccount)); - this.goBack(); - } - } - - /** - * Clear post-save validation state. - */ - private clearPostSaveValidation(): void { - this.postSaveValidationSuccess = false; - this.postSaveValidationError = false; - this.postSaveErrorMessage = null; - } - - private loadVendorOptions(): void { - this.vendorsLoading = true; - this.form.get(this.VENDOR_SYSTEM_FIELD)?.disable(); - - this.partnerService.getPartners() - .pipe( - catchError(error => { - return of([]); - }), - finalize(() => { - this.vendorsLoading = false; - this.updateVendorFieldState(); - }) - ) - .subscribe((partners: Partner[]) => { - this.partners = partners.filter(p => p.active); - - const partnerVendorOptions = this.partners - .filter(partner => partner.partnerCode) - .map(partner => ({ - label: partner.partnerCode!, - value: partner.partnerCode! - })); - - const satlocOption = { label: $localize`:Satloc vendor option@@satloc:Satloc`, value: SATLOC_VENDOR }; - - const hasSatloc = partnerVendorOptions.some(option => - this.partnerUtils.isSatlocPartner(option.value) - ); - - let partnerLabelOption = null; - if (this.isAccountDoesNotExistFlow) { - const partnerLabel = this.partnerCode || (this.partnerId ? this.getPartnerLabelFromId(this.partnerId) : null); - - if (partnerLabel) { - const hasPartnerLabel = partnerVendorOptions.some(option => - option.value.toLowerCase() === partnerLabel.toLowerCase() - ); - - if (!hasPartnerLabel) { - partnerLabelOption = { - label: partnerLabel, - value: partnerLabel - }; - } - } - } - - this.vendorOptions = [ - { label: Labels.SELECT_PARTNER_SYSTEM, value: '' }, - ...(!hasSatloc ? [satlocOption] : []), - ...(partnerLabelOption ? [partnerLabelOption] : []), - ...partnerVendorOptions - ]; - - this.availableVendorOptions = [...this.vendorOptions]; - this.updateAvailableVendorOptions(); - this.updateSelectedVendorAfterLoading(); - this.loadExistingPartnerSystemUsers(); - }); - } - - /** - * Get partner label (partnerCode) from partner ID - * Used to derive vendor selection for Account Does Not Exist flow - */ - private getPartnerLabelFromId(partnerId: string): string | null { - if (!partnerId || !this.partners) { - return null; - } - - const partner = this.partners.find(p => p._id === partnerId); - return partner?.partnerCode || partner?.name || null; - } - - /** - * Updates selectedVendor after vendors are dynamically loaded - * Handles case-insensitive matching between stored vendor and loaded vendor options - */ - private updateSelectedVendorAfterLoading(): void { - if (!this.account) return; - - const storedVendor = this.getVendorFromAccount(this.account); - - if (storedVendor) { - const matchingVendorOption = this.vendorOptions.find(vendor => - vendor.value && this.partnerUtils.matchesPartnerCode(vendor.value, storedVendor) - ); - - if (matchingVendorOption) { - this.selectedVendor = matchingVendorOption.value; - - this.form.patchValue({ - [VENDOR_SYSTEM_FIELD]: matchingVendorOption.value - }); - - if (!this.isNew && this.selectedVendor && !this.satlocLoading && this.isPartnerSystemUser(this.account)) { - setTimeout(() => { - this.onTestPartnerConnection(); - }, 200); - } - - this.updateAvailableVendorOptions(); - } - } - - if (this.isAccountDoesNotExistFlow) { - const partnerLabel = this.partnerCode || (this.partnerId ? this.getPartnerLabelFromId(this.partnerId) : null); - - if (partnerLabel) { - const matchingVendor = this.vendorOptions.find(vendor => - vendor.value && vendor.value.toLowerCase() === partnerLabel.toLowerCase() - ); - - if (matchingVendor) { - this.selectedVendor = matchingVendor.value; - - if (!this.form.get(VENDOR_SYSTEM_FIELD)?.disabled) { - this.form.patchValue({ - [VENDOR_SYSTEM_FIELD]: matchingVendor.value - }); - } - - this.updateAvailableVendorOptions(); - } - } - } - } - - private loadExistingPartnerSystemUsers(): void { - // PSUs are already in the NgRx store from POST /users/search — read from store - // instead of making N additional GET /api/partners/systemUsers calls. - this.store.select(fromUsers.getAllUsers) - .pipe(take(1)) - .subscribe(users => { - this.existingPartnerSystemUsers = users.filter( - u => u.kind === RoleIds.PARTNER_SYSTEM_USER - ) as PartnerSystemUser[]; - this.updateAvailableVendorOptions(); - }); - } - - private updateAvailableVendorOptions(): void { - // Use partner ObjectId → partnerCode for deduplication (authoritative) - const existingVendorTypes = this.existingPartnerSystemUsers.map(su => { - if (su.partner) { - const rawId = typeof su.partner === 'string' ? su.partner : (su.partner as any)._id; - return this.getPartnerCodeFromPartnerId(rawId); - } - return null; - }).filter(vendor => vendor); - - this.availableVendorOptions = this.vendorOptions.filter(option => { - if (!option.value) return true; - - if (!this.isNew && this.selectedVendor && - this.partnerUtils.matchesPartnerCode(this.selectedVendor, option.value)) return true; - - // Guard: always keep the edited account's own vendor type, even when selectedVendor is falsy - if (!this.isNew && this.account?._id) { - const thisAccount = this.existingPartnerSystemUsers.find(su => su._id === (this.account as any)?._id); - // Use partner ObjectId for this account's own vendor type lookup (authoritative) - let thisAccountVendor: string | null = null; - if (thisAccount?.partner) { - const rawId = typeof thisAccount.partner === 'string' ? thisAccount.partner : (thisAccount.partner as any)._id; - thisAccountVendor = this.getPartnerCodeFromPartnerId(rawId); - } - if (thisAccountVendor && option.value && - this.partnerUtils.matchesPartnerCode(thisAccountVendor, option.value)) { - return true; - } - } - - const isVendorAlreadyExists = existingVendorTypes.some(existingVendor => - existingVendor && option.value && - this.partnerUtils.matchesPartnerCode(existingVendor, option.value) - ); - return !isVendorAlreadyExists; - }); - - this.updateAccountTypeOptions(); - } - - private updateAccountTypeOptions(): void { - const hasAvailableVendors = this.availableVendorOptions.length > 1; - let partnerSystemLabel = Labels.PARTNER_SYSTEM_LABEL; - - // Always enable PARTNER_SYSTEM_USER option, but show constraint when no vendors available - if (!hasAvailableVendors) { - partnerSystemLabel += $localize` (All Partner System configured)`; - } - this.kinds = [ { label: Roles[RoleIds.APP_ADM], value: RoleIds.APP_ADM }, { label: Roles[RoleIds.OFFICER], value: RoleIds.OFFICER }, { label: Roles[RoleIds.INSPECTOR], value: RoleIds.INSPECTOR }, - { - label: partnerSystemLabel, - value: RoleIds.PARTNER_SYSTEM_USER, - disabled: false // Always enabled - } ]; } - saveAccount() { - if (!this.form || !this.form.valid) return; - - const formValue = this.form.getRawValue(); - - if ((formValue.kind === RoleIds.PARTNER_SYSTEM_USER) && - (!formValue[VENDOR_SYSTEM_FIELD] || formValue[VENDOR_SYSTEM_FIELD] === '')) { - this.form.get(VENDOR_SYSTEM_FIELD)?.markAsTouched(); - return; - } - - const userObj = Object.assign( - this.selectedItem, - formValue.profile, - formValue.account, - { kind: formValue.kind } - ); - - if (formValue.kind === RoleIds.PARTNER_SYSTEM_USER) { - const selectedVendorType = formValue[VENDOR_SYSTEM_FIELD]; - - const partnerConfig = { - vendorSystemType: selectedVendorType, - vendorConfiguration: this.buildVendorConfiguration(selectedVendorType) - }; - - const enhancedUserObj = { - ...userObj, - partnerConfig - }; - - const enhancedAction = this._isNew - ? new userActions.Create(enhancedUserObj) - : new userActions.Update(enhancedUserObj); - - this.store.dispatch(enhancedAction); - } else { - const enhancedAction = this._isNew - ? new userActions.Create(userObj) - : new userActions.Update(userObj); - - this.store.dispatch(enhancedAction); - } - } - - private buildVendorConfiguration(vendorType: string): any { - const baseConfig = { - companyId: null, - apiKey: null, - apiSecret: null - }; - - switch (vendorType) { - case SATLOC_VENDOR: - return { - ...baseConfig - }; - default: - return baseConfig; - } - } - - private async handleVehicleEditReturnFlow(actionType?: string): Promise { - if (!this.partnerId || !this.customerId || !this.vehicleId) { - this.goBack(); - return; - } - - if (actionType === userActions.CREATE_SUCCESS) { - this.router.navigate(['/entities/aircraft', this.vehicleId], { - queryParams: { - connectionTestResult: 'success', - message: Labels.PARTNER_ACCOUNT_CREATED_SUCCESSFULLY - } - }); - return; - } - - try { - const account = this.account; - if (!account.username || !account.password) { - throw new Error(Labels.ACCOUNT_MISSING_CREDENTIALS_FOR_CONNECTION_TEST); - } - - const authResult = await this.partnerService.testPartnerAuth( - this.customerId, - this.partnerId, - account.username, - account.password - ).toPromise(); - - // Use centralized success check (handles { ok: true }, { authSuccess: true }, or { success: true }) - if (this.partnerService.isAuthenticationSuccessful(authResult)) { - // Success - navigate back to vehicle-edit - this.router.navigate(['/entities/aircraft', this.vehicleId], { - queryParams: { - connectionTestResult: 'success', - message: Labels.ACCOUNT_AUTHENTICATION_SUCCESSFUL + ngOnInit() { + this.sub$ = this.route.data.subscribe((data) => { + const account = data[0] as User || null; + if (account) { + this._isNew = (account._id === '0'); + if (this.isNew) { + if (!account.kind) { + account.kind = this.kinds[0].value; // Set the default ADMIN user type } - }); - } else { - // Authentication failed - stay on page and show error - const errorResult = handlePartnerErr(authResult); - this.satlocIntegration.status = OperationalStatus.ERROR; - this.satlocError = errorResult.message; + account.parent = this.authSvc.byPUserId; + } + this.account = account; } - } catch (error) { - // Error during auth test - stay on page and show error - const errorResult = handlePartnerErr(error); - this.satlocIntegration.status = OperationalStatus.ERROR; - this.satlocError = errorResult.message; - } + }); + this.sub$.add(this.appActions.ofTypes([userActions.CREATE_SUCCESS, userActions.UPDATE_SUCCESS]) + .subscribe((action) => { + this.store.dispatch(new userActions.Select(action['payload'])); + this.goBack(); + })); + } + + saveAccount() { + if (!this.form || !this.form.value || !this.form.valid) return; + + const userObj = Object.assign(this.selectedItem, this.form.value.profile, this.form.value.account, { kind: this.form.value.kind }); + this.store.dispatch(this._isNew ? new userActions.Create(userObj) : new userActions.Update(userObj)); } goBack() { this.router.navigate(['../', { id: this.account._id }]); } + + ngOnDestroy() { + super.ngOnDestroy(); + } } diff --git a/Development/client/src/app/accounts/account-list/account-list.component.html b/Development/client/src/app/accounts/account-list/account-list.component.html index 3e60849..2912a68 100644 --- a/Development/client/src/app/accounts/account-list/account-list.component.html +++ b/Development/client/src/app/accounts/account-list/account-list.component.html @@ -1,9 +1,8 @@
- Account List @@ -19,33 +18,30 @@
- +
- - - - {{col.header}} - {{ resolveFieldData(rowData, col.field) | userType }} - - {{ resolveFieldData(rowData, col.field) }} + + + {{ acc.name }} + {{ acc.username }} + {{ acc.kind | userType }} + + - + {{ acc.phone }} + {{ acc.email }}
- - - + + +
diff --git a/Development/client/src/app/accounts/account-list/account-list.component.ts b/Development/client/src/app/accounts/account-list/account-list.component.ts index fb263a2..fa7a9a2 100644 --- a/Development/client/src/app/accounts/account-list/account-list.component.ts +++ b/Development/client/src/app/accounts/account-list/account-list.component.ts @@ -7,9 +7,8 @@ import { User } from '../models/user.model'; import * as fromUsers from '../reducers'; import * as userActions from '../actions/account.actions'; -import { RoleIds, globals, OperationalStatus, Labels } from '@app/shared/global'; +import { RoleIds, globals } from '@app/shared/global'; import { BaseComp } from '@app/shared/base/base.component'; -import { Utils } from '@app/shared/utils'; @Component({ @@ -18,11 +17,8 @@ import { Utils } from '@app/shared/utils'; styleUrls: ['./account-list.component.css'] }) export class AccountListComponent extends BaseComp implements OnInit, OnDestroy { - readonly resolveFieldData = Utils.resolveFieldData; - readonly KIND = 'kind'; - readonly ACTIVE = OperationalStatus.ACTIVE; + accounts: Array; - isLoading: boolean; currAcc: User; cols: any[]; userFilter: string; @@ -39,8 +35,8 @@ export class AccountListComponent extends BaseComp implements OnInit, OnDestroy this.cols = [ { field: 'name', header: globals.name, filtered: true, filterMatchMode: 'contains' }, { field: 'username', header: globals.userName, filtered: true, filterMatchMode: 'contains' }, - { field: this.KIND, header: $localize`:@@type:Type`, width: '10%' }, - { field: this.ACTIVE, header: globals.active, width: '6%' }, + { field: 'kind', header: $localize`:@@type:Type`, width: '10%' }, + { field: 'active', header: globals.active, width: '6%' }, { field: 'phone', header: globals.phone + ' ' + $localize`:@@Num:N°`, width: '10%', filtered: true, filterMatchMode: 'contains' }, { field: 'email', header: globals.email, filtered: true, filterMatchMode: 'contains' } ]; @@ -52,10 +48,11 @@ export class AccountListComponent extends BaseComp implements OnInit, OnDestroy ngOnInit() { this.sub$ = this.store.select(fromUsers.getAllUsers).subscribe(users => this.accounts = users); - this.sub$.add(this.store.select(fromUsers.getIsLoading).subscribe(loading => this.isLoading = loading)); + this.sub$.add(this.store.select(fromUsers.getSelectedUser).subscribe( (acc) => this.currAcc = acc )); + // Always fetch the fresh list of accounts this.store.dispatch(new userActions.Fetch()); } @@ -71,13 +68,6 @@ export class AccountListComponent extends BaseComp implements OnInit, OnDestroy return (this.currAcc && this.currAcc._id !== '0'); } - get canDelete() { - // WI-2: Soft lock - Allow deletion of all account types including vendor accounts - // Previously: blocked PARTNER_SYSTEM_USER accounts - // Now: allowed with warning confirmation dialog (see deleteAccount) - return this.canEdit; - } - newAccount() { this.router.navigate(['account', '0'], { relativeTo: this.route }); } @@ -88,19 +78,8 @@ export class AccountListComponent extends BaseComp implements OnInit, OnDestroy deleteAccount() { if (!this.currAcc) { return; } - - // WI-2: Soft lock - Show special warning for vendor accounts - const isVendorAccount = this.currAcc?.kind === RoleIds.PARTNER_SYSTEM_USER; - const message = isVendorAccount - ? Labels.VENDOR_DELETE_CONFIRM_MESSAGE - : globals.confirmDeleteThing.replace('#thing#', globals.account); - const header = isVendorAccount ? Labels.VENDOR_DELETE_CONFIRM_TITLE : undefined; - this.confirmSvc.confirm({ - header: header, - message: message, - acceptLabel: globals.yes, - rejectLabel: globals.no, + message: globals.confirmDeleteThing.replace('#thing#', globals.account), accept: () => { this.store.dispatch(new userActions.Delete(this.currAcc)); this.currAcc = null; diff --git a/Development/client/src/app/accounts/account.module.ts b/Development/client/src/app/accounts/account.module.ts index 02bdaa5..9bb163e 100644 --- a/Development/client/src/app/accounts/account.module.ts +++ b/Development/client/src/app/accounts/account.module.ts @@ -7,7 +7,6 @@ import { CheckboxModule } from 'primeng/checkbox'; import { AutoCompleteModule } from 'primeng/autocomplete'; import { ToolbarModule } from 'primeng/toolbar'; import { InputSwitchModule } from 'primeng/inputswitch'; -import { TooltipModule } from 'primeng/tooltip'; import { TableModule } from 'primeng/table'; import { CalendarModule } from 'primeng/calendar'; @@ -24,7 +23,7 @@ import { AccountEditComponent } from './account-edit/account-edit.component'; import { AccountsGuard } from './account.guard'; import { AccountEffects } from './effects/account.effects'; -import { FEATURE_KEY, reducer } from './reducers/users.reducer'; +import { FEATURE_KEY, reducer } from './reducers/users-reducer'; @NgModule({ imports: [ @@ -38,7 +37,6 @@ import { FEATURE_KEY, reducer } from './reducers/users.reducer'; ToolbarModule, SplitButtonModule, TableModule, - TooltipModule, StoreModule.forFeature(FEATURE_KEY, reducer), EffectsModule.forFeature([AccountEffects]), diff --git a/Development/client/src/app/accounts/actions/account.actions.ts b/Development/client/src/app/accounts/actions/account.actions.ts index dc5d03d..0dc8d95 100644 --- a/Development/client/src/app/accounts/actions/account.actions.ts +++ b/Development/client/src/app/accounts/actions/account.actions.ts @@ -22,12 +22,7 @@ export const CREATE = '[USERS] Create a user'; export class Create implements Action { type: typeof CREATE = CREATE; - constructor(readonly payload: User & { - partnerConfig?: { - vendorSystemType: string; - vendorConfiguration: any; - }; - }) { } + constructor(readonly payload: User) { } } export const CREATE_SUCCESS = '[USERS] Create user success'; export class CreateSuccess implements Action { @@ -44,12 +39,7 @@ export const UPDATE = '[USERS] Update user'; export class Update implements Action { type: typeof UPDATE = UPDATE; - constructor(readonly payload: User & { - partnerConfig?: { - vendorSystemType: string; - vendorConfiguration: any; - }; - }) { } + constructor(readonly payload: User) { } } export const UPDATE_SUCCESS = '[USERS] Update user success'; export class UpdateSuccess implements Action { @@ -59,7 +49,7 @@ export class UpdateSuccess implements Action { } export const UPDATE_FAILED = '[USERS] Update user failed'; export class UpdateFailed implements Action { - type: typeof UPDATE_FAILED = UPDATE_FAILED; + type: typeof UPDATE_FAILED = UPDATE_FAILED; } export const DELETE = '[USERS] Delete user'; diff --git a/Development/client/src/app/accounts/effects/account.effects.ts b/Development/client/src/app/accounts/effects/account.effects.ts index 8b0d904..8de577f 100644 --- a/Development/client/src/app/accounts/effects/account.effects.ts +++ b/Development/client/src/app/accounts/effects/account.effects.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { Actions, Effect, ofType } from '@ngrx/effects'; import { Observable, of } from 'rxjs'; -import { map, switchMap, catchError, repeat } from 'rxjs/operators'; +import { map, switchMap, catchError } from 'rxjs/operators'; import { Action } from '@ngrx/store'; @@ -9,18 +9,15 @@ import * as userActions from '../actions/account.actions'; import { UserService } from '@app/domain/services/user.service'; import { AuthService } from '@app/domain/services/auth.service'; import { AppMessageService } from '@app/shared/app-message.service'; -import { PartnerService } from '@app/partners/services/partner.service'; -import { PartnerSystemUser } from '@app/accounts/models/user.model'; -import { RoleIds, globals, KnownPartnerCodes } from '@app/shared/global'; +import { globals } from '@app/shared/global'; @Injectable() export class AccountEffects { - constructor( + constructor( private readonly actions$: Actions, private readonly userSvc: UserService, private readonly authSvc: AuthService, - private readonly msgSvc: AppMessageService, - private readonly partnerSvc: PartnerService + private readonly msgSvc: AppMessageService ) { } @@ -28,250 +25,55 @@ export class AccountEffects { loadUsers$: Observable = this.actions$.pipe( ofType(userActions.FETCH), switchMap(() => - // All account types (including PARTNER_SYSTEM_USER) are returned by the backend - // /api/users/search endpoint — no separate /api/partners/systemUsers call needed. this.userSvc.loadUsers({ byPuid: this.authSvc.user.parent }).pipe( - map(users => new userActions.FetchSuccess(users)) + map(users => new userActions.FetchSuccess(users)), + catchError(err => { + this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.load).replace('#thing#', globals.accounts)); + return of(new userActions.FetchError()); + }) ) - ), - catchError(err => this.handleUserOperationError(err, 'load')), - repeat() + ) ); @Effect() createUser$: Observable = this.actions$.pipe( ofType(userActions.CREATE), - switchMap(({ payload }) => { - // Extract user data and partner config from payload - const { partnerConfig, ...userData } = payload; - - // For partner system users, create them directly through PartnerService - if (partnerConfig && partnerConfig.vendorSystemType) { - return this.createPartnerSystemUser(userData, partnerConfig); - } - - // For regular users, use UserService directly - return this.userSvc.saveUser(userData).pipe( - map((savedUser) => new userActions.CreateSuccess(savedUser)) - ); - }), - catchError(err => this.handleUserOperationError(err, 'create')), - repeat() + switchMap(({ payload }) => + this.userSvc.saveUser(payload).pipe( + map((user) => new userActions.CreateSuccess(user)), + catchError(err => { + this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.create).replace('#thing#', globals.account)); + return of(new userActions.CreateFailed()) + }) + ) + ) ); @Effect() updateUser$: Observable = this.actions$.pipe( ofType(userActions.UPDATE), - switchMap(({ payload }) => { - // Extract user data and partner config from payload - const { partnerConfig, ...userData } = payload; - - // Case 1: User WITHOUT partner - use UserService directly + cleanup - if (!partnerConfig || !partnerConfig.vendorSystemType) { - return this.userSvc.saveUser(userData).pipe( - switchMap((savedUser) => { - // Clean up any existing partner system users for non-partner accounts - return this.cleanupPartnerSystemUsers(userData._id).pipe( - map(() => new userActions.UpdateSuccess(savedUser)), - catchError(err => { - console.error('Partner cleanup failed:', err); - // User update succeeded, cleanup failed is not critical - return of(new userActions.UpdateSuccess(savedUser)); - }) - ); - }) - ); - } - - // Case 2: User WITH partner - use PartnerService workflow completely - return this.updatePartnerUserWorkflow(userData, partnerConfig).pipe( - map((savedUser) => new userActions.UpdateSuccess(savedUser)) - ); - }), - catchError(err => this.handleUserOperationError(err, 'save')), - repeat() + switchMap(({ payload }) => + this.userSvc.saveUser(payload).pipe( + map(() => new userActions.UpdateSuccess(payload)), + catchError(err => { + this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.save).replace('#thing#', globals.account)); + return of(new userActions.UpdateFailed()); + }) + ) + ) ); @Effect() deleteUser$: Observable = this.actions$.pipe( ofType(userActions.DELETE), - switchMap(({ payload }) => { - // Check if the user is a PARTNER_SYSTEM_USER - if (payload.kind === RoleIds.PARTNER_SYSTEM_USER) { - // Backend only disables partner system users (sets active=false), it does NOT remove them. - // Dispatch UpdateSuccess so the store reflects the disabled state in-place rather than - // removing the row — which would cause it to reappear on the next reload. - return this.partnerSvc.deleteSystemUser(payload._id).pipe( - map(() => new userActions.UpdateSuccess({ ...payload, active: false })) - ); - } else { - // Use UserService for regular users - return this.userSvc.deleteUser(payload).pipe( - map(() => new userActions.DeleteSuccess(payload)) - ); - } - }), - catchError(err => this.handleUserOperationError(err, 'delete')), - repeat() + switchMap(({ payload }) => + this.userSvc.deleteUser(payload).pipe( + map(() => new userActions.DeleteSuccess(payload)), + catchError(err => { + this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.delete).replace('#thing#', globals.account)); + return of(new userActions.UpdateFailed()) + }) + ) + ) ); - - // Partner user workflow methods - use PartnerService exclusively - private createPartnerSystemUser(userData: any, partnerConfig: any): Observable { - // Get partner ID based on vendor type - return this.getPartnerByVendorType(partnerConfig.vendorSystemType).pipe( - switchMap(partnerId => { - if (!partnerId) { - throw new Error(`Failed to get partner for vendor type: ${partnerConfig.vendorSystemType}`); - } - - // Create vendor-specific system user data - const createData = this.buildPartnerSystemUserData(userData, partnerConfig, partnerId); - - return this.partnerSvc.createSystemUser(createData).pipe( - map((systemUser) => { - // ✅ FIX: Return the created system user with customerId/partnerId for post-save validation - // Merge the saved systemUser data with original userData to preserve all fields - return new userActions.CreateSuccess({ - ...userData, - ...systemUser, - // Ensure we have the IDs for post-save validation - customer: systemUser.customer || createData.customerId, - partner: systemUser.partner || createData.partnerId - }); - }) - ); - }) - ); - } - - private updatePartnerUserWorkflow(userData: any, partnerConfig: any): Observable { - // Use getSystemUserById to directly fetch the partner system user - return this.partnerSvc.getSystemUserById(userData._id).pipe( - switchMap(existingSystemUser => { - if (existingSystemUser) { - // Update existing partner system user with backend-compatible structure - const updateData = this.buildPartnerSystemUserData(userData, partnerConfig, existingSystemUser.partner._id); - - return this.partnerSvc.updateSystemUser(existingSystemUser._id!, updateData).pipe( - map(() => userData) // Return the user data - ); - } else { - // Partner system user doesn't exist, return error - throw new Error('Partner system user not found for update'); - } - }) - ); - } - - /** - * Build partner system user data structure based on vendor type - * This method can be extended to support additional vendors - */ - private buildPartnerSystemUserData(userData: any, partnerConfig: any, partnerId: string): any { - return { - partnerId: partnerId, - customerId: userData.parent, // AgMission customer (main applicator account) - username: userData.username, - password: userData.password, - name: userData.name, - active: userData.active, - email: userData.email, - address: userData.address, - phone: userData.phone, - companyId: partnerConfig.vendorConfiguration.companyId || null, - apiKey: partnerConfig.vendorConfiguration.apiKey || null, - apiSecret: partnerConfig.vendorConfiguration.apiSecret || null - // NOTE: metadata intentionally omitted — partner identity is carried by - // partnerId (ObjectId). metadata.vendor was a fragile frontend-derived - // copy that could silently diverge from the partner document. - }; - } - - /** - * Get partner ID by vendor type - * This method can be extended to support additional vendors - */ - private getPartnerByVendorType(vendorType: string): Observable { - return this.partnerSvc.getPartners().pipe( - map((partners: any[]) => { - let partner = null; - - switch (vendorType) { - case KnownPartnerCodes.SATLOC: - partner = partners.find(p => - p.partnerCode === KnownPartnerCodes.SATLOC.toUpperCase() || - p.name?.toLowerCase().includes(KnownPartnerCodes.SATLOC) - ); - break; - - // Add additional vendors here as needed - // case 'other_vendor': - // partner = partners.find(p => - // p.partnerCode === 'OTHER_VENDOR' || - // p.name?.toLowerCase().includes('other_vendor') - // ); - // break; - - default: - // Fallback: try to find partner by name or code matching vendor type - partner = partners.find(p => - p.partnerCode?.toLowerCase() === vendorType.toLowerCase() || - p.name?.toLowerCase().includes(vendorType.toLowerCase()) - ); - break; - } - - return partner ? partner._id : null; - }), - catchError(() => of(null)) - ); - } - - private cleanupPartnerSystemUsers(userId: string): Observable { - return this.partnerSvc.getSystemUsersForCustomer(userId).pipe( - switchMap((systemUsers: PartnerSystemUser[]) => { - if (systemUsers.length === 0) { - return of(null); - } - - // Delete all system users for this customer - const deleteOperations = systemUsers.map(systemUser => - this.partnerSvc.deleteSystemUser(systemUser._id!).pipe( - catchError(error => { - console.error('Failed to delete partner system user:', error); - return of(null); - }) - ) - ); - - // Wait for all delete operations to complete - return of(...deleteOperations); - }), - catchError(error => { - console.error('Failed to load partner system users for cleanup:', error); - return of(null); - }) - ); - } - - // Centralized error handler for user operations following subscription.effects pattern - private handleUserOperationError(err: any, operation: 'create' | 'save' | 'delete' | 'load'): Observable { - const actionVerb = operation === 'create' ? globals.create : - operation === 'save' ? globals.save : - operation === 'delete' ? globals.delete : globals.load; - - // For load operation, use 'accounts' (plural), for others use 'account' (singular) - const thingName = operation === 'load' ? globals.accounts : globals.account; - this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', actionVerb).replace('#thing#', thingName)); - - if (operation === 'create') { - return of(new userActions.CreateFailed()); - } else if (operation === 'save') { - return of(new userActions.UpdateFailed()); - } else if (operation === 'delete') { - return of(new userActions.UpdateFailed()); // Note: There's no DeleteFailed action, using UpdateFailed - } else { - return of(new userActions.FetchError()); - } - } } diff --git a/Development/client/src/app/accounts/models/user.model.ts b/Development/client/src/app/accounts/models/user.model.ts index 5eec52d..6cfc0e9 100644 --- a/Development/client/src/app/accounts/models/user.model.ts +++ b/Development/client/src/app/accounts/models/user.model.ts @@ -1,5 +1,4 @@ -import { Address } from '@app/domain/models/subscription.model'; -import { RoleIds, OperationalStatusType } from '@app/shared/global'; +import { RoleIds } from '@app/shared/global'; interface RoleArray { [index: number]: string; @@ -10,106 +9,24 @@ export interface User { username?: string; password?: string; name?: string; - address?: string | null; + address?: string; country?: string; - phone?: string | null; - email?: string | null; + Country?: any; + phone?: string; + email?: string; kind: string; roles?: RoleArray; active?: boolean; createdAt?: Date; - updatedAt?: Date; parent?: any; - contact?: string; - addresses?: Address[]; - billAddress?; - needReview?: boolean; - - // Optional partner system fields (present for partner system users) - customer?: string | { _id: string; username: string; name: string; kind: string; }; - partner?: string | { _id: string; name: string; kind: string; }; -} - -// PartnerSystemUser extends User with partner-specific fields -export interface PartnerSystemUser extends User { - // Partner relationships (populated objects from backend via .populate()) - // NOTE: backend uses .lean() so the 'customer' Mongoose virtual is NOT present. - // 'parent' is populated as { _id, username, name, kind } in API responses. - partner: { - _id: string; - name: string; - partnerCode?: string; - kind: string; - }; - // 'customer' virtual from Mongoose is NOT returned by .lean(). Use 'parent' instead. - customer?: { - _id: string; - username: string; - name: string; - kind: string; - }; - - // Partner system credentials - partnerUserId?: string; // User ID in partner system - partnerUsername?: string; // Username in partner system - companyId?: string | null; // Company ID in partner system - - // Access credentials (encrypted in production) - apiKey?: string | null; - apiSecret?: string | null; - - // Status and metadata - lastLoginAt?: Date; - lastSyncAt?: Date; - syncStatus?: OperationalStatusType; - - // Partner-specific metadata (contains vendor config) - metadata?: { - vendor?: string; - satlocUrl?: string; - satlocUsername?: string; - satlocPassword?: string; - [key: string]: any; - }; - - // Additional fields from backend response - address?: string | null; - email?: string | null; - phone?: string | null; -} - -export interface SatlocConnectionResult { - success: boolean; - message?: string; - error?: string; - connectionTime?: number; - serverInfo?: { - version?: string; - capabilities?: string[]; - }; - account_info?: SatlocAccountInfo; -} - -export interface SatlocAccountInfo { - company_name: string; - aircraft_count: number; - api_version: string; -} - -export interface SatlocIntegration { - enabled: boolean; - status: OperationalStatusType; - account_info: SatlocAccountInfo | null; - credentials_stored: boolean; - last_error: string | null; } export const createNewUser = (parentId?: string, kind: String = RoleIds.APP_ADM) => { const user = { _id: '0', kind: kind, - active: kind == RoleIds.DEVICE ? false : true, + active: true, parent: parentId }; return user; -} +} \ No newline at end of file diff --git a/Development/client/src/app/accounts/reducers/index.ts b/Development/client/src/app/accounts/reducers/index.ts index e2726ca..853ca9d 100644 --- a/Development/client/src/app/accounts/reducers/index.ts +++ b/Development/client/src/app/accounts/reducers/index.ts @@ -3,7 +3,7 @@ import { createFeatureSelector, } from '@ngrx/store'; -import * as fromUsers from './users.reducer'; +import * as fromUsers from './users-reducer'; /** * The createFeatureSelector function selects a piece of state from the root of the state object. diff --git a/Development/client/src/app/accounts/reducers/users.reducer.ts b/Development/client/src/app/accounts/reducers/users-reducer.ts similarity index 91% rename from Development/client/src/app/accounts/reducers/users.reducer.ts rename to Development/client/src/app/accounts/reducers/users-reducer.ts index 6370b80..cd8d26f 100644 --- a/Development/client/src/app/accounts/reducers/users.reducer.ts +++ b/Development/client/src/app/accounts/reducers/users-reducer.ts @@ -28,9 +28,6 @@ export function reducer( switch (action.type) { case actions.FETCH: - // Clear stale entities immediately so the list never shows old data while loading. - return adapter.removeAll({ ...state, loading: true }); - case actions.CREATE: case actions.UPDATE: case actions.DELETE: diff --git a/Development/client/src/app/actions/sub-plans.actions.ts b/Development/client/src/app/actions/sub-plans.actions.ts deleted file mode 100644 index df79b80..0000000 --- a/Development/client/src/app/actions/sub-plans.actions.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Plan, Status } from "@app/domain/models/subscription.model"; -import { Action } from "@ngrx/store"; - -export const FETCH_SUB_PLANS = '[SUB_PLANS] Fetch subscription plans'; -export class FetchSubPlans implements Action { - type: typeof FETCH_SUB_PLANS = FETCH_SUB_PLANS; - constructor() { } -} - -export const FETCH_SUB_PLANS_SUCCESS = '[SUB_PLANS] Fetch subscription plans success'; -export class FetchSubPlansSuccess implements Action { - type: typeof FETCH_SUB_PLANS_SUCCESS = FETCH_SUB_PLANS_SUCCESS; - constructor(readonly payload: Plan) { } -} - -export const FETCH_SUB_PLANS_FAILED = '[SUB_PLANS] Fetch subscription plans failed'; -export class FetchSubPlansFailed implements Action { - type: typeof FETCH_SUB_PLANS_FAILED = FETCH_SUB_PLANS_FAILED; - constructor(readonly payload: Status) { } -} - -export const RESET_SUB_PLANS = '[SUB_PLANS] Reset subscription plans'; -export class ResetSubPlans implements Action { - type: typeof RESET_SUB_PLANS = RESET_SUB_PLANS; - constructor() { } -} - -export type SubPlansAction = - | FetchSubPlans - | FetchSubPlansSuccess - | FetchSubPlansFailed - | ResetSubPlans diff --git a/Development/client/src/app/actions/subscription.actions.ts b/Development/client/src/app/actions/subscription.actions.ts deleted file mode 100644 index ddc7a13..0000000 --- a/Development/client/src/app/actions/subscription.actions.ts +++ /dev/null @@ -1,561 +0,0 @@ -import { UserModel } from '@app/auth/models/user.model'; -import { BillingInfo, Card, Invoice, StripeSubscription, SubscriptionPackage, RefreshPackage, CreatePaymentMethodPackage, Status, UnpaidPackage, PastDue, Unpaid, SubscriptionIntent, Incomplete, ConfirmPackage, PaidAmount, Coupon, TrialPmtPkg, Trial, PaymentMethod, PMPkgEdit, PMPkgAdd, Plan } from '@app/domain/models/subscription.model'; -import { Mode } from '@app/profile/common'; -import { Action } from '@ngrx/store'; - -// shared actions -export const GOTO_MY_SERVICES = '[SUBSCRIPTION Nav] Navigate to my services page'; -export class GotoMyServices implements Action { - type: typeof GOTO_MY_SERVICES = GOTO_MY_SERVICES; -} - -export const GOTO_PAYMENT_HISTORY = '[SUBSCRIPTION Nav] Navigate to payment history page'; -export class GotoPaymentHistory implements Action { - type: typeof GOTO_PAYMENT_HISTORY = GOTO_PAYMENT_HISTORY; -} - -export const GOTO_PAYMENT_DETAIL = '[SUBSCRIPTION Nav] Navigate to payment detail page'; -export class GotoPaymentDetail implements Action { - type: typeof GOTO_PAYMENT_DETAIL = GOTO_PAYMENT_DETAIL; - constructor(readonly payload: { paymentId: string }) { } -} - -export const GOTO_SERVICES = '[SUBSCRIPTION Nav] Navigate to services page'; -export class GotoServices implements Action { - type: typeof GOTO_SERVICES = GOTO_SERVICES; -} - -export const GOTO_BILLING_ADDRESS = '[SUBSCRIPTION Nav] Navigate to billing address page'; -export class GotoBillingAddress implements Action { - type: typeof GOTO_BILLING_ADDRESS = GOTO_BILLING_ADDRESS; -} - -export const GOTO_CHECK_OUT = '[SUBSCRIPTION Nav] Navigate to checkout page'; -export class GotoCheckout implements Action { - type: typeof GOTO_CHECK_OUT = GOTO_CHECK_OUT; -} - -export const GOTO_CHECK_OUT_REVIEW = '[SUBSCRIPTION Nav] Navigate to checkout review page'; -export class GotoCheckoutReview implements Action { - type: typeof GOTO_CHECK_OUT_REVIEW = GOTO_CHECK_OUT_REVIEW; -} - -export const GOTO_CHECK_OUT_CONFIRM = '[SUBSCRIPTION Nav] Navigate to checkout confirm page'; -export class GotoCheckoutConfirm implements Action { - type: typeof GOTO_CHECK_OUT_CONFIRM = GOTO_CHECK_OUT_CONFIRM; -} - -export const GOTO_HOME = '[SUBSCRIPTION Nav] Navigate to home page'; -export class GotoHome implements Action { - type: typeof GOTO_HOME = GOTO_HOME; -} - -export const GOTO_USAGE_DETAIL = '[SUBSCRIPTION Nav] Navigate to usage details'; -export class GotoUsageDetail implements Action { - type: typeof GOTO_USAGE_DETAIL = GOTO_USAGE_DETAIL; -} - -export const GOTO_AIRCRAFT_LIST = '[SUBSCRIPTION Nav] Navigate to aircraft list'; -export class GotoAircraftList implements Action { - type: typeof GOTO_AIRCRAFT_LIST = GOTO_AIRCRAFT_LIST; -} - -export const COMPOUND = '[SUBSCRIPTION Compound] Execute multiple actions sequentially'; -export class Compound implements Action { - type: typeof COMPOUND = COMPOUND; - constructor(readonly payload: Action[]) { } -} - -// In session actions -export const INIT_SUBSCRIPTION = '[SUBSCRIPTION Manage subscription] Initialize subscriptions contents'; -export class InitSubscription implements Action { - type: typeof INIT_SUBSCRIPTION = INIT_SUBSCRIPTION; - constructor(readonly payload: { custId: string }) { } -} - -export const START_MY_SERVICES = '[SUBSCRIPTION Making payment intent session] Initialize my services stage'; -export class StartMyServices implements Action { - type: typeof START_MY_SERVICES = START_MY_SERVICES; - constructor(readonly payload: { custId: string }) { } -} - -export const CREATE_NEW_BILLING = '[SUBSCRIPTION Making payment intent session] Initialize subscription intent with a new account'; -export class CreateNewBilling implements Action { - type: typeof CREATE_NEW_BILLING = CREATE_NEW_BILLING; - constructor(readonly payload: SubscriptionIntent) { } -} - -export const START_BILLING_INFO = '[SUBSCRIPTION Making payment intent session] Start billing info stage'; -export class StartBillingInfo implements Action { - type: typeof START_BILLING_INFO = START_BILLING_INFO; - constructor(readonly payload: any) { } -} - -export const START_BILLING_INFO_SUCCESS = '[SUBSCRIPTION Making payment intent session] Start billing info stage, success'; -export class StartBillingInfoSuccess implements Action { - type: typeof START_BILLING_INFO_SUCCESS = START_BILLING_INFO_SUCCESS; - constructor(readonly payload: SubscriptionIntent) { } -} - -export const CREATE_SUBSCRIPTION_INTENT_FAILED = '[SUBSCRIPTION Making payment intent session] Create subscription intent failed'; -export class CreateSubscriptionIntentFailed implements Action { - type: typeof CREATE_SUBSCRIPTION_INTENT_FAILED = CREATE_SUBSCRIPTION_INTENT_FAILED; - constructor(readonly payload: Status) { } -} - -export const START_CHECKOUT = '[SUBSCRIPTION Making payment intent session] Start checkout stage'; -export class StartCheckout implements Action { - type: typeof START_CHECKOUT = START_CHECKOUT; - constructor(readonly payload: { billingInfo: BillingInfo; subIntentPkg: SubscriptionIntent }) { } -} - -export const START_CHECKOUT_SUCCESS = '[SUBSCRIPTION Making payment intent session] Start checkout stage success'; -export class StartCheckoutSuccess implements Action { - type: typeof START_CHECKOUT_SUCCESS = START_CHECKOUT_SUCCESS; - constructor(readonly payload: SubscriptionIntent) { } -} - -export const UPDATE_BILLING_ADDRESS_SUCCESS = '[SUBSCRIPTION Making payment intent session] Update billing address success'; -export class UpdateBillingAddressSuccess implements Action { - type: typeof UPDATE_BILLING_ADDRESS_SUCCESS = UPDATE_BILLING_ADDRESS_SUCCESS; - constructor(readonly payload: BillingInfo) { } -} - -export const CREATE_PAYMENT_METHOD = '[SUBSCRIPTION Making payment intent session] Create payment method'; -export class CreatePaymentMethod implements Action { - type: typeof CREATE_PAYMENT_METHOD = CREATE_PAYMENT_METHOD; - constructor(readonly payload: CreatePaymentMethodPackage) { } -} - -export const CREATE_PAYMENT_METHOD_FAILED = '[SUBSCRIPTION Making payment intent session] Create payment method failed'; -export class CreatePaymentMethodFailed implements Action { - type: typeof CREATE_PAYMENT_METHOD_FAILED = CREATE_PAYMENT_METHOD_FAILED; - constructor(readonly payload: Status) { } -} - -export const CHECK_OUT = '[SUBSCRIPTION Making payment intent session] Checkout'; -export class Checkout implements Action { - type: typeof CHECK_OUT = CHECK_OUT; - constructor(readonly payload: Card) { } -} - -export const UPDATE_SUBSCRIPTION = '[SUBSCRIPTION Making payment intent session] Update subscription'; -export class UpdateSubscription implements Action { - type: typeof UPDATE_SUBSCRIPTION = UPDATE_SUBSCRIPTION; - constructor(readonly payload: SubscriptionPackage) { } -} - -export const CANCEL_SUBSCRIPTION = '[SUBSCRIPTION Making payment intent session] Cancel subscription attempt'; -export class CancelSubscription implements Action { - type: typeof CANCEL_SUBSCRIPTION = CANCEL_SUBSCRIPTION; - constructor(readonly payload: SubscriptionIntent) { } -} - -export const SET_SUBSCRIPTION_INTENT_PREV_STAGE = '[SUBSCRIPTION Making payment intent session] Set subscription intent previous stage'; -export class SetSubscriptionIntentPrevStage implements Action { - type: typeof SET_SUBSCRIPTION_INTENT_PREV_STAGE = SET_SUBSCRIPTION_INTENT_PREV_STAGE; - constructor(readonly payload: string) { } -} - -export const UPDATE_SUBSCRIPTION_INTENT_STATUS = '[SUBSCRIPTION Making payment intent session] Update subscription intent status'; -export class UpdateSubscriptionIntentStatus implements Action { - type: typeof UPDATE_SUBSCRIPTION_INTENT_STATUS = UPDATE_SUBSCRIPTION_INTENT_STATUS; - constructor(readonly payload: Status) { } -} - -export const CLEAR_SUBSCRIPTION_INTENT_STATUS = '[SUBSCRIPTION Making payment intent session] Clear subscription intent status'; -export class ClearSubscriptionIntentStatus implements Action { - type: typeof CLEAR_SUBSCRIPTION_INTENT_STATUS = CLEAR_SUBSCRIPTION_INTENT_STATUS; - constructor() { } -} - -export const RESET_SUBSCRIPTION_INTENT = '[SUBSCRIPTION Making payment intent session] Reset subscription intent to initial state'; -export class ResetSubscriptionIntent implements Action { - type: typeof RESET_SUBSCRIPTION_INTENT = RESET_SUBSCRIPTION_INTENT; -} - -export const CLEAR_PREV_STAGE = '[SUBSCRIPTION Making payment intent session] Set up intent default subIntent stage'; -export class ClearPrevStage implements Action { - type: typeof CLEAR_PREV_STAGE = CLEAR_PREV_STAGE; -} - -export const UPDATE_AMOUNT = '[SUBSCRIPTION Making payment intent session] Update payment amount'; -export class UpdateAmount implements Action { - type: typeof UPDATE_AMOUNT = UPDATE_AMOUNT; - constructor(readonly payload: PaidAmount) { } -} - -export const UPDATE_PROMO_SAVINGS = '[SUBSCRIPTION Making payment intent session] Update promo savings'; -export class UpdatePromoSavings implements Action { - type: typeof UPDATE_PROMO_SAVINGS = UPDATE_PROMO_SAVINGS; - constructor(readonly payload: number) { } -} - -// Resolving payment session actions -export const FETCH_LATEST_SUBSCRIPTION = '[SUBSCRIPTION Resolving payment session] Fetch latest subscription'; -export class FetchLatestSubscription implements Action { - type: typeof FETCH_LATEST_SUBSCRIPTION = FETCH_LATEST_SUBSCRIPTION; - constructor(readonly payload: { custId: string }) { } -} - -export const FETCH_LATEST_SUBSCRIPTION_SUCCESS = '[SUBSCRIPTION Resolving payment session] Fetch latest subscription success'; -export class FetchLatestSubscriptionSuccess implements Action { - type: typeof FETCH_LATEST_SUBSCRIPTION_SUCCESS = FETCH_LATEST_SUBSCRIPTION_SUCCESS; - constructor(readonly payload: Plan) { } -} - -export const POLL_UNPAID_SUBSCRIPTION = '[SUBSCRIPTION Resolving payment session] Poll for unpaid subscription'; -export class PollUnpaidSubscription implements Action { - type: typeof POLL_UNPAID_SUBSCRIPTION = POLL_UNPAID_SUBSCRIPTION; - constructor(readonly payload: { custId: string }) { } -} - -export const POLL_UNPAID_SUBSCRIPTION_SUCCESS = '[SUBSCRIPTION Resolving payment session] Poll for unpaid success'; -export class PollUnpaidSubscriptionSuccess implements Action { - type: typeof POLL_UNPAID_SUBSCRIPTION_SUCCESS = POLL_UNPAID_SUBSCRIPTION_SUCCESS; - constructor(readonly payload: Plan) { } -} - -export const CANCEL_POLL_SUBSCRIPTION = '[SUBSCRIPTION Resolving payment session] Cancel polling for latest subscriptions'; -export class CancelPollSubscription implements Action { - type: typeof CANCEL_POLL_SUBSCRIPTION = CANCEL_POLL_SUBSCRIPTION; - constructor() { } -} - -export const RESET_SUBSCRIPTION = '[SUBSCRIPTION Resolving payment session] Reset subscription to initial state'; -export class ResetSubscription implements Action { - type: typeof RESET_SUBSCRIPTION = RESET_SUBSCRIPTION; -} - -export const REFRESH_SUBSCRIPTION_INTENT = '[SUBSCRIPTION Resolving payment session] Refesh unresolved subscription'; -export class RefeshSubscriptionIntent implements Action { - type: typeof REFRESH_SUBSCRIPTION_INTENT = REFRESH_SUBSCRIPTION_INTENT; - constructor(readonly payload: RefreshPackage) { } -} - -export const REFRESH_SUBSCRIPTION_INTENT_SUCCESS = '[SUBSCRIPTION Resolving payment session] Refesh unresolved subscription success'; -export class RefeshSubscriptionIntentSuccess implements Action { - type: typeof REFRESH_SUBSCRIPTION_INTENT_SUCCESS = REFRESH_SUBSCRIPTION_INTENT_SUCCESS; - constructor(readonly payload: SubscriptionIntent) { } -} - -export const REQUIRE_ACTION = '[SUBSCRIPTION Resolving payment session] Require action on payment method'; -export class RequireAction implements Action { - type: typeof REQUIRE_ACTION = REQUIRE_ACTION; - constructor(readonly payload: ConfirmPackage[]) { } -} - -export const RESOLVE_PAYMENT = '[SUBSCRIPTION Resolving payment session] Resolve payment outstanding subsriptions'; -export class ResolvePayment implements Action { - type: typeof RESOLVE_PAYMENT = RESOLVE_PAYMENT; -} - -export const SHOW_UNPAID_SUBSCRIPTION = '[SUBSCRIPTION Resolving payment session] Show unpaid subscription list'; -export class ShowUnpaidSubscription implements Action { - type: typeof SHOW_UNPAID_SUBSCRIPTION = SHOW_UNPAID_SUBSCRIPTION; - constructor() { } -} - -export const RESUME_UNPAID_SUBSCRIPTION = '[SUBSCRIPTION Resolving payment session] Fetch unpaid subscriptions'; -export class ResumeUnpaidSubscription implements Action { - type: typeof RESUME_UNPAID_SUBSCRIPTION = RESUME_UNPAID_SUBSCRIPTION; - constructor(readonly payload: { - unpaidInvoices: Invoice[], - name: string, - authUser: UserModel - }) { } -} - -export const RESUME_UNPAID_SUBSCRIPTION_SUCCESS = '[SUBSCRIPTION Resolving payment session] Fetch unpaid subscriptions success'; -export class ResumeUnpaidSubscriptionSuccess implements Action { - type: typeof RESUME_UNPAID_SUBSCRIPTION_SUCCESS = RESUME_UNPAID_SUBSCRIPTION_SUCCESS; - constructor(readonly payload: { - unpaid: Unpaid, - subscriptions: StripeSubscription[], - status: Status - }) { } -} - -export const PAY_UNPAID_SUBSCRIPTION = '[SUBSCRIPTION Resolving payment session] Pay unpaid subscription'; -export class PayUnpaidSubscription implements Action { - type: typeof PAY_UNPAID_SUBSCRIPTION = PAY_UNPAID_SUBSCRIPTION; - constructor(readonly payload: UnpaidPackage) { } -} - -export const CONFIRM = '[SUBSCRIPTION Resolving payment session] Confirm card payment for incomplete and pastdue payments'; -export class Confirm implements Action { - type: typeof CONFIRM = CONFIRM; - constructor(readonly payload: ConfirmPackage) { } -} - -export const COMPLETE_PAYMENT = '[SUBSCRIPTION Resolving payment session] Completed resolving unresolved invoices'; -export class CompletePayment implements Action { - type: typeof COMPLETE_PAYMENT = COMPLETE_PAYMENT; -} - -export const UPDATE_PAST_DUE = '[SUBSCRIPTION Resolving payment session] Retry past due payment'; -export class UpdatePastDue implements Action { - type: typeof UPDATE_PAST_DUE = UPDATE_PAST_DUE; - constructor(readonly payload: PastDue) { } -} - -export const UPDATE_INCOMPLETE = '[SUBSCRIPTION Resolving payment session] Retry incomplete Payment'; -export class UpdateIncomplete implements Action { - type: typeof UPDATE_INCOMPLETE = UPDATE_INCOMPLETE; - constructor(readonly payload: Incomplete) { } -} - -export const UPDATE_UNPAID = '[SUBSCRIPTION Resolving payment session] Update unpaid'; -export class UpdateUnpaid implements Action { - type: typeof UPDATE_UNPAID = UPDATE_UNPAID; - constructor(readonly payload: Unpaid) { } -} - -export const UPDATE_SUBSCRIPTION_STATUS = '[SUBSCRIPTION] Update subscriptionstatus'; -export class UpdateSubscriptionStatus implements Action { - type: typeof UPDATE_SUBSCRIPTION_STATUS = UPDATE_SUBSCRIPTION_STATUS; - constructor(readonly payload: Status) { } -} - -export const CLEAR_SUBSCRIPTION_STATUS = '[SUBSCRIPTION] Clear subscriptionstatus'; -export class ClearSubscriptionStatus implements Action { - type: typeof CLEAR_SUBSCRIPTION_STATUS = CLEAR_SUBSCRIPTION_STATUS; - constructor() { } -} - -export const CLEAR_SUBSCRIPTION = '[SUBSCRIPTION] Clear subscriptions'; -export class ClearSubscription implements Action { - type: typeof CLEAR_SUBSCRIPTION = CLEAR_SUBSCRIPTION; - constructor() { } -} - -export const LOAD_STRIPE = '[SUBSCRIPTION] load stripe api'; -export class LoadStripe implements Action { - type: typeof LOAD_STRIPE = LOAD_STRIPE; - constructor() { } -} - -export const LOAD_STRIPE_SUCCESS = '[SUBSCRIPTION] load stripe api success'; -export class LoadStripeSuccess implements Action { - type: typeof LOAD_STRIPE_SUCCESS = LOAD_STRIPE_SUCCESS; - constructor() { } -} - -export const LOAD_STRIPE_FAILED = '[SUBSCRIPTION] load stripe api failed'; -export class LoadStripeFailed implements Action { - type: typeof LOAD_STRIPE_FAILED = LOAD_STRIPE_FAILED; - constructor(readonly payload: Status) { } -} - -export const APPLY_DISCOUNT_PREVIEW = '[SUBSCRIPTION] Apply discount preview'; -export class ApplyDiscountPreview implements Action { - type: typeof APPLY_DISCOUNT_PREVIEW = APPLY_DISCOUNT_PREVIEW; - constructor(readonly payload: { subIntentPkg: SubscriptionIntent, coupon?: string }) { } -} - -export const APPLY_DISCOUNT_PREVIEW_SUCCESS = '[SUBSCRIPTION] Apply discount preview success'; -export class ApplyDiscountPreviewSuccess implements Action { - type: typeof APPLY_DISCOUNT_PREVIEW_SUCCESS = APPLY_DISCOUNT_PREVIEW_SUCCESS; - constructor(readonly payload: { coupons: Coupon[], amount: PaidAmount }) { } -} - -export const APPLY_DISCOUNT_PREVIEW_FAILED = '[SUBSCRIPTION] Apply discount preview failed'; -export class ApplyDiscountPreviewFailed implements Action { - type: typeof APPLY_DISCOUNT_PREVIEW_FAILED = APPLY_DISCOUNT_PREVIEW_FAILED; - constructor(readonly payload: Status) { } -} - - -// Payment method actions -export const FETCH_PAYMENT_METHOD_LIST = '[SUBSCRIPTION] Fetch payment methods'; -export class FetchPaymentMethodList implements Action { - type: typeof FETCH_PAYMENT_METHOD_LIST = FETCH_PAYMENT_METHOD_LIST; -} - -export const FETCH_PAYMENT_METHOD_LIST_SUCCESS = '[SUBSCRIPTION] Fetch payment methods success'; -export class FetchPaymentMethodListSuccess implements Action { - type: typeof FETCH_PAYMENT_METHOD_LIST_SUCCESS = FETCH_PAYMENT_METHOD_LIST_SUCCESS; - constructor(readonly payload: { paymentMethods: PaymentMethod[] }) { } -} - -export const FETCH_DEFAULT_PM = '[SUBSCRIPTION] Fetch default payment methods'; -export class FetchDefaultPm implements Action { - type: typeof FETCH_DEFAULT_PM = FETCH_DEFAULT_PM; -} - -export const FETCH_DEFAULT_PM_SUCCESS = '[SUBSCRIPTION] Fetch default payment methods success'; -export class FetchDefaultPmSuccess implements Action { - type: typeof FETCH_DEFAULT_PM_SUCCESS = FETCH_DEFAULT_PM_SUCCESS; - constructor(readonly payload: { defPM: PaymentMethod }) { } -} - -export const EDIT_PM = '[SUBSCRIPTION] Edit payment method'; -export class EditPM implements Action { - type: typeof EDIT_PM = EDIT_PM; - constructor(readonly payload: PMPkgEdit) { } -} - -export const EDIT_PM_SUCCESS = '[SUBSCRIPTION] Edit payment method success'; -export class EditPMSuccess implements Action { - type: typeof EDIT_PM_SUCCESS = EDIT_PM_SUCCESS; - constructor(readonly payload: PaymentMethod) { } -} - -export const ADD_PM = '[SUBSCRIPTION] Add payment method'; -export class AddPM implements Action { - type: typeof ADD_PM = ADD_PM; - constructor(readonly payload: PMPkgAdd) { } -} - -export const ADD_PM_SUCCESS = '[SUBSCRIPTION] Add payment method success'; -export class AddPMSuccess implements Action { - type: typeof ADD_PM_SUCCESS = ADD_PM_SUCCESS; - constructor(readonly payload: PaymentMethod) { } -} - -export const DELETE_PM = '[SUBSCRIPTION] Delete payment method'; -export class DeletePM implements Action { - type: typeof DELETE_PM = DELETE_PM; - constructor(readonly payload: string) { } -} - -export const DELETE_PM_SUCCESS = '[SUBSCRIPTION] Delete payment method success'; -export class DeletePMSuccess implements Action { - type: typeof DELETE_PM_SUCCESS = DELETE_PM_SUCCESS; - constructor(readonly payload: string) { } -} - -export const CHANGE_PM = '[SUBSCRIPTION] Change default payment method'; -export class ChangePM implements Action { - type: typeof CHANGE_PM = CHANGE_PM; - constructor(readonly payload: { custId: string, pmId: string }) { } -} - -export const CHANGE_PM_SUCCESS = '[SUBSCRIPTION] Change default payment method success'; -export class ChangePMSuccess implements Action { - type: typeof CHANGE_PM_SUCCESS = CHANGE_PM_SUCCESS; - constructor(readonly payload: { defPM: PaymentMethod }) { } -} - -// Payment success -export const CONFIRM_ACTION_SUCCESS = '[SUBSCRIPTION Resolving payment session] Confirm card payment for incomplete and require action success'; -export class ConfirmActionSuccess implements Action { - type: typeof CONFIRM_ACTION_SUCCESS = CONFIRM_ACTION_SUCCESS; - constructor(readonly payload: Plan) { } -} - -export const CONFIRM_PAYMENT_SUCCESS = '[SUBSCRIPTION Resolving payment session] Confirm card payment for incomplete and require payment method success'; -export class ConfirmPaymentSuccess implements Action { - type: typeof CONFIRM_PAYMENT_SUCCESS = CONFIRM_PAYMENT_SUCCESS; - constructor(readonly payload: Plan) { } -} - -export const PAY_UNPAID_SUBSCRIPTION_SUCCESS = '[SUBSCRIPTION Resolving payment session] Pay unpaid subscription success'; -export class PayUnpaidSubscriptionSuccess implements Action { - type: typeof PAY_UNPAID_SUBSCRIPTION_SUCCESS = PAY_UNPAID_SUBSCRIPTION_SUCCESS; - constructor(readonly payload: Plan) { } -} - -export const UPDATE_SUBSCRIPTION_SUCCESS = '[SUBSCRIPTION Making payment intent session] Update subscription success'; -export class UpdateSubscriptionSuccess implements Action { - type: typeof UPDATE_SUBSCRIPTION_SUCCESS = UPDATE_SUBSCRIPTION_SUCCESS; - constructor(readonly payload: Plan) { } -} - -// trial subscription section -export const SET_TRIAL_MODE = '[SUBSCRIPTION Making payment intent session] Starting a trial subscription'; -export class SetMode implements Action { - type: typeof SET_TRIAL_MODE = SET_TRIAL_MODE; - constructor(readonly payload: Mode) { } -} - -export const UPDATE_TRIAL = '[SUBSCRIPTION] Update trials object'; -export class UpdateTrial implements Action { - type: typeof UPDATE_TRIAL = UPDATE_TRIAL; - constructor(readonly payload: Trial) { } -} - -export const CHECK_OUT_TRIAL = '[SUBSCRIPTION Making payment intent session] Checkout a trial subscription'; -export class CheckoutTrial implements Action { - type: typeof CHECK_OUT_TRIAL = CHECK_OUT_TRIAL; - constructor(readonly payload: TrialPmtPkg) { } -} - -export const CHECK_OUT_TRIAL_SUCCESS = '[SUBSCRIPTION Making payment intent session] Checkout a trial subscription success'; -export class CheckoutTrialSuccess implements Action { - type: typeof CHECK_OUT_TRIAL_SUCCESS = CHECK_OUT_TRIAL_SUCCESS; - constructor(readonly payload: { card?: Card, subs: StripeSubscription[], amount?: PaidAmount }) { } -} - -export type SubscriptionIntentAction = - | CompletePayment - | ConfirmActionSuccess - | ConfirmPaymentSuccess - | Checkout - | StartBillingInfo - | StartBillingInfoSuccess - | CreateSubscriptionIntentFailed - | CreatePaymentMethodFailed - | ClearSubscriptionIntentStatus - | GotoBillingAddress - | GotoCheckout - | GotoCheckoutReview - | GotoServices - | PayUnpaidSubscriptionSuccess - | RefeshSubscriptionIntent - | RefeshSubscriptionIntentSuccess - | ResetSubscriptionIntent - | SetSubscriptionIntentPrevStage - | UpdateSubscriptionIntentStatus - | StartCheckout - | StartCheckoutSuccess - | UpdateBillingAddressSuccess - | UpdateSubscriptionSuccess - | UpdateAmount - | UpdatePromoSavings - | ClearPrevStage - | GotoUsageDetail - | LoadStripe - | LoadStripeFailed - | ApplyDiscountPreviewSuccess - | ApplyDiscountPreviewFailed - | SetMode - | CheckoutTrialSuccess - -export type SubscriptionAction = - | CompletePayment - | Confirm - | ConfirmActionSuccess - | ConfirmPaymentSuccess - | ClearSubscriptionStatus - | ClearSubscription - | GotoCheckout - | GotoServices - | FetchLatestSubscriptionSuccess - | PollUnpaidSubscriptionSuccess - | PayUnpaidSubscriptionSuccess - | UpdateUnpaid - | UpdatePastDue - | UpdateIncomplete - | ResetSubscription - | ResumeUnpaidSubscription - | ResumeUnpaidSubscriptionSuccess - | StartCheckoutSuccess - | UpdateSubscriptionStatus - | UpdateSubscription - | UpdateSubscriptionSuccess - | StartBillingInfoSuccess - | FetchPaymentMethodList - | FetchPaymentMethodListSuccess - | FetchDefaultPm - | FetchDefaultPmSuccess - | EditPM - | EditPMSuccess - | AddPM - | AddPMSuccess - | DeletePMSuccess - | ChangePMSuccess - | CheckoutTrialSuccess - | UpdateTrial - - diff --git a/Development/client/src/app/app-routing.module.ts b/Development/client/src/app/app-routing.module.ts index 4bc3b7f..eff504f 100644 --- a/Development/client/src/app/app-routing.module.ts +++ b/Development/client/src/app/app-routing.module.ts @@ -1,23 +1,20 @@ import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; + import { PageNotFoundComponent } from './page-not-found.component'; import { AuthGuard } from './domain/guards/auth.guard'; + import { DashboardComponent } from './dashboard/dashboard.component'; import { ReportComponent } from './report.component'; import { AppMainComponent } from './app.main.component'; import { AppPreloader } from './app-preloader'; import { AppPasswordResetComp } from './pages/app.password-reset.component'; -import { NotificationRedirectGuard } from './domain/guards/notification-redirect.guard'; import { SettingsGuard } from './domain/guards/settings-guard.service'; -import { MembershipResolver } from './domain/resolvers/membership-resolver'; const routes: Routes = [ { path: '', redirectTo: '/home', pathMatch: 'full' }, { path: '', component: AppMainComponent, - resolve: { - membership: MembershipResolver - }, children: [ { path: 'home', @@ -31,15 +28,16 @@ const routes: Routes = [ path: 'customers', loadChildren: () => import('./customers/customer.module').then(m => m.CustomersModule), }, - { - path: 'partners', - loadChildren: () => import('./partners/partners.module').then(m => m.PartnersModule), - }, { path: 'profile', loadChildren: () => import('./profile/profile.module').then((m) => m.ProfileModule), - runGuardsAndResolvers: 'always', + // runGuardsAndResolvers: "always", }, + // { + // path: 'membership', + // loadChildren: () => import('./subscription/membership.module').then((m) => m.MembershipModule), + // // runGuardsAndResolvers: "always", + // }, { path: 'billing', loadChildren: () => import('./billing/billing.module').then(m => m.BillingModule), @@ -80,16 +78,6 @@ const routes: Routes = [ runGuardsAndResolvers: 'always', data: { preload: true } }, - { - path: 'partner-customers', - loadChildren: () => import('./partner-customers/partner-customers.module').then(m => m.PartnerCustomersModule), - runGuardsAndResolvers: 'always' - }, - { - path: 'settings', - loadChildren: () => import('./settings/settings.module').then(m => m.SettingsModule), - runGuardsAndResolvers: 'always' - }, ], }, { @@ -115,39 +103,8 @@ const routes: Routes = [ roles: null }, }, - { - path: 'signup', - loadChildren: () => import('./signup/signup.module').then(m => m.SignupModule) - }, - { - path: 'manage-subscription', - component: PageNotFoundComponent, - canActivate: [NotificationRedirectGuard], - data: { - redirectTo: ['profile', 'myservices'], - redirectToNoSubs: ['profile', 'services'], - loginNotice: $localize`:Login notice for manage-subscription link@@manageSubLoginNotice:Please log in with your Master account to manage your subscriptions.` - } - }, - { - path: 'update-pm', - component: PageNotFoundComponent, - canActivate: [NotificationRedirectGuard], - data: { - redirectTo: ['profile', 'payment-method-list'], - loginNotice: $localize`:Login notice for update-pm link@@updatePmLoginNotice:Please log in with your Master account to update your payment method.` - } - }, - { - path: 'update-bill-address', - component: PageNotFoundComponent, - canActivate: [NotificationRedirectGuard], - data: { - redirectTo: ['profile', 'billing-address'], - loginNotice: $localize`:Login notice for update-bill-address link@@updateBillAddrLoginNotice:Please log in with your Master account to update your billing address.` - } - }, { path: '**', component: PageNotFoundComponent }, + // { path: '/denied', component: AccessDeniedComponent }, ]; @NgModule({ @@ -166,6 +123,6 @@ const routes: Routes = [ exports: [ RouterModule ], - providers: [AppPreloader, MembershipResolver], + providers: [AppPreloader], }) export class AppRoutingModule { } diff --git a/Development/client/src/app/app.component.spec.ts b/Development/client/src/app/app.component.spec.ts new file mode 100644 index 0000000..f71e345 --- /dev/null +++ b/Development/client/src/app/app.component.spec.ts @@ -0,0 +1,36 @@ +/* tslint:disable:no-unused-variable */ + +import { TestBed, async } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { AppComponent } from './app.component'; +import { AppTopbarComponent } from './app.topbar.component'; +import { AppInlineProfileComponent } from './app.profile.component'; +import { AppFooterComponent } from './app.footer.component'; +import { AppBreadcrumbComponent } from './app.breadcrumb.component'; +import { AppMenuComponent, AppSubMenuComponent } from './app.menu.component'; +import { BreadcrumbService } from './breadcrumb.service'; +import { ScrollPanelModule} from 'primeng/primeng'; + +describe('AppComponent', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ RouterTestingModule, ScrollPanelModule ], + declarations: [ AppComponent, + AppTopbarComponent, + AppMenuComponent, + AppSubMenuComponent, + AppFooterComponent, + AppBreadcrumbComponent, + AppInlineProfileComponent + ], + providers: [BreadcrumbService] + }); + TestBed.compileComponents(); + }); + + it('should create the app', async(() => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.debugElement.componentInstance; + expect(app).toBeTruthy(); + })); +}); diff --git a/Development/client/src/app/app.component.ts b/Development/client/src/app/app.component.ts index 7769a54..eb6bcb9 100644 --- a/Development/client/src/app/app.component.ts +++ b/Development/client/src/app/app.component.ts @@ -1,9 +1,8 @@ import { Component, OnInit, OnDestroy, HostBinding } from '@angular/core'; import * as L from 'leaflet'; import { globals, Roles, RoleIds, ProdTypes, ProdType, vehTypes, VehType, MatType, matTypes } from './shared/global'; -import { filter } from 'rxjs/operators'; -import { NavigationEnd, NavigationError, NavigationCancel, NavigationStart } from '@angular/router'; +import { NavigationEnd } from '@angular/router'; import { environment } from '@environments/environment'; import { BaseComp } from './shared/base/base.component'; @@ -19,11 +18,6 @@ export class AppComponent extends BaseComp implements OnInit, OnDestroy { @HostBinding('@.disabled') public animationsDisabled = L.Browser.mobile; // Disable Web Animation as it is not turn on as default in IOS - private navigationStartTime: number = 0; - private previousUrl: string = ''; - private sessionPageCount: number = 0; - private pageStartTime: number = 0; - get showFooter() { return location.href.indexOf('/login') != -1; } @@ -31,450 +25,24 @@ export class AppComponent extends BaseComp implements OnInit, OnDestroy { constructor() { super(); this["name"] = "AppComp"; + + // Subscribe to router events and send page views to Google Analytics + this.router.events.subscribe(event => { + if (event instanceof NavigationEnd) { + if (!environment.production) + console.log(event.urlAfterRedirects); + + // if (this.authSvc.user && this.authSvc.byPUserId) { + // ga('set', 'userId', this.authSvc.byPUserId); + // ga('set', 'dimension1', this.authSvc.byPUserId); + // ga('set', 'page', event.urlAfterRedirects); + // ga('send', 'pageview'); + // } + } + }); } ngOnInit() { - // Initialize GA4 when Angular app is ready - this.gaSvc.initialize(); - - if (!environment.production) { - !environment.production && console.log('GA4 Service initialized:', this.gaSvc.isInitialized()); - } - - // Track session start - this.trackSessionStart(); - - // Subscribe to router events for comprehensive navigation tracking - this.router.events.subscribe(event => { - if (event instanceof NavigationStart) { - this.handleNavigationStart(event); - } else if (event instanceof NavigationEnd) { - this.handleNavigationEnd(event); - } else if (event instanceof NavigationError) { - this.handleNavigationError(event); - } else if (event instanceof NavigationCancel) { - this.handleNavigationCancel(event); - } - }); - - // Track initial page load - this.pageStartTime = Date.now(); - } - - /** - * Extract page title from URL path for analytics - * @param url - The URL path - * @returns Human-readable page title - */ - private getPageTitle(url: string): string { - // Remove query parameters and fragments - const cleanUrl = url.split('?')[0].split('#')[0]; - - // Extract main route segments - const segments = cleanUrl.split('/').filter(segment => segment.length > 0); - - if (segments.length === 0) { - return 'Dashboard'; - } - - // Map common routes to readable titles - const routeTitleMap: { [key: string]: string } = { - 'login': 'Login', - 'dashboard': 'Dashboard', - 'jobs': 'Jobs', - 'job': 'Job Details', - 'clients': 'Clients', - 'client': 'Client Details', - 'accounts': 'Accounts', - 'billing': 'Billing', - 'profile': 'Profile', - 'tools': 'Tools', - 'areas': 'Areas Management', - 'upload': 'File Upload', - 'track': 'Tracking', - 'admin': 'Administration' - }; - - const mainRoute = segments[0]; - return routeTitleMap[mainRoute] || this.capitalizeRoute(mainRoute); - } - - /** - * Capitalize route name for display - * @param route - Route string - * @returns Capitalized route name - */ - private capitalizeRoute(route: string): string { - return route.charAt(0).toUpperCase() + route.slice(1).replace(/-/g, ' '); - } - - /** - * Handle navigation start event - * @param event - NavigationStart event - */ - private handleNavigationStart(event: NavigationStart): void { - this.navigationStartTime = Date.now(); - - // Track navigation start - this.gaSvc.trackEvent('navigation_started', { - navigation_type: 'route_change', - source_url: this.previousUrl, - destination_url: event.url, - navigation_method: event.navigationTrigger === 'imperative' ? 'programmatic' : 'router_link', - navigation_timing_ms: 0, - is_authenticated: !!(this.authSvc.user && this.authSvc.byPUserId), - session_page_count: this.sessionPageCount, - time_on_previous_page_ms: this.pageStartTime ? Date.now() - this.pageStartTime : 0, - user_id: this.authSvc.byPUserId, - user_role: this.getUserRole(), - referrer: document.referrer, - user_agent: navigator.userAgent, - viewport_width: window.innerWidth, - viewport_height: window.innerHeight, - screen_resolution: `${screen.width}x${screen.height}` - }); - } - - /** - * Handle successful navigation end - * @param event - NavigationEnd event - */ - private handleNavigationEnd(event: NavigationEnd): void { - const navigationTime = this.navigationStartTime ? Date.now() - this.navigationStartTime : 0; - this.sessionPageCount++; - - if (!environment.production) { - console.log('Page navigation:', event.urlAfterRedirects); - } - - // Track navigation completion - this.gaSvc.trackEvent('navigation_completed', { - navigation_type: 'route_change', - source_url: this.previousUrl, - destination_url: event.urlAfterRedirects, - navigation_method: 'router_link', - navigation_timing_ms: navigationTime, - page_title: this.getPageTitle(event.urlAfterRedirects), - previous_page_title: this.previousUrl ? this.getPageTitle(this.previousUrl) : '', - is_authenticated: !!(this.authSvc.user && this.authSvc.byPUserId), - session_page_count: this.sessionPageCount, - time_on_previous_page_ms: this.pageStartTime ? Date.now() - this.pageStartTime : 0, - user_id: this.authSvc.byPUserId, - user_role: this.getUserRole(), - referrer: document.referrer, - user_agent: navigator.userAgent, - viewport_width: window.innerWidth, - viewport_height: window.innerHeight, - screen_resolution: `${screen.width}x${screen.height}`, - bounce_candidate: this.sessionPageCount === 1 - }); - - // Track traditional page view for backward compatibility - this.gaSvc.trackPageView( - this.getPageTitle(event.urlAfterRedirects), - event.urlAfterRedirects - ); - - // Set user ID if user is authenticated - if (this.authSvc.user && this.authSvc.byPUserId) { - this.gaSvc.setUserId(this.authSvc.byPUserId); - - // Set user properties for better segmentation - this.gaSvc.setUserProperties({ - user_type: 'authenticated', - client_name: this.authSvc.user.name || 'unknown' - }); - } - - // Update tracking variables - this.previousUrl = event.urlAfterRedirects; - this.pageStartTime = Date.now(); - - // Track slow page loads (threshold: 3 seconds) - if (navigationTime > 3000) { - this.gaSvc.trackEvent('slow_page_load', { - page_title: this.getPageTitle(event.urlAfterRedirects), - load_time_ms: navigationTime, - connection_type: this.getConnectionType(), - device_type: this.getDeviceType(), - platform: 'web' - }); - } - } - - /** - * Handle navigation error - * @param event - NavigationError event - */ - private handleNavigationError(event: NavigationError): void { - const navigationTime = this.navigationStartTime ? Date.now() - this.navigationStartTime : 0; - - if (!environment.production) { - console.error('Navigation error:', event.error, 'URL:', event.url); - } - - // Determine error type based on error message - let errorType: 'route_not_found' | 'navigation_cancelled' | 'guard_rejected' | 'resolver_error' | 'timeout' | 'network_error' | 'permission_denied' = 'navigation_cancelled'; - - if (event.error?.message?.includes('Cannot match any routes')) { - errorType = 'route_not_found'; - } else if (event.error?.message?.includes('guard')) { - errorType = 'guard_rejected'; - } else if (event.error?.message?.includes('resolver')) { - errorType = 'resolver_error'; - } else if (event.error?.message?.includes('timeout')) { - errorType = 'timeout'; - } else if (event.error?.message?.includes('network')) { - errorType = 'network_error'; - } else if (event.error?.message?.includes('permission')) { - errorType = 'permission_denied'; - } - - // Track navigation error - this.gaSvc.trackEvent('navigation_error', { - error_type: errorType, - error_message: event.error?.message || 'Unknown navigation error', - error_code: event.error?.name || 'NavigationError', - error_stack: event.error?.stack || '', - attempted_url: event.url, - source_url: this.previousUrl, - navigation_method: 'router_link', - error_timestamp: new Date().toISOString(), - navigation_timing_ms: navigationTime, - is_authenticated: !!(this.authSvc.user && this.authSvc.byPUserId), - user_permissions: this.getUserPermissions(), - session_duration_ms: this.pageStartTime ? Date.now() - this.pageStartTime : 0, - previous_successful_navigation: this.previousUrl, - user_id: this.authSvc.byPUserId, - user_role: this.getUserRole(), - browser_info: navigator.userAgent, - device_type: this.getDeviceType(), - route_depth: event.url.split('/').length - 1, - resolution_action: this.getResolutionAction(errorType), - resolution_successful: false, - resolution_time_ms: 0 - }); - - // Attempt to resolve the error - this.resolveNavigationError(event, errorType); - } - - /** - * Handle navigation cancel - * @param event - NavigationCancel event - */ - private handleNavigationCancel(event: NavigationCancel): void { - const navigationTime = this.navigationStartTime ? Date.now() - this.navigationStartTime : 0; - - if (!environment.production) { - console.log('Navigation cancelled:', event.reason, 'URL:', event.url); - } - - // Track navigation cancellation - this.gaSvc.trackEvent('navigation_cancelled', { - error_type: 'navigation_cancelled', - error_message: event.reason || 'Navigation was cancelled', - error_code: 'NavigationCancel', - attempted_url: event.url, - source_url: this.previousUrl, - navigation_method: 'router_link', - error_timestamp: new Date().toISOString(), - navigation_timing_ms: navigationTime, - is_authenticated: !!(this.authSvc.user && this.authSvc.byPUserId), - user_permissions: this.getUserPermissions(), - session_duration_ms: this.pageStartTime ? Date.now() - this.pageStartTime : 0, - previous_successful_navigation: this.previousUrl, - user_id: this.authSvc.byPUserId, - user_role: this.getUserRole(), - browser_info: navigator.userAgent, - device_type: this.getDeviceType(), - route_depth: event.url.split('/').length - 1, - resolution_action: 'none', - resolution_successful: false, - resolution_time_ms: 0 - }); - } - - /** - * Get user role from user model using shared analytics helpers - */ - private getUserRole(): string { - if (!this.authSvc.user?.roles) { - return 'anonymous'; - } - - // Use shared analytics helper through base component convenience method - return this.getAnalyticsUserRole(); - } - - /** - * Get user permissions from user model - */ - private getUserPermissions(): string[] { - if (!this.authSvc.user?.roles) { - return []; - } - - const permissions: string[] = []; - const roles = this.authSvc.user.roles; - - // Map roles to permissions - if (roles.admin) permissions.push('admin', 'full_access'); - if (roles.officer) permissions.push('officer', 'job_management', 'financial_access'); - if (roles.pilot) permissions.push('pilot', 'job_execution', 'tracking_access'); - if (roles.applicator) permissions.push('applicator', 'job_execution', 'tracking_access'); - if (roles.client) permissions.push('client', 'job_creation', 'report_access'); - if (roles.inspector) permissions.push('inspector', 'report_access'); - if (roles.aircraft) permissions.push('aircraft', 'data_upload'); - - return permissions; - } - - /** - * Determine device type based on screen size and user agent - */ - private getDeviceType(): 'desktop' | 'mobile' | 'tablet' { - const userAgent = navigator.userAgent; - - if (/tablet|ipad|playbook|silk/i.test(userAgent)) { - return 'tablet'; - } - - if (/mobile|iphone|ipod|android|blackberry|opera|mini|windows\sce|palm|smartphone|iemobile/i.test(userAgent)) { - return 'mobile'; - } - - return 'desktop'; - } - - /** - * Determine connection type based on Network Information API - */ - private getConnectionType(): 'wifi' | 'cellular' | 'ethernet' | 'unknown' { - // Check if Network Information API is available - if ('connection' in navigator) { - const connection = (navigator as any).connection; - const effectiveType = connection?.effectiveType; - - // Map effective connection types to our categories - if (effectiveType === 'slow-2g' || effectiveType === '2g' || effectiveType === '3g') { - return 'cellular'; - } - if (effectiveType === '4g') { - return 'cellular'; - } - - // Check connection type if available - const type = connection?.type; - if (type === 'wifi') return 'wifi'; - if (type === 'ethernet') return 'ethernet'; - if (type === 'cellular') return 'cellular'; - } - - return 'unknown'; - } - - /** - * Determine resolution action based on error type - */ - private getResolutionAction(errorType: string): 'redirect_to_home' | 'redirect_to_login' | 'show_error_page' | 'retry_navigation' | 'none' { - switch (errorType) { - case 'route_not_found': - return 'redirect_to_home'; - case 'guard_rejected': - case 'permission_denied': - return 'redirect_to_login'; - case 'resolver_error': - case 'timeout': - case 'network_error': - return 'retry_navigation'; - default: - return 'show_error_page'; - } - } - - /** - * Attempt to resolve navigation errors - */ - private resolveNavigationError(event: NavigationError, errorType: string): void { - const resolutionStartTime = Date.now(); - const action = this.getResolutionAction(errorType); - - switch (action) { - case 'redirect_to_home': - this.router.navigate(['/']).then(success => { - this.trackResolutionResult(event, action, success, resolutionStartTime); - }); - break; - - case 'redirect_to_login': - this.router.navigate(['/login']).then(success => { - this.trackResolutionResult(event, action, success, resolutionStartTime); - }); - break; - - case 'retry_navigation': - // Retry the original navigation after a brief delay - setTimeout(() => { - this.router.navigate([event.url]).then(success => { - this.trackResolutionResult(event, action, success, resolutionStartTime); - }); - }, 1000); - break; - - default: - this.trackResolutionResult(event, action, false, resolutionStartTime); - break; - } - } - - /** - * Track the result of navigation error resolution - */ - private trackResolutionResult(event: NavigationError, action: string, success: boolean, startTime: number): void { - const resolutionTime = Date.now() - startTime; - - // Update the original navigation error event with resolution results - this.gaSvc.trackEvent('navigation_error', { - error_type: 'navigation_cancelled', - error_message: event.error?.message || 'Navigation error resolved', - error_code: event.error?.name || 'NavigationError', - attempted_url: event.url, - source_url: this.previousUrl, - navigation_method: 'router_link', - error_timestamp: new Date().toISOString(), - is_authenticated: !!(this.authSvc.user && this.authSvc.byPUserId), - user_id: this.authSvc.byPUserId, - user_role: this.getUserRole(), - resolution_action: action as any, - resolution_successful: success, - resolution_time_ms: resolutionTime - }); - } - - /** - * Track session start event - */ - private trackSessionStart(): void { - // Get current route for entry page - const entryPage = this.router.url || '/'; - - // Track session start with required parameters - this.gaSvc.trackEvent('session_start', { - platform: 'web', - user_role: this.getUserRole(), - entry_page: entryPage, - referrer: document.referrer || undefined, - session_id: this.generateSessionId(), - user_id: this.authSvc.byPUserId - }); - } - - /** - * Generate a unique session ID - */ - private generateSessionId(): string { - return 'sess_' + Date.now().toString(36) + Math.random().toString(36).substr(2); } ngOnDestroy() { diff --git a/Development/client/src/app/app.main.component.html b/Development/client/src/app/app.main.component.html index fd226f4..f9ec3db 100644 --- a/Development/client/src/app/app.main.component.html +++ b/Development/client/src/app/app.main.component.html @@ -28,23 +28,14 @@
+
-
- - {{ getExpiryWarningMessage(warning) }} - -
+
- - - - +
diff --git a/Development/client/src/app/app.main.component.ts b/Development/client/src/app/app.main.component.ts index 425b95b..1b87935 100644 --- a/Development/client/src/app/app.main.component.ts +++ b/Development/client/src/app/app.main.component.ts @@ -1,22 +1,14 @@ -import { Component, AfterViewInit, ElementRef, ViewChild, OnDestroy, OnInit, NgZone, ChangeDetectorRef, AfterViewChecked } from '@angular/core'; +import { Component, AfterViewInit, ElementRef, ViewChild, OnDestroy, OnInit, NgZone } from '@angular/core'; import { MenuService } from './app.menu.service'; import { AuthService } from './domain/services/auth.service'; -import { ActivatedRoute, Router } from '@angular/router'; +import { Router } from '@angular/router'; import { ConfirmationService } from 'primeng-lts/api'; import { DomSanitizer } from '@angular/platform-browser'; -import { Observable, combineLatest } from 'rxjs'; -import { map } from 'rxjs/operators'; + import cloneDeep from 'clone-deep'; import { globals } from './shared/global'; import { AppConfigService } from './domain/services/app-config.service'; import { IAppConfig } from './domain/models/appconfig.model'; -import { Compound, FetchLatestSubscriptionSuccess, GotoServices, SetMode } from './actions/subscription.actions'; -import { Mode, SUB } from './profile/common'; -import { Store } from '@ngrx/store'; -import { IMembership, UserModel } from './auth/models/user.model'; -import { ExpiryWarning } from './domain/models/subscription.model'; -import { buildExpiryWarningMessage } from './app.profile.component'; -import * as fromStore from '../../src/app/reducers/index'; enum MenuOrientation { STATIC, @@ -29,7 +21,7 @@ enum MenuOrientation { selector: 'app-main', templateUrl: './app.main.component.html' }) -export class AppMainComponent implements AfterViewInit, OnDestroy, OnInit, AfterViewChecked { +export class AppMainComponent implements AfterViewInit, OnDestroy, OnInit { layoutCompact = true; @@ -74,46 +66,27 @@ export class AppMainComponent implements AfterViewInit, OnDestroy, OnInit, After rippleMouseDownListener: any; settings: IAppConfig; - membership: IMembership; - user$: Observable; - expiryWarning$: Observable; constructor( + public readonly zone: NgZone, private router: Router, private readonly sanitizer: DomSanitizer, private readonly menuService: MenuService, private readonly authSvc: AuthService, private readonly appConfSvc: AppConfigService, - private readonly confirmSvc: ConfirmationService, - private readonly route: ActivatedRoute, - private readonly store: Store, - private cdr: ChangeDetectorRef - ) { - this.membership = this.route.snapshot.data['membership']; + private readonly confirmSvc: ConfirmationService) { + this.settings = cloneDeep(this.appConfSvc.settings); - this.user$ = this.store.select(fromStore.selectAuthUser); - this.expiryWarning$ = combineLatest([ - this.store.select(fromStore.selectExpiryWarning), - this.store.select(fromStore.selectNoSubsWarning) - ]).pipe(map(([expiry, noSubs]) => expiry ?? noSubs)); } ngOnInit() { this.zone.runOutsideAngular(() => { this.bindRipple(); }); - if (/*!this.authSvc.isBillable &&*/ !this.settings.noPopup) + if (!this.authSvc.isBillable && !this.settings.noPopup) this.showPaidPopup(); } - getExpiryWarningMessage(warning: ExpiryWarning): string { - return buildExpiryWarningMessage(warning); - } - - onNavigateToManageSubscription(): void { - this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]); - } - bindRipple() { this.rippleInitListener = this.init.bind(this); document.addEventListener('DOMContentLoaded', this.rippleInitListener); @@ -229,13 +202,6 @@ export class AppMainComponent implements AfterViewInit, OnDestroy, OnInit, After ngAfterViewInit() { this.layoutContainer = this.layourContainerViewChild.nativeElement as HTMLDivElement; - if (this.membership) { - setTimeout(() => this.store.dispatch(new FetchLatestSubscriptionSuccess({ membership: this.membership })), 100); - } - } - - ngAfterViewChecked() { - this.cdr.detectChanges(); } onLayoutClick() { @@ -417,39 +383,20 @@ export class AppMainComponent implements AfterViewInit, OnDestroy, OnInit, After return this.authSvc.isAdmin; } - get isApplicator() { - return this.authSvc.isApplicator; - } - get shouldShowPaidMsg() { - return false; // Disable paid notification popup for now - return (/*!this.authSvc.isBillable && */!this.authSvc.isAdmin && !this.authSvc.isClientUser && !this.authSvc.isInspector); + return (!this.authSvc.isBillable && !this.authSvc.isAdmin && !this.authSvc.isClientUser && !this.authSvc.isInspector); } showPaidPopup() { - if (!this.shouldShowPaidMsg) return false; // Skip paid notification popup for now + if (!this.shouldShowPaidMsg) return; // Skip paid notification popup for now - // let msgHtml = $localize`:Paid start time notification popup message@@paidInformMsg:

Dear Agmission Customers,

- //

Ag-Mission will become a paid service on Saturday, July 15th 2023. - // Please contact Ag-Nav Inc. at 1-800-99-AGNAV, or email joset@agnav.com at your earliest convenience. - //

- //

Current Ag-Mission users will get the rest of 2023 at the lowest “Unlimited” tier price, and 10% off for 2024; with a special discount for users that subscribe in advance for 2024.

- //

For more information, please CLICK HERE to view the features presentation.

- //

If you have any questions please do not hesitate to call us or email general@agnav.com

- // `; - - let msgHtml = `

Important Notice

-

Dear Ag-Mission Users,

-

We sincerely apologize for the recent disruptions in accessing Ag-Mission and retrieving data. Our main server in New Jersey has been experiencing intermittent downtime since Monday, and the automatic rollover to the backup server encountered data migration challenges.

-

Our team is working diligently to resolve these issues and restore data access. We anticipate the process may take a couple of days to complete. We appreciate your patience and understanding as we work to resolve this matter as quickly as possible.

-

Action Required

-
    -
  • Update your Platinum units to software version 2.21.3 via the AgNav website to ensure continued functionality and compatibility with AgMission.
  • -
  • If you’re using Aircraft Job Assignment feature, all aircraft must be activated under Entities in your master account provided by AgNav. (The number of aircraft allowed is based on your selected package).
  • -
-

Thank you for your continued support.
- Best regards,
- Ag-Mission Team

+ let msgHtml = $localize`:Paid start time notification popup message@@paidInformMsg:

Dear Agmission Customers,

+

Ag-Mission will become a paid service on Saturday, July 15th 2023. + Please contact Ag-Nav Inc. at 1-800-99-AGNAV, or email joset@agnav.com at your earliest convenience. +

+

Current Ag-Mission users will get the rest of 2023 at the lowest “Unlimited” tier price, and 10% off for 2024; with a special discount for users that subscribe in advance for 2024.

+

For more information, please CLICK HERE to view the features presentation.

+

If you have any questions please do not hesitate to call us or email general@agnav.com

`; this.confirmSvc.confirm({ @@ -465,26 +412,4 @@ export class AppMainComponent implements AfterViewInit, OnDestroy, OnInit, After } }); } - - canDisplayTrial() { - // this.membership is populated synchronously by the route resolver, before the - // NgRx auth store is hydrated (FetchLatestSubscriptionSuccess fires 100ms later). - // Using it here prevents the banner from flashing on F5 reload for users who - // already have subscriptions (including trialing ones). - if (this.membership?.subscriptions?.length > 0) return false; - return this.authSvc.canDisplayTrial(this.membership?.trials); - } - - accept() { - if (this.router.url.includes(SUB.SERVICES)) return this.store.dispatch(new SetMode(Mode.TRIALING)); - return this.store.dispatch(new Compound([new SetMode(Mode.TRIALING), new GotoServices()])); - } - - isTrialDays() { - return this.authSvc.isTrialDays(this.membership?.trials); - } - - canDisplayAcceptTrial() { - return this.authSvc.canAcceptTrial(this.router.url); - } } \ No newline at end of file diff --git a/Development/client/src/app/app.menu.component.ts b/Development/client/src/app/app.menu.component.ts index 1705f8c..720dd1e 100644 --- a/Development/client/src/app/app.menu.component.ts +++ b/Development/client/src/app/app.menu.component.ts @@ -1,12 +1,10 @@ import { Component, OnInit } from '@angular/core'; import { AppMainComponent } from './app.main.component'; + import { AuthService } from './domain/services/auth.service'; import { RoleIds } from './shared/global'; + import { MenuItem } from 'primeng/api'; -import { Store } from '@ngrx/store'; -import { selectSubLimit } from './reducers'; -import { FetchSubPlans } from './actions/sub-plans.actions'; -import { SubKeys } from './profile/common'; @Component({ selector: 'app-menu', @@ -17,225 +15,172 @@ import { SubKeys } from './profile/common'; ` }) export class AppMenuComponent implements OnInit { - model: any[] = []; + /** + Convention: every root item with children must have routerLink for selected check after page reloaded + **/ + model: any[]; constructor( + public readonly app: AppMainComponent, - private readonly authSvc: AuthService, - private readonly store: Store<{}> - ) { } + private readonly authSvc: AuthService) { + + } ngOnInit() { + const mItems: MenuItem[] = [ + { id: 'dashboard', label: $localize`:@@dashboard:Dashboard`, icon: 'dashboard', routerLink: ['/home'] } + ]; if (this.authSvc.hasRole([RoleIds.ADMIN])) { - this.creatAdminMenu() - } else if (this.authSvc.isPartner) { - this.createPartnerMenu(); - } else { - this.createUserMenu(); - } - } - - creatAdminMenu() { - const mItems: MenuItem[] = [ - { id: 'dashboard', label: $localize`:@@dashboard:Dashboard`, icon: 'dashboard', routerLink: ['/home'] }, - { id: 'customers', label: $localize`:@@customers:Customers`, icon: 'assignment_ind', routerLink: ['/customers'] }, - { id: 'partners', label: $localize`:@@partnerMgnt:Partner Management`, icon: 'business', routerLink: ['/partners'] }, - { label: $localize`:@@billing:Billing`, icon: 'monetization_on', routerLink: ['/billing'] }, - { - id: 'settings', - label: $localize`:@@settings:Settings`, icon: 'settings', - routerLink: ['/settings'], - items: [ - { id: 'subscription', label: $localize`:@@promoManagement:Promo Management`, icon: 'credit_card', routerLink: ['/settings/subscription'] } - ] - }, - ]; - this.model = mItems; - } - - createPartnerMenu() { - const mItems: MenuItem[] = [ - { id: 'dashboard', label: $localize`:@@dashboard:Dashboard`, icon: 'dashboard', routerLink: ['/home'] }, - { - id: 'partner-customers', - label: $localize`:@@partnerCustomers:Partner Customers`, - icon: 'business', - routerLink: ['/partner-customers'] - }, - { - id: 'Help', - label: $localize`:@@help:Help`, icon: 'help_outline', - items: [{ - label: $localize`:@@trainingVideos:Training Videos`, - icon: 'video_library', - url: 'https://www.youtube.com/watch?v=QjGZan5QdAo&list=PLSMll_kIgHA3eamxiSH0Dgl95v60okMcV', - target: '_blank' - }] - } - ]; - this.model = mItems; - } - - createUserMenu() { - this.store.select(selectSubLimit).subscribe({ - next: (subLimit) => { - const hasSubLimit = !!subLimit && (Object.keys(subLimit.package || {}).length > 0 || Object.keys(subLimit.addon || {}).length > 0); - const mItems: MenuItem[] = [ - { id: 'dashboard', label: $localize`:@@dashboard:Dashboard`, icon: 'dashboard', routerLink: ['/home'] } - ]; - if (hasSubLimit) { - this.addSubItems(mItems, subLimit); - } - this.model = mItems; - }, - error: (err) => { - console.log(err); - } - }); - this.store.dispatch(new FetchSubPlans()); - } - - private addSubItems(mItems: MenuItem[], subLimit: any) { - const hasTracking = subLimit?.addon?.[SubKeys.TRACKING]?.airCraft?.numOfVehicle > 0; - const hasPackage = Object.keys(subLimit.package || {}).length > 0; - const hasOnlyTracking = !hasPackage && hasTracking; - const hasOnlyPackage = hasPackage && !hasTracking; - - if (hasOnlyTracking) { - this.addOnlyTrackingItems(mItems); - } else if (hasOnlyPackage) { - this.addOnlyPackageItems(mItems); - } else if (hasPackage && hasTracking) { - this.addFullAccessItems(mItems); - } - - mItems.push( - { - id: 'Help', - label: $localize`:@@help:Help`, icon: 'help_outline', - items: [{ - label: $localize`:@@trainingVideos:Training Videos`, - icon: 'video_library', - url: 'https://www.youtube.com/watch?v=QjGZan5QdAo&list=PLSMll_kIgHA3eamxiSH0Dgl95v60okMcV', - target: '_blank' - }] - } - ) - } - - private addOnlyTrackingItems(mItems: MenuItem[]) { - if (!this.authSvc.hasRole([RoleIds.INSPECTOR])) { mItems.push( - { - id: 'entities', - label: $localize`:@@entities:Entities`, icon: 'library_books', - routerLink: ['/entities'], - items: [{ label: $localize`:@@aircraft:Aircraft`, icon: 'airplanemode_active', routerLink: ['/entities/aircraft'] }] - }, - { - id: 'tools', - label: $localize`:@@tools:Tools`, icon: 'extension', - routerLink: ['/tools'], + ...[ + { id: 'customers', label: $localize`:@@customers:Customers`, icon: 'assignment_ind', routerLink: ['/customers'] }, + { label: $localize`:@@billing:Billing`, icon: 'monetization_on', routerLink: ['/billing'] }, + // { label: $localize`:@@reports:Reports`, icon: 'print', routerLink: ['/reports'] }, + ]); + } else { + if (this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM])) { + mItems.push({ id: 'accounts', label: $localize`:@@accounts:Accounts`, icon: 'assignment_ind', routerLink: ['/accounts'] }); + } + + if (!this.authSvc.isClientUser) { + mItems.push({ id: 'clients', label: $localize`:@@clients:Clients`, icon: 'people', routerLink: ['/clients'] }); + } + mItems.push({ id: 'jobs', label: $localize`:@@jobs:Jobs`, icon: 'assignment', routerLink: ['/jobs'] }); + + if (this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM, RoleIds.CLIENT, RoleIds.PILOT, RoleIds.OFFICER])) { + mItems.push({ + id: 'invoice', + label: $localize`:@@invoices:Invoices`, icon: 'receipt_long', + routerLink: ['/invoices'], items: [ - { id: 'settings', label: $localize`:@@settings:Settings`, icon: 'settings', routerLink: ['/tools/settings'] } ] - } - ); - } - if (!this.authSvc.hasRole([RoleIds.CLIENT, RoleIds.INSPECTOR])) { - mItems.push({ id: 'track', label: $localize`:@@tracking:Tracking`, icon: 'track_changes', routerLink: ['/track'] }); - } - } - - private addOnlyPackageItems(mItems: MenuItem[]) { - if (this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM])) { - mItems.push({ id: 'accounts', label: $localize`:@@accounts:Accounts`, icon: 'assignment_ind', routerLink: ['/accounts'] }); - } - - if (!this.authSvc.isClientUser) { - mItems.push({ id: 'clients', label: $localize`:@@clients:Clients`, icon: 'people', routerLink: ['/clients'] }); - } - mItems.push({ id: 'jobs', label: $localize`:@@jobs:Jobs`, icon: 'assignment', routerLink: ['/jobs'] }); - - if (!this.authSvc.hasRole([RoleIds.INSPECTOR])) { - this.addEntitiesAndToolsItems(mItems); - } - - this.addInvoiceItems(mItems); - } - - private addFullAccessItems(mItems: MenuItem[]) { - if (this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM])) { - mItems.push({ id: 'accounts', label: $localize`:@@accounts:Accounts`, icon: 'assignment_ind', routerLink: ['/accounts'] }); - } - - if (!this.authSvc.isClientUser) { - mItems.push({ id: 'clients', label: $localize`:@@clients:Clients`, icon: 'people', routerLink: ['/clients'] }); - } - mItems.push({ id: 'jobs', label: $localize`:@@jobs:Jobs`, icon: 'assignment', routerLink: ['/jobs'] }); - - if (!this.authSvc.hasRole([RoleIds.INSPECTOR])) { - this.addEntitiesAndToolsItems(mItems); - } - if (!this.authSvc.hasRole([RoleIds.CLIENT, RoleIds.INSPECTOR])) { - mItems.push({ id: 'track', label: $localize`:@@tracking:Tracking`, icon: 'track_changes', routerLink: ['/track'] }); - } - - this.addInvoiceItems(mItems); - } - - private addEntitiesAndToolsItems(mItems: MenuItem[]) { - mItems.push( - { - id: 'entities', - label: $localize`:@@entities:Entities`, icon: 'library_books', - routerLink: ['/entities'], - items: this.authSvc.isClientUser ? - [ - { label: $localize`:@@crops:Crops`, icon: 'list', routerLink: ['/entities/crops'] }, - { label: $localize`:@@products:Products`, icon: 'widgets', routerLink: ['/entities/products'] }, - ] - : [ - { label: $localize`:@@aircraft:Aircraft`, icon: 'airplanemode_active', routerLink: ['/entities/aircraft'] }, - { label: $localize`:@@crops:Crops`, icon: 'list', routerLink: ['/entities/crops'] }, - { label: $localize`:@@products:Products`, icon: 'widgets', routerLink: ['/entities/products'] }, - { label: $localize`:@@pilots:Pilots`, icon: 'contacts', routerLink: ['/entities/pilots'] } - ] - }, - { - id: 'tools', - label: $localize`:@@tools:Tools`, icon: 'extension', - routerLink: ['/tools'], - items: [ - { id: 'upload', label: $localize`:@@uploadJobData:Upload Job Data`, icon: 'cloud_upload', routerLink: ['/tools/upload'] }, - { id: 'areaLib', label: $localize`:@@manageAreasLib:Manage Areas Library`, icon: 'folder_special', routerLink: ['/tools/areas'] }, - { id: 'settings', label: $localize`:@@settings:Settings`, icon: 'settings', routerLink: ['/tools/settings'] } - ] + }); } - ); + const invoice = mItems.filter(i => i.id === 'invoice'); + if (invoice && invoice.length && this.authSvc.hasRole([RoleIds.CLIENT, RoleIds.OFFICER, RoleIds.PILOT])) { + invoice[0].items.push(...[ + { id: 'view', label: $localize`:@@viewInvoice:View Invoices`, icon: 'list', routerLink: ['/invoices'] } + ]); + } + if (invoice && invoice.length && this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM])) { + invoice[0].items.push(...[ + { id: 'view', label: $localize`:@@viewEditInvoice:View/Edit Invoices`, icon: 'list', routerLink: ['/invoices'] } + ]); + } + if (invoice && invoice.length && this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM, RoleIds.OFFICER, RoleIds.PILOT])) { + invoice[0].items.push(...[ + { + id: 'costing-item', + label: $localize`:@@costingItems:Costing Items`, + icon: 'payments', + routerLink: ['/invoices/costing-items'] + }, + ]); + } + if (invoice && invoice.length && this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM])) { + invoice[0].items.push(...[ + { + id: 'settings', + label: $localize`:@@invoiceSettings:Invoice Settings`, + icon: 'settings', + routerLink: ['/invoices/settings'] + }, + ]); + } + if (!this.authSvc.hasRole([RoleIds.INSPECTOR])) { + mItems.push( + ...[ + { + id: 'entities', + label: $localize`:@@entities:Entities`, icon: 'library_books', + routerLink: ['/entities'], + items: this.authSvc.isClientUser ? + [ + { label: $localize`:@@crops:Crops`, icon: 'list', routerLink: ['/entities/crops'] }, + { label: $localize`:@@products:Products`, icon: 'widgets', routerLink: ['/entities/products'] }, + ] + : [ + { label: $localize`:@@aircraft:Aircraft`, icon: 'airplanemode_active', routerLink: ['/entities/aircraft'] }, + { label: $localize`:@@crops:Crops`, icon: 'list', routerLink: ['/entities/crops'] }, + { label: $localize`:@@products:Products`, icon: 'widgets', routerLink: ['/entities/products'] }, + { label: $localize`:@@pilots:Pilots`, icon: 'contacts', routerLink: ['/entities/pilots'] } + ] + }, + { + id: 'tools', + label: $localize`:@@tools:Tools`, icon: 'extension', + routerLink: ['/tools'], + items: [ + { id: 'upload', label: $localize`:@@uploadJobData:Upload Job Data`, icon: 'cloud_upload', routerLink: ['/tools/upload'] }, + { id: 'areaLib', label: $localize`:@@manageAreasLib:Manage Areas Library`, icon: 'folder_special', routerLink: ['/tools/areas'] } + ] + } + ]); + } + + if (!this.authSvc.hasRole([RoleIds.CLIENT, RoleIds.INSPECTOR])) { + mItems.push({ id: 'track', label: $localize`:@@tracking:Tracking`, icon: 'track_changes', routerLink: ['/track'] }); + } + + const tools = mItems.filter(i => i.id === 'tools'); + if (tools && tools.length) { + tools[0].items.push({ id: 'settings', label: $localize`:@@settings:Settings`, icon: 'settings', routerLink: ['/tools/settings'] }); + } + + } + this.model = mItems; + // let validLinks = []; + // this.getValidLinks(this.router.config, this.router.config[0], validLinks); + // this.updateMenuItem(this.model, validLinks); } - private addInvoiceItems(mItems: MenuItem[]) { - if (this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM, RoleIds.CLIENT, RoleIds.OFFICER])) { - mItems.push({ - id: 'invoice', - label: $localize`:@@invoices:Invoices`, icon: 'receipt_long', - routerLink: ['/invoices'], - items: [] - }); - } - const invoice = mItems.find(i => i.id === 'invoice'); - if (invoice) { - if (this.authSvc.hasRole([RoleIds.APP_ADM, RoleIds.CLIENT, RoleIds.OFFICER])) { - invoice.items.push({ id: 'view', label: $localize`:@@viewInvoice:View Invoices`, icon: 'list', routerLink: ['/invoices'] }); + /* + private getValidLinks(routes: Route[], parent: Route, links: string[]) { + for (const route of routes) { + if (!route.children || route.children.length === 0) { + if (!route.data || (route.data && (!route.data.roles || this.authService.hasRole(route.data.roles)))) { + if (route.path !== '**') { + let combinedPath = parent ? `/${parent.path}/${route.path}` : route.path; + combinedPath = combinedPath.replace('/:id', ''); + if (!combinedPath.startsWith('/')) { + combinedPath = '/' + combinedPath; + } + if (combinedPath.length > 1 && combinedPath.endsWith('/')) { + combinedPath = combinedPath.slice(0, combinedPath.length - 1); + } + links.push(combinedPath); + } + } + } else { + this.getValidLinks(route.children, route, links); + parent = null; + } } - if (this.authSvc.hasRole([RoleIds.APP])) { - invoice.items.push( - { id: 'view', label: $localize`:@@viewEditInvoice:View/Edit Invoices`, icon: 'list', routerLink: ['/invoices'] }, - { id: 'costing-item', label: $localize`:@@costingItems:Costing Items`, icon: 'payments', routerLink: ['/invoices/costing-items'] }, - { id: 'settings', label: $localize`:@@invoiceSettings:Invoice Settings`, icon: 'settings', routerLink: ['/invoices/settings'] } - ); - } - } } -} + + private updateMenuItem(mnuItems: MenuItem[], validLinks: string[]) { + for (const it of mnuItems) { + if (it.items) { + // this.updateMenuItem(it.items, validLinks); + let visible = false; + // it.items.forEach(el => { + // if (!el.hasOwnProperty('visible') || el.visible) { + // visible = true; + // return; + // } + // }); + if (!visible) { + it.visible = false; // hide menu item which has none sub-items allowed to be shown + } + } else { + if (it.routerLink && it.routerLink.length > 0) { + if (validLinks.indexOf(it.routerLink[0]) === -1) { + it.visible = false; + } + } + } + } + } + */ +} \ No newline at end of file diff --git a/Development/client/src/app/app.module.ts b/Development/client/src/app/app.module.ts index 8df5c2f..277df52 100644 --- a/Development/client/src/app/app.module.ts +++ b/Development/client/src/app/app.module.ts @@ -1,4 +1,4 @@ -import { APP_INITIALIZER, NgModule, TRANSLATIONS, LOCALE_ID, TRANSLATIONS_FORMAT, Injector } from '@angular/core'; +import { NgModule, TRANSLATIONS, LOCALE_ID, TRANSLATIONS_FORMAT, Injector } from '@angular/core'; import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http'; import { BrowserModule } from '@angular/platform-browser'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; @@ -10,7 +10,6 @@ import { ButtonModule } from 'primeng/button'; import { MenuModule } from 'primeng/menu'; import { ProgressSpinnerModule } from 'primeng/progressspinner'; import { ConfirmDialogModule } from 'primeng/confirmdialog'; -import { DialogModule } from 'primeng/dialog'; import { DynamicDialogRef, DynamicDialogConfig } from 'primeng/dynamicdialog'; import { ConfirmationService, MessageService } from 'primeng/api'; import { ToastModule } from 'primeng/toast'; @@ -22,6 +21,7 @@ import { AppMainComponent } from './app.main.component'; import { AppMenuComponent } from './app.menu.component'; import { AppMenuitemComponent } from './app.menuitem.component'; import { AppTopbarComponent } from './app.topbar.component'; +import { AppFooterComponent } from './app.footer.component'; import { AppInlineProfileComponent } from './app.profile.component'; import { DashboardComponent } from './dashboard/dashboard.component'; @@ -52,10 +52,8 @@ import { AppConfigService } from './domain/services/app-config.service'; import { AuthInterceptor } from './domain/services/auth-interceptor.service'; import { SettingsGuard } from './domain/guards/settings-guard.service'; - +import { LanguageSwicherComponent } from './language-swicher.component'; import { AppEffects } from './effects/app.effects'; -import { SubPlansEffects } from './effects/sub-plans.effects'; - import { Utils } from './shared/utils'; import { AppInjector } from './app-injector'; import { BaseComp } from './shared/base/base.component'; @@ -67,11 +65,8 @@ import { GlobalModule } from './shared/global.module'; import { HttpCancelService } from './domain/services/httpcancel.service'; import { ManageHttpInterceptor } from './domain/services/managehttp.interceptor.service'; import { InvoiceService } from '@app/domain/services/invoice.service'; + import '@app/shared/number.extension'; -import { RoutingEffects } from './effects/routing.effects'; -import { SubscriptionEffects } from './effects/subscription.effects'; -import { AppSharedModule } from './shared/app-shared.module'; -import { GlobalErrorInterceptor } from './domain/services/global-error.interceptor'; // Use the require method provided by webpack declare const require; @@ -83,11 +78,17 @@ export function translationsFactory(locale: string) { return require(`raw-loader!../locale/messages.${locale}.xlf`).default; } +// export function loadSetting(appInitService: AppConfig) { +// return (): Promise => { +// return appInitService.load(); +// } +// } + @NgModule({ imports: [ BrowserModule, BrowserAnimationsModule, HttpClientModule, GlobalModule, InputTextModule, ButtonModule, MenuModule, ProgressSpinnerModule, ScrollPanelModule, - MessagesModule, ToastModule, ConfirmDialogModule, DialogModule, DropdownModule, CheckboxModule, AppSharedModule, + MessagesModule, ToastModule, ConfirmDialogModule, DropdownModule, CheckboxModule, // The store that defines our app state StoreModule.forRoot(reducers, { metaReducers, @@ -101,7 +102,7 @@ export function translationsFactory(locale: string) { // Must instrument after importing StoreModule StoreDevtoolsModule.instrument({ name: 'AgMission', maxAge: 15, logOnly: environment.production }), AppRoutingModule, - EffectsModule.forRoot([AppEffects, SubPlansEffects, RoutingEffects, SubscriptionEffects]), + EffectsModule.forRoot([AppEffects]), ], declarations: [ BaseComp, @@ -115,10 +116,14 @@ export function translationsFactory(locale: string) { AppMenuitemComponent, AppInlineProfileComponent, AppTopbarComponent, + AppFooterComponent, + LanguageSwicherComponent, ReportComponent, - AppPasswordResetComp + AppPasswordResetComp, ], providers: [ + // AppConfig, + // { provide: APP_INITIALIZER, useFactory: loadSetting, deps: [AppConfig], multi: true }, { provide: TRANSLATIONS, useFactory: translationsFactory, @@ -133,11 +138,6 @@ export function translationsFactory(locale: string) { useClass: AuthInterceptor, multi: true }, - { - provide: HTTP_INTERCEPTORS, - useClass: GlobalErrorInterceptor, - multi: true - }, { provide: ActionsSubject, useClass: AppDispatcher }, @@ -149,8 +149,11 @@ export function translationsFactory(locale: string) { exports: [], entryComponents: [] }) - export class AppModule { + // Diagnostic only: inspect router configuration + // constructor(router: Router) { + // console.log('Routes: ', JSON.stringify(router.config, undefined, 2)); + // } constructor(private readonly injector: Injector) { AppInjector.setInjector(injector); } diff --git a/Development/client/src/app/app.profile.component.css b/Development/client/src/app/app.profile.component.css deleted file mode 100644 index 8a06e77..0000000 --- a/Development/client/src/app/app.profile.component.css +++ /dev/null @@ -1,22 +0,0 @@ -.account-summary-info { - padding-top: 0.5em; - color: #fff; - font-size: 0.95rem; - font-weight: 500; - text-align: right; -} - -.account-summary-info .account-username { - margin-right: 0.5em; -} - -.account-summary-info .account-type { - margin-right: 0.5em; - font-style: italic; - opacity: 0.85; -} - -.account-summary-info .account-contact { - color: #ffd700; - opacity: 0.9; -} diff --git a/Development/client/src/app/app.profile.component.html b/Development/client/src/app/app.profile.component.html deleted file mode 100644 index c13413b..0000000 --- a/Development/client/src/app/app.profile.component.html +++ /dev/null @@ -1,36 +0,0 @@ - - - -

AgMission subscriptions of {{ masterInfo?.name }} are managed by the Master account, please contact:

- - - - - - - - - - - - - - - - - -
Username{{ masterInfo?.username }}
Contact{{ masterInfo?.contact }}
Phone{{ masterInfo?.phone }}
Email{{ masterInfo?.email }}
- - - -
\ No newline at end of file diff --git a/Development/client/src/app/app.profile.component.ts b/Development/client/src/app/app.profile.component.ts index ba72ed8..06c1084 100644 --- a/Development/client/src/app/app.profile.component.ts +++ b/Development/client/src/app/app.profile.component.ts @@ -1,138 +1,81 @@ -import { Component, Input, Output, EventEmitter } from '@angular/core'; -import { of } from 'rxjs'; -import { catchError } from 'rxjs/operators'; -import { globals } from './shared/global'; -import { UserModel } from './auth/models/user.model'; -import { UserService } from './domain/services/user.service'; -import { ExpiryWarning } from './domain/models/subscription.model'; +import { AppMainComponent } from './app.main.component'; +import { Component } from '@angular/core'; +import { trigger, state, transition, style, animate } from '@angular/animations'; -export function buildExpiryWarningMessage(expiryWarning: ExpiryWarning | null): string { - if (!expiryWarning) return ''; - - if (expiryWarning.noSubs) { - return $localize`:No subscription warning@@noSubsWarning:No current AgMission service subscribed` + - ' - ' + $localize`:Renew@@renewLabel:Renew`; - } - - const messages: string[] = []; - const daysLabel = (days: number) => - days === 0 - ? $localize`:Expiring today@@today:today` - : `${$localize`:In@@in:in`} ${days} ${$localize`:Days@@days:days`}`; - - if (expiryWarning.package) { - const pkg = expiryWarning.package; - const days = pkg.daysUntilExpiry; - const willRenew = pkg.willAutoRenew; - const isTrial = pkg.isTrial; - const isCanceled = pkg.isCanceled; - - if (isCanceled) { - messages.push(`${pkg.name} ${$localize`:Package canceled@@pkgCanceled:canceled - access ended`} - ${$localize`:Renew now@@renewNow:Renew Now`}`); - } else if (isTrial) { - if (willRenew) { - messages.push(`${pkg.name} ${$localize`:Trial renewing@@pkgTrialRenewing:trial ends`} ${daysLabel(days)} - ${$localize`:Will auto-renew@@willAutoRenew:will Auto-Renew`}`); - } else { - messages.push(`${pkg.name} ${$localize`:Trial expiring@@pkgTrialExpiring:trial ends`} ${daysLabel(days)} - ${$localize`:Renew now@@renewNow:Renew Now`}`); - } - } else { - if (willRenew) { - messages.push(`${pkg.name} ${$localize`:Package renewing@@pkgRenewing:renews`} ${daysLabel(days)}`); - } else { - messages.push(`${pkg.name} ${$localize`:Package expiring@@pkgExpiring:expires`} ${daysLabel(days)} - ${$localize`:Renew now@@renewNow:Renew Now`}`); - } - } - } - - if (expiryWarning.addons && expiryWarning.addons.length > 0) { - expiryWarning.addons.forEach(addon => { - const days = addon.daysUntilExpiry; - const willRenew = addon.willAutoRenew; - const isTrial = addon.isTrial; - const isCanceled = addon.isCanceled; - - if (isCanceled) { - messages.push(`${addon.name} ${$localize`:Addon canceled@@addonCanceled:canceled - access ended`} - ${$localize`:Renew now@@renewNow:Renew Now`}`); - } else if (isTrial) { - if (willRenew) { - messages.push(`${addon.name} ${$localize`:Addon trial renewing@@addonTrialRenewing:trial ends`} ${daysLabel(days)} - ${$localize`:Will auto-renew@@willAutoRenew:will Auto-Renew`}`); - } else { - messages.push(`${addon.name} ${$localize`:Addon trial expiring@@addonTrialExpiring:trial ends`} ${daysLabel(days)} - ${$localize`:Renew now@@renewNow:Renew Now`}`); - } - } else { - if (willRenew) { - messages.push(`${addon.name} ${$localize`:Addon renewing@@addonRenewing:renews`} ${daysLabel(days)}`); - } else { - messages.push(`${addon.name} ${$localize`:Addon expiring@@addonExpiring:expires`} ${daysLabel(days)} - ${$localize`:Renew now@@renewNow:Renew Now`}`); - } - } - }); - } - - return messages.join('; '); -} +import { Store } from '@ngrx/store'; +import * as authActions from './auth/actions/auth.actions'; @Component({ selector: "app-inline-profile", - templateUrl: "./app.profile.component.html", - styleUrls: ['./app.profile.component.css'] + template: ` + + + + `, + animations: [ + trigger('menu', [ + state('hidden', style({ + height: '0px' + })), + state('visible', style({ + height: '*' + })), + transition('visible => hidden', animate('400ms cubic-bezier(0.86, 0, 0.07, 1)')), + transition('hidden => visible', animate('400ms cubic-bezier(0.86, 0, 0.07, 1)')) + ]) + ], }) export class AppInlineProfileComponent { - readonly globals = globals; + active: boolean; - @Input() user: UserModel; - @Input() expiryWarning: ExpiryWarning | null; - @Output() navigateToSubscription = new EventEmitter(); + constructor( + private readonly store: Store<{}>, + public readonly app: AppMainComponent) {} - showMasterPopup = false; - masterInfo: { username: string; contact?: string; name?: string; phone?: string; email?: string } | null = null; - private masterInfoFetchedAt: number | null = null; - private readonly MASTER_INFO_TTL_MS = 2 * 60 * 1000; // re-fetch after 2 minutes - - constructor(readonly userSvc: UserService) { } - - getAccountType(user: UserModel): string { - return this.userSvc.getAccountType(user); + onClick(event) { + this.active = !this.active; + // setTimeout(() => { + // this.app.layoutMenuScrollerViewChild.moveBar(); + // }, 450); + event.preventDefault(); } - getWarningMessage(): string { - return buildExpiryWarningMessage(this.expiryWarning); - } - - onWarningClick(): void { - // Always navigate to subscription for all accounts - this.navigateToSubscription.emit(); - // Show master-account info popup only for sub-accounts: - // skip if no parent, or parent is the same as this user (self-referencing master) - const parentId = this.user?.parent; - if (!parentId || parentId === this.user._id) return; - - const now = Date.now(); - const isFresh = this.masterInfoFetchedAt !== null && (now - this.masterInfoFetchedAt) < this.MASTER_INFO_TTL_MS; - if (isFresh) { - this.showMasterPopup = true; - return; - } - this.userSvc.getUser(parentId, { view: 'profile' }).pipe( - catchError(() => of(null)) - ).subscribe(master => { - if (master) { - this.masterInfo = { - username: master.username ?? '', - contact: master.contact, - name: master.name, - phone: master.phone, - email: master.email, - }; - } else { - // Fallback: show whatever the parent field holds (may be a populated object) - const p = this.user.parent; - this.masterInfo = { - username: (typeof p === 'object' && p?.username) ? p.username : '', - }; - } - this.masterInfoFetchedAt = Date.now(); - this.showMasterPopup = true; - }); + switchProfile() {} + onLogout(e) { + this.store.dispatch(new authActions.Logout()); + e.preventDefault(); } } diff --git a/Development/client/src/app/app.topbar.component.html b/Development/client/src/app/app.topbar.component.html deleted file mode 100644 index 95c5e7f..0000000 --- a/Development/client/src/app/app.topbar.component.html +++ /dev/null @@ -1,68 +0,0 @@ - \ No newline at end of file diff --git a/Development/client/src/app/app.topbar.component.ts b/Development/client/src/app/app.topbar.component.ts index 1774ba8..9102ada 100644 --- a/Development/client/src/app/app.topbar.component.ts +++ b/Development/client/src/app/app.topbar.component.ts @@ -1,89 +1,91 @@ -import { Component, OnInit, OnDestroy } from '@angular/core'; +import { Component, OnDestroy } from '@angular/core'; import { Router } from '@angular/router'; -import { Observable, Subscription, combineLatest } from 'rxjs'; -import { first, filter, switchMap, map } from 'rxjs/operators'; + +import { Subscription } from 'rxjs'; import { Store } from '@ngrx/store'; + import { AppMainComponent } from './app.main.component'; + import * as authActions from './auth/actions/auth.actions'; + import * as fromStore from '../../src/app/reducers/index'; -import { UserModel } from './auth/models/user.model'; -import { ExpiryWarning } from './domain/models/subscription.model'; -import { SUB } from './profile/common'; -import { UserService } from './domain/services/user.service'; @Component({ selector: 'app-topbar', - templateUrl: './app.topbar.component.html' + template: ` + + `, }) -export class AppTopbarComponent implements OnInit, OnDestroy { - user$: Observable; - expiryWarning$: Observable; - private sub$ = new Subscription(); +export class AppTopbarComponent implements OnDestroy { + _user: any; + private sub$: Subscription; + private user$ = this.store.select(fromStore.selectAuthUser); constructor( public readonly app: AppMainComponent, private readonly store: Store<{}>, - private readonly router: Router, - private readonly userSvc: UserService + private router: Router ) { - this.user$ = this.store.select(fromStore.selectAuthUser); - this.expiryWarning$ = combineLatest([ - this.store.select(fromStore.selectExpiryWarning), - this.store.select(fromStore.selectNoSubsWarning) - ]).pipe(map(([expiry, noSubs]) => expiry ?? noSubs)); - } - - ngOnInit(): void { - // Fetch fresh user data from server on component init (page load/reload) - // This ensures header displays current data even if changed externally - this.sub$.add( - this.user$.pipe( - first(), // Only run once on init - filter(user => !!user && !!user._id), // Only if user exists - switchMap(user => this.userSvc.getUser(user._id, { view: 'profile' })) - ).subscribe(freshUser => { - if (freshUser) { - this.store.dispatch(new authActions.RefreshUserData({ - user: this.mapUserToUserModel(freshUser) - })); - } - }) - ); - } - - ngOnDestroy(): void { - this.sub$.unsubscribe(); - } - - /** - * Map User (from API) to UserModel (for store) - * Only maps fields that should be refreshed from server - */ - private mapUserToUserModel(user: any): UserModel { - return { - _id: user._id, - name: user.name || '', - username: user.username || '', - roles: user.roles || [], - parent: user.parent || '', - lang: user.lang || 'en', - pre: user.pre || 0, - billable: user.billable, - membership: user.membership, - contact: user.contact || '' - }; - } - - manageServices() { - return this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]); - } - - manageBilling() { - this.router.navigate([SUB.PROFILE, SUB.PM_HISTORY]); - } - - manageContact(user) { - this.router.navigate([SUB.PROFILE, SUB.BILL_ADR_LIST]); + this.sub$ = this.user$.subscribe((user) => (this._user = user)); } onLogout(e) { @@ -91,15 +93,19 @@ export class AppTopbarComponent implements OnInit, OnDestroy { e.preventDefault(); } - updateUserProfile(userId: string) { - this.router.navigate([SUB.PROFILE, 'edit', userId]); + updateUserProfile() { + this.router.navigate(['profile', this._user._id]); } - /** - * Navigate to manage subscription page - * Triggered by subscription expiry notification click - */ - onNavigateToManageSubscription(): void { - this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]); + manageServices() { + this.router.navigate(['profile/myservices', this._user._id]); + } + + manageBilling() { + this.router.navigate(['profile/mybills', this._user._id]); + } + + ngOnDestroy(): void { + if (this.sub$) this.sub$.unsubscribe(); } } diff --git a/Development/client/src/app/auth/actions/auth.actions.ts b/Development/client/src/app/auth/actions/auth.actions.ts index 68d3c32..aaa4fd2 100644 --- a/Development/client/src/app/auth/actions/auth.actions.ts +++ b/Development/client/src/app/auth/actions/auth.actions.ts @@ -1,7 +1,6 @@ import { Action } from '@ngrx/store'; import { Authenticate } from '../models/auth.model'; import { UserModel } from '../models/user.model'; -import { Plan } from '@app/domain/models/subscription.model'; export const LOGIN = '[Login Page] Login'; export class Login implements Action { @@ -34,14 +33,7 @@ export class Logout implements Action { export const LOGOUT_COMPLETE = '[Auth API] Logout Complete'; export class LogoutComplete implements Action { readonly type: typeof LOGOUT_COMPLETE = LOGOUT_COMPLETE; - -} - -export const REFRESH_USER_DATA = '[Auth] Refresh User Data'; -export class RefreshUserData implements Action { - readonly type: typeof REFRESH_USER_DATA = REFRESH_USER_DATA; - - constructor(public payload: { user: UserModel }) { } + } export type All = @@ -49,5 +41,4 @@ export type All = | LoginSuccess | LoginFailed | Logout - | LogoutComplete - | RefreshUserData; + | LogoutComplete; diff --git a/Development/client/src/app/auth/effects/auth.effects.ts b/Development/client/src/app/auth/effects/auth.effects.ts index bf2d87d..3a3cfa3 100644 --- a/Development/client/src/app/auth/effects/auth.effects.ts +++ b/Development/client/src/app/auth/effects/auth.effects.ts @@ -3,13 +3,16 @@ import { Router } from '@angular/router'; import { of } from 'rxjs'; import { Actions, Effect, ofType } from '@ngrx/effects'; import { map, exhaustMap, catchError, tap } from 'rxjs/operators'; + import { Store } from '@ngrx/store'; import * as authActions from '../actions/auth.actions'; import * as clientActions from '@app/client/actions/client.actions'; + import { ClientService } from '@app/domain/services/client.service'; import { AuthService } from '@app/domain/services/auth.service'; import { globals } from '@app/shared/global'; + @Injectable() export class AuthEffects { @Effect() @@ -17,17 +20,17 @@ export class AuthEffects { .pipe( ofType(authActions.LOGIN), map(action => action.payload), - exhaustMap(auth => { - return this.authSvc.login(auth).pipe( + exhaustMap(auth => + this.authSvc.login(auth).pipe( map(user => { - return new authActions.LoginSuccess({ user }) + return new authActions.LoginSuccess({ user: user }); }), catchError(err => { const errTag = (err.error && err.error.error) ? err.error.error['.tag'] : err.message || ''; return of(new authActions.LoginFailed(globals.apiErrorMsg(errTag))); }), - ) - }) + ), + ), ); @Effect({ dispatch: false }) @@ -50,9 +53,8 @@ export class AuthEffects { private navigateDefault(lang) { const hash = (this.router.url.indexOf('#') == -1) ? '/#/' : '/'; - const returnUrl = this.router.parseUrl(this.router.url).queryParams['returnUrl'] || 'home'; - // Replace the current page with the next target url => prevent Back to previous - window.location.replace((lang === 'en' ? `${hash}` : `/${lang}${hash}`) + returnUrl); + // Replace the current page with the next target url => prevent Back to previous + window.location.replace((lang === 'en' ? `${hash}` : `/${lang}${hash}`) + 'home'); } @Effect() diff --git a/Development/client/src/app/auth/login/login.component.html b/Development/client/src/app/auth/login/login.component.html index fa5e38a..196a5b6 100644 --- a/Development/client/src/app/auth/login/login.component.html +++ b/Development/client/src/app/auth/login/login.component.html @@ -12,11 +12,8 @@
- - + + {{ userValidMsg() }} @@ -24,29 +21,22 @@
- - Password - is required + + Password is required
- + - You must complete the reCAPTCHA to log in. + You must complete the reCAPTCHA to log in.
-
\ No newline at end of file diff --git a/Development/client/src/app/auth/login/login.component.ts b/Development/client/src/app/auth/login/login.component.ts index dd95a1c..7c705a9 100644 --- a/Development/client/src/app/auth/login/login.component.ts +++ b/Development/client/src/app/auth/login/login.component.ts @@ -1,7 +1,5 @@ import { Component, OnInit, OnDestroy, ViewChild, isDevMode } from '@angular/core'; import { ReCaptcha2Component } from 'ngx-captcha'; -import { Subject } from 'rxjs'; -import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; import { Authenticate } from '../models/auth.model'; import * as authActions from '../actions/auth.actions'; @@ -38,57 +36,23 @@ export class LoginComponent extends BaseComp implements OnInit, OnDestroy { public captchaSuccess = false; private _lastVerReqAt: number = 0; - // Debounced validation to prevent flash error on Chrome autofill - public showUsernameError = false; - public showPasswordError = false; - private usernameValidation$ = new Subject(); - private passwordValidation$ = new Subject(); - constructor( ) { super(); this['name'] = "LoginComp"; - const nav = this.router.getCurrentNavigation(); - if (nav) { - const msgs: any[] = []; - const state = nav.extras?.state; - if (state?.changedPwd) { - msgs.push({ severity: 'info', summary: '', detail: globals.pwdChangedOk }); + if (this.router.getCurrentNavigation()) { + const routeSate = this.router.getCurrentNavigation().extras && this.router.getCurrentNavigation().extras.state; + if (routeSate && routeSate.changedPwd) { + this.msgs = [{ severity: 'info', summary: '', detail: globals.pwdChangedOk }]; } - const returnUrl = nav.finalUrl?.queryParams?.['returnUrl'] ?? nav.extractedUrl?.queryParams?.['returnUrl']; - const loginNotice = nav.finalUrl?.queryParams?.['loginNotice'] ?? nav.extractedUrl?.queryParams?.['loginNotice']; - if (loginNotice) { - msgs.push({ severity: 'info', summary: '', detail: loginNotice }); - } - if (msgs.length) this.msgs = msgs; } } ngOnInit() { this.lang = this.authSvc.locale; - // Debounce username validation by 100ms to handle Chrome autofill race condition - this.sub$.add( - this.usernameValidation$.pipe( - debounceTime(100), - distinctUntilChanged() - ).subscribe(showError => { - this.showUsernameError = showError; - }) - ); - - // Debounce password validation by 100ms to handle Chrome autofill race condition - this.sub$.add( - this.passwordValidation$.pipe( - debounceTime(100), - distinctUntilChanged() - ).subscribe(showError => { - this.showPasswordError = showError; - }) - ); - this.useReCaptcha && ( this.sub$.add(this.appActions.ofTypes([authActions.LOGIN_FAILED]).subscribe(action => { this.captchaElem.resetCaptcha(); @@ -109,22 +73,6 @@ export class LoginComponent extends BaseComp implements OnInit, OnDestroy { return StringUtils.isEmpty(this.model.username) ? globals.usernameReqVal : globals.usernameInvalidVal; } - /** - * Emits username validation state with debounce to prevent flash on Chrome autofill. - * Called on input and blur events. - */ - onUsernameValidation(invalid: boolean, dirty: boolean, touched: boolean) { - this.usernameValidation$.next(invalid && (dirty || touched)); - } - - /** - * Emits password validation state with debounce to prevent flash on Chrome autofill. - * Called on input and blur events. - */ - onPasswordValidation(invalid: boolean, dirty: boolean, touched: boolean) { - this.passwordValidation$.next(invalid && (dirty || touched)); - } - handleSuccess(captchaResp: string): void { // Verify user reponse token with server side within 2 minutes according to GG Ref: https://developers.google.com/recaptcha/docs/verify this._lastVerReqAt = Date.now(); diff --git a/Development/client/src/app/auth/models/user.model.ts b/Development/client/src/app/auth/models/user.model.ts index 0f9bc3b..5671e6c 100644 --- a/Development/client/src/app/auth/models/user.model.ts +++ b/Development/client/src/app/auth/models/user.model.ts @@ -1,5 +1,3 @@ -import { AGNavSubscription, Trial } from "@app/domain/models/subscription.model"; - export interface UserModel { _id: string; name: string; @@ -9,19 +7,11 @@ export interface UserModel { lang: string; pre: number; billable?: boolean; - membership?: IMembership, - contact: string; - country?: string; - partner?: string; + membership?: IMembership } export interface IMembership { - custId: string; - endOfPeriod?: Number; - subscriptions?: AGNavSubscription[]; - trials?: Trial; - customLimits?: { - maxVehicles?: number | null; - maxAcres?: number | null; - }; -} + status: string; + endPeriod: number, + subTier: string; // 'essential', 'enterprise' +} \ No newline at end of file diff --git a/Development/client/src/app/billing/usage-list/usage-list.component.html b/Development/client/src/app/billing/usage-list/usage-list.component.html index 3f87983..0ba7a9c 100644 --- a/Development/client/src/app/billing/usage-list/usage-list.component.html +++ b/Development/client/src/app/billing/usage-list/usage-list.component.html @@ -3,7 +3,7 @@
- From + From
@@ -23,7 +23,7 @@
- Customer Spray Overview + Customer Spray Overview
@@ -57,12 +57,12 @@ - Total Spray + Total Spray
{{totals[col.field]}} -
{{totals[col.field] | number:'1.1-1':'en'}} ha
-
{{haToAcres(totals[col.field]) | number:'1.1-1':'en'}} ac
+
{{totals[col.field] | number:'1.1-1':'en'}} ha
+
{{haToAcres(totals[col.field]) | number:'1.1-1':'en'}} ac
diff --git a/Development/client/src/app/client/client.module.ts b/Development/client/src/app/client/client.module.ts index fcce23c..f325a52 100644 --- a/Development/client/src/app/client/client.module.ts +++ b/Development/client/src/app/client/client.module.ts @@ -17,7 +17,7 @@ import { ToastModule } from 'primeng/toast'; import { StoreModule } from '@ngrx/store'; import { EffectsModule } from '@ngrx/effects'; import { ClientEffects } from './effects/client.effects'; -import * as fromClients from './reducers/clients.reducer'; +import * as fromClients from './reducers/clients-reducer'; import { ClientListComponent } from './client-list/client-list.component'; import { ClientsRoutingModule } from './client-routing.module'; diff --git a/Development/client/src/app/client/effects/client.effects.ts b/Development/client/src/app/client/effects/client.effects.ts index eeaf86c..a49a95d 100644 --- a/Development/client/src/app/client/effects/client.effects.ts +++ b/Development/client/src/app/client/effects/client.effects.ts @@ -25,7 +25,7 @@ export class ClientEffects { loadClients$: Observable = this.actions$.pipe( ofType(clientActions.FETCH), switchMap(() => - this.clientSvc.loadClients({ byPuid: this.authSvc.user.parent }).pipe( + this.clientSvc.loadClients({ byUserId: this.authSvc.user.parent }).pipe( map(clients => new clientActions.FetchSuccess(clients)), catchError(err => { this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.load).replace('#thing#', globals.clients)); diff --git a/Development/client/src/app/client/reducers/clients.reducer.ts b/Development/client/src/app/client/reducers/clients-reducer.ts similarity index 100% rename from Development/client/src/app/client/reducers/clients.reducer.ts rename to Development/client/src/app/client/reducers/clients-reducer.ts diff --git a/Development/client/src/app/client/reducers/index.ts b/Development/client/src/app/client/reducers/index.ts index 9bbdd23..7ae8906 100644 --- a/Development/client/src/app/client/reducers/index.ts +++ b/Development/client/src/app/client/reducers/index.ts @@ -3,64 +3,31 @@ import { createFeatureSelector, } from '@ngrx/store'; -import * as fromClients from './clients.reducer'; +import * as fromClients from './clients-reducer'; export const getClientsState = createFeatureSelector(fromClients.FEATURE_KEY); -// Safe wrapper to handle undefined state during lazy module loading -export const getClientsStateOrInitial = createSelector( - getClientsState, - (state) => { - if (!state) { - return { - ids: [], - entities: {}, - loading: false, - loaded: false, - selectedId: null - }; - } - return state; - } -); - export const getSelectedClientId = createSelector( - getClientsStateOrInitial, + getClientsState, fromClients.getSelectedId ); export const isLoading = createSelector( - getClientsStateOrInitial, + getClientsState, fromClients.getIsLoading ); export const isLoaded = createSelector( - getClientsStateOrInitial, + getClientsState, fromClients.getIsLoaded ); -// Entity selectors wrapped for safety during lazy loading -const entitySelectors = fromClients.adapter.getSelectors(getClientsStateOrInitial); - -export const getClientsIds = createSelector( - entitySelectors.selectIds, - (ids) => ids || [] -); - -export const getClientEntities = createSelector( - entitySelectors.selectEntities, - (entities) => entities || {} -); - -export const getAllClients = createSelector( - entitySelectors.selectAll, - (clients) => clients || [] -); - -export const getTotalClients = createSelector( - entitySelectors.selectTotal, - (total) => total || 0 -); +export const { + selectIds: getClientsIds, + selectEntities: getClientEntities, + selectAll: getAllClients, + selectTotal: getTotalClients, +} = fromClients.adapter.getSelectors(getClientsState); export const getSelectedClient = createSelector( getClientEntities, diff --git a/Development/client/src/app/customers/customer-edit/customer-edit.component.css b/Development/client/src/app/customers/customer-edit/customer-edit.component.css index d196689..e69de29 100644 --- a/Development/client/src/app/customers/customer-edit/customer-edit.component.css +++ b/Development/client/src/app/customers/customer-edit/customer-edit.component.css @@ -1,128 +0,0 @@ -.theme-color { - color: #4caf50; -} - -ul { - padding-inline-start: 20px; -} - -/* Partner Selection Integration Styles */ - -.partner-option { - display: flex; - align-items: center; - padding: 8px 0; -} - -.partner-info { - display: flex; - flex-direction: column; -} - -.partner-name { - font-weight: 500; - color: #333; -} - -.partner-description { - font-size: 0.85em; - color: #666; - margin-top: 2px; -} - -.partner-selected { - display: flex; - align-items: center; -} - -.partner-config-section { - margin-top: 20px; - padding: 20px; - border: 1px solid #dee2e6; - border-radius: 4px; - background-color: #f8f9fa; -} - -.partner-config-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 15px; -} - -.partner-config-header h3 { - margin: 0; - color: #333; - font-size: 1.1em; -} - -.partner-loading { - display: flex; - align-items: center; - color: #007bff; - padding: 10px; - background-color: #e7f3ff; - border: 1px solid #b3d9ff; - border-radius: 4px; - margin-bottom: 15px; -} - -.partner-error { - display: flex; - align-items: center; - color: #dc3545; - padding: 10px; - background-color: #f8d7da; - border: 1px solid #f5c6cb; - border-radius: 4px; - margin-bottom: 15px; -} - -.satloc-config-section { - padding: 15px; - background-color: #ffffff; - border: 1px solid #ddd; - border-radius: 4px; -} - -.config-description { - margin-bottom: 15px; - color: #666; - font-size: 0.9em; -} - -.config-placeholder { - display: flex; - align-items: center; - padding: 15px; - background-color: #e8f4fd; - border: 1px solid #b3d9ff; - border-radius: 4px; - color: #0c5aa6; -} - -/* Common label span for form fields */ -.form-label-span { - margin-right: 12px; -} - -/* Responsive Design */ -@media (max-width: 768px) { - .partner-config-header { - flex-direction: column; - align-items: flex-start; - gap: 10px; - } - - .partner-option { - padding: 12px 0; - } - - .partner-config-section { - padding: 15px; - } -} - -.partner-selected>span { - font-weight: 600; -} \ No newline at end of file diff --git a/Development/client/src/app/customers/customer-edit/customer-edit.component.html b/Development/client/src/app/customers/customer-edit/customer-edit.component.html index 575b5cb..8ef753f 100644 --- a/Development/client/src/app/customers/customer-edit/customer-edit.component.html +++ b/Development/client/src/app/customers/customer-edit/customer-edit.component.html @@ -5,16 +5,14 @@
- +
- + Premium Level: - + {{ type.label }} @@ -23,121 +21,22 @@
-
- - {{ Labels.FROM_PARTNER }}: - - - -
-
-
{{ option.label }}
-
{{ - option.value.description }}
-
-
-
- -
- {{ option.label }} -
{{ option.value.description }}
-
-
-
- - -
- - {{ partnerError }} -
-
- -
- -
- -
- - - - - - - - - - - - - - - - - - +
- +
- - + +
-
- - - -
-
    -
  • {{SubTexts.lastStartDate}}: {{toTimestamp(trials.lastStartDate) | tsToDate: lang}}
  • -
  • {{SubTexts.lastEndDate}}: {{toTimestamp(trials.lastEndDate) | tsToDate: lang}}
  • -
-
-
- - -
- {{SubTexts.labelSub}} -
-
- -
- -
    -
  • - {{fullPkg.name}} -
    - - {{SubTexts.startDate}}: {{sub.periodStart | tsToDate: lang}}     - {{SubTexts.endDate}}: {{sub.periodEnd | tsToDate: lang}} - -
    -
  • -
-
-
-
-
- -
-
-
-
\ No newline at end of file +
\ No newline at end of file diff --git a/Development/client/src/app/customers/customer-edit/customer-edit.component.ts b/Development/client/src/app/customers/customer-edit/customer-edit.component.ts index 5eaba87..e9f6d8e 100644 --- a/Development/client/src/app/customers/customer-edit/customer-edit.component.ts +++ b/Development/client/src/app/customers/customer-edit/customer-edit.component.ts @@ -1,45 +1,33 @@ import { Component, OnInit, OnDestroy } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { FormGroup, FormBuilder } from '@angular/forms'; + + + import { SelectItem } from 'primeng/api'; -import { Customer, Partner } from '../models/customer.model'; + +import { Customer } from '../models/customer.model'; import * as customerActions from '../actions/customer.actions'; + import { UserService } from '@app/domain/services/user.service'; -import { PartnerService } from '@app/partners/services/partner.service'; import { BaseComp } from '@app/shared/base/base.component'; -import { GC, RoleIds, globals, Labels } from '@app/shared/global'; -import { AGNavSubscription, Trial } from '@app/domain/models/subscription.model'; -import { SubStripe, SubTexts } from '@app/profile/common'; -import { IMembership } from '@app/auth/models/user.model'; -import { DateUtils } from '@app/shared/utils'; +import { RoleIds, globals } from '@app/shared/global'; @Component({ selector: 'agm-customer-edit', templateUrl: './customer-edit.component.html', styleUrls: ['./customer-edit.component.css'] }) -export class CustomerEditComponent extends BaseComp implements OnInit { +export class CustomerEditComponent extends BaseComp implements OnInit, OnDestroy { readonly globals = globals; - readonly SubTexts = SubTexts; - readonly Labels = Labels; form: FormGroup; selectedItem: Customer; + premiumLevels: SelectItem[]; + msgs = []; - trialDays: number[]; - trialSubs: AGNavSubscription[]; - paidSubs: AGNavSubscription[]; - trials: Trial; - membership: IMembership; - lang; - - // Partner Selection Properties - partnerOptions: SelectItem[] = []; - partnerLoading = false; - partnerError: string | null = null; - private _customer: Customer; get customer(): Customer { return this._customer; } set customer(customer: Customer) { @@ -50,12 +38,7 @@ export class CustomerEditComponent extends BaseComp implements OnInit { account: { active: this.selectedItem.active, username: this.selectedItem.username, password: this.selectedItem.password }, premium: this.selectedItem.premium, billable: this.selectedItem.billable, - trials: this.selectedItem.membership?.trials, - partner: this.selectedItem.partner || null }); - - // Set partner selection based on customer.partner field, or null if not set - // Form control will be updated by loadPartners() method } private _isNew: boolean; @@ -66,8 +49,8 @@ export class CustomerEditComponent extends BaseComp implements OnInit { constructor( private readonly route: ActivatedRoute, private readonly userSvc: UserService, - private readonly partnerSvc: PartnerService, - private readonly fb: FormBuilder + + private readonly fb: FormBuilder, ) { super(); this.premiumLevels = [ @@ -81,13 +64,7 @@ export class CustomerEditComponent extends BaseComp implements OnInit { account: [], premium: [], billable: [], - trials: [], - // Partner form control - partner: [null] }); - this.lang = this.authSvc.locale; - - } ngOnInit() { @@ -97,86 +74,23 @@ export class CustomerEditComponent extends BaseComp implements OnInit { if (customer) { this._isNew = (customer._id === '0'); this.customer = customer; - this.membership = this.customer?.membership; - - if (this.membership) { - this.trials = this.membership.trials; - this.trialSubs = this.membership.subscriptions?.filter((sub) => sub.status === SubStripe.TRIALING) || []; - this.paidSubs = this.membership.subscriptions?.filter((sub) => sub.status !== SubStripe.TRIALING) || []; - } - // Load partners from service - this.loadPartners(); } }); - this.sub$.add(this.appActions.ofTypes([customerActions.CREATE_SUCCESS, customerActions.UPDATE_SUCCESS]) .subscribe((action) => { this.store.dispatch(new customerActions.Select(action['payload'])); this.goBack(); })); - - this.trialDays = this.appConf.settings.trialDays; } - hasLastEndedTrial() { - return this.authSvc.hasLastEndedTrial(this.trials); - } - - hasPaidSubs() { - return this.paidSubs?.length > 0; - } - - hasTrialSubs() { - return this.trialSubs?.length > 0; - } saveCustomer() { if (!this.form || !this.form.value || !this.form.valid) return; + this.msgs = []; - let custObj; - - const updateTrialMembship = (membership?) => { - // Get trials value from form control (includes disabled controls via ControlValueAccessor) - const trialsControl = this.form.get('trials'); - const trialsValue = trialsControl ? trialsControl.value : null; - - if (trialsValue?.selected) { - const trials: Trial = { ...trialsValue }; - delete trials.selected; - - // If type is null, but trialDays or byDate exist, set type accordingly - if (trials.type == null) { - if (trials.trialDays && trials.trialDays > 0) { - trials.type = GC.DAYS; - } else if (trials.byDate) { - trials.type = GC.BYDATE; - } - } - if (trials.type === GC.BYDATE) { - trials.trialDays = 0; - } else { - trials.byDate = null; - } - trials.startDate = DateUtils.tsToDate(DateUtils.currUTC()); - return membership - ? { ...membership, trials } - : { trials }; - } else { - return membership - ? { ...membership, trials: { ...membership.trials, type: null } } - : { trials: { type: null } }; - } - } - - custObj = Object.assign(this.selectedItem, this.form.value.profile, this.form.value.account, - { premium: this.form.value.premium || false }, - { billable: this.form.value.billable || false }, - { partner: this.form.value.partner || null }); - - this.membership - ? custObj = Object.assign(custObj, { membership: updateTrialMembship(this.membership) }) - : custObj = Object.assign(custObj, { membership: updateTrialMembship() }); + const custObj = Object.assign(this.selectedItem, this.form.value.profile, this.form.value.account, + { premium: this.form.value.premium || false }, { billable: this.form.value.billable || false }); this.store.dispatch(this._isNew ? new customerActions.Create(custObj) : new customerActions.Update(custObj)); } @@ -205,64 +119,6 @@ export class CustomerEditComponent extends BaseComp implements OnInit { this.router.navigate(['../', { id: this.customer._id }]); } - toTimestamp(date: Date): number { - return DateUtils.dateToTS(date); - } - - // Partner Methods - private loadPartners(): void { - this.partnerLoading = true; - this.partnerError = null; - - this.partnerSvc.getPartners().subscribe({ - next: (partners: Partner[]) => { - // Create dropdown options starting with "None" option for AgNav direct customers - this.partnerOptions = [ - { - label: Labels.NONE_AGNAV_DIRECT_CUSTOMER, - value: null // null value indicates AgNav direct customer - }, - // Add active partners - ...partners - .filter(partner => partner.active) // Only show active partners - .map(partner => ({ - label: partner.name, - value: partner - })) - ]; - - // Set selectedPartner based on existing customer partner - if (this.customer?.partner && this.partnerOptions.length > 0) { - // Find the partner in options that matches the customer's current partner _id - const matchingOption = this.partnerOptions.find(option => - option.value && option.value._id === this.customer.partner._id - ); - if (matchingOption) { - this.form.patchValue({ partner: matchingOption.value }); - } - } else if (!this.customer?.partner) { - // If no partner is set, default to "None" (AgNav direct customer) - this.form.patchValue({ partner: null }); - } - this.partnerLoading = false; - }, - error: (error) => { - this.partnerError = Labels.FAILED_TO_LOAD_PARTNERS; - this.partnerLoading = false; - console.error('Error loading partners:', error); - } - }); - } - - onPartnerChange(selectedPartner: Partner | null): void { - this.partnerError = null; - - // Update customer partner field - if (this.customer) { - this.customer.partner = selectedPartner; - } - } - ngOnDestroy() { super.ngOnDestroy(); } diff --git a/Development/client/src/app/customers/customer-list/customer-list.component.html b/Development/client/src/app/customers/customer-list/customer-list.component.html index 6db1445..c0ddee0 100644 --- a/Development/client/src/app/customers/customer-list/customer-list.component.html +++ b/Development/client/src/app/customers/customer-list/customer-list.component.html @@ -3,15 +3,7 @@
-
-
- Customer List -
-
- - -
-
+ Customer List
@@ -26,32 +18,26 @@
- - - - + - - - - - {{col.header}} - {{rowData[col.field] | date:'shortDate'}} - - - - - - - {{rowData[col.field]?.name}} - {{rowData[col.field]}} + + + {{cust.name}} + {{cust.username}} + {{cust.contact}} + {{cust.totalJobs}} + {{cust.createdAt | date:'shortDate' }} + + + + + - {{ state.totalRecords | i18nPlural: totalItems }} diff --git a/Development/client/src/app/customers/customer-list/customer-list.component.ts b/Development/client/src/app/customers/customer-list/customer-list.component.ts index e023601..f856f6f 100644 --- a/Development/client/src/app/customers/customer-list/customer-list.component.ts +++ b/Development/client/src/app/customers/customer-list/customer-list.component.ts @@ -7,7 +7,7 @@ import { Table } from 'primeng/table'; import { Customer } from '../models/customer.model'; import * as fromCustomers from '../reducers'; import * as customerActions from '../actions/customer.actions'; -import { globals, OperationalStatus } from '@app/shared/global'; +import { globals } from '@app/shared/global'; import { BaseComp } from '@app/shared/base/base.component'; @@ -17,26 +17,19 @@ import { BaseComp } from '@app/shared/base/base.component'; styleUrls: ['./customer-list.component.css'] }) export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy { - readonly CREATED = 'createdAt'; - readonly ACTIVE = OperationalStatus.ACTIVE; - readonly BILLABLE = 'billable'; - readonly PARTNER = 'partner'; - readonly PARTNER_NAME = 'partnerName'; customers: Array; curCust: Customer; @ViewChild("dt") dt: Table; - statuses: SelectItem[]; - partners: SelectItem[]; + statuses: SelectItem[]; cols: any[]; totalItems; - isSelfSignup = false; constructor( private readonly route: ActivatedRoute, - + ) { super(); this.totalItems = { '=0': '', '=1': '1 ' + $localize`:@@customer:customer`.toLocaleLowerCase(), 'other': $localize`:@@total#Customers:Total: # customers` }; @@ -51,52 +44,23 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy { field: "username", header: globals.userName, filtered: true, filterMatchMode: 'contains' }, { field: "contact", header: globals.contact }, { field: "totalJobs", header: globals.jobs, width: '5%', filtered: false }, - { field: this.CREATED, header: globals.from, width: '6%' }, - { field: this.BILLABLE, header: "Billable", width: '9%' }, - { field: this.ACTIVE, header: globals.active, width: '9%' }, - { field: this.PARTNER_NAME, header: globals.partner, width: '9%' } + { field: "createdAt", header: globals.from, width: '6%' }, + // { field: "email", header: globals.email, filtered: true, filterMatchMode: 'contains' }, + { field: "billable", header: "Billable", width: '9%'}, + { field: "active", header: globals.active, width: '9%' }, ]; } ngOnInit() { - const saved = localStorage.getItem('isSelfSignup'); - this.isSelfSignup = saved === 'true'; - - this.sub$ = this.store.select(fromCustomers.getAllCustomers).subscribe(customers => { - this.setCustomersAndPartners(customers); - }); + this.sub$ = this.store.select(fromCustomers.getAllCustomers).subscribe( + (customers) => this.customers = customers); this.sub$.add(this.store.select(fromCustomers.getSelectedCustomer).subscribe(cust => { this.curCust = cust; })); - this.store.dispatch(new customerActions.Fetch()); } - private setCustomersAndPartners(customers: Customer[]) { - const filtered = this.isSelfSignup ? customers.filter(c => c.selfSignup) : customers; - this.customers = filtered.map(c => ({ - ...c, - partnerName: c.partner?.name || null - })); - this.partners = [ - { label: globals.all, value: null }, - ...customers - .filter(c => c.partner) - .map(c => c.partner.name) - .filter((v, i, a) => a.indexOf(v) === i) - .map(name => ({ label: name, value: name })) - ]; - } - - onToggle(event: any): void { - this.isSelfSignup = event.checked; - localStorage.setItem('isSelfSignup', String(this.isSelfSignup)); - this.store.select(fromCustomers.getAllCustomers).subscribe(customers => { - this.setCustomersAndPartners(customers); - }); - } - onRowSelect(event) { this.store.dispatch(new customerActions.Select(event.data)); } @@ -124,6 +88,10 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy }); } + billableOverview() { + + } + ngOnDestroy() { super.ngOnDestroy(); } diff --git a/Development/client/src/app/customers/customer-resolver.service.ts b/Development/client/src/app/customers/customer-resolver.service.ts index c4ee87b..9ed5cdb 100644 --- a/Development/client/src/app/customers/customer-resolver.service.ts +++ b/Development/client/src/app/customers/customer-resolver.service.ts @@ -24,7 +24,7 @@ export class CustomerResolver implements Resolve { if (id === '0') { return createNewCustomer(); } else { - return this.customerService.getCustomer(id, 'edit').pipe( + return this.customerService.getCustomer(id).pipe( map((cust) => { if (cust) { return cust; diff --git a/Development/client/src/app/customers/customer-routing.module.ts b/Development/client/src/app/customers/customer-routing.module.ts index 23b82cb..a033031 100644 --- a/Development/client/src/app/customers/customer-routing.module.ts +++ b/Development/client/src/app/customers/customer-routing.module.ts @@ -19,6 +19,7 @@ const routes: Routes = [ roles: [RoleIds.ADMIN] }, canActivate: [AuthGuard], + // canActivateChild: [AuthGuard], children: [ { path: '', @@ -32,7 +33,7 @@ const routes: Routes = [ component: CustomerEditComponent, data: { roles: [RoleIds.ADMIN] - }, + }, // canDeactivate: [CanDeactivateGuard], resolve: [CustomerResolver] }, ] diff --git a/Development/client/src/app/customers/customer.module.ts b/Development/client/src/app/customers/customer.module.ts index 63f2b9f..d83ecda 100644 --- a/Development/client/src/app/customers/customer.module.ts +++ b/Development/client/src/app/customers/customer.module.ts @@ -17,14 +17,13 @@ import { AppSharedModule } from '../shared/app-shared.module'; import { StoreModule } from '@ngrx/store'; import { EffectsModule } from '@ngrx/effects'; -import * as fromCustomers from './reducers/customers.reducer'; +import * as fromCustomers from './reducers/customers-reducer'; import { CustomerEffects } from './effects/customer.effects'; import { CustomerListComponent } from './customer-list/customer-list.component'; import { CustomerEditComponent } from './customer-edit/customer-edit.component'; import { CustomersRoutingModule } from './customer-routing.module'; import { CustomerMgtComponent } from './customer-mgt.component'; -import { TrialComponent } from './trial/trial.component'; @NgModule({ imports: [ @@ -46,7 +45,7 @@ import { TrialComponent } from './trial/trial.component'; EffectsModule.forFeature([CustomerEffects]), CustomersRoutingModule ], - declarations: [CustomerMgtComponent, CustomerListComponent, CustomerEditComponent, TrialComponent], + declarations: [CustomerMgtComponent, CustomerListComponent, CustomerEditComponent], providers: [], schemas: [ CUSTOM_ELEMENTS_SCHEMA diff --git a/Development/client/src/app/customers/models/customer.model.ts b/Development/client/src/app/customers/models/customer.model.ts index d855f91..77067e7 100644 --- a/Development/client/src/app/customers/models/customer.model.ts +++ b/Development/client/src/app/customers/models/customer.model.ts @@ -1,31 +1,17 @@ import { RoleIds } from '@app/shared/global'; import { createNewUser, User } from '@app/accounts/models/user.model'; -import { IMembership } from '@app/auth/models/user.model'; export interface Customer extends User { contact?: string; fax?: string; premium: number; billable?: boolean; - totalJobs?: number; - membership: IMembership, - partner?: Partner; - selfSignup?: boolean; -} -export interface Partner { - _id: string; - name: string; - description: string; - kind: string; // Required to match User interface - active?: boolean; - createdAt?: string; - updatedAt?: string; + totalJobs?: number; // extension field for GUI } export const createNewCustomer = () => { - const customer = createNewUser(null, RoleIds.APP) as Customer; + const customer = createNewUser(null, RoleIds.APP); customer.premium = 0; - customer.membership = {} as IMembership; // Initialize required membership property return customer; -} +} \ No newline at end of file diff --git a/Development/client/src/app/customers/reducers/customers.reducer.ts b/Development/client/src/app/customers/reducers/customers-reducer.ts similarity index 100% rename from Development/client/src/app/customers/reducers/customers.reducer.ts rename to Development/client/src/app/customers/reducers/customers-reducer.ts diff --git a/Development/client/src/app/customers/reducers/index.ts b/Development/client/src/app/customers/reducers/index.ts index 0c25340..c29d0da 100644 --- a/Development/client/src/app/customers/reducers/index.ts +++ b/Development/client/src/app/customers/reducers/index.ts @@ -3,7 +3,7 @@ import { createFeatureSelector, } from '@ngrx/store'; -import * as fromCustomers from './customers.reducer'; +import * as fromCustomers from './customers-reducer'; export const getCustomersState = createFeatureSelector(fromCustomers.FEATURE_KEY); diff --git a/Development/client/src/app/customers/trial/trial.component.css b/Development/client/src/app/customers/trial/trial.component.css deleted file mode 100644 index d2d4a45..0000000 --- a/Development/client/src/app/customers/trial/trial.component.css +++ /dev/null @@ -1,8 +0,0 @@ -.trial-row { - margin-top: 1em; - padding-left: 0; -} - -#day-label { - padding-top: 2px; -} \ No newline at end of file diff --git a/Development/client/src/app/customers/trial/trial.component.html b/Development/client/src/app/customers/trial/trial.component.html deleted file mode 100644 index 5cbd89a..0000000 --- a/Development/client/src/app/customers/trial/trial.component.html +++ /dev/null @@ -1,36 +0,0 @@ -
-
- - - - - -
- - -
- - End in days: - - -
-
{{item.label}}
-
-
-
-
- - {{error}} - -
- - - End date: - - - {{error}} - - -
-
-
\ No newline at end of file diff --git a/Development/client/src/app/customers/trial/trial.component.ts b/Development/client/src/app/customers/trial/trial.component.ts deleted file mode 100644 index ebd070c..0000000 --- a/Development/client/src/app/customers/trial/trial.component.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { AfterContentInit, Component, HostListener, Input, OnDestroy, OnInit, forwardRef } from '@angular/core'; -import { FormBuilder, FormControl, FormGroup, NG_VALIDATORS, NG_VALUE_ACCESSOR } from '@angular/forms'; -import { Trial } from '@app/domain/models/subscription.model'; -import { BaseComp } from '@app/shared/base/base.component'; -import { GC } from '@app/shared/global'; -import { DateUtils } from '@app/shared/utils'; -import { SelectItem } from 'primeng-lts/api'; - -const MIN_DAYS = 7; - -@Component({ - selector: 'trial', - templateUrl: './trial.component.html', - styleUrls: ['./trial.component.css'], - providers: [ - { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => TrialComponent), multi: true }, - { provide: NG_VALIDATORS, useExisting: forwardRef(() => TrialComponent), multi: true } - ] -}) -export class TrialComponent extends BaseComp implements OnDestroy, OnInit, AfterContentInit { - @Input() trialDays: number[]; - @Input() trials: Trial; - @Input() disable: boolean; - - DAYS = GC.DAYS; - BYDATE = GC.BYDATE; - - dayItems: SelectItem[]; - form: FormGroup; - toDate: Date; - error: string; - calMinDate: Date; - calMaxDate: Date; - onChange: any = () => { }; - onTouched: any = () => { }; - - constructor( - private readonly fb: FormBuilder - ) { - super(); - const ONE_YEAR = 1; - this.calMinDate = new Date(); - this.calMinDate.setDate(this.calMinDate.getDate() + MIN_DAYS); - this.calMinDate.setHours(0, 0, 0); - this.calMaxDate = new Date(); - this.calMaxDate.setFullYear(this.calMaxDate.getFullYear() + ONE_YEAR); - } - - get value() { - // CRITICAL: Use getRawValue() to include disabled controls (selected, type, trialDays) - return this.form.getRawValue(); - } - - set value(val) { - this.writeValue(val); - this.onChange(val); - this.onTouched(val); - } - - ngOnInit(): void { - this.form = this.fb.group({ - selected: new FormControl({ value: false, disabled: this.disable }), - type: new FormControl({ value: '', disabled: this.disable }), - startDate: [], - lastEndDate: [], - lastStartDate: [], - trialDays: new FormControl({ value: '', disabled: this.disable }), - byDate: [] - }); - this.dayItems = this.trialDays?.map((day) => ({ label: `${day}`, value: day })); - - // CRITICAL FIX: Use getRawValue() to include disabled controls in onChange callback - this.sub$.add(this.form.valueChanges.subscribe(() => { - const rawValue = this.form.getRawValue(); - this.onChange(rawValue); - this.onTouched(rawValue); - })); - } - - ngAfterContentInit() { - // Check if user has valid trial configuration OR component is disabled (has active trial subscriptions) - const hasExistingTrial = (this.trials?.type && (this.trials.trialDays >= MIN_DAYS || this.trials.byDate)) - || (this.disable && (this.trials?.trialDays >= MIN_DAYS || this.trials?.byDate)); - - if (hasExistingTrial) { - if (this.trials?.type === this.BYDATE && this.trials.byDate) { - this.toDate = new Date(this.trials.byDate); - this.form.patchValue({ ...this.trials, selected: true }); - } else if (this.trials?.type === this.DAYS && this.trials.trialDays >= MIN_DAYS) { - this.form.patchValue({ ...this.trials, selected: true }); - } else if (this.disable && (this.trials?.trialDays >= MIN_DAYS || this.trials?.byDate)) { - // User has active trial subscriptions (disable=true) - always set selected=true - // This prevents accidental trial disable when admin edits customer with active trials - // Determine correct type from trial configuration (byDate takes precedence over trialDays) - const trialType = this.trials?.byDate ? this.BYDATE : this.DAYS; - if (trialType === this.BYDATE) { - this.toDate = new Date(this.trials.byDate); - } - // When controls are disabled, patchValue() ignores them - must use setValue() directly - this.form.get('selected').setValue(true); - this.form.get('type').setValue(trialType); - this.form.get('trialDays').setValue(this.trials.trialDays); - this.form.patchValue({ - startDate: this.trials.startDate, - lastEndDate: this.trials.lastEndDate, - lastStartDate: this.trials.lastStartDate, - byDate: this.trials.byDate - }); - } else { - this.form.patchValue({ ...this.trials }); - } - } - } - - writeValue(val): void { - if (val) { - this.form.patchValue(val); - } - } - - registerOnChange(fn: any): void { - this.onChange = fn; - } - - registerOnTouched(fn: any): void { - this.onTouched = fn; - } - - validate() { - this.checkAndDisplayErr(); - const isTrial = this.form.value.selected; - const isInValid = !this.isValidTrialDays() && !this.isValidBydate(); - return isTrial ? isInValid ? { trials: { valid: false } } : null : null; - } - - isValidTrialDays() { - return this.form.value.type === this.DAYS && this.form.value.trialDays && !isNaN(this.form.value.trialDays) && this.form.value.trialDays >= MIN_DAYS; - } - - isValidBydate() { - return this.form.value.type === this.BYDATE && this.form.value.byDate; - } - - change() { - const noPrevTrial = !this.form.value.type; - if (noPrevTrial) { - this.form.patchValue({ type: this.DAYS }); - } - - const trialDays = this.form.value.trialDays; - const notExistItem = this.dayItems?.every((item) => item.value != trialDays); - if (this.isValidTrialDays() && notExistItem) { - this.dayItems.push({ label: `${trialDays}`, value: Number(trialDays) }); - this.dayItems.sort((a, b) => a.value - b.value); - this.appConf.saveTrialDays(this.dayItems?.map((item) => item.value)); - } - - this.form.updateValueAndValidity(); - } - - changeCal() { - this.form.patchValue({ - ...this.trials, - type: this.BYDATE, - byDate: DateUtils.tsToDate( - DateUtils.endUtcTS( - DateUtils.dateToTS(this.toDate))) - }); - this.form.updateValueAndValidity(); - } - - checkAndDisplayErr() { - this.error = ''; - if (this.form.value.selected && this.form.value.type === this.BYDATE) { - if (!this.toDate) { - return this.error = `Please fill in end date MM/DD/YYYY from `; - } - } else if (this.form.value.selected && this.form.value.type === this.DAYS) { - if (!this.form.value.trialDays) { - return this.error = 'Please fill in the number of end days'; - } else if (isNaN(this.form.value.trialDays) || this.form.value.trialDays < MIN_DAYS) { - return this.error = `Number of days must be number greater than .`; - } - } - return this.error; - } - - remove(item) { - this.dayItems = this.dayItems?.filter((day) => day.label !== item.label); - this.appConf.saveTrialDays(this.dayItems?.map((item) => item.value)); - } - - @HostListener('document:keydown.enter', ['$event']) onKeydownHandler(e: KeyboardEvent) { - const target = e.target; - if (target.name === this.BYDATE) this.changeCal(); - } - - ngOnDestroy(): void { - super.ngOnDestroy(); - } -} diff --git a/Development/client/src/app/dashboard/dashboard.component.css b/Development/client/src/app/dashboard/dashboard.component.css index caa2fcc..e69de29 100644 --- a/Development/client/src/app/dashboard/dashboard.component.css +++ b/Development/client/src/app/dashboard/dashboard.component.css @@ -1,3 +0,0 @@ -.pure-white { - color: #FFFFFF; -} \ No newline at end of file diff --git a/Development/client/src/app/dashboard/dashboard.component.html b/Development/client/src/app/dashboard/dashboard.component.html index a5b7267..7ea0cb8 100644 --- a/Development/client/src/app/dashboard/dashboard.component.html +++ b/Development/client/src/app/dashboard/dashboard.component.html @@ -1,9 +1,5 @@
- -
- - -
+

Welcome to AgMission


@@ -13,5 +9,5 @@

BY ACCESSING AND USING THE APPLICATION, YOU AGREE TO THE TERMS AND CONDITIONS EXPRESSED UPON.

-
-
\ No newline at end of file +
+ \ No newline at end of file diff --git a/Development/client/src/app/dashboard/dashboard.component.ts b/Development/client/src/app/dashboard/dashboard.component.ts index bc82f7c..48810a9 100644 --- a/Development/client/src/app/dashboard/dashboard.component.ts +++ b/Development/client/src/app/dashboard/dashboard.component.ts @@ -1,11 +1,15 @@ -import { Component } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; @Component({ selector: 'agm-dashboard', templateUrl: './dashboard.component.html', styleUrls: ['./dashboard.component.css'] }) -export class DashboardComponent { +export class DashboardComponent implements OnInit { constructor() { } + + ngOnInit() { + } + } diff --git a/Development/client/src/app/domain/guards/auth.guard.ts b/Development/client/src/app/domain/guards/auth.guard.ts index 20ad171..4925624 100644 --- a/Development/client/src/app/domain/guards/auth.guard.ts +++ b/Development/client/src/app/domain/guards/auth.guard.ts @@ -1,137 +1,78 @@ import { Injectable } from '@angular/core'; -import { CanActivate, Router, ActivatedRouteSnapshot, RouterStateSnapshot, CanActivateChild, Route } from '@angular/router'; -import { Observable, of } from 'rxjs'; -import { take, map, catchError, switchMap } from 'rxjs/operators'; +import { + CanActivate, Router, ActivatedRouteSnapshot, RouterStateSnapshot, CanActivateChild, Route, +} from '@angular/router'; + +import { Observable } from 'rxjs'; +import { take, map } from 'rxjs/operators'; + import { Store } from '@ngrx/store'; import * as fromStore from '../../reducers'; + import { AuthService } from '../services/auth.service'; -import { SUB, SubStripe } from '@app/profile/common'; -import { FEATURE_KEY } from '@app/profile/reducers'; -import { Status, StripeSubscription } from '../models/subscription.model'; -import { SubscriptionService } from '../services/subscription.service'; -import { AC } from '@app/shared/global'; -import { RouterUtilsService } from '@app/shared/router-utils.service'; -import { ClearSubscriptionStatus } from '@app/actions/subscription.actions'; + @Injectable({ providedIn: 'root' }) export class AuthGuard implements CanActivate, CanActivateChild { constructor( private readonly store: Store<{}>, - private readonly authSvc: AuthService, - private readonly router: Router, - private readonly subSvc: SubscriptionService, - private readonly routerUtils: RouterUtilsService - ) { } - - canLoad(route: Route): boolean { - return this.checkRoles(route?.data?.roles); + private authService: AuthService, + private router: Router) { } - canActivate(route: ActivatedRouteSnapshot, routerState: RouterStateSnapshot): Observable { - let subs: StripeSubscription[], status: Status; - return this.store.select(fromStore.getSubscriptionState).pipe( - switchMap((subState) => { - subs = subState?.entries; - status = subState?.status; - return this.store.select(fromStore.selectIsLoggedIn) - }), - take(1), + canLoad(route: Route): boolean { + const url = `/${route.path}`; + return this.checkRoles(url, route.data.roles || null); + } + + canActivate( + route: ActivatedRouteSnapshot, + routerState: RouterStateSnapshot): Observable | Promise | boolean { + + return this.checkStoreAuth().pipe( + // mergeMap((storeAuth) => { + // if (storeAuth) + // return of(true); + // return this.checkApiAuth(); + // }), map(storeOrApiAuth => { - const LOCAL_TEMP_FLAG = 'requiredSubAttention'; - const TEMP_FLAG_VALUE = 'true'; - const hasAllowedRoles = this.checkRoles(route); - const hasNotAuth = !storeOrApiAuth; - if (hasNotAuth) { + if (!storeOrApiAuth) { this.router.navigate(['/login'], { replaceUrl: true }); return false; } - - // Early exit for partner users - they bypass all subscription checks - if (this.authSvc.isPartner) { - return hasAllowedRoles; - } - - const requiresResolution = (): boolean => { - const hasUnresolvedSubs = this.authSvc.hasSubsWithStatus(SubStripe.INCOMPLETE) || this.authSvc.hasSubsWithStatus(SubStripe.PAST_DUE) || this.authSvc.hasSubsWithStatus(SubStripe.UNPAID) || this.authSvc.hasSubsWithStatus(SubStripe.OVERDUE) || this.subSvc.hasInValTaxLoc(subs); - if (hasUnresolvedSubs && hasAllowedRoles) { - const hasReqAttentionFlag = localStorage.getItem(LOCAL_TEMP_FLAG) === TEMP_FLAG_VALUE; - const isNavToProfile = routerState.url.includes(SUB.PROFILE); - - if (isNavToProfile) { - if (hasReqAttentionFlag) { - localStorage.removeItem(LOCAL_TEMP_FLAG); - } - return true; - } - - if (!hasReqAttentionFlag) { - const profile = JSON.parse(sessionStorage.getItem(FEATURE_KEY)); - const isFirstLoggin = !profile?.usage || Object.keys(profile?.usage).length === 0; - if (isFirstLoggin) { - localStorage.setItem(LOCAL_TEMP_FLAG, TEMP_FLAG_VALUE); - this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES], { replaceUrl: true }); - return true; - } - const accountNotLocked = this.authSvc.hasSubsWithStatus(SubStripe.PAST_DUE) || this.authSvc.hasSubsWithStatus(SubStripe.OVERDUE) || this.authSvc.hasSubsWithStatus(SubStripe.INCOMPLETE); - if (accountNotLocked) { - return true; - } - } - this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES], { replaceUrl: true }); - return; - } - - if (this.subSvc.isUnderReview(status)) { - const fromPath = this.routerUtils.getCurrentUrl().split('/').pop(); - if (routerState.url.includes(AC)) return true; - if (fromPath.includes(AC)) { - this.store.dispatch(new ClearSubscriptionStatus()); - return true; - }; - this.router.navigate(['entities', AC], { replaceUrl: true }); - return; - } - } - - const shouldNavToServices = () => { - const trials = this.authSvc.user?.membership?.trials; - - if (routerState.url.includes(SUB.SERVICES) - || (routerState.url.includes('home') && !!trials?.type)) return true; - - const canNavToServices = !this.authSvc.isAdmin - && !this.authSvc.hasSubs() - && (!trials?.type || (!trials?.byDate && trials?.trialDays === 0)); - - if (canNavToServices) { - this.router.navigate([SUB.PROFILE, SUB.SERVICES]); - return; - } - } - - const canNav = requiresResolution() || shouldNavToServices() || hasAllowedRoles; - return canNav; + return this.checkRoles(routerState.url, route); }), - catchError(err => { - console.log(err); - return of(false) - }) ); } - canActivateChild(childRoute: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean | Observable | Promise { + canActivateChild( + childRoute: ActivatedRouteSnapshot, + state: RouterStateSnapshot): boolean | Observable | Promise { return this.canActivate(childRoute, state); } - checkRoles(route: ActivatedRouteSnapshot): boolean { - const hasRoles = !!route?.data?.roles; - if (hasRoles) { - const hasAllRoles = '*' === route.data.roles; - const hasAccessedByRoles = hasAllRoles || this.authSvc.hasRole(route.data.roles); - return hasAccessedByRoles; - } else { - return true; - } + checkStoreAuth() { + return this.store.select(fromStore.selectIsLoggedIn).pipe(take(1)); } + + // checkApiAuth() { + // return this.authService.check().pipe( + // map(user => !!user), + // catchError(() => of(false)) + // ); + // } + + checkRoles(url: string, route: ActivatedRouteSnapshot): boolean { + if (route.data.roles) { + if ('*' === route.data.roles || this.authService.hasRole(route.data.roles)) { + return true; + } else { + return false; + } + } + else + return true; + } + } diff --git a/Development/client/src/app/domain/guards/notification-redirect.guard.ts b/Development/client/src/app/domain/guards/notification-redirect.guard.ts deleted file mode 100644 index 94975c7..0000000 --- a/Development/client/src/app/domain/guards/notification-redirect.guard.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Injectable } from '@angular/core'; -import { CanActivate, Router, UrlTree, ActivatedRouteSnapshot } from '@angular/router'; -import { AuthService } from '../services/auth.service'; -import { SUB } from '@app/profile/common'; - -/** - * Generic guard for notification deep-link URLs (e.g. /manage-subscription, /update-pm). - * All routing logic is declared in the route's `data` — no new guard file needed per URL. - * - * Route data shape: - * data: { - * // Required: where to send an authenticated user - * redirectTo: string[]; - * - * // Optional: alternate destination when master account has no subscriptions - * redirectToNoSubs?: string[]; - * - * // Optional: i18n message shown in the login bar - * loginNotice?: string; - * } - * - * Adding a new notification URL = one route entry, zero new files. - */ -@Injectable({ providedIn: 'root' }) -export class NotificationRedirectGuard implements CanActivate { - constructor( - private readonly authSvc: AuthService, - private readonly router: Router - ) { } - - canActivate(route: ActivatedRouteSnapshot): UrlTree { - const { redirectTo, redirectToNoSubs } = route.data as { - redirectTo: string[]; - redirectToNoSubs?: string[]; - }; - - if (!this.authSvc.loggedIn) { - const { loginNotice } = route.data as { loginNotice?: string }; - return this.router.createUrlTree(['/login'], { - queryParams: { - returnUrl: route.url.map(s => s.path).join('/'), - ...(loginNotice ? { loginNotice } : {}) - } - }); - } - - const isMaster = !this.authSvc.user?.parent; - if (redirectToNoSubs && isMaster && !this.authSvc.hasSubs()) { - return this.router.createUrlTree(redirectToNoSubs); - } - return this.router.createUrlTree(redirectTo); - } -} diff --git a/Development/client/src/app/domain/guards/role.guard.ts b/Development/client/src/app/domain/guards/role.guard.ts deleted file mode 100644 index 8d5c422..0000000 --- a/Development/client/src/app/domain/guards/role.guard.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Injectable } from '@angular/core'; -import { - CanActivate, ActivatedRouteSnapshot -} from '@angular/router'; -import { AuthService } from '../services/auth.service'; - -@Injectable({ providedIn: 'root' }) -export class RoleGuard implements CanActivate { - - constructor( - private authService: AuthService) { - } - - canActivate(route: ActivatedRouteSnapshot): boolean { - if (route.data.roles) { - if ('*' === route.data.roles || this.authService.hasRole(route.data.roles)) { - return true; - } else { - return false; - } - } - else - return true; - } -} \ No newline at end of file diff --git a/Development/client/src/app/domain/guards/settings-guard.service.ts b/Development/client/src/app/domain/guards/settings-guard.service.ts index c72b448..f81f75c 100644 --- a/Development/client/src/app/domain/guards/settings-guard.service.ts +++ b/Development/client/src/app/domain/guards/settings-guard.service.ts @@ -10,19 +10,7 @@ export class SettingsGuard implements CanActivate { constructor(private readonly appCnf: AppConfigService) { } canActivate(route: ActivatedRouteSnapshot): Observable { - console.log('SettingsGuard: canActivate called for route:', route.routeConfig?.path); // Make sure to load the config whenever the module is accessed. - const loadResult = this.appCnf.load(); - - loadResult.subscribe({ - next: (success) => { - console.log('SettingsGuard: AppConfig load completed with result:', success); - }, - error: (error) => { - console.error('SettingsGuard: AppConfig load error:', error); - } - }); - - return loadResult; + return this.appCnf.load(); } } diff --git a/Development/client/src/app/domain/guards/stripe-load.guard.ts b/Development/client/src/app/domain/guards/stripe-load.guard.ts deleted file mode 100644 index 36043a0..0000000 --- a/Development/client/src/app/domain/guards/stripe-load.guard.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Injectable } from '@angular/core'; -import { CanActivate } from '@angular/router'; -import { SubscriptionService } from '../services/subscription.service'; - -@Injectable({ providedIn: 'root' }) -export class StripeLoadGuard implements CanActivate { - constructor(private readonly subSvc: SubscriptionService) { } - - canActivate(): Promise { - return this.subSvc.loadStripePromise().then(() => true).catch(() => false); - } -} \ No newline at end of file diff --git a/Development/client/src/app/domain/guards/subscription.guard.ts b/Development/client/src/app/domain/guards/subscription.guard.ts deleted file mode 100644 index 0461461..0000000 --- a/Development/client/src/app/domain/guards/subscription.guard.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, CanActivate, Router } from '@angular/router'; -import { AuthService } from '../services/auth.service'; -import { SubscriptionService } from '../services/subscription.service'; -import { catchError, map } from 'rxjs/operators'; -import { Observable, of } from 'rxjs'; -import { StripeSubscription } from '../models/subscription.model'; -import { SUB, SubStripe } from '@app/profile/common'; -import { AppMessageService } from '@app/shared/app-message.service'; -import { AC, globals } from '@app/shared/global'; -import { RouterUtilsService } from '@app/shared/router-utils.service'; - -/** - This guards against unresolved subscriptions when the user is subscribing to a new subscription in the - main flow (services <->billing-detail<->checkout<->checkout-review<->checkout-confirm). When the user has an unresolved subscription, the guard forces user to enter the resolving subscription flow. - */ - -@Injectable({ providedIn: 'root' }) -export class SubscriptionGuard implements CanActivate { - - constructor( - private readonly authSvc: AuthService, - private readonly subSvc: SubscriptionService, - private readonly router: Router, - private readonly msgSvc: AppMessageService, - private readonly routerUtils: RouterUtilsService - ) { } - - canActivate(activatedRoute: ActivatedRouteSnapshot): Observable { - - return this.subSvc.fetchSubscriptions(this.authSvc.user?.membership?.custId).pipe( - map((subs) => { - const fromPath = this.routerUtils.getCurrentUrl().split('/').pop(); - const toPath = activatedRoute.url[0]?.path; - - const hasLatestSubs = subs?.length > 0; - const hasUnresolvedSubs = this.hasUnresolvedSubs(subs); - const hasAllActiveSubs = hasLatestSubs && !hasUnresolvedSubs; - - const isSubscribing = (!hasLatestSubs || hasAllActiveSubs) - && (this.isInMainFlow(fromPath, toPath) - || this.isPageReload(fromPath, toPath)); - const isResolvingSubs = hasUnresolvedSubs - && (this.isInResolvingFlow(fromPath, toPath) - || this.isPageReload(fromPath, toPath)); - - const viewPayment = this.isPaymentPage(toPath); - const viewPaymentMethod = this.isPaymentMethodPage(toPath); - const viewAC = this.isACPage(toPath) && !hasUnresolvedSubs; - - const canNavigate = isSubscribing || viewPayment || viewPaymentMethod || viewAC || true; - const shouldNavigateToMyServices = hasUnresolvedSubs && !isResolvingSubs; - - if (canNavigate) { - return true; - } else if (shouldNavigateToMyServices) { - this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]); - } else if (isResolvingSubs) { - return true; - } else { - this.router.navigate(['/', SUB.HOME]); - } - }), - catchError(err => { - this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.load).replace('#thing#', globals.subscription)); - return of(false); - }) - ); - } - - private isInMainFlow(fromPath: string, toPath: string): boolean { - return (fromPath === SUB.SERVICES && toPath === SUB.BILL_ADR) || - (fromPath === SUB.BILL_ADR && toPath === SUB.CHKOUT) || - (fromPath === SUB.CHKOUT && toPath === SUB.BILL_ADR) || - (fromPath === SUB.CHKOUT && toPath === SUB.CHKOUT_REV) || - (fromPath === SUB.CHKOUT_REV && toPath === SUB.CHKOUT_CONF) || - (fromPath === SUB.CHKOUT_REV && toPath === SUB.CHKOUT) || - this.isTrialFLow(fromPath, toPath); - } - - private isInResolvingFlow(fromPath: string, toPath: string): boolean { - return (fromPath === SUB.UNPAID_SUB && toPath === SUB.BILL_ADR) || - (fromPath === SUB.BILL_ADR && toPath === SUB.CHKOUT) || - (fromPath === SUB.BILL_ADR && toPath === SUB.UNPAID_SUB) || - (fromPath === SUB.CHKOUT && toPath === SUB.BILL_ADR) || - (fromPath === SUB.CHKOUT && toPath === SUB.CHKOUT_REV) || - (fromPath === SUB.CHKOUT_REV && toPath === SUB.CHKOUT) || - (fromPath === SUB.CHKOUT_REV && toPath === SUB.CHKOUT_CONF) || - (fromPath === SUB.MY_SERVICES && toPath === SUB.CHKOUT_REV) || - (fromPath === SUB.MY_SERVICES && toPath === SUB.UNPAID_SUB); - } - - private isTrialFLow(fromPath: string, toPath: string): boolean { - return (fromPath === SUB.CHKOUT && toPath === SUB.CHKOUT_CONF) || - (fromPath === SUB.MY_SERVICES && toPath === SUB.BILL_ADR); - } - - private isPageReload(fromPath: string, toPath: string): boolean { - return (!fromPath && (toPath === SUB.BILL_ADR || toPath === SUB.CHKOUT || toPath === SUB.CHKOUT_REV || toPath === SUB.CHKOUT_CONF)); - } - - private hasUnresolvedSubs(subs: StripeSubscription[]): boolean { - return subs?.some(sub => sub.status === SubStripe.PAST_DUE || sub.status === SubStripe.OVERDUE || sub.status === SubStripe.UNPAID || sub.status === SubStripe.INCOMPLETE); - } - - private isPaymentPage(path: string): boolean { - return path === SUB.PM_HISTORY || path === SUB.PM_DETAIL; - } - - private isPaymentMethodPage(path: string): boolean { - return path === SUB.PM_LIST; - } - - private isACPage(path: string): boolean { - return path === AC; - } -} \ No newline at end of file diff --git a/Development/client/src/app/domain/guards/usage-detail.guard.ts b/Development/client/src/app/domain/guards/usage-detail.guard.ts deleted file mode 100644 index 1153f33..0000000 --- a/Development/client/src/app/domain/guards/usage-detail.guard.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Injectable } from '@angular/core'; -import { CanActivate } from '@angular/router'; -import { AuthService } from '../services/auth.service'; -import { SubType } from '@app/profile/common'; - -@Injectable({ providedIn: 'root' }) -export class UsageDetailGuard implements CanActivate { - - constructor(private authService: AuthService) { } - - canActivate(): boolean { - const hasPkg = this.authService.user?.membership?.subscriptions?.some((sub) => sub.type === SubType.PACKAGE); - if (hasPkg) return true; - } -} \ No newline at end of file diff --git a/Development/client/src/app/domain/models/appconfig.model.ts b/Development/client/src/app/domain/models/appconfig.model.ts index 7b24a05..18270c7 100644 --- a/Development/client/src/app/domain/models/appconfig.model.ts +++ b/Development/client/src/app/domain/models/appconfig.model.ts @@ -23,7 +23,4 @@ export interface IAppConfig { }; noPopup: boolean; - trialDays: [number]; - /** Grace-period days for promo Valid Until (sysadmin only). From PROMO_MIN_EXPIRY_DAYS env. */ - promoMinExpiryDays?: number; } diff --git a/Development/client/src/app/domain/models/play-record.model.ts b/Development/client/src/app/domain/models/play-record.model.ts index 95226d5..dd1a496 100644 --- a/Development/client/src/app/domain/models/play-record.model.ts +++ b/Development/client/src/app/domain/models/play-record.model.ts @@ -45,7 +45,7 @@ export class PlayRecord { // Output 3 areaName: string; totLnLength: number; - applicRate: number; // Applic. Rate: Application rate in Gals/Acre or Liters/Ha. Value is read from the Q file or the job. + applicRate: number; // Applic. Rate: Application rate in Gals/Acre or Liters/Ha. Value is ead from the Q file or the job. mappedArea: number; overSprayed: number; pilotName: string; diff --git a/Development/client/src/app/domain/models/subscription.model.ts b/Development/client/src/app/domain/models/subscription.model.ts deleted file mode 100644 index 31ad613..0000000 --- a/Development/client/src/app/domain/models/subscription.model.ts +++ /dev/null @@ -1,735 +0,0 @@ -import { IMembership } from '@app/auth/models/user.model'; -import { InvType, Mode } from '@app/profile/common'; -import { StripeCardCvcElement, StripeCardElement, StripeCardExpiryElement, StripeCardNumberElement } from '@stripe/stripe-js'; - -export type PriceUsd = string | number; - -export interface Price { - type: string; - lookupKey: string; - priceUSD: PriceUsd, - level: number; - maxVehicles: number; - maxAcres: number; -} - -export interface BasePackage { - price: PriceUsd; - quantity?: number; - metadata?: { - tier: string; - level?: string; - maxAcres?: string; - maxVehicles?: string; - } -} - -export interface Addon extends BasePackage { - priceId?: string; - name?: string; - desc: string; - lookupKey: string; - trialEnd?: number; - interval?: string; // Billing interval ('year' or 'month') -} - -export interface Package extends BasePackage { - priceId?: string; - desc: string; - maxVehicles?: number; - Vehicles?: string; - maxAcres?: string; - lookupKey: string; - level?: number; - trialEnd?: number; - interval?: string; // Billing interval ('year' or 'month') -} - -export interface Address { - _id?: string; - name?: string; - valid?: boolean; - city?: string; - country: string; - line1: string; - line2?: string | null; - postalCode?: string; - state?: string, - isBilling?: boolean; -} - -export interface Card { - pmId?: string; - brand: string; - country: string; - exp_month: number; - exp_year: number; - last4: string; - defaultPM: boolean; -} - -export interface BillingInfo { - applicatorId: string; - name: string; - address - email?: string; -} - -export interface PaymentMethod { - id: string; - created: number; - card: Card; - billing_details: BillingInfo; -} - -export interface InvoicePackage { - custId: string; - package: string; - addons: BasePackage[]; - prorateTS?: number; // Optional: only needed for proration calculations - coupon?: string; -} - -export interface Line { - id: string; - amount: number; - amount_excluding_tax: number; - period: { - end: number; - start: number - }; - description: string; - subscription: string; - quantity: number; - plan: { - id: string; - amount: number; - } - price: { - lookup_key: string; - } - // Issue 4 - Proration credit detection - proration?: boolean; // True for proration credits (unused subscription time) - type?: string; // 'subscription', 'invoiceitem', etc. -} - -/** - * Describes a deferred promo that will apply from the next billing period. - * Shape is identical to the inline `promoDetails` object on subscriptions, - * with two additional discriminant flags. Backend builds this from - * subscription.metadata.pending_coupon_id (r975+). - */ -export interface PendingPromoDetails { - isPending: true; - appliesToNextPeriod: true; - name: string; - discountDisplay: string; // 'FREE', '50% OFF', '$9.99 OFF' - percentOff: number | null; - amountOff: number | null; // cents - currency: string | null; - duration: string | null; // 'forever' | 'once' | 'repeating' - durationInMonths: number | null; - expiresAt: null; - discountEndsAt: null; - daysRemaining: null; - daysUntilDiscountEnds: null; - isTimeLimited: false; -} - -export interface Invoice { - id: string; - subscription: string; - tax: number; - total: number; - object: string; - subtotal_excluding_tax: number; - lines?: { - data: Line[] - } - number?: number; - subtotal?: number; - total_tax_amounts?: Taxable[] - subscription_proration_date?: number; - total_excluding_tax?: number; - created?: number; - paid?: boolean; - status?: string; - amount_due?: number; - amount_paid?: number; - hosted_invoice_url?: string; - invoice_pdf?: string; - customer_name?: string; - customer_email?: string; - customer_address?: { - city: string; - country: string; - line1: string; - postal_code: string; - state: string; - }; - attempted?: boolean; - type: InvType.INVOICE; - discount?: { - coupon: Coupon - }; - /** Invoice period type: "current" (immediate billing) or "next" (future billing cycle) */ - period_type?: string; - /** Flag indicating if this invoice has promotional pricing applied */ - has_promo?: boolean; - /** @deprecated r975: field no longer populated by backend. Use pendingPromoDetails instead. */ - promo_coupon?: string; - /** Unix timestamp (seconds) — when the next charge is collected. Use * 1000 for a JS Date. (r975+) */ - next_billing_date?: number; - /** Present when a deferred 100% FREE promo is scheduled for next billing period (r975+). */ - pendingPromoDetails?: PendingPromoDetails; - /** Discount amounts applied on this invoice. Each entry is { amount (cents), discount (Stripe discount ID) }. */ - total_discount_amounts?: { amount: number; discount: string }[]; -} - -export interface Charge { - id: string; - object: string; - amount: number; - amount_refunded: number; - created: number; - paid: boolean; - refunded: boolean; - receipt_url: string; - invoice: string; - type: InvType.CHARGE; -} - -export type Payment = Invoice | Charge; - -export interface Taxable { - amount: string; - taxable_amount: string; -} - -export interface PaidAmount { - totalExcludingTax: number; - totalTax: number; - total: number; - discount?: Discount; - refundAmount?: number; -} - -export interface Discount { - amountOff: number; - percentOff?: number; -} - -export interface SubscriptionIntent { - applicatorId: string; - custId: string; - selPkg: Package; - selAddons: Addon[]; - orgPkg?: Package; - orgAddons?: Addon[]; - upcomingInvoices?: Invoice[]; - billingInfo?: BillingInfo; - paymentMethods?: PaymentMethod[]; - card?: Card; - prorateTS?: number; - amount?: PaidAmount; - isNewAccount?: boolean; - coupons?: Coupon[]; - mode: Mode; - subIds?: string[]; - promoSavings?: number; // Total promo discount in cents (calculated in checkout) -} - -export interface SubscriptionPackage { - stage?: string; - card?: Card; - pmId?: string; - defaultPM: boolean; - package: string; - addons: BasePackage[]; - applicatorId?: string; - prorateTS?: number; - coupon?: string; - trial?: number; -} - -export interface CustChargePkg { - custId: string; - refunded?: boolean; - status?: string; - limit?: number; -} - -export interface SubscriptionPaymentMethod { - subIds: string[]; - pmId: string; -} - - -export interface PaymentIntent { - id: string; - status: string; - client_secret: string; - customer: string; - payment_method: string; - source: string; - last_payment_error: { - payment_method: PaymentMethod; - source: Card - }; -} - -export interface LatestInvoice { - subscription: string; - id: string; - object: string; - customer_address?: { - city: string; - country: string; - line1: string; - line2: string; - postal_code: string; - state: string; - }, - customer_name?: string; - period_start: number; - period_end: number; - status: string; - payment_intent: PaymentIntent; - tax: number; - total: number; - total_excluding_tax: number; - subtotal: number; - subtotal_excluding_tax: number; - type: InvType.INVOICE; - last_finalization_error: { - code: string; - type: string; - message: string; - }; - automatic_tax: { - enabled: boolean; - status: string; - } -} - -export interface PastDue { - invoices: LatestInvoice[]; - numOfRetries: number; -} - -export interface Incomplete { - invoices: LatestInvoice[]; - requiresAction: boolean; - requiresPM: boolean; - numOfRetries: number; - subscriptions?: StripeSubscription[] -} - -export interface Unpaid { - invoices: Invoice[]; - numOfRetries: number; -} - -export interface StripeSubscription { - id: string; - status: string; - latest_invoice: LatestInvoice; - items: { - data: { - quantity: number; - price: { - lookup_key: string; - metadata?: { - maxVehicles?: string; - maxAcres?: string; - tier?: string; - level?: string; - }; - } - }[]; - }; - current_period_end: number; - current_period_start: number; - default_payment_method: string; - default_source: string; - metadata?: { - type: string; - scheduleId?: string; - promoId?: string; - }; - cancel_at_period_end: boolean; - discount?: { - coupon: Coupon - } - trial_end?: number; - quantity: number; - // ✅ r962+ promoDetails enhancement (includes amountOff/percentOff) - promoDetails?: { - hasPromo: boolean; - name: string; - discountDisplay: string; - expiresAt: string | null; - discountEndsAt: string | null; - daysRemaining: number | null; - daysUntilDiscountEnds: number | null; - isTimeLimited: boolean; - durationInMonths: number | null; - duration: string | null; - percentOff: number | null; - amountOff: number | null; - currency: string | null; - }; -} - -/** - * Warning notification for subscriptions expiring within 7 days - * - * Data source: GET /api/subscription?custId={custId} - * Verified: 2025-11-10 via /server_test/subscription-data-verification.js - * - * Use case: Display topbar notification when subscription expires in 1-7 days - * Shows: Package name and/or addon names that are expiring with their individual expiry dates - */ -export interface ExpiryWarning { - /** Subscription ID from Stripe */ - id: string; - - /** Subscription type - 'package', 'addon', or 'both' */ - type: 'package' | 'addon' | 'both'; - - /** Subscription status from Stripe (trialing, active, etc.) */ - status: string; - - /** Calculated days until earliest subscription expires */ - daysUntilExpiry: number; - - /** If true, subscription will expire and NOT auto-renew */ - cancelAtPeriodEnd: boolean; - - /** Unix timestamp when earliest subscription period ends */ - periodEnd: number; - - /** True if subscription status is 'trialing' */ - isTrial: boolean; - - /** True if subscription will auto-renew (inverse of cancelAtPeriodEnd) */ - willAutoRenew: boolean; - - /** Package expiry details if package is expiring */ - package?: { - name: string; - lookupKey: string; - daysUntilExpiry: number; - periodEnd: number; - willAutoRenew: boolean; - isTrial: boolean; - isCanceled: boolean; - }; - - /** Array of addon expiry details if addons are expiring */ - addons?: Array<{ - name: string; - lookupKey: string; - daysUntilExpiry: number; - periodEnd: number; - willAutoRenew: boolean; - isTrial: boolean; - isCanceled: boolean; - }>; - - /** True when sub-account has no active subscriptions */ - noSubs?: boolean; -} - -export interface AGNavSubscription { - id: string; - status: string; - items: BasePackage[]; - periodEnd: number; - periodStart: number; - type: string; - cancelAtPeriodEnd: boolean; - trial_end?: number; - promoDetails?: { - hasPromo: boolean; - name: string; - discountDisplay: string; - expiresAt: string | null; - discountEndsAt: string | null; - daysRemaining: number | null; - daysUntilDiscountEnds: number | null; - isTimeLimited: boolean; - durationInMonths: number | null; - duration: string | null; - percentOff: number | null; - amountOff: number | null; - currency: string | null; - }; - /** - * Present when a deferred 100% FREE promo is scheduled for next billing period (r975+). - * Built from subscription.metadata.pending_coupon_id by addPromoDetailsToSubscription(). - * Absent (undefined) when no deferred promo is active or subscription is cancel_at_period_end. - */ - pendingPromoDetails?: PendingPromoDetails; -} - -export interface AGNavSubscriptionShort { - id: string; - lookupKey: PriceUsd; - status: string; - periodEnd: number; - cancelAtPeriodEnd: boolean; - quantity: number; - paymentMethod: string; - trialEnd?: number; - promoDetails?: { - hasPromo: boolean; - name: string; - discountDisplay: string; - expiresAt: string | null; - discountEndsAt: string | null; - daysRemaining: number | null; - daysUntilDiscountEnds: number | null; - isTimeLimited: boolean; - durationInMonths: number | null; - duration: string | null; - percentOff: number | null; - amountOff: number | null; - currency: string | null; - }; - /** - * Present when a deferred 100% FREE promo is scheduled for next billing period (r975+). - * Absent (undefined) when no deferred promo is active or subscription is cancel_at_period_end. - */ - pendingPromoDetails?: PendingPromoDetails; -} - -export interface Status { - code: string; - message?: string; -} - -export interface RefreshPackage { - applicatorId: string; - custId: string; - prevStage: string; - stage: string; - card: Card; -} - -export interface Unresolved { - type: string; - reason?: string; - numOfRetries: number; - invoices: LatestInvoice[]; -} - -export interface ConfirmPackage { - custId: string; - stripePkgs: { - clientSecret: string; - pmId: string; - }[]; - subIds: string[]; - unresolved: Unresolved; - applicatorId: string; - stage?: string; -} - -export interface CreatePaymentMethodPackage { - card: StripeCardElement; - billing_details: { - name: string; - address: Address; - }; - defaultPM: boolean; -} - -export interface UnpaidPackage { - pmId: string; - invIds: string[]; - unpaid?: Unpaid; - card?: Card; - custId?: string; - applicatorId?: string; -} - -export interface UnpaidSubscription { - lookupKey: PriceUsd; - id: string; - tax: number; - total: number; - subtotal_excluding_tax: number; -} - -export interface Aircraft { - numOfVehicle: number -} - -export interface Acre { - currUsage: number; - limit: number | null; // null = unlimited acres for current subscription packages - overLimit: boolean; -} - -export interface SubLimit { - package?: { [i: string]: Limit }; - addon?: { [i: string]: Limit }; -} - -export interface Plan extends SubLimit { - subscriptions?: StripeSubscription[]; - membership?: IMembership; -} - -export interface Limit { - acre: Acre; - airCraft: Aircraft; -} - -export interface BillPeriod { - custId: string; - periodEnd: number; - periodStart: number; - subId: string; - lookupKey: string; -} - -export interface JobUsage { - createdAt: string; - jobId: number; - ttSprArea: number; - totalSprayed: number; - updateDate: string; -} - -export interface Usage { - ttArea: number; - numOfAC: number; - jobUsages: JobUsage[] -} - -export interface UsagePackage { - byPuid: string; - fromTS?: number; - toTS?: number; -} - -export interface UsageDetail { - periodEnd: number; - periodStart: number; - dayPercentage: number, - dayLeft: number; - maxAcre: string; - ttArea: number; - acrePercentage: number; - jobUsages?: JobUsage[]; - billPeriods?: BillPeriod[]; -} - -export interface TSRange { - minTS: number; - maxTS: number; - fromTS: number; - toTS: number; -} - -export interface LineItem { - description: string; - tax: number; - amount: number; -} - -export interface TotalLine { - totalTax: number; - totalAmount: number; - lineItems: LineItem[]; - discount?: Discount; -} - -export interface CheckoutPayment { - payment: TotalLine; - refund?: TotalLine; -} - -export interface Coupon { - id: string; - name: string; - amount_off: number; - percent_off: number; - redeem_by: string; - times_redeemed: number; - valid: true; -} - -export interface Trial { - selected?: boolean, - type: string; - startDate: Date, - lastStartDate: Date, - lastEndDate: Date, - trialDays: number, - byDate: Date -} - -export interface TrialPmtPkg { - package: string; - addons: BasePackage[]; - pmtMethod?: { - newPmtMeth?: CreatePaymentMethodPackage; - exPmtMeth?: Card - }, - mode: Mode; - subIds?: string[]; - amount?: PaidAmount; -} - -export interface TrialItem { - description: string; - amount: PriceUsd; - trialEnd?: number; - quantity: number; - price?: { - lookup_key: string; - unit_amount: number; - } -} - -export interface StripeCard { - cardNumber: StripeCardNumberElement; - cardExpiry: StripeCardExpiryElement; - cardCvc: StripeCardCvcElement; -} - -export interface CardExp { - expMonth: number; - expYear: number; -} - -export interface PMPkgEdit { - pmId: string, - name?: string, - card?: CardExp, - setDefault?: boolean -} - -export interface PMPkgAdd { - name: string; - card: StripeCardElement; - setDefault?: boolean; -} - -export interface PkgValid { - isValid: boolean; - status?: Status; -} - -export interface BillingInfoPackage { billingInfo?: BillingInfo, isNewAccount?: boolean } - - - - - - - - diff --git a/Development/client/src/app/domain/resolvers/membership-resolver.ts b/Development/client/src/app/domain/resolvers/membership-resolver.ts deleted file mode 100644 index 05920e0..0000000 --- a/Development/client/src/app/domain/resolvers/membership-resolver.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Injectable } from '@angular/core'; -import { Resolve } from '@angular/router'; -import { Observable } from 'rxjs'; -import { first, map } from 'rxjs/operators'; -import { AuthService } from '../services/auth.service'; -import { CustomerService } from '../services/customer.service'; -import { IMembership } from '@app/auth/models/user.model'; - -@Injectable() -export class MembershipResolver implements Resolve { - constructor( - private readonly custSvc: CustomerService, - private readonly authSvc: AuthService - ) { } - - resolve(): Observable { - const id = this.authSvc.user?.parent || this.authSvc.user._id; - return this.custSvc.getCustomer(id).pipe( - map((cust) => { - const membership = cust?.membership; - if (membership) { - return membership; - } - }), - first()) - } -} diff --git a/Development/client/src/app/domain/resolvers/profile-resolver.ts b/Development/client/src/app/domain/resolvers/profile-resolver.ts deleted file mode 100644 index aaa3391..0000000 --- a/Development/client/src/app/domain/resolvers/profile-resolver.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Injectable } from '@angular/core'; -import { Router, ActivatedRouteSnapshot, Resolve } from '@angular/router'; - -import { Observable, forkJoin, of } from 'rxjs'; -import { map, first, switchMap } from 'rxjs/operators'; - -import { User } from '@app/accounts/models/user.model'; -import { UserService } from '@app/domain/services/user.service'; - -export interface UserWithParentUsername { - user: User; - parentUsername?: string; -} - -@Injectable() -export class ProfileResolver implements Resolve { - constructor( - private readonly router: Router, - private readonly userService: UserService - ) { } - - resolve(route: ActivatedRouteSnapshot): Observable { - const id = route.paramMap.get('id'); - // view:'edit' → backend returns editable profile fields (name, phone, email, contact, - // address, kind, active, username, password) but excludes membership/subscription data - // which the form never needs and is expensive to populate. - return this.userService.getUser(id, { view: 'edit' }).pipe( - switchMap(user => { - if (!user) { - this.router.navigate(['/profile']); - return of(null); - } - if (user.parent) { - return this.userService.getUser(user.parent, { view: 'profile' }).pipe( - map(parentUser => ({ user, parentUsername: parentUser?.username })), - first() - ); - } else { - return of({ user }); - } - }), - first() - ); - } -} diff --git a/Development/client/src/app/domain/resolvers/user-resolver.ts b/Development/client/src/app/domain/resolvers/user-resolver.ts deleted file mode 100644 index 6c8fca4..0000000 --- a/Development/client/src/app/domain/resolvers/user-resolver.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Injectable } from '@angular/core'; -import { Router, Resolve } from '@angular/router'; -import { Observable } from 'rxjs'; -import { map, first, switchMap } from 'rxjs/operators'; -import { User } from '@app/accounts/models/user.model'; -import { UserService } from '@app/domain/services/user.service'; -import { Store } from '@ngrx/store'; -import { selectAuthUser } from '@app/reducers'; -import { UserModel } from '@app/auth/models/user.model'; - -@Injectable({ providedIn: 'root' }) - -export class UserResolver implements Resolve { - constructor( - private readonly userService: UserService, - private readonly router: Router, - private readonly store: Store<{}>, - ) { } - - resolve(): Observable { - return this.store.select(selectAuthUser).pipe( - switchMap((authUser: UserModel) => { - return this.userService.getUser(authUser._id, { withAddresses: true }) - }), - map((user: User) => { - if (user) { - return user; - } else { - this.router.navigate(['/profile']); - } - }), - first() - ) - } -} diff --git a/Development/client/src/app/domain/services/active-promo.service.ts b/Development/client/src/app/domain/services/active-promo.service.ts deleted file mode 100644 index f043cba..0000000 --- a/Development/client/src/app/domain/services/active-promo.service.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { Injectable } from '@angular/core'; -import { HttpClient, HttpErrorResponse } from '@angular/common/http'; -import { Observable, of, BehaviorSubject } from 'rxjs'; -import { shareReplay, map, catchError, switchMap, tap } from 'rxjs/operators'; -import { PromoTranslationService } from './promo-translation.service'; - -/** - * Active Promo interface matching backend GET /api/activePromos response - * Note: couponId is intentionally NOT included (server-side only for security) - */ -export interface ActivePromo { - type: 'package' | 'addon'; - priceKey: string; // e.g., 'ess_1', 'addon_1' - validUntil: string; // ISO date string - name: string; // Display name (fallback) - nameKey?: string; // i18n key e.g., 'PROMO_ADDON_FREE' - descriptionKey?: string; // i18n key e.g., 'PROMO_ADDON_FREE_DESC' - discountType: 'free' | 'percent' | 'fixed'; - discountValue: number; // 100 for free, 50 for 50%, 500 for $5.00 - // Optional expiry fields for time-limited promos (r948+ promoDetails) - isTimeLimited?: boolean; // True if promo has expiry date - daysRemaining?: number | null; // Days until expiry (null if not time-limited) - isRenewalPromo?: boolean; // True if this is a Case 2B renewal offer (subscription has no promo, showing available promo) -} - -/** - * Response interface for /api/activePromos endpoint - * Changed in r949 to include currentMode metadata - */ -interface ActivePromoResponse { - promos: ActivePromo[]; - currentMode: { - mode: 'enabled' | 'disabled'; - description: string; - isActive: boolean; - }; -} - -/** - * Service for fetching active subscription promos from the backend. - * Used to display promo labels in manage-services and manage-subscription components. - * - * The backend returns only enabled promos with future validUntil dates, - * without exposing sensitive couponId (coupon application happens server-side). - */ -@Injectable({ - providedIn: 'root' -}) -export class ActivePromoService { - private readonly BASE_URL = '/activePromos'; - - // Use BehaviorSubject to trigger fresh API calls when needed - private refreshTrigger$ = new BehaviorSubject(0); - private readonly activePromos$: Observable; - - // Store currentMode for components to use (optional feature) - private currentModeSubject$ = new BehaviorSubject<{ - mode: string; - description: string; - isActive: boolean; - } | null>(null); - - constructor( - private readonly http: HttpClient, - private readonly promoTranslationSvc: PromoTranslationService - ) { - // Create observable that refreshes when refreshTrigger$ emits - this.activePromos$ = this.refreshTrigger$.pipe( - switchMap(() => { - return this.http.get(this.BASE_URL).pipe( - map(response => { - // Store currentMode for components to use - this.currentModeSubject$.next(response.currentMode); - return response.promos; // Extract promos array - }), - catchError(error => this.handleActivePromosError(error)) - ); - }), - shareReplay(1) // Cache until next refresh - ); - } - - /** - * Handle errors from /api/activePromos endpoint - * Returns empty promos with disabled mode to prevent component crashes - * - * Error Handling: - * - 401 Unauthorized: Token expired or invalid (global interceptor handles logout) - * - 403 Forbidden: User doesn't have permission - * - 0 or 500+: Network or server errors - * - Other: Unexpected errors - */ - private handleActivePromosError(error: HttpErrorResponse): Observable { - // 401 Unauthorized - Token expired or invalid - if (error.status === 401) { - console.error('[ActivePromoService] Authentication failed (401)', error); - // User will be redirected to login by global HTTP interceptor - // Return empty promos to prevent component errors - this.currentModeSubject$.next(this.getDisabledPromoMode('Authentication required')); - return of([]); - } - - // 403 Forbidden - User doesn't have permission - if (error.status === 403) { - console.error('[ActivePromoService] Access denied (403)', error); - this.currentModeSubject$.next(this.getDisabledPromoMode('Access denied')); - return of([]); - } - - // Network errors or server errors - if (error.status === 0 || error.status >= 500) { - console.error('[ActivePromoService] Network or server error', error); - this.currentModeSubject$.next(this.getDisabledPromoMode('Service unavailable')); - return of([]); - } - - // Other errors - return empty to prevent crashes - console.error('[ActivePromoService] Unexpected error', error); - this.currentModeSubject$.next(this.getDisabledPromoMode('Promotions unavailable')); - return of([]); - } - - /** - * Get disabled promo mode object for error states - */ - private getDisabledPromoMode(description: string): { - mode: string; - description: string; - isActive: boolean; - } { - return { - mode: 'disabled', - isActive: false, - description: description - }; - } - - /** - * Force refresh of promo data from server - * Invalidates cache and makes fresh API call - */ - refresh(): void { - this.refreshTrigger$.next(Date.now()); - } - - /** - * Get all active promos (cached until refresh) - */ - getActivePromos(): Observable { - return this.activePromos$; - } - - /** - * Get promo for a specific priceKey (e.g., 'ess_1', 'addon_1') - */ - getPromoForPriceKey(priceKey: string): Observable { - return this.activePromos$.pipe( - map(promos => promos.find(p => p.priceKey === priceKey)) - ); - } - - /** - * Check if a priceKey has an active promo - */ - hasPromo(priceKey: string): Observable { - return this.activePromos$.pipe( - map(promos => promos.some(p => p.priceKey === priceKey)) - ); - } - - /** - * Get active promos with translated names (convenience method) - */ - getActivePromosWithTranslations(): Observable<(ActivePromo & { translatedName: string; translatedDescription: string })[]> { - return this.activePromos$.pipe( - map(promos => promos.map(promo => ({ - ...promo, - translatedName: this.promoTranslationSvc.getPromoName(promo), - translatedDescription: this.promoTranslationSvc.getPromoDescription(promo) - }))) - ); - } - - /** - * Get current promo mode info - * Returns null if not yet loaded - * - * Use this to check if promotions are globally enabled: - * - mode='enabled': Promotions active and should be displayed - * - mode='disabled': Promotions disabled (hide promo banners) - * - * @example - * // In component: - * this.activePromoSvc.getCurrentMode().subscribe(mode => { - * if (mode && !mode.isActive) { - * this.showPromoBanners = false; // Hide banners when mode='disabled' - * } - * }); - */ - getCurrentMode(): Observable<{ - mode: string; - description: string; - isActive: boolean; - } | null> { - return this.currentModeSubject$.asObservable(); - } - - /** - * Format promo display text with translation support - */ - formatPromoDisplayText(promo: ActivePromo): string { - const translatedName = this.promoTranslationSvc.getPromoName(promo); - return `${translatedName} - ${this.formatPromoDiscount(promo)}`; - } - - /** - * Format promo discount for display - * Returns: "FREE", "50% OFF", "$10 OFF" - */ - formatPromoDiscount(promo: ActivePromo): string { - if (!promo) return ''; - - switch (promo.discountType) { - case 'free': - return $localize`:Promo label for free items@@promoFree:FREE`; - case 'percent': - return `${promo.discountValue}% ` + $localize`:Promo label suffix@@promoOff:OFF`; - case 'fixed': - // discountValue is in cents, convert to dollars - const dollars = promo.discountValue / 100; - return `$${dollars} ` + $localize`:Promo label suffix@@promoOff:OFF`; - default: - return promo.name || ''; - } - } -} diff --git a/Development/client/src/app/domain/services/app-config.service.ts b/Development/client/src/app/domain/services/app-config.service.ts index dbad015..db34088 100644 --- a/Development/client/src/app/domain/services/app-config.service.ts +++ b/Development/client/src/app/domain/services/app-config.service.ts @@ -6,7 +6,7 @@ import { environment } from '@environments/environment'; import { AppMessageService } from '@app/shared/app-message.service'; import { AuthService } from './auth.service'; import { MatType } from '@app/shared/global'; -import { catchError, debounceTime, map } from 'rxjs/operators'; +import { catchError, map } from 'rxjs/operators'; import { of } from 'rxjs'; @Injectable({ providedIn: 'root' }) @@ -59,23 +59,13 @@ export class AppConfigService { return this.http.get("/appConfig").pipe( map(res => { if (!environment.production) - console.log("AppConfigService: App config loaded successfully!", res); + console.log("App config loaded !"); this.checkAndSetDefault(res); return true; }), catchError(err => { - console.error('AppConfigService: Failed to load app config:', err); - - // Check if request was cancelled - if (err.name === 'AbortError' || err.message?.includes('cancel')) { - this.appMsgSvc.addFailedMsg('App configuration request was cancelled. Using default settings.'); - } else { - this.appMsgSvc.addFailedMsg('Could not load AppConfig. Please retry or contact Agnav.'); - } - - // Always set defaults and return true to prevent green screen - this.checkAndSetDefault(null); - return of(true); + this.appMsgSvc.addFailedMsg('Could not load AppConfig. Please retry or contact Agnav.'); + return of(false); }) ); } @@ -155,19 +145,4 @@ export class AppConfigService { }); }); } - - saveTrialDays(trialDays: Number[]) { - return new Promise((resolve, reject) => { - this.http.post("/appConfig", { trialDays }, { params: new HttpParams().set('loader', 'false') }).subscribe({ - next: (res: { trialDays: Number[] }) => { - this.settings = { ...this._settings, trialDays: res.trialDays }; - resolve(true); - }, - error: (err) => { - this.appMsgSvc.addFailedMsg('Could not save AppConfig. Please retry or contact Agnav.'); - reject(`Could not save AppConfig !': ${JSON.stringify(err)}`); - } - }); - }); - } } diff --git a/Development/client/src/app/domain/services/auth-interceptor.service.ts b/Development/client/src/app/domain/services/auth-interceptor.service.ts index 58e72fd..e290ec2 100644 --- a/Development/client/src/app/domain/services/auth-interceptor.service.ts +++ b/Development/client/src/app/domain/services/auth-interceptor.service.ts @@ -1,5 +1,6 @@ import { Injectable, Injector } from '@angular/core'; import { HttpEvent, HttpInterceptor, HttpHandler, HttpRequest, HttpResponse, HttpHeaders } from '@angular/common/http'; + import { Observable, throwError } from 'rxjs'; import { catchError, finalize, map } from 'rxjs/operators'; @@ -49,11 +50,20 @@ export class AuthInterceptor implements HttpInterceptor { if (showLoading) { this.loaderSvc.show(); this.requests.push(authReq); + // console.log("Num of loading reqs:", this.requests.length); } // Pass on the cloned request instead of the original request. return next.handle(authReq).pipe( map((event: HttpEvent) => { + // if (event instanceof HttpResponse) { + // const agmTk = event.headers.get('Agm-TK'); + // if ((agmTk && this.authSvc.token) && (this.authSvc.token.t != agmTk)) { + // const newTk = this.authSvc.token; + // newTk.t = agmTk; + // this.authSvc.token = newTk; + // } + // } return event; // Should always return the response event untouched as metioned in 'HttpEvents' section at https://angular.io/guide/http }), catchError(err => this.onCatch(err, req)), @@ -64,18 +74,12 @@ export class AuthInterceptor implements HttpInterceptor { removeRequest(req: HttpRequest) { const i = this.requests.indexOf(req); (i >= 0) && (this.requests.splice(i, 1)); - - const val = Boolean(this.requests.length > 0); - this.loaderSvc.loading$.next(val); + this.loaderSvc.loading$.next(this.requests.length > 0); + // console.log("Num of loading reqs:", this.requests.length); } private onCatch(err: any, req: HttpRequest): Observable { - // Don't logout on partner API errors - these are partner credential tests, not user session errors - const isPartnerApiError = req.url.includes('/partners/systemUsers/testAuth') - || req.url.includes('/partners/aircraft'); - - if ([401, 403].indexOf(err.status) != -1 && !req.url.endsWith('/login') && !isPartnerApiError) { - // JWT expired or invalid token responded from BE, force logOut + if ([401, 403].indexOf(err.status) != -1 && !req.url.endsWith('/login')) { // JWT expired or invalid token responded from BE, force logOut this.store.dispatch(new authActions.Logout(true)); } return throwError(err); diff --git a/Development/client/src/app/domain/services/auth.service.ts b/Development/client/src/app/domain/services/auth.service.ts index 7fdf0be..666a538 100644 --- a/Development/client/src/app/domain/services/auth.service.ts +++ b/Development/client/src/app/domain/services/auth.service.ts @@ -2,27 +2,20 @@ import { Injectable, OnDestroy, Inject, LOCALE_ID } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable, of, throwError, Subscription } from 'rxjs'; -import { exhaustMap, tap, catchError } from 'rxjs/operators'; +import { exhaustMap } from 'rxjs/operators'; -import { DateUtils, Utils } from '../../shared/utils'; +import { Utils } from '../../shared/utils'; import { RoleIds } from '../../shared/global'; import { Store } from '@ngrx/store'; import * as fromStore from '../../reducers'; import { UserModel } from '../../auth/models/user.model'; import { Authenticate } from '../../auth/models/auth.model'; -import { AGNavSubscription, PriceUsd, Trial } from '../models/subscription.model'; -import { Mode, SUB, SubStripe, SubType } from '@app/profile/common'; -import { SubscriptionService } from './subscription.service'; -import { GAService } from '../../shared/ga.service'; -import { GAAnalyticsHelpersService } from '../../shared/ga.analytics-helpers.service'; @Injectable({ providedIn: 'root' }) export class AuthService implements OnDestroy { - private _user: UserModel; - private _sessionStartTime: number; - - get user(): UserModel { + private _user: any; + get user(): any { return this._user; } @@ -52,9 +45,6 @@ export class AuthService implements OnDestroy { @Inject(LOCALE_ID) private localeId: string, private readonly store: Store<{}>, private readonly http: HttpClient, - private subSvc: SubscriptionService, - private readonly gaService: GAService, - private readonly gaHelpers: GAAnalyticsHelpersService, ) { this._locale = Utils.getLang(this.localeId) || 'en'; this._tk = JSON.parse(sessionStorage.getItem('cT')); @@ -73,10 +63,6 @@ export class AuthService implements OnDestroy { return this.hasRole([RoleIds.APP]); } - get isAppAdm() { - return this.hasRole([RoleIds.APP_ADM]); - } - get isClientUser() { return this.hasRole([RoleIds.CLIENT]); } @@ -89,14 +75,6 @@ export class AuthService implements OnDestroy { return this.hasRole([RoleIds.INSPECTOR]); } - get isPartner(): boolean { - return this.hasRole([RoleIds.PARTNER]); - } - - hasSubsWithStatus(status: string) { - return this.user?.membership?.subscriptions?.some((sub) => sub.status === `${status}`); - } - getAuthHeader(): string { return this.user && this.token ? 'Bearer ' + this.token.t : ''; } @@ -106,32 +84,7 @@ export class AuthService implements OnDestroy { } hasRole(roles: string[]): boolean { - return this.loggedIn && (roles && Utils.containsAny(roles, this.user?.roles)); - } - - hasAppRoleAndSub(): boolean { - return this.isApplicator && this.hasSubs(); - } - - hasSubs() { - return this.user?.membership?.subscriptions?.length > 0; - } - - getSub(lookupKey: string): AGNavSubscription { - if (this.hasSubs) return this.user?.membership?.subscriptions?.find((sub) => sub.items?.some((item) => item.price === lookupKey)); - } - - getCurLookupKey(type: SubType.PACKAGE | SubType.ADDON): PriceUsd { - // Use centralized utility methods - const subscriptions = this.user?.membership?.subscriptions; - switch (type) { - case SubType.PACKAGE: - return this.subSvc.getCurrentPackageLookupKey(subscriptions) || ''; - case SubType.ADDON: - return this.subSvc.getCurrentAddonLookupKey(subscriptions) || ''; - default: - throw new Error('Unsupported type'); - } + return this.loggedIn && (roles && Utils.containsAny(roles, this.user.roles)); } get isPlanner() { @@ -142,10 +95,6 @@ export class AuthService implements OnDestroy { return (this.user && this.user.billable); } - get isCanada(): boolean { - return this.user?.country === 'CA'; - } - /** * Parent user, to mange items under an applicator user */ @@ -169,45 +118,18 @@ export class AuthService implements OnDestroy { throwError('invalid_account'); // Store username and jwt token in local storage to keep user logged in between page refreshes - const user = { _id: res['_id'], username: auth.username, billable: res['billable'], roles: res['roles'], parent: (res['pui'] || ''), lang: res['lang'] || 'en', pre: res['pre'], membership: res['membership'], contact: res['contact'] || '', country: res['country'] || '' }; - this._user = user; + const user = { _id: res['_id'], username: auth.username, billable: res['billable'], roles: res['roles'], parent: (res['pui'] || ''), lang: res['lang'] || 'en', pre: res['pre'] }; + this.token = { t: res['token'], rt: res['rt'] }; - - // Track session start time - this._sessionStartTime = Date.now(); - - // Track login event - this.gaService.trackLogin({ - user_id: user._id, - user_role: this.gaHelpers.getUserRole(user.roles), - method: 'email', - platform: 'web' - }); - return of(user); - }), + }) ); } logout(gotoLogin: boolean = true): Observable { - // Track logout event before clearing session data - if (this._user && this._sessionStartTime) { - const sessionDuration = Math.round((Date.now() - this._sessionStartTime) / 60000); // Convert to minutes - this.gaService.trackLogout({ - user_id: this._user._id, - user_role: this.gaHelpers.getUserRole(this._user.roles), - session_duration_minutes: sessionDuration, - logout_method: 'manual', - platform: 'web', - page_location: window.location.href - }); - } - sessionStorage.clear(); - localStorage.removeItem('requiredSubAttention'); this._user = null; this._tk = null; - this._sessionStartTime = null; return of(true); } @@ -220,110 +142,15 @@ export class AuthService implements OnDestroy { } mailPwdReset(ops) { - return this.http.post('/users/mailPwdReset', ops).pipe( - tap(response => { - // Track password reset request - this.gaService.trackPasswordResetRequested({ - request_method: 'forgot_password_page', - user_exists: true, // If we get a success response, user exists - platform: 'web' - }); - }), - catchError(error => { - // Track password reset request failure - this.gaService.trackPasswordResetRequested({ - request_method: 'forgot_password_page', - user_exists: false, // If we get an error, user may not exist - platform: 'web' - }); - return throwError(error); - }) - ); + return this.http.post('/users/mailPwdReset', ops); } - validateResetPassword(ops) { - return this.http.post('/users/resetPassword/validate', ops); + resetPassword(ops) { + return this.http.get(`/users/resetPassword/${ops.id}/${ops.token}`); } changePassword(ops) { - return this.http.post('/users/resetPassword', ops).pipe( - tap(response => { - // Track password reset completion - this.gaService.trackPasswordResetCompleted({ - success: true, - reset_token_age_minutes: 0, // Token age info not available in current implementation - platform: 'web' - }); - }), - catchError(error => { - // Track password reset completion failure - this.gaService.trackPasswordResetCompleted({ - success: false, - reset_token_age_minutes: 0, // Token age info not available - failure_reason: 'other', - platform: 'web' - }); - return throwError(error); - }) - ); - } - - get trials() { - return this.user?.membership?.trials; - } - - hasActiveTrial(trials: Trial) { - if (!trials || !this.hasSubsWithStatus(SubStripe.TRIALING)) return false; - return trials.lastStartDate && DateUtils.currUTC() <= DateUtils.dateToTS(new Date(trials.lastEndDate)) - || this.hasSubsWithStatus(SubStripe.TRIALING); - } - - hasLastEndedTrial(trials: Trial) { - if (!trials) return false; - return trials.lastStartDate && trials.lastEndDate; - } - - isTrialDays(trials: Trial) { - return trials?.trialDays > 1; - } - - hasValidTrialOffer(trials: Trial) { - return !!trials.byDate || this.isTrialDays(trials); - } - - validateTrial(trials: Trial) { - if (!trials || !trials.type) return false; - - let isWithinTrialPeriod: boolean = false; - if (this.hasValidTrialOffer(trials)) { - if (trials.byDate) { - isWithinTrialPeriod = DateUtils.currUTC() <= DateUtils.dateToTS(new Date(trials.byDate)); - } else if (this.isTrialDays(trials)) { - const trialEndDate = new Date(trials.startDate); - trialEndDate.setDate(trialEndDate.getDate() + trials.trialDays); - isWithinTrialPeriod = DateUtils.currUTC() <= DateUtils.dateToTS(trialEndDate); - } - } - return this.hasRole([RoleIds.APP]) - && !this.hasSubs() - && isWithinTrialPeriod; - } - - canDisplayTrial(trials: Trial) { - return this.validateTrial(trials); - } - - canAcceptTrial(url: string) { - return !url.includes(SUB.MY_SERVICES) - && this.subSvc.subMode !== Mode.TRIALING; - } - - get canActivateVehicle() { - return this.isApplicator; - } - - get canAccessInvoice() { - return this.isApplicator; + return this.http.post('/users/resetPassword', ops); } ngOnDestroy(): void { diff --git a/Development/client/src/app/domain/services/client.service.ts b/Development/client/src/app/domain/services/client.service.ts index acf25c9..e74b627 100644 --- a/Development/client/src/app/domain/services/client.service.ts +++ b/Development/client/src/app/domain/services/client.service.ts @@ -5,7 +5,7 @@ import { Observable } from 'rxjs'; import { Store } from '@ngrx/store'; import { Client } from '../../client/models/client.model'; -import { CustomerInvoiceSetting } from '@app/invoices/models/customer-invoice-setting.model'; +import {CustomerInvoiceSetting} from '@app/invoices/models/customer-invoice-setting.model'; @Injectable() export class ClientService { @@ -46,13 +46,10 @@ export class ClientService { return this.http.delete(`${this.clientURL}/${client._id}`); } - searchWithSettings(byPuid: string): Observable { - return this.http.post(`${this.clientURL}/searchWithSettings`, { byPuid }); - } } export interface LoadClientOps { - byPuid: string; + byUserId: string; } export interface ClientWithSetting extends Client { diff --git a/Development/client/src/app/domain/services/customer.service.ts b/Development/client/src/app/domain/services/customer.service.ts index cae504a..10bdafd 100644 --- a/Development/client/src/app/domain/services/customer.service.ts +++ b/Development/client/src/app/domain/services/customer.service.ts @@ -1,14 +1,19 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; + import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; import { Customer } from '../../customers/models/customer.model'; +import { Store } from '@ngrx/store'; + @Injectable() export class CustomerService { private readonly customerURL = '/customers'; constructor( + private store: Store<{}>, private http: HttpClient ) { } @@ -17,9 +22,8 @@ export class CustomerService { return this.http.get(this.customerURL); } - getCustomer(id: string, view?: string): Observable { - const url = view ? `${this.customerURL}/${id}?view=${view}` : `${this.customerURL}/${id}`; - return this.http.get(url); + getCustomer(id: string): Observable { + return this.http.get(`${this.customerURL}/${id}`); } saveCustomer(customer: Customer): Observable { diff --git a/Development/client/src/app/domain/services/global-error.interceptor.ts b/Development/client/src/app/domain/services/global-error.interceptor.ts deleted file mode 100644 index ab87218..0000000 --- a/Development/client/src/app/domain/services/global-error.interceptor.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { Injectable } from '@angular/core'; -import { - HttpRequest, - HttpHandler, - HttpEvent, - HttpInterceptor, - HttpErrorResponse, - HttpResponse -} from '@angular/common/http'; -import { Observable, throwError } from 'rxjs'; -import { catchError, tap } from 'rxjs/operators'; -import { AppMessageService } from '@app/shared/app-message.service'; -import { globals } from '@app/shared/global'; -import { environment } from '@environments/environment'; -import { AppInjector } from '@app/app-injector'; -import { GAService } from '@app/shared/ga.service'; - -@Injectable() -export class GlobalErrorInterceptor implements HttpInterceptor { - private failedAttempts = 0; - private gaSvc: GAService; - - constructor(private readonly msgSvc: AppMessageService) { - // Use AppInjector to get GAService to avoid circular dependency - this.gaSvc = AppInjector.getInjector().get(GAService); - } - - intercept(req: HttpRequest, next: HttpHandler): Observable> { - const startTime = Date.now(); - - return next.handle(req).pipe( - tap(event => { - // Track successful but slow API responses - if (event instanceof HttpResponse) { - const responseTime = Date.now() - startTime; - - // Track slow API responses (threshold: 2 seconds) - if (responseTime > 2000) { - this.trackSlowApiResponse(req, event, responseTime); - } - } - }), - catchError((error: HttpErrorResponse) => { - const responseTime = Date.now() - startTime; - - // Track HTTP error event - this.trackHttpError(error, req, responseTime); - - if (error.status >= 500 && error.status < 600) { - this.failedAttempts++; - if (this.failedAttempts >= environment.failedRqAttempts) { - this.msgSvc.addFailedMsg(globals.server500Err); - this.failedAttempts = 0; // Reset counter after showing the error - } - } - return throwError(error); - }) - ); - } - - private trackHttpError(error: HttpErrorResponse, req: HttpRequest, responseTime: number): void { - const errorType = this.categorizeError(error); - const endpoint = this.extractEndpoint(req.url); - - this.gaSvc.trackEvent('http_error', { - platform: 'web', - error_type: errorType, - http_status_code: error.status || 0, - error_message: error.message || 'Unknown HTTP error', - request_method: req.method as any, - request_url: req.url, - request_endpoint: endpoint, - response_time_ms: responseTime, - affected_feature: this.extractFeature(endpoint) - }); - } - - private trackSlowApiResponse(req: HttpRequest, response: HttpResponse, responseTime: number): void { - const endpoint = this.extractEndpoint(req.url); - - this.gaSvc.trackEvent('api_response_slow', { - platform: 'web', - api_endpoint: endpoint, - response_time_ms: responseTime, - payload_size: this.getPayloadSize(response), - cache_hit: this.isCacheHit(response) - }); - } - - private categorizeError(error: HttpErrorResponse): 'network_error' | 'server_error' | 'client_error' | 'timeout' | 'unknown_error' { - if (error.status === 0 || error.status === -1) { - return 'network_error'; - } - if (error.status >= 500) { - return 'server_error'; - } - if (error.status >= 400 && error.status < 500) { - return 'client_error'; - } - if (error.status === 408 || error.message?.includes('timeout')) { - return 'timeout'; - } - return 'unknown_error'; - } - - private extractEndpoint(url: string): string { - try { - const pathname = new URL(url, window.location.origin).pathname; - return pathname.replace(/^\/api/, '').split('/')[1] || 'unknown'; - } catch { - return url.split('/')[1] || 'unknown'; - } - } - - private extractFeature(endpoint: string): string { - const featureMap: { [key: string]: string } = { - 'jobs': 'job_management', - 'invoices': 'billing', - 'reports': 'reporting', - 'files': 'file_management', - 'users': 'user_management', - 'auth': 'authentication', - 'customers': 'customer_management', - 'equipment': 'equipment_management' - }; - return featureMap[endpoint] || 'unknown'; - } - - private getPayloadSize(response: HttpResponse): number | undefined { - try { - const contentLength = response.headers.get('content-length'); - if (contentLength) { - return parseInt(contentLength, 10); - } - - // Fallback: estimate payload size from response body - if (response.body) { - const bodyString = JSON.stringify(response.body); - return new Blob([bodyString]).size; - } - } catch (error) { - // Ignore errors in payload size calculation - } - return undefined; - } - - private isCacheHit(response: HttpResponse): boolean | undefined { - // Check common cache indicators in response headers - const cacheControl = response.headers.get('cache-control'); - const etag = response.headers.get('etag'); - const lastModified = response.headers.get('last-modified'); - const xCache = response.headers.get('x-cache'); - - // Check for explicit cache hit indicators - if (xCache?.includes('HIT')) { - return true; - } - - // If response has cache headers but no explicit hit indicator, likely cached - if (cacheControl && (etag || lastModified)) { - return true; - } - - return undefined; // Unknown cache status - } -} diff --git a/Development/client/src/app/domain/services/invoice.service.ts b/Development/client/src/app/domain/services/invoice.service.ts index 10256cd..eb34a28 100644 --- a/Development/client/src/app/domain/services/invoice.service.ts +++ b/Development/client/src/app/domain/services/invoice.service.ts @@ -158,7 +158,7 @@ export class InvoiceService { return jobCostings.reduce((acc, jobCosting) => { const items = jobCosting?.costings?.items?.map(item => ({ - job: jobCosting.job, + id: jobCosting.job, jobName: jobCosting.name, costingName: item.name, quantity: item.quantity, @@ -197,17 +197,15 @@ export class InvoiceService { }; private calculateSubTotal(client: Client, totalJobAmount?: number): number { - const split = client?.split ? +client.split : 100; return totalJobAmount - ? totalJobAmount * (Number(split) / 100) + ? totalJobAmount * (Number(client.split) / 100) : client.subTotal ? Number(client.subTotal) : 0; } private calculateDiscounted(subTotal: number, client: Client): number { - const discount = client?.discount ? +client.discount : 0; - return subTotal * (discount / 100); + return subTotal * (+client.discount / 100); } private calculateTotalExcludingTax(subTotal: number, discounted: number): number { @@ -215,8 +213,7 @@ export class InvoiceService { } private calculateTaxed(totalExcludingTax: number, client: Client): number { - const taxRate = client?.taxRate ? +client.taxRate : 0; - return totalExcludingTax * (taxRate / 100); + return totalExcludingTax * (+client.taxRate / 100); } private calculateTotal(totalExcludingTax: number, taxed: number): number { @@ -250,6 +247,10 @@ export class InvoiceService { }; const payment = this.calculateClientPayment(client, subTotalAfterSplit); + if (print.client.paymentTerm) { + print.paymentTerm = print.client.paymentTerm; + } + return { ...print, jobItems, diff --git a/Development/client/src/app/domain/services/job.service.ts b/Development/client/src/app/domain/services/job.service.ts index 37ef4e4..15bea3f 100644 --- a/Development/client/src/app/domain/services/job.service.ts +++ b/Development/client/src/app/domain/services/job.service.ts @@ -4,6 +4,7 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; +import { Store } from '@ngrx/store'; import { IJob, IUIJob, JobLog, RptOption, toJob } from '../../job/models/job.model'; import { AppFile } from '../models/shared.model'; import { UpdateJobOps } from '../../job/actions/job.actions'; @@ -14,26 +15,13 @@ export class JobService { private readonly jobURL = '/jobs'; constructor( + private store: Store<{}>, private http: HttpClient ) { } loadJobs(ops: any): Observable { - let _ops = new HttpParams() - .set('clientId', ops?.clientId || '') - .set('jpo', ops?.jobsByPilot || 'false') - .set('status', ops?.status || ''); - - if (ops?.byTime?.length === 2) { - for (const time of ops.byTime) { - if (time) { - _ops = _ops.append('byTime', time.toISOString()); - } - } - } else { - _ops = _ops.append('byTime', ops?.byTime[0] || ''); - } - + const _ops = new HttpParams().set('clientId', ops && ops.clientId).set('jpo', (ops && ops.jobsByPilot) || 'false'); return this.http.get(this.jobURL, { params: _ops }); } @@ -117,10 +105,6 @@ export class JobService { return this.http.post(`${this.jobURL}/deleteAppFile`, options, { params: new HttpParams().set('loader', 'false') }); } - fetchInvReadyJobs(excludeIds?: string[]): Observable { - return this.http.post(`${this.jobURL}/fetchInvReadyJobs`, { excludeIds }); - } - downloadAppFile(fname) { let httpParams = new HttpParams().set('file', fname); return this.http.get('/exports/downloadAppfile', { params: httpParams, responseType: 'arraybuffer' }).pipe( @@ -187,24 +171,8 @@ export class JobService { return this.http.post(`${this.jobURL}/appFiles`, { jobId: jobId }); } - getFilesData(fileId: string, params?: { - limit?: number, - startingAfter?: string, - endingBefore?: string, - returnAll?: boolean - }) { - const body: any = { - fileId: fileId - }; - - if (params) { - if (params.limit !== undefined) body.limit = params.limit; - if (params.startingAfter !== undefined) body.startingAfter = params.startingAfter; - if (params.endingBefore !== undefined) body.endingBefore = params.endingBefore; - if (params.returnAll !== undefined) body.returnAll = params.returnAll; - } - - return this.http.post(`${this.jobURL}/filesdata`, body); + getFilesData(ids) { + return this.http.post(`${this.jobURL}/filesdata`, { fileIds: ids }); } } diff --git a/Development/client/src/app/domain/services/managehttp.interceptor.service.ts b/Development/client/src/app/domain/services/managehttp.interceptor.service.ts index b2d9219..949ecbf 100644 --- a/Development/client/src/app/domain/services/managehttp.interceptor.service.ts +++ b/Development/client/src/app/domain/services/managehttp.interceptor.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { Router, NavigationStart } from '@angular/router'; +import { Router, ActivationEnd } from '@angular/router'; import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } from '@angular/common/http'; import { Observable } from 'rxjs'; @@ -11,33 +11,18 @@ import { HttpCancelService } from './httpcancel.service'; @Injectable() export class ManageHttpInterceptor implements HttpInterceptor { - private currentUrl: string = ''; constructor(private readonly router: Router, private readonly httpCancelService: HttpCancelService) { router.events.subscribe(event => { - // Only cancel on actual route changes, not during guard/resolver execution - if (event instanceof NavigationStart) { - // Check if this is actually a new route, not just a reload or guard execution - if (this.currentUrl && event.url !== this.currentUrl) { - this.httpCancelService.cancelPendingRequests(); - } - this.currentUrl = event.url; + // An event triggered at the end of the activation part of the Resolve phase of routing. + if (event instanceof ActivationEnd) { + // Cancel pending calls + this.httpCancelService.cancelPendingRequests(); } }); } intercept(req: HttpRequest, next: HttpHandler): Observable> { - // Don't cancel critical configuration and authentication requests - const isCriticalRequest = req.url.includes('/appConfig') || - req.url.includes('/login') || - req.url.includes('/auth') || - req.url.includes('/ping'); - - if (isCriticalRequest) { - // Critical requests should not be cancelled by route changes - return next.handle(req); - } - return next.handle(req).pipe(takeUntil(this.httpCancelService.onCancelPendingRequests())) } } \ No newline at end of file diff --git a/Development/client/src/app/domain/services/promo-translation.service.ts b/Development/client/src/app/domain/services/promo-translation.service.ts deleted file mode 100644 index 2c8209f..0000000 --- a/Development/client/src/app/domain/services/promo-translation.service.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Injectable } from '@angular/core'; -import { PromoLabels } from 'src/app/profile/common'; -import { ActivePromo } from './active-promo.service'; - -@Injectable({ - providedIn: 'root' -}) -export class PromoTranslationService { - - /** - * Get translated promo name with fallback to static name - */ - getPromoName(promo: ActivePromo): string { - if (promo.nameKey && PromoLabels[promo.nameKey]) { - return PromoLabels[promo.nameKey]; - } - return promo.name; // Fallback to static name - } - - /** - * Get translated promo description with fallback to static name - */ - getPromoDescription(promo: ActivePromo): string { - if (promo.descriptionKey && PromoLabels[promo.descriptionKey]) { - return PromoLabels[promo.descriptionKey]; - } - // Fallback: use name as description if no descriptionKey translation - return this.getPromoName(promo); - } - - /** - * Check if promo has translation keys available - */ - hasTranslation(promo: ActivePromo): boolean { - return !!(promo.nameKey && PromoLabels[promo.nameKey]); - } -} \ No newline at end of file diff --git a/Development/client/src/app/domain/services/subscription.service.ts b/Development/client/src/app/domain/services/subscription.service.ts deleted file mode 100644 index 1f4575f..0000000 --- a/Development/client/src/app/domain/services/subscription.service.ts +++ /dev/null @@ -1,1229 +0,0 @@ -import { Injectable } from '@angular/core'; -import { HttpClient, HttpParams } from '@angular/common/http'; -import { environment } from '@environments/environment'; -import { Observable, of, Subject, Subscription, throwError } from 'rxjs'; -import { Price, InvoicePackage, Address, Invoice, SubscriptionPackage, StripeSubscription, PaymentMethod, UnpaidPackage, SubscriptionPaymentMethod, Charge, PaidAmount, AGNavSubscriptionShort, CustChargePkg, Usage, BillPeriod, UsagePackage, CheckoutPayment, Coupon, PMPkgEdit, PriceUsd, Acre, AGNavSubscription, Plan, Status, BillingInfoPackage, Package, Addon, TrialItem, ExpiryWarning } from '@app/domain/models/subscription.model'; -import { loadStripe, Stripe, StripeCardElement } from '@stripe/stripe-js'; -import { DateUtils, UnitUtils, Utils } from '@app/shared/utils'; -import { Mode, SUB, SubKeys, SubStripe, SubTexts, SubType, subPlans, UNLIMITED } from '@app/profile/common'; -import { map, switchMap, tap, catchError } from 'rxjs/operators'; -import { IMembership } from '@app/auth/models/user.model'; -import { Store } from '@ngrx/store'; -import { getSubIntentMode } from '@app/reducers'; -import { UserService } from './user.service'; - -export interface CCFormValues { - ccName: string, - card: StripeCardElement -} -const BASE_URL = '/subscription'; -const MAX_PERCENT = 100; -const KEY = 'subIntent'; - -interface Option { - subscriptions?: { id: string, status: string }[]; - coupon?: Coupon; -} - -@Injectable({ - providedIn: 'root' -}) -export class SubscriptionService { - private _stripe: Stripe; - private _stripeLoadStatus$ = new Subject(); - - get stripe(): Stripe { - return this._stripe; - } - - set stripe(value: Stripe) { - if (!this._stripe) this._stripe = value; - } - - private sub$: Subscription; - - private subMode$ = this.store.select(getSubIntentMode); - private _subMode: Mode; - get subMode(): Mode { - return this._subMode; - } - - constructor( - private readonly http: HttpClient, - private readonly store: Store<{}>, - private userSvc: UserService - ) { - this.sub$ = this.subMode$.subscribe((mode) => this._subMode = mode); - } - - // Rest endpoints - getPrices(): Observable { - return this.http.get(`${BASE_URL}/prices`); - } - - getPaymentMethodList(custId: string): Observable { - return this.http.get(`${BASE_URL}/paymentMethods/${custId}`); - } - - getDefPaymentMethods(custId: string): Observable { - return this.http.get(`${BASE_URL}/paymentMethods/${custId}/getDefault`); - } - - getConfig(): Observable<{ config: string }> { - return this.http.get<{ config: string }>(`${BASE_URL}/config`); - } - - getBillingAddress(applicatorId: string): Observable
{ - return this.http.get
(`${BASE_URL}/billAddress/${applicatorId}`); - } - - updateBillAddress(applicatorId: string, addrPkg: Address): Observable
{ - return this.http.put
(`${BASE_URL}/billAddress/${applicatorId}`, addrPkg); - } - - retrieveUpcomingInvoices(invoicePkg: InvoicePackage) { - return this.http.post(`${BASE_URL}/retrieveNextInvoices`, invoicePkg); - } - - updateSubscription(subPkg: SubscriptionPackage): Observable { - return this.http.post(`${BASE_URL}/update`, subPkg); - } - - /** - * Check subscription status (for polling after 3DS completion per r944) - * - * @param subscriptionId Stripe subscription ID (sub_xxxxx) - * @returns Observable with subscription status from Stripe - */ - checkSubscriptionStatus(subscriptionId: string): Observable { - if (!subscriptionId || !subscriptionId.startsWith('sub_')) { - console.error('❌ Invalid subscription ID:', subscriptionId); - return throwError(new Error('Invalid subscription ID')); - } - - return this.http.get(`${BASE_URL}/status/${subscriptionId}`).pipe( - catchError((error) => { - console.error('❌ Status check error:', error); - return throwError(error); - }) - ); - } - - fetchSubscriptions(custId: string): Observable { - return this.http.get(`${BASE_URL}?custId=${custId}&billInfo=true`); - } - - fetchPayments(pmtPkg: { custId: string, byTime: string }): Observable<{ invoices: Invoice[], charges: Charge[] }> { - return this.http.post<{ - invoices: Invoice[], - charges: Charge[] - }>(`${BASE_URL}/custInvoices`, { - custId: pmtPkg.custId, - byTime: pmtPkg.byTime - }); - } - - resumeUnpaidSub(unpaidSubs: string[]): Observable { - return this.http.post(`${BASE_URL}/resumeUnpaidSub`, { - unpaidSubs - }); - } - - payUnpaidSub(unpaidPkg: UnpaidPackage): Observable { - return this.http.post(`${BASE_URL}/payInvoice`, unpaidPkg); - } - - updateSubsPaymentMethod(subPm: SubscriptionPaymentMethod): Observable { - return this.http.post(`${BASE_URL}/setSubsPaymentMethod`, subPm); - } - - updateCustPaymentMethod(custId: string, pmId: string, setDefault?: boolean): Observable { - return this.http.put(`${BASE_URL}/paymentMethods/${custId}`, { - pmId, - setDefault - }); - } - - getCustCharges(chargePkg: CustChargePkg): Observable { - return this.http.post(`${BASE_URL}/custCharges`, chargePkg); - } - - retrieveUsage(usgPkg: UsagePackage): Observable { - return this.http.post(`${BASE_URL}/custUsages`, usgPkg); - } - - retrieveCurrUsage(custId: string, byPuid: string): Observable { - return this.retrieveBilPeriod(custId).pipe( - switchMap((_billPeriods) => { - const curPeriod = _billPeriods.sort((p1, p2) => p1.periodEnd - p2.periodEnd).reverse()[0]; - return this.retrieveUsage({ - byPuid: byPuid, - fromTS: DateUtils.startUtcTS(curPeriod?.periodStart), - toTS: DateUtils.endUtcTS(curPeriod?.periodEnd) - }); - }), - map((usage) => usage) - ) - } - - retrieveBilPeriod(custId: string, subTypes?: string[]): Observable { - return this.http.post(`${BASE_URL}/subBillPeriods`, { - custId, - subTypes - }); - } - - editSub(subsSettings: { subId: string, cancelAtPeriodEnd: boolean }[]): Observable { - return this.http.post(`${BASE_URL}/setSubsSettings`, { - subsSettings - }).pipe( - map(subs => this.normalizeSubscriptionStructure(subs)) - ); - } - - /** - * Normalize simplified backend subscription structure to full Stripe structure - * Backend returns simplified format from _toMembershipSubscription(): - * - items: [] (flat array) - * - periodEnd/periodStart instead of current_period_end/current_period_start - * - cancelAtPeriodEnd instead of cancel_at_period_end - * This method transforms it to match the full Stripe API structure expected by frontend - */ - private normalizeSubscriptionStructure(subs: any[]): StripeSubscription[] { - return subs.map(sub => { - // If already in full format, return as-is - if (sub.items?.data) { - return sub; - } - - // Transform simplified format to full Stripe structure - return { - id: sub.id, - object: 'subscription', - status: sub.status, - current_period_start: sub.periodStart || sub.current_period_start, - current_period_end: sub.periodEnd || sub.current_period_end, - cancel_at_period_end: sub.cancelAtPeriodEnd !== undefined ? sub.cancelAtPeriodEnd : sub.cancel_at_period_end, - cancel_at: sub.cancelAt || sub.cancel_at, - trial_end: sub.trialEnd || sub.trial_end, - metadata: { - type: sub.type, - scheduleId: sub.scheduleId, - ...(sub.metadata || {}) - }, - items: { - object: 'list', - data: (sub.items || []).map(item => ({ - object: 'subscription_item', - price: { - lookup_key: typeof item.price === 'string' ? item.price : item.price?.lookup_key, - metadata: item.metadata || {}, - recurring: sub.recurring || { interval: 'month', interval_count: 1 } - }, - quantity: item.quantity || 1 - })) - }, - // Preserve recurring info - plan: sub.recurring ? { - interval: sub.recurring.interval, - interval_count: sub.recurring.intervalCount || sub.recurring.interval_count - } : undefined, - // Fill in optional fields that may not be present - latest_invoice: undefined, - default_payment_method: undefined, - default_source: undefined, - quantity: undefined - } as StripeSubscription; - }); - } - - getCoupon(coupon: string, priceKeys?: string[]): Observable { - let url = `${BASE_URL}/getCoupon/${coupon}`; - - // Add price keys as query params for product restriction validation - if (priceKeys && priceKeys.length > 0) { - const params = new HttpParams().set('priceKeys', priceKeys.join(',')); - return this.http.get(url, { params }); - } - - return this.http.get(url); - } - - editPM(custId: string, pkg: PMPkgEdit): Observable { - return this.http.put(`${BASE_URL}/paymentMethods/${custId}`, pkg); - } - - addPM(custId: string, pmId: string, setDefault?: boolean): Observable { - return this.http.post(`${BASE_URL}/paymentMethods/${custId}`, { - pmId, - setDefault - }); - } - - deletePM(custId: string, pmId: string): Observable { - return this.http.request('delete', `${BASE_URL}/paymentMethods/${custId}`, { - body: { - pmId - } - }); - } - - // ============================================================================ - // SUBSCRIPTION STATE HELPERS - // ============================================================================ - - /** - * Determine if subscription will cancel at period end. - * @param sub - StripeSubscription object - * @returns true if subscription will cancel at period end - */ - willSubscriptionCancel(sub: StripeSubscription): boolean { - return sub?.cancel_at_period_end ?? false; - } - - /** - * Get the cancellation date for a subscription. - * @param sub - StripeSubscription object - * @returns Date when subscription will cancel, or null if not canceling - */ - getCancellationDate(sub: StripeSubscription): Date | null { - if (sub?.cancel_at_period_end && sub?.current_period_end) { - return new Date(sub.current_period_end * 1000); - } - return null; - } - - /** - * Check if subscription has a promo applied. - * Checks for promoId in metadata OR discount coupon presence. - * @param sub - StripeSubscription object - * @returns true if subscription has an active promo - */ - hasSubscriptionPromo(sub: StripeSubscription): boolean { - return !!sub?.metadata?.promoId || !!sub?.discount; - } - - /** - * Get promo display info from subscription promoDetails (r955+). - * @param sub - StripeSubscription object - * @returns Promo info or null if no promo - * @since r955 - Updated to use promoDetails instead of deprecated discount field - */ - getSubscriptionPromoDiscount(sub: StripeSubscription): { name: string; percentOff?: number; amountOff?: number } | null { - if (!sub?.promoDetails?.hasPromo) return null; - - // Parse discount value from discountDisplay (e.g., "50% OFF" or "FREE") - const discountDisplay = sub.promoDetails.discountDisplay; - const percentMatch = discountDisplay?.match(/(\d+)%/); - const percentOff = percentMatch ? parseInt(percentMatch[1]) : (discountDisplay?.includes('FREE') ? 100 : null); - - return { - name: sub.promoDetails.name || 'Promo', - percentOff: percentOff || undefined, - amountOff: undefined // Backend no longer provides amount_off - }; - } - - /** - * Calculate total promo savings from line items and active promos. - * This is the SINGLE SOURCE OF TRUTH for promo savings calculations. - * - * CALCULATION ORDER (CRITICAL - WI-2804): - * 1. Apply discount at native billing interval (monthly = monthly, annual = annual) - * 2. No annualization - show what customer actually pays - * - * This matches Stripe's actual billing behavior and non-promo display format. - * Uses Stripe lineItems (cents-based) for precision and consistency. - * - * @param lineItems - Stripe invoice line items (payment or refund) - * @param promos - Map of lookup_key to ActivePromo objects - * @returns Total promo savings in cents (at native billing interval) - * - * @example - * // In checkout component: - * const savings = this.subSvc.calculatePromoSavings( - * this.chkoutPmt?.payment?.lineItems, - * this.paymentPromos - * ); - * - * // Example: ESS_2 + Addon + 50% promo - * // ESS_2 (annual): $2,495 × 50% = $1,247.50 savings (annual) - * // Addon (monthly): $49.95 × 50% = $24.97 savings (monthly, not annualized) - * // Total savings: $1,272.47 (mixed interval - show separately) - */ - calculatePromoSavings(lineItems: any[], promos: Map): number { - if (!lineItems || lineItems.length === 0 || !promos || promos.size === 0) { - return 0; - } - - let totalSavings = 0; - - lineItems.forEach((item: any) => { - // Skip proration credit lines — these are refunds for old quantities where - // the user already benefited from the promo on a prior invoice. - // Identified by: proration=true AND credited_items is not null. - if (item.proration && item.proration_details?.credited_items != null) { - return; - } - - const lookupKey = item.price?.lookup_key; - const promo = promos.get(lookupKey); - - if (promo && item.price?.unit_amount) { - // Get original amount at native billing interval - const originalAmount = item.price.unit_amount * (item.quantity || 1); - let savings = 0; - - // Calculate savings at native interval (no annualization) - if (promo.discountType === 'free' || promo.discountValue === 100) { - savings = originalAmount; // 100% off - } else if (promo.discountType === 'percent') { - savings = Math.round(originalAmount * (promo.discountValue / 100)); - } else if (promo.discountType === 'fixed') { - // discountValue is already in cents (e.g., 15000 = $150.00) - // item.price.unit_amount is also in cents - // Cap discount at original amount to prevent negative prices - savings = Math.min(originalAmount, promo.discountValue); - } - - totalSavings += savings; - } - }); - - return totalSavings; - } - - /** - * Calculate discounted amount for a single item with promo applied - * CENTRALIZED METHOD - All components should use this instead of duplicating logic - * - * @param originalAmount - Original price in cents (e.g., 99500 = $995.00) - * @param promo - ActivePromo object - * @returns Discounted amount in cents - * - * @example - * const promo = { discountType: 'fixed', discountValue: 15000 }; // $150 OFF - * const discounted = calculateDiscountedAmount(99500, promo); - * // Returns: 84500 ($845.00) - */ - calculateDiscountedAmount(originalAmount: number, promo: any): number { - if (!promo || !originalAmount) { - return originalAmount; - } - - // Calculate savings based on promo type - if (promo.discountType === 'free' || promo.discountValue === 100) { - return 0; // 100% off - } else if (promo.discountType === 'percent') { - return Math.round(originalAmount * (1 - promo.discountValue / 100)); - } else if (promo.discountType === 'fixed') { - // discountValue is already in cents (e.g., 15000 = $150.00) - // Cap discount at original amount to prevent negative prices - return Math.max(0, originalAmount - promo.discountValue); - } - - return originalAmount; - } - - // ============================================================================ - // SUBSCRIPTION STATUS UTILS - // ============================================================================ - - hasSubsWithStatus(subs: StripeSubscription[], status: string): boolean { - return subs?.some((sub) => sub?.status === `${status}`); - } - - isRequirePaymentMethod(subs: StripeSubscription[]): boolean { - return subs?.some((sub) => sub?.latest_invoice?.payment_intent?.status === SubStripe.REQUIRE_PAYMENT_METHOD); - } - - isRequireAction(subs: StripeSubscription[]): boolean { - // CRITICAL: Backend returns 3DS requirements in multiple possible formats: - // 1. Standard Stripe format: latest_invoice.payment_intent.status === 'requires_action' - // 2. Pre-3DS state: latest_invoice.payment_intent.status === 'requires_confirmation' (needs confirmation which may trigger 3DS) - // 3. Backend's Direct Pattern format: requires_action === true (flat structure with client_secret) - // We must check all three to handle 3DS authentication correctly (r942 implementation) - return subs?.some((sub) => - sub?.latest_invoice?.payment_intent?.status === SubStripe.REQUIRE_ACTION || - sub?.latest_invoice?.payment_intent?.status === 'requires_confirmation' || - (sub as any)?.requires_action === true - ); - } - - getReqPmSubscription(subs: StripeSubscription[]): StripeSubscription { - return subs?.find((sub) => sub?.latest_invoice?.payment_intent?.status === SubStripe.REQUIRE_PAYMENT_METHOD); - } - - getReqActionSubscription(subs: StripeSubscription[]): StripeSubscription { - SubStripe.REQUIRE_ACTION - // CRITICAL: Check all formats for 3DS requirement (see isRequireAction comment) - return subs?.find((sub) => - sub?.latest_invoice?.payment_intent?.status === SubStripe.REQUIRE_ACTION || - sub?.latest_invoice?.payment_intent?.status === 'requires_confirmation' || - (sub as any)?.requires_action === true - ); - } - - atCheckoutReviewStage(): boolean { - const subIntent = JSON.parse(sessionStorage.getItem(KEY)); - return subIntent?.stage === SUB.CHKOUT_REV; - } - - atStage(stage: string): boolean { - const subIntent = JSON.parse(sessionStorage.getItem(KEY)); - return subIntent?.stage === `${stage}`; - } - - checkSubStatus(subs: AGNavSubscriptionShort[], status: string, op: string) { - const binaryOp = (op: string) => new Function('x', 'y', `return x ${op} y;`); - return subs?.some((sub) => binaryOp(op)(sub?.status, status)); - } - - getPeriod(periodEnd: number): number { - return periodEnd - DateUtils.startUtcTS(DateUtils.currUTC()); - } - - dayUsedPerct(periodStart: number, periodEnd: number) { - const period = periodEnd - periodStart; - const dayUsed = DateUtils.currUTC() - periodStart; - const curPeriod = this.getPeriod(periodEnd); - if (period === 0) return 0; - if (dayUsed <= 0) return 0; - if (curPeriod <= 0) return 100; - return Math.floor((dayUsed / period) * MAX_PERCENT); - } - - curDayRemain(periodEnd: number) { - const SECS_PER_DAY = 86400; - const curPeriod = this.getPeriod(periodEnd); - if (curPeriod <= 0) return 0; - return Math.floor(curPeriod / SECS_PER_DAY); - } - - acrUsedPerct(acrUsed: number, maxAcr: number) { - if (!+maxAcr || !+acrUsed || maxAcr === 0) return 0; - if (acrUsed >= maxAcr) return 100; - return Math.floor((acrUsed / maxAcr) * MAX_PERCENT); - } - - private calcTotalAmount(lines): number { - if (Utils.isEmptyArray(lines)) return 0; - return lines?.map((line) => line?.amount).reduce((t1, t2) => t1 + t2, 0); - } - - private extractLineTax(lines): [] { - if (Utils.isEmptyArray(lines)) return []; - return lines?.map((line) => line?.tax_amounts).flat(); - } - - private calcInvoice(invoices: Invoice[], coupon?: Coupon): CheckoutPayment { - let lines = []; - invoices?.map((inv) => lines = lines.concat(inv?.lines?.data)); - const pmtTotalTax = this.calcTotalAmount(this.extractLineTax(lines)); - const pmt = { - payment: { - lineItems: lines, - totalAmount: this.calcTotalAmount(lines) + pmtTotalTax, - totalTax: pmtTotalTax - } - }; - if (coupon) { - return this.applyCoupon(pmt, coupon); - } - return pmt; - } - - private calcInvoiceWithProrate(invoices: Invoice[], coupon?: Coupon): CheckoutPayment { - let lines = []; - invoices.map((inv) => lines = lines.concat(inv?.lines?.data?.filter((line) => line?.period?.start === inv?.subscription_proration_date))); - const isRefundLine = (line: any) => - line?.parent?.subscription_item_details?.proration_details?.credited_items != null || - line?.proration_details?.credited_items != null; - - const rfdLines = lines.filter(isRefundLine); - const pmtLines = lines.filter(line => !isRefundLine(line)); - let pmt: CheckoutPayment; - const pmtTotalTax = this.calcTotalAmount(this.extractLineTax(pmtLines)); - if (rfdLines.length > 0) { - const refTotalTax = this.calcTotalAmount(this.extractLineTax(rfdLines)); - pmt = { - payment: { - lineItems: pmtLines, - totalAmount: this.calcTotalAmount(pmtLines) + pmtTotalTax, - totalTax: pmtTotalTax - }, - refund: { - lineItems: rfdLines, - totalAmount: this.calcTotalAmount(rfdLines) + refTotalTax, - totalTax: refTotalTax - } - }; - } else { - pmt = { - payment: { - lineItems: pmtLines, - totalAmount: this.calcTotalAmount(pmtLines) + pmtTotalTax, - totalTax: pmtTotalTax - } - }; - } - - if (coupon) { - return this.applyCoupon(pmt, coupon); - } - return pmt; - } - - calcChkoutPayment(invoices: Invoice[], opt?: Option): CheckoutPayment { - if (Utils.isEmptyArray(invoices)) return { payment: { totalAmount: 0, totalTax: 0, lineItems: [] } }; - - const hasUnresolvedSub = opt?.subscriptions?.some((sub) => - sub.status === SubStripe.UNPAID || sub.status === SubStripe.INCOMPLETE || - sub.status === SubStripe.PAST_DUE || sub.status === SubStripe.OVERDUE - ); - if (hasUnresolvedSub) { - return this.calcInvoice(invoices, opt?.coupon); - } - - const prorateInvs = invoices.filter((inv) => - inv?.lines?.data?.some((line) => line?.period?.start === inv?.subscription_proration_date) - ); - if (prorateInvs.length > 0) { - return this.calcInvoiceWithProrate(invoices, opt?.coupon); - } - // No proration + all subs active = clean upcoming invoice (e.g., deferred promo's Invoice[1]) - return this.calcInvoice(invoices, opt?.coupon); - } - - calcAmount(invoices: Invoice[], opt?: Option): PaidAmount { - if (Utils.isEmptyArray(invoices)) return { totalExcludingTax: 0, totalTax: 0, total: 0 }; - const pmt = this.calcChkoutPayment(invoices, opt); - return { - totalExcludingTax: pmt.payment.totalAmount - pmt.payment.totalTax, - totalTax: pmt.payment.totalTax, - total: pmt.payment.totalAmount, discount: pmt.payment.discount - }; - } - - hasInValTaxLoc(subs: StripeSubscription[]): boolean { - if (Utils.isEmptyArray(subs)) return false; - return subs?.some((sub) => sub?.latest_invoice?.automatic_tax?.enabled - && sub?.latest_invoice?.automatic_tax?.status === SubStripe.REQ_LOC_INPUT); - } - - private applyCoupon(pmt: CheckoutPayment, coupon: Coupon): CheckoutPayment { - let clonedPmt: CheckoutPayment = { ...pmt }; - if (coupon) { - if (coupon.percent_off) { - const amountOff = (clonedPmt.payment.totalAmount - clonedPmt.payment.totalTax) * (coupon.percent_off / 100); - clonedPmt.payment = { - ...clonedPmt.payment, - totalAmount: clonedPmt.payment.totalAmount - amountOff, - totalTax: clonedPmt.payment.totalTax, - discount: { amountOff, percentOff: coupon.percent_off } - }; - return clonedPmt; - } - let totalAmount = clonedPmt.payment.totalAmount - coupon.amount_off; - clonedPmt.payment = { - ...clonedPmt.payment, - totalAmount: totalAmount >= 0 ? totalAmount : 0, - totalTax: clonedPmt.payment.totalTax, - discount: { amountOff: coupon.amount_off } - }; - return clonedPmt; - } - return clonedPmt; - } - - getInvCoupon(invoices: Invoice[]): Coupon { - if (Utils.isEmptyArray(invoices)) return; - return invoices?.[0]?.discount?.coupon; - } - - crtCardDesc(brand: string, last4: string): string { - if (!brand && !last4) return ''; - return `${brand.charAt(0).toUpperCase()}${brand.slice(1)} ${SubTexts.ending} **** ${last4}`; - } - - crtExp(expMonth, expYear): string { - if (!expMonth && !expYear) return ''; - return expMonth.toString().length === 1 - ? `0${expMonth}/${expYear}` - : `${expMonth}/${expYear}`; - } - - formatCurrency(currency: PriceUsd): string { - const DEFAULT_CURRENCY = '$0'; - if (currency) { - const priceToUS = (price: PriceUsd): string => { - if (price) { - return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(+price / 100); - } - return DEFAULT_CURRENCY; - } - const formatNegative = (currency: number): string => { - const usPrice = priceToUS(Math.abs(currency)); - return `$(${usPrice.substring(1, usPrice.length)})`; - } - return +currency >= 0 ? priceToUS(currency) : formatNegative(+currency); - } - return DEFAULT_CURRENCY; - } - - /** - * Convert maxAcres value to user-friendly display string - * - * **Zero Value Policy**: In agricultural context, "0 acres" doesn't make literal sense. - * Zero is used internally to mean "no restriction on acreage." - * - * Display Rules: - * - 0, null, undefined, empty string → "Unlimited" - * - Values < 1000 → Display as-is (e.g., "123") - * - Values >= 1000 → Display in thousands (e.g., "50K" for 50000) - * - * @param maxAcres - Maximum acres value from API (Stripe metadata or custom limits) - * @returns Display string ("Unlimited", "123", or "50K") - * - * @example - * convMaxAcre(0) → "Unlimited" // Zero = no restriction - * convMaxAcre(null) → "Unlimited" // Not set = unlimited - * convMaxAcre(123) → "123" // Small values display as-is - * convMaxAcre(50000) → "50K" // Large values in thousands - * convMaxAcre('') → "Unlimited" // Empty string = unlimited - * - * Frontend/Backend Coordination: - * - Backend returns literal values from Stripe or custom limits - * - Frontend interprets 0 as "Unlimited" for display - * - This separation allows backend to store raw data while frontend - * provides user-friendly interpretation - * - * Related Policy: - * - maxVehicles: Zero displayed literally ("0 Aircraft" is valid restriction) - * - maxAcres: Zero displayed as "Unlimited" (no literal "0 acres" in farming) - * - * See: Task 02 - Document Zero Handling Policy - */ - convMaxAcre(maxAcres: number | string): string { - // Display "Unlimited" for null, undefined, empty string, or 0 - if (!maxAcres || maxAcres === 0 || maxAcres === '' || maxAcres === '0') { - return UNLIMITED; - } - const THOUSAND = 1000; - const maxAcrToK = +maxAcres / THOUSAND; - return maxAcrToK > 0 ? `${maxAcrToK}K` : maxAcres.toString(); - } - - hasOpenSub(subs: AGNavSubscription[] | StripeSubscription[]): boolean { - if (Utils.isEmptyArray(subs)) return false; - return subs?.some((sub) => - sub.status === SubStripe.INCOMPLETE || - sub.status === SubStripe.PAST_DUE || - sub.status === SubStripe.OVERDUE || - sub.status === SubStripe.UNPAID - ); - } - - /** - * Infer subscription type ('package' or 'addon') from a Stripe API subscription object. - * Subscriptions created via the app set `metadata.type` explicitly. - * Subscriptions created directly in the Stripe Dashboard have `metadata: {}`, so we - * fall back to inspecting the price lookup_key (addon keys start with 'addon_'). - * This is the single source of truth — used everywhere StripeSubscription type is needed. - */ - private inferStripeSubType(sub: StripeSubscription): string { - if (sub.metadata?.type) return sub.metadata.type; - const lookupKey = sub.items?.data?.[0]?.price?.lookup_key ?? ''; - return lookupKey.startsWith('addon_') ? SubType.ADDON : SubType.PACKAGE; - } - - updateMembShip(subscriptions: StripeSubscription[], membership: IMembership): IMembership { - if (Utils.isEmptyArray(subscriptions)) return membership; - - // NOTE: This method works with StripeSubscription (from Stripe API) which has different field names - // (current_period_end vs periodEnd). The centralized utilities work with AGNavSubscription. - // We keep this logic here as it's specific to Stripe API response transformation. - - // Find all package subscriptions and get the latest one by current_period_end - const pkgSubs = subscriptions?.filter((sub) => this.inferStripeSubType(sub) === SubType.PACKAGE); - const latestPkg = pkgSubs?.reduce((acc, curr) => { - return (curr.current_period_end > acc.current_period_end) ? curr : acc; - }, pkgSubs?.[0]); - - // Find all addon subscriptions and get the latest one by current_period_end - const addonSubs = subscriptions?.filter((sub) => this.inferStripeSubType(sub) === SubType.ADDON); - const latestAddon = addonSubs?.reduce((acc, curr) => { - return (curr.current_period_end > acc.current_period_end) ? curr : acc; - }, addonSubs?.[0]); - - const transformedSubscriptions = subscriptions?.map((sub) => { - const transformed = { - id: sub.id, - periodEnd: sub.current_period_end, - periodStart: sub.current_period_start, - status: sub.status, - items: sub.items.data?.map((item) => ({ - price: item.price.lookup_key, - quantity: item.quantity, - metadata: { - tier: item.price.metadata?.tier || '', // Ensure tier is always defined - level: item.price.metadata?.level, - maxAcres: item.price.metadata?.maxAcres, - maxVehicles: item.price.metadata?.maxVehicles - } - })), - type: this.inferStripeSubType(sub), - cancelAtPeriodEnd: sub.cancel_at_period_end, - trial_end: sub.trial_end, - promoDetails: sub.promoDetails - }; - - return transformed; - }); - - return { - ...membership, - endOfPeriod: latestPkg?.latest_invoice.period_end || latestAddon?.latest_invoice.period_end, - subscriptions: transformedSubscriptions - }; - } - - createSubPlan(subscriptions: StripeSubscription[], membership: IMembership, usage: Usage): Plan { - if (Utils.isEmptyArray(membership?.subscriptions) - || Utils.isEmptyArray(subscriptions) - || this.hasOpenSub(subscriptions)) { - return { subscriptions, membership, package: {}, addon: {} }; - } - - // Use subscriptions parameter (from Stripe API with custom limits override) - // instead of membership.subscriptions (from MongoDB without override) - const getSubscriptionItem = (type: SubType) => { - const subscription = subscriptions.find(sub => sub.metadata?.type === type - && (sub.status === SubStripe.ACTIVE || sub.status === SubStripe.TRIALING)); - return subscription?.items?.data?.[0]; - }; - - const createAcrePlan = (currUsage: number, limit: number): Acre => ({ - currUsage, - limit, - overLimit: currUsage > (limit ? limit : Infinity) - }); - - const pkg = getSubscriptionItem(SubType.PACKAGE); - const addon = getSubscriptionItem(SubType.ADDON); - const pkgPrice = pkg?.price?.lookup_key; - - // ✅ FIX (2026-01-27): Read metadata from MongoDB session data instead of Stripe API - // MongoDB is source of truth for subscription metadata changes (updated by admin/backend) - // Stripe API caches metadata and doesn't sync with MongoDB direct updates - // Priority: MongoDB membership.subscriptions > Stripe API subscriptions > hardcoded fallback - const mongoSubscription = membership?.subscriptions?.find(sub => - sub.type === SubType.PACKAGE && - (sub.status === 'active' || sub.status === 'trialing') - ); - const mongoMetadata = mongoSubscription?.items?.[0]?.metadata; - - // ✅ FIX (2026-01-27): Use getEffectiveAcresLimit() for consistent empty string handling - // Empty string "" in metadata was converting to 0, triggering fallback to hardcoded 50000 - // getEffectiveAcresLimit() properly handles: "" → null, null → null, "0" → null (all = Unlimited) - const effectiveMaxAcres = this.getEffectiveAcresLimit(mongoSubscription, membership?.customLimits); - const acre = createAcrePlan(UnitUtils.haToArea(usage.ttArea, true), effectiveMaxAcres); - - // ✅ FIX (2026-01-28): Use getEffectiveVehicleLimit() for consistent customLimits handling - // Same pattern as maxAcres fix (2026-01-27) - ensures customLimits override metadata - const effectiveMaxVehicles = this.getEffectiveVehicleLimit(mongoSubscription, membership?.customLimits); - const pkgNumVeh = effectiveMaxVehicles || 0; - const trackNumVeh = addon?.quantity || 0; - - const packagePlan = pkg ? { - [pkgPrice]: { - acre, - airCraft: { - numOfVehicle: pkgNumVeh - } - } - } : {}; - - const addonPlan = addon ? { - [SubKeys.TRACKING]: { - airCraft: { - numOfVehicle: trackNumVeh - }, - acre - } - } : {}; - - return { subscriptions, membership, package: packagePlan, addon: addonPlan }; - } - - isStatusMatchingCode(status: Status, code: string) { - return status?.code === code; - } - - isUnderReview(status: Status) { - return this.isStatusMatchingCode(status, SUB.AC_REVIEW); - } - - fmtSubMsg(text: string, key: PriceUsd, vehicle: { trkQuantity?: number, pkgQuantity?: number }): string { - return text?.replace('#pkg#', subPlans[key].name) - .replace('#quantity#', `${vehicle.trkQuantity ?? ''}`) - .replace('#maxAC#', `${vehicle.pkgQuantity ?? ''}`) || ''; - } - - toVehRange(precedingMax: number, maxVehicles: number): string { - const MIN = 1; - const MAX = 10; - const lowerRange = precedingMax ? (precedingMax + MIN) : MIN; - - // For custom limits (precedingMax === 0), show range from 1 to custom limit - if (precedingMax === 0 && maxVehicles > MIN) { - return `${MIN}-${maxVehicles}`; - } - - // If range collapses to single value (e.g., "2-2"), show just the number - if (lowerRange === maxVehicles) { - return `${maxVehicles}`; - } - - // Normal tier-based range calculation - return maxVehicles > MIN && maxVehicles <= MAX - ? lowerRange ? `${lowerRange}-${maxVehicles}` - : `${maxVehicles}` - : `${maxVehicles}`; - } - - convertAddr(address) { - address.postal_code = address?.postalCode; - delete address?.postalCode; - delete address?.name; - delete address?.valid; - return address; - } - - createBillingInfoPackage(applicatorId): Observable { - let billingInfoPackage: BillingInfoPackage; - return this.getBillingAddress(applicatorId).pipe( - switchMap((address: Address) => { - const hasExistingAdr = address && Object.keys(address)?.some((key) => - key === 'name' || - key === 'postalCode' || - key === 'line1' - ); - if (hasExistingAdr) { - return of(billingInfoPackage = { - billingInfo: { - applicatorId, - name: address.name, - address: this.convertAddr(address) - } - }); - } else { - // Fallback to user info if no billing address exists - this is based on the assumption that if there's no billing address in addresses, use the default legacy address. - return this.userSvc.getUser(applicatorId, { view: 'billing' }).pipe( - map((user) => { - return billingInfoPackage = { - isNewAccount: true, - billingInfo: { - applicatorId, - name: user.name, - address: { - line1: user.address, - country: user.country, - city: '', - state: '', - postal_code: '' - } - } - }; - }) - ); - } - }), - ); - } - - createTrialItems(selPkg: Package, selAddons: Addon[]): TrialItem[] { - const trialItems = selAddons?.map((addon) => ({ - description: addon.desc, - amount: +addon.price * addon.quantity, - quantity: addon.quantity, - trialEnd: addon.trialEnd, // Populate trialEnd from addon (for extended trial display) - price: { - lookup_key: addon.lookupKey, - unit_amount: +addon.price - } - })) || []; - - if (selPkg?.lookupKey) { - trialItems.unshift({ - description: selPkg.desc, - amount: +selPkg.price, - quantity: 1, - trialEnd: selPkg.trialEnd, // Populate trialEnd from package (for extended trial display) - price: { - lookup_key: selPkg.lookupKey, - unit_amount: +selPkg.price - } - }); - } - - return trialItems; - } - - getDateOptions(): { label: string, value: string }[] { - const options = [ - { label: $localize`:@@1m:past 1 month`, value: '1m' }, - { label: $localize`:@@3m:past 3 months`, value: '3m' }, - { label: $localize`:@@6m:past 6 months`, value: '6m' }, - ]; - const currYear = new Date().getUTCFullYear(); - for (let i = currYear; i > currYear - 3; i--) { - options.push({ label: i.toString(), value: i.toString() }); - } - return options; - } - - get stripeLoadStatus$() { - return this._stripeLoadStatus$; - } - - async loadStripeApi(pk: string) { - try { - this.stripe = await loadStripe(pk); - } catch (err) { - throw err; - } - } - - loadStripePromise(): Promise { - if (this.stripe) { - return Promise.resolve(); // Stripe is already loaded, no need to load again - } - - return this.getConfig().pipe( - switchMap((res) => this.loadStripeApi(res.config)) - ).toPromise().then(() => { - this.stripeLoadStatus$.next(true); - }).catch((err) => { - console.error('Failed to load Stripe API:', err); - this.stripeLoadStatus$.error(false); - }); - } - - /** - * Updates the billing address sequence for a user and returns both the updated address and user. - * @param userId The user's id - * @param address The address to update - * @returns Observable<{ address: Address, user: User }> - */ - public updateBillingAddressSequence(userId: string, address: Address) { - const { isBilling, ...addressWithoutBilling } = address; // Remove isBilling property if it exists - return this.updateBillAddress(userId, addressWithoutBilling).pipe( - switchMap((updatedAddress: Address) => - this.userSvc.getUser(userId, { withAddresses: true }).pipe( - switchMap((user) => { - return of({ address: updatedAddress, user }) - }) - ) - ) - ); - } - - // ============================================================================ - // SUBSCRIPTION UTILITY METHODS - SINGLE SOURCE OF TRUTH - // ============================================================================ - - /** - * Find latest subscription by periodEnd for given type - * This is the canonical utility for non-store contexts (services, standalone functions) - * - * @param subscriptions - Array of AGNav subscriptions - * @param type - Subscription type (PACKAGE or ADDON) - * @returns Latest subscription or null if none found - * - * @example - * const latest = this.subSvc.getLatestSubscription(user.membership.subscriptions, SubType.PACKAGE); - */ - getLatestSubscription( - subscriptions: AGNavSubscription[], - type: SubType - ): AGNavSubscription | null { - if (!subscriptions || subscriptions.length === 0) return null; - - const filtered = subscriptions.filter(sub => sub.type === type); - if (filtered.length === 0) return null; - - return filtered.reduce((acc, curr) => - (curr.periodEnd > acc.periodEnd) ? curr : acc, filtered[0] - ); - } - - /** - * Get lookup key from latest package subscription - * Replaces duplicated logic across auth.service, effects, components - * - * @param subscriptions - Array of AGNav subscriptions - * @returns Lookup key (price ID) or null - * - * @example - * const lookupKey = this.subSvc.getCurrentPackageLookupKey(user.membership.subscriptions); - */ - getCurrentPackageLookupKey(subscriptions: AGNavSubscription[]): PriceUsd | null { - const latest = this.getLatestSubscription(subscriptions, SubType.PACKAGE); - return latest?.items?.[0]?.price || null; - } - - /** - * Get lookup key from latest addon subscription - * - * @param subscriptions - Array of AGNav subscriptions - * @returns Addon lookup key or null - */ - getCurrentAddonLookupKey(subscriptions: AGNavSubscription[]): PriceUsd | null { - const latest = this.getLatestSubscription(subscriptions, SubType.ADDON); - return latest?.items?.[0]?.price || null; - } - - /** - * Get effective vehicle limit (custom limits override plan limits) - * This is the authoritative calculation for max vehicles - * - * @param subscription - Current subscription - * @param customLimits - User custom limits - * @returns Effective max vehicles or null - * - * @example - * const maxVehicles = this.subSvc.getEffectiveVehicleLimit(latestSub, user.membership.customLimits); - */ - getEffectiveVehicleLimit( - subscription: AGNavSubscription, - customLimits?: { maxVehicles?: number; maxAcres?: number } - ): number | null { - if (!subscription) return null; - - const planMaxVehicles = Math.abs(Number(subscription.items?.[0]?.metadata?.maxVehicles)); - const customMax = customLimits?.maxVehicles ? Math.abs(customLimits.maxVehicles) : null; - - // Custom limits override plan limits - return customMax || planMaxVehicles || null; - } - - /** - * Get effective acres limit (custom limits override plan limits) - * - * @param subscription - Current subscription - * @param customLimits - User custom limits - * @returns Effective max acres or null - */ - getEffectiveAcresLimit( - subscription: AGNavSubscription, - customLimits?: { maxVehicles?: number; maxAcres?: number } - ): number | null { - if (!subscription) return null; - - const planMaxAcres = Number(subscription.items?.[0]?.metadata?.maxAcres); - - // Custom limits override plan limits - // Treat empty string as null for proper "Unlimited" display - const customLimit = customLimits?.maxAcres; - const effectiveCustomLimit = (customLimit !== null && customLimit !== undefined && customLimit !== 0) - ? customLimit - : null; - - const effectivePlanLimit = (planMaxAcres !== null && planMaxAcres !== undefined && planMaxAcres !== 0 && !isNaN(planMaxAcres)) - ? planMaxAcres - : null; - - return effectiveCustomLimit || effectivePlanLimit || null; - } - - /** - * Check if subscription has custom limits applied - * Custom limits are considered "applied" when they differ from plan defaults - * - * @param subscription - Current subscription - * @param customLimits - User custom limits - * @returns True if custom limits differ from plan limits - * - * @example - * const hasCustom = this.subSvc.hasCustomLimits(latestSub, user.membership.customLimits); - */ - hasCustomLimits( - subscription: AGNavSubscription, - customLimits?: { maxVehicles?: number; maxAcres?: number } - ): boolean { - if (!subscription || !customLimits) return false; - - const planMaxVehicles = Number(subscription.items?.[0]?.metadata?.maxVehicles); - const planMaxAcres = Number(subscription.items?.[0]?.metadata?.maxAcres); - - // Check if either vehicle or acres custom limits differ from plan - const vehicleLimitsDiffer = customLimits.maxVehicles && customLimits.maxVehicles !== planMaxVehicles; - const acresLimitsDiffer = customLimits.maxAcres && customLimits.maxAcres !== planMaxAcres; - - return vehicleLimitsDiffer || acresLimitsDiffer; - } - - // ============================================================================ - // SUBSCRIPTION EXPIRY WARNING - // ============================================================================ - - /** - * Calculate expiry warning from subscription data - * - * Returns warning if subscription expires in 1-7 days, null otherwise. - * Only triggers for package subscriptions (not addons). - * - * Data Source: GET /api/subscription?custId={custId} - * Verified: 2025-11-10 via /server_test/subscription-data-verification.js - * - * @param subscription - StripeSubscription from /api/subscription endpoint - * @returns ExpiryWarning if criteria met, null otherwise - * - * @example - * const subscriptions = await this.getSubscriptions(custId).toPromise(); - * const warnings = subscriptions - * .map(sub => this.calculateExpiryWarning(sub)) - * .filter(w => w !== null); - */ - calculateExpiryWarning(subscription: StripeSubscription): ExpiryWarning | null { - // Validate required fields (based on Phase 1 verification) - if (!subscription?.current_period_end || !subscription?.metadata?.type) { - console.warn('calculateExpiryWarning: Missing required fields', { - id: subscription?.id, - has_period_end: !!subscription?.current_period_end, - has_metadata_type: !!subscription?.metadata?.type - }); - return null; - } - - const now = Math.floor(Date.now() / 1000); // Unix timestamp in seconds - const secondsUntilExpiry = subscription.current_period_end - now; - const daysUntilExpiry = Math.floor(secondsUntilExpiry / 86400); - - // Only warn for subscriptions expiring in 0-expiryWarningDays days (inclusive of today) - if (daysUntilExpiry < 0 || daysUntilExpiry > environment.expiryWarningDays) { - return null; - } - - // Only show warnings for package subscriptions (not addons) - if (subscription.metadata?.type !== 'package') { - return null; - } - - return { - id: subscription.id, - type: subscription.metadata?.type as 'package' | 'addon', - status: subscription.status, - daysUntilExpiry, - cancelAtPeriodEnd: subscription.cancel_at_period_end ?? false, - periodEnd: subscription.current_period_end, - isTrial: subscription.status === 'trialing', - willAutoRenew: !subscription.cancel_at_period_end - }; - } - - ngOnDestroy(): void { - if (this.sub$) this.sub$.unsubscribe(); - } -} diff --git a/Development/client/src/app/domain/services/user.service.ts b/Development/client/src/app/domain/services/user.service.ts index e908487..be560ea 100644 --- a/Development/client/src/app/domain/services/user.service.ts +++ b/Development/client/src/app/domain/services/user.service.ts @@ -3,36 +3,27 @@ import { HttpClient } from '@angular/common/http'; import { Store } from '@ngrx/store'; -import { UserModel } from '../../auth/models/user.model'; import { User } from '../../accounts/models/user.model'; -import { Roles } from '../../shared/global'; import { Observable } from 'rxjs'; + @Injectable({ providedIn: 'root' }) export class UserService { private readonly userURL = '/users'; - constructor(private http: HttpClient) { + constructor( + private store: Store<{}>, + private http: HttpClient + ) { } loadUsers(options: LoadUserOptions): Observable { return this.http.post(this.userURL + '/search', options); } - getUser(id: string, ops?: { withAddresses?: boolean; view?: 'profile' | 'edit' | 'billing' }): Observable { - let url = `${this.userURL}/${id}`; - const params: string[] = []; - if (ops?.withAddresses !== undefined) { - params.push(`withAddresses=${ops.withAddresses}`); - } - if (ops?.view) { - params.push(`view=${ops.view}`); - } - if (params.length) { - url += `?${params.join('&')}`; - } - return this.http.get(url); + getUser(id: string): Observable { + return this.http.get(`${this.userURL}/${id}`); } userNameExists(userName: string): Observable { @@ -64,32 +55,9 @@ export class UserService { return this.http.post(`${this.userURL}/getUserDetail`, { username: username }); } - signup(form: any) { - return this.http.post(`${this.userURL}/signup`, form); - } - - requestVerifyEmail(email: string) { - return this.http.post(`${this.userURL}/signup/requestVerifyEmail`, { email }); - } - - signupValidate(token: string) { - return this.http.post(`${this.userURL}/signup/validate`, { token }); - } - - getAccountType(user: UserModel | User): string { - if (!user) return ''; - if ('roles' in user && Array.isArray(user.roles) && user.roles.length > 0) { - const roleId = user.roles[0]; - return Roles[roleId] || ''; - } - if ('kind' in user && user.kind && Roles[user.kind]) { - return Roles[user.kind]; - } - return ''; - } } export interface LoadUserOptions { byPuid: string; accountType?: number; -} \ No newline at end of file +} diff --git a/Development/client/src/app/domain/services/vehicle.service.ts b/Development/client/src/app/domain/services/vehicle.service.ts index b4f9d27..5a3aac2 100644 --- a/Development/client/src/app/domain/services/vehicle.service.ts +++ b/Development/client/src/app/domain/services/vehicle.service.ts @@ -1,7 +1,9 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; + import { Observable } from 'rxjs'; -import { StatusChange, Vehicle } from '../../entities/models/vehicle.model'; + +import { Vehicle } from '../../entities/models/vehicle.model'; @Injectable() export class VehicleService { @@ -37,13 +39,10 @@ export class VehicleService { return this.http.delete(`${this.vehicleURL}/${vehicle._id}`); } - unitIdExists(unitId: string): Observable { + unitIdExists(unitId: string): Observable { return this.http.post(`${this.vehicleURL}/unitIdExists`, { unitId: unitId }); } - updateVehicles(vehicles : Vehicle[]) { - return this.http.post(`${this.vehicleURL}/update`, vehicles); - } } export interface LoadVehicleOptions { diff --git a/Development/client/src/app/effects/routing.effects.ts b/Development/client/src/app/effects/routing.effects.ts deleted file mode 100644 index 87ff9ce..0000000 --- a/Development/client/src/app/effects/routing.effects.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { Injectable } from '@angular/core'; -import { Router } from '@angular/router'; -import { Action } from '@ngrx/store'; -import { Effect, Actions, ofType } from '@ngrx/effects'; -import { Observable } from 'rxjs'; -import { tap } from 'rxjs/operators'; -import * as subActions from '@app/actions/subscription.actions'; -import { SUB } from '../profile/common'; -import { AC } from '@app/shared/global'; - -@Injectable() -export class RoutingEffects { - - constructor( - private router: Router, - private actions$: Actions - ) { } - - @Effect({ dispatch: false }) - gotoMyservices$: Observable = this.actions$.pipe( - ofType(subActions.GOTO_MY_SERVICES), - tap(() => this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]).then(() => window.location.reload())) - ); - - @Effect({ dispatch: false }) - gotoServices$: Observable = this.actions$.pipe( - ofType(subActions.GOTO_SERVICES), - tap(() => this.router.navigate([SUB.PROFILE, SUB.SERVICES])) - ); - - @Effect({ dispatch: false }) - gotPaymentHistory$: Observable = this.actions$.pipe( - ofType(subActions.GOTO_PAYMENT_HISTORY), - tap(() => this.router.navigate([SUB.PROFILE, SUB.PM_HISTORY])) - ); - - @Effect({ dispatch: false }) - gotPaymentDetail$: Observable = this.actions$.pipe( - ofType(subActions.GOTO_PAYMENT_DETAIL), - tap((action: subActions.GotoPaymentDetail) => this.router.navigate([SUB.PROFILE, SUB.PM_DETAIL, action.payload.paymentId])) - ); - - @Effect({ dispatch: false }) - gotoUnpaidSub$: Observable = this.actions$.pipe( - ofType(subActions.SHOW_UNPAID_SUBSCRIPTION), - tap(() => this.router.navigate([SUB.PROFILE, SUB.UNPAID_SUB])) - ); - - @Effect({ dispatch: false }) - gotoBillingAddr$: Observable = this.actions$.pipe( - ofType(subActions.START_BILLING_INFO_SUCCESS, subActions.GOTO_BILLING_ADDRESS), - tap(() => this.router.navigate([SUB.PROFILE, SUB.BILL_ADR])) - ); - - @Effect({ dispatch: false }) - gotoCheckout$: Observable = this.actions$.pipe( - ofType(subActions.UPDATE_BILLING_ADDRESS_SUCCESS, subActions.GOTO_CHECK_OUT, subActions.START_CHECKOUT_SUCCESS), - tap(() => this.router.navigate([SUB.PROFILE, SUB.CHKOUT])) - ); - - @Effect({ dispatch: false }) - gotoCheckoutReview$: Observable = this.actions$.pipe( - ofType(subActions.CHECK_OUT, subActions.RESOLVE_PAYMENT, subActions.GOTO_CHECK_OUT_REVIEW), - tap(() => this.router.navigate([SUB.PROFILE, SUB.CHKOUT_REV])) - ); - - @Effect({ dispatch: false }) - gotoCheckoutConfirm$: Observable = this.actions$.pipe( - ofType(subActions.GOTO_CHECK_OUT_CONFIRM, subActions.PAY_UNPAID_SUBSCRIPTION_SUCCESS, subActions.CONFIRM_ACTION_SUCCESS, subActions.CONFIRM_PAYMENT_SUCCESS, - subActions.CHECK_OUT_TRIAL_SUCCESS), - tap(() => this.router.navigate([SUB.PROFILE, SUB.CHKOUT_CONF])) - ); - - @Effect({ dispatch: false }) - gotoHome$: Observable = this.actions$.pipe( - ofType(subActions.GOTO_HOME), - tap(() => this.router.navigate(['/', SUB.HOME])) - ); - - @Effect({ dispatch: false }) - gotoUsageDetail$: Observable = this.actions$.pipe( - ofType(subActions.GOTO_USAGE_DETAIL), - tap(() => this.router.navigate([SUB.PROFILE, SUB.USAGE_DETAIL])) - ); - - @Effect({ dispatch: false }) - gotoAircraftList$: Observable = this.actions$.pipe( - ofType(subActions.GOTO_AIRCRAFT_LIST), - tap(() => this.router.navigate(['entities', AC])) - ); -} diff --git a/Development/client/src/app/effects/sub-plans.effects.ts b/Development/client/src/app/effects/sub-plans.effects.ts deleted file mode 100644 index 838fcd9..0000000 --- a/Development/client/src/app/effects/sub-plans.effects.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { Injectable } from '@angular/core'; -import { Action } from '@ngrx/store'; -import { Actions, Effect, ofType } from '@ngrx/effects'; -import { SubscriptionService } from '@app/domain/services/subscription.service'; -import { Observable, of } from 'rxjs'; -import * as subPlansActions from '@app/actions/sub-plans.actions' -import { catchError, delay, exhaustMap, filter, repeat, retryWhen, switchMap, take } from 'rxjs/operators'; -import { subPlans, SubAppErr, handleErr, SubKeys, TRACKING, PACKAGE_ACTIVE, createSubStatus, SUB, SubType, DELAY, TAKE, EMPTY } from '../profile/common'; -import { AuthService } from '@app/domain/services/auth.service'; -import { StripeSubscription, Usage } from '@app/domain/models/subscription.model'; -import { AppMessageService } from '@app/shared/app-message.service'; -import { globals } from '@app/shared/global'; -import { VehicleService } from '@app/domain/services/vehicle.service'; -import { FetchLatestSubscriptionSuccess, GotoAircraftList, UpdateSubscriptionStatus } from '@app/actions/subscription.actions'; -import { Vehicle } from '@app/entities/models/vehicle.model'; -import { CustomerService } from '@app/domain/services/customer.service'; -import { Router } from '@angular/router'; - -@Injectable() -export class SubPlansEffects { - - constructor( - private readonly actions$: Actions, - private readonly subSvc: SubscriptionService, - private readonly authSvc: AuthService, - private readonly msgSvc: AppMessageService, - private readonly vehSvc: VehicleService, - private readonly custSvc: CustomerService, - private readonly router: Router - ) { } - - @Effect() - refreshSubPlans$: Observable = this.actions$.pipe( - ofType(subPlansActions.FETCH_SUB_PLANS), - exhaustMap((action: subPlansActions.FetchSubPlans) => { - let usage: Usage; - let subscriptions: StripeSubscription[]; - let vehicles: Vehicle[]; - let sortedPrices: any[]; - return this.subSvc.getPrices().pipe( - filter(prices => prices?.length > 0), - switchMap(prices => { - sortedPrices = [...prices].sort((a, b) => a.level - b.level); - - let lastEffectiveMax = 0; - const userSubscriptions = this.authSvc.user?.membership?.subscriptions; - const userCustomLimits = this.authSvc.user?.membership?.customLimits; - - // Use centralized utility to get current lookup key - const currentLookupKey = this.subSvc.getCurrentPackageLookupKey(userSubscriptions); - - // Get latest package subscription for custom limits checks - const latestPackageSub = this.subSvc.getLatestSubscription(userSubscriptions, SubType.PACKAGE); - - sortedPrices.forEach((price, indx) => { - if (price) { - const plan = subPlans[price.lookupKey] || {}; - - const isCurrentSubscription = price.lookupKey === currentLookupKey; - - // Use centralized utility to check for custom limits - const hasCustomLimit = isCurrentSubscription && - latestPackageSub && - this.subSvc.hasCustomLimits(latestPackageSub, userCustomLimits); - - // Use centralized utility to get effective vehicle limit - // For current subscription: Use API + custom limits - // For other packages: Use API data from price.maxVehicles - const effectiveMaxVehicles = isCurrentSubscription && latestPackageSub - ? this.subSvc.getEffectiveVehicleLimit(latestPackageSub, userCustomLimits) - : price.maxVehicles; - - // Use centralized utility to get effective acres limit (SAME PATTERN AS VEHICLES) - // Treat empty string as null for proper "Unlimited" display - const effectiveMaxAcres = isCurrentSubscription && latestPackageSub - ? this.subSvc.getEffectiveAcresLimit(latestPackageSub, userCustomLimits) - : null; - - plan.price = price.priceUSD || plan.price; - plan.desc = plan.desc?.replace('#price#', this.subSvc.formatCurrency(price.priceUSD)) || plan.desc; - // Current subscription: use effectiveMaxVehicles (respects custom limits). - // All other plans: use price.maxVehicles from Stripe API. - if (isCurrentSubscription) { - if (effectiveMaxVehicles != null) { - plan.maxVehicles = effectiveMaxVehicles; - } - } else if (price.maxVehicles !== null && price.maxVehicles !== undefined) { - plan.maxVehicles = Math.abs(price.maxVehicles); - } - - // Apply effective acres limit - // For current subscription: Use MongoDB session data (effectiveMaxAcres) even if null - // For other packages: Use Stripe API data (price.maxAcres) - // IMPORTANT: Only update if we have a valid value to prevent race condition - if (isCurrentSubscription) { - if (effectiveMaxAcres !== null && effectiveMaxAcres !== undefined) { - plan.maxAcres = Number(effectiveMaxAcres); - } - // Don't set to null - preserve existing value to avoid race condition - } else { - if (price.maxAcres !== null && price.maxAcres !== undefined) { - plan.maxAcres = Number(price.maxAcres); - } - // Don't set to null - preserve existing value to avoid race condition - } - plan.level = price.level || plan.level; - plan.type = price.type || plan.type; - - if (effectiveMaxVehicles) { - if (hasCustomLimit && isCurrentSubscription) { - plan.Vehicles = `1-${effectiveMaxVehicles}`; - lastEffectiveMax = price.maxVehicles; - } else { - plan.Vehicles = this.subSvc.toVehRange(lastEffectiveMax, effectiveMaxVehicles); - lastEffectiveMax = effectiveMaxVehicles; - } - } else { - lastEffectiveMax = price.maxVehicles || effectiveMaxVehicles || 0; - } - - subPlans[price.lookupKey] = plan; - } - }); - - const byPuid = this.authSvc.user?.parent || this.authSvc.user?._id; - return this.subSvc.retrieveCurrUsage(this.authSvc.user?.membership?.custId, byPuid); - }), - switchMap(_usage => { - usage = _usage; - return this.subSvc.fetchSubscriptions(this.authSvc.user?.membership?.custId); - }), - switchMap(_subs => { - subscriptions = _subs; - return this.vehSvc.loadVehicles({ byUserId: this.authSvc.user?.parent }).pipe( - catchError(() => of([])) - ); - }), - switchMap(_vehicles => { - vehicles = _vehicles; - const id = this.authSvc.user?.parent || this.authSvc.user._id; - return this.custSvc.getCustomer(id); - }), - switchMap(cust => { - const curSubPlan = this.subSvc.createSubPlan(subscriptions, cust?.membership, usage); - const getNumVehs = (type: string) => vehicles?.filter((veh) => veh[type] === true).length || 0; - const trkVehicles = getNumVehs(TRACKING); - const pkgActiveVehicles = getNumVehs(PACKAGE_ACTIVE); - const needReview = cust?.needReview; - - if (subscriptions?.length === 0) { - const actions: Action[] = [ - new subPlansActions.ResetSubPlans(), - new subPlansActions.FetchSubPlansSuccess(curSubPlan) - ]; - - if (cust?.membership) { - // Transform membership to preserve trial_end and promoDetails (Case 2C fix) - actions.push(new FetchLatestSubscriptionSuccess({ - subscriptions, - membership: this.subSvc.updateMembShip(subscriptions, cust?.membership) - })); - } - - const currentUrl = this.router.url; - const isHome = currentUrl === '/' || currentUrl.includes(`/${SUB.HOME}`); - const isProfileRoute = currentUrl.includes(`/${SUB.PROFILE}`); - - if (!isHome && !isProfileRoute) { - this.router.navigate([`/${SUB.PROFILE}/${SUB.SERVICES}`]); - } - - return of(...actions); - } - - // Use centralized utility to get current lookup key from latest subscription - const freshSubscriptions = cust?.membership?.subscriptions; - const freshCurrentLookupKey = this.subSvc.getCurrentPackageLookupKey(freshSubscriptions) || - this.authSvc.getCurLookupKey(SubType.PACKAGE); - const staleCurrentLookupKey = this.authSvc.getCurLookupKey(SubType.PACKAGE); - - // Always update the current plan's maxVehicles with fresh customer data - // (first block used stale authSvc cache — customLimits may not have been loaded yet) - const freshLatestPackageSub = this.subSvc.getLatestSubscription(freshSubscriptions, SubType.PACKAGE); - const freshUserCustomLimits = cust?.membership?.customLimits; - if (freshCurrentLookupKey && freshLatestPackageSub && subPlans[freshCurrentLookupKey]) { - const freshEffective = this.subSvc.getEffectiveVehicleLimit(freshLatestPackageSub, freshUserCustomLimits); - if (freshEffective != null) { - subPlans[freshCurrentLookupKey].maxVehicles = freshEffective; - } - } - - if (freshCurrentLookupKey && freshCurrentLookupKey !== staleCurrentLookupKey) { - let lastEffectiveMax = 0; - const userCustomLimits = cust?.membership?.customLimits; - - // Get latest package subscription for custom limits checks - const latestPackageSub = this.subSvc.getLatestSubscription(freshSubscriptions, SubType.PACKAGE); - - sortedPrices.forEach((price, indx) => { - if (price) { - const plan = subPlans[price.lookupKey]; - if (plan) { - - const isCurrentSubscription = price.lookupKey === freshCurrentLookupKey; - - // Use centralized utility to check for custom limits - const hasCustomLimit = isCurrentSubscription && - latestPackageSub && - this.subSvc.hasCustomLimits(latestPackageSub, userCustomLimits); - - // Use centralized utility to get effective vehicle limit - const effectiveMaxVehicles = isCurrentSubscription && latestPackageSub - ? this.subSvc.getEffectiveVehicleLimit(latestPackageSub, userCustomLimits) - : price.maxVehicles; - - // Current subscription: use effectiveMaxVehicles (respects custom limits). - // All other plans: use price.maxVehicles from Stripe API. - if (isCurrentSubscription) { - if (effectiveMaxVehicles != null) { - plan.maxVehicles = effectiveMaxVehicles; - } - } else if (price.maxVehicles !== null && price.maxVehicles !== undefined) { - plan.maxVehicles = Math.abs(price.maxVehicles); - } - - if (effectiveMaxVehicles) { - if (hasCustomLimit && isCurrentSubscription) { - plan.Vehicles = `1-${effectiveMaxVehicles}`; - lastEffectiveMax = price.maxVehicles; - } else { - plan.Vehicles = this.subSvc.toVehRange(lastEffectiveMax, effectiveMaxVehicles); - lastEffectiveMax = effectiveMaxVehicles; - } - } else { - lastEffectiveMax = price.maxVehicles || effectiveMaxVehicles || 0; - } - } - } - }); - } - - const isCurTrkVehAboveLimit = trkVehicles > curSubPlan?.addon?.[SubKeys.TRACKING]?.airCraft?.numOfVehicle; - const isCurActiveVehAboveLimit = pkgActiveVehicles > curSubPlan?.package?.[this.authSvc.getCurLookupKey(SubType.PACKAGE)]?.airCraft?.numOfVehicle; - - const actions: Action[] = [ - new subPlansActions.FetchSubPlansSuccess(curSubPlan) - ]; - - if (cust?.membership) { - // Transform membership to preserve trial_end and promoDetails (Case 2C fix) - actions.unshift(new FetchLatestSubscriptionSuccess({ - subscriptions, - membership: this.subSvc.updateMembShip(subscriptions, cust?.membership) - })); - } - - if (isCurActiveVehAboveLimit || isCurTrkVehAboveLimit || needReview) { - actions.push( - new UpdateSubscriptionStatus(createSubStatus(SUB.AC_REVIEW)), - new GotoAircraftList() - ); - } - - return of(...actions); - }) - ) - }), - retryWhen(errors => errors.pipe( - delay(DELAY), - take(TAKE) - )), - catchError(err => { - this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.load).replace('#thing#', globals.subPlans)); - return handleErr>({ - error: err, opt: { - extra: SubAppErr.FETCH_SUB_PLANS_ERR - } - }); - }), - repeat() - ); -} \ No newline at end of file diff --git a/Development/client/src/app/effects/subscription.effects.ts b/Development/client/src/app/effects/subscription.effects.ts deleted file mode 100644 index 086bca0..0000000 --- a/Development/client/src/app/effects/subscription.effects.ts +++ /dev/null @@ -1,1317 +0,0 @@ -import { Injectable } from '@angular/core'; -import { from, interval, Observable, of, forkJoin, throwError } from 'rxjs'; -import { map, switchMap, catchError, take, takeUntil, tap, startWith, debounceTime, concatMap, repeat, takeWhile } from 'rxjs/operators'; -import { Actions, Effect, ofType } from '@ngrx/effects'; -import { Action, Store } from '@ngrx/store'; -import * as subAction from '@app/actions/subscription.actions'; -import { SubscriptionService } from '@app/domain/services/subscription.service'; -import { Addon, Address, ConfirmPackage, Invoice, InvoicePackage, PaymentMethod, StripeSubscription, SubscriptionIntent, Card, SubscriptionPackage, Coupon, BillingInfo, TrialPmtPkg, BillingInfoPackage } from '@app/domain/models/subscription.model'; -import { PaymentIntentResult, PaymentMethodResult } from '@stripe/stripe-js'; -import { UserModel } from '@app/auth/models/user.model'; -import { createSubStatus, handleErr, SubAppErr, SUB, SubStripe, Mode, SERVICE_TYPE, PromoErrors } from '@app/profile/common'; -import { DateUtils, Utils } from '@app/shared/utils' -import { AuthService } from '@app/domain/services/auth.service'; -import { ResetSubPlans } from '@app/actions/sub-plans.actions'; -import { CustomerService } from '@app/domain/services/customer.service'; -import { Customer } from '@app/customers/models/customer.model'; -import { GAService } from '@app/shared/ga.service'; -import { GAAnalyticsHelpersService } from '@app/shared/ga.analytics-helpers.service'; - -interface UnpaidContent { - card: Card, - unpaidSubs: any, - latestSubs: any, - unpaidInvoices?: any -}; - -enum StartCheckoutCase { NEW_ACC_TRIAL, NEW_ACC, TRIALING, UNPAID }; -enum TrialChkoutCase { NEW_CARD, EXISTING }; - -@Injectable() -export class SubscriptionEffects { - - constructor( - private readonly store: Store<{}>, - private readonly actions$: Actions, - private readonly subSvc: SubscriptionService, - private readonly authSvc: AuthService, - private readonly custSvc: CustomerService, - private readonly ga: GAService, - private readonly gaHelpers: GAAnalyticsHelpersService, - ) { } - - // Common effects - @Effect() - compound$: Observable = this.actions$.pipe( - ofType(subAction.COMPOUND), - concatMap((action: subAction.Compound) => from(action.payload)), - catchError((err) => handleErr>({ error: err, opt: { extra: SubAppErr.COMP_ACT_ERR } })), - repeat() - ); - - @Effect() - fetchLatestSub$: Observable = this.actions$.pipe( - ofType(subAction.FETCH_LATEST_SUBSCRIPTION), - switchMap((action: subAction.FetchLatestSubscription) => this.subSvc.fetchSubscriptions(action.payload.custId)), - map((subscriptions) => { - // Debug: Log ALL raw backend response data - console.log('🔍 fetchLatestSub$ - EFFECT TRIGGERED:', { - hasSubscriptions: !!subscriptions, - count: subscriptions?.length || 0, - allStatuses: subscriptions?.map(sub => ({ id: sub.id, status: sub.status })) || [], - firstSubFull: subscriptions?.[0] || null - }); - - // Debug: Log raw backend response for trial subscriptions - const trialSubs = subscriptions?.filter(sub => sub.status === 'trialing'); - console.log('🔍 fetchLatestSub$ - Trial filter result:', { - trialCount: trialSubs?.length || 0, - hasTrialSubs: trialSubs && trialSubs.length > 0 - }); - - if (trialSubs && trialSubs.length > 0) { - console.log('🔍 fetchLatestSub$ - RAW Backend API Response (trial subscriptions):', { - count: trialSubs.length, - firstSub: { - id: trialSubs[0].id, - status: trialSubs[0].status, - trial_end: trialSubs[0].trial_end, - promoDetails: trialSubs[0].promoDetails, - has_trial_end_key: 'trial_end' in trialSubs[0], - has_promoDetails_key: 'promoDetails' in trialSubs[0] - } - }); - } - - return new subAction.FetchLatestSubscriptionSuccess({ subscriptions, membership: this.subSvc.updateMembShip(subscriptions, this.authSvc.user?.membership) }); - }), - catchError((err) => handleErr>({ error: err, opt: { extra: SubAppErr.FETCH_SUB_ERR } })), - repeat() - ); - - @Effect() - loadStripe$: Observable = this.actions$.pipe( - ofType(subAction.LOAD_STRIPE), - switchMap(() => this.subSvc.getConfig()), - switchMap((res) => from(this.subSvc.loadStripeApi(res.config))), - map(() => { - this.subSvc.stripeLoadStatus$.next(true); - return new subAction.LoadStripeSuccess(); - }), - catchError((err) => { - this.subSvc.stripeLoadStatus$.error(false); - return handleErr>({ error: err, opt: { extra: SubAppErr.LOAD_STRIPE_ERR } }); - }), - repeat() - ); - - // Billing info stage - @Effect() - startBillingInfo$: Observable = this.actions$.pipe( - ofType(subAction.START_BILLING_INFO), - switchMap((action: subAction.StartBillingInfo) => { - let subIntentPkg: SubscriptionIntent; - return this.subSvc.createBillingInfoPackage(action.payload.applicatorId).pipe( - map((billingInfoPkg: BillingInfoPackage) => { - subIntentPkg = { ...action.payload, ...billingInfoPkg }; - return new subAction.StartBillingInfoSuccess(subIntentPkg); - }) - ) - }), - catchError((err) => handleErr>({ error: err, opt: { extra: SubAppErr.START_BIL_INFO_ERR } })), - repeat() - ); - - // Checkout stage - @Effect() - startCheckout$: Observable = this.actions$.pipe( - ofType(subAction.START_CHECKOUT), - switchMap((action: subAction.StartCheckout) => { - const isNewAccAndTrial = action.payload.subIntentPkg?.isNewAccount && action.payload.subIntentPkg?.mode === Mode.TRIALING; - const isNewAccNoTrial = action.payload.subIntentPkg?.isNewAccount && action.payload.subIntentPkg?.mode === Mode.REGULAR; - const isTrial = action.payload.subIntentPkg?.mode === Mode.TRIALING || action.payload.subIntentPkg?.mode === Mode.CONTINUE_TRIAL; - const isUnpaid = action.payload.subIntentPkg?.mode === Mode.UNPAID; - if (isNewAccAndTrial) { - return this.handleStartChkout(action.payload, StartCheckoutCase.NEW_ACC_TRIAL); - } else if (isNewAccNoTrial) { - return this.handleStartChkout(action.payload, StartCheckoutCase.NEW_ACC); - } else if (isTrial) { - return this.handleStartChkout(action.payload, StartCheckoutCase.TRIALING); - } else if (isUnpaid) { - return this.handleStartChkout(action.payload, StartCheckoutCase.UNPAID); - } else { - return this.handleStartChkout(action.payload); - } - - }), - catchError((err) => handleErr>({ error: err, opt: { extra: SubAppErr.START_CHECKOUT_ERR } })), - repeat() - ); - - private handleStartChkout(payload: { billingInfo: BillingInfo; subIntentPkg: SubscriptionIntent }, type?: StartCheckoutCase) { - const addrPkg = { - _id: payload.billingInfo?.address?._id, - name: payload.billingInfo?.name, city: payload.billingInfo?.address?.city, - line1: payload.billingInfo?.address?.line1, - line2: payload.billingInfo?.address?.line2, - postalCode: payload.billingInfo?.address?.postal_code, - state: payload.billingInfo?.address?.state, - country: payload.billingInfo?.address?.country - }; - let subIntentPkg: SubscriptionIntent = payload.subIntentPkg; - - let handleDefault = () => this.subSvc.updateBillingAddressSequence(payload.billingInfo?.applicatorId, addrPkg).pipe( - switchMap(() => { - subIntentPkg = { ...subIntentPkg, billingInfo: payload.billingInfo }; - return this.subSvc.getPaymentMethodList(payload.subIntentPkg?.custId); - }), - switchMap((paymentMethods: PaymentMethod[]) => { - subIntentPkg = { ...subIntentPkg, paymentMethods }; - return this.subSvc.retrieveUpcomingInvoices({ custId: payload.subIntentPkg?.custId, package: payload.subIntentPkg?.selPkg?.lookupKey, addons: payload.subIntentPkg?.selAddons?.map((addon: Addon) => ({ price: addon?.lookupKey, quantity: addon?.quantity })), prorateTS: payload.subIntentPkg?.prorateTS }); - }), - map((upcomingInvoices: Invoice[]) => { - subIntentPkg = { ...subIntentPkg, upcomingInvoices }; - return new subAction.StartCheckoutSuccess(subIntentPkg); - }) - ); - - switch (type) { - case StartCheckoutCase.NEW_ACC_TRIAL: - return this.subSvc.updateBillingAddressSequence(payload.billingInfo?.applicatorId, addrPkg).pipe(map((result) => new subAction.UpdateBillingAddressSuccess({ applicatorId: payload.billingInfo?.applicatorId, name: result?.address?.name, address: this.subSvc.convertAddr(result?.address) }))); - case StartCheckoutCase.NEW_ACC: - return this.subSvc.updateBillingAddressSequence(payload.billingInfo?.applicatorId, addrPkg).pipe( - switchMap(() => { - subIntentPkg = { ...subIntentPkg, billingInfo: payload.billingInfo }; - return this.subSvc.retrieveUpcomingInvoices({ custId: payload.subIntentPkg?.custId, package: payload.subIntentPkg?.selPkg?.lookupKey, addons: payload.subIntentPkg?.selAddons?.map((addon: Addon) => ({ price: addon?.lookupKey, quantity: addon?.quantity })), prorateTS: payload.subIntentPkg?.prorateTS }); - }), - map((upcomingInvoices: Invoice[]) => { - subIntentPkg = { ...subIntentPkg, upcomingInvoices }; - return new subAction.StartCheckoutSuccess(subIntentPkg); - }) - ); - case StartCheckoutCase.TRIALING: - return this.subSvc.updateBillingAddressSequence(payload.billingInfo?.applicatorId, addrPkg).pipe( - switchMap(() => { - subIntentPkg = { ...subIntentPkg, billingInfo: payload.billingInfo }; - return this.subSvc.getPaymentMethodList(payload.subIntentPkg?.custId); - }), - map((paymentMethods: PaymentMethod[]) => { - subIntentPkg = { ...subIntentPkg, paymentMethods }; - return new subAction.StartCheckoutSuccess(subIntentPkg); - })); - case StartCheckoutCase.UNPAID: - return this.subSvc.updateBillingAddressSequence(payload.billingInfo?.applicatorId, addrPkg).pipe( - map(() => { - return new subAction.StartCheckoutSuccess(subIntentPkg); - })); - case undefined: - return handleDefault(); - default: - return handleDefault(); - } - } - - @Effect() - checkoutTrial$: Observable = this.actions$.pipe( - ofType(subAction.CHECK_OUT_TRIAL), - switchMap((action: subAction.CheckoutTrial) => { - - const isTrialing = action.payload?.mode === Mode.TRIALING; - const isContAftTrialEnd = action.payload?.mode === Mode.CONTINUE_TRIAL; - const isCrtNewPM = action.payload?.pmtMethod?.newPmtMeth; - const isExistingPM = action.payload?.pmtMethod?.exPmtMeth; - - if (isTrialing) { - if (isCrtNewPM) { - return this.handleChkoutTrial(action.payload, TrialChkoutCase.NEW_CARD); - } else if (isExistingPM) { - return this.handleChkoutTrial(action.payload, TrialChkoutCase.EXISTING); - } else { - return this.handleChkoutTrial(action.payload); - } - } else if (isContAftTrialEnd) { - if (isCrtNewPM) { - return this.handleContTrial(action.payload, TrialChkoutCase.NEW_CARD); - } else if (isExistingPM) { - return this.handleContTrial(action.payload, TrialChkoutCase.EXISTING); - } else { - return this.handleContTrial(action.payload); - } - } else { - return handleErr>({ opt: { extra: SubAppErr.CHECKOUT_TRIAL_ERR } }) - } - }), - catchError((err) => handleErr>({ error: err, opt: { extra: SubAppErr.CHECKOUT_TRIAL_ERR } })), - repeat() - ); - - private handleContTrial(payload: TrialPmtPkg, type?: TrialChkoutCase) { - switch (type) { - case TrialChkoutCase.NEW_CARD: - const { _id, isBilling, ...address } = payload.pmtMethod?.newPmtMeth?.billing_details?.address; - return from(this.subSvc.stripe.createPaymentMethod({ - type: 'card', - card: payload.pmtMethod?.newPmtMeth?.card, - billing_details: { name: payload.pmtMethod?.newPmtMeth?.billing_details?.name, address } - })).pipe( - switchMap((result: PaymentMethodResult) => { - const stripeErr = result?.error; - if (stripeErr) { - return handleErr>({ error: stripeErr, opt: { extra: SubAppErr.CRT_PM_ERR, msg: stripeErr.message } }); - } - return this.subSvc.updateSubsPaymentMethod({ subIds: payload.subIds, pmId: result?.paymentMethod?.id }).pipe( - switchMap(() => { - return this.subSvc.editSub(payload.subIds?.map((subId) => ({ subId, cancelAtPeriodEnd: false })) || []); - }), - map((subs) => { - // Track successful trial checkout - this.trackSubscriptionPurchase(subs, { payload }); - - return new subAction.CheckoutTrialSuccess({ - card: { - pmId: result?.paymentMethod?.id, - brand: result?.paymentMethod?.card?.brand, - country: result?.paymentMethod?.card?.country, - exp_month: result?.paymentMethod?.card?.exp_month, - exp_year: result?.paymentMethod?.card?.exp_year, - last4: result?.paymentMethod?.card?.last4, - defaultPM: payload.pmtMethod?.newPmtMeth?.defaultPM - }, - subs, amount: payload.amount - }); - }) - ); - }), - ); - case TrialChkoutCase.EXISTING: - return this.subSvc.updateSubsPaymentMethod({ subIds: payload.subIds, pmId: payload.pmtMethod?.exPmtMeth?.pmId }).pipe( - switchMap(() => { - return this.subSvc.editSub(payload.subIds?.map((subId) => ({ subId, cancelAtPeriodEnd: false })) || []); - }), - (map((subs) => new subAction.CheckoutTrialSuccess({ card: payload.pmtMethod.exPmtMeth, subs, amount: payload.amount }))) - ); - case undefined: - return handleErr>({ opt: { extra: SubAppErr.CHECKOUT_CONT_TRIAL_ERR } }); - default: - return handleErr>({ opt: { extra: SubAppErr.CHECKOUT_CONT_TRIAL_ERR } }); - } - } - - private handleChkoutTrial(payload: TrialPmtPkg, type?: TrialChkoutCase) { - const updatePkgPayload: SubscriptionPackage = { - defaultPM: payload.pmtMethod?.newPmtMeth?.defaultPM, - package: payload.package, addons: payload.addons, - trial: 1, - prorateTS: DateUtils.currUTC() - }; - let subs: StripeSubscription[]; - - const handleDefault = () => this.subSvc.updateSubscription(updatePkgPayload).pipe( - switchMap((_subs) => { - subs = _subs; - return this.custSvc.getCustomer(this.authSvc.user._id); - }), - switchMap((cust: Customer) => { - // Track successful trial checkout - this.trackSubscriptionPurchase(subs, { payload: updatePkgPayload }); - - // Fetch card data from customer's default payment method - return this.subSvc.getPaymentMethodList(cust.membership.custId).pipe( - map((paymentMethods: PaymentMethod[]) => { - const defaultPM = paymentMethods?.find(pm => pm.id === subs?.[0]?.default_payment_method); - const card: Card | undefined = defaultPM ? { - pmId: defaultPM.id, - brand: defaultPM.card?.brand, - country: defaultPM.card?.country, - exp_month: defaultPM.card?.exp_month, - exp_year: defaultPM.card?.exp_year, - last4: defaultPM.card?.last4, - defaultPM: true - } : undefined; - - return [new subAction.CheckoutTrialSuccess({ subs, card }), new subAction.UpdateTrial(cust.membership.trials)]; - }), - switchMap((actions) => of(...actions)) - ); - }) - ); - - - switch (type) { - case TrialChkoutCase.NEW_CARD: - const { _id, isBilling, ...address } = payload.pmtMethod?.newPmtMeth?.billing_details?.address; - return from(this.subSvc.stripe.createPaymentMethod({ - type: 'card', card: payload.pmtMethod?.newPmtMeth?.card, - billing_details: { name: payload.pmtMethod?.newPmtMeth?.billing_details?.name, address } - })).pipe( - switchMap((result: PaymentMethodResult) => { - const stripeErr = result?.error; - if (stripeErr) { - return handleErr>({ error: stripeErr, opt: { extra: SubAppErr.CRT_PM_ERR, msg: stripeErr.message } }); - } - return this.subSvc.updateSubscription({ pmId: result?.paymentMethod?.id, ...updatePkgPayload }).pipe( - switchMap((_subs) => { - subs = _subs; - return this.subSvc.editSub(subs?.map((sub) => ({ subId: sub.id, cancelAtPeriodEnd: false })) || []); - }), - switchMap((_subs) => { - return this.custSvc.getCustomer(this.authSvc.user._id); - }), - switchMap((cust: Customer) => { - return of( - new subAction.CheckoutTrialSuccess({ - card: { - pmId: result?.paymentMethod?.id, - brand: result?.paymentMethod?.card?.brand, - country: result?.paymentMethod?.card?.country, - exp_month: result?.paymentMethod?.card?.exp_month, - exp_year: result?.paymentMethod?.card?.exp_year, - last4: result?.paymentMethod?.card?.last4, - defaultPM: payload.pmtMethod?.newPmtMeth?.defaultPM - }, - subs - }), - new subAction.UpdateTrial(cust.membership.trials)) - }) - ); - }), - ); - case TrialChkoutCase.EXISTING: - return this.subSvc.updateSubscription({ pmId: payload.pmtMethod?.exPmtMeth?.pmId, ...updatePkgPayload }).pipe( - switchMap((_subs) => { - subs = _subs; - return this.subSvc.editSub(subs?.map((sub) => ({ subId: sub.id, cancelAtPeriodEnd: false })) || []); - }), - switchMap((_subs) => { - return this.custSvc.getCustomer(this.authSvc.user._id); - }), - switchMap((cust: Customer) => { - return of(new subAction.CheckoutTrialSuccess({ card: payload.pmtMethod.exPmtMeth, subs }), new subAction.UpdateTrial(cust.membership.trials)) - }) - ); - case undefined: - return handleDefault(); - default: - return handleDefault(); - } - } - - @Effect() - applyPreviewDiscount$: Observable = this.actions$.pipe( - ofType(subAction.APPLY_DISCOUNT_PREVIEW), - switchMap((action) => { - let invoicePkg: InvoicePackage = { custId: action.payload.subIntentPkg.custId, package: action.payload.subIntentPkg.selPkg?.lookupKey, addons: action.payload.subIntentPkg.selAddons?.map((addon: Addon) => ({ price: addon?.lookupKey, quantity: addon?.quantity })), prorateTS: action.payload.subIntentPkg.prorateTS }; - - if (action.payload.coupon) { - // Extract price keys for product restriction validation - const priceKeys = this._collectPriceKeys(action.payload.subIntentPkg); - - return this.subSvc.getCoupon(action.payload.coupon, priceKeys).pipe( - switchMap((coupon: Coupon) => { - if (!coupon.valid) { - return handleErr>({ error: '', opt: { extra: SubAppErr.APP_DISCOUNT_PREVIEW_ERR } }); - } - invoicePkg.coupon = coupon.id; - return this.subSvc.retrieveUpcomingInvoices(invoicePkg).pipe( - map((res) => new subAction.ApplyDiscountPreviewSuccess({ amount: this.subSvc.calcAmount(res, { coupon }), coupons: [coupon] })) - ); - }) - ); - } - return this.subSvc.retrieveUpcomingInvoices(invoicePkg).pipe( - map((res) => new subAction.ApplyDiscountPreviewSuccess({ amount: this.subSvc.calcAmount(res), coupons: [] })) - ); - }), - catchError((err) => { - // Handle promo_invalid_coupon error specifically - // Error structure: err.error.error[".tag"] and err.error.error.message - const errorTag = err?.error?.error?.[".tag"]; - const errorMessage = err?.error?.error?.message; - - if (errorTag === 'promo_invalid_coupon') { - // Match specific error message to user-friendly label from PromoErrors - let displayMessage = PromoErrors.PROMO_INVALID_COUPON; // Default - - if (errorMessage?.includes('first-time customers')) { - displayMessage = PromoErrors.PROMO_FIRST_TIME_ONLY; - } else if (errorMessage?.includes('not available for this customer')) { - displayMessage = PromoErrors.PROMO_RESTRICTED_CUSTOMER; - } else if (errorMessage?.includes('not applicable to the selected products') || errorMessage?.includes('restricted to specific products')) { - displayMessage = PromoErrors.PROMO_RESTRICTED_PRODUCT; - } else if (errorMessage?.includes('expired')) { - displayMessage = PromoErrors.PROMO_EXPIRED; - } else if (errorMessage?.includes('maximum redemption') || errorMessage?.includes('reached max')) { - displayMessage = PromoErrors.PROMO_MAX_REDEMPTIONS; - } - - return handleErr>({ - error: err, - opt: { - extra: SubAppErr.APP_DISCOUNT_PREVIEW_ERR, - msg: displayMessage - } - }); - } - - // Fallback to generic error handling (for other error types) - return handleErr>({ - error: err, - opt: { - extra: SubAppErr.APP_DISCOUNT_PREVIEW_ERR, - msg: err?.error?.message || err?.error?.raw?.message // Try new format first, fallback to old - } - }); - }), - repeat() - ); - - /** - * Collect price keys from subscription intent package for product validation - * @param subIntentPkg Subscription intent package containing selected package and addons - * @returns Array of price lookup keys (e.g., ['ess_3', 'addon_1']) - */ - private _collectPriceKeys(subIntentPkg: any): string[] { - const keys: string[] = []; - - // Add selected package - if (subIntentPkg?.selPkg?.lookupKey) { - keys.push(subIntentPkg.selPkg.lookupKey); - } - - // Add selected addons - subIntentPkg?.selAddons?.forEach(addon => { - if (addon?.lookupKey) { - keys.push(addon.lookupKey); - } - }); - - return keys; - } - - // Checkout-review stage - private finalizeConfirm({ action, results, confirmPkg }) { - return switchMap((subscriptions: StripeSubscription[]) => { - const hasSubs = subscriptions?.length > 0; - - if (hasSubs) { - const isPastdueType = action.payload.unresolved?.type === SubStripe.PAST_DUE; - const isIncompleteType = action.payload.unresolved?.type === SubStripe.INCOMPLETE; - const isContainError = (results: PaymentIntentResult[]): boolean => results?.some((result) => !!result?.error); - - if (isContainError(results)) { - const error = results?.find((result) => !!result?.error)?.error; - const lastPaymentErr: any = error?.payment_intent?.last_payment_error; - const card: Card = lastPaymentErr?.payment_method?.card || lastPaymentErr?.source; - - // Check for card decline at checkout-review stage - const isCardDeclineError = error?.code === 'card_declined' || error?.decline_code === 'generic_decline'; - const isCheckoutReviewStage = confirmPkg?.stage === SUB.CHKOUT_REV; - - if (isCardDeclineError && isCheckoutReviewStage) { - // Stay at checkout-review with error message - no navigation - return of( - new subAction.UpdateIncomplete({ - invoices: action.payload.unresolved?.invoices, - requiresAction: false, - requiresPM: true, - numOfRetries: ++action.payload.unresolved.numOfRetries, - subscriptions - }), - new subAction.UpdateSubscriptionStatus( - createSubStatus(SubStripe.CARD_DECLINED, { card }) - ) - ); - } - - if (isPastdueType) { - return of( - new subAction.UpdateSubscriptionStatus(createSubStatus(SubAppErr.CONF_ERR, { extra: lastPaymentErr, card })), - new subAction.UpdatePastDue({ invoices: action.payload.unresolved?.invoices, numOfRetries: ++action.payload.unresolved.numOfRetries }) - ); - } else if (isIncompleteType) { - const isReqAction = action.payload.unresolved.reason === SubStripe.REQUIRE_ACTION; - const isReqPM = action.payload.unresolved.reason === SubStripe.REQUIRE_PAYMENT_METHOD; - if (isReqAction) { - return of( - new subAction.UpdateIncomplete({ invoices: action.payload.unresolved?.invoices, requiresAction: true, requiresPM: false, numOfRetries: ++action.payload.unresolved.numOfRetries, subscriptions }), - new subAction.UpdateSubscriptionStatus(createSubStatus(SubAppErr.CONF_ERR, { extra: lastPaymentErr, card })) - ); - } else if (isReqPM) { - return of( - new subAction.UpdateIncomplete({ invoices: action.payload.unresolved?.invoices, requiresAction: false, requiresPM: true, numOfRetries: ++action.payload.unresolved.numOfRetries, subscriptions }), - new subAction.UpdateSubscriptionStatus(createSubStatus(SubAppErr.CONF_ERR, { extra: lastPaymentErr, card })) - ); - } - } else { - return of(new subAction.UpdateSubscriptionStatus(createSubStatus(SubAppErr.CONF_ERR, { extra: lastPaymentErr, card }))); - } - } - - return this.subSvc.retrieveCurrUsage(action.payload.custId, action.payload.applicatorId).pipe( - map((usage) => { - if (isPastdueType) { - return new subAction.ConfirmPaymentSuccess(this.subSvc.createSubPlan(subscriptions, this.authSvc.user?.membership, usage)); - } else if (isIncompleteType) { - return new subAction.ConfirmActionSuccess(this.subSvc.createSubPlan(subscriptions, this.authSvc.user?.membership, usage)); - } - return new subAction.UpdateSubscriptionSuccess(this.subSvc.createSubPlan(subscriptions, this.authSvc.user?.membership, usage)); - }) - ); - } else { - return of(new subAction.UpdateSubscriptionStatus(createSubStatus(SubAppErr.NO_SUBS_ERR))); - } - }) - } - - @Effect() - confirm$: Observable = this.actions$.pipe( - ofType(subAction.CONFIRM), - switchMap((action: subAction.Confirm) => { - let confirmPkg: ConfirmPackage; - return this.subSvc.updateSubsPaymentMethod({ pmId: action.payload.stripePkgs[0].pmId, subIds: action.payload.subIds }).pipe( - switchMap(() => { - return of(action.payload).pipe( - switchMap((_confirmPkg: ConfirmPackage) => { - confirmPkg = _confirmPkg; - const confirmations$ = confirmPkg?.stripePkgs?.map((pkg) => { - return Utils.demethodize(this.subSvc.stripe.confirmCardPayment)(pkg?.clientSecret, { payment_method: pkg?.pmId }); - }); - const promiseChain = Utils.createPromiseChain(confirmations$) - return from(promiseChain); - }), - switchMap((results: PaymentIntentResult[]) => { - // Check for errors in 3DS confirmation - const hasErrors = results?.some((result) => !!result?.error); - if (hasErrors) { - // If there are errors, proceed without polling - return this.subSvc.fetchSubscriptions(action.payload.custId).pipe( - this.finalizeConfirm({ action, results, confirmPkg }) - ); - } - - // ============================================================ - // NEW: 3DS SUCCESS → START POLLING (r944 requirement) - // ============================================================ - // PaymentIntent is 'succeeded' but subscription still 'incomplete' - // Must wait 1-3 seconds for Stripe to charge and activate - - const subscriptionIds = action.payload.subIds; - - if (!subscriptionIds || subscriptionIds.length === 0) { - console.error('❌ No subscription IDs provided for polling'); - return this.subSvc.fetchSubscriptions(action.payload.custId).pipe( - this.finalizeConfirm({ action, results, confirmPkg }) - ); - } - - // Poll EACH subscription until active - const pollingObservables = subscriptionIds.map((subId) => - this.pollSubscriptionStatus(subId, 10, 500) - ); - - return forkJoin(pollingObservables).pipe( - switchMap((polledSubscriptions) => { - // All subscriptions activated successfully - // Now fetch full subscription data and finalize - return this.subSvc.fetchSubscriptions(action.payload.custId).pipe( - this.finalizeConfirm({ action, results, confirmPkg }) - ); - }), - catchError((pollingError) => { - console.error('❌ Polling failed:', pollingError); - // Even if polling fails, try to fetch subscriptions and proceed - // The subscription might have activated despite polling timeout - return this.subSvc.fetchSubscriptions(action.payload.custId).pipe( - this.finalizeConfirm({ action, results, confirmPkg }) - ); - }) - ); - }) - ); - }) - ); - }), - catchError((err) => { - console.error('🔴 CONFIRM EFFECT ERROR', err); - return handleErr>({ error: err, opt: { extra: SubAppErr.CONF_ERR } }); - }), - repeat() - ); - - @Effect() - updateSubscription$: Observable = this.actions$.pipe( - ofType(subAction.UPDATE_SUBSCRIPTION), - switchMap((action: subAction.UpdateSubscription) => { - const card: Card = action.payload.card; - let subscriptions: StripeSubscription[]; - - return this.subSvc.updateSubscription({ pmId: action.payload.pmId, defaultPM: action.payload.defaultPM, package: action.payload.package, addons: action.payload.addons, prorateTS: action.payload.prorateTS, coupon: action.payload.coupon }).pipe( - switchMap((res) => { - subscriptions = res - return this.subSvc.retrieveCurrUsage(this.authSvc.user?.membership?.custId, action.payload.applicatorId); - }), - switchMap((usage) => { - const hasIncompleteSub = this.subSvc.hasSubsWithStatus(subscriptions, SubStripe.INCOMPLETE); - - if (hasIncompleteSub) { - const req3dsVerf = this.subSvc.isRequireAction(subscriptions); - const reqPm = this.subSvc.isRequirePaymentMethod(subscriptions); - - // CRITICAL: Transform backend's flat 3DS response structure into expected nested format - // Backend (r942 Direct Pattern) returns: { requires_action: true, client_secret: 'pi_xxx', payment_intent_id: 'pi_xxx' } - // Frontend expects: { latest_invoice: { payment_intent: { status: 'requires_action', client_secret: 'pi_xxx' } } } - const transformedSubscriptions = subscriptions?.map(sub => { - // If backend returned flat structure with client_secret at top level, transform it - if ((sub as any)?.requires_action && (sub as any)?.client_secret && !sub?.latest_invoice?.payment_intent?.client_secret) { - return { - ...sub, - latest_invoice: { - ...(sub.latest_invoice || {} as any), - id: sub.latest_invoice?.id || (sub as any).payment_intent_id || `inv_temp_${Date.now()}`, - status: 'open', - subscription: sub.id, - payment_intent: { - ...(sub.latest_invoice?.payment_intent || {} as any), - id: (sub as any).payment_intent_id, - status: 'requires_action', - client_secret: (sub as any).client_secret - } as any - } as any - }; - } - return sub; - }); - - let latestInvoices = transformedSubscriptions?.map((sub) => sub?.latest_invoice) as any[]; - const hasLatestInvoices = latestInvoices?.length > 0; - if (hasLatestInvoices) { - if (req3dsVerf) { - const atChkoutRevStage = action.payload.stage === SUB.CHKOUT_REV; - if (atChkoutRevStage) { - return of( - new subAction.UpdateSubscriptionStatus(createSubStatus(SubStripe.REQUIRE_ACTION, { card })), - new subAction.UpdateIncomplete({ invoices: latestInvoices, requiresAction: true, requiresPM: false, numOfRetries: 0, subscriptions }), - new subAction.UpdateSubscriptionSuccess(this.subSvc.createSubPlan(subscriptions, this.authSvc.user?.membership, usage)) - ); - } else { - // Not at checkout review stage - return incomplete status without navigation - return of( - new subAction.UpdateSubscriptionStatus(createSubStatus(SubStripe.REQUIRE_ACTION, { card })), - new subAction.UpdateIncomplete({ invoices: latestInvoices, requiresAction: true, requiresPM: false, numOfRetries: 0, subscriptions }), - new subAction.UpdateSubscriptionSuccess(this.subSvc.createSubPlan(subscriptions, this.authSvc.user?.membership, usage)) - ); - } - } else if (reqPm) { - const atChkoutRevStage = action.payload.stage === SUB.CHKOUT_REV; - if (atChkoutRevStage) { - return of( - new subAction.UpdateSubscriptionStatus(createSubStatus(SubStripe.REQUIRE_PAYMENT_METHOD, { card })), - new subAction.UpdateIncomplete({ invoices: latestInvoices, requiresAction: false, requiresPM: true, numOfRetries: 0, subscriptions }), - new subAction.UpdateSubscriptionSuccess(this.subSvc.createSubPlan(subscriptions, this.authSvc.user?.membership, usage)) - ); - } else { - // Not at checkout review stage - return incomplete status without navigation - return of( - new subAction.UpdateSubscriptionStatus(createSubStatus(SubStripe.REQUIRE_PAYMENT_METHOD, { card })), - new subAction.UpdateIncomplete({ invoices: latestInvoices, requiresAction: false, requiresPM: true, numOfRetries: 0, subscriptions }), - new subAction.UpdateSubscriptionSuccess(this.subSvc.createSubPlan(subscriptions, this.authSvc.user?.membership, usage)) - ); - } - } else { - // Incomplete but neither requires action nor payment method - return success - return of( - new subAction.UpdateSubscriptionSuccess(this.subSvc.createSubPlan(subscriptions, this.authSvc.user?.membership, usage)) - ); - } - } else { - return handleErr>({ opt: { extra: SubAppErr.NO_INVOICES_ERR } }); - } - } else { - // Track successful subscription purchase/update - this.trackSubscriptionPurchase(subscriptions, action); - - return of(new subAction.UpdateSubscriptionSuccess(this.subSvc.createSubPlan(subscriptions, this.authSvc.user?.membership, usage)), new subAction.GotoCheckoutConfirm()); - } - }), - catchError((err) => handleErr>({ error: err, opt: { card, extra: SubAppErr.UPDATE_SUB_ERR } })) - ); - }), - catchError((err) => handleErr>({ error: err, opt: { extra: SubAppErr.UPDATE_SUB_ERR } })), - repeat() - ); - - @Effect() - refreshSubIntent$: Observable = this.actions$.pipe( - ofType(subAction.REFRESH_SUBSCRIPTION_INTENT), - switchMap((action: subAction.RefeshSubscriptionIntent) => { - return this.subSvc.fetchSubscriptions(action.payload.custId).pipe( - switchMap((subs: StripeSubscription[]) => { - const hasSubs = subs?.length > 0; - - if (hasSubs) { - const hasIncompleteSubs = this.subSvc.hasSubsWithStatus(subs, SubStripe.INCOMPLETE); - const hasPastDueSubs = this.subSvc.hasSubsWithStatus(subs, SubStripe.PAST_DUE) || this.subSvc.hasSubsWithStatus(subs, SubStripe.OVERDUE); - const hasNonActiveSubs = hasIncompleteSubs || hasPastDueSubs; - - if (hasNonActiveSubs) { - const latestInvoices = subs?.map((sub) => (sub?.latest_invoice)); - const hasLatestInvoices = latestInvoices?.length > 0; - - if (hasLatestInvoices) { - return this.subSvc.getPaymentMethodList(action.payload.custId).pipe( - switchMap((paymentMethods: PaymentMethod[]) => { - let subIntentPkg: SubscriptionIntent; - const amount = this.subSvc.calcAmount(latestInvoices, { subscriptions: this.authSvc.user?.membership?.subscriptions }); - subIntentPkg = { ...subIntentPkg, applicatorId: action.payload.applicatorId, custId: action.payload.custId, upcomingInvoices: latestInvoices, amount }; - const hasIncompleteSubs = this.subSvc.hasSubsWithStatus(subs, SubStripe.INCOMPLETE); - const hasPastDueSubs = this.subSvc.hasSubsWithStatus(subs, SubStripe.PAST_DUE) || this.subSvc.hasSubsWithStatus(subs, SubStripe.OVERDUE); - const hasNonActiveSubs = hasIncompleteSubs || hasPastDueSubs; - const reqPM = this.subSvc.isRequirePaymentMethod(subs); - const reqAction = this.subSvc.isRequireAction(subs); - - if (hasNonActiveSubs) { - const createActions = (params: { status: string, card: Card, options: any, subIntentPkg: SubscriptionIntent, type: string }) => { - switch (params.type) { - case SubStripe.INCOMPLETE: - return of( - new subAction.UpdateSubscriptionStatus(createSubStatus(params.status, { card: params.card })), - new subAction.UpdateIncomplete(params.options), - new subAction.RefeshSubscriptionIntentSuccess(params.subIntentPkg) - ); - case SubStripe.PAST_DUE: - return of( - new subAction.UpdateSubscriptionStatus(createSubStatus(params.status, { card: params.card })), - new subAction.UpdatePastDue(params.options), - new subAction.RefeshSubscriptionIntentSuccess(params.subIntentPkg) - ); - } - }; - - const handleReqPM = () => { - const reqPmSub = this.subSvc.getReqPmSubscription(subs); - const card = action.payload.card || reqPmSub?.latest_invoice?.payment_intent?.last_payment_error?.payment_method?.card || reqPmSub?.latest_invoice?.payment_intent?.last_payment_error?.source; - const openInvoices = latestInvoices?.filter((invoice => invoice.status === SubStripe.OPEN)); - const amount = this.subSvc.calcAmount(openInvoices, { subscriptions: this.authSvc.user?.membership?.subscriptions, coupon: this.subSvc.getInvCoupon(openInvoices) }); - subIntentPkg = { ...subIntentPkg, billingInfo: { applicatorId: action.payload.applicatorId, name: reqPmSub?.latest_invoice?.customer_name, address: reqPmSub?.latest_invoice?.customer_address }, card, upcomingInvoices: openInvoices, paymentMethods, amount }; - const fromChkoutStage = action.payload.prevStage === SUB.CHKOUT; - - if (fromChkoutStage) { - if (hasIncompleteSubs) { - return createActions({ status: SubStripe.REQUIRE_ACTION, card, options: { invoices: openInvoices, requiresAction: false, requiresPM: false, numOfRetries: 0, subscriptions: subs }, subIntentPkg, type: SubStripe.INCOMPLETE }); - } else if (hasPastDueSubs) { - return createActions({ status: SubStripe.PAST_DUE, card, options: { invoices: openInvoices, numOfRetries: 0 }, subIntentPkg, type: SubStripe.PAST_DUE }); - } - } - - if (hasIncompleteSubs) { - return createActions({ status: SubStripe.REQUIRE_PAYMENT_METHOD, card, options: { requiresAction: false, requiresPM: true, invoices: openInvoices, numOfRetries: 0, subscriptions: subs }, subIntentPkg, type: SubStripe.INCOMPLETE }); - } else if (hasPastDueSubs) { - return createActions({ status: SubStripe.PAST_DUE, card, options: { invoices: openInvoices, numOfRetries: 0 }, subIntentPkg, type: SubStripe.PAST_DUE }); - } - } - - const handReqAction = () => { - const paymentMethod = paymentMethods?.find((pm) => pm.id === latestInvoices?.[0]?.payment_intent?.payment_method); - const card = action.payload.card || paymentMethod?.card; - const openInvoices = latestInvoices?.filter((invoice => invoice.status === SubStripe.OPEN)); - const amount = this.subSvc.calcAmount(openInvoices, { subscriptions: this.authSvc.user?.membership?.subscriptions, coupon: this.subSvc.getInvCoupon(openInvoices) }); - subIntentPkg = { ...subIntentPkg, card, billingInfo: paymentMethod?.billing_details, upcomingInvoices: openInvoices, paymentMethods, amount }; - const fromChkoutStage = action.payload.prevStage === SUB.CHKOUT; - - if (fromChkoutStage) { - return createActions({ status: SubStripe.REQUIRE_ACTION, card, options: { invoices: openInvoices, requiresAction: false, requiresPM: false, numOfRetries: 0, subscriptions: subs }, subIntentPkg, type: SubStripe.INCOMPLETE }); - } - return createActions({ status: SubStripe.REQUIRE_ACTION, card, options: { invoices: openInvoices, requiresAction: true, requiresPM: false, numOfRetries: 0, subscriptions: subs }, subIntentPkg, type: SubStripe.INCOMPLETE }); - } - - if (reqPM) { - return handleReqPM(); - } else if (reqAction) { - return handReqAction(); - } else { - return handleErr>({ opt: { extra: SubAppErr.NO_ACTIONS_ERR } }); - } - } else { - return of(new subAction.RefeshSubscriptionIntentSuccess(subIntentPkg)); - } - }) - ); - } else { - return handleErr>({ opt: { extra: SubAppErr.NO_INVOICES_ERR } }); - } - } else { - return of( - new subAction.ClearSubscription(), - new subAction.GotoMyServices() - ); - } - } else { - return handleErr>({ opt: { extra: SubAppErr.NO_SUBS_ERR } }); - } - }) - ); - }), - catchError((err) => handleErr>({ error: err, opt: { extra: SubAppErr.REFRESH_ERR } })), - repeat() - ); - - private updateDefaultPM(pmId: string, subIds: string[]) { - return of(new subAction.UpdateSubscriptionStatus(createSubStatus(SUB.UPDATE_DEF_PM))).pipe( - switchMap(() => this.subSvc.updateSubsPaymentMethod({ pmId, subIds })) - ); - } - - @Effect() - payUnpaidSub$: Observable = this.actions$.pipe( - ofType(subAction.PAY_UNPAID_SUBSCRIPTION), - switchMap((action: subAction.PayUnpaidSubscription) => { - const payload = { pmId: action.payload.pmId, invIds: action.payload.invIds }; - let subscriptions, usage; - return this.subSvc.payUnpaidSub(payload).pipe( - switchMap(() => { - return this.subSvc.fetchSubscriptions(action.payload.custId); - }), - switchMap((subs) => { - subscriptions = subs; - return this.subSvc.retrieveCurrUsage(action.payload.custId, action.payload.applicatorId) - }), - switchMap((_usage) => { - usage = _usage; - return this.updateDefaultPM(action.payload.pmId, action.payload.unpaid?.invoices?.map((invoice) => invoice.subscription)).pipe( - map(() => new subAction.PayUnpaidSubscriptionSuccess(this.subSvc.createSubPlan(subscriptions, this.authSvc.user?.membership, usage))) - ); - }), - catchError((err) => handleErr>({ error: err, opt: { extra: action.payload.unpaid, msg: SubAppErr.PAY_UNPAID_CARD_ERR, card: action.payload.card } })) - ); - }), - catchError((err) => handleErr>({ error: err, opt: { extra: SubAppErr.PAY_UNPAID_ERR } })), - repeat() - ); - - @Effect() - resumeUnpaidSub$: Observable = this.actions$.pipe( - ofType(subAction.RESUME_UNPAID_SUBSCRIPTION), - switchMap((action: subAction.ResumeUnpaidSubscription) => { - let authUser: UserModel = action.payload.authUser; - return this.subSvc.getBillingAddress(authUser._id).pipe( - switchMap((address: Address) => { - let subIntentPkg: SubscriptionIntent; - const amount = this.subSvc.calcAmount(action.payload.unpaidInvoices, { subscriptions: this.authSvc.user?.membership?.subscriptions, coupon: this.subSvc.getInvCoupon(action.payload.unpaidInvoices) }); - subIntentPkg = { ...subIntentPkg, billingInfo: { applicatorId: authUser?._id, name: address?.name, address: this.subSvc.convertAddr(address) }, applicatorId: authUser?._id, custId: authUser?.membership?.custId, amount, mode: Mode.UNPAID }; - return this.subSvc.getPaymentMethodList(authUser.membership.custId).pipe( - map((paymentMethods) => new subAction.StartBillingInfoSuccess({ ...subIntentPkg, upcomingInvoices: action.payload.unpaidInvoices, paymentMethods, })) - ); - }) - ); - }), - catchError((err) => handleErr>({ error: err, opt: { extra: SubAppErr.RES_UNPAID_ERR } })), - repeat() - ); - - // MyServices stage - @Effect() - initSubscription$: Observable = this.actions$.pipe( - ofType(subAction.INIT_SUBSCRIPTION), - switchMap((action: subAction.InitSubscription) => { - return this.subSvc.fetchSubscriptions(action.payload.custId).pipe( - switchMap((subscriptions: StripeSubscription[]) => { - const hasNoSubs = subscriptions?.length === 0; - const hasUnpaidSubs = subscriptions?.some((sub) => sub?.status === SubStripe.UNPAID); - const hasPastDueSubs = subscriptions?.some((sub) => sub?.status === SubStripe.PAST_DUE) || subscriptions?.some((sub) => sub?.status === SubStripe.OVERDUE); - const hasIncomplete = subscriptions?.some((sub) => sub?.status === SubStripe.INCOMPLETE); - const hasInvTaxLoc = subscriptions?.some((sub) => sub?.latest_invoice?.automatic_tax?.enabled && sub?.latest_invoice?.automatic_tax?.status === SubStripe.REQ_LOC_INPUT); - - const handleNonActiveSubs = (code: string, subs: StripeSubscription[]): Observable => { - let reqAction = this.subSvc.isRequireAction(subs); - if (reqAction) { - return of( - new subAction.FetchLatestSubscriptionSuccess({ subscriptions, membership: this.subSvc.updateMembShip(subscriptions, this.authSvc.user?.membership) }), - new subAction.UpdateSubscriptionStatus(createSubStatus(SubStripe.REQUIRE_ACTION)) - ); - } - const paymentIntent = subs?.find((sub) => sub?.status === SubStripe[code.toLocaleUpperCase()]). - latest_invoice?.payment_intent; - if (paymentIntent) { - const card = paymentIntent?.last_payment_error?.payment_method?.card || - paymentIntent?.last_payment_error?.source; - return of( - new subAction.FetchLatestSubscriptionSuccess({ subscriptions, membership: this.subSvc.updateMembShip(subscriptions, this.authSvc.user?.membership) }), - new subAction.UpdateSubscriptionStatus(createSubStatus(SubAppErr.RES_SUB_ERR, { extra: code, card })) - ); - } - return of( - new subAction.FetchLatestSubscriptionSuccess({ subscriptions, membership: this.subSvc.updateMembShip(subscriptions, this.authSvc.user?.membership) }), - new subAction.UpdateSubscriptionStatus(createSubStatus(SUB.POLLING)) - ); - } - - if (hasNoSubs) { - // Only reset the subscription-page state. Auth membership is NOT touched here – - // auth.reducer RESET_SUBSCRIPTION returns state unchanged so the expiry-warning - // banner remains visible when Stripe returns empty (e.g. transient API errors). - return of( - new ResetSubPlans(), - new subAction.ResetSubscription()); - } else if (hasUnpaidSubs) { - return handleNonActiveSubs(SubStripe.UNPAID, subscriptions); - } else if (hasPastDueSubs) { - return handleNonActiveSubs(SubStripe.PAST_DUE, subscriptions); - } else if (hasIncomplete) { - return handleNonActiveSubs(SubStripe.INCOMPLETE, subscriptions); - } else if (hasInvTaxLoc) { - return of( - new subAction.FetchLatestSubscriptionSuccess({ subscriptions, membership: this.subSvc.updateMembShip(subscriptions, this.authSvc.user?.membership) }), - new subAction.UpdateSubscriptionStatus(createSubStatus(SubStripe.REQ_LOC_INPUT)) - ); - } - - return of(new subAction.FetchLatestSubscriptionSuccess({ subscriptions, membership: this.subSvc.updateMembShip(subscriptions, this.authSvc.user?.membership) })); - })) - }), - catchError((err) => handleErr>({ error: err, opt: { extra: SubAppErr.FETCH_SUB_ERR } })), - repeat() - ); - - private extractUnpaidFromLatestSubs(latestSubs) { - const unpaidSubs = latestSubs?.filter((sub) => sub?.status === SubStripe.UNPAID); - const noUnpaidSub = unpaidSubs?.length === 0; - if (noUnpaidSub) { - this.store.dispatch(new subAction.PollUnpaidSubscriptionSuccess(latestSubs)); - this.cancelPolling(); - } else { - const hasLastPaymentErr = !!unpaidSubs?.[0]?.latest_invoice?.payment_intent?.last_payment_error; - if (hasLastPaymentErr) { - const card = unpaidSubs?.[0]?.latest_invoice?.payment_intent?.last_payment_error?.payment_method?.card || - unpaidSubs?.[0]?.latest_invoice?.payment_intent?.last_payment_error?.source - return of({ card, unpaidSubs, latestSubs }); - } else { - return this.subSvc.getPaymentMethodList(this.authSvc.user?.membership?.custId).pipe( - map((paymentMethods) => { - const card = paymentMethods?.find((pm) => pm?.id === unpaidSubs?.[0]?.latest_invoice?.payment_intent?.payment_method || pm?.id === unpaidSubs?.[0]?.latest_invoice?.payment_intent?.source)?.card; - return { card, unpaidSubs, latestSubs }; - })); - } - } - } - - private cancelPolling() { - this.store.dispatch(new subAction.CancelPollSubscription()); - } - - private resumeUnpaidSuccess({ latestSubs, unpaidInvoices, status }) { - return new subAction.ResumeUnpaidSubscriptionSuccess({ unpaid: { invoices: unpaidInvoices, numOfRetries: 0 }, subscriptions: latestSubs, status }); - } - - private resumeUnpaid({ latestSubs, unpaidInvoices, status }) { - this.store.dispatch(this.resumeUnpaidSuccess({ latestSubs, unpaidInvoices, status })); - this.cancelPolling(); - } - - @Effect() - pollUnpaidSub$: Observable = this.actions$.pipe( - ofType(subAction.POLL_UNPAID_SUBSCRIPTION), - switchMap((action: subAction.PollUnpaidSubscription) => { - let currRetry = 0; - const numOfRetry = 3; - const intervalTime = 3000; - const waitTime = 1000; - const custId = action.payload.custId; - return interval(intervalTime).pipe( - startWith(0), - debounceTime(waitTime), - switchMap(() => { - return this.subSvc.fetchSubscriptions(custId); - }), - switchMap((latestSubs: StripeSubscription[]) => { - const hasLastestSubs = latestSubs?.length > 0; - if (hasLastestSubs) { - return this.extractUnpaidFromLatestSubs(latestSubs); - } else { - this.cancelPolling(); - } - }), - switchMap(({ card, unpaidSubs, latestSubs }: UnpaidContent) => { - return this.subSvc.resumeUnpaidSub(unpaidSubs?.map((sub) => sub?.id)).pipe(( - map((unpaidInvoices) => ({ card, unpaidSubs, latestSubs, unpaidInvoices })) - )) - }), - tap(({ card, latestSubs, unpaidInvoices }: UnpaidContent) => { - const hasUnpaidInvoices = unpaidInvoices?.length > 0; - if (hasUnpaidInvoices) { - this.resumeUnpaid({ latestSubs, unpaidInvoices, status: createSubStatus(SubAppErr.RES_SUB_ERR, { extra: SubStripe.UNPAID, card }) }) - } else { - this.resumeUnpaid({ latestSubs, unpaidInvoices: [], status: createSubStatus(SubAppErr.NO_INVOICES_ERR) }); - } - }), - takeUntil(this.actions$.pipe(ofType(subAction.CANCEL_POLL_SUBSCRIPTION))), - take(numOfRetry), - map(({ card, unpaidSubs, latestSubs, unpaidInvoices }: UnpaidContent) => { - const hasUnpaidSubs = unpaidSubs?.length > 0; - if (hasUnpaidSubs) { - currRetry++; - const isMaxRetry = currRetry === numOfRetry; - if (isMaxRetry) { - return this.resumeUnpaidSuccess({ latestSubs, unpaidInvoices, status: createSubStatus(SubAppErr.RES_SUB_ERR, { extra: SubStripe.UNPAID, card }) }) - } else { - return new subAction.UpdateSubscriptionStatus(createSubStatus(SUB.POLLING)); - } - } - return new subAction.PollUnpaidSubscriptionSuccess({ subscriptions: latestSubs, membership: this.subSvc.updateMembShip(latestSubs, this.authSvc.user?.membership) }); - }) - ); - }), - catchError((err) => handleErr>({ error: err, opt: { extra: SubAppErr.POLL_ERR } })), - repeat() - ); - - // payment methods handling - @Effect() - createPaymentMethod$: Observable = this.actions$.pipe( - ofType(subAction.CREATE_PAYMENT_METHOD), - switchMap((action: subAction.CreatePaymentMethod) => { - const { _id, ...address } = action.payload.billing_details?.address; - return from(this.subSvc.stripe.createPaymentMethod({ type: 'card', card: action.payload.card, billing_details: { name: action.payload.billing_details?.name, address } })).pipe( - switchMap((result: PaymentMethodResult) => { - const stripeErr = result?.error; - if (stripeErr) { - return handleErr>({ error: stripeErr, opt: { extra: SubAppErr.CRT_PM_ERR, msg: stripeErr.message } }); - } - return this.subSvc.addPM(this.authSvc.user?.membership?.custId, result?.paymentMethod?.id, action.payload.defaultPM).pipe( - map(() => new subAction.Checkout({ pmId: result?.paymentMethod?.id, brand: result?.paymentMethod?.card?.brand, country: result?.paymentMethod?.card?.country, exp_month: result?.paymentMethod?.card?.exp_month, exp_year: result?.paymentMethod?.card?.exp_year, last4: result?.paymentMethod?.card?.last4, defaultPM: action.payload.defaultPM }))); - }) - ); - }), - catchError((err) => handleErr>({ error: err, opt: { extra: SubAppErr.CRT_PM_ERR } })), - repeat() - ); - - @Effect() - fetchPmList$: Observable = this.actions$.pipe( - ofType(subAction.FETCH_PAYMENT_METHOD_LIST), - switchMap((action: subAction.FetchPaymentMethodList) => - this.subSvc.getPaymentMethodList(this.authSvc.user?.membership?.custId).pipe( - map((paymentMethods) => new subAction.FetchPaymentMethodListSuccess({ paymentMethods })) - )), - catchError((err) => handleErr>({ error: err, opt: { extra: SubAppErr.FETCH_PM_ERR } })), - repeat() - ); - - @Effect() - fetchDefaultPm$: Observable = this.actions$.pipe( - ofType(subAction.FETCH_DEFAULT_PM), - switchMap((action: subAction.FetchDefaultPm) => - this.subSvc.getDefPaymentMethods(this.authSvc.user?.membership?.custId).pipe( - map((defPM) => new subAction.FetchDefaultPmSuccess({ defPM })) - )), - catchError((err) => handleErr>({ error: err, opt: { extra: SubAppErr.FETCH_DEFAULT_PM_ERR } })), - repeat() - ); - - @Effect() - editPM$: Observable = this.actions$.pipe( - ofType(subAction.EDIT_PM), - switchMap((action: subAction.EditPM) => this.subSvc.editPM(this.authSvc.user?.membership?.custId, action.payload).pipe( - map((pm) => new subAction.EditPMSuccess(pm)) - )), - catchError((err) => handleErr>({ error: err, opt: { extra: SubAppErr.EDIT_PM_ERR } })), - repeat() - ); - - @Effect() - addPM$: Observable = this.actions$.pipe( - ofType(subAction.ADD_PM), - switchMap((action: subAction.AddPM) => { - return from(this.subSvc.stripe.createPaymentMethod({ type: 'card', card: action.payload.card, billing_details: { name: action.payload.name } })).pipe( - switchMap((result: PaymentMethodResult) => { - const stripeErr = result?.error; - if (stripeErr) { - return handleErr>({ error: stripeErr, opt: { extra: SubAppErr.ADD_PM_ERR, msg: stripeErr.message } }); - } - return this.subSvc.addPM(this.authSvc.user?.membership?.custId, result?.paymentMethod?.id, action.payload.setDefault).pipe(map((pm) => new subAction.AddPMSuccess(pm))); - }) - ); - }), - catchError((err) => handleErr>({ error: err, opt: { extra: SubAppErr.ADD_PM_ERR } })), - repeat() - ); - - @Effect() - deletePM$: Observable = this.actions$.pipe( - ofType(subAction.DELETE_PM), - switchMap((action: subAction.DeletePM) => this.subSvc.deletePM(this.authSvc.user?.membership?.custId, action.payload).pipe( - map((pm) => new subAction.DeletePMSuccess(pm.id)) - )), - catchError((err) => handleErr>({ error: err, opt: { extra: SubAppErr.DELETE_PM_ERR } })), - repeat() - ); - - @Effect() - changePM$: Observable = this.actions$.pipe( - ofType(subAction.CHANGE_PM), - switchMap((action: subAction.ChangePM) => this.subSvc.updateCustPaymentMethod(action.payload.custId, action.payload.pmId, true).pipe( - map((defPM) => new subAction.ChangePMSuccess({ defPM })) - )), - catchError((err) => handleErr>({ error: err, opt: { extra: SubAppErr.CHANGE_PM_ERR } })), - repeat() - ); - - private getPaymentMethod(action: any): 'credit_card' | 'bank_transfer' | 'paypal' | 'invoice' { - if (action.payload?.pmId) return 'credit_card'; - // Add logic to determine other payment methods based on your data structure - return 'credit_card'; // Default to credit card - } - - private trackSubscriptionPurchase(subscriptions: StripeSubscription[], action: any): void { - try { - // Get the primary subscription (usually the first one) - const primarySub = subscriptions?.[0]; - if (!primarySub) return; - - const user = this.authSvc.user; - if (!user) return; - - // Get subscription details using the centralized analytics helpers service - const subscriptionType = this.gaHelpers.getSubscriptionType(primarySub); - const subscriptionTier = this.gaHelpers.getSubscriptionTier(primarySub); - const serviceType = this.gaHelpers.getServiceType(primarySub); - - // Check for trial status - const isTrial = primarySub.status === 'trialing' || - (primarySub.trial_end && new Date() < new Date(primarySub.trial_end * 1000)); - - // Extract pricing info from action payload or subscription metadata - const packageInfo = action.payload?.package; - const subscriptionPrice = packageInfo?.amount ? packageInfo.amount / 100 : 0; - const interval = packageInfo?.interval || 'month'; - - // Track addon purchases as placeholder - if (serviceType === SERVICE_TYPE.ADDON) { - // TODO: Implement addon tracking event when requirements are defined - return; - } - - this.ga.trackSubscriptionPurchased({ - subscription_type: subscriptionType, // e.g., "AgMission Essentials 1" - subscription_duration: interval === 'year' ? 'annual' : 'monthly', - subscription_price: subscriptionPrice, - previous_subscription_type: action.payload?.previousTier || 'none', - payment_method: this.getPaymentMethod(action), - billing_frequency: interval === 'year' ? 'annual' : 'monthly', - promo_code: action.payload?.coupon?.code, - discount_amount: action.payload?.coupon?.amount_off ? action.payload.coupon.amount_off / 100 : 0, - subscription_start_date: new Date().toISOString(), - auto_renewal: !primarySub.cancel_at_period_end, - upgrade_from: action.payload?.previousTier, - upgrade_to: subscriptionType, // Use the actual subscription type name - trial_conversion: this.gaHelpers.isTrialConversion(primarySub), - subscription_value: this.gaHelpers.calculateAnnualValue({ amount: packageInfo?.amount, interval }), - user_tenure_days: this.gaHelpers.calculateUserTenure(user), - user_id: user._id, - user_role: user.roles?.[0] || 'user', - subscription_tier: subscriptionTier, // e.g., "1", "2", "3", "4", "5" - platform: 'web', - service_type: serviceType as 'essential' | 'enterprise' | 'addon', // "essential", "enterprise", or "addon" - is_trial: isTrial - }); - } catch (error) { - console.warn('Failed to track subscription purchase:', error); - } - } - - /** - * Poll subscription status until it becomes 'active' or timeout (r944 requirement) - * - * After 3DS completion: - * - PaymentIntent status becomes 'succeeded' immediately - * - Subscription status stays 'incomplete' for 1-3 seconds - * - Stripe charges card in background - * - This method waits for subscription to become 'active' - * - * @param subscriptionId Stripe subscription ID (sub_xxxxx) - * @param maxAttempts Maximum polling attempts (default 10 = 5 seconds) - * @param intervalMs Delay between attempts in milliseconds (default 500ms) - * @returns Observable with final subscription status or error - */ - private pollSubscriptionStatus( - subscriptionId: string, - maxAttempts: number = 10, - intervalMs: number = 500 - ): Observable { - let attempts = 0; - - return interval(intervalMs).pipe( - startWith(0), // Start immediately (no initial delay) - - switchMap(() => { - attempts++; - - return this.subSvc.checkSubscriptionStatus(subscriptionId).pipe( - map(response => ({ - subscription: response, - attempts, - error: null - })), - catchError(err => { - console.error(`❌ Polling error on attempt ${attempts}:`, err); - return of({ - subscription: null, - attempts, - error: err - }); - }) - ); - }), - - // Evaluation logic - when to stop polling - tap(({ subscription, attempts, error }: any) => { - if (error) { - console.error(`❌ Status check failed:`, error); - } - }), - - // Stop conditions - takeWhile(({ subscription, attempts, error }: any) => { - // Stop if subscription is active (SUCCESS) - if (subscription?.status === 'active') { - return false; - } - - // Stop if subscription failed or canceled - if (subscription?.status === 'incomplete_expired' || - subscription?.status === 'canceled') { - console.error(`❌ Subscription ${subscription.status} during polling`); - throw new Error(`Subscription ${subscription.status} - cannot proceed`); - } - - // Stop if max attempts reached (TIMEOUT) - if (attempts >= maxAttempts) { - console.error(`❌ Polling timeout after ${attempts} attempts (${attempts * intervalMs}ms)`); - throw new Error( - `Subscription did not activate within ${maxAttempts * intervalMs / 1000} seconds. ` + - `Please check your subscription status in Stripe Dashboard.` - ); - } - - // Stop if API error occurred - if (error) { - throw new Error(`Status check failed: ${error.message || 'Unknown error'}`); - } - - // Continue polling for incomplete or past_due - return true; - }, true), // inclusive: true - emit the final value before completing - - // Extract subscription from result - map(({ subscription }) => subscription), - - // Take only the first successful result (when active) or error - take(1), - - // Error handling - catchError(err => { - console.error(`❌ Polling failed:`, err); - return throwError(err); - }) - ); - } -} diff --git a/Development/client/src/app/entities/actions/vehicle.actions.ts b/Development/client/src/app/entities/actions/vehicle.actions.ts index 8413d6c..0defdb3 100644 --- a/Development/client/src/app/entities/actions/vehicle.actions.ts +++ b/Development/client/src/app/entities/actions/vehicle.actions.ts @@ -1,5 +1,5 @@ import { Action } from "@ngrx/store"; -import { StatusChange, Vehicle } from "../models/vehicle.model"; +import { Vehicle } from "../models/vehicle.model"; export const FETCH = '[VEHICLES] Fetch vehilces'; export class Fetch implements Action { @@ -38,6 +38,7 @@ export class CreateFailed implements Action { export const UPDATE = '[VEHICLES] Update a vehilce'; export class Update implements Action { type: typeof UPDATE = UPDATE; + constructor(readonly payload: Vehicle) { } } export const UPDATE_SUCCESS = '[VEHICLES] Update vehilce success'; @@ -48,7 +49,7 @@ export class UpdateSuccess implements Action { } export const UPDATE_FAILED = '[VEHICLES] Update vehilce failed'; export class UpdateFailed implements Action { - type: typeof UPDATE_FAILED = UPDATE_FAILED; + type: typeof UPDATE_FAILED = UPDATE_FAILED; } export const DELETE = '[VEHICLES] Delete a vehilce'; @@ -71,35 +72,13 @@ export class DeleteError implements Action { export const SELECT = '[PILOTS] Select a pilot'; export class Select implements Action { type: typeof SELECT = SELECT; + constructor(readonly payload: Vehicle) { } } -interface UpdateVehiclesPayload { - vehicles: Vehicle[]; - type?: string; -} - -export const UPDATE_VEHICLES = '[VEHICLES] Update a vehicles'; -export class UpdateVehicles implements Action { - type: typeof UPDATE_VEHICLES = UPDATE_VEHICLES; - constructor(readonly payload: UpdateVehiclesPayload) { } -} -export const UPDATE_VEHICLES_SUCCESS = '[VEHICLES] Update a vehicles success'; -export class UpdateVehiclesSuccess implements Action { - type: typeof UPDATE_VEHICLES_SUCCESS = UPDATE_VEHICLES_SUCCESS; - - constructor(readonly payload: UpdateVehiclesPayload) { } -} -export const UPDATE_VEHICLES_FAILED = '[VEHICLES] Update a vehicles failed'; -export class UpdateVehiclesFailed implements Action { - type: typeof UPDATE_VEHICLES_FAILED = UPDATE_VEHICLES_FAILED; -} - export type All = | Fetch | FetchSuccess | FetchError | Create | CreateSuccess | CreateFailed | Update | UpdateSuccess | UpdateFailed | Delete | DeleteSuccess | DeleteError | Select - | UpdateVehicles | UpdateVehiclesSuccess | UpdateVehiclesFailed; - diff --git a/Development/client/src/app/entities/crop/crop-list/crop-list.component.html b/Development/client/src/app/entities/crop/crop-list/crop-list.component.html index 21e7e6f..4b6fb8a 100644 --- a/Development/client/src/app/entities/crop/crop-list/crop-list.component.html +++ b/Development/client/src/app/entities/crop/crop-list/crop-list.component.html @@ -23,11 +23,10 @@ - {{col.header}} - +
{{ GC.colors[rowData[col.field]] }} - +
{{ rowData[col.field] }} diff --git a/Development/client/src/app/entities/effects/vehicle.effects.ts b/Development/client/src/app/entities/effects/vehicle.effects.ts index 44c59f2..217d3c3 100644 --- a/Development/client/src/app/entities/effects/vehicle.effects.ts +++ b/Development/client/src/app/entities/effects/vehicle.effects.ts @@ -2,7 +2,9 @@ import { Injectable } from '@angular/core'; import { Actions, Effect, ofType } from '@ngrx/effects'; import { Observable, of } from 'rxjs'; import { map, switchMap, catchError } from 'rxjs/operators'; + import { Action } from '@ngrx/store'; + import * as vehicleActions from '../actions/vehicle.actions'; import { AuthService } from '@app/domain/services/auth.service'; import { VehicleService } from '@app/domain/services/vehicle.service'; @@ -40,14 +42,7 @@ export class VehicleEffects { this.vehilceSvc.saveVehicle(payload).pipe( map((aircraft) => new vehicleActions.CreateSuccess(aircraft)), catchError(err => { - let msg; - const subErrs = ['reached_vehicles_limit', 'reached_area_limit', 'subscription_not_found', 'pkg_subscription_not_found']; - if (subErrs.some((subErr) => subErr === err['error']['error']['.tag'])) { - msg = globals.apiErrorMsg(err['error']['error']['.tag'] || '') - } else { - msg = globals.doThingsFailed.replace('#do#', globals.save).replace('#thing#', globals.aircraft) - } - this.msgSvc.addFailedMsg(msg); + this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.save).replace('#thing#', globals.aircraft)); return of(new vehicleActions.CreateFailed()) }) ) @@ -57,19 +52,15 @@ export class VehicleEffects { @Effect() updateVehicle$: Observable = this.actions$.pipe( ofType(vehicleActions.UPDATE), - switchMap(({ payload }) => { - if (!payload.active) { - payload.pkgActive = false; - payload.tracking = false; - } - return this.vehilceSvc.updateVehicles([payload]).pipe( + switchMap(({ payload }) => + this.vehilceSvc.saveVehicle(payload).pipe( map(() => new vehicleActions.UpdateSuccess(payload)), catchError(err => { this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.save).replace('#thing#', globals.aircraft)); return of(new vehicleActions.UpdateFailed()); }) ) - }) + ) ); @Effect() @@ -88,18 +79,4 @@ export class VehicleEffects { ) ) ); - - @Effect() - updateVehicles$: Observable = this.actions$.pipe( - ofType(vehicleActions.UPDATE_VEHICLES), - switchMap(({ payload }) => - this.vehilceSvc.updateVehicles(payload.vehicles).pipe( - map(() => new vehicleActions.UpdateVehiclesSuccess(payload)), - catchError(err => { - this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.save).replace('#thing#', globals.aircraft)); - return of(new vehicleActions.UpdateVehiclesFailed()); - }) - ) - ) - ); } diff --git a/Development/client/src/app/entities/entities-routing.module.ts b/Development/client/src/app/entities/entities-routing.module.ts index 5472d10..f2cc876 100644 --- a/Development/client/src/app/entities/entities-routing.module.ts +++ b/Development/client/src/app/entities/entities-routing.module.ts @@ -3,7 +3,7 @@ import { Routes, RouterModule } from '@angular/router'; import { AuthGuard } from '../domain/guards/auth.guard'; -import { AC, RoleIds } from '../shared/global'; +import { RoleIds } from '../shared/global'; import { ProductListComponent } from './product/product-list/product-list.component'; import { PilotListComponent } from './pilot/pilot-list/pilot-list.component'; import { EntitiesMgtComponent } from './entities-mgt.component'; @@ -14,8 +14,6 @@ import { VehicleEditComponent } from './vehicle/vehicle-edit/vehicle-edit.compon import { VehicleResolver } from './vehicle-resolver.service'; import { CropListComponent } from './crop/crop-list/crop-list.component'; import { CropsLoadGuard } from '../domain/guards/crops-load.guard'; -import { SubscriptionGuard } from '@app/domain/guards/subscription.guard'; -import { UserResolver } from '@app/domain/resolvers/user-resolver'; const routes: Routes = [ { @@ -55,17 +53,13 @@ const routes: Routes = [ ] }, { - path: AC, - canActivate: [SubscriptionGuard], + path: 'aircraft', data: { roles: [RoleIds.APP, RoleIds.APP_ADM, RoleIds.OFFICER, RoleIds.INSPECTOR, RoleIds.PILOT, RoleIds.CLIENT] }, children: [ { path: '', component: VehicleListComponent, - resolve: { - user: UserResolver - } }, { path: ':id', component: VehicleEditComponent, diff --git a/Development/client/src/app/entities/entities.module.ts b/Development/client/src/app/entities/entities.module.ts index d576a6d..97755e1 100644 --- a/Development/client/src/app/entities/entities.module.ts +++ b/Development/client/src/app/entities/entities.module.ts @@ -7,7 +7,6 @@ import { AutoCompleteModule } from 'primeng/autocomplete'; import { InputSwitchModule } from 'primeng/inputswitch'; import { SplitButtonModule } from 'primeng/splitbutton'; import { TableModule } from 'primeng/table'; -import { MessagesModule } from 'primeng/messages'; import { StoreModule } from '@ngrx/store'; import { EffectsModule } from '@ngrx/effects'; @@ -17,7 +16,6 @@ import { VehicleEffects } from './effects/vehicle.effects'; import { reducers, FEATURE_KEY } from './reducers'; import { AppSharedModule } from '../shared/app-shared.module'; -import { PopupTooltipModule } from '../shared/popup-tooltip/popup-tooltip.module'; import { EntitiesRoutingModule } from './entities-routing.module'; import { EntitiesMgtComponent } from './entities-mgt.component'; @@ -28,7 +26,6 @@ import { PilotEditComponent } from './pilot/pilot-edit/pilot-edit.component'; import { PilotService } from '../domain/services/pilot.service'; import { PilotResolver } from './pilot-resolver.service'; import { VehicleEditComponent } from './vehicle/vehicle-edit/vehicle-edit.component'; -import { VehiclePartnerIntegrationComponent } from './vehicle/vehicle-partner-integration/vehicle-partner-integration.component'; import { VehicleResolver } from './vehicle-resolver.service'; import { VehicleService } from '../domain/services/vehicle.service'; import { CropEffects } from './effects/crop.effects'; @@ -38,7 +35,6 @@ import { CropListComponent } from './crop/crop-list/crop-list.component'; @NgModule({ imports: [ AppSharedModule, - PopupTooltipModule, DialogModule, ConfirmDialogModule, CheckboxModule, @@ -46,13 +42,12 @@ import { CropListComponent } from './crop/crop-list/crop-list.component'; InputSwitchModule, SplitButtonModule, TableModule, - MessagesModule, StoreModule.forFeature(FEATURE_KEY, reducers), EffectsModule.forFeature([PilotEffects, ProductEffects, VehicleEffects, CropEffects]), EntitiesRoutingModule ], - declarations: [EntitiesMgtComponent, ProductListComponent, PilotListComponent, VehicleListComponent, PilotEditComponent, VehicleEditComponent, VehiclePartnerIntegrationComponent, CropListComponent], + declarations: [EntitiesMgtComponent, ProductListComponent, PilotListComponent, VehicleListComponent, PilotEditComponent, VehicleEditComponent, CropListComponent], providers: [PilotService, PilotResolver, VehicleService, CropService, VehicleResolver], schemas: [ CUSTOM_ELEMENTS_SCHEMA diff --git a/Development/client/src/app/entities/models/vehicle.model.ts b/Development/client/src/app/entities/models/vehicle.model.ts index 3eb50cc..a89acd7 100644 --- a/Development/client/src/app/entities/models/vehicle.model.ts +++ b/Development/client/src/app/entities/models/vehicle.model.ts @@ -1,94 +1,18 @@ import { createNewUser, User } from '@app/accounts/models/user.model'; -import { RoleIds, SourceSystemType, OperationalStatusType, SystemOrPartnerType } from '@app/shared/global'; +import { RoleIds } from '@app/shared/global'; export interface Vehicle extends User { vehicleType: number; - tailNumber?: string; // Common tail number field for all aircraft unitId?: string; orgUnitId?: string; // used for unique validation at clientSide only model?: string; desc?: string; color?: string; - tracking?: boolean; - trackonDate?: Date; - pkgActive?: boolean; - pkgActiveDate?: Date; - - // Partner integration properties (legacy - for frontend compatibility) - partnerSystem?: SourceSystemType; // System identifier - partnerAircraftId?: string; // Partner system's aircraft ID - partnerAircraftData?: PartnerAircraftData; - - // Backend-compatible partner info structure (matches backend schema) - partnerInfo?: { - partner?: string; // Partner ObjectId reference - partnerAircraftId?: string; // Partner aircraft/vehicle ID in external system - systemType?: string; // System type for agnav native systems (platinum, titanium, g4, etc.) - // NEW: Direct partner identification fields (from assignments_post response) - name?: string; // Partner display name (e.g., "satloc") - partnerCode?: string; // Partner code identifier (e.g., "SATLOC") - top-level for assignments_post - metadata?: { - partnerSystem?: string; // Partner system name - partnerCode?: string; // Partner code identifier (legacy nested location) - aircraftData?: any; // Aircraft data from partner system - syncStatus?: OperationalStatusType; - lastSync?: string | null; // ISO date string - connectionStatus?: OperationalStatusType; - }; - }; -} - -// Aircraft Assignment Item interface for job assignment pickList -export interface AircraftAssignmentItem { - _id: string; - name: string; - username?: string; // Aircraft account username for AgNav aircraft - active: boolean; - pkgActive?: boolean; - tailNumber?: string; - // Partner system information derived from partnerInfo - partnerSystem?: SystemOrPartnerType; - sourceSystem?: SystemOrPartnerType; // System identifier for UI display and sorting - // Partner information - partnerId?: string; // Partner ID from partnerInfo.partner - partnerName?: string; // Partner name resolved from partner service - // Partner authentication validation state - authValidation?: { - isValidating: boolean; - authenticationValid: boolean; - accountExists: boolean; - validationError: string | null; - canMoveToTarget: boolean; - }; - partnerCode?: string; // Partner code for display - satlocData?: { - satlocId?: string; - tailNumber: string; - aircraftType?: string; - lastSync?: Date; - syncStatus: OperationalStatusType; - }; -} - -export interface PartnerAircraftData { - id: string; - tailNumber: string; - partnerSystem: string; - syncStatus?: OperationalStatusType; - lastSync?: Date; - connectionStatus?: OperationalStatusType; -} - -export interface StatusChange { - ids: { [i: string]: string[] }; - type: string; - deActivate?: { [i: string]: boolean }; } export const createNewVehicle = (parentId: string) => { const vehicle = createNewUser(parentId, RoleIds.DEVICE); vehicle.vehicleType = 0; - vehicle.tailNumber = ''; return vehicle; } diff --git a/Development/client/src/app/entities/pilot/pilot-list/pilot-list.component.html b/Development/client/src/app/entities/pilot/pilot-list/pilot-list.component.html index 34ed7c1..ad8c845 100644 --- a/Development/client/src/app/entities/pilot/pilot-list/pilot-list.component.html +++ b/Development/client/src/app/entities/pilot/pilot-list/pilot-list.component.html @@ -24,7 +24,6 @@ - {{col.header}} {{ resolveFieldData(rowData, col.field) }} diff --git a/Development/client/src/app/entities/pilot/pilot-list/pilot-list.component.spec.ts b/Development/client/src/app/entities/pilot/pilot-list/pilot-list.component.spec.ts new file mode 100644 index 0000000..cce177e --- /dev/null +++ b/Development/client/src/app/entities/pilot/pilot-list/pilot-list.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { PilotListComponent } from './pilot-list.component'; + +describe('PilotListComponent', () => { + let component: PilotListComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ PilotListComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(PilotListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/Development/client/src/app/entities/product/product-list/product-list.component.html b/Development/client/src/app/entities/product/product-list/product-list.component.html index 6364ca3..63b7d66 100644 --- a/Development/client/src/app/entities/product/product-list/product-list.component.html +++ b/Development/client/src/app/entities/product/product-list/product-list.component.html @@ -24,10 +24,9 @@ - {{col.header}} - {{ getRate(rowData[col.field]) }} - {{ rowData[col.field] | productType }} - {{ yesOrNo(rowData[col.field]) }} +
{{ getRate(rowData[col.field]) }}
+
{{ rowData[col.field] | productType }}
+
{{ yesOrNo(rowData[col.field]) }}
{{ rowData[col.field] }} diff --git a/Development/client/src/app/entities/reducers/crops.reducer.ts b/Development/client/src/app/entities/reducers/crops-reducer.ts similarity index 100% rename from Development/client/src/app/entities/reducers/crops.reducer.ts rename to Development/client/src/app/entities/reducers/crops-reducer.ts diff --git a/Development/client/src/app/entities/reducers/index.ts b/Development/client/src/app/entities/reducers/index.ts index fa8719a..37a303a 100644 --- a/Development/client/src/app/entities/reducers/index.ts +++ b/Development/client/src/app/entities/reducers/index.ts @@ -5,10 +5,10 @@ import { } from '@ngrx/store'; // import * as fromRoot from '../../reducers'; -import * as fromPilots from './pilots.reducer'; -import * as fromProducts from './products.reducer'; -import * as fromVehicles from './vehicles.reducer'; -import * as fromCrops from './crops.reducer'; +import * as fromPilots from './pilots-reducer'; +import * as fromProducts from './products-reducer'; +import * as fromVehicles from './vehicles-reducer'; +import * as fromCrops from './crops-reducer'; export const FEATURE_KEY = 'Entities'; diff --git a/Development/client/src/app/entities/reducers/pilots.reducer.ts b/Development/client/src/app/entities/reducers/pilots-reducer.ts similarity index 100% rename from Development/client/src/app/entities/reducers/pilots.reducer.ts rename to Development/client/src/app/entities/reducers/pilots-reducer.ts diff --git a/Development/client/src/app/entities/reducers/products.reducer.ts b/Development/client/src/app/entities/reducers/products-reducer.ts similarity index 100% rename from Development/client/src/app/entities/reducers/products.reducer.ts rename to Development/client/src/app/entities/reducers/products-reducer.ts diff --git a/Development/client/src/app/entities/reducers/vehicles.reducer.ts b/Development/client/src/app/entities/reducers/vehicles-reducer.ts similarity index 86% rename from Development/client/src/app/entities/reducers/vehicles.reducer.ts rename to Development/client/src/app/entities/reducers/vehicles-reducer.ts index 96c4687..834d990 100644 --- a/Development/client/src/app/entities/reducers/vehicles.reducer.ts +++ b/Development/client/src/app/entities/reducers/vehicles-reducer.ts @@ -1,4 +1,5 @@ import { createEntityAdapter, EntityAdapter, EntityState } from '@ngrx/entity'; + import * as actions from '../actions/vehicle.actions'; import { Vehicle } from '../models/vehicle.model'; @@ -40,7 +41,7 @@ export function reducer( case actions.FETCH_SUCCESS: { const selectedId = (state.entities && typeof state.entities[state.selectedId] === 'undefined') ? null : state.selectedId; return adapter.addMany(action.payload, { - ...adapter.removeAll(state), + ...adapter.removeAll(state), // clear previous items selectedId: selectedId, loading: false, loaded: true @@ -89,10 +90,6 @@ export function reducer( loading: false }; - case actions.UPDATE_VEHICLES_SUCCESS: { - const selectedId = (state.entities && typeof state.entities[state.selectedId] === 'undefined') ? null : state.selectedId; - return adapter.addMany(action.payload.vehicles, {...adapter.removeAll(state), selectedId: selectedId, loading: false, loaded: true}); - } default: return state; } diff --git a/Development/client/src/app/entities/vehicle-resolver.service.ts b/Development/client/src/app/entities/vehicle-resolver.service.ts index c26c3dd..50f1a30 100644 --- a/Development/client/src/app/entities/vehicle-resolver.service.ts +++ b/Development/client/src/app/entities/vehicle-resolver.service.ts @@ -6,6 +6,7 @@ import { Vehicle, createNewVehicle } from './models/vehicle.model'; import { VehicleService } from '../domain/services/vehicle.service'; import { AuthService } from '../domain/services/auth.service'; import { map, first } from 'rxjs/operators'; +import { createNewUser } from '../accounts/models/user.model'; @Injectable() export class VehicleResolver implements Resolve { diff --git a/Development/client/src/app/entities/vehicle/vehicle-edit/vehicle-edit.component.css b/Development/client/src/app/entities/vehicle/vehicle-edit/vehicle-edit.component.css deleted file mode 100644 index 9510c8e..0000000 --- a/Development/client/src/app/entities/vehicle/vehicle-edit/vehicle-edit.component.css +++ /dev/null @@ -1,155 +0,0 @@ -/* Partner Integration Styles */ - -.partner-aircraft-section { - padding: 12px; - border: 1px solid #bdbdbd; - /* dividerColor - AgMission borders */ - border-radius: 4px; - background-color: #ffffff; - /* contentBgColor - AgMission content background */ -} - -.partner-aircraft-section h4 { - color: #212121; - /* textColor - AgMission primary text */ - font-size: 1rem; - font-weight: 600; -} - -/* Tail Number Constraint Message Styling */ -.md-inputfield+agm-constraint-message { - margin-top: 8px; -} - -.loading-indicator { - display: flex; - align-items: center; - color: #03A9F4; - /* blue - AgMission info color */ - margin-bottom: 8px; -} - -.loading-indicator i { - margin-right: 6px; -} - -.aircraft-id { - color: #757575; - /* textSecondaryColor - AgMission secondary text */ - font-size: 0.85em; -} - -.no-aircraft-message { - display: flex; - align-items: center; - padding: 10px; - background-color: #E1F5FE; - /* Light blue background for info messages */ - border: 1px solid #03A9F4; - /* blue - AgMission info border */ - border-radius: 4px; - color: #0277BD; - /* blueHover - AgMission darker blue for text */ - margin-bottom: 8px; -} - -.no-aircraft-message i { - margin-right: 6px; - font-size: 1.1em; -} - -.selected-aircraft-info { - padding: 12px; - background-color: #ffffff; - /* contentBgColor - AgMission content background */ - border: 1px solid #bdbdbd; - /* dividerColor - AgMission borders */ - border-radius: 4px; -} - -.selected-aircraft-info h5 { - color: #212121; - /* textColor - AgMission primary text */ - font-size: 0.95rem; -} - -.status-badge { - display: inline-flex; - align-items: center; - padding: 4px 8px; - border-radius: 4px; - font-weight: 500; - font-size: 0.875rem; -} - -.status-ready { - background-color: #E8F5E8; - /* Light green background */ - color: #2E7D32; - /* primaryDarkColor - AgMission dark green */ - border: 1px solid #4CAF50; - /* primaryColor - AgMission main green */ -} - -.status-error { - background-color: #FFEBEE; - /* Light red background */ - color: #C62828; - /* redHover - AgMission dark red */ - border: 1px solid #F44336; - /* red - AgMission error color */ -} - -.status-loading { - background-color: #FFF8E1; - /* Light amber background */ - color: #FF8F00; - /* amberHover - AgMission dark amber */ - border: 1px solid #FFC107; - /* amber - AgMission warning color */ -} - -.error-message { - margin-top: 8px; -} - -.form-row { - margin-bottom: 15px; -} - -/* Input with inline constraint message */ -.input-with-inline-constraint { - display: flex; - align-items: flex-start; - gap: 6px; - /* AgMission standard spacing */ -} - -.input-with-inline-constraint .md-inputfield { - flex: 1; - /* Input takes remaining space */ -} - -/* Inline constraint beside input - vertically aligned with input field center */ -.input-with-inline-constraint .inline-constraint { - margin-top: -2px; - /* Shift icon upward to align with input box vertical center */ -} - -.input-with-inline-constraint .inline-constraint ::ng-deep .agm-constraint-wrapper { - display: inline-block; - width: auto; - vertical-align: middle; -} - -/* Responsive design */ -@media (max-width: 768px) { - .partner-aircraft-section { - padding: 10px; - } - - /* Stack input and icon vertically on very small screens if needed */ - .input-with-inline-constraint { - flex-wrap: wrap; - } -} \ No newline at end of file diff --git a/Development/client/src/app/entities/vehicle/vehicle-edit/vehicle-edit.component.html b/Development/client/src/app/entities/vehicle/vehicle-edit/vehicle-edit.component.html index b19a726..95cfdab 100644 --- a/Development/client/src/app/entities/vehicle/vehicle-edit/vehicle-edit.component.html +++ b/Development/client/src/app/entities/vehicle/vehicle-edit/vehicle-edit.component.html @@ -6,10 +6,8 @@
- - Aircraft Name is required + + Aircraft Name is required
@@ -17,8 +15,7 @@ Aircraft Type: - + {{ type.label }} @@ -26,12 +23,6 @@
-
- - -
@@ -40,38 +31,11 @@
-
- - - - - - - - - -
-
- - -
- -
- -
- - UnitId must be 10-15 digits - + + UnitId must be 10-15 digits + {{ globals.apiErrorMsg(unitId.errors?.unitIdUnique) }} @@ -89,8 +53,7 @@ Color: - +
{{item.label}} @@ -103,60 +66,16 @@
- -
- +
+
- - -
-
- {{ Labels.VEHICLE_ACTIVATION }} -
- - -
- - -
- - -
-
-
- - -
- -
- - -
- - -
-
- + - + - +
diff --git a/Development/client/src/app/entities/vehicle/vehicle-edit/vehicle-edit.component.ts b/Development/client/src/app/entities/vehicle/vehicle-edit/vehicle-edit.component.ts index d0b8222..f60956e 100644 --- a/Development/client/src/app/entities/vehicle/vehicle-edit/vehicle-edit.component.ts +++ b/Development/client/src/app/entities/vehicle/vehicle-edit/vehicle-edit.component.ts @@ -1,115 +1,54 @@ -import { Component, OnInit, ViewChild, ElementRef, AfterViewInit, OnDestroy, ChangeDetectorRef } from '@angular/core'; +import { Component, OnInit, ViewChild, ElementRef, AfterViewInit, OnDestroy } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { SelectItem } from 'primeng/api'; import { Vehicle } from '../../models/vehicle.model'; import * as vehicleActions from '../../actions/vehicle.actions'; + import { StringUtils } from '@app/shared/utils'; import { AccountEditorComponent } from '@app/shared/account-editor/account-editor.component'; -import { ConstraintMessageComponent } from '@app/shared/constraint-message/constraint-message.component'; -import { globals, VehType, vehTypes, SystemTypes, SourceSystem, OperationalStatus, Labels } from '@app/shared/global'; +import { globals, VehType, vehTypes } from '@app/shared/global'; +import { SelectItem } from 'primeng/api'; import { BaseComp } from '@app/shared/base/base.component'; -import { selectLimit } from '@app/reducers'; -import { Limit } from '@app/domain/models/subscription.model'; -import { SubKeys, SubType } from '@app/profile/common'; -import { PartnerIntegrationData, VehiclePartnerIntegrationComponent } from '../vehicle-partner-integration/vehicle-partner-integration.component'; - -// ============================================================================ -// COMPONENT -// ============================================================================ @Component({ selector: 'agm-vehicle-edit', templateUrl: './vehicle-edit.component.html', - styleUrls: ['./vehicle-edit.component.css'] + styles: [] }) export class VehicleEditComponent extends BaseComp implements OnInit, AfterViewInit, OnDestroy { - - // ============================================================================ - // CONSTANTS & READONLY PROPERTIES - // ============================================================================ - - readonly globals = globals; - readonly SourceSystem = SourceSystem; - readonly Labels = Labels; - - // ============================================================================ - // CORE VEHICLE PROPERTIES - // ============================================================================ + readonly globals = globals; selectedItem: Vehicle; orgUnitId: string; - - // Core vehicle form options acTypes: SelectItem[]; acColors: SelectItem[]; - // Partner integration state (managed by child component) - private partnerData: PartnerIntegrationData | null = null; - partnerValidationState: boolean = true; // Default to valid for basic aircraft - - // Return message handling from account-edit - connectionTestMessage: string | null = null; - connectionTestSuccess: boolean | null = null; - pendingAuthenticationSuccess: boolean = false; // Flag to update partner auth state after ViewInit - - // ============================================================================ - // VIEW CHILDREN & UI STATE - // ============================================================================ - @ViewChild('vehicleName') vehicleName: ElementRef; @ViewChild('account') accEditor: AccountEditorComponent; - @ViewChild('partnerIntegration') partnerIntegration: VehiclePartnerIntegrationComponent; - @ViewChild('tailNumberConstraint') tailNumberConstraint: ConstraintMessageComponent; - hasTracking: boolean; - - // ============================================================================ - // VEHICLE MANAGEMENT PROPERTIES - // ============================================================================ private _vehicle: Vehicle; - private _isNew: boolean; - - get vehicle(): Vehicle { - return this._vehicle; - } - + get vehicle(): Vehicle { return this._vehicle; } set vehicle(vehicle: Vehicle) { this._vehicle = vehicle; - this.selectedItem = Object.assign({}, vehicle); + this.selectedItem = Object.assign({}, vehicle); // create a clone object to work on the editor - // For new vehicles, ensure active defaults to true - // Check vehicle._id directly since _isNew is set later - if (vehicle._id === '0') { - // For new vehicles, active should always default to true - this.selectedItem.active = true; - } - - if (!this.isNew && this.selectedItem.unitId) { + if (!this.isNew && this.selectedItem.unitId) this.orgUnitId = this.selectedItem.unitId; - } } + private _isNew: boolean; get isNew(): boolean { return this._isNew; } get user() { - return this.selectedItem.username ? - { username: this.selectedItem.username, password: this.selectedItem.password } : - null; + return this.selectedItem.username ? ({ username: this.selectedItem.username, password: this.selectedItem.password }) : null; } - // ============================================================================ - // CONSTRUCTOR - // ============================================================================ - constructor( - private readonly route: ActivatedRoute, - private readonly cdr: ChangeDetectorRef + private readonly route: ActivatedRoute, ) { super(); - this.acTypes = [ { label: vehTypes[VehType.FIXEDSWING], value: VehType.FIXEDSWING }, { label: vehTypes[VehType.HELICOPTER], value: VehType.HELICOPTER } @@ -121,84 +60,30 @@ export class VehicleEditComponent extends BaseComp implements OnInit, AfterViewI { label: globals.lime, value: 'lime' }, { label: globals.yellow, value: 'yellow' }, { label: globals.orange, value: 'orange' }, - { label: globals.purple, value: 'purple' } + { label: globals.purple, value: 'purple' }, ]; } - // ============================================================================ - // LIFECYCLE METHODS - // ============================================================================ - ngOnInit() { - // Handle query parameters for return navigation messages - this.sub$ = this.route.queryParams.subscribe(params => { - if (params['connectionTestResult']) { - this.connectionTestSuccess = params['connectionTestResult'] === 'success'; - this.connectionTestMessage = params['message']; - - if (this.connectionTestSuccess) { - console.log('Account authentication successful:', this.connectionTestMessage); - // Set flag to update partner auth state after ViewInit - this.pendingAuthenticationSuccess = true; - // Optionally show success message to user - if (this.msgSvc) { - this.msgSvc.addSuccessMsg(this.connectionTestMessage); - } - } else { - console.error('Account authentication failed:', this.connectionTestMessage); - // Show error message to user - if (this.msgSvc) { - this.msgSvc.addFailedMsg(this.connectionTestMessage); - } + this.sub$ = this.route.data + .subscribe((data) => { + const vehicle = data[0] as Vehicle || null; + if (vehicle) { + this.vehicle = vehicle; + this._isNew = (this.vehicle._id === '0'); } - - // Clear query parameters to prevent message from showing again - // Wait longer (15 seconds) to allow partner aircraft API call to complete - // Navigation during API call can cancel the HTTP request - setTimeout(() => { - this.router.navigate([], { - relativeTo: this.route, - queryParams: {}, - replaceUrl: true - }); - }, 15000); // Wait 15 seconds for API call to complete - } - }); - - // Route data subscription - this.sub$.add(this.route.data.subscribe((data) => { - const vehicle = data[0] as Vehicle || null; - if (vehicle) { - this.vehicle = vehicle; - this._isNew = (this.vehicle._id === '0'); - } - })); - - // Vehicle actions subscription - this.sub$.add( - this.appActions.ofTypes([vehicleActions.CREATE_SUCCESS, vehicleActions.UPDATE_SUCCESS]) - .subscribe((action) => { - const savedVehicle = action['payload']; - this.store.dispatch(new vehicleActions.Select(savedVehicle)); - this.goBack(savedVehicle); - }) - ); - - // Tracking subscription - this.sub$.add( - this.store.select(selectLimit(SubType.ADDON)) - .subscribe((addon) => { - const tracking: Limit = addon?.[SubKeys.TRACKING]; - this.hasTracking = tracking?.airCraft?.numOfVehicle > 0; - }) - ); + }); + this.sub$.add(this.appActions.ofTypes([vehicleActions.CREATE_SUCCESS, vehicleActions.UPDATE_SUCCESS]) + .subscribe((action) => { + this.store.dispatch(new vehicleActions.Select(action['payload'])); + this.goBack(); + })); } ngAfterViewInit(): void { - // Auto-focus vehicle name field for new vehicles const timer = setInterval(() => { if (this.selectedItem && StringUtils.isEmpty(this.selectedItem.name)) { - if (this.vehicleName && this.vehicleName.nativeElement) { + if (this.vehicleName.nativeElement) { this.vehicleName.nativeElement.focus(); clearInterval(timer); } @@ -206,387 +91,23 @@ export class VehicleEditComponent extends BaseComp implements OnInit, AfterViewI clearInterval(timer); } }, 500); - setTimeout(() => clearInterval(timer), 1500); - - // Handle pending authentication success from query params - if (this.pendingAuthenticationSuccess && this.partnerIntegration) { - console.log('Applying pending authentication success to partner integration'); - this.partnerIntegration.updateAuthenticationSuccess(); - this.pendingAuthenticationSuccess = false; - } - - // Check for stored form data from Account Does Not Exist flow and restore it - if (this.partnerIntegration) { - const storedFormData = this.partnerIntegration.getStoredFormData(); - if (storedFormData) { - console.log('Restoring vehicle form data after account creation:', storedFormData); - this.restoreFormData(storedFormData); - } - } + setTimeout(() => { clearInterval(timer); }, 1500); } - ngOnDestroy() { - super.ngOnDestroy(); - } - - // ============================================================================ - // PARTNER INTEGRATION EVENT HANDLERS - // ============================================================================ - - onPartnerDataChange(partnerData: PartnerIntegrationData): void { - this.partnerData = partnerData; - - // Update vehicle tail number if partner aircraft is selected - if (partnerData.tailNumber) { - this.selectedItem.tailNumber = partnerData.tailNumber; - } - } - - onPartnerValidationStateChange(isValid: boolean): void { - this.partnerValidationState = isValid; - } - - /** - * Get constraint message details when partner validation is invalid - */ - getPartnerConstraintDetails(): { title: string; message: string } | null { - // Only show partner-specific constraint messages - // Basic form validation (like aircraft name) is handled by Angular forms - - // Check partner validation state - if (!this.partnerValidationState && this.partnerIntegration) { - const integration = this.partnerIntegration; - - // If partner system selected, check integration requirements - if (integration.isPartnerSystemSelected) { - // If partner validation failed - if (!integration.partnerValidation.accountExists || !integration.partnerValidation.authenticationValid) { - return null; // These are handled by the partner integration component - } - - // If no aircraft selected - if (!integration.selectedPartnerAircraft) { - return { - title: this.Labels.AIRCRAFT_SELECTION_REQUIRED_TITLE, - message: this.Labels.AIRCRAFT_SELECTION_REQUIRED_MESSAGE - }; - } - - // If Satloc partner and no system type selected - if (integration.isSatlocPartnerSelected && !integration.selectedSystemType) { - return { - title: this.Labels.SYSTEM_TYPE_REQUIRED_TITLE, - message: this.Labels.SYSTEM_TYPE_REQUIRED_MESSAGE - }; - } - - // General integration incomplete message - return { - title: this.Labels.PARTNER_INTEGRATION_INCOMPLETE_TITLE, - message: this.Labels.PARTNER_INTEGRATION_INCOMPLETE_MESSAGE - }; - } - } - - return null; - } - - /** - * Check if account fields are incomplete (username/password missing) - */ - isAccountIncomplete(): boolean { - if (!this.accEditor) { - // If no account editor, consider account incomplete - return true; - } - - const accountValue = this.accEditor.value; - if (!accountValue) { - return true; - } - - const hasUsername = accountValue?.username && accountValue.username.trim() !== ''; - const hasPassword = accountValue?.password && accountValue.password.trim() !== ''; - const isActive = accountValue?.active === true; - - // Account is incomplete if username, password, or active status is missing - return !hasUsername || !hasPassword || !isActive; - } - - /** - * Get appropriate severity for constraint message - */ - getConstraintSeverity(constraintDetails: { title: string; message: string }): string { - // Account incomplete is informational (allows saving) - if (constraintDetails.title === this.Labels.ACCOUNT_INCOMPLETE_TITLE) { - return 'info'; - } - // All other constraints are warnings (block saving) - return 'warning'; - } - - /** - * Get appropriate icon for constraint message - */ - getConstraintIcon(constraintDetails: { title: string; message: string }): string { - // Account incomplete uses info icon - if (constraintDetails.title === this.Labels.ACCOUNT_INCOMPLETE_TITLE) { - return 'pi-info-circle'; - } - // All other constraints use warning icon (using ui-icon-warning as pi-exclamation-triangle has rendering issues) - return 'ui-icon-warning'; - } - - /** - * Get current account editor data for form data preservation - * This method is called by the vehicle-partner-integration component - * when navigating to account creation - */ - getAccountEditorData(): any { - if (this.accEditor && this.accEditor.valid) { - return this.accEditor.value; - } - return null; - } - - // ============================================================================ - // VEHICLE SAVE OPERATIONS - // ============================================================================ - saveVehicle() { if (this.accEditor) { const acc = this.accEditor.value; this.selectedItem.username = acc.username; this.selectedItem.password = acc.password; - this.selectedItem.active = acc.active; } - - if (this.selectedItem?.tracking && !this.selectedItem?.unitId) { - this.selectedItem.tracking = false; - } - - this.preparePartnerDataForBackend(); - this.store.dispatch(this._isNew ? - new vehicleActions.Create(this.selectedItem) : - new vehicleActions.Update(this.selectedItem) - ); + this.store.dispatch(this._isNew ? new vehicleActions.Create(this.selectedItem) : new vehicleActions.Update(this.selectedItem)); } - private preparePartnerDataForBackend(): void { - if (this.partnerData && this.partnerData.selectedPartner && this.partnerData.selectedPartner !== SourceSystem.AGNAV && this.partnerData.selectedPartnerData) { - this.selectedItem.partnerInfo = { - partner: this.partnerData.selectedPartnerData._id!, - partnerAircraftId: this.partnerData.selectedPartnerAircraft || null, - systemType: this.partnerData.systemType || SystemTypes.PLATINUM, // Include system type from partner integration - metadata: { - partnerSystem: this.partnerData.selectedPartnerData.name, - partnerCode: this.partnerData.selectedPartnerData.partnerCode, - aircraftData: this.partnerData.selectedPartnerAircraftDetails, - syncStatus: this.partnerData.selectedPartnerAircraftDetails ? OperationalStatus.PENDING : null, - lastSync: null, - connectionStatus: OperationalStatus.CONNECTED - } - }; - - if (this.partnerData.selectedPartnerAircraftDetails?.tailNumber) { - this.selectedItem.tailNumber = this.partnerData.selectedPartnerAircraftDetails.tailNumber; - } - - // Clean up legacy properties - delete this.selectedItem.partnerSystem; - delete this.selectedItem.partnerAircraftId; - delete this.selectedItem.partnerAircraftData; - } else { - this.selectedItem.partnerInfo = { - partner: null, - partnerAircraftId: null, - systemType: this.partnerData?.systemType || SystemTypes.PLATINUM, // Preserve system type even for AgNav - metadata: null - }; - - // Clean up legacy properties - delete this.selectedItem.partnerSystem; - delete this.selectedItem.partnerAircraftId; - delete this.selectedItem.partnerAircraftData; - } + goBack() { + this.router.navigate(['/entities/aircraft/', { id: this.vehicle._id }]); } - goBack(savedVehicle?: any) { - // Use savedVehicle if provided (from CREATE_SUCCESS), otherwise use this.vehicle - const vehicleToCheck = savedVehicle || this.vehicle; - - // If this was a newly created vehicle that was successfully saved, add query param to show tooltip - const vehicleSuccessfullySaved = vehicleToCheck?._id && vehicleToCheck._id !== '0'; - - if (this.isNew && vehicleSuccessfullySaved) { - this.router.navigate(['/entities/aircraft'], { - queryParams: { newVehicleCreated: vehicleToCheck._id } - }); - } else { - this.router.navigate(['/entities/aircraft']); - } + ngOnDestroy() { + super.ngOnDestroy(); } - - // ============================================================================ - // FORM DATA RESTORATION HELPERS - // ============================================================================ - - /** - * Restore vehicle form data after Account Does Not Exist flow - * @param formData The stored form data to restore - */ - private restoreFormData(formData: any): void { - if (!formData || !this.selectedItem) { - return; - } - - try { - // Restore basic vehicle properties - if (formData.name) this.selectedItem.name = formData.name; - if (formData.vehicleType !== undefined) this.selectedItem.vehicleType = formData.vehicleType; - if (formData.model) this.selectedItem.model = formData.model; - if (formData.tailNumber) this.selectedItem.tailNumber = formData.tailNumber; - if (formData.unitId) this.selectedItem.unitId = formData.unitId; - if (formData.desc) this.selectedItem.desc = formData.desc; - if (formData.color) this.selectedItem.color = formData.color; - - // Restore account credentials to vehicle object (for backward compatibility) - if (formData.username) this.selectedItem.username = formData.username; - if (formData.password) this.selectedItem.password = formData.password; - if (formData.active !== undefined) this.selectedItem.active = formData.active; - - // Restore account editor form data if available and component is ready - if (formData.accountEditor && this.accEditor) { - // Use a timeout to ensure the account editor is fully initialized - setTimeout(() => { - if (this.accEditor) { - this.accEditor.writeValue(formData.accountEditor); - console.log('Restored account editor data:', formData.accountEditor); - } - }, 100); - } - - // Trigger change detection to update the UI - this.cdr.detectChanges(); - - console.log('Successfully restored vehicle form data'); - } catch (error) { - console.error('Error restoring form data:', error); - } - } - - // ============================================================================ - // COMPUTED PROPERTIES - // ============================================================================ - - get canActivateVehicle() { - return this.authSvc.canActivateVehicle; - } - - get isPartnerSystemSelected(): boolean { - return this.partnerData?.selectedPartner !== null && this.partnerData?.selectedPartner !== SourceSystem.AGNAV; - } - - get canEditPartnerFields(): boolean { - return this.partnerData?.partnerValidation?.accountExists && - this.partnerData?.partnerValidation?.authenticationValid && - !this.partnerData?.partnerValidation?.isValidating; - } - - get canEditBasicFields(): boolean { - return !this.partnerData?.partnerValidation?.isValidating; - } - - get canSaveVehicle(): boolean { - if (!this.selectedItem?.name || this.selectedItem.name.trim() === '') { - return false; - } - - const basicFieldsValid = this.selectedItem?.name?.trim() && - this.selectedItem?.model && - this.selectedItem?.vehicleType !== undefined; - - // Check account editor validity - const accountValid = !this.accEditor || this.accEditor.form?.valid; - - if (!this.isPartnerSystemSelected) { - return basicFieldsValid && accountValid; - } - - if (!this.partnerData?.partnerValidation?.accountExists || !this.partnerData?.partnerValidation?.authenticationValid) { - return basicFieldsValid && accountValid; - } - - return basicFieldsValid && accountValid && !!this.partnerData?.selectedPartnerAircraft; - } - - get saveButtonTooltip(): string { - if (this.isPartnerSystemSelected) { - if (!this.partnerData?.partnerValidation?.accountExists) { - return Labels.SAVE_TOOLTIP_NO_ACCOUNT; - } - if (!this.partnerData?.partnerValidation?.authenticationValid) { - return Labels.SAVE_TOOLTIP_AUTH_FAILED; - } - const partnerName = this.partnerData?.selectedPartnerData?.name || Labels.GENERIC_PARTNER; - return `${Labels.SAVE_TOOLTIP_BASE_MESSAGE} ${partnerName} ${Labels.SAVE_TOOLTIP_INTEGRATION_SUFFIX}`; - } - return Labels.SAVE_TOOLTIP_NATIVE; - } - - get partnerSystemName(): string { - return this.partnerData?.selectedPartnerData?.name || 'partner system'; - } - - /** - * Check if account editor is valid - * For partner systems, account editor is hidden, so validation is skipped - */ - get isAccountValid(): boolean { - if (this.isPartnerSystemSelected) { - return true; // No account editor for partner systems - } - return !this.accEditor || this.accEditor.form?.valid; - } - - // ============================================================================ - // PARTNER VEHICLE ACTIVATION METHODS - // ============================================================================ - - /** - * Determines if a partner system vehicle can be activated - * Requires: partner account exists, authentication valid, and aircraft selected - */ - canActivatePartnerVehicle(): boolean { - if (!this.isPartnerSystemSelected) { - return false; - } - - return this.partnerData?.partnerValidation?.accountExists === true && - this.partnerData?.partnerValidation?.authenticationValid === true && - !!this.partnerData?.selectedPartnerAircraft; - } - - /** - * Returns constraint message explaining why partner vehicle cannot be activated - */ - getPartnerActivationConstraintMessage(): string { - if (!this.isPartnerSystemSelected) { - return ''; - } - - if (!this.partnerData?.partnerValidation?.accountExists) { - return Labels.PARTNER_ACCOUNT_REQUIRED_FOR_ACTIVATION; - } - - if (!this.partnerData?.partnerValidation?.authenticationValid) { - return Labels.PARTNER_AUTH_REQUIRED_FOR_ACTIVATION; - } - - if (!this.partnerData?.selectedPartnerAircraft) { - return Labels.PARTNER_AIRCRAFT_REQUIRED_FOR_ACTIVATION; - } - - return ''; - } -} \ No newline at end of file +} diff --git a/Development/client/src/app/entities/vehicle/vehicle-list/vehicle-list.component.css b/Development/client/src/app/entities/vehicle/vehicle-list/vehicle-list.component.css index b824541..e69de29 100644 --- a/Development/client/src/app/entities/vehicle/vehicle-list/vehicle-list.component.css +++ b/Development/client/src/app/entities/vehicle/vehicle-list/vehicle-list.component.css @@ -1,292 +0,0 @@ -.highlight-btn { - background-color: #4CAF50; - /* $primaryColor */ -} - -/* Custom Aircraft Review Message - Enhanced UX Layout */ -.aircraft-review-container { - background-color: #FFF8E1; - /* Warning background - AgMission amber light */ - border: 1px solid #FFC107; - /* $amber */ - border-radius: 6px; - margin: 0.75rem 0; - padding: 1rem 1.25rem; - display: flex; - align-items: flex-start; - /* Top-align for better text flow */ - gap: 0.875rem; - /* Optimized spacing for visual hierarchy */ - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); - /* Subtle depth */ -} - -.aircraft-review-container .pi-info-circle { - color: #FF8F00 !important; - /* $amberHover - dark warning color for good contrast */ - font-size: 1.375em !important; - flex-shrink: 0; - margin-top: 0.125rem; - /* Optical alignment with text baseline */ - line-height: 1; -} - -.aircraft-review-message { - color: #212121 !important; - /* $textColor - high contrast text */ - font-weight: 500; - font-size: 1rem; - line-height: 1.4; - /* Optimized line height for readability */ - flex: 1; - margin: 0; - /* Remove default margins for precise control */ -} - -/* Custom Generic Error Message - Enhanced UX Layout */ -.generic-error-container { - background-color: #FFEBEE; - /* Error background - AgMission red light */ - border: 1px solid #F44336; - /* $red */ - border-radius: 6px; - margin: 0.75rem 0; - padding: 1rem 1.25rem; - display: flex; - align-items: flex-start; - /* Top-align for better text flow */ - gap: 0.875rem; - /* Consistent spacing with warning */ - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); - /* Subtle depth */ -} - -.generic-error-container .pi-exclamation-triangle { - color: #C62828 !important; - /* $redHover - dark error red for good contrast */ - font-size: 1.375em !important; - flex-shrink: 0; - margin-top: 0.125rem; - /* Optical alignment with text baseline */ - line-height: 1; -} - -.generic-error-message { - color: #C62828 !important; - /* $redHover - dark error text for contrast */ - font-weight: 500; - font-size: 1rem; - line-height: 1.4; - /* Consistent line height */ - flex: 1; - margin: 0; - /* Remove default margins for precise control */ -} - -/* Generic Message Enhanced Styling */ -:host ::ng-deep generic-message { - display: block; - margin: 0.5rem 0; -} - -:host ::ng-deep generic-message .icon-message { - display: flex; - align-items: center; - justify-content: center; - gap: 0.75rem; - padding: 0.75rem; -} - -/* Button Styling for Aircraft Review */ -:host ::ng-deep generic-message .amber-btn { - background-color: #FFC107; - /* $amber */ - border-color: #FF8F00; - /* $amberHover */ - color: #212121; - /* $textColor for good contrast on yellow */ - font-weight: 600; - padding: 0.75rem 1.5rem; - min-height: 44px; - /* Accessibility: Touch target size */ - border-radius: 4px; - transition: all 0.2s ease; -} - -:host ::ng-deep generic-message .amber-btn:hover { - background-color: #FF8F00; - /* $amberHover */ - border-color: #f9a825; - /* $accentLightColor */ - transform: translateY(-1px); - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); -} - -/* System Type Display Styles */ -.system-type-display { - display: inline-flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; - min-height: 44px; - /* Accessibility: Larger touch targets */ -} - -/* Responsive Design - Improved Accessibility */ -@media (max-width: 768px) { - .system-type-display { - display: inline-flex; - /* Keep inline for proper alignment with ui-column-title */ - gap: 6px; - align-items: flex-start; - vertical-align: top; - } - - .partner-code-badge { - font-size: 0.8rem; - /* Maintain readability on mobile */ - padding: 3px 6px; - min-width: 60px; - } - - .auth-status-indicator { - font-size: 0.8rem; - margin-left: 4px; - padding: 3px 6px; - min-width: 28px; - min-height: 32px; - /* Maintain touch target on mobile */ - } - - .auth-status-indicator i { - font-size: 0.75rem; - } -} - -@media (max-width: 480px) { - .system-type-display { - gap: 4px; - } - - .partner-badge { - font-size: 0.75rem; - padding: 2px 6px; - letter-spacing: 0.3px; - } - - .partner-code-badge { - font-size: 0.75rem; - padding: 2px 5px; - min-width: 50px; - } - - .auth-status-indicator { - font-size: 0.75rem; - min-width: 24px; - } -} - -/* Table Column Specific Styles - Enhanced for Accessibility */ -.ui-table .ui-table-tbody>tr>td .system-type-display { - min-width: 140px; - /* Increased for better layout */ - padding: 4px 0; -} - -/* Improved Focus Management for Table Cells */ -:host ::ng-deep .ui-table .ui-table-tbody>tr>td:focus-within { - outline: 2px solid #4CAF50; - /* $primaryColor for focus */ - outline-offset: 1px; -} - -/* Dark Theme Support - Enhanced Contrast */ -@media (prefers-color-scheme: dark) { - .partner-code-badge { - background-color: #757575; - /* $grayBgColor */ - color: #ffffff; - /* $primaryTextColor */ - border-color: #bdbdbd; - /* $dividerColor */ - } - - .partner-code-badge:hover, - .partner-code-badge:focus { - background-color: #616161; - /* Darker gray */ - border-color: #e8e8e8; - /* $hoverBgColor */ - } - - .auth-status-indicator.auth-valid { - color: #A5D6A7; - /* $primaryLightColor */ - background-color: rgba(165, 214, 167, 0.2); - border-color: rgba(165, 214, 167, 0.4); - } - - .auth-status-indicator.auth-invalid { - color: #EF5350; - /* Lighter red for dark theme */ - background-color: rgba(239, 83, 80, 0.2); - border-color: rgba(239, 83, 80, 0.4); - } - - .auth-status-indicator.auth-validating { - color: #f9a825; - /* $accentLightColor */ - background-color: rgba(249, 168, 37, 0.2); - border-color: rgba(249, 168, 37, 0.4); - } -} - -/* ============================================================================ -PACKAGE ACTIVATION TOOLTIP STYLES -============================================================================ */ - -/* Highlight effect for package checkbox when tooltip is shown */ -.package-activation-highlight .p-checkbox-box { - border: 2px solid #FFC107 !important; - /* $amber */ - box-shadow: 0 0 8px rgba(255, 193, 7, 0.4) !important; - /* $amber with transparency */ - animation: pulseGlow 2s ease-in-out infinite; -} - -@keyframes pulseGlow { - 0% { - box-shadow: 0 0 8px rgba(255, 193, 7, 0.4); - /* $amber */ - } - - 50% { - box-shadow: 0 0 16px rgba(255, 193, 7, 0.6); - /* $amber */ - } - - 100% { - box-shadow: 0 0 8px rgba(255, 193, 7, 0.4); - /* $amber */ - } -} - -/* ============================================================================ -AIRCRAFT REVIEW BANNER – NO-CHANGES CONFIRM BUTTON -============================================================================ */ - -/* Flex column body: stacks message text + button vertically inside the flex row. - Takes the flex:1 growth previously on .aircraft-review-message. */ -.aircraft-review-body { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - /* Center message text and button horizontally */ - text-align: center; - gap: 10px; -} - -/* No custom CSS needed for the confirm button - uses .highlight-btn - (already defined at top of this file) with PrimeNG default button layout. - This matches the existing toolbar button pattern (ui-icon-* + pButton). */ \ No newline at end of file diff --git a/Development/client/src/app/entities/vehicle/vehicle-list/vehicle-list.component.html b/Development/client/src/app/entities/vehicle/vehicle-list/vehicle-list.component.html index 2f167cd..077417a 100644 --- a/Development/client/src/app/entities/vehicle/vehicle-list/vehicle-list.component.html +++ b/Development/client/src/app/entities/vehicle/vehicle-list/vehicle-list.component.html @@ -1,186 +1,44 @@
- - - - - -
-
-
- - -
- -
- - -
-
- {{ status?.message }} -
- - -
-
-
-
- - -
- - - Aircraft List - - - - - {{ col.header }} - - - -
- - -
- - - - - - -
- - - - {{col.header}} - - {{ rowData[col.field] | vehicleType }} - - - - - - - -
-
- - {{ formatDate(rowData[TRK_ON_DATE]) }} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Unit ID is missing. Please enter a Unit ID to enable the - tracking feature. - - {{ resolveFieldData(rowData, col.field) }} - - - {{ resolveFieldData(rowData, col.field) }} - - -
-
-
-
- - - - Active - InActive - - - - -
- - - - - - -
-
- - - Max vehicles: {{numOfVehicle}} - - - - -
- - - - - - - - - -
-
- - -
-
-
-
- -
+ + + Aircraft List + + + + {{ col.header }} + + + + +
+ + +
+ + + + +
+ + + +
{{ rowData[col.field] | vehicleType }}
+
+
+ +
+ {{ resolveFieldData(rowData, col.field) }} + + +
+
+
+ + +
- \ No newline at end of file +
\ No newline at end of file diff --git a/Development/client/src/app/entities/vehicle/vehicle-list/vehicle-list.component.ts b/Development/client/src/app/entities/vehicle/vehicle-list/vehicle-list.component.ts index 8106fe7..3258076 100644 --- a/Development/client/src/app/entities/vehicle/vehicle-list/vehicle-list.component.ts +++ b/Development/client/src/app/entities/vehicle/vehicle-list/vehicle-list.component.ts @@ -1,125 +1,35 @@ -import { Component, OnInit, OnDestroy, ViewChild, ElementRef, AfterViewInit } from '@angular/core'; +import { Component, OnInit, OnDestroy, ViewChild } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; + import { Table } from 'primeng/table'; import { ConfirmationService, SelectItem } from 'primeng/api'; + import { Vehicle } from '../../models/vehicle.model'; import * as vehicleActions from '../../actions/vehicle.actions'; import * as fromEntity from '../../reducers'; -import { RoleIds, globals, vehTypes, VehType, SourceSystem, Labels } from '@app/shared/global'; -import { DateUtils, Utils } from '@app/shared/utils'; -import { BaseComp } from '@app/shared/base/base.component'; -import { PartnerUtilsService } from '@app/shared/services/partner-utils.service'; -import { BadgeFactoryService } from '@app/shared/services/badge-factory.service'; -import { BadgeConfig } from '@app/shared/badge/badge-config.model'; -import { getSubIntentState, getSubscriptionStatus, selectLimit } from '@app/reducers'; -import { SUB, SubAppErr, SubTexts, SubType, createSubStatus, SubKeys, ACTIVE, TRACKING, hasVendorErr } from '@app/profile/common'; -import { Limit, Status } from '@app/domain/models/subscription.model'; -import { map, switchMap, take } from 'rxjs/operators'; -import { ClearSubscriptionStatus, Compound, GotoMyServices } from '@app/actions/subscription.actions'; -import { SubscriptionService } from '@app/domain/services/subscription.service'; -import { FetchSubPlans } from '@app/actions/sub-plans.actions'; -import { User } from '@app/accounts/models/user.model'; -import { UserService } from '@app/domain/services/user.service'; -import { PartnerService } from '@app/partners/services/partner.service'; -import { PopupTooltipService } from '@app/shared/popup-tooltip/popup-tooltip.service'; -const HIGHLIGHT = 'highlight-btn'; +import { RoleIds, globals, vehTypes, VehType } from '@app/shared/global'; +import { Utils } from '@app/shared/utils'; + +import { BaseComp } from '@app/shared/base/base.component'; + @Component({ selector: 'agm-vehicle-list', templateUrl: './vehicle-list.component.html', styleUrls: ['./vehicle-list.component.css'] }) -export class VehicleListComponent extends BaseComp implements OnInit, AfterViewInit, OnDestroy { +export class VehicleListComponent extends BaseComp implements OnInit, OnDestroy { readonly resolveFieldData = Utils.resolveFieldData; - readonly SubTexts = SubTexts; - readonly ID = '_id'; - readonly ACTIVE = ACTIVE; - readonly PACKAGE_ACTIVE = 'pkgActive'; - readonly PACKAGE_ACTIVE_DATE = 'pkgActiveDate'; - readonly TRACKING = TRACKING; - readonly TRK_ON_DATE = 'trackonDate'; - readonly VEHICLE_TYPE = 'vehicleType'; - readonly COLOR = 'color'; - readonly MODEL = 'model'; - readonly UNIT_ID = 'unitId'; - readonly SOURCE_SYSTEM = 'sourceSystem'; - - vehicles: Vehicle[] = []; - vehiclesChanged = false; - vehSelLastUpdated: { - [i: string]: { pkgActive: boolean, tracking: boolean } - }; - vehSelCurrent: { - [i: string]: { pkgActive: boolean, tracking: boolean } - }; - + vehicles: Array; currVehicle: Vehicle; + @ViewChild('dt') dt: Table; - @ViewChild('updateBtn') updateBtn: ElementRef; - cols: any[] = []; + cols: any[]; + acTypes: SelectItem[]; + loading$ = this.store.select(fromEntity.getVehiclesLoading); - trkLimit: Limit; - pkgLimit: Limit; - status: Status; - - displayDialog: boolean; - dialogMsg: string; - - vendorErr: boolean; - user: User; - - // Track if we're in review aircraft flow (from checkout/manage-subscription) - private isReviewAircraftFlow = false; - - // ============================================================================ - // NEW VEHICLE TOOLTIP STATE - // ============================================================================ - - // Track newly created vehicle to show package activation tooltip - newVehicleId: string | null = null; - packageTooltipShown: boolean = false; - - // ============================================================================ - // PARTNER AUTHENTICATION STATUS CACHING - // ============================================================================ - - // Cache authentication status per partner to avoid repeated API calls - private partnerAuthCache = new Map(); - - // Per-partner debounce timers for authentication checks - private authCheckTimers = new Map(); - - BASE_FIELDS = [ - { field: 'name', header: globals.name, filtered: true }, - { field: 'tailNumber', header: $localize`:@@tailNumber:Tail Number`, filtered: true }, - { field: this.VEHICLE_TYPE, header: $localize`:@@type:Type` }, - { field: this.MODEL, header: $localize`:@@model:Model` }, - { field: this.ACTIVE, header: globals.active, width: '5%' }, - { field: this.SOURCE_SYSTEM, header: $localize`:@@systemType:System Type` }, // NEW COLUMN - ]; - - PACKAGE_ACTIVE_FIELDS = [ - { field: this.COLOR, header: globals.color }, - { field: 'username', header: globals.userName, filtered: true }, - { field: this.PACKAGE_ACTIVE, header: globals.packageActive }, - { field: this.UNIT_ID, header: globals.unitId, filtered: true }, - ]; - - TRACKING_FIELDS = [ - { field: this.TRK_ON_DATE, header: $localize`:@@trackonDate:Track on Date` }, - { field: this.TRACKING, header: $localize`:@@tracking:Tracking` }, - ]; - - COLS_ACTIVE = [...this.BASE_FIELDS, ...this.PACKAGE_ACTIVE_FIELDS]; - COLS_TRACKING = [...this.BASE_FIELDS, ...this.PACKAGE_ACTIVE_FIELDS.filter((item) => item.field !== this.PACKAGE_ACTIVE), ...this.TRACKING_FIELDS]; - COLS_ACTIVE_TRACKING = [...this.BASE_FIELDS, ...this.PACKAGE_ACTIVE_FIELDS, ...this.TRACKING_FIELDS]; get canWrite(): boolean { return this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM, RoleIds.OFFICER]); @@ -128,14 +38,18 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI constructor( private readonly route: ActivatedRoute, private readonly confirmService: ConfirmationService, - private readonly subSvc: SubscriptionService, - private readonly userSvc: UserService, - private readonly partnerService: PartnerService, - private readonly partnerUtils: PartnerUtilsService, - private readonly badgeFactory: BadgeFactoryService, - private readonly popupTooltipService: PopupTooltipService + ) { super(); + this.cols = [ + { field: 'name', header: globals.name, width: '25%', filtered: true }, + { field: "vehicleType", header: $localize`:@@type:Type`, width: "12%", }, + { field: "model", header: $localize`:@@model:Model`, width: "15%", }, + { field: 'unitId', header: globals.unitId, width: '15%', filtered: true }, + { field: 'color', header: globals.color, width: '15%', filtered: false }, + { field: 'username', header: globals.userName, filtered: true }, + // { field: 'desc', header: globals.desc } + ]; this.acTypes = [ { label: globals.all, value: null }, { label: vehTypes[VehType.FIXEDSWING], value: VehType.FIXEDSWING }, @@ -144,854 +58,27 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI } ngOnInit() { - this.user = this.route.snapshot.data['user']; - this.clearNeedReview(); + this.sub$ = this.store.select(fromEntity.getAllVehicles) + .subscribe(items => this.vehicles = items); - // Check for newly created vehicle query parameter - this.checkForNewVehicleTooltip(); - - // Track if we're in review aircraft flow via query param - this.route.queryParams.pipe(take(1)).subscribe((params) => { - this.isReviewAircraftFlow = params['reviewFlow'] === 'true'; - }); - - this.initVehList(); - this.initStatus(); - this.store.dispatch(new Compound([ - new FetchSubPlans(), - new vehicleActions.Fetch(), - ])); - } - - clearNeedReview() { - if (this.user?.needReview) { - this.user.needReview = false; - this.userSvc.saveUser(this.user).subscribe({ - error: (err) => { - this.status = createSubStatus(SubAppErr.AC_LIST_ERR); - } - }); - } - } - - // ============================================================================ - // NEW VEHICLE TOOLTIP METHODS - // ============================================================================ - - /** - * Check for newly created vehicle and prepare to show package activation tooltip - */ - checkForNewVehicleTooltip() { - this.sub$.add( - this.route.queryParams.subscribe(params => { - if (params.newVehicleCreated && !this.packageTooltipShown) { - this.newVehicleId = params.newVehicleCreated; - // Remove the query parameter to clean up the URL - this.router.navigate([], { - relativeTo: this.route, - queryParams: {}, - replaceUrl: true - }); - } - }) - ); - } - - /** - * Show package activation tooltip for newly created vehicle - * Only shown if vehicle meets all eligibility requirements - */ - showPackageActivationTooltip(vehicleId: string) { - if (!this.newVehicleId || this.newVehicleId !== vehicleId || this.packageTooltipShown) { - return; - } - - // Check if vehicle is eligible for tooltip - if (!this.shouldShowAircraftReadyTooltip(vehicleId)) { - // EDGE CASE: Check if partner auth is still validating - const vehicle = this.vehicles.find(v => v._id === vehicleId); - if (vehicle?.partnerInfo?.partner && !this.partnerUtils.isNativeSystem(vehicle.partnerInfo.partner)) { - const authStatus = this.getPartnerAuthStatus(vehicle.partnerInfo.partner); - - if (authStatus.isValidating) { - // Wait for auth validation to complete - let retryAttempts = 0; - const maxRetries = 10; // Max 5 seconds (10 * 500ms) - const checkInterval = setInterval(() => { - retryAttempts++; - const updatedStatus = this.getPartnerAuthStatus(vehicle.partnerInfo.partner); - - if (!updatedStatus.isValidating || retryAttempts >= maxRetries) { - clearInterval(checkInterval); - - if (retryAttempts >= maxRetries) { - return; - } - - // Re-check eligibility after auth completes - if (this.shouldShowAircraftReadyTooltip(vehicleId)) { - this.showPackageActivationTooltip(vehicleId); - } - } - }, 500); // Check every 500ms - - return; // Don't show tooltip yet - } - } - - return; // Don't show tooltip if requirements not met - } - - // Set flag immediately to prevent duplicate calls during setTimeout delay - this.packageTooltipShown = true; - - // Wait for DOM updates and table rendering - setTimeout(() => { - // Find the package checkbox using the ID we added to the template - const checkboxContainer = document.querySelector(`#package-checkbox-${vehicleId}`) as HTMLElement; - - if (checkboxContainer) { - // Get the actual checkbox element for precise positioning - const checkboxBox = checkboxContainer.querySelector('.p-checkbox-box') as HTMLElement || - checkboxContainer.querySelector('.ui-chkbox-box') as HTMLElement || - checkboxContainer.querySelector('.p-checkbox') as HTMLElement; - - // Use the visual checkbox box if found, otherwise the container - const targetElement = checkboxBox || checkboxContainer; - - if (targetElement) { - // Add highlight class for visual emphasis - checkboxContainer.classList.add('package-activation-highlight'); - - // Position tooltip on the left side of the package active column - const isMobile = window.innerWidth <= 768; - const preferredPosition = isMobile ? 'bottom' : 'left'; // Changed from 'right' to 'left' - - // Check if package can be activated and customize tooltip accordingly - const canActivate = this.canActivatePackageForVehicle(vehicleId); - const tooltipConfig = this.getPackageActivationTooltipConfig(vehicleId, canActivate); - - const tooltipRef = this.popupTooltipService.showActionReminder( - tooltipConfig.message, - tooltipConfig.actionText, - targetElement, - { - position: preferredPosition, - autoHide: false, // Keep visible until user takes action - title: tooltipConfig.title, - severity: tooltipConfig.severity - } - ); - - // Subscribe to action button click - if (tooltipRef && tooltipRef.instance) { - tooltipRef.instance.actionClicked.subscribe(() => { - this.handlePackageActivationAction(vehicleId, canActivate); - }); - } - } - } - }, 500); // Allow time for DOM rendering - } - - /** - * Check if package can be activated for the given vehicle - */ - canActivatePackageForVehicle(vehicleId: string): boolean { - if (!this.pkgLimit) { - return true; // No limits, can activate - } - - const activePackageVehicles = this.getVehicles(this.PACKAGE_ACTIVE, this.vehicles); - const maxAllowed = this.pkgLimit?.airCraft?.numOfVehicle || 0; - return activePackageVehicles.length < maxAllowed; - } - - /** - * Check if vehicle has proper account credentials - * Required for showing "Aircraft Ready" tooltip - * - * Note: Passwords are not returned from backend for security. - * For newly created vehicles, we assume password was set because - * backend validation would fail without it. - * - * @param vehicleId Vehicle ID to check - * @returns true if vehicle has username and active=true - */ - hasProperAccountCredentials(vehicleId: string): boolean { - const vehicle = this.vehicles.find(v => v._id === vehicleId); - - if (!vehicle) { - return false; - } - - // Partner aircraft: Only require active status (no username/password needed) - if (vehicle.partnerInfo?.partner && !this.partnerUtils.isNativeSystem(vehicle.partnerInfo.partner)) { - return vehicle.active === true; - } - - // Native AgMission aircraft: Require username and active status - const hasUsername = vehicle.username && vehicle.username.trim() !== ''; - const isActive = vehicle.active === true; - - // Note: Password field is not included in backend response for security - // For newly created vehicles (via newVehicleCreated query param), - // we assume password was provided since backend validates this - - // Both username and active must be present for native aircraft - return hasUsername && isActive; - } - - /** - * Check if vehicle has valid partner authentication (if applicable) - * For AgNav native systems, returns true immediately - * For partner systems, checks cached authentication status - * - * @param vehicleId Vehicle ID to check - * @returns true if partner auth is valid or not required - */ - hasValidPartnerAuthForVehicle(vehicleId: string): boolean { - const vehicle = this.vehicles.find(v => v._id === vehicleId); - - if (!vehicle) { - return false; - } - - // Use existing partner authentication validation - return this.hasValidPartnerAuth(vehicle); - } - - /** - * Check if vehicle is eligible to show "Aircraft Ready" tooltip - * - * Requirements: - * 1. Has proper account credentials (username + active=true) - * 2. Package limit has not been reached - * 3. Partner authentication is valid (if applicable) - * - * @param vehicleId Vehicle ID to check - * @returns true if all conditions are met - */ - shouldShowAircraftReadyTooltip(vehicleId: string): boolean { - // Requirement 1: Check account credentials - const hasCredentials = this.hasProperAccountCredentials(vehicleId); - if (!hasCredentials) { - return false; - } - - // Requirement 2: Check package limit - const canActivate = this.canActivatePackageForVehicle(vehicleId); - if (!canActivate) { - return false; - } - - // Requirement 3: Check partner authentication (if applicable) - const hasValidAuth = this.hasValidPartnerAuthForVehicle(vehicleId); - if (!hasValidAuth) { - return false; - } - - // All checks passed - return true; - } - - /** - * Get tooltip configuration based on whether package can be activated - */ - getPackageActivationTooltipConfig(vehicleId: string, canActivate: boolean) { - if (canActivate) { - return { - title: Labels.AIRCRAFT_READY_TITLE, - message: Labels.PACKAGE_ACTIVATION_REMINDER, - actionText: Labels.ACTIVATE_PACKAGE_ACTION, - severity: 'warning' as const - }; - } else { - const activeCount = this.getVehicles(this.PACKAGE_ACTIVE, this.vehicles).length; - const maxAllowed = this.pkgLimit?.airCraft?.numOfVehicle || 0; - - return { - title: Labels.PACKAGE_LIMIT_REACHED_TITLE, - message: `${Labels.PACKAGE_LIMIT_REACHED_MESSAGE} (${activeCount}/${maxAllowed})`, - actionText: Labels.MANAGE_PACKAGE_LIMIT_ACTION, - severity: 'error' as const - }; - } - } - - /** - * Handle the action button click in the tooltip - */ - handlePackageActivationAction(vehicleId: string, canActivate: boolean) { - if (canActivate) { - // Activate package for this vehicle - this.activatePackageForVehicle(vehicleId); - } else { - // Show options for managing package limits - this.showPackageLimitOptions(vehicleId); // Pass vehicleId for consistent positioning - } - } - - /** - * Activate package for the specified vehicle - */ - activatePackageForVehicle(vehicleId: string) { - const vehicle = this.vehicles.find(v => v._id === vehicleId); - if (vehicle && !vehicle.pkgActive) { - // Simulate checkbox change - vehicle.pkgActive = true; - this.vehSelChange(vehicle, this.PACKAGE_ACTIVE); - - // Trigger backend update directly without confirmation dialog - this.store.dispatch(new vehicleActions.UpdateVehicles({ vehicles: this.vehicles })); - - // Hide tooltip and show success feedback - this.popupTooltipService.hideAll(); - - // Show success message after a brief delay to allow for update processing - setTimeout(() => { - // Use consistent positioning with package activation tooltip - const isMobile = window.innerWidth <= 768; - const successPosition = isMobile ? 'bottom' : 'left'; - - this.popupTooltipService.showSuccess( - Labels.PACKAGE_ACTIVATED_SUCCESS, - document.querySelector(`#package-checkbox-${vehicleId}`) as HTMLElement, - { - autoHide: true, - autoHideDelay: 3000, - title: Labels.SUCCESS_TITLE, - position: successPosition // Use consistent positioning - } - ); - }, 300); - } - } - - /** - * Show options for managing package limits - */ - showPackageLimitOptions(vehicleId: string) { - // Hide current tooltip - this.popupTooltipService.hideAll(); - - // Use consistent positioning with package activation tooltip - const isMobile = window.innerWidth <= 768; - const warningPosition = isMobile ? 'bottom' : 'left'; - - // Find the same target element (checkbox) for consistent positioning - const targetElement = document.querySelector(`#package-checkbox-${vehicleId}`) as HTMLElement; - - // For now, show constraint message component or navigate to upgrade - // This could be enhanced to show a modal with specific options - this.popupTooltipService.showWarning( - Labels.PACKAGE_LIMIT_UPGRADE_MESSAGE, - targetElement, // Use the same target as other tooltips - { - autoHide: true, - autoHideDelay: 5000, - title: Labels.UPGRADE_REQUIRED_TITLE, - position: warningPosition // Use consistent positioning - } - ); - } - - initVehList() { - this.sub$ = this.store.select(fromEntity.getAllVehicles).pipe( - map((vehicles) => { - this.vehicles = vehicles; - this.vehSelLastUpdated = this.createVehSelections(vehicles); - this.vehiclesChanged = this.isVehSelChanged(); - - // Show package activation tooltip after vehicles are loaded and view is initialized - if (this.newVehicleId && vehicles.length > 0) { - setTimeout(() => { - this.showPackageActivationTooltip(this.newVehicleId); - }, 500); - } - - // Call resolveVehicleList when vehicles are loaded to ensure aircraft limits are enforced - if (vehicles && vehicles.length > 0) { - this.resolveVehicleList(); - - // Show package activation tooltip for newly created vehicle after table renders - if (this.newVehicleId && !this.packageTooltipShown) { - // Use setTimeout to ensure the table is fully rendered - setTimeout(() => { - this.showPackageActivationTooltip(this.newVehicleId!); - }, 500); - } - } - }) - ).subscribe({ - error: (err) => { - this.status = createSubStatus(SubAppErr.AC_LIST_ERR); - } - }); - - this.sub$.add(this.store.select(fromEntity.getSelectedVehicle).subscribe((vehicle) => this.currVehicle = vehicle)); - - this.sub$.add(this.store.select(selectLimit(SubType.PACKAGE)).pipe( - switchMap((pkg) => { - if (!Utils.isEmptyObj(pkg)) { - this.pkgLimit = pkg[this.authSvc.getCurLookupKey(SubType.PACKAGE)]; - } else { - this.pkgLimit = null; - if (this.subSvc.isStatusMatchingCode(this.status, SUB.AC_REVIEW)) { - this.store.dispatch(new ClearSubscriptionStatus()); - } - } - return this.store.select(selectLimit(SubType.ADDON)); - }), - map((addon => { - if (!Utils.isEmptyObj(addon)) { - const isTrackingExclusive = addon[SubKeys.TRACKING] && !this.pkgLimit; - const hasBothTrackingAndPackage = addon[SubKeys.TRACKING] && this.pkgLimit; - this.trkLimit = addon[SubKeys.TRACKING]; - this.resolveVehicleList(); - if (isTrackingExclusive) { - return this.cols = this.COLS_TRACKING; - } else if (hasBothTrackingAndPackage) { - return this.cols = this.COLS_ACTIVE_TRACKING; - } - } else { - this.trkLimit = null; - if (this.pkgLimit) { - this.resolveVehicleList(); - return this.cols = this.COLS_ACTIVE; - } - if (this.subSvc.isStatusMatchingCode(this.status, SUB.AC_REVIEW)) { - this.store.dispatch(new ClearSubscriptionStatus()); - } - return this.cols = []; - } - })) - ).subscribe({ - error: (err) => { - this.status = createSubStatus(SubAppErr.AC_LIST_ERR); - } - })); - } - - private createVehSelections(vehicles: Vehicle[]): { [i: string]: { pkgActive: boolean, tracking: boolean } } { - return vehicles?.reduce((acc, veh) => { - acc[veh._id] = { pkgActive: veh.pkgActive, tracking: veh.tracking }; - return acc; - }, {}); - } - - vehSelChange(rowData: Vehicle, type: string) { - const currentUTCDate = DateUtils.tsToDate(DateUtils.currUTC()); - - if (type === this.TRACKING && rowData[this.TRACKING] && (!rowData[this.TRK_ON_DATE] || new Date(rowData[this.TRK_ON_DATE]) < currentUTCDate)) { - rowData[this.TRK_ON_DATE] = currentUTCDate; - } - - if (type === this.PACKAGE_ACTIVE && rowData[this.PACKAGE_ACTIVE] && (!rowData[this.PACKAGE_ACTIVE_DATE] || new Date(rowData[this.PACKAGE_ACTIVE_DATE]) < currentUTCDate)) { - rowData[this.PACKAGE_ACTIVE_DATE] = currentUTCDate; - } - - // Hide package activation tooltip when user activates package for newly created vehicle - if (type === this.PACKAGE_ACTIVE && rowData[this.PACKAGE_ACTIVE] && - this.newVehicleId === rowData._id && this.packageTooltipShown) { - this.popupTooltipService.hideAll(); - - // Remove highlight class - const checkboxContainer = document.querySelector(`#package-checkbox-${rowData._id}`) as HTMLElement; - if (checkboxContainer) { - checkboxContainer.classList.remove('package-activation-highlight'); - } - - this.newVehicleId = null; // Reset to prevent showing again - this.packageTooltipShown = false; - } - - if (!this.vehSelCurrent) this.vehSelCurrent = this.createVehSelections(this.vehicles); - this.vehSelCurrent[rowData[this.ID]][type] = rowData[type]; - this.vehiclesChanged = this.isVehSelChanged(); - } - - isVehSelChanged(): boolean { - if (!this.vehSelLastUpdated || !this.vehSelCurrent) return false; - return !Object.keys(this.vehSelLastUpdated).every((key) => { - return this.vehSelLastUpdated[key].pkgActive === this.vehSelCurrent[key].pkgActive - && this.vehSelLastUpdated[key].tracking === this.vehSelCurrent[key].tracking; - }); - } - - private getVehicles(field: string, vehicles: Vehicle[]): Vehicle[] { - return vehicles?.filter((veh) => veh[field]) || []; - } - - private resolveVehicleList() { - // Ensure we have both vehicles data and limits before proceeding - if (!this.vehicles || this.vehicles.length === 0) { - return; - } - - if (!this.pkgLimit && !this.trkLimit) { - return; - } - - const trkVehicles = this.getVehicles(this.TRACKING, this.vehicles); - const pkgActiveVehs = this.getVehicles(this.PACKAGE_ACTIVE, this.vehicles); - - const isTrkVehicleAboveLimit = this.trkLimit && trkVehicles.length > this.trkLimit?.airCraft?.numOfVehicle; - const isPkgActiveVehicleAboveLimit = this.pkgLimit && pkgActiveVehs.length > this.pkgLimit?.airCraft?.numOfVehicle; - - if (isTrkVehicleAboveLimit || isPkgActiveVehicleAboveLimit) { - this.vehicles = this.vehicles.map((veh) => ({ - ...veh, - tracking: isTrkVehicleAboveLimit ? trkVehicles.slice(0, this.trkLimit?.airCraft?.numOfVehicle).some((trkVeh) => trkVeh._id === veh._id) : veh.tracking, - pkgActive: isPkgActiveVehicleAboveLimit ? pkgActiveVehs.slice(0, this.pkgLimit?.airCraft?.numOfVehicle).some((pkgActiveVeh) => pkgActiveVeh._id === veh._id) : veh.pkgActive + this.sub$.add(this.store.select(fromEntity.getSelectedVehicle) + .subscribe((vehicle) => { + this.currVehicle = vehicle; })); - this.store.dispatch(new vehicleActions.UpdateVehicles({ vehicles: this.vehicles, type: SUB.AC_REVIEW })); - } - } - - initStatus() { - this.sub$.add( - this.store.select(getSubscriptionStatus).pipe( - switchMap((status) => { - if (hasVendorErr(status?.code)) { - this.vendorErr = true; - } - this.status = this.isCompLoaded() && this.subSvc.isUnderReview(status) - ? status - : null; - return this.store.select(getSubIntentState); - }), - map((state) => { - if (this.subSvc.isUnderReview(this.status) && state.stage !== SUB.CHKOUT_CONF) { - this.dialogMsg = this.status?.message; - this.displayDialog = true; - } - }) - ).subscribe({ - error: (err) => { - this.status = createSubStatus(SubAppErr.AC_LIST_ERR); - } - }) - ); - } - - ngAfterViewInit() { - if (this.subSvc.isUnderReview(this.status)) { - this.updateBtn?.nativeElement.classList.add(HIGHLIGHT); - } - } - - get canEdit() { - return (this.currVehicle && this.currVehicle._id !== '0'); - } - - isDisabled(rowData: Vehicle, type: string): boolean { - const isActive = this.vehicles?.find((veh) => veh._id === rowData[this.ID])?.active; - if (!isActive) return true; - - switch (type) { - case this.TRACKING: - return this.isDisabledByTrkLimit(rowData); - case this.PACKAGE_ACTIVE: - return this.isDisabledBYPkgLimit(rowData); - default: - return false; - } - } - - isDisabledByTrkLimit(rowData: Vehicle): boolean { - const vehicles = this.getVehicles(this.TRACKING, this.vehicles) - const isVehicleAtLimit = vehicles.length == this.trkLimit?.airCraft?.numOfVehicle; - return isVehicleAtLimit && !rowData[this.TRACKING]; - } - - isDisabledBYPkgLimit(rowData: Vehicle): boolean { - const vehicles = this.getVehicles(this.PACKAGE_ACTIVE, this.vehicles); - const isVehicleAtLimit = vehicles.length == this.pkgLimit?.airCraft?.numOfVehicle; - return isVehicleAtLimit && !rowData[this.PACKAGE_ACTIVE]; + this.store.dispatch(new vehicleActions.Fetch()); } onRowSelect(event) { this.store.dispatch(new vehicleActions.Select(this.currVehicle)); } - /** - * Get partner display name for badge - */ - getPartnerDisplayName(vehicle: Vehicle): string { - // Check if vehicle has partner integration data - if (vehicle.partnerInfo?.metadata?.partnerSystem) { - return vehicle.partnerInfo.metadata.partnerSystem; - } - - // Default to AgNav brand name (non-translatable) if no partner info exists - return Labels.AGNAV_BRAND_NAME; + get canAddNew(): boolean { + return true; } - /** - * Get source system for vehicle (used for SOURCE_SYSTEM column) - */ - getSourceSystem(vehicle: Vehicle): string { - return vehicle.partnerInfo?.metadata?.partnerSystem || SourceSystem.AGNAV; - } - - /** - * Badge Configuration Methods (using BadgeFactoryService) - * Uses configuration-driven badge component for consistent styling - */ - - /** - * Get badge configuration for partner name (system type badge) - */ - getPartnerNameBadge(vehicle: Vehicle): BadgeConfig { - const sourceSystem = this.getSourceSystem(vehicle); - const partnerName = this.getPartnerDisplayName(vehicle); - const badge = this.badgeFactory.createSystemBadge(sourceSystem, partnerName); - - // Override tooltip with component-specific tooltip logic - return { - ...badge, - tooltip: this.getPartnerTooltip(vehicle) - }; - } - - /** - * Get badge configuration for authentication status (icon only) - */ - getAuthStatusBadge(vehicle: Vehicle): BadgeConfig { - const partnerId = vehicle.partnerInfo?.partner; - const authStatus = partnerId ? this.getPartnerAuthStatus(partnerId) : null; - - const badge = this.badgeFactory.createAuthStatusBadge( - authStatus?.isAuthenticated || false, - authStatus?.isValidating || false, - partnerId || null - ); - - // Override tooltip with component-specific tooltip logic - return { - ...badge, - tooltip: this.getPartnerAuthTooltip(vehicle) - }; - } - - /** - * Get badge configuration for partner code (tail number badge) - */ - getPartnerCodeBadge(vehicle: Vehicle): BadgeConfig { - const badge = this.badgeFactory.createPartnerCodeBadge(vehicle.tailNumber || ''); - - // Override tooltip with component-specific tooltip logic - return { - ...badge, - tooltip: this.getPartnerCodeTooltip(vehicle) - }; - } - - /** - * Get tooltip text for partner information - */ - getPartnerTooltip(vehicle: Vehicle): string { - if (!vehicle.partnerInfo?.metadata?.partnerSystem) { - return Labels.AGMISSION_NATIVE_SYSTEM; - } - - const partnerName = vehicle.partnerInfo.metadata.partnerSystem; - const lastSync = vehicle.partnerInfo.metadata.lastSync - ? this.formatDate(new Date(vehicle.partnerInfo.metadata.lastSync)) - : Labels.NEVER; - - return `${partnerName} - ${Labels.LAST_SYNC_PREFIX} ${lastSync}`; - } - - /** - * Get tooltip text for partner code (tail number) - */ - getPartnerCodeTooltip(vehicle: Vehicle): string { - return `${Labels.TAIL_NUMBER_PREFIX} ${vehicle.tailNumber}`; - } - - // ============================================================================ - // PARTNER AUTHENTICATION STATUS METHODS - // ============================================================================ - - /** - * Get authentication status for a partner system (cached) - */ - getPartnerAuthStatus(partnerId: string): { isAuthenticated: boolean; isValidating: boolean; error?: string } { - const cached = this.partnerAuthCache.get(partnerId); - - if (!cached) { - // Start validation for this partner if not in cache - this.schedulePartnerAuthCheck(partnerId); - return { isAuthenticated: false, isValidating: true }; - } - - // Return cached status - return { - isAuthenticated: cached.isAuthenticated, - isValidating: cached.isValidating, - error: cached.error - }; - } - - /** - * Schedule authentication check for a partner (debounced per partner) - */ - private schedulePartnerAuthCheck(partnerId: string): void { - // Mark as validating - this.partnerAuthCache.set(partnerId, { - isAuthenticated: false, - isValidating: true, - lastChecked: new Date() - }); - - // Clear existing timer for this specific partner - const existingTimer = this.authCheckTimers.get(partnerId); - if (existingTimer) { - clearTimeout(existingTimer); - } - - // Schedule check for this specific partner after short delay - const timer = setTimeout(() => { - this.performPartnerAuthCheck(partnerId); - }, 100); - - // Store the timer for this partner - this.authCheckTimers.set(partnerId, timer); - } - - /** - * Perform authentication check for a specific partner using centralized service method - */ - private async performPartnerAuthCheck(partnerId: string): Promise { - try { - const currentCustomerId = this.authSvc.byPUserId; - if (!currentCustomerId) { - console.warn('No current customer ID available for partner auth check'); - return; - } - - // Use centralized validation method - const result = await this.partnerService.validatePartnerAuthentication( - currentCustomerId, - partnerId - ); - - // Cache the result with appropriate error mapping - this.partnerAuthCache.set(partnerId, { - isAuthenticated: result.isValid, - isValidating: false, - lastChecked: new Date(), - error: result.isValid ? undefined : this.mapAuthErrorMessage(result.errorMessage) - }); - - } catch (error) { - console.error(`Error checking partner ${partnerId} authentication:`, error); - - // Cache the error - this.partnerAuthCache.set(partnerId, { - isAuthenticated: false, - isValidating: false, - lastChecked: new Date(), - error: error.message || Labels.UNKNOWN_ERROR - }); - } finally { - // Clean up the timer for this partner - this.authCheckTimers.delete(partnerId); - } - } - - /** - * Map authentication error messages from centralized service to user-friendly labels - */ - private mapAuthErrorMessage(errorMessage?: string): string { - if (!errorMessage) { - return Labels.AUTHENTICATION_FAILED_SHORT; - } - - if (errorMessage.includes('No system users found')) { - return Labels.NO_SYSTEM_ACCOUNT_FOUND; - } - - if (errorMessage.includes('credentials are missing')) { - return Labels.MISSING_CREDENTIALS; - } - - if (errorMessage.includes('Authentication test failed')) { - return Labels.AUTHENTICATION_FAILED_SHORT; - } - - // Default to authentication failed for any other error - return Labels.AUTHENTICATION_FAILED_SHORT; - } - - /** - * Check if a partner system has valid authentication - */ - hasValidPartnerAuth(vehicle: Vehicle): boolean { - const partnerId = vehicle.partnerInfo?.partner; - if (!partnerId || this.partnerUtils.isNativeSystem(partnerId)) { - return true; // AgNav doesn't need external auth - } - - const authStatus = this.getPartnerAuthStatus(partnerId); - return authStatus.isAuthenticated; - } - - /** - * Check if a partner system is currently validating authentication - */ - isPartnerAuthValidating(vehicle: Vehicle): boolean { - const partnerId = vehicle.partnerInfo?.partner; - if (!partnerId || this.partnerUtils.isNativeSystem(partnerId)) { - return false; // AgNav doesn't need validation - } - - const authStatus = this.getPartnerAuthStatus(partnerId); - return authStatus.isValidating; - } - - /** - * Get authentication status icon class for partner - */ - getPartnerAuthIconClass(vehicle: Vehicle): string { - const partnerId = vehicle.partnerInfo?.partner; - if (!partnerId || this.partnerUtils.isNativeSystem(partnerId)) { - return 'ui-icon-check'; // AgNav is always valid - } - - const authStatus = this.getPartnerAuthStatus(partnerId); - - if (authStatus.isValidating) { - return 'pi pi-spin pi-spinner'; - } else if (authStatus.isAuthenticated) { - return 'ui-icon-vpn-key'; - } else { - return 'ui-icon-warning'; - } - } - - /** - * Get authentication status tooltip for partner - */ - getPartnerAuthTooltip(vehicle: Vehicle): string { - const partnerId = vehicle.partnerInfo?.partner; - if (!partnerId || this.partnerUtils.isNativeSystem(partnerId)) { - return Labels.AGMISSION_NATIVE_SYSTEM; - } - - const authStatus = this.getPartnerAuthStatus(partnerId); - const partnerName = vehicle.partnerInfo?.metadata?.partnerSystem || Labels.PARTNER_SYSTEM_DEFAULT; - - if (authStatus.isValidating) { - return `${partnerName}: ${Labels.VALIDATING_AUTHENTICATION}`; - } else if (authStatus.isAuthenticated) { - return `${partnerName}: ${Labels.AUTHENTICATION_VALID}`; - } else { - return `${partnerName}: ${Labels.AUTHENTICATION_FAILED_WITH_ERROR} ${authStatus.error || Labels.UNKNOWN_ERROR}`; - } + get canEdit() { + return (this.currVehicle && this.currVehicle._id !== '0'); } newVehicle() { @@ -1004,6 +91,7 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI deleteItem() { if (!this.currVehicle) return; + this.confirmService.confirm({ message: globals.confirmDeleteThing.replace('#thing#', globals.aircraft), accept: () => { @@ -1013,65 +101,7 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI }); } - update() { - this.confirmService.confirm({ - message: SubTexts.textUpdateAC, - accept: () => { - this.store.dispatch(new vehicleActions.UpdateVehicles({ vehicles: this.vehicles })); - this.updateBtn?.nativeElement.classList.remove(HIGHLIGHT); - - // Navigate to service overview if in review aircraft flow - if (this.isReviewAircraftFlow) { - this.router.navigate(['/profile/myservices']); - } - } - }); - } - - formatDate(date: Date) { - if (!date) return; - return DateUtils.toSlash(date, this.authSvc.locale); - } - - reviewAC() { - this.displayDialog = false; - } - - isCompLoaded() { - return this.status?.code !== SubAppErr.AC_LIST_ERR - && this.status?.code !== SubAppErr._500_ERR - && !this.vendorErr; - } - - isAircraftReviewStatus(): boolean { - return this.subSvc.isStatusMatchingCode(this.status, SUB.AC_REVIEW); - } - - gotoMySubs() { - this.store.dispatch(new GotoMyServices()); - } - - /** - * Called when user confirms no aircraft changes are needed during review flow. - * Navigates directly via Router (no reload) — unlike GotoMyServices which - * always calls window.location.reload() after navigation. - */ - noChangesToReview(): void { - this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]); - } - - get canActivateVehicle() { - return this.authSvc.canActivateVehicle; - } - ngOnDestroy() { - // Clear all partner-specific authentication check timers - this.authCheckTimers.forEach((timer) => { - clearTimeout(timer); - }); - this.authCheckTimers.clear(); - - this.store.dispatch(new ClearSubscriptionStatus()); super.ngOnDestroy(); } } diff --git a/Development/client/src/app/entities/vehicle/vehicle-partner-integration/vehicle-partner-integration.component.css b/Development/client/src/app/entities/vehicle/vehicle-partner-integration/vehicle-partner-integration.component.css deleted file mode 100644 index 2ddf5eb..0000000 --- a/Development/client/src/app/entities/vehicle/vehicle-partner-integration/vehicle-partner-integration.component.css +++ /dev/null @@ -1,468 +0,0 @@ -/* Partner Integration Component Styles - AgMission Theme Compliance */ - -/* Host element typography foundation - AgMission standards */ -:host { - font-family: "Roboto", "Helvetica Neue", sans-serif; - /* $fontFamily - AgMission standard */ - line-height: 1.5; - /* $lineHeight - AgMission standard */ - letter-spacing: 0.25px; - /* $letterSpacing - AgMission standard */ -} - -.partner-validation-section { - margin-top: 12px; -} - -/* ============================================================================ -INTEGRATION STEPS INDICATOR - AgMission Project Color Compliance -============================================================================ */ - -.integration-steps { - display: flex; - align-items: center; - margin: 16px 0 24px 0; - padding: 12px; - background: #ffffff; - /* contentBgColor - AgMission content background */ - border-radius: 3px; - /* AgMission standard border radius */ - border: 1px solid #bdbdbd; - /* dividerColor - AgMission borders */ -} - -.step { - display: flex; - flex-direction: column; - align-items: center; - flex: 1; - text-align: center; - min-width: 120px; -} - -.step-indicator { - width: 32px; - height: 32px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 8px; - font-weight: bold; - font-size: 14px; - transition: all 0.3s ease; - border: 2px solid #bdbdbd; - /* dividerColor - AgMission neutral border */ - background: #ffffff; - /* contentBgColor - AgMission white background */ - color: #757575; - /* textSecondaryColor - AgMission secondary text */ - font-family: "Roboto", "Helvetica Neue", sans-serif; - /* $fontFamily - AgMission standard */ - line-height: 1.5; - /* $lineHeight - AgMission standard */ - letter-spacing: 0.25px; - /* $letterSpacing - AgMission standard */ -} - -.step.active .step-indicator { - border-color: #03A9F4; - /* blue - AgMission info color */ - background: #03A9F4; - /* blue - AgMission info color */ - color: #ffffff; - /* primaryTextColor - white text on colored backgrounds */ -} - -.step.completed .step-indicator { - border-color: #4CAF50; - /* primaryColor - AgMission main green */ - background: #4CAF50; - /* primaryColor - AgMission main green */ - color: #ffffff; - /* primaryTextColor - white text on colored backgrounds */ -} - -.step-label { - font-size: 12px; - font-weight: 500; - color: #757575; - /* textSecondaryColor - AgMission secondary text */ - line-height: 1.3; - max-width: 100px; - font-family: "Roboto", "Helvetica Neue", sans-serif; - /* $fontFamily - AgMission standard */ - letter-spacing: 0.25px; - /* $letterSpacing - AgMission standard */ -} - -.step.active .step-label { - color: #03A9F4; - /* blue - AgMission info color */ -} - -.step.completed .step-label { - color: #4CAF50; - /* primaryColor - AgMission main green */ -} - -.step-connector { - flex: 0 0 auto; - height: 2px; - width: 40px; - background: #bdbdbd; - /* dividerColor - AgMission neutral */ - margin: 0 8px; - border-radius: 1px; -} - -.step.completed+.step-connector { - background: #4CAF50; - /* primaryColor - AgMission main green */ -} - -.step.active+.step-connector { - background: linear-gradient(to right, #4CAF50 50%, #bdbdbd 50%); - /* Green to neutral gradient */ -} - -/* ============================================================================ -PARTNER SYSTEM INDICATORS - AgMission Project Color Compliance -============================================================================ */ - -.partner-selection-with-indicator { - display: flex; - align-items: center; - gap: 4px; - width: fit-content; -} - -.partner-selection-with-indicator p-dropdown { - flex-shrink: 0; -} - -.partner-selection-with-indicator p-dropdown .ui-dropdown { - margin-right: 0 !important; -} - -.success-indicator { - color: #4CAF50 !important; - /* primaryColor - AgMission main green */ - font-size: 1.2rem; - opacity: 1; - animation: fadeInScale 0.3s ease-in; - margin-left: 0 !important; - margin-right: 0 !important; -} - -.loading-indicator { - color: #03A9F4; - /* blue - AgMission info color */ - font-size: 0.95rem; - display: flex; - align-items: center; - gap: 4px; - font-family: "Roboto", "Helvetica Neue", sans-serif; - /* $fontFamily - AgMission standard */ - letter-spacing: 0.25px; - /* $letterSpacing - AgMission standard */ - margin-left: 0 !important; - margin-right: 0 !important; -} - -.loading-indicator i { - font-size: 1.1rem; -} - -/* ============================================================================ -VALIDATION LOADING INDICATOR - AgMission Project Color Compliance -============================================================================ */ - -.validation-loading-indicator { - color: #03A9F4 !important; - /* blue - AgMission info color */ - margin-left: 0 !important; - margin-right: 0 !important; - font-size: 1.1rem; -} - -/* ============================================================================ -AIRCRAFT INFORMATION PANEL - AgMission Project Color Compliance -============================================================================ */ - -.enhanced-aircraft-info-panel { - background: linear-gradient(135deg, #E8F5E8 0%, #ffffff 100%); - /* Light green to white gradient matching AgMission success styling */ - border: 1px solid #4CAF50; - /* primaryColor - AgMission main green */ - border-radius: 3px; - /* AgMission standard border radius - matches constraint-message */ - padding: 12px 16px; - /* Matches constraint-message content padding */ - margin: 12px 0; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); - /* Matches constraint-message shadow */ - transition: all 0.3s ease-in-out; - /* Matches constraint-message transition */ - position: relative; - overflow: hidden; -} - -.enhanced-aircraft-info-panel:hover { - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); - /* Matches constraint-message hover shadow */ - transform: translateY(-1px); - /* Matches constraint-message hover effect */ -} - -.aircraft-info-content { - display: flex; - align-items: flex-start; - gap: 12px; - /* Matches constraint-message content gap */ -} - -.aircraft-info-icon { - font-size: 1.125rem; - /* Matches constraint-message icon size: 18px */ - color: #4CAF50; - /* primaryColor - AgMission main green */ - margin-top: 2px; - /* Matches constraint-message icon alignment */ - flex-shrink: 0; -} - -.aircraft-info-text { - flex: 1; - min-width: 0; - /* Matches constraint-message text container */ -} - -.aircraft-info-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 8px; - flex-wrap: wrap; - gap: 8px; -} - -.aircraft-info-title { - font-size: 0.875rem; - /* 14px - matches constraint-message title size */ - font-weight: 500; - /* Matches constraint-message title weight */ - color: #2E7D32; - /* primaryDarkColor - darker green for headers */ - font-family: "Roboto", "Helvetica Neue", sans-serif; - /* $fontFamily - AgMission standard */ - line-height: 1.5; - /* Matches constraint-message line height */ - letter-spacing: 0.25px; - /* $letterSpacing - AgMission standard */ - margin-bottom: 4px; - /* Matches constraint-message title margin */ -} - -.aircraft-details { - display: flex; - flex-direction: column; - gap: 6px; -} - -.detail-row { - display: flex; - align-items: center; - gap: 8px; - font-size: 0.8125rem; - /* 13px - matches constraint-message description size */ - line-height: 1.5; - /* Matches constraint-message line height */ - font-family: "Roboto", "Helvetica Neue", sans-serif; - /* $fontFamily - AgMission standard */ - letter-spacing: 0.25px; - /* $letterSpacing - AgMission standard */ - color: #212121; - /* textColor - matches constraint-message description */ - margin: 0; - word-wrap: break-word; - /* Matches constraint-message description */ -} - -.detail-row strong { - color: #2E7D32; - /* primaryDarkColor - darker green for labels */ - font-weight: 500; - min-width: 80px; -} - -.system-type-row { - align-items: flex-start; -} - -.system-type-value { - color: #4CAF50; - /* primaryColor - AgMission success color for selected system type */ - font-weight: 600; - /* Bold text for emphasis */ -} - -.system-type-pending { - color: #f39c12; - /* Warning color */ - font-style: italic; - display: flex; - align-items: center; - gap: 4px; -} - -/* ============================================================================ -DISABLED PREVIEW STYLING -============================================================================ */ - -.preview-disabled { - opacity: 0.6; -} - -/* ============================================================================ -ANIMATIONS -============================================================================ */ - -@keyframes fadeInScale { - 0% { - opacity: 0; - transform: scale(0.8); - } - - 100% { - opacity: 1; - transform: scale(1); - } -} - -/* ============================================================================ -RESPONSIVE DESIGN - Mobile and Tablet -============================================================================ */ - -@media (max-width: 768px) { - .integration-steps { - flex-direction: column; - gap: 16px; - padding: 16px 12px; - } - - .step { - min-width: auto; - flex-direction: row; - align-items: center; - gap: 12px; - justify-content: flex-start; - text-align: left; - } - - .step-indicator { - margin-bottom: 0; - width: 28px; - height: 28px; - font-size: 12px; - } - - .step-label { - font-size: 14px; - max-width: none; - } - - .step-connector { - display: none; - } - - .enhanced-aircraft-info-panel { - padding: 10px 12px; - /* Matches constraint-message mobile padding */ - margin: 8px 0; - /* Matches constraint-message mobile margin */ - max-width: 100%; - /* Matches constraint-message mobile width */ - } - - .aircraft-info-content { - gap: 10px; - /* Matches constraint-message mobile gap */ - } - - .aircraft-info-icon { - font-size: 1rem; - /* 16px - matches constraint-message mobile icon size */ - } - - .aircraft-info-title { - font-size: 0.8125rem; - /* 13px - matches constraint-message mobile title size */ - } - - .detail-row { - font-size: 0.75rem; - /* 12px - matches constraint-message mobile description size */ - } -} - -@media (max-width: 480px) { - .integration-steps { - padding: 12px 8px; - } - - .step-indicator { - width: 24px; - height: 24px; - font-size: 11px; - } - - .step-label { - font-size: 13px; - } - - .partner-selection-with-indicator { - flex-direction: column; - align-items: flex-start; - gap: 8px; - } - - .enhanced-aircraft-info-panel { - padding: 10px; - margin: 8px 0; - } - - .aircraft-info-icon { - font-size: 1rem; - /* Consistent with tablet size */ - } - - .aircraft-info-title { - font-size: 0.75rem; - /* Smaller for mobile screens */ - } - - .detail-row { - font-size: 0.7rem; - /* Smaller for mobile screens */ - } -} - -/* ============================================================================ -INPUT FIELD WITH INLINE CONSTRAINT - Detached Mode Pattern -============================================================================ */ - -.input-with-inline-constraint { - display: flex; - align-items: center; - gap: 8px; -} - -.input-with-inline-constraint label { - white-space: nowrap; -} - -/* Position the constraint trigger button closer to dropdown */ -.input-with-inline-constraint ::ng-deep .agm-constraint-trigger { - margin-right: 32px; -} \ No newline at end of file diff --git a/Development/client/src/app/entities/vehicle/vehicle-partner-integration/vehicle-partner-integration.component.html b/Development/client/src/app/entities/vehicle/vehicle-partner-integration/vehicle-partner-integration.component.html deleted file mode 100644 index f6747bf..0000000 --- a/Development/client/src/app/entities/vehicle/vehicle-partner-integration/vehicle-partner-integration.component.html +++ /dev/null @@ -1,263 +0,0 @@ -
- -
- -
- - - {{ partner.label }} - - - - - - - - - - {{ Labels.LOADING_PARTNERS }} - - - - - -
-
- - -
-
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
-
-
- - -
-
-

{{ Labels.AIRCRAFT_INTEGRATION }}

- - -
- -
-
- - 1 -
- {{ Labels.SELECT_PARTNER_SYSTEM }} -
- -
- - -
-
- - - 2 -
- {{ Labels.VALIDATE_PARTNER_ACCOUNT }} -
- -
- - -
-
- - - 3 -
- {{ Labels.SELECT_PARTNER_AIRCRAFT }} -
- - - -
- -
-
- - 4 -
- - {{ Labels.SELECT_SYSTEM_TYPE_PLACEHOLDER }} - -
-
-
-
- - -
- -
-
- - {{ Labels.LOADING_AVAILABLE_AIRCRAFT }} -
-
- - -
-
- - - - {{ aircraft.label }} ({{ aircraft.value - }}) - - -
- - -
- - - - {{ systemType.label }} - - -
- - -
- -
-
- - -
- - -
- - -
- - -
-
- - -
-
-
- - - - - - - -
-
- - -
- -
-
-
-
\ No newline at end of file diff --git a/Development/client/src/app/entities/vehicle/vehicle-partner-integration/vehicle-partner-integration.component.ts b/Development/client/src/app/entities/vehicle/vehicle-partner-integration/vehicle-partner-integration.component.ts deleted file mode 100644 index 5813d0f..0000000 --- a/Development/client/src/app/entities/vehicle/vehicle-partner-integration/vehicle-partner-integration.component.ts +++ /dev/null @@ -1,1211 +0,0 @@ -import { Component, OnInit, Input, Output, EventEmitter, OnDestroy, ChangeDetectorRef, ViewChild } from '@angular/core'; -import { Router } from '@angular/router'; -import { SelectItem } from 'primeng/api'; -import { Subscription } from 'rxjs'; - -import { Vehicle } from '../../models/vehicle.model'; -import { PartnerService, PartnerAircraftResponse } from '@app/partners/services/partner.service'; -import { Partner } from '@app/partners/models/partner.model'; -import { BaseComp } from '@app/shared/base/base.component'; -import { SourceSystem, OperationalStatus, Labels, SystemTypes } from '@app/shared/global'; -import { PartnerUtilsService } from '@app/shared/services/partner-utils.service'; -import { BadgeFactoryService } from '@app/shared/services/badge-factory.service'; -import { BadgeConfig } from '@app/shared/badge/badge-config.model'; -import { handlePartnerErr, partnerErrorCode } from '@app/profile/common'; -import { ConstraintMessageComponent } from '@app/shared/constraint-message/constraint-message.component'; - -// ============================================================================ -// INTERFACES -// ============================================================================ - -interface PartnerSystemValidation { - accountExists: boolean; - authenticationValid: boolean; - isValidating: boolean; - validationError: string | null; - lastValidated: Date | null; -} - -export interface PartnerIntegrationData { - selectedPartner: string; - selectedPartnerData: Partner | null; - selectedPartnerAircraft: string; - selectedPartnerAircraftDetails: any; - partnerValidation: PartnerSystemValidation; - tailNumber?: string; - systemType?: string; // Add system type for Satloc partners -} - -// ============================================================================ -// COMPONENT -// ============================================================================ - -@Component({ - selector: 'agm-vehicle-partner-integration', - templateUrl: './vehicle-partner-integration.component.html', - styleUrls: ['./vehicle-partner-integration.component.css'] -}) -export class VehiclePartnerIntegrationComponent extends BaseComp implements OnInit, OnDestroy { - - // ============================================================================ - // INPUTS & OUTPUTS - // ============================================================================ - - @Input() vehicle: Vehicle; - @Input() isNew: boolean = false; - - // Function to get account editor data from parent component - @Input() getAccountEditorData: () => any; - - @Output() partnerDataChange = new EventEmitter(); - @Output() validationStateChange = new EventEmitter(); - @Output() tailNumberChange = new EventEmitter(); - - // ViewChild references for constraint messages - @ViewChild('partnerValidationConstraint') partnerValidationConstraint: ConstraintMessageComponent; - - // ============================================================================ - // CONSTANTS & READONLY PROPERTIES - // ============================================================================ - - readonly SourceSystem = SourceSystem; - readonly Labels = Labels; - - // Session storage keys for partner integration state persistence - private readonly STORAGE_KEYS = { - SELECTED_PARTNER: 'vehiclePartnerIntegration_selectedPartner', - VEHICLE_ID: 'vehiclePartnerIntegration_vehicleId', - FORM_DATA: 'vehicleEdit_formData' - } as const; - - // ============================================================================ - // PARTNER INTEGRATION PROPERTIES - // ============================================================================ - - // Partner selection - partnerOptions: SelectItem[] = [ - { label: '', value: SourceSystem.AGNAV } // Will be set in loadPartners using agmissionNativeDisplayName - ]; - selectedPartner: string = SourceSystem.AGNAV; - selectedPartnerData: Partner | null = null; - partners: Partner[] = []; - - // Partner aircraft selection - partnerAircraftOptions: SelectItem[] = []; - selectedPartnerAircraft: string = ''; - selectedPartnerAircraftDetails: any = null; - - // System type selection (for Satloc partners only) - systemTypeOptions: SelectItem[] = []; - selectedSystemType: string = ''; - - // Partner validation state - partnerValidation: PartnerSystemValidation = { - accountExists: false, - authenticationValid: false, - isValidating: false, - validationError: null, - lastValidated: null - }; - - // Loading states - partnersLoading: boolean = false; - partnerAircraftLoading: boolean = false; - partnerAircraftError: string = ''; - - // Store the actual aircraft data from API response - private loadedPartnerAircraft: any[] = []; - - // Consolidated cache objects for better organization - private systemUsersCache = { - users: [] as any[], - partnerId: '', - customerId: '' - }; - - private partnerAircraftCache = { - aircraft: [] as any[], - partnerId: '', - customerId: '', - selection: '', - details: null as any - }; - - // Track previous partner state for proper caching - private previousPartnerState: { - partnerId: string; - aircraftSelection: string; - aircraftDetails: any; - loadedAircraft: any[]; - } | null = null; - - // ============================================================================ - // PRIVATE PROPERTIES - // ============================================================================ - - private subscriptions = new Subscription(); - - // ============================================================================ - // CONSTRUCTOR - // ============================================================================ - - constructor( - private readonly partnerService: PartnerService, - private readonly partnerUtils: PartnerUtilsService, - private readonly badgeFactory: BadgeFactoryService, - private readonly cdr: ChangeDetectorRef, - protected readonly router: Router - ) { - super(); - } - - // ============================================================================ - // LIFECYCLE METHODS - // ============================================================================ - - ngOnInit() { - this.initializeSystemTypes(); - this.loadPartners(); - } - - ngOnDestroy() { - this.subscriptions.unsubscribe(); - } - - // ============================================================================ - // PARTNER INTEGRATION - INITIALIZATION - // ============================================================================ - - private initializeSystemTypes(): void { - // Initialize Satloc system type options (only the three Satloc-specific types) - this.systemTypeOptions = [ - { label: 'G4', value: SystemTypes.G4 }, - { label: 'Bantam2', value: SystemTypes.BANTAM2 }, - { label: 'Falcon', value: SystemTypes.FALCON } - ]; - } - - private loadPartners(): void { - this.partnersLoading = true; - - const subscription = this.partnerService.getPartners().subscribe({ - next: (partners: Partner[]) => { - this.partners = partners.filter(p => p.active); - - this.partnerOptions = [ - { label: this.agmissionNativeDisplayName, value: SourceSystem.AGNAV }, // AgMission (brand) + Native (translatable) - ...this.partners - .filter(partner => partner.partnerCode) // Only include partners with partnerCode - .map(partner => ({ - label: partner.partnerCode!, // Use partnerCode as label for consistency - value: partner._id! // Keep _id as value for partner identification - })) - ]; - - this.partnersLoading = false; - this.initializePartnerIntegration(); - }, - error: () => { - this.partnersLoading = false; - this.partnerOptions = [ - { label: this.agmissionNativeDisplayName, value: SourceSystem.AGNAV } // AgMission (brand) + Native (translatable) for fallback - ]; - this.initializePartnerIntegration(); - } - }); - - this.subscriptions.add(subscription); - } - - private initializePartnerIntegration(): void { - // Check for partner restoration from session storage (after account creation return) - const storedPartner = sessionStorage.getItem(this.STORAGE_KEYS.SELECTED_PARTNER); - const storedVehicleId = sessionStorage.getItem(this.STORAGE_KEYS.VEHICLE_ID); - - if (storedPartner && storedVehicleId === (this.vehicle?._id || '')) { - // Clear the stored partner and vehicle ID - sessionStorage.removeItem(this.STORAGE_KEYS.SELECTED_PARTNER); - sessionStorage.removeItem(this.STORAGE_KEYS.VEHICLE_ID); - - // Restore the partner selection - this.selectedPartner = storedPartner; - this.selectedPartnerData = this.partners.find(p => p._id === storedPartner) || null; - - // Validate the restored partner system (will trigger loadPartnerAircraft which will restore aircraft) - if (this.partnerUtils.isPartnerSystem(this.selectedPartner)) { - this.validatePartnerSystem(this.selectedPartner); - } else { - this.resetPartnerValidation(); - } - } else if (!this.isNew && this.vehicle) { - // Determine partner selection from saved data - if (this.vehicle.partnerInfo?.partner) { - this.selectedPartner = this.vehicle.partnerInfo.partner; - } else if (this.vehicle.partnerSystem) { - this.selectedPartner = this.vehicle.partnerSystem; - } else { - this.selectedPartner = SourceSystem.AGNAV; - } - - if (this.partnerUtils.isPartnerSystem(this.selectedPartner)) { - this.selectedPartnerData = this.partners.find(p => p._id === this.selectedPartner) || null; - - // Restore saved aircraft data - if (this.vehicle.partnerInfo?.partnerAircraftId) { - this.selectedPartnerAircraft = this.vehicle.partnerInfo.partnerAircraftId; - } - if (this.vehicle.partnerInfo?.metadata?.aircraftData) { - this.selectedPartnerAircraftDetails = this.vehicle.partnerInfo.metadata.aircraftData; - } - - // Restore system type data - if (this.vehicle.partnerInfo?.systemType) { - this.selectedSystemType = this.vehicle.partnerInfo.systemType; - } - - // Validate partner system - this.validatePartnerSystem(this.selectedPartner); - } else { - this.resetPartnerValidation(); - } - } else { - // For new vehicles, use default AgNav selection - this.resetPartnerValidation(); - } - - // Initialize previous state tracker after setting up initial state - this.updatePreviousPartnerState(); - - // Emit initial state - this.emitPartnerDataChange(); - } - - // ============================================================================ - // DISPLAY NAME HELPERS - // ============================================================================ - - /** - * Get AgMission Native display name with proper brand name + translated 'Native' term - * Brand name 'AgMission' remains untranslated, 'Native' gets translated - */ - get agmissionNativeDisplayName(): string { - return `${Labels.AGMISSION_BRAND_NAME} ${Labels.NATIVE_SYSTEM_TYPE}`; - } - - // ============================================================================ - // FORM DATA RESTORATION HELPERS - // ============================================================================ - - /** - * Check if there's stored form data that needs to be restored after account creation - * This method should be called by the parent vehicle-edit component - */ - getStoredFormData(): any | null { - const storedData = sessionStorage.getItem(this.STORAGE_KEYS.FORM_DATA); - if (storedData) { - try { - const formData = JSON.parse(storedData); - // Clear the stored data after retrieval - sessionStorage.removeItem(this.STORAGE_KEYS.FORM_DATA); - return formData; - } catch (error) { - console.error('Error parsing stored form data:', error); - sessionStorage.removeItem(this.STORAGE_KEYS.FORM_DATA); - } - } - return null; - } - - // ============================================================================ - // PARTNER INTEGRATION - USER ACTIONS - // ============================================================================ - - onPartnerChange(): void { - // Save previous partner state to cache - this.savePreviousPartnerStateToCache(); - - // Reset aircraft selection for UI - this.selectedPartnerAircraft = ''; - this.selectedPartnerAircraftDetails = null; - this.partnerAircraftOptions = []; - this.partnerAircraftError = ''; - this.loadedPartnerAircraft = []; - - // Reset system type selection - this.selectedSystemType = ''; - - // Clear system users cache for new partner - this.clearSystemUsersCache(); - - // Clear tail number when switching to partner system (will be restored if cache exists) - if (this.partnerUtils.isPartnerSystem(this.selectedPartner)) { - this.tailNumberChange.emit(''); - } - - // Update partner data - if (this.partnerUtils.isPartnerSystem(this.selectedPartner)) { - this.selectedPartnerData = this.partners.find(p => p._id === this.selectedPartner) || null; - - // Try to restore aircraft data from cache - const cacheRestored = this.restorePartnerAircraftFromCache(); - - if (cacheRestored) { - // Cache was restored, validate without reloading aircraft - this.validatePartnerSystemWithoutReloadingAircraft(); - } else { - // No cache available, do full validation with aircraft loading - this.validatePartnerSystem(this.selectedPartner); - } - } else { - this.selectedPartnerData = null; - this.resetPartnerValidation(); - } - - // Update previous state tracker for next change - this.updatePreviousPartnerState(); - this.emitPartnerDataChange(); - } - - onPartnerAircraftChange(): void { - if (!this.selectedPartnerAircraft) { - this.selectedPartnerAircraftDetails = null; - if (this.partnerUtils.isPartnerSystem(this.selectedPartner)) { - this.tailNumberChange.emit(''); - } - this.emitPartnerDataChange(); - return; - } - - // Find aircraft data from the loaded partner aircraft - const aircraftData = this.loadedPartnerAircraft.find(aircraft => - aircraft.id === this.selectedPartnerAircraft - ); - - if (aircraftData) { - this.selectedPartnerAircraftDetails = { - ...aircraftData, - partnerSystem: this.selectedPartnerData?.partnerCode || this.selectedPartnerData?.name || this.selectedPartner, - partnerId: this.selectedPartner, - syncStatus: OperationalStatus.PENDING, - connectionStatus: OperationalStatus.CONNECTED - }; - - if (aircraftData.tailNumber) { - this.tailNumberChange.emit(aircraftData.tailNumber); - } - } else { - // Clear details if aircraft not found - this.selectedPartnerAircraftDetails = null; - this.tailNumberChange.emit(''); - } - - // Update previous state tracker whenever aircraft selection changes - this.updatePreviousPartnerState(); - - this.emitPartnerDataChange(); - this.emitValidationStateChange(); // Trigger validation when aircraft selection changes - } - - onSystemTypeChange(): void { - // Emit partner data change to notify parent component - this.emitPartnerDataChange(); - this.emitValidationStateChange(); // Trigger validation when system type changes - } - - // ============================================================================ - // PARTNER SYSTEM VALIDATION - // ============================================================================ - - async validatePartnerSystem(partnerType: string): Promise { - if (this.partnerUtils.isNativeSystem(partnerType)) { - this.resetPartnerValidation(); - return; - } - - this.partnerValidation.isValidating = true; - this.partnerValidation.validationError = null; - this.emitValidationStateChange(); - - try { - this.partnerValidation.accountExists = await this.checkPartnerAccount(partnerType); - - if (this.partnerValidation.accountExists) { - this.partnerValidation.authenticationValid = await this.validateAuthentication(partnerType); - } else { - this.partnerValidation.authenticationValid = false; - } - - this.partnerValidation.lastValidated = new Date(); - this.updateFormFieldStates(); - - if (this.partnerValidation.accountExists && this.partnerValidation.authenticationValid) { - this.loadPartnerAircraft(); - } - - } catch (error) { - this.partnerValidation.validationError = error.message || - Labels.PARTNER_VALIDATION_ERROR.replace('{error}', Labels.UNKNOWN_ERROR); - this.partnerValidation.accountExists = false; - this.partnerValidation.authenticationValid = false; - this.disablePartnerDependentFields(); - console.error('Partner system validation failed:', error); - } finally { - this.partnerValidation.isValidating = false; - this.emitValidationStateChange(); - this.emitPartnerDataChange(); - } - } - - /** - * Validate partner system without reloading aircraft (used when cache is restored) - */ - async validatePartnerSystemWithoutReloadingAircraft(): Promise { - if (this.partnerUtils.isNativeSystem(this.selectedPartner)) { - this.resetPartnerValidation(); - return; - } - - this.partnerValidation.isValidating = true; - this.partnerValidation.validationError = null; - this.emitValidationStateChange(); - - try { - this.partnerValidation.accountExists = await this.checkPartnerAccount(this.selectedPartner); - - if (this.partnerValidation.accountExists) { - this.partnerValidation.authenticationValid = await this.validateAuthentication(this.selectedPartner); - } else { - this.partnerValidation.authenticationValid = false; - } - - this.partnerValidation.lastValidated = new Date(); - this.updateFormFieldStates(); - - // Don't call loadPartnerAircraft() since we already have cached data - - } catch (error) { - this.partnerValidation.validationError = error.message || - Labels.PARTNER_VALIDATION_ERROR.replace('{error}', Labels.UNKNOWN_ERROR); - this.partnerValidation.accountExists = false; - this.partnerValidation.authenticationValid = false; - this.disablePartnerDependentFields(); - console.error('Partner system validation failed:', error); - } finally { - this.partnerValidation.isValidating = false; - this.emitValidationStateChange(); - this.emitPartnerDataChange(); - } - } - - private async checkPartnerAccount(partnerType: string): Promise { - try { - const currentCustomerId = this.authSvc.byPUserId; - if (!currentCustomerId) { - return false; - } - - const systemUsers = await this.getSystemUsersForCurrentPartner(); - - const accountExists = systemUsers && systemUsers.length > 0; - - return accountExists; - } catch (error) { - console.error('Error checking partner account:', error); - throw new Error(`Failed to verify partner system account: ${error.message}`); - } - } - - /** - * Validate partner authentication using centralized service method - */ - private async validateAuthentication(partnerType: string): Promise { - try { - const currentCustomerId = this.authSvc.byPUserId; - if (!currentCustomerId) { - return false; - } - - // Use centralized validation method - const result = await this.partnerService.validatePartnerAuthentication( - currentCustomerId, - partnerType - ); - - if (!result.isValid && result.errorMessage) { - console.error('Partner authentication validation failed:', result.errorMessage); - } - - return result.isValid; - } catch (error) { - console.error('Error validating authentication:', error); - throw new Error(`Failed to validate partner system authentication: ${error.message}`); - } - } - - private resetPartnerValidation(): void { - this.partnerValidation = { - accountExists: false, - authenticationValid: false, - isValidating: false, - validationError: null, - lastValidated: null - }; - this.updateFormFieldStates(); - this.emitValidationStateChange(); - } - - // ============================================================================ - // SYSTEM USERS CACHING - // ============================================================================ - - /** - * Get system users for current partner/customer combination with caching - * Avoids repeated API calls for the same data during validation process - */ - private async getSystemUsersForCurrentPartner(): Promise { - const currentCustomerId = this.authSvc.byPUserId; - - if (!currentCustomerId || !this.selectedPartner) { - return []; - } - - // Check if we have cached data for the same partner/customer combination - if (this.systemUsersCache.users.length > 0 && - this.systemUsersCache.partnerId === this.selectedPartner && - this.systemUsersCache.customerId === currentCustomerId) { - return this.systemUsersCache.users; - } - - try { - // Fetch fresh data and cache it - const systemUsers = await this.partnerService.getSystemUsers( - this.selectedPartner, - currentCustomerId - ).toPromise(); - - // Cache the results - this.systemUsersCache.users = systemUsers || []; - this.systemUsersCache.partnerId = this.selectedPartner; - this.systemUsersCache.customerId = currentCustomerId; - - return this.systemUsersCache.users; - } catch (error) { - console.error('Error fetching system users:', error); - return []; - } - } - - /** - * Clear system users cache when partner changes - */ - private clearSystemUsersCache(): void { - this.systemUsersCache.users = []; - this.systemUsersCache.partnerId = ''; - this.systemUsersCache.customerId = ''; - } - - /** - * Save previous partner state to cache if it was a partner system with data - */ - private savePreviousPartnerStateToCache(): void { - if (this.previousPartnerState && - this.partnerUtils.isPartnerSystem(this.previousPartnerState.partnerId)) { - - const currentCustomerId = this.authSvc.byPUserId; - if (!currentCustomerId || this.previousPartnerState.loadedAircraft.length === 0) { - return; - } - - this.partnerAircraftCache.aircraft = [...this.previousPartnerState.loadedAircraft]; - this.partnerAircraftCache.partnerId = this.previousPartnerState.partnerId; - this.partnerAircraftCache.customerId = currentCustomerId; - this.partnerAircraftCache.selection = this.previousPartnerState.aircraftSelection; - this.partnerAircraftCache.details = this.previousPartnerState.aircraftDetails ? - { ...this.previousPartnerState.aircraftDetails } : null; - } - } - - /** - * Update previous partner state tracker for next change - */ - private updatePreviousPartnerState(): void { - this.previousPartnerState = { - partnerId: this.selectedPartner, - aircraftSelection: this.selectedPartnerAircraft, - aircraftDetails: this.selectedPartnerAircraftDetails ? - { ...this.selectedPartnerAircraftDetails } : null, - loadedAircraft: [...this.loadedPartnerAircraft] - }; - } - - /** - * Restore partner aircraft state from cache if available - * Returns true if cache was restored, false if no cache available - */ - private restorePartnerAircraftFromCache(): boolean { - const currentCustomerId = this.authSvc.byPUserId; - if (this.partnerAircraftCache.aircraft.length > 0 && - this.partnerAircraftCache.partnerId === this.selectedPartner && - this.partnerAircraftCache.customerId === currentCustomerId) { - - // Restore aircraft data - this.loadedPartnerAircraft = [...this.partnerAircraftCache.aircraft]; - this.partnerAircraftOptions = this.loadedPartnerAircraft.map(aircraft => ({ - label: aircraft.tailNumber || aircraft.name || aircraft.id, - value: aircraft.id - })); - - // Restore selection - this.selectedPartnerAircraft = this.partnerAircraftCache.selection; - this.selectedPartnerAircraftDetails = this.partnerAircraftCache.details ? - { ...this.partnerAircraftCache.details } : null; - - // Emit the restored tail number if we have aircraft details - if (this.selectedPartnerAircraftDetails?.tailNumber) { - this.tailNumberChange.emit(this.selectedPartnerAircraftDetails.tailNumber); - } - - // Force Angular change detection to update the UI - this.cdr.detectChanges(); - - return true; - } - return false; - } - - /** - * Clear aircraft cache when needed - */ - private clearPartnerAircraftCache(): void { - this.partnerAircraftCache.aircraft = []; - this.partnerAircraftCache.partnerId = ''; - this.partnerAircraftCache.customerId = ''; - this.partnerAircraftCache.selection = ''; - this.partnerAircraftCache.details = null; - this.previousPartnerState = null; - } - - // ============================================================================ - // PARTNER AIRCRAFT LOADING - // ============================================================================ - - private loadPartnerAircraft(): void { - if (this.partnerUtils.isNativeSystem(this.selectedPartner) || !this.selectedPartnerData) { - return; - } - - if (!this.partnerValidation.accountExists || !this.partnerValidation.authenticationValid) { - return; - } - - const currentCustomerId = this.authSvc.byPUserId; - if (!currentCustomerId) { - return; - } - - // Check if we already have cached data for this partner - if (this.partnerAircraftCache.aircraft.length > 0 && - this.partnerAircraftCache.partnerId === this.selectedPartner && - this.partnerAircraftCache.customerId === currentCustomerId) { - - // Use cached data - no need to call API - this.loadedPartnerAircraft = [...this.partnerAircraftCache.aircraft]; - this.partnerAircraftOptions = this.loadedPartnerAircraft.map(aircraft => ({ - label: aircraft.tailNumber || aircraft.name || aircraft.id, - value: aircraft.id - })); - - // Restore previous selection if it exists - if (this.partnerAircraftCache.selection) { - this.selectedPartnerAircraft = this.partnerAircraftCache.selection; - this.selectedPartnerAircraftDetails = this.partnerAircraftCache.details ? - { ...this.partnerAircraftCache.details } : null; - } - - return; - } - - this.partnerAircraftLoading = true; - this.partnerAircraftError = ''; - - // Call the real API endpoint - this.partnerService.getPartnerAircraft(this.selectedPartner, currentCustomerId) - .subscribe({ - next: (response: PartnerAircraftResponse) => { - this.partnerAircraftLoading = false; - - if (response.success && response.aircraft) { - // Store the actual aircraft data for later use - this.loadedPartnerAircraft = response.aircraft; - - this.partnerAircraftOptions = response.aircraft.map(aircraft => ({ - label: aircraft.tailNumber || aircraft.name || aircraft.id, - value: aircraft.id - })); - - // Cache the newly loaded data for future use - this.partnerAircraftCache.aircraft = [...response.aircraft]; - this.partnerAircraftCache.partnerId = this.selectedPartner; - this.partnerAircraftCache.customerId = currentCustomerId; - - // Check for session storage restoration (from account creation return) - const storedAircraftId = sessionStorage.getItem('vehiclePartnerIntegration_partnerAircraft'); - const storedSystemType = sessionStorage.getItem('vehiclePartnerIntegration_systemType'); - const storedAircraftDetails = sessionStorage.getItem('vehiclePartnerIntegration_aircraftDetails'); - - if (storedAircraftId || storedSystemType) { - // Restore aircraft selection - if (storedAircraftId) { - this.selectedPartnerAircraft = storedAircraftId; - sessionStorage.removeItem('vehiclePartnerIntegration_partnerAircraft'); - } - - // Restore system type - if (storedSystemType) { - this.selectedSystemType = storedSystemType; - sessionStorage.removeItem('vehiclePartnerIntegration_systemType'); - } - - // Restore aircraft details - if (storedAircraftDetails) { - try { - this.selectedPartnerAircraftDetails = JSON.parse(storedAircraftDetails); - } catch (e) { - console.error('Failed to parse stored aircraft details:', e); - } - sessionStorage.removeItem('vehiclePartnerIntegration_aircraftDetails'); - } - - // If we have aircraft ID but no details, find it from the loaded aircraft - if (this.selectedPartnerAircraft && !this.selectedPartnerAircraftDetails) { - const aircraftData = response.aircraft.find(a => a.id === this.selectedPartnerAircraft); - if (aircraftData) { - this.selectedPartnerAircraftDetails = { - ...aircraftData, - partnerSystem: this.selectedPartnerData?.partnerCode || this.selectedPartnerData?.name || this.selectedPartner, - partnerId: this.selectedPartner, - syncStatus: OperationalStatus.PENDING, - connectionStatus: OperationalStatus.CONNECTED - }; - } - } - - // Emit tail number if restored - if (this.selectedPartnerAircraftDetails?.tailNumber) { - this.tailNumberChange.emit(this.selectedPartnerAircraftDetails.tailNumber); - } - } - - // Update previous state tracker after successful load - this.updatePreviousPartnerState(); - - // Ensure no aircraft is auto-selected - if (!this.selectedPartnerAircraft && this.partnerUtils.isPartnerSystem(this.selectedPartner)) { - this.tailNumberChange.emit(''); - } - - } else { - // Handle partner API error response using structured error handling - const errorResult = handlePartnerErr(response); - this.partnerAircraftLoading = false; - this.partnerAircraftError = errorResult.message; - this.partnerAircraftOptions = []; - this.loadedPartnerAircraft = []; - } - }, - error: (error) => { - // Handle HTTP/network errors using structured error handling - const errorResult = handlePartnerErr(error); - this.partnerAircraftLoading = false; - this.partnerAircraftError = errorResult.message; - this.partnerAircraftOptions = []; - this.loadedPartnerAircraft = []; - } - }); - } - - // ============================================================================ - // FORM FIELD STATE MANAGEMENT - // ============================================================================ - - private updateFormFieldStates(): void { - if (this.partnerValidation.accountExists && this.partnerValidation.authenticationValid) { - // Partner fields are enabled by default when validation passes - // No additional field enabling logic needed at this time - } else { - this.disablePartnerDependentFields(); - } - } - - private disablePartnerDependentFields(): void { - const tailNumberFromPartner = this.partnerUtils.isPartnerSystem(this.selectedPartner) && - this.selectedPartnerAircraftDetails?.tailNumber; - - this.selectedPartnerAircraft = ''; - this.selectedPartnerAircraftDetails = null; - - if (tailNumberFromPartner) { - this.tailNumberChange.emit(''); - } - - } - - // ============================================================================ - // NAVIGATION HELPERS - // ============================================================================ - - navigateToAccountCreation(): void { - const currentCustomerId = this.authSvc.byPUserId; - - if (!currentCustomerId || !this.selectedPartner) { - return; - } - - // Get aircraft data - prefer component state, fallback to vehicle saved data - const aircraftId = this.selectedPartnerAircraft || this.vehicle?.partnerInfo?.partnerAircraftId || ''; - const systemType = this.selectedSystemType || this.vehicle?.partnerInfo?.systemType || ''; - const aircraftDetails = this.selectedPartnerAircraftDetails || this.vehicle?.partnerInfo?.metadata?.aircraftData || null; - - // Store the current partner selection in session storage to restore after return - sessionStorage.setItem(this.STORAGE_KEYS.SELECTED_PARTNER, this.selectedPartner); - sessionStorage.setItem(this.STORAGE_KEYS.VEHICLE_ID, this.vehicle?._id || ''); - - // Store aircraft data (from component or vehicle saved data) - if (aircraftId) { - sessionStorage.setItem('vehiclePartnerIntegration_partnerAircraft', aircraftId); - } - - if (systemType) { - sessionStorage.setItem('vehiclePartnerIntegration_systemType', systemType); - } - - if (aircraftDetails) { - sessionStorage.setItem('vehiclePartnerIntegration_aircraftDetails', JSON.stringify(aircraftDetails)); - } - - // Get current account editor data from parent if available - const accountEditorData = this.getAccountEditorData ? this.getAccountEditorData() : null; - - // Store all vehicle form data to preserve user input during account creation flow - if (this.vehicle) { - const formData = { - name: this.vehicle.name || '', - vehicleType: this.vehicle.vehicleType || '', - model: this.vehicle.model || '', - tailNumber: this.vehicle.tailNumber || '', - unitId: this.vehicle.unitId || '', - desc: this.vehicle.desc || '', - color: this.vehicle.color || '', - // Store account credentials if they exist (from vehicle object) - username: this.vehicle.username || '', - password: this.vehicle.password || '', - active: this.vehicle.active, - // Store account editor data if provided (current form state) - accountEditor: accountEditorData || null - }; - - sessionStorage.setItem(this.STORAGE_KEYS.FORM_DATA, JSON.stringify(formData)); - } - - // Get the partner code (human-readable label) instead of just the ID - // This ensures the account-edit component can immediately match the vendor dropdown - const partnerCode = this.selectedPartnerData?.partnerCode || this.selectedPartnerData?.name; - - this.router.navigate(['/accounts/account', '0'], { - queryParams: { - partner: this.selectedPartner, // Keep partner ID for backend operations - partnerCode: partnerCode, // Add partnerCode for vendor dropdown matching - returnTo: 'vehicle-edit', - vehicleId: this.vehicle?._id, - customerId: currentCustomerId, - accountDoesNotExist: true // Flag to indicate Account Does Not Exist flow - } - }); - } - - async navigateToAccountEdit(): Promise { - try { - const currentCustomerId = this.authSvc.byPUserId; - if (!currentCustomerId || !this.selectedPartner) { - return; - } - - // Get the system users to find the account ID - const systemUsers = await this.getSystemUsersForCurrentPartner(); - - if (!systemUsers || systemUsers.length === 0) { - return; - } - - // Use the active system user's ID for navigation (not necessarily the first in the array) - const activeUser = systemUsers.find(u => u.active) || systemUsers[0]; - const accountId = activeUser._id; - - this.router.navigate(['/accounts/account', accountId], { - queryParams: { - returnTo: 'vehicle-edit', - vehicleId: this.vehicle?._id, - partner: this.selectedPartner, - customerId: currentCustomerId - } - }); - } catch (error) { - console.error('Error navigating to account edit:', error); - // Fallback to generic account creation if account lookup fails - this.navigateToAccountCreation(); - } - } - - // ============================================================================ - // EVENT EMITTERS - // ============================================================================ - - private emitPartnerDataChange(): void { - const partnerData: PartnerIntegrationData = { - selectedPartner: this.selectedPartner, - selectedPartnerData: this.selectedPartnerData, - selectedPartnerAircraft: this.selectedPartnerAircraft, - selectedPartnerAircraftDetails: this.selectedPartnerAircraftDetails, - partnerValidation: { ...this.partnerValidation }, - systemType: this.selectedSystemType || undefined // Include system type for Satloc partners - }; - - // Include tail number if from partner aircraft - if (this.selectedPartnerAircraftDetails?.tailNumber) { - partnerData.tailNumber = this.selectedPartnerAircraftDetails.tailNumber; - } - - this.partnerDataChange.emit(partnerData); - } - - private emitValidationStateChange(): void { - // For AgMission native, always valid - if (!this.isPartnerSystemSelected) { - this.validationStateChange.emit(true); - return; - } - - // For partner systems, check all required fields - const isValid = this.partnerValidation.accountExists && - this.partnerValidation.authenticationValid && - this.isPartnerIntegrationComplete(); - - this.validationStateChange.emit(isValid); - } - - /** - * Check if partner integration is complete based on partner type - */ - private isPartnerIntegrationComplete(): boolean { - // Must have selected an available aircraft from partner system - if (!this.selectedPartnerAircraft) { - return false; - } - - // For Satloc partners, must also have system type selected - if (this.isSatlocPartnerSelected && !this.selectedSystemType) { - return false; - } - - return true; - } - - // ============================================================================ - // COMPUTED PROPERTIES - // ============================================================================ - - get isPartnerSystemSelected(): boolean { - return this.partnerUtils.isPartnerSystem(this.selectedPartner); - } - - get canEditPartnerFields(): boolean { - return this.partnerValidation.accountExists && - this.partnerValidation.authenticationValid && - !this.partnerValidation.isValidating; - } - - getIntegrationProgress(): number { - let step = 1; - - // Step 1: Partner selected - if (this.selectedPartner && this.selectedPartner !== SourceSystem.AGNAV) { - step = 2; - } - - // Step 2: Partner validated - if (this.canEditPartnerFields) { - step = 3; - } - - // Step 3: Aircraft selected - if (this.selectedPartnerAircraft) { - step = this.isSatlocPartnerSelected ? 4 : 3; // Satloc has 4 steps, others have 3 - } - - // Step 4: System Type selected (Satloc only) - if (this.isSatlocPartnerSelected && this.selectedSystemType) { - step = 4; - } - - return step; - } - - /** - * Get the maximum number of integration steps based on partner type - */ - getMaxIntegrationSteps(): number { - return this.isSatlocPartnerSelected ? 4 : 3; - } - - /** - * Check if the system type step is completed (Satloc partners only) - */ - get isSystemTypeStepCompleted(): boolean { - return this.isSatlocPartnerSelected ? !!this.selectedSystemType : true; // Non-Satloc always considered complete - } - - /** - * Human-friendly partner display name for UI (partnerCode > name > id) - * Provides consistent partner naming across the interface - */ - get partnerDisplayName(): string { - if (!this.selectedPartnerData) { - return this.selectedPartner; - } - return this.selectedPartnerData.partnerCode || - this.selectedPartnerData.name || - this.selectedPartner; - } - - /** - * Check if the selected partner is Satloc - */ - get isSatlocPartnerSelected(): boolean { - return this.selectedPartnerData?.partnerCode?.toLowerCase() === 'satloc' && this.isPartnerSystemSelected; - } - - /** - * Get the display label for the selected system type - */ - getSelectedSystemTypeLabel(): string { - if (!this.selectedSystemType) { - return ''; - } - - const systemTypeOption = this.systemTypeOptions.find(option => option.value === this.selectedSystemType); - return systemTypeOption ? systemTypeOption.label : this.selectedSystemType; - } - - /** - * Get badge configuration for partner integration status - */ - getPartnerIntegratedBadge(): BadgeConfig { - return this.badgeFactory.createActiveStatusBadge(Labels.PARTNER_INTEGRATED); - } - - /** - * Get validation success message with optional timestamp - */ - get validationSuccessMessage(): string { - let message = this.Labels.PARTNER_VALIDATION_SUCCESS_MESSAGE; - if (this.partnerValidation.lastValidated) { - const timestamp = new Date(this.partnerValidation.lastValidated).toLocaleString(); - message += ` (${this.Labels.LAST_VALIDATED}: ${timestamp})`; - } - return message; - } - - // ============================================================================ - // PUBLIC METHODS FOR PARENT COMPONENT - // ============================================================================ - - /** - * Prepare partner data for backend submission - * Called by parent component during save operation - */ - preparePartnerDataForBackend(): any { - if (this.partnerUtils.isPartnerSystem(this.selectedPartner) && this.selectedPartnerData) { - return { - partner: this.selectedPartnerData._id!, - partnerAircraftId: this.selectedPartnerAircraft || null, - systemType: this.selectedSystemType || SystemTypes.PLATINUM, // Include selected system type - metadata: { - partnerSystem: this.partnerUtils.getPartnerDisplayName(this.selectedPartnerData._id!), - partnerCode: this.selectedPartnerData.partnerCode, - aircraftData: this.selectedPartnerAircraftDetails, - syncStatus: this.selectedPartnerAircraftDetails ? OperationalStatus.PENDING : null, - lastSync: null, - connectionStatus: OperationalStatus.CONNECTED - } - }; - } else { - return { - partner: null, - partnerAircraftId: null, - systemType: this.selectedSystemType || SystemTypes.PLATINUM, // Preserve system type even for non-partner systems - metadata: null - }; - } - } - - /** - * Check if partner aircraft selection is required for save - */ - isPartnerAircraftRequired(): boolean { - return this.isPartnerSystemSelected && - this.partnerValidation.accountExists && - this.partnerValidation.authenticationValid; - } - - /** - * Get current partner aircraft selection state - */ - hasPartnerAircraftSelected(): boolean { - return !!this.selectedPartnerAircraft; - } - - /** - * Called when returning from account-edit with successful auth test - * Updates validation state without re-testing authentication - */ - public updateAuthenticationSuccess(): void { - // Check if we have session storage that needs to be restored first - const storedPartner = sessionStorage.getItem(this.STORAGE_KEYS.SELECTED_PARTNER); - const storedVehicleId = sessionStorage.getItem(this.STORAGE_KEYS.VEHICLE_ID); - - if (storedPartner && storedVehicleId === (this.vehicle?._id || '')) { - // Session storage exists, which means initializePartnerIntegration hasn't run yet - // It will be called soon and will handle the partner restoration - // Just update the validation state so when init runs, it can proceed - this.partnerValidation.accountExists = true; - this.partnerValidation.authenticationValid = true; - this.partnerValidation.isValidating = false; - this.partnerValidation.validationError = null; - this.partnerValidation.lastValidated = new Date(); - return; - } - - // No session storage, proceed normally - if (this.partnerUtils.isPartnerSystem(this.selectedPartner)) { - - // Update validation state without re-testing - this.partnerValidation.accountExists = true; - this.partnerValidation.authenticationValid = true; - this.partnerValidation.isValidating = false; - this.partnerValidation.validationError = null; - this.partnerValidation.lastValidated = new Date(); - - // Update form field states - this.updateFormFieldStates(); - - // Emit validation state change - this.emitValidationStateChange(); - this.emitPartnerDataChange(); - - // Load partner aircraft if auth is now valid - if (this.partnerValidation.accountExists && this.partnerValidation.authenticationValid) { - this.loadPartnerAircraft(); - } - } - } -} diff --git a/Development/client/src/app/guards/vendor.guard.ts b/Development/client/src/app/guards/vendor.guard.ts deleted file mode 100644 index 49274e2..0000000 --- a/Development/client/src/app/guards/vendor.guard.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Injectable } from '@angular/core'; -import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree } from '@angular/router'; -import { Observable } from 'rxjs'; - -@Injectable({ - providedIn: 'root' -}) -export class VendorGuard implements CanActivate { - canActivate( - next: ActivatedRouteSnapshot, - state: RouterStateSnapshot): Observable | Promise | boolean | UrlTree { - return true; - } - -} diff --git a/Development/client/src/app/invoices/costing-item/costing-item.component.html b/Development/client/src/app/invoices/costing-item/costing-item.component.html index c101ac5..900558c 100644 --- a/Development/client/src/app/invoices/costing-item/costing-item.component.html +++ b/Development/client/src/app/invoices/costing-item/costing-item.component.html @@ -1,7 +1,7 @@
- +
@@ -28,7 +28,7 @@ - + {{cols[0].header}}{{item.name}} {{cols[1].header}}{{item.type | costingItemType}} {{cols[2].header}}{{item.unit | costingItemUnit}} diff --git a/Development/client/src/app/invoices/costing-item/costing-item.component.ts b/Development/client/src/app/invoices/costing-item/costing-item.component.ts index 9508ca7..e78b65d 100644 --- a/Development/client/src/app/invoices/costing-item/costing-item.component.ts +++ b/Development/client/src/app/invoices/costing-item/costing-item.component.ts @@ -16,7 +16,6 @@ import { CurrencyPipe } from '@angular/common'; import { Dropdown } from 'primeng/dropdown'; import { MultiSelect } from 'primeng/multiselect'; import { DomUtils } from '@app/shared/dom-util'; -import { GAService } from '@app/shared/ga.service'; @Component({ selector: 'agm-costing-item', @@ -133,20 +132,6 @@ export class CostingItemComponent extends BaseComp implements OnInit, OnDestroy const payload = { ...this.selectedItem }; - - // Track costing item management - this.gaSvc.trackInvoiceCostingItemManaged({ - item_id: payload._id !== '0' ? payload._id : undefined, - item_type: this.mapItemType(payload.type), - unit_type: this.mapUnitType(payload.unit), - base_rate: payload.price || 0, - action_type: payload._id === '0' ? 'created' : 'updated', - affects_existing_invoices: payload._id !== '0', // Updates affect existing invoices - user_id: this.getAnalyticsUserId(), - user_role: this.getAnalyticsUserRole(), - platform: 'web' - }); - if (payload._id === '0') { this.store.dispatch(new costingItemActions.Create(payload)); } else { @@ -191,19 +176,6 @@ export class CostingItemComponent extends BaseComp implements OnInit, OnDestroy this.confirmSvc.confirm({ message: globals.confirmDeleteThing.replace('#thing#', $localize`:@@costingItem:Costing item`), accept: () => { - // Track costing item deletion - this.gaSvc.trackInvoiceCostingItemManaged({ - item_id: item._id, - item_type: this.mapItemType(item.type), - unit_type: this.mapUnitType(item.unit), - base_rate: item.price || 0, - action_type: 'deleted', - affects_existing_invoices: true, // Deletions affect existing invoices - user_id: this.getAnalyticsUserId(), - user_role: this.getAnalyticsUserRole(), - platform: 'web' - }); - this.store.dispatch(new costingItemActions.Delete(item)); } }); @@ -232,29 +204,4 @@ export class CostingItemComponent extends BaseComp implements OnInit, OnDestroy ngOnDestroy() { super.ngOnDestroy(); } - - private mapItemType(type: number): 'service' | 'material' | 'equipment' | 'labor' { - switch (type) { - case CostingItemType.BY_ACRE: - case CostingItemType.BY_HA: - return 'service'; - case CostingItemType.BY_AMOUNT: - return 'material'; - default: - return 'service'; - } - } - - private mapUnitType(unit: number): 'per_acre' | 'per_hour' | 'flat_rate' | 'per_unit' { - switch (unit) { - case CostingItemUnit.ACRE: - return 'per_acre'; - case CostingItemUnit.HOUR: - return 'per_hour'; - case CostingItemUnit.HA: - return 'per_unit'; - default: - return 'flat_rate'; - } - } } diff --git a/Development/client/src/app/invoices/customer-settings-list/customer-settings-list.component.html b/Development/client/src/app/invoices/customer-settings-list/customer-settings-list.component.html index e98624b..17f643d 100644 --- a/Development/client/src/app/invoices/customer-settings-list/customer-settings-list.component.html +++ b/Development/client/src/app/invoices/customer-settings-list/customer-settings-list.component.html @@ -1,7 +1,11 @@
- +
@@ -20,7 +24,8 @@
- +
@@ -31,20 +36,23 @@ {{cols[0].header}}{{client.name}} {{cols[1].header}}{{client.phone}} {{cols[2].header}}{{client.email}} - {{cols[3].header}}{{client.discount}} - {{cols[4].header}}{{client.tax}} + {{cols[3].header}}{{client.term}} + {{cols[4].header}}{{client.discount}} + {{cols[5].header}}{{client.tax}} - {{cols[5].header}} -
- + {{cols[6].header}} +
+
- +
-
\ No newline at end of file +
diff --git a/Development/client/src/app/invoices/customer-settings-list/customer-settings-list.component.ts b/Development/client/src/app/invoices/customer-settings-list/customer-settings-list.component.ts index 88f959e..e80eeff 100644 --- a/Development/client/src/app/invoices/customer-settings-list/customer-settings-list.component.ts +++ b/Development/client/src/app/invoices/customer-settings-list/customer-settings-list.component.ts @@ -1,11 +1,11 @@ -import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; -import { BaseComp } from '@app/shared/base/base.component'; -import { ActivatedRoute } from '@angular/router'; -import { globals } from '@app/shared/global'; -import { Table } from 'primeng/table'; +import {Component, OnDestroy, OnInit, ViewChild} from '@angular/core'; +import {BaseComp} from '@app/shared/base/base.component'; +import {ActivatedRoute} from '@angular/router'; +import {globals} from '@app/shared/global'; +import {Table} from 'primeng/table'; import * as clientActions from '@app/client/actions/client.actions'; -import { ClientService, ClientWithSetting } from '@app/domain/services/client.service'; -import { RestoreTableState } from '@app/shared/restore-table-state'; +import {ClientService, ClientWithSetting} from '@app/domain/services/client.service'; +import {RestoreTableState} from '@app/shared/restore-table-state'; @Component({ selector: 'agm-invoices-customer-settings-list', @@ -18,7 +18,6 @@ export class CustomerSettingsListComponent extends BaseComp implements OnInit, O cols: any[]; rows1Page = [10, 15, 30, 60, 100]; - selectedSetting; @ViewChild('dt') public dt: Table; @@ -29,20 +28,22 @@ export class CustomerSettingsListComponent extends BaseComp implements OnInit, O ) { super(); this.cols = [ - { field: 'name', header: globals.name, filtered: true, filterMatchMode: 'contains' }, - { field: 'phone', header: globals.phone, filtered: true, filterMatchMode: 'contains', width: '150px' }, - { field: 'email', header: globals.email, filtered: true, filterMatchMode: 'contains' }, - { field: 'discount', header: $localize`:@@discount:Discount`, filtered: true, filterMatchMode: 'contains' }, - { field: 'tax', header: $localize`:@@tax:Tax`, filtered: true, filterMatchMode: 'contains' }, - { field: 'action', header: $localize`:@@actions:Actions`, width: '10%' }, + {field: 'name', header: globals.name, filtered: true, filterMatchMode: 'contains'}, + {field: 'phone', header: globals.phone, filtered: true, filterMatchMode: 'contains', width: '150px'}, + {field: 'email', header: globals.email, filtered: true, filterMatchMode: 'contains'}, + {field: 'term', header: $localize`:@@paymentTerms:Payment Terms`, filtered: true, filterMatchMode: 'contains'}, + {field: 'discount', header: $localize`:@@discount:Discount`, filtered: true, filterMatchMode: 'contains'}, + {field: 'tax', header: $localize`:@@tax:Tax`, filtered: true, filterMatchMode: 'contains'}, + {field: 'action', header: $localize`:@@actions:Actions`, width: '10%'}, ]; } ngOnInit(): void { - this.clientSvc.getClientWithInvoiceSetting({ byPuid: this.authSvc.user.parent }).subscribe((clients: ClientWithSetting[]) => { + this.clientSvc.getClientWithInvoiceSetting({byUserId: this.authSvc.user.parent}).subscribe((clients: ClientWithSetting[]) => { if (clients) { this.clients = clients.map(client => ({ ...client, + term: client.invoiceSettings?.paymentTerm ? 'Net ' + client.invoiceSettings?.paymentTerm : '', discount: client.invoiceSettings?.discount ? client.invoiceSettings?.discount + '%' : '', tax: client.invoiceSettings?.taxValue ? client.invoiceSettings?.taxValue + '%' : '' })); @@ -59,7 +60,7 @@ export class CustomerSettingsListComponent extends BaseComp implements OnInit, O } goBack() { - this.router.navigate(['../'], { relativeTo: this.route }); + this.router.navigate(['../'], {relativeTo: this.route}); } onRowSelect(event) { @@ -67,11 +68,7 @@ export class CustomerSettingsListComponent extends BaseComp implements OnInit, O } editSetting(setting) { - this.selectedSetting = setting; - this.dt.selection = setting; - this.dt.updateSelectionKeys(); - this.dt.saveState(); - this.router.navigate([`./${setting._id}`], { relativeTo: this.route }); + this.router.navigate([`./${setting._id}`], {relativeTo: this.route}); } ngOnDestroy(): void { diff --git a/Development/client/src/app/invoices/customer-settings/customer-settings.component.html b/Development/client/src/app/invoices/customer-settings/customer-settings.component.html index fc0b2cc..088f936 100644 --- a/Development/client/src/app/invoices/customer-settings/customer-settings.component.html +++ b/Development/client/src/app/invoices/customer-settings/customer-settings.component.html @@ -5,63 +5,71 @@
- -
-
+
+
+ + + Company Name is required + + +
+
- - Company Name is required - - -
- -
-
- - - Address is required - - -
-
- -
-
- - - - -
-
- - - - -
-
- -
- - - + + Address is required +
- -
-
- -
- - - +
+
Payment Terms
+
+
Payment is due by Invoice Open Date +
+
+ + + {{selectedItem.label}} + + +
+ {{item.label}} + close +
+
+
+ + + + + + + +
- +
+
+ + + + +
+
+ + + + +
+
+
+ + + + +
-
@@ -82,6 +90,17 @@
+
+
+ +
+ + + +
+
+
+
diff --git a/Development/client/src/app/invoices/customer-settings/customer-settings.component.ts b/Development/client/src/app/invoices/customer-settings/customer-settings.component.ts index 1e3c1e9..7e5f5d1 100644 --- a/Development/client/src/app/invoices/customer-settings/customer-settings.component.ts +++ b/Development/client/src/app/invoices/customer-settings/customer-settings.component.ts @@ -2,14 +2,13 @@ import { AfterViewInit, Component, ElementRef, OnDestroy, OnInit, ViewChild } fr import { BaseComp } from '@app/shared/base/base.component'; import { ActivatedRoute } from '@angular/router'; import { createNewCustomerSetting, CustomerInvoiceSetting } from '@app/invoices/models/customer-invoice-setting.model'; -import { allowedLogoFormats, globals, maxLogoSize, allowedLogoFileExt, RoleIds } from '@app/shared/global'; +import { allowedLogoFormats, globals, maxLogoSize, allowedLogoFileExt } from '@app/shared/global'; import { select } from '@ngrx/store'; import * as fromClients from '@app/client/reducers'; import * as SettingActions from '@app/invoices/actions/setting.actions'; import { SelectItem } from 'primeng/api'; import { InvoiceService } from '@app/domain/services/invoice.service'; import { InputNumber } from 'primeng/inputnumber'; -import { GAService } from '@app/shared/ga.service'; @Component({ selector: 'agm-invoices-customer-settings', @@ -97,7 +96,7 @@ export class CustomerSettingsComponent extends BaseComp implements OnInit, After ...setting, }; this.paymentTermOpts = this.setting.termOpts.map(t => { - label: $localize`:@@numOfDays:#day# days`.replace('#day#', t), + label: $localize`:@@paymentTermDay:#day# days`.replace('#day#', t), value: t }); this.previewLogo = setting.logo; @@ -107,7 +106,7 @@ export class CustomerSettingsComponent extends BaseComp implements OnInit, After } else { this.paymentTermOpts .push({ - label: $localize`:@@numOfDays:#day# days`.replace('#day#', `${setting.paymentTerm}`), + label: $localize`:@@paymentTermDay:#day# days`.replace('#day#', `${setting.paymentTerm}`), value: setting.paymentTerm }); this.paymentTermOpts = this.paymentTermOpts.sort((a, b) => a.value - b.value); @@ -179,7 +178,7 @@ export class CustomerSettingsComponent extends BaseComp implements OnInit, After const newTerm = this.customTerm; if (newTerm > 1 && !this.paymentTermOpts.map(i => i.value).includes(newTerm)) { - this.paymentTermOpts.push({ label: $localize`:@@numOfDayss:#day# days`.replace('#day#', String(newTerm)), value: newTerm }); + this.paymentTermOpts.push({ label: $localize`:@@paymentTermDays:#day# days`.replace('#day#', String(newTerm)), value: newTerm }); this.paymentTermOpts = this.paymentTermOpts.sort((a, b) => a.value - b.value); if (!this.isNew) { @@ -234,20 +233,6 @@ export class CustomerSettingsComponent extends BaseComp implements OnInit, After address: this.setting?.address?.trim(), }; payload.paymentTerm = this.paymentTerm; - - // Track settings changes - const settingsModified = this.getModifiedSettings(payload); - this.gaSvc.trackCustomerInvoiceSettingsUpdated({ - client_id: this.currClient?._id || 'unknown', - settings_modified: settingsModified, - automation_enabled: this.hasAutomationEnabled(payload), - payment_terms_changed: this.hasPaymentTermsChanged(payload), - billing_preferences_updated: this.hasBillingPreferencesUpdated(payload), - user_id: this.authSvc.user?._id, - user_role: this.getUserRole(), - platform: 'web' - }); - if (this.isNew) { this.store.dispatch(new SettingActions.Create(payload)); } else { @@ -274,50 +259,4 @@ export class CustomerSettingsComponent extends BaseComp implements OnInit, After ngOnDestroy(): void { super.ngOnDestroy(); } - - private getModifiedSettings(payload: CustomerInvoiceSetting): string[] { - const modified: string[] = []; - - // Compare with original setting or default values - const original = this.isNew ? this.invoiceSvc.defaultSetting : this.setting; - - if (payload.companyName !== original.companyName) modified.push('company_name'); - if (payload.address !== original.address) modified.push('address'); - if (payload.taxValue !== original.taxValue) modified.push('tax_value'); - if (payload.discount !== original.discount) modified.push('discount'); - if (payload.paymentTerm !== original.paymentTerm) modified.push('payment_term'); - if (payload.note !== original.note) modified.push('note'); - if (payload.logo !== original.logo) modified.push('logo'); - - return modified; - } - - private hasAutomationEnabled(payload: CustomerInvoiceSetting): boolean { - // Check if any automation features are enabled - return payload.taxValue > 0 || payload.discount > 0 || payload.paymentTerm > 0; - } - - private hasPaymentTermsChanged(payload: CustomerInvoiceSetting): boolean { - const original = this.isNew ? this.invoiceSvc.defaultSetting : this.setting; - return payload.paymentTerm !== original.paymentTerm; - } - - private hasBillingPreferencesUpdated(payload: CustomerInvoiceSetting): boolean { - const original = this.isNew ? this.invoiceSvc.defaultSetting : this.setting; - return payload.companyName !== original.companyName || - payload.address !== original.address || - payload.logo !== original.logo; - } - - private getUserRole(): 'admin' | 'applicator' | 'office_admin' | 'client' | 'officer' | 'pilot' | 'inspector' | 'aircraft' { - const roles = this.authSvc.user?.roles || []; - if (roles.includes(RoleIds.ADMIN)) return 'admin'; - if (roles.includes(RoleIds.APP)) return 'applicator'; - if (roles.includes(RoleIds.APP_ADM)) return 'office_admin'; - if (roles.includes(RoleIds.PILOT)) return 'pilot'; - if (roles.includes(RoleIds.OFFICER)) return 'officer'; - if (roles.includes(RoleIds.INSPECTOR)) return 'inspector'; - if (roles.includes(RoleIds.DEVICE)) return 'aircraft'; - return 'client'; - } } diff --git a/Development/client/src/app/invoices/invoice-detail/invoice-detail.component.html b/Development/client/src/app/invoices/invoice-detail/invoice-detail.component.html index ec7b76c..08e0c56 100644 --- a/Development/client/src/app/invoices/invoice-detail/invoice-detail.component.html +++ b/Development/client/src/app/invoices/invoice-detail/invoice-detail.component.html @@ -1,264 +1,248 @@ - -
-
- - -
-
- Invoice #{{invoice.code}} -
-
- -
+
+
+ + +
+
+ Invoice #{{invoice.code}}
- -
-
-
-

Information

-
-
-
Company Name
-
{{invoice.companyName}}
-
-
-
Address
-
{{invoice.address}}
-
-
-
P.O Number
-
{{invoice.poNumber}}
-
-
-
Terms
-
- {{invoice.paymentTerm}} - day - days -
-
-
-
Open Date
-
{{invoice.openDate | date:'shortDate'}}
-
-
-
Due Date
-
{{invoice.dueDate | date:'shortDate'}}
-
-
-
Currency
-
{{invoice.currency | currencyName}}
-
-
-
Status
-
{{invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) | invoiceStatus | uppercase}}
-
-
-
+
+
- -
- - -
-
- Job -
-
-
- - - - {{col.header}} - - - - - - {{jobCols[0].header}}{{jobCosting.job}} - {{jobCols[1].header}}{{jobCosting.name}} - {{jobCols[2].header}}{{jobCosting.totalAmount | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}} - - - - - - - - - Item - Quantity - Unit Cost - Total - - - - - Item{{item.name}} - Quantity{{item.quantity}} - Unit Cost{{item.price | currency : invoice.currency : 'symbol-narrow' : '1.0-2'}}/{{item.unit | costingItemUnit}} - Total{{(+item.quantity * +item.price) | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}} - - - - - - -
-
- -
- - -
-
- Payment History -
-
-
- - - {{col.header}} - - - - - {{paymentCols[0].header}}{{payment.client.name}} - {{paymentCols[1].header}}{{payment.paymentDate | date:'shortDate'}} - {{paymentCols[2].header}}{{paymentMethodText(payment.paymentMethod)}} - {{paymentCols[3].header}}{{payment.amount | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}} - {{paymentCols[4].header}}{{payment.amountDue | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}} - - - - - -
- No payment log data. -
- - -
- - - - {{totalPaid | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}} - {{totalDue | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}} - - -
-
-
-
Subtotal:
-
{{totalSubtotal | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}}
-
-
-
Total Excluding Tax:
-
{{totalExcludingTax | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}}
-
-
-
Total:
-
{{totalTotal | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}}
-
-
-
Amount paid:
-
{{totalPaid | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}}
-
-
-
Amount due:
-
{{totalDue | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}}
-
-
-
- -
- - -
-
- Bill to List -
-
-
- - - {{col.header}} - - - - - {{billToCols[0].header}}{{client.clientName}} - {{billToCols[1].header}}{{client.clientAddress}} - {{billToCols[2].header}}{{client.split}}% - {{billToCols[3].header}}{{client.subTotal | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}} - {{billToCols[4].header}}{{client.discount}}% - {{billToCols[5].header}}{{client.totalExcludingTax | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}} - {{billToCols[6].header}}{{client.taxRate}}% - {{billToCols[7].header}}{{client.total | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}} - {{billToCols[8].header}}{{client.paid | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}} - {{billToCols[9].header}}{{client.due | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}} - - {{billToCols[10].header}} -
- -
- - -
- - - Totals - {{totalSplit}}% - {{totalSubtotal | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}} - - {{totalExcludingTax | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}} - - {{totalTotal | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}} - {{ totalPaid | currency: invoice.currency : 'symbol-narrow' : '1.0-2' }} - {{ totalDue | currency: invoice.currency : 'symbol-narrow' : '1.0-2' }} - - - -
-
-
- -
-
- -
- - + +
+
+
+

Information

+
+
+
Company Name
+
{{invoice.companyName}}
+
+
+
Address
+
{{invoice.address}}
+
+
+
P.O Number
+
{{invoice.poNumber}}
+
+
+
Terms
+
+ {{invoice.paymentTerm}} + day + days +
+
+
+
Open Date
+
{{invoice.openDate | date:'shortDate'}}
+
+
+
Due Date
+
{{invoice.dueDate | date:'shortDate'}}
+
+
+
Currency
+
{{invoice.currency | currencyName}}
+
+
+
Status
+
{{invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) | invoiceStatus | uppercase}}
+
+
+
+
+
+ + +
+
+ Job +
+
+
+ + + + {{col.header}} + + + + + + {{jobCols[0].header}}{{jobCosting.job}} + {{jobCols[1].header}}{{jobCosting.name}} + {{jobCols[2].header}}{{jobCosting.totalAmount | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}} + + + + + + + + + Item + Quantity + Unit Cost + Total + + + + + Item{{item.name}} + Quantity{{item.quantity}} + Unit Cost{{item.price | currency : invoice.currency : 'symbol-narrow' : '1.0-2'}}/{{item.unit | costingItemUnit}} + Total{{(+item.quantity * +item.price) | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}} + + + + + + +
+
+
+ + +
+
+ Payment History +
+
+
+ + + {{col.header}} + + + + + {{paymentCols[0].header}}{{payment.client.name}} + {{paymentCols[1].header}}{{payment.paymentDate | date:'shortDate'}} + {{paymentCols[2].header}}{{paymentMethodText(payment.paymentMethod)}} + {{paymentCols[3].header}}{{payment.amount | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}} + {{paymentCols[4].header}}{{payment.amountDue | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}} + + + + + +
+ No payment log data. +
+ + +
+ + + + {{totalPaid | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}} + {{totalDue | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}} + + +
+
+
+
Subtotal:
+
{{totalSubtotal | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}}
+
+
+
Total Excluding Tax:
+
{{totalExcludingTax | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}}
+
+
+
Total:
+
{{totalTotal | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}}
+
+
+
Amount paid:
+
{{totalPaid | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}}
+
+
+
Amount due:
+
{{totalDue | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}}
+
+
+
+
+ + +
+
+ Bill to List +
+
+
+ + + {{col.header}} + + + + + {{billToCols[0].header}}{{client.clientName}} + {{billToCols[1].header}}{{client.clientAddress}} + {{billToCols[2].header}}{{client.split}}% + {{billToCols[3].header}}{{client.subTotal | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}} + {{billToCols[4].header}}{{client.discount}}% + {{billToCols[5].header}}{{client.totalExcludingTax | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}} + {{billToCols[6].header}}{{client.taxRate}}% + {{billToCols[7].header}}{{client.total | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}} + {{billToCols[8].header}}{{client.paid | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}} + {{billToCols[9].header}}{{client.due | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}} + + {{billToCols[10].header}} +
+ +
+ + +
+ + + Totals + {{totalSplit}}% + {{totalSubtotal | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}} + + {{totalExcludingTax | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}} + + {{totalTotal | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}} + {{ totalPaid | currency: invoice.currency : 'symbol-narrow' : '1.0-2' }} + {{ totalDue | currency: invoice.currency : 'symbol-narrow' : '1.0-2' }} + + + +
+
+ +
+
+ +
+ +
+
- - - -
- - -
-
-
- - - - -
+ + +
- +
Powered by AgMission - AgNav Inc.
-
{{printDetail.client.issuerCompanyName}}
-
{{printDetail.client.issuerAddress}}
+
{{printDetail.client.applicatorCompanyName}}
+
{{printDetail.client.applicatorAddress}}
@@ -372,116 +356,122 @@
- + + +
+ + +
+
+ - -
-
-
-
Total
-
{{totalTotal | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}}
-
-
-
-

Payment {{logPaymentList.length + 1 }}

-
-
- -
-
- - Client is required -
+ + +
+
+
Total
+
{{totalTotal | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}}
+
+
+
+

Payment {{logPaymentList.length + 1 }}

+
+
+
-
-
- -
-
- - Amount is required - Do not enter more than amount due -
-
-
-
- -
-
- {{logPaymentForm.amountDue | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}} -
-
-
-
- -
-
- -
-
-
-
- -
-
- - A valid date is required -
+
+ + Client is required
-
-
-

Payment {{i + 1}}

-
-
-
Client
-
{{log.client.name}}
-
-
-
Amount paid
-
{{log.amount | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}}
-
-
-
Amount due
-
{{log.amountDue | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}}
-
-
-
Payment method
-
{{paymentMethodText(log.paymentMethod)}}
-
-
-
Payment date
-
{{log.paymentDate | date:'shortDate'}}
-
+
+
+ +
+
+ + Amount is required + Do not enter more than amount due +
+
+
+
+ +
+
+ {{logPaymentForm.amountDue | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}} +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ + A valid date is required +
+
+
+
+
+

Payment {{i + 1}}

+
+
+
Client
+
{{log.client.name}}
+
+
+
Amount paid
+
{{log.amount | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}}
+
+
+
Amount due
+
{{log.amountDue | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}}
+
+
+
Payment method
+
{{paymentMethodText(log.paymentMethod)}}
+
+
+
Payment date
+
{{log.paymentDate | date:'shortDate'}}
- - -
- - -
-
- - - -
-
-
Export as IIF
-
For QuickBooks Desktop
-
-
-
Export as CSV
-
For QuickBooks Online
-
- -
- -
-
-
- \ No newline at end of file + + +
+ + +
+
+ + + +
+
+
Export as IIF
+
For QuickBooks Desktop
+
+
+
Export as CSV
+
For QuickBooks Online
+
+
+ +
+ +
+
+
\ No newline at end of file diff --git a/Development/client/src/app/invoices/invoice-detail/invoice-detail.component.ts b/Development/client/src/app/invoices/invoice-detail/invoice-detail.component.ts index a40dce5..df92a98 100644 --- a/Development/client/src/app/invoices/invoice-detail/invoice-detail.component.ts +++ b/Development/client/src/app/invoices/invoice-detail/invoice-detail.component.ts @@ -1,7 +1,7 @@ import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { Invoice } from '@app/invoices/models/invoice.model'; import { BaseComp } from '@app/shared/base/base.component'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { globals, RoleIds } from '@app/shared/global'; import { SelectItem } from 'primeng/api'; import { InvoiceService } from '@app/domain/services/invoice.service'; @@ -24,7 +24,7 @@ import { MultiSelect } from 'primeng/multiselect'; export class InvoiceDetailComponent extends BaseComp implements OnInit, OnDestroy { readonly invoiceStatus = invoiceStatus; - invoice; + invoice: any = {}; jobCols; paymentCols; billToCols; @@ -82,10 +82,6 @@ export class InvoiceDetailComponent extends BaseComp implements OnInit, OnDestro return Utils.arraySum(this.invoice.clients.map(i => this.billToListPriceObject(i).due)); } - get canAccessInvoice() { - return this.authSvc.canAccessInvoice; - } - paymentMethodText(method) { switch (method) { case 'transfer': @@ -156,7 +152,7 @@ export class InvoiceDetailComponent extends BaseComp implements OnInit, OnDestro ngOnInit(): void { this.sub$ = this.route.data.subscribe((data) => { - const invoice = data[0]?.invoice as Invoice || null; + const invoice = data[0].invoice as Invoice || null; if (invoice) { if (invoice._id === '0') { this.location.back(); @@ -170,18 +166,6 @@ export class InvoiceDetailComponent extends BaseComp implements OnInit, OnDestro ...c, ...this.billToListPriceObject(c) })); - - // Track invoice viewed - this.gaSvc.trackInvoiceViewed({ - invoice_id: invoice._id, - invoice_status: invoice.status, - invoice_amount: this.calculateInvoiceAmount(invoice), - view_source: 'direct_link', - client_id: invoice.clients?.[0]?.billTo?._id || 'unknown', - user_id: this.getAnalyticsUserId(), - user_role: this.getAnalyticsUserRole(), - platform: 'web' - }); } else { this.goBack(); } @@ -284,20 +268,6 @@ export class InvoiceDetailComponent extends BaseComp implements OnInit, OnDestro delete payload.amountDue; this.invoiceSvc.createLogPayment(payload).subscribe(log => { if (log) { - // Track payment logging - this.gaSvc.trackInvoicePaymentLogged({ - invoice_id: this.invoice._id, - payment_amount: this.logPaymentForm.amount || 0, - payment_method: this.gaHelpers.mapPaymentMethod(this.logPaymentForm.paymentMethod), - payment_date: this.logPaymentForm.paymentDate?.toISOString().split('T')[0] || new Date().toISOString().split('T')[0], - remaining_balance: this.gaHelpers.calculateRemainingBalance(this.invoice), - days_to_payment: this.gaHelpers.calculateDaysToPayment(new Date(this.invoice.openDate), this.logPaymentForm.paymentDate), - payment_reference: this.gaHelpers.generatePaymentReference(this.invoice?.code || 'INV'), - user_id: this.getAnalyticsUserId(), - user_role: this.getAnalyticsUserRole(), - platform: 'web' - }); - this.logPaymentDlg = false; this.msgSvc.addSuccessMsg($localize`:@@logPaymentSucceeded: Create log payment succeeded`); this.fetchInvoiceDetail(this.invoice._id); @@ -327,52 +297,32 @@ export class InvoiceDetailComponent extends BaseComp implements OnInit, OnDestro } downloadCSV() { + this.loaderSvc.show(); this.invoiceSvc.downloadInvoiceCSV(this.invoice._id).subscribe( (res) => { try { saveAs(res, `Agmission_invoice_${this.invoice.code}_${this.datePipe.transform(new Date(), 'yyyy-MM-ddTHH-mm-ss')}.csv`); - - // Track invoice export - this.gaSvc.trackInvoiceExported({ - invoice_id: this.invoice._id, - export_format: 'csv', - invoice_amount: this.calculateInvoiceAmount(this.invoice), - export_method: 'single', - includes_job_details: true, - user_id: this.getAnalyticsUserId(), - user_role: this.getAnalyticsUserRole(), - platform: 'web' - }); } catch (error) { alert('Sorry. Your browser does not support this feature !'); } this.exportDlg = false; + this.loaderSvc.hide(); }, (err) => { this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.download).replace('#thing#', globals.invoice)); }); } downloadIIF() { + this.loaderSvc.show(); this.invoiceSvc.downloadInvoiceIIF(this.invoice._id).subscribe( (res) => { try { saveAs(res, `Agmission_invoice_${this.invoice.code}_${this.datePipe.transform(new Date(), 'yyyy-MM-ddTHH-mm-ss')}.iif`); - - // Track invoice export - this.gaSvc.trackInvoiceExported({ - invoice_id: this.invoice._id, - export_format: 'iif', - invoice_amount: this.calculateInvoiceAmount(this.invoice), - export_method: 'single', - includes_job_details: true, - user_id: this.getAnalyticsUserId(), - user_role: this.getAnalyticsUserRole(), - platform: 'web' - }); } catch (error) { alert('Sorry. Your browser does not support this feature !'); } this.exportDlg = false; + this.loaderSvc.hide(); }, (err) => { this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.download).replace('#thing#', globals.invoice)); }); @@ -386,14 +336,6 @@ export class InvoiceDetailComponent extends BaseComp implements OnInit, OnDestro DomUtils.hide(elts) } - private calculateInvoiceAmount(invoice: any): number { - if (!invoice) return 0; - if (invoice.status == invoiceStatus.VOID) { - return 0; - } - return Utils.arraySum(invoice?.clients?.map(client => this.billToListPriceObject(client).total) || [0]); - } - ngOnDestroy() { super.ngOnDestroy(); } diff --git a/Development/client/src/app/invoices/invoice-edit/invoice-edit.component.html b/Development/client/src/app/invoices/invoice-edit/invoice-edit.component.html index c25bb55..5508338 100644 --- a/Development/client/src/app/invoices/invoice-edit/invoice-edit.component.html +++ b/Development/client/src/app/invoices/invoice-edit/invoice-edit.component.html @@ -72,14 +72,14 @@
- + P.O Number only allow number, letter, underscore and hyphen
- + A valid date is required @@ -169,7 +169,7 @@ - {{jobCols[0].header}}{{job.job}} + {{jobCols[0].header}}{{job._id}} {{jobCols[1].header}}{{job.name}} {{jobCols[2].header}}{{job.costings.billableAmount | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}} {{jobCols[2].header}}{{job.totalAmount | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}} @@ -189,28 +189,28 @@ - - Item - Quantity - Unit Cost - Total - - - - - - Item{{item.name}} - Quantity{{item.quantity}} - Unit Cost{{item.price | currency : invoice.currency : 'symbol-narrow' : '1.0-2'}}/{{item.unit | costingItemUnit}} - Total{{(+item.quantity * +item.price) | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}} - + + Item + Quantity + Unit Cost + Total + + + + + + Item{{item.name}} + Quantity{{item.quantity}} + Unit Cost{{item.price | currency : invoice.currency : 'symbol-narrow' : '1.0-2'}}/{{item.unit | costingItemUnit}} + Total{{(+item.quantity * +item.price) | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}} + + + + + - - - - @@ -248,7 +248,7 @@ Bill to List
- +
@@ -315,7 +315,7 @@
- +
@@ -334,8 +334,8 @@
-
{{job.value.job}} - {{job.value.name}}
-
Client: {{job?.value?.client?.name}}
+
{{job.value._id}} - {{job.value.name}}
+
Client: {{job.value.client.name}}
{{job.value.costings ? (job.value.costings.billableAmount | currency: job.value.costings.currency : 'symbol-narrow' : '1.0-2') : ''}}
{{job.value.totalAmount ? (job.value.totalAmount | currency: invoice.currency : 'symbol-narrow' : '1.0-2') : ''}}
@@ -344,7 +344,7 @@
- {{job.job}} - {{job.name}} + {{job._id}} - {{job.name}}
{{(job.costings ? job.costings.billableAmount : job.totalAmount) | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}} close diff --git a/Development/client/src/app/invoices/invoice-edit/invoice-edit.component.ts b/Development/client/src/app/invoices/invoice-edit/invoice-edit.component.ts index 8c33b25..e0bb330 100644 --- a/Development/client/src/app/invoices/invoice-edit/invoice-edit.component.ts +++ b/Development/client/src/app/invoices/invoice-edit/invoice-edit.component.ts @@ -2,7 +2,7 @@ import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { BaseComp } from '@app/shared/base/base.component'; import * as invoiceAction from '../actions/invoice.actions'; import { ActivatedRoute } from '@angular/router'; -import { globals, invoiceStatus, RoleIds } from '@app/shared/global'; +import { globals, invoiceStatus, jobInvoiceStatus } from '@app/shared/global'; import { Invoice } from '@app/invoices/models/invoice.model'; import { InvoiceService } from '@app/domain/services/invoice.service'; import { SelectItem } from 'primeng/api'; @@ -16,7 +16,6 @@ import { filter, map, switchMap } from 'rxjs/operators'; import { DomUtils } from '@app/shared/dom-util'; import { MultiSelect } from 'primeng/multiselect'; import { ClientService } from '@app/domain/services/client.service'; -import { GAService } from '@app/shared/ga.service'; @Component({ selector: 'agm-invoice-edit', @@ -25,9 +24,9 @@ import { GAService } from '@app/shared/ga.service'; }) export class InvoiceEditComponent extends BaseComp implements OnInit, OnDestroy { readonly globals = globals; - readonly OPTION_1 = 'option_1'; @ViewChild('clientPayLogRef') paymentLogClient: Dropdown; + @ViewChild('od') od: Calendar; @ViewChild('term') termEl: InputNumber; @ViewChild('billToClient') billToEl: Dropdown; @ViewChild('split') split: InputNumber; @@ -111,7 +110,7 @@ export class InvoiceEditComponent extends BaseComp implements OnInit, OnDestroy // utility methods isTrimAddress() { - return this.clientForm?.issuerAddress?.length == this.clientForm?.issuerAddress?.trim()?.length; + return this.clientForm?.applicatorAddress?.length == this.clientForm?.applicatorAddress?.trim()?.length; } hide(elts: (Dropdown | MultiSelect | Calendar)[]) { @@ -156,7 +155,7 @@ export class InvoiceEditComponent extends BaseComp implements OnInit, OnDestroy }; this.defaultSetting = this.invoiceSvc.defaultSetting; this.paymentTermOpts = this.defaultSetting.termOpts.map(t => { - label: $localize`:@@numOfDays:#day# days`.replace('#day#', t), + label: $localize`:@@paymentTermDay:#day# days`.replace('#day#', t), value: t }); } @@ -254,7 +253,6 @@ export class InvoiceEditComponent extends BaseComp implements OnInit, OnDestroy private initJobs(jobs: any[]): void { this.selectedJobs = jobs; - this._orgSelectedJobs = jobs; this.calculateTotalJobAmount(); } @@ -279,12 +277,11 @@ export class InvoiceEditComponent extends BaseComp implements OnInit, OnDestroy }; })]; } - this._orgSelectedClients = this.selectedClients; } private initPaymentTermOpts(paymentTermOpts: any[]): void { this.paymentTermOpts = paymentTermOpts.map(t => { - label: $localize`:@@numOfDays:#day# days`.replace('#day#', t), + label: $localize`:@@paymentTermDay:#day# days`.replace('#day#', t), value: t }); this.changePaymentTermOpt(this.invoice.createOp); @@ -335,11 +332,7 @@ export class InvoiceEditComponent extends BaseComp implements OnInit, OnDestroy return data?.[0]?.invoice?.clients && data?.[0]?.jobs.length > 0 && data?.[0]?.paymentTermOpts?.length > 0 - && data?.[0]?.logs - && (data?.[0]?.mode == invoiceStatus.NEW - || data?.[0]?.mode == invoiceStatus.DRAFT - || data?.[0]?.mode == invoiceStatus.OPEN - || data?.[0]?.mode == invoiceStatus.UNCOLLECTIBLE); + && data?.[0]?.logs; } // Payment term logics @@ -392,9 +385,6 @@ export class InvoiceEditComponent extends BaseComp implements OnInit, OnDestroy } changePaymentTermOpt(type?: string) { - if (!this.invoice?.openDate) { - return; - } if (!type) { type = this.invoice.createOp; } @@ -437,8 +427,8 @@ export class InvoiceEditComponent extends BaseComp implements OnInit, OnDestroy private addTermOption(term: number): void { const label = term > 1 - ? $localize`:@@numOfDayss:#day# days`.replace('#day#', String(term)) - : $localize`:@@numOfDays:#day# day`.replace('#day#', String(term)); + ? $localize`:@@paymentTermDays:#day# days`.replace('#day#', String(term)) + : $localize`:@@paymentTermDay:#day# day`.replace('#day#', String(term)); this.paymentTermOpts.push({ label, value: term }); this.paymentTermOpts = this.paymentTermOpts.sort((a, b) => a.value - b.value); } @@ -456,33 +446,24 @@ export class InvoiceEditComponent extends BaseComp implements OnInit, OnDestroy // Job handling logics addJobsDlg() { - this.jobSvc.fetchInvReadyJobs().pipe( - map((jobs) => jobs.map(job => ({ - ...job, - totalAmount: +job?.costings?.billableAmount, - job: job._id - }))) + this.jobSvc.loadJobs(null).pipe( + filter(jobs => jobs?.length > 0), + map(jobs => this.filterAndMapJobs(jobs)) ).subscribe({ next: jobs => this.handleJobLoadSuccess(jobs), error: err => this.handleJobLoadError(err) }); } - private handleJobLoadSuccess(jobs: any[]): void { - if (this.isEditable) { - this.selectedSearchJob = [...this.selectedJobs]; - this.searchJobList = jobs; - } - this.searchJobList = [...jobs, ...this.selectedJobs].filter((job, index, self) => - index === self.findIndex((t) => ( - t.job === job.job - )) - ); - this.jobSelectDlgOn = true; - } - - private handleJobLoadError(err: any): void { - this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.load).replace('#thing#', globals.job)); + deleteJob(job) { + this.confirmSvc.confirm({ + message: globals.confirmDeleteThing.replace('#thing#', globals.job), + accept: () => { + this.selectedJobs = this.selectedJobs.filter(item => item._id != job._id); + this.updateClientPayments(); + this.calculateTotalClientPayments(); + } + }); } canDeleteJob() { @@ -490,26 +471,10 @@ export class InvoiceEditComponent extends BaseComp implements OnInit, OnDestroy } deleteSearchJob(job) { - this.selectedSearchJob = this.selectedSearchJob?.filter(item => item.job != job.job) ?? this.selectedSearchJob; + this.selectedSearchJob = this.selectedSearchJob?.filter(item => item._id != job._id) ?? this.selectedSearchJob; this.calculateTotalJobAmount(); } - private updateInvoiceCalculations(): void { - this.calculateTotalJobAmount(); - this.updateClientPayments(); - this.calculateTotalClientPayments(); - } - - deleteJob(job) { - this.confirmSvc.confirm({ - message: globals.confirmDeleteThing.replace('#thing#', globals.job), - accept: () => { - this.selectedJobs = this.selectedJobs.filter(item => item.job != job.job); - this.updateInvoiceCalculations(); - } - }); - } - addJobToInvoice() { const missingClients = this.getMissingClients(); if (missingClients.length > 0) { @@ -518,7 +483,9 @@ export class InvoiceEditComponent extends BaseComp implements OnInit, OnDestroy if (this.isEditable) { this.selectedJobs = [...this.selectedSearchJob]; - this.updateInvoiceCalculations(); + this.calculateTotalJobAmount(); + this.updateClientPayments(); + this.calculateTotalClientPayments(); this.jobSelectDlgOn = false; } } @@ -526,10 +493,10 @@ export class InvoiceEditComponent extends BaseComp implements OnInit, OnDestroy private calculateTotalJobAmount() { if (this.isNew) { return this.totalJobAmount = Utils.arraySum(this.selectedJobs.map(job => (+job?.costings?.billableAmount).roundToDecimalPlace(2))); - } - return this.totalJobAmount = Utils.arraySum(this.selectedJobs.map(job => (+job?.totalAmount).roundToDecimalPlace(2))); + } + return this.totalJobAmount = Utils.arraySum(this.selectedJobs.map(job => (+job?.totalAmount).roundToDecimalPlace(2))); } - + private updateClientPayments() { this.selectedClients = this.selectedClients.map(client => ({ ...client, @@ -542,7 +509,33 @@ export class InvoiceEditComponent extends BaseComp implements OnInit, OnDestroy } viewJobDetail(job) { - this.router.navigate([`/jobs/${job.job}/edit`], { queryParams: { previous: 'invoice' } }); + this.router.navigate([`/jobs/${job._id}/edit`], { queryParams: { previous: 'invoice' } }); + } + + private filterAndMapJobs(jobs: any[]): any[] { + return jobs + .filter(job => this.isJobEligible(job)) + .map(job => ({ ...job, totalAmount: +job?.costings?.billableAmount })); + } + + private isJobEligible(job: any): boolean { + return job?.costings && + job?.costings?.billableAmount && + job?.status != 0 && + job?.invoiceStatus == jobInvoiceStatus.NONE && + job?.costings?.currency == this.invoice.currency; + } + + private handleJobLoadSuccess(jobs: any[]): void { + if (this.isEditable) { + this.selectedSearchJob = [...this.selectedJobs]; + } + this.searchJobList = jobs.filter(job => !this.selectedJobs?.map(i => i._id).includes(job._id)); + this.jobSelectDlgOn = true; + } + + private handleJobLoadError(err: any): void { + this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.load).replace('#thing#', globals.job)); } private getMissingClients(): string[] { @@ -569,36 +562,6 @@ export class InvoiceEditComponent extends BaseComp implements OnInit, OnDestroy this.loadClients(); } - private loadClients() { - this.clientSvc.searchWithSettings(this.authSvc.user?.parent).pipe( - filter((clients) => clients?.length > 0), - map(clients => this.filterClients(clients)) - ).subscribe({ - next: clients => this.handleClientLoadSuccess(clients), - error: err => this.handleClientLoadError(err) - }); - } - - private filterClients(clients: any[]): any[] { - return clients - .filter(client => this.selectedClients?.some(i => i?.billTo?._id != client?._id)) - .map(client => ({ label: client?.name || '', value: client })); - } - - private handleClientLoadSuccess(clients: any[]): void { - this.searchClientList = clients; - this.dueSplit = 100 - this.totalSplit; - this.initializeClientForm(); - setTimeout(() => this.billToEl.containerViewChild.nativeElement.click(), 300); - } - - private handleClientLoadError(err: any): void { - console.error(err); - this.clienFormDlgOn = false; - this.isAddingNewClient = false; - this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.load).replace('#thing#', globals.clients)); - } - handleDlgSelectClient(event) { const client = event.value; this.loadClientSettings(client); @@ -612,8 +575,8 @@ export class InvoiceEditComponent extends BaseComp implements OnInit, OnDestroy } private handleClientSettingsLoadSuccess(client, setting): void { - this.clientForm.issuerCompanyName = setting?.companyName || this.defaultSetting.companyName; - this.clientForm.issuerAddress = setting?.address || this.defaultSetting.address; + this.clientForm.applicatorCompanyName = setting?.companyName || this.defaultSetting.companyName; + this.clientForm.applicatorAddress = setting?.address || this.defaultSetting.address; this.clientForm.logo = setting?.logo || ''; this.clientForm.clientName = client.name; this.clientForm.clientAddress = client.address; @@ -636,11 +599,18 @@ export class InvoiceEditComponent extends BaseComp implements OnInit, OnDestroy this.clienFormDlgOn = true; } + private handleClientLoadSuccess(clients: any[]): void { + this.searchClientList = clients; + this.dueSplit = 100 - this.totalSplit; + this.initializeClientForm(); + setTimeout(() => this.billToEl.containerViewChild.nativeElement.click(), 300); + } + private initializeClientForm(client?: any) { this.clientForm = { billTo: client?.billTo || null, - issuerCompanyName: client?.issuerCompanyName || this.defaultSetting.companyName, - issuerAddress: client?.issuerAddress || this.defaultSetting.address, + applicatorCompanyName: client?.applicatorCompanyName || this.defaultSetting.companyName, + applicatorAddress: client?.applicatorAddress || this.defaultSetting.address, clientName: client?.billTo?.name || '', clientAddress: client?.billTo?.address || '', logo: client?.logo || '', @@ -675,10 +645,6 @@ export class InvoiceEditComponent extends BaseComp implements OnInit, OnDestroy this.msgSvc.addFailedMsg($localize`:@@exceededSplitVal:The total split exceeds 100%. Please ensure that the split percentages accurately reflect the allocation.`); return; } - this.selectedClients.forEach(client => { - delete client.amountDue; - delete client.amountPaid; - }); const payload = { ...this.clientForm, ...this.calculateClientPayment(this.clientForm) @@ -717,6 +683,29 @@ export class InvoiceEditComponent extends BaseComp implements OnInit, OnDestroy } } + private loadClients() { + this.clientSvc.loadClients({ byUserId: this.authSvc.user?.parent }).pipe( + filter((clients) => clients?.length > 0), + map(clients => this.filterClients(clients)) + ).subscribe({ + next: clients => this.handleClientLoadSuccess(clients), + error: err => this.handleClientLoadError(err) + }); + } + + private filterClients(clients: any[]): any[] { + return clients + .filter(client => this.selectedClients?.some(i => i?.billTo?._id != client?._id)) + .map(client => ({ label: client?.name || '', value: client })); + } + + private handleClientLoadError(err: any): void { + console.error(err); + this.clienFormDlgOn = false; + this.isAddingNewClient = false; + this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.load).replace('#thing#', globals.clients)); + } + private getLinkedJobs(client: any): string[] { return this.selectedJobs.filter(job => job?.client?._id == client.billTo._id).map(job => job.name); } @@ -804,7 +793,7 @@ export class InvoiceEditComponent extends BaseComp implements OnInit, OnDestroy ...this.logPaymentForm, invoiceId: this.invoice._id, clientId: this.logPaymentForm.client.billTo._id, - amount: this.logPaymentForm.amount?.toString() + amount: this.logPaymentForm.amount.toString() }; delete payload.amountDue; return payload; @@ -846,21 +835,6 @@ export class InvoiceEditComponent extends BaseComp implements OnInit, OnDestroy private handleLogPaymentSuccess(logs: any[], invoice: Invoice) { this.logPaymentList = logs; this.logPaymentDlg = false; - - // Track payment logging - this.gaSvc.trackInvoicePaymentLogged({ - invoice_id: this.invoice._id, - payment_amount: this.logPaymentForm.amount || 0, - payment_method: this.gaHelpers.mapPaymentMethod(this.logPaymentForm.paymentMethod), - payment_date: this.logPaymentForm.paymentDate?.toISOString().split('T')[0] || new Date().toISOString().split('T')[0], - remaining_balance: this.gaHelpers.calculateRemainingBalance(invoice), - days_to_payment: this.gaHelpers.calculateDaysToPayment(this.invoice?.openDate, this.logPaymentForm.paymentDate), - payment_reference: this.gaHelpers.generatePaymentReference(this.invoice?.code), - user_id: this.getAnalyticsUserId(), - user_role: this.getAnalyticsUserRole(), - platform: 'web' - }); - this.store.dispatch(new invoiceAction.FetchSuccess([invoice])); } @@ -868,30 +842,12 @@ export class InvoiceEditComponent extends BaseComp implements OnInit, OnDestroy this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.create).replace('#thing#', $localize`:@@logPayment:Log Payment`)); } - isPaid() { - return this.totalDue == 0; - } - // Save logics postInvoice() { const payload = { _id: this.invoice._id, status: invoiceStatus.OPEN }; - - // Track invoice status change - this.gaSvc.trackInvoiceStatusChanged({ - invoice_id: this.invoice._id, - old_status: this.invoice.status as any, - new_status: invoiceStatus.OPEN as any, - status_change_reason: 'user_action', - total_amount: this.totalTotal || 0, - days_in_previous_status: this.calculateDaysInStatus(), - user_id: this.authSvc.user?._id, - user_role: this.getUserRole(), - platform: 'web' - }); - this.store.dispatch(new invoiceAction.Update(payload)); } @@ -910,52 +866,11 @@ export class InvoiceEditComponent extends BaseComp implements OnInit, OnDestroy const payload = this.prepareInvoicePayload(); if (this.isNew) { this.store.dispatch(new invoiceAction.Create(payload)); - - // Track invoice creation - this.gaSvc.trackInvoiceCreated({ - invoice_id: payload._id || 'new', - client_id: this.selectedClients?.[0]?.billTo?._id || 'unknown', - total_amount: this.totalTotal || 0, - currency: 'USD', - job_count: this.selectedJobs?.length || 0, - creation_method: 'manual', - due_date_days: this.calculateDueDateDays(payload.dueDate), - payment_terms: String(payload.paymentTerm) || 'net_30', - user_id: this.authSvc.user?._id, - user_role: this.getUserRole(), - platform: 'web' - }); } else { this.store.dispatch(new invoiceAction.Update(payload)); - - // Track invoice update - this.gaSvc.trackInvoiceUpdated({ - invoice_id: this.invoice._id, - fields_modified: this.getModifiedFields(), - amount_change: this.calculateAmountChange(), - previous_status: this._orgInvoice?.status, - current_status: this.invoice.status, - modification_type: this.determineModificationType(), - user_id: this.authSvc.user?._id, - user_role: this.getUserRole(), - platform: 'web' - }); } } - private validateInvoiceDates(): boolean { - const currDate = new Date(); - if (DateUtils.isSameDate(this.invoice.openDate, currDate) == 0 && this.invoice.status == invoiceStatus.DRAFT) { - this.msgSvc.addFailedMsg($localize`:@@openDateInvalid:Please ensure that the open date is set for today or any date in the future.`); - return false; - } - if (DateUtils.isSameDate(this.invoice.dueDate, currDate) == 0 && this.invoice.status == invoiceStatus.DRAFT) { - this.msgSvc.addFailedMsg($localize`:@@dueDateInvalid:Please ensure that the due date is set for today or any date in the future.`); - return false; - } - return true; - } - saveInvoiceLogPayment() { if (!this.validateLogPaymentDate()) return; @@ -972,7 +887,7 @@ export class InvoiceEditComponent extends BaseComp implements OnInit, OnDestroy private prepareLogPaymentPayload() { return this.selectedClients.map(client => ({ clientId: client.billTo._id, - amount: this.calculateClientPayment(client).total?.toString(), + amount: this.calculateClientPayment(client).total.toString(), paymentMethod: this.autoLogPaymentForm.paymentMethod, paymentDate: this.autoLogPaymentForm.paymentDate })); @@ -981,46 +896,61 @@ export class InvoiceEditComponent extends BaseComp implements OnInit, OnDestroy private prepareInvoicePayload() { const payload = { ...this.invoice }; payload.jobs = this.selectedJobs.map(job => ({ - job: job.job, + job: job._id, name: job.name, - totalAmount: this.isNew ? job.costings.billableAmount?.toString() : job.totalAmount?.toString(), + totalAmount: this.isNew ? job.costings.billableAmount.toString() : job.totalAmount.toString(), costings: job.costings })); payload.clients = this.selectedClients.map(client => ({ billTo: client.billTo._id, - issuerCompanyName: client.issuerCompanyName, - issuerAddress: client.issuerAddress, + applicatorCompanyName: client.applicatorCompanyName, + applicatorAddress: client.applicatorAddress, clientName: client.billTo.name, clientAddress: client.billTo.address, clientPhone: client.billTo.phone, clientEmail: client.billTo.email, logo: client.logo, - split: client.split?.toString(), - subTotal: client.subTotal?.toString(), - discount: client.discount?.toString(), - taxRate: client.taxRate?.toString(), + split: client.split.toString(), + subTotal: client.subTotal.toString(), + discount: client.discount.toString(), + taxRate: client.taxRate.toString(), note: client.note, paymentTerm: client.paymentTerm, - amountPaid: client.amountPaid?.toString(), - amountDue: client.amountDue?.toString() })); return payload; } isSaveDisabled(): boolean { - return this.selectedClients.length === 0 - || this.selectedJobs.length === 0 - || this.totalSplit !== 100 - || !this.invoice.openDate + return this.selectedClients.length === 0 || this.selectedJobs.length === 0 || this.totalSplit !== 100 || !(this.isEditable || this.isUncollectibleEdit) || !this.changed; } - changed() { + private changed() { return !(Utils.deepEquals(this._orgInvoice, this.invoice) && Utils.deepEquals(this._orgSelectedJobs, this.selectedJobs) && Utils.deepEquals(this._orgSelectedClients, this.selectedClients)); } + // Helper methods for Save logics + private validateInvoiceDates(): boolean { + const currDate = new Date(); + if (!this.isDateValid(this.invoice.openDate, currDate, 'openDateInvalid', 'open date')) { + return false; + } + if (!this.isDateValid(this.invoice.dueDate, currDate, 'dueDateInvalid', 'due date')) { + return false; + } + return true; + } + + private isDateValid(date: Date, currDate: Date, errorMsgKey: string, dateType: string): boolean { + if (DateUtils.isSameDate(date, currDate) == 0 && this.invoice.status == invoiceStatus.DRAFT) { + this.msgSvc.addFailedMsg($localize`:@@${errorMsgKey}:Please ensure that the ${dateType} is set for today or any date in the future.`); + return false; + } + return true; + } + private validateLogPaymentDate(): boolean { const currDate = new Date(); if (DateUtils.isSameDate(this.autoLogPaymentForm.paymentDate, currDate) == 2) { @@ -1035,22 +965,6 @@ export class InvoiceEditComponent extends BaseComp implements OnInit, OnDestroy if (invoice) { paymentPayload = paymentPayload.map(i => ({ ...i, invoiceId: invoice._id })); this.invoiceSvc.createListLogPayment(paymentPayload).subscribe(res => { - // Track bulk payment logging for new invoice - paymentPayload.forEach((payment, index) => { - this.gaSvc.trackInvoicePaymentLogged({ - invoice_id: invoice._id, - payment_amount: parseFloat(payment.amount) || 0, - payment_method: this.gaHelpers.mapPaymentMethod(payment.paymentMethod), - payment_date: payment.paymentDate?.toISOString?.()?.split('T')[0] || new Date().toISOString().split('T')[0], - remaining_balance: 0, // Full payment scenario - days_to_payment: this.gaHelpers.calculateDaysToPayment(this.invoice?.openDate, payment.paymentDate), - payment_reference: `${this.gaHelpers.generatePaymentReference(this.invoice?.code)}-${index + 1}`, - user_id: this.getAnalyticsUserId(), - user_role: this.getAnalyticsUserRole(), - platform: 'web' - }); - }); - this.store.dispatch(new invoiceAction.CreateSuccess(invoice)); }, err => { this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.create).replace('#thing#', $localize`:@@logPayment:Log Payment`)); @@ -1066,22 +980,6 @@ export class InvoiceEditComponent extends BaseComp implements OnInit, OnDestroy if (invoice) { paymentPayload = paymentPayload.map(i => ({ ...i, invoiceId: invoice._id })); this.invoiceSvc.createListLogPayment(paymentPayload).subscribe(res => { - // Track bulk payment logging for updated invoice - paymentPayload.forEach((payment, index) => { - this.gaSvc.trackInvoicePaymentLogged({ - invoice_id: invoice._id, - payment_amount: parseFloat(payment.amount) || 0, - payment_method: this.gaHelpers.mapPaymentMethod(payment.paymentMethod), - payment_date: payment.paymentDate?.toISOString?.()?.split('T')[0] || new Date().toISOString().split('T')[0], - remaining_balance: 0, // Full payment scenario - days_to_payment: this.gaHelpers.calculateDaysToPayment(this.invoice?.openDate, payment.paymentDate), - payment_reference: `${this.gaHelpers.generatePaymentReference(this.invoice?.code)}-${index + 1}`, - user_id: this.getAnalyticsUserId(), - user_role: this.getAnalyticsUserRole(), - platform: 'web' - }); - }); - this.store.dispatch(new invoiceAction.UpdateSuccess(invoice)); }, err => { this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.create).replace('#thing#', $localize`:@@logPayment:Log Payment`)); @@ -1092,70 +990,6 @@ export class InvoiceEditComponent extends BaseComp implements OnInit, OnDestroy }); } - // GA4 Analytics Helper Methods - private calculateDueDateDays(dueDate: Date): number { - if (!dueDate) return 30; // Default to 30 days - const today = new Date(); - const due = new Date(dueDate); - const timeDiff = due.getTime() - today.getTime(); - return Math.ceil(timeDiff / (1000 * 3600 * 24)); - } - - private getModifiedFields(): string[] { - const fields = []; - if (!this._orgInvoice) return fields; - - // Compare key fields - if (this.invoice.status !== this._orgInvoice.status) fields.push('status'); - if (this.invoice.dueDate !== this._orgInvoice.dueDate) fields.push('due_date'); - if (this.invoice.paymentTerm !== this._orgInvoice.paymentTerm) fields.push('payment_terms'); - if (this.selectedJobs?.length !== this._orgSelectedJobs?.length) fields.push('jobs'); - if (this.selectedClients?.length !== this._orgSelectedClients?.length) fields.push('clients'); - - return fields; - } - - private calculateAmountChange(): number { - if (!this._orgInvoice) return 0; - const currentAmount = this.totalTotal || 0; - const originalAmount = this.calculateInvoiceAmount(this._orgInvoice); - return currentAmount - originalAmount; - } - - private determineModificationType(): 'amount' | 'due_date' | 'jobs' | 'customer' | 'payment_terms' { - const modifiedFields = this.getModifiedFields(); - if (modifiedFields.includes('jobs')) return 'jobs'; - if (modifiedFields.includes('clients')) return 'customer'; - if (modifiedFields.includes('payment_terms')) return 'payment_terms'; - if (modifiedFields.includes('due_date')) return 'due_date'; - return 'amount'; - } - - private getUserRole(): 'admin' | 'applicator' | 'office_admin' | 'client' | 'officer' | 'pilot' | 'inspector' | 'aircraft' { - const roles = this.authSvc.user?.roles || []; - if (roles.includes(RoleIds.ADMIN)) return 'admin'; - if (roles.includes(RoleIds.APP)) return 'applicator'; - if (roles.includes(RoleIds.APP_ADM)) return 'office_admin'; - if (roles.includes(RoleIds.PILOT)) return 'pilot'; - if (roles.includes(RoleIds.OFFICER)) return 'officer'; - if (roles.includes(RoleIds.INSPECTOR)) return 'inspector'; - if (roles.includes(RoleIds.DEVICE)) return 'aircraft'; - return 'client'; - } - - private calculateInvoiceAmount(invoice: any): number { - if (!invoice) return 0; - return invoice.totalAmount || 0; - } - - private calculateDaysInStatus(): number { - if (!this.invoice?.openDate) return 0; - const statusDate = new Date(this.invoice.openDate); - const now = new Date(); - const timeDiff = now.getTime() - statusDate.getTime(); - return Math.ceil(timeDiff / (1000 * 3600 * 24)); - } - ngOnDestroy() { super.ngOnDestroy(); } diff --git a/Development/client/src/app/invoices/invoice-resolver.service.ts b/Development/client/src/app/invoices/invoice-resolver.service.ts index 19c2bbc..706688e 100644 --- a/Development/client/src/app/invoices/invoice-resolver.service.ts +++ b/Development/client/src/app/invoices/invoice-resolver.service.ts @@ -49,7 +49,6 @@ export class InvoiceResolver implements Resolve { } private createNewInvoiceDataFromJob(job: any): Observable { - job.job = job._id; return this.getClientById(job.client._id).pipe( switchMap(client => this.invoiceSvc.getSettingByClientId(job.client._id).pipe( map(clientInvoiceSetting => { @@ -68,8 +67,8 @@ export class InvoiceResolver implements Resolve { private createNewClient(clientInvoiceSetting: any, globalInvoiceSetting: any, client: any): Client { const invoiceSetting = clientInvoiceSetting || globalInvoiceSetting; const newClient = { - issuerCompanyName: invoiceSetting.companyName, - issuerAddress: invoiceSetting.address, + applicatorCompanyName: invoiceSetting.companyName, + applicatorAddress: invoiceSetting.address, logo: clientInvoiceSetting?.logo || '', split: '100', taxRate: `${invoiceSetting.taxValue}`, @@ -103,10 +102,11 @@ export class InvoiceResolver implements Resolve { private createExistingInvoiceData(_invoice: any, globalInvoiceSetting: any, logs: any): InvoiceData { if (_invoice) { const invoice = this.invoiceSvc.createInvoiceDate(_invoice); + let jobs = invoice.jobs.map(jobCosting => ({ ...jobCosting, _id: jobCosting.job })); return { mode: invoice.status, invoice, - jobs: invoice.jobs, + jobs, paymentTermOpts: globalInvoiceSetting.termOpts, logs }; diff --git a/Development/client/src/app/invoices/invoices-list/invoices-list.component.html b/Development/client/src/app/invoices/invoices-list/invoices-list.component.html index 6204209..b119f70 100644 --- a/Development/client/src/app/invoices/invoices-list/invoices-list.component.html +++ b/Development/client/src/app/invoices/invoices-list/invoices-list.component.html @@ -1,7 +1,7 @@
- +
@@ -17,11 +17,11 @@
- +
- + @@ -30,7 +30,7 @@ {{cols[0].header}}{{invoice.totalAmount | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}} {{cols[1].header}}{{invoice.code}} - {{cols[2].header}}{{invoice.clientsDisplay}} + {{cols[2].header}}{{invoice.clients}} {{cols[3].header}}{{invoice.openDate | date:'shortDate'}} {{cols[4].header}}{{invoice.dueDate | date:'shortDate'}} {{cols[5].header}}{{ (invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1)) | invoiceStatus }} diff --git a/Development/client/src/app/invoices/invoices-list/invoices-list.component.ts b/Development/client/src/app/invoices/invoices-list/invoices-list.component.ts index def6f53..e239e49 100644 --- a/Development/client/src/app/invoices/invoices-list/invoices-list.component.ts +++ b/Development/client/src/app/invoices/invoices-list/invoices-list.component.ts @@ -6,16 +6,17 @@ import { globals, RoleIds } from '@app/shared/global'; import { select } from '@ngrx/store'; import * as fromInvoices from '@app/invoices/reducers'; import * as invoiceActions from '../actions/invoice.actions'; +import * as jobsActions from '../../job/actions/job.actions'; import { invoiceStatus } from '@app/shared/global'; import { ActivatedRoute } from '@angular/router'; import { Table } from 'primeng/table'; import { DatePipe } from '@angular/common'; import { saveAs } from 'file-saver'; +import { LoaderService } from '@app/shared/loader/loader.service'; import { InvoiceService } from '@app/domain/services/invoice.service'; import { FilterUtils } from 'primeng/utils'; import { DateUtils, Utils } from '@app/shared/utils'; import { RestoreTableState } from '@app/shared/restore-table-state'; -import { GAService } from '@app/shared/ga.service'; @Component({ selector: 'agm-invoices-list', @@ -37,7 +38,7 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy rows1Page = [10, 15, 30, 60, 100]; cols: any[]; status: SelectItem[]; - selectedInvoice: any[] = []; + selectedInvoice = []; totalInvoices; exportDlg = false; @@ -47,6 +48,7 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy private readonly route: ActivatedRoute, private readonly datePipe: DatePipe, private readonly invoiceSvc: InvoiceService, + private readonly loaderSvc: LoaderService, private readonly restoreTableSvc: RestoreTableState ) { super(); @@ -58,7 +60,7 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy this.cols = [ { field: 'totalAmount', header: $localize`:@@totalAmount:Total Amount`, filtered: false, filterMatchMode: 'contains' }, { field: 'code', header: $localize`:@@invoiceNumber:Invoice Number`, filtered: true, filterMatchMode: 'contains' }, - { field: 'clientsDisplay', header: globals.clients, width: '20%', filtered: true, filterMatchMode: 'contains' }, + { field: 'clients', header: globals.clients, width: '20%', filtered: true, filterMatchMode: 'contains' }, { field: 'openDate', header: $localize`:@@openDate:Open Date`, filtered: false, filterMatchMode: 'contains' }, { field: 'dueDate', header: $localize`:@@dueDate:Due Date`, filtered: false, filterMatchMode: 'contains' }, { field: 'status', header: $localize`:@@status:Status` }, @@ -82,22 +84,15 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy this.invoices = invoices .map(i => ({ ...i, - totalAmount: this.calculateInvoiceAmount(i), - clientsDisplay: i?.clients?.map(client => client.billTo?.name)?.join(' ; ') || '' + ...this.invoiceRowDataFormatter(i) })); - - // Track invoice list viewed - this.gaSvc.trackInvoiceListViewed({ - view_type: 'table', - total_invoices: this.invoices.length, - displayed_invoices: Math.min(this.invoices.length, 10), // Default page size - user_id: this.getAnalyticsUserId(), - user_role: this.getAnalyticsUserRole(), - platform: 'web' - }); } }); this.store.dispatch(new invoiceActions.Fetch()); + if (this.canEdit) { + this.store.dispatch(new jobsActions.Fetch(null)); + } + FilterUtils[this.openDateFilter] = (value, filter): boolean => { if (filter === undefined || filter === null) { return true; @@ -148,14 +143,7 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy && field && filterName; - if (canFilter) { - this.dt.filter(range, field, filterName); - - // Track date range filtering - setTimeout(() => { - this.trackFilterOperation('date_range', range, this.dt.filteredValue?.length || this.invoices.length); - }, 100); - } + if (canFilter) return this.dt.filter(range, field, filterName); } closeCal(range, field, filterName) { @@ -167,12 +155,7 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy if (canFilter) { range[1] = range[0]; - this.dt.filter(range, field, filterName); - - // Track date range filtering - setTimeout(() => { - this.trackFilterOperation('date_range', range, this.dt.filteredValue?.length || this.invoices.length); - }, 100); + return this.dt.filter(range, field, filterName); } } @@ -195,55 +178,35 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy return Utils.arraySum(invoice?.clients.map(client => this.calculateSingleClientInvoiceAmount(client).total)); } - get canEdit(): boolean { - return this.authSvc.hasRole([RoleIds.APP]); + invoiceRowDataFormatter(invoice) { + const row = { + totalAmount: this.calculateInvoiceAmount(invoice), + poNumber: invoice?.poNumber, + clients: invoice?.clients?.map(i => i.billTo?.name)?.join(' ; '), + openDate: invoice?.openDate, + dueDate: invoice?.dueDate, + }; + return row; } - selectInvoice(invoice) { - this.selectedInvoice = invoice ? [invoice] : []; // Replace the selection with the new row - this.dt.selection = this.selectedInvoice; - this.dt.updateSelectionKeys(); - this.dt.saveState(); + get canEdit(): boolean { + return this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM]); } editInvoice(invoice: Invoice) { - this.selectInvoice(invoice); - - // Track invoice selection - this.gaSvc.trackInvoiceSelected({ - invoice_id: invoice._id, - selection_method: 'edit_button', - invoice_status: invoice.status, - invoice_amount: this.calculateInvoiceAmount(invoice), - user_id: this.getAnalyticsUserId(), - user_role: this.getAnalyticsUserRole(), - platform: 'web' - }); - this.router.navigate([`/invoices/edit/${invoice._id}`]); } viewInvoice(invoice: Invoice) { - this.selectInvoice(invoice); - - // Track invoice selection - this.gaSvc.trackInvoiceSelected({ - invoice_id: invoice._id, - selection_method: 'view_button', - invoice_status: invoice.status, - invoice_amount: this.calculateInvoiceAmount(invoice), - user_id: this.getAnalyticsUserId(), - user_role: this.getAnalyticsUserRole(), - platform: 'web' - }); - this.router.navigate([`./detail/${invoice._id}`], { relativeTo: this.route }); } onSelectInvoice(e) { + this.selectedInvoice.push(e.data); } onUnselectInvoice(e) { + this.selectedInvoice = this.selectedInvoice.filter(i => i._id != e.data._id); } deleteInvoice() { @@ -257,18 +220,6 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy const payload = { invoiceIds: this.selectedInvoice.map(i => i._id) }; - - // Track invoice bulk action - this.gaSvc.trackInvoiceBulkAction({ - action_type: 'delete', - invoice_count: this.selectedInvoice.length, - invoice_ids: this.selectedInvoice.map(i => i._id), - total_amount_affected: this.selectedInvoice.reduce((sum, inv) => sum + this.calculateInvoiceAmount(inv), 0), - user_id: this.getAnalyticsUserId(), - user_role: this.getAnalyticsUserRole(), - platform: 'web' - }); - this.store.dispatch(new invoiceActions.Delete(payload)); this.selectedInvoice = []; } @@ -289,6 +240,7 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy downloadCSV() { const payload = [...this.selectedInvoice]; + this.loaderSvc.show(); this.invoiceSvc.downloadInvoicesCSV(payload.map(i => i._id)).subscribe((res) => { try { if (payload.length == 1) { @@ -296,22 +248,11 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy } else { saveAs(res, `Agmission_invoices_${this.datePipe.transform(new Date(), 'yyyy-MM-ddTHH-mm-ss')}.csv`); } - - // Track invoice bulk export - this.gaSvc.trackInvoiceBulkAction({ - action_type: 'export', - invoice_count: payload.length, - invoice_ids: payload.map(i => i._id), - total_amount_affected: payload.reduce((sum, inv) => sum + this.calculateInvoiceAmount(inv), 0), - success_rate: 1.0, - user_id: this.getAnalyticsUserId(), - user_role: this.getAnalyticsUserRole(), - platform: 'web' - }); } catch (error) { alert('Sorry. Your browser does not support this feature !'); } this.exportDlg = false; + this.loaderSvc.hide(); }, (err) => { this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.download).replace('#thing#', globals.invoice)); }); @@ -319,6 +260,7 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy downloadIIF() { const payload = [...this.selectedInvoice]; + this.loaderSvc.show(); this.invoiceSvc.downloadInvoicesIIF(payload.map(i => i._id)).subscribe((res) => { try { if (payload.length == 1) { @@ -326,22 +268,11 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy } else { saveAs(res, `Agmission_invoices_${this.datePipe.transform(new Date(), 'yyyy-MM-ddTHH-mm-ss')}.iif`); } - - // Track invoice bulk export - this.gaSvc.trackInvoiceBulkAction({ - action_type: 'export', - invoice_count: payload.length, - invoice_ids: payload.map(i => i._id), - total_amount_affected: payload.reduce((sum, inv) => sum + this.calculateInvoiceAmount(inv), 0), - success_rate: 1.0, - user_id: this.getAnalyticsUserId(), - user_role: this.getAnalyticsUserRole(), - platform: 'web' - }); } catch (error) { alert('Sorry. Your browser does not support this feature !'); } this.exportDlg = false; + this.loaderSvc.hide(); }, (err) => { this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.download).replace('#thing#', globals.invoice)); }); @@ -350,52 +281,4 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy ngOnDestroy(): void { super.ngOnDestroy(); } - - private trackFilterOperation(filterType: 'status' | 'date_range' | 'client' | 'amount_range' | 'overdue', filterValue: any, resultsAfter: number) { - // Track invoice list filtering - this.gaSvc.trackInvoiceListFiltered({ - filter_type: filterType, - filter_value: filterValue, - results_before: this.invoices.length, - results_after: resultsAfter, - filter_effectiveness: this.invoices.length > 0 ? resultsAfter / this.invoices.length : 0, - multiple_filters_active: this.hasMultipleFiltersActive(), - user_id: this.getAnalyticsUserId(), - user_role: this.getAnalyticsUserRole(), - platform: 'web' - }); - } - - private hasMultipleFiltersActive(): boolean { - if (!this.dt?.filters) return false; - - const activeFilters = Object.keys(this.dt.filters) - .filter(key => this.dt.filters[key]?.value !== null && this.dt.filters[key]?.value !== undefined && this.dt.filters[key]?.value !== ''); - - return activeFilters.length > 1; - } - - onTextFilter(event: any, field: string, matchMode: string) { - const filterValue = event.target.value; - this.dt.filter(filterValue, field, matchMode); - - // Track filtering after a short delay to ensure the table is updated - setTimeout(() => { - if (filterValue && filterValue.trim()) { - const filterType = field === 'clientsDisplay' ? 'client' : field === 'totalAmount' ? 'amount_range' : 'client'; - this.trackFilterOperation(filterType, filterValue, this.dt.filteredValue?.length || this.invoices.length); - } - }, 100); - } - - onStatusFilter(event: any) { - this.dt.filter(event.value, 'status', 'in'); - - // Track filtering after a short delay to ensure the table is updated - setTimeout(() => { - if (event.value && event.value.length > 0) { - this.trackFilterOperation('status', event.value, this.dt.filteredValue?.length || this.invoices.length); - } - }, 100); - } -} \ No newline at end of file +} diff --git a/Development/client/src/app/invoices/invoices.module.ts b/Development/client/src/app/invoices/invoices.module.ts index 3da8f17..be00329 100644 --- a/Development/client/src/app/invoices/invoices.module.ts +++ b/Development/client/src/app/invoices/invoices.module.ts @@ -26,10 +26,10 @@ import { RadioButtonModule } from 'primeng/radiobutton'; import { CostingItemComponent } from './costing-item/costing-item.component'; import { CostingItemEffects } from '@app/invoices/effects/costing-item.effects'; import { StoreModule } from '@ngrx/store'; -import * as fromInvoiceSettings from './reducers/settings.reducer'; -import * as fromCostingItems from './reducers/costing-items.reducer'; -import * as fromInvoices from './reducers/invoices.reducer'; -import * as fromJobs from '../job/reducers/jobs.reducer'; +import * as fromInvoiceSettings from './reducers/settings-reducer'; +import * as fromCostingItems from './reducers/costing-items-reducer'; +import * as fromInvoices from './reducers/invoices-reducer'; +import * as fromJobs from '../job/reducers/jobs-reducer'; import { CostingItemTypePipe } from '@app/invoices/pipes/costing-item-type.pipe'; import { CostingItemUnitPipe } from '@app/invoices/pipes/costing-item-unit.pipe'; import { InvoiceDetailComponent } from './invoice-detail/invoice-detail.component'; diff --git a/Development/client/src/app/invoices/models/invoice.model.ts b/Development/client/src/app/invoices/models/invoice.model.ts index a016902..3bd3057 100644 --- a/Development/client/src/app/invoices/models/invoice.model.ts +++ b/Development/client/src/app/invoices/models/invoice.model.ts @@ -8,8 +8,8 @@ export interface Client { phone: string; email: string; }; - issuerCompanyName?: string; - issuerAddress?: string; + applicatorCompanyName?: string; + applicatorAddress?: string; clientAddress?: string; clientEmail?: string; clientName?: string; diff --git a/Development/client/src/app/invoices/models/setting.model.ts b/Development/client/src/app/invoices/models/setting.model.ts index bc0d0be..c723f0a 100644 --- a/Development/client/src/app/invoices/models/setting.model.ts +++ b/Development/client/src/app/invoices/models/setting.model.ts @@ -1,8 +1,3 @@ -export enum PastDueOpt { - MARK_UNCOLLECTIBLE = 'mark_uncollectible', - NONE = 'none' -} - export interface Setting { _id: string; companyName: string; @@ -14,27 +9,8 @@ export interface Setting { note?: string; logo?: File | string; termOpts?: any[]; - dueToUncollectibleDaysOps?: any[]; - dueToUncollectibleDays?: number; - dueToUncollectibleOps?: PastDueOpt[]; - dueToUncollectibleOp?: PastDueOpt; } -export const getLocalizedPastDueOpt = (action: PastDueOpt): string => { - switch (action) { - case PastDueOpt.MARK_UNCOLLECTIBLE: - return $localize`:@@markUncollectible:mark the invoice as uncollectible`; - case PastDueOpt.NONE: - return $localize`:@@noAction:no action is required`; - default: - return action; - } -}; - -export const DEFAULT_UNCOLLECTIBLE_DAY = 30; -export const UNCOLLECTIBLE_DAY_OPTIONS = [DEFAULT_UNCOLLECTIBLE_DAY, 60, 90]; -export const UNCOLLECTIBLE_ACTIONS = [PastDueOpt.NONE, PastDueOpt.MARK_UNCOLLECTIBLE]; - export const createNewSetting = () => { const setting: Setting = { _id: '0', @@ -45,11 +21,7 @@ export const createNewSetting = () => { currency: 'CAD', paymentTerm: 15, note: '', - termOpts: [15, 30, 60], - dueToUncollectibleDaysOps: UNCOLLECTIBLE_DAY_OPTIONS, - dueToUncollectibleOps: UNCOLLECTIBLE_ACTIONS, - dueToUncollectibleDays: DEFAULT_UNCOLLECTIBLE_DAY, - dueToUncollectibleOp: PastDueOpt.MARK_UNCOLLECTIBLE + termOpts: [15, 30, 60] }; return setting; }; diff --git a/Development/client/src/app/invoices/reducers/costing-items.reducer.ts b/Development/client/src/app/invoices/reducers/costing-items-reducer.ts similarity index 100% rename from Development/client/src/app/invoices/reducers/costing-items.reducer.ts rename to Development/client/src/app/invoices/reducers/costing-items-reducer.ts diff --git a/Development/client/src/app/invoices/reducers/index.ts b/Development/client/src/app/invoices/reducers/index.ts index 45a20d2..10b05e2 100644 --- a/Development/client/src/app/invoices/reducers/index.ts +++ b/Development/client/src/app/invoices/reducers/index.ts @@ -1,7 +1,7 @@ import {createFeatureSelector, createSelector} from '@ngrx/store'; -import * as fromInvoices from './invoices.reducer'; -import * as fromSettings from './settings.reducer'; -import * as fromCostingItems from './costing-items.reducer'; +import * as fromInvoices from './invoices-reducer'; +import * as fromSettings from './settings-reducer'; +import * as fromCostingItems from './costing-items-reducer'; export const getInvoicesState = createFeatureSelector(fromInvoices.FEATURE_KEY); export const getSettingsState = createFeatureSelector(fromSettings.FEATURE_KEY); diff --git a/Development/client/src/app/invoices/reducers/invoices.reducer.ts b/Development/client/src/app/invoices/reducers/invoices-reducer.ts similarity index 100% rename from Development/client/src/app/invoices/reducers/invoices.reducer.ts rename to Development/client/src/app/invoices/reducers/invoices-reducer.ts diff --git a/Development/client/src/app/invoices/reducers/settings.reducer.ts b/Development/client/src/app/invoices/reducers/settings-reducer.ts similarity index 100% rename from Development/client/src/app/invoices/reducers/settings.reducer.ts rename to Development/client/src/app/invoices/reducers/settings-reducer.ts diff --git a/Development/client/src/app/invoices/setting-resolver.service.ts b/Development/client/src/app/invoices/setting-resolver.service.ts index ada07f2..7d3f9a8 100644 --- a/Development/client/src/app/invoices/setting-resolver.service.ts +++ b/Development/client/src/app/invoices/setting-resolver.service.ts @@ -1,5 +1,5 @@ import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; -import { createNewSetting, Setting, UNCOLLECTIBLE_ACTIONS, UNCOLLECTIBLE_DAY_OPTIONS } from '@app/invoices/models/setting.model'; +import { createNewSetting, Setting } from '@app/invoices/models/setting.model'; import { Injectable } from '@angular/core'; import { Observable, of } from 'rxjs'; import { InvoiceService } from '@app/domain/services/invoice.service'; @@ -18,8 +18,6 @@ export class SettingResolver implements Resolve { return this.invoiceSvc.getDefaultSetting().pipe( map(setting => { if (setting) { - setting.dueToUncollectibleDaysOps = setting.dueToUncollectibleDaysOps || UNCOLLECTIBLE_DAY_OPTIONS; - setting.dueToUncollectibleOps = setting.dueToUncollectibleOps || UNCOLLECTIBLE_ACTIONS; return setting; } else { return createNewSetting(); diff --git a/Development/client/src/app/invoices/settings/settings.component.html b/Development/client/src/app/invoices/settings/settings.component.html index 9006fe9..eb13909 100644 --- a/Development/client/src/app/invoices/settings/settings.component.html +++ b/Development/client/src/app/invoices/settings/settings.component.html @@ -20,12 +20,11 @@
- -
+
Payment Terms
Payment is due by Invoice Open Date +
-
+
{{selectedItem.label}} @@ -48,11 +47,6 @@
- -
- -
-
@@ -120,17 +114,4 @@
-
- - -
Invoice Status
-
-
If an invoice is past due by
-
- -
-
- -
-
-
\ No newline at end of file +
\ No newline at end of file diff --git a/Development/client/src/app/invoices/settings/settings.component.ts b/Development/client/src/app/invoices/settings/settings.component.ts index 2e0adef..975ea0d 100644 --- a/Development/client/src/app/invoices/settings/settings.component.ts +++ b/Development/client/src/app/invoices/settings/settings.component.ts @@ -2,7 +2,7 @@ import { AfterViewInit, Component, ElementRef, OnDestroy, OnInit, ViewChild } fr import { BaseComp } from '@app/shared/base/base.component'; import { allowedLogoFileExt, allowedLogoFormats, globals, maxLogoSize } from '@app/shared/global'; import { ActivatedRoute } from '@angular/router'; -import { Setting, getLocalizedPastDueOpt } from '@app/invoices/models/setting.model'; +import { Setting } from '@app/invoices/models/setting.model'; import { currencies } from '@app/shared/currencies'; import * as SettingActions from '../actions/setting.actions'; import { SelectItem } from 'primeng/api'; @@ -41,16 +41,13 @@ export class SettingsComponent extends BaseComp implements OnInit, AfterViewInit get paymentTerm() { return this._paymentTerm; } + set paymentTerm(paymentTerm) { this._paymentTerm = paymentTerm; } - dueToUncollectibleDaysOps: SelectItem[] = []; - dueToUncollectibleDays; - dueToUncollectibleOps: SelectItem[] = []; - dueToUncollectibleOp; - addingNewTerm = false; + customTerm; @ViewChild('name') name: ElementRef; @@ -74,47 +71,30 @@ export class SettingsComponent extends BaseComp implements OnInit, AfterViewInit const setting = data[0] as Setting || null; if (setting) { this._isNew = (setting._id === '0'); - this.setting = setting; + this.setting = { + ...setting, + }; + this.paymentTermOpts = setting.termOpts.map(t => { + label: $localize`:@@paymentTermDay:#day# days`.replace('#day#', t), + value: t + }); this.previewLogo = setting.logo; - - this.initializePaymentTermOpts(setting); - this.initializeDueToUncollectibleOpts(setting); + const paymentTerm = this.paymentTermOpts.find(i => i.value == setting.paymentTerm); + if (paymentTerm) { + this.paymentTerm = paymentTerm.value; + } else { + this.paymentTermOpts + .push({ + label: $localize`:@@paymentTermDay:#day# days`.replace('#day#', `${setting.paymentTerm}`), + value: setting.paymentTerm + }); + this.paymentTermOpts = this.paymentTermOpts.sort((a, b) => a.value - b.value); + this.paymentTerm = setting.paymentTerm; + } } }); } - private initializePaymentTermOpts(setting: Setting) { - this.paymentTermOpts = setting.termOpts.map(t => { - label: $localize`:@@numOfDays:#day# days`.replace('#day#', t), - value: t - }); - const paymentTerm = this.paymentTermOpts.find(i => i.value == setting.paymentTerm); - if (paymentTerm) { - this.paymentTerm = paymentTerm.value; - } else { - this.paymentTermOpts.push({ - label: $localize`:@@numOfDays:#day# days`.replace('#day#', `${setting.paymentTerm}`), - value: setting.paymentTerm - }); - this.paymentTermOpts = this.paymentTermOpts.sort((a, b) => a.value - b.value); - this.paymentTerm = setting.paymentTerm; - } - } - - private initializeDueToUncollectibleOpts(setting: Setting) { - this.dueToUncollectibleDaysOps = setting.dueToUncollectibleDaysOps.map(day => { - label: $localize`:@@numOfDays:#day# days`.replace('#day#', day), - value: day - }); - this.dueToUncollectibleDays = this.dueToUncollectibleDaysOps.find(i => i.value == setting.dueToUncollectibleDays)?.value || this.dueToUncollectibleDaysOps[0].value; - - this.dueToUncollectibleOps = setting.dueToUncollectibleOps.map(action => { - label: getLocalizedPastDueOpt(action), - value: action - }); - this.dueToUncollectibleOp = this.dueToUncollectibleOps.find(i => i.value == setting.dueToUncollectibleOp)?.value || this.dueToUncollectibleOps[0].value; - } - ngAfterViewInit() { this.focusName(); } @@ -170,11 +150,11 @@ export class SettingsComponent extends BaseComp implements OnInit, AfterViewInit if (!this.paymentTermOpts.map(i => i.value).includes(newDate)) { if (newDate > 1) { this.paymentTermOpts - .push({ label: $localize`:@@numOfDayss:#day# days`.replace('#day#', String(newDate)), value: newDate }); + .push({ label: $localize`:@@paymentTermDays:#day# days`.replace('#day#', String(newDate)), value: newDate }); this.paymentTermOpts = this.paymentTermOpts.sort((a, b) => a.value - b.value); } else { this.paymentTermOpts - .push({ label: $localize`:@@numOfDays:#day# day`.replace('#day#', String(newDate)), value: newDate }); + .push({ label: $localize`:@@paymentTermDay:#day# day`.replace('#day#', String(newDate)), value: newDate }); this.paymentTermOpts = this.paymentTermOpts.sort((a, b) => a.value - b.value); } if (!this._isNew) { @@ -229,8 +209,6 @@ export class SettingsComponent extends BaseComp implements OnInit, AfterViewInit companyName: this.setting?.companyName?.trim(), address: this.setting?.address?.trim(), termOpts: [...this.paymentTermOpts.map(o => o.value)], - dueToUncollectibleDays: this.dueToUncollectibleDays, - dueToUncollectibleOp: this.dueToUncollectibleOp }; payload.paymentTerm = this.paymentTerm; if (this._isNew) { @@ -247,4 +225,5 @@ export class SettingsComponent extends BaseComp implements OnInit, AfterViewInit ngOnDestroy(): void { super.ngOnDestroy(); } + } diff --git a/Development/client/src/app/job/effects/job.effects.ts b/Development/client/src/app/job/effects/job.effects.ts index 68eee85..1a248a9 100644 --- a/Development/client/src/app/job/effects/job.effects.ts +++ b/Development/client/src/app/job/effects/job.effects.ts @@ -51,20 +51,7 @@ export class JobEffects { ofType(jobActions.CREATE), switchMap(({ payload }) => this.jobSvc.createJob(payload).pipe( - map((job) => { - // Track job creation with GA4 - this.gaSvc.trackJobCreated({ - user_id: 'system', - platform: 'web', - job_type: this.normalizeJobType(payload.appType), - field_size_acres: payload.ttSprArea || 0, - crop_type: payload.crop?.name || 'unknown', - client_id: payload.client?._id?.toString() || 'unknown', - priority: 'medium' // Default priority - }); - - return new jobActions.CreateSuccess(job); - }), + map((job) => new jobActions.CreateSuccess(job)), catchError(err => { this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.save).replace('#thing#', globals.job)); return of(new jobActions.CreateFailed()) @@ -76,37 +63,11 @@ export class JobEffects { @Effect() updateJob$: Observable = this.actions$.pipe( ofType(jobActions.UPDATE), - switchMap(({ payload }) => { - const oldStatus = payload.job?.status; - - return this.jobSvc.saveJob(payload).pipe( + switchMap(({ payload }) => + this.jobSvc.saveJob(payload).pipe( map((data) => { - const updatedJob = toJob(data); - const newStatus = updatedJob.status; - - // Check if status changed during update - if (oldStatus !== undefined && oldStatus !== newStatus) { - this.gaSvc.trackJobStatusChanged({ - user_id: 'system', - platform: 'web', - job_id: payload.job?._id?.toString() || 'unknown', - old_status: this.mapStatusToString(oldStatus), - new_status: this.mapStatusToString(newStatus), - status_change_reason: 'api_update' - }); - } - - // Track general job update - this.gaSvc.trackJobUpdated({ - user_id: 'system', - platform: 'web', - job_id: payload.job?._id?.toString() || 'unknown', - fields_modified: this.detectModifiedFields(payload, updatedJob), - change_magnitude: oldStatus !== newStatus ? 'major' : 'minor', - save_method: 'manual' - }); - - return new jobActions.UpdateSuccess(updatedJob) + this.gaSvc.gaEvent("JOBS", "CRUD", "U"); + return new jobActions.UpdateSuccess(toJob(data)) }), catchError(err => { if (err?.error?.error['.tag'] == 'cannot_edit_job_have_invoice_opened') { @@ -117,7 +78,7 @@ export class JobEffects { return of(new jobActions.UpdateFailed()); }) ) - }) + ) ); @Effect() @@ -126,19 +87,7 @@ export class JobEffects { switchMap(({ payload }) => this.jobSvc.deleteJob(payload).pipe( map(() => { - // Track job deletion with GA4 - this.gaSvc.trackJobDeleted({ - user_id: 'system', // Effects don't have direct user context - platform: 'web', - job_id: payload._id?.toString() || 'unknown', - job_type: payload.appType || 'unknown', - job_status: payload.status?.toString() || 'unknown', - deletion_reason: 'user_action', - deletion_method: 'api_call', - time_since_creation: payload.createdAt ? - Math.floor((new Date().getTime() - new Date(payload.createdAt).getTime()) / (1000 * 60 * 60)) : 0 - }); - + this.gaSvc.gaEvent("JOBS", "CRUD", "D"); return new jobActions.DeleteSuccess(payload) }), catchError(err => { @@ -156,17 +105,7 @@ export class JobEffects { this.jobSvc.assign(payload).pipe( map(() => { this.msgSvc.addSuccessMsg($localize`:@@jobAssigned:Job assigned`); - - // Track job assignment with GA4 - this.gaSvc.trackJobAssigned({ - user_id: 'system', - platform: 'web', - job_id: payload.jobId?.toString() || 'unknown', - assignee_id: payload.asUsers?.[0]?._id?.toString() || 'unknown', - assignee_role: 'applicator', // Updated to use valid role - assignment_method: 'manual' - }); - + this.gaSvc.gaEvent("JOBS", "DATA", "A"); return new jobActions.AssignSuccess({ _id: payload.jobId }) }), catchError(err => { @@ -193,49 +132,4 @@ export class JobEffects { ) ) ); - - // Helper method to normalize job type to GA4 enum values - private normalizeJobType(appType: string): 'spraying' | 'seeding' | 'fertilizing' | 'harvesting' { - const type = appType?.toLowerCase(); - if (type?.includes('spray')) return 'spraying'; - if (type?.includes('seed')) return 'seeding'; - if (type?.includes('fertiliz')) return 'fertilizing'; - if (type?.includes('harvest')) return 'harvesting'; - return 'spraying'; // Default fallback - } - - /** - * Map numeric status to string for GA4 tracking - */ - private mapStatusToString(status: number): 'new' | 'ready' | 'downloaded' | 'sprayed' | 'archived' { - switch (status) { - case 0: return 'new'; - case 1: return 'ready'; - case 2: return 'downloaded'; - case 3: return 'sprayed'; - case 9: return 'archived'; - default: return 'new'; - } - } - - /** - * Detect which fields were modified in the job update - */ - private detectModifiedFields(payload: any, updatedJob: any): string[] { - const modifiedFields: string[] = []; - const originalJob = payload.job; - - if (!originalJob) return ['unknown']; - - // Check common fields that might change - if (originalJob.status !== updatedJob.status) modifiedFields.push('status'); - if (originalJob.name !== updatedJob.name) modifiedFields.push('name'); - if (originalJob.priority !== updatedJob.priority) modifiedFields.push('priority'); - if (originalJob.startDate !== updatedJob.startDate) modifiedFields.push('startDate'); - if (originalJob.endDate !== updatedJob.endDate) modifiedFields.push('endDate'); - if (originalJob.operator?._id !== updatedJob.operator?._id) modifiedFields.push('operator'); - if (originalJob.vehicle?._id !== updatedJob.vehicle?._id) modifiedFields.push('vehicle'); - - return modifiedFields.length > 0 ? modifiedFields : ['unknown']; - } } diff --git a/Development/client/src/app/job/job-assignment/job-assignment.component.css b/Development/client/src/app/job/job-assignment/job-assignment.component.css deleted file mode 100644 index df67034..0000000 --- a/Development/client/src/app/job/job-assignment/job-assignment.component.css +++ /dev/null @@ -1,393 +0,0 @@ -/* Job Assignment Component Styles - AgMission Theme Compliance */ - -/* Host element typography foundation - AgMission standards */ -/* These properties cascade to all child elements, reducing repetition */ -:host { - font-family: "Roboto", "Helvetica Neue", sans-serif; - /* $fontFamily - AgMission standard */ - line-height: 1.5; - /* $lineHeight - AgMission standard */ - letter-spacing: 0.25px; - /* $letterSpacing - AgMission standard */ -} - -.job-assignment-container { - margin-top: 20px; -} - -/* Aircraft Item Styling */ -.aircraft-item { - display: flex; - align-items: center; - border-bottom: 1px solid #bdbdbd; - /* $dividerColor */ - position: relative; -} - -.aircraft-icon { - color: #03A9F4; - /* $blue - info states */ - font-size: 16px; -} - -.aircraft-name { - flex: 1; - font-weight: 500; - cursor: pointer; -} - -/* Aircraft Tooltip Styling */ -:host ::ng-deep .aircraft-tooltip-enhanced { - max-width: 350px; - white-space: pre-line; - line-height: 1.4; - font-size: 13px; - background: #2E7D32; - /* $primaryDarkColor */ - color: #ffffff; - /* $primaryTextColor */ - border: 1px solid #4CAF50; - /* $primaryColor */ - border-radius: 3px; - /* AgMission standard border radius */ - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - padding: 12px 14px; -} - -:host ::ng-deep .aircraft-tooltip-enhanced .ui-tooltip-text { - background: transparent; - color: inherit; - border: none; - padding: 0; -} - -:host ::ng-deep .aircraft-tooltip-enhanced .ui-tooltip-arrow::before { - border-top-color: #2E7D32; - /* $primaryDarkColor */ -} - -.aircraft-details { - margin-top: 4px; - font-size: 0.85rem; - color: #757575; - /* $textSecondaryColor */ -} - -.sync-status { - margin-left: 8px; -} - -/* Download Options Info */ -.download-options-info { - display: flex; - align-items: center; - margin-top: 4px; - font-size: 0.85rem; - color: #757575; - /* $textSecondaryColor */ -} - -.download-options-info .pi { - margin-right: 4px; - color: #4CAF50; - /* $primaryColor - success indicator */ -} - -/* Assignment Status Styling */ -.assignment-status-section { - background: #ffffff; - /* $contentBgColor */ - border-radius: 3px; - /* AgMission standard border radius */ - padding: 16px; - border: 1px solid #bdbdbd; - /* $dividerColor */ -} - -.assignment-status-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 16px; -} - -.assignment-status-header h4 { - font-size: 1.25rem; - font-weight: 600; - color: #212121; - /* $textColor - matches other page labels */ - margin: 0; -} - -.assignment-header-actions { - display: flex; - gap: 8px; - align-items: center; -} - -.status-control-btn, -.clear-status-btn { - padding: 8px 12px !important; - font-size: 14px !important; - min-width: 44px !important; - min-height: 44px !important; - border-radius: 3px !important; - /* AgMission standard border radius */ -} - -.status-control-btn:focus, -.clear-status-btn:focus { - outline: 2px solid #03A9F4; - /* $blue - info states */ - outline-offset: 2px; -} - -.polling-status-indicator { - display: flex; - align-items: center; - gap: 8px; - padding: 12px 16px; - background-color: #E1F5FE; - /* Light blue background for info */ - border: 1px solid #03A9F4; - /* $blue */ - border-radius: 3px; - /* AgMission standard border radius */ - margin-bottom: 12px; - font-size: 0.95rem; - color: #0277BD; - /* $blueHover */ -} - -.assignment-progress { - display: flex; - align-items: center; - gap: 8px; - padding: 12px 16px; - background-color: #FFF8E1; - /* Light amber background for progress */ - border: 1px solid #FFC107; - /* $amber */ - border-radius: 3px; - /* AgMission standard border radius */ - margin-bottom: 12px; - font-weight: 600; - font-size: 0.95rem; - color: #FF8F00; - /* $amberHover */ -} - -.assignment-error-summary { - display: flex; - align-items: center; - gap: 8px; - padding: 12px 16px; - background-color: #FFEBEE; - /* Light red background for error */ - border: 1px solid #F44336; - /* $red */ - border-radius: 3px; - /* AgMission standard border radius */ - margin-bottom: 12px; - color: #C62828; - /* $redHover */ - font-weight: 600; - font-size: 0.95rem; -} - -/* Assignment Status Table Styling */ -.assignment-status-table { - margin-top: 12px; - border-radius: 3px; - /* AgMission standard border radius */ - overflow: hidden; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); -} - -.assignment-status-table .ui-table-thead th { - background-color: #e8e8e8; - /* $hoverBgColor */ - border-bottom: 2px solid #bdbdbd; - /* $dividerColor */ - color: #212121; - /* $textColor */ - font-weight: 600; - font-size: 0.9rem; - padding: 12px 8px; -} - -.assignment-status-table .ui-table-tbody>tr { - border-left: 4px solid transparent; - transition: background-color 0.2s ease; -} - -.assignment-status-table .ui-table-tbody>tr:hover { - background-color: #e8e8e8; - /* $hoverBgColor */ -} - -.assignment-status-table .ui-table-tbody>tr>td { - padding: 12px 8px; - font-size: 0.9rem; - border-bottom: 1px solid #bdbdbd; - /* $dividerColor */ -} - -.assignment-status-table .status-row-new { - border-left-color: #4527A0; - /* $accentDarkColor - new assignments */ -} - -.assignment-status-table .status-row-downloaded { - border-left-color: #f9a825; - /* $accentLightColor - downloaded assignments */ -} - -.assignment-status-table .status-row-uploaded { - border-left-color: #2E7D32; - /* $primaryDarkColor - uploaded/completed assignments */ -} - -.assignment-status-table .status-row-error { - border-left-color: #F44336; - /* Semantic red - error states */ -} - -.aircraft-cell { - display: flex; - align-items: center; - gap: 8px; -} - -.aircraft-cell .pi { - font-size: 16px; - color: #03A9F4; - /* $blue - info states */ -} - -.aircraft-cell .aircraft-name { - font-weight: 600; - color: #212121; - /* $textColor */ -} - -/* Status message and error details - AgMission Typography */ -.status-message { - font-weight: 500; - margin-top: 6px; - color: #212121; - /* $textColor */ - font-size: 0.9rem; -} - -.status-error-details { - margin-top: 6px; - color: #757575; - /* $textSecondaryColor */ - font-size: 0.85rem; - font-style: italic; -} - -.status-timestamp { - font-size: 0.9rem; - color: #757575; - /* $textSecondaryColor */ - font-weight: 500; -} - -.status-actions { - display: flex; - justify-content: center; - align-items: center; -} - -.status-indicator-text { - display: flex; - align-items: center; - gap: 6px; - color: #757575; - /* $textSecondaryColor */ - font-size: 0.9rem; - font-weight: 500; -} - -.assignment-action-button { - font-size: 12px !important; - min-height: 32px !important; -} - -.empty-message { - text-align: center; - padding: 24px; - color: #757575; - /* $textSecondaryColor */ - font-style: italic; - font-size: 1rem; -} - -/* Screen reader only content */ -.sr-only { - position: absolute !important; - width: 1px !important; - height: 1px !important; - padding: 0 !important; - margin: -1px !important; - overflow: hidden !important; - clip: rect(0, 0, 0, 0) !important; - white-space: nowrap !important; - border: 0 !important; -} - -/* Focus improvements for accessibility */ -.assignment-status-table tbody tr:focus-within { - outline: 2px solid #03A9F4; - /* $blue - info states */ - outline-offset: 2px; -} - -/* Responsive design */ -@media (max-width: 768px) { - .assignment-status-section { - padding: 12px; - } - - .assignment-status-table .ui-table-thead { - display: none; - } - - .assignment-status-table .ui-table-tbody>tr>td { - display: block; - border: none; - border-bottom: 1px solid #bdbdbd; - /* $dividerColor */ - padding: 12px 8px; - font-size: 0.9rem; - } - - .assignment-status-table .ui-table-tbody>tr>td:before { - content: attr(data-label) ": "; - font-weight: 600; - display: inline-block; - width: 120px; - color: #212121; - /* $textColor */ - } - - .assignment-header-actions { - flex-wrap: wrap; - gap: 6px; - } - - .status-control-btn, - .clear-status-btn { - padding: 6px 10px !important; - font-size: 12px !important; - min-width: 40px !important; - min-height: 40px !important; - } - - .polling-status-indicator { - padding: 8px 12px; - font-size: 0.85rem; - } -} \ No newline at end of file diff --git a/Development/client/src/app/job/job-assignment/job-assignment.component.html b/Development/client/src/app/job/job-assignment/job-assignment.component.html deleted file mode 100644 index 8ea30d7..0000000 --- a/Development/client/src/app/job/job-assignment/job-assignment.component.html +++ /dev/null @@ -1,205 +0,0 @@ -
- -
-
- - - -
- - -
- - - - - - {{ aircraft.name }} - - - - -
- - -
- - - - - - -
- - -
- -
-
-
-
-
- -
-
- -
- - {{ Labels.AGNAV_BRAND_NAME }} Aircraft - Only -
-
-
- - -
-
- -
- -
- - -
-
-

Assignment Status

-
- - -
-
- - -
- - Polling assignment status... - (Updates every 5 seconds) -
- - -
- - Assignment in progress... -
- - - - - - - - - - Aircraft - - - Status & Message - Assign Time - Actions - - - - - - - - Aircraft -
-
- {{ status.aircraftName }} - - -
-
- - - - - Status & Message -
- -
{{ status.message }}
-
- {{ status.errorDetails }} -
-
- - - - - Assign Time -
{{ status.timestamp | date:'short' }}
- - - - - Actions -
- - - - - - - - {{ Labels.PROCESSING_ASSIGNMENT }} - -
- - -
- - - - -
- No assignment status to display -
- - -
-
-
-
-
-
\ No newline at end of file diff --git a/Development/client/src/app/job/job-assignment/job-assignment.component.ts b/Development/client/src/app/job/job-assignment/job-assignment.component.ts deleted file mode 100644 index edb4852..0000000 --- a/Development/client/src/app/job/job-assignment/job-assignment.component.ts +++ /dev/null @@ -1,822 +0,0 @@ -import { Component, OnInit, OnDestroy, Input, Output, EventEmitter, ChangeDetectorRef } from '@angular/core'; -import { Observable, Subject, timer, interval } from 'rxjs'; -import { switchMap, takeUntil, retryWhen, delayWhen, startWith } from 'rxjs/operators'; -import { Store } from '@ngrx/store'; - -import { MenuItem, SelectItem } from 'primeng/api'; - -import { IUIJob } from '../models/job.model'; -import * as fromEntity from '@app/entities/reducers'; -import * as jobActions from '../actions/job.actions'; - -import { JobService } from '@app/domain/services/job.service'; -import { PartnerService } from '@app/partners/services/partner.service'; -import { PartnerUtilsService } from '@app/shared/services/partner-utils.service'; -import { BadgeFactoryService } from '@app/shared/services/badge-factory.service'; -import { AuthService } from '@app/domain/services/auth.service'; - -import { AircraftAssignmentItem } from '@app/entities/models/vehicle.model'; -import { Partner } from '@app/partners/models/partner.model'; -import { BadgeConfig } from '@app/shared/badge/badge-config.model'; - -import { BaseComp } from '@app/shared/base/base.component'; -import { SourceSystem, OperationalStatus, AssignStatus, AssignStatusType, Labels, globals, KnownPartnerCodes, SystemOrPartnerType } from '@app/shared/global'; - -// ============================================================================ -// INTERFACES -// ============================================================================ - -// Assignment Status Tracking Interface -interface AssignmentStatus { - aircraftId: string; - aircraftName: string; - sourceSystem: SystemOrPartnerType; // Track source system for badge display - state: AssignStatusType; // Using AssignStatus values - message: string; - timestamp: Date; - errorDetails?: string; -} - -/** - * Job Assignment Component - * - * Supports both AgNav and partner aircraft assignment to jobs. - * - * Partner Info Response Structure (from assignments_post): - * - AgNav vehicles: No partnerInfo field - * - Partner vehicles: { partnerInfo: { name: "satloc", partnerCode: "SATLOC" } } - */ -@Component({ - selector: 'agm-job-assignment', - templateUrl: './job-assignment.component.html', - styleUrls: ['./job-assignment.component.css'] -}) -export class JobAssignmentComponent extends BaseComp implements OnInit, OnDestroy { - // Template readonly objects for direct usage - readonly SourceSystem = SourceSystem; - readonly KnownPartnerCodes = KnownPartnerCodes; - readonly OperationalStatus = OperationalStatus; - readonly Labels = Labels; - - // Inputs from parent component - @Input() job: IUIJob; - @Input() isArchived: boolean = false; - @Input() canDownload: boolean = false; - @Input() dlOps: SelectItem[] = []; - - // Outputs to parent component - @Output() assignmentComplete = new EventEmitter(); - - // Assignment-related properties - srcUsers: AircraftAssignmentItem[] = []; - tarUsers: AircraftAssignmentItem[] = []; - allAircraft: AircraftAssignmentItem[] = []; // Store all aircraft data - - // Assignment Status Tracking - assignmentStatuses: AssignmentStatus[] = []; - isAssignmentInProgress = false; - assignmentErrorMsg: string | null = null; - - // Assignment Status Polling - isPollingAssignments = false; - private stoppedAssignmentPoll: Subject; - - // Partner caching for performance - private partnersCache = new Map(); - - constructor( - protected store: Store, - private jobSvc: JobService, - private partnerSvc: PartnerService, - private partnerUtils: PartnerUtilsService, - private badgeFactory: BadgeFactoryService, - protected authSvc: AuthService, - private cdr: ChangeDetectorRef - ) { - super(cdr); - } - - ngOnInit(): void { - this.stoppedAssignmentPoll = new Subject(); - - // Load partners for aircraft display - this.loadPartners(); - - // Load aircraft data - this.loadAircraftData(); - } - - ngOnDestroy(): void { - // Stop assignment status polling - this.stopAssignmentStatusPolling(); - super.ngOnDestroy(); - } - - /** - * Load partners and cache them for performance - */ - private loadPartners(): void { - this.partnerSvc.getPartners().subscribe({ - next: (partners) => { - this.partnersCache.clear(); - partners.forEach(partner => { - this.partnersCache.set(partner._id, partner); - }); - - // Refresh aircraft data now that partners are loaded - this.refreshAircraftDisplay(); - }, - error: (error) => { - console.error(globals.consoleFailedToLoadPartners, error); - } - }); - } - - /** - * Get partner from cache by ID - */ - private getPartner(partnerId: string): Partner | null { - return this.partnersCache.get(partnerId) || null; - } - - /** - * Load aircraft data from backend API for job assignment - */ - private loadAircraftData(): void { - if (!this.job || !this.job._id) { - return; - } - - // Use backend API to get assignment data with partnerInfo - this.jobSvc.getAssignments({ 'jobId': this.job._id }).subscribe({ - next: (assignmentData) => { - this.updateAircraftAssignmentData(assignmentData); - }, - error: (error) => { - console.error(globals.consoleFailedToLoadExistingAssignments, error); - } - }); - } - - /** - * Update aircraft assignment data from backend API response - */ - private updateAircraftAssignmentData(assignmentData: any): void { - // Process available users - let availableAircraft: AircraftAssignmentItem[] = []; - if (assignmentData.avUsers && assignmentData.avUsers.length > 0) { - availableAircraft = assignmentData.avUsers.map(user => this.convertBackendUserToAssignmentItem(user)); - } - - // Process assigned users - let assignedAircraft: AircraftAssignmentItem[] = []; - if (assignmentData.asUsers && assignmentData.asUsers.length > 0) { - assignedAircraft = assignmentData.asUsers.map(user => this.convertBackendUserToAssignmentItem(user)); - } - - // Combine all aircraft for sorting - this.allAircraft = this.sortAircraftBySource([...availableAircraft, ...assignedAircraft]); - - // Set source users (available) and target users (assigned) - this.srcUsers = availableAircraft; - this.tarUsers = assignedAircraft; - - // Always update assignment status to reflect current state (including when empty) - this.updateAssignmentStatus(assignmentData); - - // Start polling if there are assigned aircraft to track - if (assignedAircraft.length > 0 && !this.isPollingAssignments) { - this.startAssignmentStatusPolling(); - } else if (assignedAircraft.length === 0 && this.isPollingAssignments) { - // Stop polling if no assignments - this.stopAssignmentStatusPolling(); - } - } - - /** - * Refresh aircraft display after partners are loaded - */ - private refreshAircraftDisplay(): void { - // Reload aircraft data from backend API now that partners are cached - // This ensures partner names are resolved correctly - if (!this.job || !this.job._id) { - return; - } - - this.jobSvc.getAssignments({ 'jobId': this.job._id }).subscribe({ - next: (assignmentData) => { - this.updateAircraftAssignmentData(assignmentData); - // Trigger change detection to update the UI with partner names - this.cdr.detectChanges(); - }, - error: (error) => { - console.error(globals.consoleFailedToLoadExistingAssignments, error); - } - }); - } - - /** - * Convert backend user object (from assignments API) to AircraftAssignmentItem - */ - private convertBackendUserToAssignmentItem(user: any): AircraftAssignmentItem { - // Determine partner information from partnerInfo - let partnerId: string | undefined; - let partnerName: string | undefined; - let partnerCode: string | undefined; - let sourceSystem: SystemOrPartnerType = SourceSystem.AGNAV; // default - - // Check for partnerInfo.partnerCode (from assignments_post response) - if (user.partnerInfo?.partnerCode) { - partnerCode = user.partnerInfo.partnerCode; - partnerName = user.partnerInfo.name; - - // Find partner by partnerCode to get partner ID - const partner = Array.from(this.partnersCache.values()).find(p => - p.partnerCode?.toUpperCase() === partnerCode!.toUpperCase() - ); - - if (partner) { - partnerId = partner._id; - // Use partner ID as sourceSystem for all partner aircraft - sourceSystem = partnerId as SystemOrPartnerType; - } - } - - const assignmentItem: AircraftAssignmentItem = { - _id: user.uid, // Backend returns uid instead of _id - name: user.name, - active: user.active || false, - pkgActive: user.pkgActive || false, - tailNumber: user.tailNumber, - username: user.username, // Add username for tooltip display - partnerSystem: sourceSystem, - sourceSystem: sourceSystem, - partnerId: partnerId, - partnerName: partnerName, - partnerCode: partnerCode - }; - - // Add partner-specific data for all partner aircraft (not just SATLOC) - if (!this.partnerUtils.isNativeSystem(sourceSystem)) { - const partnerObj = partnerId ? this.getPartner(partnerId) : null; - - // For backward compatibility, keep satlocData for SATLOC aircraft - if (partnerObj && this.partnerUtils.isSatlocPartner(partnerObj)) { - assignmentItem.satlocData = { - tailNumber: user.tailNumber || Labels.N_A, - syncStatus: user.partnerInfo?.metadata?.syncStatus || OperationalStatus.PENDING - }; - } - } - - return assignmentItem; - } - - /** - * Get partner display name for aircraft - */ - getPartnerDisplayName(aircraft: AircraftAssignmentItem): string { - if (aircraft.partnerName) { - return aircraft.partnerName; - } - return Labels.AGNAV_BRAND_NAME; // Use consistent non-translatable brand name - } - - /** - * Get partner display name from source system (for assignment status table) - */ - getPartnerDisplayNameFromSource(sourceSystem: SystemOrPartnerType): string { - if (this.partnerUtils.isNativeSystem(sourceSystem)) { - return Labels.AGNAV_BRAND_NAME; - } - const partner = this.getPartner(sourceSystem); - return partner ? partner.name : sourceSystem.toString(); - } - - /** - * Get simplified tooltip text for aircraft - * - For AgNav: name
username - * - For Partner: partner name
tailNumber - * - Adds warning if package is not active - */ - getAircraftTooltip(aircraft: AircraftAssignmentItem): string { - let tooltip = ''; - - // For AgNav aircraft: show name and username - if (aircraft.sourceSystem === SourceSystem.AGNAV) { - if (aircraft.username) { - tooltip = `${aircraft.name}
${aircraft.username}`; - } else { - tooltip = aircraft.name; - } - } else { - // For Partner aircraft: show partner name and tail number - const partnerName = this.getPartnerDisplayName(aircraft); - if (aircraft.tailNumber) { - tooltip = `${partnerName}
${aircraft.tailNumber}`; - } else { - tooltip = partnerName; - } - } - - // Add package inactive warning if applicable - if (!aircraft.pkgActive) { - tooltip += `
⚠️ ${Labels.PACKAGE_INACTIVE}`; - } - - return tooltip; - } - - /** - * Check if aircraft can be assigned to job - */ - canAssignAircraft(aircraft: AircraftAssignmentItem): boolean { - // Only check package status - no authentication constraints - return aircraft.pkgActive === true; - } - - // ============================================================================ - // AIRCRAFT SORTING UTILITIES - // ============================================================================ - - /** - * Sort aircraft by source system (AgNav first, then all partners alphabetically) - */ - private sortAircraftBySource(aircraft: AircraftAssignmentItem[]): AircraftAssignmentItem[] { - return aircraft.sort((a, b) => { - // Primary sort: AgNav first, then all partner systems - const aIsNative = this.partnerUtils.isNativeSystem(a.sourceSystem); - const bIsNative = this.partnerUtils.isNativeSystem(b.sourceSystem); - - if (aIsNative && !bIsNative) return -1; // AgNav comes first - if (!aIsNative && bIsNative) return 1; // AgNav comes first - - // If both are partners or both are native, sort by partner name then aircraft name - if (!aIsNative && !bIsNative) { - // Both are partners - sort by partner name first - const aPartnerName = a.partnerName || Labels.UNKNOWN_PARTNER; - const bPartnerName = b.partnerName || Labels.UNKNOWN_PARTNER; - const partnerCompare = aPartnerName.localeCompare(bPartnerName); - if (partnerCompare !== 0) return partnerCompare; - } - - // Secondary sort: Alphabetical by aircraft name within each source group - return a.name.localeCompare(b.name); - }); - } - - /** - * Main assignment method - handles both AgNav and partner aircraft - */ - assignJob(): void { - if (!this.job) { - return; - } - - // Transform aircraft data to backend API format - // Backend expects 'uid' property, but frontend model uses '_id' - const formattedAsUsers = this.tarUsers.map(aircraft => { - // Base assignment data - ALL aircraft need uid for backend - const assignmentData: any = { - uid: aircraft._id, // Required for all aircraft types - backend uses this for 'user' field - name: aircraft.name - }; - - // Add partner-specific data for Satloc aircraft - if (aircraft.sourceSystem === KnownPartnerCodes.SATLOC && aircraft.satlocData) { - assignmentData.partnerAircraftId = aircraft.satlocData.satlocId || aircraft._id; - assignmentData.notes = `${Labels.SATLOC_AIRCRAFT_PREFIX} ${aircraft.satlocData.tailNumber}`; - assignmentData.jobName = this.job.name; - } - - return assignmentData; - }); - - const formattedAvUsers = this.srcUsers.map(aircraft => ({ - uid: aircraft._id, - name: aircraft.name - })); - - const assignment: jobActions.AssignInfo = { - jobId: this.job._id, - dlOp: this.job.dlOp, - avUsers: formattedAvUsers, - asUsers: formattedAsUsers - }; - - this.store.dispatch(new jobActions.Assign(assignment)); - - // Start polling immediately if assigning aircraft (to track assignment progress) - if (formattedAsUsers.length > 0 && !this.isPollingAssignments) { - this.startAssignmentStatusPolling(); - } - } - - // ============================================================================ - // ASSIGNMENT STATUS POLLING - // ============================================================================ - - /** - * Start polling assignment status from backend API - */ - private startAssignmentStatusPolling(): void { - if (this.isPollingAssignments) { - return; // Already polling - } - - this.isPollingAssignments = true; - this.stoppedAssignmentPoll.next(false); - - const polling$ = this.pollAssignmentStatus().subscribe({ - next: (assignmentData) => { - this.updateAssignmentStatus(assignmentData); - }, - error: (error) => { - console.error(globals.consoleAssignmentStatusPollingError, error); - this.isPollingAssignments = false; - // Reset assignment progress flag on polling error to prevent UI from being stuck - if (this.isAssignmentInProgress) { - this.isAssignmentInProgress = false; - } - } - }); - - // Add subscription to component's subscription manager - this.sub$.add(polling$); - } - - private stopAssignmentStatusPolling(): void { - if (this.stoppedAssignmentPoll) { - this.stoppedAssignmentPoll.next(true); - this.isPollingAssignments = false; - } - } - - private pollAssignmentStatus(): Observable { - return interval(10000).pipe( // Poll every 10 seconds for real status updates - startWith(1000), // Start after 1 second - switchMap(() => this.jobSvc.getAssignments({ 'jobId': this.job._id })), - takeUntil(this.stoppedAssignmentPoll), - retryWhen(errors => - errors.pipe( - delayWhen(val => timer(15 * 1000)) // Retry after 15 seconds on error - ) - ) - ); - } - - private updateAssignmentStatus(assignmentData: any): void { - // Clear assignment statuses if no assigned users (all unassigned) - if (!assignmentData || !assignmentData.asUsers || assignmentData.asUsers.length === 0) { - this.assignmentStatuses = []; - return; - } - - // Update assignment statuses with real assignment data - this.assignmentStatuses = assignmentData.asUsers.map((assignedAircraft) => { - const existingStatus = this.assignmentStatuses.find(s => s.aircraftId === assignedAircraft.uid); - - // Use the assignStatus field from the backend data - const backendStatus = assignedAircraft.assignStatus !== undefined ? assignedAircraft.assignStatus : AssignStatus.NEW; - - // Generate status object from real assignment data - const statusUpdate = this.generateAssignmentStatusFromBackend(assignedAircraft, backendStatus); - - // If there's existing status, preserve timestamp if status hasn't changed - if (existingStatus) { - return { - ...statusUpdate, - // Preserve timestamp if status hasn't changed - timestamp: existingStatus.state === statusUpdate.state ? existingStatus.timestamp : new Date() - }; - } - - return statusUpdate; - }); - - // Check if all assignments have completed (no longer in NEW/pending status) - this.checkAssignmentProgress(); - } - - /** - * Check assignment progress and update isAssignmentInProgress flag - */ - private checkAssignmentProgress(): void { - if (this.assignmentStatuses.length === 0) { - // No assignments to track - this.isAssignmentInProgress = false; - return; - } - - // Check if any assignments are still in NEW (pending) status - const hasNewAssignments = this.assignmentStatuses.some(status => status.state === AssignStatus.NEW); - - if (!hasNewAssignments && this.isAssignmentInProgress) { - // All assignments have completed (no longer in NEW status) - this.isAssignmentInProgress = false; - } - } - - private generateAssignmentStatusFromBackend(assignedAircraft: any, backendStatus: number): AssignmentStatus { - const statusMessages = { - [AssignStatus.NEW]: globals.assignmentInProgress, - [AssignStatus.DOWNLOADED]: globals.assignmentDownloaded, - [AssignStatus.UPLOADED]: globals.assignmentCompleted, - [AssignStatus.ERROR]: globals.assignmentFailed - }; - - // Find the aircraft in tarUsers or allAircraft to get sourceSystem - const aircraft = this.tarUsers.find(a => a._id === assignedAircraft.uid) || - this.allAircraft.find(a => a._id === assignedAircraft.uid); - const sourceSystem = aircraft?.sourceSystem || SourceSystem.AGNAV; - - return { - aircraftId: assignedAircraft.uid, - aircraftName: assignedAircraft.name, - sourceSystem: sourceSystem, - state: backendStatus as AssignStatusType, - message: statusMessages[backendStatus] || globals.unknownStatus, - timestamp: new Date(), - // Use actual error details from backend if available - errorDetails: backendStatus === AssignStatus.ERROR ? assignedAircraft.errorDetails : undefined - }; - } - - // ============================================================================ - // AIRCRAFT SELECTION HANDLERS - // ============================================================================ - - /** - * Handle aircraft selection/click - validate package status only - */ - async onAircraftSelect(aircraft: AircraftAssignmentItem, event: Event): Promise { - // Only validate if this is in the source list (available aircraft) - const isInSourceList = this.srcUsers.some(ac => ac._id === aircraft._id); - if (!isInSourceList) { - return; - } - - // Check package status (applies to all aircraft) - if (!aircraft.pkgActive) { - // Package not enabled - visual feedback already provided via red highlighting and tooltip - return; - } - - // No authentication validation - allow all aircraft with active packages to be assigned - } - - /** - * Aircraft movement event handlers - */ - async onMoveToTarget(event: any): Promise { - // Get the aircraft that were just moved - const movedAircraft = event.items || []; - - // Validate each moved aircraft for package status only - for (const aircraft of movedAircraft) { - let shouldMoveBack = false; - let reason = ''; - - // Check package active status - only constraint remaining - if (!aircraft.pkgActive) { - shouldMoveBack = true; - reason = Labels.PACKAGE_NOT_ENABLED_REASON; - } - - // Move aircraft back to source if validation failed - if (shouldMoveBack) { - const aircraftIndex = this.tarUsers.findIndex(ac => ac._id === aircraft._id); - if (aircraftIndex !== -1) { - this.tarUsers.splice(aircraftIndex, 1); - this.srcUsers.push(aircraft); - } - } - } - - // Apply sorting to maintain AgNav-first order - this.tarUsers = this.sortAircraftBySource(this.tarUsers); - this.srcUsers = this.sortAircraftBySource(this.srcUsers); - } - - onMoveToSource(event: any): void { - // Apply sorting to maintain AgNav-first order - this.srcUsers = this.sortAircraftBySource(this.srcUsers); - this.tarUsers = this.sortAircraftBySource(this.tarUsers); - } - - /** - * UI Helper Methods (unified badge system) - * Uses BadgeFactoryService to create configuration-driven badges - */ - - /** - * Get badge configuration for aircraft source system (picklist) - */ - getAircraftSystemBadge(aircraft: AircraftAssignmentItem): BadgeConfig { - return this.badgeFactory.createSystemBadge( - aircraft.sourceSystem, - this.getPartnerDisplayName(aircraft) - ); - } - - /** - * Get badge configuration for assignment status source system (status table) - */ - getStatusSystemBadge(sourceSystem: SystemOrPartnerType): BadgeConfig { - return this.badgeFactory.createSystemBadge( - sourceSystem, - this.getPartnerDisplayNameFromSource(sourceSystem) - ); - } - - /** - * Get badge configuration for assignment status (status table) - */ - getAssignmentStatusBadge(status: AssignmentStatus): BadgeConfig { - return this.badgeFactory.createAssignmentStatusBadge( - status.state, - status.message - ); - } - - getRefreshStatusTooltip(): string { - return Labels.MANUALLY_REFRESH_ASSIGNMENT_STATUS; - } - - getAssignButtonTooltip(): string { - if (this.isArchived) { - return Labels.ASSIGN_BUTTON_ARCHIVED_TOOLTIP; - } - if (!this.canDownload) { - return Labels.ASSIGN_BUTTON_NO_BOUNDARY_TOOLTIP; - } - return Labels.ASSIGN_BUTTON_READY_TOOLTIP; - } - - getPickListSourceTooltip(): string { - return Labels.PICK_LIST_SOURCE_TOOLTIP; - } - - getPickListTargetTooltip(): string { - return Labels.PICK_LIST_TARGET_TOOLTIP; - } - - getDownloadOptionsTooltip(): string { - return Labels.DOWNLOAD_OPTIONS_DROPDOWN_TOOLTIP; - } - - getStatusTableTooltip(): string { - return Labels.ASSIGNMENT_STATUS_TABLE_TOOLTIP; - } - - getStatusIconTooltip(status: AssignmentStatus): string { - switch (status.state) { - case AssignStatus.NEW: - return Labels.ASSIGNMENT_STATUS_NEW_TOOLTIP; - case AssignStatus.DOWNLOADED: - return Labels.ASSIGNMENT_STATUS_DOWNLOADED_TOOLTIP; - case AssignStatus.UPLOADED: - return Labels.ASSIGNMENT_STATUS_UPLOADED_TOOLTIP; - case AssignStatus.ERROR: - return Labels.ASSIGNMENT_STATUS_ERROR_TOOLTIP; - default: - return Labels.ASSIGNMENT_STATUS_NEW_TOOLTIP; // Default to new/pending - } - } - - /** - * Assignment Status UI Methods - */ - refreshAssignmentStatus(): void { - if (!this.job || !this.job._id) { - console.warn(globals.consoleCannotRefreshAssignmentStatus); - return; - } - - this.jobSvc.getAssignments({ 'jobId': this.job._id }).subscribe({ - next: (assignmentData) => { - // Update both assignment status AND aircraft lists - this.updateAircraftAssignmentData(assignmentData); - }, - error: (error) => { - console.error(globals.consoleFailedToRefreshAssignmentStatus, error); - this.msgSvc.addFailedMsg(globals.failedToRefreshAssignmentStatus); - } - }); - } - - /** - * Assignment Status Action Methods - */ - getUnifiedActionOptions(status: AssignmentStatus): MenuItem[] { - const commonActions = [ - { - label: globals.clearStatus, - icon: 'ui-icon-clear', - command: () => this.clearSingleStatus(status.aircraftId) - } - ]; - - if (status.state === AssignStatus.ERROR) { - return [ - ...commonActions, - { - separator: true - }, - { - label: globals.resetToAvailable, - icon: 'ui-icon-arrow-back', - command: () => this.resetAircraftToAvailable(status.aircraftId) - } - ]; - } - - if (status.state === AssignStatus.UPLOADED) { - return [ - ...commonActions - ]; - } - - // Default actions for any other states - return commonActions; - } - - clearSingleStatus(aircraftId: string): void { - this.assignmentStatuses = this.assignmentStatuses.filter( - status => status.aircraftId !== aircraftId - ); - } - - - - resetAircraftToAvailable(aircraftId: string): void { - // Find the aircraft in assigned list - const aircraft = this.tarUsers.find(a => a._id === aircraftId); - if (!aircraft) return; - - // Move aircraft back to available list - this.tarUsers = this.tarUsers.filter(a => a._id !== aircraftId); - - // Add to source list only if package active, meets auth requirements, and not already there - // Partner aircraft: Only require active package - // Native aircraft: Require active package AND credentials (matches backend filter: username: { $nin: [null, ''] }) - const isPartner = !this.partnerUtils.isNativeSystem(aircraft.sourceSystem); - const hasCredentials = aircraft.username && aircraft.username !== ''; - const meetsAuthRequirements = isPartner || hasCredentials; - - if (aircraft.pkgActive === true && meetsAuthRequirements && !this.srcUsers.find(a => a._id === aircraftId)) { - this.srcUsers.push(aircraft); - // Apply sorting to maintain AgNav-first order - this.srcUsers = this.sortAircraftBySource(this.srcUsers); - } - - // Clear the status - this.clearSingleStatus(aircraftId); - } - - /** - * Status helper methods for template - */ - getStatusIcon(status: AssignmentStatus): string { - switch (status.state) { - case AssignStatus.NEW: - return 'pi-spin pi-spinner'; - case AssignStatus.DOWNLOADED: - return 'pi-download'; - case AssignStatus.UPLOADED: - return 'pi-check-circle'; - case AssignStatus.ERROR: - return 'pi-times-circle'; - default: - return 'pi-question-circle'; - } - } - - isStatusNew(status: AssignmentStatus): boolean { - return status.state === AssignStatus.NEW; - } - - /** - * Check if a specific aircraft's assignment is in progress - */ - isAircraftAssignmentInProgress(status: AssignmentStatus): boolean { - return status.state === AssignStatus.NEW; - } - - getStatusCssClass(status: AssignmentStatus): string { - switch (status.state) { - case AssignStatus.NEW: - return 'new'; - case AssignStatus.DOWNLOADED: - return 'downloaded'; - case AssignStatus.UPLOADED: - return 'uploaded'; - case AssignStatus.ERROR: - return 'error'; - default: - return 'unknown'; - } - } - - // ============================================================================ -} diff --git a/Development/client/src/app/job/job-canactive.guard.ts b/Development/client/src/app/job/job-canactive.guard.ts index 066c4dc..60c3f35 100644 --- a/Development/client/src/app/job/job-canactive.guard.ts +++ b/Development/client/src/app/job/job-canactive.guard.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { CanActivate } from '@angular/router'; -import { Observable, combineLatest } from 'rxjs'; +import { Observable, forkJoin } from 'rxjs'; import { map, filter, take } from 'rxjs/operators'; import { Store } from '@ngrx/store'; @@ -11,42 +11,58 @@ import * as pilotActions from '../entities/actions/pilot.actions'; import * as vehicleActions from '../entities/actions/vehicle.actions'; import * as fromCostingItem from '../invoices/reducers'; import * as costingItemAction from '../invoices/actions/costing-item.actions'; -import { AuthService } from '@app/domain/services/auth.service'; @Injectable() export class JobCanActiveGuard implements CanActivate { - constructor( - private readonly store: Store<{}>, - private readonly authSvc: AuthService - ) { } + constructor(private readonly store: Store<{}>) { } - private checkAndFetch(selector: any, action: any): Observable { - return this.store.select(selector).pipe( - map((loaded: boolean) => { - if (!loaded) this.store.dispatch(action); - return loaded; - }), - filter(loaded => loaded), + // TODO: Later to consider whether or when best to use the cached loaded entities or just load them fresh before loading any related features/functions + canActivate(): Observable { + return forkJoin( + ( + this.store.select(fromEnity.getProductsLoaded).pipe( + map(loaded => { + if (!loaded) + this.store.dispatch(new productActions.Fetch()); + return loaded; + }), + filter(loaded => loaded), + take(1)) + ), + ( + this.store.select(fromEnity.getPilotsLoaded).pipe( + map(loaded => { + if (!loaded) + this.store.dispatch(new pilotActions.Fetch()); + return loaded; + }), + filter(loaded => loaded), + take(1)) + ), + ( + this.store.select(fromEnity.getVehiclesLoaded).pipe( + map(loaded => { + if (!loaded) + this.store.dispatch(new vehicleActions.Fetch()); + return loaded; + }), + filter(loaded => loaded), + take(1)) + ), + ( + this.store.select(fromCostingItem.isCostingItemLoaded).pipe( + map(loaded => { + if (!loaded) + this.store.dispatch(new costingItemAction.Fetch()); + return loaded; + }), + filter(loaded => loaded), + take(1) + ) + ) + ).pipe( + map(([l1, l2, l3, l4]) => l1 && l2 && l3 && l4), take(1) ); } - - canActivate(): Observable { - const product$ = this.checkAndFetch(fromEnity.getProductsLoaded, new productActions.Fetch()); - const pilot$ = this.checkAndFetch(fromEnity.getPilotsLoaded, new pilotActions.Fetch()); - const vehicle$ = this.checkAndFetch(fromEnity.getVehiclesLoaded, new vehicleActions.Fetch()); - - if (this.authSvc.canAccessInvoice) { - const costingItem$ = this.checkAndFetch(fromCostingItem.isCostingItemLoaded, new costingItemAction.Fetch()); - return combineLatest([product$, pilot$, vehicle$, costingItem$]).pipe( - map(([l1, l2, l3, l4]) => l1 && l2 && l3 && l4), - take(1) - ); - } else { - return combineLatest([product$, pilot$, vehicle$]).pipe( - map(([l1, l2, l3]) => l1 && l2 && l3), - take(1) - ); - } - } } diff --git a/Development/client/src/app/job/job-edit/job-edit.component.css b/Development/client/src/app/job/job-edit/job-edit.component.css index abfca4f..1e94769 100644 --- a/Development/client/src/app/job/job-edit/job-edit.component.css +++ b/Development/client/src/app/job/job-edit/job-edit.component.css @@ -1,732 +1,3 @@ .sprayed-value { margin-top: .25em; -} - -/* Aircraft Item Layout */ -.aircraft-item { - display: flex; - align-items: center; - justify-content: space-between; - padding: 8px 4px; - min-height: 40px; -} - -.aircraft-name { - flex: 1; - margin-right: 8px; - font-weight: 500; -} - -.aircraft-icon { - color: #007ad9; - font-size: 14px; -} - -/* Satloc-specific Details */ -.satloc-details { - margin-top: 4px; - font-size: 11px; - color: #666; -} - -.tail-number { - background-color: #f5f5f5; - padding: 1px 4px; - border-radius: 3px; - margin-right: 6px; - font-family: monospace; -} - -/* Sync Status Indicators */ -.sync-status { - margin-left: 4px; -} - -.sync-status-active { - color: #4caf50; -} - -.sync-status-pending { - color: #ff9800; -} - -.sync-status-error { - color: #f44336; -} - -/* Package Status */ -.package-inactive { - margin-left: 4px; -} - -/* Hover Effects */ -.aircraft-item:hover { - background-color: #f8f9fa; - border-radius: 4px; -} - -/* Aircraft item hover effects are now handled by global badge system */ - -/* Download Options Styling */ -.download-options-info { - display: flex; - align-items: center; - gap: 6px; - margin-top: 4px; - padding: 4px 8px; - background-color: #e8f4fd; - border: 1px solid #bbdefb; - border-radius: 4px; - font-size: 0.8rem; - color: #1976d2; - font-weight: 500; -} - -.download-options-info .pi { - font-size: 0.9rem; - color: #1976d2; -} - -/* Responsive adjustments for download options */ -@media (max-width: 768px) { - .download-options-info { - font-size: 0.75rem; - padding: 3px 6px; - } - - .download-options-info .pi { - font-size: 0.8rem; - } -} - -@media (max-width: 480px) { - .download-options-info { - margin-top: 2px; - padding: 2px 4px; - } -} - -/* Responsive Adjustments */ -@media (max-width: 768px) { - .aircraft-item { - flex-direction: column; - align-items: flex-start; - padding: 6px 4px; - } - - .satloc-details { - margin-top: 2px; - } -} - - -/* Round Split Button Styling with ::ng-deep */ -:host ::ng-deep .assignment-action-button.slim .ui-splitbutton { - width: 32px !important; - height: 32px !important; -} - -:host ::ng-deep .assignment-action-button.slim .ui-splitbutton .ui-button { - border-radius: 50% !important; - width: 32px !important; - height: 32px !important; - padding: 0 !important; - min-width: auto !important; - background-color: #6c757d !important; - border-color: #6c757d !important; - color: white !important; -} - -:host ::ng-deep .assignment-action-button.slim .ui-splitbutton .ui-button:hover { - background-color: #5a6268 !important; - border-color: #545b62 !important; -} - -/* Hide the main button, show only dropdown arrow */ -:host ::ng-deep .assignment-action-button.slim .ui-splitbutton .ui-button:first-child { - display: none !important; -} - -/* Style the dropdown arrow button to be round */ -:host ::ng-deep .assignment-action-button.slim .ui-splitbutton .ui-splitbutton-menubutton { - border-radius: 50% !important; - width: 32px !important; - height: 32px !important; - border-left: none !important; - padding: 0 !important; - background-color: #6c757d !important; - border-color: #6c757d !important; - color: white !important; -} - -:host ::ng-deep .assignment-action-button.slim .ui-splitbutton .ui-splitbutton-menubutton:hover { - background-color: #5a6268 !important; - border-color: #545b62 !important; -} - -/* Override PrimeNG corner classes */ -:host ::ng-deep .assignment-action-button.slim .ui-corner-right { - border-radius: 50% !important; -} - -/* Center the dropdown arrow icon */ -:host ::ng-deep .assignment-action-button.slim .ui-splitbutton .ui-splitbutton-menubutton .ui-button-icon-left { - margin: 0 !important; - font-size: 0.8rem !important; -} - -/* Assignment Status Display Styles (Update 1.1.4a) */ -.assignment-status-section { - border: 1px solid #e0e0e0; - border-radius: 6px; - padding: 16px; - background-color: #fafafa; - margin-top: 15px; -} - -.assignment-status-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - margin-bottom: 12px; - padding-bottom: 8px; - border-bottom: 1px solid #e0e0e0; - gap: 16px; -} - -.assignment-status-header h4 { - margin: 0; - color: #333; - font-size: 1.1rem; - font-weight: 600; -} - -.clear-status-btn { - padding: 4px 8px !important; - min-width: auto !important; - font-size: 0.8rem !important; - background-color: #ffc107 !important; - color: #666 !important; -} - -.clear-status-btn:hover { - background-color: #e0e0e0 !important; - color: #333 !important; -} - -.assignment-progress { - display: flex; - align-items: center; - gap: 8px; - padding: 10px; - background-color: #e3f2fd; - border: 1px solid #bbdefb; - border-radius: 4px; - margin-bottom: 12px; - color: #1976d2; - font-weight: 500; -} - -.assignment-progress .pi-spinner { - font-size: 1.2rem; -} - -/* Assignment Status Polling Indicator */ -.polling-status-indicator { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 12px; - background-color: #e3f2fd; - border: 1px solid #90caf9; - border-radius: 4px; - margin-bottom: 12px; - color: #1565c0; - font-size: 0.9rem; - font-weight: 500; -} - -.polling-status-indicator .pi-refresh { - font-size: 1rem; - color: #1976d2; -} - -.polling-status-indicator small { - margin-left: auto; - color: #424242; - font-weight: 400; -} - -.assignment-error-summary { - display: flex; - align-items: center; - gap: 8px; - padding: 10px; - background-color: #ffebee; - border: 1px solid #ffcdd2; - border-radius: 4px; - margin-bottom: 12px; - color: #c62828; - font-weight: 500; -} - -.assignment-error-summary .pi { - font-size: 1.2rem; -} - -.status-list { - display: flex; - flex-direction: column; - gap: 8px; -} - -.status-item { - display: flex; - align-items: flex-start; - gap: 12px; - padding: 12px; - border-radius: 6px; - border: 1px solid #e0e0e0; - background-color: white; - transition: all 0.2s ease; -} - -.status-item:hover { - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); -} - -.status-icon { - flex-shrink: 0; - width: 24px; - display: flex; - justify-content: center; - align-items: center; - margin-top: 2px; -} - -.status-content { - flex: 1; - display: flex; - flex-direction: column; - gap: 4px; -} - -.status-aircraft-info { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; -} - -.status-aircraft-info .aircraft-name { - font-weight: 600; - color: #333; - font-size: 0.95rem; -} - -.status-timestamp { - font-size: 0.8rem; - color: #666; - white-space: nowrap; -} - -.status-message { - font-size: 0.9rem; - color: #555; -} - -.status-error-details { - font-size: 0.8rem; - color: #999; - font-style: italic; -} - -/* Status actions column centering and sizing */ -.status-actions { - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center !important; - width: 100% !important; - margin: 0 auto !important; -} - -/* Assignment Status Table Actions column centering */ -.assignment-status-table .status-actions { - display: flex !important; - align-items: center !important; - justify-content: center !important; - width: 100% !important; - max-width: 56px !important; - margin: 0 auto !important; -} - - -/* Ensure the assignment action button container is centered */ -.assignment-action-button.slim { - display: inline-flex !important; - align-items: center !important; - justify-content: center !important; -} - -.retry-btn { - padding: 6px 12px !important; - font-size: 0.8rem !important; - min-width: auto !important; - background-color: #ff9800 !important; - border: 1px solid #f57c00 !important; - color: white !important; -} - -.retry-btn:hover:not(:disabled) { - background-color: #f57c00 !important; - border-color: #ef6c00 !important; -} - -.retry-btn:disabled { - opacity: 0.6 !important; - cursor: not-allowed !important; -} - -/* Enhanced Status Actions Menu Items */ -.p-menu .p-menuitem-link { - font-size: 0.9rem !important; - padding: 8px 12px !important; -} - -.p-menu .p-menuitem-icon { - margin-right: 8px !important; - font-size: 0.85rem !important; -} - -/* Split Button Menu Positioning */ -.status-actions .p-splitbutton .p-menu { - min-width: 180px; - margin-top: 2px; -} - -/* Position dropdown menu slightly to the left to prevent cutoff at screen edge */ -:host ::ng-deep .assignment-action-button.slim .ui-menu { - transform: translateX(-120px) !important; - margin-top: 2px !important; - min-width: 180px !important; -} - -/* Alternative positioning for PrimeNG p-menu */ -:host ::ng-deep .assignment-action-button.slim .p-menu { - transform: translateX(-120px) !important; - margin-top: 2px !important; - min-width: 180px !important; -} - -/* Responsive adjustments for split buttons */ -@media (max-width: 768px) { - - .retry-split-button .p-button, - .status-split-button .p-button { - padding: 4px 8px !important; - font-size: 0.75rem !important; - } - - .status-actions .p-splitbutton .p-menu { - min-width: 160px; - } -} - -/* Status State Specific Styles */ -.status-pending .status-icon { - color: #ff9800; -} - -.status-retrying .status-icon { - color: #ff9800; -} - -.status-success { - border-color: #c8e6c9; - background-color: #f1f8e9; -} - -.status-success .status-icon { - color: #4caf50; -} - -.status-error { - border-color: #ffcdd2; - background-color: #ffebee; -} - -.status-error .status-icon { - color: #f44336; -} - -/* Responsive Design for Assignment Status */ -@media (max-width: 768px) { - .assignment-status-section { - padding: 12px; - } - - .assignment-status-header { - flex-direction: column; - align-items: flex-start; - gap: 8px; - } - - .assignment-status-header h4 { - font-size: 1rem; - } - - .status-item { - padding: 10px; - gap: 8px; - } - - .status-aircraft-info { - flex-direction: column; - align-items: flex-start; - gap: 4px; - } - - .status-aircraft-info .aircraft-name { - font-size: 0.9rem; - } - - .status-timestamp { - font-size: 0.75rem; - } - - .status-message { - font-size: 0.85rem; - } - - .retry-btn { - padding: 4px 8px !important; - font-size: 0.75rem !important; - } -} - -@media (max-width: 480px) { - .status-item { - flex-direction: column; - gap: 8px; - } - - .status-icon { - align-self: flex-start; - } - - .status-actions { - align-self: flex-end; - max-width: 48px !important; - } - - .assignment-progress { - padding: 8px; - } - - .assignment-error-summary { - padding: 8px; - } -} - -/* Assignment Status Table Styling (Update 1.1.4b) */ -.assignment-status-table { - margin-top: 10px; -} - -.assignment-status-table .ui-table-tbody>tr { - border-left: 4px solid transparent; -} - -.assignment-status-table .status-row-pending { - border-left-color: #2196f3; -} - -.assignment-status-table .status-row-success { - border-left-color: #4caf50; -} - -.assignment-status-table .status-row-error { - border-left-color: #f44336; -} - -.assignment-status-table .status-row-retrying { - border-left-color: #ff9800; -} - -.aircraft-cell { - display: flex; - align-items: center; - gap: 8px; -} - -.aircraft-cell .pi { - font-size: 0.9rem; -} - -.status-badge { - display: inline-block; - padding: 2px 8px; - border-radius: 4px; - font-size: 0.75rem; - font-weight: 500; - text-transform: uppercase; - width: fit-content; -} - -.status-badge-pending { - background-color: #e3f2fd; - color: #1976d2; -} - -.status-badge-success { - background-color: #e8f5e8; - color: #2e7d32; -} - -.status-badge-error { - background-color: #ffebee; - color: #c62828; -} - -.status-badge-retrying { - background-color: #fff3e0; - color: #f57c00; -} - -.status-message { - font-weight: 500; -} - -.status-error-details { - margin-top: 4px; - color: #666; -} - -.status-timestamp { - font-size: 0.85rem; - color: #666; -} - -.empty-message { - text-align: center; - padding: 20px; - color: #666; - font-style: italic; -} - -/* Responsive design for table */ -@media (max-width: 768px) { - .assignment-status-table .ui-table-thead { - display: none; - } - - .assignment-status-table .ui-table-tbody>tr>td { - display: block; - border: none; - border-bottom: 1px solid #ddd; - padding: 6px; - } - - .assignment-status-table .ui-table-tbody>tr>td:before { - content: attr(data-label) ": "; - font-weight: bold; - display: inline-block; - width: 80px; - } -} - - -/* Update 1.1.6a: Slim Split Button Actions Styling */ - -/* Slim Split Button for Assignment Status Table */ -.assignment-action-button.slim .ui-splitbutton { - width: 32px !important; - height: 32px !important; -} - -.assignment-action-button.slim .ui-splitbutton .ui-button { - width: 32px !important; - height: 32px !important; - border-radius: 50% !important; - padding: 0 !important; - min-width: auto !important; - display: flex !important; - align-items: center !important; - justify-content: center !important; - background-color: #6c757d !important; - border-color: #6c757d !important; - color: white !important; -} - -.assignment-action-button.slim .ui-splitbutton .ui-button:hover { - background-color: #5a6268 !important; - border-color: #545b62 !important; -} - -.assignment-action-button.slim .ui-splitbutton .ui-button:focus { - outline: none !important; - box-shadow: 0 0 0 2px rgba(108, 117, 125, 0.5) !important; -} - -/* Hide the left button for slim style - enhanced for assignment status */ -.assignment-action-button.slim .ui-splitbutton .ui-button:first-child { - display: none !important; -} - -/* Style the dropdown arrow button */ -.assignment-action-button.slim .ui-splitbutton .ui-splitbutton-menubutton { - width: 32px !important; - height: 32px !important; - border-radius: 50% !important; - border-left: none !important; - padding: 0 !important; - display: flex !important; - align-items: center !important; - justify-content: center !important; -} - -.assignment-action-button.slim .ui-splitbutton .ui-splitbutton-menubutton .ui-button-icon-primary { - margin: 0 !important; - font-size: 1rem !important; -} - -/* Status indicator text for pending/retrying states */ -.status-indicator-text { - display: flex; - align-items: center; - gap: 6px; - font-size: 0.8rem; - color: #666; - font-style: italic; -} - -.status-indicator-text .pi { - font-size: 0.9rem; -} - -.status-indicator-text .pi-spin { - color: #ff9800; -} - -.status-indicator-text .pi-clock { - color: #2196f3; -} - -/* Responsive adjustments */ -@media (max-width: 768px) { - .assignment-action-button.slim .ui-splitbutton { - width: 28px !important; - height: 28px !important; - } - - .assignment-action-button.slim .ui-splitbutton .ui-button, - .assignment-action-button.slim .ui-splitbutton .ui-splitbutton-menubutton { - width: 28px !important; - height: 28px !important; - } - - .status-indicator-text { - font-size: 0.75rem; - } } \ No newline at end of file diff --git a/Development/client/src/app/job/job-edit/job-edit.component.html b/Development/client/src/app/job/job-edit/job-edit.component.html index 4ebd8c9..a2f31b4 100644 --- a/Development/client/src/app/job/job-edit/job-edit.component.html +++ b/Development/client/src/app/job/job-edit/job-edit.component.html @@ -18,7 +18,7 @@
Name
- + Job Name is required and must not contains special characters
@@ -77,17 +77,26 @@
- + - - - {{col.header}} - - - - {{col.header}} - + + Name + + + + Type + + + + Rate + /{{ byAreaUnit }} + + + Unit + + + @@ -100,34 +109,37 @@ - - + - - - {{ col.header }} - - - - - - {{ rowData[col.field] }} - - - - - - - {{ col.header }} - {{ rowData.product[col.field] }} - {{ rowData.product[col.field] | productType }} - - - - {{ resolveFieldData(rowData, col.field) }} - - - + + {{ prod.product.name }} {{ columns.length }} + + + {{ prod.product.type | productType }} + + + + + + + + {{prod.rate}} + + + + + + + + + +
{{ prod.unit | unit }}
+
+
+ + + +
@@ -188,7 +200,7 @@
-
+
{{ vehicle.value.name }}
@@ -221,7 +233,7 @@
Crop/Job
- +
@@ -255,21 +267,18 @@
-
-
Invoice Status
-
{{ selectedItem.invoiceStatus }}
-
- +
+
-
- +
+ - +
@@ -284,15 +293,23 @@
- - - +
+ +
+
+ - - - - - +
+
+ +
+
+ +
+
+ +
+
@@ -329,9 +346,7 @@ - - {{ uploadErrorMsg }}. - + {{ uploadErrorMsg }}
Select or Drag and drop a Job file (.zip/.kmz/.kml) here @@ -341,48 +356,47 @@
- + Uploaded Files - + - - - {{col.header}} - - - - {{col.header}} - + + Name + + + + Size + + + + When + + + + Tools - + - - - {{ col.header }} - - - - {{ file[col.field] }} - - - - {{ file[col.field] }} - -
- Coverage - : {{ UnitUtils.haToArea(file.totalSprayed, job.measureUnit) | number:'1.1-1':'en' }} {{ job.measureUnit | areaUnit:false }} -
-
- {{ file[col.field] }} - {{ file[col.field] | date:'shortDate' }} {{ file[col.field] | date:'HH:mm' }} - - - - - -
+ + + {{ file.name }} + + + {{ file.name }} + +
+ Coverage + : {{ UnitUtils.haToArea(file.totalSprayed, job.measureUnit) | number:'1.1-1':'en' }} {{ job.measureUnit | areaUnit:false }} +
+ + {{ file.size }} + {{ file.when | date:'shortDate' }} {{ file.when | date:'HH:mm' }} + + + +
@@ -426,8 +440,31 @@
- - + +
+
+ + +
+ + {{ user.name }} +
+
+
+
+
+
+
+
+ + +
+
+
+ +
+
+
diff --git a/Development/client/src/app/job/job-edit/job-edit.component.ts b/Development/client/src/app/job/job-edit/job-edit.component.ts index f8c5cd9..92fe4ca 100644 --- a/Development/client/src/app/job/job-edit/job-edit.component.ts +++ b/Development/client/src/app/job/job-edit/job-edit.component.ts @@ -1,8 +1,8 @@ import { AfterViewInit, ChangeDetectorRef, Component, ElementRef, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { Subject, Observable, timer } from 'rxjs'; -import { switchMap, takeUntil, retryWhen, delayWhen, take } from 'rxjs/operators'; +import { Observable, Subject, timer } from 'rxjs'; +import { delayWhen, retryWhen, switchMap, takeUntil } from 'rxjs/operators'; import { MenuItem, SelectItem, SelectItemGroup } from 'primeng/api'; import { FileUpload } from 'primeng/fileupload'; @@ -37,9 +37,6 @@ import { InvoiceService } from '@app/domain/services/invoice.service'; import { CostingItemUnitPipe } from '@app/invoices/pipes/costing-item-unit.pipe'; import { Location } from '@angular/common'; import { InputNumber } from 'primeng/inputnumber'; -import { selectLimit } from '@app/reducers'; -import { Acre } from '@app/domain/models/subscription.model'; -import { SUB, SubTexts, SubType } from '@app/profile/common'; @Component({ selector: 'agm-job-edit', @@ -53,23 +50,11 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit, readonly UnitUtils = UnitUtils; readonly MAX_VALUE = 999999; readonly MIN_VALUE = 0; - readonly SubTexts = SubTexts; - readonly resolveFieldData = Utils.resolveFieldData; - readonly NAME = 'name'; - readonly TYPE = 'type'; - readonly RATE = 'rate'; - readonly ACTION = 'action'; - readonly TOOLS = 'tools'; - readonly SIZE = 'size'; - readonly WHEN = 'when'; private mode = MODE.NONE; private stoppedImportPoll; private okDl = false; - prodCols: any[]; - uploadCols: any[]; - curProduct: any; prodRate = 10; // default rate/area unit value prodUnit; @@ -97,13 +82,16 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit, prodUnits: SelectItem[]; updateOps: SelectItem[]; uploadSettings: any; - status: SelectItem[] = [...GC.selJobStatuses]; + status: SelectItem[]; prodTypes: SelectItem[]; byAreaUnit: string; byAreaUnitL: string; grpedProds: SelectItemGroup[] = []; + srcUsers: any[]; + tarUsers: any[]; + uploadUrl = '/imports/uploadJob'; uploadedFiles = []; dlLogs = []; @@ -120,7 +108,6 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit, vehicles: SelectItem[] = []; downloadOptions: MenuItem[]; crops: Crop[]; - addingNewCropJob = false; totalCoverage: number; @@ -150,9 +137,7 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit, { label: $localize`:@@normal:Normal`, value: 0 }, { label: $localize`:@@equal:Equal`, value: 1 } ]; - loadSheetDlgOn: boolean = false; - - acre: Acre; + loadSheetDlgOn = false; // invoice costing section costingItems: SelectItem[]; // Costing items dropdown list @@ -160,17 +145,12 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit, currencyUnit; costingErrorMsg = ''; - get canAccessInvoice() { - return this.authSvc.canAccessInvoice; - } - get canCreateInvoice() { return this.job && this.job.status != 0 && this.job.costings?.billableAmount && this.job.invoiceStatus == jobInvoiceStatus.NONE - && !this.isBillableAreaRequired - && this.canAccessInvoice; + && !this.isBillableAreaRequired; } get canViewInvoice() { @@ -205,19 +185,6 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit, ) { super(cdRef); this.init(); - this.prodCols = [ - { field: this.NAME, header: $localize`:@@name:Name`, width: "*" }, - { field: this.TYPE, header: $localize`:@@type:Type`, width: "14%" }, - { field: this.RATE, header: $localize`:@@rate:Rate`, width: "14%" }, - { field: 'unit', header: $localize`:@@unit:Unit`, width: "11%" }, - { field: this.ACTION, header: $localize`:@@action:Action`, width: "11%" }, - ]; - this.uploadCols = [ - { field: this.NAME, header: $localize`:@@name:Name`, width: "48%" }, - { field: this.SIZE, header: $localize`:@@size:Size`, width: "13%" }, - { field: this.WHEN, header: $localize`:@@when:When` }, - { field: this.TOOLS, header: $localize`:@@tools:Tools`, width: "11%" }, - ]; this.costingItemForm = { selCostingItem: null, quantity: null @@ -244,6 +211,7 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit, }, ]; this.updateOps = [ + { label: $localize`:Append to existing jobs items with what found in the file@@append:Append`, value: '2' }, { label: $localize`:Overwrite all jobs items with what found in the file@@overwrite:Overwrite`, value: '3' }, { label: $localize`:Import data from the zip file only@@dataOnly:Data Only`, value: '1' }, { label: $localize`:Exclusion zones@@xcls:XCLs`, value: '4' }, @@ -286,7 +254,7 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit, if (!job) { return; } - + // invoice costing section if (this.mode === MODE.CLONE) { delete job.invoiceId; delete job.invoiceStatus; @@ -320,7 +288,7 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit, } }); - // // Populate dropdown items and subcribe to auto refresh the list if changed + // Populate dropdown items and subcribe to auto refresh the list if changed this.sub$.add(this.store.select(fromEntity.getAllProducts).subscribe(products => { this.grpedProds = []; if (products) { @@ -336,7 +304,7 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit, this.grpedProds.push({ label: this.prodTypePipe.transform(key), items: grItems[key].map(i => ({ label: i['name'], value: i })) }); } } - if (this.grpedProds[0]?.items && this.grpedProds[0].items.length) { + if (this.grpedProds[0].items && this.grpedProds[0].items.length) { this.onProductChanged(this.grpedProds[0].items[0].value); } } @@ -352,9 +320,7 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit, this.sub$.add(this.store.select(fromEntity.getAllVehicles).subscribe(vehicles => { this.vehicles = []; if (!Utils.isEmptyArray(vehicles)) { - vehicles.forEach(item => { - this.vehicles.push({ label: item.name, value: item }); - }); + vehicles.forEach(item => { this.vehicles.push({ label: item.name, value: item }); }); } })); this.sub$.add(this.store.select(fromEntity.getAllCrops).subscribe(crops => { @@ -364,6 +330,7 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit, } })); + // invoice costing section this.sub$.add(this.store.select(fromCostingItems.getAllCostingItems).subscribe(costingItems => { this.costingItems = (costingItems || []) .filter(item => item) @@ -401,13 +368,8 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit, this.resetNewEntities(); this.checkOKDl(); })); - - this.sub$.add(this.store.select(selectLimit(SubType.PACKAGE)).pipe(take(1)).subscribe((pkg) => { - this.acre = pkg[this.authSvc.getCurLookupKey(SubType.PACKAGE)]?.acre; - if (this.acre?.overLimit) { - return this.updateOps.unshift({ label: $localize`:Append to existing jobs items with what found in the file@@append:Append`, value: '2', disabled: true }); - } - this.updateOps.unshift({ label: $localize`:Append to existing jobs items with what found in the file@@append:Append`, value: '2' }); + this.sub$.add(this.appActions.ofType(jobActions.ASSIGN_SUCCESS).subscribe((action) => { + this._job['dlOp'] = this.selectedItem.dlOp; })); } @@ -434,6 +396,9 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit, if (this.isEdit) { this.getUploadedFiles(); this.getLogs(); + if (this.isPlanner) { + this.getAssignments(); + } } }, 500); @@ -477,6 +442,15 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit, }); } + private getAssignments() { + this.jobSvc.getAssignments({ 'jobId': this.job._id }).subscribe((res) => { + if (res) { + this.srcUsers = !Utils.isEmptyArray(res.avUsers) ? res.avUsers.filter(u => u.active) : []; + this.tarUsers = res.asUsers; + } + }); + } + private getAppRateUnits(isUS: boolean) { if (isUS) { this.rateUnits = [ @@ -531,6 +505,19 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit, return valid; } + onMoveToActiveList(items) { + if (items && items.length) { + const inactiveACList = items.filter(i => i.active === false); + if (inactiveACList.length) { + this.tarUsers = [...this.tarUsers, ...inactiveACList]; + this.srcUsers = this.srcUsers.filter(u => u.active === true); + let errMsg = $localize`:@@cannotUnAssignInactiveVehicles:Cannot unassign inactive Aircraft`; + errMsg += ':[ ' + (inactiveACList.map(u => u.name)).join(',') + ' ]'; + this.msgSvc.addFailedMsg(errMsg); + } + } + } + getUserToolTip(user) { if (Utils.isEmptyObj(user)) return ''; let userTT = user.username; @@ -558,7 +545,9 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit, this.setNewRefEnties(); - const job = this.toJobWithCostings(this.selectedItem); + // invoice costing section + const job = this.selectedItem; + this.handleCosting(job); if (this.mode === MODE.CLONE) { job['cloneId'] = this.cloneId; @@ -571,26 +560,23 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit, return; } + // invoice costing section if (this.job.invoiceStatus == jobInvoiceStatus.INVOICED && this.job.costings?.currency != this.selectedItem.costings.currency) { this.msgSvc.addFailedMsg($localize`:@@jobInvoiceCurrencyInvalid:This job is already invoiced in #currency# currency! Please void the invoice first or create new jobs.`.replace('#currency#', this.job.costings.currency)); return; } - - const job = this.toJobWithCostings(this.selectedItem); + const job = this.selectedItem; + if (this.costingChanged) { + this.handleCosting(job); + } else { + delete job.costings; + } this.setNewRefEnties(); this.store.dispatch(new jobActions.Update({ job, updateItems: false, useDefRate: this.useDefRate })); this.resetNewEntities(); } - private toJobWithCostings(job) { - const hasCostingItems = job?.costings?.items?.length > 0 - if (!hasCostingItems) { - delete job.costings?.currency; - } - return job; - } - addNewProduct() { this.addingNewProduct = true; } @@ -654,22 +640,6 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit, } onStatusChanged(event) { - const oldStatus = this.selectedItem.status; // Current status before change - const newStatus = event.value; // New status from dropdown - - // Track job status change with GA4 - this.gaSvc.trackJobStatusChanged({ - user_id: this.authSvc.user?._id || 'anonymous', - platform: 'web', - job_id: this.selectedItem._id?.toString() || 'unknown', - old_status: this.mapStatusToString(oldStatus), - new_status: this.mapStatusToString(newStatus), - status_change_reason: 'user_action', - completion_time: newStatus === 3 ? new Date().toISOString() : undefined, - efficiency_score: this.calculateEfficiencyScore(oldStatus, newStatus) - }); - - // Existing logic if (this.isEndStatus(event.value)) { if (!this.selectedItem.endDate) { this.selectedItem.endDate = new Date(); @@ -689,38 +659,6 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit, return [1, 2, 3].includes(status) && this.selectedItem.status > 0; } - /** - * Map numeric status to string for GA4 tracking - */ - private mapStatusToString(status: number): 'new' | 'ready' | 'downloaded' | 'sprayed' | 'archived' { - switch (status) { - case 0: return 'new'; - case 1: return 'ready'; - case 2: return 'downloaded'; - case 3: return 'sprayed'; - case 9: return 'archived'; - default: return 'new'; - } - } - - /** - * Calculate efficiency score based on status transition - */ - private calculateEfficiencyScore(oldStatus: number, newStatus: number): number { - // Simple efficiency scoring based on forward progression - if (newStatus > oldStatus && newStatus !== 9) { - // Forward progression (positive) - return Math.min(100, 70 + (newStatus - oldStatus) * 10); - } else if (newStatus < oldStatus && oldStatus !== 9) { - // Backward progression (less efficient) - return Math.max(30, 50 - (oldStatus - newStatus) * 10); - } else if (newStatus === 9) { - // Archived status - return oldStatus >= 3 ? 90 : 60; // High if completed work, lower if archived early - } - return 50; // Default/neutral score - } - onUnitChanged(event) { if (event) { this.updateUnits(this.selectedItem.measureUnit); @@ -755,6 +693,23 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit, } } + downLoadJob(type: number) { + this.doDownLoadJob(type); + } + + private doDownLoadJob(type) { + // TODO: Need to be handled in effects ??? + this.jobSvc.downloadJob({ jobId: this.selectedItem._id, type: type }).subscribe( + (res) => { + try { + saveAs(res, `${this.selectedItem.name}_${this.selectedItem._id}.zip`); + } catch (error) { + alert('Sorry. Your browser does not support this feature !'); + } + this.getLogs(); + }); + } + editJobMap(id?: number) { this.router.navigate( [ @@ -766,17 +721,6 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit, onSelectUpload(event) { this.uploadErrorMsg = ''; if (this.uploader.hasFiles()) { - // Track file upload start using GA4 convention - const files = this.uploader.files; - files.forEach(file => { - this.gaSvc.trackFileUploadStarted({ - file_type: this.gaHelpers.determineFileType(file.name), - file_size_mb: Number((file.size / (1024 * 1024)).toFixed(2)), - related_job_id: this.job?._id?.toString(), - upload_source: 'manual', - platform: 'web' - }); - }); this.uploader.upload(); } } @@ -797,19 +741,7 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit, if (res && res['_id']) { this.curAppId = res['_id']; this.checkImportStatus(this.curAppId); - // Track successful file upload using GA4 convention - const fileType = this.uploader.files && this.uploader.files.length > 0 - ? this.gaHelpers.determineFileType(this.uploader.files[0].name) - : 'prescription_map'; // Default fallback for job context - this.gaSvc.trackFileUploadCompleted({ - file_size_mb: 0, - file_type: fileType, - related_job_id: this.job?._id?.toString(), - upload_source: 'manual', - processing_time_seconds: 0, - validation_status: 'passed', - platform: 'web' - }); + this.gaSvc.gaEvent('JOBS', 'DATA', 'U'); } } } @@ -818,22 +750,6 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit, if (event && event.error) { const resp = event.error; const status = resp.status; - - // Track file upload failure using GA4 convention - if (this.uploader.files && this.uploader.files.length > 0) { - const file = this.uploader.files[0]; - this.gaSvc.trackFileUploadFailed({ - file_type: this.gaHelpers.determineFileType(file.name), - file_size_mb: Number((file.size / (1024 * 1024)).toFixed(2)), - related_job_id: this.job?._id?.toString(), - upload_source: 'manual', - error_type: status === 401 ? 'authentication_error' : 'server_error', - error_message: resp.error?.['error']?.['.tag'] || 'Upload failed', - retry_attempted: false, - platform: 'web' - }); - } - if (status === 401) { this.store.dispatch(new authActions.Logout); } else if (status > 400) { @@ -986,20 +902,6 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit, this.jobSvc.deleteAppFile({ appId: appFile.id }).subscribe((data) => { if (data['appId']) { this.uploadedFiles = this.uploadedFiles.filter(it => it.id !== data['appId']); - - // Track file deletion using GA4 convention - const fileType = appFile.fileName - ? this.gaHelpers.determineFileType(appFile.fileName) - : 'prescription_map'; // Default fallback for job context - this.gaSvc.trackFileDeleted({ - file_type: fileType, - file_size_mb: 0, // File size not available in appFile object - related_job_id: this.job?._id?.toString(), - deletion_reason: 'user_action', - file_age_days: appFile.when ? Math.floor((Date.now() - new Date(appFile.when).getTime()) / (1000 * 60 * 60 * 24)) : undefined, - confirmation_required: true, - platform: 'web' - }); } this.updateTotalCoverage(); }); @@ -1007,51 +909,18 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit, }); } - // Assignment functionality moved to job-assignment component + assignJob() { + if (!this.job) { + return; + } - downLoadJob(type: number) { - this.doDownLoadJob(type); - } - - private doDownLoadJob(type) { - // TODO: Need to be handled in effects ??? - this.jobSvc.downloadJob({ jobId: this.selectedItem._id, type: type }).subscribe( - (data) => { - this.okDl = true; - try { - saveAs(data, this.selectedItem.name + '.zip'); - - // Track job download using GA4 convention - this.gaSvc.trackFileDownloaded({ - file_type: 'prescription_map', - file_size_mb: 0, // Size not available from response - related_job_id: this.selectedItem._id?.toString(), - download_method: 'button_click', - file_format: 'original', - download_source: 'job_edit', - platform: 'web' - }); - } catch (error) { - console.error('Download failed:', error); - alert('Sorry. Your browser does not support this feature !'); - } - }, - (error) => { - console.error('Download job failed:', error); - this.msgSvc.addFailedMsg('Failed to download job'); - } - ); - } - - // Event handlers for job assignment component - onAssignmentComplete(event: any): void { - console.log('Assignment completed:', event); - // Handle assignment completion if needed - } - - onAssignmentError(error: any): void { - console.error('Assignment error:', error); - // Handle assignment error if needed + const assignment = { + jobId: this.job._id, + dlOp: this.selectedItem.dlOp, + avUsers: this.srcUsers, + asUsers: this.tarUsers + }; + this.store.dispatch(new jobActions.Assign(assignment)); } downloadAppfile(data) { @@ -1059,20 +928,6 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit, (res) => { try { saveAs(res, data.name); - - // Track file download using GA4 convention - const fileType = data.name - ? this.gaHelpers.determineFileType(data.name) - : 'prescription_map'; // Default fallback for job context - this.gaSvc.trackFileDownloaded({ - file_type: fileType, - file_size_mb: data.size ? this.parseFileSizeToMB(data.size) : 0, - related_job_id: this.job?._id?.toString(), - download_method: 'button_click', - file_format: 'original', - download_source: 'job_edit', - platform: 'web' - }); } catch (error) { alert('Sorry. Your browser does not support this feature !'); } @@ -1365,38 +1220,16 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit, return this.costingItems?.find(costingItem => !costingItem.disabled)?.value; } - isAddingNew() { - return this.addingNewPilot || this.addingNewVehicle || this.addingNewProduct || this.addingNewCropJob; - } - - onAddingNewCropJobEvt(evt) { - this.addingNewCropJob = evt == 1; - } - - /** - * Parse file size string to megabytes for GA4 tracking - */ - private parseFileSizeToMB(sizeString: string): number { - if (!sizeString) return 0; - - // Handle formats like "2.3 MB", "1.5 KB", "500 B" - const match = sizeString.match(/(\d+\.?\d*)\s*(B|KB|MB|GB)/i); - if (!match) return 0; - - const value = parseFloat(match[1]); - const unit = match[2].toUpperCase(); - - switch (unit) { - case 'B': - return value / (1024 * 1024); - case 'KB': - return value / 1024; - case 'MB': - return value; - case 'GB': - return value * 1024; - default: - return 0; + private handleCosting(job: any) { + if (job.status == 0) { + job.costings.items = []; + } + if (job.costings?.items?.length == 0) { + delete job.costings?.currency; } } + + ngOnDestroy(): void { + super.ngOnDestroy(); + } } diff --git a/Development/client/src/app/job/job-list/job-list.component.css b/Development/client/src/app/job/job-list/job-list.component.css index 92ea5ae..e69de29 100644 --- a/Development/client/src/app/job/job-list/job-list.component.css +++ b/Development/client/src/app/job/job-list/job-list.component.css @@ -1,21 +0,0 @@ -@media (max-width: 767px) { - .ui-sm-12.no-pad { - display: flex; - justify-content: flex-start; - } -} - -.inline-flex-end { - display: inline-flex; - justify-content: flex-end; -} - -:host ::ng-deep .ui-calendar input, -:host ::ng-deep .ui-calendar .ui-datepicker-trigger { - opacity: 0; - height: 1px; - width: 1px; - overflow: hidden; - position: absolute; - pointer-events: auto; -} \ No newline at end of file diff --git a/Development/client/src/app/job/job-list/job-list.component.html b/Development/client/src/app/job/job-list/job-list.component.html index a83eeed..1c130a2 100644 --- a/Development/client/src/app/job/job-list/job-list.component.html +++ b/Development/client/src/app/job/job-list/job-list.component.html @@ -1,17 +1,19 @@
- +
Job List
- +
+
+ + + +
+
@@ -27,13 +29,10 @@
- +
- - + + @@ -44,12 +43,10 @@ {{cols[1].header}}{{job._id}} {{cols[2].header}}{{job.orderNumber}} {{cols[3].header}}{{job.name}} - {{cols[4].header}}{{job.startDate | - date:'shortDate'}} - {{cols[5].header}}{{job.endDate | - date:'shortDate'}} + {{cols[4].header}}{{job.startDate | date:'shortDate'}} + {{cols[5].header}}{{job.endDate | date:'shortDate'}} - {{cols[5].header}} + {{cols[6].header}} {{ job.status | jobStatus }} @@ -60,59 +57,16 @@
- - - - - - - - - - - + + + + + + + +
-
- - -
-
-
-
- Filter Jobs By Created Date - -
-
- - -
-
{{ item.label }}
-
-
-
-
-
-
- - - -
-
-
\ No newline at end of file +
\ No newline at end of file diff --git a/Development/client/src/app/job/job-list/job-list.component.ts b/Development/client/src/app/job/job-list/job-list.component.ts index 030e835..8e39ac7 100644 --- a/Development/client/src/app/job/job-list/job-list.component.ts +++ b/Development/client/src/app/job/job-list/job-list.component.ts @@ -16,18 +16,13 @@ import * as fromJobs from '../reducers/'; import * as fromClients from '@app/client/reducers'; -import { GC, RoleIds, globals, jobInvoiceStatus, jobListStatus, locales } from '@app/shared/global'; +import { RoleIds, globals, jobInvoiceStatus, jobListStatus } from '@app/shared/global'; import { DatePipe } from '@angular/common'; import { Client } from '@app/client/models/client.model'; import { BaseComp } from '@app/shared/base/base.component'; import { Utils } from '@app/shared/utils'; -import { selectLimit } from '@app/reducers'; -import { Acre } from '@app/domain/models/subscription.model'; -import { SUB, SubTexts, SubType } from '@app/profile/common'; import { InvoiceService } from '@app/domain/services/invoice.service'; import { RestoreTableState } from '@app/shared/restore-table-state'; -import { SubscriptionService } from '@app/domain/services/subscription.service'; -import { GAService } from '@app/shared/ga.service'; @Component({ @@ -37,9 +32,6 @@ import { GAService } from '@app/shared/ga.service'; }) export class JobListComponent extends BaseComp implements OnInit, AfterViewInit, OnDestroy { globals = globals; - readonly dropdownStyle = { 'min-width': '170px', 'color': 'black' }; - readonly customeDate = 'customDate'; - jobs: Array = []; currentJob: IUIJob; currClient: SelectItem; @@ -48,12 +40,11 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit, @ViewChild('dt') public dt: Table; @ViewChild('cl') public cl: Dropdown; - @ViewChild('calendar') calendar: any; rows1Page = [10, 15, 30, 60, 100]; cols: any[]; - status: SelectItem[] = [GC.selAll, ...GC.selJobStatuses]; + status: SelectItem[]; statusFilter; reloadOps: SelectItem[]; reloadBy = 0; @@ -62,21 +53,12 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit, totalJobs; - acre: Acre; - dateOptions: { - label: string; - value: string; - }[]; - selDate: string; - - selCalDate: [Date, Date]; - get canWrite(): boolean { return this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM, RoleIds.OFFICER, RoleIds.PILOT, RoleIds.CLIENT]); } get canWriteInvoice(): boolean { - return this.authSvc.canAccessInvoice + return this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM]) && this.jobs?.length > 0; } @@ -84,9 +66,7 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit, private readonly route: ActivatedRoute, private readonly datePipe: DatePipe, private readonly invoiceSvc: InvoiceService, - private readonly restoreTableSvc: RestoreTableState, - private readonly subscriptionService: SubscriptionService, - private readonly gaService: GAService + private readonly restoreTableSvc: RestoreTableState ) { super(); this.currClient = ({ label: globals.all, value: null }); @@ -98,7 +78,7 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit, { label: globals.statusReady, value: jobListStatus.READY }, { label: globals.statusDownloaded, value: jobListStatus.DOWNLOAD }, { label: globals.statusSprayed, value: jobListStatus.SPRAY }, - { label: globals.statusInvoiced, value: jobListStatus.INVOICED }, + { label: $localize`:@@invoiced:Invoiced`, value: jobListStatus.INVOICED }, ]; this.statusFilter = jobListStatus.ALL; @@ -129,13 +109,9 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit, ]; this.showStatusPlus = !this.authSvc.hasRole([RoleIds.CLIENT, RoleIds.INSPECTOR]); this.defaultInvoiceSetting = this.invoiceSvc.defaultSetting; - - this.dateOptions = this.subscriptionService.getDateOptions(); - this.dateOptions.push({ label: $localize`:@@customDate:Custom Date`, value: this.customeDate }); } ngOnInit() { - // Initialize subscriptions first to get accurate data this.sub$ = this.store.pipe(select(fromClients.getAllClients)).subscribe(clients => { if (Utils.isEmptyArray(clients)) { return; @@ -154,59 +130,22 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit, } else { this.currClient = ({ label: globals.all, value: null }); } - })); this.sub$.add(this.store.pipe(select(fromJobs.getJobsByClient)).subscribe(jobs => { + })); + this.sub$.add(this.store.pipe(select(fromJobs.getJobsByClient)).subscribe(jobs => { this.jobs = jobs; })); this.sub$.add(this.store.pipe(select(fromJobs.getSelectedJob)).subscribe((job) => { this.currentJob = job; })); - - this.sub$.add(this.store.select(selectLimit(SubType.PACKAGE)).subscribe((pkg) => { - if (pkg) { - const lookupKey = this.authSvc.getCurLookupKey(SubType.PACKAGE); - - // If lookup key is empty (user data not loaded yet), find first package key - let effectiveLookupKey = lookupKey; - if (!lookupKey && pkg) { - const packageKeys = Object.keys(pkg); - if (packageKeys.length > 0) { - effectiveLookupKey = packageKeys[0]; // Use first available package - } - } - - this.acre = pkg[effectiveLookupKey]?.acre; - } - })); } ngAfterViewInit(): void { - // Track job list viewed ONCE when component is fully initialized - this.trackJobListViewedEvent(); - const listFilter = sessionStorage.getItem('jtb-ops') ? JSON.parse(sessionStorage.getItem('jtb-ops')) : null; - if (listFilter?.filters) { const status = listFilter.filters.status?.value; const invoiced = listFilter.filters.invoiceStatus?.value; this.restoreStatusState(status, invoiced); } - const storedDateSelection = sessionStorage.getItem('jobListSelDate'); - if (storedDateSelection) { - const parsedDateSelection = JSON.parse(storedDateSelection); - if (parsedDateSelection.selDate) { - this.selDate = parsedDateSelection.selDate; - } else { - if (parsedDateSelection.selCalDate) { - if (parsedDateSelection.selCalDate[0] && parsedDateSelection.selCalDate[1]) { - this.selCalDate = [new Date(parsedDateSelection.selCalDate[0]), new Date(parsedDateSelection.selCalDate[1])]; - } else { - this.selCalDate = [new Date(parsedDateSelection.selCalDate[0]), null]; - } - } - this.selDate = this.selCalDate ? this.customeDate : this.dateOptions[0].value; - this.setCustomDateLabel(); - } - } if (this.cl) { this.cl.registerOnChange((newVal) => { this.store.dispatch(new clientActions.Select(({ _id: newVal.value }))); @@ -222,56 +161,29 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit, }, 100); } - private trackJobListViewedEvent(): void { - // Track agricultural business intelligence (complements automatic page_view) - this.gaService.trackJobListViewed({ - user_id: this.authSvc.user?._id || 'anonymous', - platform: 'web', - view_type: 'table', - total_jobs: this.jobs?.length || 0, - displayed_jobs: this.jobs?.length || 0, - sort_by: this.dt?.sortField || null, - filter_count: this.getActiveFilterCount(), - client_filter_applied: !!this.currClient?.value, - reload_interval: this.reloadBy - }); - } - restoreStatusState(status, invoiced) { - const statusMap = { - 0: jobListStatus.NEW, - 1: jobListStatus.READY, - 2: jobListStatus.DOWNLOAD, - 3: jobListStatus.SPRAY, - [jobInvoiceStatus.INVOICED]: jobListStatus.INVOICED - }; - this.statusFilter = statusMap[status] ?? statusMap[invoiced] ?? jobListStatus.ALL; + if (status == undefined && invoiced == undefined) { + return this.statusFilter = jobListStatus.ALL; + } + if (status == 0) { + return this.statusFilter = jobListStatus.NEW; + } + if (status == 1) { + return this.statusFilter = jobListStatus.READY; + } + if (status == 2) { + return this.statusFilter = jobListStatus.DOWNLOAD; + } + if (status == 3) { + return this.statusFilter = jobListStatus.SPRAY; + } + if (invoiced == jobInvoiceStatus.INVOICED) { + return this.statusFilter = jobListStatus.INVOICED; + } } fetchJobsByClient(clientId) { - const statusMap = { - [jobListStatus.ALL]: jobListStatus.ALL, - [jobListStatus.NEW]: 0, - [jobListStatus.READY]: 1, - [jobListStatus.DOWNLOAD]: 2, - [jobListStatus.SPRAY]: 3, - [jobListStatus.INVOICED]: jobInvoiceStatus.INVOICED - }; - - const byTime = - this.selDate - ? this.selDate == this.customeDate - ? this.selCalDate - : [this.selDate] - : [this.dateOptions[0].value]; - - const statusValue = statusMap[this.statusFilter] ?? jobListStatus.ALL; - this.store.dispatch(new jobActions.Fetch({ - clientId: clientId, - jobsByPilot: (this.authSvc.isPilotUser && this.settings.jobsByPilot), - byTime, - status: statusValue - })); + this.store.dispatch(new jobActions.Fetch({ clientId: clientId, jobsByPilot: (this.authSvc.isPilotUser && this.settings.jobsByPilot) })); } restoreTableFirst() { @@ -284,62 +196,14 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit, onRowSelect(event) { this.store.dispatch(new jobActions.Select(this.currentJob)); - - // Track job selection - if (this.currentJob) { - const positionInList = this.jobs.findIndex(job => job._id === this.currentJob._id) + 1; - - this.gaService.trackJobSelected({ - user_id: this.authSvc.user?._id || 'anonymous', - platform: 'web', - job_id: this.currentJob._id.toString(), - selection_method: 'row_click', - position_in_list: positionInList, - job_type: this.currentJob.appType || 'unknown', - job_status: this.currentJob.status?.toString() || 'unknown' - }); - } - } - - get canAddNew(): boolean { - // Check subscription package loaded (!!this.acre) and not over limit - // Note: With unlimited acres (limit: null), overLimit will always be false, - // but keep this check for defensive programming in case limited plans return - return !!this.acre && !this.acre.overLimit; - } - - displaySubDia() { - return this.confirmSvc.confirm({ - header: SubTexts.textUpgradeSub, - message: SubTexts.textUpgradeSubMsg, - accept: () => { - this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]); - } - }); } newJob() { - if (this.canAddNew) { - return this.router.navigate(['./0/edit'], { relativeTo: this.route }); - } - return this.displaySubDia(); + this.router.navigate(['./0/edit'], { relativeTo: this.route }); } duplicateJob() { - if (this.canAddNew) { - // Track bulk action (duplicate) - this.gaService.trackJobBulkAction({ - user_id: this.authSvc.user?._id || 'anonymous', - platform: 'web', - action_type: 'duplicate', - job_count: 1, - job_ids: [this.currentJob._id.toString()], - success_rate: 1.0 - }); - - return this.router.navigate([`./${this.currentJob._id}/edit`, { dup: true }], { relativeTo: this.route }); - } - return this.displaySubDia(); + this.router.navigate([`./${this.currentJob._id}/edit`, { dup: true }], { relativeTo: this.route }); } editJob() { @@ -363,26 +227,7 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit, } reloadJobs() { - const startTime = performance.now(); - this.fetchJobsByClient(this.currClient && this.currClient.value); - - // Track job list reload - setTimeout(() => { - const endTime = performance.now(); - this.gaService.trackJobListViewed({ - user_id: this.authSvc.user?._id || 'anonymous', - platform: 'web', - view_type: 'table', - total_jobs: this.jobs?.length || 0, - displayed_jobs: this.jobs?.length || 0, - sort_by: this.dt?.sortField || null, - filter_count: this.getActiveFilterCount(), - load_time_ms: Math.round(endTime - startTime), - client_filter_applied: !!this.currClient?.value, - reload_interval: this.reloadBy - }); - }, 100); } reloadChanged(value) { @@ -437,186 +282,32 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit, } handleStatusFilter(value) { - const previousCount = this.jobs?.length || 0; - switch (value) { case jobListStatus.ALL: this.dt.filter(null, 'status', 'equals'); this.dt.filter('', 'invoiceStatus', 'contains'); - this.statusFilter = jobListStatus.ALL; break; case jobListStatus.NEW: this.dt.filter(0, 'status', 'equals'); this.dt.filter('', 'invoiceStatus', 'contains'); - this.statusFilter = jobListStatus.NEW; break; case jobListStatus.READY: this.dt.filter(1, 'status', 'equals'); this.dt.filter('', 'invoiceStatus', 'contains'); - this.statusFilter = jobListStatus.READY; break; case jobListStatus.DOWNLOAD: this.dt.filter(2, 'status', 'equals'); this.dt.filter('', 'invoiceStatus', 'contains'); - this.statusFilter = jobListStatus.DOWNLOAD; break; case jobListStatus.SPRAY: this.dt.filter(3, 'status', 'equals'); this.dt.filter('', 'invoiceStatus', 'contains'); - this.statusFilter = jobListStatus.SPRAY; break; case jobListStatus.INVOICED: this.dt.filter(null, 'status', 'equals'); this.dt.filter(jobInvoiceStatus.INVOICED, 'invoiceStatus', 'contains'); - this.statusFilter = jobListStatus.INVOICED; break; } - - // Track filter usage - setTimeout(() => { - const currentCount = this.jobs?.length || 0; - this.gaService.trackJobListFiltered({ - user_id: this.authSvc.user?._id || 'anonymous', - platform: 'web', - filter_type: 'status', - filter_value: value, - results_before: previousCount, - results_after: currentCount, - filter_effectiveness: previousCount > 0 ? (currentCount / previousCount) : 0 - }); - }, 100); - } - - private setJobListSelDate(dateSelection): void { - sessionStorage.setItem('jobListSelDate', JSON.stringify(dateSelection)); - } - - private setCustomDateLabel(): void { - const dateFormat = this.locale.dateFormat.replace(/(^|\/)mm(\/|$)/g, '$1MM$2'); - - if (!this.selCalDate) { - this.dateOptions.find(it => it.value === this.customeDate).label = $localize`:@@customDate:Custom Date`; - } else if (!this.selCalDate[1]) { - this.dateOptions.find(it => it.value === this.customeDate).label = - `${this.datePipe.transform(this.selCalDate[0], dateFormat)}`; - } else { - this.dateOptions.find(it => it.value === this.customeDate).label = - `${this.datePipe.transform(this.selCalDate[0], dateFormat)} - ${this.datePipe.transform(this.selCalDate[1], dateFormat)}`; - } - } - - onDropdownChange(evt): void { - const previousCount = this.jobs?.length || 0; - - if (evt.value === this.customeDate) { - setTimeout(() => this.showCal()); - } else { - this.setJobListSelDate({ selDate: evt.value, selCalDate: null }); - this.reloadJobs(); - - // Track date filter usage - setTimeout(() => { - const currentCount = this.jobs?.length || 0; - this.gaService.trackJobListFiltered({ - user_id: this.authSvc.user?._id || 'anonymous', - platform: 'web', - filter_type: 'date', - filter_value: evt.value, - results_before: previousCount, - results_after: currentCount, - filter_effectiveness: previousCount > 0 ? (currentCount / previousCount) : 0, - date_filter_type: this.getDateFilterType(evt.value) - }); - }, 500); - } - } - - onCalClose(): void { - const previousCount = this.jobs?.length || 0; - - this.setCustomDateLabel(); - if (this.selCalDate) { - this.setJobListSelDate({ selDate: null, selCalDate: this.selCalDate }); - } else { - this.selDate = this.dateOptions[0].value; - this.setJobListSelDate({ selDate: this.selDate, selCalDate: null }); - } - this.reloadJobs(); - - // Track custom date filter usage - if (this.selCalDate) { - setTimeout(() => { - const currentCount = this.jobs?.length || 0; - this.gaService.trackJobListFiltered({ - user_id: this.authSvc.user?._id || 'anonymous', - platform: 'web', - filter_type: 'date', - filter_value: 'custom_date_range', - results_before: previousCount, - results_after: currentCount, - filter_effectiveness: previousCount > 0 ? (currentCount / previousCount) : 0, - date_filter_type: 'custom', - custom_date_range: [ - this.selCalDate[0]?.toISOString().split('T')[0], - this.selCalDate[1]?.toISOString().split('T')[0] - ] - }); - }, 500); - } - } - - onCalClick() { - setTimeout(() => this.showCal()); - } - - showCal() { - this.calendar?.el.nativeElement.querySelector('button')?.click(); - } - - isShowXBtn(item) { - return item?.value == this.customeDate && this.selDate == this.customeDate - } - - // Helper method to count active filters - private getActiveFilterCount(): number { - let count = 0; - - // Check status filter - if (this.statusFilter && this.statusFilter !== jobListStatus.ALL) { - count++; - } - - // Check client filter - if (this.currClient?.value) { - count++; - } - - // Check date filter - if (this.selDate && this.selDate !== this.dateOptions[0]?.value) { - count++; - } - - // Check table column filters - if (this.dt?.filters) { - Object.keys(this.dt.filters).forEach(key => { - const filter = this.dt.filters[key]; - if (filter && filter.value && filter.value !== '') { - count++; - } - }); - } - - return count; - } - - // Helper method to determine date filter type - private getDateFilterType(value: string): 'today' | 'week' | 'month' | 'quarter' | 'custom' { - if (value === this.customeDate) return 'custom'; - if (value?.includes('today')) return 'today'; - if (value?.includes('week')) return 'week'; - if (value?.includes('month')) return 'month'; - if (value?.includes('quarter')) return 'quarter'; - return 'custom'; } ngOnDestroy() { diff --git a/Development/client/src/app/job/job-map-edit/job-map-edit.component.html b/Development/client/src/app/job/job-map-edit/job-map-edit.component.html index b7dfcba..2532c1b 100644 --- a/Development/client/src/app/job/job-map-edit/job-map-edit.component.html +++ b/Development/client/src/app/job/job-map-edit/job-map-edit.component.html @@ -678,7 +678,7 @@
{{curPlayRec.timeLocal || "00:00:00.0"}}
-
+
@@ -700,18 +700,14 @@
{{playXt.avg | length:isUS:0 }} / {{curPlayRec.xt | xtract:isUS:0}}
TrckAngle
{{curPlayRec.trckAngle}}
- -
LckedLine
-
{{curPlayRec.lockedLine | lockline:curPlayLoc?.xTrack }}
-
+
LckedLine
+
{{curPlayRec.lockedLine | lockline:curPlayLoc?.xTrack }}
HDOP
{{curPlayRec.hdop}}
Sat/Cor/ID
{{curPlayRec.sats || 0}} / {{curPlayRec.corId || 0}}/ {{curPlayRec.waasId}}
- -
SprayStat
-
{{curPlayLoc?.sprayStat}} (DEBUG)
-
+
SprayStat
+
{{curPlayLoc?.sprayStat}}
@@ -720,14 +716,8 @@
Applic.RateAp
{{ curPlayRec.appRateAp | appRate:playMatType:isUS:null:false }}
Applic.RateRq
- -
{{ curPlayRec.applicRate | number:'1.2-2':'en'}} {{ curPlayRec.applicRateUnit | rateUnit:2:false }}
-
- -
{{ curPlayRec.applicRate | appRate:playMatType:isUS:null:false }}
-
-
FlowRateAp -
+
{{ curPlayRec.applicRate | appRate:playMatType:isUS:curPlayRec.applicRateUnit:false }}
+
FlowRateAp
{{curPlayRec.flowRateAp || 0 | flowRate:isUS }}
FlowRateRq
{{curPlayRec.flowRateRq || 0 | flowRate:isUS }}
@@ -742,9 +732,9 @@
Flow Control
-
{{curPlayRec.flowControl }}
+
{{curPlayRec.flowControl || "No FC" }}
- +
Bm Pressure
{{curPlayRec.bmPressure | number:'1.1-1':'en'}} psi
@@ -783,10 +773,8 @@
AutoSpr On/Off
{{curPlayRec.sprOnLag | number:'1.2-2':'en' }} / {{curPlayRec.sprOffLag | number:'1.2-2':'en'}}
- -
Pulses/Liter
-
{{curPlayRec.pulsesPLiter | number:'1.0-0':'en'}}
-
+
Pulses/Liter
+
{{curPlayRec.pulsesPLiter | number:'1.0-0':'en'}}
@@ -807,10 +795,8 @@
{{NumUtils.fixedTo(curPlayRec.utmY, 1, '0.0')}}
Speed
{{UnitUtils.mpsToKnot(curPlayRec.speed) | number:'1.1-1':'en'}} knots
- -
LckedLine
-
{{curPlayRec.lockedLine | lockline}}
-
+
LckedLine
+
{{curPlayRec.lockedLine | lockline}}
Wind Spd
{{curPlayRec.windSpd | number:'1.1-1':'en' }} knots
Wind Dir
@@ -837,10 +823,8 @@
- -
AreaName
-
{{curPlayRec.areaName}}
-
+
AreaName
+
{{curPlayRec.areaName}}
Mapped Area
{{curPlayRec.mappedArea | number:'1.1-1':'en' }} {{ currentJob.measureUnit | areaUnit:false }}
AreaSprTot
@@ -850,13 +834,7 @@
Pilot Name
{{curPlayRec.pilotName}}
Applic.Rate
- - -
{{ curPlayRec.applicRate | number:'1.2-2':'en'}} {{ curPlayRec.applicRateUnit | rateUnit:2:false }}
-
- -
{{ curPlayRec.applicRate | appRate:playMatType:isUS:null:false }}
-
+
{{ curPlayRec.applicRate | number:'1.2-2':'en'}} {{ curPlayRec.applicRateUnit | rateUnit:null:false }}
Mat Needed
{{( totalAmount?.value || 0) | number:'1.1-1':'en'}} {{ totalAmount?.appRateUnit | rateUnit:1:false }}
Mat Sprayed
@@ -867,4 +845,4 @@
- \ No newline at end of file + diff --git a/Development/client/src/app/job/job-map-edit/job-map-edit.component.ts b/Development/client/src/app/job/job-map-edit/job-map-edit.component.ts index e10ee2e..7ce44ae 100644 --- a/Development/client/src/app/job/job-map-edit/job-map-edit.component.ts +++ b/Development/client/src/app/job/job-map-edit/job-map-edit.component.ts @@ -26,7 +26,7 @@ import { IJob, Area, WayPoint, ITEM, BufferZone, RptOption, WeatherInfo, defWeat import * as jobActions from '../actions/job.actions'; import { UpdateJobOps } from '../actions/job.actions'; -import { RoleIds, globals, DRAW, KEY_CODE, PANE, GC, MatType, RateUnit, SysDataTypes, MatType2 } from '@app/shared/global'; +import { RoleIds, globals, DRAW, KEY_CODE, PANE, GC, MatType, RateUnit } from '@app/shared/global'; import { LengthUnitPipe } from '@app/shared/pipes/length-unit.pipe'; import { JobService } from '@app/domain/services/job.service'; import { ObstacleService } from '@app/domain/services/obstacle.service'; @@ -41,8 +41,6 @@ import { TabView } from 'primeng/tabview'; import PlayBack, { PlayMode } from '@app/shared/playback'; import { PlayRecord } from '@app/domain/models/play-record.model'; import { StatNum } from '@app/shared/statnum'; -import { selectLimit } from '@app/reducers'; -import { SUB, SubTexts, SubType } from '@app/profile/common'; declare var turf: any; declare var UTM: any; @@ -179,13 +177,6 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte private lastPlayUnit; // {: { }}, = { data: [], other fields } filesDataSet = {}; - // Track pagination state per file - private fileDataPagination: Map = new Map(); playIdx: number = -1; centerPlayPos: boolean = false; playMarker: any; @@ -237,14 +228,6 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte return this.job; } - get isPlayingAgNavFile(): boolean { - return Boolean(this.playingFile && this.playingFile?.file?.meta && this.playingFile?.file?.meta?.type === SysDataTypes.AGNAV); - } - - get isPlayingSatLocFile(): boolean { - return Boolean(this.playingFile && this.playingFile?.file?.meta && this.playingFile?.file?.meta?.type === SysDataTypes.SATLOC); - } - protected postUpdateDrawToolTips(type: DRAW) { switch (type) { case DRAW.BUFFER: @@ -325,6 +308,7 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte private readonly weatherSvc: WeatherService, private readonly ngZone: NgZone, protected cdRef: ChangeDetectorRef, + ) { super(cdRef); @@ -368,8 +352,6 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte var updated = false; if (e.layer === this.obstaclesLGrp) { if (add) this.loadObstacles(); - else this.obstaclesOn = false; - this.settings.mapOps.obs = add; updated = true; } @@ -597,24 +579,6 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte }); this.sub$.add(weatherSub); - this.sub$.add(this.store.select(selectLimit(SubType.PACKAGE)).subscribe((pkg) => { - const acre = pkg[this.authSvc.getCurLookupKey(SubType.PACKAGE)]?.acre; - if (acre?.overLimit) { - this.drawItems = this.drawItems?.filter((item) => item.label !== globals.sprayZone); - this.drawItems.unshift({ - label: globals.sprayZone, icon: '', command: () => { - this.confirmSvc.confirm({ - header: SubTexts.textUpgradeSub, - message: $localize`:@@upgradeSprayZone:You have exceeded the permitted limit for the maximum applicable area. Please upgrade your subscription to enable this feature.`, - accept: () => { - this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]); - } - }); - } - }) - } - })); - super.ngOnInit(); } @@ -2642,7 +2606,6 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte this.map && this.map.removeLayer(this.playMarker); this.playMarker = null; } - } private findRefZone() { @@ -2700,7 +2663,7 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte if (!file.meta) { file.meta = { appRate: this.job.appRate, rateUnit: this.job.appRateUnit, hasQfile: false, useFC: false }; } else { - file.meta.useFC = (file.meta.fcType && typeof file.meta.fcType === 'string' && file.meta.fcType.length && !file.meta.fcType.match(/none/i)) ? true : false; + file.meta.useFC = (file.meta.fcType && file.meta.fcType.length && !file.meta.fcType.match(/none/i)) ? true : false; file.rateUnit = file.meta.appRateUnitStr ? UnitUtils.rateStringToCode(file.meta.appRateUnitStr, this.isUS) : this.job.appRateUnit; } } @@ -2715,8 +2678,6 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte this.selDataFiles = []; this.dataFiles = []; this.filesDataSet = {}; - // Reset loaded file pagination data - this.fileDataPagination.clear(); } private updatePlayRecord(idx: number, forward: boolean) { @@ -2728,13 +2689,8 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte const newRec = new PlayRecord(); newRec.timeGPS = this.curPlayLoc.gpsTime; - if (newRec.timeGPS) { - if (this.isPlayingAgNavFile) - newRec.timeLocal = DateUtils.msToTime(newRec.timeGPS * 1000, this.localTz); - else { - newRec.timeLocal = DateUtils.gpsTimeToLocalISO(this.curPlayLoc.gpsTime, this.curPlayLoc.gmtOffset || 0); - } - } + if (newRec.timeGPS) + newRec.timeLocal = DateUtils.msToTime(newRec.timeGPS * 1000, this.localTz); newRec.lat = this.curPlayLoc.lat; newRec.lon = this.curPlayLoc.lon; @@ -2764,24 +2720,26 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte newRec.flowRateRq = this.curPlayLoc.lminReq; - newRec.flowControl = file.meta?.fcName && !file?.meta?.fcName.match(/none/i) ? file.meta.fcName : 'No FC'; + // If an FC used => assign the flowControl field w/ the value from the Qfile + if (file.meta.fcType && file.meta.fcType.trim().length && !file.meta.fcType.match(/none/i)) + newRec.flowControl = file.meta.fcType; if (this.curPlayLoc.sprayStat) { newRec.flowRateAp = this.curPlayLoc.lminApp; // Apply for Liquid // if (file.meta && file.meta.appRate && (!file.meta.useFC || !this.curPlayLoc.lminApp)) { - if (file.meta?.appRate && (!file.meta?.useFC || !this.curPlayLoc.lminApp)) { + if (file.meta && file.meta.appRate && (!file.meta.useFC || !this.curPlayLoc.lminApp)) { const uniRate = UnitUtils.toRateUnit(file.meta.appRate, file.rateUnit, false); - newRec.rateUnit = uniRate.unit; // Expected in Metrics newRec.appRateAp = uniRate.value; + newRec.rateUnit = uniRate.unit; // Expected in Metrics } else { if (this.playMatType === MatType.LIQUID) { - newRec.rateUnit = RateUnit.LPH; newRec.appRateAp = UnitUtils.appRateFromFlowRate(newRec.flowRateAp, this.curPlayLoc.swath, newRec.speed); + newRec.rateUnit = RateUnit.LPH; } else { - newRec.rateUnit = RateUnit.KGPH; newRec.appRateAp = this.curPlayLoc.lminApp; + newRec.rateUnit = RateUnit.KGPH; } } this.lastPlayUnit = newRec.rateUnit; @@ -2791,36 +2749,25 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte newRec.bmPressure = this.curPlayLoc.psi || 0.0; - if (file.meta?.sprCoverage && file.meta.sprCoverage.length === 3) + if (file.meta.sprCoverage && file.meta.sprCoverage.length === 3) newRec.area = file.meta.sprCoverage[1]; // Current spray zone area size in metric, ha newRec.swathWidth = this.curPlayLoc.swath; - newRec.sprOnLag = file.meta?.sprOnLag || 0; - newRec.sprOffLag = file.meta?.sprOffLag || 0; - newRec.pulsesPLiter = file.meta?.pulsesPerLit; + newRec.sprOnLag = file.meta.sprOnLag || 0; + newRec.sprOffLag = file.meta.sprOffLag || 0; + newRec.pulsesPLiter = file.meta.pulsesPerLit; // For Output 3 - newRec.areaName = file.meta?.areaOrZone; + newRec.areaName = file.meta.areaOrZone; newRec.totLnLength = this.totLnLength; // Skipped, not necessary. It requires reading all gridlines from files - const matType = this.playingFile?.file?.meta?.matType; - // Planned/Target Application Rate - if (this.isPlayingAgNavFile) { - if ((file.meta && !isNaN(file.meta?.appRate) && file.meta?.appRate !== 0)) { - newRec.applicRate = file.meta.appRate; - newRec.applicRateUnit = file.rateUnit; - } else { - newRec.applicRateUnit = this.job.appRateUnit; - newRec.applicRate = this.job.appRate; - } - } - else if (!isNaN(this.curPlayLoc?.lhaReq) && matType) { - newRec.applicRateUnit = matType === MatType2.WET ? RateUnit.LPH : RateUnit.KGPH; - newRec.applicRate = this.curPlayLoc.lhaReq; - } - else { - newRec.applicRateUnit = this.job.appRateUnit; + // Planned/Target Application Rate + if ((file.meta && file.meta.appRate !== 0)) { + newRec.applicRate = file.meta.appRate; + newRec.applicRateUnit = file.rateUnit; + } else { newRec.applicRate = this.job.appRate; + newRec.applicRateUnit = this.job.appRateUnit; } // @@ -2828,8 +2775,8 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte const sprTot = UnitUtils.toArea(this.areaSprTot.total, this.isUS); // (AreaSprTot - Mapped Area)/Mapped Area * 100. If value is negative, it indicates undersprayed or area not finished - newRec.overSprayed = newRec.mappedArea ? ((sprTot - newRec.mappedArea) / newRec.mappedArea) * 100 : 0; - newRec.pilotName = file.meta?.operator; + newRec.overSprayed = ((sprTot - newRec.mappedArea) / newRec.mappedArea) * 100; + newRec.pilotName = file.meta.operator; if (!newRec.pilotName && this.job.operator) newRec.pilotName = this.job.operator.name; @@ -2936,21 +2883,14 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte if (cb) cb(); }; const fid = this.selDataFiles[nextFileIdx].fid; - - // Initialize pagination tracking if not exists - if (!this.fileDataPagination.has(fid)) { - this.fileDataPagination.set(fid, { - hasMore: true, - startingAfter: null, - loading: false, - allLoaded: false + if (!this.filesDataSet[fid].loaded) { + this.jobSvc.getFilesData([fid]).subscribe(filesdata => { + if (filesdata.length) { + this.filesDataSet[fid].data = filesdata[0].data; + this.filesDataSet[fid].loaded = true; + } + setNextFile(nextFileIdx, cb); }); - } - - const pagination = this.fileDataPagination.get(fid); - - if (!this.filesDataSet[fid].loaded || !pagination.allLoaded) { - this.loadFileDataWithPagination(fid, () => setNextFile(nextFileIdx, cb)); } else { setNextFile(nextFileIdx, cb); } @@ -2960,80 +2900,6 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte } } - private async loadFileDataWithPagination(fileId: string, callback?: Function) { - const pagination = this.fileDataPagination.get(fileId); - if (pagination.loading) return; - - // Initialize file data array if not exists - if (!this.filesDataSet[fileId]) { - this.filesDataSet[fileId] = { data: [], loaded: false }; - } - - pagination.loading = true; - let hasMore = true; - - try { - while (hasMore) { - const params: any = {}; - - if (pagination.startingAfter) { - params.startingAfter = pagination.startingAfter; - } - // console.log(`Loading page for ${fileId}, cursor: ${pagination.startingAfter}`); - const result = await this.jobSvc.getFilesData(fileId, params).toPromise(); - - // console.log(`Response for ${fileId}:`, { - // dataLength: result?.data?.length, - // hasMore: result?.hasMore, - // startingAfter: result?.startingAfter - // }); - if (result && result.data && result.data.length > 0) { - // Append data to existing array - this.filesDataSet[fileId].data.push(...result.data); - - // Check if there's more data - hasMore = result.hasMore === true; - - if (hasMore && result.startingAfter) { - // Update cursor for next page - pagination.startingAfter = result.startingAfter; - } else { - hasMore = false; - } - } else { - // No more data or empty response - hasMore = false; - } - } - - // All data loaded - pagination.allLoaded = true; - pagination.loading = false; - pagination.hasMore = false; - this.filesDataSet[fileId].loaded = true; - if (callback) callback(); - - } catch (error) { - pagination.loading = false; - console.error('Error loading file data:', error); - if (callback) callback(); - } - } - - private resetFilePagination(fileId: string) { - if (this.filesDataSet[fileId]) { - this.filesDataSet[fileId].data = []; - this.filesDataSet[fileId].loaded = false; - } - - this.fileDataPagination.set(fileId, { - hasMore: true, - startingAfter: null, - loading: false, - allLoaded: false - }); - } - private createLine(locs = [], isSpray: boolean) { locs = locs || []; let line, ops; @@ -3080,8 +2946,6 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte const utmP = UTM.fromLatLng(_latlng, this.refZ.zone); if (NumUtils.isNumber(this.curPlayRec.driftX) && NumUtils.isNumber(this.curPlayRec.driftY) && (this.curPlayRec.driftX !== 0.0 || this.curPlayRec.driftY !== 0.0)) { const newLL = UTM.toLatLng({ zone: utmP.zone, x: (+utmP.x + this.curPlayRec.driftX), y: (+utmP.y + this.curPlayLoc.driftY) }); - // if (NumUtils.isNumber(this.curPlayRec.depositX) && NumUtils.isNumber(this.curPlayRec.depositY) && (this.curPlayRec.depositX !== 0.0 || this.curPlayRec.depositY !== 0.0)) { - // const newLL = UTM.toLatLng({ zone: utmP.zone, x: (+utmP.x + this.curPlayRec.depositX), y: (+utmP.y + this.curPlayLoc.depositY) }); if (newLL) { _latlng.lat = newLL.lat; _latlng.lng = newLL.lng; @@ -3105,12 +2969,12 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte /* Rules: If the deposit location is NOT inside any XCLs, and if drift position is on an XCL, don’t plot or paint spray on*/ const depLL = UTM.toLatLng({ zone: this.refZ.zone, x: (+orgUtmPoint.x + this.curPlayRec.depositX), y: (+orgUtmPoint.y + this.curPlayRec.depositY) }); // if (this.isDebug) { - // L.circle([llPoint.lat, llPoint.lng], 4, { color: 'yellow' }).addTo(this.map); - // L.circle([depLL.lat, depLL.lng], 7, { color: 'white' }).addTo(this.map) + // L.circle([llPoint.lat, llPoint.lng], 4, { color: 'yellow' }).addTo(this.map); + // L.circle([depLL.lat, depLL.lng], 7, { color: 'white' }).addTo(this.map) // }; if (!this.isPointinPolys(depLL.lat, depLL.lng, this.job.excludedAreas)) { // this.isDebug && L.circle([depLL.lat, depLL.lng], 10, { color: 'red' }).addTo(this.map); - return false; + return null; } } @@ -3299,7 +3163,7 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte this._player = new PlayBack(this.atRecord.bind(this)); this.player.speed = this.playSpd; - if ((this.job.appRateUnit == RateUnit.LBPA || this.job.appRateUnit == RateUnit.KGPH)) + if ((this.job.appRateUnit == 2 || this.job.appRateUnit == 4)) this.playMatType = MatType.DRY; this.playIdx = -1; this.totLnLength = 0; @@ -3316,7 +3180,6 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte this.player.speed = e.value; }); } - onTzChange(e) { if (!e) return; if (this.curPlayRec) { diff --git a/Development/client/src/app/job/job.module.ts b/Development/client/src/app/job/job.module.ts index b2ded9a..ccdddaa 100644 --- a/Development/client/src/app/job/job.module.ts +++ b/Development/client/src/app/job/job.module.ts @@ -26,17 +26,16 @@ import { OrderListModule } from 'primeng/orderlist'; import { StoreModule } from '@ngrx/store'; import { EffectsModule } from '@ngrx/effects'; -import * as fromJobs from './reducers/jobs.reducer'; +import * as fromJobs from './reducers/jobs-reducer'; import { JobEffects } from './effects/job.effects'; import { JobMgtComponent } from './job-mgt.component'; import { AppSharedModule } from '../shared/app-shared.module'; import { JobListComponent } from './job-list/job-list.component'; import { JobEditComponent } from './job-edit/job-edit.component'; -import { JobAssignmentComponent } from './job-assignment/job-assignment.component'; import { JobMapEditComponent } from './job-map-edit/job-map-edit.component'; import { JobsRoutingModule } from './job-routing.module'; -import { InvoicesModule } from '@app/invoices/invoices.module'; +import {InvoicesModule} from '@app/invoices/invoices.module'; @NgModule({ imports: [ @@ -51,7 +50,7 @@ import { InvoicesModule } from '@app/invoices/invoices.module'; StoreModule.forFeature(fromJobs.FEATURE_KEY, fromJobs.reducer), EffectsModule.forFeature([JobEffects]), InvoicesModule, ], - declarations: [JobMgtComponent, JobListComponent, JobEditComponent, JobAssignmentComponent, JobMapEditComponent], + declarations: [JobMgtComponent, JobListComponent, JobEditComponent, JobMapEditComponent], providers: [DatePipe], schemas: [ CUSTOM_ELEMENTS_SCHEMA diff --git a/Development/client/src/app/job/reducers/index.ts b/Development/client/src/app/job/reducers/index.ts index 56b6ba4..d312dba 100644 --- a/Development/client/src/app/job/reducers/index.ts +++ b/Development/client/src/app/job/reducers/index.ts @@ -3,67 +3,33 @@ import { createFeatureSelector, } from '@ngrx/store'; -import * as fromJobs from './jobs.reducer'; +import * as fromJobs from './jobs-reducer'; import * as fromClients from '@app/client/reducers'; import { Client } from '@app/client/models/client.model'; import { IUIJob } from '../models/job.model'; export const getJobsState = createFeatureSelector(fromJobs.FEATURE_KEY); -// Safe wrapper that handles undefined state during lazy module loading -// This prevents "Cannot read properties of undefined (reading 'ids')" error -export const getJobsStateOrInitial = createSelector( - getJobsState, - (state) => { - if (!state) { - return { - ids: [], - entities: {}, - loading: false, - loaded: false, - selectedId: null - } as fromJobs.State; - } - return state; - } -); - export const getSelectedJobId = createSelector( - getJobsStateOrInitial, + getJobsState, fromJobs.getSelectedId ); export const getIsLoading = createSelector( - getJobsStateOrInitial, + getJobsState, fromJobs.getIsLoading ); export const getIsLoaded = createSelector( - getJobsStateOrInitial, + getJobsState, fromJobs.getIsLoaded ); -// Use safe wrapper with adapter selectors to prevent undefined access -const entitySelectors = fromJobs.adapter.getSelectors(getJobsStateOrInitial); - -export const getJobsIds = createSelector( - entitySelectors.selectIds, - (ids) => ids || [] -); - -export const getJobEntities = createSelector( - entitySelectors.selectEntities, - (entities) => entities || {} -); - -export const getAllJobs = createSelector( - entitySelectors.selectAll, - (jobs) => jobs || [] -); - -export const getTotalJobs = createSelector( - entitySelectors.selectTotal, - (total) => total || 0 -); +export const { + selectIds: getJobsIds, + selectEntities: getJobEntities, + selectAll: getAllJobs, + selectTotal: getTotalJobs, +} = fromJobs.adapter.getSelectors(getJobsState); export const getSelectedJob = createSelector( getJobEntities, diff --git a/Development/client/src/app/job/reducers/jobs.reducer.ts b/Development/client/src/app/job/reducers/jobs-reducer.ts similarity index 100% rename from Development/client/src/app/job/reducers/jobs.reducer.ts rename to Development/client/src/app/job/reducers/jobs-reducer.ts diff --git a/Development/client/src/app/pages/app.password-reset.component.html b/Development/client/src/app/pages/app.password-reset.component.html index c013af8..f321b54 100644 --- a/Development/client/src/app/pages/app.password-reset.component.html +++ b/Development/client/src/app/pages/app.password-reset.component.html @@ -6,30 +6,26 @@

Reset your password

- -
Enter your login Username (email) and we will send you a password reset link
- Invalid Email + Invalid Email
- +
-
- - If the email address you entered is valid, we have sent you a password reset email. -

- Check your email for a link to reset your password. It it doesn't appear within a few minutes, check your spam folder.
+ Check your email for a link to reset your password. It it doesn't appear within a few minutes, check your spam folder. +
+
+
-
@@ -52,20 +48,5 @@
- - -
- - {{errorMessage}} - -
-
- -
-
- -
\ No newline at end of file diff --git a/Development/client/src/app/pages/app.password-reset.component.ts b/Development/client/src/app/pages/app.password-reset.component.ts index d9d2d41..585678b 100644 --- a/Development/client/src/app/pages/app.password-reset.component.ts +++ b/Development/client/src/app/pages/app.password-reset.component.ts @@ -1,14 +1,13 @@ import { Component, OnInit, ElementRef, ViewChild, AfterViewInit } from '@angular/core'; +import { environment } from '@environments/environment'; import { ActivatedRoute, ParamMap } from '@angular/router'; import { switchMap } from 'rxjs/operators'; import { of } from 'rxjs'; import { BaseComp } from '../shared/base/base.component'; -import { GC, globals } from '@app/shared/global'; -import { GAService } from '@app/shared/ga.service'; -import { AuthService } from '@app/domain/services/auth.service'; +import { GC } from '@app/shared/global'; -export enum MODE { NONE, MAILED, CONFIRMED, ERROR }; +export enum MODE { NONE, MAILED, CONFIRMED }; @Component({ selector: 'agm-app.password-reset', @@ -25,16 +24,12 @@ export class AppPasswordResetComp extends BaseComp implements OnInit, AfterViewI confirmedInfo: any; password: string; confirmPassword: string; - errorMessage: string; @ViewChild('f') form: HTMLFormElement; @ViewChild('pwdInput') pwdInput: ElementRef; @ViewChild('mailInput') mailInput: ElementRef; - constructor( - private readonly route: ActivatedRoute, - private readonly gaService: GAService - ) { + constructor(private readonly route: ActivatedRoute) { super(); this["name"] = "AppPasswordResetComp"; } @@ -44,7 +39,7 @@ export class AppPasswordResetComp extends BaseComp implements OnInit, AfterViewI switchMap( (params: ParamMap) => { if (params.get('id') && params.get('token')) - return this.authSvc.validateResetPassword({ id: params.get('id'), token: params.get('token') }); + return this.authSvc.resetPassword({ id: params.get('id'), token: params.get('token') }); else return of(undefined); } @@ -83,33 +78,9 @@ export class AppPasswordResetComp extends BaseComp implements OnInit, AfterViewI this.authSvc.mailPwdReset({ email: this.userEmail }).subscribe( (res) => { - // Track password reset request - this.gaService.trackPasswordResetRequested({ - request_method: 'forgot_password_page', - user_exists: true, // Assuming user exists if request was successful - platform: 'web' - }); - this.mode = MODE.MAILED; }, - (err) => { - // Check on the error code - if (err.status < 500) { - // Track password reset request (even if user doesn't exist) - this.gaService.trackPasswordResetRequested({ - request_method: 'forgot_password_page', - user_exists: false, - platform: 'web' - }); - - this.mode = MODE.MAILED; - // this.reset(); - } else { - // Show error message for server errors using global error handler message - this.mode = MODE.ERROR; - this.errorMessage = globals.apiErrorMsg(err); - } - } + () => this.reset() ); } @@ -124,26 +95,9 @@ export class AppPasswordResetComp extends BaseComp implements OnInit, AfterViewI this.authSvc.changePassword({ ...this.confirmedInfo, password: this.password }) .subscribe( data => { - // Track successful password reset - this.gaService.trackPasswordResetCompleted({ - success: true, - reset_token_age_minutes: 0, // Token age info not available - platform: 'web' - }); - this.toLogin(true); }, - error => { - // Track password reset failure - this.gaService.trackPasswordResetCompleted({ - success: false, - reset_token_age_minutes: 0, // Token age info not available - failure_reason: 'other', - platform: 'web' - }); - - this.reset() - } + () => this.reset() ); } @@ -154,12 +108,11 @@ export class AppPasswordResetComp extends BaseComp implements OnInit, AfterViewI } } - reset() { + private reset() { this.mode = MODE.NONE; this.confirmedInfo = null; this.password = ""; this.confirmPassword = ""; - this.errorMessage = ""; this.setFocus(); } diff --git a/Development/client/src/app/partner-customers/models/partner-customer.model.ts b/Development/client/src/app/partner-customers/models/partner-customer.model.ts deleted file mode 100644 index f930ed6..0000000 --- a/Development/client/src/app/partner-customers/models/partner-customer.model.ts +++ /dev/null @@ -1,71 +0,0 @@ -// Interface for package information from subscription data -export interface PackageInfo { - packageName: string; - status: string; - startDate: Date; - endDate: Date; - recurring: boolean; -} - -// Interface that matches the backend API response -export interface PartnerCustomerApiResponse { - _id: string; - name: string; - email: string; - username: string; - contact?: string; - active: boolean; - country?: string; - createdAt: Date; - updatedAt: Date; - packageInfo: PackageInfo[]; -} - -// Frontend model for display (transformed from API response) -export interface PartnerCustomer { - _id?: string; - name: string; - contactName: string; - phone: string; - email: string; - package: string; // Customer's service package type - partnerId?: string; // Reference to the partner account - createdAt?: Date; - updatedAt?: Date; - active?: boolean; - username?: string; - country?: string; -} - -// Transform function to convert API response to display model -export function transformPartnerCustomer(apiResponse: PartnerCustomerApiResponse): PartnerCustomer { - // Extract the primary package name from packageInfo - const primaryPackage = apiResponse.packageInfo && apiResponse.packageInfo.length > 0 - ? apiResponse.packageInfo[0].packageName || 'No Package' - : 'No Package'; - - return { - _id: apiResponse._id, - name: apiResponse.name, - contactName: apiResponse.contact || apiResponse.name, // Use contact or fallback to name - phone: '', // Not provided by backend - could be added later - email: apiResponse.email, - package: primaryPackage, - createdAt: apiResponse.createdAt, - updatedAt: apiResponse.updatedAt, - active: apiResponse.active, - username: apiResponse.username, - country: apiResponse.country - }; -} - -export function createNewPartnerCustomer(partnerId: string): PartnerCustomer { - return { - name: '', - contactName: '', - phone: '', - email: '', - package: '', - partnerId: partnerId - }; -} diff --git a/Development/client/src/app/partner-customers/partner-customer-list/partner-customer-list.component.css b/Development/client/src/app/partner-customers/partner-customer-list/partner-customer-list.component.css deleted file mode 100644 index a85615d..0000000 --- a/Development/client/src/app/partner-customers/partner-customer-list/partner-customer-list.component.css +++ /dev/null @@ -1 +0,0 @@ -/* Partner Customer List Component Styles */ \ No newline at end of file diff --git a/Development/client/src/app/partner-customers/partner-customer-list/partner-customer-list.component.html b/Development/client/src/app/partner-customers/partner-customer-list/partner-customer-list.component.html deleted file mode 100644 index 7587112..0000000 --- a/Development/client/src/app/partner-customers/partner-customer-list/partner-customer-list.component.html +++ /dev/null @@ -1,52 +0,0 @@ -
-
-
- - - -
-
- Partner Customers -
-
- -
-
-
- - - - - {{col.header}} - - - - - - - - - - - - - - {{col.header}} - - {{getPackageName(customer.package)}} - - {{customer[col.field]}} - - - - -
-
-
-
\ No newline at end of file diff --git a/Development/client/src/app/partner-customers/partner-customer-list/partner-customer-list.component.ts b/Development/client/src/app/partner-customers/partner-customer-list/partner-customer-list.component.ts deleted file mode 100644 index 7abf755..0000000 --- a/Development/client/src/app/partner-customers/partner-customer-list/partner-customer-list.component.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { Component, OnInit, OnDestroy, ViewChild } from '@angular/core'; -import { Table } from 'primeng/table'; - -import { PartnerCustomer } from '../models/partner-customer.model'; -import { PartnerCustomerService } from '../services/partner-customer.service'; -import { globals, Labels } from '@app/shared/global'; -import { BaseComp } from '@app/shared/base/base.component'; -import { AppMessageService } from '@app/shared/app-message.service'; -import { getPackageDisplayName } from '@app/profile/common'; - -@Component({ - selector: 'agm-partner-customer-list', - templateUrl: './partner-customer-list.component.html', - styleUrls: ['./partner-customer-list.component.css'] -}) -export class PartnerCustomerListComponent extends BaseComp implements OnInit, OnDestroy { - partnerCustomers: PartnerCustomer[] = []; - loading = false; - - cols: any[]; - - // Translation constants for template access - readonly searchPlaceholder = Labels.SEARCH_PLACEHOLDER; - - @ViewChild('dt') dt: Table; - - constructor( - private readonly partnerCustSvc: PartnerCustomerService, - protected readonly msgSvc: AppMessageService - ) { - super(); - - this.cols = [ - { field: 'name', header: globals.name, filtered: true, filterMatchMode: 'contains', width: '23%' }, - { field: 'contactName', header: $localize`:Contact name column header@@contactName:Contact Name`, filtered: true, filterMatchMode: 'contains', width: '23%' }, - { field: 'phone', header: globals.phone, filtered: true, filterMatchMode: 'contains', width: '16%' }, - { field: 'email', header: globals.email, filtered: true, filterMatchMode: 'contains', width: '18%' }, - { field: 'package', header: globals.package, filtered: true, filterMatchMode: 'contains', width: '20%' } - ]; - } - - ngOnInit() { - this.loadPartnerCustomers(); - } - - loadPartnerCustomers() { - this.loading = true; - - this.partnerCustSvc.getPartnerCustomers().subscribe({ - next: (customers) => { - this.partnerCustomers = customers; - this.loading = false; - }, - error: (error) => { - this.msgSvc.addFailedMsg(Labels.ERROR_LOADING_PARTNER_CUSTOMERS); - this.loading = false; - } - }); - } - - reloadCustomers() { - this.loadPartnerCustomers(); - } - - /** - * Get display name for package lookup key - * @param lookupKey - Package lookup key (e.g., 'ess_1', 'ent_2') - * @returns Descriptive name (e.g., 'Essential 1', 'Enterprise 2') - */ - getPackageName(lookupKey: string): string { - return getPackageDisplayName(lookupKey); - } - - ngOnDestroy() { - super.ngOnDestroy(); - } -} diff --git a/Development/client/src/app/partner-customers/partner-customer-mgt.component.ts b/Development/client/src/app/partner-customers/partner-customer-mgt.component.ts deleted file mode 100644 index 372a924..0000000 --- a/Development/client/src/app/partner-customers/partner-customer-mgt.component.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Component } from '@angular/core'; - -// A child routing component for Partner Customer Feature -@Component({ - template: ` - - ` -}) -export class PartnerCustomerMgtComponent { - constructor() { } -} diff --git a/Development/client/src/app/partner-customers/partner-customers-routing.module.ts b/Development/client/src/app/partner-customers/partner-customers-routing.module.ts deleted file mode 100644 index 386e110..0000000 --- a/Development/client/src/app/partner-customers/partner-customers-routing.module.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { NgModule } from '@angular/core'; -import { Routes, RouterModule } from '@angular/router'; - -import { PartnerCustomerListComponent } from './partner-customer-list/partner-customer-list.component'; -import { PartnerCustomerMgtComponent } from './partner-customer-mgt.component'; -import { AuthGuard } from '../domain/guards/auth.guard'; -import { RoleIds } from '../shared/global'; - -const routes: Routes = [ - { - path: '', - component: PartnerCustomerMgtComponent, - data: { - roles: [RoleIds.VENDOR], - }, - canActivate: [], - children: [ - { - path: '', - component: PartnerCustomerListComponent - } - ] - } -]; - -@NgModule({ - imports: [RouterModule.forChild(routes)], - exports: [RouterModule] -}) -export class PartnerCustomersRoutingModule { } diff --git a/Development/client/src/app/partner-customers/partner-customers.module.ts b/Development/client/src/app/partner-customers/partner-customers.module.ts deleted file mode 100644 index cdf38ea..0000000 --- a/Development/client/src/app/partner-customers/partner-customers.module.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common'; - -// PrimeNG imports -import { TableModule } from 'primeng/table'; -import { InputTextModule } from 'primeng/inputtext'; -import { ProgressSpinnerModule } from 'primeng/progressspinner'; -import { TooltipModule } from 'primeng/tooltip'; -import { ButtonModule } from 'primeng/button'; - -import { PartnerCustomersRoutingModule } from './partner-customers-routing.module'; -import { PartnerCustomerListComponent } from './partner-customer-list/partner-customer-list.component'; -import { PartnerCustomerMgtComponent } from './partner-customer-mgt.component'; - - -@NgModule({ - declarations: [PartnerCustomerListComponent, PartnerCustomerMgtComponent], - imports: [ - CommonModule, - PartnerCustomersRoutingModule, - // PrimeNG modules - TableModule, - InputTextModule, - ProgressSpinnerModule, - TooltipModule, - ButtonModule - ] -}) -export class PartnerCustomersModule { } diff --git a/Development/client/src/app/partner-customers/services/partner-customer.service.ts b/Development/client/src/app/partner-customers/services/partner-customer.service.ts deleted file mode 100644 index 9796bdd..0000000 --- a/Development/client/src/app/partner-customers/services/partner-customer.service.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Injectable } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; -import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; - -import { PartnerCustomer, PartnerCustomerApiResponse, transformPartnerCustomer } from '../models/partner-customer.model'; -import { AuthService } from '../../domain/services/auth.service'; - -@Injectable({ - providedIn: 'root' -}) -export class PartnerCustomerService { - private readonly apiUrl = '/partners'; - - constructor( - private readonly http: HttpClient, - private readonly authSvc: AuthService - ) { } - - /** - * Get partner customers for a specific partner - * @param partnerId Optional partner ID. If not provided, defaults to current authenticated user's ID - * @returns Observable array of partner customers - */ - getPartnerCustomers(partnerId?: string): Observable { - const targetPartnerId = partnerId || this.authSvc.user._id; - return this.http.get(`${this.apiUrl}/customers`, { - params: { partnerId: targetPartnerId } - }).pipe( - map(apiResponse => apiResponse.map(customer => transformPartnerCustomer(customer))) - ); - } - -} diff --git a/Development/client/src/app/partners/actions/partner.actions.ts b/Development/client/src/app/partners/actions/partner.actions.ts deleted file mode 100644 index b7be6a8..0000000 --- a/Development/client/src/app/partners/actions/partner.actions.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { createAction, props } from '@ngrx/store'; -import { Partner } from '../models/partner.model'; - -// Load Actions -export const loadPartners = createAction('[Partner] Load Partners'); -export const loadPartnersSuccess = createAction( - '[Partner] Load Partners Success', - props<{ partners: Partner[] }>() -); -export const loadPartnersFailure = createAction( - '[Partner] Load Partners Failure', - props<{ error: string }>() -); - -// Load Single Partner Actions -export const loadPartner = createAction( - '[Partner] Load Partner', - props<{ id: string }>() -); -export const loadPartnerSuccess = createAction( - '[Partner] Load Partner Success', - props<{ partner: Partner }>() -); -export const loadPartnerFailure = createAction( - '[Partner] Load Partner Failure', - props<{ error: string }>() -); - -// Create Actions -export const createPartner = createAction( - '[Partner] Create Partner', - props<{ partner: Partner }>() -); -export const createPartnerSuccess = createAction( - '[Partner] Create Partner Success', - props<{ partner: Partner }>() -); -export const createPartnerFailure = createAction( - '[Partner] Create Partner Failure', - props<{ error: string }>() -); - -// Update Actions -export const updatePartner = createAction( - '[Partner] Update Partner', - props<{ id: string; partner: Partner }>() -); -export const updatePartnerSuccess = createAction( - '[Partner] Update Partner Success', - props<{ partner: Partner }>() -); -export const updatePartnerFailure = createAction( - '[Partner] Update Partner Failure', - props<{ error: string }>() -); - -// Delete Actions -export const deletePartner = createAction( - '[Partner] Delete Partner', - props<{ id: string }>() -); -export const deletePartnerSuccess = createAction( - '[Partner] Delete Partner Success', - props<{ id: string }>() -); -export const deletePartnerFailure = createAction( - '[Partner] Delete Partner Failure', - props<{ error: string }>() -); - -// UI Actions -export const selectPartner = createAction( - '[Partner] Select Partner', - props<{ partner: Partner | null }>() -); - -export const clearPartnerError = createAction('[Partner] Clear Error'); -export const setPartnerLoading = createAction( - '[Partner] Set Loading', - props<{ loading: boolean }>() -); diff --git a/Development/client/src/app/partners/effects/partner.effects.ts b/Development/client/src/app/partners/effects/partner.effects.ts deleted file mode 100644 index cbc73fb..0000000 --- a/Development/client/src/app/partners/effects/partner.effects.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { Injectable } from '@angular/core'; -import { Router } from '@angular/router'; -import { Actions, createEffect, ofType } from '@ngrx/effects'; -import { of } from 'rxjs'; -import { map, mergeMap, catchError, tap, repeat } from 'rxjs/operators'; -import { MessageService } from 'primeng/api'; - -import { PartnerService } from '../services'; -import * as PartnerActions from '../actions/partner.actions'; - -@Injectable() -export class PartnerEffects { - - constructor( - private actions$: Actions, - private partnerService: PartnerService, - private messageService: MessageService, - private router: Router - ) { } - - loadPartners$ = createEffect(() => - this.actions$.pipe( - ofType(PartnerActions.loadPartners), - tap(() => console.log('PartnerEffects: loadPartners action received')), - mergeMap(() => - this.partnerService.getPartners().pipe( - tap(partners => console.log('PartnerEffects: service returned partners:', partners)), - map(partners => PartnerActions.loadPartnersSuccess({ partners })), - catchError(error => { - console.error('PartnerEffects: error loading partners:', error); - return of(PartnerActions.loadPartnersFailure({ error: error.message })); - }) - ) - ), - repeat() - ) - ); - - loadPartner$ = createEffect(() => - this.actions$.pipe( - ofType(PartnerActions.loadPartner), - mergeMap(action => - this.partnerService.getPartnerById(action.id).pipe( - map(partner => PartnerActions.loadPartnerSuccess({ partner })), - catchError(error => of(PartnerActions.loadPartnerFailure({ error: error.message }))) - ) - ), - repeat() - ) - ); - - createPartner$ = createEffect(() => - this.actions$.pipe( - ofType(PartnerActions.createPartner), - mergeMap(action => - this.partnerService.createPartner(action.partner).pipe( - map(partner => PartnerActions.createPartnerSuccess({ partner })), - catchError(error => of(PartnerActions.createPartnerFailure({ error: error.message }))) - ) - ), - repeat() - ) - ); - - updatePartner$ = createEffect(() => - this.actions$.pipe( - ofType(PartnerActions.updatePartner), - mergeMap(action => - this.partnerService.updatePartner(action.id, action.partner).pipe( - map(partner => PartnerActions.updatePartnerSuccess({ partner })), - catchError(error => of(PartnerActions.updatePartnerFailure({ error: error.message }))) - ) - ), - repeat() - ) - ); - - deletePartner$ = createEffect(() => - this.actions$.pipe( - ofType(PartnerActions.deletePartner), - mergeMap(action => - this.partnerService.deletePartner(action.id).pipe( - map(() => PartnerActions.deletePartnerSuccess({ id: action.id })), - catchError(error => of(PartnerActions.deletePartnerFailure({ error: error.message }))) - ) - ), - repeat() - ) - ); - - // Success Effects with Toast Messages - createPartnerSuccess$ = createEffect(() => - this.actions$.pipe( - ofType(PartnerActions.createPartnerSuccess), - tap(action => { - this.messageService.add({ - severity: 'success', - summary: 'Success', - detail: `Partner "${action.partner.name}" created successfully` - }); - // Navigate back to partner list after successful creation - this.router.navigate(['/partners']); - }) - ), - { dispatch: false } - ); - - updatePartnerSuccess$ = createEffect(() => - this.actions$.pipe( - ofType(PartnerActions.updatePartnerSuccess), - tap(action => { - this.messageService.add({ - severity: 'success', - summary: 'Success', - detail: `Partner "${action.partner.name}" updated successfully` - }); - // Navigate back to partner list after successful update - this.router.navigate(['/partners']); - }) - ), - { dispatch: false } - ); - - deletePartnerSuccess$ = createEffect(() => - this.actions$.pipe( - ofType(PartnerActions.deletePartnerSuccess), - tap(() => { - this.messageService.add({ - severity: 'success', - summary: 'Success', - detail: 'Partner deleted successfully' - }); - }) - ), - { dispatch: false } - ); - - // Error Effects with Toast Messages - partnerFailure$ = createEffect(() => - this.actions$.pipe( - ofType( - PartnerActions.loadPartnersFailure, - PartnerActions.loadPartnerFailure, - PartnerActions.createPartnerFailure, - PartnerActions.updatePartnerFailure, - PartnerActions.deletePartnerFailure - ), - tap(action => { - this.messageService.add({ - severity: 'error', - summary: 'Error', - detail: action.error || 'An error occurred' - }); - }) - ), - { dispatch: false } - ); -} diff --git a/Development/client/src/app/partners/models/partner.model.ts b/Development/client/src/app/partners/models/partner.model.ts deleted file mode 100644 index ec22d4b..0000000 --- a/Development/client/src/app/partners/models/partner.model.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { User } from '@app/accounts/models/user.model'; -import { RoleIds } from '@app/shared/global'; - -export interface Partner extends User { - // Partner-specific fields (extending User base fields) - partnerCode?: string; // e.g., "SATLOC", "AGIDRONEX" - unique identifier - - // User base fields are inherited: - // _id, username, password, name, email, phone, kind, active, createdAt, updatedAt, etc. -} - -export function createNewPartner(): Partner { - return { - _id: '0', // Required from User interface - name: '', - partnerCode: '', - email: '', - phone: '', - username: '', - kind: RoleIds.PARTNER, // Set to PARTNER role by default - active: true - }; -} - -// Mock data for development and testing -export const mockPartners: Partner[] = [ - { - _id: '1', - name: 'AgTech Solutions Inc.', - kind: RoleIds.PARTNER, - active: true, - createdAt: new Date('2024-01-15'), - updatedAt: new Date('2024-01-15') - }, - { - _id: '2', - name: 'FarmData Analytics', - kind: RoleIds.PARTNER, - active: true, - createdAt: new Date('2024-02-01'), - updatedAt: new Date('2024-02-15') - }, - { - _id: '3', - name: 'Crop Monitoring Systems', - kind: RoleIds.PARTNER, - active: false, - createdAt: new Date('2024-01-20'), - updatedAt: new Date('2024-03-01') - }, - { - _id: '4', - name: 'Irrigation Tech Corp', - kind: RoleIds.PARTNER, - active: true, - createdAt: new Date('2024-03-10'), - updatedAt: new Date('2024-03-10') - }, - { - _id: '5', - name: 'Soil Health Innovations', - kind: RoleIds.PARTNER, - active: true, - createdAt: new Date('2024-02-20'), - updatedAt: new Date('2024-03-05') - } -]; diff --git a/Development/client/src/app/partners/partner-edit/partner-edit.component.css b/Development/client/src/app/partners/partner-edit/partner-edit.component.css deleted file mode 100644 index ae8a4f3..0000000 --- a/Development/client/src/app/partners/partner-edit/partner-edit.component.css +++ /dev/null @@ -1,19 +0,0 @@ -/* Partner Edit Component Styles */ - -/* ============================================================================ - PARTNER CODE CONSTRAINT - INLINE ICON (Detached Mode) - ============================================================================ - Similar to Tail Number pattern in vehicle-edit. Icon appears beside input - field, message content renders below via *ngTemplateOutlet projection. - ========================================================================= */ - -.input-with-inline-constraint { - display: flex; - align-items: flex-start; - gap: 6px; -} - -.input-with-inline-constraint .inline-constraint { - margin-top: -2px; - /* Visual debugger recommendation - align with input center */ -} \ No newline at end of file diff --git a/Development/client/src/app/partners/partner-edit/partner-edit.component.html b/Development/client/src/app/partners/partner-edit/partner-edit.component.html deleted file mode 100644 index 22a5134..0000000 --- a/Development/client/src/app/partners/partner-edit/partner-edit.component.html +++ /dev/null @@ -1,107 +0,0 @@ -
-
-
-

Partner Information

- -
-
- -
- - - Partner name is required - Partner name must be at least 2 characters - Partner name cannot exceed 100 characters - - -
- - -
-
- - - Partner code is required - Partner code must be at least 2 characters - Partner code cannot exceed 20 characters - - - - - - -
- - -
- -
-
- - -
- - - Please enter a valid email address - - -
- - -
- - - - -
- - -
- -
- - Checking partner customer dependencies... -
- - - -
- - -
- -
- -
- - -
-
-
- -
-
-
\ No newline at end of file diff --git a/Development/client/src/app/partners/partner-edit/partner-edit.component.ts b/Development/client/src/app/partners/partner-edit/partner-edit.component.ts deleted file mode 100644 index 1579a57..0000000 --- a/Development/client/src/app/partners/partner-edit/partner-edit.component.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { Component, OnInit, OnDestroy, ViewChild } from '@angular/core'; -import { FormBuilder, FormGroup, Validators } from '@angular/forms'; -import { ActivatedRoute, Router } from '@angular/router'; -import { Store } from '@ngrx/store'; -import { Subject } from 'rxjs'; -import { takeUntil } from 'rxjs/operators'; - -import { Partner, createNewPartner } from '../models/partner.model'; -import { PartnerState } from '../reducers/partner.reducer'; -import * as PartnerActions from '../actions/partner.actions'; -import { globals, RoleIds, Labels } from '@app/shared/global'; -import { BaseComp } from '@app/shared/base/base.component'; -import { ConstraintMessageComponent } from '@app/shared/constraint-message/constraint-message.component'; -import { AccountEditorComponent } from '@app/shared/account-editor/account-editor.component'; -import { PartnerCustomerService } from '@app/partner-customers/services/partner-customer.service'; -import { AuthService } from '@app/domain/services/auth.service'; - -@Component({ - selector: 'app-partner-edit', - templateUrl: './partner-edit.component.html', - styleUrls: ['./partner-edit.component.css'] -}) -export class PartnerEditComponent extends BaseComp implements OnInit, OnDestroy { - readonly globals = globals; - readonly Labels = Labels; - - partnerForm: FormGroup; - - isEditMode = false; - partnerId: string | null = null; - selectedItem: Partner; - - // Partner customer constraint tracking - hasPartnerCustomers = false; - partnerCustomerCount = 0; - checkingPartnerCustomers = false; - - // ViewChild for detached constraint messages - @ViewChild('partnerCodeConstraint') partnerCodeConstraint: ConstraintMessageComponent; - @ViewChild('accountEditor') accountEditor: AccountEditorComponent; - - private destroy$ = new Subject(); - - private _partner: Partner; - get partner(): Partner { return this._partner; } - set partner(partner: Partner) { - this._partner = partner; - this.selectedItem = Object.assign({}, partner); // create a clone object to work on the editor - this.populateForm(partner); - } - - private _isNew: boolean; - get isNew(): boolean { - return this._isNew; - } - - // Computed property for account editor constraint - get shouldDisableActiveCheckbox(): boolean { - return !this.isNew && this.hasPartnerCustomers; - } - - // Computed property for partner code constraint - get shouldDisablePartnerCode(): boolean { - return !this.isNew && this.hasPartnerCustomers; - } - - // Computed property for constraint message - get activeCheckboxConstraintMessage(): string { - if (this.shouldDisableActiveCheckbox) { - const prefix = Labels.CANNOT_DEACTIVATE_PARTNER_PREFIX; - const suffix = Labels.CANNOT_DEACTIVATE_PARTNER_SUFFIX; - return `${prefix} ${this.partnerCustomerCount} ${suffix}`; - } - return ''; - } - - // Computed property for partner code constraint message - get partnerCodeConstraintMessage(): string { - if (this.shouldDisablePartnerCode) { - const prefix = Labels.CANNOT_CHANGE_PARTNER_CODE_PREFIX; - const suffix = Labels.CANNOT_CHANGE_PARTNER_CODE_SUFFIX; - return `${prefix} ${this.partnerCustomerCount} ${suffix}`; - } - return ''; - } - - constructor( - private fb: FormBuilder, - private route: ActivatedRoute, - protected router: Router, - protected store: Store<{ partner: PartnerState }>, - private partnerCustomerService: PartnerCustomerService, - protected authSvc: AuthService - ) { - super(); - this.partnerForm = this.createForm(); - } - - ngOnInit(): void { - // Get resolved partner data from route - const resolvedPartner = this.route.snapshot.data['partner'] as Partner | null; - this.partnerId = this.route.snapshot.paramMap.get('id'); - this.isEditMode = !!resolvedPartner; - this._isNew = !this.isEditMode; - - if (resolvedPartner) { - // Edit mode: use resolved partner data - this.partner = resolvedPartner; - // Check for partner customers to determine active checkbox constraints - this.checkPartnerCustomers(); - } else { - // New partner mode: create new partner - const newPartner = createNewPartner(); - this.partner = newPartner; - } - } - - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - } - - // ============================================================================ - // PARTNER CUSTOMER CONSTRAINT CHECKING - // ============================================================================ - - /** - * Check if partner has associated customers that would prevent deactivation - */ - private checkPartnerCustomers(): void { - if (this.isNew || !this.partnerId) { - return; // New partners don't have customers yet, or no partner ID available - } - - this.checkingPartnerCustomers = true; - this.partnerCustomerService.getPartnerCustomers(this.partnerId) - .pipe(takeUntil(this.destroy$)) - .subscribe({ - next: (customers) => { - this.partnerCustomerCount = customers?.length || 0; - this.hasPartnerCustomers = this.partnerCustomerCount > 0; - this.checkingPartnerCustomers = false; - - // Update form control states based on constraint - this.updateFormControlStates(); - }, - error: (error) => { - console.error('Error checking partner customers:', error); - // On error, assume no customers to avoid blocking legitimate deactivation - this.hasPartnerCustomers = false; - this.partnerCustomerCount = 0; - this.checkingPartnerCustomers = false; - this.updateFormControlStates(); - } - }); - } - - /** - * Update form control disabled states based on partner customer constraints - */ - private updateFormControlStates(): void { - const partnerCodeControl = this.partnerForm.get('partnerCode'); - - if (partnerCodeControl) { - if (this.shouldDisablePartnerCode) { - partnerCodeControl.disable(); - } else { - partnerCodeControl.enable(); - } - } - } - - private createForm(): FormGroup { - return this.fb.group({ - name: ['', [Validators.required, Validators.minLength(2), Validators.maxLength(100)]], - partnerCode: ['', [Validators.required, Validators.minLength(2), Validators.maxLength(20)]], - email: ['', [Validators.email]], - phone: [''], - account: [{ username: '', password: '', active: true }] // Single control for account editor - }); - } - - private populateForm(partner: Partner): void { - this.partnerForm.patchValue({ - name: partner.name, - partnerCode: partner.partnerCode || '', - email: (partner as any).email || '', - phone: (partner as any).phone || '', - account: { - username: (partner as any).username || '', - password: partner.password || '', - active: partner.active !== undefined ? partner.active : true - } - }); - } - - onSubmit(): void { - this.savePartner(); - } - - savePartner(): void { - if (this.partnerForm.valid) { - // Get raw value to include disabled controls - const formValue = this.partnerForm.getRawValue(); - const accountValue = formValue.account; - const partner: Partner = { - _id: this.isNew ? '0' : this.selectedItem._id, - name: formValue.name, - partnerCode: formValue.partnerCode, - email: formValue.email, - phone: formValue.phone, - username: accountValue.username, - password: accountValue.password, - active: accountValue.active, - kind: RoleIds.PARTNER, // UserTypes.PARTNER from backend constants - parent: this.authSvc.user.parent - - }; - - if (this.isEditMode && this.partnerId) { - this.store.dispatch(PartnerActions.updatePartner({ - id: this.partnerId, - partner - })); - } else { - this.store.dispatch(PartnerActions.createPartner({ partner })); - } - } else { - this.markFormGroupTouched(); - } - } - - onCancel(): void { - this.goBack(); - } - - goBack(): void { - this.router.navigate(['/partners']); - } - - private markFormGroupTouched(): void { - Object.keys(this.partnerForm.controls).forEach(key => { - const control = this.partnerForm.get(key); - if (control) { - control.markAsTouched(); - // For FormGroup controls (if any), mark all nested controls as touched - if (control instanceof FormGroup) { - Object.keys(control.controls).forEach(nestedKey => { - control.get(nestedKey)?.markAsTouched(); - }); - } - } - }); - } - - // Form validation helpers - isFieldInvalid(fieldName: string): boolean { - const field = this.partnerForm.get(fieldName); - return !!(field && field.invalid && (field.dirty || field.touched)); - } -} diff --git a/Development/client/src/app/partners/partner-list/partner-list.component.css b/Development/client/src/app/partners/partner-list/partner-list.component.css deleted file mode 100644 index e69de29..0000000 diff --git a/Development/client/src/app/partners/partner-list/partner-list.component.html b/Development/client/src/app/partners/partner-list/partner-list.component.html deleted file mode 100644 index 83cbf85..0000000 --- a/Development/client/src/app/partners/partner-list/partner-list.component.html +++ /dev/null @@ -1,54 +0,0 @@ -
-
-
- - -
-
- {{Labels.PARTNER_LIST_TITLE}} -
-
-
- - - - {{col.header}} - - - - - -
- - -
- - - - -
- - - - - {{col.header}} - {{rowData[col.field] | date:'shortDate'}} - - - - {{rowData[col.field]}} - - - - - - {{Labels.TOTAL_PARTNERS}} {{ state.totalRecords }} {{Labels.PARTNERS_COUNT_SUFFIX}} - -
-
- - -
-
-
-
\ No newline at end of file diff --git a/Development/client/src/app/partners/partner-list/partner-list.component.ts b/Development/client/src/app/partners/partner-list/partner-list.component.ts deleted file mode 100644 index 730c347..0000000 --- a/Development/client/src/app/partners/partner-list/partner-list.component.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { Component, OnInit, OnDestroy, ViewChild } from '@angular/core'; -import { Router } from '@angular/router'; -import { Store } from '@ngrx/store'; -import { Observable, Subject } from 'rxjs'; -import { SelectItem } from 'primeng/api'; -import { Table } from 'primeng/table'; - -import { Partner } from '../models/partner.model'; -import { PartnerState } from '../reducers/partner.reducer'; -import * as PartnerActions from '../actions/partner.actions'; -import { Labels } from '../../shared/global'; - -@Component({ - selector: 'app-partner-list', - templateUrl: './partner-list.component.html', - styleUrls: ['./partner-list.component.css'] -}) -export class PartnerListComponent implements OnInit, OnDestroy { - partners$: Observable; - loading$: Observable; - error$: Observable; - curPartner: Partner | null = null; - - @ViewChild("dt") dt: Table; - - private destroy$ = new Subject(); - - statuses: SelectItem[]; - cols: any[]; - - constructor( - private store: Store<{ partner: PartnerState }>, - private router: Router - ) { - this.partners$ = this.store.select(state => state.partner.partners); - this.loading$ = this.store.select(state => state.partner.loading); - this.error$ = this.store.select(state => state.partner.error); - - this.statuses = [ - { label: Labels.ALL_STATUS_FILTER, value: null }, - { label: Labels.ACTIVE_STATUS, value: true }, - { label: Labels.INACTIVE_STATUS, value: false } - ]; - - this.cols = [ - { field: "name", header: Labels.NAME_COLUMN_HEADER, filtered: true, filterMatchMode: 'contains' }, - { field: "partnerCode", header: Labels.PARTNER_CODE_COLUMN_HEADER, filtered: true, filterMatchMode: 'contains', width: '15%' }, - { field: "email", header: Labels.EMAIL_COLUMN_HEADER, filtered: true, filterMatchMode: 'contains' }, - { field: "phone", header: Labels.PHONE_COLUMN_HEADER, filtered: true, filterMatchMode: 'contains' }, - { field: "username", header: Labels.USERNAME_COLUMN_HEADER, filtered: true, filterMatchMode: 'contains' }, - { field: "active", header: Labels.ACTIVE_COLUMN_HEADER, width: '10%' }, - { field: "createdAt", header: Labels.CREATED_COLUMN_HEADER, width: '15%' } - ]; - } - - ngOnInit(): void { - this.loadPartners(); - } - - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - } - - get canEdit(): boolean { - return !!this.curPartner; - } - - get Labels() { - return Labels; - } - - onRowSelect(event: any): void { - this.curPartner = event.data; - } - - onRowUnselect(event: any): void { - this.curPartner = null; - } - - loadPartners(): void { - this.store.dispatch(PartnerActions.loadPartners()); - } - - createPartner(): void { - this.router.navigate(['/partners/new']); - } - - editPartner(partner: Partner): void { - this.router.navigate(['/partners', partner._id]); - } - - togglePartnerStatus(partner: Partner): void { - const updatedPartner = { ...partner, active: !partner.active }; - if (partner._id) { - this.store.dispatch(PartnerActions.updatePartner({ - id: partner._id, - partner: updatedPartner - })); - } - } - - getStatusSeverity(active: boolean): string { - return active ? 'success' : 'danger'; - } - - getStatusLabel(active: boolean): string { - return active ? Labels.ACTIVE_STATUS : Labels.INACTIVE_STATUS; - } - - formatDate(date: Date | string | undefined): string { - if (!date) return ''; - const d = new Date(date); - return d.toLocaleDateString(); - } - - refresh(): void { - this.loadPartners(); - } -} diff --git a/Development/client/src/app/partners/partner-mgt.component.ts b/Development/client/src/app/partners/partner-mgt.component.ts deleted file mode 100644 index d1ea19b..0000000 --- a/Development/client/src/app/partners/partner-mgt.component.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Component } from '@angular/core'; - -@Component({ - template: ` - - ` -}) -export class PartnerMgtComponent { } diff --git a/Development/client/src/app/partners/partners-routing.module.ts b/Development/client/src/app/partners/partners-routing.module.ts deleted file mode 100644 index 885f3d9..0000000 --- a/Development/client/src/app/partners/partners-routing.module.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { NgModule } from '@angular/core'; -import { RouterModule, Routes } from '@angular/router'; - -import { AuthGuard } from '../domain/guards/auth.guard'; -import { PartnerListComponent } from './partner-list/partner-list.component'; -import { PartnerEditComponent } from './partner-edit/partner-edit.component'; -import { PartnerMgtComponent } from './partner-mgt.component'; -import { PartnerResolver } from './resolvers/partner.resolver'; -import { RoleIds } from '../shared/global'; - -const routes: Routes = [ - { - path: '', - component: PartnerMgtComponent, - data: { - roles: [RoleIds.ADMIN] - }, - canActivate: [AuthGuard], - children: [ - { - path: '', - component: PartnerListComponent, - data: { - roles: [RoleIds.ADMIN] - } - }, - { - path: 'new', - component: PartnerEditComponent, - resolve: { partner: PartnerResolver }, - data: { - roles: [RoleIds.ADMIN] - } - }, - { - path: ':id', - component: PartnerEditComponent, - resolve: { partner: PartnerResolver }, - data: { - roles: [RoleIds.ADMIN] - } - } - ] - } -]; - -@NgModule({ - imports: [RouterModule.forChild(routes)], - exports: [RouterModule], - providers: [AuthGuard] -}) -export class PartnersRoutingModule { } diff --git a/Development/client/src/app/partners/partners.module.ts b/Development/client/src/app/partners/partners.module.ts deleted file mode 100644 index 96431d2..0000000 --- a/Development/client/src/app/partners/partners.module.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; - -// PrimeNG Components -import { DialogModule } from 'primeng/dialog'; -import { ConfirmDialogModule } from 'primeng/confirmdialog'; -import { ToastModule } from 'primeng/toast'; -import { MessagesModule } from 'primeng/messages'; -import { MessageModule } from 'primeng/message'; -import { CheckboxModule } from 'primeng/checkbox'; -import { InputSwitchModule } from 'primeng/inputswitch'; -import { ToolbarModule } from 'primeng/toolbar'; -import { TableModule } from 'primeng/table'; -import { ButtonModule } from 'primeng/button'; -import { InputTextModule } from 'primeng/inputtext'; -import { InputTextareaModule } from 'primeng/inputtextarea'; -import { DropdownModule } from 'primeng/dropdown'; -import { PanelModule } from 'primeng/panel'; -import { CardModule } from 'primeng/card'; -import { ProgressSpinnerModule } from 'primeng/progressspinner'; -import { TooltipModule } from 'primeng/tooltip'; - -// Shared Modules -import { AppSharedModule } from '../shared/app-shared.module'; - -// NgRx -import { StoreModule } from '@ngrx/store'; -import { EffectsModule } from '@ngrx/effects'; - -// Components -import { PartnerListComponent } from './partner-list/partner-list.component'; -import { PartnerEditComponent } from './partner-edit/partner-edit.component'; -import { PartnerMgtComponent } from './partner-mgt.component'; - -// Routing -import { PartnersRoutingModule } from './partners-routing.module'; - -// Store -import { partnerReducer } from './reducers/partner.reducer'; -import { PartnerEffects } from './effects/partner.effects'; - -export const FEATURE_KEY = 'partner'; - -@NgModule({ - declarations: [ - PartnerMgtComponent, - PartnerListComponent, - PartnerEditComponent - ], - imports: [ - DialogModule, - ConfirmDialogModule, - ToastModule, - MessagesModule, - MessageModule, - CheckboxModule, - InputSwitchModule, - ToolbarModule, - TableModule, - ButtonModule, - InputTextModule, - InputTextareaModule, - DropdownModule, - PanelModule, - CardModule, - ProgressSpinnerModule, - TooltipModule, - AppSharedModule, - - StoreModule.forFeature(FEATURE_KEY, partnerReducer), - EffectsModule.forFeature([PartnerEffects]), - PartnersRoutingModule - ], - providers: [], - schemas: [ - CUSTOM_ELEMENTS_SCHEMA - ] -}) -export class PartnersModule { } diff --git a/Development/client/src/app/partners/reducers/partner.reducer.ts b/Development/client/src/app/partners/reducers/partner.reducer.ts deleted file mode 100644 index dfe1bc0..0000000 --- a/Development/client/src/app/partners/reducers/partner.reducer.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { createReducer, on } from '@ngrx/store'; -import { Partner } from '../models/partner.model'; -import * as PartnerActions from '../actions/partner.actions'; - -export interface PartnerState { - partners: Partner[]; - selectedPartner: Partner | null; - loading: boolean; - error: string | null; -} - -export const initialState: PartnerState = { - partners: [], - selectedPartner: null, - loading: false, - error: null -}; - -export const partnerReducer = createReducer( - initialState, - - // Load Partners - on(PartnerActions.loadPartners, (state) => ({ - ...state, - loading: true, - error: null - })), - on(PartnerActions.loadPartnersSuccess, (state, { partners }) => ({ - ...state, - partners, - loading: false, - error: null - })), - on(PartnerActions.loadPartnersFailure, (state, { error }) => ({ - ...state, - loading: false, - error - })), - - // Load Single Partner - on(PartnerActions.loadPartner, (state) => ({ - ...state, - loading: true, - error: null - })), - on(PartnerActions.loadPartnerSuccess, (state, { partner }) => ({ - ...state, - selectedPartner: partner, - loading: false, - error: null - })), - on(PartnerActions.loadPartnerFailure, (state, { error }) => ({ - ...state, - loading: false, - error - })), - - // Create Partner - on(PartnerActions.createPartner, (state) => ({ - ...state, - loading: true, - error: null - })), - on(PartnerActions.createPartnerSuccess, (state, { partner }) => ({ - ...state, - partners: [...state.partners, partner], - selectedPartner: partner, - loading: false, - error: null - })), - on(PartnerActions.createPartnerFailure, (state, { error }) => ({ - ...state, - loading: false, - error - })), - - // Update Partner - on(PartnerActions.updatePartner, (state) => ({ - ...state, - loading: true, - error: null - })), - on(PartnerActions.updatePartnerSuccess, (state, { partner }) => ({ - ...state, - partners: state.partners.map(p => p._id === partner._id ? partner : p), - selectedPartner: partner, - loading: false, - error: null - })), - on(PartnerActions.updatePartnerFailure, (state, { error }) => ({ - ...state, - loading: false, - error - })), - - // Delete Partner - on(PartnerActions.deletePartner, (state) => ({ - ...state, - loading: true, - error: null - })), - on(PartnerActions.deletePartnerSuccess, (state, { id }) => ({ - ...state, - partners: state.partners.filter(p => p._id !== id), - selectedPartner: state.selectedPartner?._id === id ? null : state.selectedPartner, - loading: false, - error: null - })), - on(PartnerActions.deletePartnerFailure, (state, { error }) => ({ - ...state, - loading: false, - error - })), - - // UI Actions - on(PartnerActions.selectPartner, (state, { partner }) => ({ - ...state, - selectedPartner: partner - })), - on(PartnerActions.clearPartnerError, (state) => ({ - ...state, - error: null - })), - on(PartnerActions.setPartnerLoading, (state, { loading }) => ({ - ...state, - loading - })) -); diff --git a/Development/client/src/app/partners/resolvers/partner.resolver.ts b/Development/client/src/app/partners/resolvers/partner.resolver.ts deleted file mode 100644 index 236ebf2..0000000 --- a/Development/client/src/app/partners/resolvers/partner.resolver.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Injectable } from '@angular/core'; -import { Resolve, ActivatedRouteSnapshot, Router } from '@angular/router'; -import { Observable, of } from 'rxjs'; -import { catchError } from 'rxjs/operators'; - -import { Partner } from '../models/partner.model'; -import { PartnerService } from '../services'; - -@Injectable({ - providedIn: 'root' -}) -export class PartnerResolver implements Resolve { - constructor( - private partnerService: PartnerService, - private router: Router - ) { } - - resolve(route: ActivatedRouteSnapshot): Observable { - const id = route.paramMap.get('id'); - - // If no ID or 'new', return null for new partner creation - if (!id || id === 'new') { - return of(null); - } - - // Load existing partner from API - return this.partnerService.getPartnerById(id).pipe( - catchError((error) => { - console.error('Failed to load partner:', error); - // On error, redirect back to partner list - this.router.navigate(['/partners']); - return of(null); - }) - ); - } -} diff --git a/Development/client/src/app/partners/services/index.ts b/Development/client/src/app/partners/services/index.ts deleted file mode 100644 index f78ecb1..0000000 --- a/Development/client/src/app/partners/services/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './partner.service'; diff --git a/Development/client/src/app/partners/services/partner.service.ts b/Development/client/src/app/partners/services/partner.service.ts deleted file mode 100644 index 6847f46..0000000 --- a/Development/client/src/app/partners/services/partner.service.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { Injectable } from '@angular/core'; -import { HttpClient, HttpParams } from '@angular/common/http'; -import { Observable, of, forkJoin } from 'rxjs'; -import { switchMap, map, catchError } from 'rxjs/operators'; -import { - PartnerSystemUser, -} from '../../accounts/models/user.model'; -import { handlePartnerErr } from '../../profile/common'; - -// Partner Aircraft Response Interface -export interface PartnerAircraftResponse { - success: boolean; - partnerId: string; - customerId: string; - partnerCode?: string; - aircraft?: PartnerAircraft[]; - error?: string; -} - -export interface PartnerAircraft { - id: string; - tailNumber?: string; - name?: string; - model?: string; - type?: string; - [key: string]: any; // Allow for partner-specific fields -} - -@Injectable({ providedIn: 'root' }) -export class PartnerService { - private readonly apiURL = '/partners'; - - constructor(private readonly http: HttpClient) { } - - getPartners(): Observable { - return this.http.get(this.apiURL); - } - - createPartner(partner: any): Observable { - return this.http.post(this.apiURL, partner); - } - - getPartnerById(id: string | number): Observable { - return this.http.get(`${this.apiURL}/${id}`); - } - - updatePartner(id: string | number, partner: any): Observable { - return this.http.put(`${this.apiURL}/${id}`, partner); - } - - deletePartner(id: string | number): Observable { - return this.http.delete(`${this.apiURL}/${id}`); - } - - // NEW: Test partner system user authentication - testPartnerAuth(customerId: string, partnerId: string, username: string, password: string): Observable { - return this.http.post(`${this.apiURL}/systemUsers/testAuth`, { - customerId, - partnerId, - username, - password - }); - } - - // NEW: Partner System User CRUD operations - // Get system users for specific partner and customer (matches backend API) - getSystemUsers(partnerId: string, customerId: string): Observable { - const params = new HttpParams() - .set('partnerId', partnerId) - .set('customerId', customerId); - - return this.http.get(`${this.apiURL}/systemUsers`, { params }); - } - - // Get the current (first active) system user for a partner+customer pair. - // Uses r987 endpoint: GET /api/partners/systemUsers/current - // Always returns the active PSU — safe to use after a PSU rotation (old account disabled, new one created). - getCurrentSystemUser(partnerId: string, customerId: string): Observable { - const params = new HttpParams() - .set('partnerId', partnerId) - .set('customerId', customerId); - - return this.http.get(`${this.apiURL}/systemUsers/current`, { params }); - } - - // Gets all system users for a customer across all partners. - // Pass knownPartners to reuse an already-loaded partners list and skip the GET /api/partners call. - getSystemUsersForCustomer(customerId: string, knownPartners?: any[]): Observable { - const partners$ = knownPartners ? of(knownPartners) : this.getPartners(); - - return partners$.pipe( - switchMap(partners => { - if (!partners || partners.length === 0) { - return of([]); - } - - const systemUserRequests = partners.map(partner => - this.getSystemUsers(partner._id, customerId).pipe( - catchError(() => of([])) - ) - ); - - return forkJoin(systemUserRequests).pipe( - map((results: PartnerSystemUser[][]) => { - const flattened: PartnerSystemUser[] = []; - results.forEach(result => flattened.push(...result)); - return flattened; - }) - ); - }) - ); - } - - // Method to get all system users across all partners and customers - // Note: This is expensive as it requires multiple API calls - getAllSystemUsers(): Observable { - // This method would require getting all partners and all customers first - // For now, return empty array as the backend doesn't support this efficiently - console.warn('getAllSystemUsers() is not efficiently supported by current backend API'); - return of([]); - } - - createSystemUser(systemUser: any): Observable { - return this.http.post(`${this.apiURL}/systemUsers`, systemUser); - } - - getSystemUserById(id: string): Observable { - return this.http.get(`${this.apiURL}/systemUsers/${id}`); - } - - updateSystemUser(id: string, systemUser: any): Observable { - return this.http.put(`${this.apiURL}/systemUsers/${id}`, systemUser); - } - - deleteSystemUser(id: string): Observable { - return this.http.delete(`${this.apiURL}/systemUsers/${id}`); - } - - // NEW: Get aircraft list from partner system - // GET /api/partners/aircraft?partnerId=SATLOC&customerId= - // OR GET /api/partners/aircraft?partnerId=&customerId= - getPartnerAircraft(partnerId: string, customerId: string): Observable { - const params = new HttpParams() - .set('partnerId', partnerId) - .set('customerId', customerId); - - return this.http.get(`${this.apiURL}/aircraft`, { params }); - } - - /** - * Centralized partner authentication validation - * - * Retrieves system users for the partner and tests authentication with the first user's credentials. - * This method consolidates authentication logic previously duplicated across multiple components - * (account-edit, job-assignment, vehicle-list, vehicle-partner-integration). - * - * @param customerId - Customer ID to validate authentication for - * @param partnerId - Partner ID to validate authentication for - * @returns Promise resolving to object with isValid boolean and optional errorMessage string - * - * @example - * const result = await this.partnerService.validatePartnerAuthentication(customerId, partnerId); - * if (result.isValid) { - * // Authentication successful - * } else { - * console.error(result.errorMessage); - * } - */ - async validatePartnerAuthentication( - customerId: string, - partnerId: string - ): Promise<{ isValid: boolean; errorMessage?: string }> { - try { - // Step 1: Get the current active system user for this partner+customer. - // Uses GET /api/partners/systemUsers/current which filters active:true server-side, - // ensuring a PSU rotation (disable old, create new) is handled correctly. - const systemUser = await this.getCurrentSystemUser(partnerId, customerId).toPromise(); - - if (!systemUser) { - return { - isValid: false, - errorMessage: 'No active system user found for this partner' - }; - } - - // Step 2: Get credentials from the active system user - if (!systemUser.username || !systemUser.password) { - return { - isValid: false, - errorMessage: 'System user credentials are missing' - }; - } - - // Step 3: Test authentication with partner API - const authResult = await this.testPartnerAuth( - customerId, - partnerId, - systemUser.username, - systemUser.password - ).toPromise(); - - // Step 4: Check all possible success response formats - const isAuthenticated = this.isAuthenticationSuccessful(authResult); - - if (isAuthenticated) { - return { isValid: true }; - } else { - // Use centralized error handler for consistent error messages - const errorResult = handlePartnerErr(authResult); - return { - isValid: false, - errorMessage: errorResult.message - }; - } - - } catch (error) { - console.error('Error validating partner authentication:', error); - // Use centralized error handler for HTTP errors - const errorResult = handlePartnerErr(error); - return { - isValid: false, - errorMessage: errorResult.message - }; - } - } - - /** - * Check if authentication result indicates success - * - * Centralized method to check all possible success response formats from partner authentication. - * Server may return { ok: true }, { authSuccess: true }, or { success: true } depending on mode. - * - * @param authResult - Authentication result object from testPartnerAuth API - * @returns true if authentication was successful, false otherwise - * - * @example - * const result = await this.partnerService.testPartnerAuth(...).toPromise(); - * if (this.partnerService.isAuthenticationSuccessful(result)) { - * // Handle success - * } - */ - isAuthenticationSuccessful(authResult: any): boolean { - return authResult && ( - authResult.ok === true || - authResult.authSuccess === true || - authResult.success === true - ); - } -} diff --git a/Development/client/src/app/profile/actions/payment.action.ts b/Development/client/src/app/profile/actions/payment.action.ts deleted file mode 100644 index 6d64fee..0000000 --- a/Development/client/src/app/profile/actions/payment.action.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Payment, Status } from "@app/domain/models/subscription.model"; -import { Action } from "@ngrx/store"; - -export const FETCH = '[PAYMENT] Fetch payments'; -export class Fetch implements Action { - type: typeof FETCH = FETCH; - constructor(readonly payload: { - custId: string, - byTime?: string - }) { } -} - -export const FETCH_SUCCESS = '[PAYMENT] Fetch payments success'; -export class FetchSuccess implements Action { - type: typeof FETCH_SUCCESS = FETCH_SUCCESS; - constructor(readonly payload: {payments: Payment[] , curTime: string}) { } -} - -export const FETCH_FAILED = '[PAYMENT Fetch payments failed'; -export class FetchError implements Action { - type: typeof FETCH_FAILED = FETCH_FAILED; - constructor(readonly payload: Status) { } -} - -export type PaymentAction = - | Fetch - | FetchSuccess - | FetchError \ No newline at end of file diff --git a/Development/client/src/app/profile/actions/usage.actions.ts b/Development/client/src/app/profile/actions/usage.actions.ts deleted file mode 100644 index 4fa82c2..0000000 --- a/Development/client/src/app/profile/actions/usage.actions.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Status, UsageDetail } from "@app/domain/models/subscription.model"; -import { Action } from "@ngrx/store"; - -export const FETCH_USAGE = '[USAGE] Fetch usage'; -export class FetchUsage implements Action { - type: typeof FETCH_USAGE = FETCH_USAGE; - constructor(readonly payload: { - byPuid: string; - lookupKey?: string; - period?: { - fromTS: number; - toTS: number; - } - custId: string; - effectiveMaxAcres?: number | null; - }) { } -} - -export const FETCH_USAGE_SUCCESS = '[USAGE] Fetch usage success'; -export class FetchUsageSuccess implements Action { - type: typeof FETCH_USAGE_SUCCESS = FETCH_USAGE_SUCCESS; - constructor(readonly payload: UsageDetail) { } -} - -export const FETCH_USAGE_FAILED = '[USAGE] Fetch usage failed'; -export class FetchUsageFailed implements Action { - type: typeof FETCH_USAGE_FAILED = FETCH_USAGE_FAILED; - constructor(readonly payload: Status) { } -} - -export const RESET_USAGE = '[USAGE] Reset usage'; -export class ResetUsage implements Action { - type: typeof RESET_USAGE = RESET_USAGE; -} - -export type UsageAction = - | FetchUsage - | FetchUsageSuccess - | FetchUsageFailed - | ResetUsage diff --git a/Development/client/src/app/profile/billing-address-list/billing-address-list.component.css b/Development/client/src/app/profile/billing-address-list/billing-address-list.component.css deleted file mode 100644 index ce88909..0000000 --- a/Development/client/src/app/profile/billing-address-list/billing-address-list.component.css +++ /dev/null @@ -1,3 +0,0 @@ -*:focus { - outline: none; -} diff --git a/Development/client/src/app/profile/billing-address-list/billing-address-list.component.html b/Development/client/src/app/profile/billing-address-list/billing-address-list.component.html deleted file mode 100644 index 226498e..0000000 --- a/Development/client/src/app/profile/billing-address-list/billing-address-list.component.html +++ /dev/null @@ -1,85 +0,0 @@ -
-
-
-

Billing Addresses

-
-

Select a billing address

- -
- - - - - - {{error}} -
- -
- -
- -
-
-
-
-
- - -
-
Address
-
Name
-
City, State, Zip/Postal Code
-
-
- - -
-
-
-
-
{{address.line1}}
-
-
-
{{address.name}}
-
-
{{address.city}}, {{address.state}} {{address.postalCode}}
-
- - - - -
-
-
-
- - - - - - - - - - - - - Add Billing Address - - - Edit Billing Address - - - -
- - - - -
- {{error}} - - - - -
\ No newline at end of file diff --git a/Development/client/src/app/profile/billing-address-list/billing-address-list.component.ts b/Development/client/src/app/profile/billing-address-list/billing-address-list.component.ts deleted file mode 100644 index 6f84718..0000000 --- a/Development/client/src/app/profile/billing-address-list/billing-address-list.component.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { Component, OnInit } from '@angular/core'; -import { BaseComp } from '@app/shared/base/base.component'; -import { SUB, SubTexts } from '../common'; -import { ActivatedRoute } from '@angular/router'; -import { User } from '@app/accounts/models/user.model'; -import { UserService } from '@app/domain/services/user.service'; -import { SubscriptionService } from '@app/domain/services/subscription.service'; -import { catchError } from 'rxjs/operators'; -import { of } from 'rxjs'; -import { Address } from '@app/domain/models/subscription.model'; - -@Component({ - selector: 'billing-address-list', - templateUrl: './billing-address-list.component.html', - styleUrls: ['./billing-address-list.component.css'] -}) -export class BillingAddressListComponent extends BaseComp implements OnInit { - readonly SubTexts = SubTexts; - selectedAddress: Address; - displayAddressDialog: boolean; - currentAddress: Address; - isNewAddress: boolean; - canSubmit: boolean; - user: User; - error: string; - - constructor( - private readonly route: ActivatedRoute, - private readonly userSvc: UserService, - private readonly subSvc: SubscriptionService - ) { - super(); - } - - ngOnInit(): void { - this.user = this.route.snapshot.data['user']; - if (this.user && this.user.addresses && this.user.country) { - this.selectedAddress = this.user.addresses.find(address => address.isBilling); - } - } - - edit(address: Address) { - this.displayAddressDialog = true; - this.isNewAddress = false; - this.currentAddress = { ...address }; - } - - add() { - this.displayAddressDialog = true; - this.isNewAddress = true; - this.currentAddress = { - name: this.user.name, - line1: SubTexts.labelStreetAdr, - line2: '', - city: '', - postalCode: '', - state: '', - country: this.user.country, - isBilling: false - }; - } - - del(address: Address) { - this.confirmSvc.confirm({ - message: $localize`:@@addrDelConf:Are you sure you want to delete this address?`, - accept: () => { - this.user.addresses = this.user.addresses.filter(addr => addr !== address); - this.userSvc.saveUser(this.user) - .pipe( - catchError(() => { - this.error = $localize`:@@addrDelErr:There was an error deleting the address, Please try again later.`; - return of(null); - }) - ) - .subscribe((user) => { - if (user && user._id) { - this.user = user; - this.selectedAddress = this.user.addresses.find(addr => addr.isBilling); - } - }); - } - }); - } - - changeBilAdr(address: Address) { - if (!this.user?.addresses) return; - this.subSvc.updateBillingAddressSequence(this.user._id, address) - .pipe( - catchError((error) => { - this.error = $localize`:@@addrDelErr:There was an error updating the billing address, Please try again later.`; - return of(null); - }) - ) - .subscribe((result) => { - if (result && result.user) { - this.user = result.user; - this.selectedAddress = this.user.addresses.find(address => address.isBilling); - this.displayAddressDialog = false; - } - }); - } - - gotoMySubs() { - this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]); - } - - onStripeAddressChange(evt: { isValid: boolean, address: Address, name: string }) { - this.canSubmit = evt.isValid; - if (evt.isValid) { - const { postal_code, ...rest } = evt.address as any; - this.currentAddress = { ...this.currentAddress, ...rest, postalCode: postal_code, name: evt.name }; - this.error = ''; - } else { - this.error = $localize`:@@addrIncomplete:Please complete the address`; - } - } - - submit() { - if (!this.currentAddress) { - this.error = $localize`:@@addrIncomplete:Please complete the address.`; - return; - } - - const isBillingUpdate = this.currentAddress._id === this.selectedAddress?._id; - let saveUser$; - - if (this.isNewAddress) { - this.user.addresses.push(this.currentAddress); - } else { - const idx = this.user.addresses.findIndex(addr => addr._id === this.currentAddress._id); - if (idx > -1) { - this.user.addresses[idx] = this.currentAddress; - } - } - - if (isBillingUpdate) { - saveUser$ = this.subSvc.updateBillingAddressSequence(this.user._id, this.currentAddress); - } else { - saveUser$ = this.userSvc.saveUser(this.user); - } - - saveUser$ - .pipe( - catchError((error) => { - this.error = $localize`:@@addrSubmitErr:There was an error submitting the address, Please try again later.`; - return of(null); - }) - ) - .subscribe((result) => { - if (result && result.user && result.user._id) { - this.user = result.user; - this.selectedAddress = this.user.addresses.find(address => address.isBilling); - } else if (result && result._id) { - this.user = result; - this.selectedAddress = this.user.addresses.find(address => address.isBilling); - } - this.displayAddressDialog = false; - }); - } - - closeDialog() { - this.displayAddressDialog = false; - this.canSubmit = false; - this.currentAddress = null; - this.error = ''; - } - - trackByAddressId(index: number, address: Address): string { - return address._id; - } -} diff --git a/Development/client/src/app/profile/billing-address/billing-address.component.css b/Development/client/src/app/profile/billing-address/billing-address.component.css deleted file mode 100644 index 7b7a728..0000000 --- a/Development/client/src/app/profile/billing-address/billing-address.component.css +++ /dev/null @@ -1,14 +0,0 @@ -.cc-form { - display: flex; - align-items: center; -} - -.error { - font-weight: bold; - color: red; -} - -/* Minimum width for payment summary cards to prevent layout collapse */ -.card.in-card-pad { - min-width: 300px; -} \ No newline at end of file diff --git a/Development/client/src/app/profile/billing-address/billing-address.component.html b/Development/client/src/app/profile/billing-address/billing-address.component.html deleted file mode 100644 index d2c5c11..0000000 --- a/Development/client/src/app/profile/billing-address/billing-address.component.html +++ /dev/null @@ -1,51 +0,0 @@ - -
-
-
-

Billing Address

-
-
-
-
-
- -
-
- - - - - - - -
-
-
-
-
-
-
- - - - - - - - - - - - -
-
-
-
- -
-
-
-
-
\ No newline at end of file diff --git a/Development/client/src/app/profile/billing-address/billing-address.component.ts b/Development/client/src/app/profile/billing-address/billing-address.component.ts deleted file mode 100644 index b5c818c..0000000 --- a/Development/client/src/app/profile/billing-address/billing-address.component.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { AfterViewInit, Component, OnDestroy } from '@angular/core'; -import { FormBuilder, FormGroup, Validators } from '@angular/forms'; -import { StripeAddressElement, StripeElementLocale } from '@stripe/stripe-js'; -import { Subscription, of } from 'rxjs'; -import { catchError, map, switchMap, take } from 'rxjs/operators'; -import { environment } from '@environments/environment'; -import { Address, BillingInfoPackage, Status, SubscriptionIntent } from '@app/domain/models/subscription.model'; -import { SubscriptionService } from '@app/domain/services/subscription.service'; -import { LOCALE_ID, Inject } from '@angular/core'; -import { getSubIntentState, getSubIntentStatus } from '@app/reducers'; -import { ClearSubscriptionStatus, Compound, GotoMyServices, GotoServices, LoadStripe, SetSubscriptionIntentPrevStage, ShowUnpaidSubscription, StartCheckout } from '@app/actions/subscription.actions'; -import { ActivatedRoute } from '@angular/router'; -import { User } from '@app/accounts/models/user.model'; -import { SubAppErr, SUB, createSubStatus, SubTexts, SubStripe, Mode, hasVendorErr, STRIPE_BIL_ADDR_STYLE } from '../common'; -import { BaseComp } from '@app/shared/base/base.component'; -import { getSubscriptionStatus } from '@app/reducers'; - -const NAME = 'name'; -const ADDRESS = 'address'; -@Component({ - selector: 'billing-address', - templateUrl: './billing-address.component.html', - styleUrls: ['./billing-address.component.css'] -}) -export class BillingAddressComponent extends BaseComp implements OnDestroy, AfterViewInit { - readonly SubTexts = SubTexts; - readonly Mode = Mode; - - address: StripeAddressElement; - sub$: Subscription; - status: Status; - subStatus: Status; - form: FormGroup; - profileUser: User; - subIntentPkg: SubscriptionIntent; - hasUnResolvedSubs: boolean; - hasUnpaidSubs: boolean; - addrValid: boolean; - mode: Mode; - - vendorErr: boolean; - - constructor( - private readonly fb: FormBuilder, - private readonly subSvc: SubscriptionService, - private readonly route: ActivatedRoute, - @Inject(LOCALE_ID) private readonly stripeLocale: StripeElementLocale - ) { - super(); - this.profileUser = this.route.snapshot.data['user']; - this.form = this.fb.group({ - name: ['', Validators.required], - address: [{ - line1: '', - country: '' - }, Validators.required] - }); - } - - ngAfterViewInit(): void { - this.initSub$(); - } - - private initSub$() { - this.sub$ = this.store.select(getSubIntentStatus).pipe( - switchMap((status) => { - this.status = status; - if (hasVendorErr(this.status?.code)) { - this.vendorErr = true; - } - return this.store.select(getSubscriptionStatus); - }), - map((subStatus) => { - this.hasUnResolvedSubs = subStatus?.code === SubStripe.PAST_DUE - || subStatus?.code === SubStripe.OVERDUE - || !!subStatus; - - if (this.hasUnResolvedSubs) this.hasUnpaidSubs = subStatus?.code === SubStripe.UNPAID; - }), - catchError((err) => { - console.log(err); - this.status = createSubStatus(SubAppErr.BIL_ADDR_ERR); - return of(err); - }) - ).subscribe(); - - this.sub$.add( - this.store.select(getSubIntentState).pipe( - take(1), - switchMap((subIntent) => { - if (subIntent?.package) { - this.subIntentPkg = subIntent?.package; - this.mode = this.subIntentPkg.mode; - this.initializeBillingForm(subIntent.package); - } else { - return this.subSvc.createBillingInfoPackage(this.profileUser._id).pipe( - map((billingInfoPackage: BillingInfoPackage) => { - this.mode = Mode.UPDATE_BIL_ADR; - this.initializeBillingForm(billingInfoPackage); - }) - ); - } - return of(null); - }), - catchError((err) => { - console.log(err); - this.status = createSubStatus(SubAppErr.BIL_ADDR_ERR); - return of(err); - }) - ).subscribe() - ); - } - - private initializeBillingForm(pkg: BillingInfoPackage | SubscriptionIntent) { - const billingInfo = pkg?.billingInfo; - const address = this.buildAddressWithDefaults(billingInfo?.address); - this.updateFormValues(billingInfo?.name ?? this.profileUser.name, address); - this.loadStripe(billingInfo?.address?.country); - } - - private buildAddressWithDefaults(billingAddress?: any) { - return { - line1: SubTexts.labelStreetAdr, - country: this.profileUser.country, - city: '', - state: '', - postal_code: '', - ...billingAddress - }; - } - - private updateFormValues(name: string, address: any) { - this.form.controls[NAME].setValue(name); - this.form.controls[ADDRESS].setValue(address); - } - - private loadStripe(country: string = this.profileUser.country) { - try { - const elements = this.subSvc.stripe.elements({ appearance: STRIPE_BIL_ADDR_STYLE, locale: this.stripeLocale }); - - this.address = elements.create(ADDRESS, { - mode: "billing", - allowedCountries: [country], - autocomplete: { mode: "google_maps_api", apiKey: environment.stripeGapiKey }, - defaultValues: { name: this.form.controls[NAME].value, address: this.form.controls[ADDRESS].value } - }); - this.address.mount("#address-element"); - this.address.on('change', (evt) => { - this.addrValid = !!evt?.complete; - this.status = evt?.complete ? null : createSubStatus(SubAppErr.INC_ADDR_ERR); - this.form.controls[NAME].setValue(evt?.value?.name || ''); - this.form.controls[ADDRESS].setValue(evt?.value?.address || ''); - }); - } catch (err) { - console.log(err); - this.status = createSubStatus(SubAppErr.STRIPE_ERR); - } - } - - isFormValid(): boolean { - return this.form?.status === 'VALID' && this.addrValid && this.status?.code !== SubAppErr._500_ERR; - } - - isCompLoaded() { - return this.status?.code !== SubAppErr.BIL_ADDR_ERR - && this.status?.code !== SubAppErr.STRIPE_ERR - && !this.vendorErr; - } - - contToCheckout() { - try { - if (!this.subIntentPkg) return this.status = createSubStatus(SubAppErr.BIL_ADDR_ERR); - this.store.dispatch(new Compound([ - new SetSubscriptionIntentPrevStage(SUB.BILL_ADR), - new StartCheckout({ - billingInfo: { - applicatorId: this.profileUser._id, - name: this.form.controls[NAME].value, - address: { ...this.form.controls[ADDRESS].value, _id: this.subIntentPkg.billingInfo.address?._id } - }, - subIntentPkg: this.subIntentPkg - })])); - } catch (err) { - console.log(err); - this.status = createSubStatus(SubAppErr.BIL_ADDR_ERR); - } - } - - back() { - if (this.hasUnpaidSubs) { - return this.store.dispatch(new Compound([ - new SetSubscriptionIntentPrevStage(SUB.BILL_ADR), - new ShowUnpaidSubscription() - ])); - } else if (this.mode === Mode.UPDATE_BIL_ADR || this.mode === Mode.CONTINUE_TRIAL) { - return this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]); - } - this.store.dispatch(new Compound([new SetSubscriptionIntentPrevStage(SUB.BILL_ADR), new GotoServices()])); - } - - updateAddr() { - this.subSvc.updateBillingAddressSequence(this.profileUser._id, { - name: this.form.controls[NAME].value, - city: this.form.controls[ADDRESS].value.city, - line1: this.form.controls[ADDRESS].value.line1, - line2: this.form.controls[ADDRESS].value.line2, - postalCode: this.form.controls[ADDRESS].value.postal_code, - state: this.form.controls[ADDRESS].value.state, - country: this.form.controls[ADDRESS].value.country, - _id: this.profileUser.billAddress?._id || '' - }).pipe( - map(() => this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES])), - catchError((err) => { - console.log(err); - this.status = createSubStatus(SubAppErr.BIL_ADDR_ERR); - return of(err); - }) - ).subscribe(); - } - - gotoMySubs() { - this.store.dispatch(new Compound([new ClearSubscriptionStatus(), new GotoMyServices()])); - } - - ngOnDestroy(): void { - super.ngOnDestroy(); - } -} diff --git a/Development/client/src/app/profile/checkout-confirm/checkout-confirm.component.css b/Development/client/src/app/profile/checkout-confirm/checkout-confirm.component.css index efdcb35..e69de29 100644 --- a/Development/client/src/app/profile/checkout-confirm/checkout-confirm.component.css +++ b/Development/client/src/app/profile/checkout-confirm/checkout-confirm.component.css @@ -1,33 +0,0 @@ -/* ============================================================================ - * PROMO DISPLAY STYLES (WI-13) - * ============================================================================ */ - -/* Promo notice text in success message */ -::ng-deep .promo-notice { - color: #2E7D32; - font-weight: 500; - margin-top: 0.5em; -} - -/* Line item styling for subscription details */ -.line-item { - padding: 0.5em 0; - border-bottom: 1px solid #e8e8e8; -} - -.line-item:last-of-type { - border-bottom: none; -} - -/* Subscription details title */ -.title-one { - font-size: 1.2em; - font-weight: 600; - margin-bottom: 1em; - color: #212121; -} - -/* Minimum width for payment summary cards to prevent layout collapse */ -.card.in-card-pad { - min-width: 300px; -} \ No newline at end of file diff --git a/Development/client/src/app/profile/checkout-confirm/checkout-confirm.component.html b/Development/client/src/app/profile/checkout-confirm/checkout-confirm.component.html index f6b64c4..7497559 100644 --- a/Development/client/src/app/profile/checkout-confirm/checkout-confirm.component.html +++ b/Development/client/src/app/profile/checkout-confirm/checkout-confirm.component.html @@ -1,105 +1,26 @@ - -
-
-
-

Confirmation

- - -
-
-
- -
+
+
+
+

Confirmation

- -
- - - - - - - - - -
-
- - - - - - - - - - - - - -
- - - - - - - - - - - - -
- - -
-

Credit Card Information

-
-
-
Card number:
-
**** {{ card?.last4 }}
-
-
-
Card type:
-
{{ card?.brand | uppercase }}
-
-
-
Expiration date:
-
{{card?.exp_month}}/{{card?.exp_year}}
-
-
-
-
-
- - -
-
-
-
- +
+
+ check_circle +
+ +

Thank you. We have received your payment

+

Confirmation number:01133E

+

We’ve sent a confirmation to john.smith@gmail.com. If this is not your correct email address, please update your profile.

+
+
+ +
+
+ +
+ +
-
- \ No newline at end of file +
\ No newline at end of file diff --git a/Development/client/src/app/profile/checkout-confirm/checkout-confirm.component.ts b/Development/client/src/app/profile/checkout-confirm/checkout-confirm.component.ts index 5924a2b..8b4ede2 100644 --- a/Development/client/src/app/profile/checkout-confirm/checkout-confirm.component.ts +++ b/Development/client/src/app/profile/checkout-confirm/checkout-confirm.component.ts @@ -1,356 +1,15 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; -import { Addon, Card, Package, PaidAmount, Status, TrialItem, SubscriptionIntent } from '@app/domain/models/subscription.model'; -import { ActivePromo, ActivePromoService } from '@app/domain/services/active-promo.service'; -import { getSubIntentPkg } from '@app/reducers'; -import { of } from 'rxjs'; -import { UserModel } from '@app/auth/models/user.model'; -import { GotoAircraftList, UpdateSubscriptionStatus } from '@app/actions/subscription.actions' -import { switchMap, take, tap } from 'rxjs/operators'; -import { SubTexts, SubAppErr, createSubStatus, SUB, Mode, SubKeys, SUB_NAME } from '../common'; -import { BaseComp } from '@app/shared/base/base.component'; -import { AuthService } from '@app/domain/services/auth.service'; -import { SubscriptionService } from '@app/domain/services/subscription.service'; -import { getTotalVehicles } from '@app/entities/reducers'; -import { ActivatedRoute } from '@angular/router'; -import { User } from '@app/accounts/models/user.model'; -import { UserService } from '@app/domain/services/user.service'; -import { VehicleService } from '@app/domain/services/vehicle.service'; +import { Component, OnInit } from '@angular/core'; @Component({ selector: 'checkout-confirm', templateUrl: './checkout-confirm.component.html', styleUrls: ['./checkout-confirm.component.css'] }) -export class CheckoutConfirmComponent extends BaseComp implements OnInit, OnDestroy { - readonly SubTexts = SubTexts; - readonly Mode = Mode; - readonly SUB_NAME = SUB_NAME; +export class CheckoutConfirmComponent implements OnInit { - user: User; - mode: Mode; - selPkg: Package; - selAddons: Addon[]; - status: Status; - card: Card; - payment: PaidAmount; - trialItems: TrialItem[]; - displayDialog: boolean; - dialogMsg: string; - subIntentPkg: SubscriptionIntent; - - // ============================================================================ - // PROMO SUPPORT PROPERTIES (WI-13) - // ============================================================================ - activePromos: Map = new Map(); - packagePromo: ActivePromo | null = null; - addonPromos: Map = new Map(); - hasApplicablePromos = false; - totalPromoSavings: number = 0; - - constructor( - private readonly subSvc: SubscriptionService, - private readonly route: ActivatedRoute, - private userSvc: UserService, - private readonly activePromoSvc: ActivePromoService, - readonly authSvc: AuthService - ) { - super(); - } + constructor() { } ngOnInit(): void { - this.user = this.route.snapshot.data['user']; - this.loadActivePromos(); - this.initSub$(); } - initSub$() { - this.sub$ = this.store.select(getSubIntentPkg).pipe( - switchMap((subIntentPkg) => { - if (!subIntentPkg) { - this.status = createSubStatus(SubAppErr.CHKOUT_CONF_ERR); - return of(null); - } - - this.subIntentPkg = subIntentPkg; - this.mode = subIntentPkg?.mode; - this.payment = subIntentPkg?.amount; - this.card = subIntentPkg?.card; - this.selPkg = subIntentPkg?.selPkg; - this.selAddons = subIntentPkg?.selAddons; - this.trialItems = this.subSvc.createTrialItems(this.selPkg, this.selAddons); - - // Check for applicable promos (WI-13) - // Note: checkApplicablePromos() will be called again in loadActivePromos() - // when promos are loaded, and calculateTotalPromoSavings() will be called there - this.checkApplicablePromos(); - // Also calculate savings here in case promos already loaded - this.calculateTotalPromoSavings(); - const curTrkQuantity = subIntentPkg?.selAddons?.find((addon) => addon?.lookupKey === SubKeys.TRACKING)?.quantity; - const orgTrkQuantity = subIntentPkg?.orgAddons?.find((addon) => addon?.lookupKey === SubKeys.TRACKING)?.quantity; - - const isTrkQuantityModified = !!orgTrkQuantity && curTrkQuantity != orgTrkQuantity - const isPackageModified = !!subIntentPkg?.orgPkg && subIntentPkg?.orgPkg?.lookupKey !== subIntentPkg?.selPkg?.lookupKey; - const isNewPackage = !subIntentPkg?.orgPkg && !!subIntentPkg?.selPkg; - const isNewTrkQuantity = !orgTrkQuantity && !!curTrkQuantity; - return this.store.select(getTotalVehicles).pipe(switchMap((totalVehicles) => { - const shouldReview = totalVehicles > 0 && - (isPackageModified || isTrkQuantityModified || isNewPackage || isNewTrkQuantity); - - if (shouldReview) { - if (isPackageModified && isTrkQuantityModified) { - this.dialogMsg = this.subSvc.fmtSubMsg(SubTexts.textPkgTrkChng, this.selPkg.lookupKey, { pkgQuantity: this.selPkg.maxVehicles, trkQuantity: curTrkQuantity }); - } else if (isPackageModified) { - this.dialogMsg = this.subSvc.fmtSubMsg(SubTexts.textPkgChng, this.selPkg.lookupKey, { pkgQuantity: this.selPkg.maxVehicles }); - } else if (isTrkQuantityModified) { - this.dialogMsg = this.subSvc.fmtSubMsg(SubTexts.textTrkChng, SubKeys.TRACKING, { trkQuantity: curTrkQuantity }); - } else if (isNewPackage && isNewTrkQuantity) { - this.dialogMsg = this.subSvc.fmtSubMsg(SubTexts.textNewPkgTrk, this.selPkg.lookupKey, { pkgQuantity: this.selPkg.maxVehicles, trkQuantity: curTrkQuantity }); - } else if (isNewPackage) { - this.dialogMsg = this.subSvc.fmtSubMsg(SubTexts.textNewPkg, this.selPkg.lookupKey, { pkgQuantity: this.selPkg.maxVehicles }); - } else if (isNewTrkQuantity) { - this.dialogMsg = this.subSvc.fmtSubMsg(SubTexts.textNewTrk, SubKeys.TRACKING, { trkQuantity: curTrkQuantity }); - } - return this.userSvc.saveUser({ ...this.user, needReview: true }).pipe( - tap(() => { - this.store.dispatch(new UpdateSubscriptionStatus(createSubStatus(SUB.AC_REVIEW))); - this.displayDialog = true; - }) - ); - } - return of(null); - })); - }), - take(1) - ).subscribe({ - error: err => { - console.log(err); - this.status = createSubStatus(SubAppErr.CHKOUT_CONF_ERR); - } - }); - } - - isCompLoaded() { - return this.status?.code !== SubAppErr.CHKOUT_CONF_ERR; - } - - replaceUserName(user: UserModel, text: string) { - return text.replace('#user#', user?.username || ''); - } - - gotoMySubs() { - this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]); - } - - reviewAC() { - this.displayDialog = false; - // Navigate with reviewFlow query param to enable conditional navigation after update - this.router.navigate(['entities', 'aircraft'], { queryParams: { reviewFlow: 'true' } }); - } - - // ============================================================================ - // PROMO SUPPORT METHODS (WI-13) - // ============================================================================ - - /** - * Load active promos and build lookup maps - * Creates Map for exact and type-only matches - */ - private loadActivePromos(): void { - this.activePromoSvc.getActivePromos().subscribe(promos => { - this.activePromos = new Map(); - promos.forEach(p => { - // Exact match by priceKey - if (p.priceKey) { - this.activePromos.set(p.priceKey, p); - } else if (p.type) { - // Type-only promo (priceKey = null in backend) - this.activePromos.set(`${p.type}_all`, p); - } else { - // Universal promo (no type, no priceKey) - applies to EVERYTHING - this.activePromos.set('package_all', p); - this.activePromos.set('addon_all', p); - } - }); - - // Try to check promos now (will only process if selPkg/selAddons are also ready) - this.checkApplicablePromos(); - // Calculate savings after promos are checked - this.calculateTotalPromoSavings(); - }); - } - - /** - * Check which items have applicable promos - * CRITICAL: Only applies promos to NEW subscriptions (no existing subscription of same type) - * NOTE: Can be called multiple times safely - will only process if both activePromos and selPkg are ready - */ - private checkApplicablePromos(): void { - // Guard: Need activePromos and at least one item (package OR addon) to be ready - if (!this.activePromos || this.activePromos.size === 0) { - return; - } - - // Only return early if BOTH package AND addons are missing - if (!this.selPkg && (!this.selAddons || this.selAddons.length === 0)) { - return; - } - - // Reset promo state - this.packagePromo = null; - this.addonPromos = new Map(); - - // Check package promo - if (this.selPkg?.lookupKey) { - this.packagePromo = this.getPromoForLookupKey(this.selPkg.lookupKey, 'package'); - } - - // Check addon promos - if (this.selAddons && this.selAddons.length > 0) { - this.selAddons.forEach(addon => { - if (addon.lookupKey) { - const promo = this.getPromoForLookupKey(addon.lookupKey, 'addon'); - if (promo) { - this.addonPromos.set(addon.lookupKey, promo); - } - } - }); - } - - // Set flag if any promos exist - this.hasApplicablePromos = !!this.packagePromo || this.addonPromos.size > 0; - } - - /** - * Get promo for a specific lookup key - * Checks if item is eligible (new subscription) and returns matching promo - * CRITICAL: Only applies promos to NEW subscriptions - * @param lookupKey Package or addon lookup key (e.g., 'ess_3', 'addon_1') - * @param type Item type ('package' or 'addon') - * @returns ActivePromo if exists and item is new subscription, null otherwise - */ - private getPromoForLookupKey(lookupKey: string, type: 'package' | 'addon'): ActivePromo | null { - if (!lookupKey) return null; - - // Check if user has existing subscription of this type - const userSubs = this.authSvc.user?.membership?.subscriptions || []; - - if (type === 'package') { - // For packages: Check if user has ANY active (non-trial) package subscription - // Trial subscriptions should still be eligible for promos - const hasActivePackageSubscription = userSubs.some(sub => - sub.type === 'package' && sub.status !== 'trialing' - ); - - // Only show promo if user has NO active package subscriptions (new subscription or trial) - if (hasActivePackageSubscription) { - return null; - } - } - // For addons: If addon is in checkout flow, backend already validated user doesn't have it - // No additional check needed (backend filtering ensures this) - // This matches checkout.component.ts logic (lines 502-507) - - // Priority 1: Exact match by priceKey - const exactMatch = this.activePromos.get(lookupKey); - if (exactMatch) return exactMatch; - - // Priority 2: Type-only match (priceKey = null in backend = applies to all of type) - const typeOnlyPromo = this.activePromos.get(`${type}_all`); - if (typeOnlyPromo) return typeOnlyPromo; - - return null; - } - - - /** - * Get promo savings from Redux state (calculated in checkout component). - * This ensures consistent pricing across checkout, checkout-review, and checkout-confirm. - */ - get promoSavings(): number { - return this.subIntentPkg?.promoSavings || 0; - } - - /** - * Get charge date from trial items for display in charge date banner - * Returns formatted date string in user's locale (e.g., "December 24, 2025") - */ - getTrialChargeDate(): string { - if (!this.trialItems || this.trialItems.length === 0) return ''; - - const firstItem = this.trialItems[0]; - if (!firstItem.trialEnd) return ''; - - const chargeDate = new Date(firstItem.trialEnd * 1000); - return chargeDate.toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric' - }); - } - - /** - * Convert promo properties to Map for payment-info component - * payment-info expects Map - */ - getPromosMap(): Map { - const promosMap = new Map(); - - if (this.packagePromo && this.selPkg?.lookupKey) { - promosMap.set(this.selPkg.lookupKey, this.packagePromo); - } - - if (this.addonPromos && this.addonPromos.size > 0) { - this.addonPromos.forEach((promo, lookupKey) => { - promosMap.set(lookupKey, promo); - }); - } - - return promosMap; - } - - /** - * Calculate total promo savings for trial items - * Uses same logic as checkout component - */ - private calculateTotalPromoSavings(): void { - if (!this.trialItems || this.trialItems.length === 0) { - this.totalPromoSavings = 0; - return; - } - - const promosMap = this.getPromosMap(); - this.totalPromoSavings = this.subSvc.calculatePromoSavings( - this.trialItems, - promosMap - ); - } - - /** - * Get list of applicable promos for template display - * Returns array of {item, promo} objects - */ - getApplicablePromos(): Array<{ item: Package | Addon, promo: ActivePromo }> { - const promoList: Array<{ item: Package | Addon, promo: ActivePromo }> = []; - - // Add package promo if exists - if (this.packagePromo && this.selPkg) { - promoList.push({ item: this.selPkg, promo: this.packagePromo }); - } - - // Add addon promos if exist - if (this.selAddons && this.selAddons.length > 0) { - this.selAddons.forEach(addon => { - const promo = this.addonPromos.get(addon.lookupKey); - if (promo) { - promoList.push({ item: addon, promo }); - } - }); - } - - return promoList; - } - - ngOnDestroy(): void { - super.ngOnDestroy(); - } } diff --git a/Development/client/src/app/profile/checkout-review/checkout-review.component.css b/Development/client/src/app/profile/checkout-review/checkout-review.component.css index c57651e..6eab46c 100644 --- a/Development/client/src/app/profile/checkout-review/checkout-review.component.css +++ b/Development/client/src/app/profile/checkout-review/checkout-review.component.css @@ -1,21 +1,7 @@ -/* Promo Banner Styling */ -.promo-banner { - background: linear-gradient(135deg, #E8F5E9 0%, #F1F8E9 100%); - border-left: 4px solid #4CAF50; +.line-item { + padding: 0; } -.promo-title { - color: #2E7D32; - font-size: 1.2em; - font-weight: 600; - margin: 0 0 0.5em 0; +.line-item div:first-child { + font-weight : bold; } - -.promo-item { - margin: 0.5em 0; -} - -/* Minimum width for payment summary cards to prevent layout collapse */ -.card.in-card-pad { - min-width: 300px; -} \ No newline at end of file diff --git a/Development/client/src/app/profile/checkout-review/checkout-review.component.html b/Development/client/src/app/profile/checkout-review/checkout-review.component.html index cfe78bd..d746a72 100644 --- a/Development/client/src/app/profile/checkout-review/checkout-review.component.html +++ b/Development/client/src/app/profile/checkout-review/checkout-review.component.html @@ -1,212 +1,27 @@ - -
-
-
-

Review and Submit

+
+
+
+

Review and Submit

- - - - - -
- -
-
- -
-
- -
-
-
- -
- -
-
- -
-
- -
-
-
+
+
+ check_circle +
+ +

Please review the information below and select Submit to complete your payment.

+ Use the Edit button to make changes.
- - -
- -
-
- -
-
- -
-
-
- - - -
- -
-
- -
-
- -
-
-
-
- - - -
- -
-
- -
-
- -
-
-
-
- - - -
- -
-
- -
-
- -
-
-
- -
- -
-
- -
-
- -
-
-
-
- - -
- -
-
- -
-
- -
-
-
- - - - - - - - - - - - - - - - - - -
-
- -
- -
-
- -
-
-
- +
-
-
- - -
-
-
-
- +
+ +
+
+
- - - -
- -
-
- - -
- -
-
\ No newline at end of file +
\ No newline at end of file diff --git a/Development/client/src/app/profile/checkout-review/checkout-review.component.ts b/Development/client/src/app/profile/checkout-review/checkout-review.component.ts index f724bd9..260e136 100644 --- a/Development/client/src/app/profile/checkout-review/checkout-review.component.ts +++ b/Development/client/src/app/profile/checkout-review/checkout-review.component.ts @@ -1,433 +1,15 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; -import { map, switchMap, take, filter } from 'rxjs/operators'; -import { SubscriptionIntent, Status, Card, PastDue, Unpaid, Incomplete, LatestInvoice, Unresolved, PaidAmount } from '@app/domain/models/subscription.model'; -import { getRefreshSubIntent, getSubIntentState } from '@app/reducers'; -import { ClearSubscriptionStatus, Confirm, UpdateSubscription, GotoCheckout, PayUnpaidSubscription, RefeshSubscriptionIntent, SetSubscriptionIntentPrevStage, UpdatePastDue, UpdateIncomplete, UpdateUnpaid, GotoMyServices, Compound } from '@app/actions/subscription.actions'; -import { Utils } from '@app/shared/utils'; -import { SubTexts, SubAppErr, SUB, SubStripe, createSubStatus, Mode, hasVendorErr } from '../common'; -import { BaseComp } from '@app/shared/base/base.component'; -import { getIncomplete, getPastDue, getSubscriptionStatus, getUnpaid } from '@app/reducers'; -import { ActivePromo, ActivePromoService } from '@app/domain/services/active-promo.service'; +import { Component, OnInit } from '@angular/core'; @Component({ selector: 'checkout-review', templateUrl: './checkout-review.component.html', styleUrls: ['./checkout-review.component.css'] }) -export class CheckoutReviewComponent extends BaseComp implements OnInit, OnDestroy { - readonly SUB = SUB; - readonly SubStripe = SubStripe; - readonly SubAppErr = SubAppErr; - readonly SubTexts = SubTexts; - readonly Mode = Mode; +export class CheckoutReviewComponent implements OnInit { - subIntentPkg: SubscriptionIntent; - status: Status; - stage: string; - pastDue: PastDue; - incomplete: Incomplete; - unpaid: Unpaid - card: Card; - payment: PaidAmount; - hasPrevInvoices: boolean; - - vendorErr: boolean; - - // Processing state flag to prevent duplicate submissions during 3DS - isProcessing: boolean = false; - - // Promo support - activePromos: Map = new Map(); - packagePromo: ActivePromo | null = null; - addonPromos: Map = new Map(); - hasApplicablePromos = false; - - constructor( - private activePromoSvc: ActivePromoService - ) { - super(); - } + constructor() { } ngOnInit(): void { - this.initSub$(); - this.loadActivePromos(); - this.hasPrevInvoices = this.authSvc.hasSubsWithStatus(SubStripe.INCOMPLETE) || this.authSvc.hasSubsWithStatus(SubStripe.PAST_DUE) || this.authSvc.hasSubsWithStatus(SubStripe.OVERDUE); - if (this.hasPrevInvoices) { - this.refreshPrevSubIntent(); - } else { - this.initNewSubIntent(); - } } - private initSub$() { - this.sub$ = this.store.select(getSubscriptionStatus).subscribe({ - next: status => { - this.status = status; - if (hasVendorErr(this.status?.code)) { - this.vendorErr = true; - } - }, - error: err => { - console.error('Subscription status error:', err); - this.status = createSubStatus(SubAppErr.CHKOUT_REV_ERR); - } - }); - - this.sub$.add(this.store.select(getPastDue).pipe( - switchMap((pastDue) => { - this.pastDue = pastDue; - return this.store.select(getIncomplete); - }), - switchMap((incomplete) => { - const prevIncomplete = this.incomplete; - this.incomplete = incomplete; - - // Reset processing flag when requiresAction detected (waiting for 3DS, no longer processing) - if (incomplete?.requiresAction && incomplete?.invoices?.length > 0) { - this.isProcessing = false; - } - - // Auto-trigger 3DS popup when incomplete state changes with requiresAction - // This handles the direct subscription pattern (r942) where backend returns - // subscription with requires_action status after clicking Submit - if (incomplete?.requiresAction && - incomplete?.invoices?.length > 0 && - incomplete !== prevIncomplete) { - // Delay to ensure state is fully updated - setTimeout(() => this.resolveIncomplete(), 100); - } - - return this.store.select(getUnpaid); - }), - map((unpaid) => { - this.unpaid = unpaid; - }) - ).subscribe({ - error: err => { - console.log(err); - this.status = createSubStatus(SubAppErr.CHKOUT_REV_ERR); - } - })); - } - - private initOpenSubPmt(subIntent) { - const pkgReady = !!subIntent?.package; - if (pkgReady) { - this.subIntentPkg = subIntent.package; - this.payment = this.subIntentPkg.amount; - this.card = this.subIntentPkg.card; - } - } - - private refreshPrevSubIntent() { - this.sub$.add(this.store.select(getRefreshSubIntent).pipe( - take(1), - switchMap((refreshPkg) => { - this.store.dispatch(new RefeshSubscriptionIntent(refreshPkg)); - return this.store.select(getSubIntentState); - }), - map((subIntent) => { - this.initOpenSubPmt(subIntent); - }) - ).subscribe({ - error: err => { - console.log(err); - this.status = createSubStatus(SubAppErr.CHKOUT_REV_ERR); - } - })); - } - - private initNewSubIntent() { - this.sub$.add(this.store.select(getSubIntentState).pipe( - map((subIntent) => { - if (!subIntent?.package) return this.status = createSubStatus(SubAppErr.CHKOUT_REV_ERR); - this.stage = subIntent?.stage; - this.subIntentPkg = subIntent?.package; - this.payment = this.subIntentPkg?.amount; - this.card = this.subIntentPkg?.card; - this.checkApplicablePromos(); - }) - ).subscribe({ - error: err => { - console.log(err); - this.status = createSubStatus(SubAppErr.CHKOUT_REV_ERR); - } - })); - } - - isCompLoaded() { - return this.status?.code !== SubAppErr.CHKOUT_REV_ERR - && !this.vendorErr; - } - - isFirstPastDueRetry() { - return this.pastDue?.numOfRetries === 0; - } - - isPastDueHasRetried() { - return this.pastDue?.numOfRetries > 0; - } - - isFirstPayUnpaid() { - return this.unpaid?.numOfRetries === 0; - } - - isFailPayUnpaid() { - return this.unpaid?.numOfRetries > 0; - } - - isIncompleteReqAction() { - return this.incomplete?.requiresAction; - } - - isIncompleteReqPM() { - return this.incomplete?.requiresPM; - } - - isFailIncompleteRetried() { - return this.incomplete?.numOfRetries > 0; - } - - submit() { - try { - // Check if already processing to prevent duplicate submissions - if (this.isProcessing) { - console.warn('⚠️ Submit already in progress, ignoring duplicate click'); - return; - } - - // Set processing flag immediately to disable button - this.isProcessing = true; - - this.store.dispatch(new UpdateSubscription({ - stage: this.stage, card: this.subIntentPkg?.card, - pmId: this.subIntentPkg?.card?.pmId, defaultPM: this.subIntentPkg?.card?.defaultPM, - package: this.subIntentPkg?.selPkg?.lookupKey, - addons: this.subIntentPkg?.selAddons?.map((addon) => ({ price: addon.lookupKey, quantity: addon.quantity })) || [], - prorateTS: this.subIntentPkg?.prorateTS, applicatorId: this.subIntentPkg?.applicatorId, - coupon: this.subIntentPkg?.coupons?.[0]?.id - })); - } catch (err) { - console.log(err); - this.status = createSubStatus(SubAppErr.CHKOUT_REV_ERR); - // Reset flag on error - this.isProcessing = false; - } - } - - resolvePastDue() { - try { - // Check if already processing to prevent duplicate submissions - if (this.isProcessing) { - console.warn('⚠️ ResolvePastDue already in progress, ignoring duplicate click'); - return; - } - - if (Utils.isEmptyArray(this.pastDue?.invoices)) return this.status = createSubStatus(SubAppErr.CHKOUT_REV_ERR); - - // Set processing flag immediately - this.isProcessing = true; - - const openInvoices = this.pastDue.invoices?.filter((invoice) => invoice?.status === SubStripe.OPEN) || []; - this.confirmPayment(openInvoices, { type: SubStripe.PAST_DUE, numOfRetries: this.pastDue.numOfRetries, invoices: this.pastDue.invoices }); - } catch (err) { - console.log(err); - this.status = createSubStatus(SubAppErr.CHKOUT_REV_ERR); - // Reset flag on error - this.isProcessing = false; - } - } - - resolveIncomplete() { - try { - // Check if already processing to prevent duplicate submissions - if (this.isProcessing) { - console.warn('⚠️ ResolveIncomplete already in progress, ignoring duplicate click'); - return; - } - - if (Utils.isEmptyArray(this.incomplete?.invoices)) return this.status = createSubStatus(SubAppErr.CHKOUT_REV_ERR); - - // Set processing flag immediately - this.isProcessing = true; - - this.store.dispatch(new UpdateIncomplete({ invoices: this.incomplete.invoices, requiresAction: false, requiresPM: false, numOfRetries: 0 })); - const openInvoices = this.incomplete.invoices?.filter((invoice) => invoice?.status === SubStripe.OPEN) || []; - this.confirmPayment(openInvoices, { type: SubStripe.INCOMPLETE, reason: this.status?.code, invoices: this.incomplete.invoices, numOfRetries: this.incomplete.numOfRetries, }); - } catch (err) { - console.log(err); - this.status = createSubStatus(SubAppErr.CHKOUT_REV_ERR); - // Reset flag on error - this.isProcessing = false; - } - } - - confirmPayment(openInvoices: LatestInvoice[], unresolved: Unresolved) { - try { - if (Utils.isEmptyArray(openInvoices) || !unresolved) return this.status = createSubStatus(SubAppErr.CHKOUT_REV_ERR); - const stripePkgs = openInvoices?.map((invoice) => ({ clientSecret: invoice?.payment_intent?.client_secret, pmId: this.subIntentPkg?.card?.pmId ? this.subIntentPkg.card.pmId : invoice?.payment_intent?.last_payment_error ? invoice.payment_intent.last_payment_error.payment_method?.id : invoice?.payment_intent?.payment_method })); - const subIds = openInvoices?.map((invoice) => invoice.subscription) || []; - this.store.dispatch(new Confirm({ custId: this.subIntentPkg?.custId, stripePkgs, subIds, unresolved, applicatorId: this.subIntentPkg?.applicatorId, stage: SUB.CHKOUT_REV })); - } catch (err) { - console.log(err); - this.status = createSubStatus(SubAppErr.CHKOUT_REV_ERR); - } - } - - resolveUnpaid() { - try { - // Check if already processing to prevent duplicate submissions - if (this.isProcessing) { - console.warn('⚠️ ResolveUnpaid already in progress, ignoring duplicate click'); - return; - } - - if (Utils.isEmptyArray(this.unpaid?.invoices)) return this.status = createSubStatus(SubAppErr.CHKOUT_REV_ERR); - - // Set processing flag immediately - this.isProcessing = true; - - this.store.dispatch(new PayUnpaidSubscription({ pmId: this.subIntentPkg?.card?.pmId, invIds: this.unpaid.invoices?.map((invoice) => invoice.id) || [], unpaid: this.unpaid, card: this.subIntentPkg?.card, custId: this.subIntentPkg?.custId, applicatorId: this.subIntentPkg?.applicatorId })); - } catch (err) { - console.log(err); - this.status = createSubStatus(SubAppErr.CHKOUT_REV_ERR); - // Reset flag on error - this.isProcessing = false; - } - } - - editCheckout() { - try { - // Reset processing flag when going back to edit - this.isProcessing = false; - - const handlePastDue = () => this.store.dispatch(new UpdatePastDue({ invoices: this.pastDue?.invoices, numOfRetries: 0 })); - const handleIncomplete = () => this.store.dispatch(new UpdateIncomplete({ invoices: this.incomplete?.invoices, requiresAction: false, requiresPM: false, numOfRetries: 0 })); - const handleUnpaid = () => this.store.dispatch(new UpdateUnpaid({ invoices: this.unpaid?.invoices, numOfRetries: 0 })); - const caseMap = { - [SubStripe.PAST_DUE]: handlePastDue, - [SubStripe.UNPAID]: handleUnpaid, - [SubStripe.REQUIRE_ACTION]: handleIncomplete, - [SubStripe.REQUIRE_PAYMENT_METHOD]: handleIncomplete, - [SubStripe.CARD_DECLINED]: () => this.store.dispatch(new ClearSubscriptionStatus()), - 'default': () => { } - } - caseMap[this.status?.code || 'default'](); - this.store.dispatch(new Compound([new SetSubscriptionIntentPrevStage(SUB.CHKOUT_REV), new GotoCheckout()])); - } catch (err) { - console.log(err); - this.status = createSubStatus(SubAppErr.CHKOUT_REV_ERR); - } - } - - back() { - this.store.dispatch(new Compound([new ClearSubscriptionStatus(), new SetSubscriptionIntentPrevStage(SUB.CHKOUT_REV), new GotoCheckout()])); - } - - gotoMySubs() { - this.store.dispatch(new Compound([new ClearSubscriptionStatus(), new GotoMyServices()])); - } - - isNotReady() { - return this.status?.code === SUB.UPDATE_DEF_PM || this.isProcessing; - } - - // ============================================================================ - // PROMO SUPPORT METHODS - // ============================================================================ - - private loadActivePromos(): void { - this.activePromoSvc.getActivePromos().subscribe(promos => { - this.activePromos = new Map(); - promos.forEach(p => { - if (p.priceKey) { - this.activePromos.set(p.priceKey, p); - } else if (p.type) { - this.activePromos.set(`${p.type}_all`, p); - } else { - // Universal promo (no type, no priceKey) - applies to EVERYTHING - this.activePromos.set('package_all', p); - this.activePromos.set('addon_all', p); - } - }); - this.checkApplicablePromos(); - }); - } - - private checkApplicablePromos(): void { - if (!this.activePromos || this.activePromos.size === 0) return; - if (!this.subIntentPkg) return; - - const selPkg = this.subIntentPkg.selPkg; - const selAddons = this.subIntentPkg.selAddons || []; - - if (!selPkg) return; - - // Check package promo - this.packagePromo = this.getPromoForLookupKey(selPkg.lookupKey, 'package'); - - // Check addon promos - this.addonPromos = new Map(); - selAddons.forEach(addon => { - const promo = this.getPromoForLookupKey(addon.lookupKey, 'addon'); - if (promo) { - this.addonPromos.set(addon.lookupKey, promo); - } - }); - - this.hasApplicablePromos = !!this.packagePromo || this.addonPromos.size > 0; - } - - private getPromoForLookupKey(lookupKey: string, type: 'package' | 'addon'): ActivePromo | null { - // Only apply promos to NEW subscriptions - const userSubs = this.authSvc.user?.membership?.subscriptions || []; - - if (type === 'package') { - const hasAnyPackageSubscription = userSubs.some(sub => sub.type === 'package'); - if (hasAnyPackageSubscription) return null; // Not a new subscription - } - - // Priority 1: Exact match - const exactMatch = this.activePromos.get(lookupKey); - if (exactMatch) return exactMatch; - - // Priority 2: Type-only match - const typeOnlyPromo = this.activePromos.get(`${type}_all`); - return typeOnlyPromo || null; - } - - getApplicablePromos(): { item: any; promo: ActivePromo }[] { - const result: { item: any; promo: ActivePromo }[] = []; - - if (this.packagePromo && this.subIntentPkg?.selPkg) { - result.push({ item: this.subIntentPkg.selPkg, promo: this.packagePromo }); - } - - if (this.subIntentPkg?.selAddons) { - this.subIntentPkg.selAddons.forEach(addon => { - const promo = this.addonPromos.get(addon.lookupKey); - if (promo) { - result.push({ item: addon, promo }); - } - }); - } - - return result; - } - - /** - * Get promo savings from Redux state (calculated in checkout component). - * This ensures consistent pricing across checkout and checkout-review. - */ - get promoSavings(): number { - return this.subIntentPkg?.promoSavings || 0; - } - - ngOnDestroy(): void { - if (this.isProcessing) { - console.warn('⚠️ Component destroyed while processing'); - } - this.isProcessing = false; - super.ngOnDestroy(); - } } diff --git a/Development/client/src/app/profile/checkout/checkout.component.css b/Development/client/src/app/profile/checkout/checkout.component.css index 3ffd467..9bc82c0 100644 --- a/Development/client/src/app/profile/checkout/checkout.component.css +++ b/Development/client/src/app/profile/checkout/checkout.component.css @@ -1,37 +1,10 @@ +.balance { + font-weight: bold; + font-size: large; + margin-right: 1em; +} + .balance-val { font-weight: bold; font-size: large; -} - -#test-span { - font-weight: lighter; -} - -/* Override constraint-message max-width for trial charge banner */ -/* Global styles limit to 600px, but we need full-width to match card container below */ -/* Target the internal div with class .agm-constraint-message, not the component tag */ -:host ::ng-deep .in-card-pad>.ui-g-12 .agm-constraint-message { - max-width: 100% !important; -} - -/* Remove padding from the .ui-g-12 container wrapping the constraint message banner */ -/* Add bottom margin for consistent spacing between banner and card below (1em = 16px) */ -:host ::ng-deep .in-card-pad>.ui-g-12:not(.card) { - padding: 0 !important; - margin-bottom: 1em; -} - -/* Trial charge date banner: calendar icon in AgMission primary green to match promo icon style */ -:host ::ng-deep .in-card-pad .agm-constraint-content .pi-calendar { - color: #4CAF50; -} - -/* Minimum width for payment summary cards to prevent Stripe element collapse */ -.card.in-card-pad { - min-width: 300px; -} - -/* Minimum width for the dual-card (charges + refund) flex container */ -.dyn-col { - min-width: 580px; -} +} \ No newline at end of file diff --git a/Development/client/src/app/profile/checkout/checkout.component.html b/Development/client/src/app/profile/checkout/checkout.component.html index a363c4b..4c3c062 100644 --- a/Development/client/src/app/profile/checkout/checkout.component.html +++ b/Development/client/src/app/profile/checkout/checkout.component.html @@ -1,254 +1,42 @@ - -
-
-
-

Payment details

- - - -
-
-
-
+
+
+
+

Payment details

- -
- -
-
- - -
- - - - - - -
- -
- - -
- -
-
-
- -
- - -
- - - - - - -
-
-
- -
- - - -
- - -
- - - {{ Labels.YOUR_TRIAL_IS_ACTIVE_UNTIL }} {{ getTrialChargeDate() }}. {{ - Labels.YOU_WILL_BE_CHARGED_ON_THAT_DATE }} {{ Labels.NO_CHARGE_WILL_BE_MADE_TODAY }} - - -
- - -
-
- Your Subscription After Trial Ends -
- -
-
- Items -
-
- Price -
-
- - - - - -
-
-
- Total Promo Savings: +
+
+
+
+ Total Amount
-
- -${{ formatCurrency(totalPromoSavings) }} US -
-
-
- -
- -
-
- TotalTotal (Before Tax): -
-
- ${{ formatCurrency(amount?.total) }} US -
-
- -
-
- Plus Applicable Tax -
-
- -
-
- - - -
-
- - - - - -
-
-
- Total Promo Savings: -
-
- -${{ formatCurrency(totalPromoSavings) }} US -
+
+
-
-
- After Trial TotalAfter Trial Total (Before Tax): -
-
- ${{ formatCurrency(amount?.total) }} US -
+ Required payment date: October 1, 2022 +
+ +
+
+
+ Last payment received: October 1, 2022 for $2,500
- -
- - -
- - {{status?.message}} - - -
- - - - - -
-
{{type}}
-
-
-
- Items -
-
- Price -
-
-
- - -
-
-
-
-
- Payment Methods -
- - -
-
- -
-
-
- - -
-
- - -
- - -
-
- - -
-
- - -
-
-
-
- -
-
-
\ No newline at end of file +
\ No newline at end of file diff --git a/Development/client/src/app/profile/checkout/checkout.component.ts b/Development/client/src/app/profile/checkout/checkout.component.ts index 2d56c0e..cdca004 100644 --- a/Development/client/src/app/profile/checkout/checkout.component.ts +++ b/Development/client/src/app/profile/checkout/checkout.component.ts @@ -1,813 +1,15 @@ -import { AfterViewInit, ChangeDetectorRef, Component, Inject, LOCALE_ID, OnDestroy, OnInit } from '@angular/core'; -import { FormGroup, FormBuilder, Validators } from '@angular/forms'; -import { SelectItem } from 'primeng/api'; -import { Store } from '@ngrx/store'; -import { of, Subscription } from 'rxjs'; -import { map, switchMap, take } from 'rxjs/operators'; -import { getDefPM, getSubIntentPkgAmt, getSubIntentPkgCoupons, getSubIntentState, getSubIntentStatus } from '@app/reducers'; -import { ApplyDiscountPreview, Checkout, CheckoutTrial, ClearSubscriptionIntentStatus, ClearSubscriptionStatus, Compound, CreatePaymentMethod, GotoBillingAddress, GotoCheckoutReview, GotoMyServices, GotoServices, SetSubscriptionIntentPrevStage, UpdateAmount, UpdatePromoSavings } from '@app/actions/subscription.actions'; -import { PriceUsd, SubscriptionIntent, Status, PaidAmount, CheckoutPayment, TrialItem } from '@app/domain/models/subscription.model'; -import { SubscriptionService } from '@app/domain/services/subscription.service'; -import { SubTexts, SubAppErr, SUB, createSubStatus, SubStripe, Mode, hasVendorErr } from '../common'; -import { AuthService } from '@app/domain/services/auth.service'; -import { getSubscriptionState } from '@app/reducers'; -import { GC, Labels } from '@app/shared/global'; -import { DateUtils } from '@app/shared/utils'; -import { ActivePromo, ActivePromoService } from '@app/domain/services/active-promo.service'; - -const CHECKED = 'true'; +import { Component, OnInit } from '@angular/core'; @Component({ selector: 'checkout', templateUrl: './checkout.component.html', styleUrls: ['./checkout.component.css'] }) -export class CheckoutComponent implements OnInit, OnDestroy, AfterViewInit { - readonly SUB = SUB; - readonly SubTexts = SubTexts; - readonly Labels = Labels; +export class CheckoutComponent implements OnInit { - sub$: Subscription; - subIntentPkg: SubscriptionIntent; - prevStage: string; - status: Status; - form: FormGroup; - totalPrice: PriceUsd; - refund: PriceUsd; - pmOptions: SelectItem[]; - selectedCC: string; - radioCheck: string; - dismount: boolean; - hasExistingPMs: boolean; - stripeLoaded: boolean; - shouldMakePayment: boolean; - hasPaymentNoRefund: boolean; - hasPaymentAndRefund: boolean; - - amount: PaidAmount; - chkoutPmt: CheckoutPayment; - hasRefund: boolean; - coupons: { id: string; name: string; }[]; - discountErr: string; - disableCoupon: boolean; - - isTrial: boolean; - isContAftTrialEnd: boolean; - trialItems: TrialItem[]; - trialPmSelected: boolean; - - vendorErr: boolean; - - // Promo support (WI-4-v2: Option 2 - Inline badges + savings summary) - activePromos: Map = new Map(); - paymentPromos: Map = new Map(); // Promos for payment line items - refundPromos: Map = new Map(); // Promos for refund line items - totalPromoSavings: number = 0; // Total promo discount amount (native interval) - - constructor( - private readonly fb: FormBuilder, - private readonly store: Store<{}>, - private readonly cdRef: ChangeDetectorRef, - private readonly subSvc: SubscriptionService, - readonly authSvc: AuthService, - private readonly activePromoSvc: ActivePromoService, - @Inject(LOCALE_ID) private localeId: string - ) { } + constructor() { } ngOnInit(): void { - this.store.dispatch(new ClearSubscriptionIntentStatus()); - // Force refresh to get latest promo data from server - this.activePromoSvc.refresh(); - this.loadActivePromos(); } - ngAfterViewInit(): void { - this.initSub$(); - } - - ngAfterViewChecked() { - this.cdRef.detectChanges(); - } - - // Generate friendly display name for coupons - private getCouponDisplayName(coupon: any): string { - return coupon.name || - (coupon.percent_off ? `${coupon.percent_off}% off` : - coupon.amount_off ? `$${(coupon.amount_off / 100).toFixed(2)} off` : - coupon.id); - } - - private initSub$() { - const initCoupons = () => { - this.sub$.add(this.store.select(getSubIntentPkgCoupons).pipe( - map((coupons) => { - const hasCoupons = coupons?.length > 0; - // Pass full coupon objects with id and name - if (hasCoupons) { - this.coupons = coupons?.map((coupon) => ({ - id: coupon.id, - name: this.getCouponDisplayName(coupon) - })) || []; - } - }) - ).subscribe({ - error: err => { - console.log(err); - this.status = createSubStatus(SubAppErr.CHKOUT_ERR); - } - })); - } - const initPage = () => { - this.sub$.add(this.store.select(getSubIntentState).pipe( - switchMap((subIntent) => { - if (!subIntent?.package) return of(null); - this.prevStage = subIntent?.prevStage; - this.subIntentPkg = subIntent?.package; - this.status = subIntent?.status; - if (hasVendorErr(this.status?.code)) { - this.vendorErr = true; - } - this.isTrial = subIntent?.mode === Mode.TRIALING || this.subIntentPkg?.mode === Mode.CONTINUE_TRIAL; - this.isContAftTrialEnd = this.subIntentPkg?.mode === Mode.CONTINUE_TRIAL; - this.trialPmSelected = this.isContAftTrialEnd; - - this.form = this.fb.group({ ccInfo: [{ name: this.subIntentPkg?.billingInfo?.name, address: this.subIntentPkg?.billingInfo?.address }, Validators.required], defaultPM: [false] }); - this.hasExistingPMs = this.subIntentPkg?.paymentMethods?.length > 0; - if (this.hasExistingPMs) { - const toUpperCase = (brand: string) => `${brand.charAt(0).toLocaleUpperCase()}${brand.slice(1)}`; - this.pmOptions = this.subIntentPkg?.paymentMethods?.map(({ card, id }) => ({ label: `${toUpperCase(card.brand)} ${SubTexts.card} **** ${card.last4}`, value: `${id}` })) || []; - this.pmOptions.unshift({ label: $localize`:@@none:None`, value: '' }); - // Restore previously-selected card when navigating back from checkout-review. - // subIntentPkg.card.pmId is preserved in the store by editCheckout() / back() — - // neither dispatches a card-clearing action — so we can use it to skip the - // getDefPM default and keep the user's explicit selection intact. - const prevCardId = this.subIntentPkg?.card?.pmId; - if (prevCardId && this.pmOptions.some((pm) => pm.value === prevCardId)) { - this.selectedCC = prevCardId; - this.radioCheck = null; - return this.store.select(getSubscriptionState); - } - return this.store.select(getDefPM).pipe( - take(1), - switchMap((defPM) => { - if (defPM) { - this.selectedCC = this.pmOptions.find((pm) => pm.value === defPM.id).value; - } else { - this.selectedCC = this.pmOptions[1].value; - } - this.radioCheck = null; - return this.store.select(getSubscriptionState); - }) - ); - } - return this.store.select(getSubscriptionState); - }), - take(1), - map((subState) => { - if (!subState) return this.status = createSubStatus(SubAppErr.CHKOUT_ERR); - - if (this.isTrial) { - this.trialItems = this.subSvc.createTrialItems(this.subIntentPkg?.selPkg, this.subIntentPkg?.selAddons); - const total = this.trialItems?.map((item) => Number(item.amount)).reduce((t1, t2) => t1 + t2, 0); - - // Check for promo applicability on trial items (only if activePromos already loaded) - this.checkTrialItemPromos(); - - // Update total to discounted price (after calculating promo savings) - const discountedTotal = total - this.totalPromoSavings; - this.amount = { total: discountedTotal, totalTax: 0, totalExcludingTax: 0 }; - - return; - } - - let isDeferredPromo = false; - const hasOpenInvoices = this.authSvc.hasSubsWithStatus(SubStripe.INCOMPLETE) || this.authSvc.hasSubsWithStatus(SubStripe.PAST_DUE) || this.authSvc.hasSubsWithStatus(SubStripe.OVERDUE) || this.authSvc.hasSubsWithStatus(SubStripe.UNPAID); - - if (hasOpenInvoices) { - if (this.authSvc.hasSubsWithStatus(SubStripe.INCOMPLETE)) { - const invoices = subState.incomplete?.invoices; - const currentInvoices = this.filterCurrentPeriodInvoices(invoices); - this.chkoutPmt = this.subSvc.calcChkoutPayment(currentInvoices, { subscriptions: this.authSvc.user.membership.subscriptions, coupon: this.subSvc.getInvCoupon(invoices) }); - } else if (this.authSvc.hasSubsWithStatus(SubStripe.PAST_DUE) || this.authSvc.hasSubsWithStatus(SubStripe.OVERDUE)) { - const invoices = subState.pastDue?.invoices; - const currentInvoices = this.filterCurrentPeriodInvoices(invoices); - this.chkoutPmt = this.subSvc.calcChkoutPayment(currentInvoices, { subscriptions: this.authSvc.user.membership.subscriptions, coupon: this.subSvc.getInvCoupon(invoices) }); - } else if (this.authSvc.hasSubsWithStatus(SubStripe.UNPAID)) { - const invoices = subState.unpaid?.invoices; - const currentInvoices = this.filterCurrentPeriodInvoices(invoices); - this.chkoutPmt = this.subSvc.calcChkoutPayment(currentInvoices, { subscriptions: this.authSvc.user.membership.subscriptions, coupon: this.subSvc.getInvCoupon(invoices) }); - } - this.amount = this.subIntentPkg?.amount; - this.disableCoupon = true; - } else { - const upcomingInvoices = this.subIntentPkg?.upcomingInvoices; - // Deferred promo path: qty changes immediately with no charge/refund (proration_behavior: 'none' - // on actual subscription update). Invoice[0]'s proration lines are Stripe retrieveUpcoming - // simulation artifacts that never execute. Use Invoice[1] (period_type: 'next', has_promo: true) - // which directly represents the actual outcome: qty:N Aircraft Tracking FREE. - // NOTE: Invoice[1] qty display requires the backend adoSubItems[0] fix to show correct quantity. - // r975 fallback: has_promo may not be injected — also detect via pendingPromoDetails presence. - isDeferredPromo = upcomingInvoices?.some(inv => inv?.period_type === 'next' && (inv?.has_promo === true || !!inv?.pendingPromoDetails)) ?? false; - const invoicesToDisplay = isDeferredPromo - ? upcomingInvoices?.filter(inv => inv?.period_type === 'next') - : this.filterCurrentPeriodInvoices(upcomingInvoices); - this.chkoutPmt = this.subSvc.calcChkoutPayment(invoicesToDisplay, { subscriptions: this.authSvc.user.membership.subscriptions }); - const hasExistingCoupon = this.subIntentPkg?.coupons?.length > 0; - // v3.1: coupon objects are sanitized from invoice responses; detect via discount refs - const hasActiveDiscount = invoicesToDisplay?.some(inv => (inv?.total_discount_amounts?.length ?? 0) > 0); - if (hasExistingCoupon) { - // Pass full coupon objects with id and name - this.coupons = this.subIntentPkg?.coupons?.map((coupon) => ({ - id: coupon.id, - name: this.getCouponDisplayName(coupon) - })) || []; - this.amount = { total: this.subIntentPkg?.amount.total, totalExcludingTax: this.subIntentPkg?.amount.totalExcludingTax, totalTax: this.subIntentPkg?.amount.totalTax, discount: this.subIntentPkg?.amount?.discount }; - this.disableCoupon = true; - } else { - this.coupons = []; - this.amount = { - total: this.chkoutPmt?.payment?.totalAmount || 0, - totalExcludingTax: this.chkoutPmt?.payment?.totalAmount || 0, - totalTax: this.chkoutPmt?.payment?.totalTax || 0, - refundAmount: this.chkoutPmt?.refund - ? Math.abs(this.chkoutPmt.refund.totalAmount || 0) - : undefined - }; - } - // For deferred promo: Invoice[1] line `amount` is the pre-discount gross value. - // calcChkoutPayment sums line amounts → gives the pre-coupon total (e.g. $49.95). - // The actual charge is Invoice[1].total = 0 (100% coupon applied by Stripe). - // Override amount with Invoice[1]'s real total fields. - // For deferred promo: Invoice[1].total from Stripe's retrieveUpcoming is a simulation - // artifact that may be negative (proration credit accounting). The actual charge today - // is $0.00 — the 100% FREE coupon applies at next billing period, nothing is due now. - if (isDeferredPromo) { - this.amount = { - total: 0, - totalExcludingTax: 0, - totalTax: 0 - }; - this.disableCoupon = true; - } else if (hasActiveDiscount) { - // Subscription already has a Stripe discount applied — hide coupon input - this.disableCoupon = true; - } - this.store.dispatch(new UpdateAmount(this.amount)); - // Invoice[1] has no proration lines → calcInvoice path → no refund split naturally. - this.hasRefund = !!this.chkoutPmt.refund && !isDeferredPromo; - } - if (this.hasRefund === undefined) { - this.hasRefund = !!this.chkoutPmt?.refund; - } - - // Check for applicable promos after chkoutPmt is populated - this.checkApplicablePromos(); - - // For deferred promo: override promoSavings — Invoice[1] line amounts are 0 - // because Stripe pre-applies the 100% coupon in retrieveUpcoming preview. - // calcPromoSavings uses unit_amount which is correct, but paymentPromos may be - // empty if the addon already exists (existing sub check). Compute directly from - // unit_amount × quantity as the "would-have-paid" gross value. - if (isDeferredPromo) { - const nextInvoice = this.subIntentPkg?.upcomingInvoices?.find((inv: any) => inv?.period_type === 'next'); - const deferredSavings = nextInvoice?.lines?.data?.reduce((sum: number, line: any) => { - return sum + (line.price?.unit_amount ?? 0) * (line.quantity ?? 1); - }, 0) ?? 0; - this.totalPromoSavings = deferredSavings; - this.store.dispatch(new UpdatePromoSavings(this.totalPromoSavings)); - } - }) - ).subscribe({ - error: err => { - console.log(err); - this.status = createSubStatus(SubAppErr.CHKOUT_ERR); - } - })); - } - - this.sub$ = this.store.select(getSubIntentStatus).subscribe({ - next: status => { - if (status?.code === SubAppErr.APP_DISCOUNT_PREVIEW_ERR) { - return this.discountErr = status.message; - } - this.status = status; - }, - error: err => { - console.log(err); - this.status = createSubStatus(SubAppErr.CHKOUT_ERR); - } - }); - - if (!this.isTrial) { - this.sub$.add(this.store.select(getSubIntentPkgAmt).pipe( - map((amount) => { - this.amount = amount; - }) - ).subscribe({ - error: err => { - console.log(err); - this.status = createSubStatus(SubAppErr.CHKOUT_ERR); - } - })); - } - - initCoupons(); - initPage(); - } - - isCompLoaded() { - return this.status?.code !== SubAppErr.CHKOUT_ERR - && !this.vendorErr; - } - - /** - * Check if there are any active promos - * Used to hide coupon section when promos are active (promos and coupons are mutually exclusive) - */ - get hasActivePromos(): boolean { - return this.paymentPromos?.size > 0 || this.refundPromos?.size > 0; - } - - /** Payment section header — always "Added" regardless of whether there is a refund or not */ - get paymentSectionLabel(): string { - return SubTexts.added; - } - - /** Refund section header: "Removed (Refunding)" when non-zero credit, otherwise "Removed" */ - get refundSectionLabel(): string { - const totalRefund = Math.abs(this.chkoutPmt?.refund?.totalAmount || 0); - return totalRefund !== 0 ? SubTexts.removedRefunding : SubTexts.removed; - } - - isFormValid() { - return (this.selectedCC ? true : false || this.form?.status === 'VALID') && this.status?.code !== SubAppErr._500_ERR; - } - - isValid() { - if (this.isTrial) { - if (this.trialPmSelected) return this.stripeLoaded && this.isFormValid(); - return true; - } - return this.stripeLoaded && this.isFormValid(); - } - - choosePmOption(option: string) { - this.store.dispatch(new ClearSubscriptionIntentStatus()); - const shouldCrtNewCard = !option || option === CHECKED; - if (shouldCrtNewCard) { - this.radioCheck = CHECKED; - this.selectedCC = null; - this.dismount = false; - } else { - this.radioCheck = null; - this.dismount = true; - } - } - - showHideCCForm(hasLoaded: boolean) { - if (hasLoaded) { - this.stripeLoaded = hasLoaded; - const fromCheckRevStage = this.prevStage === SUB.CHKOUT_REV; - // When returning from checkout-review with a previously selected existing card, - // the card is already restored in ngOnInit via subIntentPkg.card.pmId. - // Treat this case the same as a normal page load with existing PMs so that - // the Stripe new-card element is dismounted and selectedCC is NOT wiped. - const prevCardId = this.subIntentPkg?.card?.pmId; - const prevCardInOptions = !!(prevCardId && this.pmOptions.some((pm) => pm.value === prevCardId)); - const canDismountStripeElt = this.hasExistingPMs && (!fromCheckRevStage || prevCardInOptions); - if (canDismountStripeElt) { - this.dismount = true; - } else { - this.radioCheck = CHECKED; - this.selectedCC = null; - } - } - } - - submit() { - try { - if (this.isTrial) { - return this.submitTrial(); - } - return this.submitPayment(); - } catch (err) { - console.log(err); - this.status = createSubStatus(SubAppErr.CHKOUT_ERR); - } - } - - private getSelCard() { - const selectedPM = this.subIntentPkg?.paymentMethods?.find(({ id }) => id === this.selectedCC); - if (selectedPM) { - return { - pmId: selectedPM.id, - defaultPM: this.form.value.defaultPM, - ...selectedPM.card - }; - } - } - - private getNewPm() { - return { - card: this.form.value.ccInfo.card, - billing_details: { name: this.form.value.ccInfo.name, address: this.form.value.ccInfo.address }, - defaultPM: this.form.value.defaultPM - }; - } - - private submitTrial() { - const subs = { - package: this.subIntentPkg?.selPkg?.lookupKey, - addons: this.subIntentPkg?.selAddons?.map((addon) => ({ price: addon.lookupKey, quantity: addon.quantity })), - subIds: this.subIntentPkg?.subIds || [] - }; - - if (this.isContAftTrialEnd) { - if (this.selectedCC) { - return this.store.dispatch(new CheckoutTrial({ ...subs, pmtMethod: { exPmtMeth: this.getSelCard() }, mode: Mode.CONTINUE_TRIAL, amount: this.amount })); - } - return this.store.dispatch(new CheckoutTrial({ ...subs, pmtMethod: { newPmtMeth: this.getNewPm() }, mode: Mode.CONTINUE_TRIAL, amount: this.amount })); - } - - if (this.trialPmSelected) { - if (this.selectedCC) { - return this.store.dispatch(new CheckoutTrial({ ...subs, pmtMethod: { exPmtMeth: this.getSelCard() }, mode: Mode.TRIALING, amount: this.amount })); - } - return this.store.dispatch(new CheckoutTrial({ ...subs, pmtMethod: { newPmtMeth: this.getNewPm() }, mode: Mode.TRIALING, amount: this.amount })); - } - return this.store.dispatch(new CheckoutTrial({ ...subs, mode: Mode.TRIALING, amount: this.amount })); - } - - private submitPayment() { - if (this.selectedCC) { - return this.store.dispatch(new Checkout(this.getSelCard())); - } - return this.store.dispatch(new CreatePaymentMethod(this.getNewPm())); - } - - back() { - try { - const actionMap = { - [SUB.BILL_ADR]: new GotoBillingAddress(), - [SUB.CHKOUT_REV]: new GotoCheckoutReview(), - [SUB.SERVICES]: new GotoServices(), - 'default': new GotoMyServices() - }; - - // Validate action before dispatching to prevent undefined actions - const targetAction = actionMap[this.prevStage] || actionMap['default']; - - if (!targetAction) { - console.error('[Checkout] Invalid prevStage value:', this.prevStage, '- defaulting to MyServices'); - return this.store.dispatch(new GotoMyServices()); - } - - this.store.dispatch(new Compound([ - new SetSubscriptionIntentPrevStage(SUB.CHKOUT), - targetAction - ])); - } catch (err) { - console.log(err); - this.status = createSubStatus(SubAppErr.CHKOUT_ERR); - } - } - - gotoMySubs() { - this.store.dispatch(new Compound([new ClearSubscriptionStatus(), new GotoMyServices()])); - } - - applyCoupon(coupon) { - this.store.dispatch(new ApplyDiscountPreview({ subIntentPkg: this.subIntentPkg, coupon })); - } - - removeCoupon() { - this.store.dispatch(new ApplyDiscountPreview({ subIntentPkg: this.subIntentPkg })); - } - - selTrialPm(evt) { - const checked = evt?.checked; - if (checked) { - if (this.pmOptions?.length > 0) { - this.dismount = true; - this.trialPmSelected = checked; - this.selectedCC = this.pmOptions[1].value; - this.radioCheck = null; - } else { - this.dismount = false; - this.trialPmSelected = checked; - } - } else { - this.trialPmSelected = false; - } - } - - createMsg(type: string) { - if (type === 'trial') { - const trials = this.authSvc?.user?.membership?.trials; - switch (trials?.type) { - case GC.DAYS: return `${SubTexts.freeTrialFor} ${trials.trialDays} ${SubTexts.days}`; - case GC.BYDATE: return `${SubTexts.freeTrialUntil} ${DateUtils.toSlash(trials.byDate, this.authSvc.locale)}`; - } - } - } - - // ============================================================================ - // PROMO SUPPORT (WI-4-v2: Inline Badges Only) - // ============================================================================ - - /** - * Load active promos and build lookup maps - * Creates Map for exact and type-only matches - */ - private loadActivePromos(): void { - this.activePromoSvc.getActivePromos().subscribe(promos => { - this.activePromos = new Map(); - promos.forEach(p => { - // Exact match by priceKey - if (p.priceKey) { - this.activePromos.set(p.priceKey, p); - } else if (p.type) { - // Type-only promo (priceKey = null in backend) - this.activePromos.set(`${p.type}_all`, p); - } else { - // Universal promo (no type, no priceKey) - applies to EVERYTHING - this.activePromos.set('package_all', p); - this.activePromos.set('addon_all', p); - } - }); - - // Check regular checkout promos (will only process if chkoutPmt is also ready) - this.checkApplicablePromos(); - - // Check trial item promos (will only process if trialItems are ready) - this.checkTrialItemPromos(); - - // For trial flow: re-apply promo savings to this.amount now that promos are loaded. - // initPage() sets this.amount before activePromos arrive (totalPromoSavings=0 at that - // point), so the amount is stale (full price). Once promos are fetched and - // checkTrialItemPromos() has recalculated totalPromoSavings, recompute the total. - if (this.isTrial && this.trialItems?.length > 0 && this.totalPromoSavings > 0) { - const grossTotal = this.trialItems.map(item => Number(item.amount)).reduce((s, v) => s + v, 0); - this.amount = { total: grossTotal - this.totalPromoSavings, totalTax: 0, totalExcludingTax: 0 }; - this.store.dispatch(new UpdateAmount(this.amount)); - } - }); - } /** - * Check which line items have applicable promos - * Populates paymentPromos and refundPromos maps - * CRITICAL: Only applies promos to NEW subscriptions (no existing subscription of same type) - * NOTE: Can be called multiple times safely - will only process if both activePromos and chkoutPmt are ready - */ - private checkApplicablePromos(): void { - // Guard: Need both activePromos and chkoutPmt to be ready - if (!this.activePromos || this.activePromos.size === 0 || !this.chkoutPmt) { - return; - } - - // Check payment line items - if (this.chkoutPmt?.payment?.lineItems) { - this.paymentPromos = this.getPromosForLineItems(this.chkoutPmt.payment.lineItems); - } - - // Check refund line items (if any) - if (this.chkoutPmt?.refund?.lineItems) { - this.refundPromos = this.getPromosForLineItems(this.chkoutPmt.refund.lineItems); - } - - // Calculate total promo savings for display - this.calculateTotalPromoSavings(); - } - - /** - * Get applicable promos for a list of line items - * Returns Map for items that have promos - * CRITICAL: Only includes items with positive amounts (actual charges) - * Refunds (negative amounts) are credits, not promo discounts - */ - private getPromosForLineItems(lineItems: any[]): Map { - const promosMap = new Map(); - if (!lineItems || lineItems.length === 0) return promosMap; - - lineItems.forEach(item => { - // Skip refund line items (negative amounts) - they're credits, not promo discounts - if (item.amount < 0) { - return; - } - - const promo = this.getPromoForLookupKey(item.price?.lookup_key); - if (promo) { - promosMap.set(item.price?.lookup_key, promo); - } - }); - - return promosMap; - } - - /** - * Get promo for a specific lookup key - * Checks if item is eligible (new subscription) and returns matching promo - * CRITICAL: Only applies promos to NEW subscriptions - * @param lookupKey Package or addon lookup key (e.g., 'ess_3', 'addon_1') - * @returns ActivePromo if exists and item is new subscription, null otherwise - */ - private getPromoForLookupKey(lookupKey: string): ActivePromo | null { - if (!lookupKey) return null; - - // Determine if this is a package or addon - const isPackage = lookupKey.startsWith('ess_') || lookupKey.startsWith('ent_'); - const type: 'package' | 'addon' = isPackage ? 'package' : 'addon'; - - // Check if user has existing subscription of this type - const userSubs = this.authSvc.user?.membership?.subscriptions || []; - - if (type === 'package') { - // For packages: Check if user has ANY package subscription (packages are mutually exclusive) - // AGNavSubscription has type field: 'package' | 'addon' - const hasAnyPackageSubscription = userSubs.some(sub => sub.type === 'package'); - - // Only show promo if user has NO package subscriptions (new subscription) - if (hasAnyPackageSubscription) { - return null; - } - } - // For addons: If addon is in checkout flow, backend already validated user doesn't have it - // No additional check needed (backend filtering ensures this) - - // Priority 1: Exact match by priceKey - const exactMatch = this.activePromos.get(lookupKey); - if (exactMatch) return exactMatch; - - // Priority 2: Type-only match (priceKey = null in backend = applies to all of type) - const typeOnlyPromo = this.activePromos.get(`${type}_all`); - if (typeOnlyPromo) return typeOnlyPromo; - - return null; - } - - /** - * Calculate total promo savings amount - * Calculates discount based on promo discountType and discountValue - * NOTE: Preview invoice doesn't have discount applied yet, so we calculate it manually - */ - private calculateTotalPromoSavings(): void { - // Calculate payment promo savings using centralized service method - const paymentSavings = this.subSvc.calculatePromoSavings( - this.chkoutPmt?.payment?.lineItems, - this.paymentPromos - ); - - // Calculate refund promo savings (if any) using centralized service method - const refundSavings = this.subSvc.calculatePromoSavings( - this.chkoutPmt?.refund?.lineItems, - this.refundPromos - ); - - // Total savings at native interval (matches non-promo display) - this.totalPromoSavings = paymentSavings + refundSavings; - - // Store in Redux state for use in checkout-review and checkout-confirm - this.store.dispatch(new UpdatePromoSavings(this.totalPromoSavings)); - - // Re-dispatch corrected total so payment-amount receives post-promo value. - // payment-amount's design contract: [totalAmount] must already be post-discount. - // Use chkoutPmt.payment.totalAmount as the stable base — NOT this.amount.total, - // which would be re-decremented on every call (this method fires from both - // initPage() and the loadActivePromos() HTTP callback). - if (this.totalPromoSavings > 0 && this.amount && this.chkoutPmt) { - const baseTotal = this.chkoutPmt.payment?.totalAmount || 0; - const correctedTotal = Math.max(0, baseTotal - this.totalPromoSavings); - this.amount = { ...this.amount, total: correctedTotal }; - this.store.dispatch(new UpdateAmount(this.amount)); - } - } - - - - // ============================================================================ - // TRIAL CHECKOUT SUPPORT (Solution A: Inline Charge Date Banner) - // ============================================================================ - - /** - * Check for promos applicable to trial items - * Similar to checkApplicablePromos() but for trial flow - */ - private checkTrialItemPromos(): void { - if (!this.trialItems || this.trialItems.length === 0) return; - - this.paymentPromos = new Map(); - let totalSavings = 0; - - this.trialItems.forEach(item => { - const lookupKey = item.price?.lookup_key; - if (!lookupKey) return; - - // Try exact match first - let promo = this.activePromos.get(lookupKey); - - // If no exact match, try type-only match - if (!promo) { - const type = lookupKey.startsWith('addon_') ? 'addon' : 'package'; - promo = this.activePromos.get(`${type}_all`); - } - - if (promo) { - this.paymentPromos.set(lookupKey, promo); - - // Calculate savings for this item - const originalAmount = item.price.unit_amount * (item.quantity || 1); - const discountedAmount = this.calculateDiscountedAmount(originalAmount, promo); - totalSavings += (originalAmount - discountedAmount); - } - }); - - this.totalPromoSavings = totalSavings; - this.store.dispatch(new UpdatePromoSavings(totalSavings)); - } - - /** - * Calculate discounted amount based on promo type - * Uses centralized calculation from SubscriptionService - */ - private calculateDiscountedAmount(originalAmount: number, promo: ActivePromo): number { - return this.subSvc.calculateDiscountedAmount(originalAmount, promo); - } - - /** - * Get charge date from user's active trial subscription - * Falls back to trial item if subscription not found - */ - getTrialChargeDate(): string { - // Try to get trialEnd from user's active subscriptions first - // Note: AGNavSubscription interface doesn't include trialEnd, but Stripe API returns it - const activeSub = this.authSvc.user?.membership?.subscriptions?.find( - sub => sub.status === 'trialing' - ) as any; - - // Check for trial end date (trial_end is the correct field name in snake_case) - const trialEndTimestamp = activeSub?.trial_end; - - if (trialEndTimestamp) { - const chargeDate = new Date(trialEndTimestamp * 1000); - return chargeDate.toLocaleDateString(this.localeId, { - year: 'numeric', - month: 'long', - day: 'numeric' - }); - } - - // Fallback: Try to get from trial items (if populated) - if (this.trialItems && this.trialItems.length > 0) { - const firstItem = this.trialItems[0]; - if (firstItem.trialEnd) { - const chargeDate = new Date(firstItem.trialEnd * 1000); - return chargeDate.toLocaleDateString(this.localeId, { - year: 'numeric', - month: 'long', - day: 'numeric' - }); - } - } - - return ''; - } /** - * Get total amount including tax - */ - getTotalWithTax(): number { - return (this.amount?.total || 0) + (this.amount?.totalTax || 0); - } - - /** - * Format currency for display - */ - formatCurrency(amountInCents: number): string { - return (amountInCents / 100).toFixed(2); - } - - /** - * Filter invoices to only include current period transactions. - * Excludes next billing period invoices (period_type: "next") from checkout display. - * - * Backend v3.1 dual-invoice response: When deferred promo detected (100% off + active + auto-renew), - * returns TWO invoices - current period proration + next period preview. - * - * Checkout page should ONLY display current period (what user pays NOW). - * Next period invoice is for manage-subscription page (Issue 2) to show future billing preview. - * - * @param invoices - Array of invoices from backend API - * @returns Filtered array containing only current period invoices - */ - private filterCurrentPeriodInvoices(invoices: any[]): any[] { - if (!invoices || invoices.length === 0) { - return []; - } - - // Filter out invoices with period_type === "next" - // Include: invoices with NO period_type field (standard Stripe) OR period_type === "current" - // Exclude: invoices with period_type === "next" (future billing cycle) - return invoices.filter(inv => !inv.period_type || inv.period_type === 'current'); - } - - ngOnDestroy(): void { - this.sub$?.unsubscribe(); - } } diff --git a/Development/client/src/app/profile/common.ts b/Development/client/src/app/profile/common.ts deleted file mode 100644 index eb0edd4..0000000 --- a/Development/client/src/app/profile/common.ts +++ /dev/null @@ -1,968 +0,0 @@ -import { HttpErrorResponse } from "@angular/common/http" -import { Card, Status } from "@app/domain/models/subscription.model" -import { errorHandler } from "@app/shared/global" -import { of } from "rxjs" -import { ApplyDiscountPreviewFailed, CreatePaymentMethodFailed, CreateSubscriptionIntentFailed, LoadStripeFailed, UpdateSubscriptionIntentStatus, UpdateSubscriptionStatus, UpdateUnpaid } from "@app/actions/subscription.actions" -import { FetchError } from "./actions/payment.action" -import { FetchUsageFailed } from "./actions/usage.actions" -import { FetchSubPlansFailed } from "@app/actions/sub-plans.actions" - -export const DELAY = 1000; -export const TAKE = 3; -export enum InvType { CHARGE = 'charge', INVOICE = 'invoice' }; -export enum SubType { PACKAGE = 'package', ADDON = 'addon' }; -export enum SubKeys { ESS_1 = 'ess_1', ESS_1_1 = 'ess_1_1', ESS_2 = 'ess_2', ESS_3 = 'ess_3', ESS_4 = 'ess_4', ESS_5 = 'ess_5', ENT_1 = 'ent_1', ENT_2 = 'ent_2', ENT_3 = 'ent_3', ENT_4 = 'ent_4', ENT_5 = 'ent_5', TRACKING = 'addon_1' }; -export enum Mode { REGULAR, TRIALING, CONTINUE_TRIAL, UPDATE_BIL_ADR, UNPAID }; -export const ACTIVE = 'active'; -export const PACKAGE_ACTIVE = 'pkgActive'; -export const TRACKING = 'tracking'; -export const STRIPE_ELT_STYLE = { - base: { - fontSize: '14px', - color: '#212121', - fontFamily: 'Roboto, "Helvetica Neue", sans-serif', - fontSmoothing: 'antialiased', - '::placeholder': { color: '#212121' }, - padding: '0.5rem' - }, - invalid: { - fontSize: '14px', - fontFamily: 'Roboto, "Helvetica Neue", sans-serif', - fontSmoothing: 'antialiased', - color: 'red', - '::placeholder': { color: '#212121' }, - padding: '0.5rem' - } -} - -export const STRIPE_BIL_ADDR_STYLE = { - variables: { borderRadius: '1px', fontFamily: 'Roboto, "Helvetica Neue", sans-serif', colorText: '#000000', colorPrimary: '#4CAF50' } -} - -// Promo Translation Keys -export const PromoLabels = { - // Package Promos - PROMO_ESS_FREE: $localize`:Promo name@@PROMO_ESS_FREE:Essential Free Trial`, - PROMO_ESS_FREE_DESC: $localize`:Promo desc@@PROMO_ESS_FREE_DESC:Get Essential tier free until specified date`, - PROMO_ENT_FREE: $localize`:Promo name@@PROMO_ENT_FREE:Enterprise Free Trial`, - PROMO_ENT_FREE_DESC: $localize`:Promo desc@@PROMO_ENT_FREE_DESC:Get Enterprise tier free until specified date`, - - // Addon Promos - PROMO_ADDON_FREE: $localize`:Promo name@@PROMO_ADDON_FREE:Addon Free Until April`, - PROMO_ADDON_FREE_DESC: $localize`:Promo desc@@PROMO_ADDON_FREE_DESC:Get addon features free until April 2026`, - - // Generic fallbacks - PROMO_GENERIC_FREE: $localize`:Promo name@@PROMO_GENERIC_FREE:Free Promotion`, - PROMO_GENERIC_FREE_DESC: $localize`:Promo desc@@PROMO_GENERIC_FREE_DESC:Limited time free access`, -}; - -/** - * Promo Error Messages (Admin-facing) - * Used in: subscription-mgt.component.ts (admin promo management UI) - * Backend error types defined in: server/helpers/constants.js lines 142-150 - * Backend thrown in: server/controllers/main.js lines 535, 547, 690, 695 - */ -export const PromoErrors = { - /** - * Error: Promo not found during lookup - * Backend error type: PROMO_NOT_FOUND - * Thrown in: server/controllers/main.js lines 690, 695 - */ - PROMO_NOT_FOUND: $localize`:Admin error - promo not found@@promoNotFound:Promotion not found. Please verify the promotion ID and try again.`, - - /** - * Error: Active promo already exists for this package/addon combination - * Backend error type: PROMO_DUPLICATE_TYPE_PRICEKEY - * Thrown in: server/controllers/main.js line 535 - */ - PROMO_DUPLICATE_TYPE_PRICEKEY: $localize`:Admin error - duplicate promo type/price@@promoDupTypePriceKey:A promotion already exists for this package/addon combination. Please update the existing promotion or disable it before creating a new one.`, - - /** - * Error: This coupon is already used by another active promo - * Backend error type: PROMO_DUPLICATE_COUPON - * Thrown in: server/controllers/main.js line 547 - */ - PROMO_DUPLICATE_COUPON: $localize`:Admin error - duplicate coupon@@promoDupCoupon:This Stripe coupon is already used by another active promotion. Each coupon can only be used in one promotion at a time.`, - - /** - * Error: Date ranges overlap with existing promo - * Backend error type: PROMO_OVERLAPPING_DATES - * Note: Not yet implemented in backend r955 (reserved for future use) - */ - PROMO_OVERLAPPING_DATES: $localize`:Admin error - overlapping dates@@promoOverlapDates:Promotion dates overlap with an existing promotion. Please adjust the valid date range.`, - - /** - * Error: Stripe coupon lookup failed - * Backend error type: PROMO_COUPON_NOT_FOUND - * Note: Not yet implemented in backend r955 (reserved for future use) - */ - PROMO_COUPON_NOT_FOUND: $localize`:Admin error - Stripe coupon not found@@promoCouponNotFound:Stripe coupon not found. Please verify the coupon ID exists in Stripe dashboard.`, - - /** - * Generic promo error (fallback for unknown error types) - */ - PROMO_GENERIC_ERROR: $localize`:Admin error - generic promo error@@promoGenericError:Failed to manage promotion. Please check the error details and try again.`, - - /** - * Error: Invalid coupon or promotion code (user-facing) - * Backend error type: PROMO_INVALID_COUPON (NEW in r959) - * Thrown in: server/controllers/subscription.js (resolveCouponCode, getCoupon_get) - * Use case: User enters invalid/restricted coupon at checkout - */ - PROMO_INVALID_COUPON: $localize`:User error - invalid coupon@@promoInvalidCoupon:Invalid promotion code. Please check and try again.`, - - /** - * Error: Coupon restricted to specific customers - * Backend error type: PROMO_INVALID_COUPON (customer restriction variant) - */ - PROMO_RESTRICTED_CUSTOMER: $localize`:User error - customer restriction@@promoRestrictedCustomer:This promotion is not available for your account.`, - - /** - * Error: Coupon doesn't apply to selected products - * Backend error type: PROMO_INVALID_COUPON (product restriction variant) - */ - PROMO_RESTRICTED_PRODUCT: $localize`:User error - product restriction@@promoRestrictedProduct:This promotion does not apply to the selected package.`, - - /** - * Error: Coupon restricted to first-time customers only - * Backend error type: PROMO_INVALID_COUPON (first-time transaction restriction) - */ - PROMO_FIRST_TIME_ONLY: $localize`:User error - first time only@@promoFirstTimeOnly:This promotion is only available for first-time customers.`, - - /** - * Error: Coupon has expired - * Backend error type: PROMO_INVALID_COUPON (expired variant) - * Thrown in: server/controllers/subscription.js (resolveCouponCode) - */ - PROMO_EXPIRED: $localize`:User error - coupon expired@@promoExpired:This promotion has expired.`, - - /** - * Error: Coupon reached maximum redemption limit - * Backend error type: PROMO_INVALID_COUPON (max redemptions variant) - * Thrown in: server/controllers/subscription.js (resolveCouponCode) - */ - PROMO_MAX_REDEMPTIONS: $localize`:User error - max redemptions@@promoMaxRedemptions:This promotion has reached its maximum usage limit.` -}; - -// Application errors -export const SubAppErr = Object.freeze({ - BIL_ADDR_ERR: 'subMsgBillAddrErr', - CANCEL_ERR: 'subMsgCancelErr', - CONF_ERR: 'subMsgConfErr', - CRT_PM_ERR: 'subMsgCrtPmErr', - COMP_ACT_ERR: 'subMsgCompActErr', - START_BIL_INFO_ERR: 'subMsgStartBilInfoErr', - CHKOUT_ERR: 'subMsgChkoutErr', - CHKOUT_REV_ERR: 'subMsgChkoutRevErr', - CHKOUT_CONF_ERR: 'subMsgChkoutConfErr', - MGE_SERV_ERR: 'subMsgManageServErr', - MGE_SUB_ERR: 'subMsgManageSubErr', - PAY_UNPAID_ERR: 'subMsgPayUnpaidErr', - PAY_UNPAID_CARD_ERR: 'subMsgPayUnpaidCardErr', - POLL_ERR: 'subMsgPollingErr', - PM_DETAIL_ERR: 'subMsgPmDetailErr', - PM_LIST_ERR: 'subMsgPmListErr', - FETCH_PMT_ERR: 'subMsgFetchPmtErr', - FETCH_SUB_ERR: 'subMsgFetchSubErr', - FETCH_USAGE_ERR: 'subMsgFetchUsageErr', - START_CHECKOUT_ERR: 'subMsgStartChkoutErr', - CHECKOUT_TRIAL_ERR: 'subMsgChkoutTrialErr', - CHECKOUT_CONT_TRIAL_ERR: 'subMsgChkoutContTrialErr', - UPDATE_SUB_ERR: 'subMsgUpdateSubErr', - UPDATE_PM_ERR: 'subMsgUpdatePmErr', - UNPAID_ERR: 'subMsgUnpaidErr', - STRIPE_ERR: 'subMsgStripeErr', - REFUND_ERR: 'subMsgRefundErr', - REFRESH_ERR: 'subMsgRefreshErr', - RES_UNPAID_ERR: 'subMsgResumeUnpaidErr', - RES_ERR: 'subMsgResErr', - _500_ERR: 'subMsg500Err', - RES_SUB_ERR: 'subMsgResSubErr', - RES_SUB_ERR_NO_CARD: 'subMsgResSubErrNoCard', - NO_SUBS_ERR: 'subMsgNoSubErr', - NO_INVOICES_ERR: 'subMsgNoInvoiceErr', - NO_ACTIONS_ERR: 'subMsgNoActionErr', - INC_ADDR_ERR: 'subMsgIncAddrErr', - INC_CARD_ERR: 'subMsgIncCardErr', - USAGE_DETAIL_ERR: 'subMsgUsageDetailErr', - FETCH_SUB_PLANS_ERR: 'subMsgFetchSubPlansErr', - LOAD_STRIPE_ERR: 'subMsgLoadStripeErr', - APP_DISCOUNT_PREVIEW_ERR: 'subMsgAppDisPrevErr', - FETCH_DEFAULT_PM_ERR: 'subFetchDefaultPmErr', - FETCH_PM_ERR: 'subFetchPmErr', - PMT_METHOD_LIST_ERR: 'subPmtMethodListErr', - EDIT_PM_ERR: 'subEditPMErr', - ADD_PM_ERR: 'subAddPMErr', - DELETE_PM_ERR: 'subDeletePMErr', - CHANGE_PM_ERR: 'subChangePMErr', - INVALID_DATE: 'subInvalidDateErr', - AC_LIST_ERR: 'subAcListErr', - APP_VENDOR_NOT_FOUND: 'app_vendor_not_found', - LOCAL_VENDOR_NOT_FOUND: 'local_vendor_not_found', - PROMO_NOT_FOUND_ERR: 'subMsgPromoNotFoundErr', - PROMO_DUPLICATE_TYPE_PRICEKEY_ERR: 'subMsgPromoDupTypePriceKeyErr', - PROMO_DUPLICATE_COUPON_ERR: 'subMsgPromoDupCouponErr', - PROMO_OVERLAPPING_DATES_ERR: 'subMsgPromoOverlapDatesErr', - PROMO_COUPON_NOT_FOUND_ERR: 'subMsgPromoCouponNotFoundErr' -}); - -// Stripe specific constants -export const SubStripe = Object.freeze({ - PI_AUTH_FAILURE: 'payment_intent_authentication_failure', - PI_IMCOMPAT_PM: 'payment_intent_incompatible_payment_method', - CARD_DECLINED: 'card_declined', - INSUFFICIENT_FUNDS: 'insufficient_funds', - STOLEN_CARD: 'stolen_card', - LOST_CARD: 'lost_card', - EXPIRED_CARD: 'expired_card', - INCORRECT_CVC: 'incorrect_cvc', - PROC_ERR: 'processing_error', - EXCEEDED_VELOCITY: 'card_velocity_exceeded', - REQUIRE_ACTION: 'requires_action', - REQUIRE_PAYMENT_METHOD: 'requires_payment_method', - ACTIVE: 'active', - INCOMPLETE: 'incomplete', - PAST_DUE: 'past_due', - OVERDUE: 'overdue', - UNPAID: 'unpaid', - OPEN: 'open', - CANCELED: 'canceled', - REQ_LOC_INPUT: 'requires_location_inputs', - TRIALING: 'trialing' -}); - -// Subscription constants -export const SUB = Object.freeze({ - BILL_ADR: 'billing-address', - BILL_ADR_LIST: 'billing-address-list', - BILL: 'bill', - BILL_OVEVIEW: 'billing-overview', - CHKOUT: 'checkout', - CHKOUT_CONF: 'checkout-confirm', - CHKOUT_REV: 'checkout-review', - HOME: 'home', - MY_SERVICES: 'myservices', - PM_HISTORY: 'payment-history', - PM_DETAIL: 'payment-detail', - PROFILE: 'profile', - SERVICES: 'services', - REFUND: 'refund', - UNPAID_SUB: 'unpaid-sub', - UPDATE_DEF_PM: 'textUpdateDefPm', - POLLING: 'textPolling', - USAGE_DETAIL: 'usage-detail', - PM_LIST: 'payment-method-list', - AC_REVIEW: 'acReview', - TRACKING_REVIEW: 'trackingReview', - ACTIVE_AC_REVIEW: 'activeAcReview', - TRACKING_AND_ACTIVE_AC_REVIEW: 'trkAndActiveAcReview', - CONTACT: 'contact', -}); - -// Subscription texts (translatable); -export const SubTexts: any = Object.freeze({ - textCancel: $localize`:@@textCancel:Thank you. We hope to see you subscribe again in the future.`, - textConfirm: $localize`:@@textConfirm:We've sent a confirmation to #user#. If this is not your correct email address, please update your profile.`, - textProceed: $localize`:@@textProceed:Click Confirm to procceed to payment.`, - textBackPm: $localize`:@@textBackPm:Use the Back button to change your payment method.`, - textBackSub: $localize`:@@textBackSub:Use the Back button to go to my subscriptions.`, - textEdit: $localize`:@@textEdit:Use the Edit button to make changes.`, - textThank: $localize`:@@textThank:Thank you. We have received your payment.`, - textReview: $localize`:@@textReview:Review the details below and click Submit to finalize your payment.`, - textRefund: $localize`:@@textRefund:Your refund will be processed to the original card used for payment.`, - textRefProc: $localize`:@@textRefProc:Procceed to payment review.`, - textResUnpaid: $localize`:@@textResUnpaid:Please address the outstanding payments for your subscriptions.`, - textPastdue: $localize`:@@textPastdue:Status: You have an overdue payment`, - textUnpaid: $localize`:@@textUnpaid:Status: Your account is currently locked because of unpaid dues`, - textInc: $localize`:@@textInc:Status: Your subscription is incomplete`, - textCanceled: $localize`:@@textCanceled:Status: Your subscription is canceled`, - textActive: $localize`:@@textActive:Status: Active`, - textChangeSub: $localize`:@@textChangeSub:Your subscriptions have been modified. Would you like to continue?`, - textChangeTrial: $localize`:@@textChangeTrial:Your trial subscriptions have been modified. Would you like to continue?`, - - textCancelConf: $localize`:@@textCancelConf:Do you want to cancel all your current subscriptions?`, - textPolling: $localize`:@@textPolling:Resolving issue with unpaid subscription. Please wait...`, - textUpdateDefPm: $localize`:@@textUpdateDefPm:Your default payment method is being updated. Please wait...`, - textReq3ds: $localize`:@@textReq3ds:To complete your payment, 3DS verification is required. Click [Update Payment Information] to continue with the verification process.`, - textUpgradeSub: $localize`:@@upgradeSub:Upgrade Subscription.`, - textUpgradeSubMsg: $localize`:@@upgradeSubMsg:Upgrade your subscription to access more features.`, - textInvalidTime: $localize`:@@invalidTime:Package upgrades are not available within 24 hours of the last change. Please contact support for assistance.`, - textTrial: $localize`:@@textThank:Thank you for subscribing to our trial. Welcome aboard!`, - textContTrial: $localize`:@@textThank:Your subscriptions will automatically renew and be charged at the end of the trial period.`, - textIncCard: $localize`:@@textIncCard:Please complete the #field# field on your card.`, - textAddPMSuccess: $localize`:@@textAddPMSuccess:Payment method added successfully.`, - textEditPMSuccess: $localize`:@@textAddPMSuccess:Your payment method has been successfully updated to #card#.`, - textDeletePM: $localize`:@@textDeletePM:You are about to delete the payment method #card#. Do you wish to continue?`, - textPromoApplied: $localize`:@@textPromoApplied:Promotional pricing has been applied to your subscription. See details below.`, - textDeletePMSuccess: $localize`:@@textDeletePMSuccess:Successfully deleted your default payment method #card#.`, - textDeletePMFailed: $localize`:@@textDeletePMFailed:Removal of #card# as your default payment method was unsuccessful. Please contact support for help.`, - textChangePMSuccess: $localize`:@@textChangePMSuccess:Successfully changed your default payment method to #card#`, - textChangePMFailed: $localize`:@@textChangePMFailed:We couldn't update your default payment method to #card#. Please reach out to support for assistance.`, - contactSupport: $localize`:@@contactSupport:Please contact support for further assistance.`, - - textPkgChng: $localize`:@@textPkgChng:Your package is now #pkg#, allowing for up to #maxAC# aircraft. Please review your active aircraft selections.`, - textTrkChng: $localize`:@@textTrkChng:The quantity of your tracked aircraft has changed to #quantity#. Please review your selections.`, - textPkgTrkChng: $localize`:@@textPkgTrkChng:Your package has been upgraded to #pkg#, allowing a maximum of #maxAC# aircraft. The tracking quantity has been adjusted to #quantity#. Please review your active and tracked aircraft selections.`, - - textReviewAC: $localize`:@@textReviewAC:Verify aircraft selections and click Update to apply changes.`, - textUpdateAC: $localize`:@@textUpdateAC:Confirm the update to your aircraft service selections. Click [Yes] to continue.`, - - textNewPkg: $localize`:@@textNewPkg:You have selected a new package #pkg# allowing up to #maxAC# aircraft. Please review your active aircraft selections.`, - textNewTrk: $localize`:@@textNewTrk:You have added a new tracking quantity #quantity#. Please review your selections.`, - textNewPkgTrk: $localize`:@@textNewPkgTrk:You have selected a new package #pkg# allowing up to #maxAC# aircraft and added a new tracking quantity #quantity#. Please review your active and tracked aircraft selections.`, - - labelBack: $localize`:@@labelBack:Back`, - labelConf: $localize`:@@labelConf:Confirm`, - labelContRev: $localize`:@@labelContRev:Proceed to Review`, - labelContPmt: $localize`:@@labelContPmt:Proceed to Payment`, - labelCrtPm: $localize`:@@labelCrtPm:Create new Payment Method`, - labelStreetAdr: $localize`:@@labelStreetAdr:Your Street Address`, - labelSrvOverview: $localize`:@@labelSrvOverview:Services Overview`, - labelSubmit: $localize`:@@labelSubmit:Submit`, - labelUsePm: $localize`:@@labelUsePm:Use this card as default payment method`, - labelReset: $localize`:@@labelReset:Reset`, - labelResolvePmt: $localize`:@@labelResolvePmt:Update Payment Information`, - labelCreateSub: $localize`:@@labelCreateSub:Create New Subscription Plan`, - labelCreateTrialSub: $localize`:@@labelCreateTrialSub:Create Trial Subscription Plan`, - labelChngSub: $localize`:@@labelChngSub:Modify Your Subscription Plan`, - labelChngTrial: $localize`:@@labelChngTrial:Modify Your Trial Plan`, - labelUpdateAddr: $localize`:@@labelUpdateAddr:Update Address`, - labelContTrial: $localize`:@@labelContTrial:Continue Subscription After Trial`, - labelAutoRenew: $localize`:@@labelAutoRenew:Auto Renew`, - labelEdit: $localize`:@@labelEdit:Edit`, - labelChange: $localize`:@@labelEdit:Change`, - labeAddCC: $localize`:@@labeAddCC:Add a credit card`, - labelSave: $localize`:@@labelSave:Save`, - labelCancel: $localize`:@@labelCancel:Cancel`, - labelResolvePM: $localize`:@@labelResolvePM:Please update your payment method.`, - labelApply: $localize`:@@labelApply:Apply`, - labelSub: $localize`:@@labelSub:Subscription`, - labelChngBilAddr: $localize`:@@labelChngBilAdr:Change Billing Address`, - - unlimited: $localize`:@@unlimited:Unlimited`, - contact: $localize`:@@contact:contact`, - yearly: $localize`:@@yearly:Yearly`, - monthly: $localize`:@@monthly:Monthly`, - priceYearly: $localize`:@@priceYearly:(at #price# / year)`, - priceMonthly: $localize`:@@priceMonthly:(at #price# / month)`, - ending: $localize`:@@ending:ending with`, - agmEss: $localize`:@@agmEss:AgMission Essentials`, - agmEnt: $localize`:@@agmEnt:AgMission Enterprise`, - code: $localize`:@@code:Code`, - off: $localize`:@@off:off`, - dollar: $localize`:@@dollar:Dollar`, - chargesToday: $localize`:@@chargesToday:Charges Today`, - added: $localize`:@@added:Added`, - planRefund: $localize`:@@planRefund:Plan Refund`, - removed: $localize`:@@removed:Removed`, - removedAndRefunding: $localize`:@@removedAndRefunding:Removed (and Refunding)`, - removedRefunding: $localize`:@@removedRefunding:Removed (Refunding)`, - refunding: $localize`:@@refunding:Refunding`, - trial: $localize`:@@trial:Trial`, - paid: $localize`:@@paid:Paid`, - lastTrial: $localize`:@@lastTrial:Last Trial`, - startDate: $localize`:@@startDate:Start Date`, - endDate: $localize`:@@endDate:End Date`, - lastStartDate: $localize`:@@lastStartDate:Last Start Date`, - lastEndDate: $localize`:@@lastEndDate:Last End Date`, - card: $localize`:@@card:Card`, - - incomplete_number: $localize`:@@incNum:Your card number is incomplete.`, - invalid_number: $localize`:@@invNum:Your card number is invalid.`, - incomplete_expiry: $localize`:@@incExp:Your card's expiration date is incomplete.`, - invalid_expiry_month_past: $localize`:@@invMonthPast:Your card's expiration date is in the past.`, - invalid_expiry_year_past: $localize`:@@invYearPast:Your card's expiration year is in the past.`, - incomplete_cvc: $localize`:@@incCvc:Your card's security code is incomplete.`, - - freeTrialUntil: $localize`:@@freeTrialUntil:Free trial until`, - freeTrial: $localize`:@@freeTrial:Free trial`, - freeTrialFor: $localize`:@@freeTrialFor:Free trial for`, - day: $localize`:@@day:day`, - days: $localize`:@@days:days`, - to: $localize`:@@to:to`, - month: $localize`:@@month:month`, - year: $localize`:@@year:year`, - at: $localize`:@@at:at` -}); - -export const SUB_NAME = Object.freeze({ - [SubKeys.ESS_1]: `${SubTexts.agmEss} 1`, - [SubKeys.ESS_1_1]: `${SubTexts.agmEss} 1 Plus`, - [SubKeys.ESS_2]: `${SubTexts.agmEss} 2`, - [SubKeys.ESS_3]: `${SubTexts.agmEss} 3`, - [SubKeys.ESS_4]: `${SubTexts.agmEss} 4`, - [SubKeys.ESS_5]: `${SubTexts.agmEss} 5`, - [SubKeys.ENT_1]: `${SubTexts.agmEnt} 1`, - [SubKeys.ENT_2]: `${SubTexts.agmEnt} 2`, - [SubKeys.ENT_3]: `${SubTexts.agmEnt} 3`, - [SubKeys.ENT_4]: `${SubTexts.agmEnt} 4`, - [SubKeys.ENT_5]: `${SubTexts.agmEnt} 5`, - [SubKeys.TRACKING]: $localize`:@@addon1:Aircraft Tracking`, -}); - -export enum SERVICE_TYPE { ESS = 'essential', ENT = 'enterprise', ADDON = 'addon' }; -const X1 = '1 x'; -export const UNLIMITED = $localize`:@@unlimted:Unlimited`; -export const EMPTY = ''; -const TEN_PLUS = '10+'; -export const subPlans = { - [SubKeys.ESS_1]: { - priceId: 1, maxVehicles: 1, maxAcres: '50000', desc: `${X1} ${SUB_NAME[SubKeys.ESS_1]} ${SubTexts.priceYearly}`, name: SUB_NAME[SubKeys.ESS_1], price: 99500, level: 1, Vehicles: EMPTY, type: SERVICE_TYPE.ESS, lookupKey: SubKeys.ESS_1, interval: 'year' - }, - [SubKeys.ESS_1_1]: { - priceId: 1.1, maxVehicles: 1, maxAcres: null, desc: `${X1} ${SUB_NAME[SubKeys.ESS_1_1]} ${SubTexts.priceYearly}`, name: SUB_NAME[SubKeys.ESS_1_1], price: 139500, level: 1, Vehicles: EMPTY, type: SERVICE_TYPE.ESS, lookupKey: SubKeys.ESS_1_1, interval: 'year' - }, - [SubKeys.ESS_2]: { - priceId: 2, maxVehicles: 2, maxAcres: UNLIMITED, desc: `${X1} ${SUB_NAME[SubKeys.ESS_2]} ${SubTexts.priceYearly}`, name: SUB_NAME[SubKeys.ESS_2], price: 249500, level: 2, Vehicles: EMPTY, type: SERVICE_TYPE.ESS, lookupKey: SubKeys.ESS_2, interval: 'year' - }, - [SubKeys.ESS_3]: { - priceId: 3, maxVehicles: 5, maxAcres: UNLIMITED, desc: `${X1} ${SUB_NAME[SubKeys.ESS_3]} ${SubTexts.priceYearly}`, name: SUB_NAME[SubKeys.ESS_3], price: 349500, level: 3, Vehicles: EMPTY, type: SERVICE_TYPE.ESS, lookupKey: SubKeys.ESS_3, interval: 'year' - }, - [SubKeys.ESS_4]: { - priceId: 4, maxVehicles: 10, maxAcres: UNLIMITED, desc: `${X1} ${SUB_NAME[SubKeys.ESS_4]} ${SubTexts.priceYearly}`, name: SUB_NAME[SubKeys.ESS_4], price: 449500, level: 4, Vehicles: EMPTY, type: SERVICE_TYPE.ESS, lookupKey: SubKeys.ESS_4, interval: 'year' - }, - [SubKeys.ESS_5]: { - priceId: 5, maxVehicles: UNLIMITED, maxAcres: SubTexts.unlimited, desc: `${X1} ${SUB_NAME[SubKeys.ESS_5]} ${SubTexts.priceYearly}`, name: SUB_NAME[SubKeys.ESS_5], price: 899000, level: 5, Vehicles: EMPTY, type: SERVICE_TYPE.ESS, lookupKey: SubKeys.ESS_5, interval: 'year' - }, - [SubKeys.ENT_1]: { - priceId: 6, maxVehicles: 1, maxAcres: UNLIMITED, desc: `${X1} ${SUB_NAME[SubKeys.ENT_1]} ${SubTexts.priceYearly}`, name: SUB_NAME[SubKeys.ENT_1], price: 149500, level: 11, Vehicles: EMPTY, type: SERVICE_TYPE.ENT, lookupKey: SubKeys.ENT_1, interval: 'year' - }, - [SubKeys.ENT_2]: { - priceId: 7, maxVehicles: 1, maxAcres: UNLIMITED, desc: `${X1} ${SUB_NAME[SubKeys.ENT_2]} ${SubTexts.priceYearly}`, name: SUB_NAME[SubKeys.ENT_2], price: 249500, level: 12, Vehicles: EMPTY, type: SERVICE_TYPE.ENT, lookupKey: SubKeys.ENT_2, interval: 'year' - }, - [SubKeys.ENT_3]: { - priceId: 8, maxVehicles: 5, maxAcres: UNLIMITED, desc: `${X1} ${SUB_NAME[SubKeys.ENT_3]} ${SubTexts.priceYearly}`, name: SUB_NAME[SubKeys.ENT_3], price: 499500, level: 13, Vehicles: EMPTY, type: SERVICE_TYPE.ENT, lookupKey: SubKeys.ENT_3, interval: 'year' - }, - [SubKeys.ENT_4]: { - priceId: 9, maxVehicles: 10, maxAcres: UNLIMITED, desc: `${X1} ${SUB_NAME[SubKeys.ENT_4]} ${SubTexts.priceYearly}`, name: SUB_NAME[SubKeys.ENT_4], price: 499500, level: 14, Vehicles: EMPTY, type: SERVICE_TYPE.ENT, lookupKey: SubKeys.ENT_4, interval: 'year' - }, - [SubKeys.ENT_5]: { - priceId: 10, maxVehicles: UNLIMITED, maxAcres: SubTexts.unlimited, desc: `${X1} ${SUB_NAME[SubKeys.ENT_5]} ${SubTexts.priceYearly}`, name: SUB_NAME[SubKeys.ENT_5], price: SubTexts.contact, Vehicles: TEN_PLUS, type: SERVICE_TYPE.ENT, lookupKey: SubKeys.ENT_5, interval: 'year' - - }, - [SubKeys.TRACKING]: { - priceId: 1, desc: `${SUB_NAME[SubKeys.TRACKING]} ${SubTexts.priceMonthly}`, name: SUB_NAME[SubKeys.TRACKING], price: 4995, level: 0, type: SERVICE_TYPE.ADDON, lookupKey: SubKeys.TRACKING, interval: 'month' - }, -} - -/** - * Get descriptive package name from lookup key - * @param lookupKey - Package lookup key (e.g., 'ess_1', 'ent_2') - * @returns Descriptive name (e.g., 'AgMission Essentials 1', 'AgMission Enterprise 2') - */ -export function getPackageDisplayName(lookupKey: string): string { - if (!lookupKey) { - return ''; - } - - // Convert to lowercase to match SubKeys enum values (which are lowercase) - const key = lookupKey.toLowerCase(); - - // Check if key exists in subPlans - if (subPlans[key]) { - return subPlans[key].name; - } - - // Fallback: Format the key (e.g., unknown_key -> Unknown Key) - return lookupKey - .replace(/_/g, ' ') - .split(' ') - .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) - .join(' '); -} - - -export const SubErrMsgs = {} -// stripe errors -SubErrMsgs[SubStripe.REQUIRE_ACTION] = $localize`:@@subMsgReqAction:Please verify your card (#card#) for 3DS authentication. Click 'Submit' to confirm.`; -SubErrMsgs[SubStripe.REQUIRE_PAYMENT_METHOD] = $localize`:@@subMsgReqPm:Your recent invoice couldn't be processed as the card (#card#) is invalid. Please attempt once more with this card, or use a different valid card.`; -SubErrMsgs[SubStripe.PAST_DUE] = $localize`:@@subMsgPastDue:Processing for #card# failed due to overdue account. Please retry with this card once more, or opt for an alternative valid card.`; -SubErrMsgs[SubStripe.UNPAID] = $localize`:@@textUnpaid:Processing of unpaid invoices with #card# was unsuccessful. Please reach out to support for assistance.`; -SubErrMsgs[SubStripe.PI_AUTH_FAILURE] = $localize`:@@subMsgPiAuthFail:Failed to process #card# due to 3DS authentication error. Please click 'Back' to choose a different card or enter a new valid one.`; -SubErrMsgs[SubStripe.PI_IMCOMPAT_PM] = $localize`:@@subMsgPiIncompat:Unable to process #card# as the payment method is incompatible. Please click 'Back' to choose a different card or enter a new valid one.`; -SubErrMsgs[SubStripe.CARD_DECLINED] = $localize`:@@subMsgCardDecline:#card# declined. Please use a different, valid card.`; -SubErrMsgs[SubStripe.INSUFFICIENT_FUNDS] = $localize`:@@subMsgInfFund:#card# has insufficient funds. Please use an alternative card with available balance.`; -SubErrMsgs[SubStripe.STOLEN_CARD] = $localize`:@@subMsgStolenCard:#card# reported as stolen. Please use a different, valid card.`; -SubErrMsgs[SubStripe.LOST_CARD] = $localize`:@@subMsgLostCard:#card# reported as lost. Please use a different, valid card.`; -SubErrMsgs[SubStripe.EXPIRED_CARD] = $localize`:@@subMsgExpCard:#card# is expired. Please use a different, valid card.`; -SubErrMsgs[SubStripe.INCORRECT_CVC] = $localize`:@@subMsgIncorrectCard:#card# has Incorrect CVC. Please verify or use a different, valid card.`; -SubErrMsgs[SubStripe.PROC_ERR] = $localize`:@@subMsgProcErr:#card# could not be completed due to a processing error. Please verify or use a different, valid card.`; -SubErrMsgs[SubStripe.EXCEEDED_VELOCITY] = $localize`:@@subMsgExceededVel:#card# was declined for making repeated attempts too frequently or exceeding its amount limit.`; -SubErrMsgs[SubStripe.REQ_LOC_INPUT] = $localize`:@@subMsgReqLoc:Tax location not recognized. Please click [Update Address] to correct your address details.`; - -// effects errors -SubErrMsgs[SubAppErr.NO_SUBS_ERR] = $localize`:@@subMsgNoSubErr:Your subscriptions are currently not visible. For assistance, please reach out to support.`; -SubErrMsgs[SubAppErr.NO_INVOICES_ERR] = $localize`:@@subMsgNoInvoiceErr:Invoices not found. Please reach out to support for help.`; -SubErrMsgs[SubAppErr.NO_ACTIONS_ERR] = $localize`:@@subMsgNoActionErr:Your recent subscription issue cannot be resolved automatically. Please contact support for assistance.`; -SubErrMsgs[SubAppErr.RES_SUB_ERR] = $localize`:@@subMsgResSubErr:Payment for your recent invoice via #card# was unsuccessful. Please click [Update Payment Information] to settle the outstanding balance.`; -SubErrMsgs[SubAppErr.RES_SUB_ERR_NO_CARD] = $localize`:@@subMsgResSubErrNoCard:Payment for your recent invoice could not be processed due to an unrecognized card. Please click [Update Payment Information] to clear the outstanding balance.`; -SubErrMsgs[SubAppErr.UPDATE_PM_ERR] = $localize`:@@subMsgUpdatePmErr:Updating your payment method was unsuccessful. For assistance, please contact support.`; -SubErrMsgs[SubAppErr.CRT_PM_ERR] = $localize`:@@subMsgCrtPmErr:Creation of payment method card failed. Please reach out to support for help.`; -SubErrMsgs[SubAppErr.CONF_ERR] = $localize`:@@subMsgConfErr:Confirmation of your subscription payment was unsuccessful. Please reach out to support for assistance.` -SubErrMsgs[SubAppErr.CANCEL_ERR] = $localize`:@@subMsgCancelErr:Cancellation of your subscription could not be processed. For assistance, please contact support.`; -SubErrMsgs[SubAppErr.REFUND_ERR] = $localize`:@@subMsgRefundErr:Refund for your subscription could not be processed. Please reach out to support for assistance.`; -SubErrMsgs[SubAppErr.REFRESH_ERR] = $localize`:@@subMsgRefreshErr:Reinitialization of your subscription status was unsuccessful. Please contact support for further assistance.`; -SubErrMsgs[SubAppErr.RES_UNPAID_ERR] = $localize`:@@subMsgResumeUnpaidErr:Resuming your unpaid subscription was not possible. Please reach out to support for assistance.`; -SubErrMsgs[SubAppErr.FETCH_SUB_ERR] = $localize`:@@subMsgFetchSubErr:Retrieving your subscription details was unsuccessful. For assistance, please contact support.`; -SubErrMsgs[SubAppErr.FETCH_PMT_ERR] = $localize`:@@subMsgFetchPmtErr:Fetching your payment invoices was unsuccessful. Please reach out to support for help.`; -SubErrMsgs[SubAppErr.FETCH_USAGE_ERR] = $localize`:@@subMsgFetchUsageErr:Retrieving your usage details was unsuccessful. Please contact support for assistance.`; -SubErrMsgs[SubAppErr.FETCH_SUB_PLANS_ERR] = $localize`:@@subMsgFetchSubPlansErr:Fetching subscription plans was unsuccessful. For assistance, please contact support.`; - -SubErrMsgs[SubAppErr.START_BIL_INFO_ERR] = $localize`:@@subMsgStartBilInfoErr:Initialization of Billing Information failed. Please reach out to support for assistance.`; -SubErrMsgs[SubAppErr.START_CHECKOUT_ERR] = $localize`:@@subMsgStartChkoutErr:Checkout process initialization failed. Please contact support for assistance.`; -SubErrMsgs[SubAppErr.CHECKOUT_TRIAL_ERR] = $localize`:@@subMsgChkoutTrialErr:Starting your trial subscription was unsuccessful. Please reach out to support for help.`; -SubErrMsgs[SubAppErr.UPDATE_SUB_ERR] = $localize`:@@subMsgUpdateSubErr:Subscription update failed. Please contact support for assistance.`; -SubErrMsgs[SubAppErr.POLL_ERR] = $localize`:@@subMsgPollingErr:Monitoring your unpaid subscription was unsuccessful. Please reach out to support for assistance.`; -SubErrMsgs[SubAppErr.PAY_UNPAID_ERR] = $localize`:@@subMsgPayUnpaidErr:Payment for your outstanding subscription could not be processed. Please contact support for assistance.`; -SubErrMsgs[SubAppErr.PAY_UNPAID_CARD_ERR] = $localize`:@@subMsgPayUnpaidCardErr:Payment for your subscription using #card# was unsuccessful. Please choose a different card from your available payment options.`; -SubErrMsgs[SubAppErr.COMP_ACT_ERR] = $localize`:@@subMsgCompActErr:Execution of multiple actions at once was unsuccessful. For assistance, please contact support.` -SubErrMsgs[SubAppErr.APP_DISCOUNT_PREVIEW_ERR] = $localize`:@@subMsgAppDisPrevErr:Invalid discount code. Please check and try again.` -SubErrMsgs[SubAppErr.CHECKOUT_CONT_TRIAL_ERR] = $localize`:@@subMsgChkoutContTrialErr:Extending your trial subscription was unsuccessful. Please reach out to support for assistance.` - -// payment method errors -SubErrMsgs[SubAppErr.FETCH_PM_ERR] = $localize`:@@subFetchPmErr:Unable to retrieve your payment methods. Please contact support for assistance.` -SubErrMsgs[SubAppErr.FETCH_DEFAULT_PM_ERR] = $localize`:@@subFetchDefaultPmErr:Trouble identifying your default subscription payment method. Please update it for continued service.` -SubErrMsgs[SubAppErr.EDIT_PM_ERR] = $localize`:@@subEditPMErr:Editing your payment method was unsuccessful. For assistance, please contact support.` -SubErrMsgs[SubAppErr.ADD_PM_ERR] = $localize`:@@subAddPMErr:Adding your payment method failed. Please reach out to support for help.` -SubErrMsgs[SubAppErr.DELETE_PM_ERR] = $localize`:@@subDeletePMErr:Deletion of your payment method was unsuccessful. Please contact support for assistance.` -SubErrMsgs[SubAppErr.CHANGE_PM_ERR] = $localize`:@@subChangePMErr:Changing your default payment method was unsuccessful. Please reach out to support for assistance.` - -// component errors -SubErrMsgs[SubAppErr.MGE_SERV_ERR] = $localize`:@@subMsgManageServErr:Issue encountered during service management. Please contact support for resolution.`; -SubErrMsgs[SubAppErr.BIL_ADDR_ERR] = $localize`:@@subMsgBillAddrErr:Issue with billing address verification. Please contact support for assistance.`; -SubErrMsgs[SubAppErr.CHKOUT_ERR] = $localize`:@@subMsgChkoutErr:Issue encountered at checkout. Please contact support for assistance.`; -SubErrMsgs[SubAppErr.CHKOUT_REV_ERR] = $localize`:@@subMsgChkoutRevErr:Issue during checkout review. Please contact support for assistance.`; -SubErrMsgs[SubAppErr.CHKOUT_CONF_ERR] = $localize`:@@subMsgChkoutConfErr:Issue during checkout confirmation. Please contact support for assistance.`; -SubErrMsgs[SubAppErr.MGE_SUB_ERR] = $localize`:@@subMsgManageSubErr:Issue managing subscriptions. Please contact support for assistance.`; -SubErrMsgs[SubAppErr.PM_LIST_ERR] = $localize`:@@subMsgPmListErr:Issue with payment list retrieval. Please contact support for assistance.`; -SubErrMsgs[SubAppErr.PM_DETAIL_ERR] = $localize`:@@subMsgPmDetailErr:Issue accessing payment details. Please contact support for assistance.`; -SubErrMsgs[SubAppErr.UNPAID_ERR] = $localize`:@@subMsgUnpaidErr:Issue encountered on the unpaid page. Please contact support for assistance.`; -SubErrMsgs[SubAppErr.INC_ADDR_ERR] = $localize`:@@subMsgIncAddrErr:Please complete all address fields.`; -SubErrMsgs[SubAppErr.INC_CARD_ERR] = $localize`:@@subMsgIncCardErr:Please complete all card information fields.`; -SubErrMsgs[SubAppErr.USAGE_DETAIL_ERR] = $localize`:@@subMsgUsageDetailErr:Issue accessing usage details. Please contact support for assistance.`; -SubErrMsgs[SubAppErr.PMT_METHOD_LIST_ERR] = $localize`:@@subPmtMethodListErr:Issue retrieving payment method list. Please contact support for assistance.`; -SubErrMsgs[SubAppErr.INVALID_DATE] = $localize`:@@subInvalidDateErr:Please enter a valid date.`; -SubErrMsgs[SubAppErr.AC_LIST_ERR] = $localize`:@@subAcListErr:Issue retrieving aircraft list. Please contact support for assistance.`; - -// stripe errors -SubErrMsgs[SubAppErr.LOAD_STRIPE_ERR] = $localize`:@@subMsgLoadStripeErr:Issue loading Stripe system detected. Please contact support for assistance.`; -SubErrMsgs[SubAppErr.STRIPE_ERR] = $localize`:@@subMsgStripeErr:Connection issue with our payment system detected. Please contact support for assistance.`; - -// critical errors -SubErrMsgs[SubAppErr._500_ERR] = $localize`:@@subMsg500Err:Please contact support for assistance.`; -SubErrMsgs[SubAppErr.RES_ERR] = $localize`:@@subMsgResErr:Please contact support for assistance.`; -SubErrMsgs[SubAppErr.APP_VENDOR_NOT_FOUND] = $localize`:@@subMsgAppVendorNotFound:Application vendor not found. Please contact support for assistance.`; -SubErrMsgs[SubAppErr.LOCAL_VENDOR_NOT_FOUND] = $localize`:@@subMsgLocalVendorNotFound:Local vendor not found. Please contact support for assistance.`; - -export const signupCode = Object.freeze({ - userExist: 'user_exist', - signupFailed: 'signupFailed', - invalidAccountErr: 'invalid_account', - activeAccountErr: 'active_account', - invalidTokenErr: 'invalid_token', - unknownErr: 'unknown_error', - signupLoadingError: 'signupLoadingError', - emailError: 'email_error', - verificationCodeExpired: 'verification_code_expired', - invalidVerificationCode: 'invalid_verification_code', - alreadySignedUp: 'already_signed_up', -}); - -export const signupMsg: any = Object.freeze({ - userExist: $localize`:@@userExist:User already exists`, - unknownErr: $localize`:@@unknownErr:An unexpected error occurred during signup. Please try again later or contact support for assistance.`, - activeAccountErr: $localize`:@@activeAccountErr:Account already active. Please login to continue.`, - invalidAccountErr: $localize`:@@invalidAccountErr:Invalid account. Please check your contact email account and try again.`, - invalidTokenErr: $localize`:@@invalidTokenErr:The verification link is invalid or expired. A new email has been sent to your registered address. Please check your inbox.`, - signupLoadingError: $localize`:@@signupLoadingError:An error occurred while loading the signup page. Please try again later or contact support for assistance.`, - emailMsg: $localize`:@@emailMsg:Your email helps us identify you and keep in touch about your account.`, - reValidateMsg: $localize`:@@revalidateMsg:Your email address has been updated. Please verify your new email to keep your account secure.`, - emailError: $localize`:@@emailError:Invalid email address. Please enter a valid email address and re-submit.`, - verificationCodeExpired: $localize`:@@verificationCodeExpired:Your verification code has expired. Please re-verify your email address to receive a new code.`, - invalidVerificationCode: $localize`:@@invalidVerificationCode:Invalid verification code. Please check your email and try again.`, - alreadySignedUp: $localize`:@@alreadySignedUp:You have already signed up with #email#. Please login to continue or Verify with another email.`, -}) - -// ============================================================================ -// PARTNER INTEGRATION ERROR CODES & MESSAGES -// ============================================================================ - -/** - * Partner integration error codes returned from backend API. - * These codes are used to identify specific partner system errors. - * Matches server-side constants in server/helpers/constants.js - * - * More error codes will be added as backend implementation expands. - */ -export const partnerErrorCode = Object.freeze({ - // Current backend error codes (as of 2025-10-10) - partnerServiceUnavailable: 'partner_service_unavailable', - invalidAssignment: 'invalid_assignment', - wrongCredential: 'wrong_credential', - - // Default fallback for unmapped errors - unknownError: 'unknown_error' -}); - -/** - * User-facing error messages for partner integration errors. - * Mapped to partner error codes for consistent UX. - * Matches server-side error codes in server/helpers/constants.js - */ -export const partnerErrorMsg: any = Object.freeze({ - // Current backend error messages (as of 2025-10-10) - partnerServiceUnavailable: $localize`:@@partnerServiceUnavailable:Partner system is currently unavailable. Please try again later.`, - invalidAssignment: $localize`:@@partnerInvalidAssignment:Invalid partner assignment. Please verify your configuration.`, - wrongCredential: $localize`:@@partnerWrongCredential:Invalid username or password. Please check your credentials and try again.`, - - // Default fallback message - unknownError: $localize`:@@partnerUnknownError:An unexpected error occurred with partner system. Please contact support for assistance.` -}); - -/** - * Generic error handler for user and subscription-related operations. - * - * This function processes errors from API calls and transforms them into appropriate - * typed responses based on the error context. It delegates error handling to the - * middleware function `handleSubErr` which will identify the error type and return - * the appropriate response (typically an Observable with an error action). - * - * @template T - The return type of the error handler (usually an Observable with an error action) - * @param {any} err - The error object to process, typically from an HTTP request - * @returns {T} A typed response containing the appropriate error action based on the error context - * - * @example - * // Handle error in an effect - * catchError(err => handleErr>(err)) - * - * @example - * // Handle error with specific error type - * catchError(err => handleErr>(err)) - */ -export const handleErr = (err): T => { - return errorHandler(err, handleSubErr); -} - -export const handleSubErr = (params: { error, opt?}) => { - if (params?.error instanceof HttpErrorResponse) { - const tag = params?.error?.error?.['.tag'] || params?.error?.error?.error?.['.tag']; - if (tag) { - if (params?.opt?.extra == signupCode.signupFailed) { - return handleSignupErr({ error: params.error, opt: { ...params.opt, tag } }); - } - if (hasVendorErr(tag)) { - return handleVendorErr({ error: params.error, opt: { ...params.opt, tag } }); - } - // Preserve custom message if provided, only use tag as fallback - return handleAppErr({ error: params.error, opt: { ...params.opt, msg: params?.opt?.msg || tag } }); - } else { - return handleAppErr(params); - } - } else { - return handleAppErr(params); - } -} - -export const handleSignupErr = (params: { error, opt?}) => { - const tag = params?.opt?.tag; - switch (tag) { - case signupCode.userExist: - return { code: signupCode.userExist, message: signupMsg.userExist }; - case signupCode.activeAccountErr: - return { code: signupCode.activeAccountErr, message: signupMsg.activeAccountErr }; - case signupCode.invalidAccountErr: - return { code: signupCode.invalidAccountErr, message: signupMsg.invalidAccountErr }; - case signupCode.invalidTokenErr: - return { code: signupCode.invalidTokenErr, message: signupMsg.invalidTokenErr }; - case signupCode.signupLoadingError: - return { code: signupCode.signupLoadingError, message: signupMsg.signupLoadingError }; - case signupCode.emailError: - return { code: signupCode.emailError, message: signupMsg.emailError }; - case signupCode.verificationCodeExpired: - return { code: signupCode.verificationCodeExpired, message: signupMsg.verificationCodeExpired }; - case signupCode.invalidVerificationCode: - return { code: signupCode.invalidVerificationCode, message: signupMsg.invalidVerificationCode }; - case signupCode.alreadySignedUp: - return { code: signupCode.alreadySignedUp, message: signupMsg.alreadySignedUp.replace('#email#', params?.opt?.email || '') }; - default: - return { code: signupCode.unknownErr, message: signupMsg.unknownErr }; - } -} - -/** - * Partner integration error handler (standalone function). - * Can be used directly in components or through the middleware chain. - * Extracts .tag from error response and maps to user-facing error messages. - * - * @param {any} error - HttpErrorResponse or error object from API - * @returns {object} Object with code and message properties - * - * @example - * // Direct usage in components - * catchError(err => { - * const errorResult = handlePartnerErr(err); - * this.satlocError = errorResult.message; - * return of(null); - * }) - * - * @example - * // Backend returns: { error: { '.tag': 'invalid_credentials' } } - * // Returns: { code: 'invalid_credentials', message: 'Invalid username or password...' } - */ -export const handlePartnerErr = (error: any): { code: string, message: string } => { - // Extract .tag from error response (supports multiple nesting patterns) - // Backend structure: HttpErrorResponse.error = { error: { ".tag": "...", "message": "..." } } - let tag: string | null = null; - - if (error instanceof HttpErrorResponse) { - // Try deeper nesting first (error.error.error['.tag']), then fallback to error.error['.tag'] - tag = error?.error?.error?.['.tag'] || error?.error?.['.tag']; - } else if (error?.error) { - // For non-HttpErrorResponse objects (e.g., success response with error property) - tag = error?.error?.error?.['.tag'] || error?.error?.['.tag']; - } - - // Map tag to error code and message - // More cases will be added as backend implementation expands - switch (tag) { - // Current backend error codes (as of 2025-10-10) - case partnerErrorCode.partnerServiceUnavailable: - return { code: partnerErrorCode.partnerServiceUnavailable, message: partnerErrorMsg.partnerServiceUnavailable }; - case partnerErrorCode.invalidAssignment: - return { code: partnerErrorCode.invalidAssignment, message: partnerErrorMsg.invalidAssignment }; - case partnerErrorCode.wrongCredential: - return { code: partnerErrorCode.wrongCredential, message: partnerErrorMsg.wrongCredential }; - - // Default fallback for unmapped errors - default: - return { code: partnerErrorCode.unknownError, message: partnerErrorMsg.unknownError }; - } -} - -/** - * Check if error tag is a partner integration error. - * Used by middleware chain to route to handlePartnerErr. - */ -export const hasPartnerErr = (tag: string): boolean => { - return Object.values(partnerErrorCode).includes(tag as any); -} - -const handleVendorErr = (params: { error, opt?}) => { - const tag = params?.opt?.tag == SubAppErr.APP_VENDOR_NOT_FOUND ? SubAppErr.APP_VENDOR_NOT_FOUND : SubAppErr.LOCAL_VENDOR_NOT_FOUND; - const code = params?.opt?.extra; - const message = SubErrMsgs[tag]; - - if (isSubscriptionIntentStatus(code)) { - return of(new UpdateSubscriptionIntentStatus({ code: tag, message })); - } else if (isSubscriptionStatus(code)) { - return of(new UpdateSubscriptionStatus({ code: tag, message })); - } - - switch (code) { - case SubAppErr.FETCH_PMT_ERR: - return of(new FetchError({ code: tag, message })); - case SubAppErr.FETCH_USAGE_ERR: - return of(new FetchUsageFailed({ code: tag, message })); - case SubAppErr.FETCH_SUB_PLANS_ERR: - return of(new FetchSubPlansFailed({ code: tag, message })); - default: - return of(new UpdateSubscriptionStatus({ code: tag, message })); - } -} - -const isSubscriptionIntentStatus = (code): boolean => { - return [ - SubAppErr.CRT_PM_ERR, - SubAppErr.START_BIL_INFO_ERR, - SubAppErr.START_CHECKOUT_ERR, - SubAppErr.CHECKOUT_TRIAL_ERR, - SubAppErr.CHECKOUT_CONT_TRIAL_ERR, - SubAppErr.CANCEL_ERR, - SubAppErr.REFUND_ERR, - SubAppErr.RES_UNPAID_ERR, - SubAppErr.COMP_ACT_ERR, - SubAppErr.STRIPE_ERR, - SubAppErr.LOAD_STRIPE_ERR, - SubAppErr.APP_DISCOUNT_PREVIEW_ERR - ].includes(code); -}; - -const isSubscriptionStatus = (code): boolean => { - return [ - SubAppErr.REFRESH_ERR, - SubAppErr.UPDATE_SUB_ERR, - SubAppErr.FETCH_SUB_ERR, - SubAppErr.POLL_ERR, - SubAppErr.CONF_ERR, - SubAppErr.PAY_UNPAID_ERR, - SubAppErr.NO_INVOICES_ERR, - SubAppErr.NO_SUBS_ERR, - SubAppErr.NO_ACTIONS_ERR, - SubAppErr.ADD_PM_ERR, - SubAppErr.EDIT_PM_ERR, - SubAppErr.DELETE_PM_ERR, - SubAppErr.CHANGE_PM_ERR - ].includes(code); -}; - -export const hasVendorErr = (code): boolean => { - return code == SubAppErr.APP_VENDOR_NOT_FOUND || code == SubAppErr.LOCAL_VENDOR_NOT_FOUND; -} - -const handleAppErr = (params: { error, opt?}) => { - // Handle card-specific errors first (unpaid and decline errors) - const isStripeCardErr = !!params?.opt?.card; - if (isStripeCardErr) { - const declineErr = params?.error?.error?.decline_code || params?.error?.error?.code; - const isUnpaidCardErr = params?.opt?.msg === SubAppErr.PAY_UNPAID_CARD_ERR; - if (isUnpaidCardErr) { - return of(new UpdateUnpaid({ invoices: params?.opt?.extra?.invoices, numOfRetries: 1 }), new UpdateSubscriptionStatus({ code: SubStripe.UNPAID, message: SubErrMsgs[SubAppErr.PAY_UNPAID_CARD_ERR]?.replace('#card#', `${params?.opt?.card?.brand} ${SubTexts.ending} **** ${params?.opt?.card?.last4}`) || '' })); - } else if (declineErr) { - return of(new UpdateSubscriptionStatus({ code: SubStripe.CARD_DECLINED, message: SubErrMsgs[declineErr]?.replace('#card#', `${params?.opt?.card?.brand} ${SubTexts.ending} **** ${params?.opt?.card?.last4}`) || SubErrMsgs[SubStripe.CARD_DECLINED]?.replace('#card#', `${params?.opt?.card?.brand} ${SubTexts.ending} **** ${params?.opt?.card?.last4}`) || '' })); - } - // No decline error - fall through to errTag handling below - } - - // Handle errors by errTag (applies to both card and non-card errors) - const errTag = params?.opt?.extra; - - // subscription intent error status - if (errTag == SubAppErr.CRT_PM_ERR || SubAppErr.CHECKOUT_TRIAL_ERR) { - const declineErr = params?.error?.error?.decline_code || params?.error?.error?.code; - if (errTag == SubAppErr.CRT_PM_ERR) { - return of(new CreatePaymentMethodFailed({ code: SubAppErr.CRT_PM_ERR, message: SubErrMsgs[declineErr]?.replace('#card#', SubTexts.card) || params?.opt?.msg || SubErrMsgs[SubAppErr.CRT_PM_ERR] })); - } else if (errTag == SubAppErr.CHECKOUT_TRIAL_ERR) { - return of(new CreateSubscriptionIntentFailed({ code: SubAppErr.CHECKOUT_TRIAL_ERR, message: SubErrMsgs[declineErr]?.replace('#card#', SubTexts.card) || params?.opt?.msg || SubErrMsgs[SubAppErr.CHECKOUT_TRIAL_ERR] })); - } - } - - switch (errTag) { - - // subscription intent error status - case SubAppErr.START_BIL_INFO_ERR: - return of(new CreateSubscriptionIntentFailed({ code: SubAppErr.START_BIL_INFO_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.START_BIL_INFO_ERR] })); - case SubAppErr.START_CHECKOUT_ERR: - return of(new CreateSubscriptionIntentFailed({ code: SubAppErr.START_CHECKOUT_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.START_CHECKOUT_ERR] })); - case SubAppErr.CHECKOUT_CONT_TRIAL_ERR: - return of(new CreateSubscriptionIntentFailed({ code: SubAppErr.CHECKOUT_CONT_TRIAL_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.CHECKOUT_CONT_TRIAL_ERR] })); - case SubAppErr.CANCEL_ERR: - return of(new CreateSubscriptionIntentFailed({ code: SubAppErr.CANCEL_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.CANCEL_ERR] })); - case SubAppErr.REFUND_ERR: - return of(new CreateSubscriptionIntentFailed({ code: SubAppErr.REFUND_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.REFUND_ERR] })); - case SubAppErr.RES_UNPAID_ERR: - return of(new CreateSubscriptionIntentFailed({ code: SubAppErr.RES_UNPAID_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.RES_UNPAID_ERR] })); - case SubAppErr.COMP_ACT_ERR: - return of(new UpdateSubscriptionIntentStatus({ code: SubAppErr.COMP_ACT_ERR, message: SubErrMsgs[SubAppErr.COMP_ACT_ERR] })); - case SubAppErr.STRIPE_ERR: - return of(new UpdateSubscriptionIntentStatus({ code: SubAppErr.STRIPE_ERR, message: SubErrMsgs[SubAppErr.STRIPE_ERR] })); - case SubAppErr.LOAD_STRIPE_ERR: - return of(new LoadStripeFailed({ code: SubAppErr.LOAD_STRIPE_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.LOAD_STRIPE_ERR] })); - case SubAppErr.APP_DISCOUNT_PREVIEW_ERR: - return of(new ApplyDiscountPreviewFailed({ code: SubAppErr.APP_DISCOUNT_PREVIEW_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.APP_DISCOUNT_PREVIEW_ERR] })); - - // subscription error status - case SubAppErr.REFRESH_ERR: - return of(new UpdateSubscriptionStatus({ code: SubAppErr.REFRESH_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.REFRESH_ERR] })); - case SubAppErr.UPDATE_SUB_ERR: - return of(new UpdateSubscriptionStatus({ code: SubAppErr.UPDATE_SUB_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.UPDATE_SUB_ERR] })); - case SubAppErr.FETCH_SUB_ERR: - return of(new UpdateSubscriptionStatus({ code: SubAppErr.FETCH_SUB_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.FETCH_SUB_ERR] })); - case SubAppErr.POLL_ERR: - return of(new UpdateSubscriptionStatus({ code: SubAppErr.POLL_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.POLL_ERR] })); - case SubAppErr.CONF_ERR: - return of(new UpdateSubscriptionStatus({ code: SubAppErr.CONF_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.CONF_ERR] })); - case SubAppErr.PAY_UNPAID_ERR: - return of(new UpdateSubscriptionStatus({ code: SubAppErr.PAY_UNPAID_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.PAY_UNPAID_ERR] })); - case SubAppErr.NO_INVOICES_ERR: - return of(new UpdateSubscriptionStatus({ code: SubAppErr.NO_INVOICES_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.NO_INVOICES_ERR] })); - case SubAppErr.NO_SUBS_ERR: - return of(new UpdateSubscriptionStatus({ code: SubAppErr.NO_SUBS_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.NO_SUBS_ERR] })); - case SubAppErr.NO_ACTIONS_ERR: - return of(new UpdateSubscriptionStatus({ code: SubAppErr.NO_ACTIONS_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.NO_ACTIONS_ERR] })); - case SubAppErr.ADD_PM_ERR: - return of(new UpdateSubscriptionStatus({ code: SubAppErr.ADD_PM_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.ADD_PM_ERR] })); - case SubAppErr.EDIT_PM_ERR: - return of(new UpdateSubscriptionStatus({ code: SubAppErr.EDIT_PM_ERR, message: SubErrMsgs[SubAppErr.EDIT_PM_ERR] })); - case SubAppErr.DELETE_PM_ERR: - return of(new UpdateSubscriptionStatus({ code: SubAppErr.DELETE_PM_ERR, message: SubErrMsgs[SubAppErr.DELETE_PM_ERR] })); - case SubAppErr.CHANGE_PM_ERR: - return of(new UpdateSubscriptionStatus({ code: SubAppErr.CHANGE_PM_ERR, message: SubErrMsgs[SubAppErr.CHANGE_PM_ERR] })); - - // payment method error status - case SubAppErr.FETCH_PMT_ERR: - return of(new FetchError({ code: SubAppErr.FETCH_PMT_ERR, message: SubErrMsgs[SubAppErr.FETCH_PMT_ERR] })); - - // usage error status - case SubAppErr.FETCH_USAGE_ERR: - return of(new FetchUsageFailed({ code: SubAppErr.FETCH_USAGE_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.FETCH_USAGE_ERR] })); - - // subplan error status - case SubAppErr.FETCH_SUB_PLANS_ERR: - return of(new FetchSubPlansFailed({ code: SubAppErr.FETCH_SUB_PLANS_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.FETCH_SUB_PLANS_ERR] })); - - // default error status - default: - return of(new UpdateSubscriptionStatus({ code: SubAppErr._500_ERR, message: SubErrMsgs[SubAppErr._500_ERR] })); - } -} - -export const createSubStatus = (code: string, opt?: { card?: Card, extra?: any, msg?: string }): Status => { - switch (code) { - case SubStripe.REQUIRE_ACTION: - return { code: SubStripe.REQUIRE_ACTION, message: opt?.card ? SubErrMsgs[SubStripe.REQUIRE_ACTION]?.replace('#card#', `${opt?.card?.brand} ${SubTexts.ending} **** ${opt?.card?.last4}`) : SubErrMsgs[SubTexts.textReq3ds] }; - case SubStripe.REQUIRE_PAYMENT_METHOD: - return { code: SubStripe.REQUIRE_PAYMENT_METHOD, message: SubErrMsgs[SubStripe.REQUIRE_PAYMENT_METHOD]?.replace('#card#', `${opt?.card?.brand} ${SubTexts.ending} **** ${opt?.card?.last4}`) || '' }; - case SubStripe.CANCELED: - return { code: SubStripe.CANCELED, message: '' }; - case SubStripe.PAST_DUE: - return { code: SubStripe.PAST_DUE, message: SubErrMsgs[SubStripe.PAST_DUE]?.replace('#card#', `${opt?.card?.brand} ${SubTexts.ending} **** ${opt?.card?.last4}`) || '' } - case SubStripe.REQ_LOC_INPUT: - return { code: SubStripe.REQ_LOC_INPUT, message: SubErrMsgs[SubStripe.REQ_LOC_INPUT] }; - case SUB.UPDATE_DEF_PM: - return { code: SUB.UPDATE_DEF_PM, message: SubTexts.textUpdateDefPm }; - case SUB.POLLING: - return { code: SUB.POLLING, message: SubTexts.textPolling }; - case SUB.POLLING: - return { code: SUB.POLLING, message: SubTexts.textPolling }; - case SubAppErr.NO_INVOICES_ERR: - return { code: SubAppErr.NO_INVOICES_ERR, message: SubErrMsgs[SubAppErr.NO_INVOICES_ERR] }; - case SubAppErr.CONF_ERR: - return { code: SubAppErr.CONF_ERR, message: opt?.extra?.decline_code ? SubErrMsgs[opt?.extra?.decline_code]?.replace('#card#', `${opt?.card?.brand} ${SubTexts.ending} **** ${opt?.card?.last4}`) : opt?.extra?.code ? SubErrMsgs[opt?.extra?.code]?.replace('#card#', `${opt?.card?.brand} ${SubTexts.ending} **** ${opt?.card?.last4}`) : SubErrMsgs[SubAppErr.CONF_ERR] }; - case SubAppErr.MGE_SERV_ERR: - return { code: SubAppErr.MGE_SERV_ERR, message: SubErrMsgs[SubAppErr.MGE_SERV_ERR] }; - case SubAppErr.BIL_ADDR_ERR: - return { code: SubAppErr.BIL_ADDR_ERR, message: SubErrMsgs[SubAppErr.BIL_ADDR_ERR] }; - case SubAppErr.CHKOUT_ERR: - return { code: SubAppErr.CHKOUT_ERR, message: SubErrMsgs[SubAppErr.CHKOUT_ERR] }; - case SubAppErr.CHKOUT_REV_ERR: - return { code: SubAppErr.CHKOUT_REV_ERR, message: SubErrMsgs[SubAppErr.CHKOUT_REV_ERR] }; - case SubAppErr.CHKOUT_CONF_ERR: - return { code: SubAppErr.CHKOUT_CONF_ERR, message: SubErrMsgs[SubAppErr.CHKOUT_CONF_ERR] }; - case SubAppErr.MGE_SUB_ERR: - return { code: SubAppErr.MGE_SUB_ERR, message: SubErrMsgs[SubAppErr.MGE_SUB_ERR] }; - case SubAppErr.PM_LIST_ERR: - return { code: SubAppErr.PM_LIST_ERR, message: SubErrMsgs[SubAppErr.PM_LIST_ERR] }; - case SubAppErr.PM_DETAIL_ERR: - return { code: SubAppErr.PM_DETAIL_ERR, message: SubErrMsgs[SubAppErr.PM_DETAIL_ERR] }; - case SubAppErr.UNPAID_ERR: - return { code: SubAppErr.UNPAID_ERR, message: SubErrMsgs[SubAppErr.UNPAID_ERR] }; - case SubAppErr.RES_SUB_ERR: - return { code: opt?.extra || SubAppErr.RES_SUB_ERR, message: opt?.card ? SubErrMsgs[SubAppErr.RES_SUB_ERR]?.replace('#card#', `${opt?.card?.brand} ${SubTexts.ending} **** ${opt?.card?.last4}`) : SubErrMsgs[SubAppErr.RES_SUB_ERR_NO_CARD] }; - case SubAppErr.PMT_METHOD_LIST_ERR: - return { code: SubAppErr.PMT_METHOD_LIST_ERR, message: SubErrMsgs[SubAppErr.PMT_METHOD_LIST_ERR] }; - case SubAppErr.INC_ADDR_ERR: - return { code: SubAppErr.INC_ADDR_ERR, message: SubErrMsgs[SubAppErr.INC_ADDR_ERR] }; - case SubAppErr.FETCH_DEFAULT_PM_ERR: - return { code: SubAppErr.FETCH_DEFAULT_PM_ERR, message: SubErrMsgs[SubAppErr.FETCH_DEFAULT_PM_ERR] }; - case SubAppErr.FETCH_PM_ERR: - return { code: SubAppErr.FETCH_PM_ERR, message: SubErrMsgs[SubAppErr.FETCH_PM_ERR] }; - case SUB.AC_REVIEW: - return { code: SUB.AC_REVIEW, message: opt?.msg || SubTexts.textReviewAC }; - case SubAppErr.INC_CARD_ERR: - return { code: SubAppErr.INC_CARD_ERR, message: SubErrMsgs[SubAppErr.INC_CARD_ERR] }; - case SubAppErr.AC_LIST_ERR: - return { code: SubAppErr.AC_LIST_ERR, message: SubErrMsgs[SubAppErr.AC_LIST_ERR] }; - default: - return { code: SubAppErr._500_ERR, message: SubErrMsgs[SubAppErr._500_ERR] }; - } -} diff --git a/Development/client/src/app/profile/coupon/coupon.component.css b/Development/client/src/app/profile/coupon/coupon.component.css deleted file mode 100644 index 390ce2d..0000000 --- a/Development/client/src/app/profile/coupon/coupon.component.css +++ /dev/null @@ -1,7 +0,0 @@ -.pad-bottom { - padding-bottom: 0; -} - -.left-align { - text-align: left; -} \ No newline at end of file diff --git a/Development/client/src/app/profile/coupon/coupon.component.html b/Development/client/src/app/profile/coupon/coupon.component.html deleted file mode 100644 index aff5e18..0000000 --- a/Development/client/src/app/profile/coupon/coupon.component.html +++ /dev/null @@ -1,19 +0,0 @@ -
-
Coupon
- - - -
{{getCouponName(coupon)}}
-
-
- -
- -
{{error}}
-
-
-
\ No newline at end of file diff --git a/Development/client/src/app/profile/coupon/coupon.component.ts b/Development/client/src/app/profile/coupon/coupon.component.ts deleted file mode 100644 index e4fbcef..0000000 --- a/Development/client/src/app/profile/coupon/coupon.component.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { Component, EventEmitter, Input, Output, OnChanges, SimpleChanges } from '@angular/core'; -import { SubTexts } from '../common'; - -// Coupon interface to display name instead of ID -interface CouponDisplay { - id: string; - name: string; -} - -@Component({ - selector: 'coupon', - templateUrl: './coupon.component.html', - styleUrls: ['./coupon.component.css'] -}) -export class CouponComponent implements OnChanges { - readonly SubTexts = SubTexts; - - @Input() coupons: string[] | CouponDisplay[]; - @Input() error: string; - @Output() appCoupEvt = new EventEmitter(); - @Output() remvCoupEvt = new EventEmitter(); - couponCode: string; - private previousCouponCount = 0; - - ngOnChanges(changes: SimpleChanges): void { - // Clear input when a coupon is successfully added - if (changes['coupons'] && this.coupons) { - const currentCount = this.coupons.length; - // If count increased, a coupon was successfully added - if (currentCount > this.previousCouponCount) { - this.couponCode = ''; - } - this.previousCouponCount = currentCount; - } - } - - // Helper to get coupon ID (supports both string[] and CouponDisplay[]) - getCouponId(coupon: string | CouponDisplay): string { - return typeof coupon === 'string' ? coupon : coupon.id; - } - - // Helper to get coupon display name - getCouponName(coupon: string | CouponDisplay): string { - return typeof coupon === 'string' ? coupon : (coupon.name || coupon.id); - } - - get cantApply() { - return this.coupons?.some((coupon) => this.getCouponId(coupon) === this.couponCode); - } - - applyCoupon() { - if (this.couponCode) { - if (this.cantApply) return this.error = $localize`:@@couponExist:Coupon already exist.`; - this.appCoupEvt.emit(this.couponCode); - } - } - - removeCoupon(coupon: string | CouponDisplay) { - const codeToRemove = this.getCouponId(coupon); - if (this.coupons) { - const filtered = (this.coupons as any[]).filter(c => this.getCouponId(c) !== codeToRemove); - this.coupons = filtered as string[] | CouponDisplay[]; - } - // Update previousCouponCount since we locally modified the array - this.previousCouponCount = this.coupons?.length || 0; - this.couponCode = ''; - this.error = ''; - this.remvCoupEvt.emit(codeToRemove); - } - - onChange() { - this.error = ''; - } - - handleErr() { - console.log(this.error); - } -} diff --git a/Development/client/src/app/profile/effects/payment.effects.ts b/Development/client/src/app/profile/effects/payment.effects.ts deleted file mode 100644 index f27e037..0000000 --- a/Development/client/src/app/profile/effects/payment.effects.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Injectable } from '@angular/core'; -import { Action } from '@ngrx/store'; -import { Actions, Effect, ofType } from '@ngrx/effects'; -import { SubscriptionService } from '@app/domain/services/subscription.service'; -import { Observable } from 'rxjs'; -import * as paymentAction from '../actions/payment.action'; -import { catchError, map, repeat, switchMap } from 'rxjs/operators'; -import { Charge, Invoice, Payment } from '@app/domain/models/subscription.model'; -import { FetchSuccess } from '../actions/payment.action'; -import { SubAppErr, handleErr, InvType } from '../common'; - -@Injectable() -export class PaymentEffects { - - constructor( - private readonly actions$: Actions, - private readonly subSvc: SubscriptionService - ) { } - - @Effect() - fetchPayments$: Observable = this.actions$.pipe( - ofType(paymentAction.FETCH), - switchMap((action: paymentAction.Fetch) => { - return this.subSvc.fetchPayments({custId: action.payload.custId, byTime: action.payload.byTime}).pipe( - map((res: { invoices: Invoice[], charges: Charge[] }) => { - const charges: Charge[] = res.charges?.map((charge) => ({ ...charge, type: InvType.CHARGE })) || []; - const invoices: Invoice[] = res.invoices?.map((invoice) => ({ ...invoice, type: InvType.INVOICE })) || []; - const payments: Payment[] = [...charges, ...invoices]; - return new FetchSuccess({payments, curTime: action.payload.byTime}); - }) - ) - }), - catchError((err) => { - return handleErr>({ - error: err, opt: { - extra: SubAppErr.FETCH_PMT_ERR - } - }); - }), - repeat() - ); -} \ No newline at end of file diff --git a/Development/client/src/app/profile/effects/usage.effects.ts b/Development/client/src/app/profile/effects/usage.effects.ts deleted file mode 100644 index c961c3d..0000000 --- a/Development/client/src/app/profile/effects/usage.effects.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { Injectable } from '@angular/core'; -import { Action } from '@ngrx/store'; -import { Actions, Effect, ofType } from '@ngrx/effects'; -import { SubscriptionService } from '@app/domain/services/subscription.service'; -import { Observable, of } from 'rxjs'; -import * as usageAction from '../actions/usage.actions'; -import { catchError, map, repeat, retryWhen, switchMap, delay, take } from 'rxjs/operators'; -import { BillPeriod, Usage, UsageDetail } from '@app/domain/models/subscription.model'; -import { DELAY, SubAppErr, TAKE, handleErr, subPlans } from '../common'; -import { UnitUtils } from '@app/shared/utils'; - -@Injectable() -export class UsageEffects { - - constructor( - private readonly actions$: Actions, - private readonly subSvc: SubscriptionService - ) { } - - @Effect() - fetchUsage$: Observable = this.actions$.pipe( - ofType(usageAction.FETCH_USAGE), - switchMap((action: usageAction.FetchUsage) => { - return action.payload.period ? this.fetchUsageForSpecificPeriod(action) : this.fetchUsageForLatestPeriod(action); - }), - retryWhen(errors => errors.pipe( - delay(DELAY), - take(TAKE) - )), - catchError((err) => { - return handleErr>({ - error: err, opt: { - extra: SubAppErr.FETCH_USAGE_ERR - } - }); - }), - repeat() - ); - - private calcUsageDetail( - usage: Usage, - lookupKey: string, - periods: { periodStart: number, periodEnd: number }, - billPeriods?: BillPeriod[], - effectiveMaxAcres?: number | null - ): usageAction.FetchUsageSuccess { - const pkg = subPlans[lookupKey]; - if (pkg) { - // Use MongoDB-prioritized maxAcres if provided, otherwise fall back to static subPlans - const pkgMaxAcre = effectiveMaxAcres !== undefined - ? effectiveMaxAcres - : pkg.maxAcres; - - const periodStart = periods.periodStart; - const periodEnd = periods.periodEnd; - const ttArea = UnitUtils.haToArea(Number(usage.ttArea), true); - - let usageDetail: UsageDetail = { - periodStart, - periodEnd, - dayPercentage: this.subSvc.dayUsedPerct(periodStart, periodEnd), - dayLeft: this.subSvc.curDayRemain(periodEnd), - maxAcre: this.subSvc.convMaxAcre(pkgMaxAcre), - ttArea, - acrePercentage: this.subSvc.acrUsedPerct(ttArea, pkgMaxAcre), - jobUsages: usage.jobUsages?.map(jobUsage => ({ - ...jobUsage, - ttSprArea: UnitUtils.haToArea(jobUsage.ttSprArea, true), - totalSprayed: UnitUtils.haToArea(jobUsage.totalSprayed, true) - })) || [], - }; - - if (billPeriods && billPeriods.length) usageDetail = { ...usageDetail, billPeriods }; - return new usageAction.FetchUsageSuccess(usageDetail); - } - return new usageAction.FetchUsageSuccess(void 0); - } - - private fetchUsageForSpecificPeriod(action: usageAction.FetchUsage): Observable { - const periodStart = action.payload.period.fromTS; - const periodEnd = action.payload.period.toTS; - const effectiveMaxAcres = action.payload.effectiveMaxAcres; - - return this.subSvc.retrieveUsage({ - byPuid: action.payload.byPuid, - fromTS: periodStart, - toTS: periodEnd - }).pipe( - map((usage: Usage) => this.calcUsageDetail( - usage, - action.payload.lookupKey, - { periodStart, periodEnd }, - undefined, - effectiveMaxAcres - )) - ); - } - - private fetchUsageForLatestPeriod(action: usageAction.FetchUsage): Observable { - const effectiveMaxAcres = action.payload.effectiveMaxAcres; - - return this.subSvc.retrieveBilPeriod(action.payload.custId).pipe( - switchMap((_billPeriods: BillPeriod[]) => { - if (!_billPeriods || !_billPeriods.length) return of(new usageAction.FetchUsageSuccess(void 0)); - - const latestPeriod = _billPeriods.reduce((acc, curr) => { - if (curr.periodStart > acc.periodStart) { - return curr; - } - return acc; - }, _billPeriods[0]); - - const periodStart = latestPeriod.periodStart; - const periodEnd = latestPeriod.periodEnd; - - return this.subSvc.retrieveUsage({ - byPuid: action.payload.byPuid, - fromTS: periodStart, - toTS: periodEnd - }).pipe( - map((usage: Usage) => this.calcUsageDetail( - usage, - latestPeriod.lookupKey, - { periodStart, periodEnd }, - _billPeriods, - effectiveMaxAcres - )) - ); - }) - ); - } -} \ No newline at end of file diff --git a/Development/client/src/app/profile/manage-services/manage-services.component.css b/Development/client/src/app/profile/manage-services/manage-services.component.css index 0b35752..e69de29 100644 --- a/Development/client/src/app/profile/manage-services/manage-services.component.css +++ b/Development/client/src/app/profile/manage-services/manage-services.component.css @@ -1,412 +0,0 @@ -.price { - font-weight: bold; -} - -/* ============================================================================ - * PROMO BANNER STYLES (Using agm-constraint-message) - * ============================================================================ */ - -/* Override constraint-message defaults for promo banners in this context */ -::ng-deep agm-constraint-message.promo-banner-packages .agm-constraint-message, -::ng-deep agm-constraint-message.promo-banner-addons .agm-constraint-message { - margin-top: 0; - margin-bottom: 8px; - max-width: 100%; -} - -/* Override constraint-message icon with Material Icons local_offer */ -::ng-deep agm-constraint-message .agm-constraint-icon.ui-icon-local-offer { - font-family: 'Material Icons'; - font-weight: normal; - font-style: normal; - font-size: 1.25rem; - display: inline-block; - line-height: 1; - text-transform: none; - letter-spacing: normal; - word-wrap: normal; - white-space: nowrap; - direction: ltr; - -webkit-font-smoothing: antialiased; - text-rendering: optimizeLegibility; - -moz-osx-font-smoothing: grayscale; - font-feature-settings: 'liga'; -} - -::ng-deep agm-constraint-message .agm-constraint-icon.ui-icon-local-offer::before { - content: "local_offer"; -} - -/* ============================================================================ - * PRICE CELL STYLES WITH PROMO (Option A - Inline Strikethrough) - * ============================================================================ */ - -/* Price cell container */ -.price-cell { - font-weight: bold; -} - -/* Regular price (no promo) */ -.regular-price { - color: #212121; -} - -/* Price content wrapper for promo display */ -.price-content { - display: inline-flex; - flex-wrap: wrap; - align-items: center; - justify-content: center; - /* Center content when it wraps to multiple lines */ - gap: 4px; -} - -/* Original price with strikethrough */ -.original-price { - text-decoration: line-through; - color: #757575; - font-weight: normal; - font-size: 0.9em; - white-space: nowrap; -} - -/* Arrow between prices */ -.price-arrow { - margin: 0 4px; - color: #4CAF50; - font-weight: bold; -} - -/* New promo price */ -.promo-price { - color: #2E7D32; - font-weight: bold; - white-space: nowrap; -} - -/* Promo line wrapper - keeps price and badge together */ -.promo-line { - display: inline-flex; - align-items: center; -} - -/* ============================================================================ - * PROMO NAME IN NAME COLUMN (Name only, no valid until date) - * Shows promo name below package/addon name in Name column - * Valid until date moved to price column below prices - * ============================================================================ */ - -/* Package promo name only - Dual-mode styling: Blue for active, green for available */ -.package-promo-name-only { - margin-top: 4px; -} - -/* Promo name with emoji - Available promo (green) */ -.package-promo-name-only.available-promo .promo-name { - font-size: 0.85em; - color: #2E7D32; - /* AgMission primary dark green */ - font-weight: 500; -} - -/* Addon promo name only - Dual-mode styling: Blue for active, green for available */ -.addon-promo-name-only { - margin-top: 4px; -} - -/* Promo name with emoji - Available promo (green) */ -.addon-promo-name-only.available-promo .promo-name { - font-size: 0.85em; - color: #2E7D32; - /* AgMission primary dark green */ - font-weight: 500; -} - -/* ============================================================================ - * PRICE WITH PROMO - VALID UNTIL DATE BELOW PRICES - * Shows prices with valid until date below in price column - * ============================================================================ */ - -/* Price with promo container - vertical layout */ -.price-with-promo { - display: flex; - flex-direction: column; - gap: 8px; - align-items: center; - /* Center all content horizontally */ -} - -/* Promo validity date below prices - secondary text */ -.promo-validity { - font-size: 0.75em; - color: #757575; - /* AgMission secondary text color */ - font-weight: normal; - font-style: italic; - margin-top: 4px; -} - -/* Name cell styling for proper vertical layout */ -.package-name-cell, -.addon-name-cell { - vertical-align: top; -} - -/* Caption with subtitle container - Package table */ -::ng-deep .prices-table .caption-with-subtitle { - display: flex; - flex-direction: row; - align-items: baseline; - gap: 0.5rem; - /* 8px horizontal spacing between caption and duration */ -} - -/* Caption with subtitle container - Addon table */ -::ng-deep .addons-table .caption-with-subtitle { - display: flex; - flex-direction: row; - align-items: baseline; - gap: 0.5rem; - /* 8px horizontal spacing between caption and duration */ -} - -/* Main caption styling (preserve existing styles if any) */ -.main-caption { - font-weight: bold; -} - -/* Duration subtitle styling */ -.duration-subtitle { - font-size: 0.85em; - font-style: italic; - color: #757575; - /* AgMission secondary text color */ - font-weight: normal; - letter-spacing: 0.25px; -} - -/* Addon table subtitle - white text for green background (WCAG compliance) */ -::ng-deep .addons-table .duration-subtitle { - color: #ffffff; - /* White text for sufficient contrast on green background (21:1 ratio) */ -} - -/* Mobile responsiveness */ -@media (max-width: 768px) { - ::ng-deep .prices-table .caption-with-subtitle { - gap: 0.375rem; - /* 6px horizontal spacing on mobile for package table */ - } - - ::ng-deep .addons-table .caption-with-subtitle { - gap: 0.375rem; - /* 6px horizontal spacing on mobile for addon table */ - } - - .duration-subtitle { - font-size: 0.8em; - } - - /* - * Two-Line Stacked Layout for Mobile: - * Line 1: Strikethrough original price (smaller, muted) - * Line 2: Promo price + badge (prominent) - * This keeps full context while fitting narrower columns - */ - .price-content { - display: flex; - flex-direction: column; - align-items: center; - /* Center content on mobile */ - gap: 4px; - } - - /* Hide arrow on mobile - stacked layout replaces it */ - .price-arrow { - display: none; - } - - /* Original price - smaller and muted on line 1 */ - .original-price { - font-size: 0.8em; - color: #757575; - /* textSecondaryColor - de-emphasized */ - } - - /* Promo line wrapper - keeps price + badge together on line 2 */ - .promo-line { - display: flex; - align-items: center; - flex-wrap: nowrap; - } - - /* Promo price + label wrapper - stays on line 2 */ - .promo-price { - font-weight: bold; - } - - /* Promo label inline with new price on line 2 */ - .promo-label { - margin-left: 6px; - font-size: 0.7em; - padding: 2px 6px; - } -} - -/* Extra small screens - tighter spacing */ -@media (max-width: 480px) { - .original-price { - font-size: 0.75em; - } - - .promo-label { - font-size: 0.65em; - padding: 2px 4px; - } -} - -/* ============================================================================ - * PROMO EXPIRY COUNTDOWN STYLES (r948+ promoDetails) - * ============================================================================ */ - -/* Promo expiry information for time-limited promos */ -.promo-expiry-info { - display: inline-flex; - align-items: center; - gap: 4px; - margin-left: 8px; - padding: 2px 6px; - background: #FFF3E0; - /* Light amber background */ - border-left: 3px solid #FF9800; - /* AgMission warning orange */ - border-radius: 3px; - color: #E65100; - /* Dark orange text */ - font-size: 0.8125rem; -} - -.promo-expiry-info .pi { - font-size: 0.75rem; -} - -/* Responsive adjustments for promo expiry display */ -@media (max-width: 768px) { - .promo-expiry-info { - display: block; - margin-left: 0; - margin-top: 4px; - font-size: 0.75rem; - } -} - -/* ============================================================================ - * LEGACY ESS_1 AND ESS_1_1 UPGRADE STYLES (Option A) - * ============================================================================ */ - -/* Legacy badge for ESS_1 - DEPRECATED: Badge removed per user request */ -/* Discontinuation notice now uses agm-legacy-notice-label component */ -/* See: /client/src/app/shared/legacy-notice-label/ */ - -/* ============================================================================ - * SKELETON LOADER STYLES (Task 04 - Eliminate Double Render) - * ============================================================================ */ - -.skeleton-loader { - padding: 1.5rem; - background: #ffffff; -} - -.skeleton-header { - margin-bottom: 1.5rem; -} - -.skeleton-title { - width: 60%; - height: 28px; - background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); - background-size: 200% 100%; - animation: shimmer 1.5s infinite; - border-radius: 4px; -} - -.skeleton-table { - display: flex; - flex-direction: column; - gap: 12px; -} - -.skeleton-row { - display: flex; - gap: 12px; - padding: 16px; - background: #f9f9f9; - border-radius: 4px; -} - -.skeleton-cell { - height: 20px; - background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); - background-size: 200% 100%; - animation: shimmer 1.5s infinite; - border-radius: 4px; -} - -.skeleton-name { - flex: 4; -} - -.skeleton-vehicles { - flex: 1.5; -} - -.skeleton-acres { - flex: 1.5; -} - -.skeleton-price { - flex: 3; -} - -.skeleton-loading-text { - text-align: center; - color: #757575; - font-size: 14px; - margin-top: 1.5rem; - display: flex; - align-items: center; - justify-content: center; - gap: 8px; -} - -.skeleton-loading-text i { - font-size: 1.2rem; - color: #4CAF50; -} - -@keyframes shimmer { - 0% { - background-position: -200% 0; - } - - 100% { - background-position: 200% 0; - } -} - -/* Responsive skeleton on mobile */ -@media (max-width: 768px) { - .skeleton-row { - flex-direction: column; - gap: 8px; - } - - .skeleton-cell { - width: 100%; - } -} - -/* Minimum width for payment summary cards to prevent layout collapse */ -.card.in-card-pad { - min-width: 300px; -} diff --git a/Development/client/src/app/profile/manage-services/manage-services.component.html b/Development/client/src/app/profile/manage-services/manage-services.component.html index f2f328e..56d3b99 100644 --- a/Development/client/src/app/profile/manage-services/manage-services.component.html +++ b/Development/client/src/app/profile/manage-services/manage-services.component.html @@ -1,232 +1,96 @@ - -
-
-
+
+
+
+
-
- - -
-
-
+
+
+
+
+ + + AgMission Essentials + + + + + {{col.header}} + + + + + + {{ rowIdx+1 }} + {{ item.Vehicles }} + {{ item.maxAcres }} + {{ item.price }} + + + + {{ rowIdx+1 }} + {{ item.Vehicles }} + {{ item.maxAcres }} + {{ item.price }} + + + +
-
-
-
-
-
-
-
+
+ + + AgMission Enterprise + + + + + {{col.header}} + + + + + + {{ rowIdx+1 }} + {{ item.Vehicles }} + {{ item.maxAcres }} + {{ item.price }} + + + + {{ rowIdx+1 }} + {{ item.Vehicles }} + {{ item.maxAcres }} + {{ item.price }} + + + +
-

- - {{ Labels.LOADING_SUBSCRIPTION_DATA }} -

- - - -
- - - +
+ + + Add-Ons + + + + {{ item.name }} + {{ item.price }} + + +
-
-
+
- - - -
- - - - - -
- AgMission Essentials - - / {{ getDurationLabel(essPkgs[0].interval ? essPkgs[0].interval : '') }} - -
-
- - - {{col.header}} - - - - - - - -
- {{item.name}} -
- - - - -
- -
-
- - -
-
🏷️ {{ getPromoDisplayName(availablePromo) }}
-
-
-
- - - - {{formatVehiclesDisplay(item.maxVehicles)}} - {{convMaxAcre(item.maxAcres)}} - - - -
-
- {{item.price | usCurrency}} - - {{calculatePromoPrice(item.price, promo) | usCurrency}} US -
-
🏷️ {{ Labels.PROMO_VALID_UNTIL }} {{ - formatPromoValidUntil(promo.validUntil) }}
-
-
- - {{item.price | usCurrency}} US - - - -
-
-
-
- - -
- - - -
- Addons - - / {{ getDurationLabel(addons[0].interval ? addons[0].interval : '') }} - -
-
- - - {{col.header}} - - - - - - -
{{item.name}}
- - -
- -
-
- - -
-
🏷️ {{ getPromoDisplayName(availablePromo) }}
-
-
- - - - -
- {{item.price | usCurrency}} - - {{calculatePromoPrice(item.price, promo) | usCurrency}} US -
-
- - {{item.price | usCurrency}} US - - - - - - - - - {{$any(addonQuan)[item.lookupKey]}} - - - - - - -
-
- {{calAddonTotal(item) | usCurrency}} - - {{calculatePromoTotal(item, promo) | usCurrency}} US -
-
🏷️ {{ Labels.PROMO_VALID_UNTIL }} {{ - formatPromoValidUntil(promo.validUntil) }}
-
-
- - {{calAddonTotal(item) | usCurrency}} US - - - -
-
-
-
- - -
- - -
-
- - -
-
-
-
- - -
-
-
-
-
\ No newline at end of file +
\ No newline at end of file diff --git a/Development/client/src/app/profile/manage-services/manage-services.component.ts b/Development/client/src/app/profile/manage-services/manage-services.component.ts index cbc80cf..49f76dc 100644 --- a/Development/client/src/app/profile/manage-services/manage-services.component.ts +++ b/Development/client/src/app/profile/manage-services/manage-services.component.ts @@ -1,932 +1,64 @@ -import { Component, HostListener, OnDestroy, OnInit } from '@angular/core'; -import { combineLatest, Observable, Subscription } from 'rxjs'; -import { StartBillingInfo, GotoMyServices } from '@app/actions/subscription.actions'; -import { getSubIntentState, getSubscriptions, selectSubPlansStatus, selectSubLimit, selectSubPlansLoading } from '@app/reducers' -import { GC, globals, Labels } from '@app/shared/global'; -import { Package, Addon, Status, PriceUsd, StripeSubscription, SubscriptionIntent } from '@app/domain/models/subscription.model'; -import { DateUtils, Utils } from '@app/shared/utils'; -import { SubTexts, SubAppErr, SUB, createSubStatus, SubType, Mode, SubKeys, subPlans, SERVICE_TYPE, hasVendorErr, PromoLabels } from '../common'; +import { Component, OnInit } from '@angular/core'; +import { globals } from '@app/shared/global'; +import { ActivatedRoute } from '@angular/router'; import { BaseComp } from '@app/shared/base/base.component'; -import { map, filter } from 'rxjs/operators'; -import { FetchSubPlans } from '@app/actions/sub-plans.actions'; -import { SubscriptionService } from '@app/domain/services/subscription.service'; -import { ActivePromoService, ActivePromo } from '@app/domain/services/active-promo.service'; - -const DEF_QUANTITY = 1; @Component({ selector: 'my-services', templateUrl: './manage-services.component.html', styleUrls: ['./manage-services.component.css'] }) -export class ManageServicesComponent extends BaseComp implements OnInit, OnDestroy { +export class ManageServicesComponent extends BaseComp implements OnInit { + readonly globals = globals; - readonly Labels = Labels; - readonly SUB = SUB; - readonly SubTexts = SubTexts; - readonly pkgCols: any[]; - readonly addonCols: any[]; - readonly addonQuanName = 'addonQuan'; + pkgCols: any[]; + essPkgs: any; + entPkgs: any; + addOns: any; - essPkgs: Package[]; - entPkgs: Package[]; - addons: Addon[]; - sub$: Subscription; - status: Status; - loading$: Observable; // Loading state observable for skeleton loader - currSel: { selPkg: Package; selAddons: Addon[] }; - originalSel: { selPkg: Package; selAddons: Addon[] }; - addonQuan: { [SubKeys.TRACKING]: number | string } = { [SubKeys.TRACKING]: void 0 }; - prevStage: string; - isEmpty: boolean; - isTrial: boolean; - subIntentPkg: SubscriptionIntent - subs: StripeSubscription[]; - - vendorErr: boolean; - - /** Map of lookup keys to active promos for display */ - activePromos: Map = new Map(); - - /** Current PROMO_MODE from backend (for global kill switch) */ - promoMode: 'enabled' | 'disabled' | null = null; + selPkg: any; + selAddons: any; constructor( - private readonly subSvc: SubscriptionService, - public readonly activePromoSvc: ActivePromoService + private readonly route: ActivatedRoute ) { super(); + this.pkgCols = [ - { header: globals.package, width: '40%' }, - { header: globals.aircraft, width: '15%' }, - { header: globals.maxAcres, width: '15%' }, - { header: globals.price, width: '30%' } + { field: "priceId", header: globals.package }, + { field: "aircraft", header: globals.aircraft }, + { field: "maxAcres", header: globals.maxAcres }, + { field: "price", header: globals.price }, + ]; + this.essPkgs = [ + { priceId: '1', desc: '', maxVehicles: 1, Vehicles: '1', maxAcres: '50K', price: 995 }, + { priceId: '2', desc: '', maxVehicles: 1, Vehicles: '1', maxAcres: 'Unlimited', price: 2495 }, + { priceId: '3', desc: '', maxVehicles: 5, Vehicles: '2-5', maxAcres: 'Unlimited', price: 3495 }, + { priceId: '4', desc: '', maxVehicles: 10, Vehicles: '6-10', maxAcres: 'Unlimited', price: 4495 }, + { priceId: '5', desc: '', maxVehicles: 11, Vehicles: '10+', maxAcres: 'Unlimited', price: 'Contact' }, + ]; + this.entPkgs = [ + { priceId: '6', desc: '', maxVehicles: 1, Vehicles: '1', maxAcres: '50K', price: 1495 }, + { priceId: '7', desc: '', maxVehicles: 1, Vehicles: '1', maxAcres: 'Unlimited', price: 2995 }, + { priceId: '8', desc: '', maxVehicles: 5, Vehicles: '2-5', maxAcres: 'Unlimited', price: 4995 }, + { priceId: '9', desc: '', maxVehicles: 10, Vehicles: '6-10', maxAcres: 'Unlimited', price: 4995 }, + { priceId: '10', desc: '', maxVehicles: 11, Vehicles: '10+', maxAcres: 'Unlimited', price: 'Contact' }, ]; - this.addonCols = [ - { header: $localize`:@@name:Name`, width: '30%' }, - { header: $localize`:@@uPrice:Unit Price`, width: '30%' }, - { header: $localize`:@@quantity:Quantity`, width: '10%' }, - { header: $localize`:@@totalPrice:Total Price`, width: '30%' } + this.addOns = [ + { priceId: '99', name: 'Aircraft Tracking (Per Aircraft)', desc: '', price: 1495 }, ]; } ngOnInit(): void { - // Setup loading observable for skeleton loader - this.loading$ = this.store.select(selectSubPlansLoading); - - this.store.dispatch(new FetchSubPlans()); - this.initSub$(); - this.loadActivePromos(); - this.loadPromoMode(); - } - - /** - * Load current PROMO_MODE from backend (global kill switch) - * When mode='disabled', ALL promo UI should be hidden - */ - private loadPromoMode(): void { - this.activePromoSvc.getCurrentMode().subscribe(mode => { - this.promoMode = (mode?.mode as 'enabled' | 'disabled') || null; - }); - } - - /** - * Load active promos from backend and build lookup map - * Handles three promo types: - * 1. Exact-match promos (priceKey specified): applies to specific package/addon - * 2. Type-only promos (type specified, priceKey null): applies to all of that type - * 3. Universal promos (both type and priceKey null/undefined): applies to ALL packages AND addons - */ - private loadActivePromos(): void { - this.activePromoSvc.getActivePromos().subscribe(promos => { - this.activePromos = new Map(); - promos.forEach(p => { - if (p.priceKey) { - // Priority 1: Exact match promo - keyed by priceKey (e.g., 'ess_1') - this.activePromos.set(p.priceKey, p); - } else if (p.type) { - // Priority 2: Type-only promo - applies to ALL of that type - // Store with special key convention: "package_all" or "addon_all" - this.activePromos.set(`${p.type}_all`, p); - } else { - // Priority 3: Universal promo (no type, no priceKey) - applies to EVERYTHING - // Store under both "package_all" AND "addon_all" keys - this.activePromos.set('package_all', p); - this.activePromos.set('addon_all', p); - } - }); - }); - } - - /** - * Get promo for a given lookup key with display mode support (Dual-Mode Promo Display) - * Checks for exact match first, then falls back to type-only promo - * - * @param lookupKey Package or addon lookup key (e.g., 'ess_1', 'addon_1') - * @param type Subscription type ('package' or 'addon') for type-only promo fallback - * @param mode Display mode: - * - 'available': Show promos only for NEW subscriptions (default - user doesn't have this item) - * - 'subscribed': Show promos only for EXISTING subscriptions (user already has this item) - * - 'all': Show promos regardless of subscription status - * @returns ActivePromo if exists based on mode, null otherwise - */ - getPromoForLookupKey( - lookupKey: string, - type: 'package' | 'addon' = 'package', - mode: 'available' | 'subscribed' | 'all' = 'available' - ): ActivePromo | null { - - // CRITICAL: Global kill switch - respect PROMO_MODE='disabled' - // When backend sets mode to 'disabled', hide ALL promo UI (existing and new subscriptions) - // Backend continues to apply existing promos (billing logic unchanged) - // But frontend makes promo system "invisible" to user - if (this.promoMode === 'disabled') { - return null; // Global kill switch - hide ALL promo UI - } - - const userHasThis = this.getUserSubscriptionForLookupKey(lookupKey, type); - - // Filter by mode - // 'available': only show promo for items the user does NOT subscribe to - if (mode === 'available' && userHasThis) { - return null; - } - - // 'subscribed': only show promo for items the user DOES subscribe to - if (mode === 'subscribed' && !userHasThis) { - return null; - } - - if (mode === 'subscribed') { - // Priority 1: promoDetails from subscription (r948+) — already billed promo - if (userHasThis?.promoDetails?.hasPromo) { - return this.convertPromoDetailsToActivePromo(userHasThis.promoDetails, lookupKey, type); - } - - // Priority 2: For trialing subscriptions, the promo hasn't been billed yet but will apply - // on the first invoice after trial ends. Show it from activePromos so the user can see - // what discount they'll receive when their trial converts to paid. - if (userHasThis?.status === 'trialing') { - const exactMatch = this.activePromos.get(lookupKey); - if (exactMatch) return exactMatch; - const typeOnlyPromo = this.activePromos.get(`${type}_all`); - if (typeOnlyPromo) return typeOnlyPromo; - } - - // Non-trialing subscriptions without a promoDetails entry have no promo to show. - return null; - } - - // Priority 3: For 'available' and 'all' modes, check global activePromos - // Exact match by priceKey - const exactMatch = this.activePromos.get(lookupKey); - if (exactMatch) return exactMatch; - - // Type-only match (priceKey: null in backend = applies to all of type) - const typeOnlyPromo = this.activePromos.get(`${type}_all`); - if (typeOnlyPromo) return typeOnlyPromo; - - return null; - } - - /** - * Get user's subscription for a specific lookup key - * @param lookupKey Package or addon lookup key - * @param type Subscription type - * @returns StripeSubscription if user has this subscription, null otherwise - */ - private getUserSubscriptionForLookupKey( - lookupKey: string, - type: 'package' | 'addon' - ): StripeSubscription | null { - if (type === 'package') { - // For packages: Return the specific package subscription matching this lookup key - return this.subs?.find(sub => - sub.metadata?.type === SubType.PACKAGE && - sub.items?.data?.[0]?.price?.lookup_key === lookupKey - ) || null; - } else { - // For addons: Return specific addon subscription - return this.subs?.find(sub => - sub.items?.data?.[0]?.price?.lookup_key === lookupKey - ) || null; - } - } - - /** - * Convert backend promoDetails to ActivePromo format (r948+) - * Preserves expiry information for countdown display - * @param promoDetails Backend promoDetails object from subscription - * @param lookupKey Lookup key for priceKey field - * @param type Subscription type for type field - * @returns ActivePromo with full metadata including expiry - */ - private convertPromoDetailsToActivePromo( - promoDetails: any, - lookupKey: string, - type: 'package' | 'addon' - ): ActivePromo { - const discountType = this.inferDiscountType(promoDetails.discountDisplay); - const discountValue = this.parseDiscountValue(promoDetails.discountDisplay); - - return { - type: type, - priceKey: lookupKey, - name: promoDetails.name || 'Active Promo', - discountType: discountType, - discountValue: discountValue, - validUntil: promoDetails.expiresAt || null, - isTimeLimited: promoDetails.isTimeLimited || false, - daysRemaining: promoDetails.daysRemaining || null - }; - } - - /** - * Infer discount type from discountDisplay string - * @param discountDisplay String like "100% OFF", "50% OFF", "$5.00 OFF" - * @returns Discount type: 'free' | 'percent' | 'fixed' - */ - private inferDiscountType(discountDisplay: string): 'free' | 'percent' | 'fixed' { - if (!discountDisplay) return 'percent'; - if (discountDisplay.includes('100%') || discountDisplay.toLowerCase().includes('free')) { - return 'free'; - } - if (discountDisplay.includes('%')) { - return 'percent'; - } - return 'fixed'; // Assumes dollar amounts like "$5.00 OFF" - } - - /** - * Parse discount value from discountDisplay string - * @param discountDisplay String like "100% OFF", "50% OFF", "$5.00 OFF" - * @returns Numeric discount value (100 for 100%, 50 for 50%, 500 for $5.00) - */ - private parseDiscountValue(discountDisplay: string): number { - if (!discountDisplay) return 0; - const match = discountDisplay.match(/([0-9.]+)/); - if (!match) return 0; - const value = parseFloat(match[1]); - // For fixed amounts like "$5.00", convert to cents - if (discountDisplay.includes('$')) { - return Math.round(value * 100); - } - return value; - } - - /** - * Map Stripe discount object to ActivePromo format - * @param discount Stripe discount object from subscription - * @returns ActivePromo compatible object - */ - private mapStripeDiscountToActivePromo(discount: any): ActivePromo { - const coupon = discount.coupon; - const discountType = coupon.percent_off ? 'percent' : coupon.amount_off ? 'fixed' : 'free'; - const discountValue = coupon.percent_off || (coupon.amount_off ? coupon.amount_off / 100 : 0); - - // Generate display name based on discount type - let name = ''; - if (discountType === 'free') { - name = 'FREE'; - } else if (discountType === 'percent') { - name = `${discountValue}% OFF`; - } else { - name = `$${discountValue.toFixed(2)} OFF`; - } - - return { - type: null, // From Stripe, not our promo system - priceKey: null, - name: name, - discountType: discountType as 'free' | 'percent' | 'fixed', - discountValue: discountValue, - validUntil: discount.end ? new Date(discount.end * 1000).toISOString() : null, - }; - } - - /** - * Check if user is subscribed to a specific item - * Template helper for conditional rendering - * @param lookupKey Package or addon lookup key - * @param type Subscription type - * @returns true if user has this subscription, false otherwise - */ - isUserSubscribed(lookupKey: string, type: 'package' | 'addon'): boolean { - return this.getUserSubscriptionForLookupKey(lookupKey, type) !== null; - } - - /** - * Check if user has active legacy ESS_1 subscription - * @returns true if user has ESS_1 subscription with active/trialing status - */ - hasLegacyEss1Subscription(): boolean { - return this.subs?.some( - sub => sub.items?.data?.[0]?.price?.lookup_key === 'ess_1' && - sub.metadata?.type === SubType.PACKAGE && - (sub.status === 'active' || sub.status === 'trialing') - ) || false; - } - - /** - * Determine if ESS_1 (legacy) should be shown in the list - * Only show if user has active ESS_1 subscription - * @param pkg Package to check - * @returns true if ESS_1 should be displayed - */ - shouldShowEss1Legacy(pkg: Package): boolean { - return pkg.lookupKey === 'ess_1' && this.hasLegacyEss1Subscription(); - } - - /** - * Determine if ESS_1_1 should be shown in the list - * Always show ESS_1_1 (either as replacement or upgrade option) - * @param pkg Package to check - * @returns true if ESS_1_1 should be displayed - */ - shouldShowEss1Plus(pkg: Package): boolean { - return pkg.lookupKey === 'ess_1_1'; - } - - /** - * Determine if standard package should be shown - * Hide ESS_1 if user is NOT subscribed to it - * All other packages always shown - * @param pkg Package to check - * @returns true if package should be displayed - */ - shouldShowPackage(pkg: Package): boolean { - // ESS_1: Only show if user has legacy subscription - if (pkg.lookupKey === 'ess_1') { - return this.hasLegacyEss1Subscription(); - } - // ESS_1_1: Always show (handled separately for clarity) - if (pkg.lookupKey === 'ess_1_1') { - return true; - } - // All other packages: Always show - return true; - } - - /** - * Check if package is the legacy ESS_1 - * Used for special styling and promo suppression - * @param lookupKey Package lookup key - * @returns true if this is legacy ESS_1 - */ - isLegacyEss1(lookupKey: string): boolean { - return lookupKey === 'ess_1' && this.hasLegacyEss1Subscription(); - } - - /** - * Check if ALL packages have the same promo (package-wide promo) - * First checks for type-only package promo, then falls back to checking individual promos - * Per P2-B wireframe: Banner only shown when all packages have same promo - * CRITICAL: Only shows promo for NEW subscriptions (user has no existing package subscription) - */ - isAllPackagesPromo(): ActivePromo | null { - if (!this.essPkgs || this.essPkgs.length === 0) return null; - - // Check if user has ANY existing package subscription - const hasExistingPackageSubscription = this.subs?.some(sub => - sub.metadata?.type === SubType.PACKAGE - ); - - // Only apply promos to NEW subscriptions (no existing package subscription) - if (hasExistingPackageSubscription) { - return null; - } - - // Priority 1: Check for type-only package promo (applies to ALL packages) - const typeOnlyPromo = this.activePromos.get('package_all'); - if (typeOnlyPromo) return typeOnlyPromo; - - // Priority 2: Check if all packages have individual promos with same discount - const packagePromos = this.essPkgs - .map(pkg => this.activePromos.get(pkg.lookupKey)) - .filter(Boolean) as ActivePromo[]; - - // Check if ALL packages have a promo - if (packagePromos.length !== this.essPkgs.length) return null; - - // Check if all promos are the same (same discount type and value = same campaign) - const first = packagePromos[0]; - const allSamePromo = packagePromos.every(p => - p.discountType === first.discountType && - p.discountValue === first.discountValue - ); - - return allSamePromo ? first : null; - } - - // NOTE: Addon banner removed per P2-D wireframe - addons never show banner - // Promo description is shown below addon name in the Name column instead - - /** - * Calculate the promo price for a given original price and promo - * Uses centralized calculation from SubscriptionService - * @param originalPrice Original price in cents (e.g., 99500 = $995.00) - * @param promo Active promo - * @returns Discounted price in cents - */ - calculatePromoPrice(originalPrice: number, promo: ActivePromo): number { - return this.subSvc.calculateDiscountedAmount(originalPrice, promo); - } - - /** - * Calculate the promo total for an addon (promo price × quantity) - * @param addon Addon item - * @param promo Active promo - * @returns Total discounted price in dollars - */ - calculatePromoTotal(addon: Addon, promo: ActivePromo): PriceUsd { - if (!promo || !this.isAddonQuanValid(addon.lookupKey)) return this.calAddonTotal(addon); - const promoUnitPrice = this.calculatePromoPrice(Number(addon.price), promo); - return promoUnitPrice * Number(this.addonQuan[addon.lookupKey]); - } - - /** - * Format validity date for promo banner - * @param validUntil ISO date string - * @returns Formatted date string (e.g., "April 30, 2026") - */ - formatPromoValidUntil(validUntil: string): string { - if (!validUntil) return ''; - const date = new Date(validUntil); - return date.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }); - } - - /** - * Build the promo banner message for packages - * @param promo Active promo - * @returns Formatted promo message string - */ - getPackagePromoMessage(promo: ActivePromo): string { - if (!promo) return ''; - return `${Labels.PROMO_ALL_PACKAGES_PREFIX} ${this.activePromoSvc.formatPromoDiscount(promo)} ${Labels.PROMO_UNTIL} ${this.formatPromoValidUntil(promo.validUntil)}`; - } - - /** - * Get display name for promo description (shown below package/addon name) - * Per P2-A/P2-D wireframes: Shows "🏷️ Launch Special" style text - * @param promo Active promo - * @returns Localized promo description from nameKey or fallback to formatted discount - */ - getPromoDisplayName(promo: ActivePromo): string { - if (!promo) return ''; - // Try nameKey for localized string first, then name, then fallback to formatted discount - if (promo.nameKey && PromoLabels[promo.nameKey]) { - return PromoLabels[promo.nameKey]; - } - return promo.name || this.activePromoSvc.formatPromoDiscount(promo); - } - - initSub$() { - const initServices = () => { - const service = { [SERVICE_TYPE.ESS]: [], [SERVICE_TYPE.ENT]: [], [SERVICE_TYPE.ADDON]: [] }; - const lookupKeys = Object.values(SubKeys); - lookupKeys.map((key) => { - service[subPlans[key]?.type] = [...service[subPlans[key]?.type], subPlans[key]]; - }); - service.essential.sort((p1: Package, p2: Package) => +p1.priceId - +p2.priceId) - service.enterprise.sort((p1: Package, p2: Package) => +p1.priceId - +p2.priceId); - service.addon = service.addon.map((addon) => { - const sub = this.subs?.find((sub) => sub.items?.data?.[0]?.price?.lookup_key === addon.lookupKey); - const selAddon = this.subIntentPkg?.selAddons?.find((selAddon) => selAddon.lookupKey === addon.lookupKey); - const quantity = selAddon ? selAddon.quantity : sub ? sub.items?.data?.[0]?.quantity : 1; - - // Populate trialEnd from Stripe subscription (uses snake_case field names from Stripe API) - const trialEnd = sub?.trial_end || sub?.current_period_end; - - return { ...addon, quantity, trialEnd }; - }).sort((a1: Addon, a2: Addon) => +a1.priceId - +a2.priceId); - - this.essPkgs = service[SERVICE_TYPE.ESS]; - this.entPkgs = service[SERVICE_TYPE.ENT]; - this.addons = service[SERVICE_TYPE.ADDON]; - this.addons?.forEach((addon) => this.addonQuan[addon.lookupKey] = addon.quantity) - } - - // Use combineLatest to wait for all data streams before rendering - // This prevents race condition where component renders with stale subPlans data - // before FetchSubPlans effect completes - this.sub$ = combineLatest([ - this.store.select(getSubscriptions), - this.store.select(getSubIntentState), - this.store.select(selectSubLimit).pipe(filter(subLimit => !!subLimit)) - ]).pipe( - map(([subs, subIntent, subLimit]) => { - this.subs = subs; - this.subIntentPkg = subIntent?.package; - this.isTrial = subIntent?.mode === Mode.TRIALING; - this.prevStage = subIntent?.prevStage; - - // All data ready - initialize once with correct data - initServices(); - this.initSelections(); - }) - ).subscribe({ - error: err => { - console.log(err); - this.status = createSubStatus(SubAppErr.MGE_SERV_ERR); - } - }); - - // Separate subscription for status updates (independent of data flow) - this.sub$.add(this.store.select(selectSubPlansStatus).subscribe({ - next: (status) => { - this.status = status; - if (hasVendorErr(this.status?.code)) { - this.vendorErr = true; - } - }, - error: err => { - console.log(err); - this.status = createSubStatus(SubAppErr.MGE_SERV_ERR); - } - })); - } - - initSelections() { - try { - let orgAddons = []; - this.addons?.forEach((addon) => { - const orgAddon = this.subs?.find((elt => elt.metadata?.type === SubType.ADDON && elt.items?.data?.[0]?.price?.lookup_key === addon?.lookupKey)); - if (orgAddon) { - // Populate trialEnd from Stripe subscription (uses snake_case field names from Stripe API) - const trialEnd = orgAddon.trial_end || orgAddon.current_period_end; - orgAddons.push({ ...addon, quantity: orgAddon.quantity, trialEnd }); - } - }); - - // NOTE: This method works with StripeSubscription (from Stripe API) which has different field names - // (current_period_end vs periodEnd). The centralized utilities work with AGNavSubscription. - // We keep this logic here as it's specific to Stripe API response handling. - - // Find all package subscriptions - const pkgSubs = this.subs?.filter((elt) => elt.metadata?.type === SubType.PACKAGE); - - // Get latest package subscription by current_period_end - const latestPkgSub = pkgSubs?.reduce((acc, curr) => { - return (curr.current_period_end > acc.current_period_end) ? curr : acc; - }, pkgSubs?.[0]); - - const latestLookupKey = latestPkgSub?.items?.data?.[0]?.price?.lookup_key; - - // Populate trialEnd from latest package subscription (uses snake_case field names from Stripe API) - const pkgTrialEnd = latestPkgSub?.trial_end || latestPkgSub?.current_period_end; - const selPkgWithTrial = this.essPkgs?.concat(this.entPkgs).find((pkg) => pkg.lookupKey === latestLookupKey); - - this.originalSel = { - selPkg: selPkgWithTrial ? { ...selPkgWithTrial, trialEnd: pkgTrialEnd } : null, - selAddons: orgAddons - }; - - this.currSel = { - selPkg: !!this.subIntentPkg?.selPkg ? this.subIntentPkg.selPkg : this.originalSel.selPkg, - selAddons: this.subIntentPkg?.selAddons?.length > 0 ? this.subIntentPkg.selAddons : this.originalSel.selAddons - }; - - this.currSel.selAddons?.forEach((addon) => { - if (addon) this.addonQuan[addon.lookupKey] = addon.quantity - }); - - return this.updateIsEmpty(); - } catch (err) { - console.log(err); - this.status = createSubStatus(SubAppErr.MGE_SERV_ERR); - } - } - - /** - * Frontend Display Policy for Subscription Limits - * =============================================== - * - * This component displays subscription package limits to users. - * Zero values have special meaning depending on the field. - * - * MaxAcres Display Rules: - * ----------------------- - * - 0 or null → "Unlimited" (agricultural context: no acreage restriction) - * - Positive number → Formatted value (e.g., "123", "50K") - * - * Rationale: In farming, "0 acres" doesn't make literal sense. Zero is used - * internally to mean "no restriction on acreage." - * - * MaxVehicles Display Rules: - * --------------------------- - * - 0 → "0 Aircraft" (literal zero, explicit restriction shown in Vehicles column) - * - Positive number → "X Aircraft" (e.g., "1-2", "1-5") - * - * Rationale: "0 aircraft" is a valid restriction (e.g., trial accounts, - * restricted access). Display literally as received from API. - * - * Examples: - * --------- - * 1. Essential 2 Plan (regular): - * { maxAcres: 0, maxVehicles: 2 } - * Display: "Unlimited" acres, "1-2" Aircraft - * - * 2. Trial Account (restricted): - * { maxAcres: 0, maxVehicles: 0 } - * Display: "Unlimited" acres, "0" Aircraft - * - * 3. Essential 1 Plan (limited acres): - * { maxAcres: 123, maxVehicles: 1 } - * Display: "123" acres, "1" Aircraft - * - * Backend Coordination: - * --------------------- - * Backend API returns literal values from Stripe metadata or custom limits. - * Frontend interprets these values according to display rules above. - * See: server/controllers/subscription.js for backend custom limits logic - * See: SubscriptionService.convMaxAcre() for implementation - */ - - /** - * Convert maxAcres value to user-friendly display string - * Delegates to SubscriptionService for centralized display logic - * - * @param maxAcres - Maximum acres value from API - * @returns Display string ("Unlimited", "123", or "50K") - * - * @see SubscriptionService.convMaxAcre() for full documentation - */ - convMaxAcre(maxAcres: number | string) { - return this.subSvc.convMaxAcre(maxAcres); - } - - /** - * Format vehicle display for package selection table - * - * Display Rules: - * - maxVehicles = 0: "0" (addons without vehicle limits) - * - maxVehicles = 1: "1" - * - maxVehicles > 1: "Up to X" (localized) - * - * Handles all cases including custom limits from subscription data. - * Uses maxVehicles property which is already populated from getPrices API - * or custom limits via the effect pipeline. - * - * @param maxVehicles - Maximum vehicles value from package/addon - * @returns Display string ("Up to 4", "1", or "0") with localized text - */ - formatVehiclesDisplay(maxVehicles: number | string): string { - const vehicles = Number(maxVehicles); - - if (vehicles > 1) { - return `${Labels.UP_TO} ${vehicles}`; - } - - return String(vehicles); - } - - isCompLoaded() { - return this.status?.code !== SubAppErr.MGE_SERV_ERR - && this.status?.code !== SubAppErr.FETCH_SUB_PLANS_ERR - && !this.vendorErr; - } - - hasError() { - return this.status?.code === SubAppErr.CANCEL_ERR || this.status?.code === SubAppErr.RES_ERR || this.status?.code === SubAppErr._500_ERR || this.status?.code === SubAppErr.START_BIL_INFO_ERR; - } - - isSame() { - // Compare by lookupKey/priceId instead of deep object equality - // This handles ESS_1 → ESS_1_1 upgrade detection correctly - const isSamePkg = this.currSel?.selPkg?.lookupKey === this.originalSel?.selPkg?.lookupKey; - const isSameAddons = Utils.deepEquals(this.currSel?.selAddons, this.originalSel?.selAddons); - return isSamePkg && isSameAddons; - } - - isNotValidSel() { - // Special handling for ESS_1 → ESS_1_1 upgrade (same level, but valid upgrade path) - const isLegacyUpgrade = this.originalSel?.selPkg?.lookupKey === 'ess_1' && this.currSel?.selPkg?.lookupKey === 'ess_1_1'; - - const isPkgDowngrade = this.currSel?.selPkg?.level < this.originalSel?.selPkg?.level || (!this.currSel.selPkg && !!this.originalSel.selPkg); - let isNotValidAddon = this.currSel?.selAddons?.length < this.originalSel?.selAddons?.length; - if (this.originalSel?.selAddons?.length > 0) { - isNotValidAddon = !this.originalSel.selAddons.every((orgAdd) => this.currSel?.selAddons?.some((selAdd) => selAdd?.lookupKey === orgAdd?.lookupKey)); - } - - // Allow ESS_1 → ESS_1_1 upgrade even though they're same level - const isNotValid = this.isSame() || this.isEmpty || (isPkgDowngrade && !isLegacyUpgrade) || isNotValidAddon; - return isNotValid; - } - - isAddonQuanValid(lookupKey: string) { - return Number(this.addonQuan[lookupKey]) >= 1; - } - - isSubscribed(lookupKey: string) { - return this.subs?.some((sub) => sub.items?.data?.[0]?.price?.lookup_key === lookupKey); - } - - onPkgChange() { - if (this.isTrial && this.currSel.selPkg && this.originalSel?.selPkg?.trialEnd) { - this.currSel.selPkg = { ...this.currSel.selPkg, trialEnd: this.originalSel.selPkg.trialEnd }; - } - this.updateIsEmpty(); - } - - onAddonChange() { - if (this.isTrial && this.originalSel?.selPkg?.trialEnd) { - this.currSel.selAddons = this.currSel.selAddons?.map((addon) => { - return addon.trialEnd ? addon : { ...addon, trialEnd: this.originalSel.selPkg.trialEnd }; - }); - } - this.updateCurSelAddonQuan(); - this.updateIsEmpty(); } confirmServices() { - const isSamePkg = Utils.deepEquals(this.currSel.selPkg, this.originalSel.selPkg); - const isNewSub = this.originalSel?.selAddons?.length === 0 && !this.originalSel.selPkg; + console.log("Selected Package:", this.selPkg); + console.log("Selected Addons:", this.selAddons); - const startRegularSession = (selPkg: Package, selAddons: Addon[], orgPkg: Package, orgAddons: Addon[]) => { - const prorateTS = DateUtils.currUTC(); - // if (!this.isValidTime(isSamePkg, prorateTS)) { - // return this.msgSvc.addFailedMsg(SubTexts.textInvalidTime); - // } - if (isNewSub) { - return this.dispatchStartBillingInfo(selPkg, selAddons, orgPkg, orgAddons, prorateTS, Mode.REGULAR); - } - return this.confirmSvc.confirm({ - message: SubTexts.textChangeSub, - accept: () => this.dispatchStartBillingInfo(selPkg, selAddons, orgPkg, orgAddons, prorateTS, Mode.REGULAR) - }); - }; - - const startTrialSession = (selPkg: Package, selAddons: Addon[], orgPkg: Package, orgAddons: Addon[]) => { - if (isNewSub) { - return this.dispatchStartBillingInfo(selPkg, selAddons, orgPkg, orgAddons, null, Mode.TRIALING); - } - return this.confirmSvc.confirm({ - message: SubTexts.textChangeTrial, - accept: () => this.dispatchStartBillingInfo(selPkg, selAddons, orgPkg, orgAddons, null, Mode.TRIALING) - }); - }; - - try { - if (this.isTrial) { - // Get trial end date from user's trial configuration for NEW trial subscriptions - // For existing trial subscriptions, trialEnd is already populated from Stripe API in initSelections() - const trials = this.authSvc.user?.membership?.trials; - let trialEndDate: number = null; - if (trials?.type === GC.BYDATE && trials?.byDate) { - trialEndDate = new Date(trials.byDate).getTime() / 1000; - } else if (trials?.type === GC.DAYS && trials?.trialDays) { - // Calculate trial end date from today + trialDays - const futureDate = new Date(); - futureDate.setDate(futureDate.getDate() + trials.trialDays); - trialEndDate = futureDate.getTime() / 1000; - } - - // Add trialEnd to selected package (for new trials or preserve existing trialEnd) - const selPkg = this.currSel.selPkg - ? { - ...this.currSel.selPkg, - desc: subPlans[this.currSel.selPkg.lookupKey].desc, - trialEnd: this.currSel.selPkg.trialEnd || trialEndDate - } - : null; - - // Add trialEnd to selected addons (for new trials or preserve existing trialEnd) - const selAddons = this.currSel.selAddons.map((addon) => ({ - ...addon, - desc: `${addon.quantity} x ${addon.name}`, - trialEnd: addon.trialEnd || trialEndDate - })); - - return startTrialSession(selPkg, selAddons, this.originalSel?.selPkg, this.originalSel?.selAddons); - } - return startRegularSession(this.currSel?.selPkg, this.currSel?.selAddons, this.originalSel?.selPkg, this.originalSel?.selAddons); - } catch (err) { - console.log(err); - this.status = createSubStatus(SubAppErr.MGE_SERV_ERR); - } } - private isValidTime(isSamePkg: boolean, prorateTS: number): boolean { - const isLessThanOneDay = (periodStart: number = 0): boolean => { - const SECS_PER_MINUTE = 60; - const MINUTES_PER_HOUR = 60; - const HOURS_PER_DAY = 24; - return Math.floor((prorateTS - periodStart) / (SECS_PER_MINUTE * MINUTES_PER_HOUR)) < HOURS_PER_DAY; - }; - - const isPkgTimeValid = (): boolean => { - // Use centralized utility to get latest package subscription - const pkg = this.subSvc.getLatestSubscription( - this.authSvc.user?.membership?.subscriptions, - SubType.PACKAGE - ); - return isSamePkg || !isLessThanOneDay(pkg?.periodStart); - }; - - const isTrkTimeValid = (): boolean => { - const tracking = this.authSvc.user?.membership?.subscriptions?.find((sub) => sub.type === SubType.ADDON && sub.items?.[0]?.price === SubKeys.TRACKING); - const curSelTrking = this.currSel?.selAddons?.find((addon) => addon.lookupKey === SubKeys.TRACKING); - const orgSelTrking = this.originalSel?.selAddons?.find((addon) => addon.lookupKey === SubKeys.TRACKING); - const isTrkQtyChange = !Utils.deepEquals(curSelTrking, orgSelTrking); - return !isTrkQtyChange || !isLessThanOneDay(tracking?.periodStart); - }; - - return isPkgTimeValid() && isTrkTimeValid(); - } - - private dispatchStartBillingInfo(selPkg: Package, selAddons: Addon[], orgPkg: Package, orgAddons: Addon[], prorateTS: number, mode: Mode) { - // Apply custom limits to selected package if user has custom limits - const customMaxVehicles = this.authSvc.user?.membership?.customLimits?.maxVehicles; - const customMaxAcres = this.authSvc.user?.membership?.customLimits?.maxAcres; - - // Handle addon-only subscription (no package selected) - // Override package limits with custom limits for the entire subscription flow - // Use explicit null/undefined check to handle 0 values (e.g., disabled vehicles) - const packageWithCustomLimits = selPkg ? { - ...selPkg, - maxVehicles: (customMaxVehicles !== null && customMaxVehicles !== undefined) - ? customMaxVehicles - : selPkg.maxVehicles, - maxAcres: (customMaxAcres !== null && customMaxAcres !== undefined) - ? customMaxAcres - : selPkg.maxAcres - } : null; - - this.store.dispatch(new StartBillingInfo({ - applicatorId: this.authSvc.user?._id, - custId: this.authSvc.user?.membership.custId, - selPkg: packageWithCustomLimits, - selAddons, - orgPkg, - orgAddons, - prorateTS, - mode - })); - } - - resetChanges() { - try { - this.currSel.selPkg = this.originalSel.selPkg; - this.currSel.selAddons = this.originalSel.selAddons; - this.addonQuan[SubKeys.TRACKING] = this.originalSel?.selAddons?.find((addon) => addon?.lookupKey === SubKeys.TRACKING)?.quantity || 1; - return this.updateIsEmpty(); - } catch (err) { - console.log(err); - this.status = createSubStatus(SubAppErr.MGE_SERV_ERR); - } - } - - gotoMySubs() { - this.store.dispatch(new GotoMyServices()); - } - - @HostListener('document:keydown.enter', ['$event']) onKeydownHandler(e: KeyboardEvent) { - const target = e.target; - this.setAddonQuan(target.name); - } - - updateIsEmpty() { - this.isEmpty = !this.currSel?.selPkg && Utils.isEmptyArray(this.currSel.selAddons); - } - - setAddonQuan(lookupKey: string) { - if (!this.isAddonQuanValid(lookupKey)) return this.addonQuan[lookupKey] = DEF_QUANTITY; - this.updateCurSelAddonQuan(); - } - - updateCurSelAddonQuan() { - this.currSel = { ...this.currSel, selAddons: this.currSel.selAddons?.map((addon) => ({ ...addon, quantity: Number(this.addonQuan[addon.lookupKey]) })) || [] }; - } - - calAddonTotal(addon: Addon): PriceUsd { - if (this.isAddonQuanValid(addon.lookupKey)) return Number(addon.price) * Number(this.addonQuan[addon.lookupKey]); - } - - changeAddonQuantity(lookupKey: string) { - this.currSel.selAddons = this.currSel.selAddons.map((addon) => { - if (addon.lookupKey === lookupKey) { - return { ...addon, quantity: Number(this.addonQuan[lookupKey]) }; - } - return addon; - }); - } - - /** - * Get billing duration label for display in caption subtitle - * @param interval - Stripe interval ('year' or 'month') - * @returns Localized duration label - */ - getDurationLabel(interval: string): string { - if (interval === 'year') { - return $localize`:@@annualBilling:Annual billing`; - } else if (interval === 'month') { - return $localize`:@@monthlyBilling:Monthly billing`; - } - return interval; // Fallback to raw interval - } - - ngOnDestroy(): void { - super.ngOnDestroy(); + goBack() { } } \ No newline at end of file diff --git a/Development/client/src/app/profile/manage-subscription/manage-subscription.component.css b/Development/client/src/app/profile/manage-subscription/manage-subscription.component.css index c0e6932..df3d030 100644 --- a/Development/client/src/app/profile/manage-subscription/manage-subscription.component.css +++ b/Development/client/src/app/profile/manage-subscription/manage-subscription.component.css @@ -3,8 +3,9 @@ } .feature-content ul { - margin: 0; - padding: 4px 20px; + margin : 0; + list-style-type: none; + padding : 4px 20px; } .feature-content ul li:not(:last-child) { @@ -12,721 +13,10 @@ } .feature-content ul li i { - margin-right: 16px; + margin-right : 16px; vertical-align: middle; } .feature-content ul li span { vertical-align: middle; } - -.status-red { - color: red; -} - -.edit { - display: flex; - justify-content: flex-end; -} - -.edit-dialog { - width: 30vw; -} - -/* Promo display styling in subscription list */ -.promo-info { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; -} - -.promo-icon { - color: #FFC107; - /* AgMission amber accent */ - font-size: 1.125rem; -} - -.promo-label { - display: inline-block; - background-color: #FFC107; - /* AgMission amber */ - color: #212121; - /* Dark text for contrast */ - font-size: 0.75em; - font-weight: 600; - padding: 2px 8px; - border-radius: 3px; - /* AgMission standard border-radius */ - text-transform: uppercase; - letter-spacing: 0.5px; - white-space: nowrap; -} - -.promo-validity { - font-size: 0.85em; - color: #757575; - /* AgMission secondary text */ - font-style: italic; -} - -/* Data integrity warning for multiple packages */ -.data-integrity-warning { - display: flex; - align-items: center; - gap: 12px; - padding: 12px 16px; - margin-bottom: 16px; - background-color: #FFF3E0; - /* Light amber background */ - border: 1px solid #FF9800; - /* AgMission warning orange */ - border-left: 4px solid #FF9800; - border-radius: 3px; - color: #E65100; - /* Dark orange text */ - font-size: 0.9rem; -} - -.data-integrity-warning i { - font-size: 1.25rem; - color: #FF9800; - flex-shrink: 0; -} - -/* ✅ NEW r948: Promo badge from promoDetails object */ -.promo-badge { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 4px 8px; - background: #E3F2FD; - color: #1976D2; - border-radius: 4px; - font-size: 0.875rem; - font-weight: 600; -} - -/* Case 2A: Retention badge (subscription has promo) */ -.promo-badge.retention { - background: #FFF3E0; - color: #E65100; - border-left: 3px solid #FF9800; -} - -/* Case 2B: Acquisition badge (subscription does NOT have promo) */ -.promo-badge.acquisition { - background: #E8F5E9; - color: #2E7D32; - border-left: 3px solid #4CAF50; -} - -.promo-badge .pi { - font-size: 0.875rem; -} - -/* Case 2A: Expiry warning (urgent retention message) */ -.promo-expiry-warning { - display: inline-flex; - align-items: center; - gap: 4px; - margin-left: 8px; - padding: 4px 8px; - background: #FFEBEE; - border-left: 3px solid #F44336; - border-radius: 3px; - color: #C62828; - font-size: 0.8125rem; - font-weight: 600; -} - -.promo-expiry-warning .pi { - font-size: 0.75rem; -} - -/* ============================================================================ - WIREFRAME 1: VERTICAL CARD LAYOUT - PROMOTION DISPLAY - ============================================================================ */ - -/* Promotion Card Container */ -.promotion-card { - background: linear-gradient(135deg, #f0f9f0 0%, #ffffff 100%); - border: 2px solid #4CAF50; - border-radius: 8px; - padding: 24px; - margin-bottom: 16px; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); - transition: all 0.3s ease; -} - -.promotion-card:hover { - box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15); - transform: translateY(-2px); -} - -/* Promotion Card Header */ -.promotion-card-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 20px; - flex-wrap: wrap; - gap: 12px; -} - -/* Discount Badge */ -.discount-badge { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 8px 16px; - background: #4CAF50; - color: #ffffff; - font-size: 16px; - font-weight: 700; - border-radius: 20px; - text-transform: uppercase; - letter-spacing: 0.5px; - box-shadow: 0 2px 4px rgba(76, 175, 80, 0.3); -} - -.discount-badge.limited-time { - background: #FF9800; - box-shadow: 0 2px 4px rgba(255, 152, 0, 0.3); - animation: pulse-badge 2s ease-in-out infinite; -} - -.discount-badge.permanent { - background: #2E7D32; -} - -.discount-badge .pi { - font-size: 14px; -} - -/* Package Name */ -.package-name { - font-size: 20px; - font-weight: 600; - color: #212121; -} - -/* Promotion Card Body */ -.promotion-card-body { - display: flex; - flex-direction: column; - gap: 20px; -} - -/* Price Section */ -.price-section { - text-align: left; -} - -.current-price-label { - font-size: 14px; - color: #757575; - margin-bottom: 8px; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.promotional-price { - display: flex; - align-items: baseline; - gap: 4px; - color: #2E7D32; - margin-bottom: 12px; -} - -.promotional-price .currency { - font-size: 32px; - font-weight: 600; -} - -.promotional-price .amount { - font-size: 48px; - font-weight: 700; - line-height: 1; -} - -.promotional-price .period { - font-size: 18px; - color: #757575; -} - -/* Pricing Breakdown */ -.pricing-breakdown { - display: flex; - flex-direction: column; - gap: 8px; - padding: 16px; - background: #ffffff; - border-radius: 6px; - border-left: 4px solid #4CAF50; -} - -.regular-price { - font-size: 16px; - color: #757575; -} - -.regular-price .strikethrough { - text-decoration: line-through; - font-weight: 500; -} - -.savings-highlight { - display: flex; - align-items: center; - gap: 8px; - font-size: 18px; - font-weight: 600; - color: #4CAF50; -} - -.savings-highlight .pi { - font-size: 16px; -} - -.savings-amount { - font-weight: 700; -} - -/* Renewal Notice */ -.renewal-notice { - padding: 16px; - background: #FFF3E0; - border-left: 4px solid #FF9800; - border-radius: 6px; -} - -.renewal-notice.permanent { - background: #E8F5E9; - border-left: 4px solid #4CAF50; -} - -.expiry-warning { - display: flex; - align-items: center; - gap: 8px; - font-size: 14px; - font-weight: 600; - color: #E65100; - margin-bottom: 8px; -} - -.expiry-warning .pi { - font-size: 16px; -} - -.permanent-discount-info { - display: flex; - align-items: center; - gap: 8px; - font-size: 14px; - font-weight: 600; - color: #2E7D32; - margin-bottom: 8px; -} - -.permanent-discount-info .pi { - font-size: 16px; -} - -.renewal-info { - display: flex; - align-items: center; - gap: 8px; - font-size: 14px; - color: #424242; -} - -.renewal-info .pi { - font-size: 14px; - color: #757575; -} - -/* Pulse Animation for Limited-Time Badge */ - -/* ============================================================================ - COMPACT VERTICAL LIST STYLING (Wireframe 4) - ============================================================================ */ - -/* Compact Vertical Card Container */ -.compact-vertical-card { - padding: 12px 16px; - border-radius: 6px; - margin-bottom: 16px; -} - -/* Header Section */ -.cv-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 8px; -} - -.cv-package-info { - display: flex; - align-items: center; - gap: 8px; -} - -.cv-separator { - color: #BDBDBD; - font-weight: 300; - font-size: 14px; -} - -.cv-package-name { - font-size: 16px; - font-weight: 600; - color: #212121; -} - -/* Divider */ -.cv-divider { - height: 1px; - background: #E0E0E0; - margin: 8px 0; -} - -/* Sections */ -.cv-section { - display: flex; - flex-direction: column; - gap: 6px; -} - -/* Section Headers for Promo and Details */ -.section-header { - font-size: 14px; - font-weight: 700; - color: #2E7D32; - /* AgMission dark green */ - text-transform: uppercase; - letter-spacing: 0.5px; - margin: 0 0 8px 0; - padding-bottom: 4px; - border-bottom: 2px solid #4CAF50; - /* AgMission primary green */ -} - -/* Rows (Label: Value pairs) */ -.cv-row { - display: flex; - justify-content: space-between; - align-items: baseline; - font-size: 14px; - line-height: 1.4; -} - -.cv-label { - color: #757575; - font-weight: 500; - flex-shrink: 0; - margin-right: 12px; - display: flex; - align-items: center; - gap: 8px; -} - -.cv-value { - color: #212121; - font-weight: 400; - text-align: right; -} - -/* Pricing-specific styles */ -.cv-current-price { - color: #2E7D32; - font-weight: 600; -} - -.cv-savings { - font-size: 12px; - color: #4CAF50; - margin-left: 4px; -} - -.cv-future-price { - color: #616161; -} - -/* Promo details text styling within cv-value */ -.cv-value.promo-text { - font-size: 0.875rem; - /* 14px - matches cv-row font size */ - font-weight: 400; - /* Matches cv-value weight */ - color: #212121; - /* AgMission primary text color */ - line-height: 1.4; - /* Matches cv-row line-height */ -} - -/* ============================================================================ - ENHANCED PROMO DISPLAY STYLING (8-Case System) - ============================================================================ */ - -/* Promo Section with Urgency State */ -.cv-promo.promo-urgent { - background: #FFF3E0; - /* Light amber background for urgency */ - border-left: 3px solid #FF9800; - /* Amber border for visual emphasis */ - padding: 8px 12px; - border-radius: 3px; - margin: 4px 0; -} - -/* Promo Type Header Row */ -.promo-header { - display: flex; - align-items: center; - gap: 8px; - font-weight: 600; - color: #212121; - margin-bottom: 4px; -} - -/* Promo Type Icon */ -.promo-type-icon { - font-size: 18px; - flex-shrink: 0; - color: #4CAF50; - /* AgMission green */ -} - -/* Promo Type Label */ -.promo-type-label { - font-size: 14px; - font-weight: 600; - color: #2E7D32; - /* AgMission dark green */ - text-transform: uppercase; - letter-spacing: 0.5px; -} - -/* Promo Expiry Row */ -.promo-expiry { - color: #616161; - /* Neutral gray for informational text */ - font-style: italic; -} - -/* Promo Duration Row (Standard State) */ -.promo-duration { - color: #757575; - /* AgMission secondary text */ - font-weight: 500; -} - -/* Promo Duration Row (Urgent State) */ -.promo-urgent-text { - color: #F44336; - /* AgMission red for urgent warnings */ - font-weight: 600; - animation: pulse-urgent-text 2s ease-in-out infinite; -} - -/* Urgent Text Pulse Animation */ -@keyframes pulse-urgent-text { - - 0%, - 100% { - opacity: 1; - } - - 50% { - opacity: 0.7; - } -} - -/* ============================================================================ - CASE 2C: TRIAL SUBSCRIPTION WITH PROMO DISPLAY - ============================================================================ */ - -/* Promo Badge Row */ -.cv-promo-badge-row { - justify-content: flex-start; - margin: 8px 0; -} - -/* After-Trial Pricing Section with Promo - Consistent with regular flow */ - -/* Discounted Price Styling - matches regular .cv-current-price */ -.cv-discounted-price { - color: #2E7D32; - font-weight: 600; -} - -.cv-promo-indicator { - font-size: 12px; - color: #4CAF50; - margin-left: 4px; -} - -/* Regular Price Strikethrough - matches regular .cv-value */ -.cv-strikethrough { - text-decoration: line-through; - color: #616161; -} - -/* Pulse Animation for Limited-Time Badge */ -@keyframes pulse-badge { - - 0%, - 100% { - box-shadow: 0 2px 4px rgba(255, 152, 0, 0.3); - } - - 50% { - box-shadow: 0 4px 12px rgba(255, 152, 0, 0.6); - } -} - -/* Responsive adjustments for promoDetails display */ -@media (max-width: 768px) { - .promo-badge { - font-size: 0.75rem; - } - - .promo-expiry-warning { - display: block; - margin-left: 0; - margin-top: 4px; - } - - /* Wireframe 1: Mobile responsive adjustments */ - .promotion-card { - padding: 16px; - } - - .promotion-card-header { - flex-direction: column; - align-items: flex-start; - } - - .discount-badge { - font-size: 14px; - padding: 6px 12px; - } - - .package-name { - font-size: 18px; - } - - .promotional-price .currency { - font-size: 24px; - } - - .promotional-price .amount { - font-size: 36px; - } - - .promotional-price .period { - font-size: 16px; - } - - .pricing-breakdown { - padding: 12px; - } - - .regular-price { - font-size: 14px; - } - - .savings-highlight { - font-size: 16px; - } - - .renewal-notice { - padding: 12px; - } - - .expiry-warning, - .permanent-discount-info, - .renewal-info { - font-size: 12px; - } -} - -/* Tablet responsive adjustments */ -@media (min-width: 769px) and (max-width: 1024px) { - .promotion-card { - padding: 20px; - } - - .promotional-price .currency { - font-size: 28px; - } - - .promotional-price .amount { - font-size: 42px; - } -} - -/* High contrast mode support */ -@media (prefers-contrast: high) { - .promotion-card { - border: 3px solid #2E7D32; - } - - .discount-badge { - border: 2px solid #ffffff; - } - - .pricing-breakdown { - border-left: 5px solid #4CAF50; - } - - .renewal-notice { - border-left: 5px solid #FF9800; - } - - .renewal-notice.permanent { - border-left: 5px solid #4CAF50; - } -} - -/* ============================================================================ - DUAL-PERIOD PROMO DISPLAY (Issue 2 - Deferred Promo) - ============================================================================ */ - -/* Next period promo icon (green star indicator) */ -.next-period-promo-icon { - color: #4CAF50; - /* AgMission primary green */ - font-size: 0.875rem; - /* 14px - slightly smaller than base text */ - margin-right: 4px; - vertical-align: middle; -} - -/* Next period promo value (green amount with emphasis) */ -.next-period-promo-value { - color: #4CAF50; - /* AgMission primary green */ - font-weight: 500; - /* Medium weight for emphasis */ -} - -/* Next billing date label (standard styling) */ -.next-billing-date-label { - color: #212121; - /* AgMission primary text */ -} - -/* Next billing date value (standard styling) */ -.next-billing-date-value { - color: #212121; - /* AgMission primary text */ -} - -.promo-note { - text-transform: none; - font-style: italic; -} \ No newline at end of file diff --git a/Development/client/src/app/profile/manage-subscription/manage-subscription.component.html b/Development/client/src/app/profile/manage-subscription/manage-subscription.component.html index 9e8c265..87664aa 100644 --- a/Development/client/src/app/profile/manage-subscription/manage-subscription.component.html +++ b/Development/client/src/app/profile/manage-subscription/manage-subscription.component.html @@ -1,968 +1,114 @@ - -
-
-
-

Hello {{profileUser?.contact}} -

- - -
-
- -
- +
+
+
+

Hi Micheal Smith

+
+
+
+
+

Total Balance: $2,500

+
+
+

Required payment date: October 1, 2022

+
+
+
+
+ +
+ +
+
+
+
+
Last payment received: October 1, 2022 for $2,500
+ +
+
+ +
+
+
+
+

Package

+
+
+ Ag-Mission Essentials +
+
+
    +
  • + check + 2-5 Aircraft +
  • +
  • + check + Max Acres 50K/Unlimited Acres +
  • +
  • + today + Yearly +
  • +
+
+
+ +
+
+
+
+
+
+

Addons

+
+
+
+ Aircraft Tracking (Per Vehicle) +
+
+
    +
  • + check + 2 Aircraft +
  • +
  • + today + Monthly +
  • +
+
+
+ +
+
+
+
+ +
+
+
+

Usage

+
+ +
+
+ + today + Oct 01, 2022 to Nov 01, 2022 + +
+
+ +
+
+ Total acres allowance: +

Unlimited

- - - -
- -
-
-

Total - Balance: {{totalBalance}}

-
-
- - - - - - - - - - - - - - - -
-
-
- -
-
- - -
-
-
- - - -

- {{status?.message}} -

-
-
- - -
- -
- Last payment received: -
- - {{charge?.created | tsToDate: lang }}  - for  - {{charge?.amount | usCurrency}} -
-
-
- -
-
-
- - -
-
-

Payment Method

- -
- -
Name: {{pmDefault.billing_details?.name}} -
-
{{crtCardDesc(pmDefault.card?.brand, - pmDefault.card?.last4)}}
-
Expiration - date: {{crtExp(pmDefault.card?.exp_month, pmDefault.card?.exp_year)}}
-
- -
{{crtCardDesc(pmDefault.card?.brand, pmDefault.card?.last4)}}
-
Expiration - date: {{crtExp(pmDefault.card?.exp_month, pmDefault.card?.exp_year)}}
-
-
-
-
- -
-
-
- - -
-
-

Package

- - -
- - {{ Labels.MULTIPLE_PACKAGES_WARNING }} -
- - -
  • Subscription end date: {{periodEnd | tsToDate: lang - }}
  • -
    - -
  • Max vehicles: {{vehicles}} Aircraft
  • -
    - -
  • Billing cycle: {{cycle}}
  • -
    - -
  • Payment Method: {{paymentMethod}}
  • -
    - -
    - - -
    - -
    -
    - {{ fullPkg.name }} -
    - - -
    - -
    - - - - - -
    - -
    - - - grade - {{ getPromoTypeLabel(pkg) }} - (If continue after trial) - -
    - - -
    - Discount: - {{ getPromoDescription(pkg) }} -
    - - -
    - {{ getPromoExpiryLabel(pkg) }} - {{ getPromoExpiryText(pkg) }} -
    - - -
    - Duration: - {{ - getPromoDurationText(pkg) }} -
    -
    - - -
    - - -
    - - - - - - - - - - - -
    - Trial Ends: - {{ formatTrialEndDate(pkg.trialEnd) }} -
    - -
    - Regular Price: - ${{ (fullPkg.price / 100) | number:'1.2-2' }}/year -
    -
    - - - - - - - - -
    - Trial Ends: - {{ formatTrialEndDate(pkg.trialEnd) }} -
    - -
    - Regular Price: - ${{ (fullPkg.price / 100) | number:'1.2-2' }}/year -
    - - -
    - {{ Labels.PAID_PRICE }}: - - {{ getAfterTrialPrice(pkg) }} - (save ${{ - getSavingsAmount(pkg, fullPkg) | number:'1.2-2' }}) - -
    - - -
    - After Promo Ends: - - ${{ getRegularPrice(pkg, fullPkg) | number:'1.2-2' }}/year - -
    -
    - - - - - - - -
    - Trial Ends: - {{ pkg.trialEnd | tsToDate: lang }} -
    -
    - After Trial: - ${{ (fullPkg.price / 100) | number:'1.2-2' }}/year -
    -
    -
    - - - - - - - - - - - -
    - - - {{ getRenewalPromoMessage(pkg) }} - -
    - - - - - - - -
    - Regular Price: - ${{ (fullPkg.price / 100) | number:'1.2-2' }}/year -
    -
    - - {{ Labels.PAID_PRICE }}: - - - ${{ getCurrentPrice(pkg, fullPkg) | number:'1.2-2' }}/year - (save ${{ - getSavingsAmount(pkg, fullPkg) | number:'1.2-2' }}) - -
    -
    - - -
    - After Promo Ends: - - ${{ getRegularPrice(pkg, fullPkg) | number:'1.2-2' }}/year - -
    - -
    - - - -
    - {{ Labels.PAID_PRICE }}: - ${{ (fullPkg.price / 100) | number:'1.2-2' }}/year -
    -
    - - - -
    - Ended On: - {{ pkg.periodEnd | tsToDate: lang }} -
    -
    - Previous Price: - ${{ (fullPkg.price / 100) | number:'1.2-2' }}/year -
    -
    - - - -
    - {{ Labels.PAID_PRICE }}: - ${{ (fullPkg.price / 100) | number:'1.2-2' }}/year -
    -
    - Next Bill Date: - {{ pkg.periodEnd | tsToDate: lang }} -
    -
    -
    - -
    - - -
    -
    - Max Vehicles: - {{ getMaxVehicles(pkg, fullPkg) }} Aircraft -
    - -
    - Max Acres: - {{ getMaxAcres(pkg, fullPkg) > 0 ? (getMaxAcres(pkg, fullPkg) | number:'1.0-0') : - SubTexts.unlimited }} -
    - -
    - Billing Cycle: - {{ SubTexts.yearly }} -
    - -
    - Payment Method: - {{ pkg.paymentMethod }} -
    - - - - - - -
    - Next Bill Date: - {{ pkg.periodEnd | tsToDate: lang }} -
    - - -
    - {{ isCustomerInCanada() ? (pkg.status === SubStripe.TRIALING ? Labels.NEXT_BILL_AMOUNT_BEFORE_TAX : Labels.NEXT_BILL_AMOUNT_INCL_TAX) : Labels.NEXT_BILL_AMOUNT }} - - {{ nextBillAmounts[pkg.lookupKey] }} - - - Loading... - -
    - - -
    - - - Promo: - - {{ pending.discountDisplay }} - - next billing period - - - for - {{ getPendingPromoDurationMonths(pending) }} - months - - - forever - - - (save - {{ pendingPromoSavings[pkg.lookupKey] }}/mo) - - -
    - - -
    - - - Next Period Amount - - - ${{ (nextPeriodCharge[pkg.lookupKey] / 100).toFixed(2) }} US - -
    - - -
    - Next Billing Date - {{ nextBillingDate[pkg.lookupKey] | date:'MMM d, yyyy' }} -
    -
    - -
    - Subscription end date - {{ pkg.periodEnd | tsToDate: lang }} -
    -
    -
    -
    -
    - - - -
    -
    - -
    -

    Addons

    - -
    - - -
    - -
    -
    - {{ fullAddon.name }} -
    - - -
    - -
    - - - - - -
    - -
    - - - grade - {{ getPromoTypeLabel(addon) }} - (If continue after trial) - -
    - - -
    - Discount: - {{ getPromoDescription(addon) }} -
    - - -
    - {{ getPromoExpiryLabel(addon) }} - {{ getPromoExpiryText(addon) }} -
    - - -
    - Duration: - {{ - getPromoDurationText(addon) }} -
    -
    - - -
    - - -
    - - - - - - - - - - - - -
    - Trial Ends: - {{ formatTrialEndDate(addon.trialEnd) }} -
    - -
    - Regular Price: - ${{ (fullAddon.price / 100) | number:'1.2-2' }}/month -
    -
    - - - - - - - - -
    - Trial Ends: - {{ formatTrialEndDate(addon.trialEnd) }} -
    - - - -
    - {{ Labels.PAID_PRICE }}: - - {{ getAfterTrialPrice(addon) }} - (save ${{ - getSavingsAmount(addon, fullAddon) | number:'1.2-2' }}) - -
    - - -
    - After Promo Ends: - - ${{ getRegularPrice(addon, fullAddon) | number:'1.2-2' }}/month - -
    -
    - - - - - - - -
    - Trial Ends: - {{ addon.trialEnd | tsToDate: lang }} -
    -
    - After Trial: - ${{ (fullAddon.price / 100) | number:'1.2-2' }}/month -
    -
    -
    - - - - - - - -
    - - - {{ getRenewalPromoMessage(addon) }} - -
    - - -
    - - {{ Labels.PAID_PRICE }}: - - - ${{ getCurrentPrice(addon, fullAddon) | number:'1.2-2' }}/month - (save ${{ - getSavingsAmount(addon, fullAddon) | number:'1.2-2' }}) - -
    -
    - - -
    - After Promo Ends: - - ${{ (fullAddon.price / 100) | number:'1.2-2' }}/month - -
    -
    - - - -
    - {{ Labels.PAID_PRICE }}: - ${{ (fullAddon.price / 100) | number:'1.2-2' }}/month -
    -
    - - - -
    - Ended On: - {{ addon.periodEnd | tsToDate: lang }} -
    -
    - Previous Price: - ${{ (fullAddon.price / 100) | number:'1.2-2' }}/month -
    -
    - - - -
    - {{ Labels.PAID_PRICE }}: - ${{ (fullAddon.price / 100) | number:'1.2-2' }}/month -
    -
    - Next Bill Date: - {{ addon.periodEnd | tsToDate: lang }} -
    -
    -
    - -
    - - -
    -
    - Max Vehicles: - {{ getMaxVehicles(addon, fullAddon) }} Aircraft -
    - -
    - Billing Cycle: - {{ SubTexts.monthly }} -
    - -
    - Payment Method: - {{ addon.paymentMethod }} -
    - - - - - - -
    - Next Bill Date: - {{ addon.periodEnd | tsToDate: lang }} -
    - - -
    - {{ isCustomerInCanada() ? (addon.status === SubStripe.TRIALING ? Labels.NEXT_BILL_AMOUNT_BEFORE_TAX : Labels.NEXT_BILL_AMOUNT_INCL_TAX) : Labels.NEXT_BILL_AMOUNT }} - - {{ nextBillAmounts[addon.lookupKey] }} - - - Loading... - -
    - - -
    - - - Promo: - - {{ pending.discountDisplay }} - - next billing period - - - for - {{ getPendingPromoDurationMonths(pending) }} - months - - - forever - - - (save - {{ pendingPromoSavings[addon.lookupKey] }}/mo) - - -
    - - -
    - - - Next Period Amount - - - ${{ (nextPeriodCharge[addon.lookupKey] / 100).toFixed(2) }} US - -
    - - -
    - Next Billing Date - {{ nextBillingDate[addon.lookupKey] | date:'MMM d, yyyy' }} -
    -
    - -
    - Subscription end date - {{ addon.periodEnd | tsToDate: lang }} -
    -
    -
    -
    -
    - - - -
    -
    -
    -
    -
    - - -
    -
    -
    -

    Usage

    -
    - -
    -
    - -
    -
    -
    - - - Edit Subscriptions -
    -
    -

    Package

    -
    - - - - -
    -
    -
    -
    -

    Addons

    -
    - - - - -
    -
    -
    - - - - -
    - - - - -
    -
    -
    -
    - -
    -
    -
    -
    -
    -
    - - - - -
  • {{SubTexts.textPastdue}}
  • -
    - -
  • {{SubTexts.textUnpaid}}
  • -
    - -
  • {{SubTexts.textInc}}
  • -
    - -
  • {{SubTexts.textCanceled}}
  • -
    -
    -
    - - -
  • {{SubTexts.textActive}}
  • -
    - - - - - -
    - -
    -
    - -
    - -
    -
    -
    -
    -
    - - - - - -
    - - - {{name}} Trial - - - {{name}}
    Trial - ends {{periodEnd | tsToDate: lang }}
    -
    -
    - - {{name}} - -
    -
    - -
    {{name}}
    -
    - -
    - - - {{name}} Trial - - - {{name}}
    Trial - ends {{periodEnd | tsToDate: lang }}
    -
    -
    - - {{name}} - -
    -
    - -
    {{name}}
    -
    -
    -
    -
    - - - -
  • Next charge on: {{periodEnd | tsToDate: lang - }}
  • -
    - -
  • - - Bill yearly (Next bill date: {{periodEnd | - tsToDate: lang }}) - - - Bill monthly (Next bill date: {{periodEnd | - tsToDate: lang }}) - -
  • -
    -
    - - -
    -
    -
    -
    - -
    -
    -
    -
    -
    \ No newline at end of file +
    \ No newline at end of file diff --git a/Development/client/src/app/profile/manage-subscription/manage-subscription.component.ts b/Development/client/src/app/profile/manage-subscription/manage-subscription.component.ts index 3855e79..699153a 100644 --- a/Development/client/src/app/profile/manage-subscription/manage-subscription.component.ts +++ b/Development/client/src/app/profile/manage-subscription/manage-subscription.component.ts @@ -1,2242 +1,16 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; -import { CancelPollSubscription, ClearSubscriptionStatus, GotoServices, PollUnpaidSubscription, ResetSubscriptionIntent, ResolvePayment, Compound, InitSubscription, FetchLatestSubscriptionSuccess, FETCH_LATEST_SUBSCRIPTION_SUCCESS, StartBillingInfo, FetchDefaultPm, FetchPaymentMethodList, SetMode } from '@app/actions/subscription.actions'; -import { FetchUsage, ResetUsage } from '../actions/usage.actions'; -import { selectSubPkgs, selectSubAddons, getSubscriptionStatus, getUnpaid, getSubscriptions, getDefPM, getPaymentMethods } from '../../reducers'; -import { catchError, filter, map, switchMap, take } from 'rxjs/operators'; -import { User } from '@app/accounts/models/user.model'; -import { AGNavSubscriptionShort, Addon, Discount, Package, PaymentMethod, PendingPromoDetails, Status, StripeSubscription, Unpaid, UsageDetail, Invoice, InvoicePackage } from '@app/domain/models/subscription.model'; -import { getUsageState } from '../selectors/profile.selector'; -import { SubscriptionService } from '@app/domain/services/subscription.service'; -import { ActivePromoService, ActivePromo } from '@app/domain/services/active-promo.service'; -import { SubAppErr, SUB, SubTexts, createSubStatus, SubStripe, SubType, Mode, subPlans, hasVendorErr } from '../common'; -import { BaseComp } from '@app/shared/base/base.component'; -import { GC, globals, Labels } from '@app/shared/global'; -import { of } from 'rxjs'; -import { FETCH_SUB_PLANS_SUCCESS, FetchSubPlans, RESET_SUB_PLANS } from '@app/actions/sub-plans.actions'; -import { DateUtils } from '@app/shared/utils'; -import { IMembership, UserModel } from '@app/auth/models/user.model'; -import { BadgeConfig, BadgeType, BadgeSize } from '@app/shared/badge/badge-config.model'; - -/** - * Promo details provided by backend in subscription response (r962) - */ -interface PromoDetails { - hasPromo: boolean; - name: string | null; - discountDisplay: string | null; - expiresAt: string | null; - discountEndsAt: string | null; - daysRemaining: number | null; - daysUntilDiscountEnds: number | null; - isTimeLimited: boolean; - durationInMonths: number | null; - duration: string | null; - percentOff: number | null; - amountOff: number | null; - currency: string | null; -} - -enum EditDiaContentType { AUTO_RENEW, CONTINUE_TRIAL }; +import { Component, OnInit } from '@angular/core'; @Component({ selector: 'my-bills', templateUrl: './manage-subscription.component.html', styleUrls: ['./manage-subscription.component.css'] }) -export class ManageSubscriptionComponent extends BaseComp implements OnInit, OnDestroy { - readonly SubTexts = SubTexts; - readonly globals = globals; - readonly Labels = Labels; - readonly SubStripe = SubStripe; - readonly SubType = SubType; - readonly EditDiaContentType = EditDiaContentType; +export class ManageSubscriptionComponent implements OnInit { + useUp: number= 50; - usageDetail: UsageDetail; - profileUser: User; - status: Status; - subscriptions: StripeSubscription[]; - packages: AGNavSubscriptionShort[]; - addons: AGNavSubscriptionShort[]; - totalBalance: string; - dueDate: string; - charges: any[]; - lastPayment: number; - existIncompleteSub: boolean; - existNonActiveSub: boolean; - existNonActivePkg: boolean; - existNonActiveAddon: boolean; - existNoError: boolean; - existPastDueSub: boolean; - existUnpaidSub: boolean; - existUnpaidInvoice: boolean; - existLastPayment: boolean; - hasSubscription: boolean; - hasUnpaidBalances: boolean; - isPolling: boolean; - displayEdit: boolean; - hasInvTaxLoc: boolean; - editDiaContentType: EditDiaContentType; - disableSave: boolean; - pmDefault: PaymentMethod; - pmDefaultErr: boolean; - paymentMethodList: PaymentMethod[]; - membership: IMembership; - user: UserModel; - - /** Country code from the user's saved billing address (e.g. 'CA', 'US') */ - billingCountry: string | null = null; - - autoRenewChkbox: { [i: string]: boolean }; - autoRenewChkboxDef: { [i: string]: boolean }; - contTrialChkbox: { [i: string]: boolean }; - contTrialChkboxDef: { [i: string]: boolean }; - isLoadingSubscriptions: boolean = false; - - /** Map of lookup keys to next bill amounts for display */ - nextBillAmounts: { [lookupKey: string]: string } = {}; - - // ============================================================================ - // INVOICE PREVIEW PROPERTIES (Dual-Period Support) - // ============================================================================ - - /** - * Current period charge amount (immediate billing) - * Used when backend returns multiple invoices for deferred promo scenarios - */ - currentPeriodCharge: { [lookupKey: string]: number } = {}; - - /** - * Next period charge amount (future billing cycle) - * Only populated when deferred promo applies (100% FREE promo on quantity change) - */ - nextPeriodCharge: { [lookupKey: string]: number } = {}; - - /** - * Flag indicating if next period has active promo - * True when invoice.has_promo === true for next period invoice - */ - hasPromoNextPeriod: { [lookupKey: string]: boolean } = {}; - - /** - * Next billing date (when next period charge will be billed) - * Extracted from next period invoice.period_start or subscription.current_period_end - */ - nextBillingDate: { [lookupKey: string]: Date } = {}; - - /** r975+: pendingPromoDetails from invoice or subscription — keyed by lookupKey. - * Present when a deferred 100% FREE promo is scheduled for the next billing period. */ - pendingPromoDetails: { [lookupKey: string]: PendingPromoDetails } = {}; - - /** Formatted savings amount for pending promo badge (e.g. "$99.90"). Populated from - * invoice.total_discount_amounts after retrieveNextInvoices completes. Keyed by lookupKey. */ - pendingPromoSavings: { [lookupKey: string]: string } = {}; - - // ============================================================================ - // PRORATION CREDIT STATE (Issue 4 - Proration Credit Display) - // ============================================================================ - - get hasValidTrialOffer(): boolean { - return this.authSvc.validateTrial(this.membership?.trials); - }; - hasActiveTrial: boolean; - - vendorErr: boolean; - lang; - - /** Map of lookup keys to active promos for display */ - activePromos: Map = new Map(); - - /** - * Check if user has multiple subscription packages - */ - get hasMultiplePackages(): boolean { - return this.packages?.length > 1; - } - - constructor( - private readonly route: ActivatedRoute, - private readonly subSvc: SubscriptionService, - public readonly activePromoSvc: ActivePromoService - ) { - super(); - this.profileUser = this.route.snapshot.data['user']; - this.user = this.authSvc.user; - this.membership = this.user?.membership; - this.lang = this.authSvc.locale; - } + constructor() { } ngOnInit(): void { - this.store.dispatch(new Compound([ - new ClearSubscriptionStatus(), - new ResetSubscriptionIntent(), - new FetchSubPlans(), - new FetchPaymentMethodList(), - new FetchDefaultPm() - ])); - this.initSub$(); - this.initSubInfo(); - this.loadActivePromos(); - this.sub$.add( - this.subSvc.getBillingAddress(this.user?._id).subscribe({ - next: (addr) => this.billingCountry = addr?.country ?? null, - error: () => this.billingCountry = null - }) - ); } - /** - * Load active promos from backend and build lookup map - * Handles both exact-match promos (priceKey specified) and type-only promos (priceKey: null) - * Type-only promos apply to ALL items of that type (e.g., all packages or all addons) - */ - private loadActivePromos(): void { - this.activePromoSvc.getActivePromos().subscribe(promos => { - this.activePromos = new Map(); - promos.forEach(p => { - if (p.priceKey) { - // Exact match promo - keyed by priceKey - this.activePromos.set(p.priceKey, p); - } else if (p.type) { - // Type-only promo (priceKey is null) - applies to ALL of that type - // Store with special key convention: "package_all" or "addon_all" - this.activePromos.set(`${p.type}_all`, p); - } else { - // Universal promo (no type, no priceKey) - applies to EVERYTHING - this.activePromos.set('package_all', p); - this.activePromos.set('addon_all', p); - } - }); - }); - } - - /** - * Get promo for a given lookup key (used in template) - * Checks for exact match first, then falls back to type-only promo - * - * CRITICAL: Trial subscriptions NEVER show promos because: - * 1. Trial IS the promotion - no additional discount needed - * 2. Prevents confusing UX where promotional pricing shows during trial period - * 3. Consistent with manage-services component behavior - * - * @param lookupKey Package or addon lookup key (e.g., 'ess_1', 'addon_1') - * @param type Subscription type ('package' or 'addon') for type-only promo fallback - * @returns ActivePromo if exists and subscription is not a trial, null otherwise - */ - getPromoForLookupKey(lookupKey: string, type: 'package' | 'addon' = 'package'): ActivePromo | null { - // CRITICAL: Hide promos for trial subscriptions (status='trialing') - // Trial IS the promotion - user doesn't need to see additional promo badges - const subscription = this.subscriptions?.find(sub => - sub.items?.data?.some(item => item?.price?.lookup_key === lookupKey) - ); - - if (subscription?.status === SubStripe.TRIALING) { - return null; // Hide ALL promos for trial subscriptions - } - - // ✅ FIX (2026-01-26): Only show promo if subscription actually has one applied - // Prevents global activePromos from showing for existing subscriptions without promos - // See: docs/current_work/.../2026-01-26-16-45-promo-display-unexpected-behavior-investigation.md - - // This component is used in manage-subscription context (user viewing their existing subscription) - // Therefore, we should ONLY show promo if the subscription itself has a promo applied - // DO NOT show available global promos for existing subscriptions - - // Check if subscription has promo applied via promoDetails (r948+) - if (subscription?.promoDetails?.hasPromo) { - // ✅ FIX (2026-01-28): Use MongoDB promo validUntil instead of Stripe coupon duration - // Stripe coupons with duration='forever' don't have discount.end, so backend returns isTimeLimited=false - // BUT MongoDB promo may have validUntil date that should be honored for expiry calculations - - // Try to get MongoDB promo data from activePromos map - const mongoPromo = this.activePromos.get(lookupKey) || - this.activePromos.get(`${type}_all`) || - this.activePromos.get('package_all') || - this.activePromos.get('addon_all'); - - // If MongoDB promo has validUntil, use that instead of Stripe's promoDetails - if (mongoPromo && mongoPromo.validUntil) { - const expiryDate = new Date(mongoPromo.validUntil); - const now = new Date(); - const daysRemaining = Math.max(0, Math.ceil((expiryDate.getTime() - now.getTime()) / 86400000)); - - return { - type: type, - priceKey: lookupKey, - validUntil: mongoPromo.validUntil, - name: mongoPromo.name || subscription.promoDetails.name || 'Active Promo', - discountType: mongoPromo.discountType, - discountValue: mongoPromo.discountValue, - isTimeLimited: true, // MongoDB promo with validUntil is time-limited - daysRemaining: daysRemaining - }; - } - - // Fallback to Stripe promoDetails (for time-limited Stripe discounts) - return { - type: type, - priceKey: lookupKey, - validUntil: subscription.promoDetails.expiresAt || '', - name: subscription.promoDetails.name || 'Active Promo', - discountType: subscription.promoDetails.discountDisplay?.includes('FREE') ? 'free' : 'percent', - discountValue: subscription.promoDetails.discountDisplay?.includes('FREE') ? 100 : 50, - isTimeLimited: subscription.promoDetails.isTimeLimited, - daysRemaining: subscription.promoDetails.daysRemaining - }; - } - - // ❌ REMOVED (r955): subscription.discount field no longer returned by backend - // Backend now returns promoDetails only (handled above) - // No fallback needed - if promoDetails doesn't exist, no promo is active - - // ✅ NEW (2026-01-30): Case 2B - Renewal Promo for Subscriptions WITHOUT Promo Applied (Re-acquisition) - // Show available global promo for subscriptions with auto-renew DISABLED - // Target: Legacy customers who subscribed without promo, now have auto-renew OFF - // Business Goal: Incentivize renewal by offering new promo to non-auto-renewing customers (churn reduction) - // ✅ UPDATED (2026-02-24): Removed promoExpiry > subscriptionEnd gate — only check is validUntil > now. - // The old gate hid the banner when the promo expired before the billing cycle end, which was too strict. - if (subscription?.cancel_at_period_end) { - // Query activePromos map for global promos (lookup_key, type_all, package_all, addon_all) - const mongoPromo = this.activePromos.get(lookupKey) || - this.activePromos.get(`${type}_all`) || - this.activePromos.get('package_all') || - this.activePromos.get('addon_all'); - - // Case 2B Condition: Promo must not yet be expired (validUntil > now) - if (mongoPromo && mongoPromo.validUntil) { - const promoExpiry = new Date(mongoPromo.validUntil); - const now = new Date(); - - if (promoExpiry > now) { - const daysRemaining = Math.max(0, Math.ceil((promoExpiry.getTime() - now.getTime()) / 86400000)); - - return { - type: type, - priceKey: lookupKey, - validUntil: mongoPromo.validUntil, - name: mongoPromo.name || 'Renewal Promo', - discountType: mongoPromo.discountType, - discountValue: mongoPromo.discountValue, - isTimeLimited: true, - daysRemaining: daysRemaining, - isRenewalPromo: true // ✅ Case 2B flag - Distinguish renewal offer from existing promo - }; - } - } - } - - // ✅ For existing subscriptions without promos and NOT Case 2B, do NOT show global activePromos - // This prevents "Active Promo" label from appearing for non-promo subscriptions (Issue 1 fix) - return null; - } - - /** Returns the duration key for a pending promo row. - * Drives the template's ng-container switch structure. - * - * Edge case: Stripe's schema allows duration="repeating" with durationInMonths=null - * (open-ended repeating coupon with no month count). In that case we fall back to - * "once" so the template renders "next billing period" rather than "for 0 months". */ - getPendingPromoDuration(pending: PendingPromoDetails): 'once' | 'repeating' | 'forever' { - const d = (pending?.duration as 'once' | 'repeating' | 'forever') || 'once'; - if (d === 'repeating' && !pending?.durationInMonths) { return 'once'; } - return d; - } - - /** Returns the durationInMonths value for a pending repeating promo. */ - getPendingPromoDurationMonths(pending: PendingPromoDetails): number { - return pending?.durationInMonths || 0; - } - - private initSubInfo() { - try { - if (this.membership) { - this.isLoadingSubscriptions = true; - - // ✅ CRITICAL: Reset usage state FIRST to clear stale data from previous navigation - // This prevents showing old "Unlimited" values while waiting for fresh data - this.store.dispatch(new ResetUsage()); - - this.store.dispatch(new InitSubscription({ custId: this.membership.custId })); - - // ✅ FIX: Use take(1) but filter for the specific custId we just dispatched - // Problem: take(1) without filtering can complete with wrong customer's data - // Solution: Filter by custId to ensure we get the right action for THIS component instance - this.sub$.add( - this.appActions.ofTypes([FETCH_LATEST_SUBSCRIPTION_SUCCESS]) - .pipe( - filter((action: any) => action.payload?.membership?.custId === this.membership.custId), - take(1) - ) - .subscribe((action: any) => { - const updatedMembership = action.payload.membership; - - // Use centralized utility to get latest package subscription with fresh data - const latestPkg = this.subSvc.getLatestSubscription( - updatedMembership.subscriptions, - SubType.PACKAGE - ); - - if (latestPkg) { - // Get MongoDB-prioritized maxAcres from fresh membership data - const effectiveMaxAcres = this.subSvc.getEffectiveAcresLimit( - latestPkg, - updatedMembership.customLimits - ); - - this.store.dispatch(new FetchUsage({ - custId: this.membership.custId, - byPuid: this.user._id, - lookupKey: latestPkg.items?.[0]?.price as string, - effectiveMaxAcres - })); - } else { - this.store.dispatch(new ResetUsage()); - } - }) - ); - } - } catch (err) { - console.error('Manage subscription error:', err); - this.status = createSubStatus(SubAppErr.MGE_SUB_ERR); - } - } - - private initSub$() { - const initBalSection = () => { - this.sub$.add(this.store.select(getSubscriptionStatus).pipe( - map((status) => { - this.status = status; - if (this.status?.code === SubAppErr.FETCH_DEFAULT_PM_ERR) { - this.pmDefaultErr = true; - if (!this.hasAutoRenew()) this.status = null; - } - if (hasVendorErr(this.status?.code)) { - this.vendorErr = true; - } - }) - ).subscribe({ - error: (err) => { - console.error('Subscription fetch error:', err); - this.status = createSubStatus(SubAppErr.MGE_SUB_ERR); - } - })); - - const hasSub = this.membership?.subscriptions?.length > 0; - if (hasSub) { - this.sub$.add(this.subSvc.getCustCharges({ custId: this.membership.custId, limit: this.membership.subscriptions.length }).pipe( - map((res) => { - this.existLastPayment = res?.length > 0; - if (this.existLastPayment) { - const hasMulInvoices = res.length > 1; - const ONE_DAY_IN_SEC = 86400; - if (hasMulInvoices) { - const latestPayment = res?.reduce((c1, c2) => c1.created > c2.created ? c1 : c2); - const sameDayPmts = res?.filter((c1) => res.every((c2) => Math.abs(c1.created - c2.created) < ONE_DAY_IN_SEC) && Math.abs(latestPayment.created - c1.created) < ONE_DAY_IN_SEC); - if (sameDayPmts.length > 0) { - return this.charges = sameDayPmts.reduce((accum, curVal) => { - const created = Math.max(accum[0].created, curVal.created); - const amount = accum[0].amount + curVal.amount; - return [{ created, amount }]; - }, [{ created: 0, amount: 0 }]); - } - return this.charges = res; - } else { - return this.charges = res; - } - } - }) - ).subscribe({ - error: (err) => { - console.error('Addons fetch error:', err); - this.status = createSubStatus(SubAppErr.MGE_SUB_ERR); - } - })); - } - } - - const loadSubscriptionDetails = () => { - const initSubBalance = (unpaid: Unpaid, subscriptions: StripeSubscription[]) => { - this.existUnpaidInvoice = unpaid?.invoices?.length !== 0; - let balances = 0; - if (this.existUnpaidInvoice) { - balances = unpaid.invoices.map((invoice) => invoice.total).reduce((curr, next) => curr + next, 0); - this.totalBalance = this.subSvc.formatCurrency(balances); - } else if (this.existPastDueSub) { - const pastDueInvoices = subscriptions?.filter((sub) => sub.status === SubStripe.PAST_DUE || sub.status === SubStripe.OVERDUE).map((sub) => sub.latest_invoice) || []; - const hasPastDueInvoices = pastDueInvoices.length > 0; - if (hasPastDueInvoices) { - balances = this.subSvc.calcAmount(pastDueInvoices, { subscriptions, coupon: this.subSvc.getInvCoupon(pastDueInvoices) }).total; - this.totalBalance = this.subSvc.formatCurrency(balances); - } - } else if (this.existIncompleteSub) { - const incompleteInvoices = subscriptions?.filter((sub) => sub.status === SubStripe.INCOMPLETE).map((sub) => sub.latest_invoice) || []; - const hasIncompleteInvoices = incompleteInvoices?.length > 0; - if (hasIncompleteInvoices) { - balances = this.subSvc.calcAmount(incompleteInvoices, { subscriptions, coupon: this.subSvc.getInvCoupon(incompleteInvoices) }).total; - this.totalBalance = this.subSvc.formatCurrency(balances); - } - } else { - this.totalBalance = this.subSvc.formatCurrency(balances); - } - - this.hasUnpaidBalances = this.existUnpaidSub && balances > 0; - this.existNoError = this.status?.code !== SubAppErr._500_ERR && this.status?.code !== SubAppErr.RES_ERR && this.status?.code !== SubAppErr.FETCH_SUB_ERR && this.status?.code !== SubAppErr.POLL_ERR && this.status?.code !== SubAppErr.NO_INVOICES_ERR && this.status?.code !== SubAppErr.NO_SUBS_ERR; - this.isPolling = this.status?.code === SUB.POLLING; - } - - const assignPaymentMethod = (agNavSub: AGNavSubscriptionShort, id: string): void => { - const formatPaymentMethod = (pm: PaymentMethod) => `${this.subSvc.crtCardDesc(pm?.card?.brand, pm?.card?.last4)}`; - const sub = this.subscriptions?.find((sub) => sub?.id === id); - const defaultPM = sub?.default_payment_method || sub?.default_source || this.pmDefault?.id; - agNavSub.paymentMethod = formatPaymentMethod(this.paymentMethodList?.find((pm) => pm.id === defaultPM)); - } - - this.sub$.add(this.store.select(selectSubPkgs).pipe( - switchMap((packages) => { - this.packages = packages ? packages : []; - return this.store.select(selectSubAddons).pipe( - map((addons) => { - this.addons = addons ? addons : []; - return this.packages?.concat(this.addons); - }) - ); - }), - switchMap((subs) => { - this.hasSubscription = subs?.length > 0; - this.existNonActiveSub = this.subSvc.checkSubStatus(subs, SubStripe.ACTIVE, '!=='); - this.existNonActivePkg = this.subSvc.checkSubStatus(this.packages, SubStripe.ACTIVE, '!=='); - this.existNonActiveAddon = this.subSvc.checkSubStatus(this.addons, SubStripe.ACTIVE, '!=='); - this.existIncompleteSub = this.subSvc.checkSubStatus(subs, SubStripe.INCOMPLETE, '==='); - this.existPastDueSub = this.subSvc.checkSubStatus(subs, SubStripe.PAST_DUE, '===') || this.subSvc.checkSubStatus(subs, SubStripe.OVERDUE, '==='); - this.existUnpaidSub = this.subSvc.checkSubStatus(subs, SubStripe.UNPAID, '==='); - - subs?.forEach((sub) => { - this.autoRenewChkbox = { ...this.autoRenewChkbox, [sub.lookupKey]: !sub.cancelAtPeriodEnd }; - this.autoRenewChkboxDef = { ...this.autoRenewChkboxDef, [sub.lookupKey]: !sub.cancelAtPeriodEnd }; - this.contTrialChkbox = { ...this.contTrialChkbox, [sub.lookupKey]: !sub.cancelAtPeriodEnd }; - this.contTrialChkboxDef = { ...this.contTrialChkboxDef, [sub.lookupKey]: !sub.cancelAtPeriodEnd }; - }); - if (this.existUnpaidSub) this.store.dispatch(new PollUnpaidSubscription({ custId: this.membership.custId })); - return this.store.select(getUnpaid); - }), - switchMap((unpaid) => { - return this.store.select(getSubscriptions).pipe( - switchMap((subscriptions) => { - this.subscriptions = subscriptions; - this.isLoadingSubscriptions = false; - return this.store.select(getPaymentMethods); - }), - switchMap((paymentMethodList) => { - this.paymentMethodList = paymentMethodList; - return this.store.select(getDefPM); - }), - map((pmDefault) => { - this.pmDefault = pmDefault || this.paymentMethodList?.[0]; - if (this.subscriptions?.length === (this.packages?.length + this.addons?.length)) { - this.packages?.forEach((pkg) => assignPaymentMethod(pkg, pkg.id)); - this.addons?.forEach((addon) => assignPaymentMethod(addon, addon.id)); - - // Load next bill amounts after packages and addons are fully loaded - this.loadAllNextBillAmounts(); - } - this.hasActiveTrial = this.authSvc.hasActiveTrial(this.membership?.trials); - if (!this.subSvc.hasInValTaxLoc(this.subscriptions)) initSubBalance(unpaid, this.subscriptions); - }) - ) - }), - ).subscribe({ - error: (err) => { - console.error('Packages fetch error:', err); - this.status = createSubStatus(SubAppErr.MGE_SUB_ERR); - } - })); - } - - const initUsageSection = () => { - this.sub$.add(this.store.select(getUsageState).pipe( - map((state) => { - if (state.status) { - this.status = state.status; - } else { - this.usageDetail = state.usageDetail; - } - }) - ).subscribe({ - error: (err) => { - console.error('Usage fetch error:', err); - this.status = createSubStatus(SubAppErr.MGE_SUB_ERR); - } - })); - } - - this.sub$ = this.appActions.ofTypes([FETCH_SUB_PLANS_SUCCESS, RESET_SUB_PLANS]).subscribe((action) => { - initBalSection(); - loadSubscriptionDetails(); - initUsageSection(); - }); - } - - /** - * Load next bill amount for a subscription by calling retrieveUpcomingInvoices API - * Fetches upcoming invoice data and stores formatted amount for display - * - * Special handling for trial subscriptions: - * - Stripe API cannot generate upcoming invoices for active trials (returns invoice_upcoming_none error) - * - For trials, calculate expected post-trial amount from subscription plan data - * - * @param lookupKey Package or addon lookup key (e.g., 'ess_1', 'addon_1') - * @param subscriptionId Stripe subscription ID - */ - private loadNextBillAmount(lookupKey: string, subscriptionId: string): void { - if (!this.membership?.custId || !subscriptionId) { - console.warn('Cannot load next bill amount: missing custId or subscriptionId'); - return; - } - - // Find the subscription to get package details - const subscription = this.subscriptions?.find(sub => sub.id === subscriptionId); - if (!subscription) { - console.warn(`Cannot load next bill amount: subscription ${subscriptionId} not found`); - return; - } - - // Handle trial subscriptions: Stripe cannot generate upcoming invoices for trials - // Calculate expected amount from plan data instead - if (subscription.status === SubStripe.TRIALING) { - const expectedAmount = this.calculateTrialPostAmount(subscription, lookupKey); - if (expectedAmount !== null) { - this.nextBillAmounts[lookupKey] = expectedAmount; - return; - } - // If calculation fails, fall through to API call (will likely fail but has error handling) - } - - // Build invoice package request - // Use current UTC time instead of future period end to avoid Stripe validation errors - // Stripe requires proration_date within current period or phase - // Date.now() returns milliseconds since Unix epoch (UTC), divide by 1000 for seconds - // This is timezone-independent and matches Stripe's Unix timestamp format (always UTC) - // For annual subscriptions, current_period_end can be 1 year in future (outside Stripe's valid window) - const currentTimeSeconds = Math.floor(Date.now() / 1000); - - // Determine if this is an addon or package subscription. - // CRITICAL: sending addons:[] (empty) to the backend is interpreted as "cancel all addons", - // which generates phantom proration credits. Must pass current quantity to get a clean renewal preview. - const addonInfo = this.addons?.find(a => String(a.lookupKey) === lookupKey); - const isAddon = !!addonInfo; - - const invoicePkg: InvoicePackage = { - custId: this.membership.custId, - package: isAddon ? '' : lookupKey, // Only set package for package lookup keys - addons: isAddon - ? [{ price: lookupKey, quantity: addonInfo.quantity ?? 1 }] // Pass current quantity - no change - : [], // Package: addons empty, backend resolves from subscription data - prorateTS: currentTimeSeconds // Current UTC time always valid for Stripe API - }; - - // Call API to get upcoming invoices - this.subSvc.retrieveUpcomingInvoices(invoicePkg).subscribe({ - next: (invoices: Invoice[]) => { - if (!invoices || invoices.length === 0) { - console.warn(`No upcoming invoices returned for subscription ${subscriptionId}`); - this.nextBillAmounts[lookupKey] = 'N/A'; - return; - } - - // Filter to invoices for THIS subscription before path selection. - // The backend may return invoices for multiple subscriptions in one response - // (e.g., a package proration credit alongside an addon renewal). - // In the genuine dual-invoice deferred-promo case both invoices share the - // same subscriptionId, so this filter is safe. - const subscriptionInvoices = invoices.filter( - inv => !inv.subscription || inv.subscription === subscriptionId - ); - const invoicesToProcess = subscriptionInvoices.length > 0 ? subscriptionInvoices : invoices; - - // Handle dual-invoice scenario (deferred promo with quantity change) - if (invoicesToProcess.length > 1) { - // Find current and next period invoices - const currentInvoice = this.findInvoiceByPeriodType(invoicesToProcess, 'current'); - const nextInvoice = this.findInvoiceByPeriodType(invoicesToProcess, 'next'); - - // Store current period charge (immediate billing) - if (currentInvoice) { - const currentAmountCents = currentInvoice.total ?? currentInvoice.amount_due ?? 0; - - // Store current period charge - this.currentPeriodCharge[lookupKey] = currentAmountCents; - - // Display net total - const currentAmountDollars = currentAmountCents / 100; - this.nextBillAmounts[lookupKey] = `$${currentAmountDollars.toFixed(2)} US`; - } - - // Store next period charge (future billing cycle) - if (nextInvoice) { - const nextAmountCents = nextInvoice.total ?? nextInvoice.amount_due ?? 0; - this.nextPeriodCharge[lookupKey] = nextAmountCents; - this.hasPromoNextPeriod[lookupKey] = nextInvoice.has_promo === true; - - // r975: populate pendingPromoDetails from whichever invoice carries it - if (currentInvoice?.pendingPromoDetails) { - this.pendingPromoDetails[lookupKey] = currentInvoice.pendingPromoDetails; - } else if (nextInvoice?.pendingPromoDetails) { - this.pendingPromoDetails[lookupKey] = nextInvoice.pendingPromoDetails; - } else { - delete this.pendingPromoDetails[lookupKey]; - } - - // Populate pending promo savings from next period invoice discount amounts - const nextSavings = this.getInvoiceSavings(nextInvoice); - if (nextSavings) { - this.pendingPromoSavings[lookupKey] = nextSavings; - } else { - delete this.pendingPromoSavings[lookupKey]; - } - - // Extract next billing date from r975 field (when the charge is collected) - this.nextBillingDate[lookupKey] = currentInvoice?.next_billing_date - ? new Date(currentInvoice.next_billing_date * 1000) - : new Date(subscription.current_period_end * 1000); - } - } else { - // Standard single-invoice scenario - const invoice = invoicesToProcess.find(inv => inv.subscription === subscriptionId) || invoicesToProcess[0]; - - if (invoice) { - const amountInCents = invoice.total ?? invoice.amount_due ?? 0; - - // Store current period charge - this.currentPeriodCharge[lookupKey] = amountInCents; - - // Clear next period data (no deferred promo) - this.nextPeriodCharge[lookupKey] = undefined; - this.hasPromoNextPeriod[lookupKey] = false; - // r975: pendingPromoDetails may be injected on standard invoices too - if (invoice?.pendingPromoDetails) { - this.pendingPromoDetails[lookupKey] = invoice.pendingPromoDetails; - } else { - delete this.pendingPromoDetails[lookupKey]; - // Detect discount-covered invoice: total = $0 but subtotal > $0 (Stripe-level active coupon). - // Backend only injects pendingPromoDetails for deferred metadata-based coupons (r975+). - // For already-active Stripe discounts the coupon simply zeroes the invoice total. - if (amountInCents === 0 && (invoice.subtotal_excluding_tax ?? 0) > 0) { - // Cross-reference already-loaded subscription data so the synthetic pending promo - // carries real coupon metadata (duration, durationInMonths, discountDisplay). - // this.packages / this.addons are populated before loadAllNextBillAmounts() runs. - const existingSub = [...(this.packages || []), ...(this.addons || [])] - .find(s => s.lookupKey === lookupKey); - const subPromo = existingSub && existingSub.promoDetails && existingSub.promoDetails.hasPromo - ? existingSub.promoDetails : null; - - this.pendingPromoDetails[lookupKey] = { - isPending: true as const, - appliesToNextPeriod: true as const, - name: subPromo && subPromo.name ? subPromo.name : Labels.DISCOUNT_APPLIED, - discountDisplay: subPromo && subPromo.discountDisplay ? subPromo.discountDisplay : Labels.DISCOUNT_DISPLAY_FALLBACK, - percentOff: subPromo ? subPromo.percentOff : null, - amountOff: subPromo ? subPromo.amountOff : null, - currency: subPromo ? subPromo.currency : null, - duration: subPromo ? subPromo.duration : null, - durationInMonths: subPromo ? subPromo.durationInMonths : null, - expiresAt: null, - discountEndsAt: null, - daysRemaining: null, - daysUntilDiscountEnds: null, - isTimeLimited: false as const, - }; - } - } - // Populate pending promo savings from invoice discount amounts - const savingsAmount = this.getInvoiceSavings(invoice); - if (savingsAmount) { - this.pendingPromoSavings[lookupKey] = savingsAmount; - } else { - delete this.pendingPromoSavings[lookupKey]; - } - - // r975: next_billing_date present = next charge date; absent = no upcoming charge - this.nextBillingDate[lookupKey] = invoice.next_billing_date - ? new Date(invoice.next_billing_date * 1000) - : undefined; - - // Display net total - const amountInDollars = amountInCents / 100; - this.nextBillAmounts[lookupKey] = `$${amountInDollars.toFixed(2)} US`; - } else { - console.warn(`No invoice found for subscription ${subscriptionId}`); - this.nextBillAmounts[lookupKey] = 'N/A'; - this.resetInvoiceState(lookupKey); - } - } - }, - error: (err) => { - console.error(`Failed to load next bill amount for ${lookupKey}:`, err); - - // Reset state on error - this.resetInvoiceState(lookupKey); - - // Handle specific Stripe error codes - if (err?.raw?.code === 'invoice_upcoming_none') { - // Trial subscription without upcoming invoice - should have been handled above - // Fallback: try to calculate from subscription data - const fallbackAmount = this.calculateTrialPostAmount(subscription, lookupKey); - this.nextBillAmounts[lookupKey] = fallbackAmount ?? 'See trial details'; - } else { - // Other errors - display fallback message - this.nextBillAmounts[lookupKey] = 'N/A'; - } - } - }); - } - - /** - * Reset invoice preview state for a lookup key - * Called on error or when clearing data - * - * @param lookupKey Package or addon lookup key - */ - private resetInvoiceState(lookupKey: string): void { - this.currentPeriodCharge[lookupKey] = undefined; - this.nextPeriodCharge[lookupKey] = undefined; - this.hasPromoNextPeriod[lookupKey] = false; - this.nextBillingDate[lookupKey] = undefined; - delete this.pendingPromoDetails[lookupKey]; - delete this.pendingPromoSavings[lookupKey]; - } - - /** - * Extract and format the total savings amount from invoice discount amounts. - * Uses invoice.total_discount_amounts as source of truth (set by Stripe on promo invoices). - * @returns Formatted dollar string (e.g. "$99.90") or null if no discount applied. - */ - private getInvoiceSavings(invoice: Invoice): string | null { - const savings = invoice?.total_discount_amounts - ?.reduce((sum, d) => sum + d.amount, 0) ?? 0; - return savings > 0 ? `$${(savings / 100).toFixed(2)}` : null; - } - - // ============================================================================ - // DUAL-INVOICE HELPER METHODS - // ============================================================================ - - /** - * Find invoice by period_type metadata field - * Backend returns invoices with period_type = "current" | "next" for deferred promos - * - * @param invoices - Array of invoice objects from backend - * @param periodType - "current" or "next" - * @returns Invoice object matching period type, or undefined - */ - private findInvoiceByPeriodType(invoices: Invoice[], periodType: 'current' | 'next'): Invoice | undefined { - // First try: Check period_type field (v3.1 backend adds this for dual invoices) - let invoice = invoices.find(inv => inv.period_type === periodType); - - // Fallback: If no period_type metadata, use array order heuristic - // Backend pattern: First invoice without period_type = current, second with period_type = next - if (!invoice && invoices.length > 1) { - if (periodType === 'current') { - // Current period invoice: either has no period_type or is first in array - invoice = invoices.find(inv => !inv.period_type) || invoices[0]; - } else if (periodType === 'next') { - // Next period invoice: second in array as fallback - invoice = invoices[1]; - } - } - - // Single invoice case: treat as current period - if (!invoice && invoices.length === 1 && periodType === 'current') { - invoice = invoices[0]; - } - - return invoice; - } - - // ============================================================================ - // PRORATION CREDIT PARSING (Issue 4 - Proration Credit Display) - // ============================================================================ - - /** - * Load next bill amounts for all active subscriptions with auto-renew enabled - * Called after packages and addons are loaded - */ - private loadAllNextBillAmounts(): void { - // Load for packages - this.packages?.forEach(pkg => { - const lookupKey = String(pkg.lookupKey); - - // r975: seed pendingPromoDetails immediately from subscription data so the - // FREE badge appears on page load — invoice fetch will overwrite if needed. - if (pkg.pendingPromoDetails) { - this.pendingPromoDetails[lookupKey] = pkg.pendingPromoDetails; - } - - if (this.autoRenewChkbox[lookupKey] && - (pkg.status === SubStripe.ACTIVE || pkg.status === SubStripe.TRIALING)) { - this.loadNextBillAmount(lookupKey, pkg.id); - } - }); - - // Load for addons - this.addons?.forEach(addon => { - const lookupKey = String(addon.lookupKey); - - // r975: same seed for addon subscriptions - if (addon.pendingPromoDetails) { - this.pendingPromoDetails[lookupKey] = addon.pendingPromoDetails; - } - - if (this.autoRenewChkbox[lookupKey] && - (addon.status === SubStripe.ACTIVE || addon.status === SubStripe.TRIALING)) { - this.loadNextBillAmount(lookupKey, addon.id); - } - }); - } - - /** - * Calculate expected post-trial bill amount for trial subscriptions - * Stripe API cannot generate upcoming invoices for active trials (when there is not any valid payment method on file), - * so to make it simple, calculate to core total bill value (before tax) from plan data - * - * @param subscription The subscription object from Stripe - * @param lookupKey The package/addon lookup key - * @returns Formatted amount string or null if calculation fails - */ - private calculateTrialPostAmount(subscription: any, lookupKey: string): string | null { - try { - // Get base amount from subscription plan (in cents) - const baseAmountCents = subscription.plan?.amount ?? subscription.items?.data?.[0]?.price?.unit_amount ?? 0; - - if (baseAmountCents === 0) { - console.warn(`Cannot calculate trial post amount: no plan amount found for ${lookupKey}`); - return null; - } - - // Check if subscription has promo discount - let discountedAmount = baseAmountCents; - const promoDetails = subscription.promoDetails; - - if (promoDetails?.hasPromo) { - // Check for one-time coupons that have already been applied - // For trials continuing to paid, one-time discounts don't apply to next bill - const isOneTimeApplied = promoDetails.duration === 'once' || - promoDetails.discountEndsAt === 'applied'; - - if (isOneTimeApplied) { - // One-time discount already used - next bill is full price - // No discount calculation needed - } else { - // Apply discount based on promo type - if (promoDetails.percentOff) { - // Percentage discount - const discountPercent = promoDetails.percentOff / 100; - discountedAmount = baseAmountCents * (1 - discountPercent); - } else if (promoDetails.amountOff) { - // Fixed amount discount (already in cents) - discountedAmount = baseAmountCents - promoDetails.amountOff; - } - } - - // Ensure amount is not negative - discountedAmount = Math.max(0, discountedAmount); - } - - // Convert to dollars - const amountInDollars = discountedAmount / 100; - - // Note: This is estimated amount before tax - // Actual invoice will include tax calculation - return `$${amountInDollars.toFixed(2)} US`; - } catch (err) { - console.error(`Error calculating trial post amount for ${lookupKey}:`, err); - return null; - } - } - - /** - * Returns true when the customer's billing address country is Canada (CA). - * Reads from the stored billing address — authoritative and always current. - */ - isCustomerInCanada(): boolean { - return this.billingCountry === 'CA'; - } - - isCompLoaded() { - return this.status?.code !== SubAppErr.MGE_SUB_ERR - && this.status?.code !== SubAppErr._500_ERR - && !this.vendorErr; - } - - hasNoUsageErr() { - return this.status?.code !== SubAppErr.FETCH_USAGE_ERR; - } - - hasAutoRenew() { - return this.autoRenewChkbox && Object.values(this.autoRenewChkbox)?.some((checked) => checked === true); - } - - /** - * Handle auto-renew checkbox change event - * NOTE: We don't fetch next bill amount here because subscription hasn't been updated on backend yet - * The actual API call happens after save() completes successfully - * - * @param lookupKey Package or addon lookup key - * @param isChecked New checkbox state (true = auto-renew enabled) - */ - onAutoRenewChange(lookupKey: string, isChecked: boolean): void { - // Just update the checkbox state - actual next bill amount will be fetched after save() - // This prevents showing $0.00 when the backend still has cancel_at_period_end: true - } - - isResolvePM() { - return this.pmDefaultErr && this.hasAutoRenew(); - } - - changeSub() { - this.store.dispatch(new Compound([new SetMode(Mode.REGULAR), new GotoServices()])); - } - - resolvePayment() { - this.store.dispatch(new ResolvePayment()); - } - - resolveUnpaid() { - this.router.navigate([SUB.PROFILE, SUB.UNPAID_SUB]); - } - - gotoPmHistory() { - this.router.navigate([SUB.PROFILE, SUB.PM_HISTORY]); - } - - gotoServices() { - this.store.dispatch(new Compound([new ClearSubscriptionStatus(), new GotoServices()])); - } - - gotoUsageDetail() { - this.router.navigate([SUB.PROFILE, SUB.USAGE_DETAIL]); - } - - edit() { - this.displayEdit = true; - this.editDiaContentType = EditDiaContentType.AUTO_RENEW; - } - - save() { - const updateAutoRenew = () => { - const currSubs = this.packages?.concat(this.addons); - - // Detect trial subscriptions that changed from cancel → continue (need billing setup) - const trialSubsNeedingBilling = currSubs?.filter((sub) => { - const fullSub = this.subscriptions?.find((s) => s.items?.data?.some((item) => item?.price?.lookup_key === sub.lookupKey)); - const isTrialing = fullSub?.status === SubStripe.TRIALING; - const wasCanceling = this.autoRenewChkboxDef[sub.lookupKey] === false; // Previously unchecked - const nowContinuing = this.autoRenewChkbox[sub.lookupKey] === true; // Now checked - return isTrialing && wasCanceling && nowContinuing; - }) || []; - - if (trialSubsNeedingBilling.length > 0) { - // User enabled trial subscriptions → Navigate to billing-address - let selPkg: Package; - let selAddons: Addon[]; - let subIds: string[] = []; - - trialSubsNeedingBilling.forEach((sub) => { - const lookupKey = String(sub.lookupKey); // Convert PriceUsd to string - const isPkg = this.packages?.some((pkg) => pkg.lookupKey === sub.lookupKey); - const isAddon = this.addons?.some((addon) => addon.lookupKey === sub.lookupKey); - - if (isPkg) { - selPkg = { lookupKey, desc: subPlans[lookupKey].desc, price: subPlans[lookupKey].price }; - subIds = [...subIds, ...this.getSubIds(lookupKey)]; - } - - if (isAddon) { - const fullAddonSub = this.subscriptions?.find((s) => s.items?.data?.some((item) => item?.price?.lookup_key === sub.lookupKey)); - const quantity = fullAddonSub?.items?.data?.[0]?.quantity || sub.quantity || 1; - selAddons = [{ lookupKey, quantity, desc: `${quantity} x ${subPlans[lookupKey].desc}`, price: subPlans[lookupKey].price * quantity }]; - subIds = [...subIds, ...this.getSubIds(lookupKey)]; - } - }); - - this.displayEdit = false; - - // Navigate to billing-address to set up payment - this.store.dispatch(new StartBillingInfo({ - applicatorId: this.user?._id, - custId: this.membership?.custId, - selPkg, - selAddons, - prorateTS: DateUtils.currUTC(), - mode: Mode.CONTINUE_TRIAL, - subIds - })); - - return; // Exit early - billing flow handles the rest - } - - // No trial subscriptions need billing setup → Just update backend - - const nonRecurSubIds = currSubs?.filter((sub) => !this.autoRenewChkbox[sub.lookupKey])?.map((sub) => sub.id) || []; - const editSubs = currSubs?.map((sub) => ({ subId: sub.id, cancelAtPeriodEnd: nonRecurSubIds.includes(sub.id) })) || []; - - this.subSvc.editSub(editSubs).pipe( - map((subscriptions) => { - this.msgSvc.addSuccessMsg($localize`:@@subEditSuccess:Subscriptions Updated Successfully`); - this.displayEdit = false; - - // Update component state directly from API response (no navigation needed) - // API response has fresh cancel_at_period_end values from backend - subscriptions?.forEach(sub => { - // Find matching subscription by ID to get lookupKey - const matchingSub = currSubs?.find(s => s.id === sub.id); - if (matchingSub) { - const lookupKey = matchingSub.lookupKey; - // Update checkbox states: autoRenew = !cancel_at_period_end - this.autoRenewChkbox[lookupKey] = !sub.cancel_at_period_end; - this.autoRenewChkboxDef[lookupKey] = !sub.cancel_at_period_end; - // CRITICAL: Also update trial checkbox states (same logic) - // When user unchecks "Proceed with Subscription Post-Trial" and saves, - // contTrialChkbox must also be updated so dialog shows correct state on re-open - this.contTrialChkbox[lookupKey] = !sub.cancel_at_period_end; - this.contTrialChkboxDef[lookupKey] = !sub.cancel_at_period_end; - } - }); - - // Update this.subscriptions array for button label refresh (Trial button fix) - // Template conditionals (*ngIf="sub.cancel_at_period_end") read from this array - // Updating cancel_at_period_end field triggers Angular change detection - subscriptions?.forEach(apiSub => { - const existingSub = this.subscriptions?.find(s => s.id === apiSub.id); - if (existingSub) { - existingSub.cancel_at_period_end = apiSub.cancel_at_period_end; - } - }); - - // CRITICAL: Update packages and addons arrays for getBtnType() to return correct button type - // getBtnType() reads sub.cancelAtPeriodEnd (camelCase) from AGNavSubscriptionShort objects - // Template uses getBtnType(pkg) to determine if button shows "Edit" vs "Proceed with Subscription Post-Trial" - subscriptions?.forEach(apiSub => { - const matchingSub = currSubs?.find(s => s.id === apiSub.id); - if (matchingSub) { - const lookupKey = matchingSub.lookupKey; - // Update packages array - const pkgToUpdate = this.packages?.find(p => p.lookupKey === lookupKey); - if (pkgToUpdate) { - pkgToUpdate.cancelAtPeriodEnd = apiSub.cancel_at_period_end; - } - // Update addons array - const addonToUpdate = this.addons?.find(a => a.lookupKey === lookupKey); - if (addonToUpdate) { - addonToUpdate.cancelAtPeriodEnd = apiSub.cancel_at_period_end; - } - } - }); - - // Reload next bill amounts for subscriptions with auto-renew enabled - // CRITICAL: Must happen AFTER backend update completes so Stripe has correct cancel_at_period_end - subscriptions?.forEach(apiSub => { - const matchingSub = currSubs?.find(s => s.id === apiSub.id); - if (matchingSub) { - const lookupKey = String(matchingSub.lookupKey); - const isAutoRenew = !apiSub.cancel_at_period_end; - - if (isAutoRenew && (apiSub.status === SubStripe.ACTIVE || apiSub.status === SubStripe.TRIALING)) { - // Reload next bill amount with updated subscription state - this.loadNextBillAmount(lookupKey, apiSub.id); - } else if (!isAutoRenew) { - // Clear amount if auto-renew disabled - delete this.nextBillAmounts[lookupKey]; - } - } - }); - }), - catchError((err) => { - console.error('Trial continuation error:', err); - this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.save).replace('#thing#', globals.subscription)); - return of(err); - }) - ).subscribe(); - } - - const startBilInfoStage = () => { - const isNoneSelected = this.contTrialChkbox && Object.values(this.contTrialChkbox)?.every((val) => !val); - if (isNoneSelected) { - return this.displayEdit = false; - } - const isPkg = this.packages?.some((pkg) => this.contTrialChkbox && this.contTrialChkbox[pkg.lookupKey]); - const isAddon = this.addons?.some((addon) => this.contTrialChkbox && this.contTrialChkbox[addon.lookupKey]); - let selPkg: Package; - let selAddons: Addon[]; - let subIds: string[] = []; - - if (isPkg) { - const pkgSub = this.subscriptions?.find((sub) => sub.items?.data?.some((item) => this.contTrialChkbox && this.contTrialChkbox[item?.price?.lookup_key]) && sub.metadata?.type === SubType.PACKAGE); - const lookupKey = pkgSub?.items?.data?.[0]?.price?.lookup_key; - selPkg = { lookupKey, desc: subPlans[lookupKey].desc, price: subPlans[lookupKey].price }; - subIds = this.getSubIds(lookupKey); - } - if (isAddon) { - const addonSub = this.subscriptions?.find((sub) => sub.items?.data?.some((item) => this.contTrialChkbox && this.contTrialChkbox[item?.price?.lookup_key]) && sub.metadata?.type === SubType.ADDON); - const lookupKey = addonSub?.items?.data?.[0]?.price?.lookup_key; - const quantity = addonSub?.items?.data?.[0]?.quantity; - selAddons = [{ lookupKey, quantity, desc: `${quantity} x ${subPlans[lookupKey].desc}`, price: subPlans[lookupKey].price * quantity }]; - subIds = [...subIds, ...this.getSubIds(lookupKey)]; - } - - this.store.dispatch(new StartBillingInfo({ applicatorId: this.user?._id, custId: this.membership?.custId, selPkg, selAddons, prorateTS: DateUtils.currUTC(), mode: Mode.CONTINUE_TRIAL, subIds })); - } - - try { - switch (this.editDiaContentType) { - case EditDiaContentType.AUTO_RENEW: return updateAutoRenew(); - case EditDiaContentType.CONTINUE_TRIAL: return startBilInfoStage(); - } - } catch (err) { - console.error('Edit subscription error:', err); - this.status = createSubStatus(SubAppErr.MGE_SUB_ERR); - } - } - - cancel() { - this.displayEdit = false; - this.autoRenewChkbox = { ...this.autoRenewChkboxDef }; - this.contTrialChkbox = { ...this.contTrialChkboxDef }; - } - - private getSubIds(lookupKey: string) { - return this.subscriptions?.filter((sub) => sub.items?.data?.some((item) => item?.price?.lookup_key === lookupKey) && sub.status === SubStripe.TRIALING).map((sub) => sub.id) || []; - } - - updateAddr() { - this.router.navigate([SUB.PROFILE, SUB.BILL_ADR]); - } - - getDiscount(id: string): Discount { - const sub = this.subscriptions?.find((sub) => sub.id === id); - if (!sub) return; - - // CRITICAL: Hide discount coupons for trial subscriptions (status='trialing') - // Trial IS the promotion - no need to show discount/coupon labels during trial - // Consistent with getPromoForLookupKey() behavior - if (sub.status === SubStripe.TRIALING) { - return null; // Hide discount for trial subscriptions - } - - const coupon = this.subSvc.getInvCoupon([sub.latest_invoice]); - return this.subSvc.calcAmount([sub.latest_invoice], { subscriptions: this.subscriptions, coupon }).discount; - } - - contTrial(lookupKey: string, quantity: number) { - // Guard: Don't execute if subscriptions not yet loaded - if (this.isLoadingSubscriptions || !this.subscriptions) { - console.warn('[contTrial] Subscriptions not yet loaded, ignoring click'); - return; - } - - // Filter to only TRIALING subscriptions with cancel_at_period_end (pending post-trial decision) - const trialSubsToDecide = this.subscriptions?.filter((sub) => - sub.status === SubStripe.TRIALING && sub.cancel_at_period_end - ) || []; - - const isOne = trialSubsToDecide.length === 1; - if (isOne) { - const isPkg = this.packages?.some((pkg) => pkg.lookupKey === lookupKey); - const isAddon = this.addons?.some((addon) => addon.lookupKey === lookupKey); - let selPkg: Package; - let selAddons: Addon[]; - let subIds: string[] = []; - - if (isPkg) { - selPkg = { lookupKey, desc: subPlans[lookupKey].desc, price: subPlans[lookupKey].price }; - subIds = this.getSubIds(lookupKey); - } - if (isAddon) { - selAddons = [{ lookupKey, quantity, desc: `${quantity} x ${subPlans[lookupKey].desc}`, price: subPlans[lookupKey].price * quantity }]; - subIds = [...subIds, ...this.getSubIds(lookupKey)]; - } - - this.store.dispatch(new StartBillingInfo({ applicatorId: this.user?._id, custId: this.membership?.custId, selPkg, selAddons, prorateTS: DateUtils.currUTC(), mode: Mode.CONTINUE_TRIAL, subIds })); - } else { - this.displayEdit = true; - this.editDiaContentType = EditDiaContentType.CONTINUE_TRIAL; - } - } - - getBtnType(sub: AGNavSubscriptionShort) { - if (sub.status === SubStripe.TRIALING && sub.cancelAtPeriodEnd) return EditDiaContentType.CONTINUE_TRIAL; - return EditDiaContentType.AUTO_RENEW; - } - - gotoPMList() { - this.router.navigate([SUB.PROFILE, SUB.PM_LIST]); - } - - crtCardDesc(brand: string, last4) { - return this.subSvc.crtCardDesc(brand, last4); - } - - crtExp(expMonth, expYear) { - return this.subSvc.crtExp(expMonth, expYear); - } - - chngTrial() { - this.store.dispatch(new Compound([new SetMode(Mode.TRIALING), new GotoServices()])); - } - - hasRegularSub() { - return !this.hasActiveTrial && this.hasSubscription; - } - - trial() { - this.store.dispatch(new Compound([new SetMode(Mode.TRIALING), new GotoServices()])); - } - - requiresResolution() { - return this.hasInvTaxLoc - || this.existIncompleteSub - || this.existPastDueSub - || this.hasUnpaidBalances - || this.isPolling - || this.isResolvePM(); - } - - hasValidTrialType() { - return this.membership?.trials?.type === GC.DAYS || this.membership?.trials?.type === GC.BYDATE; - } - - /** - * Get formatted promo display string for subscription - * Returns empty string if no promo active - * Uses getPromoForLookupKey which checks subscription.promoDetails from this.subscriptions - */ - getSubscriptionPromoDisplay(subscription: any): string { - if (!subscription?.lookupKey) return ''; - const type = subscription.lookupKey.startsWith('addon_') ? 'addon' : 'package'; - const promo = this.getPromoForLookupKey(subscription.lookupKey, type); - if (!promo) return ''; - return this.activePromoSvc.formatPromoDiscount(promo); - } - - /** - * Check if subscription has active promo - * Uses getPromoForLookupKey which checks subscription.promoDetails from this.subscriptions - */ - hasActivePromo(subscription: any): boolean { - if (!subscription?.lookupKey) return false; - const type = subscription.lookupKey.startsWith('addon_') ? 'addon' : 'package'; - const promo = this.getPromoForLookupKey(subscription.lookupKey, type); - return promo !== null; - } - - /** - * Check if promo is time-limited (has expiry date) - * Uses getPromoForLookupKey which checks subscription.promoDetails from this.subscriptions - */ - isTimeLimitedPromo(subscription: any): boolean { - if (!subscription?.lookupKey) return false; - const type = subscription.lookupKey.startsWith('addon_') ? 'addon' : 'package'; - const promo = this.getPromoForLookupKey(subscription.lookupKey, type); - return promo?.isTimeLimited || false; - } - - /** - * Determines if "After Promo Ends" section should be displayed - * Shows only for auto-renewing subscriptions with time-limited promos - * - * - Hides for trial subscriptions (trials show after-trial pricing only) - * - Hides for non-renewing subscriptions (subscription end date set) - * - Hides for forever promos (isTimeLimited = false) - * - Shows only when user will actually pay full price after promo - * - * @param subscription - Package or addon subscription - * @returns true if should show "After Promo Ends" section - */ - showAfterPromoEnds(subscription: any): boolean { - // Find full subscription data to get status and cancel_at_period_end - const fullSub = this.subscriptions?.find(s => - s.items?.data?.[0]?.price?.lookup_key === subscription?.lookupKey - ); - - // CRITICAL: Never show "After Promo Ends" for trial subscriptions - // Trials already have "After Trial" pricing - showing post-promo confuses users - if (fullSub?.status === SubStripe.TRIALING) { - return false; - } - - return subscription.promoDetails?.hasPromo && - subscription.promoDetails?.isTimeLimited && - !fullSub?.cancel_at_period_end; - } - - /** - * Get days remaining until promo expires - * Returns null if promo is not time-limited - * Uses getPromoForLookupKey which checks subscription.promoDetails from this.subscriptions - */ - getPromoExpiryDays(subscription: any): number | null { - if (!subscription?.lookupKey) return null; - const type = subscription.lookupKey.startsWith('addon_') ? 'addon' : 'package'; - const promo = this.getPromoForLookupKey(subscription.lookupKey, type); - return promo?.daysRemaining || null; - } - - /** - * Get the discount multiplier for price calculations - * For 50% off: multiplier = 0.5 (regular price = current price / 0.5) - * For 30% off: multiplier = 0.7 (regular price = current price / 0.7) - * @param subscription Subscription package or addon object - * @returns Discount multiplier (0-1) or 1 if no promo - */ - getPromoDiscountMultiplier(subscription: any): number { - if (!subscription?.lookupKey) return 1; - const type = subscription.lookupKey.startsWith('addon_') ? 'addon' : 'package'; - const promo = this.getPromoForLookupKey(subscription.lookupKey, type); - - if (!promo || !promo.discountValue) return 1; // No discount - - const discountPercent = promo.discountType === 'percent' ? promo.discountValue : 0; - return (100 - discountPercent) / 100; // Convert to multiplier - } - - /** - * Get formatted expiry date for display - * Returns null if promo is not time-limited - * Uses new promoDetails object from r948 backend enhancement - */ - getPromoExpiryDate(subscription: any): string | null { - return subscription.promoDetails?.expiresAt || null; - } - - /** - * Check if subscription has a renewal promo (Case 2B) - * - * CASE 2B: Active Subscription - Renewal Promo Offer - * Conditions: - * - Subscription status: ACTIVE - * - Auto-renew: OFF (cancel_at_period_end = true) - * - Current subscription: NO promo applied - * - Available global promo: YES (from ActivePromoService) - * - * Business Goal: Re-acquisition - incentivize renewal with new promo offer - * Display: "Renew by [date] and get 50% OFF!" (green incentive text) - * - * @param subscription Package or addon subscription - * @returns True if this is a Case 2B renewal promo offer - */ - isRenewalPromo(subscription: any): boolean { - if (!subscription?.lookupKey) return false; - const type = subscription.lookupKey.startsWith('addon_') ? 'addon' : 'package'; - const promo = this.getPromoForLookupKey(subscription.lookupKey, type); - return promo?.isRenewalPromo === true; - } - - /** - * Get formatted renewal promo expiry date (Case 2B) - * Returns formatted date string like '01/31/2027' for display in "Renew by XX and get 50% OFF!" - * - * CASE 2B: Used in renewal promo incentive message - * - * @param subscription Package or addon subscription - * @returns Formatted date string or empty string - */ - getRenewalPromoExpiryDate(subscription: any): string { - if (!subscription?.lookupKey) return ''; - const type = subscription.lookupKey.startsWith('addon_') ? 'addon' : 'package'; - const promo = this.getPromoForLookupKey(subscription.lookupKey, type); - - if (!promo?.validUntil) return ''; - - // Format: MM/DD/YYYY - const expiryDate = new Date(promo.validUntil); - const month = String(expiryDate.getMonth() + 1).padStart(2, '0'); - const day = String(expiryDate.getDate()).padStart(2, '0'); - const year = expiryDate.getFullYear(); - return `${month}/${day}/${year}`; - } - - /** - * Get promo discount display text (e.g., '50% OFF', 'FREE') - * - * CASE 2B: Used in renewal promo incentive message "...and get 50% OFF!" - * CASE 3: Used for badge display on active subscriptions with applied promo - * - * @param subscription Package or addon subscription - * @returns Formatted discount display string (localized) - */ - getPromoDiscountDisplay(subscription: any): string { - if (!subscription?.lookupKey) return ''; - const type = subscription.lookupKey.startsWith('addon_') ? 'addon' : 'package'; - const promo = this.getPromoForLookupKey(subscription.lookupKey, type); - - if (!promo) return ''; - - if (promo.discountType === 'free' || promo.discountValue === 100) { - return Labels.FREE; - } - - // Check if it's a fixed discount (in cents) or percentage - if (promo.discountType === 'fixed') { - // Fixed discount: discountValue is in cents (e.g., 15000 = $150.00) - const dollarAmount = (promo.discountValue / 100).toFixed(2); - return `$${dollarAmount} ${Labels.OFF_SUFFIX}`; - } else { - // Percentage discount - return `${promo.discountValue}% ${Labels.OFF_SUFFIX}`; - } - } - - /** - * Get full renewal promo message with localized text - * - * Constructs message like: "Renew by 02/28/2027 and get $150.00 OFF!" - * All text parts are localized for multi-language support - * - * @param subscription Package or addon subscription - * @returns Formatted, localized renewal promo message - */ - getRenewalPromoMessage(subscription: any): string { - const date = this.getRenewalPromoExpiryDate(subscription); - const discount = this.getPromoDiscountDisplay(subscription); - - if (!date || !discount) return ''; - - // Construct: "Renew by {date} and get {discount}!" - return `${Labels.RENEW_BY_PREFIX} ${date} ${Labels.AND_GET} ${discount}!`; - } - - /** - * Generates comprehensive promo description using promoDetails object - * Uses r962 backend enhancements (durationInMonths, discountEndsAt, daysUntilDiscountEnds) - * - * - Replaces simple badge with concise promo information - * - Shows discount amount and duration in natural format (matches Stripe) - * - Format: "$150.00 OFF for 12 months" (instead of "Ends in 365 days") - * - * @param subscription - Package or addon subscription - * @returns Concise promo description string - */ - getPromoDescription(subscription: any): string { - const promo = subscription.promoDetails; - if (!promo?.hasPromo) return ''; - - // Start with discount amount (e.g., "$150.00 OFF", "FREE") - let desc = promo.discountDisplay; - - // Forever promos: Always show "until subscription ends" - // Ignore isTimeLimited flag as it may reflect coupon redeem_by deadline, - // not the discount duration after application - if (promo.duration === 'forever') { - desc += ` • ${Labels.PROMO_UNTIL_SUBSCRIPTION_ENDS}`; - return desc; - } - - // Repeating promos: Add duration information if time-limited - if (promo.isTimeLimited) { - if (promo.durationInMonths) { - // Use months when available (more intuitive than days) - const months = promo.durationInMonths; - const unit = months === 1 ? Labels.PROMO_MONTH : Labels.PROMO_MONTHS; - desc += ` ${Labels.PROMO_FOR} ${months} ${unit}`; - } else if (promo.daysUntilDiscountEnds) { - // Fallback to days if durationInMonths not available - desc += ` • ${Labels.PROMO_EXPIRES_IN} ${promo.daysUntilDiscountEnds} ${Labels.PROMO_DAYS}`; - } - } else { - // No expiration - desc += ` • ${Labels.PROMO_NO_EXPIRATION}`; - } - - return desc; - } - - /** - * Check if promo should be displayed based on Case 2 requirements - * - * Case 2A (Retention): Subscription HAS promo + auto-renew OFF - * - Message: "Your 50% OFF expires in X days - renew to keep it!" - * - Shows expiry warning to prevent churn - * - * Case 2B (Re-acquisition): Subscription does NOT have promo + auto-renew OFF + available global promo - * - Message: "Renew by 01/31 and get 50% OFF!" - * - Shows available promo to incentivize renewal - * - * See: docs/current_work/.../2026-01-26-15-35-promo-display-cases-analysis.md - */ - shouldShowPromoCase2(subscription: any): boolean { - // Must have auto-renew OFF (cancel_at_period_end = true) - const lookupKey = subscription.lookupKey; - const hasAutoRenew = this.autoRenewChkbox?.[lookupKey]; - if (hasAutoRenew) { - return false; // Auto-renew is ON, don't show promo - } - - // SCENARIO A: Subscription HAS promo applied (Case 2A - Retention) - if (this.hasActivePromo(subscription)) { - // Show expiry warning: "Your 50% OFF expires in X days" - if (!this.isTimeLimitedPromo(subscription)) { - return false; // Forever coupon - not relevant for expiry warning - } - - const promoExpiresAt = subscription.promoDetails?.expiresAt; - const subscriptionPeriodEnd = subscription.periodEnd; - - if (!promoExpiresAt || !subscriptionPeriodEnd) { - return false; // Missing required dates - } - - const promoExpiryTimestamp = new Date(promoExpiresAt).getTime(); - const periodEndTimestamp = subscriptionPeriodEnd * 1000; - - // Show promo when it expires AFTER subscription period ends - return promoExpiryTimestamp > periodEndTimestamp; - } - - // SCENARIO B: Subscription does NOT have promo (Case 2B - Re-acquisition) - // Check if there's an available global promo to incentivize renewal - const availablePromo = this.getAvailablePromo(subscription); - - if (availablePromo && availablePromo.validUntil) { - const promoExpiryTimestamp = new Date(availablePromo.validUntil).getTime(); - const periodEndTimestamp = subscription.periodEnd * 1000; - - // Show available promo when it would apply beyond current subscription period - return promoExpiryTimestamp > periodEndTimestamp; - } - - return false; - } - - /** - * Get available global promo for subscription type - * Used in Case 2B to show renewal incentive - * - * For packages, checks: - * 1. Exact match by lookupKey (e.g., 'ess_1') - * 2. Type-only promo for all packages ('package_all') - * - * For addons, checks: - * 1. Exact match by lookupKey (e.g., 'addon_1') - * 2. Type-only promo for all addons ('addon_all') - */ - getAvailablePromo(subscription: any): ActivePromo | null { - if (!this.activePromos || this.activePromos.size === 0) { - return null; - } - - const lookupKey = subscription.lookupKey; - - // Determine type based on lookupKey pattern - // Packages: ess_*, ent_* - // Addons: addon_* - const isAddon = lookupKey?.startsWith('addon_'); - const type = isAddon ? 'addon' : 'package'; - - // Try exact match first - const exactMatch = this.activePromos.get(lookupKey); - if (exactMatch) { - return exactMatch; - } - - // Try type-only match - const typeOnlyKey = `${type}_all`; - const typeMatch = this.activePromos.get(typeOnlyKey); - if (typeMatch) { - return typeMatch; - } - - return null; - } - - /** - * Get expiry date timestamp for available promo (Case 2B) - * Returns Unix timestamp for use with tsToDate pipe for proper localization - */ - getAvailablePromoExpiry(subscription: any): number | null { - const promo = this.getAvailablePromo(subscription); - if (!promo?.validUntil) return null; - return new Date(promo.validUntil).getTime() / 1000; // Convert to Unix timestamp - } - - // ============================================================================ - // ENHANCED PROMO DISPLAY LOGIC (8 Cases) - // ============================================================================ - - /** - * Determine which promo display template to use (Cases 1-8) - * Returns case number and urgency flag for conditional styling - * - * Case 1: Permanent discount (forever, no expiry) - * Case 2: Forever with redemption deadline - * Case 3: Schedule-managed forever (ending soon) - * Case 4: Repeating duration (standard) - * Case 5: Repeating with urgency (<30 days) - * Case 6: One-time discount (already applied) - * Case 7: FREE promo (100% discount) - * Case 8: No active promo - */ - getPromoDisplayTemplate(subscription: any): { case: number; isUrgent: boolean } { - const promo = subscription?.promoDetails; - - if (!promo?.hasPromo) { - return { case: 8, isUrgent: false }; // No promo - } - - // Case 6: One-time already applied - if (promo.duration === 'once' && promo.discountEndsAt === 'applied') { - return { case: 6, isUrgent: false }; - } - - // Forever duration cases (1, 2, 3) - if (promo.duration === 'forever') { - // Case 1: Permanent (no expiry) - if (!promo.expiresAt && !promo.discountEndsAt) { - return { case: 1, isUrgent: false }; - } - - // Case 2: Forever with redeem_by deadline - if (promo.expiresAt && promo.discountEndsAt && promo.expiresAt === promo.discountEndsAt) { - return { case: 2, isUrgent: false }; - } - - // Case 3: Schedule-managed forever (ending soon) - if (promo.expiresAt && promo.daysRemaining !== null) { - const isUrgent = promo.daysRemaining < 30; - return { case: 3, isUrgent }; - } - } - - // Repeating duration cases (4, 5) - if (promo.duration === 'repeating') { - const daysRemaining = promo.daysUntilDiscountEnds ?? 0; - const isUrgent = daysRemaining < 30; - - if (isUrgent) { - return { case: 5, isUrgent: true }; // Urgent - } else { - return { case: 4, isUrgent: false }; // Standard - } - } - - // Case 7: Permanent FREE promo (100% discount with NO time limits) - // Only applies to promos that are truly permanent (no expiry, no discount end date) - if ((promo.percentOff === 100 || promo.discountDisplay?.toUpperCase() === 'FREE') && - !promo.expiresAt && !promo.discountEndsAt && - (!promo.daysRemaining || promo.daysRemaining === 0) && - (!promo.daysUntilDiscountEnds || promo.daysUntilDiscountEnds === 0)) { - return { case: 7, isUrgent: false }; - } - - // Default fallback - return { case: 8, isUrgent: false }; - } - - /** - * Check if promo is in urgent state (<30 days remaining) - * Used for conditional styling (amber/red backgrounds) - */ - isPromoUrgent(subscription: any): boolean { - const template = this.getPromoDisplayTemplate(subscription); - return template.isUrgent; - } - - /** - * Get promo expiry text for display - * Returns formatted date string or null if no expiry - * - * Examples: - * - "Valid until: Dec 31, 2026" - * - "Promo ends: Jun 30, 2026" - * - null (for permanent promos or already-redeemed deadlines) - * - * Note: Uses UTC timezone to avoid date shifting issues - * (expiresAt/discountEndsAt represent calendar dates, not specific moments) - * - * UX Decision: Case 2 (forever promo with redeem_by) hides expiry date - * because the redemption deadline is irrelevant once user has the promo locked in. - * Showing "Valid until: [past date]" creates false anxiety about discount ending. - */ - getPromoExpiryText(subscription: any): string | null { - const promo = subscription?.promoDetails; - if (!promo?.hasPromo) return null; - - const template = this.getPromoDisplayTemplate(subscription); - - // Case 2: Forever with redeem_by deadline - hide the date (already locked in) - // Don't show redemption deadlines for permanent discounts user already has - if (template.case === 2) { - return null; - } - - // Case 3: Schedule-managed forever (actual end date) - show the date - if (template.case === 3) { - if (promo.expiresAt) { - const date = new Date(promo.expiresAt); - return date.toLocaleDateString('en-US', { timeZone: 'UTC' }); - } - } - - if (template.case === 4 || template.case === 5) { - // Repeating standard or urgent. - // discountEndsAt is the first billing date WITHOUT the discount, so the last active - // discount day is the day before — subtract 1 day for display. - if (promo.discountEndsAt && promo.discountEndsAt !== 'applied') { - const date = new Date(promo.discountEndsAt); - date.setUTCDate(date.getUTCDate() - 1); - return date.toLocaleDateString('en-US', { timeZone: 'UTC' }); - } - } - - return null; - } - - /** - * Get the appropriate label text for promo expiry - * Returns the label that should be shown based on promo type: - * - null for case 2 (forever with redeem_by - hides irrelevant redemption deadline) - * - "Expires:" for case 3 (schedule-managed forever with actual end date) - * - "Discount ends:" for repeating promos (cases 4, 5) - * - null if no expiry info to display - */ - getPromoExpiryLabel(subscription: any): string | null { - const promo = subscription?.promoDetails; - if (!promo?.hasPromo) return null; - - const template = this.getPromoDisplayTemplate(subscription); - - // Cases 2, 3: Forever promos show "Expires:" (but Case 2 returns null text, so won't display) - if (template.case === 2 || template.case === 3) { - return this.getPromoExpiryText(subscription) ? Labels.PROMO_VALID_UNTIL_COLON : null; - } - - // Cases 4, 5: Repeating promos show "Discount ends:" - if (template.case === 4 || template.case === 5) { - return this.getPromoExpiryText(subscription) ? Labels.PROMO_DISCOUNT_ENDS : null; - } - - return null; - } - - /** - * Get promo duration text with days remaining - * Used for urgency messaging - * - * Examples: - * - "6 months (177 days remaining)" - * - "Only 25 days remaining!" - * - null (for permanent or applied promos) - */ - getPromoDurationText(subscription: any): string | null { - const promo = subscription?.promoDetails; - if (!promo?.hasPromo) return null; - - const template = this.getPromoDisplayTemplate(subscription); - - // Case 3: Schedule-managed forever - if (template.case === 3 && promo.daysRemaining !== null) { - if (template.isUrgent) { - return `${Labels.PROMO_ONLY} ${promo.daysRemaining} ${Labels.PROMO_DAYS_REMAINING_SUFFIX}!`; - } else { - return `${promo.daysRemaining} ${Labels.PROMO_DAYS_UNTIL_EXPIRES}`; - } - } - - // Case 4 & 5: Repeating promos - if ((template.case === 4 || template.case === 5) && promo.durationInMonths) { - const months = promo.durationInMonths; - const unit = months === 1 ? Labels.PROMO_MONTH : Labels.PROMO_MONTHS; - // daysUntilDiscountEnds counts to the first billing WITHOUT the discount; - // subtract 1 to show days until the last active discount day. - const daysLeft = promo.daysUntilDiscountEnds !== null ? promo.daysUntilDiscountEnds - 1 : null; - const daysText = daysLeft !== null - ? ` (${daysLeft} ${Labels.PROMO_DAYS_REMAINING_SUFFIX})` - : ''; - - if (template.isUrgent && daysLeft !== null) { - return `${Labels.PROMO_ONLY} ${daysLeft} ${Labels.PROMO_DAYS_REMAINING_SUFFIX}!`; - } else { - return `${months} ${unit}${daysText}`; - } - } - - return null; - } - - /** - * Get promo type icon based on case - * Used for visual indicators - * - * Returns PrimeIcons class names (only using icons available in theme-green.min.css): - * - "pi-check" - Permanent discount / Already applied - * - "pi-calendar" - Time-limited - * - "pi-exclamation-triangle" - Urgent (ending soon) - * - "pi-star" - FREE promo - */ - getPromoTypeIcon(subscription: any): string { - const template = this.getPromoDisplayTemplate(subscription); - - switch (template.case) { - case 1: return 'pi-check'; // Permanent - case 2: return 'pi-calendar'; // Forever with deadline - case 3: return template.isUrgent ? 'pi-exclamation-triangle' : 'pi-calendar'; // Schedule-managed - case 4: return 'pi-calendar'; // Repeating standard - case 5: return 'pi-exclamation-triangle'; // Repeating urgent - case 6: return 'pi-check'; // Once applied - case 7: return 'pi-tag'; // FREE - Testing with tag icon - default: return ''; - } - } - - /** - * Get promo type label for accessibility and clarity - * - * Returns: - * - "Permanent Discount" - * - "Time-Limited Offer" - * - "Ending Soon" - * - "One-Time Discount" - * - "FREE Promotion" - */ - getPromoTypeLabel(subscription: any): string { - const template = this.getPromoDisplayTemplate(subscription); - - switch (template.case) { - case 1: return Labels.PROMO_TYPE_PERMANENT; - case 2: return Labels.PROMO_TYPE_TIME_LIMITED; - case 3: return template.isUrgent ? Labels.PROMO_TYPE_ENDING_SOON : Labels.PROMO_TYPE_LIMITED_TIME; - case 4: return Labels.PROMO_TYPE_PROMOTIONAL_PERIOD; - case 5: return Labels.PROMO_TYPE_ENDING_SOON; - case 6: return Labels.PROMO_TYPE_ONE_TIME; - case 7: return Labels.PROMO_TYPE_FREE; - default: return ''; - } - } - - // ============================================================================ - // COMPACT VERTICAL LIST - BADGE CONFIGURATION GETTERS - // ============================================================================ - - /** - * Get discount badge configuration for compact vertical list header - * Shows promotional discount (e.g., "50% OFF") - */ - getDiscountBadgeConfig(subscription: any): any { - return { - text: this.getSubscriptionPromoDisplay(subscription), - type: 'promo-discount', - icon: 'pi pi-tag', - size: 'sm' - }; - } - - /** - * Get status badge configuration for compact vertical list header - * Shows subscription status (Active, Trialing, etc.) - */ - getStatusBadgeConfig(subscription: any): any { - const statusMap = { - [SubStripe.ACTIVE]: { text: Labels.SUBSCRIPTION_STATUS_ACTIVE, type: 'status-active', icon: 'pi pi-check' }, - [SubStripe.TRIALING]: { text: Labels.SUBSCRIPTION_STATUS_TRIAL, type: 'status-pending', icon: 'pi pi-calendar' }, - [SubStripe.PAST_DUE]: { text: Labels.SUBSCRIPTION_STATUS_PAST_DUE, type: 'status-error', icon: 'pi pi-exclamation-triangle' }, - [SubStripe.CANCELED]: { text: Labels.SUBSCRIPTION_STATUS_CANCELED, type: 'status-inactive', icon: 'pi pi-times' }, - [SubStripe.INCOMPLETE]: { text: Labels.SUBSCRIPTION_STATUS_INCOMPLETE, type: 'status-pending', icon: 'pi pi-ellipsis-h' } - }; - - const config = statusMap[subscription.status] || { text: subscription.status, type: 'status-inactive', icon: 'pi pi-info-circle' }; - return { - ...config, - size: 'sm' - }; - } - - /** - * Get regular price (price before discount) - * Returns the base price from subPlans (fullPkg.price is already the regular price) - * For ESS_1 with 50% OFF: base=$995, discount=50%, current=$497.50 - */ - getRegularPrice(subscription: any, fullPkg: any): number { - // fullPkg.price is already the BASE/REGULAR price from subPlans - return fullPkg.price / 100; - } - - /** - * Get current discounted price user is actually paying - * Uses latest_invoice.total_excluding_tax as source of truth for actual charged amount - * Fallback to fullPkg.price if invoice data not available - * - * For ESS_1 with $150 OFF: - * - Base: $995.00 (99500 cents) - * - Invoice total_excluding_tax: $845.00 (84500 cents) ✅ ACTUAL PRICE - */ - getCurrentPrice(subscription: any, fullPkg: any): number { - // Find full subscription data from subscriptions array (has invoice data) - // StripeSubscription has lookup_key at items.data[0].price.lookup_key - const fullSub = this.subscriptions?.find(s => - s.items?.data?.[0]?.price?.lookup_key === subscription?.lookupKey - ); - - // Use invoice data as source of truth (already includes discount) - if (fullSub?.latest_invoice?.total_excluding_tax !== undefined) { - return fullSub.latest_invoice.total_excluding_tax / 100; - } - - // Fallback to fullPkg price if invoice not available - return (fullPkg?.price || 0) / 100; - } - - /** - * Calculate actual savings amount from promotional discount - * Uses latest_invoice.total_discount_amounts as source of truth - * - * For ESS_1 with $150 OFF: - * - Discount applied: $150.00 (15000 cents from invoice) - * For ADDON_1 with FREE (100% OFF): - * - Discount applied: $49.95 (4995 cents - full price) - */ - getSavingsAmount(subscription: any, fullPkg: any): number { - // Find full subscription data from subscriptions array (has invoice data) - const fullSub = this.subscriptions?.find(s => - s.items?.data?.[0]?.price?.lookup_key === subscription?.lookupKey - ); - - // Cast to any to access Stripe fields not in our TypeScript interface - const invoice: any = fullSub?.latest_invoice; - - // Use invoice discount amounts as source of truth - if (invoice?.total_discount_amounts?.length) { - // Sum all discount amounts (usually just one) - const totalDiscount = invoice.total_discount_amounts - .reduce((sum, discount) => sum + discount.amount, 0); - return totalDiscount / 100; - } - - // No discount applied - return 0; - } - - /** - * Get formatted billing cycle text - * Maps subscription interval to human-readable text - */ - getBillingCycleText(subscription: any): string { - const intervalMap = { - 'year': 'Yearly', - 'month': 'Monthly', - 'week': 'Weekly', - 'day': 'Daily' - }; - return intervalMap[subscription.interval] || subscription.interval; - } - - // ============================================================================ - // CASE 2C: TRIAL WITH PROMO - POST-TRIAL CONTINUATION - // ============================================================================ - // - // CONDITIONS: - // - Subscription status: TRIALING - // - User selected "Proceed with Subscription Post-Trial" (cancel_at_period_end = false) - // - Active promo available (promoDetails.hasPromo = true) - // - // BUSINESS GOAL: Price Confirmation - show confirmed discounted price after trial - // DISPLAY: "After Trial: $497.50" with strikethrough regular price - // ============================================================================ - - /** - * Calculate after-trial price with promo discount applied - * Used for Case 2C: Trial subscription with active global promo - * Handles both amountOff and percentOff from promoDetails - * @param subscription - AGNavSubscriptionShort with trialEnd and promoDetails - * @returns Discounted price string (e.g., "$497.50/year" or "$845.00/year") - */ - getAfterTrialPrice(subscription: AGNavSubscriptionShort): string { - if (!subscription?.trialEnd || !subscription?.promoDetails?.hasPromo) { - // No trial or no promo - return base price from subPlans - const fullPkg = subPlans[subscription?.lookupKey]; - if (!fullPkg) return ''; - return this.subSvc.formatCurrency(fullPkg.price); - } - - const fullPkg = subPlans[subscription.lookupKey]; - if (!fullPkg) return ''; - - const basePrice = fullPkg.price; // Price in cents - let discountedPrice = basePrice; - - // Handle amountOff (e.g., $150.00 OFF = 15000 cents) - if (subscription.promoDetails.amountOff) { - discountedPrice = basePrice - subscription.promoDetails.amountOff; - } - // Handle percentOff (e.g., 50% OFF) - else if (subscription.promoDetails.percentOff) { - discountedPrice = basePrice * (1 - subscription.promoDetails.percentOff / 100); - } - - return this.subSvc.formatCurrency(discountedPrice); - } - - /** - * Parse discount percentage from display string - * Examples: "50% OFF" → 50, "30% OFF" → 30 - * @param discountDisplay - Discount display string from promoDetails - * @returns Numeric discount percentage - */ - private parseDiscountPercent(discountDisplay: string): number { - if (!discountDisplay) return 0; - const match = discountDisplay.match(/(\d+)\s*%/); - return match ? parseInt(match[1], 10) : 0; - } - - /** - * Get badge configuration for promo display - * Used for Case 2C trial promo badge - * @param promoDetails - Promo information from subscription - * @returns BadgeConfig for agm-badge component or null if no promo - */ - getTrialPromoBadgeConfig(promoDetails: any): BadgeConfig | null { - if (!promoDetails?.hasPromo) return null; - - return { - text: promoDetails.discountDisplay, - type: BadgeType.PROMO_DISCOUNT, - icon: 'pi-tag', - size: BadgeSize.SMALL - }; - } - - /** - * Check if subscription is in trial period with active promo - * - * CASE 2C & 2D: Returns true for both cases (with/without post-trial continuation) - * CASE 2A: Returns false (trial without promo) - shows basic trial display - * - * Used with isTrialWithoutContinuation() to differentiate: - * - isTrialWithPromo() && !isTrialWithoutContinuation() → Case 2C - * - isTrialWithoutContinuation() → Case 2D - * - !isTrialWithPromo() → Case 2A - * - * @param subscription - AGNavSubscriptionShort to check - * @returns True if trial + promo applies - */ - isTrialWithPromo(subscription: AGNavSubscriptionShort): boolean { - return subscription?.status === SubStripe.TRIALING && - !!subscription?.trialEnd && - !!subscription?.promoDetails?.hasPromo; - } - - // ============================================================================ - // CASE 2D: TRIAL WITH PROMO - NO POST-TRIAL CONTINUATION - // ============================================================================ - // - // CONDITIONS: - // - Subscription status: TRIALING - // - User did NOT select "Proceed with Subscription Post-Trial" (cancel_at_period_end = true) - // - Available promo exists (promoDetails.hasPromo = true) - // - // BUSINESS GOAL: Incentive Offer - encourage renewal by highlighting available discount - // DISPLAY: "Renew by [date] and get 50% OFF!" (green incentive text, NO strikethrough) - // - // KEY DIFFERENCE FROM CASE 2C: - // - Case 2C: Shows confirmed price user WILL pay ("After Trial: $497.50") - // - Case 2D: Shows incentive offer user CAN get ("Renew by ... and get 50% OFF!") - // ============================================================================ - - /** - * Check if trial subscription will NOT continue after trial - * Used for Case 2D: Show incentive offer instead of confirmed pricing - * - * Returns true when: - * 1. Subscription status is TRIALING - * 2. User has NOT selected "Proceed with Subscription Post-Trial" (cancel_at_period_end = true) - * 3. Active promo is available - * - * Business Logic: - * - During trial creation, if user does NOT check "Proceed", Stripe sets cancel_at_period_end=true - * - This means subscription will cancel at trial end unless user manually renews - * - We should incentivize renewal by showing available promo offer (like Case 2B) - * - * @param subscription - AGNavSubscriptionShort to check - * @returns True if trial will NOT continue and has promo - */ - isTrialWithoutContinuation(subscription: AGNavSubscriptionShort): boolean { - // Must be trialing status - if (subscription?.status !== SubStripe.TRIALING) { - return false; - } - - // Must have cancel_at_period_end = true (no continuation) - if (!subscription?.cancelAtPeriodEnd) { - return false; - } - - // Must have available promo to offer as incentive - if (!subscription?.promoDetails?.hasPromo) { - return false; - } - - return true; - } - - /** - * Format trial end date for display - * @param trialEnd - UNIX timestamp from subscription - * @returns Formatted date string (e.g., "2/2/2026") - */ - formatTrialEndDate(trialEnd: number): string { - if (!trialEnd) return ''; - const date = new Date(trialEnd * 1000); - return date.toLocaleDateString(); - } - - /** - * Get max vehicles from subscription price metadata (includes custom limits) - * Reads from full StripeSubscription's price.metadata.maxVehicles which has custom limits applied by backend - * Falls back to fullPkg.maxVehicles, then to subscription quantity (for addons) - * - * @param agNavSub - AGNavSubscriptionShort from packages/addons array - * @param fullPkg - Package details from subPlans (via subPkg pipe) - * @returns Max vehicles count as number - */ - getMaxVehicles(agNavSub: AGNavSubscriptionShort, fullPkg: any): number { - // Find the full StripeSubscription by ID (has complete items.data structure) - const fullSub = this.subscriptions?.find(sub => sub.id === agNavSub.id); - - // Try to get from subscription's price metadata first (includes custom limits) - if (fullSub?.items?.data?.[0]?.price?.metadata?.maxVehicles) { - return parseInt(fullSub.items.data[0].price.metadata.maxVehicles, 10); - } - - // Fallback to price catalog maxVehicles - if (fullPkg?.maxVehicles) { - return fullPkg.maxVehicles; - } - - // Final fallback to subscription quantity (for addons where quantity = vehicle count) - return agNavSub?.quantity || 0; - } - - /** - * Get max acres from subscription price metadata (includes custom limits) - * Reads from full StripeSubscription's price.metadata.maxAcres which has custom limits applied by backend - * Falls back to fullPkg.maxAcres from price catalog - * - * @param agNavSub - AGNavSubscriptionShort from packages array - * @param fullPkg - Package details from subPlans (via subPkg pipe) - * @returns Max acres count as number (0 = unlimited) - */ - getMaxAcres(agNavSub: AGNavSubscriptionShort, fullPkg: any): number { - // Find the full StripeSubscription by ID (has complete items.data structure) - const fullSub = this.subscriptions?.find(sub => sub.id === agNavSub.id); - - // Try to get from subscription's price metadata first (includes custom limits) - if (fullSub?.items?.data?.[0]?.price?.metadata?.maxAcres) { - return parseInt(fullSub.items.data[0].price.metadata.maxAcres, 10); - } - - // Fallback to price catalog maxAcres - return fullPkg?.maxAcres || 0; - } - - ngOnDestroy(): void { - this.store.dispatch(new CancelPollSubscription()); - super.ngOnDestroy(); - } } diff --git a/Development/client/src/app/profile/payment-checkout-coupon/payment-checkout-coupon.component.css b/Development/client/src/app/profile/payment-checkout-coupon/payment-checkout-coupon.component.css deleted file mode 100644 index e69de29..0000000 diff --git a/Development/client/src/app/profile/payment-checkout-coupon/payment-checkout-coupon.component.html b/Development/client/src/app/profile/payment-checkout-coupon/payment-checkout-coupon.component.html deleted file mode 100644 index bd13450..0000000 --- a/Development/client/src/app/profile/payment-checkout-coupon/payment-checkout-coupon.component.html +++ /dev/null @@ -1,14 +0,0 @@ - -
    - -
    -
    - - -
    - -
    -
    - -
    -
    \ No newline at end of file diff --git a/Development/client/src/app/profile/payment-checkout-coupon/payment-checkout-coupon.component.ts b/Development/client/src/app/profile/payment-checkout-coupon/payment-checkout-coupon.component.ts deleted file mode 100644 index e0f5db0..0000000 --- a/Development/client/src/app/profile/payment-checkout-coupon/payment-checkout-coupon.component.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; -import { Discount } from '@app/domain/models/subscription.model'; - -@Component({ - selector: 'payment-checkout-coupon', - templateUrl: './payment-checkout-coupon.component.html', - styleUrls: ['./payment-checkout-coupon.component.css'] -}) -export class PaymentCheckoutCouponComponent implements OnInit { - @Input() totalTax: number; - @Input() totalAmount: number; - @Input() totalExcludingTax: number; - @Input() discount: Discount; - @Input() coupons: string[]; - @Input() disableCoupon: boolean; - @Input() error: string; - @Input() hasRefund: boolean; - @Output() appCoupEvt = new EventEmitter(); - @Output() remvCoupEvt = new EventEmitter(); - - constructor() { } - - ngOnInit(): void { - } -} diff --git a/Development/client/src/app/profile/payment-detail/payment-detail.component.css b/Development/client/src/app/profile/payment-detail/payment-detail.component.css deleted file mode 100644 index 60e371a..0000000 --- a/Development/client/src/app/profile/payment-detail/payment-detail.component.css +++ /dev/null @@ -1,12 +0,0 @@ -tr > th.pm-history-header { - background-color: #4CAF50; - color: #FFFFFF; -} - -tr > td { - text-align: center; -} - -.pm-detail-container { - justify-content: flex-end; -} \ No newline at end of file diff --git a/Development/client/src/app/profile/payment-detail/payment-detail.component.html b/Development/client/src/app/profile/payment-detail/payment-detail.component.html deleted file mode 100644 index 50b4060..0000000 --- a/Development/client/src/app/profile/payment-detail/payment-detail.component.html +++ /dev/null @@ -1,172 +0,0 @@ - -
    -
    -
    -

    Summary

    - - - -
    - -
    -
    -
    - - -
    -
    -
    -
    -
    -
    Refunded to
    -
    {{invoice?.billing_details?.name}}
    -
    {{invoice?.billing_details?.address?.line1}}
    -
    {{invoice?.billing_details?.address?.city}} {{invoice?.billing_details?.address?.state}} - {{invoice?.billing_details?.address?.postal_code}}
    -
    {{invoice?.billing_details?.address?.country}}
    -
    {{invoice?.receipt_email}}
    -
    -
    -
    -
    -
    -
    - - -
    -
    -
    -
    -
    -
    Billed to
    -
    {{invoice?.customer_name}}
    -
    {{invoice?.customer_address?.line1}}
    -
    {{invoice?.customer_address?.city}} {{invoice?.customer_address?.state}} - {{invoice?.customer_address?.postal_code}}
    -
    {{invoice?.customer_address?.country}}
    -
    {{invoice?.customer_email}}
    -
    -
    -
    -
    -
    - Invoice Number {{invoice?.number}} -
    USD - US {{SubTexts.dollar}}
    -
    -
    -
    -
    -
    - - -
    - - - - {{col.header}} - - - - - - {{col.header}} - - - - {{ rowData[DESCRIPTION] }}
    - {{ period.start | tsToDate: lang }} - {{ period.end | tsToDate: lang }} -
    -
    -
    - {{ rowData[QUANTITY] }} - - - - {{ plan.amount | usCurrency }} - - - {{ rowData[AMOUNT] | usCurrency }} - - -
    -
    -
    -
    - - -
    -
    -
    -
    - - - -
    -
    Amount due
    -
    {{invoice?.amount_due | usCurrency}}
    -
    -
    -
    -
    -
    -
    - - -
    -
    -
    -
    -
    -
    Amount
    -
    {{getCharge()?.amount | usCurrency}}
    -
    -
    -
    -
    Fees
    -
    {{getCharge()?.amount - getCharge()?.amount_refunded | - usCurrency}}
    -
    -
    -
    -
    Credited Total
    -
    {{getCharge()?.amount_refunded | usCurrency | creditCurrency}} -
    -
    -
    -
    -
    -
    -
    - - -
    -
    - - - - -
    -
    -
    - - -
    -
    - - - -
    -
    -
    - - - - - \ No newline at end of file diff --git a/Development/client/src/app/profile/payment-detail/payment-detail.component.ts b/Development/client/src/app/profile/payment-detail/payment-detail.component.ts deleted file mode 100644 index f78a2c3..0000000 --- a/Development/client/src/app/profile/payment-detail/payment-detail.component.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; -import { Charge, Invoice, PaidAmount, Payment, Status } from '@app/domain/models/subscription.model'; -import { SubscriptionService } from '@app/domain/services/subscription.service'; -import { SubTexts, InvType, SubAppErr, SUB, createSubStatus, subPlans } from '@app/profile/common'; -import { BaseComp } from '@app/shared/base/base.component'; -import { getPayment } from '../selectors/profile.selector'; -import { map, take } from 'rxjs/operators'; -import { Utils } from '@app/shared/utils'; - -@Component({ - selector: 'agm-payment-detail', - templateUrl: './payment-detail.component.html', - styleUrls: ['./payment-detail.component.css'] -}) -export class PaymentDetailComponent extends BaseComp implements OnInit, OnDestroy { - readonly SUB = SUB; - readonly SubTexts = SubTexts; - readonly InvType = InvType; - readonly DESCRIPTION = 'description'; - readonly QUANTITY = 'quantity'; - readonly UNIT_PRICE = 'unitPrice'; - readonly AMOUNT = 'amount'; - - payments: Payment[]; - id: string; - invoice; - status: Status; - cols: any[]; - amount: PaidAmount; - lang; - - constructor( - private readonly route: ActivatedRoute, - private readonly subSvc: SubscriptionService - ) { - super(); - this.id = this.route.snapshot.paramMap.get('id'); - this.cols = [ - { field: this.DESCRIPTION, header: $localize`:@@description:Description`, width: "40%" }, - { field: this.QUANTITY, header: $localize`:@@quantity:Quantity` }, - { field: this.UNIT_PRICE, header: $localize`:@@unitPrice:Unit Price` }, - { field: this.AMOUNT, header: $localize`:@@amount:Amount` }, - ]; - this.lang = this.authSvc.locale; - } - - ngOnInit(): void { - this.sub$ = this.store.select(getPayment).pipe( - map((payments) => { - this.payments = payments; - this.invoice = this.getInvoice(); - this.amount = this.subSvc.calcAmount([this.invoice], { coupon: this.subSvc.getInvCoupon([this.invoice]) }); - }), - take(1) - ).subscribe({ - error: (err) => { - console.log(err); - this.status = createSubStatus(SubAppErr.PM_DETAIL_ERR); - } - }); - } - - isCompLoaded() { - return this.status?.code !== SubAppErr._500_ERR && this.status?.code !== SubAppErr.PM_DETAIL_ERR; - } - - isPaid(item: Invoice | Charge | undefined) { - return !!item?.paid; - } - - getInvoice() { - const chngLineDesc = (invoice) => { - return { ...invoice, lines: { data: invoice?.lines?.data?.map((item) => ({ ...item, description: subPlans[item?.price?.lookup_key]?.name })) || [] } }; - } - try { - const charge = this.getCharge(); - if (charge) { - return chngLineDesc(charge); - } else { - return chngLineDesc(this.payments?.find((payment: Payment) => payment?.type === InvType.INVOICE && payment?.id === this.id)); - } - } catch (err) { - this.status = createSubStatus(SubAppErr.PM_DETAIL_ERR); - } - } - - getCharge() { - return this.payments?.find((payment: Payment) => payment?.type === InvType.CHARGE && payment?.id === this.id); - } - - isRefund() { - return this.payments?.some((payment: Payment) => payment?.type === InvType.CHARGE && payment?.id === this.id); - } - - hasInvoiceNum(item: Invoice) { - return !!item?.number; - } - - hasTax(item: Invoice) { - return !!item?.tax - } - - hasDiscount(item: Invoice) { - return !!(item?.discount || (item as any)?.total_discount_amounts?.length > 0); - } - - gotoMySubs() { - this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]); - } - - gotoLink(url: string) { - window.open(url, "_blank"); - } - - downloadInvoice(url: string) { - Utils.downloadFileFromUrl(url); - } - - ngOnDestroy(): void { - super.ngOnDestroy(); - } -} diff --git a/Development/client/src/app/profile/payment-history/payment-history.component.css b/Development/client/src/app/profile/payment-history/payment-history.component.css deleted file mode 100644 index 5838534..0000000 --- a/Development/client/src/app/profile/payment-history/payment-history.component.css +++ /dev/null @@ -1,15 +0,0 @@ -tr > th.pm-history-header { - background-color: #4CAF50; - color: #FFFFFF; -} - -tr > td { - text-align: center; -} - -td > button { - padding: 0; - border: none; - background: none; - cursor: pointer; -} \ No newline at end of file diff --git a/Development/client/src/app/profile/payment-history/payment-history.component.html b/Development/client/src/app/profile/payment-history/payment-history.component.html deleted file mode 100644 index 01b7860..0000000 --- a/Development/client/src/app/profile/payment-history/payment-history.component.html +++ /dev/null @@ -1,76 +0,0 @@ - -
    -
    -
    -

    Payment history

    -
    -
    -
    -
    If you recently made a payment, please allow 24 hours for the payment to appear in the history.
    -
    - -
    -
    -
    -
    - - - - - {{col.header}} - - - - - - - - {{col.header}} - {{rowData[col.field] | tsToDate: lang}} - - - Bill - Refund - - - - - {{rowData.amount_due | usCurrency}} - - - {{rowData.amount_refunded | usCurrency | creditCurrency}} - - - - - - {{rowData.amount_paid | usCurrency}} - - - {{rowData.amount_refunded | usCurrency | creditCurrency}} - - - - - - - -
    -
    -
    -
    -
    -
    - - -
    -
    -
    -
    - - -
    -
    -
    -
    -
    \ No newline at end of file diff --git a/Development/client/src/app/profile/payment-history/payment-history.component.ts b/Development/client/src/app/profile/payment-history/payment-history.component.ts deleted file mode 100644 index 64bea2c..0000000 --- a/Development/client/src/app/profile/payment-history/payment-history.component.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { AfterContentInit, Component, OnDestroy, OnInit } from '@angular/core'; -import { Invoice, Payment, Status } from '@app/domain/models/subscription.model'; -import { SubTexts, InvType, SubAppErr, SUB, createSubStatus, hasVendorErr } from '../common'; -import { SortEvent } from 'primeng-lts/api'; -import { Fetch } from '../actions/payment.action'; -import { getPayment, getPaymentState, getPmtStatus } from '../selectors/profile.selector'; -import { BaseComp } from '@app/shared/base/base.component'; -import { map, take } from 'rxjs/operators'; -import { FetchSubPlans } from '@app/actions/sub-plans.actions'; -import { SubscriptionService } from '@app/domain/services/subscription.service'; - -@Component({ - selector: 'payment-history', - templateUrl: './payment-history.component.html', - styleUrls: ['./payment-history.component.css'] -}) -export class PaymentHistoryComponent extends BaseComp implements OnInit, AfterContentInit, OnDestroy { - readonly SUB = SUB; - readonly SubTexts = SubTexts; - readonly InvType = InvType; - readonly date = "created"; - readonly ACTIONS = 'actions'; - readonly AMT_DUE = 'amount_due'; - readonly AMT_PAID = 'amount_paid'; - readonly TYPE = 'type'; - - payments: Payment[]; - status: Status; - optKey: string; - cols: { - field: string; - header: string; - width: string; - }[]; - options: { - label: string; - value: string; - }[]; - - vendorErr: boolean; - lang; - - constructor( - private readonly subscriptionService: SubscriptionService - ) { - super(); - this.cols = [ - { field: this.date, header: $localize`:@@date:Date`, width: "10%" }, - { field: "type", header: $localize`:@@type:Type`, width: "10%" }, - { field: "amount_due", header: $localize`:@@amtDue:Amount Due`, width: "10%" }, - { field: "amount_paid", header: $localize`:@@amtPaid:Amount Paid`, width: "10%" }, - { field: this.ACTIONS, header: $localize`:@@actions:Actions`, width: "5%" }, - ]; - this.options = this.subscriptionService.getDateOptions(); - this.lang = this.authSvc.locale; - } - - ngOnInit() { - this.initSub$(); - this.store.dispatch(new FetchSubPlans()); - } - - private initSub$() { - this.sub$ = this.store.select(getPmtStatus).subscribe({ - next: status => { - this.status = status; - if (hasVendorErr(this.status?.code)) { - this.vendorErr = true; - } - }, - error: err => { - this.status = createSubStatus(SubAppErr.PM_LIST_ERR); - } - }) - this.sub$.add(this.store.select(getPayment).pipe( - map((payments) => { - this.payments = payments; - }) - ).subscribe({ - error: err => { - this.status = createSubStatus(SubAppErr.PM_LIST_ERR); - } - })); - } - - ngAfterContentInit() { - this.sub$.add(this.store.select(getPaymentState).pipe(take(1)) - .subscribe({ - next: (pmtState) => { - try { - const hasNotLoaded = !pmtState?.loaded; - if (hasNotLoaded) { - return this.store.dispatch(new Fetch({ custId: this.authSvc.user.membership.custId, byTime: this.options[0].value })); - } - this.optKey = pmtState.curTime; - } catch { - this.status = createSubStatus(SubAppErr.PM_LIST_ERR); - } - }, - error: err => { - this.status = createSubStatus(SubAppErr.PM_LIST_ERR); - } - })); - } - - onDateChange(event) { - try { - this.store.dispatch(new Fetch({ custId: this.authSvc.user.membership.custId, byTime: event.value })); - } catch { - this.status = createSubStatus(SubAppErr.PM_LIST_ERR); - } - } - - isCompLoaded() { - return this.status?.code !== SubAppErr._500_ERR && - this.status?.code !== SubAppErr.PM_LIST_ERR && - this.status?.code !== SubAppErr.FETCH_PMT_ERR - && !this.vendorErr; - } - - isPaid(invoice: Invoice) { - return invoice?.paid; - } - - isRefund(payment: Payment) { - if (payment.type === InvType.CHARGE) { - return payment?.refunded - } - } - - customSort(event: SortEvent) { - event.data.sort((data1, data2) => { - const isDate = event.field === this.date; - let result = null; - let value1 = null; - let value2 = null; - if (isDate) { - value1 = data1[event.field]; - value2 = data2[event.field]; - result = (value1 < value2) ? -1 : (value1 > value2) ? 1 : 0; - return (event.order * result); - } - const isInvoice1 = data1.type === InvType.INVOICE; - const isInvoice2 = data2.type === InvType.INVOICE; - value1 = isInvoice1 ? data1[event.field] : data1['amount_refunded']; - value2 = isInvoice2 ? data2[event.field] : data2['amount_refunded']; - if (value1 == null && value2 != null) - result = -1; - else if (value1 != null && value2 == null) - result = 1; - else if (value1 == null && value2 == null) - result = 0; - else if (typeof value1 === 'string' && typeof value2 === 'string') - result = value1.localeCompare(value2); - else { - if (isInvoice1 && !isInvoice2) { - result = 1; - } else if (!isInvoice1 && isInvoice2) { - result = -1; - } else { - result = (value1 < value2) ? -1 : (value1 > value2) ? 1 : 0; - } - } - return (event.order * result); - }); - } - - gotoMySubs() { - this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]) - } - - gotoPaymentDetail(payment: Payment) { - this.router.navigate([ - SUB.PROFILE, - SUB.PM_DETAIL, - payment?.id - ]) - } - - ngOnDestroy(): void { - super.ngOnDestroy(); - } -} diff --git a/Development/client/src/app/profile/payment-method-list/payment-method-list.component.css b/Development/client/src/app/profile/payment-method-list/payment-method-list.component.css deleted file mode 100644 index 2ef4254..0000000 --- a/Development/client/src/app/profile/payment-method-list/payment-method-list.component.css +++ /dev/null @@ -1,3 +0,0 @@ -*:focus { - outline: none; -} \ No newline at end of file diff --git a/Development/client/src/app/profile/payment-method-list/payment-method-list.component.html b/Development/client/src/app/profile/payment-method-list/payment-method-list.component.html deleted file mode 100644 index 822946d..0000000 --- a/Development/client/src/app/profile/payment-method-list/payment-method-list.component.html +++ /dev/null @@ -1,115 +0,0 @@ - -
    -
    -
    -

    Payment Methods

    -
    -
    Select a payment method
    -
    -
    -
    Credit Card
    -
    Cardholder Name
    -
    Expiration Date
    -
    -
    -
    -
    -
    -
    {{pmRow.desc}}
    -
    -
    -
    {{pmRow.name}}
    -
    -
    {{pmRow.expiry}}
    -
    - - - - -
    -
    -
    - -
    -
    -
    - - - - -
    -
    -
    -
    -
    -
    - - - Edit Payment Method -
    - - - - - -
    - -
    - - - Add Payment Method -
    - - -
    - -
    -
    - -
    - -
    - - -
    -
    Secure Payment
    -

    Your credit or debit card information will be kept encrypted and secure.

    -
    -
    -
    Name
    -
    - - - - - - - - -
    -
    -
    - - -
    - * Required field -
    -
    {{status?.message || pmPkgValid?.status?.message}}
    -
    - - - - - - - -
    -
    -
    -
    - -
    -
    -
    -
    -
    \ No newline at end of file diff --git a/Development/client/src/app/profile/payment-method-list/payment-method-list.component.ts b/Development/client/src/app/profile/payment-method-list/payment-method-list.component.ts deleted file mode 100644 index 818e6db..0000000 --- a/Development/client/src/app/profile/payment-method-list/payment-method-list.component.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; -import { SUB, SubAppErr, SubTexts, createSubStatus, hasVendorErr } from '../common'; -import { BaseComp } from '@app/shared/base/base.component'; -import { getDefPM, getPaymentMethods, getSubscriptionStatus } from '@app/reducers'; -import { map, switchMap } from 'rxjs/operators'; -import { PMPkgAdd, PMPkgEdit, PaymentMethod, PkgValid, Status } from '@app/domain/models/subscription.model'; -import { ADD_PM_SUCCESS, AddPM, CHANGE_PM_SUCCESS, ChangePM, ClearSubscriptionStatus, Compound, DELETE_PM_SUCCESS, DeletePM, EDIT_PM_SUCCESS, EditPM, FetchDefaultPm, FetchPaymentMethodList } from '@app/actions/subscription.actions'; -import { SubscriptionService } from '@app/domain/services/subscription.service'; -import { DateUtils } from '@app/shared/utils'; -import { ActivatedRoute } from '@angular/router'; - -enum DiaContentType { EDIT, ADD }; -interface PMRow { - id: string; - name?: string; - desc: string; - email?: string; - expiry: string; - isExpired: boolean; - isDefault: boolean; - month: number; - year: number; -} - -@Component({ - selector: 'payment-method-list', - templateUrl: './payment-method-list.component.html', - styleUrls: ['./payment-method-list.component.css'] -}) -export class PaymentMethodListComponent extends BaseComp implements OnInit, OnDestroy { - readonly SubTexts = SubTexts; - readonly DiaContentType = DiaContentType; - - displayEdit: boolean; - displayAdd: boolean; - status: Status; - - pmList: PaymentMethod[]; - pmDefault: PaymentMethod; - pmDefaultErr: boolean; - pmRows: PMRow[]; - pmRowSel: PMRow; - pmRowEdit: PMRow; - pmPkgEdit: PMPkgEdit; - pmPkgAdd: PMPkgAdd; - pmPkgValid: PkgValid; - custId: string; - name: string; - - vendorErr: boolean; - - constructor( - private readonly subSvc: SubscriptionService, - private readonly route: ActivatedRoute, - ) { - super(); - const profileUser = this.route.snapshot.data['user']; - this.pmPkgEdit = { name: '', pmId: void 0 }; - this.pmPkgAdd = { name: '', card: void 0 }; - this.custId = this.authSvc.user?.membership?.custId; - this.name = profileUser?.contact; - } - - ngOnInit(): void { - this.initSub$(); - this.store.dispatch(new Compound([new ClearSubscriptionStatus(), new FetchPaymentMethodList()])); - } - - initSub$() { - this.sub$ = this.store.select(getSubscriptionStatus).pipe( - map((status) => { - this.status = status; - const code = this.status?.code; - if (code == SubAppErr.CHANGE_PM_ERR) { - return this.msgSvc.addFailedMsg(SubTexts.textChangePMFailed.replace('#card#', this.pmRowSel?.desc)); - } - if (code == SubAppErr.DELETE_PM_ERR) { - return this.msgSvc.addFailedMsg(SubTexts.textDeletePMFailed.replace('#card#', this.pmRowSel?.desc)); - } - if (code == SubAppErr.FETCH_DEFAULT_PM_ERR) { - this.pmDefaultErr = true; - } - if (hasVendorErr(this.status?.code)) { - this.vendorErr = true; - } - }) - ).subscribe({ - error: (err) => { - console.log(err); - this.status = createSubStatus(SubAppErr.ADD_PM_ERR); - } - }); - - const isCardExp = (month, year) => DateUtils.dateToTS(new Date()) > DateUtils.dateToTS(new Date(year, month)); - - this.sub$.add(this.store.select(getDefPM).pipe( - switchMap((pmDefault) => { - this.pmDefault = pmDefault; - if (!this.pmDefault) this.store.dispatch(new FetchDefaultPm()); - return this.store.select(getPaymentMethods); - }), - map((pmList) => { - this.pmList = pmList; - const hasExistingCards = this.pmList?.length > 0; - - if (hasExistingCards) { - if (!this.pmDefault) { - return this.change(this.custId, this.pmList[0].id); - } - this.pmRows = this.pmList.map((pm) => { - if (pm && pm.card) return { - id: pm.id, - name: pm.billing_details?.name || '', - desc: this.subSvc.crtCardDesc(pm.card.brand, pm.card.last4), - expiry: this.subSvc.crtExp(pm.card.exp_month, pm.card.exp_year), - isExpired: isCardExp(pm.card.exp_month, pm.card.exp_year), - isDefault: this.pmDefault?.id === pm.id, - month: pm.card.exp_month, year: pm.card.exp_year - }; - }) || []; - - this.pmRowSel = this.pmRows?.find((pm) => pm.isDefault); - if (!this.pmRowSel) return this.store.dispatch(new FetchDefaultPm()); - - this.pmRowEdit = this.pmRowSel; - this.pmPkgAdd.name = this.pmRowSel?.name; - } else { - this.pmRows = []; - this.pmRowSel = null; - this.pmPkgAdd.name = this.name; - } - })).subscribe({ - error: (err) => { - console.log(err); - this.status = createSubStatus(SubAppErr.PMT_METHOD_LIST_ERR); - } - })); - - this.sub$.add(this.appActions.ofTypes([ADD_PM_SUCCESS]).subscribe(() => { - this.displayAdd = false; - this.msgSvc.addSuccessMsg(SubTexts.textAddPMSuccess); - })); - this.sub$.add(this.appActions.ofTypes([EDIT_PM_SUCCESS]).subscribe(() => { - this.displayEdit = false; - this.msgSvc.addSuccessMsg(SubTexts.textEditPMSuccess.replace('#card#', this.pmRowSel?.desc)); - })); - this.sub$.add(this.appActions.ofTypes([DELETE_PM_SUCCESS]).subscribe(() => this.msgSvc.addSuccessMsg(SubTexts.textDeletePMSuccess.replace('#card#', this.pmRowSel?.desc)))); - this.sub$.add(this.appActions.ofTypes([CHANGE_PM_SUCCESS]).subscribe(() => this.msgSvc.addSuccessMsg(SubTexts.textChangePMSuccess.replace('#card#', this.pmRowSel?.desc)))); - } - - gotoMySubs() { - this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]); - } - - save(type: DiaContentType) { - switch (type) { - case DiaContentType.EDIT: - return this.store.dispatch(new EditPM({ ...this.pmPkgEdit, pmId: this.pmRowEdit.id, name: this.pmRowEdit.name })); - case DiaContentType.ADD: - return this.store.dispatch(new AddPM({ ...this.pmPkgAdd, name: this.pmPkgAdd.name, setDefault: this.pmList?.length === 0 })); - } - } - - edit(pmRow: PMRow) { - this.store.dispatch(new ClearSubscriptionStatus()); - this.displayEdit = true; - this.displayAdd = false; - this.pmRowEdit = pmRow; - this.pmPkgValid = null; - } - - add() { - this.store.dispatch(new ClearSubscriptionStatus()); - this.displayEdit = false; - this.displayAdd = true; - this.pmPkgValid = null; - } - - del(pmRow: PMRow) { - return this.confirmSvc.confirm({ - message: SubTexts.textDeletePM.replace('#card#', pmRow.desc), - accept: () => this.store.dispatch(new DeletePM(pmRow.id)) - }); - } - - change(custId: string, pmId: string) { - this.store.dispatch(new ChangePM({ custId, pmId })); - } - - isCompLoaded() { - return this.status?.code !== SubAppErr.PMT_METHOD_LIST_ERR - && !this.vendorErr; - } - - handlePkgValid(evt: PkgValid) { - this.pmPkgValid = evt; - } - - isAtLeastTwoCards() { - return this.pmRows?.length > 1; - } - - ngOnDestroy(): void { - super.ngOnDestroy(); - } -} diff --git a/Development/client/src/app/profile/payment-method-review/payment-method-review.component.html b/Development/client/src/app/profile/payment-method-review/payment-method-review.component.html index a3f89c3..f33e5c5 100644 --- a/Development/client/src/app/profile/payment-method-review/payment-method-review.component.html +++ b/Development/client/src/app/profile/payment-method-review/payment-method-review.component.html @@ -18,7 +18,7 @@

    - +
    diff --git a/Development/client/src/app/profile/payment-method-review/payment-method-review.component.ts b/Development/client/src/app/profile/payment-method-review/payment-method-review.component.ts index e2e91c9..6771b68 100644 --- a/Development/client/src/app/profile/payment-method-review/payment-method-review.component.ts +++ b/Development/client/src/app/profile/payment-method-review/payment-method-review.component.ts @@ -1,5 +1,4 @@ import { Component, OnInit } from '@angular/core'; -import { SubTexts } from '../common'; @Component({ selector: 'payment-method-review', @@ -7,8 +6,7 @@ import { SubTexts } from '../common'; styleUrls: ['./payment-method-review.component.css'] }) export class PaymentMethodReviewComponent implements OnInit { - readonly SubTexts = SubTexts; - + constructor() { } ngOnInit(): void { diff --git a/Development/client/src/app/customers/models/partner-system.model.ts b/Development/client/src/app/profile/payment-method/payment-method.component.css similarity index 100% rename from Development/client/src/app/customers/models/partner-system.model.ts rename to Development/client/src/app/profile/payment-method/payment-method.component.css diff --git a/Development/client/src/app/profile/payment-method/payment-method.component.html b/Development/client/src/app/profile/payment-method/payment-method.component.html new file mode 100644 index 0000000..f1aa17a --- /dev/null +++ b/Development/client/src/app/profile/payment-method/payment-method.component.html @@ -0,0 +1 @@ +

    payment-method works!

    diff --git a/Development/client/src/app/profile/payment-method/payment-method.component.ts b/Development/client/src/app/profile/payment-method/payment-method.component.ts new file mode 100644 index 0000000..f450467 --- /dev/null +++ b/Development/client/src/app/profile/payment-method/payment-method.component.ts @@ -0,0 +1,15 @@ +import { Component, OnInit } from '@angular/core'; + +@Component({ + selector: 'payment-method', + templateUrl: './payment-method.component.html', + styleUrls: ['./payment-method.component.css'] +}) +export class PaymentMethodComponent implements OnInit { + + constructor() { } + + ngOnInit(): void { + } + +} diff --git a/Development/client/src/app/profile/profile-mgt.component.ts b/Development/client/src/app/profile/profile-mgt.component.ts index fbd9ed5..b2153d6 100644 --- a/Development/client/src/app/profile/profile-mgt.component.ts +++ b/Development/client/src/app/profile/profile-mgt.component.ts @@ -1,8 +1,8 @@ import { Component } from '@angular/core'; @Component({ - template: ``, + template: ` `, }) export class ProfileMgtComponent { - constructor() {} + constructor() { } } diff --git a/Development/client/src/app/profile/profile-resolver.ts b/Development/client/src/app/profile/profile-resolver.ts new file mode 100644 index 0000000..4c8abb5 --- /dev/null +++ b/Development/client/src/app/profile/profile-resolver.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@angular/core'; +import { Router, ActivatedRouteSnapshot, Resolve } from '@angular/router'; + +import { Observable } from 'rxjs'; +import { map, first } from 'rxjs/operators'; + +import { User } from '@app/accounts/models/user.model'; +import { UserService } from '@app/domain/services/user.service'; + +@Injectable() +export class ProfileResolver implements Resolve { + constructor( + private readonly router: Router, + private readonly userService: UserService + ) { + } + + resolve(route: ActivatedRouteSnapshot): Observable | Promise | User { + const id = route.paramMap.get('id'); + + return this.userService.getUser(id).pipe( + map(user => { + if (user) { + return user; + } else { + this.router.navigate(['/profile']); + return null; + } + }), + first() + ); + } +} diff --git a/Development/client/src/app/profile/profile-routing.module.ts b/Development/client/src/app/profile/profile-routing.module.ts index d9bc8b0..b9c9d30 100644 --- a/Development/client/src/app/profile/profile-routing.module.ts +++ b/Development/client/src/app/profile/profile-routing.module.ts @@ -1,28 +1,22 @@ import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; + import { AuthGuard } from '@app/domain/guards/auth.guard'; -import { RoleGuard } from '@app/domain/guards/role.guard'; -import { ProfileResolver } from '@app/domain/resolvers/profile-resolver'; -import { UserResolver } from '@app/domain/resolvers/user-resolver'; + +import { ProfileResolver } from './profile-resolver'; import { ProfileMgtComponent } from './profile-mgt.component'; import { ProfileUpdateComponent } from './update-profile/update-profile.component'; import { ManageServicesComponent } from './manage-services/manage-services.component'; +import { ManageBillingComponent } from './manage-billing/manage-billing.component'; import { ManageSubscriptionComponent } from './manage-subscription/manage-subscription.component'; +import { BillingOverviewComponent } from './billing-overview/billing-overview.component'; import { CheckoutComponent } from './checkout/checkout.component'; import { CheckoutReviewComponent } from './checkout-review/checkout-review.component'; import { CheckoutConfirmComponent } from './checkout-confirm/checkout-confirm.component'; -import { BillingAddressComponent } from './billing-address/billing-address.component'; -import { UnpaidSubscriptionComponent } from './unpaid-subscription/unpaid-subscription.component'; -import { PaymentHistoryComponent } from './payment-history/payment-history.component'; -import { RoleIds } from '@app/shared/global'; -import { SubscriptionGuard } from '@app/domain/guards/subscription.guard'; -import { PaymentDetailComponent } from './payment-detail/payment-detail.component'; -import { SUB } from './common'; -import { UsageComponent } from './usage/usage.component'; -import { UsageDetailGuard } from '@app/domain/guards/usage-detail.guard'; -import { PaymentMethodListComponent } from './payment-method-list/payment-method-list.component'; -import { StripeLoadGuard } from '@app/domain/guards/stripe-load.guard'; -import { BillingAddressListComponent } from './billing-address-list/billing-address-list.component'; +import { PaymentMethodComponent } from './payment-method/payment-method.component'; +import { PaymentMethodReviewComponent } from './payment-method-review/payment-method-review.component'; +import { PaymentMethodConfirmComponent } from './payment-method-confirm/payment-method-confirm.component'; + const routes: Routes = [ { @@ -34,7 +28,7 @@ const routes: Routes = [ canActivate: [AuthGuard], children: [ { - path: 'edit/:id', + path: ':id', component: ProfileUpdateComponent, data: { roles: '*' @@ -42,130 +36,92 @@ const routes: Routes = [ resolve: [ProfileResolver], }, { - path: SUB.MY_SERVICES, - component: ManageSubscriptionComponent, - data: { - roles: [RoleIds.APP] - }, - canActivate: [RoleGuard], - resolve: { - user: UserResolver - } - }, - { - path: SUB.PM_HISTORY, - component: PaymentHistoryComponent, - data: { - roles: [RoleIds.APP] - }, - canActivate: [RoleGuard] - }, - { - path: `${SUB.PM_DETAIL}/:id`, - component: PaymentDetailComponent, - data: { - roles: [RoleIds.APP] - }, - canActivate: [RoleGuard], - }, - { - path: SUB.UNPAID_SUB, - component: UnpaidSubscriptionComponent, - data: { - roles: [RoleIds.APP] - }, - canActivate: [RoleGuard, SubscriptionGuard], - resolve: { - user: UserResolver - } - }, - { - path: SUB.SERVICES, + path: 'services/:id', component: ManageServicesComponent, data: { - roles: [RoleIds.APP] + roles: '*' }, - canActivate: [RoleGuard], + // resolve: [ProfileResolver], }, { - path: SUB.BILL_ADR, - component: BillingAddressComponent, + path: 'myservices/:id', + component: ManageSubscriptionComponent, data: { - roles: [RoleIds.APP] + roles: '*' }, - canActivate: [RoleGuard, StripeLoadGuard], - resolve: { - user: UserResolver - } + // resolve: [ProfileResolver], }, { - path: SUB.BILL_ADR_LIST, - component: BillingAddressListComponent, + path: 'mybills/:id', + component: ManageBillingComponent, data: { - roles: [RoleIds.APP] + roles: '*' }, - canActivate: [RoleGuard, StripeLoadGuard], - resolve: { - user: UserResolver - } + // resolve: [ProfileResolver], }, { - path: SUB.CHKOUT, + path: 'mybilloverview/:id', + component: BillingOverviewComponent, + data: { + roles: '*' + }, + // resolve: [ProfileResolver], + }, + { + path: 'checkout/:id', component: CheckoutComponent, data: { - roles: [RoleIds.APP] + roles: '*' }, - canActivate: [RoleGuard, StripeLoadGuard, SubscriptionGuard] + // resolve: [ProfileResolver], }, { - path: SUB.CHKOUT_REV, + path: 'checkout-review/:id', component: CheckoutReviewComponent, data: { - roles: [RoleIds.APP] + roles: '*' }, - canActivate: [RoleGuard, SubscriptionGuard] + // resolve: [ProfileResolver], }, { - path: SUB.CHKOUT_CONF, + path: 'checkout-confirm/:id', component: CheckoutConfirmComponent, data: { - roles: [RoleIds.APP] + roles: '*' }, - canActivate: [RoleGuard, SubscriptionGuard], - resolve: { - user: UserResolver - } + // resolve: [ProfileResolver], }, { - path: SUB.USAGE_DETAIL, - component: UsageComponent, + path: 'payment-method/:id', + component: PaymentMethodComponent, data: { - roles: [RoleIds.APP], - showFull: true + roles: '*' }, - canActivate: [RoleGuard, UsageDetailGuard] + // resolve: [ProfileResolver], }, { - path: SUB.PM_LIST, - component: PaymentMethodListComponent, + path: 'payment-method-review/:id', + component: PaymentMethodReviewComponent, data: { - roles: [RoleIds.APP] + roles: '*' }, - canActivate: [RoleGuard, StripeLoadGuard, SubscriptionGuard], - resolve: { - user: UserResolver - } + // resolve: [ProfileResolver], + }, + { + path: 'payment-method-confirm/:id', + component: PaymentMethodConfirmComponent, + data: { + roles: '*' + }, + // resolve: [ProfileResolver], }, ], - } + }, ]; @NgModule({ imports: [RouterModule.forChild(routes)], exports: [RouterModule], - providers: [ - AuthGuard, - ProfileResolver - ], + providers: [AuthGuard, ProfileResolver], }) export class ProfileRoutingModule { } diff --git a/Development/client/src/app/profile/profile.module.ts b/Development/client/src/app/profile/profile.module.ts index 4ac5996..de4d8bf 100644 --- a/Development/client/src/app/profile/profile.module.ts +++ b/Development/client/src/app/profile/profile.module.ts @@ -7,19 +7,20 @@ import { EffectsModule } from '@ngrx/effects'; import { PaginatorModule } from 'primeng/paginator'; import { DialogModule } from 'primeng/dialog'; import { ConfirmDialogModule } from 'primeng/confirmdialog'; +import { MessagesModule } from 'primeng/messages'; +import { InputTextModule } from 'primeng/inputtext'; +import { CheckboxModule } from 'primeng/checkbox'; import { ToolbarModule } from 'primeng/toolbar'; +import { ButtonModule } from 'primeng/button'; +import { DropdownModule } from 'primeng/dropdown'; import { TableModule } from 'primeng/table'; import { ToastModule } from 'primeng/toast'; import { ProgressBarModule } from 'primeng/progressbar'; -import { TabViewModule } from 'primeng/tabview'; -import { CalendarModule } from 'primeng/calendar'; import { AppSharedModule } from '@app/shared/app-shared.module'; import { ProfileRoutingModule } from './profile-routing.module'; -import { FEATURE_KEY, profileReducers } from './reducers'; - +import { FEATURE_KEY, reducer } from './reducers/profile-reducer'; import { ProfileEffects } from './effects/profile.effects'; -import { UsageEffects } from './effects/usage.effects'; import { ProfileMgtComponent } from './profile-mgt.component'; import { ProfileUpdateComponent } from './update-profile/update-profile.component'; @@ -30,34 +31,25 @@ import { BillingOverviewComponent } from './billing-overview/billing-overview.co import { CheckoutComponent } from './checkout/checkout.component'; import { CheckoutReviewComponent } from './checkout-review/checkout-review.component'; import { CheckoutConfirmComponent } from './checkout-confirm/checkout-confirm.component'; +import { PaymentMethodComponent } from './payment-method/payment-method.component'; import { PaymentMethodReviewComponent } from './payment-method-review/payment-method-review.component'; import { PaymentMethodConfirmComponent } from './payment-method-confirm/payment-method-confirm.component'; -import { BillingAddressComponent } from './billing-address/billing-address.component'; -import { UnpaidSubscriptionComponent } from './unpaid-subscription/unpaid-subscription.component'; -import { PaymentEffects } from './effects/payment.effects'; -import { PaymentHistoryComponent } from './payment-history/payment-history.component'; -import { PaymentDetailComponent } from './payment-detail/payment-detail.component'; -import { UsageDetailComponent } from './usage-detail/usage-detail.component'; -import { UsageComponent } from './usage/usage.component'; -import { UsageSummaryComponent } from './usage-summary/usage-summary.component'; -import { CouponComponent } from './coupon/coupon.component'; -import { PaymentCheckoutCouponComponent } from './payment-checkout-coupon/payment-checkout-coupon.component'; -import { PaymentMethodListComponent } from './payment-method-list/payment-method-list.component'; -import { BillingAddressListComponent } from './billing-address-list/billing-address-list.component'; + @NgModule({ imports: [ - CommonModule, AppSharedModule, ProfileRoutingModule, - TableModule, PaginatorModule, DialogModule, ConfirmDialogModule, ToastModule, ToolbarModule, ProgressBarModule, TabViewModule, - CalendarModule, - StoreModule.forFeature(FEATURE_KEY, profileReducers), - EffectsModule.forFeature([ProfileEffects, PaymentEffects, UsageEffects]), + CommonModule, AppSharedModule, TableModule, PaginatorModule, DialogModule, ConfirmDialogModule, ToastModule, MessagesModule, InputTextModule, CheckboxModule, ToolbarModule, + ButtonModule, DropdownModule, ProgressBarModule, ProfileRoutingModule, + + StoreModule.forFeature(FEATURE_KEY, reducer), + EffectsModule.forFeature([ProfileEffects]), ], declarations: [ - ProfileMgtComponent, ProfileUpdateComponent, ManageServicesComponent, ManageBillingComponent, ManageSubscriptionComponent, BillingOverviewComponent, CheckoutComponent, CheckoutReviewComponent, CheckoutConfirmComponent, PaymentMethodReviewComponent, PaymentMethodConfirmComponent, BillingAddressComponent, UnpaidSubscriptionComponent, PaymentHistoryComponent, PaymentDetailComponent, UsageDetailComponent, UsageComponent, UsageSummaryComponent, CouponComponent, PaymentCheckoutCouponComponent, PaymentMethodListComponent, BillingAddressListComponent + ProfileMgtComponent, ProfileUpdateComponent, ManageServicesComponent, ManageBillingComponent, ManageSubscriptionComponent, BillingOverviewComponent, CheckoutComponent, CheckoutReviewComponent, CheckoutConfirmComponent, PaymentMethodComponent, PaymentMethodReviewComponent, PaymentMethodConfirmComponent ], providers: [], schemas: [CUSTOM_ELEMENTS_SCHEMA], }) - -export class ProfileModule { } +export class ProfileModule { + constructor() { } +} diff --git a/Development/client/src/app/profile/reducers/index.ts b/Development/client/src/app/profile/reducers/index.ts deleted file mode 100644 index 4f5acaa..0000000 --- a/Development/client/src/app/profile/reducers/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { ActionReducerMap } from "@ngrx/store"; -import { reducer as profileReducer, State as Profile } from './profile.reducer'; -import { reducer as paymentReducer, State as Payment } from './payment.reducer'; -import { reducer as usageReducer, State as Usage } from './usage.reducer'; - -export const FEATURE_KEY = 'Profile'; - -export interface ProfileState { - ManageProfile: Profile; - payment: Payment, - usage: Usage -} - -export const profileReducers: ActionReducerMap = { - ManageProfile: profileReducer, - payment: paymentReducer, - usage: usageReducer -}; diff --git a/Development/client/src/app/profile/reducers/payment.reducer.ts b/Development/client/src/app/profile/reducers/payment.reducer.ts deleted file mode 100644 index 4534e29..0000000 --- a/Development/client/src/app/profile/reducers/payment.reducer.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Payment, Status } from "@app/domain/models/subscription.model"; -import * as actions from "../actions/payment.action"; - -export interface State { - loading: boolean; - loaded: boolean; - payments: Payment[]; - status: Status; - curTime: string; -} - -const initialState: State = { - loading: false, - loaded: false, - payments: [], - status: void 0, - curTime: '' -}; - -export function reducer(state: State = initialState, action: actions.PaymentAction): State { - switch (action.type) { - case actions.FETCH: - return { ...state, loading: true }; - case actions.FETCH_SUCCESS: - return { ...state, payments: action.payload.payments, loaded: true, loading: false, status: void 0, curTime: action.payload.curTime }; - case actions.FETCH_FAILED: - return { ...state, loaded: false, loading: false, status : action.payload }; - default: - return state; - } -} \ No newline at end of file diff --git a/Development/client/src/app/profile/reducers/profile.reducer.ts b/Development/client/src/app/profile/reducers/profile-reducer.ts similarity index 100% rename from Development/client/src/app/profile/reducers/profile.reducer.ts rename to Development/client/src/app/profile/reducers/profile-reducer.ts diff --git a/Development/client/src/app/profile/reducers/usage.reducer.ts b/Development/client/src/app/profile/reducers/usage.reducer.ts deleted file mode 100644 index d3b74f4..0000000 --- a/Development/client/src/app/profile/reducers/usage.reducer.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Status, UsageDetail } from "@app/domain/models/subscription.model"; -import * as actions from "../actions/usage.actions"; - -export interface State { - usageDetail: UsageDetail; - status: Status; -} - -const initialState: State = { - usageDetail: void 0, - status: void 0, -}; - -export function reducer(state: State = initialState, action: actions.UsageAction): State { - switch (action.type) { - case actions.FETCH_USAGE: - return { ...state } - case actions.FETCH_USAGE_SUCCESS: - return { ...state, usageDetail: { ...state.usageDetail, ...action.payload }, status: void 0 } - case actions.FETCH_USAGE_FAILED: - return { ...state, usageDetail: void 0, status: action.payload }; - case actions.RESET_USAGE: - return initialState; - default: - return state; - } -} \ No newline at end of file diff --git a/Development/client/src/app/profile/selectors/profile.selector.ts b/Development/client/src/app/profile/selectors/profile.selector.ts deleted file mode 100644 index d4aa0ee..0000000 --- a/Development/client/src/app/profile/selectors/profile.selector.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { createFeatureSelector, createSelector } from "@ngrx/store"; -import * as fromFeature from '../reducers'; -import * as fromPayment from '../reducers/payment.reducer'; - -export const getProfileState = createFeatureSelector(fromFeature.FEATURE_KEY); - -// payment -export const getPaymentState = createSelector( - getProfileState, - (state: fromFeature.ProfileState) => state?.payment -); - -export const getPayment = createSelector( - getPaymentState, - (state: fromPayment.State) => state?.payments -); - -export const getPmtStatus = createSelector( - getPaymentState, - (state: fromPayment.State) => state?.status -); - -// usage -export const getUsageState = createSelector( - getProfileState, - (state: fromFeature.ProfileState) => state?.usage -); - - - diff --git a/Development/client/src/app/profile/unpaid-subscription/unpaid-subscription.component.css b/Development/client/src/app/profile/unpaid-subscription/unpaid-subscription.component.css deleted file mode 100644 index e69de29..0000000 diff --git a/Development/client/src/app/profile/unpaid-subscription/unpaid-subscription.component.html b/Development/client/src/app/profile/unpaid-subscription/unpaid-subscription.component.html deleted file mode 100644 index 3710aba..0000000 --- a/Development/client/src/app/profile/unpaid-subscription/unpaid-subscription.component.html +++ /dev/null @@ -1,49 +0,0 @@ - -
    -
    -
    -

    Unpaid

    -
    -
    - - -
    -
    -
    - Payment -
    -
    -
    - Items -
    -
    - Price -
    -
    - -
    - -
    -
    -
    - - -
    -
    -
    -
    -
    -
    - - -
    -
    -
    -
    - - -
    -
    -
    -
    -
    \ No newline at end of file diff --git a/Development/client/src/app/profile/unpaid-subscription/unpaid-subscription.component.ts b/Development/client/src/app/profile/unpaid-subscription/unpaid-subscription.component.ts deleted file mode 100644 index 031c695..0000000 --- a/Development/client/src/app/profile/unpaid-subscription/unpaid-subscription.component.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; -import { Subscription } from 'rxjs'; -import { getSubIntentStatus } from '@app/reducers'; -import { CheckoutPayment, Invoice, Status } from '@app/domain/models/subscription.model'; -import { ResumeUnpaidSubscription } from '@app/actions/subscription.actions'; -import { getUnpaidInvoices, selectAuthUser } from '@app/reducers'; -import { UserModel } from '@app/auth/models/user.model'; -import { User } from '@app/accounts/models/user.model'; -import { ActivatedRoute } from '@angular/router'; -import { SubTexts, SubAppErr, SUB, createSubStatus } from '../common'; -import { BaseComp } from '@app/shared/base/base.component'; -import { map } from 'rxjs/operators'; -import { SubscriptionService } from '@app/domain/services/subscription.service'; - -@Component({ - selector: 'unpaid-subscription', - templateUrl: './unpaid-subscription.component.html', - styleUrls: ['./unpaid-subscription.component.css'] -}) -export class UnpaidSubscriptionComponent extends BaseComp implements OnInit, OnDestroy { - readonly SUB = SUB; - readonly SubTexts = SubTexts; - - profileUser: User; - sub$: Subscription; - status: Status; - chkoutPmt: CheckoutPayment; - authUser: UserModel; - invoices: Invoice[] - - constructor( - private readonly route: ActivatedRoute, - private readonly subSvc: SubscriptionService, - ) { - super(); - this.profileUser = this.route.snapshot.data['user']; - } - - ngOnInit(): void { - this.initSub$(); - } - - initSub$() { - this.sub$ = this.store.select(getSubIntentStatus).subscribe({ - next: status => this.status = status, - error: err => { - this.status = createSubStatus(SubAppErr.UNPAID_ERR); - } - }); - - this.sub$.add(this.store.select(getUnpaidInvoices).pipe( - map((invoices) => { - this.invoices = invoices; - this.chkoutPmt = this.subSvc.calcChkoutPayment(invoices, { subscriptions: this.authSvc.user.membership.subscriptions, coupon: this.subSvc.getInvCoupon(invoices) }); - }) - ).subscribe({ - error: err => { - console.log(err); - this.status = createSubStatus(SubAppErr.UNPAID_ERR); - } - })); - - this.sub$.add(this.store.select(selectAuthUser).pipe( - map((authUser) => { - this.authUser = authUser; - }) - ).subscribe({ - error: err => { - console.log(err); - this.status = createSubStatus(SubAppErr.UNPAID_ERR); - } - })); - } - - hasError(): boolean { - return this.status?.code === SubAppErr._500_ERR || this.status?.code === SubAppErr.RES_ERR || this.status?.code === SubAppErr.RES_UNPAID_ERR || this.status?.code === SubAppErr.UNPAID_ERR; - } - - hasInvoice(): boolean { - return this.invoices?.length > 0; - } - - confirm() { - const invalidInputs = this.invoices?.length === 0 || !this.authUser || !this.profileUser.name; - if (invalidInputs) return this.status = createSubStatus(SubAppErr.UNPAID_ERR); - this.store.dispatch(new ResumeUnpaidSubscription({ unpaidInvoices: this.invoices, name: this.profileUser.name, authUser: this.authUser })); - } - - gotoMySubs() { - this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]); - } - - ngOnDestroy(): void { - super.ngOnDestroy(); - } -} diff --git a/Development/client/src/app/profile/update-profile/update-profile.component.html b/Development/client/src/app/profile/update-profile/update-profile.component.html index 190d279..6743c20 100644 --- a/Development/client/src/app/profile/update-profile/update-profile.component.html +++ b/Development/client/src/app/profile/update-profile/update-profile.component.html @@ -7,22 +7,13 @@
    -
    -
    - {{globals.accountType}}: {{accountType}} -
    -
    - {{globals.masterAcc}}: {{parentUsername}} -
    -
    - - + +
    diff --git a/Development/client/src/app/profile/update-profile/update-profile.component.ts b/Development/client/src/app/profile/update-profile/update-profile.component.ts index 4218770..fc71c8e 100644 --- a/Development/client/src/app/profile/update-profile/update-profile.component.ts +++ b/Development/client/src/app/profile/update-profile/update-profile.component.ts @@ -6,9 +6,8 @@ import { User } from '@app/accounts/models/user.model'; import { BaseComp } from '@app/shared/base/base.component'; import * as profileAction from '../actions/profile.actions'; -import { globals, RoleIds } from '@app/shared/global'; +import { globals } from '@app/shared/global'; import { FormGroup, FormBuilder } from '@angular/forms'; -import { UserService } from '@app/domain/services/user.service'; @Component({ selector: 'agm-user-profile-update', @@ -19,9 +18,7 @@ export class ProfileUpdateComponent extends BaseComp implements OnInit, OnDestro constructor( private readonly route: ActivatedRoute, - private readonly fb: FormBuilder, - private userSvc: UserService - ) { + private readonly fb: FormBuilder) { super(); this.form = this.fb.group({ @@ -32,8 +29,6 @@ export class ProfileUpdateComponent extends BaseComp implements OnInit, OnDestro form: FormGroup; selectedItem: User; - parentUsername?: string; - accountType: string; _user: User; get user(): User { @@ -50,17 +45,20 @@ export class ProfileUpdateComponent extends BaseComp implements OnInit, OnDestro ngOnInit(): void { this.sub$ = this.route.data.subscribe((data) => { - const resolved = data[0] as { user: User, parentUsername?: string }; - if (resolved && resolved.user) { - this.user = resolved.user; - this.parentUsername = resolved.parentUsername; - this.accountType = this.userSvc.getAccountType(this.user); + const user = (data[0] as User) || null; + if (user) { + this.user = user; } }); + this.sub$.add(this.appActions.ofTypes([profileAction.UPDATE_SUCCESS]).subscribe(action => { + //TODO: Update current logged in user in app's state ??? + })); } updateProfile() { if (!this.form || !this.form.value || !this.form.valid) return; + + // TODO: storing user profile in state ?? const userObj = Object.assign(this.selectedItem, this.form.value.profile, this.form.value.account); this.store.dispatch(new profileAction.Update(userObj)); } @@ -69,14 +67,6 @@ export class ProfileUpdateComponent extends BaseComp implements OnInit, OnDestro this.router.navigate(['../']); } - getAccountTitle(): string { - return this.authSvc.isApplicator ? globals.masterAcc : globals.subAcc.replace('#account#', this.parentUsername); - } - - get isPartner(): boolean { - return this.user?.kind === RoleIds.PARTNER; - } - ngOnDestroy() { super.ngOnDestroy(); } diff --git a/Development/client/src/app/profile/usage-detail/usage-detail.component.css b/Development/client/src/app/profile/usage-detail/usage-detail.component.css deleted file mode 100644 index 798eaa1..0000000 --- a/Development/client/src/app/profile/usage-detail/usage-detail.component.css +++ /dev/null @@ -1,12 +0,0 @@ -td { - text-align: center; -} - -tr th.usage-header { - background-color: #4CAF50; - color: #FFFFFF; -} - -.flex { - display: flex; -} \ No newline at end of file diff --git a/Development/client/src/app/profile/usage-detail/usage-detail.component.html b/Development/client/src/app/profile/usage-detail/usage-detail.component.html deleted file mode 100644 index f1ebb02..0000000 --- a/Development/client/src/app/profile/usage-detail/usage-detail.component.html +++ /dev/null @@ -1,60 +0,0 @@ -
    -
    -
    -
    -
    From:
    -
    -
    -
    -
    To:
    -
    -
    -
    - -
    -
    -
    - -
    - - - - - {{col.header}} - - - - - - - - {{col.header}} - - {{rowData[col.field] | date:'shortDate'}} - - - {{rowData[col.field] | number:'1.2-2'}} - {{rowData[col.field] | number:'1.2-2'}} - {{rowData[col.field]}} - - - - - - - - Total - {{numOfJobs}} jobs - {{ttArea | number:'1.2-2'}} - {{sumOfTotalSprayed | number:'1.2-2'}} - - - - - -
    - -
    - -
    -
    \ No newline at end of file diff --git a/Development/client/src/app/profile/usage-detail/usage-detail.component.ts b/Development/client/src/app/profile/usage-detail/usage-detail.component.ts deleted file mode 100644 index 935f16f..0000000 --- a/Development/client/src/app/profile/usage-detail/usage-detail.component.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { Component, EventEmitter, HostListener, Input, OnChanges, OnInit, Output } from '@angular/core'; -import { SubTexts } from '../common'; -import { JobUsage, TSRange } from '@app/domain/models/subscription.model'; -import { DateUtils } from '@app/shared/utils'; -import { SortEvent } from 'primeng-lts/api'; - -interface DateRange { - minDate: Date; - maxDate: Date; - fromDate?: Date; - toDate?: Date; -} - -@Component({ - selector: 'usage-detail', - templateUrl: './usage-detail.component.html', - styleUrls: ['./usage-detail.component.css'] -}) -export class UsageDetailComponent implements OnInit, OnChanges { - @Input() jobUsages: JobUsage[]; - @Input() ttArea: number; - @Input() TSRange: TSRange; - @Input() locale; - @Output() selPeriodEvt = new EventEmitter(); - - readonly SubTexts = SubTexts; - readonly updateDate = 'updateDate'; - readonly createdAt = 'createdAt'; - readonly ttSprArea = 'ttSprArea'; - readonly totalSprayed = 'totalSprayed'; - - readonly fromInput = 'fromInput'; - readonly toInput = 'toInput' - - defDateRange: DateRange; - fromDate: Date; - toDate: Date; - - calMinDate: Date; - calMaxDate: Date; - - numOfJobs: number; - sumOfTotalSprayed: number; - - fileExportName: string; - - cols: { - field: string; - header: string; - footer?: any; - width: string; - }[]; - - ngOnInit(): void { - this.fileExportName = this.createExportFileName(this.fromDate, this.toDate); - this.initializeColumns(); - this.calculateSums(); - this.refreshCal(); - } - - initializeColumns(): void { - this.cols = [ - { field: this.createdAt, header: $localize`:@@createdDate:Created Date`, width: "15%" }, - { field: "jobId", header: $localize`:@@jobId:Job Id`, width: "15%" }, - { field: this.ttSprArea, header: $localize`:@@ttArea:Total Area (acres)`, width: "20%" }, - { field: this.totalSprayed, header: $localize`:@@appliedAcres:Applied Acres`, width: "25%" }, - { field: this.updateDate, header: $localize`:@@appliedAcres:Last Applied Date`, width: "25%" } - ]; - } - - calculateSums() { - this.sumOfTotalSprayed = this.jobUsages?.reduce((total, jobUsage) => total + jobUsage.totalSprayed, 0); - this.numOfJobs = this.jobUsages?.length || 0; - } - - createExportFileName(fromDate: Date, toDate: Date): string { - const fromDateString = fromDate?.toLocaleDateString(this.locale, { year: 'numeric', month: 'short', day: 'numeric' }) || ''; - const toDateString = toDate?.toLocaleDateString(this.locale, { year: 'numeric', month: 'short', day: 'numeric' }) || ''; - return `${fromDateString}_to_${toDateString}`; - } - - ngOnChanges() { - this.refreshCal(); - this.calculateSums(); - } - - refreshCal() { - const minDate = DateUtils.tsToDate(this.TSRange.minTS); - minDate.setHours(0, 0, 0); - const maxDate = DateUtils.tsToDate(this.TSRange.maxTS); - maxDate.setHours(23, 59, 59); - this.defDateRange = { - minDate, - maxDate, - fromDate: DateUtils.tsToDate(this.TSRange.fromTS), - toDate: DateUtils.tsToDate(this.TSRange.toTS) - }; - this.calMinDate = this.defDateRange.minDate; - this.calMaxDate = this.defDateRange.maxDate; - this.fromDate = this.defDateRange.fromDate; - this.toDate = this.defDateRange.toDate; - this.changePeriod(); - } - - selPeriod(targetName?: string) { - /* - NOTE: Mar 16/2026, relax validation to allow dates outside of default range, as backend can handle it and will return empty data if out of bounds. This allows users to select any date range without being blocked by the UI validation, while still ensuring that the fromDate is not after the toDate. - This can be revisited in the future if we want to add stricter validation based on the actual data range or other business rules such as min/max from subscription periods. */ - const validRange = !!this.fromDate && !!this.toDate && this.fromDate <= this.toDate /*&& this.fromDate >= this.defDateRange.minDate*/ && this.toDate <= this.defDateRange.maxDate; - if (validRange) { - this.selPeriodEvt.emit({ fromTS: DateUtils.dateToTS(this.fromDate), toTS: DateUtils.dateToTS(this.toDate) }); - } else { - this.changePeriod(targetName); - }; - } - - changePeriod(targetName?: string) { - this.fileExportName = this.createExportFileName(this.fromDate, this.toDate); - if (!this.fromDate) { - this.fromDate = this.defDateRange.minDate; - this.selPeriod(); - } - if (!this.toDate) { - this.toDate = this.defDateRange.maxDate; - this.selPeriod(); - } - if (this.fromDate > this.toDate) { - if (targetName === this.fromInput) { - this.fromDate = this.toDate; - this.toDate = this.defDateRange.maxDate; - this.selPeriod(); - } else if (targetName === this.toInput) { - this.toDate = this.fromDate; - this.fromDate = this.defDateRange.minDate; - this.selPeriod(); - } - } - } - - reset() { - this.fromDate = this.defDateRange.minDate; - this.toDate = this.defDateRange.maxDate; - this.selPeriod(); - } - - customSort(event: SortEvent) { - event.data.sort((data1, data2) => { - const value1 = data1[event.field]; - const value2 = data2[event.field]; - const result = (value1 < value2) ? -1 : (value1 > value2) ? 1 : 0; - return (event.order * result); - }); - } - - @HostListener('document:keydown.enter', ['$event']) onKeydownHandler(e: KeyboardEvent) { - const target = e.target; - if (target.name === this.fromInput || target.name === this.toInput) this.selPeriod(target.name); - } - - hasJobs() { - return this.jobUsages && this.jobUsages.length > 0; - } -} diff --git a/Development/client/src/app/profile/usage-summary/usage-summary.component.css b/Development/client/src/app/profile/usage-summary/usage-summary.component.css deleted file mode 100644 index eb278f8..0000000 --- a/Development/client/src/app/profile/usage-summary/usage-summary.component.css +++ /dev/null @@ -1,20 +0,0 @@ -.summary-text { - padding: 0; -} - -.summary-text-right { - padding: 0; - text-align: right; -} - -.text { - padding-bottom: 0; -} - -.bar-container { - padding-top: 0; -} - -::ng-deep .custom-progress .ui-progressbar .ui-progressbar-value { - background: #ef5439; -} diff --git a/Development/client/src/app/profile/usage-summary/usage-summary.component.html b/Development/client/src/app/profile/usage-summary/usage-summary.component.html deleted file mode 100644 index 15e5cfe..0000000 --- a/Development/client/src/app/profile/usage-summary/usage-summary.component.html +++ /dev/null @@ -1,42 +0,0 @@ -
    - - - -
    - - -
    -
    -
    Usage period: - {{fromTS | tsToDate: lang}} - to - {{toTS | tsToDate: lang}} -
    -
    {{dayLeft}} days left
    -
    -
    -
    - - -
    - -
    -
    - - -
    - Total acres allowance: -

    {{UNLIMITED}}

    -
    - -
    -
    -
    {{maxAcre}}
    -
    {{ttArea | number:'1.2-2'}} Acre used of {{maxAcre}}
    -
    -
    -
    - -
    -
    -
    \ No newline at end of file diff --git a/Development/client/src/app/profile/usage-summary/usage-summary.component.ts b/Development/client/src/app/profile/usage-summary/usage-summary.component.ts deleted file mode 100644 index a324996..0000000 --- a/Development/client/src/app/profile/usage-summary/usage-summary.component.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Component, Input } from '@angular/core'; -import { SubTexts, UNLIMITED } from '../common'; -import { AuthService } from '@app/domain/services/auth.service'; - -@Component({ - selector: 'usage-summary', - templateUrl: './usage-summary.component.html', - styleUrls: ['./usage-summary.component.css'] -}) -export class UsageSummaryComponent { - readonly SubTexts = SubTexts; - readonly UNLIMITED = UNLIMITED; - - @Input() fromTS: number; - @Input() toTS: number; - @Input() dayLeft: number; - @Input() dayPercentage: number; - @Input() maxAcre: string; - @Input() ttArea: string; - @Input() acrePercentage: number; - - lang; - - constructor( - private readonly authSvc: AuthService - ) { - this.lang = this.authSvc.locale; - } - - custProgress(acrePercentage: number): string { - return acrePercentage >= 100 ? 'custom-progress' : ''; - } -} diff --git a/Development/client/src/app/profile/usage/usage.component.css b/Development/client/src/app/profile/usage/usage.component.css deleted file mode 100644 index 2d2a05a..0000000 --- a/Development/client/src/app/profile/usage/usage.component.css +++ /dev/null @@ -1,5 +0,0 @@ -.bill-period { - display: flex; - align-items: center; - margin-right: 1em; -} \ No newline at end of file diff --git a/Development/client/src/app/profile/usage/usage.component.html b/Development/client/src/app/profile/usage/usage.component.html deleted file mode 100644 index f038e3c..0000000 --- a/Development/client/src/app/profile/usage/usage.component.html +++ /dev/null @@ -1,46 +0,0 @@ - -
    -
    -
    -

    Usage Details

    - -
    -
    Billing period:
    -
    - - -
    -
    - -
    - - - - - - - - - - - - - -
    -
    -
    -
    -
    - - -
    -
    -
    -
    - - -
    -
    -
    -
    -
    \ No newline at end of file diff --git a/Development/client/src/app/profile/usage/usage.component.ts b/Development/client/src/app/profile/usage/usage.component.ts deleted file mode 100644 index 47fcab4..0000000 --- a/Development/client/src/app/profile/usage/usage.component.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; -import { BaseComp } from '@app/shared/base/base.component'; -import { SelectItem } from 'primeng-lts/api'; -import { getUsageState } from '../selectors/profile.selector'; -import { Status, TSRange, UsageDetail } from '@app/domain/models/subscription.model'; -import { SUB, SubAppErr, SubTexts, SubType, createSubStatus, hasVendorErr } from '../common'; -import { FetchUsage } from '../actions/usage.actions'; -import { DateUtils } from '@app/shared/utils'; -import { map } from 'rxjs/operators'; - -@Component({ - selector: 'usage', - templateUrl: './usage.component.html', - styleUrls: ['./usage.component.css'] -}) -export class UsageComponent extends BaseComp implements OnInit, OnDestroy { - readonly SubTexts = SubTexts; - - showFull: boolean; - selBilPeriod; - bilPeriods: SelectItem[]; - usageDetail: UsageDetail; - status: Status; - TSRange: TSRange; - - vendorErr: boolean; - - constructor(private readonly route: ActivatedRoute) { - super(); - this.showFull = this.route.snapshot.data['showFull']; - } - - ngOnInit(): void { - this.sub$ = this.store.select(getUsageState).pipe( - map((state) => { - if (state.status) { - this.status = state.status; - if (hasVendorErr(this.status?.code)) { - this.vendorErr = true; - } - } else { - if (state.usageDetail) { - this.usageDetail = state.usageDetail; - if (!this.bilPeriods) { - const billPeriods = this.usageDetail.billPeriods?.map(billPeriod => ({ label: `${this.toBilPeriod(billPeriod.periodStart)} ${SubTexts.to} ${this.toBilPeriod(billPeriod.periodEnd)}`, value: { fromTS: billPeriod.periodStart, toTS: billPeriod.periodEnd, lookupKey: billPeriod.lookupKey } })); - this.bilPeriods = billPeriods ? [billPeriods[0]] : []; - } - if (this.selBilPeriod) { - this.TSRange = { minTS: this.selBilPeriod.fromTS, maxTS: this.selBilPeriod.toTS, fromTS: this.usageDetail.periodStart, toTS: this.usageDetail.periodEnd } - } else { - this.selBilPeriod = this.bilPeriods[0].value; - this.TSRange = { minTS: this.bilPeriods[0].value.fromTS, maxTS: this.bilPeriods[0].value.toTS, fromTS: this.usageDetail.periodStart, toTS: this.usageDetail.periodEnd } - } - } - } - }) - ).subscribe({ - error: err => { - console.log(err); - this.status = createSubStatus(SubAppErr.USAGE_DETAIL_ERR); - } - }); - } - - isCompLoaded() { - return this.status?.code !== SubAppErr._500_ERR - && this.status?.code !== SubAppErr.FETCH_USAGE_ERR - && this.status?.code !== SubAppErr.USAGE_DETAIL_ERR - && !this.vendorErr; - } - - toBilPeriod(ts: number): string { - return DateUtils.toSlash(DateUtils.tsToDate(ts), this.authSvc.locale); - } - - getBilPeriod(period: TSRange) { - try { - const isPkg = this.authSvc.user.membership.subscriptions?.some((sub) => sub.type === SubType.PACKAGE); - if (isPkg) { - this.store.dispatch(new FetchUsage({ - custId: this.authSvc.user.membership.custId, - byPuid: this.authSvc.user._id, - lookupKey: this.selBilPeriod?.lookupKey, - period: { fromTS: period.fromTS, toTS: period.toTS } - })); - } - } catch (err) { - this.status = createSubStatus(SubAppErr.USAGE_DETAIL_ERR); - } - - } - - gotoMySubs() { - this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]); - } - - ngOnDestroy(): void { - super.ngOnDestroy(); - } -} diff --git a/Development/client/src/app/reducers/auth.reducer.ts b/Development/client/src/app/reducers/auth.reducer.ts index 15f363e..0e30541 100644 --- a/Development/client/src/app/reducers/auth.reducer.ts +++ b/Development/client/src/app/reducers/auth.reducer.ts @@ -1,7 +1,5 @@ import { UserModel } from '../auth/models/user.model'; import * as actions from '../auth/actions/auth.actions'; -import * as subActions from '@app/actions/subscription.actions'; -import * as profileActions from '@app/profile/actions/profile.actions'; export interface State { user: UserModel | null; @@ -11,59 +9,20 @@ export const initialState: State = { user: null, }; -export function reducer(state = initialState, action: actions.All | subActions.SubscriptionAction | profileActions.All): State { +export function reducer(state = initialState, action: actions.All): State { switch (action.type) { case actions.LOGIN_SUCCESS: - return { ...state, user: action.payload.user }; - case actions.REFRESH_USER_DATA: - // Merge fresh user data from server, preserving fields not returned by getUser API - const freshUser = action.payload.user; return { ...state, - user: { - ...state.user, - username: freshUser.username, - contact: freshUser.contact, - name: freshUser.name, - } + user: action.payload.user }; + case actions.LOGOUT_COMPLETE: return initialState; - case profileActions.UPDATE_SUCCESS: - // Update only the fields that can change in profile: username, contact, and kind (account type) - const { username, contact } = action.payload; - return { ...state, user: { ...state.user, username, contact } }; - case subActions.CONFIRM_ACTION_SUCCESS: - return { ...state, user: { ...state.user, membership: action.payload.membership } }; - case subActions.CONFIRM_PAYMENT_SUCCESS: - return { ...state, user: { ...state.user, membership: action.payload.membership } }; - case subActions.PAY_UNPAID_SUBSCRIPTION_SUCCESS: - return { ...state, user: { ...state.user, membership: action.payload.membership } }; - case subActions.UPDATE_SUBSCRIPTION_SUCCESS: - return { ...state, user: { ...state.user, membership: action.payload.membership } }; - case subActions.FETCH_LATEST_SUBSCRIPTION_SUCCESS: { - const incoming = action.payload.membership; - // Merge: keep existing subscriptions when the incoming membership doesn't carry them - // (e.g. MembershipResolver on startup returns DB-level membership without subscriptions) - const merged = { - ...incoming, - subscriptions: incoming?.subscriptions ?? state.user?.membership?.subscriptions, - }; - return { ...state, user: { ...state.user, membership: merged } }; - } - case subActions.POLL_UNPAID_SUBSCRIPTION_SUCCESS: - return { ...state, user: { ...state.user, membership: action.payload.membership } }; - case subActions.RESET_SUBSCRIPTION: - // Do NOT clear membership.subscriptions here – auth subscription data is authoritative - // and must only be updated via FETCH_LATEST_SUBSCRIPTION_SUCCESS (which carries confirmed - // Stripe data). Clearing it here caused the expiry-warning banner to flash off whenever - // manage-subscription dispatched InitSubscription (e.g. navigating to My Services). - return state; - case subActions.UPDATE_TRIAL: - return { ...state, user: { ...state.user, membership: { ...state.user.membership, trials: action.payload } } }; + default: return state; } } -export const selectUser = (state: State) => state.user; \ No newline at end of file +export const selectUser = (state: State) => state.user; diff --git a/Development/client/src/app/reducers/index.ts b/Development/client/src/app/reducers/index.ts index 04a1d04..d73e1ea 100644 --- a/Development/client/src/app/reducers/index.ts +++ b/Development/client/src/app/reducers/index.ts @@ -1,33 +1,19 @@ import { ActionReducerMap, ActionReducer, MetaReducer, createFeatureSelector, createSelector } from '@ngrx/store'; import { localStorageSync } from 'ngrx-store-localstorage'; + import { environment } from '@environments/environment'; import * as authActions from '../auth/actions/auth.actions'; import * as fromAuth from './auth.reducer'; import * as fromLogin from './login.reducer'; -import * as fromSubPlans from './sub-plans.reducer'; -import * as fromSubs from './subscription.reducer'; -import * as fromSubIntent from './subscription-intent.reducer'; -import { SubLimit, SubscriptionIntent, Unpaid, ExpiryWarning } from '@app/domain/models/subscription.model'; -import { UserModel } from '@app/auth/models/user.model'; -import { SubType, SUB_NAME, SubStripe } from '@app/profile/common'; - -// ExpiryWarning type constants -const EXPIRY_TYPE_BOTH = 'both'; export interface State { auth: fromAuth.State; login: fromLogin.State; - subPlans: fromSubPlans.State; - subscription: fromSubs.State; - subIntent: fromSubIntent.State } export const reducers: ActionReducerMap = { auth: fromAuth.reducer, login: fromLogin.reducer, - subPlans: fromSubPlans.reducer, - subscription: fromSubs.reducer, - subIntent: fromSubIntent.reducer }; export function logger(reducer: ActionReducer): ActionReducer { @@ -51,10 +37,6 @@ export function sessionStorageSyncReducer(reducer: ActionReducer): ActionRe storage: sessionStorage, keys: [ //Specify list of state needs to be rehydrated after page reloaded - // Persist full auth state including subscriptions so the expiry-warning banner - // survives F5/reload. The auth reducer merges incoming membership data so that - // a FETCH_LATEST_SUBSCRIPTION_SUCCESS without subscriptions (e.g. from the - // MembershipResolver on startup) does not wipe out the persisted subscriptions. 'auth', 'Entities', 'Clients', @@ -63,13 +45,9 @@ export function sessionStorageSyncReducer(reducer: ActionReducer): ActionRe 'Customers', 'Costing items', 'Invoices', - 'Invoice settings', - 'Profile', - 'subPlans', - 'subscription', - 'subIntent' + 'Invoice settings' ], - rehydrate: true + rehydrate: true })(reducer); } @@ -99,302 +77,3 @@ export const selectLoginError = createSelector( selectLoginState, fromLogin.selectError ); - -// auth user membership -export const selectUserMembership = createSelector( - selectAuthUser, - (user) => user?.membership -); - -export const selectUserSubscriptions = createSelector( - selectUserMembership, - (membership) => membership?.subscriptions -); - -export const selectUserCustomLimits = createSelector( - selectUserMembership, - (membership) => membership?.customLimits -); - -/** - * Select subscription expiry warning - * Returns warning details if subscription expires in 1-7 days - * Checks both package and addon subscriptions with individual expiry dates - */ -export const selectExpiryWarning = createSelector( - selectUserSubscriptions, - (subscriptions): ExpiryWarning | null => { - if (!subscriptions || subscriptions.length === 0) { - return null; - } - - const now = Math.floor(Date.now() / 1000); - const expiringItems: { - package?: any; - addons: any[]; - earliestExpiry: number; - } = { - addons: [], - earliestExpiry: Infinity - }; - - // Check package subscription - const packageSub = subscriptions.find(sub => sub.type === SubType.PACKAGE); - if (packageSub && packageSub.periodEnd) { - const daysUntilExpiry = Math.floor((packageSub.periodEnd - now) / 86400); - if (daysUntilExpiry >= 0 && daysUntilExpiry <= environment.expiryWarningDays) { - const lookupKey = packageSub.items?.[0]?.price as string; - expiringItems.package = { - subscription: packageSub, - daysUntilExpiry, - lookupKey - }; - expiringItems.earliestExpiry = Math.min(expiringItems.earliestExpiry, packageSub.periodEnd); - } - } - - // Check addon subscriptions - const addonSubs = subscriptions.filter(sub => sub.type === SubType.ADDON); - addonSubs.forEach(addon => { - if (addon.periodEnd) { - const daysUntilExpiry = Math.floor((addon.periodEnd - now) / 86400); - if (daysUntilExpiry >= 0 && daysUntilExpiry <= environment.expiryWarningDays) { - const lookupKey = addon.items?.[0]?.price as string; - expiringItems.addons.push({ - subscription: addon, - daysUntilExpiry, - lookupKey - }); - expiringItems.earliestExpiry = Math.min(expiringItems.earliestExpiry, addon.periodEnd); - } - } - }); - - // Return null if nothing is expiring - if (!expiringItems.package && expiringItems.addons.length === 0) { - return null; - } - - // Use the earliest expiry date for the warning - const daysUntilExpiry = Math.floor((expiringItems.earliestExpiry - now) / 86400); - - // Determine subscription type and details - const hasPackage = !!expiringItems.package; - const hasAddons = expiringItems.addons.length > 0; - const primarySub = expiringItems.package?.subscription || expiringItems.addons[0].subscription; - - // Build package details - const packageDetails = expiringItems.package ? { - name: SUB_NAME[expiringItems.package.lookupKey] || expiringItems.package.lookupKey, - lookupKey: expiringItems.package.lookupKey, - daysUntilExpiry: expiringItems.package.daysUntilExpiry, - periodEnd: expiringItems.package.subscription.periodEnd, - willAutoRenew: !expiringItems.package.subscription.cancelAtPeriodEnd, - isTrial: expiringItems.package.subscription.status === SubStripe.TRIALING, - isCanceled: expiringItems.package.subscription.status === SubStripe.CANCELED - } : undefined; - - // Build addon details - const addonDetails = expiringItems.addons.map(addon => ({ - name: SUB_NAME[addon.lookupKey] || addon.lookupKey, - lookupKey: addon.lookupKey, - daysUntilExpiry: addon.daysUntilExpiry, - periodEnd: addon.subscription.periodEnd, - willAutoRenew: !addon.subscription.cancelAtPeriodEnd, - isTrial: addon.subscription.status === SubStripe.TRIALING, - isCanceled: addon.subscription.status === SubStripe.CANCELED - })); - - return { - id: primarySub.id, - type: hasPackage && hasAddons ? EXPIRY_TYPE_BOTH : hasPackage ? SubType.PACKAGE : SubType.ADDON, - status: primarySub.status, - daysUntilExpiry, - cancelAtPeriodEnd: primarySub.cancelAtPeriodEnd, - periodEnd: expiringItems.earliestExpiry, - isTrial: primarySub.status === SubStripe.TRIALING, - willAutoRenew: !primarySub.cancelAtPeriodEnd, - package: packageDetails, - addons: addonDetails.length > 0 ? addonDetails : undefined - }; - } -); - -/** - * Returns a warning for sub-accounts that have no active or trialing subscriptions. - */ -export const selectNoSubsWarning = createSelector( - selectAuthUser, - selectUserSubscriptions, - (user, subscriptions): ExpiryWarning | null => { - if (!user?.parent || user.parent === user._id) return null; - const hasActiveSub = subscriptions?.some( - sub => sub.status === SubStripe.ACTIVE || sub.status === SubStripe.TRIALING - ); - if (hasActiveSub) return null; - return { - id: '', type: 'package', status: '', daysUntilExpiry: 0, - cancelAtPeriodEnd: false, periodEnd: 0, - isTrial: false, willAutoRenew: false, noSubs: true - }; - } -); - -export const selectSubPkgs = createSelector( - selectUserSubscriptions, - (subs) => { - // Find latest package subscription by periodEnd - const pkgSubs = subs?.filter(sub => sub.type === SubType.PACKAGE); - if (!pkgSubs || pkgSubs.length === 0) return []; - - const latestPkg = pkgSubs.reduce((acc, curr) => - (curr.periodEnd > acc.periodEnd) ? curr : acc, pkgSubs[0] - ); - - const result = [{ - id: latestPkg.id, - lookupKey: latestPkg.items?.[0]?.price, - status: latestPkg.status, - periodEnd: latestPkg.periodEnd, - cancelAtPeriodEnd: latestPkg.cancelAtPeriodEnd, - quantity: latestPkg.items?.[0]?.quantity, - paymentMethod: '', - trialEnd: latestPkg.trial_end, - promoDetails: latestPkg.promoDetails - }]; - - return result; - } -); - -export const selectSubAddons = createSelector( - selectUserSubscriptions, - (subs) => subs?.filter(((sub) => sub.type === SubType.ADDON)) - .map((sub) => ({ - id: sub.id, - lookupKey: sub.items?.[0]?.price, - status: sub.status, - periodEnd: sub.periodEnd, - cancelAtPeriodEnd: sub.cancelAtPeriodEnd, - quantity: sub.items?.[0]?.quantity, - paymentMethod: '', - trialEnd: sub.trial_end, // Added for Case 2C trial promo display - promoDetails: sub.promoDetails // Added for Case 2C trial promo display - }) - ) -); - -// subscription -export const getSubscriptionState = createFeatureSelector('subscription'); - -export const getSubscriptions = createSelector( - getSubscriptionState, - (state: fromSubs.State) => state?.entries -); - -export const getSubscriptionStatus = createSelector( - getSubscriptionState, - (state: fromSubs.State) => state?.status -); - -export const getPastDue = createSelector( - getSubscriptionState, - (state: fromSubs.State) => state?.pastDue -); - -export const getIncomplete = createSelector( - getSubscriptionState, - (state: fromSubs.State) => state?.incomplete -); - -export const getUnpaid = createSelector( - getSubscriptionState, - (invoices: fromSubs.State) => invoices.unpaid -); - -export const getUnpaidInvoices = createSelector( - getUnpaid, - (unpaid: Unpaid) => unpaid.invoices -); - -export const getDefPM = createSelector( - getSubscriptionState, - (state: fromSubs.State) => state.defPM -); - -export const getPaymentMethods = createSelector( - getSubscriptionState, - (state: fromSubs.State) => state.paymentMethods -); - -// subPlans -export const selectSubPlansState = createFeatureSelector('subPlans'); -export const selectSubLimit = createSelector( - selectSubPlansState, - fromSubPlans.selectSubLimit -); -export const selectLimit = (limit: string) => createSelector( - selectSubLimit, - (subLimit: SubLimit) => subLimit ? subLimit[limit] : void 0 -); - -export const selectSubPlansStatus = createSelector( - selectSubPlansState, - (state: fromSubPlans.State) => state.status -); - -export const selectSubPlansLoading = createSelector( - selectSubPlansState, - (state: fromSubPlans.State) => state.loading -); - -export const selectSubPlansLoaded = createSelector( - selectSubPlansState, - (state: fromSubPlans.State) => state.loaded -); - -// subIntent -export const getSubIntentState = createFeatureSelector('subIntent'); - -export const getSubIntentPkg = createSelector( - getSubIntentState, - (state: fromSubIntent.State) => state?.package -); - -export const getSubIntentStatus = createSelector( - getSubIntentState, - (state: fromSubIntent.State) => state?.status -); - - -export const getRefreshSubIntent = createSelector( - selectAuthUser, - getSubIntentState, - (user: UserModel, subIntent: fromSubIntent.State) => - ({ - applicatorId: user?._id, - custId: user.membership?.custId, - prevStage: subIntent?.prevStage, - stage: subIntent?.stage, - card: subIntent?.package?.card - }) -); - -export const getSubIntentPkgAmt = createSelector( - getSubIntentPkg, - (state: SubscriptionIntent) => state?.amount -); - -export const getSubIntentPkgCoupons = createSelector( - getSubIntentPkg, - (state: SubscriptionIntent) => state?.coupons -); - -export const getSubIntentMode = createSelector( - getSubIntentState, - (state: fromSubIntent.State) => state?.mode -); - - - - diff --git a/Development/client/src/app/reducers/sub-plans.reducer.ts b/Development/client/src/app/reducers/sub-plans.reducer.ts deleted file mode 100644 index 60e557e..0000000 --- a/Development/client/src/app/reducers/sub-plans.reducer.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Plan, Status, SubLimit } from "@app/domain/models/subscription.model"; -import * as authActions from "../auth/actions/auth.actions"; -import * as subActions from '@app/actions/subscription.actions'; -import * as subPlansActions from '@app/actions/sub-plans.actions' - -export interface State { - subLimit: SubLimit; - status: Status; - loading: boolean; // Track loading state for skeleton UI - loaded: boolean; // Track if data has been loaded at least once -} - -const initialState: State = { - subLimit: void 0, - status: void 0, - loading: false, - loaded: false -} - -export function reducer(state = initialState, action: authActions.All | subActions.SubscriptionAction | subPlansActions.SubPlansAction): State { - switch (action.type) { - case subPlansActions.FETCH_SUB_PLANS: - return { ...state, loading: true, status: void 0 }; - case subActions.CONFIRM_ACTION_SUCCESS: - return { ...state, subLimit: getLimit(action.payload), loading: false, loaded: true }; - case subActions.CONFIRM_PAYMENT_SUCCESS: - return { ...state, subLimit: getLimit(action.payload), loading: false, loaded: true }; - case subActions.PAY_UNPAID_SUBSCRIPTION_SUCCESS: - return { ...state, subLimit: getLimit(action.payload), loading: false, loaded: true }; - case subActions.UPDATE_SUBSCRIPTION_SUCCESS: - return { ...state, subLimit: getLimit(action.payload), loading: false, loaded: true }; - case subPlansActions.FETCH_SUB_PLANS_SUCCESS: - const newSubLimit = getLimit(action.payload); - if (JSON.stringify(state.subLimit) === JSON.stringify(newSubLimit)) { - return { ...state, loading: false, loaded: true }; - } - return { ...state, subLimit: newSubLimit, status: void 0, loading: false, loaded: true }; - case subPlansActions.FETCH_SUB_PLANS_FAILED: - return { ...state, status: action.payload, loading: false }; - case subPlansActions.RESET_SUB_PLANS: - return initialState; - default: - return state; - } -} - -export const selectSubLimit = (state: State) => state.subLimit; - -const getLimit = (plan: Plan): SubLimit => ({ addon: { ...plan.addon }, package: { ...plan.package } }); \ No newline at end of file diff --git a/Development/client/src/app/reducers/subscription-intent.reducer.ts b/Development/client/src/app/reducers/subscription-intent.reducer.ts deleted file mode 100644 index 7e17247..0000000 --- a/Development/client/src/app/reducers/subscription-intent.reducer.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { Status, StripeSubscription, SubscriptionIntent } from '@app/domain/models/subscription.model'; -import * as actions from "@app/actions/subscription.actions"; -import { SUB, Mode } from '../profile/common'; - -export interface State { - package: SubscriptionIntent; - prevStage: string; - stage: string; - status: Status; - mode: Mode; -} - -const initialState: State = { - package: void 0, - prevStage: void 0, - stage: SUB.SERVICES, - status: void 0, - mode: void 0 -}; - -export function reducer(state: State = initialState, action: actions.SubscriptionIntentAction): State { - switch (action.type) { - case actions.CONFIRM_ACTION_SUCCESS: - return { ...state, stage: SUB.CHKOUT_CONF }; - case actions.CONFIRM_PAYMENT_SUCCESS: - return { ...state, stage: SUB.CHKOUT_CONF }; - case actions.PAY_UNPAID_SUBSCRIPTION_SUCCESS: - return { ...state, stage: SUB.CHKOUT_CONF }; - case actions.UPDATE_SUBSCRIPTION_SUCCESS: - return { ...state, stage: SUB.CHKOUT_CONF }; - case actions.CHECK_OUT: - return { ...state, package: { ...state.package, card: action.payload }, prevStage: SUB.CHKOUT, stage: SUB.CHKOUT_REV }; - case actions.CHECK_OUT_TRIAL_SUCCESS: - return { - ...state, package: { - ...state.package, - card: action.payload.card, - selAddons: state.package?.selAddons?.map((addon) => ({ ...addon, trialEnd: getTrialEnd(action.payload.subs, addon.lookupKey, addon.trialEnd) })) || [], - selPkg: state.package?.selPkg ? { ...state.package.selPkg, trialEnd: getTrialEnd(action.payload.subs, state.package.selPkg.lookupKey, state.package.selPkg.trialEnd) } : null, amount: action.payload.amount - }, - prevStage: SUB.CHKOUT, - stage: SUB.CHKOUT_CONF - }; - case actions.CLEAR_SUBSCRIPTION_INTENT_STATUS: - return { ...state, status: void 0 }; - case actions.START_BILLING_INFO: - return { ...state }; - case actions.START_BILLING_INFO_SUCCESS: - return { ...state, package: action.payload, status: void 0, stage: SUB.BILL_ADR }; - case actions.CREATE_SUBSCRIPTION_INTENT_FAILED: - return { ...state, status: action.payload, stage: SUB.BILL_ADR }; - case actions.CREATE_PAYMENT_METHOD_FAILED: - return { ...state, status: action.payload, stage: SUB.CHKOUT }; - case actions.GOTO_BILLING_ADDRESS: - return { ...state, stage: SUB.BILL_ADR }; - case actions.GOTO_CHECK_OUT: - return { ...state, stage: SUB.CHKOUT }; - case actions.GOTO_CHECK_OUT_REVIEW: - return { ...state, stage: SUB.CHKOUT_REV }; - case actions.GOTO_SERVICES: - return { ...state, stage: SUB.SERVICES }; - case actions.REFRESH_SUBSCRIPTION_INTENT_SUCCESS: - return { ...state, package: { ...state.package, ...action.payload, }, stage: SUB.CHKOUT_REV }; - case actions.RESET_SUBSCRIPTION_INTENT: - return initialState; - case actions.SET_SUBSCRIPTION_INTENT_PREV_STAGE: - return { ...state, prevStage: action.payload }; - case actions.CLEAR_PREV_STAGE: - return { ...state, prevStage: void 0 }; - case actions.UPDATE_BILLING_ADDRESS_SUCCESS: - return { ...state, package: { ...state.package, billingInfo: action.payload }, stage: SUB.CHKOUT, status: void 0 }; - case actions.UPDATE_SUBSCRIPTION_INTENT_STATUS: - return { ...state, status: action.payload }; - case actions.UPDATE_AMOUNT: - return { ...state, package: { ...state.package, amount: action.payload } }; - case actions.UPDATE_PROMO_SAVINGS: - return { ...state, package: { ...state.package, promoSavings: action.payload } }; - case actions.START_CHECKOUT_SUCCESS: - return { ...state, package: action.payload, status: void 0, stage: SUB.CHKOUT }; - case actions.LOAD_STRIPE_FAILED: - return { ...state, status: action.payload }; - case actions.APPLY_DISCOUNT_PREVIEW_SUCCESS: - return { ...state, package: { ...state.package, ...action.payload }, status: void 0, stage: SUB.CHKOUT }; - case actions.APPLY_DISCOUNT_PREVIEW_FAILED: - return { ...state, status: action.payload }; - case actions.SET_TRIAL_MODE: - return { ...state, mode: action.payload }; - default: - return state; - } -} - -const getTrialEnd = (subs: StripeSubscription[], lookupKey: string, curTrialEnd: number): number => { - return subs?.find((sub) => sub.items?.data?.some((item) => item?.price?.lookup_key === lookupKey)).trial_end || curTrialEnd; -} - diff --git a/Development/client/src/app/reducers/subscription.reducer.ts b/Development/client/src/app/reducers/subscription.reducer.ts deleted file mode 100644 index 216cd2d..0000000 --- a/Development/client/src/app/reducers/subscription.reducer.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { StripeSubscription, Status, PastDue, Unpaid, Incomplete, PaymentMethod } from '@app/domain/models/subscription.model'; -import * as actions from "@app/actions/subscription.actions"; -import * as subPlansActions from '../actions/sub-plans.actions' -import * as vehActions from '../entities/actions/vehicle.actions'; -import { SUB } from '@app/profile/common'; - -export interface State { - entries: StripeSubscription[]; - pastDue: PastDue; - incomplete: Incomplete; - unpaid: Unpaid; - status: Status; - paymentMethods: PaymentMethod[]; - defPM: PaymentMethod; -} - -const initialState: State = { - entries: [], - pastDue: { - numOfRetries: 0, - invoices: [] - }, - unpaid: { - numOfRetries: 0, - invoices: [] - }, - incomplete: { - invoices: [], - requiresAction: false, - requiresPM: false, - numOfRetries: 0 - }, - status: void 0, - paymentMethods: [], - defPM: void 0 -}; - -export function reducer(state: State = initialState, action: actions.SubscriptionAction | subPlansActions.SubPlansAction | vehActions.All): State { - switch (action.type) { - case actions.CONFIRM_ACTION_SUCCESS: - return { ...state, entries: action.payload.subscriptions, incomplete: { invoices: [], requiresAction: false, requiresPM: false, numOfRetries: 0 } }; - case actions.CONFIRM_PAYMENT_SUCCESS: - return { ...state, entries: action.payload.subscriptions, pastDue: { numOfRetries: 0, invoices: [] } }; - case actions.PAY_UNPAID_SUBSCRIPTION_SUCCESS: - return { ...state, unpaid: { numOfRetries: 0, invoices: [] } }; - case actions.UPDATE_SUBSCRIPTION_SUCCESS: - return { ...state, entries: action.payload.subscriptions }; - case actions.CONFIRM: - return { ...state }; - case actions.CLEAR_SUBSCRIPTION_STATUS: - return { ...state, status: void 0 }; - case actions.CLEAR_SUBSCRIPTION: - return { ...initialState }; - case actions.START_BILLING_INFO_SUCCESS: - return { ...state, entries: void 0 }; - case actions.GOTO_CHECK_OUT: - return { ...state }; - case actions.GOTO_SERVICES: - return { ...state, status: void 0 }; - case actions.FETCH_LATEST_SUBSCRIPTION_SUCCESS: - return { ...state, entries: action.payload.subscriptions }; - case actions.POLL_UNPAID_SUBSCRIPTION_SUCCESS: - return { ...state, entries: action.payload.subscriptions }; - case actions.RESET_SUBSCRIPTION: - return initialState; - case actions.RESUME_UNPAID_SUBSCRIPTION: - return { ...state }; - case actions.RESUME_UNPAID_SUBSCRIPTION_SUCCESS: - return { ...state, unpaid: action.payload.unpaid, entries: action.payload.subscriptions, status: action.payload.status, pastDue: { numOfRetries: 0, invoices: [] } }; - case actions.UPDATE_UNPAID: - return { ...state, unpaid: { ...state.unpaid, ...action.payload } }; - case actions.UPDATE_PAST_DUE: - return { ...state, pastDue: { ...state.pastDue, ...action.payload } }; - case actions.UPDATE_INCOMPLETE: - return { ...state, incomplete: { ...state.incomplete, invoices: action.payload.invoices, requiresAction: action.payload.requiresAction, requiresPM: action.payload.requiresPM, numOfRetries: action.payload.numOfRetries }, entries: action.payload.subscriptions }; - case actions.UPDATE_SUBSCRIPTION_STATUS: - return { ...state, status: { ...state.status, ...action.payload } }; - case actions.UPDATE_SUBSCRIPTION: - return { ...state }; - case actions.START_CHECKOUT_SUCCESS: - return { ...state, entries: void 0 }; - case subPlansActions.FETCH_SUB_PLANS_SUCCESS: - return { ...state, entries: action.payload.subscriptions }; - case subPlansActions.RESET_SUB_PLANS: - return { ...initialState, defPM: state.defPM, paymentMethods: state.paymentMethods }; - case actions.FETCH_PAYMENT_METHOD_LIST_SUCCESS: - return { ...state, paymentMethods: action.payload.paymentMethods }; - case actions.FETCH_DEFAULT_PM_SUCCESS: - return { ...state, defPM: action.payload.defPM }; - case actions.ADD_PM_SUCCESS: - return { ...state, paymentMethods: [...state.paymentMethods, action.payload], defPM: state.paymentMethods?.length === 0 ? action.payload : state.defPM }; - case actions.EDIT_PM_SUCCESS: - return { ...state, paymentMethods: updatePaymentMethodsOnEdit(state, action) }; - case actions.DELETE_PM_SUCCESS: - return { ...state, paymentMethods: updatePaymentMethodsOnDelete(state, action) }; - case actions.CHANGE_PM_SUCCESS: - return { ...state, defPM: action.payload.defPM }; - case vehActions.UPDATE_VEHICLES_SUCCESS: - return { ...state, status: getUpdatedStatus(state, action.payload.type) }; - default: - return state; - } -} - -function updatePaymentMethodsOnEdit(state, action) { - return state.paymentMethods?.map((pm) => pm?.id === action.payload.id ? action.payload : pm) || [] -} - -function updatePaymentMethodsOnDelete(state, action) { - return state.paymentMethods?.filter((pm) => pm?.id != action.payload) || []; -} - -function getUpdatedStatus(state, type) { - if (type != SUB.AC_REVIEW) { - return state.status?.code === SUB.AC_REVIEW - ? void 0 - : state.status; - } - return state.status; -} diff --git a/Development/client/src/app/report.component.ts b/Development/client/src/app/report.component.ts index 192e013..0906da3 100644 --- a/Development/client/src/app/report.component.ts +++ b/Development/client/src/app/report.component.ts @@ -6,7 +6,6 @@ import { environment } from '@environments/environment'; import { JobService } from '@app/domain/services/job.service'; import { globals, locales } from '@app/shared/global'; import { RSLoaderService } from '@app/domain/services/rsloader.service'; -import { GAService } from '@app/shared/ga.service'; declare var Stimulsoft: any; @@ -24,16 +23,13 @@ export class ReportComponent implements OnInit, OnDestroy { designer: any; locale: string = 'en'; rid: string; - private reportViewStartTime: number; - private reportType: 'job_summary' | 'financial_analysis' | 'field_report' | 'performance_dashboard'; constructor( private readonly route: ActivatedRoute, private readonly zone: NgZone, private readonly jobSvc: JobService, private readonly rsLoaderSvc: RSLoaderService, - private readonly title: Title, - private readonly gaSvc: GAService + private readonly title: Title ) { this.title.setTitle("AgMission - Report"); } @@ -137,15 +133,8 @@ export class ReportComponent implements OnInit, OnDestroy { this.viewer = new Stimulsoft.Viewer.StiViewer(options, 'StiViewer', false); - this.viewer.onPrintReport = (event) => { + this.viewer.onPrintReport = function (event) { if (!environment.production) console.log(event); - - // Track report export/print - this.gaSvc.trackReportExported({ - report_type: this.reportType, - export_format: 'pdf', - platform: 'web' - }); } // Add the design button event @@ -155,14 +144,6 @@ export class ReportComponent implements OnInit, OnDestroy { this.viewer.onInteraction = (args, cb) => { if (args.action === "Variables") { - // Track report parameter interaction - this.gaSvc.trackReportFiltered({ - report_type: this.reportType || 'unknown', - report_id: this.rid, - filter_applied: 'parameters', - platform: 'web' - }); - // Save parameters to BE const vars = args.variables; delete vars['yes']; @@ -181,13 +162,6 @@ export class ReportComponent implements OnInit, OnDestroy { private designReport(report) { if (!report) return; - // Track report design mode entry - this.gaSvc.trackReportDesignModeEntered({ - report_type: this.reportType || 'unknown', - report_id: this.rid, - platform: 'web' - }); - this.viewer.visible = false; if (!this.designer) { this.rsLoaderSvc.loadRptDesigner().subscribe( @@ -223,23 +197,11 @@ export class ReportComponent implements OnInit, OnDestroy { const lang = this.route.snapshot.queryParams["lang"]; this.locale = lang || 'en'; - // Detect report type from rid or path for better analytics - this.reportType = this.detectReportType(this.rid, path); - if (!this.viewer) this.initViewer.call(this, isVar); this.viewer.showProcessIndicator(); - // Track report generation start - const generationStartTime = Date.now(); - this.gaSvc.trackReportGenerated({ - report_type: this.reportType, - date_range_days: 30, // Default assumption - could be enhanced - jobs_included: 0, // Will be updated if we can determine this - platform: 'web' - }); - var report = rptObj; if (reload || !rptObj) { report = new Stimulsoft.Report.StiReport(); @@ -287,86 +249,17 @@ export class ReportComponent implements OnInit, OnDestroy { } private renderReport(report) { - const renderStartTime = Date.now(); - report.renderAsync(() => { - const renderDuration = Date.now() - renderStartTime; - if (this.designer && this.designer.visible) { // Refresh the report if the being in Design mode this.designer.report = report; this.designer.renderHtml("designerContent"); } this.viewer.report = report; this.viewer.renderHtml("viewerContent"); - - // Track report viewing - this.reportViewStartTime = Date.now(); - this.gaSvc.trackReportViewed({ - report_id: this.rid, - report_type: this.reportType, - platform: 'web' - }); - - // Track report generation performance - this.gaSvc.trackReportRendered({ - report_type: this.reportType || 'unknown', - report_id: this.rid, - render_duration_ms: renderDuration, - platform: 'web' - }); }); } ngOnDestroy() { - // Track report view duration if available - if (this.reportViewStartTime) { - const viewDuration = Math.round((Date.now() - this.reportViewStartTime) / 1000); - this.gaSvc.trackReportViewDuration({ - report_type: this.reportType || 'unknown', - report_id: this.rid, - view_duration_seconds: viewDuration, - platform: 'web' - }); - } - } - /** - * Detect report type from report ID or path for better analytics - */ - private detectReportType(rid: string, path: string): 'job_summary' | 'financial_analysis' | 'field_report' | 'performance_dashboard' { - // Map report IDs or paths to report types - const reportTypeMap: { [key: string]: 'job_summary' | 'financial_analysis' | 'field_report' | 'performance_dashboard' } = { - 'job': 'job_summary', - 'financial': 'financial_analysis', - 'field': 'field_report', - 'performance': 'performance_dashboard', - 'summary': 'job_summary', - 'analysis': 'financial_analysis', - 'report': 'field_report', - 'dashboard': 'performance_dashboard' - }; - - // Check rid first - if (rid) { - const ridLower = rid.toLowerCase(); - for (const [key, value] of Object.entries(reportTypeMap)) { - if (ridLower.includes(key)) { - return value; - } - } - } - - // Check path - if (path) { - const pathLower = path.toLowerCase(); - for (const [key, value] of Object.entries(reportTypeMap)) { - if (pathLower.includes(key)) { - return value; - } - } - } - - // Default to job_summary if unable to determine - return 'job_summary'; } } diff --git a/Development/client/src/app/settings/settings-routing.module.ts b/Development/client/src/app/settings/settings-routing.module.ts deleted file mode 100644 index bf57b2e..0000000 --- a/Development/client/src/app/settings/settings-routing.module.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { NgModule } from '@angular/core'; -import { Routes, RouterModule } from '@angular/router'; - -import { AuthGuard } from '../domain/guards/auth.guard'; -import { RoleIds } from '../shared/global'; -import { SubscriptionMgtComponent } from './subscription/subscription-mgt.component'; - -const routes: Routes = [ - { - path: '', - redirectTo: 'subscription', - pathMatch: 'full' - }, - { - path: 'subscription', - component: SubscriptionMgtComponent, - data: { - roles: [RoleIds.ADMIN] - }, - canActivate: [AuthGuard] - } -]; - -@NgModule({ - imports: [RouterModule.forChild(routes)], - exports: [RouterModule] -}) -export class SettingsRoutingModule { } diff --git a/Development/client/src/app/settings/settings.module.ts b/Development/client/src/app/settings/settings.module.ts deleted file mode 100644 index 8ef7281..0000000 --- a/Development/client/src/app/settings/settings.module.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { HttpClientModule } from '@angular/common/http'; - -import { SettingsRoutingModule } from './settings-routing.module'; -import { SubscriptionMgtComponent } from './subscription/subscription-mgt.component'; -import { AppSharedModule } from '@app/shared/app-shared.module'; - -// PrimeNG Modules -import { AccordionModule } from 'primeng/accordion'; -import { ButtonModule } from 'primeng/button'; -import { DropdownModule } from 'primeng/dropdown'; -import { CalendarModule } from 'primeng/calendar'; -import { InputTextModule } from 'primeng/inputtext'; -import { InputNumberModule } from 'primeng/inputnumber'; -import { PanelModule } from 'primeng/panel'; -import { TableModule } from 'primeng/table'; -import { DialogModule } from 'primeng/dialog'; -import { TooltipModule } from 'primeng/tooltip'; -import { MessageModule } from 'primeng/message'; -import { MessagesModule } from 'primeng/messages'; -import { ProgressSpinnerModule } from 'primeng/progressspinner'; - -@NgModule({ - declarations: [ - SubscriptionMgtComponent - ], - imports: [ - CommonModule, - FormsModule, - ReactiveFormsModule, - HttpClientModule, - SettingsRoutingModule, - AppSharedModule, - // PrimeNG - AccordionModule, - ButtonModule, - DropdownModule, - CalendarModule, - InputTextModule, - InputNumberModule, - PanelModule, - TableModule, - DialogModule, - TooltipModule, - MessageModule, - MessagesModule, - ProgressSpinnerModule - ], - schemas: [CUSTOM_ELEMENTS_SCHEMA] -}) -export class SettingsModule { } diff --git a/Development/client/src/app/settings/subscription/promo.service.ts b/Development/client/src/app/settings/subscription/promo.service.ts deleted file mode 100644 index 915a51a..0000000 --- a/Development/client/src/app/settings/subscription/promo.service.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { Injectable } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; -import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; - -// ============================================================================ -// INTERFACES -// ============================================================================ - -/** - * Represents a subscription promo from the backend API - */ -export interface Promo { - _id: string; - type: 'package' | 'addon' | 'all'; - priceKey: string; // e.g., 'ess_1', 'addon_1', 'all' - enabled: boolean; - validUntil: string; // ISO date - couponId: string; // Stripe coupon ID - name: string; // Display name - nameKey?: string; // i18n key (auto-generated) - descriptionKey?: string; // i18n key (auto-generated) - discountType: 'free' | 'percent' | 'fixed'; - discountValue: number; // 100 for free, 50 for 50% - usageCount: number; // Number of subscriptions using this promo - createdAt: string; // ISO date - /** Who is eligible to redeem this promo. 'all' = everyone, 'new_only' = new customers only, 'renew_only' = existing customers renewing only */ - eligibility?: 'all' | 'new_only' | 'renew_only'; -} - -/** - * Response structure from /api/admin/subscriptionPromos (r949+) - * Backend returns both promos array and current PROMO_MODE info - */ -export interface AdminPromoResponse { - promos: Promo[]; - currentMode: { - mode: 'enabled' | 'disabled'; - isActive: boolean; - description: string; - behavior: { - newSubscriptions: boolean; - renewals: boolean; - activeAutoRenewal: boolean; - }; - }; -} - -/** - * Request payload for creating a new promo - */ -export interface CreatePromoRequest { - type: 'package' | 'addon' | 'all'; - priceKey: string; - validUntil: string; - couponId: string; - discountType: 'free' | 'percent' | 'fixed'; - discountValue: number; - name: string; - enabled?: boolean; - /** Who is eligible to redeem this promo. Default: 'all' */ - eligibility?: 'all' | 'new_only' | 'renew_only'; -} - -/** - * Response from DELETE promo endpoint - */ -export interface DeletePromoResponse { - action: 'deleted' | 'disabled'; - promo: Promo; - schedulesUpdated?: number; - schedulesFailed?: number; -} - -/** - * Request payload for updating a promo - */ -export interface UpdatePromoRequest { - validUntil?: string; - name?: string; -} - -/** - * Response from PUT promo endpoint - */ -export interface UpdatePromoResponse { - action: 'updated'; - promo: Partial; - schedulesUpdated?: number; - schedulesFailed?: number; -} - -/** - * Represents a Stripe coupon option for dropdown - * Matches backend response from /api/admin/subscriptionPromos/coupons - */ -export interface StripeCoupon { - id: string; - name: string; - percent_off?: number; // Percentage discount (e.g., 50 for 50% off) - amount_off?: number; // Fixed amount discount in cents - currency?: string; // Currency for amount_off - duration: string; // 'forever', 'once', 'repeating' - duration_in_months?: number; // Number of months for repeating coupons - valid: boolean; // Whether coupon is valid - created: number; // Unix timestamp -} - -// ============================================================================ -// SERVICE DEFINITION -// ============================================================================ - -// Valid enum values (must match backend Mongoose schema) -// Note: Empty string '' is also valid for universal promos (backend requirement) -const VALID_TYPES = ['package', 'addon', ''] as const; -const VALID_DISCOUNT_TYPES = ['free', 'percent', 'fixed'] as const; - -@Injectable({ - providedIn: 'root' -}) -export class PromoService { - - private readonly baseUrl = '/admin/subscriptionPromos'; - - constructor(private readonly http: HttpClient) { } - - // ============================================================================ - // VALIDATION HELPERS - // ============================================================================ - - /** - * Validates and sanitizes promo request before sending to backend. - * Note: Empty string '' is valid for universal promos (backend requirement) - */ - private validateAndSanitizeRequest(request: CreatePromoRequest): CreatePromoRequest { - const errors: string[] = []; - - // Validate type - allow empty string for universal promos - // Type widening: Use string[] to properly include empty string in .includes() check - const validTypes: string[] = ['package', 'addon', '']; - if (!validTypes.includes(request.type)) { - errors.push(`Invalid type "${request.type}". Must be: package, addon, (empty string for universal)`); - } - - // Validate discountType - default to 'free' if invalid/missing - if (!request.discountType || !VALID_DISCOUNT_TYPES.includes(request.discountType as any)) { - console.warn(`PromoService: Invalid discountType "${request.discountType}", defaulting to "free"`); - request = { ...request, discountType: 'free' }; - } - - // Validate discountValue - default to 100 if invalid/missing - if (request.discountValue === undefined || request.discountValue === null || isNaN(request.discountValue)) { - console.warn(`PromoService: Invalid discountValue "${request.discountValue}", defaulting to 100`); - request = { ...request, discountValue: 100 }; - } - - // Throw error for critical validation failures - if (errors.length > 0) { - throw new Error(`Validation failed: ${errors.join('; ')}`); - } - - return request; - } - - // ============================================================================ - // API METHODS - // ============================================================================ - - /** - * GET /api/admin/subscriptionPromos - * Returns all promo rules with full details - * - * @since r949 - Backend returns {promos, currentMode} object - */ - getPromos(): Observable { - return this.http.get(this.baseUrl).pipe( - map(response => response.promos) - ); - } - - /** - * GET /api/admin/subscriptionPromos - Returns current PROMO_MODE status - * - * @returns Observable of current mode information - * @since r949 - * - * @example - * ```typescript - * this.promoService.getCurrentMode().subscribe(mode => { - * console.log(`Current mode: ${mode.mode}`); - * console.log(`Active: ${mode.isActive}`); - * console.log(`Description: ${mode.description}`); - * }); - * ``` - */ - getCurrentMode(): Observable { - return this.http.get(this.baseUrl).pipe( - map(response => response.currentMode) - ); - } - - /** - * POST /api/admin/subscriptionPromos/add - * Add a single promo rule - * - * NOTE: Includes frontend validation to protect against backend bug - * that accepts invalid enum values for discountType/type fields. - * - * @since r949 - Backend returns {promos, currentMode} object - */ - addPromo(request: CreatePromoRequest): Observable { - // Validate and sanitize before sending to backend - const sanitizedRequest = this.validateAndSanitizeRequest(request); - return this.http.post(`${this.baseUrl}/add`, sanitizedRequest).pipe( - map(response => response.promos) - ); - } - - /** - * PUT /api/admin/subscriptionPromos/:id - * Update a promo rule (only validUntil is editable) - * Backend returns { action, promo, schedulesUpdated?, schedulesFailed? } - */ - updatePromo(id: string, request: UpdatePromoRequest): Observable> { - return this.http.put(`${this.baseUrl}/${id}`, request).pipe( - map(response => response.promo) - ); - } - - /** - * DELETE /api/admin/subscriptionPromos/:id - * Delete or disable a promo rule - * - If usageCount === 0: Permanently deletes - * - If usageCount > 0: Requires validUntil, disables instead - */ - deletePromo(id: string, validUntil?: string): Observable { - const url = `${this.baseUrl}/${id}`; - if (validUntil) { - // Use request method to send body with DELETE - return this.http.request('DELETE', url, { body: { validUntil } }); - } - return this.http.delete(url); - } - - /** - * PUT /api/admin/subscriptionPromos/:id — re-activates a promo. - * Sends enabled=true AND a new validUntil so isActive() passes both checks. - * Response shape: { action, promo: { _id, name, nameKey, validUntil, enabled, usageCount } } - */ - activatePromo(id: string, validUntil: Date): Observable { - return this.http.put<{ promo: Promo }>(`${this.baseUrl}/${id}`, { - enabled: true, - validUntil: validUntil.toISOString() - }).pipe(map(res => res.promo)); - } - - /** - * GET /admin/subscriptionPromos/coupons - * Fetch available Stripe coupons with 'forever' duration for dropdown - * Backend enforces 'forever' duration only (added in r934) - */ - getAvailableCoupons(): Observable { - return this.http.get(`${this.baseUrl}/coupons`); - } -} diff --git a/Development/client/src/app/settings/subscription/subscription-mgt.component.css b/Development/client/src/app/settings/subscription/subscription-mgt.component.css deleted file mode 100644 index 0b9e91c..0000000 --- a/Development/client/src/app/settings/subscription/subscription-mgt.component.css +++ /dev/null @@ -1,694 +0,0 @@ -/* ============================================================================ - SUBSCRIPTION PROMO MANAGEMENT COMPONENT STYLES - Following AgMission Style Guide (constraint-message.component.css reference) - ============================================================================ */ - -/* Container */ -.subscription-mgt-container { - padding: 16px 24px; - max-width: 1200px; - margin: 0 auto; -} - -/* ============================================================================ - PAGE HEADER - ============================================================================ */ - -.page-header { - margin-bottom: 24px; -} - -.page-header h2 { - color: #212121; - font-family: "Roboto", "Helvetica Neue", sans-serif; - font-size: 1.5rem; - font-weight: 500; - margin: 0 0 8px 0; - letter-spacing: 0.25px; -} - -.page-description { - color: #757575; - font-family: "Roboto", "Helvetica Neue", sans-serif; - font-size: 0.875rem; - margin: 0; - line-height: 1.5; -} - -/* ============================================================================ - PANEL STYLES (SHARED) - ============================================================================ */ - -/* Panel margins, form control widths, table font, dialog sizes, and calendar widths - are all applied via PrimeNG styleClass to elements inside PrimeNG templates. - They cannot be reached from scoped component CSS — see styles.scss for these rules. */ - - -.panel-header { - display: flex; - align-items: center; - gap: 10px; - font-family: "Roboto", "Helvetica Neue", sans-serif; - font-weight: 500; - color: #212121; -} - -.panel-header i { - font-size: 1.125rem; - color: #4CAF50; -} - -.promo-count { - color: #757575; - font-weight: 400; - font-size: 0.875rem; - margin-left: 4px; -} - -/* ============================================================================ - CREATE FORM - TOP PANEL - ============================================================================ */ - -.create-form { - padding: 16px; -} - -.form-row { - display: flex; - flex-wrap: wrap; - gap: 16px; - margin-bottom: 16px; -} - -.form-row:last-child { - margin-bottom: 0; -} - -.form-field { - flex: 1; - min-width: 150px; - display: flex; - flex-direction: column; - gap: 6px; - position: relative; - padding-bottom: 18px; - /* Reserve space for error message */ -} - -.form-field label { - font-family: "Roboto", "Helvetica Neue", sans-serif; - font-size: 0.75rem; - font-weight: 500; - color: #757575; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.form-field-wide { - flex: 2; - min-width: 250px; -} - -/* Group wrapper — always breaks to its own row so Type/Package/Coupon stay on row 1 - and Promo Name + Valid Until + Add button occupy row 2 */ -.form-field-group { - display: flex; - gap: 16px; - align-items: flex-end; - flex: 0 0 100%; -} - -.form-field-group .form-field { - flex: 1; - min-width: 120px; -} - -.form-field-group .form-field-button { - flex: 0 0 auto; - padding-top: 0; -} - -.form-field-button { - flex: 0 0 auto; - min-width: auto; - justify-content: flex-end; - padding-top: 22px; -} - -/* Promo name text input in create form row */ -.promo-name-input { - width: 100%; - box-sizing: border-box; -} - -/* Promo name text input in edit row */ -.edit-promo-name-input { - width: 200px; - box-sizing: border-box; - font-size: 0.8125rem; -} - -/* PrimeNG p-dropdown and p-calendar internals are in styles.scss under body .form-dropdown / body .form-calendar */ - -.field-error { - color: #F44336; - font-size: 0.75rem; - position: absolute; - bottom: 0; - left: 0; -} - -.field-help { - color: #757575; - font-size: 0.75rem; - position: absolute; - bottom: 0; - left: 0; -} - -/* Add button - match standard PrimeNG button sizing */ -.add-btn { - min-width: 100px; -} - -/* ============================================================================ - PREVIEW AREA - ============================================================================ */ - -.preview-area { - margin-top: 16px; -} - -/* Card — matches compact-vertical-card from manage-subscription */ -.preview-card { - background: linear-gradient(135deg, #f0f9f0 0%, #ffffff 100%); - border: 2px solid #4CAF50; - border-radius: 6px; - padding: 16px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); -} - -/* Header row: promo name left, Preview pill right */ -.preview-cv-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 10px; -} - -.preview-cv-package-info { - display: flex; - align-items: center; - /* gap: 8px; */ -} - -.preview-cv-icon { - font-size: 1rem; - color: #4CAF50; - flex-shrink: 0; -} - -.preview-cv-name { - font-size: 1rem; - font-weight: 600; - color: #212121; - letter-spacing: 0.15px; -} - -/* Muted pill badge matching AgMission badge style */ -.preview-pill { - display: inline-flex; - align-items: center; - padding: 2px 10px; - border-radius: 12px; - font-size: 0.6875rem; - font-weight: 600; - font-family: "Roboto", "Helvetica Neue", sans-serif; - text-transform: uppercase; - letter-spacing: 0.5px; - background: #A5D6A7; - color: #1B5E20; - border: 1px solid #4CAF50; - white-space: nowrap; - flex-shrink: 0; -} - -/* Divider — identical to cv-divider in manage-subscription */ -.preview-cv-divider { - height: 1px; - background: #E0E0E0; - margin: 0 0 10px 0; -} - -/* Section rows — identical to cv-row / cv-label / cv-value pattern */ -.preview-cv-section { - display: flex; - flex-direction: column; - gap: 6px; - padding-left: 1em; -} - -.preview-cv-row { - display: flex; - justify-content: space-between; - align-items: baseline; - font-size: 0.875rem; - line-height: 1.4; -} - -.preview-cv-label { - color: #757575; - font-weight: 500; - flex-shrink: 0; - margin-right: 12px; - font-family: "Roboto", "Helvetica Neue", sans-serif; -} - -.preview-cv-value { - color: #212121; - font-weight: 400; - text-align: right; - font-family: "Roboto", "Helvetica Neue", sans-serif; -} - -.preview-cv-value.coupon-mono { - font-family: "Roboto Mono", monospace; - font-size: 0.8125rem; - color: #757575; -} - -/* ============================================================================ - LOADING & EMPTY STATES - BOTTOM PANEL - ============================================================================ */ - -.loading-container { - display: flex; - align-items: center; - justify-content: center; - gap: 12px; - padding: 48px; - color: #757575; - font-family: "Roboto", "Helvetica Neue", sans-serif; -} - -.empty-state { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 12px; - padding: 48px; - color: #757575; - font-family: "Roboto", "Helvetica Neue", sans-serif; -} - -.empty-state i { - font-size: 3rem; - color: #bdbdbd; -} - -/* ============================================================================ - PROMOS TABLE - ============================================================================ */ - -/* p-table outer wrapper — see styles.scss */ -/* PrimeNG p-table internals (th, td, hover) are in styles.scss under body .promos-table */ - -/* Inactive row styling */ -.inactive-row td { - color: #9e9e9e; - background: #fafafa; - opacity: 0.7; -} - -/* Activate-badge button: replaces the static inactive badge. - Matches agm-badge pill styling exactly; hover reveals amber tint to signal it is clickable. */ -.activate-badge-btn { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 3px 8px; - border-radius: 12px; - border: 1px solid #4CAF50; - background: #A5D6A7; - color: #ffffff; - font-size: 11px; - font-weight: 600; - font-family: "Roboto", "Helvetica Neue", sans-serif; - text-transform: uppercase; - letter-spacing: 0.5px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); - cursor: pointer; - transition: all 0.2s ease-in-out; - white-space: nowrap; - line-height: 1.4; -} - -.activate-badge-btn:hover:not(:disabled) { - background: #FFC107; - border-color: #FF8F00; - color: #212121; - box-shadow: 0 2px 6px rgba(255, 193, 7, 0.35); - transform: translateY(-1px); -} - -.activate-badge-btn:disabled { - cursor: not-allowed; - opacity: 0.7; -} - -.activate-badge-btn .pi { - font-size: 9px; -} - -/* Coupon cell */ -.coupon-cell { - vertical-align: top; -} - -.coupon-cell-content { - display: flex; - flex-direction: column; - gap: 2px; -} - -.coupon-name-primary { - font-size: 0.875rem; - color: #212121; - font-weight: 500; - line-height: 1.3; -} - -/* Name column two-line layout */ -.name-cell { - display: flex; - flex-direction: column; - gap: 2px; -} - -.name-primary { - font-size: 0.875rem; - color: #212121; - font-weight: 500; - line-height: 1.3; -} - -/* i18n indicator icon (shows when promo has translation key) */ -.i18n-indicator { - font-size: 0.75rem; - color: #03A9F4; - margin-left: 6px; - opacity: 0.7; - cursor: help; -} - -.i18n-indicator:hover { - opacity: 1; -} - -/* Usage cell */ -.usage-cell { - text-align: center; - font-weight: 500; -} - -/* Tools cell */ -.tools-cell { - white-space: nowrap; -} - -.tools-buttons { - display: inline-flex; - gap: 4px; -} - -.button-ttip { - display: inline-block; -} - -/* ============================================================================ - EDIT ROW (EXPANDED) - ============================================================================ */ - -.edit-row td { - background: #fff; - padding: 0 !important; -} - -.edit-panel { - padding: 12px 16px; - border-top: 2px solid #FFC107; - border-radius: 0 0 6px 6px; - display: flex; - flex-direction: column; -} - -/* Header: mirrors cv-header */ -.edit-cv-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 8px; -} - -.edit-cv-package-info { - display: flex; - align-items: center; - /* gap: 8px; */ -} - -.edit-cv-icon { - font-size: 1rem; - color: #FF8F00; -} - -/* Editing pill: amber tone vs green Preview pill */ -.edit-pill { - font-size: 0.6875rem; - font-weight: 600; - padding: 2px 8px; - border-radius: 10px; - background: #FFF3E0; - color: #E65100; - border: 1px solid #FFC107; - letter-spacing: 0.5px; - text-transform: uppercase; - white-space: nowrap; -} - -/* Divider: mirrors cv-divider */ -.edit-cv-divider { - height: 1px; - background: #E0E0E0; - margin: 8px 0; -} - -/* Context section: mirrors cv-section */ -.edit-cv-section { - display: flex; - flex-direction: column; - gap: 6px; - padding-left: 1em; -} - -/* Context rows: mirrors cv-row */ -.edit-cv-row { - display: flex; - justify-content: space-between; - align-items: baseline; - font-size: 14px; - line-height: 1.4; -} - -/* Label: mirrors cv-label */ -.edit-cv-label { - color: #757575; - font-weight: 500; - flex-shrink: 0; - margin-right: 12px; -} - -/* Value: mirrors cv-value */ -.edit-cv-value { - color: #212121; - font-weight: 400; - text-align: right; -} - -.edit-promo-name { - font-family: "Roboto", "Helvetica Neue", sans-serif; - font-size: 1rem; - font-weight: 500; - color: #E65100; - letter-spacing: 0.25px; -} - -/* Edit header: two-line promo name block (mirrors preview heading) */ -.edit-header-name-block { - display: flex; - flex-direction: column; - gap: 2px; -} - -.edit-header-name-primary { - font-size: 1rem; - font-weight: 600; - color: #212121; - line-height: 1.3; -} - -.edit-content { - display: flex; - align-items: flex-end; - gap: 32px; - flex-wrap: wrap; - justify-content: flex-end; -} - -.edit-field { - display: flex; - flex-direction: column; - gap: 6px; -} - -.edit-field label { - font-family: "Roboto", "Helvetica Neue", sans-serif; - font-size: 0.75rem; - font-weight: 500; - color: #757575; -} - -/* p-calendar outer wrapper in edit row — see styles.scss */ -/* PrimeNG p-calendar internals are in styles.scss under body .edit-calendar */ - -.edit-actions { - display: flex; - gap: 8px; - /* Removed margin-left: auto - keep buttons close to calendar */ -} - -/* ============================================================================ - DELETE DIALOG - ============================================================================ */ - -/* p-dialog outer wrapper — see styles.scss */ -/* PrimeNG p-dialog internals are in styles.scss under body .delete-dialog */ - -.dialog-content { - padding: 8px 0; - font-family: "Roboto", "Helvetica Neue", sans-serif; -} - -.dialog-content p { - margin: 0 0 12px 0; - color: #212121; -} - -.dialog-field { - display: flex; - align-items: center; - gap: 12px; - margin-bottom: 16px; -} - -.dialog-field label { - font-weight: 500; - color: #757575; - white-space: nowrap; -} - -/* p-calendar inside dialog — see styles.scss */ -/* agm-constraint-message icon colors inside dialog — in styles.scss under body .delete-dialog */ - -/* ============================================================================ - RESPONSIVE ADJUSTMENTS - ============================================================================ */ - -/* Medium screens - form wraps, group keeps Valid Until + Add together */ -@media (max-width: 1150px) { - .form-field { - min-width: 180px; - } - - .form-field-wide { - min-width: 200px; - } - - .form-field-button { - padding-top: 22px; - } -} - -/* Small screens - single column layout */ -@media (max-width: 768px) { - .subscription-mgt-container { - padding: 12px 16px; - } - - .form-row { - flex-direction: column; - } - - .form-field, - .form-field-wide, - .form-field-button, - .form-field-group { - flex: none; - width: 100%; - min-width: unset; - } - - .form-field-group { - flex-direction: column; - gap: 12px; - } - - .form-field-group .form-field { - width: 100%; - } - - .form-field-button { - padding-top: 8px; - } - - /* Preview area - mobile optimizations (ultra-compact) */ - .preview-card { - padding: 12px; - } - - .preview-cv-name { - font-size: 0.875rem; - word-break: break-word; - } - - .preview-cv-row { - font-size: 0.8125rem; - } - - .preview-pill { - font-size: 0.625rem; - } - -/* PrimeNG table/calendar/dialog responsive overrides are in styles.scss under body .xxx @media (max-width: 768px) */ - - .tools-buttons { - display: inline-flex; - gap: 8px; - } - - /* Expanded edit row in responsive mode */ - .edit-row { - display: table-row !important; - } - - .edit-row td { - display: block !important; - width: 100% !important; - text-align: left !important; - } - - .edit-row td::before { - display: none !important; - } -} \ No newline at end of file diff --git a/Development/client/src/app/settings/subscription/subscription-mgt.component.html b/Development/client/src/app/settings/subscription/subscription-mgt.component.html deleted file mode 100644 index 0f4ce75..0000000 --- a/Development/client/src/app/settings/subscription/subscription-mgt.component.html +++ /dev/null @@ -1,402 +0,0 @@ -
    -
    -
    - - - - - - -
    - - Create New Promo -
    -
    - -
    - -
    -
    - - - -
    - -
    - - - - Required -
    - -
    - - - - Required -
    - -
    - - - -
    - - -
    -
    - - - Required -
    - -
    - - - - - Required ONLY for 'Forever' or 'Once' coupon - - Required -
    - -
    - -
    -
    -
    - - -
    -
    - -
    -
    - - {{ createForm.get(F.promoName)?.value }} -
    - Preview -
    - -
    - - -
    -
    - Type - {{ createForm.get(F.subType)?.value | titlecase }} -
    -
    - Applies to - {{ previewApplyTo }} -
    -
    - Coupon - {{ previewCouponLabel }} -
    -
    - Discount - {{ previewDiscountValue }} -
    -
    - {{ previewExpiryLabel }} - {{ previewExpiryValue }} -
    -
    -
    -
    -
    -
    - - - - -
    - - Existing Promos - ({{ promos.length }}) -
    -
    - - -
    - - Loading promos... -
    - - -
    - - No promos found. Create one above. -
    - - - - - - Type - Name - Coupon - Valid Until - Usage - Status - Tools - - - - - - - - Type - {{ formatSubType(promo?.type) }} - - - Name -
    - {{ promo.name || getPromoDisplayName(promo) }} -
    - - - - Coupon -
    - {{ getCouponName(promo.couponId) }} -
    - - - - Valid Until - {{ formatDate(promo.validUntil) }} - - - Usage - {{ promo.usageCount }} - - - Status - - - - - - - Tools -
    - -
    - -
    - -
    - -
    -
    - -
    -
    - - - - - - -
    -
    - - Reactivate: -  {{ getPromoDisplayName(promo) }} -
    -
    - - -
    - - - -
    -
    - - -
    -
    -
    - - - - - - -
    - - -
    -
    - -
    - {{ promo.name || getPromoDisplayName(promo) }} -
    -
    - Editing -
    - -
    - - -
    -
    - Type - {{ formatSubType(promo.type) }} -
    -
    - Applies to - {{ getPromoDisplayName(promo) }} -
    -
    - -
    - - -
    -
    - Coupon - {{ getCouponName(promo.couponId) }} -
    -
    - Discount - {{ getCouponDiscountSummary(promo.couponId) }} -
    -
    - - -
    -
    - Eligibility - {{ getEligibilityLabel(promo.eligibility) }} -
    -
    - -
    - - -
    -
    - - -
    -
    - - - -
    -
    - - -
    -
    -
    - - -
    -
    -
    - - - - -
    - - - - -

    To disable this promo, set an expiry date:

    -
    - - - -
    - - -
    - - - - - -

    It will be permanently deleted.

    -
    -
    - - - - - -
    -
    -
    -
    \ No newline at end of file diff --git a/Development/client/src/app/settings/subscription/subscription-mgt.component.ts b/Development/client/src/app/settings/subscription/subscription-mgt.component.ts deleted file mode 100644 index c029a1e..0000000 --- a/Development/client/src/app/settings/subscription/subscription-mgt.component.ts +++ /dev/null @@ -1,1140 +0,0 @@ -import { Component, OnInit, OnDestroy, ViewChild, ElementRef } from '@angular/core'; -import { FormBuilder, FormGroup, Validators } from '@angular/forms'; -import { Subject } from 'rxjs'; -import { takeUntil } from 'rxjs/operators'; -import { SelectItem } from 'primeng/api'; -import { Labels, locales } from '@app/shared/global'; -import { subPlans, SERVICE_TYPE, PromoErrors } from '@app/profile/common'; -import { PromoService, Promo, CreatePromoRequest, StripeCoupon } from './promo.service'; -import { BadgeConfig, BadgeType } from '@app/shared/badge/badge-config.model'; -import { BadgeFactoryService } from '@app/shared/services/badge-factory.service'; -import { AppMessageService } from '@app/shared/app-message.service'; -import { AppConfigService } from '@app/domain/services/app-config.service'; - -// ============================================================================ -// FORM FIELD NAME CONSTANTS -// ============================================================================ - -/** Typed constants for all create-promo form control names — eliminates typo bugs */ -export const PromoFormFields = { - subType: 'subType', - priceKey: 'priceKey', - validUntil: 'validUntil', - couponId: 'couponId', - promoName: 'promoName', - eligibility: 'eligibility', -} as const; - -/** Short alias for use within this file */ -const F = PromoFormFields; - -// ============================================================================ -// INTERFACES -// ============================================================================ - -/** - * Represents a promo row in the table with UI state - */ -interface PromoRow extends Promo { - isExpanded: boolean; - editValidUntil: Date | null; - editName: string; - isActivateExpanded: boolean; - activateValidUntil: Date | null; - couponData?: StripeCoupon; // Full coupon data for edit eligibility check -} - -// ============================================================================ -// COMPONENT DEFINITION -// ============================================================================ - -@Component({ - selector: 'agm-subscription-mgt', - templateUrl: './subscription-mgt.component.html', - styleUrls: ['./subscription-mgt.component.css'] -}) -export class SubscriptionMgtComponent implements OnInit, OnDestroy { - - // ============================================================================ - // CORE PROPERTIES - // ============================================================================ - - /** Reference to Labels for template access */ - readonly Labels = Labels; - - /** Form control name constants — exposed for template bindings */ - readonly F = PromoFormFields; - - /** Loading state for promos table */ - loading = true; - - /** Loading state for form submission */ - submitting = false; - - /** Destroy subject for cleanup */ - private destroy$ = new Subject(); - - // ============================================================================ - // FORM PROPERTIES - TOP PANEL (CREATE PROMO) - // ============================================================================ - - /** Reactive form for promo creation */ - createForm: FormGroup; - - /** Sub Type dropdown options */ - subTypeOptions: SelectItem[] = [ - { label: $localize`:@@typeAll:All`, value: 'all' }, - { label: $localize`:@@typePackage:Package`, value: 'package' }, - { label: $localize`:@@typeAddon:Addon`, value: 'addon' } - ]; - - /** Eligibility dropdown options */ - eligibilityOptions: SelectItem[] = [ - { label: $localize`:@@eligibilityAll:All Customers`, value: 'all' }, - { label: $localize`:@@eligibilityNew:New Customers Only`, value: 'new_only' }, - { label: $localize`:@@eligibilityExisting:Renewing Customers Only`, value: 'renew_only' } - ]; - - /** Package/Addon dropdown options (dynamic based on subType) */ - priceKeyOptions: SelectItem[] = []; - - /** Coupon dropdown options */ - couponOptions: SelectItem[] = []; - - /** Full coupon objects from Stripe (for discount value lookup) */ - availableCoupons: StripeCoupon[] = []; - - /** Currently selected coupon (for duration checking) */ - selectedCoupon: StripeCoupon | null = null; - - /** Reference to the promoName input for auto-focus after coupon selection */ - @ViewChild('promoNameInput') promoNameInputRef: ElementRef; - - /** Minimum date for validUntil. Set in ngOnInit from promoMinExpiryDays app setting. */ - minDate: Date; - - /** Date format from global locales (for p-calendar) */ - readonly dateFormat: string = locales.en.dateFormat; - - /** Year range for calendar year navigator (current year to +10 years) */ - readonly yearRange: string = `${new Date().getFullYear()}:${new Date().getFullYear() + 10}`; - - // ============================================================================ - // TABLE PROPERTIES - BOTTOM PANEL (EXISTING PROMOS) - // ============================================================================ - - /** Promo data for table */ - promos: PromoRow[] = []; - - /** Currently expanded row ID for editing */ - expandedRowId: string | null = null; - - // ============================================================================ - // DELETE DIALOG PROPERTIES - // ============================================================================ - - /** Whether delete dialog is visible */ - showDeleteDialog = false; - - /** Promo being deleted */ - deletePromo: PromoRow | null = null; - - /** Valid Until date for delete (when promo has usage) */ - deleteValidUntil: Date | null = null; - - /** Minimum date for delete validUntil (3 days from now) */ - deleteMinDate: Date; - - // ============================================================================ - // ACTIVATE PROMO PROPERTIES - // ============================================================================ - - /** Tracks which promo IDs are currently being activated (prevents double-clicks) */ - activatingPromoIds = new Set(); - - // ============================================================================ - // CONSTRUCTOR - // ============================================================================ - - constructor( - private fb: FormBuilder, - private promoService: PromoService, - private badgeFactory: BadgeFactoryService, - private msgSvc: AppMessageService, - private appConfSvc: AppConfigService - ) { } - - // ============================================================================ - // LIFECYCLE METHODS - // ============================================================================ - - ngOnInit(): void { - // Apply PROMO_MIN_EXPIRY_DAYS grace period to both calendar minDates. - // SettingsGuard ensures AppConfigService.settings is loaded before this route activates. - const graceDays = this.appConfSvc.settings?.promoMinExpiryDays ?? 0; - - // minDate — create promo calendar - const d = new Date(); - if (graceDays > 0) { - d.setDate(d.getDate() + graceDays); - } - this.minDate = d; - - // deleteMinDate — disable dialog calendar (must stay in sync with backend PROMO_MIN_EXPIRY_DAYS) - const deleteDays = graceDays > 0 ? graceDays : 3; - this.deleteMinDate = new Date(); - this.deleteMinDate.setDate(this.deleteMinDate.getDate() + deleteDays); - - this.initForm(); - this.loadPromos(); - this.loadCoupons(); - } - - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - } - - // ============================================================================ - // FORM INITIALIZATION - // ============================================================================ - - /** - * Initializes the create promo form with validators - */ - private initForm(): void { - this.createForm = this.fb.group({ - [F.subType]: ['package', Validators.required], - [F.priceKey]: [null, Validators.required], - [F.validUntil]: [null, Validators.required], // Required by default, cleared for repeating in onCouponSelected - [F.couponId]: [null, Validators.required], - [F.promoName]: ['', Validators.required], - [F.eligibility]: ['all', Validators.required] - }); - - // Update priceKey options when subType changes - this.createForm.get(F.subType)?.valueChanges - .pipe(takeUntil(this.destroy$)) - .subscribe(subType => { - this.updatePriceKeyOptions(subType); - - // Reset priceKey: 'all' for universal type, null otherwise - const defaultPriceKey = subType === 'all' ? 'all' : null; - const priceKeyControl = this.createForm.get(F.priceKey); - if (priceKeyControl) { - priceKeyControl.setValue(defaultPriceKey); - priceKeyControl.markAsPristine(); - // Mark touched only when null so "Required" shows immediately after type selection - if (defaultPriceKey === null) { - priceKeyControl.markAsTouched(); - } else { - priceKeyControl.markAsUntouched(); - } - priceKeyControl.updateValueAndValidity(); - } - - // Reset coupon + promoName so the new type starts fresh. - // Setting couponId to null triggers the couponId valueChanges subscription, - // which calls onCouponSelected(null) and correctly resets selectedCoupon - // and the validUntil required/optional validator. - const couponControl = this.createForm.get(F.couponId); - couponControl?.setValue(null); - couponControl?.markAsUntouched(); - couponControl?.markAsPristine(); - - const promoNameControl = this.createForm.get(F.promoName); - promoNameControl?.setValue(''); - promoNameControl?.markAsUntouched(); - promoNameControl?.markAsPristine(); - - }); - - // Watch for coupon selection changes to show/hide Valid Until field - this.createForm.get(F.couponId)?.valueChanges - .pipe(takeUntil(this.destroy$)) - .subscribe(couponId => { - this.onCouponSelected(couponId); - }); - - // Initialize with package options - this.updatePriceKeyOptions('package'); - // priceKey starts null with type=Package — mark touched so "Required" shows immediately - this.createForm.get(F.priceKey)?.markAsTouched(); - } - - /** - * Updates priceKey dropdown options based on selected subType - * Note: Enterprise (ENT) packages are excluded as they are not available yet - */ - private updatePriceKeyOptions(subType: string): void { - const plans = Object.entries(subPlans); - - // Handle "All" type selection - if (subType === 'all') { - // When Type = "All", show single "All Packages/Addons" option - this.priceKeyOptions = [ - { label: $localize`:@@allPackagesAddons:All Packages/Addons`, value: 'all' } - ]; - return; - } - - if (subType === 'package') { - this.priceKeyOptions = [ - { label: $localize`:@@allPackages:All Packages`, value: 'all' }, - ...plans - .filter(([_, plan]) => plan.type === SERVICE_TYPE.ESS) - .map(([key, plan]) => ({ - label: plan.name, - value: key.toLowerCase() - })) - ]; - } else { - this.priceKeyOptions = [ - { label: $localize`:@@allAddons:All Addons`, value: 'all' }, - ...plans - .filter(([_, plan]) => plan.type === SERVICE_TYPE.ADDON) - .map(([key, plan]) => ({ - label: plan.name, - value: key.toLowerCase() - })) - ]; - } - } - - // ============================================================================ - // DATA LOADING - // ============================================================================ - - /** - * Loads existing promos from the service - */ - private loadPromos(): void { - this.loading = true; - this.promoService.getPromos() - .pipe(takeUntil(this.destroy$)) - .subscribe({ - next: (promos) => { - this.promos = promos?.map(p => { - const transformedPromo = this.transformPromoFromBackend(p); - // Enrich with coupon data for edit eligibility check - const couponData = this.availableCoupons.find(c => c.id === p.couponId); - return { - ...transformedPromo, - isExpanded: false, - editValidUntil: null, - editName: '', - isActivateExpanded: false, - activateValidUntil: null, - couponData - }; - }) ?? []; - this.loading = false; - }, - error: (err) => { - console.error('Error loading promos:', err); - this.msgSvc.addFailedMsg($localize`:@@errorLoadingPromos:Failed to load promos. Please refresh the page.`); - this.promos = []; - this.loading = false; - } - }); - } - - /** - * Loads available coupons for dropdown - * Stores full coupon objects for discount value lookup during promo creation - */ - private loadCoupons(): void { - this.promoService.getAvailableCoupons() - .pipe(takeUntil(this.destroy$)) - .subscribe({ - next: (coupons) => { - this.availableCoupons = coupons ?? []; - - // Create dropdown options - this.couponOptions = coupons?.map(c => ({ - label: `${c.name}`, - value: c.id - })) ?? []; - - // Enrich existing promos with coupon data (handles race condition) - this.enrichPromosWithCouponData(); - }, - error: (err) => { - console.error('Error loading coupons:', err); - this.msgSvc.addWarnMsg($localize`:@@errorLoadingCoupons:Failed to load coupons. Some features may be unavailable.`); - this.availableCoupons = []; - this.couponOptions = []; - } - }); - } - - /** - * Enriches promos with coupon data for edit eligibility check - * Called after coupons are loaded to handle race condition - */ - private enrichPromosWithCouponData(): void { - this.promos = this.promos.map(promo => ({ - ...promo, - couponData: this.availableCoupons.find(c => c.id === promo.couponId) - })); - } - - // ============================================================================ - // PREVIEW AND COUPON SELECTION HANDLING - // ============================================================================ - - /** - * Handles coupon selection changes - updates Valid Until validation based on duration - */ - onCouponSelected(couponId: string): void { - if (!couponId) { - this.selectedCoupon = null; - // Reset to required validation when no coupon selected - this.createForm.get(F.validUntil)?.setValidators([Validators.required]); - this.createForm.get(F.validUntil)?.setValue(null); - this.createForm.get(F.validUntil)?.updateValueAndValidity(); - return; - } - - // Find selected coupon from available coupons - this.selectedCoupon = this.availableCoupons.find(c => c.id === couponId) || null; - - // Update validators based on coupon duration - const validUntilControl = this.createForm.get(F.validUntil); - if (this.selectedCoupon?.duration === 'repeating') { - // Repeating coupons: validUntil is optional - validUntilControl?.clearValidators(); - validUntilControl?.setValue(null); // Clear the date value - validUntilControl?.setErrors(null); // Clear any existing validation errors - validUntilControl?.markAsUntouched(); // Reset touched state - } else { - // Forever/Once coupons: validUntil is required — mark touched so "Required" shows immediately - validUntilControl?.setValidators([Validators.required]); - validUntilControl?.markAsTouched(); - } - validUntilControl?.updateValueAndValidity(); - - // Auto-focus promoName input and prefill suggested name if currently empty - setTimeout(() => { - if (this.promoNameInputRef?.nativeElement) { - this.promoNameInputRef.nativeElement.focus(); - } - const promoNameCtrl = this.createForm?.get(F.promoName); - if (promoNameCtrl && !promoNameCtrl.value) { - promoNameCtrl.setValue(this.suggestPromoName(this.selectedCoupon)); - } - }, 0); - } - - /** - * Generates a suggested promo name from a coupon's discount type and duration. - * Only used as a prefill default — user can overwrite freely. - * Examples: "$150off-12mo", "50pct-Forever", "Free-1mo" - */ - private suggestPromoName(coupon: StripeCoupon | null): string { - if (!coupon) { return ''; } - - let discountPart: string; - if (coupon.percent_off === 100) { - discountPart = 'Free'; - } else if (coupon.percent_off) { - discountPart = `${coupon.percent_off}pct`; - } else if (coupon.amount_off) { - const dollars = Math.round(coupon.amount_off / 100); - discountPart = `$${dollars}off`; - } else { - discountPart = 'Free'; - } - - let durationPart: string; - if (coupon.duration === 'forever') { - durationPart = 'Forever'; - } else if (coupon.duration === 'once') { - durationPart = '1mo'; - } else if (coupon.duration === 'repeating' && coupon.duration_in_months) { - durationPart = `${coupon.duration_in_months}mo`; - } else { - durationPart = ''; - } - - return durationPart ? `${discountPart}-${durationPart}` : discountPart; - } - - /** - * Checks if Valid Until field is required based on selected coupon duration - * @returns true if validUntil is required (forever/once), false if optional (repeating) - */ - isValidUntilRequired(): boolean { - return this.selectedCoupon?.duration !== 'repeating'; - } - - /** - * Checks if preview should be visible. - * Requires the identity fields (couponId) to be filled. - */ - get previewVisible(): boolean { - const form = this.createForm; - return !!(form.get(F.couponId)?.value); - } - - /** - * Auto-generates preview name from form values (plan name only) - */ - get previewApplyTo(): string { - const form = this.createForm; - const subType = form.get(F.subType)?.value; - const priceKey = form.get(F.priceKey)?.value; - - if (!priceKey && priceKey !== 'all') return '?'; - - // Handle universal promo (type = 'all', priceKey = 'all') - if (subType === 'all' && priceKey === 'all') { - return $localize`:@@allPackagesAddons:All Packages/Addons`; - } - - // Handle type-specific "All" (e.g., type = 'package', priceKey = 'all') - if (priceKey === 'all') { - return subType === 'package' - ? $localize`:@@allPackages:All Packages` - : $localize`:@@allAddons:All Addons`; - } - - // Get specific package name from priceKey - return this.getPlanNameByKey(priceKey); - } - - /** - * Gets coupon name for preview details - */ - get previewCouponLabel(): string { - return this.selectedCoupon?.name || ''; - } - - /** - * Gets dynamic label for expiry section based on coupon type - * Returns "Redeem by" when coupon selected, "Expires" otherwise - */ - get previewExpiryLabel(): string { - return this.selectedCoupon - ? $localize`:@@redeemByLabel:Redeem by` - : $localize`:@@expiresLabel:Expires`; - } - - /** - * Gets redeem-by / expiry date value. Returns '' if no date set (row hidden via *ngIf). - */ - get previewExpiryValue(): string { - const validUntil = this.createForm.get(F.validUntil)?.value; - return validUntil ? this.formatPreviewDate(validUntil) : ''; - } - - /** - * Gets coupon discount summary: amount/percent + duration for repeating coupons. - * Returns '' when no coupon selected (row hidden via *ngIf). - */ - get previewDiscountValue(): string { - if (!this.selectedCoupon) { return ''; } - - // Build discount amount string - let discountStr = ''; - if (this.selectedCoupon.percent_off === 100) { - discountStr = $localize`:@@freeLabel:Free`; - } else if (this.selectedCoupon.percent_off) { - discountStr = `${this.selectedCoupon.percent_off}% off`; - } else if (this.selectedCoupon.amount_off) { - const dollars = (this.selectedCoupon.amount_off / 100).toFixed(0); - discountStr = `$${dollars} off`; - } - - // Append duration for repeating coupons - if (this.selectedCoupon.duration === 'repeating' && this.selectedCoupon.duration_in_months) { - const months = this.selectedCoupon.duration_in_months; - const monthLabel = months === 1 - ? $localize`:@@monthSingular:month` - : $localize`:@@monthsPlural:months`; - discountStr += ` - ${months} ${monthLabel}`; - } - - return discountStr; - } - - /** - * Gets plan display name from priceKey - */ - getPlanNameByKey(priceKey: string): string { - if (!priceKey) return ''; - const lowerKey = priceKey.toLowerCase(); - const plan = subPlans[lowerKey]; - return plan?.name || priceKey; - } - - /** - * Gets coupon display name from couponId - */ - getCouponName(couponId: string): string { - const coupon = this.availableCoupons.find(c => c.id === couponId); - return coupon?.name || couponId; - } - - /** - * Returns discount summary string for a coupon: amount/percent + duration. - * Used in table coupon cell and edit panel context strip. - */ - getCouponDiscountSummary(couponId: string): string { - const coupon = this.availableCoupons.find(c => c.id === couponId); - if (!coupon) { return ''; } - - let discountStr = ''; - if (coupon.percent_off === 100) { - discountStr = $localize`:@@freeLabel:Free`; - } else if (coupon.percent_off) { - discountStr = `${coupon.percent_off}% off`; - } else if (coupon.amount_off) { - const dollars = (coupon.amount_off / 100).toFixed(0); - discountStr = `$${dollars} off`; - } - - if (coupon.duration === 'repeating' && coupon.duration_in_months) { - const months = coupon.duration_in_months; - const monthLabel = months === 1 - ? $localize`:@@monthSingular:month` - : $localize`:@@monthsPlural:months`; - discountStr += ` - ${months} ${monthLabel}`; - } else if (coupon.duration === 'forever' && discountStr) { - discountStr += ` - ` + $localize`:@@foreverLabel:forever`; - } - - return discountStr; - } - - /** - * Formats date for preview display - */ - private formatPreviewDate(date: Date): string { - return date.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); - } - - // ============================================================================ - // FORM SUBMISSION - ADD PROMO - // ============================================================================ - - /** - * Handles Add Promo button click - */ - onAddPromo(): void { - if (this.createForm.invalid) { - // Mark all fields as touched to show validation errors - Object.keys(this.createForm.controls).forEach(key => { - this.createForm.get(key)?.markAsTouched(); - }); - return; - } - - this.submitting = true; - - // Use getRawValue() to get form values - const formValue = this.createForm.getRawValue(); - - // Transform frontend 'all' values to backend empty strings '' - const transformed = this.transformPromoForBackend(formValue); - - // Lookup selected coupon from already-loaded data (no API call needed!) - const selectedCoupon = this.availableCoupons.find(c => c.id === formValue.couponId); - - if (!selectedCoupon) { - this.submitting = false; - this.msgSvc.addFailedMsg($localize`:@@couponNotFound:Selected coupon not found. Please refresh the page.`); - return; - } - - // Extract discount values from Stripe coupon data - let discountType: 'free' | 'percent' | 'fixed'; - let discountValue: number; - - if (selectedCoupon.percent_off !== undefined && selectedCoupon.percent_off !== null) { - // Percentage-based discount - discountType = selectedCoupon.percent_off === 100 ? 'free' : 'percent'; - discountValue = selectedCoupon.percent_off; - } else if (selectedCoupon.amount_off !== undefined && selectedCoupon.amount_off !== null) { - // Fixed amount discount - discountType = 'fixed'; - discountValue = selectedCoupon.amount_off; // in cents - } else { - // Invalid coupon - no discount defined - this.submitting = false; - this.msgSvc.addFailedMsg($localize`:@@invalidCoupon:Invalid coupon: no discount amount defined.`); - return; - } - - // Build request with actual Stripe coupon values - const request: CreatePromoRequest = { - type: transformed.type, - priceKey: transformed.priceKey, - discountType: discountType, // ✅ From Stripe coupon - discountValue: discountValue, // ✅ From Stripe coupon - validUntil: formValue.validUntil - ? formValue.validUntil.toISOString() - : new Date(Date.now() + 100 * 365 * 24 * 60 * 60 * 1000).toISOString(), // 100 years far-future for optional/repeating - couponId: formValue.couponId, - name: (formValue.promoName as string)?.trim() || this.previewApplyTo, - enabled: true, // New promos are enabled by default - eligibility: formValue.eligibility || 'all' - }; - - this.promoService.addPromo(request) - .pipe(takeUntil(this.destroy$)) - .subscribe({ - next: (promos) => { - // Backend returns full list - refresh table with extended promo data - this.promos = promos?.map(p => { - const couponData = this.availableCoupons.find(c => c.id === p.couponId); - return { - ...p, - isExpanded: false, - editValidUntil: null, - editName: '', - isActivateExpanded: false, - activateValidUntil: null, - couponData - }; - }) ?? []; - - // Reset form to defaults - this.createForm.reset({ - [F.subType]: 'package', - [F.promoName]: '', - [F.eligibility]: 'all' - }); - this.updatePriceKeyOptions('package'); - - this.submitting = false; - - this.msgSvc.addSuccessMsg($localize`:@@promoCreated:Promo created successfully`); - }, - error: (err) => { - console.error('Error creating promo:', err); - this.submitting = false; - - // Show specific error message based on backend error type - const errorMessage = this.getPromoErrorMessage(err); - this.msgSvc.addFailedMsg(errorMessage); - } - }); - } - - /** - * Map backend promo error type to admin-friendly message - * @param error - Backend error response (HttpErrorResponse) - * @returns User-friendly error message string - */ - private getPromoErrorMessage(error: any): string { - // Backend response is double-nested: HttpErrorResponse.error.error['.tag'] - const errorType = error?.error?.error?.['.tag']; - const errorMessage = error?.error?.error?.message; - - switch (errorType) { - case 'promo_not_found': - return PromoErrors.PROMO_NOT_FOUND; - - case 'promo_duplicate_type_pricekey': - return PromoErrors.PROMO_DUPLICATE_TYPE_PRICEKEY; - - case 'promo_duplicate_coupon': - return PromoErrors.PROMO_DUPLICATE_COUPON; - - case 'promo_overlapping_dates': - return PromoErrors.PROMO_OVERLAPPING_DATES; - - case 'promo_coupon_not_found': - return PromoErrors.PROMO_COUPON_NOT_FOUND; - - case 'promo_invalid_coupon': - return PromoErrors.PROMO_INVALID_COUPON; - - default: - // Fallback for unknown error types - console.error('Unknown promo error type:', errorType, 'Message:', errorMessage, 'Full error:', error); - return PromoErrors.PROMO_GENERIC_ERROR; - } - } - - // ============================================================================ - // TABLE METHODS - EDIT - // ============================================================================ - - /** - * Handles Edit button click - expands row for editing - */ - onEditClick(promo: PromoRow, event: Event): void { - event.stopPropagation(); - - // Collapse any currently expanded row - if (this.expandedRowId && this.expandedRowId !== promo._id) { - const prevRow = this.promos.find(p => p._id === this.expandedRowId); - if (prevRow) { - prevRow.isExpanded = false; - prevRow.editValidUntil = null; - } - } - - // Toggle expansion - promo.isExpanded = !promo.isExpanded; - - if (promo.isExpanded) { - this.expandedRowId = promo._id; - promo.editValidUntil = new Date(promo.validUntil); - promo.editName = promo.name || ''; - } else { - this.expandedRowId = null; - promo.editValidUntil = null; - promo.editName = ''; - } - } - - /** - * Handles Save button click in edit mode - */ - onSaveEdit(promo: PromoRow): void { - const trimmedName = promo.editName?.trim() || ''; - if (!promo.editValidUntil && !trimmedName) return; - - this.promoService.updatePromo(promo._id, { - ...(promo.editValidUntil ? { validUntil: promo.editValidUntil.toISOString() } : {}), - ...(trimmedName ? { name: trimmedName } : {}) - }) - .pipe(takeUntil(this.destroy$)) - .subscribe({ - next: (updated) => { - // Update local data - if (updated.validUntil) { promo.validUntil = updated.validUntil; } - if (trimmedName) { promo.name = trimmedName; } - promo.isExpanded = false; - promo.editValidUntil = null; - promo.editName = ''; - this.expandedRowId = null; - - this.msgSvc.addSuccessMsg($localize`:@@promoUpdated:Promo updated successfully`); - }, - error: (err) => { - console.error('Error updating promo:', err); - this.msgSvc.addFailedMsg($localize`:@@errorUpdatingPromo:Failed to update promo. Please try again.`); - } - }); - } - - /** - * Handles Cancel button click in edit mode - */ - onCancelEdit(promo: PromoRow): void { - promo.isExpanded = false; - promo.editValidUntil = null; - promo.editName = ''; - this.expandedRowId = null; - } - - /** - * Opens the inline activate panel for an inactive promo. - * Pre-fills activateValidUntil with today + 30 days. - * Closes any other open edit or activate panel first. - */ - onActivateClick(promo: PromoRow): void { - if (this.activatingPromoIds.has(promo._id)) return; - - // Close any other open panel - this.promos.forEach(p => { - if (p._id !== promo._id) { - p.isExpanded = false; - p.isActivateExpanded = false; - } - }); - - const isOpen = promo.isActivateExpanded; - promo.isActivateExpanded = !isOpen; - if (promo.isActivateExpanded) { - // Pre-fill: today + 30 days - const suggested = new Date(); - suggested.setDate(suggested.getDate() + 30); - promo.activateValidUntil = suggested; - this.expandedRowId = promo._id; - } else { - promo.activateValidUntil = null; - this.expandedRowId = null; - } - } - - /** - * Cancels the activate inline panel without making any changes. - */ - onCancelActivate(promo: PromoRow): void { - promo.isActivateExpanded = false; - promo.activateValidUntil = null; - this.expandedRowId = null; - } - - /** - * Sends PUT /:id with enabled=true + new validUntil so both isActive() checks pass. - * On success, merges server response so the badge flips to ACTIVE immediately. - */ - onConfirmActivate(promo: PromoRow): void { - if (!promo || !promo.activateValidUntil || this.activatingPromoIds.has(promo._id)) return; - - this.activatingPromoIds.add(promo._id); - this.promoService.activatePromo(promo._id, promo.activateValidUntil) - .pipe(takeUntil(this.destroy$)) - .subscribe({ - next: (updated: Promo) => { - this.activatingPromoIds.delete(promo._id); - Object.assign(promo, updated); - promo.isActivateExpanded = false; - promo.activateValidUntil = null; - this.expandedRowId = null; - this.msgSvc.addSuccessMsg(Labels.PROMO_ACTIVATED_SUCCESS); - }, - error: () => { - this.activatingPromoIds.delete(promo._id); - this.msgSvc.addFailedMsg(Labels.PROMO_ACTIVATE_FAILED); - } - }); - } - - /** - * Determines if a promo can be edited - * Forever/once coupons: Can edit validUntil - * Repeating coupons: Can edit ONLY if validUntil is a real date (not far-future placeholder) - */ - canEditPromo(promo: PromoRow): boolean { - // Check if coupon data is available - if (!promo.couponData) { - // Fallback: hide edit button if coupon data not loaded yet (safer default) - return false; - } - - // Forever/Once coupons: Always allow editing validUntil - if (promo.couponData.duration !== 'repeating') { - return true; - } - - // Repeating coupons: Only allow editing if validUntil is a real date (not far-future placeholder) - const validUntilDate = new Date(promo.validUntil); - const fiftyYearsFromNow = new Date(); - fiftyYearsFromNow.setFullYear(fiftyYearsFromNow.getFullYear() + 50); - - // If validUntil is more than 50 years in future, it's a placeholder - hide edit button - return validUntilDate < fiftyYearsFromNow; - } - - /** - * Returns the display label for the given eligibility value. - */ - getEligibilityLabel(eligibility: string | undefined): string { - const opt = this.eligibilityOptions.find(o => o.value === (eligibility || 'all')); - return opt ? opt.label : $localize`:@@eligibilityAll:All Customers`; - } - - // ============================================================================ - // TABLE METHODS - DELETE - // ============================================================================ - - /** - * Handles Delete button click - shows confirmation dialog - */ - onDeleteClick(promo: PromoRow, event: Event): void { - event.stopPropagation(); - this.deletePromo = promo; - this.deleteValidUntil = null; - this.showDeleteDialog = true; - } - - /** - * Handles delete dialog confirm button - */ - onConfirmDelete(): void { - if (!this.deletePromo) return; - - const validUntil = this.deletePromo.usageCount > 0 && this.deleteValidUntil - ? this.deleteValidUntil.toISOString() - : undefined; - - this.promoService.deletePromo(this.deletePromo._id, validUntil) - .pipe(takeUntil(this.destroy$)) - .subscribe({ - next: (response) => { - if (response.action === 'deleted') { - this.promos = this.promos?.filter(p => p._id !== this.deletePromo?._id) ?? []; - } else { - const index = this.promos?.findIndex(p => p._id === this.deletePromo?._id) ?? -1; - if (index !== -1) { - this.promos[index] = { - ...this.promos[index], - ...response.promo, - isExpanded: false, - editValidUntil: null - }; - } - } - - this.showDeleteDialog = false; - this.deletePromo = null; - this.deleteValidUntil = null; - - const msg = response.action === 'deleted' - ? $localize`:@@promoDeleted:Promo deleted successfully` - : $localize`:@@promoDisabled:Promo disabled successfully`; - this.msgSvc.addSuccessMsg(msg); - }, - error: (err) => { - console.error('Error deleting promo:', err); - this.msgSvc.addFailedMsg($localize`:@@errorDeletingPromo:Failed to delete promo. Please try again.`); - } - }); - } - - /** - * Handles delete dialog cancel button - */ - onCancelDelete(): void { - this.showDeleteDialog = false; - this.deletePromo = null; - this.deleteValidUntil = null; - } - - // ============================================================================ - // DISPLAY HELPERS - // ============================================================================ - - /** - * Formats date for table display using global locale format (mm/dd/yy) - */ - formatDate(isoDate: string | Date): string { - if (!isoDate) return '-'; - const date = new Date(isoDate); - if (isNaN(date.getTime())) return '-'; - - // Use global locale format: mm/dd/yy (2-digit year) - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - const year = String(date.getFullYear()).slice(-2); - return `${month}/${day}/${year}`; - } - - /** - * Determines promo status (active or inactive) - */ - isActive(promo: Promo): boolean { - if (!promo.enabled) return false; - const validUntil = new Date(promo.validUntil); - return validUntil > new Date(); - } - - /** - * Gets status display text - */ - getStatusText(promo: Promo): string { - return this.isActive(promo) - ? $localize`:@@statusActive:Active` - : $localize`:@@statusInactive:Inactive`; - } - - /** - * Gets badge configuration for promo status - */ - getStatusBadgeConfig(promo: Promo): BadgeConfig { - if (this.isActive(promo)) { - return this.badgeFactory.createActiveStatusBadge( - $localize`:@@statusActive:Active` - ); - } - return { - text: $localize`:@@statusInactive:Inactive`, - type: BadgeType.STATUS_INACTIVE, - tooltip: 'Promo is inactive', - ariaLabel: 'Promo status: Inactive' - }; - } - - /** - * Transforms promo data from frontend format to backend format - * Frontend uses 'all' for universal promos, backend expects empty string '' - */ - private transformPromoForBackend(formValue: any): any { - return { - ...formValue, - type: formValue.subType === 'all' ? '' : formValue.subType, - priceKey: formValue.priceKey === 'all' ? '' : formValue.priceKey - }; - } - - /** - * Transforms promo data from backend format to frontend format - * Backend uses empty string '' for universal promos, frontend needs 'all' - */ - private transformPromoFromBackend(promo: any): any { - return { - ...promo, - type: promo.type === '' ? 'all' : promo.type, - priceKey: promo.priceKey === '' ? 'all' : promo.priceKey - }; - } - - /** - * Capitalizes first letter of sub type for display - */ - formatSubType(type: string): string { - // Handle 'all' type (frontend representation) - if (type === 'all') return $localize`:@@typeAll:All`; - - // Handle empty string (backend representation for universal promos) - if (type === '') return $localize`:@@typeAll:All`; - - // Handle undefined/null - if (!type) return ''; - - return type.charAt(0).toUpperCase() + type.slice(1); - } - - /** - * Gets display name for promo in table - * Handles universal, type-specific "All", and specific packages - */ - getPromoDisplayName(promo: Promo): string { - // Universal promo (type = 'all', priceKey = 'all') - if (promo.type === 'all' && promo.priceKey === 'all') { - return $localize`:@@allPackagesAddons:All Packages/Addons`; - } - - // Type-specific "All" (e.g., type = 'package', priceKey = 'all') - if (promo.priceKey === 'all') { - return promo.type === 'package' - ? $localize`:@@allPackages:All Packages` - : $localize`:@@allAddons:All Addons`; - } - - // Specific package/addon (existing logic) - return this.getPlanNameByKey(promo.priceKey); - } - - /** - * Gets warning message for promo in use (with dynamic count) - * Note: Using method instead of template interpolation for i18n compliance - */ - getPromoInUseMessage(): string { - const count = this.deletePromo?.usageCount || 0; - return $localize`:@@promoInUseWarning:This promo is used by ${count}:count: subscription(s).`; - } - - /** - * Gets i18n translation key tooltip for a promo - * Shows nameKey if available, otherwise null (no tooltip) - */ - getI18nKeyTooltip(promo: Promo): string | null { - if (!promo.nameKey) return null; - return `i18n: ${promo.nameKey}`; - } - - /** - * Track by function for ngFor optimization - */ - trackByPromoId(index: number, promo: PromoRow): string { - return promo._id; - } -} diff --git a/Development/client/src/app/shared/account-editor/account-editor.component.css b/Development/client/src/app/shared/account-editor/account-editor.component.css deleted file mode 100644 index 2ab08a3..0000000 --- a/Development/client/src/app/shared/account-editor/account-editor.component.css +++ /dev/null @@ -1,31 +0,0 @@ -/* ============================================================================ - ACCOUNT CONSTRAINT ICON POSITIONING (Detached Mode - Responsive) - ============================================================================ - Positions constraint icon beside fieldset legend (similar to Account - Information Incomplete pattern in vehicle-edit). Icon appears inline - with legend, message content renders in parent component via - *ngTemplateOutlet projection. - - Exception to FlexGrid: Absolute positioning required for space-efficient - legend alignment without creating extra vertical space from ui-g-12 row. - Switches to static positioning on mobile for better accessibility. - ========================================================================= */ - -.account-editor-inline-constraint { - position: absolute; - top: 0; - right: 20px; - z-index: 100; - transform: translateY(0); - /* Align with legend baseline */ -} - -/* Responsive: Switch to static positioning on mobile for better flow */ -@media (max-width: 768px) { - .account-editor-inline-constraint { - position: static; - display: block; - margin-bottom: 12px; - text-align: right; - } -} \ No newline at end of file diff --git a/Development/client/src/app/shared/account-editor/account-editor.component.html b/Development/client/src/app/shared/account-editor/account-editor.component.html index 2fbcac9..7c170fd 100644 --- a/Development/client/src/app/shared/account-editor/account-editor.component.html +++ b/Development/client/src/app/shared/account-editor/account-editor.component.html @@ -1,20 +1,13 @@
    -
    +
    {{ title }} - - - -
    - - + + {{ userValidMsg() }} @@ -23,41 +16,24 @@
    - Password is required - Password must be at least 5 characters long + Password is required + Password must be at least 5 characters long
    - - -
    - -
    -
    - -
    - -
    -
    - -
    -
    - +
    + +
    - - + + {{ userValidMsg() }} @@ -65,12 +41,9 @@
    - - Password is required - Password must be at least 5 characters long. + + Password is required + Password must be at least 5 characters long.
    diff --git a/Development/client/src/app/shared/account-editor/account-editor.component.ts b/Development/client/src/app/shared/account-editor/account-editor.component.ts index 7518253..365827c 100644 --- a/Development/client/src/app/shared/account-editor/account-editor.component.ts +++ b/Development/client/src/app/shared/account-editor/account-editor.component.ts @@ -1,25 +1,26 @@ -import { Component, Input, OnDestroy, Output, EventEmitter, forwardRef, OnChanges, SimpleChanges, OnInit, ViewChild } from '@angular/core'; +import { Component, Input, OnDestroy, Output, EventEmitter, forwardRef } from '@angular/core'; import { FormControl, FormGroup, Validators, NG_VALUE_ACCESSOR, NG_VALIDATORS, ControlValueAccessor, FormBuilder } from '@angular/forms'; + import { Subscription } from 'rxjs'; import { distinctUntilChanged, tap, debounceTime } from 'rxjs/operators'; + + import { StringUtils } from '../utils'; import { UniqueUserValidator } from '../user-unique.directive'; -import { GC, globals, Labels } from '../global'; -import { ConstraintMessageComponent } from '../constraint-message/constraint-message.component'; +import { GC, globals } from '../global'; @Component({ selector: 'agm-account-editor', templateUrl: './account-editor.component.html', - styleUrls: ['./account-editor.component.css'], + styles: [], providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => AccountEditorComponent), multi: true }, { provide: NG_VALIDATORS, useExisting: forwardRef(() => AccountEditorComponent), multi: true }, ] }) -export class AccountEditorComponent implements ControlValueAccessor, OnInit, OnDestroy, OnChanges { +export class AccountEditorComponent implements ControlValueAccessor, OnDestroy { readonly GC = GC; - readonly Labels = Labels; - + private sub$: Subscription; form: FormGroup; @@ -28,43 +29,13 @@ export class AccountEditorComponent implements ControlValueAccessor, OnInit, OnD @Input() showActive: boolean = false; @Input() required: boolean = false; @Input() simple: boolean = false; - @Input() isAircraftAccount: boolean = false; - @Input() isVendorAccount: boolean = false; - @Input() isPartnerSystemUser: boolean = false; - @Input() canActivateVehicle: boolean; - @Input() disableActiveCheckbox: boolean = false; - @Input() activeCheckboxTooltip: string = ''; - - // Account constraint message (for locked account types in account-edit) - @Input() showAccountConstraint: boolean = false; - @Input() accountConstraintMessage: string = ''; - @Input() accountConstraintTitle: string = ''; @Output() userExisted: EventEmitter = new EventEmitter(); - @ViewChild('accountConstraint') accountConstraint: ConstraintMessageComponent; - onChange: any = () => { }; onTouched: any = () => { }; - // ============================================================================ - // REACTIVE FORM DISABLED STATE MANAGEMENT - // ============================================================================ - - /** - * Update the active control's disabled state based on input changes - */ - private updateActiveControlState(): void { - if (this.active) { - if (this.disableActiveCheckbox) { - this.active.disable(); - } else { - this.active.enable(); - } - } - } - get valid() { return this.form.valid; } @@ -73,18 +44,6 @@ export class AccountEditorComponent implements ControlValueAccessor, OnInit, OnD get password() { return this.form.get('password'); } get active() { return this.form.get('active'); } - /** - * Determines if the active checkbox should be visible - * Only show in edit mode (!isNew) when other conditions are met - * - * Behavior: - * - New Accounts: Active field defaults to true, checkbox is hidden - * - Edit Mode: Active checkbox is visible and editable - */ - get shouldShowActiveCheckbox(): boolean { - return !this.isNew; - } - private _account = {}; @Input('account') set value(val: any) { @@ -97,13 +56,7 @@ export class AccountEditorComponent implements ControlValueAccessor, OnInit, OnD private _orgPwd; get value(): any { - // Get raw value to include disabled controls - const formValue = this.form.getRawValue(); - return { - username: formValue.username, - password: formValue.password, - active: formValue.active - }; + return { username: this.username.value, password: this.password.value, active: this.active.value }; } writeValue(val: any): void { @@ -114,42 +67,14 @@ export class AccountEditorComponent implements ControlValueAccessor, OnInit, OnD } this._orgPwd = val['password']; - // For new accounts, ensure active defaults to true - const formValue = { - ...val, - active: this.isNew ? true : val['active'] - }; - - this.form.patchValue(formValue); + this.form.patchValue(val); if (val === null) { this.form.reset(); - // Reset with default active = true for new accounts - if (this.isNew) { - this.form.patchValue({ active: true }); - } } this.initChangeHandlers(); - - // Update disabled state after form initialization - this.updateActiveControlState(); } - - /** - * Update the original username after successful account creation. - * This prevents the async validator from flagging the newly created - * username as "taken" when the user stays on the same page. - */ - markUsernameAsSaved(username: string): void { - this._orgUName = username; - // Clear any existing userExisted errors since the username is now the saved one - if (this.username?.hasError('userExisted')) { - this.username.setErrors(null); - this.username.updateValueAndValidity({ emitEvent: false }); - } - } - registerOnChange(fn: any): void { this.onChange = fn; } @@ -159,19 +84,16 @@ export class AccountEditorComponent implements ControlValueAccessor, OnInit, OnD constructor( private readonly uniqueUserValidator: UniqueUserValidator, - + private readonly fb: FormBuilder) { if (!this.simple && !this.title) - this.title = globals.account; + this.title = $localize`:@@account:Account`; this.form = this.fb.group({ username: new FormControl(this._account['username']), password: new FormControl(this._account['password']), - active: new FormControl({ - value: this.isNew ? true : this._account['active'], // Default to true for new accounts - disabled: false // Initialize as enabled, will be updated in ngOnChanges/writeValue - }) + active: new FormControl(this._account['active']) }); } @@ -188,10 +110,9 @@ export class AccountEditorComponent implements ControlValueAccessor, OnInit, OnD } if (!this.sub$) { + // Any time the inner form changes update the parent of any change this.sub$ = this.form.valueChanges.subscribe(value => { - // Use getRawValue to include disabled controls in change notifications - const rawValue = this.form.getRawValue(); - this.onChange(rawValue); + this.onChange(value); this.onTouched(); }); @@ -256,18 +177,6 @@ export class AccountEditorComponent implements ControlValueAccessor, OnInit, OnD return this.form.valid ? null : { account: { valid: false } }; } - ngOnInit(): void { - // Ensure disabled state is properly set after inputs are available - this.updateActiveControlState(); - } - - ngOnChanges(changes: SimpleChanges): void { - // Handle changes to disableActiveCheckbox input - if (changes['disableActiveCheckbox']) { - this.updateActiveControlState(); - } - } - ngOnDestroy(): void { if (this.sub$) this.sub$.unsubscribe(); diff --git a/Development/client/src/app/shared/active-promo-label/active-promo-label.component.css b/Development/client/src/app/shared/active-promo-label/active-promo-label.component.css deleted file mode 100644 index 138b29c..0000000 --- a/Development/client/src/app/shared/active-promo-label/active-promo-label.component.css +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Active Promo Label Component Styles - * - * Blue, bold styling to emphasize active subscription state. - * No "valid until" date (irrelevant for active subscriptions). - * - * Based on AgMission design system: - * - Color: #1976D2 (blue for active state) - * - Font weight: 700 (bold to emphasize active) - * - Size: 0.85em (consistent with manage-services) - * - * @see /docs/current_work/.../2026-01-22-16-00-active-promo-display-consistency.md - */ - -.active-promo-badge { - display: inline-flex; - align-items: center; - gap: 0.25em; - font-size: 0.85em; - color: #1976D2; - /* Blue for active state */ - font-weight: 700; - /* Bold to emphasize active */ -} - -.promo-discount { - font-weight: 700; -} \ No newline at end of file diff --git a/Development/client/src/app/shared/active-promo-label/active-promo-label.component.html b/Development/client/src/app/shared/active-promo-label/active-promo-label.component.html deleted file mode 100644 index 12331f7..0000000 --- a/Development/client/src/app/shared/active-promo-label/active-promo-label.component.html +++ /dev/null @@ -1,4 +0,0 @@ -
    - ✓ {{ Labels.ACTIVE_PROMO }}: - {{ formattedDiscount }} -
    \ No newline at end of file diff --git a/Development/client/src/app/shared/active-promo-label/active-promo-label.component.ts b/Development/client/src/app/shared/active-promo-label/active-promo-label.component.ts deleted file mode 100644 index 29ed15b..0000000 --- a/Development/client/src/app/shared/active-promo-label/active-promo-label.component.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Component, Input, ChangeDetectionStrategy } from '@angular/core'; -import { ActivePromo } from '@app/domain/services/active-promo.service'; -import { ActivePromoService } from '@app/domain/services/active-promo.service'; -import { PromoTranslationService } from '@app/domain/services/promo-translation.service'; -import { Labels } from '@app/shared/global'; - -/** - * Active Promo Label Component - * - * Displays active promotional discount for subscribed users. - * Shows discount name with checkmark, NO "valid until" date. - * - * Usage: - * ```html - * - * ``` - * - * Display Format: ✓ Active Promo: [DISCOUNT] - * Example: ✓ Active Promo: 50% OFF - * - * - */ -@Component({ - selector: 'agm-active-promo-label', - templateUrl: './active-promo-label.component.html', - styleUrls: ['./active-promo-label.component.css'], - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class ActivePromoLabelComponent { - readonly Labels = Labels; - - /** - * Promo object containing discount information - */ - @Input() promo: ActivePromo; - - constructor( - public activePromoSvc: ActivePromoService, - public promoTranslationSvc: PromoTranslationService - ) { } - - /** - * Get translated promo name with fallback - */ - get translatedPromoName(): string { - return this.promoTranslationSvc.getPromoName(this.promo); - } - - /** - * Get formatted discount string - * @returns Formatted discount (e.g., "50% OFF", "$10.00 OFF", "FREE") - */ - get formattedDiscount(): string { - return this.activePromoSvc.formatPromoDiscount(this.promo); - } -} diff --git a/Development/client/src/app/shared/app-shared.module.ts b/Development/client/src/app/shared/app-shared.module.ts index 72be11e..e283c54 100644 --- a/Development/client/src/app/shared/app-shared.module.ts +++ b/Development/client/src/app/shared/app-shared.module.ts @@ -11,9 +11,6 @@ import { KeyFilterModule } from 'primeng/keyfilter'; import { PanelModule } from 'primeng/panel'; import { MessagesModule } from 'primeng/messages'; import { MessageModule } from 'primeng/message'; -import { RadioButtonModule } from 'primeng/radiobutton'; -import { CalendarModule } from 'primeng/calendar'; -import { DialogModule } from 'primeng/dialog'; import { LengthUnitPipe } from './pipes/length-unit.pipe'; import { RateUnitPipe } from './pipes/rate-unit.pipe'; @@ -44,64 +41,43 @@ import { FlowRatePipe } from './pipes/flow-rate.pipe'; import { LockLinePipe } from './pipes/lockline.pipe'; import { XtractPipe } from './pipes/xtract.pipe'; import { AppVolumePipe } from './pipes/app-volume.pipe'; -import { SubscriptionPkgPipe } from './pipes/subscription-pkg.pipe'; -import { TsDatePipe } from './pipes/ts-to-date.pipe'; -import { CreditCurrencyPipe } from './pipes/credit-currency.pipe'; -import { UsCurrencyPipe } from './pipes/us-currency.pipe'; import { InputNumberModule } from 'primeng/inputnumber'; import { ProfileFormComponent } from './profile-form/profile-form.component'; +import { CreditcardFormComponent } from './creditcard-form/creditcard-form.component'; import { CardInfoComponent } from './card-info/card-info.component'; import { PaymentSummaryComponent } from './payment-summary/payment-summary.component'; import { PaymentMethodSummaryComponent } from './payment-method-summary/payment-method-summary.component'; import { PaymentInfoComponent } from './payment-info/payment-info.component'; -import { CostingItemTypePipe } from '@app/invoices/pipes/costing-item-type.pipe'; -import { CostingItemUnitPipe } from '@app/invoices/pipes/costing-item-unit.pipe'; -import { CurrencyNamePipe } from '@app/invoices/pipes/currency-name.pipe'; -import { CurrencyCodePositionPipe } from '@app/invoices/pipes/currency-code-position.pipe'; -import { InputTrimDirective } from '@app/shared/input-trim.directive'; -import { CreditcardFormComponent } from './creditcard-form/creditcard-form.component'; -import { SubPlansDirective } from './sub-plans.directive'; -import { PaymentAmountComponent } from './payment-amount/payment-amount.component'; -import { CreditcardExpCalComponent } from './creditcard-exp-cal/creditcard-exp-cal.component'; -import { CreditcardComponent } from './creditcard/creditcard.component'; -import { BillingAddressEltComponent } from './billing-address-elt/billing-address-elt.component'; - -import { ReviewAircraftComponent } from './review-aircraft/review-aircraft.component'; -import { GenericMessageComponent } from './generic-message/generic-message.component'; -import { TrialMessageComponent } from './trial-message/trial-message.component'; -import { AppFooterComponent } from '@app/app.footer.component'; -import { LanguageSwicherComponent } from '@app/language-swicher.component'; -import { ConstraintMessageComponent } from './constraint-message/constraint-message.component'; -import { BadgeComponent } from './badge/badge.component'; -import { PromoLabelComponent } from './promo-label/promo-label.component'; -import { ActivePromoLabelComponent } from './active-promo-label/active-promo-label.component'; -import { LegacyNoticeLabelComponent } from './legacy-notice-label/legacy-notice-label.component'; +import {CostingItemTypePipe} from '@app/invoices/pipes/costing-item-type.pipe'; +import {CostingItemUnitPipe} from '@app/invoices/pipes/costing-item-unit.pipe'; +import {CurrencyNamePipe} from '@app/invoices/pipes/currency-name.pipe'; +import {CurrencyCodePositionPipe} from '@app/invoices/pipes/currency-code-position.pipe'; +import {InputTrimDirective} from '@app/shared/input-trim.directive'; @NgModule({ imports: [ CommonModule, GlobalModule, SharedModule, InputTextModule, ButtonModule, DropdownModule, KeyFilterModule, ReactiveFormsModule, CheckboxModule, PanelModule, - MessagesModule, MessageModule, InputNumberModule, CalendarModule, DialogModule + MessagesModule, MessageModule, InputNumberModule, ], declarations: [ LengthUnitPipe, RateUnitPipe, UserTypePipe, AreaUnitPipe, NoCommaPipe, ProductEditorComponent, AccountEditorComponent, ItemEditorComponent, DisplayConfigComponent, CropEditorComponent, UniqueUserValidatorDirective, UnitPipe, ProductTypePipe, ActivityPipe, CoordinatePipe, SpeedPipe, LengthPipe, TemperaturePipe, AppRatePipe, DistancePipe, - JobStatusPipe, VehicleTypePipe, FlowRatePipe, LockLinePipe, XtractPipe, SubscriptionPkgPipe, UsCurrencyPipe, TsDatePipe, CreditCurrencyPipe, + JobStatusPipe, VehicleTypePipe, FlowRatePipe, LockLinePipe, XtractPipe, DebounceDirective, UnitIdUniqueDirective, AppVolumePipe, ProfileFormComponent, CreditcardFormComponent, CardInfoComponent, PaymentSummaryComponent, - PaymentMethodSummaryComponent, PaymentInfoComponent, SubPlansDirective, PaymentAmountComponent, CreditcardExpCalComponent, CreditcardComponent, ReviewAircraftComponent, GenericMessageComponent, TrialMessageComponent, InputTrimDirective, BillingAddressEltComponent, AppFooterComponent, LanguageSwicherComponent, ConstraintMessageComponent, BadgeComponent, PromoLabelComponent, ActivePromoLabelComponent, LegacyNoticeLabelComponent, - + PaymentMethodSummaryComponent, PaymentInfoComponent, InputTrimDirective ], exports: [ CommonModule, GlobalModule, SharedModule, ReactiveFormsModule, - InputTextModule, ButtonModule, DropdownModule, KeyFilterModule, CheckboxModule, MessagesModule, MessageModule, InputNumberModule, RadioButtonModule, + InputTextModule, ButtonModule, DropdownModule, KeyFilterModule, CheckboxModule, MessagesModule, MessageModule, InputNumberModule, ItemEditorComponent, ProductEditorComponent, AccountEditorComponent, DisplayConfigComponent, CropEditorComponent, LengthUnitPipe, RateUnitPipe, AreaUnitPipe, UserTypePipe, NoCommaPipe, UniqueUserValidatorDirective, UnitPipe, ProductTypePipe, ActivityPipe, CoordinatePipe, SpeedPipe, LengthPipe, TemperaturePipe, AppRatePipe, DistancePipe, JobStatusPipe, - VehicleTypePipe, FlowRatePipe, LockLinePipe, XtractPipe, AppVolumePipe, SubscriptionPkgPipe, UsCurrencyPipe, TsDatePipe, CreditCurrencyPipe, + VehicleTypePipe, FlowRatePipe, LockLinePipe, XtractPipe, AppVolumePipe, DebounceDirective, UnitIdUniqueDirective, ProfileFormComponent, CreditcardFormComponent, CardInfoComponent, - PaymentInfoComponent, PaymentSummaryComponent, PaymentMethodSummaryComponent, SubPlansDirective, PaymentAmountComponent, CreditcardExpCalComponent, CreditcardComponent, ReviewAircraftComponent, GenericMessageComponent, TrialMessageComponent, InputTrimDirective, BillingAddressEltComponent, AppFooterComponent, LanguageSwicherComponent, ConstraintMessageComponent, BadgeComponent, PromoLabelComponent, ActivePromoLabelComponent, LegacyNoticeLabelComponent + PaymentInfoComponent, PaymentSummaryComponent, PaymentMethodSummaryComponent, InputTrimDirective ], providers: [RateUnitPipe, LengthUnitPipe, UnitPipe, ProductTypePipe, CostingItemTypePipe, CostingItemUnitPipe, CurrencyNamePipe, CurrencyCodePositionPipe] }) diff --git a/Development/client/src/app/shared/area-unit.pipe.spec.ts b/Development/client/src/app/shared/area-unit.pipe.spec.ts new file mode 100644 index 0000000..20fc7bf --- /dev/null +++ b/Development/client/src/app/shared/area-unit.pipe.spec.ts @@ -0,0 +1,8 @@ +import { AreaUnitPipe } from './area-unit.pipe'; + +describe('AreaUnitPipe', () => { + it('create an instance', () => { + const pipe = new AreaUnitPipe(); + expect(pipe).toBeTruthy(); + }); +}); diff --git a/Development/client/src/app/shared/badge/README.md b/Development/client/src/app/shared/badge/README.md deleted file mode 100644 index f2330ff..0000000 --- a/Development/client/src/app/shared/badge/README.md +++ /dev/null @@ -1,642 +0,0 @@ -# Badge Component - -**Location**: `src/app/shared/components/badge/` -**Status**: Production Ready -**Version**: 1.0.0 - ---- - -## Overview - -The Badge Component is a generic, configuration-driven component for displaying badges throughout the AgMission application. It consolidates badge rendering logic previously duplicated across multiple components (`job-assignment`, `vehicle-list`) into a single, reusable component that follows SOLID principles. - ---- - -## Key Features - -✅ **Configuration-Driven** - All badge variations controlled by `BadgeConfig` interface -✅ **Type-Safe** - TypeScript interfaces for compile-time validation -✅ **OnPush Optimized** - Uses `ChangeDetectionStrategy.OnPush` for performance -✅ **Accessible** - ARIA attributes, semantic HTML, tooltips -✅ **Themeable** - Respects AgMission color palette from `styles.scss` -✅ **Open/Closed Compliant** - New badge types via configuration, no component changes - ---- - -## Quick Start - -### Basic Usage - -```typescript -import { BadgeConfig, BadgeType } from '@app/shared/components/badge/badge-config.model'; -import { BadgeFactoryService } from '@app/shared/services/badge-factory.service'; - -// In component class: -export class MyComponent { - systemBadge: BadgeConfig; - - constructor(private badgeFactory: BadgeFactoryService) { - // Using factory service (recommended) - this.systemBadge = badgeFactory.createSystemBadge('SATLOC', 'Satloc'); - } -} -``` - -```html - - -``` - -### Direct Configuration - -```typescript -// Create badge config directly (for simple cases) -const activeBadge: BadgeConfig = { - text: 'Active', - type: BadgeType.STATUS_ACTIVE, - tooltip: 'Vehicle is active', - ariaLabel: 'Vehicle is active' -}; -``` - -```html - -``` - ---- - -## Badge Types - -### System Badges (Green - AgMission Primary Color) - -```typescript -// AgNav Native System -BadgeType.AGNAV -// Color: #4CAF50 -// Use: AgMission native aircraft/features - -// Partner Systems -BadgeType.PARTNER -// Color: #4CAF50 -// Use: Satloc, Ag Leader, other partner integrations - -// Unknown Systems -BadgeType.UNKNOWN -// Color: #4CAF50 -// Use: Fallback for unrecognized systems -``` - -### Status Badges (Semantic Colors) - -```typescript -// Active Status -BadgeType.STATUS_ACTIVE -// Color: #4CAF50 (Green) -// Use: Valid authentication, active features - -// Pending Status -BadgeType.STATUS_PENDING -// Color: #ffeb3b (Yellow) -// Use: Validating credentials, processing - -// Error Status -BadgeType.STATUS_ERROR -// Color: #f44336 (Red) -// Use: Failed authentication, errors - -// Inactive Status -BadgeType.STATUS_INACTIVE -// Color: #A5D6A7 (Light Green) -// Use: Disabled features, inactive state -``` - -### Assignment Status Badges (Job Assignment Workflow) - -```typescript -// New Assignment -BadgeType.STATUS_NEW -// Color: #4527A0 (Purple) -// Use: Assignment in progress - -// Downloaded -BadgeType.STATUS_DOWNLOADED -// Color: #f9a825 (Gold) -// Use: Job downloaded to aircraft - -// Uploaded -BadgeType.STATUS_UPLOADED -// Color: #2E7D32 (Dark Green) -// Use: Job completed and uploaded -``` - ---- - -## Badge Factory Service - -The `BadgeFactoryService` provides convenient methods for creating common badge configurations. - -### System Badges - -```typescript -constructor(private badgeFactory: BadgeFactoryService) {} - -// AgNav system badge -const agnavBadge = this.badgeFactory.createSystemBadge(SourceSystem.AGNAV); -// Result: { text: 'AgNav', type: BadgeType.AGNAV, tooltip: '...' } - -// Partner system badge -const partnerBadge = this.badgeFactory.createSystemBadge('partnerId', 'Satloc'); -// Result: { text: 'Satloc', type: BadgeType.PARTNER, tooltip: '...' } - -// Partner code badge (tail number) -const codeBadge = this.badgeFactory.createPartnerCodeBadge('N12345'); -// Result: { text: 'N12345', type: BadgeType.PARTNER, size: BadgeSize.SMALL, ... } -``` - -### Authentication Status Badges - -```typescript -// Authenticated partner -const authBadge = this.badgeFactory.createAuthStatusBadge( - true, // isAuthenticated - false, // isValidating - 'partnerId' -); -// Result: { icon: 'pi pi-check', type: BadgeType.STATUS_ACTIVE, ... } - -// Validating credentials -const validatingBadge = this.badgeFactory.createAuthStatusBadge( - false, // isAuthenticated - true, // isValidating - 'partnerId' -); -// Result: { icon: 'pi pi-spin pi-spinner', type: BadgeType.STATUS_PENDING, ... } - -// Authentication failed -const errorBadge = this.badgeFactory.createAuthStatusBadge( - false, // isAuthenticated - false, // isValidating - 'partnerId' -); -// Result: { icon: 'pi pi-exclamation-triangle', type: BadgeType.STATUS_ERROR, ... } -``` - -### Assignment Status Badges - -```typescript -import { AssignStatus } from '@app/shared/global'; - -// New assignment -const newBadge = this.badgeFactory.createAssignmentStatusBadge(AssignStatus.NEW); -// Result: { text: 'New', type: BadgeType.STATUS_NEW, ... } - -// Downloaded assignment with custom message -const downloadedBadge = this.badgeFactory.createAssignmentStatusBadge( - AssignStatus.DOWNLOADED, - 'Downloaded 5 minutes ago' -); -// Result: { text: 'Downloaded', type: BadgeType.STATUS_DOWNLOADED, tooltip: '...', ... } -``` - -### Generic Status Badges - -```typescript -// Active status -const activeBadge = this.badgeFactory.createActiveStatusBadge('Online'); -// Result: { text: 'Online', type: BadgeType.STATUS_ACTIVE, ... } - -// Pending status -const pendingBadge = this.badgeFactory.createPendingStatusBadge('Processing'); -// Result: { text: 'Processing', type: BadgeType.STATUS_PENDING, ... } - -// Error status with details -const errorBadge = this.badgeFactory.createErrorStatusBadge( - 'Failed', - 'Connection timeout after 30 seconds' -); -// Result: { text: 'Failed', type: BadgeType.STATUS_ERROR, tooltip: '...', ... } -``` - ---- - -## Badge Sizes - -```typescript -import { BadgeSize } from '@app/shared/components/badge/badge-config.model'; - -// Small badge (for dense layouts) -const smallBadge: BadgeConfig = { - text: 'Small', - type: BadgeType.PARTNER, - size: BadgeSize.SMALL // 9px font, 2px padding -}; - -// Medium badge (default) -const mediumBadge: BadgeConfig = { - text: 'Medium', - type: BadgeType.PARTNER, - size: BadgeSize.MEDIUM // 11px font, 3px padding (or omit size) -}; - -// Large badge (for emphasis) -const largeBadge: BadgeConfig = { - text: 'Large', - type: BadgeType.PARTNER, - size: BadgeSize.LARGE // 13px font, 4px padding -}; -``` - ---- - -## Badge Styles - -```typescript -import { BadgeStyle } from '@app/shared/components/badge/badge-config.model'; - -// Solid style (default - filled background) -const solidBadge: BadgeConfig = { - text: 'Solid', - type: BadgeType.STATUS_ACTIVE, - style: BadgeStyle.SOLID // or omit style -}; - -// Outline style (transparent background with border) -const outlineBadge: BadgeConfig = { - text: 'Outline', - type: BadgeType.STATUS_ACTIVE, - style: BadgeStyle.OUTLINE -}; -``` - ---- - -## Icons in Badges - -```typescript -// Icon with text -const iconTextBadge: BadgeConfig = { - text: 'Active', - type: BadgeType.STATUS_ACTIVE, - icon: 'pi pi-check' // PrimeIcons class -}; - -// Icon only (no text) -const iconOnlyBadge: BadgeConfig = { - text: '', // Empty text - type: BadgeType.STATUS_ACTIVE, - icon: 'pi pi-check', - ariaLabel: 'Authenticated' // Important for accessibility -}; - -// Spinner icon (for loading states) -const spinnerBadge: BadgeConfig = { - text: '', - type: BadgeType.STATUS_PENDING, - icon: 'pi pi-spin pi-spinner', - ariaLabel: 'Validating...' -}; -``` - ---- - -## Advanced Usage - -### Dynamic Badge Configurations - -```typescript -export class VehicleListComponent { - constructor(private badgeFactory: BadgeFactoryService) {} - - // Computed badge configuration (called in template) - getVehiclePartnerBadge(vehicle: Vehicle): BadgeConfig { - const sourceSystem = this.getSourceSystem(vehicle); - return this.badgeFactory.createSystemBadge( - sourceSystem, - this.getPartnerDisplayName(vehicle) - ); - } - - // In template: - // -} -``` - -### Custom Classes - -```typescript -// Add custom CSS classes for edge cases -const customBadge: BadgeConfig = { - text: 'Custom', - type: BadgeType.PARTNER, - customClasses: ['my-custom-class', 'another-class'] -}; - -// Resulting classes: 'agm-badge agm-badge-partner my-custom-class another-class' -``` - -### Cached Badge Configurations - -```typescript -export class JobAssignmentComponent implements OnInit { - // Cache badge configs to avoid recreating on every change detection - private badgeConfigCache = new Map(); - - constructor(private badgeFactory: BadgeFactoryService) {} - - getAircraftSystemBadge(aircraft: Aircraft): BadgeConfig { - const cacheKey = `system-${aircraft.sourceSystem}`; - - if (!this.badgeConfigCache.has(cacheKey)) { - const config = this.badgeFactory.createSystemBadge( - aircraft.sourceSystem, - this.getPartnerDisplayName(aircraft) - ); - this.badgeConfigCache.set(cacheKey, config); - } - - return this.badgeConfigCache.get(cacheKey)!; - } -} -``` - ---- - -## Migration Guide - -### From: Hardcoded Badge Classes - -**BEFORE** (Old Pattern): -```html - - {{ getPartnerDisplayName(aircraft) }} - -``` - -```typescript -getBadgeClass(sourceSystem: string): string { - if (this.partnerUtils.isNativeSystem(sourceSystem)) { - return 'agm-badge agm-badge-agnav'; - } - return 'agm-badge agm-badge-partner'; -} -``` - -**AFTER** (New Pattern): -```html - -``` - -```typescript -constructor(private badgeFactory: BadgeFactoryService) {} - -getSystemBadge(aircraft: Aircraft): BadgeConfig { - return this.badgeFactory.createSystemBadge( - aircraft.sourceSystem, - this.getPartnerDisplayName(aircraft) - ); -} - -// Delete old getBadgeClass() method -``` - ---- - -## Performance Considerations - -### OnPush Change Detection - -The badge component uses `ChangeDetectionStrategy.OnPush`, which means: - -✅ **DO**: Use immutable badge configurations -```typescript -// Good - new object reference triggers change detection -this.badge = { ...this.badge, text: 'Updated' }; - -// Good - factory creates new config each time -this.badge = this.badgeFactory.createSystemBadge(newSystem); -``` - -❌ **DON'T**: Mutate existing badge configurations -```typescript -// Bad - mutation doesn't trigger OnPush change detection -this.badge.text = 'Updated'; // Won't update UI! -``` - -### Memoization for Large Lists - -```typescript -// For large lists (100+ items), memoize badge configs -private badgeConfigs = new Map(); - -getBadge(item: any): BadgeConfig { - const key = item.id; - if (!this.badgeConfigs.has(key)) { - this.badgeConfigs.set(key, this.badgeFactory.createSystemBadge(item.system)); - } - return this.badgeConfigs.get(key)!; -} -``` - ---- - -## Accessibility - -All badges automatically include: - -- ✅ **ARIA labels** - `aria-label` from config or defaults to badge text -- ✅ **Tooltips** - Native HTML `title` attribute from config -- ✅ **Semantic role** - `role="status"` for screen readers -- ✅ **Hidden icons** - `aria-hidden="true"` on decorative icons - -**Best Practice**: Always provide meaningful ARIA labels for icon-only badges: - -```typescript -// ✅ Good - descriptive label -const iconBadge: BadgeConfig = { - text: '', - icon: 'pi pi-check', - ariaLabel: 'Partner authentication successful', // ← Important! - type: BadgeType.STATUS_ACTIVE -}; - -// ❌ Bad - no context for screen readers -const iconBadge: BadgeConfig = { - text: '', - icon: 'pi pi-check', - type: BadgeType.STATUS_ACTIVE // Screen reader reads nothing useful -}; -``` - ---- - -## Extending the Badge System - -### Adding a New Badge Type - -**1. Add enum value** to `BadgeType` in `badge-config.model.ts`: -```typescript -export enum BadgeType { - // ... existing types - STATUS_WARNING = 'status-warning', // New type -} -``` - -**2. Add CSS class** to `styles.scss`: -```scss -.agm-badge-status-warning { - background-color: #ff9800; // Orange - border-color: #f57c00; -} -``` - -**3. Add factory method** (optional) to `badge-factory.service.ts`: -```typescript -createWarningStatusBadge(text: string = 'Warning'): BadgeConfig { - return { - text, - type: BadgeType.STATUS_WARNING, - tooltip: `Warning: ${text}`, - ariaLabel: `Warning: ${text}` - }; -} -``` - -**4. Use it** in components: -```typescript -const warningBadge = this.badgeFactory.createWarningStatusBadge('Low Battery'); -``` - -**✅ No component code changes required!** (Open/Closed Principle) - ---- - -## Troubleshooting - -### Badge Not Updating - -**Problem**: Badge text/color doesn't update when data changes. - -**Cause**: OnPush change detection requires new object reference. - -**Solution**: Create new config object instead of mutating: -```typescript -// ✅ Correct -this.badge = this.badgeFactory.createSystemBadge(newSystem); - -// ❌ Incorrect -this.badge.text = 'New Text'; // Mutation doesn't trigger OnPush -``` - -### Custom Classes Not Applied - -**Problem**: `customClasses` array not showing up. - -**Cause**: CSS classes may not be defined in styles. - -**Solution**: Ensure custom CSS classes exist in `styles.scss` or component styles: -```scss -.my-custom-badge-class { - /* your styles */ -} -``` - -### Icon Not Showing - -**Problem**: Icon doesn't appear in badge. - -**Cause**: PrimeIcons CSS not loaded or incorrect class name. - -**Solution**: -1. Verify PrimeIcons is imported in `angular.json` -2. Use correct PrimeIcons class names: `pi pi-check`, `pi pi-times`, etc. -3. Check browser dev tools for CSS errors - ---- - -## Testing - -### Component Unit Tests - -```typescript -import { BadgeComponent } from './badge.component'; -import { BadgeConfig, BadgeType, BadgeSize } from './badge-config.model'; - -describe('BadgeComponent', () => { - it('should generate correct CSS classes for basic badge', () => { - const component = new BadgeComponent(); - component.config = { - text: 'Test', - type: BadgeType.AGNAV - }; - - expect(component.getBadgeClasses()).toBe('agm-badge agm-badge-agnav'); - }); - - it('should include size variant class', () => { - const component = new BadgeComponent(); - component.config = { - text: 'Test', - type: BadgeType.PARTNER, - size: BadgeSize.SMALL - }; - - expect(component.getBadgeClasses()).toContain('agm-badge-sm'); - }); -}); -``` - -### Factory Service Tests - -```typescript -import { BadgeFactoryService } from './badge-factory.service'; -import { BadgeType, BadgeSize } from '../components/badge/badge-config.model'; - -describe('BadgeFactoryService', () => { - let service: BadgeFactoryService; - - beforeEach(() => { - service = new BadgeFactoryService(partnerUtilsMock); - }); - - it('should create system badge for AgNav', () => { - const badge = service.createSystemBadge(SourceSystem.AGNAV); - - expect(badge.text).toBe('AgNav'); - expect(badge.type).toBe(BadgeType.AGNAV); - }); - - it('should create auth status badge with correct icon', () => { - const badge = service.createAuthStatusBadge(true, false, 'partner123'); - - expect(badge.icon).toBe('pi pi-check'); - expect(badge.type).toBe(BadgeType.STATUS_ACTIVE); - expect(badge.size).toBe(BadgeSize.SMALL); - }); -}); -``` - ---- - -## Related Documentation - -- **Implementation Plan**: `/docs/current_work/badge-component-consolidation-plan.md` -- **SOLID Principles**: `/docs/angular-change-detection-guide.md` - Open/Closed Principle section -- **AgMission Theme**: `src/styles.scss` - Badge CSS classes (lines 1340-1470) -- **Partner Utils**: `src/app/shared/services/partner-utils.service.ts` - ---- - -## Change Log - -### Version 1.0.0 (2025-11-06) -- ✅ Initial implementation -- ✅ Badge component with OnPush strategy -- ✅ Badge factory service with common patterns -- ✅ Configuration interfaces and enums -- ✅ Full documentation and examples - ---- - -## Support - -For questions or issues with the badge component, contact the AgMission development team or create an issue in the project tracker. diff --git a/Development/client/src/app/shared/badge/badge-config.model.ts b/Development/client/src/app/shared/badge/badge-config.model.ts deleted file mode 100644 index c2885a2..0000000 --- a/Development/client/src/app/shared/badge/badge-config.model.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Badge Configuration Models - * - * Type-safe configuration system for badge component. - * Following Open/Closed Principle: extend with new types, don't modify component. - * - * @see /docs/angular-change-detection-guide.md - Open/Closed Principle section - */ - -/** - * Badge Configuration Interface - * - * Defines all aspects of badge appearance and behavior. - * Components provide this configuration to the badge component. - */ -export interface BadgeConfig { - /** Badge text content */ - text: string; - - /** Badge type determines color scheme */ - type: BadgeType; - - /** Optional size variant */ - size?: BadgeSize; - - /** Optional style variant */ - style?: BadgeStyle; - - /** Optional icon (PrimeIcons class name) */ - icon?: string; - - /** Optional tooltip text */ - tooltip?: string; - - /** Optional ARIA label for accessibility */ - ariaLabel?: string; - - /** Optional CSS classes to append */ - customClasses?: string[]; -} - -/** - * Badge Type Enum - * - * Defines all supported badge types. - * Each type maps to corresponding CSS class in styles.scss. - * - * To add new badge type: - * 1. Add enum value here - * 2. Add corresponding CSS class in styles.scss (.agm-badge-{type}) - * 3. Component code remains unchanged (Open/Closed Principle) - */ -export enum BadgeType { - // System badges (green - AgMission primary color) - AGNAV = 'agnav', - PARTNER = 'partner', - UNKNOWN = 'unknown', - - // Status badges (semantic colors from AgMission palette) - STATUS_ACTIVE = 'status-active', - STATUS_PENDING = 'status-pending', - STATUS_ERROR = 'status-error', - STATUS_INACTIVE = 'status-inactive', - - // Assignment status badges (specific to job assignment workflow) - STATUS_NEW = 'status-new', - STATUS_DOWNLOADED = 'status-downloaded', - STATUS_UPLOADED = 'status-uploaded', - - // Subscription badges (for compact promotion card display) - PROMO_DISCOUNT = 'promo-discount', // "50% OFF" promotional discount badge - SAVINGS = 'savings', // "Save $995" savings amount - EXPIRY_WARNING = 'expiry-warning', // "2 days left" expiry countdown - RENEWAL_DATE = 'renewal-date', // "Renews 1/28/27" renewal date - VEHICLE_LIMIT = 'vehicle-limit', // "1 Aircraft" vehicle limit - ACRE_LIMIT = 'acre-limit', // "50K acres" acre limit - BILLING_CYCLE = 'billing-cycle', // "Yearly" billing cycle - PAYMENT_METHOD = 'payment-method' // "Visa 4242" payment method -} - -/** - * Badge Size Variants - */ -export enum BadgeSize { - SMALL = 'sm', - MEDIUM = 'md', // default - LARGE = 'lg' -} - -/** - * Badge Style Variants - */ -export enum BadgeStyle { - SOLID = 'solid', // default - filled background - OUTLINE = 'outline' // transparent background with border -} diff --git a/Development/client/src/app/shared/badge/badge.component.ts b/Development/client/src/app/shared/badge/badge.component.ts deleted file mode 100644 index d931fbb..0000000 --- a/Development/client/src/app/shared/badge/badge.component.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { Component, Input, ChangeDetectionStrategy } from '@angular/core'; -import { BadgeConfig, BadgeSize, BadgeStyle } from './badge-config.model'; - -/** - * Generic Badge Component - * - * SOLID PRINCIPLES APPLIED: - * - * 1. SINGLE RESPONSIBILITY: - * - Only renders badge based on configuration - * - All logic is configuration-driven - * - No business logic or data fetching - * - * 2. OPEN/CLOSED PRINCIPLE: - * - OPEN for extension: Add new badge types via BadgeType enum + CSS - * - CLOSED for modification: Component code never changes for new types - * - * 3. DEPENDENCY INVERSION: - * - Depends on BadgeConfig abstraction, not concrete implementations - * - Parent components provide configurations via factory or direct creation - * - * PERFORMANCE: - * - OnPush change detection strategy (only checks when @Input changes) - * - Immutable configuration objects enable efficient change detection - * - Pure function for class generation (no side effects) - * - * USAGE EXAMPLES: - * - * ```typescript - * // In parent component: - * systemBadge: BadgeConfig = { - * text: 'AgNav', - * type: BadgeType.AGNAV, - * tooltip: 'AgMission Native System' - * }; - * - * statusBadge: BadgeConfig = { - * text: 'Active', - * type: BadgeType.STATUS_ACTIVE, - * size: BadgeSize.SMALL, - * icon: 'pi pi-check' - * }; - * ``` - * - * ```html - * - * - * - * ``` - * - */ -@Component({ - selector: 'agm-badge', - template: ` - - - {{ config.text }} - - `, - styles: [` - /* Component-specific styles (minimal - most styling in global styles.scss) */ - :host { - display: inline-block; - } - - span[role="status"] { - display: inline-flex; - align-items: center; - gap: 4px; - } - - /* Icon spacing when combined with text */ - i + span { - margin-left: 2px; - } - `], - changeDetection: ChangeDetectionStrategy.OnPush // ✅ Performance optimization -}) -export class BadgeComponent { - /** - * Badge configuration (required) - * Must be immutable for OnPush to work correctly - */ - @Input() config!: BadgeConfig; - - /** - * Build CSS classes from configuration - * - * Pure function - deterministic output from inputs, no side effects. - * This enables: - * - Predictable rendering - * - Easy testing - * - OnPush optimization - * - * @returns Space-separated CSS class string - */ - getBadgeClasses(): string { - const classes = ['agm-badge']; - - // Add type-specific class (maps to CSS in styles.scss) - classes.push(`agm-badge-${this.config.type}`); - - // Add size variant (if not default medium) - if (this.config.size && this.config.size !== BadgeSize.MEDIUM) { - classes.push(`agm-badge-${this.config.size}`); - } - - // Add style variant (if outline style) - if (this.config.style === BadgeStyle.OUTLINE) { - classes.push('agm-badge-outline'); - } - - // Add custom classes (for edge cases) - if (this.config.customClasses && this.config.customClasses.length > 0) { - classes.push(...this.config.customClasses); - } - - return classes.join(' '); - } -} diff --git a/Development/client/src/app/shared/base/base.component.ts b/Development/client/src/app/shared/base/base.component.ts index 2eab22c..3353815 100644 --- a/Development/client/src/app/shared/base/base.component.ts +++ b/Development/client/src/app/shared/base/base.component.ts @@ -12,7 +12,6 @@ import { ConfirmationService } from 'primeng/api'; import { AppMessageService } from '../app-message.service'; import { AppActions } from '@app/app-actions'; import { GAService } from '../ga.service'; -import { GAAnalyticsHelpersService } from '../ga.analytics-helpers.service'; import { AppConfigService } from '@app/domain/services/app-config.service'; import { IAppConfig } from '@app/domain/models/appconfig.model'; import { environment } from '@environments/environment'; @@ -34,7 +33,6 @@ export class BaseComp implements OnDestroy { protected msgSvc: AppMessageService; protected appActions: AppActions; protected gaSvc: GAService; - protected gaHelpers: GAAnalyticsHelpersService; sub$: Subscription = new Subscription(); locale; @@ -84,7 +82,6 @@ export class BaseComp implements OnDestroy { this.msgSvc = injector.get(AppMessageService); this.appActions = injector.get(AppActions); this.gaSvc = injector.get(GAService); - this.gaHelpers = injector.get(GAAnalyticsHelpersService); this.locale = locales[this.authSvc.locale]; this.appConf = injector.get(AppConfigService); this.settings = cloneDeep(this.appConf.settings); @@ -111,26 +108,4 @@ export class BaseComp implements OnDestroy { ngOnDestroy(): void { if (this.sub$) this.sub$.unsubscribe(); } - - /** - * Convenience method to get standardized user role for GA4 analytics - * Uses the shared analytics helpers service - */ - protected getAnalyticsUserRole(): 'admin' | 'applicator' | 'office_admin' | 'client' | 'officer' | 'pilot' | 'inspector' | 'aircraft' { - return this.gaHelpers.getUserRole(this.authSvc.user?.roles || []); - } - - /** - * Convenience method to get current user ID for analytics - */ - protected getAnalyticsUserId(): string { - return this.authSvc.user?._id || 'anonymous'; - } - - /** - * Convenience method to get platform value for analytics - */ - protected getAnalyticsPlatform(): 'web' | 'mobile' | 'api' { - return 'web'; - } } diff --git a/Development/client/src/app/shared/billing-address-elt/billing-address-elt.component.css b/Development/client/src/app/shared/billing-address-elt/billing-address-elt.component.css deleted file mode 100644 index 5885a5d..0000000 --- a/Development/client/src/app/shared/billing-address-elt/billing-address-elt.component.css +++ /dev/null @@ -1,8 +0,0 @@ -.cc-field { - margin-bottom: 1em; -} - -.cc-err { - color: red; - font-size: 0.9em; -} diff --git a/Development/client/src/app/shared/billing-address-elt/billing-address-elt.component.html b/Development/client/src/app/shared/billing-address-elt/billing-address-elt.component.html deleted file mode 100644 index 19ffee4..0000000 --- a/Development/client/src/app/shared/billing-address-elt/billing-address-elt.component.html +++ /dev/null @@ -1,2 +0,0 @@ -
    - diff --git a/Development/client/src/app/shared/billing-address-elt/billing-address-elt.component.ts b/Development/client/src/app/shared/billing-address-elt/billing-address-elt.component.ts deleted file mode 100644 index 27f5629..0000000 --- a/Development/client/src/app/shared/billing-address-elt/billing-address-elt.component.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { Component, EventEmitter, Inject, Input, LOCALE_ID, OnDestroy, OnInit, Output } from '@angular/core'; -import { STRIPE_BIL_ADDR_STYLE, SubTexts } from '@app/profile/common'; -import { BaseComp } from '../base/base.component'; -import { SubscriptionService } from '@app/domain/services/subscription.service'; -import { LoadStripe } from '@app/actions/subscription.actions'; -import { StripeAddressElement, StripeElementLocale } from '@stripe/stripe-js'; -import { environment } from '@environments/environment'; -import { Address } from '@app/domain/models/subscription.model'; - -@Component({ - selector: 'billing-address-elt', - templateUrl: './billing-address-elt.component.html', - styleUrls: ['./billing-address-elt.component.css'] -}) -export class BillingAddressEltComponent extends BaseComp implements OnInit, OnDestroy { - readonly SubTexts = SubTexts; - @Output() addressEvt = new EventEmitter<{ - isValid: boolean; - address: any; - name: string; - }>(); - @Input() address: Address; - @Input() isNewAddress: boolean = false; - private billingAddressElement: StripeAddressElement; - - stripeHasLoaded: boolean; - - constructor( - private readonly subSvc: SubscriptionService, - @Inject(LOCALE_ID) private readonly stripeLocale: StripeElementLocale - ) { - super(); - } - - ngOnInit(): void { - this.initStripe(); - } - - private initStripe() { - const tryCreateElement = () => { - if (this.subSvc.stripe) { - this.createAddressElt(); - } else { - this.store.dispatch(new LoadStripe()); - } - }; - - this.sub$ = this.subSvc.stripeLoadStatus$.subscribe({ - next: loaded => { - if (loaded) this.createAddressElt(); - } - }); - tryCreateElement(); - } - - private createAddressElt() { - const elements = this.subSvc.stripe.elements({ appearance: STRIPE_BIL_ADDR_STYLE, locale: this.stripeLocale }); - this.billingAddressElement = elements.create('address', { - mode: 'billing', - allowedCountries: [this.address.country], - autocomplete: { mode: "google_maps_api", apiKey: environment.stripeGapiKey }, - defaultValues: this.isNewAddress ? { - name: this.address.name, - address: { - line1: this.address.line1, - city: '', - state: '', - postal_code: '', - country: this.address.country, - }, - } : { - name: this.address.name, - address: { - line1: this.address.line1, - line2: this.address.line2, - city: this.address.city, - state: this.address.state, - postal_code: this.address.postalCode, - country: this.address.country, - }, - }, - }); - - this.billingAddressElement.on('change', (event) => { - if (event.complete && event.value?.address) { - this.addressEvt.emit({ - isValid: event.complete, - address: event.value.address, - name: event.value.name, - }); - } else { - this.addressEvt.emit({ - isValid: false, - address: null, - name: '', - }); - } - }); - - setTimeout(() => this.billingAddressElement.mount('#billing-address-element')); - } - - ngOnDestroy(): void { - super.ngOnDestroy(); - } -} diff --git a/Development/client/src/app/shared/card-info/card-info.component.html b/Development/client/src/app/shared/card-info/card-info.component.html index b68a6de..9da36b9 100644 --- a/Development/client/src/app/shared/card-info/card-info.component.html +++ b/Development/client/src/app/shared/card-info/card-info.component.html @@ -1,19 +1,30 @@
    -

    Credit card Information

    +

    Credit card Information


    - - - +
    -
    Card number:
    -
    **** {{card?.last4}}
    +
    + Card number +
    +
    ************1234
    -
    Card type:
    -
    {{card?.brand | uppercase}}
    +
    + Card holder name +
    +
    John Smith
    -
    Expiration date:
    -
    {{card?.exp_month}}/{{card?.exp_year}}
    +
    + Card type +
    +
    MasterCard/div> +
    +
    +
    + Expiration date +
    +
    25/01
    +
    \ No newline at end of file diff --git a/Development/client/src/app/shared/card-info/card-info.component.ts b/Development/client/src/app/shared/card-info/card-info.component.ts index dd6c3e2..9625285 100644 --- a/Development/client/src/app/shared/card-info/card-info.component.ts +++ b/Development/client/src/app/shared/card-info/card-info.component.ts @@ -1,19 +1,18 @@ -import { Component, EventEmitter, Input, Output } from '@angular/core'; -import { Card } from '@app/domain/models/subscription.model'; +import { Component, OnInit, Input } from '@angular/core'; @Component({ selector: 'card-info', templateUrl: './card-info.component.html', styleUrls: ['./card-info.component.css'] }) -export class CardInfoComponent { - @Input() card: Card; - @Input() editable: boolean; - @Output() editCheckout = new EventEmitter(); +export class CardInfoComponent implements OnInit { + @Input("editable") + showEdit: boolean = false; + constructor() { } - edit() { - this.editCheckout.emit(); + ngOnInit(): void { } + } diff --git a/Development/client/src/app/shared/constraint-message/README.md b/Development/client/src/app/shared/constraint-message/README.md deleted file mode 100644 index a29c931..0000000 --- a/Development/client/src/app/shared/constraint-message/README.md +++ /dev/null @@ -1,142 +0,0 @@ -# AGM Constraint Message Component - -## Overview - -The `agm-constraint-message` component provides consistent, accessible, and AgMission brand-compliant constraint and informational messaging across the application. This component follows UX/UI best practices and implements the official AgMission Project Color Palette. - -## AgMission Project Color Palette Compliance - -This component is fully compliant with the **AgMission Project Color Palette** as documented in `.github/copilot-instructions.md`. - -### Severity Color Mapping - -| Severity | Border Color | Icon Color | Title Color | Background | Usage | -|----------|-------------|------------|-------------|------------|-------| -| **info** | `#03A9F4` (blue) | `#03A9F4` (blue) | `#0277BD` (blueHover) | Light blue gradient | Informational constraints, general notices | -| **warning** | `#FFC107` (amber) | `#FFC107` (amber) | `#FF8F00` (amberHover) | Light amber gradient | Warnings, cautions, important notices | -| **error** | `#F44336` (red) | `#F44336` (red) | `#C62828` (redHover) | Light red gradient | Errors, validation failures, critical issues | - -### Text Colors -- **Description Text**: `#212121` (textColor) - AgMission primary text color -- **Secondary Text**: `#757575` (textSecondaryColor) - Supporting text when needed - -## Features - -- **Accessibility Compliant**: ARIA attributes, semantic structure, screen reader support -- **Responsive Design**: Mobile-first approach with adaptive layouts -- **Brand Consistency**: Official AgMission color palette throughout -- **Internationalization**: Full i18n support with Angular `$localize` -- **Multiple Severity Levels**: Info, warning, error with appropriate visual indicators -- **Customizable**: Flexible inputs for icons, titles, messages, and styling - -## Usage Examples - -### Basic Information Constraint -```html - - -``` - -### Warning Constraint -```html - - -``` - -### Error Constraint -```html - - -``` - -## Input Properties - -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| `message` | `string` | `''` | **Required**: Main constraint message text | -| `title` | `string` | `''` | Optional title (uses severity-based default if not provided) | -| `severity` | `'info' \| 'warning' \| 'error'` | `'info'` | Visual severity level | -| `icon` | `string` | `'pi-info-circle'` | PrimeIcons icon class | -| `showTitle` | `boolean` | `true` | Whether to display the title | -| `styleClass` | `string` | `''` | Additional CSS classes | -| `tooltip` | `string` | `''` | Optional tooltip text | - -## Styling Architecture - -### Global Styles (styles.scss) -- Base component styling with AgMission colors -- Severity-specific color variants -- Responsive design breakpoints -- Hover and interaction states - -### Component-Specific Styles -- Focus states for accessibility -- High contrast mode support -- Mobile optimizations - -### Component Overrides -- Individual components can override spacing via CSS -- Color changes should use AgMission palette variables -- Maintain consistency with global styling patterns - -## Accessibility Features - -- **ARIA Attributes**: `role="alert"`, `aria-live="polite"`, `aria-label` -- **Semantic Structure**: Proper heading hierarchy and text structure -- **Keyboard Navigation**: Focus management and keyboard accessibility -- **Screen Reader Support**: Descriptive labels and announcement patterns -- **High Contrast**: Enhanced visibility in high contrast mode - -## Development Guidelines - -### When to Use -- Displaying user constraints (e.g., "Cannot deactivate partner with active customers") -- Showing informational messages (e.g., "All vendor types configured") -- Error prevention messaging -- Status explanations -- Form validation guidance - -### Color Compliance Rules -1. **Always use AgMission color palette** - Never use custom colors -2. **Follow semantic color usage** - Info=blue, Warning=amber, Error=red -3. **Maintain contrast ratios** - Ensure accessibility compliance -4. **Use hover variations** - Provide visual feedback for interactive elements - -### Internationalization Best Practices -- Store all text in `global.ts` Labels constants -- Use Angular i18n patterns with `$localize` -- Avoid hardcoded strings in component templates -- Follow AgMission i18n guidelines for variable interpolation - -## Related Components - -- **popup-tooltip**: For temporary overlay messages -- **account-editor**: Uses constraint messages for account validation -- **vehicle-edit**: Partner integration constraint messaging -- **partner-edit**: Partner dependency constraints - -## Maintenance Notes - -- Color updates should be made in `styles.scss` global definitions -- Component-specific overrides should maintain AgMission color compliance -- Test all severity levels when making style changes -- Verify accessibility compliance with screen readers and keyboard navigation - ---- - -For additional UX/UI guidelines and AgMission color specifications, refer to: -- `.github/copilot-instructions.md` - AgMission Project Color Palette -- `docs/ux_ui/` - Comprehensive UX/UI documentation -- `src/app/shared/global.ts` - Centralized constants and labels \ No newline at end of file diff --git a/Development/client/src/app/shared/constraint-message/constraint-message.component.css b/Development/client/src/app/shared/constraint-message/constraint-message.component.css deleted file mode 100644 index 5129583..0000000 --- a/Development/client/src/app/shared/constraint-message/constraint-message.component.css +++ /dev/null @@ -1,407 +0,0 @@ -/* ============================================================================ - * AGM CONSTRAINT MESSAGE COMPONENT - * AgMission Project Color Palette & Typography Compliance - * ============================================================================ */ - -/* Import AgMission Color Variables if needed for future reference */ -:host { - display: block; - font-family: "Roboto", "Helvetica Neue", sans-serif; - /* $fontFamily - AgMission standard */ -} - -/* Component-specific overrides can be added here if needed */ -/* Base styling is handled in global styles.scss */ - -/* Typography consistency enforcement */ -::ng-deep .agm-constraint-message { - font-family: "Roboto", "Helvetica Neue", sans-serif; - /* Ensure AgMission typography */ - letter-spacing: 0.25px; - /* $letterSpacing - AgMission standard */ -} - -/* Mobile-specific adjustments */ -@media (max-width: 768px) { - :host { - margin: 8px 0; - } -} - -/* High contrast mode support */ -@media (prefers-contrast: high) { - ::ng-deep .agm-constraint-message { - border-width: 2px; - } -} - -/* Focus states for accessibility using AgMission primary color */ -::ng-deep .agm-constraint-message:focus-within { - outline: 2px solid #4CAF50; - /* $primaryColor - AgMission standard */ - outline-offset: 2px; -} - -/* ============================================================================ - * SPINNER ANIMATION - Loading State Support - * ============================================================================ */ - -/* Spinner animation for loading icons */ -::ng-deep .agm-constraint-icon.pi-spinner { - animation: agm-spin 1s linear infinite; -} - -@keyframes agm-spin { - 0% { - transform: rotate(0deg); - } - - 100% { - transform: rotate(360deg); - } -} - -/* ============================================================================ - * MISSING ICON DEFINITIONS - Icons not defined in theme CSS - * ============================================================================ */ - -/* Add missing pi-exclamation-triangle icon definition */ -::ng-deep .agm-constraint-icon.pi-exclamation-triangle { - font-family: 'Material Icons'; - font-weight: normal; - font-style: normal; - font-size: 1.125rem; - /* 18px - consistent with other constraint icons */ - display: inline-block; - width: 1em; - height: 1em; - line-height: 1; - text-transform: none; - letter-spacing: normal; - word-wrap: normal; - white-space: nowrap; - direction: ltr; - text-indent: 0; - -webkit-font-smoothing: antialiased; - text-rendering: optimizeLegibility; - -moz-osx-font-smoothing: grayscale; - font-feature-settings: 'liga'; -} - -::ng-deep .agm-constraint-icon.pi-exclamation-triangle:before { - content: "warning"; -} - -/* ============================================================================ - * ACTION BUTTON STYLING - AgMission Theme Compliance - * ============================================================================ */ - -.agm-constraint-action { - margin-left: auto; - flex-shrink: 0; - display: flex; - align-items: center; -} - -.agm-constraint-button { - display: flex; - align-items: center; - gap: 6px; - padding: 6px 12px; - background: #ffffff; - /* contentBgColor - AgMission white background */ - border: 1px solid #4CAF50; - /* primaryColor - AgMission main green */ - border-radius: 3px; - /* AgMission standard border radius */ - color: #4CAF50; - /* primaryColor - AgMission main green */ - font-family: "Roboto", "Helvetica Neue", sans-serif; - /* $fontFamily - AgMission standard */ - font-size: 0.875rem; - /* 14px - consistent with AgMission button sizing */ - font-weight: 500; - /* AgMission standard button weight */ - line-height: 1.4; - letter-spacing: 0.25px; - /* $letterSpacing - AgMission standard */ - cursor: pointer; - transition: all 0.2s ease; - white-space: nowrap; - min-height: 32px; - /* Consistent button height */ -} - -.agm-constraint-button:hover:not(:disabled) { - background: #4CAF50; - /* primaryColor - AgMission main green */ - color: #ffffff; - /* primaryTextColor - white text on colored backgrounds */ - box-shadow: 0 2px 4px rgba(76, 175, 80, 0.2); - /* green shadow with opacity */ - transform: translateY(-1px); - /* Subtle lift effect */ -} - -.agm-constraint-button:active:not(:disabled) { - background: #2E7D32; - /* primaryDarkColor - darker green for active state */ - border-color: #2E7D32; - /* primaryDarkColor - darker green for active state */ - transform: translateY(0); - /* Reset transform on click */ -} - -.agm-constraint-button:focus { - outline: 2px solid #4CAF50; - /* primaryColor - AgMission standard focus */ - outline-offset: 2px; - box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1); - /* green focus ring */ -} - -.agm-constraint-button:disabled { - background: #f5f5f5; - /* Light gray background for disabled state */ - border-color: #bdbdbd; - /* dividerColor - AgMission neutral border */ - color: #bdbdbd; - /* dividerColor - AgMission neutral text */ - cursor: not-allowed; - transform: none; - box-shadow: none; -} - -.agm-constraint-button i { - font-size: 0.875rem; - /* Consistent icon size */ - opacity: 1; -} - -/* Responsive adjustments for action button */ -@media (max-width: 768px) { - .agm-constraint-button { - font-size: 0.8125rem; - /* 13px - slightly smaller on mobile */ - padding: 5px 10px; - min-height: 28px; - /* Smaller button height on mobile */ - } - - .agm-constraint-button i { - font-size: 0.8125rem; - /* Smaller icon on mobile */ - } -} - -/* ============================================================================ - * COLLAPSIBLE MODE - Trigger and Close Buttons - * ============================================================================ */ - -/* Wrapper for collapsible mode components */ -.agm-constraint-wrapper { - display: inline-block; - position: relative; - width: 100%; -} - -/* Trigger Button (Collapsed State - Icon Only) */ -.agm-constraint-trigger { - display: inline-flex; - align-items: center; - justify-content: center; - width: 44px; - /* WCAG AAA touch target size (44×44px) */ - height: 44px; - /* WCAG AAA touch target size (44×44px) */ - padding: 0; - background: transparent; - /* Transparent background - no visual box */ - border: none; - /* No border - clean icon only */ - color: #4CAF50; - /* primaryColor - AgMission main green */ - cursor: pointer; - transition: all 0.2s ease; - position: relative; - flex-shrink: 0; -} - -.agm-constraint-trigger i { - font-size: 1.125rem; - /* 18px - prominent icon for visibility */ - transition: transform 0.2s ease; -} - -.agm-constraint-trigger:hover { - color: #2E7D32; - /* primaryDarkColor - darker green on hover */ - transform: scale(1.1); - /* Slight scale effect on hover */ -} - -.agm-constraint-trigger:active { - transform: scale(0.95); - /* Press effect */ -} - -.agm-constraint-trigger:focus { - outline: 2px solid #4CAF50; - /* primaryColor - AgMission standard focus */ - outline-offset: 2px; -} - -/* Close Button (Expanded State) */ -.agm-constraint-close { - display: inline-flex; - align-items: center; - justify-content: center; - width: 32px; - /* Slightly smaller than trigger */ - height: 32px; - padding: 0; - background: transparent; - border: none; - border-radius: 3px; - /* AgMission standard border radius */ - color: #757575; - /* textSecondaryColor - AgMission secondary text */ - cursor: pointer; - transition: all 0.2s ease; - margin-left: 8px; - flex-shrink: 0; -} - -.agm-constraint-close i { - font-size: 1rem; - /* 16px - clear close icon */ - transition: transform 0.2s ease; -} - -.agm-constraint-close:hover { - background: rgba(0, 0, 0, 0.05); - /* Subtle hover background */ - color: #212121; - /* textColor - AgMission primary text */ -} - -.agm-constraint-close:hover i { - transform: rotate(90deg); - /* Rotate effect on hover */ -} - -.agm-constraint-close:active { - background: rgba(0, 0, 0, 0.1); - transform: scale(0.9); -} - -.agm-constraint-close:focus { - outline: 2px solid #4CAF50; - /* primaryColor - AgMission standard focus */ - outline-offset: 2px; -} - -/* Update constraint content to include close button */ -::ng-deep .agm-constraint-content { - display: flex; - align-items: flex-start; - gap: 12px; - position: relative; -} - -/* Smooth transition for expanded content */ -::ng-deep .agm-constraint-message { - transition: all 0.3s ease-in-out; - animation: agm-slide-in 0.3s ease-out; - transform-origin: top left; -} - -@keyframes agm-slide-in { - from { - opacity: 0; - transform: translateY(-8px); - } - - to { - opacity: 1; - transform: translateY(0); - } -} - -/* Mobile-specific adjustments for collapsible mode */ -@media (max-width: 768px) { - .agm-constraint-trigger { - width: 44px; - /* Maintain WCAG AAA touch target */ - height: 44px; - /* No box-shadow - keeps icon clean and borderless on mobile */ - } - - .agm-constraint-trigger i { - font-size: 1.25rem; - /* 20px - larger icon for mobile visibility */ - } - - .agm-constraint-close { - width: 44px; - /* Larger touch target on mobile */ - height: 44px; - margin-left: 4px; - /* Reduce spacing on mobile */ - } - - .agm-constraint-close i { - font-size: 1.125rem; - /* 18px - larger close icon on mobile */ - } - - /* Expanded message takes full width on mobile */ - ::ng-deep .agm-constraint-message { - margin-top: 8px; - width: 100%; - } -} - -/* High contrast mode support for collapsible buttons */ -@media (prefers-contrast: high) { - - .agm-constraint-trigger, - .agm-constraint-close { - border-width: 2px; - } - - .agm-constraint-trigger { - border-color: #2E7D32; - /* primaryDarkColor - higher contrast */ - } -} - -/* Ensure proper spacing when trigger button is inline */ -.agm-constraint-wrapper+* { - margin-left: 8px; - /* Space between trigger and next element */ -} - -/* ============================================================================ - * DETACHED MODE - Icon and Message Separated - * ============================================================================ */ - -/* Detached mode wrapper - keeps trigger inline */ -.agm-constraint-wrapper.agm-detached-mode { - display: inline-block; - width: auto; - vertical-align: middle; -} - -/* Detached content appears in separate container */ -::ng-deep .agm-detached-content { - width: 100%; - margin-top: 8px; - /* Space between input row and message */ -} - -/* Ensure detached trigger aligns with input field center */ -.agm-detached-mode .agm-constraint-trigger { - margin-top: -2px; - /* Align with input field vertical center */ -} \ No newline at end of file diff --git a/Development/client/src/app/shared/constraint-message/constraint-message.component.html b/Development/client/src/app/shared/constraint-message/constraint-message.component.html deleted file mode 100644 index 149c86e..0000000 --- a/Development/client/src/app/shared/constraint-message/constraint-message.component.html +++ /dev/null @@ -1,67 +0,0 @@ -
    - - - - -
    -
    - -
    - - {{ constraintTitle }} - - -
    - {{ message }} - -
    -
    -
    - -
    - - -
    -
    -
    - - - -
    -
    - -
    - - {{ constraintTitle }} - - -

    {{ message }}

    -
    -
    - -
    - - -
    -
    -
    \ No newline at end of file diff --git a/Development/client/src/app/shared/constraint-message/constraint-message.component.ts b/Development/client/src/app/shared/constraint-message/constraint-message.component.ts deleted file mode 100644 index 5092eae..0000000 --- a/Development/client/src/app/shared/constraint-message/constraint-message.component.ts +++ /dev/null @@ -1,332 +0,0 @@ -import { Component, Input, Output, EventEmitter, OnInit, ViewChild, TemplateRef, AfterViewInit, OnDestroy } from '@angular/core'; -import { Labels } from '../global'; - -/** - * Shared constraint message component following UX/UI best practices - * Provides consistent styling and accessibility for informational constraints - * across the application. - * - * Features: - * - Accessibility compliant with ARIA attributes - * - Responsive design with mobile-first approach - * - Consistent visual hierarchy and typography - * - Internationalization support - * - Multiple severity levels (info, warning, error) - * - Optional action button support for user interactions - */ -@Component({ - selector: 'agm-constraint-message', - templateUrl: './constraint-message.component.html', - styleUrls: ['./constraint-message.component.css'] -}) -export class ConstraintMessageComponent implements OnInit, AfterViewInit, OnDestroy { - readonly Labels = Labels; - - // ============================================================================ - // INPUT PROPERTIES - // ============================================================================ - - /** - * The main constraint message text to display - */ - @Input() message: string = ''; - - /** - * Optional title for the constraint (uses default if not provided) - */ - @Input() title: string = ''; - - /** - * Icon to display (defaults to info circle) - */ - @Input() icon: string = 'pi-info-circle'; - - /** - * Severity level affecting visual styling - * - 'info': Blue styling for informational constraints (default) - * - 'warning': Orange styling for warnings - * - 'error': Red styling for errors - * - 'promo': Green styling for promotional messages - */ - @Input() severity: 'info' | 'warning' | 'error' | 'promo' = 'info'; - - /** - * Whether to show the constraint title - */ - @Input() showTitle: boolean = true; - - /** - * Additional CSS classes to apply - */ - @Input() styleClass: string = ''; - - /** - * Optional tooltip text to display on hover - */ - @Input() tooltip: string = ''; - - /** - * Optional action button label text - */ - @Input() actionLabel?: string; - - /** - * Optional action button icon (PrimeNG icon class) - */ - @Input() actionIcon?: string; - - /** - * Whether the action button is disabled - */ - @Input() actionDisabled?: boolean = false; - - /** - * Enable collapsible mode (shows icon trigger, expands on click) - * When true, message starts collapsed and can be toggled by user - * When false (default), message is always visible - */ - @Input() collapsible: boolean = false; - - /** - * Initial collapsed state (only used when collapsible=true) - * true: Message starts collapsed (shows icon only) - * false: Message starts expanded (shows full content) - */ - @Input() collapsed: boolean = true; - - /** - * Icon to display on the trigger button when collapsed - * Defaults to info circle icon for informational constraints - */ - @Input() triggerIcon: string = 'pi-info-circle'; - - /** - * Detached mode - separates icon from message content - * When true, icon stays inline (e.g., beside input field) - * and expanded message appears in a separate container below - * Requires a target element ID to render the message - * Example: Icon beside input, message appears below in full-width container - */ - @Input() detached: boolean = false; - - /** - * Target element ID where detached message should render - * Only used when detached=true - * The message will be rendered inside the element with this ID - */ - @Input() detachedTarget: string = ''; - - /** - * Event emitter for action button click - */ - @Output() actionClick = new EventEmitter(); - - /** - * Event emitter for visibility state changes - * Emits true when expanded, false when collapsed - */ - @Output() visibilityChange = new EventEmitter(); - - // ============================================================================ - // COMPONENT STATE - // ============================================================================ - - /** - * Internal state tracking whether message is currently expanded - * Used for collapsible mode toggle behavior - */ - isExpanded: boolean = false; - - /** - * Template reference for detached content - * Used to render message in a separate location from the trigger icon - */ - @ViewChild('detachedContent', { static: false }) detachedContentTemplate?: TemplateRef; - - // ============================================================================ - // LIFECYCLE METHODS - // ============================================================================ - - /** - * Initialize component state based on collapsible and collapsed inputs - */ - ngOnInit(): void { - // Set initial expansion state - if (this.collapsible) { - this.isExpanded = !this.collapsed; - } else { - // Non-collapsible messages are always expanded - this.isExpanded = true; - } - } - - /** - * After view init - handle detached content rendering - */ - ngAfterViewInit(): void { - if (this.detached && this.detachedTarget && this.detachedContentTemplate) { - // Render detached content into target container - this.renderDetachedContent(); - } - } - - /** - * Cleanup when component is destroyed - */ - ngOnDestroy(): void { - // Cleanup detached content if needed - if (this.detached && this.detachedTarget) { - this.clearDetachedContent(); - } - } - - // ============================================================================ - // DETACHED MODE METHODS - // ============================================================================ - - /** - * Render detached content into target container - */ - private renderDetachedContent(): void { - const targetElement = document.getElementById(this.detachedTarget); - if (targetElement && this.detachedContentTemplate) { - // Note: For Angular 9, we're using a simpler approach - // The template will be rendered via *ngIf in the parent component - // This method is kept for future enhancement if needed - } - } - - /** - * Clear detached content from target container - */ - private clearDetachedContent(): void { - const targetElement = document.getElementById(this.detachedTarget); - if (targetElement) { - targetElement.innerHTML = ''; - } - } - - // ============================================================================ - // COMPUTED PROPERTIES - // ============================================================================ - - /** - * Get the appropriate title based on severity and input - */ - get constraintTitle(): string { - if (!this.showTitle) return ''; - - if (this.title) { - return this.title; - } - - // Default titles based on severity - switch (this.severity) { - case 'info': - return this.Labels.CONSTRAINT_INFO_TITLE; - case 'warning': - return this.Labels.CONSTRAINT_WARNING_TITLE; - case 'error': - return this.Labels.CONSTRAINT_ERROR_TITLE; - case 'promo': - return this.Labels.PROMO_TITLE; - default: - return this.Labels.CONSTRAINT_INFO_TITLE; - } - } - - /** - * Get CSS classes for the constraint message container - */ - get containerClasses(): string { - const baseClass = 'agm-constraint-message'; - const severityClass = `agm-constraint-${this.severity}`; - const customClass = this.styleClass; - - return [baseClass, severityClass, customClass].filter(Boolean).join(' '); - } - - /** - * Get the icon CSS classes - */ - get iconClasses(): string { - return `pi ${this.icon} agm-constraint-icon`; - } - - // ============================================================================ - // EVENT HANDLERS - // ============================================================================ - - /** - * Toggle the expansion state of the constraint message - * Only functional when collapsible mode is enabled - */ - toggleExpansion(): void { - if (this.collapsible) { - this.isExpanded = !this.isExpanded; - this.visibilityChange.emit(this.isExpanded); - } - } - - /** - * Expand the constraint message - * Used for programmatic expansion - */ - expand(): void { - if (this.collapsible && !this.isExpanded) { - this.isExpanded = true; - this.visibilityChange.emit(true); - } - } - - /** - * Collapse the constraint message - * Used for programmatic collapse or close button - */ - collapse(): void { - if (this.collapsible && this.isExpanded) { - this.isExpanded = false; - this.visibilityChange.emit(false); - } - } - - /** - * Handle action button click event - */ - onActionClick(): void { - if (!this.actionDisabled) { - this.actionClick.emit(); - } - } - - /** - * Check if action button should be displayed - */ - get hasAction(): boolean { - return !!(this.actionLabel && this.actionLabel.trim().length > 0); - } - - /** - * Check if trigger button should be displayed - * Shows when in collapsible mode and currently collapsed - */ - get showTrigger(): boolean { - return this.collapsible && !this.isExpanded; - } - - /** - * Check if close button should be displayed - * Shows when in collapsible mode and currently expanded - */ - get showClose(): boolean { - return this.collapsible && this.isExpanded; - } - - /** - * Check if message content should be displayed - * Always shown in non-collapsible mode, or when expanded in collapsible mode - */ - get showContent(): boolean { - return !this.collapsible || this.isExpanded; - } -} diff --git a/Development/client/src/app/shared/creditcard-exp-cal/creditcard-exp-cal.component.css b/Development/client/src/app/shared/creditcard-exp-cal/creditcard-exp-cal.component.css deleted file mode 100644 index e69de29..0000000 diff --git a/Development/client/src/app/shared/creditcard-exp-cal/creditcard-exp-cal.component.html b/Development/client/src/app/shared/creditcard-exp-cal/creditcard-exp-cal.component.html deleted file mode 100644 index 459de4b..0000000 --- a/Development/client/src/app/shared/creditcard-exp-cal/creditcard-exp-cal.component.html +++ /dev/null @@ -1,6 +0,0 @@ -
    -
    *Expiration date
    -
    - -
    -
    \ No newline at end of file diff --git a/Development/client/src/app/shared/creditcard-exp-cal/creditcard-exp-cal.component.ts b/Development/client/src/app/shared/creditcard-exp-cal/creditcard-exp-cal.component.ts deleted file mode 100644 index 7487256..0000000 --- a/Development/client/src/app/shared/creditcard-exp-cal/creditcard-exp-cal.component.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; -import { CardExp, PkgValid } from '@app/domain/models/subscription.model'; -import { SubAppErr, SubErrMsgs } from '@app/profile/common'; -const MAX_YEAR = 2040; - -@Component({ - selector: 'creditcard-exp-cal', - templateUrl: './creditcard-exp-cal.component.html', - styleUrls: ['./creditcard-exp-cal.component.css'] -}) -export class CreditcardExpCalComponent implements OnInit { - @Input() month: number; - @Input() year: number; - @Output() selDateEvt = new EventEmitter(); - @Output() cardExpValidator = new EventEmitter(); - - yearRange: string; - minDate: Date; - maxDate: Date; - cardExp: CardExp; - selectedDate: Date; - - constructor() { - this.minDate = new Date(); - this.maxDate = new Date(); - this.selectedDate = new Date(); - this.maxDate.setFullYear(MAX_YEAR); - this.yearRange = `${this.minDate.getFullYear()}:${this.maxDate.getFullYear()}`; - } - - ngOnInit(): void { - this.selectedDate.setMonth(this.month - 1); - this.selectedDate.setFullYear(this.year); - this.cardExp = { expMonth: this.month, expYear: this.year }; - if (!this.isExpired()) this.cardExpValidator.emit({ isValid: true }); - } - - selDate() { - if (this.selectedDate && !this.isExpired()) { - this.cardExp = { expMonth: this.selectedDate.getMonth() + 1, expYear: this.selectedDate.getFullYear() }; - this.cardExpValidator.emit({ isValid: true }); - return this.selDateEvt.emit(this.cardExp); - } - return this.cardExpValidator.emit({ isValid: false, status: { code: SubAppErr.INVALID_DATE, message: SubErrMsgs[SubAppErr.INVALID_DATE] } }); - } - - isExpired() { - return this.selectedDate < this.minDate; - } -} diff --git a/Development/client/src/app/shared/creditcard-form/creditcard-form.component.css b/Development/client/src/app/shared/creditcard-form/creditcard-form.component.css index 4b0fbb6..892c059 100644 --- a/Development/client/src/app/shared/creditcard-form/creditcard-form.component.css +++ b/Development/client/src/app/shared/creditcard-form/creditcard-form.component.css @@ -1,14 +1,20 @@ +.cc-title { + font-weight : bold; + font-size : x-large; + padding-bottom: 0.75em; +} + .cc-form { - display: flex; + display : flex; align-items: center; } .cc-form div.cc-field:first-child { - text-align: left; - padding-left: 0; - font-size: medium; - font-weight: 500; - line-height: 1.5em; + text-align : right; + padding-right: 1em; + font-size : medium; + font-weight : 500; + line-height : 1.5em; } .cc-date { @@ -17,36 +23,4 @@ .cc-date :not(:last-child) { margin-right: 1em; -} - -.field { - border: 1px solid; - box-sizing: border-box; -} - -/* Ensure Stripe Elements use full width of their container */ -.field>* { - width: 100%; - max-width: 100%; -} - -.field-label { - text-align: right; -} - -/* Mobile responsive adjustments */ -@media (max-width: 768px) { - .field { - padding: 0; - margin-bottom: 0.5rem; - } - - .field-label { - text-align: left; - margin-bottom: 0.25rem; - } -} - -[hidden] { - display: none !important; } \ No newline at end of file diff --git a/Development/client/src/app/shared/creditcard-form/creditcard-form.component.html b/Development/client/src/app/shared/creditcard-form/creditcard-form.component.html index a4c5eb2..d388be4 100644 --- a/Development/client/src/app/shared/creditcard-form/creditcard-form.component.html +++ b/Development/client/src/app/shared/creditcard-form/creditcard-form.component.html @@ -1,57 +1,51 @@
    -
    Secure Payment
    -

    Your credit or debit card information will be kept encrypted and secure.

    +
    Secure Payment
    +

    Your credit or debit card information will be kept encrypted and secure.

    - -
    -
    -
    -
    - -
    -
    -
    -
    -
    - {{cardNumErrMsg.incomplete_number || cardNumErrMsg.invalid_number}}
    -
    +
    +
    +
    + *Card holder name +
    +
    +
    + account_circle +
    - -
    -
    -
    - -
    -
    -
    -
    -
    {{cardExpErrMsg.incomplete_expiry || cardExpErrMsg.invalid_expiry_month_past || - cardExpErrMsg.invalid_expiry_year_past}}
    -
    +
    +
    + *Credit card number +
    +
    +
    + credit_card +
    - -
    -
    -
    - -
    -
    -
    -
    -
    {{cardCvcErrMsg.incomplete_cvc}}
    -
    -
    +
    +
    + *Expiration date +
    +
    + +
    - -
    - * Required field +
    +
    + *Card security code +
    +
    + +
    +
    +
    + +
    +
    + * Required field
    \ No newline at end of file diff --git a/Development/client/src/app/shared/creditcard-form/creditcard-form.component.ts b/Development/client/src/app/shared/creditcard-form/creditcard-form.component.ts index 4ddf36e..2f13b0e 100644 --- a/Development/client/src/app/shared/creditcard-form/creditcard-form.component.ts +++ b/Development/client/src/app/shared/creditcard-form/creditcard-form.component.ts @@ -1,242 +1,17 @@ -import { Component, OnInit, OnDestroy, forwardRef, Input, SimpleChange, Output, EventEmitter } from '@angular/core'; -import { ControlValueAccessor, NG_VALUE_ACCESSOR, NG_VALIDATORS, FormGroup, FormBuilder, FormControl, Validators } from '@angular/forms'; -import { SubscriptionService, CCFormValues } from '@app/domain/services/subscription.service'; -import { Status, StripeCard } from '@app/domain/models/subscription.model'; -import { ClearSubscriptionIntentStatus, LoadStripe, UpdateSubscriptionIntentStatus } from '@app/actions/subscription.actions'; -import { STRIPE_ELT_STYLE, SubAppErr, SubTexts, createSubStatus } from '@app/profile/common'; -import { BaseComp } from '../base/base.component'; - -type Func = (args: any) => void +import { Component, OnInit } from '@angular/core'; +import { GC } from '../global'; @Component({ selector: 'creditcard-form', templateUrl: './creditcard-form.component.html', - styleUrls: ['./creditcard-form.component.css'], - providers: [ - { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => CreditcardFormComponent), multi: true }, - { provide: NG_VALIDATORS, useExisting: forwardRef(() => CreditcardFormComponent), multi: true } - ] + styleUrls: ['./creditcard-form.component.css'] }) -export class CreditcardFormComponent extends BaseComp implements ControlValueAccessor, OnDestroy, OnInit { - readonly SubTexts = SubTexts; +export class CreditcardFormComponent implements OnInit { + ccRegex = GC.ccRegex; - @Input() formControl: FormControl; - @Input() dismount: boolean; - @Output() compLoaded = new EventEmitter(); - form: FormGroup; - cardNumErr: Status; - cardExpErr: Status; - cardCvcErr: Status; - cardNumErrMsg: { - incomplete_number: string; - invalid_number: string; - }; - cardExpErrMsg: { - incomplete_expiry: string; - invalid_expiry_month_past: string; - invalid_expiry_year_past: string; - }; - cardCvcErrMsg: { - incomplete_cvc: string; - }; - - card: StripeCard; - onChange: Func; - onTouched: Func; - cardNumCompleted: boolean; - cardExpCompleted: boolean; - cardCvcCompleted: boolean; - stripeHasLoaded: boolean; - isMounted: boolean; - - constructor( - private readonly fb: FormBuilder, - private readonly subSvc: SubscriptionService, - ) { - super(); - this.card = { cardNumber: void 0, cardExpiry: void 0, cardCvc: void 0 }; - this.form = this.fb.group({ - name: ['', Validators.required], - card: [null, Validators.required], - address: [{}, Validators.required] - }); - this.onChange = (args: CCFormValues) => { }; - this.onTouched = (args: CCFormValues) => { }; - this.sub$.add(this.form.valueChanges.subscribe((val: CCFormValues) => { - this.onChange(val); - this.onTouched(val); - })); - } - - get value(): CCFormValues { - return this.form.value; - } - - set value(val: CCFormValues) { - this.writeValue(val); - this.onChange(val); - this.onTouched(val); - } + constructor() { } ngOnInit(): void { - this.clearCardErrMsg(); - this.store.dispatch(new ClearSubscriptionIntentStatus()); - const hasFormValues = !!this.formControl?.value; - if (hasFormValues) { - this.form.controls['name'].setValue(this.formControl.value['name']); - this.form.controls['address'].setValue(this.formControl.value['address']) - } - this.initStripe(); } - private initStripe() { - this.stripeHasLoaded = !!this.subSvc.stripe; - const loadStripe = () => { - if (this.stripeHasLoaded) { - if (this.dismount) { - this.createCard(); - } else { - this.createCard(); - this.remount(); - } - this.compLoaded.emit(true); - } else { - this.store.dispatch(new LoadStripe()); - } - } - this.sub$.add(this.subSvc.stripeLoadStatus$.subscribe({ - next: (loaded) => { - this.stripeHasLoaded = loaded; - if (loaded) loadStripe(); - }, - error: (loaded) => { - if (!loaded) this.compLoaded.emit(false); - } - })); - loadStripe(); - } - - ngOnChanges(changes: { [property: string]: SimpleChange }) { - let change: SimpleChange = changes['dismount']; - const isDismount = !!change.currentValue; - if (isDismount) { - this.unmountCard(); - this.isMounted = false; - } else { - this.isMounted = true; - if (!change.isFirstChange()) { - if (this.card) { - return this.remount(); - } - return this.createCard(); - } - } - } - - private createCard() { - const elements = this.subSvc.stripe.elements(); - this.card.cardNumber = elements.create('cardNumber', { style: STRIPE_ELT_STYLE }); - this.card.cardExpiry = elements.create('cardExpiry', { style: STRIPE_ELT_STYLE }); - this.card.cardCvc = elements.create('cardCvc', { style: STRIPE_ELT_STYLE }); - } - - private remount() { - this.clearCardErrMsg(); - this.mountCard() - this.attachListeners(); - } - - private mountCard() { - this.card?.cardNumber?.mount('#card-number'); - this.card?.cardExpiry?.mount('#card-expiry'); - this.card?.cardCvc?.mount('#card-cvc'); - } - - private unmountCard() { - this.card?.cardNumber?.unmount(); - this.card?.cardExpiry?.unmount(); - this.card?.cardCvc?.unmount(); - } - - private clearCardErrMsg() { - this.cardNumErrMsg = { incomplete_number: '', invalid_number: '' }; - this.cardExpErrMsg = { incomplete_expiry: '', invalid_expiry_month_past: '', invalid_expiry_year_past: '' }; - this.cardCvcErrMsg = { incomplete_cvc: '' }; - } - - private attachListeners() { - const handleIncomplete = () => { - const hasNoCardError = !this.cardNumErr && !this.cardExpErr && !this.cardCvcErr; - if (hasNoCardError) { - const incomplete = !this.cardNumCompleted || !this.cardExpCompleted || !this.cardCvcCompleted; - if (incomplete) this.store.dispatch(new UpdateSubscriptionIntentStatus(createSubStatus(SubAppErr.INC_CARD_ERR))); - } - } - this.card?.cardNumber?.on('ready', () => { - this.form.controls['card'].setValue(null); - this.cardNumCompleted = false; - this.cardExpCompleted = false; - this.cardCvcCompleted = false; - this.card?.cardNumber.focus(); - }); - this.card?.cardNumber?.on('change', (evt) => { - this.cardNumCompleted = evt.complete; - this.cardNumErr = evt.error; - this.cardNumErrMsg = { incomplete_number: '', invalid_number: '' }; - if (this.cardNumErr) { - this.cardNumErrMsg[this.cardNumErr?.code] = SubTexts[this.cardNumErr?.code]; - } else { - this.store.dispatch(new ClearSubscriptionIntentStatus()); - } - this.form.controls['card'].setValue(this.card.cardNumber); - }); - this.card?.cardNumber?.on('blur', () => handleIncomplete()); - this.card?.cardExpiry?.on('change', (evt) => { - this.cardExpCompleted = evt.complete; - this.cardExpErr = evt.error; - this.cardExpErrMsg = { incomplete_expiry: '', invalid_expiry_month_past: '', invalid_expiry_year_past: '' }; - if (this.cardExpErr) { - this.cardExpErrMsg[this.cardExpErr?.code] = SubTexts[this.cardExpErr?.code]; - } else { - this.store.dispatch(new ClearSubscriptionIntentStatus()); - } - this.form.updateValueAndValidity(); - }); - this.card?.cardExpiry?.on('blur', () => handleIncomplete()); - this.card?.cardCvc?.on('change', (evt) => { - this.cardCvcCompleted = evt.complete; - this.cardCvcErr = evt.error; - this.cardCvcErrMsg = { incomplete_cvc: '' }; - if (this.cardCvcErr) { - this.cardCvcErrMsg[this.cardCvcErr?.code] = SubTexts[this.cardCvcErr?.code]; - } else { - this.store.dispatch(new ClearSubscriptionIntentStatus()); - } - this.form.updateValueAndValidity(); - }); - this.card?.cardCvc?.on('blur', () => handleIncomplete()); - } - - validate() { - const formValid = this.form.valid && this.cardNumCompleted && this.cardExpCompleted && this.cardCvcCompleted; - return formValid ? null : { ccInfo: { valid: false } }; - } - - writeValue(val: CCFormValues): void { - if (val) this.form.patchValue(val); - if (val === null) this.form.reset(); - } - - registerOnChange(fn: any): void { - this.onChange = fn; - } - - registerOnTouched(fn: any): void { - this.onTouched = fn; - } - - ngOnDestroy(): void { - this.store.dispatch(new ClearSubscriptionIntentStatus()); - super.ngOnDestroy(); - } } diff --git a/Development/client/src/app/shared/creditcard/creditcard.component.css b/Development/client/src/app/shared/creditcard/creditcard.component.css deleted file mode 100644 index e69de29..0000000 diff --git a/Development/client/src/app/shared/creditcard/creditcard.component.html b/Development/client/src/app/shared/creditcard/creditcard.component.html deleted file mode 100644 index cae6870..0000000 --- a/Development/client/src/app/shared/creditcard/creditcard.component.html +++ /dev/null @@ -1,21 +0,0 @@ -
    -
    *Credit card number
    -
    -
    -
    {{getCardNumErrMsg()}}
    -
    -
    -
    -
    *Expiration date
    -
    -
    -
    {{getCardExpErrMsg()}}
    -
    -
    -
    -
    *Card security code
    -
    -
    -
    {{getCardCVCErrMsg()}}
    -
    -
    \ No newline at end of file diff --git a/Development/client/src/app/shared/creditcard/creditcard.component.ts b/Development/client/src/app/shared/creditcard/creditcard.component.ts deleted file mode 100644 index 5a5ad20..0000000 --- a/Development/client/src/app/shared/creditcard/creditcard.component.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core'; -import { STRIPE_ELT_STYLE, SubAppErr, SubTexts, createSubStatus } from '@app/profile/common'; -import { BaseComp } from '../base/base.component'; -import { SubscriptionService } from '@app/domain/services/subscription.service'; -import { PkgValid, Status, StripeCard } from '@app/domain/models/subscription.model'; -import { LoadStripe } from '@app/actions/subscription.actions'; -import { StripeCardNumberElement } from '@stripe/stripe-js'; - -@Component({ - selector: 'creditcard', - templateUrl: './creditcard.component.html', - styleUrls: ['./creditcard.component.css'] -}) -export class CreditcardComponent extends BaseComp implements OnInit, OnDestroy { - readonly SubTexts = SubTexts; - @Output() cardEvt = new EventEmitter(); - @Output() cardEvtValidator = new EventEmitter(); - - stripeHasLoaded: boolean; - card: StripeCard; - cardNumErrMsg: { - incomplete_number: string; - invalid_number: string; - }; - cardExpErrMsg: { - incomplete_expiry: string; - invalid_expiry_month_past: string; - invalid_expiry_year_past: string; - }; - cardCvcErrMsg: { - incomplete_cvc: string; - }; - cardNumErr: Status; - cardExpErr: Status; - cardCvcErr: Status; - cardNumCompleted: boolean; - cardExpCompleted: boolean; - cardCvcCompleted: boolean; - - constructor( - private readonly subSvc: SubscriptionService, - ) { - super(); - this.card = { cardNumber: void 0, cardExpiry: void 0, cardCvc: void 0 }; - } - - ngOnInit(): void { - this.clearCardErrMsg(); - this.loadStripe(); - } - - private loadStripe() { - this.stripeHasLoaded = !!this.subSvc.stripe; - if (this.stripeHasLoaded) { - this.createCardElts(); - this.attachListeners(); - setTimeout(() => this.mountCard()); - } - } - - private createCardElts() { - const elements = this.subSvc.stripe.elements(); - this.card.cardNumber = elements.create('cardNumber', { style: STRIPE_ELT_STYLE }); - this.card.cardExpiry = elements.create('cardExpiry', { style: STRIPE_ELT_STYLE }); - this.card.cardCvc = elements.create('cardCvc', { style: STRIPE_ELT_STYLE }); - } - - private mountCard() { - this.card?.cardNumber?.mount('#card-number'); - this.card?.cardExpiry?.mount('#card-expiry'); - this.card?.cardCvc?.mount('#card-cvc'); - } - - private attachListeners() { - const handleIncomplete = () => { - const isIncomplete = (!this.cardNumErr && !this.cardExpErr && !this.cardCvcErr) && !this.cardNumCompleted || !this.cardExpCompleted || !this.cardCvcCompleted; - if (isIncomplete) this.cardEvtValidator.emit({ isValid: false, status: createSubStatus(SubAppErr.INC_CARD_ERR) }); - } - this.card?.cardNumber?.on('ready', () => { - this.cardNumCompleted = false; - this.cardExpCompleted = false; - this.cardCvcCompleted = false; - this.card?.cardNumber.focus(); - }); - this.card?.cardNumber?.on('change', (evt) => { - this.cardNumCompleted = evt.complete; - this.cardNumErr = evt.error; - this.cardNumErrMsg = { incomplete_number: '', invalid_number: '' }; - if (this.cardNumErr) this.cardNumErrMsg[this.cardNumErr?.code] = SubTexts[this.cardNumErr?.code]; - this.cardEvt.emit(this.card.cardNumber); - this.validateCard(); - }); - this.card?.cardNumber?.on('blur', () => { - handleIncomplete(); - }); - this.card?.cardExpiry?.on('change', (evt) => { - this.cardExpCompleted = evt.complete; - this.cardExpErr = evt.error; - this.cardExpErrMsg = { incomplete_expiry: '', invalid_expiry_month_past: '', invalid_expiry_year_past: '' }; - if (this.cardExpErr) this.cardExpErrMsg[this.cardExpErr?.code] = SubTexts[this.cardExpErr?.code]; - this.validateCard(); - }); - this.card?.cardExpiry?.on('blur', () => { - handleIncomplete(); - }); - this.card?.cardCvc?.on('change', (evt) => { - this.cardCvcCompleted = evt.complete; - this.cardCvcErr = evt.error; - this.cardCvcErrMsg = { incomplete_cvc: '' }; - if (this.cardCvcErr) this.cardCvcErrMsg[this.cardCvcErr?.code] = SubTexts[this.cardCvcErr?.code]; - this.validateCard(); - }); - this.card?.cardCvc?.on('blur', () => { - handleIncomplete(); - }); - } - - private clearCardErrMsg() { - this.cardNumErrMsg = { incomplete_number: '', invalid_number: '' }; - this.cardExpErrMsg = { incomplete_expiry: '', invalid_expiry_month_past: '', invalid_expiry_year_past: '' }; - this.cardCvcErrMsg = { incomplete_cvc: '' }; - } - - private validateCard() { - const isCardNumValid = this.cardNumCompleted && !this.cardNumErr; - const isCardExpValid = this.cardExpCompleted && !this.cardExpErr; - const isCardCvcValid = this.cardCvcCompleted && !this.cardCvcErr; - const isValid = isCardNumValid && isCardExpValid && isCardCvcValid; - this.cardEvtValidator.emit({ isValid }); - } - - getCardNumErrMsg() { - return this.cardNumErrMsg.invalid_number || this.cardNumErrMsg.incomplete_number; - } - - getCardExpErrMsg() { - return this.cardExpErrMsg.invalid_expiry_month_past || this.cardExpErrMsg.invalid_expiry_year_past || this.cardExpErrMsg.incomplete_expiry; - } - - getCardCVCErrMsg() { - return this.cardCvcErrMsg.incomplete_cvc; - } - - ngOnDestroy(): void { - super.ngOnDestroy(); - } - -} diff --git a/Development/client/src/app/shared/crop-editor.component.ts b/Development/client/src/app/shared/crop-editor.component.ts index 6d21400..3eda22f 100644 --- a/Development/client/src/app/shared/crop-editor.component.ts +++ b/Development/client/src/app/shared/crop-editor.component.ts @@ -56,7 +56,6 @@ export class CropEditorComponent implements OnInit, AfterViewInit, OnDestroy { @Input() colors: SelectItem[]; @Output() added = new EventEmitter(); - @Output() addingNewCropJobEvt = new EventEmitter(); @ViewChild('crop') cropName: ElementRef; color: any; @@ -84,7 +83,6 @@ export class CropEditorComponent implements OnInit, AfterViewInit, OnDestroy { addNew() { this.mode = 1; - this.addingNewCropJobEvt.emit(this.mode); setTimeout(() => { if (this.cropName) this.cropName.nativeElement.focus(); }, 500); @@ -100,17 +98,14 @@ export class CropEditorComponent implements OnInit, AfterViewInit, OnDestroy { this.added.next(newCrop); this.mode = 0; - this.addingNewCropJobEvt.emit(this.mode); } onCancel() { this.mode = 0; - this.addingNewCropJobEvt.emit(this.mode); } ngOnDestroy() { this.mode = 0; - this.addingNewCropJobEvt.emit(this.mode); if (this.sub$) this.sub$.unsubscribe(); } } diff --git a/Development/client/src/app/shared/debounce.directive.ts b/Development/client/src/app/shared/debounce.directive.ts index e10c346..7f83305 100644 --- a/Development/client/src/app/shared/debounce.directive.ts +++ b/Development/client/src/app/shared/debounce.directive.ts @@ -3,81 +3,6 @@ import { Subject, Subscription } from 'rxjs'; import { debounceTime } from 'rxjs/operators'; // Ref: https://coryrylan.com/blog/creating-a-custom-debounce-click-directive-in-angular -/** -### **DebounceDirective** - -The `DebounceDirective` is an Angular directive that provides a way to debounce input events, ensuring that the output event is emitted only after a specified delay. This is particularly useful for scenarios like form inputs or search fields where frequent user interactions can trigger multiple events in a short period of time. - ---- - -#### **Selector** -```html -[agmDebounce] -``` - ---- - -#### **Inputs** -| Input | Type | Default | Description | -|-------------|---------|---------|-----------------------------------------------------------------------------| -| `delay` | `number`| `500` | The debounce delay in milliseconds. This determines how long to wait before emitting the event. | - ---- - -#### **Outputs** -| Output | Type | Description | -|------------------|--------------------|-----------------------------------------------------------------------------| -| `debounceInput` | `EventEmitter`| Emits the debounced event after the specified delay. | - ---- - -#### **Usage** -1. Add the directive to an input element to debounce its events. -2. Bind to the `debounceInput` output to handle the debounced event. - -```html - -``` - -```typescript -onDebouncedInput(event: any) { - console.log('Debounced input:', event); -} -``` - ---- - -#### **Features** -- Debounces multiple input events (`input`, `onInput`, `onChange`) to reduce the frequency of event emissions. -- Prevents unnecessary event handling for high-frequency user interactions. -- Configurable debounce delay via the `delay` input. - ---- - -#### **Implementation Details** -1. **Debouncing Logic**: - - The directive uses an internal `Subject` (`inputs`) to collect input events. - - The `debounceTime` operator is applied to delay the emission of events. - - The debounced event is emitted via the `debounceInput` `EventEmitter`. - -2. **Event Handling**: - - The directive listens to `input`, `onInput`, and `onChange` events using the `@HostListener` decorator. - - It prevents the default behavior and stops propagation of the original events. - -3. **Lifecycle Management**: - - The subscription to the debounced observable is created in the `ngOnInit` lifecycle hook. - - The subscription is properly cleaned up in the `ngOnDestroy` lifecycle hook to prevent memory leaks. - ---- - -#### **References** -- [RxJS debounceTime Operator](https://rxjs.dev/api/operators/debounceTime) -- [Angular HostListener Documentation](https://angular.io/api/core/HostListener) - ---- - -This directive is a reusable utility for debouncing input events in Angular applications. - */ @Directive({ selector: '[agmDebounce]' }) @@ -99,7 +24,6 @@ export class DebounceDirective implements OnInit, OnDestroy { this.inputs.next(event); } - @HostListener('onChange', ['$event']) @HostListener('input', ['$event']) inputEvent(event) { event.preventDefault && event.preventDefault(); diff --git a/Development/client/src/app/shared/display-config/display-config.component.html b/Development/client/src/app/shared/display-config/display-config.component.html index 75b1a74..10ff744 100644 --- a/Development/client/src/app/shared/display-config/display-config.component.html +++ b/Development/client/src/app/shared/display-config/display-config.component.html @@ -3,17 +3,17 @@ Display
    -
    +
    - % + %
    -
    +