Compare commits

...

1 Commits

Author SHA1 Message Date
66c90b731d copy of subscription-invoicing branch as of April 22 2026 2026-04-22 15:08:58 -04:00
652 changed files with 5589 additions and 863296 deletions

View File

@ -2,5 +2,5 @@ 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_PROJECT_ID=agmission
GOOGLE_APPLICATION_CREDENTIALS=google-cloud.json

View File

@ -6,6 +6,5 @@
"formate.alignColon": true,
"formate.verticalAlignProperties": true,
"formate.enable": true,
"formate.additionalSpaces": 0,
"specstory.cloudSync.enabled": "never"
"formate.additionalSpaces": 0
}

File diff suppressed because it is too large Load Diff

View File

@ -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
1 Event Category Event Name Event Description Component Location Parameter Name Parameter Type Parameter Description Allowed Values Example Value Required/Optional Business Purpose Validation Rules
2 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
3 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
4 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
5 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
6 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
7 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
8 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
9 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
10 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
11 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
12 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
13 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
14 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
15 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
16 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
17 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
18 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
19 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
20 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
21 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
22 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
23 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
24 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
25 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
26 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
27 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
28 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
29 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
30 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
31 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
32 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
33 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
34 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
35 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
36 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
37 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
38 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
39 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
40 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
41 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
42 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
43 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
44 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
45 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
46 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
47 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
48 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
49 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
50 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
51 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
52 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
53 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
54 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
55 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
56 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
57 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
58 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
59 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
60 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
61 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
62 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
63 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
64 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
65 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
66 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
67 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
68 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
69 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
70 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
71 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
72 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
73 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
74 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
75 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
76 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
77 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
78 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
79 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
80 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
81 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
82 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
83 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
84 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
85 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
86 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
87 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
88 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
89 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
90 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
91 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
92 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
93 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
94 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
95 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
96 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
97 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
98 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
99 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
100 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
101 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
102 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
103 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
104 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
105 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
106 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
107 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
108 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
109 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
110 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
111 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
112 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
113 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
114 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
115 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)
116 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
117 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
118 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
119 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
120 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
121 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
122 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
123 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
124 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
125 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
126 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
127 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
128 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
129 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
130 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
131 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
132 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
133 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
134 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
135 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
136 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
137 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
138 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
139 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
140 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
141 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
142 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
143 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
144 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
145 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
146 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
147 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
148 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
149 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
150 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
151 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
152 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
153 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
154 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
155 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
156 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
157 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
158 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
159 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
160 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
161 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
162 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
163 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
164 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
165 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
166 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
167 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
168 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
169 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
170 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
171 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
172 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
173 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
174 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
175 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
176 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
177 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
178 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
179 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
180 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
181 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
182 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
183 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
184 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
185 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
186 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
187 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
188 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
189 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
190 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
191 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
192 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
193 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
194 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
195 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
196 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
197 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
198 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
199 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
200 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
201 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
202 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
203 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
204 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
205 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
206 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
207 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
208 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
209 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
210 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
211 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
212 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
213 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
214 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
215 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
216 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
217 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
218 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
219 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
220 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
221 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

View File

@ -103,8 +103,7 @@
},
{
"type": "anyComponentStyle",
"maximumWarning": "12kb",
"maximumError": "18kb"
"maximumWarning": "6kb"
}
]
},

View File

@ -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<br/>Auth required - v3.0"]
F --> G["Server filters by customer eligibility<br/>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<string, ActivePromo>` 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)<br/>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)<br/>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<br/>mode = Mode.REGULAR]
ISNEW -->|No - existing sub change| CONFIRM[Confirm dialog<br/>then dispatchStartBillingInfo<br/>mode = Mode.REGULAR]
REGDIRECT --> SBI[StartBillingInfo dispatched<br/>prorateTS = DateUtils.currUTC]
CONFIRM --> SBI
SBI --> NAV([Navigate to /checkout])
NAV --> INIT[initPage]
INIT --> ISTRIALCK{isTrial?}
ISTRIALCK -->|No - regular| INVOICES[Fetch upcoming invoices<br/>calcChkoutPayment]
INVOICES --> CAP[checkApplicablePromos]
CAP --> GATE["getPromoForLookupKey<br/>hasAnyPackageSub? NO<br/>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<br/>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<br/>Calculate trialEndDate]
TRIALS --> PKGTRIALEND["selPkg = { ...currSel.selPkg,<br/>trialEnd: trialEndDate }"]
PKGTRIALEND --> ADDONTRIALEND["selAddons = addons.map<br/>addon.trialEnd = trialEndDate"]
ADDONTRIALEND --> ISNEW{isNewSub?}
ISNEW -->|Yes| TRIALDIRECT[dispatchStartBillingInfo<br/>mode = Mode.TRIALING<br/>prorateTS = null]
ISNEW -->|No - existing trial change| TRIALCONFIRM[Confirm dialog<br/>then dispatchStartBillingInfo<br/>mode = Mode.TRIALING]
TRIALDIRECT --> SBI[StartBillingInfo dispatched]
TRIALCONFIRM --> SBI
SBI --> NAV([Navigate to /checkout])
NAV --> INIT[initPage]
INIT --> ISTRIALCK{isTrial = true}
ISTRIALCK --> TRIALITEMS["createTrialItems<br/>from selPkg + selAddons"]
TRIALITEMS --> CTIP1["checkTrialItemPromos<br/>activePromos EMPTY at this point<br/>totalPromoSavings = 0"]
CTIP1 --> AMOUNT1["amount.total = grossTotal - 0<br/>STALE - full price"]
NAV --> LAP[loadActivePromos async]
LAP --> PROMOMAP[activePromos Map built<br/>from /api/activePromos response]
PROMOMAP --> CTIP2["checkTrialItemPromos<br/>activePromos NOW loaded"]
CTIP2 --> PROMOFOUND{promo in activePromos<br/>for this lookupKey?}
PROMOFOUND -->|Yes - e.g. ess_1_1 eligibility=all| SAVINGS["totalPromoSavings recalculated<br/>paymentPromos populated"]
SAVINGS --> AMOUNTFIX["amount.total = grossTotal - totalPromoSavings<br/>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<br/>Mode.REGULAR]) --> REGPATH["checkApplicablePromos<br/>uses chkoutPmt.lineItems<br/>gate: hasAnyPackageSub"]
REGPATH --> REGPROMO([paymentPromos map<br/>promo badges + savings])
TRIAL([Trial flow<br/>Mode.TRIALING]) --> TRIALPATH["checkTrialItemPromos<br/>uses trialItems<br/>no subscription gate<br/>looks up activePromos directly"]
TRIALPATH --> TRIALPROMO(["paymentPromos map<br/>promo badges + discounted trial total<br/>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<br/>AND status === trialing?}
C -->|Yes| NULL2([return null - trial IS the promo])
C -->|No| D{mode === available<br/>AND userHasThis?}
D -->|Yes| NULL3([return null - item already subscribed])
D -->|No| E{mode === subscribed<br/>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<br/>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<br/>an individual promo?}
E -->|No| NULL3([return null])
E -->|Yes| F{All promos share same<br/>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<br/>Promo display suppressed]
LEGACY -->|No| SUB{isUserSubscribed?}
SUB -->|Yes - subscribed| ACTIVE["getPromoForLookupKey(key, package, subscribed)"]
ACTIVE -->|promo found| ACTIVELABEL["agm-active-promo-label<br/>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<br/>e.g. AgMission Essentials 1 Plus"]
AVAIL -->|null| NOTHING2[No promo label]
ROW --> PRICECOL[Price column]
PRICECOL --> PROMOCHECK["getPromoForLookupKey(key, package)<br/>mode defaults to available"]
PROMOCHECK -->|promo found| CROSSEDPRICE["original-price crossed out<br/>promo-price shown<br/>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<br/>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)<br/>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<br/>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.

View File

@ -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=<i18n message>
LoginComponent constructor reads queryParams
nav.extractedUrl.queryParams['loginNotice']
Pushes { severity: 'info', detail: loginNotice }
into this.msgs → rendered by <p-messages>
┌──────────────────────────────────────────┐
│ [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 `<p-messages>` 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.

View File

@ -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)
- [`<payment-amount>` Template Guide](#payment-amount-template-guide)
- [`<payment-summary>` 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 `<payment-summary [mode]="REGULAR">`**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
### `<payment-amount>` 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
```
---
### `<payment-summary>` Mode Guide
A mode-driven wrapper that picks the layout and calls `<payment-amount>` 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 `<payment-amount>` (Template 7) |
| `editable` | `boolean` | Shows Edit button in REGULAR mode |
| `promos` | `Map<string, any>` | Promo badge data for `<payment-info>` |
---
## 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)
└── <payment-summary [showApplicableTax]="authSvc.isCanada">
└── <payment-amount [showApplicableTax]="showApplicableTax">
└── 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 |

View File

@ -21,7 +21,7 @@
"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",
"pre-translate": "npx --prefix ../shared/translation translation start && npm run sync-i18n && npx --prefix ../shared/translation translation translate && npx --prefix ../shared/translation 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"
},

View File

@ -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;
}
}

View File

@ -8,17 +8,10 @@
<user-profile-form formControlName="profile" [focusOnFirst]="isNew"></user-profile-form>
</div>
<div class="ui-g-12 ui-md-6 ui-lg-6 form-row">
<label for="accountType" class="field-label">
<ng-container i18n="@@accountType">Account Type</ng-container>
<!-- Account Type Disabled Feedback - Icon inline with label (detached mode) -->
<agm-constraint-message #accountTypeConstraint *ngIf="shouldShowAccountTypeDisabledMessage"
[collapsible]="true" [detached]="true" [message]="accountTypeConstraintMessage"
[title]="accountTypeConstraintTitle" severity="info" icon="pi-info-circle" class="inline-constraint">
</agm-constraint-message>
</label>
<div class="field-input">
<p-dropdown name="type" formControlName="kind" [options]="kinds" [style]="{'min-width': '120px'}"
(onChange)="onAccountTypeChange($event.value)">
<span style="margin-right:12px">
<ng-container i18n="@@accountType">Account Type</ng-container>:
</span>
<p-dropdown name="type" formControlName="kind" [options]="kinds" [style]="{'min-width': '120px'}">
<ng-template let-type pTemplate="item">
<span>
<strong>{{ type.label }}</strong>
@ -26,151 +19,13 @@
</ng-template>
</p-dropdown>
</div>
<!-- Account Type message appears below dropdown (detached content) -->
<div *ngIf="shouldShowAccountTypeDisabledMessage" class="field-message">
<ng-container *ngTemplateOutlet="accountTypeConstraint?.detachedContentTemplate"></ng-container>
<div class="ui-g-12 ui-g-nopad form-row ui-fluid" style="padding-top: 0px">
<agm-account-editor formControlName="account" [isNew]="isNew" [required]="true" [showActive]="true"></agm-account-editor>
</div>
</div>
<!-- Vendor Selection (conditionally shown) -->
<div *ngIf="showVendorOptions" class="ui-g-12 ui-md-6 ui-lg-6 form-row">
<!-- Show label and dropdown only when vendors are available -->
<ng-container *ngIf="availableVendorOptions.length > 1">
<label for="{{VENDOR_SYSTEM_FIELD}}" class="field-label">
<ng-container i18n="@@vendorSystemLabel">Partner System</ng-container>
<!-- Vendor System Disabled Feedback - Icon inline with label (detached mode) -->
<agm-constraint-message #vendorSystemConstraint *ngIf="shouldShowVendorSystemDisabledMessage"
[collapsible]="true" [detached]="true" [message]="vendorSystemConstraintMessage"
[title]="vendorSystemConstraintTitle" severity="info" icon="pi-info-circle" class="inline-constraint">
</agm-constraint-message>
</label>
<div class="field-input">
<p-dropdown name="{{VENDOR_SYSTEM_FIELD}}" formControlName="{{VENDOR_SYSTEM_FIELD}}"
[options]="availableVendorOptions" [style]="{'min-width': '120px'}"
(onChange)="onVendorChange($event.value)"
[class.ng-invalid]="form.get(VENDOR_SYSTEM_FIELD)?.invalid && form.get(VENDOR_SYSTEM_FIELD)?.touched">
</p-dropdown>
</div>
<!-- Validation error for when dropdown is visible -->
<div *ngIf="form.get(VENDOR_SYSTEM_FIELD)?.invalid && form.get(VENDOR_SYSTEM_FIELD)?.touched"
class="ui-message ui-messages-error field-message">
<div *ngIf="form.get(VENDOR_SYSTEM_FIELD)?.hasError('required')" i18n="@@vendorSelectionRequired">
Partner System
selection is required for partner system accounts</div>
</div>
<!-- Vendor System message appears below dropdown (detached content) -->
<div *ngIf="shouldShowVendorSystemDisabledMessage" class="field-message">
<ng-container *ngTemplateOutlet="vendorSystemConstraint?.detachedContentTemplate"></ng-container>
</div>
</ng-container>
<!-- Vendor loading indicator -->
<div *ngIf="vendorsLoading" class="loading-indicator">
<i class="fa fa-spinner fa-spin"></i> {{ Labels.LOADING_VENDOR_OPTIONS }}
</div>
<!-- Constraint message when no vendors available (replaces dropdown) -->
<agm-constraint-message *ngIf="availableVendorOptions.length <= 1 && !vendorsLoading" [collapsible]="true"
[message]="Labels.NO_AVAILABLE_VENDORS_MESSAGE" [title]="Labels.NO_AVAILABLE_VENDORS_TITLE"
severity="info" icon="pi-info-circle" class="field-message">
</agm-constraint-message>
</div>
<!-- Test Connection Section (conditionally shown for all partner systems) -->
<div *ngIf="showVendorOptions && selectedVendor && selectedVendor !== ''"
class="ui-g-12 test-connection-section">
<div class="test-connection-controls">
<button *ngIf="!isNew" pButton type="button" icon="ui-icon-link" [label]="Labels.TEST_CONNECTION"
i18n-pTooltip="@@testPartnerConnection" pTooltip="Test partner connection with current credentials"
tooltipPosition="bottom" [disabled]="satlocLoading" (click)="onTestPartnerConnection()">
</button>
<!-- Connection Status Icons -->
<div *ngIf="!isNew && !satlocLoading">
<!-- Success/Active Status -->
<span *ngIf="satlocIntegration.status === 'active'" class="connection-status-badge success"
i18n-pTooltip="@@connectionActive" pTooltip="Connection is active and working"
tooltipPosition="right">
<i class="ui-icon-check"></i>
</span>
<!-- Error Status -->
<span *ngIf="satlocIntegration.status === 'error'" class="connection-status-badge error"
i18n-pTooltip="@@connectionError" pTooltip="Connection failed or has errors" tooltipPosition="right">
<i class="ui-icon-close"></i>
</span>
</div>
</div>
<p-dialog [(visible)]="showSaveBeforeTestDialog" [modal]="true" [responsive]="true" [closable]="false"
[style]="{width: '450px'}" [header]="Labels.SAVE_BEFORE_TEST_TITLE">
<div class="dialog-content">
<!-- Info Message -->
<p>{{ Labels.SAVE_BEFORE_TEST_MESSAGE }}</p>
<!-- Warning Message -->
<agm-constraint-message [title]="Labels.SAVE_BEFORE_TEST_WARNING_TITLE"
[message]="Labels.SAVE_BEFORE_TEST_WARNING_MESSAGE" severity="warning" icon="pi-exclamation-triangle">
</agm-constraint-message>
</div>
<p-footer>
<button pButton type="button" [label]="Labels.CANCEL_BUTTON" icon="ui-icon-close"
(click)="onCancelSaveBeforeTest()" class="ui-button-secondary">
</button>
<button pButton type="button" [label]="Labels.SAVE_AND_TEST_BUTTON" icon="ui-icon-save"
(click)="onConfirmSaveBeforeTest()" class="ui-button-success">
</button>
</p-footer>
</p-dialog>
<agm-constraint-message *ngIf="(postSaveValidationError && !postSaveValidationInProgress) || satlocError"
[message]="postSaveValidationError ? postSaveErrorMessage : satlocError"
[title]="Labels.POST_SAVE_VALIDATION_FAILED_TITLE" severity="error" icon="pi-times"
class="post-save-message">
</agm-constraint-message>
<div *ngIf="postSaveValidationInProgress" class="validation-progress">
<i class="pi pi-spinner pi-spin"></i>
<span>{{ Labels.VALIDATING_CREDENTIALS }}</span>
</div>
<agm-constraint-message *ngIf="isNew" [message]="Labels.TEST_CONNECTION_UNAVAILABLE_MESSAGE"
[title]="Labels.TEST_CONNECTION_UNAVAILABLE_TITLE" severity="info" icon="pi-info-circle"
class="field-message">
</agm-constraint-message>
<div *ngIf="satlocLoading" class="loading-indicator">
<i class="pi pi-spinner pi-spin"></i>
<span i18n="@@processingRequest">Processing request...</span>
</div>
</div>
<div class="ui-g-12 ui-g-nopad form-row ui-fluid" style="padding-top: 0px; margin-top: 16px;">
<agm-account-editor #accountEditor formControlName="account" [isNew]="isNew" [required]="true"
[showActive]="true" [isPartnerSystemUser]="isCurrentAccountPartnerSystemUser"
[showAccountConstraint]="shouldShowAccountTypeDisabledMessage"
[accountConstraintMessage]="accountTypeConstraintMessage"
[accountConstraintTitle]="accountTypeConstraintTitle">
</agm-account-editor>
</div>
<!-- Account constraint message appears below account-editor (detached content) -->
<div *ngIf="shouldShowAccountTypeDisabledMessage" class="ui-g-12">
<ng-container *ngTemplateOutlet="accountEditor?.accountConstraint?.detachedContentTemplate"></ng-container>
</div>
<div class="ui-g-12 toolbar padtop1 ui-fluid">
<button pButton [disabled]="form.invalid" type="button" style="width:auto"
[icon]="isNew ? 'ui-icon-plus' : 'ui-icon-save'" [label]="isNew ? globals.create : globals.save"
(click)="saveAccount(); false"></button>
<button pButton type="button" style="width:auto" class="amber-btn" icon="ui-icon-arrow-back"
(click)="goBack()" [label]="globals.back"></button>
[icon]="isNew ? 'ui-icon-plus' : 'ui-icon-save'" [label]="isNew ? globals.create : globals.save" (click)="saveAccount(); false"></button>
<button pButton type="button" style="width:auto" class="amber-btn" icon="ui-icon-arrow-back" (click)="goBack()" [label]="globals.back"></button>
</div>
</div>
</form>

View File

@ -1,10 +1,7 @@
<div class="ui-g">
<div class="ui-g-12">
<div class="card">
<p-table #dt [columns]="cols" [value]="accounts" [loading]="isLoading" selectionMode="single"
(onRowSelect)="onRowSelect($event)" (onRowUnselect)="onRowSelect($event)" [paginator]="true" [rows]="15"
[pageLinks]="5" [rowsPerPageOptions]="null" [alwaysShowPaginator]="false" [(selection)]="currAcc" dataKey="_id"
[resetPageOnSort]="false" [responsive]="true" stateStorage="session" stateKey="atb-ops">
<p-table #dt [columns]="cols" [value]="accounts" selectionMode="single" (onRowSelect)="onRowSelect($event)" (onRowUnselect)="onRowSelect($event)" [paginator]="true" [rows]="15" [pageLinks]="5" [rowsPerPageOptions]="null" [alwaysShowPaginator]="false" [(selection)]="currAcc" dataKey="_id" [resetPageOnSort]="false" [responsive]="true" stateStorage="session" stateKey="atb-ops">
<ng-template pTemplate="caption">
<span class="table-caption-1" i18n="@@acountList">Account List</span>
</ng-template>
@ -19,8 +16,7 @@
<th *ngFor="let col of columns" [ngSwitch]="col.filtered" class="ui-fluid">
<div class="input-with-icon" *ngSwitchCase="true">
<i class="ui-icon-search"></i>
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)"
[value]="dt.filters[col.field]?.value">
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
</div>
<span *ngSwitchDefault></span>
</th>
@ -31,8 +27,7 @@
<td *ngFor="let col of columns" [ngSwitch]="col.field">
<span class="ui-column-title">{{col.header}}</span>
<span *ngSwitchCase="KIND">{{ resolveFieldData(rowData, col.field) | userType }}</span>
<span *ngSwitchCase="ACTIVE"><p-checkbox [ngModel]="rowData[ACTIVE]" disabled
binary="true"></p-checkbox></span>
<span *ngSwitchCase="ACTIVE"><p-checkbox [ngModel]="rowData[ACTIVE]" disabled binary="true"></p-checkbox></span>
<span *ngSwitchDefault>{{ resolveFieldData(rowData, col.field) }}</span>
</td>
@ -40,12 +35,9 @@
</ng-template>
</p-table>
<div class="ui-widget-header ui-helper-clearfix toolbar">
<button type="button" *ngIf="canWrite" pButton icon="ui-icon-plus" (click)="newAccount()" i18n-label="@@new"
label="New"></button>
<button type="button" [disabled]="!canEdit" pButton icon="ui-icon-edit" (click)="editAccount()"
i18n-label="@@detail" label="Detail"></button>
<button type="button" [disabled]="!canDelete" *ngIf="canWrite" pButton icon="ui-icon-trash"
(click)="deleteAccount()" i18n-label="@@delete" label="Delete"></button>
<button type="button" *ngIf="canWrite" pButton icon="ui-icon-plus" (click)="newAccount()" i18n-label="@@new" label="New"></button>
<button type="button" [disabled]="!canEdit" pButton icon="ui-icon-edit" (click)="editAccount()" i18n-label="@@detail" label="Detail"></button>
<button type="button" [disabled]="!canEdit" *ngIf="canWrite" pButton icon="ui-icon-trash" (click)="deleteAccount()" i18n-label="@@delete" label="Delete"></button>
</div>
</div>
</div>

View File

@ -7,7 +7,7 @@ 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';
@ -20,9 +20,8 @@ import { Utils } from '@app/shared/utils';
export class AccountListComponent extends BaseComp implements OnInit, OnDestroy {
readonly resolveFieldData = Utils.resolveFieldData;
readonly KIND = 'kind';
readonly ACTIVE = OperationalStatus.ACTIVE;
readonly ACTIVE = 'active';
accounts: Array<User>;
isLoading: boolean;
currAcc: User;
cols: any[];
userFilter: string;
@ -52,10 +51,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 +71,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 +81,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;

View File

@ -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';
@ -38,7 +37,6 @@ import { FEATURE_KEY, reducer } from './reducers/users.reducer';
ToolbarModule,
SplitButtonModule,
TableModule,
TooltipModule,
StoreModule.forFeature(FEATURE_KEY, reducer),
EffectsModule.forFeature([AccountEffects]),

View File

@ -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 {

View File

@ -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,9 +9,7 @@ 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 {
@ -19,8 +17,7 @@ export class AccountEffects {
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<Action> = this.actions$.pipe(
ofType<userActions.Fetch>(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<Action> = this.actions$.pipe(
ofType<userActions.Create>(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<Action> = this.actions$.pipe(
ofType<userActions.Update>(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)),
switchMap(({ payload }) =>
this.userSvc.saveUser(payload).pipe(
map(() => new userActions.UpdateSuccess(payload)),
catchError(err => {
console.error('Partner cleanup failed:', err);
// User update succeeded, cleanup failed is not critical
return of(new userActions.UpdateSuccess(savedUser));
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.save).replace('#thing#', globals.account));
return of(new userActions.UpdateFailed());
})
);
})
);
}
// 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()
)
)
);
@Effect()
deleteUser$: Observable<Action> = this.actions$.pipe(
ofType<userActions.Delete>(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()
);
// Partner user workflow methods - use PartnerService exclusively
private createPartnerSystemUser(userData: any, partnerConfig: any): Observable<Action> {
// 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<any> {
// 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<string | null> {
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<any> {
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);
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())
})
)
);
// 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<Action> {
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());
}
}
}

View File

@ -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,98 +9,18 @@ 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) => {

View File

@ -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:

View File

@ -185,12 +185,6 @@ export class UpdateAmount implements Action {
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 {
@ -512,7 +506,6 @@ export type SubscriptionIntentAction =
| UpdateBillingAddressSuccess
| UpdateSubscriptionSuccess
| UpdateAmount
| UpdatePromoSavings
| ClearPrevStage
| GotoUsageDetail
| LoadStripe

View File

@ -7,7 +7,6 @@ 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';
@ -31,10 +30,6 @@ 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),
@ -80,16 +75,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,38 +100,6 @@ 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 },
];

View File

@ -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() {

View File

@ -28,23 +28,17 @@
<app-topbar></app-topbar>
<div class="layout-menu" [ngClass]="{'layout-menu-dark':darkMenu}" (click)="onMenuClick($event)">
<app-inline-profile *ngIf="profileMode=='inline'&&!isHorizontal()"></app-inline-profile>
<app-menu></app-menu>
</div>
<div class="layout-main">
<div class="content-expiry-banner" *ngIf="expiryWarning$ | async as warning">
<span class="expiry-warning"
[class.warning]="!warning.willAutoRenew"
[class.info]="warning.willAutoRenew"
(click)="onNavigateToManageSubscription()">
{{ getExpiryWarningMessage(warning) }}
</span>
</div>
<div class="layout-content">
<ng-container *ngIf="canDisplayTrial()">
<trial-message [trials]="membership.trials" [isTrialDays]="isTrialDays()" [canDisplayAcceptTrial]="canDisplayAcceptTrial()" (accept)="accept()">
</trial-message>
</ng-container> <router-outlet></router-outlet>
</ng-container>
<router-outlet></router-outlet>
<agm-footer *ngIf="showFooter" [showLang]="!isAdmin"></agm-footer>
</div>
</div>

View File

@ -4,8 +4,7 @@ import { AuthService } from './domain/services/auth.service';
import { ActivatedRoute, 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';
@ -13,10 +12,7 @@ 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';
import { IMembership } from './auth/models/user.model';
enum MenuOrientation {
STATIC,
@ -75,8 +71,6 @@ export class AppMainComponent implements AfterViewInit, OnDestroy, OnInit, After
settings: IAppConfig;
membership: IMembership;
user$: Observable<UserModel>;
expiryWarning$: Observable<ExpiryWarning | null>;
constructor(
public readonly zone: NgZone,
@ -92,11 +86,6 @@ export class AppMainComponent implements AfterViewInit, OnDestroy, OnInit, After
) {
this.membership = this.route.snapshot.data['membership'];
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() {
@ -106,14 +95,6 @@ export class AppMainComponent implements AfterViewInit, OnDestroy, OnInit, After
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);
@ -467,11 +448,6 @@ 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);
}

View File

@ -28,8 +28,6 @@ export class AppMenuComponent implements OnInit {
ngOnInit() {
if (this.authSvc.hasRole([RoleIds.ADMIN])) {
this.creatAdminMenu()
} else if (this.authSvc.isPartner) {
this.createPartnerMenu();
} else {
this.createUserMenu();
}
@ -39,39 +37,7 @@ export class AppMenuComponent implements OnInit {
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;
}
@ -108,19 +74,6 @@ export class AppMenuComponent implements OnInit {
} 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[]) {
@ -208,8 +161,7 @@ export class AppMenuComponent implements OnInit {
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'] }
{ id: 'areaLib', label: $localize`:@@manageAreasLib:Manage Areas Library`, icon: 'folder_special', routerLink: ['/tools/areas'] }
]
}
);

View File

@ -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,6 +52,7 @@ 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';
@ -87,7 +88,7 @@ export function translationsFactory(locale: string) {
imports: [
BrowserModule, BrowserAnimationsModule, HttpClientModule, GlobalModule,
InputTextModule, ButtonModule, MenuModule, ProgressSpinnerModule, ScrollPanelModule,
MessagesModule, ToastModule, ConfirmDialogModule, DialogModule, DropdownModule, CheckboxModule, AppSharedModule,
MessagesModule, ToastModule, ConfirmDialogModule, DropdownModule, CheckboxModule, AppSharedModule,
// The store that defines our app state
StoreModule.forRoot(reducers, {
metaReducers,
@ -115,8 +116,10 @@ export function translationsFactory(locale: string) {
AppMenuitemComponent,
AppInlineProfileComponent,
AppTopbarComponent,
AppFooterComponent,
LanguageSwicherComponent,
ReportComponent,
AppPasswordResetComp
AppPasswordResetComp,
],
providers: [
{

View File

@ -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;
}

View File

@ -1,36 +0,0 @@
<div class="account-summary-info" *ngIf="user">
<span class="account-username">{{ user.username }}</span>
<span class="account-type font-bold">{{ getAccountType(user) }}</span>
<span *ngIf="user.contact" class="account-contact">({{ user.contact }})</span>
<span *ngIf="expiryWarning" class="expiry-warning" (click)="onWarningClick()"
[class.warning]="!expiryWarning.willAutoRenew" [class.info]="expiryWarning.willAutoRenew">
{{ getWarningMessage() }}
</span>
</div>
<p-dialog [(visible)]="showMasterPopup" [modal]="true" [resizable]="false"
i18n-header="@@masterAccDetails" header="Master Account Details" [style]="{'width': '400px'}">
<p i18n="@@subManagedByMasterMsg">AgMission subscriptions of <strong>{{ masterInfo?.name }}</strong> are managed by the Master account, please contact:</p>
<table class="master-info-table" style="width:100%; border-collapse:collapse;">
<tr>
<td style="padding:4px 8px; font-weight:bold;" i18n="@@usernameLabel">Username</td>
<td style="padding:4px 8px;">{{ masterInfo?.username }}</td>
</tr>
<tr *ngIf="masterInfo?.contact">
<td style="padding:4px 8px; font-weight:bold;" i18n="@@contactLabel">Contact</td>
<td style="padding:4px 8px;">{{ masterInfo?.contact }}</td>
</tr>
<tr *ngIf="masterInfo?.phone">
<td style="padding:4px 8px; font-weight:bold;" i18n="@@phoneLabel">Phone</td>
<td style="padding:4px 8px;">{{ masterInfo?.phone }}</td>
</tr>
<tr *ngIf="masterInfo?.email && masterInfo?.email !== masterInfo?.username">
<td style="padding:4px 8px; font-weight:bold;" i18n="@@emailLabel">Email</td>
<td style="padding:4px 8px;">{{ masterInfo?.email }}</td>
</tr>
</table>
<ng-template pTemplate="footer">
<button type="button" pButton icon="pi pi-times" (click)="showMasterPopup = false"
i18n-label="@@closeBtn" label="Close"></button>
</ng-template>
</p-dialog>

View File

@ -1,138 +1,69 @@
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: `
<div class="profile" [ngClass]="{'profile-expanded':active}">
<a href="#" (click)="onClick($event)">
<img class="profile-image" src="assets/layout/images/avatar.png" />
<span class="profile-name">Jane Williams</span>
<i class="material-icons">keyboard_arrow_down</i>
</a>
</div>
<ul class="ultima-menu profile-menu" [@menu]="active ? 'visible' : 'hidden'">
<li role="menuitem">
<a href="#" class="ripplelink" [attr.tabindex]="!active ? '-1' : null">
<i class="material-icons">person</i>
<span>Profile</span>
</a>
</li>
<li role="menuitem">
<a
href="#"class="ripplelink" [attr.tabindex]="!active ? '-1' : null" (click)="onLogout($event)"
>
<i class="material-icons">power_settings_new</i>
<span>Logout</span>
</a>
</li>
</ul>
`,
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<void>();
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();
}
}

View File

@ -2,9 +2,8 @@
<div class="topbar-left">
<div class="agm-logo"></div>
</div>
<div *ngIf="user$ | async as user" class="topbar-right" style="display: flex; justify-content: flex-end;">
<app-inline-profile [user]="user" [expiryWarning]="expiryWarning$ | async"
(navigateToSubscription)="onNavigateToManageSubscription()"></app-inline-profile>
<div *ngIf="user$ | async as user" class="topbar-right">
<a id="menu-button" href="#" (click)="app.onMenuButtonClick($event)">
<i></i>
</a>
@ -14,8 +13,7 @@
<span *ngIf="!app.isAdmin" class="topbar-badge animated">1</span>
</a>
<ul class="topbar-items animated fadeInDown" [ngClass]="{ 'topbar-items-visible': app.topbarMenuActive }">
<li #profile class="profile-item" *ngIf="app.profileMode === 'top' || app.isHorizontal()"
[ngClass]="{ 'active-top-menu': app.activeTopbarItem === profile }">
<li #profile class="profile-item" *ngIf="app.profileMode === 'top' || app.isHorizontal()" [ngClass]="{ 'active-top-menu': app.activeTopbarItem === profile }">
<a href="#" (click)="app.onTopbarItemClick($event, profile)">
<i class="topbar-icon material-icons">apps</i>
<span class="topbar-item-name">Profile</span>

View File

@ -1,77 +1,27 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { Observable, Subscription, combineLatest } from 'rxjs';
import { first, filter, switchMap, map } from 'rxjs/operators';
import { Observable } 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';
import { Mode, SUB } from './profile/common';
import { StartBillingInfo } from './actions/subscription.actions';
@Component({
selector: 'app-topbar',
templateUrl: './app.topbar.component.html'
})
export class AppTopbarComponent implements OnInit, OnDestroy {
user$: Observable<UserModel>;
expiryWarning$: Observable<ExpiryWarning | null>;
private sub$ = new Subscription();
export class AppTopbarComponent {
user$: Observable<UserModel>
constructor(
public readonly app: AppMainComponent,
private readonly store: Store<{}>,
private readonly router: Router,
private readonly userSvc: UserService
private readonly 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() {
@ -83,7 +33,7 @@ export class AppTopbarComponent implements OnInit, OnDestroy {
}
manageContact(user) {
this.router.navigate([SUB.PROFILE, SUB.BILL_ADR_LIST]);
this.store.dispatch(new StartBillingInfo({ applicatorId: user?._id, mode: Mode.UPDATE_BIL_ADR }));
}
onLogout(e) {
@ -94,12 +44,4 @@ export class AppTopbarComponent implements OnInit, OnDestroy {
updateUserProfile(userId: string) {
this.router.navigate([SUB.PROFILE, 'edit', userId]);
}
/**
* Navigate to manage subscription page
* Triggered by subscription expiry notification click
*/
onNavigateToManageSubscription(): void {
this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]);
}
}

View File

@ -37,17 +37,9 @@ export class LogoutComplete implements Action {
}
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 =
| Login
| LoginSuccess
| LoginFailed
| Logout
| LogoutComplete
| RefreshUserData;
| LogoutComplete;

View File

@ -50,9 +50,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);
window.location.replace((lang === 'en' ? `${hash}` : `/${lang}${hash}`) + 'home');
}
@Effect()

View File

@ -12,11 +12,8 @@
<div class="ui-g-12">
<span class="md-inputfield">
<input type="text" name="username" [pattern]="GC?.emailRegex" email [(ngModel)]="model.username"
#username="ngModel" required autocomplete="on" pInputText
(input)="onUsernameValidation(username.invalid, username.dirty, username.touched)"
(blur)="onUsernameValidation(username.invalid, username.dirty, username.touched)">
<span *ngIf="showUsernameError" class="ui-message ui-messages-error ui-corner-all">
<input type="text" name="username" [pattern]="GC?.emailRegex" email [(ngModel)]="model.username" #username="ngModel" required autocomplete="on" pInputText>
<span *ngIf="username.invalid && (username.dirty || username.touched)" class="ui-message ui-messages-error ui-corner-all">
{{ userValidMsg() }}
</span>
<label i18n="@@userName">Username</label>
@ -24,29 +21,22 @@
</div>
<div class="ui-g-12">
<span class="md-inputfield">
<input type="password" name="password" agmPwdToggle [(ngModel)]="model.password" #password="ngModel" required
autocomplete="on" pInputText
(input)="onPasswordValidation(password.invalid, password.dirty, password.touched)"
(blur)="onPasswordValidation(password.invalid, password.dirty, password.touched)">
<span i18n="@@pwdReqVal" *ngIf="showPasswordError" class="ui-message ui-messages-error ui-corner-all">Password
is required</span>
<input type="password" name="password" agmPwdToggle [(ngModel)]="model.password" #password="ngModel" required autocomplete="on" pInputText>
<span i18n="@@pwdReqVal" *ngIf="password.invalid && (password.dirty || password.touched)" class="ui-message ui-messages-error ui-corner-all">Password is required</span>
<label i18n="@@password">Password</label>
</span>
</div>
<div class="ui-g-12" *ngIf="useReCaptcha">
<span class="md-inputfield">
<ngx-recaptcha2 #captchaElem [siteKey]="siteKey" [size]="size" [hl]="lang" [theme]="theme" [type]="type"
name="recaptcha" data-size="compact" [useGlobalDomain]="false" [(ngModel)]="model.token"
#recaptcha="ngModel" (load)="handleLoad()" (reload)="handleReload()" (success)="handleSuccess($event)"
(expire)="handleExpire()" (reset)="handleReset()" (error)="handleError($event)">
<ngx-recaptcha2 #captchaElem [siteKey]="siteKey" [size]="size" [hl]="lang" [theme]="theme" [type]="type" name="recaptcha" data-size="compact" [useGlobalDomain]="false"
[(ngModel)]="model.token" #recaptcha="ngModel"
(load)="handleLoad()" (reload)="handleReload()" (success)="handleSuccess($event)" (expire)="handleExpire()" (reset)="handleReset()" (error)="handleError($event)">
</ngx-recaptcha2>
<span i18n="@@reCaptchaReqMsg" *ngIf="(useReCaptcha && !captchaSuccess)" class="ui-message ui-messages-error"
style="width: 100%;">You must complete the reCAPTCHA to log in.</span>
<span i18n="@@reCaptchaReqMsg" *ngIf="(useReCaptcha && !captchaSuccess)" class="ui-message ui-messages-error" style="width: 100%;">You must complete the reCAPTCHA to log in.</span>
</span>
</div>
<div class="ui-g-12">
<button type="submit" [disabled]="(!f.valid) || (useReCaptcha && !captchaSuccess) || (pending$ | async)"
i18n-label="@@login" label="Login" icon="ui-icon-person" pButton></button>
<button type="submit" [disabled]="(!f.valid) || (useReCaptcha && !captchaSuccess) || (pending$ | async)" i18n-label="@@login" label="Login" icon="ui-icon-person" pButton></button>
<img *ngIf="pending$ | async"
src="data:image/gif;base64,R0lGODlhEAAQAPIAAP///wAAAMLCwkJCQgAAAGJiYoKCgpKSkiH/C05FVFNDQVBFMi4wAwEAAAAh/hpDcmVhdGVkIHdpdGggYWpheGxvYWQuaW5mbwAh+QQJCgAAACwAAAAAEAAQAAADMwi63P4wyklrE2MIOggZnAdOmGYJRbExwroUmcG2LmDEwnHQLVsYOd2mBzkYDAdKa+dIAAAh+QQJCgAAACwAAAAAEAAQAAADNAi63P5OjCEgG4QMu7DmikRxQlFUYDEZIGBMRVsaqHwctXXf7WEYB4Ag1xjihkMZsiUkKhIAIfkECQoAAAAsAAAAABAAEAAAAzYIujIjK8pByJDMlFYvBoVjHA70GU7xSUJhmKtwHPAKzLO9HMaoKwJZ7Rf8AYPDDzKpZBqfvwQAIfkECQoAAAAsAAAAABAAEAAAAzMIumIlK8oyhpHsnFZfhYumCYUhDAQxRIdhHBGqRoKw0R8DYlJd8z0fMDgsGo/IpHI5TAAAIfkECQoAAAAsAAAAABAAEAAAAzIIunInK0rnZBTwGPNMgQwmdsNgXGJUlIWEuR5oWUIpz8pAEAMe6TwfwyYsGo/IpFKSAAAh+QQJCgAAACwAAAAAEAAQAAADMwi6IMKQORfjdOe82p4wGccc4CEuQradylesojEMBgsUc2G7sDX3lQGBMLAJibufbSlKAAAh+QQJCgAAACwAAAAAEAAQAAADMgi63P7wCRHZnFVdmgHu2nFwlWCI3WGc3TSWhUFGxTAUkGCbtgENBMJAEJsxgMLWzpEAACH5BAkKAAAALAAAAAAQABAAAAMyCLrc/jDKSatlQtScKdceCAjDII7HcQ4EMTCpyrCuUBjCYRgHVtqlAiB1YhiCnlsRkAAAOwAAAAAAAAAAAA==" />
<a [routerLink]="['/password-reset']" href="">
@ -54,10 +44,5 @@
</a>
</div>
</form>
<div class="ui-g-12" style="padding-left: 0; padding-right: 0; padding-top: 0;">
<a [routerLink]="['/signup']" href="">
<ng-container i18n="@@signup">Sign Up for a new Agmission Master Account</ng-container>
</a>
</div>
</div>
</div>

View File

@ -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<boolean>();
private passwordValidation$ = new Subject<boolean>();
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();

View File

@ -9,10 +9,7 @@ export interface UserModel {
lang: string;
pre: number;
billable?: boolean;
membership?: IMembership,
contact: string;
country?: string;
partner?: string;
membership?: IMembership
}
export interface IMembership {
@ -20,8 +17,4 @@ export interface IMembership {
endOfPeriod?: Number;
subscriptions?: AGNavSubscription[];
trials?: Trial;
customLimits?: {
maxVehicles?: number | null;
maxAcres?: number | null;
};
}

View File

@ -7,60 +7,27 @@ import * as fromClients from './clients.reducer';
export const getClientsState = createFeatureSelector<fromClients.State>(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,

View File

@ -5,124 +5,3 @@
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;
}

View File

@ -5,16 +5,14 @@
<div class="ui-g ui-g-nopad" style="margin-top:40px">
<form [formGroup]="form">
<div class="ui-g-12 ui-g-nopad">
<user-profile-form formControlName="profile" [requireName]="true" [focusOnFirst]="isNew"
[updateCountry]="true"></user-profile-form>
<user-profile-form formControlName="profile" [requireName]="true" [focusOnFirst]="isNew" [updateCountry]="true"></user-profile-form>
</div>
<div class="ui-g-12 ui-md-6 ui-lg-6 form-row">
<span class="form-label-span">
<span style="margin-right:12px">
<ng-container i18n="@@premiumLevel">Premium Level</ng-container>:
</span>
<p-dropdown id="premium" name="premium" formControlName="premium" [options]="premiumLevels"
[style]="{'min-width': '120px'}">
<p-dropdown id="premium" name="premium" formControlName="premium" [options]="premiumLevels" [style]="{'min-width': '120px'}">
<ng-template let-type pTemplate="item">
<span>
<strong>{{ type.label }}</strong>
@ -23,53 +21,17 @@
</p-dropdown>
</div>
<!-- Partner Selection -->
<div class="ui-g-12 ui-md-6 ui-lg-6 form-row">
<span class="form-label-span">
{{ Labels.FROM_PARTNER }}:
</span>
<p-dropdown id="partner" name="partner" formControlName="partner" [options]="partnerOptions"
[style]="{'min-width': '200px'}" placeholder="Select Partner" (onChange)="onPartnerChange($event.value)"
[loading]="partnerLoading">
<ng-template let-option pTemplate="item">
<div class="partner-option">
<div class="partner-info">
<div class="partner-name">{{ option.label }}</div>
<div class="partner-description" *ngIf="option.value && option.value.description">{{
option.value.description }}</div>
</div>
</div>
</ng-template>
<ng-template let-option pTemplate="selectedItem">
<div class="partner-selected" *ngIf="option">
<span>{{ option.label }}</span>
<div class="partner-description" *ngIf="option.value && option.value.description"
style="font-size: 0.9em; color: #666;">{{ option.value.description }}</div>
</div>
</ng-template>
</p-dropdown>
<!-- Partner Error Display -->
<div *ngIf="partnerError" class="ui-message ui-messages-error ui-corner-all" style="margin-top: 5px;">
<span class="ui-messages-error-icon ui-icon ui-icon-close"></span>
<span class="ui-messages-error-summary">{{ partnerError }}</span>
</div>
</div>
<div class="ui-g-12 ui-md-6 ui-lg-6 form-row">
<p-checkbox id="billable" name="billable" formControlName="billable" label="Billable"
binary="true"></p-checkbox>
<p-checkbox id="billable" name="billable" formControlName="billable" label="Billable" binary="true"></p-checkbox>
</div>
<div class="ui-g-12 ">
<ng-container *ngIf="hasPaidSubs(); else trialing">
<ng-container [ngTemplateOutlet]="fieldSet"
[ngTemplateOutletContext]="{subs: paidSubs, label: SubTexts.paid}"></ng-container>
<ng-container [ngTemplateOutlet]="fieldSet" [ngTemplateOutletContext]="{subs: paidSubs, label: SubTexts.paid}"></ng-container>
</ng-container>
<ng-template #trialing>
<ng-container *ngIf="hasTrialSubs(); else noTrialSubs">
<ng-container [ngTemplateOutlet]="fieldSet"
[ngTemplateOutletContext]="{subs: trialSubs, label: SubTexts.trial}"></ng-container>
<ng-container [ngTemplateOutlet]="fieldSet" [ngTemplateOutletContext]="{subs: trialSubs, label: SubTexts.trial}"></ng-container>
<trial formControlName="trials" [trialDays]="trialDays" [trials]="trials" [disable]="true"></trial>
</ng-container>
<ng-template #noTrialSubs>
@ -86,16 +48,12 @@
<div class="ui-g-12">
<p-messages [(value)]="msgs" [closable]="false"></p-messages>
<agm-account-editor formControlName="account" [isNew]="isNew" (userExisted)="onUserExisted($event)"
required="true" i18n-title="@@accessAccount" title="Access Account" showActive="true">
<agm-account-editor formControlName="account" [isNew]="isNew" (userExisted)="onUserExisted($event)" required="true" i18n-title="@@accessAccount" title="Access Account" showActive="true">
</agm-account-editor>
</div>
<div class="ui-g-12 toolbar padtop1 ui-fluid">
<button pButton [disabled]="form.invalid || partnerLoading" type="button" style="width:auto"
[icon]="isNew ? 'ui-icon-plus' : 'ui-icon-save'" [label]="isNew ? globals.create : globals.save"
(click)="saveCustomer(); false"></button>
<button pButton type="button" style="width:auto" class="amber-btn" icon="ui-icon-arrow-back"
(click)="goBack()" [label]="globals.back"></button>
<button pButton [disabled]="form.invalid" type="button" style="width:auto" [icon]="isNew ? 'ui-icon-plus' : 'ui-icon-save'" [label]="isNew ? globals.create : globals.save" (click)="saveCustomer(); false"></button>
<button pButton type="button" style="width:auto" class="amber-btn" icon="ui-icon-arrow-back" (click)="goBack()" [label]="globals.back"></button>
</div>
</form>
</div>

View File

@ -2,12 +2,11 @@ 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 { GC, RoleIds, globals } 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';
@ -18,10 +17,9 @@ import { DateUtils } from '@app/shared/utils';
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;
@ -35,11 +33,6 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
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 +43,8 @@ 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
trials: this.selectedItem.membership?.trials
});
// 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,7 +55,6 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
constructor(
private readonly route: ActivatedRoute,
private readonly userSvc: UserService,
private readonly partnerSvc: PartnerService,
private readonly fb: FormBuilder
) {
super();
@ -81,13 +69,9 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
account: [],
premium: [],
billable: [],
trials: [],
// Partner form control
partner: [null]
trials: []
});
this.lang = this.authSvc.locale;
}
ngOnInit() {
@ -104,8 +88,6 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
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();
}
});
@ -136,33 +118,16 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
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 };
if (this.form.value.trials?.selected) {
const trials: Trial = { ...this.form.value.trials };
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.type === GC.BYDATE ? trials.trialDays = 0 : trials.byDate = null;
trials.startDate = DateUtils.tsToDate(DateUtils.currUTC());
return membership
return membership = membership
? { ...membership, trials }
: { trials };
} else {
return membership
return membership = membership
? { ...membership, trials: { ...membership.trials, type: null } }
: { trials: { type: null } };
}
@ -170,8 +135,7 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
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 });
{ billable: this.form.value.billable || false });
this.membership
? custObj = Object.assign(custObj, { membership: updateTrialMembship(this.membership) })
@ -209,60 +173,6 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
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();
}

View File

@ -3,15 +3,7 @@
<div class="card">
<p-table #dt [value]="customers" [columns]="cols" selectionMode="single" (onRowSelect)="onRowSelect($event)" [paginator]="true" [rows]="15" [pageLinks]="5" [rowsPerPageOptions]="[10, 15, 30]" [alwaysShowPaginator]="true" [(selection)]="curCust" dataKey="_id" [resetPageOnSort]="false" stateStorage="session" stateKey="ctb-ops" [responsive]="true">
<ng-template pTemplate="caption">
<div class="ui-g ui-g-nopad">
<div class="ui-g-6 cc-field-label">
<span class="table-caption-1" i18n="@@customerList">Customer List</span>
</div>
<div class="ui-g-6 cc-field-label">
<label style="margin-right: 8px;">Self Signup Accounts {{ isSelfSignup ? 'On' : 'Off' }}</label>
<p-inputSwitch [(ngModel)]="isSelfSignup" (onChange)="onToggle($event)"></p-inputSwitch>
</div>
</div>
</ng-template>
<ng-template pTemplate="header" let-columns>
<tr>
@ -27,9 +19,6 @@
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
</div>
<p-dropdown *ngIf="[ACTIVE, BILLABLE].includes(col.field)" [options]="statuses" [style]="{'width':'100%'}" [ngModel]="dt.filters[col.field]?.value" (onChange)="dt.filter($event.value, col.field, 'equals')"></p-dropdown>
<p-dropdown *ngIf="col.field === PARTNER_NAME" [options]="partners" [style]="{'width':'100%'}" [ngModel]="dt.filters[col.field]?.value" (onChange)="dt.filter($event.value, col.field, 'equals')"></p-dropdown>
<span *ngSwitchDefault></span>
</th>
</tr>
@ -46,7 +35,6 @@
<span *ngSwitchCase="BILLABLE">
<p-checkbox [ngModel]="rowData[BILLABLE]" disabled binary="true"></p-checkbox>
</span>
<span *ngSwitchCase="PARTNER">{{rowData[col.field]?.name}}</span>
<span *ngSwitchDefault>{{rowData[col.field]}}</span>
</td>
</tr>

View File

@ -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';
@ -18,10 +18,8 @@ import { BaseComp } from '@app/shared/base/base.component';
})
export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy {
readonly CREATED = 'createdAt';
readonly ACTIVE = OperationalStatus.ACTIVE;
readonly ACTIVE = 'active';
readonly BILLABLE = 'billable';
readonly PARTNER = 'partner';
readonly PARTNER_NAME = 'partnerName';
customers: Array<Customer>;
curCust: Customer;
@ -29,10 +27,8 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
@ViewChild("dt") dt: Table;
statuses: SelectItem[];
partners: SelectItem[];
cols: any[];
totalItems;
isSelfSignup = false;
constructor(
private readonly route: ActivatedRoute,
@ -52,51 +48,22 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
{ field: "contact", header: globals.contact },
{ field: "totalJobs", header: globals.jobs, width: '5%', filtered: false },
{ field: this.CREATED, header: globals.from, width: '6%' },
// { field: "email", header: globals.email, filtered: true, filterMatchMode: 'contains' },
{ field: this.BILLABLE, header: "Billable", width: '9%'},
{ field: this.ACTIVE, header: globals.active, width: '9%' },
{ field: this.PARTNER_NAME, header: globals.partner, 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 +91,10 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
});
}
billableOverview() {
}
ngOnDestroy() {
super.ngOnDestroy();
}

View File

@ -24,7 +24,7 @@ export class CustomerResolver implements Resolve<Customer> {
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;

View File

@ -8,24 +8,11 @@ export interface Customer extends User {
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;
membership: IMembership
}
export const createNewCustomer = () => {
const customer = createNewUser(null, RoleIds.APP) as Customer;
const customer = <Customer>createNewUser(null, RoleIds.APP);
customer.premium = 0;
customer.membership = {} as IMembership; // Initialize required membership property
return customer;
}

View File

@ -47,8 +47,7 @@ export class TrialComponent extends BaseComp implements OnDestroy, OnInit, After
}
get value() {
// CRITICAL: Use getRawValue() to include disabled controls (selected, type, trialDays)
return this.form.getRawValue();
return this.form.value;
}
set value(val) {
@ -69,43 +68,20 @@ export class TrialComponent extends BaseComp implements OnDestroy, OnInit, After
});
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);
this.sub$.add(this.form.valueChanges.subscribe((val) => {
this.onChange(val);
this.onTouched(val);
}));
}
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));
const hasExistingTrial = this.trials?.type && (this.trials.trialDays >= MIN_DAYS || this.trials.byDate);
if (hasExistingTrial) {
if (this.trials?.type === this.BYDATE && this.trials.byDate) {
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) {
} 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 });
}

View File

@ -46,12 +46,6 @@ export class AuthGuard implements CanActivate, CanActivateChild {
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) {

View File

@ -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 <p-messages> 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);
}
}

View File

@ -10,19 +10,7 @@ export class SettingsGuard implements CanActivate {
constructor(private readonly appCnf: AppConfigService) { }
canActivate(route: ActivatedRouteSnapshot): Observable<boolean> {
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();
}
}

View File

@ -24,6 +24,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;
}

View File

@ -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;

View File

@ -30,7 +30,6 @@ export interface Addon extends BasePackage {
desc: string;
lookupKey: string;
trialEnd?: number;
interval?: string; // Billing interval ('year' or 'month')
}
export interface Package extends BasePackage {
@ -42,7 +41,6 @@ export interface Package extends BasePackage {
lookupKey: string;
level?: number;
trialEnd?: number;
interval?: string; // Billing interval ('year' or 'month')
}
export interface Address {
@ -53,9 +51,19 @@ export interface Address {
country: string;
line1: string;
line2?: string | null;
postal_code?: string;
postalCode?: string;
state?: string,
isBilling?: boolean;
state?: string
}
export interface AddressPackage {
name: string;
city: string;
line1: string;
line2: string | null;
postal_code: string;
state: string;
country: string;
}
export interface Card {
@ -71,7 +79,7 @@ export interface Card {
export interface BillingInfo {
applicatorId: string;
name: string;
address
address: Address
email?: string;
}
@ -86,7 +94,7 @@ export interface InvoicePackage {
custId: string;
package: string;
addons: BasePackage[];
prorateTS?: number; // Optional: only needed for proration calculations
prorateTS: number;
coupon?: string;
}
@ -108,32 +116,6 @@ export interface Line {
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 {
@ -172,18 +154,6 @@ export interface 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 {
@ -211,7 +181,6 @@ export interface PaidAmount {
totalTax: number;
total: number;
discount?: Discount;
refundAmount?: number;
}
export interface Discount {
@ -236,7 +205,6 @@ export interface SubscriptionIntent {
coupons?: Coupon[];
mode: Mode;
subIds?: string[];
promoSavings?: number; // Total promo discount in cents (calculated in checkout)
}
export interface SubscriptionPackage {
@ -339,12 +307,6 @@ export interface StripeSubscription {
quantity: number;
price: {
lookup_key: string;
metadata?: {
maxVehicles?: string;
maxAcres?: string;
tier?: string;
level?: string;
};
}
}[];
};
@ -352,10 +314,8 @@ export interface StripeSubscription {
current_period_start: number;
default_payment_method: string;
default_source: string;
metadata?: {
metadata: {
type: string;
scheduleId?: string;
promoId?: string;
};
cancel_at_period_end: boolean;
discount?: {
@ -363,82 +323,6 @@ export interface StripeSubscription {
}
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 {
@ -449,28 +333,6 @@ export interface AGNavSubscription {
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 {
@ -481,27 +343,6 @@ export interface AGNavSubscriptionShort {
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 {
@ -533,7 +374,6 @@ export interface ConfirmPackage {
subIds: string[];
unresolved: Unresolved;
applicatorId: string;
stage?: string;
}
export interface CreatePaymentMethodPackage {
@ -568,7 +408,7 @@ export interface Aircraft {
export interface Acre {
currUsage: number;
limit: number | null; // null = unlimited acres for current subscription packages
limit: number;
overLimit: boolean;
}
@ -664,7 +504,7 @@ export interface Coupon {
export interface Trial {
selected?: boolean,
type: string;
type: 'days' | 'byDate';
startDate: Date,
lastStartDate: Date,
lastEndDate: Date,

View File

@ -14,8 +14,7 @@ export class MembershipResolver implements Resolve<IMembership> {
) { }
resolve(): Observable<IMembership> {
const id = this.authSvc.user?.parent || this.authSvc.user._id;
return this.custSvc.getCustomer(id).pipe(
return this.custSvc.getCustomer(this.authSvc.user._id).pipe(
map((cust) => {
const membership = cust?.membership;
if (membership) {

View File

@ -1,42 +1,30 @@
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 { 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';
export interface UserWithParentUsername {
user: User;
parentUsername?: string;
}
@Injectable()
export class ProfileResolver implements Resolve<UserWithParentUsername> {
export class ProfileResolver implements Resolve<User> {
constructor(
private readonly router: Router,
private readonly userService: UserService
) { }
resolve(route: ActivatedRouteSnapshot): Observable<UserWithParentUsername> {
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()
);
resolve(route: ActivatedRouteSnapshot): Observable<User> | Promise<User> | User {
const id = route.paramMap.get('id');
return this.userService.getUser(id).pipe(
map(user => {
if (user) {
return user;
} else {
return of({ user });
this.router.navigate(['/profile']);
return null;
}
}),
first()

View File

@ -20,7 +20,7 @@ export class UserResolver implements Resolve<User> {
resolve(): Observable<User> {
return this.store.select(selectAuthUser).pipe(
switchMap((authUser: UserModel) => {
return this.userService.getUser(authUser._id, { withAddresses: true })
return this.userService.getUser(authUser._id)
}),
map((user: User) => {
if (user) {

View File

@ -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<number>(0);
private readonly activePromos$: Observable<ActivePromo[]>;
// 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<ActivePromoResponse>(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<ActivePromo[]> {
// 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<ActivePromo[]> {
return this.activePromos$;
}
/**
* Get promo for a specific priceKey (e.g., 'ess_1', 'addon_1')
*/
getPromoForPriceKey(priceKey: string): Observable<ActivePromo | undefined> {
return this.activePromos$.pipe(
map(promos => promos.find(p => p.priceKey === priceKey))
);
}
/**
* Check if a priceKey has an active promo
*/
hasPromo(priceKey: string): Observable<boolean> {
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 || '';
}
}
}

View File

@ -59,23 +59,13 @@ export class AppConfigService {
return this.http.get<IAppConfig>("/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);
return of(false);
})
);
}

View File

@ -70,12 +70,7 @@ export class AuthInterceptor implements HttpInterceptor {
}
private onCatch(err: any, req: HttpRequest<any>): Observable<any> {
// 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);

View File

@ -2,7 +2,7 @@ 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 { RoleIds } from '../../shared/global';
@ -14,14 +14,10 @@ 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 {
return this._user;
}
@ -53,8 +49,6 @@ export class AuthService implements OnDestroy {
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'));
@ -89,10 +83,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}`);
}
@ -122,16 +112,18 @@ export class AuthService implements OnDestroy {
}
getCurLookupKey(type: SubType.PACKAGE | SubType.ADDON): PriceUsd {
// Use centralized utility methods
const subscriptions = this.user?.membership?.subscriptions;
let lookupKey: PriceUsd;
switch (type) {
case SubType.PACKAGE:
return this.subSvc.getCurrentPackageLookupKey(subscriptions) || '';
lookupKey = this.user?.membership?.subscriptions?.find((sub) => sub.type === SubType.PACKAGE)?.items?.[0].price || '';
break;
case SubType.ADDON:
return this.subSvc.getCurrentAddonLookupKey(subscriptions) || '';
lookupKey = this.user?.membership?.subscriptions?.find((sub) => sub.type === SubType.ADDON)?.items?.[0].price || '';
break;
default:
throw new Error('Unsupported type');
}
return lookupKey;
}
get isPlanner() {
@ -142,10 +134,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 +157,19 @@ 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 = <UserModel>{ _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'] || '' };
const user = <UserModel>{ _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'] };
this._user = user;
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<boolean> {
// 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,52 +182,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);
})
);
return this.http.post('/users/resetPassword', ops);
}
get trials() {
@ -304,13 +229,14 @@ export class AuthService implements OnDestroy {
isWithinTrialPeriod = DateUtils.currUTC() <= DateUtils.dateToTS(trialEndDate);
}
}
return this.hasRole([RoleIds.APP])
return !this.hasRole([RoleIds.ADMIN])
&& !this.hasSubs()
&& isWithinTrialPeriod;
}
canDisplayTrial(trials: Trial) {
return this.validateTrial(trials);
return this.validateTrial(trials)
&& this.subSvc.subMode !== Mode.REGULAR;
}
canAcceptTrial(url: string) {

View File

@ -17,9 +17,8 @@ export class CustomerService {
return this.http.get<Customer[]>(this.customerURL);
}
getCustomer(id: string, view?: string): Observable<Customer> {
const url = view ? `${this.customerURL}/${id}?view=${view}` : `${this.customerURL}/${id}`;
return this.http.get<Customer>(url);
getCustomer(id: string): Observable<Customer> {
return this.http.get<Customer>(`${this.customerURL}/${id}`);
}
saveCustomer(customer: Customer): Observable<Customer> {

View File

@ -4,52 +4,27 @@ import {
HttpHandler,
HttpEvent,
HttpInterceptor,
HttpErrorResponse,
HttpResponse
HttpErrorResponse
} from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';
import { catchError } 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);
}
constructor(private readonly msgSvc: AppMessageService) { }
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
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.msgSvc.addFailedMsg(globals.backendErr);
this.failedAttempts = 0; // Reset counter after showing the error
}
}
@ -57,110 +32,4 @@ export class GlobalErrorInterceptor implements HttpInterceptor {
})
);
}
private trackHttpError(error: HttpErrorResponse, req: HttpRequest<any>, 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<any>, response: HttpResponse<any>, 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<any>): 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<any>): 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
}
}

View File

@ -187,24 +187,8 @@ export class JobService {
return this.http.post<any>(`${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<any>(`${this.jobURL}/filesdata`, body);
getFilesData(ids) {
return this.http.post<any>(`${this.jobURL}/filesdata`, { fileIds: ids });
}
}

View File

@ -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) {
// 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();
}
this.currentUrl = event.url;
}
});
}
intercept<T>(req: HttpRequest<T>, next: HttpHandler): Observable<HttpEvent<T>> {
// 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()))
}
}

View File

@ -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]);
}
}

View File

@ -0,0 +1,93 @@
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { SubscriptionService } from './subscription.service';
import {
DisplayPackage,
Addon,
Package,
Price,
ConfigRes,
Address
} from '@app/domain/models/subscription.model';
describe('SubscriptionService', () => {
let subSvc: SubscriptionService;
let httpTestingController: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule]
});
subSvc = TestBed.inject(SubscriptionService);
httpTestingController = TestBed.inject(HttpTestingController);
});
const MockedPrices: Price[] = [
{type: 'essential', priceUSD: 995, lookupKey: 'ess_1'},
{type: 'enterprise', priceUSD: 1495, lookupKey: 'ent_1'},
{type: 'addon', priceUSD: 2495, lookupKey: 'addon_2'},
{type: 'addon', priceUSD: 1495, lookupKey: 'addon_1'}
];
it('should test createSubscription with prices return Display Packages', () => {
const EssPkgs: Package[] = [
{ priceId: '1', desc: '', maxVehicles: 1, Vehicles: '1', maxAcres: '50K', price: '$995.00', lookupKey: 'ess_1'},
{ priceId: '2', desc: '', maxVehicles: 11, Vehicles: '10+', maxAcres: 'Unlimited', price: 'contact', lookupKey: ''}
];
const EntPkgs: Package[] = [
{ priceId: '6', desc: '', maxVehicles: 1, Vehicles: '1', maxAcres: '50K', price: '$1,495.00', lookupKey: 'ent_1'},
{ priceId: '7', desc: '', maxVehicles: 11, Vehicles: '10+', maxAcres: 'Unlimited', price: 'contact', lookupKey: ''}
];
const Addons: Addon[] = [
{ priceId: '1', name: 'Aircraft Tracking (Per Aircraft)', desc: '', price: '$1,495.00', lookupKey: 'addon_1', quantity: 1},
{ priceId: '2', name: 'Aircraft Managing (Per Aircraft)', desc: '', price: '$2,495.00', lookupKey: 'addon_2', quantity: 1}
];
const expectedDispPkg: DisplayPackage = {
essential: EssPkgs,
enterprise: EntPkgs,
addon: Addons,
}
expect(subSvc.pricesToPkgs(MockedPrices)).toEqual(expectedDispPkg)
});
it('should test getPrices and return an array of Prices', () => {
subSvc.getPrices().subscribe((res) => {
expect(res).toEqual(MockedPrices)
});
const request = httpTestingController.expectOne('/subscription/prices');
request.flush(MockedPrices);
httpTestingController.verify();
});
it('should test getConfig and return Config object', () => {
subSvc.getConfig().subscribe((res: ConfigRes) => {
expect(res.config).toEqual('pk_1234')
});
const request = httpTestingController.expectOne('/subscription/config');
request.flush({config: 'pk_1234'});
httpTestingController.verify();
});
it('should test convertPostalCode', () => {
const address: Address = {
_id: '123',
valid: true,
name: 'justin',
city: "Richmond",
country: "CA",
line1: "4070 Robson Street",
line2: null,
postal_code: null,
postalCode: "V6V 0A4",
state: "BC"
}
const actual = subSvc.convertAddr(address)
expect(actual._id).toBeFalsy();
expect(actual.postal_code).toEqual('V6V 0A4');
expect(actual.postalCode).toBeFalsy();
expect(actual.valid).toBeFalsy();
});
});

View File

@ -1,16 +1,14 @@
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 { HttpClient } from '@angular/common/http';
import { Observable, Subject, Subscription } from 'rxjs';
import { Price, InvoicePackage, Address, Invoice, SubscriptionPackage, StripeSubscription, PaymentMethod, UnpaidPackage, AddressPackage, SubscriptionPaymentMethod, Charge, PaidAmount, AGNavSubscriptionShort, CustChargePkg, Usage, BillPeriod, UsagePackage, CheckoutPayment, Coupon, PMPkgEdit, PriceUsd, Acre, AGNavSubscription, Plan, Status, BillingInfoPackage, Package, Addon, TrialItem } 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 { Mode, SUB, SubKeys, SubStripe, SubTexts, SubType, subPlans } from '@app/profile/common';
import { map, switchMap } 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,
@ -51,7 +49,6 @@ export class SubscriptionService {
constructor(
private readonly http: HttpClient,
private readonly store: Store<{}>,
private userSvc: UserService
) {
this.sub$ = this.subMode$.subscribe((mode) => this._subMode = mode);
}
@ -77,7 +74,7 @@ export class SubscriptionService {
return this.http.get<Address>(`${BASE_URL}/billAddress/${applicatorId}`);
}
updateBillAddress(applicatorId: string, addrPkg: Address): Observable<Address> {
updateBilAdr(applicatorId: string, addrPkg: AddressPackage): Observable<Address> {
return this.http.put<Address>(`${BASE_URL}/billAddress/${applicatorId}`, addrPkg);
}
@ -89,26 +86,6 @@ export class SubscriptionService {
return this.http.post<StripeSubscription[]>(`${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<any> {
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<StripeSubscription[]> {
return this.http.get<StripeSubscription[]>(`${BASE_URL}?custId=${custId}&billInfo=true`);
}
@ -176,77 +153,11 @@ export class SubscriptionService {
editSub(subsSettings: { subId: string, cancelAtPeriodEnd: boolean }[]): Observable<StripeSubscription[]> {
return this.http.post<StripeSubscription[]>(`${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<Coupon> {
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<Coupon>(url, { params });
}
return this.http.get<Coupon>(url);
getCoupon(coupon: string): Observable<Coupon> {
return this.http.get<Coupon>(`${BASE_URL}/getCoupon/${coupon}`);
}
editPM(custId: string, pkg: PMPkgEdit): Observable<PaymentMethod> {
@ -268,167 +179,7 @@ export class SubscriptionService {
});
}
// ============================================================================
// 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<string, any>): 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
// ============================================================================
// Utils
hasSubsWithStatus(subs: StripeSubscription[], status: string): boolean {
return subs?.some((sub) => sub?.status === `${status}`);
}
@ -438,16 +189,7 @@ export class SubscriptionService {
}
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
);
return subs?.some((sub) => sub?.latest_invoice?.payment_intent?.status === SubStripe.REQUIRE_ACTION);
}
getReqPmSubscription(subs: StripeSubscription[]): StripeSubscription {
@ -456,12 +198,7 @@ export class SubscriptionService {
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
);
return subs?.find((sub) => sub?.latest_invoice?.payment_intent?.status === SubStripe.REQUIRE_ACTION);
}
atCheckoutReviewStage(): boolean {
@ -536,16 +273,12 @@ export class SubscriptionService {
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));
const pmtLines = lines.filter((line) => line.amount >= 0);
const refLines = lines.filter((line) => line.amount < 0);
let pmt: CheckoutPayment;
const pmtTotalTax = this.calcTotalAmount(this.extractLineTax(pmtLines));
if (rfdLines.length > 0) {
const refTotalTax = this.calcTotalAmount(this.extractLineTax(rfdLines));
if (refLines.length > 0) {
const refTotalTax = this.calcTotalAmount(this.extractLineTax(refLines));
pmt = {
payment: {
lineItems: pmtLines,
@ -553,8 +286,8 @@ export class SubscriptionService {
totalTax: pmtTotalTax
},
refund: {
lineItems: rfdLines,
totalAmount: this.calcTotalAmount(rfdLines) + refTotalTax,
lineItems: refLines,
totalAmount: this.calcTotalAmount(refLines) + refTotalTax,
totalTax: refTotalTax
}
};
@ -576,24 +309,19 @@ export class SubscriptionService {
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) {
const prorateInvs = invoices.filter((inv) => inv?.lines?.data?.some((line) => line?.period?.start === inv?.subscription_proration_date));
const hasNoProrate = prorateInvs.length === 0;
const hasUnResolvedInvoice = opt?.subscriptions?.some((sub) =>
sub.status === SubStripe.UNPAID ||
sub.status === SubStripe.INCOMPLETE ||
sub.status === SubStripe.PAST_DUE ||
sub.status === SubStripe.OVERDUE
) || hasNoProrate;
if (hasUnResolvedInvoice) {
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 };
@ -671,44 +399,8 @@ export class SubscriptionService {
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;
}
if (!maxAcres) return '';
const THOUSAND = 1000;
const maxAcrToK = +maxAcres / THOUSAND;
return maxAcrToK > 0 ? `${maxAcrToK}K` : maxAcres.toString();
@ -724,67 +416,24 @@ export class SubscriptionService {
);
}
/**
* 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 = {
return {
...membership,
endOfPeriod: subscriptions?.find((sub) => sub.metadata.type === SubType.PACKAGE)?.latest_invoice.period_end ||
subscriptions?.find((sub) => sub.metadata.type === SubType.ADDON)?.latest_invoice.period_end,
subscriptions: subscriptions?.map((sub) => ({
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
}
quantity: item.quantity
})),
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
type: sub.metadata.type,
cancelAtPeriodEnd: sub.cancel_at_period_end
}))
};
}
@ -795,13 +444,9 @@ export class SubscriptionService {
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 getSubscriptionItem = (type: SubType) =>
membership.subscriptions.find(sub => sub.type === type
&& (sub.status === SubStripe.ACTIVE || sub.status === SubStripe.TRIALING))?.items[0];
const createAcrePlan = (currUsage: number, limit: number): Acre => ({
currUsage,
@ -811,28 +456,12 @@ export class SubscriptionService {
const pkg = getSubscriptionItem(SubType.PACKAGE);
const addon = getSubscriptionItem(SubType.ADDON);
const pkgPrice = pkg?.price?.lookup_key;
const pkgPrice = pkg?.price;
// ✅ 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 maxAcres = isNaN(+pkg?.metadata?.maxAcres) ? 0 : +pkg?.metadata?.maxAcres;
const acre = createAcrePlan(UnitUtils.haToArea(usage.ttArea, true), maxAcres || subPlans[pkgPrice]?.maxAcres);
const maxVehicles = isNaN(+pkg?.metadata?.maxVehicles) ? 0 : +pkg?.metadata?.maxVehicles;
const pkgNumVeh = maxVehicles || subPlans[pkgPrice]?.maxVehicles || 0;
const trackNumVeh = addon?.quantity || 0;
const packagePlan = pkg ? {
@ -866,35 +495,24 @@ export class SubscriptionService {
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 ?? ''}`) || '';
.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
const lowerRange = precedingMax + MIN;
return maxVehicles > MIN && maxVehicles <= MAX
? lowerRange ? `${lowerRange}-${maxVehicles}`
: `${maxVehicles}`
: `${maxVehicles}`;
}
convertAddr(address) {
convertAddr(address: Address) {
address.postal_code = address?.postalCode;
delete address?.postalCode;
delete address?._id;
delete address?.name;
delete address?.valid;
return address;
@ -903,41 +521,26 @@ export class SubscriptionService {
createBillingInfoPackage(applicatorId): Observable<BillingInfoPackage> {
let billingInfoPackage: BillingInfoPackage;
return this.getBillingAddress(applicatorId).pipe(
switchMap((address: Address) => {
map((address: Address) => {
const hasExistingAdr = address && Object.keys(address)?.some((key) =>
key === 'name' ||
key === 'postalCode' ||
key === 'line1'
);
if (hasExistingAdr) {
return of(billingInfoPackage = {
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: ''
}
}
};
})
);
} else {
billingInfoPackage = {
isNewAccount: true
};
}
return billingInfoPackage;
}),
);
}
@ -947,7 +550,6 @@ export class SubscriptionService {
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
@ -959,7 +561,6 @@ export class SubscriptionService {
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
@ -1010,219 +611,6 @@ export class SubscriptionService {
});
}
/**
* 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();
}

View File

@ -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<User[]> {
return this.http.post<User[]>(this.userURL + '/search', options);
}
getUser(id: string, ops?: { withAddresses?: boolean; view?: 'profile' | 'edit' | 'billing' }): Observable<User> {
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<User>(url);
getUser(id: string): Observable<User> {
return this.http.get<User>(`${this.userURL}/${id}`);
}
userNameExists(userName: string): Observable<boolean> {
@ -64,29 +55,6 @@ export class UserService {
return this.http.post<User>(`${this.userURL}/getUserDetail`, { username: username });
}
signup(form: any) {
return this.http.post<any>(`${this.userURL}/signup`, form);
}
requestVerifyEmail(email: string) {
return this.http.post<any>(`${this.userURL}/signup/requestVerifyEmail`, { email });
}
signupValidate(token: string) {
return this.http.post<any>(`${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 {

View File

@ -4,9 +4,10 @@ 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 { catchError, delay, filter, repeat, retryWhen, switchMap, take } from 'rxjs/operators';
import { subPlans, SubAppErr, handleErr, SubKeys, TRACKING, PACKAGE_ACTIVE, createSubStatus, SUB, SubType, DELAY, TAKE } from '../profile/common';
import { AuthService } from '@app/domain/services/auth.service';
import { UserService } from '@app/domain/services/user.service';
import { StripeSubscription, Usage } from '@app/domain/models/subscription.model';
import { AppMessageService } from '@app/shared/app-message.service';
import { globals } from '@app/shared/global';
@ -14,7 +15,6 @@ 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 {
@ -23,107 +23,42 @@ export class SubPlansEffects {
private readonly actions$: Actions,
private readonly subSvc: SubscriptionService,
private readonly authSvc: AuthService,
private readonly userSvc: UserService,
private readonly msgSvc: AppMessageService,
private readonly vehSvc: VehicleService,
private readonly custSvc: CustomerService,
private readonly router: Router
private readonly custSvc: CustomerService
) { }
@Effect()
refreshSubPlans$: Observable<Action> = this.actions$.pipe(
ofType<subPlansActions.FetchSubPlans>(subPlansActions.FETCH_SUB_PLANS),
exhaustMap((action: subPlansActions.FetchSubPlans) => {
switchMap((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);
const sortedPrices = [...prices].sort((a, b) => a.level - b.level);
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.maxVehicles = price.maxVehicles || plan.maxVehicles;
plan.maxAcres = price.maxAcres || plan.maxAcres;
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;
if (price.maxVehicles && indx > 0) {
plan.Vehicles = this.subSvc.toVehRange(sortedPrices[indx - 1].maxVehicles, price.maxVehicles);
}
} 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);
return this.userSvc.getUser(this.authSvc.user?._id);
}),
switchMap(profileUser => {
return this.subSvc.retrieveCurrUsage(this.authSvc.user?.membership?.custId, profileUser._id);
}),
switchMap(_usage => {
usage = _usage;
@ -131,16 +66,21 @@ export class SubPlansEffects {
}),
switchMap(_subs => {
subscriptions = _subs;
return this.vehSvc.loadVehicles({ byUserId: this.authSvc.user?.parent }).pipe(
catchError(() => of([]))
);
return this.vehSvc.loadVehicles({ byUserId: this.authSvc.user?.parent });
}),
switchMap(_vehicles => {
switchMap((_vehicles) => {
vehicles = _vehicles;
const id = this.authSvc.user?.parent || this.authSvc.user._id;
let id;
if (this.authSvc.user?.parent) {
id = this.authSvc.user?.parent;
} else {
id = this.authSvc.user._id;
}
return this.custSvc.getCustomer(id);
}),
switchMap(cust => {
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);
@ -148,97 +88,8 @@ export class SubPlansEffects {
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)
}));
return of(new subPlansActions.ResetSubPlans());
}
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;
@ -247,11 +98,7 @@ export class SubPlansEffects {
];
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)
}));
actions.unshift(new FetchLatestSubscriptionSuccess({ membership: cust?.membership }));
}
if (isCurActiveVehAboveLimit || isCurTrkVehAboveLimit || needReview) {
@ -269,7 +116,7 @@ export class SubPlansEffects {
delay(DELAY),
take(TAKE)
)),
catchError(err => {
catchError((err) => {
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.load).replace('#thing#', globals.subPlans));
return handleErr<Observable<Action>>({
error: err, opt: {

View File

@ -1,21 +1,19 @@
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 { from, interval, Observable, of } from 'rxjs';
import { map, switchMap, catchError, take, takeUntil, tap, startWith, debounceTime, concatMap, repeat } 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 { Addon, Address, ConfirmPackage, Invoice, InvoicePackage, PaymentMethod, StripeSubscription, SubscriptionIntent, AddressPackage, 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 { createSubStatus, handleErr, SubAppErr, SUB, SubStripe, Mode } 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,
@ -36,8 +34,6 @@ export class SubscriptionEffects {
private readonly subSvc: SubscriptionService,
private readonly authSvc: AuthService,
private readonly custSvc: CustomerService,
private readonly ga: GAService,
private readonly gaHelpers: GAAnalyticsHelpersService,
) { }
// Common effects
@ -53,38 +49,7 @@ export class SubscriptionEffects {
fetchLatestSub$: Observable<Action> = this.actions$.pipe(
ofType<subAction.FetchLatestSubscription>(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) });
}),
map((subscriptions) => new subAction.FetchLatestSubscriptionSuccess({ subscriptions, membership: this.subSvc.updateMembShip(subscriptions, this.authSvc.user?.membership) })),
catchError((err) => handleErr<Observable<Action>>({ error: err, opt: { extra: SubAppErr.FETCH_SUB_ERR } })),
repeat()
);
@ -149,18 +114,10 @@ export class SubscriptionEffects {
);
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
};
const addrPkg: AddressPackage = { name: payload.billingInfo?.name, city: payload.billingInfo?.address?.city, line1: payload.billingInfo?.address?.line1, line2: payload.billingInfo?.address?.line2, postal_code: 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(
let handleDefault = () => this.subSvc.updateBilAdr(payload.billingInfo?.applicatorId, addrPkg).pipe(
switchMap(() => {
subIntentPkg = { ...subIntentPkg, billingInfo: payload.billingInfo };
return this.subSvc.getPaymentMethodList(payload.subIntentPkg?.custId);
@ -177,9 +134,9 @@ export class SubscriptionEffects {
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) })));
return this.subSvc.updateBilAdr(payload.billingInfo?.applicatorId, addrPkg).pipe(map((address: Address) => new subAction.UpdateBillingAddressSuccess({ applicatorId: payload.billingInfo?.applicatorId, name: address?.name, address: this.subSvc.convertAddr(address) })));
case StartCheckoutCase.NEW_ACC:
return this.subSvc.updateBillingAddressSequence(payload.billingInfo?.applicatorId, addrPkg).pipe(
return this.subSvc.updateBilAdr(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 });
@ -190,7 +147,7 @@ export class SubscriptionEffects {
})
);
case StartCheckoutCase.TRIALING:
return this.subSvc.updateBillingAddressSequence(payload.billingInfo?.applicatorId, addrPkg).pipe(
return this.subSvc.updateBilAdr(payload.billingInfo?.applicatorId, addrPkg).pipe(
switchMap(() => {
subIntentPkg = { ...subIntentPkg, billingInfo: payload.billingInfo };
return this.subSvc.getPaymentMethodList(payload.subIntentPkg?.custId);
@ -200,7 +157,7 @@ export class SubscriptionEffects {
return new subAction.StartCheckoutSuccess(subIntentPkg);
}));
case StartCheckoutCase.UNPAID:
return this.subSvc.updateBillingAddressSequence(payload.billingInfo?.applicatorId, addrPkg).pipe(
return this.subSvc.updateBilAdr(payload.billingInfo?.applicatorId, addrPkg).pipe(
map(() => {
return new subAction.StartCheckoutSuccess(subIntentPkg);
}));
@ -248,11 +205,10 @@ export class SubscriptionEffects {
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 }
billing_details: { name: payload.pmtMethod?.newPmtMeth?.billing_details?.name, address: payload.pmtMethod?.newPmtMeth?.billing_details?.address }
})).pipe(
switchMap((result: PaymentMethodResult) => {
const stripeErr = result?.error;
@ -263,11 +219,7 @@ export class SubscriptionEffects {
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({
map((subs) => new subAction.CheckoutTrialSuccess({
card: {
pmId: result?.paymentMethod?.id,
brand: result?.paymentMethod?.card?.brand,
@ -278,8 +230,7 @@ export class SubscriptionEffects {
defaultPM: payload.pmtMethod?.newPmtMeth?.defaultPM
},
subs, amount: payload.amount
});
})
}))
);
}),
);
@ -305,44 +256,20 @@ export class SubscriptionEffects {
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))
);
return of(new subAction.CheckoutTrialSuccess({ subs }), new subAction.UpdateTrial(cust.membership.trials))
})
);
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 }
billing_details: { name: payload.pmtMethod?.newPmtMeth?.billing_details?.name, address: payload.pmtMethod?.newPmtMeth?.billing_details?.address }
})).pipe(
switchMap((result: PaymentMethodResult) => {
const stripeErr = result?.error;
@ -403,10 +330,7 @@ export class SubscriptionEffects {
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(
return this.subSvc.getCoupon(action.payload.coupon).pipe(
switchMap((coupon: Coupon) => {
if (!coupon.valid) {
return handleErr<Observable<Action>>({ error: '', opt: { extra: SubAppErr.APP_DISCOUNT_PREVIEW_ERR } });
@ -422,72 +346,10 @@ export class SubscriptionEffects {
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<Observable<Action>>({
error: err,
opt: {
extra: SubAppErr.APP_DISCOUNT_PREVIEW_ERR,
msg: displayMessage
}
});
}
// Fallback to generic error handling (for other error types)
return handleErr<Observable<Action>>({
error: err,
opt: {
extra: SubAppErr.APP_DISCOUNT_PREVIEW_ERR,
msg: err?.error?.message || err?.error?.raw?.message // Try new format first, fallback to old
}
});
}),
catchError((err) => handleErr<Observable<Action>>({ error: err, opt: { extra: SubAppErr.APP_DISCOUNT_PREVIEW_ERR, msg: err?.error?.raw?.message } })),
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[]) => {
@ -503,26 +365,6 @@ export class SubscriptionEffects {
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 })),
@ -573,54 +415,11 @@ export class SubscriptionEffects {
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 confirmations$ = confirmPkg?.stripePkgs?.map((pkg) => Utils.demethodize(this.subSvc.stripe.confirmCardPayment)(pkg?.clientSecret, { payment_method: pkg?.pmId }));
const promiseChain = Utils.createPromiseChain<PaymentIntentResult>(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 })
);
@ -628,13 +427,8 @@ export class SubscriptionEffects {
);
})
);
})
);
}),
catchError((err) => {
console.error('🔴 CONFIRM EFFECT ERROR', err);
return handleErr<Observable<Action>>({ error: err, opt: { extra: SubAppErr.CONF_ERR } });
}),
catchError((err) => handleErr<Observable<Action>>({ error: err, opt: { extra: SubAppErr.CONF_ERR } })),
repeat()
);
@ -656,33 +450,7 @@ export class SubscriptionEffects {
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[];
let latestInvoices = subscriptions?.map((sub) => sub?.latest_invoice);
const hasLatestInvoices = latestInvoices?.length > 0;
if (hasLatestInvoices) {
if (req3dsVerf) {
@ -693,13 +461,6 @@ export class SubscriptionEffects {
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;
@ -709,31 +470,16 @@ export class SubscriptionEffects {
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<Observable<Action>>({ 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<Observable<Action>>({ error: err, opt: { card, extra: SubAppErr.UPDATE_SUB_ERR } }))
catchError((err) => handleErr<Observable<Action>>({ error: err, opt: { card } }))
);
}),
catchError((err) => handleErr<Observable<Action>>({ error: err, opt: { extra: SubAppErr.UPDATE_SUB_ERR } })),
@ -915,6 +661,7 @@ export class SubscriptionEffects {
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);
@ -946,9 +693,6 @@ export class SubscriptionEffects {
}
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());
@ -964,8 +708,7 @@ export class SubscriptionEffects {
new subAction.UpdateSubscriptionStatus(createSubStatus(SubStripe.REQ_LOC_INPUT))
);
}
return of(new subAction.FetchLatestSubscriptionSuccess({ subscriptions, membership: this.subSvc.updateMembShip(subscriptions, this.authSvc.user?.membership) }));
return of(new subAction.FetchLatestSubscriptionSuccess({ subscriptions, membership: this.authSvc.user?.membership }));
}))
}),
catchError((err) => handleErr<Observable<Action>>({ error: err, opt: { extra: SubAppErr.FETCH_SUB_ERR } })),
@ -1069,8 +812,7 @@ export class SubscriptionEffects {
createPaymentMethod$: Observable<Action> = this.actions$.pipe(
ofType<subAction.CreatePaymentMethod>(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(
return from(this.subSvc.stripe.createPaymentMethod({ type: 'card', card: action.payload.card, billing_details: { name: action.payload.billing_details?.name, address: action.payload.billing_details?.address } })).pipe(
switchMap((result: PaymentMethodResult) => {
const stripeErr = result?.error;
if (stripeErr) {
@ -1154,164 +896,4 @@ export class SubscriptionEffects {
catchError((err) => handleErr<Observable<Action>>({ 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<any> {
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);
})
);
}
}

View File

@ -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

View File

@ -1,9 +1,8 @@
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;
@ -13,70 +12,6 @@ export interface Vehicle extends User {
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 {
@ -88,7 +23,6 @@ export interface StatusChange {
export const createNewVehicle = (parentId: string) => {
const vehicle = <Vehicle>createNewUser(parentId, RoleIds.DEVICE);
vehicle.vehicleType = 0;
vehicle.tailNumber = '';
return vehicle;
}

View File

@ -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;
}
}

View File

@ -6,10 +6,8 @@
<div class="ui-g ui-g-nopad" style="margin-top:40px">
<div class="ui-g-12 ui-lg-6 form-row">
<span class="md-inputfield">
<input type="text" id="vehicleName" name="vehicleName" #name="ngModel" required
[(ngModel)]="selectedItem.name" #vehicleName pInputText maxlength="100">
<span i18n="@@aircraftNameReqVal" *ngIf="!name?.valid"
class="ui-message ui-messages-error ui-corner-all">Aircraft Name is required</span>
<input type="text" id="vehicleName" name="vehicleName" #name="ngModel" required [(ngModel)]="selectedItem.name" #vehicleName pInputText maxlength="100">
<span i18n="@@aircraftNameReqVal" *ngIf="!name?.valid" class="ui-message ui-messages-error ui-corner-all">Aircraft Name is required</span>
<label i18n="@@name">Name</label>
</span>
</div>
@ -17,8 +15,7 @@
<span style="margin-right:12px">
<ng-container i18n="@@aircraftType">Aircraft Type</ng-container>:
</span>
<p-dropdown name="type" [options]="acTypes" [(ngModel)]="selectedItem.vehicleType"
[style]="{'width': '120px'}">
<p-dropdown name="type" [options]="acTypes" [(ngModel)]="selectedItem.vehicleType" [style]="{'width': '120px'}">
<ng-template let-type pTemplate="item">
<span>
<strong>{{ type.label }}</strong>
@ -26,12 +23,6 @@
</ng-template>
</p-dropdown>
</div>
<div class="ui-g-12 form-row">
<agm-vehicle-partner-integration #partnerIntegration [vehicle]="selectedItem" [isNew]="isNew"
[getAccountEditorData]="getAccountEditorData.bind(this)" (partnerDataChange)="onPartnerDataChange($event)"
(validationStateChange)="onPartnerValidationStateChange($event)">
</agm-vehicle-partner-integration>
</div>
<div class="ui-g-12 ui-lg-6 form-row">
<span class="md-inputfield">
<input type="text" id="model" name="model" [(ngModel)]="selectedItem.model" pInputText maxlength="200">
@ -40,38 +31,11 @@
</span>
</div>
<div class="ui-g-12 ui-lg-6 form-row">
<div class="input-with-inline-constraint">
<span class="md-inputfield">
<input type="text" id="tailNumber" name="tailNumber" [(ngModel)]="selectedItem.tailNumber" pInputText
maxlength="20" [disabled]="isPartnerSystemSelected && canEditPartnerFields">
<span></span>
<label i18n="@@tailNumber">Tail Number</label>
</span>
<!-- Inline icon trigger (detached mode) -->
<agm-constraint-message #tailNumberConstraint *ngIf="isPartnerSystemSelected && canEditPartnerFields"
[collapsible]="true" [detached]="true" [message]="Labels.TAIL_NUMBER_PARTNER_MANAGED_MESSAGE"
[title]="Labels.PARTNER_SYSTEM_MANAGED_TITLE" severity="info" icon="pi-info-circle"
class="inline-constraint">
</agm-constraint-message>
</div>
</div>
<!-- Detached message content (appears below tail number on right side) -->
<div class="ui-g-12 ui-lg-6 ui-lg-offset-6" id="tail-number-message-container">
<ng-container *ngTemplateOutlet="tailNumberConstraint?.detachedContentTemplate"></ng-container>
</div>
<div class="ui-g-12 ui-lg-6 form-row" *ngIf="!isPartnerSystemSelected">
<span class="md-inputfield">
<input type="hidden" name="orgUnitId" [ngModel]="orgUnitId">
<input autocomplete="off" type="text" id="unitId" name="unitId" #unitId="ngModel"
[(ngModel)]="selectedItem.unitId" pInputText maxlength="15" minlength="10" agmUnitIdUnique
pKeyFilter="pint" [disabled]="!hasTracking">
<span i18n="@@minLenUnitIdMsg" *ngIf="(unitId.dirty || unitId.touched) && unitId.errors?.minlength"
class="ui-message ui-messages-error ui-corner-all">UnitId must be 10-15 digits</span>
<span *ngIf="(unitId.dirty || unitId.touched) && unitId.errors?.unitIdUnique"
class="ui-message ui-messages-error ui-corner-all">
<input autocomplete="off" type="text" id="unitId" name="unitId" #unitId="ngModel" [(ngModel)]="selectedItem.unitId" pInputText maxlength="15" minlength="10" agmUnitIdUnique pKeyFilter="pint" [disabled]="!hasTracking">
<span i18n="@@minLenUnitIdMsg" *ngIf="(unitId.dirty || unitId.touched) && unitId.errors?.minlength" class="ui-message ui-messages-error ui-corner-all">UnitId must be 10-15 digits</span>
<span *ngIf="(unitId.dirty || unitId.touched) && unitId.errors?.unitIdUnique" class="ui-message ui-messages-error ui-corner-all">
{{ globals.apiErrorMsg(unitId.errors?.unitIdUnique) }}
</span>
<label i18n="@@unitId">UnitId</label>
@ -89,8 +53,7 @@
<span style="margin-right:12px">
<ng-container i18n="@@color">Color</ng-container>:
</span>
<p-dropdown id="color" name="color" [style]="{'width':'120px'}" [options]="acColors"
[(ngModel)]="selectedItem.color">
<p-dropdown id="color" name="color" [style]="{'width':'120px'}" [options]="acColors" [(ngModel)]="selectedItem.color">
<ng-template let-item pTemplate="selectedItem">
<div class="color-box" [ngStyle]="{ 'background-color': item.value }"></div>
<span style="vertical-align:middle; margin-left: .5em">{{item.label}}</span>
@ -103,60 +66,16 @@
</ng-template>
</p-dropdown>
</div>
<!-- AgMission Native Account Editor (hidden for partner systems) -->
<div class="ui-g-12" *ngIf="!isPartnerSystemSelected" style="padding-top: 0;">
<agm-account-editor #account [isNew]="isNew" [account]="selectedItem" [showActive]="true"
[isAircraftAccount]="true" [canActivateVehicle]="canActivateVehicle" i18n-title="@@accessAccInPt"
title="Access Account in Guia Platinum" [showAccountConstraint]="isAccountIncomplete()"
[accountConstraintMessage]="Labels.ACCOUNT_INCOMPLETE_MESSAGE"
[accountConstraintTitle]="Labels.ACCOUNT_INCOMPLETE_TITLE">
<div class="ui-g-12" style="padding-top: 0">
<agm-account-editor #account [isNew]="isNew" [account]="selectedItem" [showActive]="true" [isAircraftAccount]="true" [canActivateVehicle]="canActivateVehicle" i18n-title="@@accessAccInPt" title="Access Account in Guia Platinum">
</agm-account-editor>
</div>
<!-- Partner Vehicle Activation Section (edit mode only) -->
<div class="ui-g-12" *ngIf="isPartnerSystemSelected && !isNew && canActivateVehicle"
style="padding-top: 12px;">
<fieldset>
<legend>{{ Labels.VEHICLE_ACTIVATION }}</legend>
<div class="ui-g-12">
<p-checkbox id="partnerVehicleActive" name="active" [label]="Labels.ACTIVE_STATUS"
[(ngModel)]="selectedItem.active" [disabled]="!canActivatePartnerVehicle()" binary="true">
</p-checkbox>
</div>
<!-- Partner Activation Constraint Message -->
<div class="ui-g-12" *ngIf="!canActivatePartnerVehicle()" style="margin-top: 8px;">
<agm-constraint-message [message]="getPartnerActivationConstraintMessage()"
[title]="Labels.CONSTRAINT_INFO_TITLE" severity="info" icon="pi-info-circle">
</agm-constraint-message>
</div>
</fieldset>
</div>
<!-- Account Completion Reminder (detached message stays here, only for native vehicles) -->
<div class="ui-g-12" *ngIf="!isPartnerSystemSelected && isAccountIncomplete()">
<ng-container *ngTemplateOutlet="accEditor?.accountConstraint?.detachedContentTemplate"></ng-container>
</div>
<!-- Partner Integration Constraint Messages -->
<div class="ui-g-12" *ngIf="getPartnerConstraintDetails() as constraintDetails">
<agm-constraint-message [collapsible]="getConstraintSeverity(constraintDetails) === 'info'"
[message]="constraintDetails.message" [title]="constraintDetails.title"
[severity]="getConstraintSeverity(constraintDetails)" [icon]="getConstraintIcon(constraintDetails)">
</agm-constraint-message>
</div>
<div class="ui-g-12 toolbar padtop1">
<button pButton *ngIf="isNew; else editTpl"
[disabled]="!f.valid || !isAccountValid || !partnerValidationState" type="button" style="width:auto"
icon="ui-icon-plus" i18n-label="@@create" label="Create" (click)="saveVehicle(); false"></button>
<button pButton *ngIf="isNew; else editTpl" [disabled]="!f.valid || !account.valid" type="button" style="width:auto" icon="ui-icon-plus" i18n-label="@@create" label="Create" (click)="saveVehicle(); false"></button>
<ng-template #editTpl>
<button class="blue-btn" pButton type="button" style="width:auto"
[disabled]="!f.valid || !isAccountValid || !partnerValidationState" icon="ui-icon-save"
i18n-label="@@save" label="Save" (click)="saveVehicle(); false"></button>
<button class="blue-btn" pButton type="button" style="width:auto" [disabled]="!f.valid || !account.valid" icon="ui-icon-save" i18n-label="@@save" label="Save" (click)="saveVehicle(); false"></button>
</ng-template>
<button pButton type="button" style="width:auto" class="amber-btn" icon="ui-icon-arrow-back"
(click)="goBack()" i18n-label="@@back" label="Back"></button>
<button pButton type="button" style="width:auto" class="amber-btn" icon="ui-icon-arrow-back" (click)="goBack()" i18n-label="@@back" label="Back"></button>
</div>
</div>
</form>

View File

@ -1,115 +1,59 @@
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
// ============================================================================
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
) {
super();
this.acTypes = [
{ label: vehTypes[VehType.FIXEDSWING], value: VehType.FIXEDSWING },
{ label: vehTypes[VehType.HELICOPTER], value: VehType.HELICOPTER }
@ -121,84 +65,36 @@ 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);
}
}
// 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) => {
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');
}
});
this.sub$.add(this.appActions.ofTypes([vehicleActions.CREATE_SUCCESS, vehicleActions.UPDATE_SUCCESS])
.subscribe((action) => {
this.store.dispatch(new vehicleActions.Select(action['payload']));
this.goBack();
}));
// 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))
this.sub$.add(this.store.select(selectLimit(SubType.ADDON))
.subscribe((addon) => {
const tracking: Limit = addon?.[SubKeys.TRACKING];
this.hasTracking = tracking?.airCraft?.numOfVehicle > 0;
})
);
}));
}
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,153 +102,9 @@ 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;
setTimeout(() => { clearInterval(timer); }, 1500);
}
// 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);
}
}
}
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;
@ -360,233 +112,21 @@ export class VehicleEditComponent extends BaseComp implements OnInit, AfterViewI
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
goBack() {
this.router.navigate(['/entities/aircraft/', { id: this.vehicle._id }]);
}
};
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(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']);
}
}
// ============================================================================
// 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 '';
ngOnDestroy() {
super.ngOnDestroy();
}
}

View File

@ -1,292 +1,3 @@
.highlight-btn{
background-color: #4CAF50;
/* $primaryColor */
background-color: green;
}
/* 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). */

View File

@ -4,8 +4,7 @@
<ng-container *ngIf="isCompLoaded(); else err">
<ng-container [ngTemplateOutlet]="listSection"></ng-container>
<ng-container [ngTemplateOutlet]="btnSection"></ng-container>
<review-aircraft [visible]="displayDialog" [messages]="[{text: dialogMsg}]"
(reviewEvt)="reviewAC()"></review-aircraft>
<review-aircraft [visible]="displayDialog" [messages]="[{text: dialogMsg}]" (reviewEvt)="reviewAC()"></review-aircraft>
</ng-container>
</div>
</div>
@ -13,53 +12,30 @@
<ng-template #msgSection>
<section>
<!-- Consolidated Message Display with Appropriate Icons -->
<div [ngClass]="isAircraftReviewStatus() ? 'aircraft-review-container' : 'generic-error-container'">
<i [ngClass]="isAircraftReviewStatus() ? 'pi pi-info-circle' : 'pi pi-exclamation-triangle'"></i>
<!-- Wrapper stacks message text + no-changes button vertically inside the flex row -->
<div class="aircraft-review-body">
<div [ngClass]="isAircraftReviewStatus() ? 'aircraft-review-message' : 'generic-error-message'">
{{ status?.message }}
</div>
<!-- Confirm-and-continue: only shown in review mode when nothing has changed -->
<button *ngIf="isAircraftReviewStatus() && !vehiclesChanged" type="button" pButton icon="ui-icon-check"
class="highlight-btn" (click)="noChangesToReview()" i18n-label="@@reviewNoChangesBtn"
label="All looks good Go to My Services">
</button>
</div>
</div>
<generic-message [messages]="[{text: status?.message, style: 'error'}]"></generic-message>
</section>
</ng-template>
<ng-template #listSection>
<section>
<p-table #dt [columns]="cols" [value]="vehicles" sortField="name" [paginator]="true" [rows]="15" [pageLinks]="5"
[rowsPerPageOptions]="null" [alwaysShowPaginator]="false" [responsive]="true" [dataKey]="ID"
compareSelectionBy="equals" selectionMode="single" (onRowSelect)="onRowSelect($event)" [resetPageOnSort]="false"
(onRowUnselect)="onRowSelect($event)" [(selection)]="currVehicle" stateStorage="session" stateKey="actb-ops"
mutable="false">
<p-table #dt [columns]="cols" [value]="vehicles" sortField="name" [paginator]="true" [rows]="15" [pageLinks]="5" [rowsPerPageOptions]="null" [alwaysShowPaginator]="false" [responsive]="true" [dataKey]="ID" compareSelectionBy="equals" selectionMode="single" (onRowSelect)="onRowSelect($event)" [resetPageOnSort]="false" (onRowUnselect)="onRowSelect($event)" [(selection)]="currVehicle" stateStorage="session" stateKey="actb-ops" mutable="false">
<ng-template pTemplate="caption">
<span class="table-caption-1" i18n="@@aircraftList">Aircraft List</span>
<ng-container *ngIf="status" [ngTemplateOutlet]="msgSection"></ng-container>
</ng-template>
<ng-template pTemplate="header" let-columns>
<tr>
<th *ngFor="let col of columns" [pSortableColumn]="col.field" [width]="col.width">{{ col.header }}<p-sortIcon
[field]="col.field"></p-sortIcon></th>
<th *ngFor="let col of columns" [pSortableColumn]="col.field" [width]="col.width">{{ col.header }}<p-sortIcon [field]="col.field"></p-sortIcon></th>
</tr>
<tr>
<th *ngFor="let col of columns" [ngSwitch]="col.filtered" class="ui-fluid">
<div class="input-with-icon" *ngSwitchCase="true">
<i class="ui-icon-search"></i>
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)"
[value]="dt.filters[col.field]?.value">
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
</div>
<ng-container *ngIf="col.field === TRACKING" [ngTemplateOutlet]="maxVeh"
[ngTemplateOutletContext]="{numOfVehicle: trkLimit?.airCraft?.numOfVehicle}"></ng-container>
<ng-container *ngIf="col.field === PACKAGE_ACTIVE" [ngTemplateOutlet]="maxVeh"
[ngTemplateOutletContext]="{numOfVehicle: pkgLimit?.airCraft?.numOfVehicle || 0}"></ng-container>
<p-dropdown *ngIf="col.field === VEHICLE_TYPE" [options]="acTypes" [ngModel]="dt.filters[col.field]?.value"
(onChange)="dt.filter($event.value, VEHICLE_TYPE, 'equals')"></p-dropdown>
<ng-container *ngIf="col.field === TRACKING" [ngTemplateOutlet]="maxVeh" [ngTemplateOutletContext]="{numOfVehicle: trkLimit?.airCraft?.numOfVehicle}"></ng-container>
<ng-container *ngIf="col.field === PACKAGE_ACTIVE" [ngTemplateOutlet]="maxVeh" [ngTemplateOutletContext]="{numOfVehicle: pkgLimit?.airCraft?.numOfVehicle || 0}"></ng-container>
<p-dropdown *ngIf="col.field === VEHICLE_TYPE" [options]="acTypes" [ngModel]="dt.filters[col.field]?.value" (onChange)="dt.filter($event.value, VEHICLE_TYPE, 'equals')"></p-dropdown>
<span *ngSwitchDefault></span>
</th>
</tr>
@ -71,11 +47,6 @@
<span *ngSwitchCase="VEHICLE_TYPE">{{ rowData[col.field] | vehicleType }}</span>
<!-- System Type Column -->
<span *ngSwitchCase="SOURCE_SYSTEM">
<ng-container *ngTemplateOutlet="systemTypeTemplate; context: { vehicle: rowData }"></ng-container>
</span>
<span *ngSwitchCase="COLOR">
<div class="color-box" [ngStyle]="{ 'background-color': resolveFieldData(rowData, col.field) }"></div>
</span>
@ -84,8 +55,7 @@
<span *ngSwitchCase="TRACKING">
<ng-container *ngIf="rowData[UNIT_ID] && canActivateVehicle; else readonlyStatusTemplate">
<p-checkbox [(ngModel)]="rowData.tracking" [binary]="true" (onChange)="vehSelChange(rowData, TRACKING)"
[disabled]="isDisabled(rowData, TRACKING)"></p-checkbox>
<p-checkbox [(ngModel)]="rowData.tracking" [binary]="true" (onChange)="vehSelChange(rowData, TRACKING)" [disabled]="isDisabled(rowData, TRACKING)"></p-checkbox>
</ng-container>
<ng-template #readonlyStatusTemplate>
<ng-container *ngTemplateOutlet="readonlyStatus; context: { flag: rowData.tracking }"></ng-container>
@ -94,10 +64,7 @@
<span *ngSwitchCase="PACKAGE_ACTIVE">
<ng-container *ngIf="canActivateVehicle; else readonlyStatusTemplate">
<p-checkbox [(ngModel)]="rowData.pkgActive" [binary]="true"
(onChange)="vehSelChange(rowData, PACKAGE_ACTIVE)" [disabled]="isDisabled(rowData, PACKAGE_ACTIVE)"
[id]="'package-checkbox-' + rowData._id">
</p-checkbox>
<p-checkbox [(ngModel)]="rowData.pkgActive" [binary]="true" (onChange)="vehSelChange(rowData, PACKAGE_ACTIVE)" [disabled]="isDisabled(rowData, PACKAGE_ACTIVE)"></p-checkbox>
</ng-container>
<ng-template #readonlyStatusTemplate>
<ng-container *ngTemplateOutlet="readonlyStatus; context: { flag: rowData.pkgActive }"></ng-container>
@ -115,8 +82,7 @@
<span *ngSwitchCase="UNIT_ID">
<ng-container *ngIf="!rowData[UNIT_ID] && currVehicle && rowData[ID] == currVehicle[ID]; else uId">
<span i18n="@@noUnit" style="color: red;">Unit ID is missing. Please enter a Unit ID to enable the
tracking feature.</span>
<span i18n="@@noUnit" style="color: red;">Unit ID is missing. Please enter a Unit ID to enable the tracking feature.</span>
</ng-container>
<ng-template #uId>{{ resolveFieldData(rowData, col.field) }}</ng-template>
</span>
@ -138,16 +104,12 @@
<ng-template #btnSection>
<section class="ui-widget-header ui-helper-clearfix toolbar">
<button type="button" *ngIf="canWrite" pButton icon="ui-icon-plus" (click)="newVehicle()" i18n-label="@@new"
label="New"></button>
<button type="button" [disabled]="!canEdit" pButton icon="ui-icon-edit" (click)="editVehicle()"
i18n-label="@@detail" label="Detail"></button>
<button type="button" *ngIf="canWrite" pButton icon="ui-icon-plus" (click)="newVehicle()" i18n-label="@@new" label="New"></button>
<button type="button" [disabled]="!canEdit" pButton icon="ui-icon-edit" (click)="editVehicle()" i18n-label="@@detail" label="Detail"></button>
<ng-container *ngIf="canActivateVehicle">
<button type="button" pButton [disabled]="!vehiclesChanged" icon="ui-icon-save" (click)="update()" #updateBtn
i18n-label="@@update" label="Update"></button>
<button type="button" pButton [disabled]="!vehiclesChanged" icon="ui-icon-save" (click)="update()" #updateBtn i18n-label="@@update" label="Update"></button>
</ng-container>
<button type="button" [disabled]="!canEdit" *ngIf="canWrite" pButton icon="ui-icon-trash" (click)="deleteItem()"
i18n-label="@@delete" label="Delete"></button>
<button type="button" [disabled]="!canEdit" *ngIf="canWrite" pButton icon="ui-icon-trash" (click)="deleteItem()" i18n-label="@@delete" label="Delete"></button>
</section>
</ng-template>
@ -155,30 +117,12 @@
<ng-container i18n="@@maxVeh">Max vehicles</ng-container>:&nbsp;{{numOfVehicle}}
</ng-template>
<!-- System Type Display Template -->
<ng-template #systemTypeTemplate let-vehicle="vehicle">
<div class="system-type-display">
<!-- Partner Name Badge -->
<agm-badge [config]="getPartnerNameBadge(vehicle)"></agm-badge>
<!-- System Account Authentication Status Indicator -->
<agm-badge *ngIf="vehicle.partnerInfo?.partner && vehicle.partnerInfo.partner !== 'AGNAV'"
[config]="getAuthStatusBadge(vehicle)">
</agm-badge>
<!-- Partner Code Badge (only for partner systems with codes) -->
<agm-badge *ngIf="vehicle.tailNumber" [config]="getPartnerCodeBadge(vehicle)"></agm-badge>
</div>
</ng-template>
<ng-template #err>
<div class="ui-g">
<div class="ui-g-7" style="min-width: 740px; margin: auto;">
<div class="ui-g" style="padding: 1em;">
<div class="ui-g-12 card in-card-pad" style="margin-bottom: 1em;">
<generic-message [icon]="'error'" [iconStyle]="'color: red;'"
[messages]="[{text: status?.message || SubTexts.contactSupport, style: 'title sub-messages'}, {text: SubTexts.textBackSub}]"
[buttons]="[{label: SubTexts.labelBack}]" (backEvt)="gotoMySubs()"></generic-message>
<generic-message [icon]="'error'" [iconStyle]="'color: red;'" [messages]="[{text: status?.message || SubTexts.contactSupport, style: 'title sub-messages'}, {text: SubTexts.textBackSub}]" [buttons]="[{label: SubTexts.labelBack}]" (backEvt)="gotoMySubs()"></generic-message>
</div>
</div>
</div>

View File

@ -5,23 +5,18 @@ 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 { RoleIds, globals, vehTypes, VehType } 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 { map, switchMap, tap } 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';
@ -43,7 +38,6 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
readonly COLOR = 'color';
readonly MODEL = 'model';
readonly UNIT_ID = 'unitId';
readonly SOURCE_SYSTEM = 'sourceSystem';
vehicles: Vehicle[] = [];
vehiclesChanged = false;
@ -70,39 +64,11 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
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<string, {
isAuthenticated: boolean;
isValidating: boolean;
lastChecked: Date;
error?: string;
}>();
// Per-partner debounce timers for authentication checks
private authCheckTimers = new Map<string, any>();
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 = [
@ -130,10 +96,6 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
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.acTypes = [
@ -146,15 +108,6 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
ngOnInit() {
this.user = this.route.snapshot.data['user'];
this.clearNeedReview();
// 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([
@ -168,363 +121,23 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
this.user.needReview = false;
this.userSvc.saveUser(this.user).subscribe({
error: (err) => {
console.log(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) => {
console.log(err);
this.status = createSubStatus(SubAppErr.AC_LIST_ERR);
}
});
@ -568,6 +181,7 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
}))
).subscribe({
error: (err) => {
console.log(err);
this.status = createSubStatus(SubAppErr.AC_LIST_ERR);
}
}));
@ -591,21 +205,6 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
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();
@ -624,20 +223,11 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
}
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;
const isTrkVehicleAboveLimit = trkVehicles.length > this.trkLimit?.airCraft?.numOfVehicle;
const isPkgActiveVehicleAboveLimit = pkgActiveVehs.length > this.pkgLimit?.airCraft?.numOfVehicle;
if (isTrkVehicleAboveLimit || isPkgActiveVehicleAboveLimit) {
this.vehicles = this.vehicles.map((veh) => ({
@ -645,7 +235,6 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
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.store.dispatch(new vehicleActions.UpdateVehicles({ vehicles: this.vehicles, type: SUB.AC_REVIEW }));
}
}
@ -670,6 +259,7 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
})
).subscribe({
error: (err) => {
console.log(err);
this.status = createSubStatus(SubAppErr.AC_LIST_ERR);
}
})
@ -716,284 +306,6 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
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 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<void> {
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}`;
}
}
newVehicle() {
this.router.navigate(['.', '0'], { relativeTo: this.route });
}
@ -1019,11 +331,6 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
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']);
}
}
});
}
@ -1043,34 +350,15 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
&& !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();
}

View File

@ -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;
}

View File

@ -1,263 +0,0 @@
<div class="ui-g-12 ui-g-nopad">
<!-- Partner Selection -->
<div class="ui-g-12 ui-lg-6" style="padding-top: 0;">
<label for="partner-select" class="field-label" style="margin-right:12px">
{{ Labels.PARTNER_SYSTEM }}
</label>
<div class="partner-selection-with-indicator">
<p-dropdown id="partner-select" name="partnerSystem" [options]="partnerOptions" [(ngModel)]="selectedPartner"
[placeholder]="Labels.SELECT_PARTNER_SYSTEM" (onChange)="onPartnerChange()" [style]="{'width': '200px'}"
[disabled]="partnersLoading">
<ng-template let-partner pTemplate="item">
<span><strong>{{ partner.label }}</strong></span>
</ng-template>
</p-dropdown>
<!-- Success Indicator -->
<i class="pi pi-check success-indicator"
*ngIf="selectedPartner && selectedPartner !== SourceSystem.AGNAV && !partnerValidation.isValidating && partnerValidation.accountExists && partnerValidation.authenticationValid"
[title]="Labels.PARTNER_VALIDATION_SUCCESS + ' - ' + partnerDisplayName">
</i>
<!-- Loading Indicator -->
<span *ngIf="partnersLoading" class="loading-indicator">
<i class="pi pi-spin pi-spinner"></i> {{ Labels.LOADING_PARTNERS }}
</span>
<!-- Validation Loading Indicator -->
<i class="pi pi-spin pi-spinner validation-loading-indicator"
*ngIf="selectedPartner && selectedPartner !== SourceSystem.AGNAV && partnerValidation.isValidating"
[title]="Labels.VALIDATING_PARTNER_SYSTEM">
</i>
</div>
</div>
<!-- Partner System Validation Messages -->
<div *ngIf="selectedPartner && selectedPartner !== SourceSystem.AGNAV" class="ui-g-12">
<div class="partner-validation-section">
<!-- Validation in Progress -->
<div *ngIf="partnerValidation.isValidating" class="ui-g-12 form-row">
<agm-constraint-message [collapsible]="true" severity="info" [message]="Labels.VALIDATING_PARTNER_SYSTEM"
[title]="Labels.VALIDATING_PARTNER_SYSTEM" icon="pi-spinner">
</agm-constraint-message>
</div>
<!-- Account Does Not Exist -->
<div *ngIf="!partnerValidation.isValidating && !partnerValidation.accountExists" class="ui-g-12 form-row">
<agm-constraint-message severity="warning" [title]="Labels.PARTNER_ACCOUNT_NOT_FOUND"
[message]="Labels.PARTNER_ACCOUNT_CREATE_GUIDANCE" icon="pi-exclamation-triangle"
[actionLabel]="Labels.CREATE_PARTNER_ACCOUNT" actionIcon="pi-plus"
(actionClick)="navigateToAccountCreation()">
</agm-constraint-message>
</div>
<!-- Authentication Failed -->
<div
*ngIf="!partnerValidation.isValidating && partnerValidation.accountExists && !partnerValidation.authenticationValid"
class="ui-g-12 form-row">
<agm-constraint-message severity="error" [title]="Labels.PARTNER_AUTH_FAILED"
[message]="Labels.PARTNER_AUTH_FIX_GUIDANCE" icon="pi-times" [actionLabel]="Labels.FIX_AUTHENTICATION"
actionIcon="pi-plus" (actionClick)="navigateToAccountEdit()">
</agm-constraint-message>
</div>
<!-- Validation Error -->
<div *ngIf="partnerValidation.validationError" class="ui-g-12 form-row">
<agm-constraint-message severity="error" [title]="Labels.CONSTRAINT_ERROR_TITLE"
[message]="partnerValidation.validationError" icon="pi-times">
</agm-constraint-message>
</div>
</div>
</div>
<!-- Partner Aircraft Selection (shown when partner is selected and validated) -->
<div *ngIf="selectedPartner && selectedPartner !== SourceSystem.AGNAV" class="ui-g-12 partner-aircraft-section">
<div class="ui-g-12">
<h4 style="margin: 0;">{{ Labels.AIRCRAFT_INTEGRATION }}</h4>
<!-- Step Progress Indicator - Only show for new vehicles -->
<div *ngIf="isNew" class="integration-steps" role="progressbar" [attr.aria-valuenow]="getIntegrationProgress()"
aria-valuemin="0" [attr.aria-valuemax]="getMaxIntegrationSteps()"
[attr.aria-label]="Labels.INTEGRATION_PROGRESS_LABEL">
<!-- Step 1: Select Partner System -->
<div class="step" [class.completed]="true" [class.active]="!partnerValidation.isValidating">
<div class="step-indicator">
<i class="pi pi-check" *ngIf="!partnerValidation.isValidating"></i>
<span class="step-number" *ngIf="partnerValidation.isValidating">1</span>
</div>
<span class="step-label">{{ Labels.SELECT_PARTNER_SYSTEM }}</span>
</div>
<div class="step-connector"></div>
<!-- Step 2: Validate Partner Account -->
<div class="step" [class.completed]="canEditPartnerFields"
[class.active]="partnerValidation.isValidating || canEditPartnerFields">
<div class="step-indicator">
<i class="pi pi-check" *ngIf="canEditPartnerFields"></i>
<i class="pi pi-spin pi-spinner" *ngIf="partnerValidation.isValidating"></i>
<span class="step-number" *ngIf="!partnerValidation.isValidating && !canEditPartnerFields">2</span>
</div>
<span class="step-label">{{ Labels.VALIDATE_PARTNER_ACCOUNT }}</span>
</div>
<div class="step-connector"></div>
<!-- Step 3: Select Partner Aircraft -->
<div class="step" [class.completed]="selectedPartnerAircraft"
[class.active]="canEditPartnerFields && !partnerAircraftLoading">
<div class="step-indicator">
<i class="pi pi-check" *ngIf="selectedPartnerAircraft"></i>
<i class="pi pi-spin pi-spinner" *ngIf="partnerAircraftLoading"></i>
<span class="step-number" *ngIf="!selectedPartnerAircraft && !partnerAircraftLoading">3</span>
</div>
<span class="step-label">{{ Labels.SELECT_PARTNER_AIRCRAFT }}</span>
</div>
<!-- Step 4: Select System Type (Satloc Only) -->
<ng-container *ngIf="isSatlocPartnerSelected">
<div class="step-connector"></div>
<div class="step" [class.completed]="isSystemTypeStepCompleted"
[class.active]="selectedPartnerAircraft && !selectedSystemType">
<div class="step-indicator">
<i class="pi pi-check" *ngIf="isSystemTypeStepCompleted"></i>
<span class="step-number" *ngIf="!isSystemTypeStepCompleted">4</span>
</div>
<span class="step-label">
{{ Labels.SELECT_SYSTEM_TYPE_PLACEHOLDER }}
</span>
</div>
</ng-container>
</div>
</div>
<!-- Aircraft Selection Section - Always visible when partner is selected -->
<div *ngIf="canEditPartnerFields" class="ui-g-12">
<!-- Loading indicator -->
<div *ngIf="partnerAircraftLoading" class="ui-g-12 form-row">
<div class="loading-indicator">
<i class="pi pi-spin pi-spinner"></i>
<span>{{ Labels.LOADING_AVAILABLE_AIRCRAFT }}</span>
</div>
</div>
<!-- Aircraft Selection Dropdown -->
<div *ngIf="!partnerAircraftLoading" class="ui-g-12">
<div class="ui-g-12 ui-lg-6">
<label for="aircraft-select" class="field-label" style="margin-right:12px">
{{ Labels.AVAILABLE_AIRCRAFT }}:
<span *ngIf="!selectedPartnerAircraft" style="color: #e74c3c; margin-left: 4px;"
[title]="Labels.REQUIRED_FOR_PARTNER_INTEGRATION_TOOLTIP">*</span>
</label>
<p-dropdown id="aircraft-select" name="partnerAircraft" [options]="partnerAircraftOptions"
[(ngModel)]="selectedPartnerAircraft" [placeholder]="Labels.SELECT_AIRCRAFT"
(onChange)="onPartnerAircraftChange()" [style]="{'width': '250px'}"
[disabled]="!partnerAircraftOptions.length" [class.p-invalid]="!selectedPartnerAircraft">
<ng-template let-aircraft pTemplate="item">
<span><strong>{{ aircraft.label }}</strong> <small class="aircraft-id">({{ aircraft.value
}})</small></span>
</ng-template>
</p-dropdown>
</div>
<!-- System Type Selection for Satloc Partners -->
<div *ngIf="isSatlocPartnerSelected" class="ui-g-12 ui-lg-6">
<label for="system-type-select" class="field-label" style="margin-right:12px">
{{ Labels.SYSTEM_TYPE }}:
<span *ngIf="!selectedSystemType" style="color: #e74c3c; margin-left: 4px;"
[title]="Labels.REQUIRED_FOR_SATLOC_INTEGRATION_TOOLTIP">*</span>
</label>
<p-dropdown id="system-type-select" name="systemType" [options]="systemTypeOptions"
[(ngModel)]="selectedSystemType" [placeholder]="Labels.SELECT_SYSTEM_TYPE_PLACEHOLDER"
(onChange)="onSystemTypeChange()" [style]="{'width': '180px'}"
[title]="Labels.SYSTEM_TYPE_SELECTION_TOOLTIP" [disabled]="!selectedPartnerAircraft"
[class.p-invalid]="selectedPartnerAircraft && !selectedSystemType">
<ng-template let-systemType pTemplate="item">
<span><strong>{{ systemType.label }}</strong></span>
</ng-template>
</p-dropdown>
</div>
<!-- Enhanced Aircraft Information Panel with System Type -->
<div *ngIf="selectedPartnerAircraft && selectedPartnerAircraftDetails" class="ui-g-12">
<div class="enhanced-aircraft-info-panel" role="alert" aria-live="polite"
[attr.aria-label]="Labels.SELECTED_AIRCRAFT_DETAILS + ': ' + selectedPartnerAircraftDetails.id">
<div class="aircraft-info-content">
<!-- Success Icon -->
<i class="pi pi-check aircraft-info-icon" aria-hidden="true"></i>
<!-- Aircraft Information -->
<div class="aircraft-info-text">
<div class="aircraft-info-header">
<strong class="aircraft-info-title">{{ Labels.SELECTED_AIRCRAFT_DETAILS }}</strong>
<agm-badge [config]="getPartnerIntegratedBadge()"></agm-badge>
</div>
<div class="aircraft-details">
<div class="detail-row">
<strong>{{ Labels.AIRCRAFT_ID }}:</strong> {{ selectedPartnerAircraftDetails.id }}
</div>
<!-- System Type Information for Satloc Partners -->
<div *ngIf="isSatlocPartnerSelected" class="detail-row system-type-row">
<strong>
{{ Labels.SYSTEM_TYPE }}:
</strong>
<span *ngIf="selectedSystemType" class="system-type-value">
{{ getSelectedSystemTypeLabel() }}
</span>
<span *ngIf="!selectedSystemType" class="system-type-pending">
<i class="pi pi-exclamation-triangle" style="color: #f39c12; margin-right: 4px;"></i>
<em>{{ Labels.SYSTEM_TYPE_SELECTION_REQUIRED }}</em>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- No Aircraft Available Message (only show when no error) -->
<div *ngIf="!partnerAircraftLoading && !partnerAircraftOptions.length && !partnerAircraftError"
class="ui-g-12 form-row">
<agm-constraint-message [collapsible]="true" severity="info" [title]="Labels.NO_AIRCRAFT_AVAILABLE_TITLE"
[message]="Labels.NO_AVAILABLE_AIRCRAFT_FOUND + ' ' + partnerDisplayName + '.'" icon="pi-info-circle">
</agm-constraint-message>
</div>
<!-- Error Display (takes priority over no aircraft message) -->
<div *ngIf="partnerAircraftError" class="ui-g-12 form-row">
<agm-constraint-message severity="error" [title]="Labels.PARTNER_AIRCRAFT_ERROR_TITLE"
[message]="partnerAircraftError" icon="pi-times">
</agm-constraint-message>
</div>
</div>
<!-- Aircraft Selection Preview - Shown when partner selected but not validated -->
<div *ngIf="!canEditPartnerFields && selectedPartner && selectedPartner !== SourceSystem.AGNAV" class="ui-g-12">
<div class="ui-g-6">
<div class="input-with-inline-constraint">
<label class="field-label" style="margin-right:12px">{{ Labels.AVAILABLE_AIRCRAFT }}:</label>
<p-dropdown [placeholder]="Labels.COMPLETE_VALIDATION_FIRST" [disabled]="true" [style]="{'width': '250px'}"
class="preview-disabled">
</p-dropdown>
<!-- Partner validation required icon (detached mode) -->
<agm-constraint-message #partnerValidationConstraint [collapsible]="true" [collapsed]="true" [detached]="true"
[message]="Labels.AIRCRAFT_SELECTION_AVAILABLE_AFTER_VALIDATION"
[title]="Labels.PARTNER_VALIDATION_REQUIRED_TITLE" severity="info" icon="pi-info-circle"
class="inline-constraint">
</agm-constraint-message>
</div>
</div>
<!-- Partner validation constraint message -->
<div class="ui-g-12" style="margin-top: 8px;">
<ng-container *ngTemplateOutlet="partnerValidationConstraint?.detachedContentTemplate"></ng-container>
</div>
</div>
</div>
</div>

View File

@ -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<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
return true;
}
}

View File

@ -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';
}
}
}

View File

@ -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',
@ -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';
}
}

View File

@ -253,7 +253,7 @@
<div class="ui-g ui-g-nopad ui-g-12">
<div class="ui-g-nopad ui-g-7">
<div *ngIf="printDetail.client.logo" class="ui-g-12 ui-g-nopad" style="margin-bottom: 20px;">
<!-- <img [src]="printDetail.client.logo" alt="" style="max-width: 90%; max-height: 120px; object-fit: contain" /> -->
<img [src]="printDetail.client.logo" alt="" style="max-width: 90%; max-height: 120px; object-fit: contain" />
<div style="font-size: 12px; color: #212121;" i18n="@@poweredByAgnavAlt">Powered by AgMission - AgNav Inc.</div>
</div>
<div class="ui-g ui-g-nopad ui-g-12">

View File

@ -170,18 +170,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 +272,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);
@ -331,18 +305,6 @@ export class InvoiceDetailComponent extends BaseComp implements OnInit, OnDestro
(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 !');
}
@ -357,18 +319,6 @@ export class InvoiceDetailComponent extends BaseComp implements OnInit, OnDestro
(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 !');
}
@ -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();
}

View File

@ -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 } 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',
@ -846,21 +845,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]));
}
@ -878,20 +862,6 @@ export class InvoiceEditComponent extends BaseComp implements OnInit, OnDestroy
_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,36 +880,8 @@ 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'
});
}
}
@ -1035,22 +977,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 +992,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 +1002,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();
}

View File

@ -17,11 +17,11 @@
<th *ngFor="let col of columns" [ngSwitch]="col.filtered" class="ui-fluid">
<div class="input-with-icon" *ngSwitchCase="true">
<i class="ui-icon-search"></i>
<input pInputText type="text" (input)="onTextFilter($event, col.field, col.filterMatchMode)" [value]="il.filters[col.field]?.value">
<input pInputText type="text" (input)="il.filter($event.target.value, col.field, col.filterMatchMode)" [value]="il.filters[col.field]?.value">
</div>
<p-calendar #odf *ngIf="col.field == 'openDate'" selectionMode="range" [(ngModel)]="openDateRange" [locale]="locale" placeholder=" " [readonlyInput]="true" [showButtonBar]="true" [showIcon]="true" [dateFormat]="locale.dateFormat" (onSelect)="handleCalDateRange(openDateRange, col.field, openDateFilter)" (onClearClick)="il.filter('', col.field, 'equals')" (onClose)="closeCal(openDateRange, col.field, openDateFilter)"></p-calendar>
<p-calendar #ddf *ngIf="col.field == 'dueDate'" selectionMode="range" [(ngModel)]="dueDateRange" [locale]="locale" placeholder=" " [readonlyInput]="true" [showButtonBar]="true" [showIcon]="true" [dateFormat]="locale.dateFormat" (onSelect)="handleCalDateRange(dueDateRange, col.field, dueDateFilter)" (onClearClick)="il.filter('', col.field, 'equals')" (onClose)="closeCal(dueDateRange, col.field, dueDateFilter)"></p-calendar>
<p-multiSelect *ngIf="col.field === 'status'" [options]="status" [(ngModel)]="statusFilter" i18n-defaultLabel="@@all" defaultLabel="All" (onChange)="onStatusFilter($event)"></p-multiSelect>
<p-multiSelect *ngIf="col.field === 'status'" [options]="status" [(ngModel)]="statusFilter" i18n-defaultLabel="@@all" defaultLabel="All" (onChange)="il.filter($event.value, 'status', 'in')"></p-multiSelect>
<span *ngSwitchDefault></span>
</th>
</tr>
@ -30,7 +30,7 @@
<tr [pSelectableRow]="invoice" [pSelectableRowIndex]="rowIndex">
<td class="table-col-center"><span class="ui-column-title">{{cols[0].header}}</span>{{invoice.totalAmount | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}}</td>
<td class="table-col-center"><span class="ui-column-title">{{cols[1].header}}</span>{{invoice.code}}</td>
<td class="table-col-center"><span class="ui-column-title">{{cols[2].header}}</span>{{invoice.clientsDisplay}}</td>
<td class="table-col-center"><span class="ui-column-title">{{cols[2].header}}</span>{{invoice.clients}}</td>
<td class="table-col-center"><span class="ui-column-title">{{cols[3].header}}</span>{{invoice.openDate | date:'shortDate'}}</td>
<td class="table-col-center"><span class="ui-column-title">{{cols[4].header}}</span>{{invoice.dueDate | date:'shortDate'}}</td>
<td class="table-col-center"><span class="ui-column-title">{{cols[5].header}}</span>{{ (invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1)) | invoiceStatus }}</td>

View File

@ -15,7 +15,6 @@ 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',
@ -58,7 +57,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,19 +81,8 @@ 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());
@ -148,14 +136,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 +148,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,6 +171,17 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
return Utils.arraySum(invoice?.clients.map(client => this.calculateSingleClientInvoiceAmount(client).total));
}
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;
}
get canEdit(): boolean {
return this.authSvc.hasRole([RoleIds.APP]);
}
@ -208,35 +195,11 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
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 });
}
@ -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 = [];
}
@ -296,18 +247,6 @@ 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 !');
}
@ -326,18 +265,6 @@ 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 !');
}
@ -350,52 +277,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);
}
}

View File

@ -51,20 +51,7 @@ export class JobEffects {
ofType<jobActions.Create>(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<Action> = this.actions$.pipe(
ofType<jobActions.Update>(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'];
}
}

View File

@ -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;
}
}

View File

@ -1,205 +0,0 @@
<div class="job-assignment-container">
<p-panel i18n-header="@@jobAssignment" header="Job Assignment" [toggleable]="true" [collapsed]="false">
<div class="ui-g">
<div class="ui-g-12 ui-g-nopad">
<p-pickList [source]="srcUsers" [target]="tarUsers" i18n-sourceHeader="@@aircraft"
sourceHeader="Available Aircraft" i18n-targetHeader="@@assignedAircraft" targetHeader="Assigned Aircraft"
[disabled]="isArchived" dragdrop="true" dragdropScope="users" [responsive]="true"
[sourceStyle]="{'height':'180px'}" [targetStyle]="{'height':'180px'}" [showSourceControls]="false"
[showTargetControls]="false" [filterBy]="'name'" filterMatchMode="contains"
i18n-sourceFilterPlaceholder="@@filterAcByName" sourceFilterPlaceholder="Filter AC by name"
i18n-targetFilterPlaceholder="@@filterAcByName" targetFilterPlaceholder="Filter AC by name"
(onMoveToSource)="onMoveToSource($event)" (onMoveToTarget)="onMoveToTarget($event)"
[pTooltip]="getPickListSourceTooltip()" tooltipPosition="top">
<ng-template let-aircraft pTemplate="item">
<div class="aircraft-item ui-helper-clearfix"
[style.color]="!aircraft.active || !aircraft.pkgActive || aircraft.authValidation?.validationError ? 'red' : null"
[style.opacity]="aircraft.authValidation?.isValidating ? '0.6' : '1'"
style="display: flex; flex-direction: column; align-items: flex-start;"
(click)="onAircraftSelect(aircraft, $event)">
<!-- Main row with icon, name, and badge -->
<div style="display: flex; align-items: center; width: 100%; margin-bottom: 4px;">
<!-- Aircraft Icon -->
<i class="fa" [class.fa-plane]="!aircraft.authValidation?.isValidating"
[class.fa-spinner]="aircraft.authValidation?.isValidating"
[class.fa-spin]="aircraft.authValidation?.isValidating" class="aircraft-icon"
style="vertical-align: middle; margin-right: 8px" aria-hidden="true"></i>
<!-- Aircraft Name -->
<span class="aircraft-name" [pTooltip]="getAircraftTooltip(aircraft)" tooltipPosition="top"
[escape]="false" [showDelay]="500" [hideDelay]="300" tooltipStyleClass="aircraft-tooltip-enhanced"
style="flex: 1;">
{{ aircraft.name }}
</span>
<!-- Source System Badge -->
<agm-badge [config]="getAircraftSystemBadge(aircraft)"></agm-badge>
</div>
<!-- Aircraft Details (Sync Status) -->
<div class="aircraft-details" style="margin-left: 24px;">
<small class="aircraft-details">
<!-- Satloc-specific sync status -->
<span *ngIf="aircraft.sourceSystem === KnownPartnerCodes.SATLOC && aircraft.satlocData"
class="sync-status" [class]="'sync-status-' + aircraft.satlocData.syncStatus">
<i class="fa" [class.fa-circle]="aircraft.satlocData.syncStatus === OperationalStatus.ACTIVE"
[class.fa-clock-o]="aircraft.satlocData.syncStatus === OperationalStatus.PENDING"
[class.fa-exclamation-triangle]="aircraft.satlocData.syncStatus === OperationalStatus.ERROR"></i>
</span>
</small>
</div>
<!-- Package status indicator -->
<div *ngIf="!aircraft.pkgActive" class="package-inactive"
style="position: absolute; top: 5px; right: 5px;">
<i class="fa fa-warning" style="color: orange" [pTooltip]="Labels.PACKAGE_INACTIVE"
tooltipPosition="top"></i>
</div>
</div>
</ng-template>
</p-pickList>
</div>
<div class="ui-g-12 ui-g-nopad" style="margin-top: 10px">
<div class="ui-g-12 ui-sm-4 ui-md-4 ui-lg-4 ui-xl-3">
<label for="dlOps"> <ng-container i18n="@@downloadOptions">Download Options</ng-container>:</label>
<div class="download-options-info">
<i class="pi pi-info-circle" [pTooltip]="Labels.DOWNLOAD_OPTIONS_AGNAV_ONLY_TOOLTIP"
tooltipPosition="top"></i>
<span>{{ Labels.AGNAV_BRAND_NAME }} <ng-container i18n="@@aircraftOnlySuffix">Aircraft
Only</ng-container></span>
</div>
</div>
<div class="ui-g-12 ui-sm-8 ui-md-6 ui-lg-8 ui-xl-9">
<p-dropdown id="dlOps" name="formats" [(ngModel)]="job.dlOp.type" [disabled]="isArchived"
[style]="{'minWidth':'120px'}" [options]="dlOps" [pTooltip]="getDownloadOptionsTooltip()"
tooltipPosition="top">
</p-dropdown>
</div>
</div>
<div class="ui-g-12 ui-g-nopad" style="margin-top: 10px">
<button class="blue-btn" pButton type="button" icon="ui-icon-assignment-ind" i18n-label="@@assign"
label="Assign" (click)="assignJob()" [disabled]="isArchived || !canDownload"
[pTooltip]="getAssignButtonTooltip()" tooltipPosition="top">
</button>
</div>
<!-- Assignment Status Display -->
<div class="ui-g-12 ui-g-nopad assignment-status-section" style="margin-top: 15px">
<div class="assignment-status-header">
<h4 id="assignment-status-heading" i18n="@@assignmentStatus"
title="Track real-time assignment progress and manage aircraft assignments">Assignment Status</h4>
<div class="assignment-header-actions">
<!-- Refresh Assignment Status -->
<button class="status-control-btn" pButton type="button" icon="ui-icon-refresh"
[pTooltip]="getRefreshStatusTooltip()" (click)="refreshAssignmentStatus()" [disabled]="!job || !job._id">
</button>
</div>
</div>
<!-- Assignment Status Polling Indicator -->
<div *ngIf="isPollingAssignments" class="polling-status-indicator" role="status" aria-live="polite">
<i class="pi pi-spin pi-refresh" aria-hidden="true"></i>
<span i18n="@@pollingAssignmentStatus">Polling assignment status...</span>
<small i18n="@@updatesEvery5Seconds">(Updates every 5 seconds)</small>
</div>
<!-- Overall Assignment Progress -->
<div *ngIf="isAssignmentInProgress" class="assignment-progress" role="status" aria-live="assertive">
<i class="pi pi-spin pi-spinner" aria-hidden="true"></i>
<span i18n="@@assignmentInProgress">Assignment in progress...</span>
</div>
<!-- Assignment Error Summary -->
<div *ngIf="assignmentErrorMsg" class="assignment-error-summary" role="alert" aria-live="assertive">
<i class="pi pi-exclamation-triangle" aria-hidden="true"></i>
<span>{{ assignmentErrorMsg }}</span>
</div>
<!-- Assignment Status Table -->
<p-table *ngIf="assignmentStatuses.length > 0" [value]="assignmentStatuses" dataKey="aircraftId"
[responsive]="true" [scrollable]="assignmentStatuses.length > 4"
[scrollHeight]="assignmentStatuses.length > 4 ? '300px' : 'auto'" styleClass="assignment-status-table"
[pTooltip]="getStatusTableTooltip()" tooltipPosition="top">
<ng-template pTemplate="header">
<tr>
<th style="width: 25%" i18n="@@aircraft" title="Aircraft name and assignment status indicator">Aircraft
</th>
<th style="width: 35%" i18n="@@statusMessage" title="Current assignment status and any error messages">
Status & Message</th>
<th style="width: 25%" i18n="@@assignTime" title="When the assignment was initiated">Assign Time</th>
<th style="width: 15%" i18n="@@actions" title="Available actions for this assignment">Actions</th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-status>
<tr [ngClass]="'status-row-' + getStatusCssClass(status)">
<!-- Aircraft Name Column -->
<td style="text-align: center;">
<span class="ui-column-title" i18n="@@aircraft">Aircraft</span>
<div class="aircraft-cell" style="justify-content: center;">
<div style="display: flex; flex-direction: column; align-items: center;">
<span class="aircraft-name">{{ status.aircraftName }}</span>
<agm-badge [config]="getStatusSystemBadge(status.sourceSystem)"
style="margin-top: 4px; font-size: 0.75rem;">
</agm-badge>
</div>
</div>
</td>
<!-- Combined Status & Message Column -->
<td>
<span class="ui-column-title" i18n="@@statusMessage">Status & Message</span>
<div>
<agm-badge [config]="getAssignmentStatusBadge(status)"></agm-badge>
<div class="status-message">{{ status.message }}</div>
<div *ngIf="status.errorDetails" class="status-error-details">
<small>{{ status.errorDetails }}</small>
</div>
</div>
</td>
<!-- Assign Time Column -->
<td>
<span class="ui-column-title" i18n="@@assignTime">Assign Time</span>
<div class="status-timestamp">{{ status.timestamp | date:'short' }}</div>
</td>
<!-- Actions Column -->
<td>
<span class="ui-column-title" i18n="@@actions">Actions</span>
<div class="status-actions">
<!-- Unified Slim Split Button for All States -->
<p-splitButton *ngIf="!isStatusNew(status)" styleClass="slim assignment-action-button"
[model]="getUnifiedActionOptions(status)" [disabled]="isAircraftAssignmentInProgress(status)"
i18n-pTooltip="@@assignmentActionsTooltip" pTooltip="Assignment actions">
</p-splitButton>
<!-- Simple indicator for new/pending states -->
<span *ngIf="isStatusNew(status)" class="status-indicator-text">
<i class="pi pi-spin pi-spinner"></i>
<span class="sr-only">{{ Labels.PROCESSING_ASSIGNMENT }}</span>
</span>
</div>
</td>
</tr>
</ng-template>
<ng-template pTemplate="emptymessage">
<tr>
<td colspan="4" role="cell">
<div class="empty-message" i18n="@@noAssignmentStatus">
No assignment status to display
</div>
</td>
</tr>
</ng-template>
</p-table>
</div>
</div>
</p-panel>
</div>

View File

@ -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<any>();
// 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<boolean>;
// Partner caching for performance
private partnersCache = new Map<string, Partner>();
constructor(
protected store: Store<fromEntity.EntityState>,
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 <br> username
* - For Partner: partner name <br> 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}<br>${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}<br>${aircraft.tailNumber}`;
} else {
tooltip = partnerName;
}
}
// Add package inactive warning if applicable
if (!aircraft.pkgActive) {
tooltip += `<br><span style="color: orange;">⚠️ ${Labels.PACKAGE_INACTIVE}</span>`;
}
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<any> {
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<void> {
// 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<void> {
// 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';
}
}
// ============================================================================
}

View File

@ -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;
}
}

View File

@ -18,7 +18,8 @@
<div class="ui-g">
<div class="ui-g-3 ui-sm-4"><strong i18n="@@name">Name</strong></div>
<div class="ui-g-9 ui-sm-8">
<input type="text" id="jobName" name="jobName" #jobName="ngModel" [pattern]="GC?.itemNameRegex" pInputText [(ngModel)]="selectedItem.name" #jobNameRef maxlength="20" (blur)="selectedItem.name = selectedItem.name.trim()" required>
<input type="text" id="jobName" name="jobName" #jobName="ngModel" [pattern]="GC?.itemNameRegex" pInputText [(ngModel)]="selectedItem.name" #jobNameRef maxlength="20"
(blur)="selectedItem.name = selectedItem.name.trim()" required>
<span i18n="@@invalidJobNamVal" *ngIf="jobName.invalid && (jobName.dirty || jobName.touched)" class="ui-message ui-messages-error ui-corner-all">Job Name is required and must not contains special characters</span>
</div>
</div>
@ -426,8 +427,31 @@
</div>
<div *ngIf="isPlanner && isEdit" class="ui-g-12 ui-md-12 ui-lg-12">
<div class="ui-g-12 ui-g-nopad">
<agm-job-assignment [job]="selectedItem" [isEdit]="isEdit" [isArchived]="isArchived" [canDownload]="canDownload" [dlOps]="dlOps" (assignmentComplete)="onAssignmentComplete($event)" (assignmentErrorEvent)="onAssignmentError($event)">
</agm-job-assignment>
<p-panel i18n-header="@@jobAssignment" header="Job Assignment" [toggleable]="true" [collapsed]="false">
<div class="ui-g">
<div class="ui-g-12 ui-g-nopad">
<p-pickList [source]="srcUsers" [target]="tarUsers" i18n-sourceHeader="@@aircraft" sourceHeader="Aircraft" i18n-targetHeader="@@assignedAircraft" [disabled]="isArchived" targetHeader="Assigned Aircraft" dragdrop="true" dragdropScope="users" [responsive]="true" [sourceStyle]="{'height':'180px'}" [targetStyle]="{'height':'180px'}" [showSourceControls]="false" [showTargetControls]="false" (onMoveToSource)="onMoveToActiveList($event.items)">
<ng-template let-user pTemplate="item">
<div class="ui-helper-clearfix;" [style.color]="!user.active ? 'red' : null">
<i class="fa ui-icon-account-circle" style="vertical-align: middle; margin-right: 4px" aria-hidden="true"></i>
<span pTooltip="{{ getUserToolTip(user) }}">{{ user.name }}</span>
</div>
</ng-template>
</p-pickList>
</div>
<div class="ui-g-12 ui-g-nopad" style="margin-top: 10px">
<div class="ui-g-12 ui-sm-4 ui-md-4 ui-lg-4 ui-xl-3"><label for="dlOps"> <ng-container i18n="@@downloadOptions">Download Options</ng-container>:</label>
</div>
<div class="ui-g-12 ui-sm-8 ui-md-6 ui-lg-8 ui-xl-9">
<p-dropdown id="dlOps" name="formats" [(ngModel)]="selectedItem.dlOp.type" [disabled]="isArchived" [style]="{'minWidth':'120px'}" [options]="dlOps">
</p-dropdown>
</div>
</div>
<div class="ui-g-12 ui-g-nopad" style="margin-top: 10px">
<button class="blue-btn" pButton type="button" icon="ui-icon-assignment-ind" i18n-label="@@assign" label="Assign" (click)="assignJob()" [disabled]="isArchived || !canDownload"></button>
</div>
</div>
</p-panel>
</div>
</div>
</div>

View File

@ -41,6 +41,7 @@ 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',
templateUrl: './job-edit.component.html',
@ -104,6 +105,9 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
grpedProds: SelectItemGroup[] = [];
srcUsers: any[];
tarUsers: any[];
uploadUrl = '/imports/uploadJob';
uploadedFiles = [];
dlLogs = [];
@ -401,6 +405,9 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
this.resetNewEntities();
this.checkOKDl();
}));
this.sub$.add(this.appActions.ofType(jobActions.ASSIGN_SUCCESS).subscribe((action) => {
this._job['dlOp'] = this.selectedItem.dlOp;
}));
this.sub$.add(this.store.select(selectLimit(SubType.PACKAGE)).pipe(take(1)).subscribe((pkg) => {
this.acre = pkg[this.authSvc.getCurLookupKey(SubType.PACKAGE)]?.acre;
@ -434,6 +441,9 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
if (this.isEdit) {
this.getUploadedFiles();
this.getLogs();
if (this.isPlanner) {
this.getAssignments();
}
}
}, 500);
@ -477,6 +487,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.pkgActive) : [];
this.tarUsers = res.asUsers;
}
});
}
private getAppRateUnits(isUS: boolean) {
if (isUS) {
this.rateUnits = [
@ -531,6 +550,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;
@ -654,22 +686,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 +705,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 +739,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 +767,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 +787,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 +796,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 +948,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 +955,18 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
});
}
// Assignment functionality moved to job-assignment component
downLoadJob(type: number) {
this.doDownLoadJob(type);
assignJob() {
if (!this.job) {
return;
}
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 = <jobActions.AssignInfo>{
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 +974,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 !');
}
@ -1373,30 +1274,7 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
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;
}
ngOnDestroy(): void {
super.ngOnDestroy();
}
}

View File

@ -1,11 +1,7 @@
<div class="ui-g">
<div class="ui-g-12">
<div class="card clearfix">
<p-table #dt [value]="jobs" [columns]="cols" selectionMode="single" (firstChange)="restoreTableFirst()"
(onPage)="onPageChange($event)" (onFilter)="restoreTableFirst()" (onRowSelect)="onRowSelect($event)"
(onRowUnselect)="onRowSelect($event)" [paginator]="true" [rows]="rows1Page[0]" [pageLinks]="5"
[rowsPerPageOptions]="rows1Page" [alwaysShowPaginator]="true" [(selection)]="currentJob" stateStorage="session"
stateKey="jtb-ops" dataKey="_id" mutable="false" [responsive]="true" [resetPageOnSort]="false">
<p-table #dt [value]="jobs" [columns]="cols" selectionMode="single" (firstChange)="restoreTableFirst()" (onPage)="onPageChange($event)" (onFilter)="restoreTableFirst()" (onRowSelect)="onRowSelect($event)" (onRowUnselect)="onRowSelect($event)" [paginator]="true" [rows]="rows1Page[0]" [pageLinks]="5" [rowsPerPageOptions]="rows1Page" [alwaysShowPaginator]="true" [(selection)]="currentJob" stateStorage="session" stateKey="jtb-ops" dataKey="_id" mutable="false" [responsive]="true" [resetPageOnSort]="false">
<ng-template pTemplate="caption">
<div class="ui-g ui-g-nopad">
<div class="ui-g-6 ui-g-nopad" style="text-align: left">
@ -27,13 +23,10 @@
<th *ngFor="let col of columns" [ngSwitch]="col.filtered" class="ui-fluid">
<div class="input-with-icon" *ngSwitchCase="true">
<i class="ui-icon-search"></i>
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)"
[value]="dt.filters[col.field]?.value">
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
</div>
<p-dropdown #cl *ngIf="col.field === 'client.name'" name="clients" [options]="clients" optionLabel="label"
[ngModel]="currClient" filter="true" [emptyFilterMessage]="globals.emptyFilterMsg"></p-dropdown>
<p-dropdown *ngIf="col.field === 'status'" [options]="status" [ngModel]="statusFilter"
(onChange)="handleStatusFilter($event.value)"></p-dropdown>
<p-dropdown #cl *ngIf="col.field === 'client.name'" name="clients" [options]="clients" optionLabel="label" [ngModel]="currClient" filter="true" [emptyFilterMessage]="globals.emptyFilterMsg"></p-dropdown>
<p-dropdown *ngIf="col.field === 'status'" [options]="status" [ngModel]="statusFilter" (onChange)="handleStatusFilter($event.value)"></p-dropdown>
<span *ngSwitchDefault></span>
</th>
</tr>
@ -44,10 +37,8 @@
<td class="table-col-center"><span class="ui-column-title">{{cols[1].header}}</span>{{job._id}}</td>
<td class="table-col-center"><span class="ui-column-title">{{cols[2].header}}</span>{{job.orderNumber}}</td>
<td><span class="ui-column-title">{{cols[3].header}}</span>{{job.name}}</td>
<td class="table-col-center"><span class="ui-column-title">{{cols[4].header}}</span>{{job.startDate |
date:'shortDate'}}</td>
<td class="table-col-center"><span class="ui-column-title">{{cols[5].header}}</span>{{job.endDate |
date:'shortDate'}}</td>
<td class="table-col-center"><span class="ui-column-title">{{cols[4].header}}</span>{{job.startDate | date:'shortDate'}}</td>
<td class="table-col-center"><span class="ui-column-title">{{cols[5].header}}</span>{{job.endDate | date:'shortDate'}}</td>
<td class="table-col-center">
<span class="ui-column-title">{{cols[5].header}}</span>
{{ job.status | jobStatus }}
@ -61,24 +52,15 @@
</p-table>
<div class="ui-widget-header ui-helper-clearfix toolbar">
<span class="ui-g ui-g-10 ui-sm-12 no-pad">
<!-- Note: !acre checks if subscription package loaded (not acre limits, packages have unlimited acres) -->
<button type="button" pButton icon="ui-icon-plus" *ngIf="canWrite"
[disabled]="currClient.value === null || !acre" (click)="newJob()" i18n-label="@@new" label="New"></button>
<button type="button" *ngIf="canWrite" [disabled]="!canEdit() || currClient.value === null || !acre" pButton
icon="ui-icon-control-point-duplicate" (click)="duplicateJob()" i18n-label="@@duplicate"
label="Duplicate"></button>
<button type="button" [disabled]="!canEdit()" pButton icon="ui-icon-edit" (click)="editJob()"
i18n-label="@@detail" label="Detail"></button>
<button type="button" [disabled]="!canEdit()" pButton icon="ui-icon-map" (click)="editJobMap()"
i18n-label="@@map" label="Map"></button>
<button type="button" [disabled]="!canEdit()" *ngIf="canWrite" pButton icon="ui-icon-trash"
(click)="deleteJob()" i18n-label="@@delete" label="Delete"></button>
<button type="button" [disabled]="!canCreateInvoice()" *ngIf="canWriteInvoice" pButton icon="ui-icon-add"
(click)="createInvoice()" i18n-label="@@createInvoice" label="Create Invoice"></button>
<button type="button" pButton icon="ui-icon-plus" *ngIf="canWrite" [disabled]="currClient.value === null || !acre" (click)="newJob()" i18n-label="@@new" label="New"></button>
<button type="button" *ngIf="canWrite" [disabled]="!canEdit() || currClient.value === null || !acre" pButton icon="ui-icon-control-point-duplicate" (click)="duplicateJob()" i18n-label="@@duplicate" label="Duplicate"></button>
<button type="button" [disabled]="!canEdit()" pButton icon="ui-icon-edit" (click)="editJob()" i18n-label="@@detail" label="Detail"></button>
<button type="button" [disabled]="!canEdit()" pButton icon="ui-icon-map" (click)="editJobMap()" i18n-label="@@map" label="Map"></button>
<button type="button" [disabled]="!canEdit()" *ngIf="canWrite" pButton icon="ui-icon-trash" (click)="deleteJob()" i18n-label="@@delete" label="Delete"></button>
<button type="button" [disabled]="!canCreateInvoice()" *ngIf="canWriteInvoice" pButton icon="ui-icon-add" (click)="createInvoice()" i18n-label="@@createInvoice" label="Create Invoice"></button>
</span>
<span class="ui-g-2 ui-sm-12 no-pad" *ngIf="!isClientUser">
<button pButton type="button" class="amber-btn" icon="ui-icon-arrow-back" (click)="gotoClients()"
style="float:right" i18n-label="@@clientList" label="Client List"></button>
<button pButton type="button" class="amber-btn" icon="ui-icon-arrow-back" (click)="gotoClients()" style="float:right" i18n-label="@@clientList" label="Client List"></button>
</span>
</div>
</div>
@ -91,28 +73,22 @@
<div class="ui-g">
<div class="ui-g-12">
<span i18n="@@filtJobsByCreatedDate">Filter Jobs By Created Date</span>
<p-calendar #calendar [(ngModel)]="selCalDate" selectionMode="range" [readonlyInput]="true"
[showButtonBar]="true" [showIcon]="true" (onClose)="onCalClose()"></p-calendar>
<p-calendar #calendar [(ngModel)]="selCalDate" selectionMode="range" [readonlyInput]="true" [showButtonBar]="true" [showIcon]="true" (onClose)="onCalClose()"></p-calendar>
</div>
</div>
<p-dropdown [style]="dropdownStyle" [options]="dateOptions" [(ngModel)]="selDate"
(onChange)="onDropdownChange($event)">
<p-dropdown [style]="dropdownStyle" [options]="dateOptions" [(ngModel)]="selDate" (onChange)="onDropdownChange($event)">
<ng-template let-item pTemplate="item">
<div class="ui-g">
<div [ngClass]="isShowXBtn(item) ? 'ui-g-8' : 'ui-g-12'" class="ui-g-nopad">{{ item.label }}</div>
<div *ngIf="isShowXBtn(item)" class="ui-g-4 ui-g-nopad" style="text-align: center;"><button
style="border: unset; background: none; cursor: pointer;" class="pi pi-times"
(click)="onCalClick()"></button></div>
<div *ngIf="isShowXBtn(item)" class="ui-g-4 ui-g-nopad" style="text-align: center;"><button style="border: unset; background: none; cursor: pointer;" class="pi pi-times" (click)="onCalClick()"></button></div>
</div>
</ng-template>
</p-dropdown>
</div>
<div class="ui-g-4 ui-lg-4 ui-md-12 ui-sm-12 inline-flex-end">
<p-dropdown [options]="reloadOps" [style]="dropdownStyle" [(ngModel)]="reloadBy"
(onChange)="reloadChanged($event.value)">
<p-dropdown [options]="reloadOps" [style]="dropdownStyle" [(ngModel)]="reloadBy" (onChange)="reloadChanged($event.value)">
</p-dropdown>
<button pButton type="button" style="margin-left:6px" icon="ui-icon-refresh" class="ui-button-secondary"
(click)="reloadJobs()"></button>
<button pButton type="button" style="margin-left:6px" icon="ui-icon-refresh" class="ui-button-secondary" (click)="reloadJobs()"></button>
</div>
</div>
</ng-template>

View File

@ -27,7 +27,6 @@ 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({
@ -85,8 +84,7 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
private readonly datePipe: DatePipe,
private readonly invoiceSvc: InvoiceService,
private readonly restoreTableSvc: RestoreTableState,
private readonly subscriptionService: SubscriptionService,
private readonly gaService: GAService
private readonly subscriptionService: SubscriptionService
) {
super();
this.currClient = ({ label: globals.all, value: null });
@ -135,7 +133,6 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
}
ngOnInit() {
// Initialize subscriptions first to get accurate data
this.sub$ = this.store.pipe(select(fromClients.getAllClients)).subscribe(clients => {
if (Utils.isEmptyArray(clients)) {
return;
@ -154,7 +151,8 @@ 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) => {
@ -162,27 +160,11 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
}));
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;
}
if (pkg) this.acre = pkg[this.authSvc.getCurLookupKey(SubType.PACKAGE)]?.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) {
@ -222,21 +204,6 @@ 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,
@ -284,27 +251,9 @@ 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;
}
@ -327,16 +276,6 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
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();
@ -363,26 +302,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,8 +357,6 @@ 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');
@ -471,20 +389,6 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
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 {
@ -506,34 +410,15 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
}
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 });
@ -542,27 +427,6 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
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() {
@ -577,48 +441,6 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
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() {
super.ngOnDestroy();
if (this.reload$) {

View File

@ -678,7 +678,7 @@
<div>
{{curPlayRec.timeLocal || "00:00:00.0"}}
</div>
<div *ngIf="isPlayingAgNavFile">
<div>
<p-dropdown id="tzone" name="tzone" [style]="{'width':'50px'}" [(ngModel)]="localTz" [options]="timeZones" (onChange)="onTzChange($event)"></p-dropdown>
</div>
</div>
@ -700,18 +700,14 @@
<div class="ui-g-8 data-field">{{playXt.avg | length:isUS:0 }} / {{curPlayRec.xt | xtract:isUS:0}}</div>
<div class="ui-g-4 data-field field-name">TrckAngle</div>
<div class="ui-g-8 data-field">{{curPlayRec.trckAngle}}</div>
<ng-container *ngIf="isPlayingAgNavFile">
<div class="ui-g-4 data-field field-name">LckedLine</div>
<div class="ui-g-8 data-field">{{curPlayRec.lockedLine | lockline:curPlayLoc?.xTrack }}</div>
</ng-container>
<div class="ui-g-4 data-field field-name">HDOP</div>
<div class="ui-g-8 data-field">{{curPlayRec.hdop}}</div>
<div class="ui-g-4 data-field field-name">Sat/Cor/ID</div>
<div class="ui-g-8 data-field">{{curPlayRec.sats || 0}} / {{curPlayRec.corId || 0}}<span *ngIf="curPlayRec.waasId">/ {{curPlayRec.waasId}}</span></div>
<ng-container *ngIf="isDebug">
<div class="ui-g-4 data-field field-name">SprayStat </div>
<div class="ui-g-8 data-field">{{curPlayLoc?.sprayStat}} (DEBUG)</div>
</ng-container>
<div *ngIf="isDebug" class="ui-g-4 data-field field-name">SprayStat </div>
<div *ngIf="isDebug" class="ui-g-8 data-field">{{curPlayLoc?.sprayStat}}</div>
</div>
</p-tabPanel>
<p-tabPanel i18n-header="@@applicInfo" header="Applic Info">
@ -720,14 +716,8 @@
<div class="ui-g-4 data-field field-name">Applic.RateAp</div>
<div class="ui-g-8 data-field">{{ curPlayRec.appRateAp | appRate:playMatType:isUS:null:false }}</div>
<div class="ui-g-4 data-field field-name">Applic.RateRq</div>
<ng-container *ngIf="isPlayingAgNavFile; else PARTNERATE">
<div class="ui-g-8 data-field">{{ curPlayRec.applicRate | number:'1.2-2':'en'}} {{ curPlayRec.applicRateUnit | rateUnit:2:false }}</div>
</ng-container>
<ng-template #PARTNERATE>
<div class="ui-g-8 data-field">{{ curPlayRec.applicRate | appRate:playMatType:isUS:null:false }}</div>
</ng-template>
<div class="ui-g-4 data-field field-name">FlowRateAp
</div>
<div class="ui-g-8 data-field">{{ curPlayRec.applicRate | appRate:playMatType:isUS:curPlayRec.applicRateUnit:false }}</div>
<div class="ui-g-4 data-field field-name">FlowRateAp</div>
<div class="ui-g-8 data-field">{{curPlayRec.flowRateAp || 0 | flowRate:isUS }}</div>
<div class="ui-g-4 data-field field-name">FlowRateRq</div>
<div class="ui-g-8 data-field">{{curPlayRec.flowRateRq || 0 | flowRate:isUS }}</div>
@ -742,9 +732,9 @@
</ng-template>
<div class="ui-g-4 data-field field-name">Flow Control</div>
<div class="ui-g-8 data-field">{{curPlayRec.flowControl }}</div>
<div class="ui-g-8 data-field">{{curPlayRec.flowControl || "No FC" }}</div>
<ng-container *ngIf="isPlayingAgNavFile && (playMatType === MatType.LIQUID)">
<ng-container *ngIf="(playMatType === MatType.LIQUID)">
<div class="ui-g-4 data-field field-name">Bm Pressure</div>
<div class="ui-g-8 data-field">{{curPlayRec.bmPressure | number:'1.1-1':'en'}} psi</div>
</ng-container>
@ -783,10 +773,8 @@
<div class="ui-g-4 data-field field-name">AutoSpr On/Off</div>
<div class="ui-g-8 data-field">{{curPlayRec.sprOnLag | number:'1.2-2':'en' }} / {{curPlayRec.sprOffLag | number:'1.2-2':'en'}}</div>
<ng-container *ngIf="isPlayingAgNavFile">
<div class="ui-g-4 data-field field-name" *ngIf="playMatType === MatType.LIQUID">Pulses/Liter</div>
<div class="ui-g-8 data-field" *ngIf="playMatType === MatType.LIQUID">{{curPlayRec.pulsesPLiter | number:'1.0-0':'en'}}</div>
</ng-container>
</div>
</p-tabPanel>
<p-tabPanel i18n-header="@@met" header="MET">
@ -807,10 +795,8 @@
<div class="ui-g-8 data-field">{{NumUtils.fixedTo(curPlayRec.utmY, 1, '0.0')}}</div>
<div class="ui-g-4 data-field field-name">Speed</div>
<div class="ui-g-8 data-field">{{UnitUtils.mpsToKnot(curPlayRec.speed) | number:'1.1-1':'en'}} knots</div>
<ng-container *ngIf="isPlayingAgNavFile">
<div class="ui-g-4 data-field field-name">LckedLine</div>
<div class="ui-g-8 data-field">{{curPlayRec.lockedLine | lockline}}</div>
</ng-container>
<div class="ui-g-4 data-field field-name">Wind Spd</div>
<div class="ui-g-8 data-field">{{curPlayRec.windSpd | number:'1.1-1':'en' }} knots</div>
<div class="ui-g-4 data-field field-name">Wind Dir</div>
@ -837,10 +823,8 @@
</p-tabPanel>
<p-tabPanel i18n-header="@@summary" header="Summary">
<div class="ui-g ui-g-nopad output">
<ng-container *ngIf="isPlayingAgNavFile">
<div class="ui-g-4 data-field field-name">AreaName</div>
<div class="ui-g-8 data-field">{{curPlayRec.areaName}}</div>
</ng-container>
<div class="ui-g-4 data-field field-name">Mapped Area</div>
<div class="ui-g-8 data-field">{{curPlayRec.mappedArea | number:'1.1-1':'en' }} {{ currentJob.measureUnit | areaUnit:false }}</div>
<div class="ui-g-4 data-field field-name">AreaSprTot</div>
@ -850,13 +834,7 @@
<div class="ui-g-4 data-field field-name">Pilot Name</div>
<div class="ui-g-8 data-field">{{curPlayRec.pilotName}}</div>
<div class="ui-g-4 data-field field-name">Applic.Rate</div>
<!-- <div class="ui-g-8 data-field">{{ curPlayRec.applicRate | number:'1.2-2':'en'}} {{ curPlayRec.applicRateUnit | rateUnit:null:false }}</div> -->
<ng-container *ngIf="isPlayingAgNavFile; else PARTNERATE">
<div class="ui-g-8 data-field">{{ curPlayRec.applicRate | number:'1.2-2':'en'}} {{ curPlayRec.applicRateUnit | rateUnit:2:false }}</div>
</ng-container>
<ng-template #PARTNERATE>
<div class="ui-g-8 data-field">{{ curPlayRec.applicRate | appRate:playMatType:isUS:null:false }}</div>
</ng-template>
<div class="ui-g-8 data-field">{{ curPlayRec.applicRate | number:'1.2-2':'en'}} {{ curPlayRec.applicRateUnit | rateUnit:null:false }}</div>
<div class="ui-g-4 data-field field-name">Mat Needed</div>
<div class="ui-g-8 data-field">{{( totalAmount?.value || 0) | number:'1.1-1':'en'}} {{ totalAmount?.appRateUnit | rateUnit:1:false }}</div>
<div class="ui-g-4 data-field field-name">Mat Sprayed</div>

View File

@ -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';
@ -179,13 +179,6 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
private lastPlayUnit;
// {<fileId/Name>: { <file meta object> }}, <file meta object> = { data: [], other fields }
filesDataSet = <any>{};
// Track pagination state per file
private fileDataPagination: Map<string, {
hasMore: boolean;
startingAfter: string | null;
loading: boolean;
allLoaded: boolean;
}> = new Map();
playIdx: number = -1;
centerPlayPos: boolean = false;
playMarker: any;
@ -237,14 +230,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 +310,7 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
private readonly weatherSvc: WeatherService,
private readonly ngZone: NgZone,
protected cdRef: ChangeDetectorRef,
) {
super(cdRef);
@ -605,7 +591,7 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
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.`,
message: $localize`:@@upgradeSprayZone:You have exceeded allowable acre limit. Please upgrade your subscription to enable this feature.`,
accept: () => {
this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]);
}
@ -2642,7 +2628,6 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
this.map && this.map.removeLayer(this.playMarker);
this.playMarker = null;
}
}
private findRefZone() {
@ -2700,7 +2685,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 +2700,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 +2711,8 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
const newRec = new PlayRecord();
newRec.timeGPS = this.curPlayLoc.gpsTime;
if (newRec.timeGPS) {
if (this.isPlayingAgNavFile)
if (newRec.timeGPS)
newRec.timeLocal = DateUtils.msToTime(newRec.timeGPS * 1000, this.localTz);
else {
newRec.timeLocal = DateUtils.gpsTimeToLocalISO(this.curPlayLoc.gpsTime, this.curPlayLoc.gmtOffset || 0);
}
}
newRec.lat = this.curPlayLoc.lat;
newRec.lon = this.curPlayLoc.lon;
@ -2764,24 +2742,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 +2771,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)) {
if ((file.meta && 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;
newRec.applicRate = this.job.appRate;
}
//
@ -2828,8 +2797,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 +2905,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;
}
const pagination = this.fileDataPagination.get(fid);
if (!this.filesDataSet[fid].loaded || !pagination.allLoaded) {
this.loadFileDataWithPagination(fid, () => setNextFile(nextFileIdx, cb));
setNextFile(nextFileIdx, cb);
});
} else {
setNextFile(nextFileIdx, cb);
}
@ -2960,80 +2922,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 +2968,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;
@ -3110,7 +2996,7 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
// };
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 +3185,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 +3202,6 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
this.player.speed = e.value;
});
}
onTzChange(e) {
if (!e) return;
if (this.curPlayRec) {

View File

@ -33,7 +33,6 @@ 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';
@ -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

Some files were not shown because too many files have changed in this diff Show More