Compare commits
1 Commits
master
...
feature/jo
| Author | SHA1 | Date | |
|---|---|---|---|
| c935eed4d9 |
@ -1,6 +0,0 @@
|
||||
CURRENT_FILE_PATH=src/locale/messages.xlf
|
||||
TRANSLATED_FILE_PATH_ES=src/locale/messages.es.xlf
|
||||
TRANSLATED_FILE_PATH_PT=src/locale/messages.pt.xlf
|
||||
GOOGLE_LOCATION=global
|
||||
GOOGLE_PROJECT_ID=predictive-fx-392018
|
||||
GOOGLE_APPLICATION_CREDENTIALS=google-cloud.json
|
||||
3
Development/client/.vscode/settings.json
vendored
3
Development/client/.vscode/settings.json
vendored
@ -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
@ -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
|
||||
|
@ -103,8 +103,7 @@
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "12kb",
|
||||
"maximumError": "18kb"
|
||||
"maximumWarning": "6kb"
|
||||
}
|
||||
]
|
||||
},
|
||||
@ -165,34 +164,29 @@
|
||||
"tsConfig": "src/tsconfig.spec.json",
|
||||
"karmaConfig": "src/karma.conf.js",
|
||||
"scripts": [
|
||||
"node_modules/rbush/rbush.min.js",
|
||||
"src/assets/js/turf.min.js"
|
||||
"node_modules/leaflet/dist/leaflet.js",
|
||||
"src/assets/js/leaflet-draw/leaflet.draw-src.js",
|
||||
"src/assets/js/L.Path.Drag.js",
|
||||
"src/assets/js/Leaflet.draw.drag-src.js",
|
||||
"src/assets/js/leaflet-measure/leaflet-measure-path.js",
|
||||
"src/assets/js/leaflet.circle.topolygon.js",
|
||||
"src/assets/js/turf.min.js",
|
||||
"src/assets/js/leaflet-corridor.js",
|
||||
"src/assets/js/utm.js",
|
||||
"src/assets/js/L.Control.MapCenterCoord.js",
|
||||
"src/assets/js/leaflet.polylineDecorator.js"
|
||||
],
|
||||
"styles": [
|
||||
"node_modules/leaflet/dist/leaflet.css",
|
||||
"src/assets/js/leaflet-draw/leaflet.draw.css",
|
||||
"src/assets/js/leaflet-measure/leaflet-measure-path.css",
|
||||
"node_modules/primeng/resources/primeng.min.css",
|
||||
"node_modules/nanoscroller/bin/css/nanoscroller.css",
|
||||
"src/assets/js/L.Control.MapCenterCoord.css",
|
||||
"src/assets/js/Leaflet.AgmIcon.css",
|
||||
"src/assets/js/Leaflet.AgmACIcon.css",
|
||||
"node_modules/primeng-lts/resources/primeng.min.css",
|
||||
"node_modules/@fullcalendar/core/main.min.css",
|
||||
"node_modules/@fullcalendar/daygrid/main.min.css",
|
||||
"node_modules/@fullcalendar/timegrid/main.min.css",
|
||||
"node_modules/quill/dist/quill.snow.css",
|
||||
"src/styles.scss"
|
||||
],
|
||||
"assets": [
|
||||
"src/assets/js/L.Control.MapCenterCoord.css",
|
||||
"src/assets/js/L.Control.MapCenterCoord.js",
|
||||
"src/assets/js/utm.js",
|
||||
"src/assets/js/Leaflet.GoogleMutant.js",
|
||||
"src/assets/js/leaflet-corridor.js",
|
||||
"src/assets/js/sti-rpt/",
|
||||
"src/assets/theme/theme-green.min.css",
|
||||
"src/assets/layout/css/layout-green.min.css",
|
||||
"src/assets/layout/fonts/",
|
||||
"src/assets/images/",
|
||||
"src/assets",
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "node_modules/leaflet/dist/images",
|
||||
@ -248,4 +242,4 @@
|
||||
"cli": {
|
||||
"analytics": "a39ac155-50fa-4441-b491-60e55b83e6ff"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
@ -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.
|
||||
@ -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 |
|
||||
@ -5,8 +5,7 @@
|
||||
"angular-cli": {},
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start-cert": "ng serve --ssl true --sslKey ~/ssl/server.key --sslCert ~/ssl/server.crt --proxy-config proxy.config.json --host 0.0.0.0 --disableHostCheck",
|
||||
"start": "CHOKIDAR_USEPOLLING=true ng serve --ssl true --proxy-config proxy.config.json --host 0.0.0.0 --disableHostCheck",
|
||||
"start": "ng serve --ssl true --proxy-config proxy.config.json --host 0.0.0.0 --disableHostCheck",
|
||||
"start-es": "ng serve --ssl true --proxy-config proxy.config.json --host 0.0.0.0 --disableHostCheck --configuration=es",
|
||||
"start-pt": "ng serve --ssl true --proxy-config proxy.config.json --host 0.0.0.0 --disableHostCheck --configuration=pt",
|
||||
"build": "ng build",
|
||||
@ -21,7 +20,6 @@
|
||||
"i18n-merge-w": "(for %i in (pt es) do (xliffmerge --profile xliffmerge.json en %i))",
|
||||
"sync-i18n": "npm run build-prep && npm run i18n-extract && npm run i18n-merge",
|
||||
"sync-i18n-w": "npm run build-prep && npm run i18n-extract-w && npm run i18n-merge-w",
|
||||
"pre-translate": "npx translation start && npm run sync-i18n && npx translation translate && npx translation cleanup",
|
||||
"build-prod": "ng build --prod --localize && cp -R dist/en/* dist/ && rm -R dist/en",
|
||||
"build-prod-window": "ng build --prod --localize && xcopy /E /Y dist\\en\\* dist\\ && rmdir /S /Q dist\\en"
|
||||
},
|
||||
@ -47,13 +45,12 @@
|
||||
"@ngrx/entity": "^9.2.0",
|
||||
"@ngrx/store": "^9.2.0",
|
||||
"@ngrx/store-devtools": "^9.2.0",
|
||||
"@stripe/stripe-js": "1.46.0",
|
||||
"angular-resizable-element": "^3.3.2",
|
||||
"angular-svg-icon": "^7.2.1",
|
||||
"chart.js": "^2.9.3",
|
||||
"classlist.js": "^1.1.20150312",
|
||||
"clone-deep": "^4.0.0",
|
||||
"esri-leaflet": "3.0.10",
|
||||
"esri-leaflet": "^3.0.1",
|
||||
"file-saver": "^1.3.8",
|
||||
"geodesy": "^1.1.3",
|
||||
"intl": "^1.2.5",
|
||||
@ -73,41 +70,32 @@
|
||||
"@angular/compiler-cli": "9.1.13",
|
||||
"@angular/language-service": "9.1.13",
|
||||
"@locl/cli": "^1.0.0",
|
||||
"@types/esri-leaflet": "2.1.9",
|
||||
"@types/esri-leaflet": "^2.1.6",
|
||||
"@types/file-saver": "^1.3.1",
|
||||
"@types/geodesy": "^1.1.3",
|
||||
"@types/jasmine": "^2.8.16",
|
||||
"@types/jasminewd2": "2.0.3",
|
||||
"@types/leaflet": "1.9.4",
|
||||
"@types/leaflet": "^1.5.17",
|
||||
"@types/leaflet-draw": "^0.4.14",
|
||||
"@types/node": "12.12.29",
|
||||
"@types/node": "12.11.1",
|
||||
"ajv": "6.12.2",
|
||||
"codelyzer": "5.2.1",
|
||||
"jasmine-core": "4.6.0",
|
||||
"codelyzer": "5.1.2",
|
||||
"jasmine-core": "3.5.0",
|
||||
"jasmine-spec-reporter": "4.2.1",
|
||||
"karma": "4.4.1",
|
||||
"karma-chrome-launcher": "3.1.0",
|
||||
"karma-cli": "2.0.0",
|
||||
"karma-coverage-istanbul-reporter": "2.1.1",
|
||||
"karma-chrome-launcher": "2.2.0",
|
||||
"karma-cli": "1.0.1",
|
||||
"karma-coverage-istanbul-reporter": "2.0.4",
|
||||
"karma-jasmine": "2.0.1",
|
||||
"karma-jasmine-html-reporter": "1.5.2",
|
||||
"ngx-i18nsupport": "^0.17.1",
|
||||
"protractor": "5.4.3",
|
||||
"karma-jasmine-html-reporter": "1.4.2",
|
||||
"protractor": "5.4.1",
|
||||
"rxjs-tslint": "0.1.8",
|
||||
"ts-node": "8.3.0",
|
||||
"ts-node": "7.0.1",
|
||||
"tslint": "5.20.1",
|
||||
"typescript": "3.8.3"
|
||||
},
|
||||
"resolutions": {
|
||||
"serialize-javascript": "^2.1.1",
|
||||
"tree-kill": "^1.2.2"
|
||||
},
|
||||
"overrides": {
|
||||
"@locl/cli": {
|
||||
"@angular/compiler": "9.1.13",
|
||||
"@angular/core": "9.1.13",
|
||||
"@angular/localize": "9.1.13"
|
||||
},
|
||||
"websocket-driver": "0.7.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,18 +1,18 @@
|
||||
{
|
||||
"/api/*": {
|
||||
"target": "https://127.0.0.1:4100",
|
||||
"target": "https://127.0.0.1:4000",
|
||||
"secure": false,
|
||||
"changeOrigin": false,
|
||||
"logLevel": "debug"
|
||||
},
|
||||
"/uploads/*": {
|
||||
"target": "https://127.0.0.1:4100",
|
||||
"target": "https://127.0.0.1:4000",
|
||||
"secure": false,
|
||||
"changeOrigin": false,
|
||||
"logLevel": "debug"
|
||||
},
|
||||
"/es/uploads/*": {
|
||||
"target": "https://127.0.0.1:4100",
|
||||
"target": "https://127.0.0.1:4000",
|
||||
"pathRewrite": {
|
||||
"/es/uploads/": "/uploads/"
|
||||
},
|
||||
@ -21,7 +21,7 @@
|
||||
"logLevel": "debug"
|
||||
},
|
||||
"/pt/uploads/*": {
|
||||
"target": "https://127.0.0.1:4100",
|
||||
"target": "https://127.0.0.1:4000",
|
||||
"pathRewrite": {
|
||||
"/pt/uploads/": "/uploads/"
|
||||
},
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -8,169 +8,24 @@
|
||||
<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)">
|
||||
<ng-template let-type pTemplate="item">
|
||||
<span>
|
||||
<strong>{{ type.label }}</strong>
|
||||
</span>
|
||||
</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>
|
||||
</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 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>
|
||||
</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>
|
||||
</ng-template>
|
||||
</p-dropdown>
|
||||
</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 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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,9 +1,8 @@
|
||||
<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"
|
||||
<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>
|
||||
@ -19,33 +18,30 @@
|
||||
<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>
|
||||
</tr>
|
||||
</ng-template>
|
||||
<ng-template pTemplate="body" let-rowData let-columns="columns">
|
||||
<tr [pSelectableRow]="rowData">
|
||||
<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 *ngSwitchDefault>{{ resolveFieldData(rowData, col.field) }}</span>
|
||||
<ng-template pTemplate="body" let-acc>
|
||||
<tr [pSelectableRow]="acc">
|
||||
<td>{{ acc.name }}</td>
|
||||
<td>{{ acc.username }}</td>
|
||||
<td style="text-align: center"><span>{{ acc.kind | userType }}</span></td>
|
||||
<td style="text-align: center">
|
||||
<p-checkbox [ngModel]="acc.active" disabled binary="true"></p-checkbox>
|
||||
</td>
|
||||
|
||||
<td>{{ acc.phone }}</td>
|
||||
<td>{{ acc.email }}</td>
|
||||
</tr>
|
||||
</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>
|
||||
|
||||
@ -7,9 +7,8 @@ import { User } from '../models/user.model';
|
||||
import * as fromUsers from '../reducers';
|
||||
import * as userActions from '../actions/account.actions';
|
||||
|
||||
import { RoleIds, globals, OperationalStatus, Labels } from '@app/shared/global';
|
||||
import { RoleIds, globals } from '@app/shared/global';
|
||||
import { BaseComp } from '@app/shared/base/base.component';
|
||||
import { Utils } from '@app/shared/utils';
|
||||
|
||||
|
||||
@Component({
|
||||
@ -18,11 +17,8 @@ import { Utils } from '@app/shared/utils';
|
||||
styleUrls: ['./account-list.component.css']
|
||||
})
|
||||
export class AccountListComponent extends BaseComp implements OnInit, OnDestroy {
|
||||
readonly resolveFieldData = Utils.resolveFieldData;
|
||||
readonly KIND = 'kind';
|
||||
readonly ACTIVE = OperationalStatus.ACTIVE;
|
||||
|
||||
accounts: Array<User>;
|
||||
isLoading: boolean;
|
||||
currAcc: User;
|
||||
cols: any[];
|
||||
userFilter: string;
|
||||
@ -39,8 +35,8 @@ export class AccountListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
this.cols = [
|
||||
{ field: 'name', header: globals.name, filtered: true, filterMatchMode: 'contains' },
|
||||
{ field: 'username', header: globals.userName, filtered: true, filterMatchMode: 'contains' },
|
||||
{ field: this.KIND, header: $localize`:@@type:Type`, width: '10%' },
|
||||
{ field: this.ACTIVE, header: globals.active, width: '6%' },
|
||||
{ field: 'kind', header: $localize`:@@type:Type`, width: '10%' },
|
||||
{ field: 'active', header: globals.active, width: '6%' },
|
||||
{ field: 'phone', header: globals.phone + ' ' + $localize`:@@Num:N°`, width: '10%', filtered: true, filterMatchMode: 'contains' },
|
||||
{ field: 'email', header: globals.email, filtered: true, filterMatchMode: 'contains' }
|
||||
];
|
||||
@ -52,10 +48,11 @@ export class AccountListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
|
||||
ngOnInit() {
|
||||
this.sub$ = this.store.select(fromUsers.getAllUsers).subscribe(users => this.accounts = users);
|
||||
this.sub$.add(this.store.select(fromUsers.getIsLoading).subscribe(loading => this.isLoading = loading));
|
||||
|
||||
this.sub$.add(this.store.select(fromUsers.getSelectedUser).subscribe(
|
||||
(acc) => this.currAcc = acc
|
||||
));
|
||||
// Always fetch the fresh list of accounts
|
||||
this.store.dispatch(new userActions.Fetch());
|
||||
}
|
||||
|
||||
@ -71,13 +68,6 @@ export class AccountListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
return (this.currAcc && this.currAcc._id !== '0');
|
||||
}
|
||||
|
||||
get canDelete() {
|
||||
// WI-2: Soft lock - Allow deletion of all account types including vendor accounts
|
||||
// Previously: blocked PARTNER_SYSTEM_USER accounts
|
||||
// Now: allowed with warning confirmation dialog (see deleteAccount)
|
||||
return this.canEdit;
|
||||
}
|
||||
|
||||
newAccount() {
|
||||
this.router.navigate(['account', '0'], { relativeTo: this.route });
|
||||
}
|
||||
@ -88,19 +78,8 @@ export class AccountListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
|
||||
deleteAccount() {
|
||||
if (!this.currAcc) { return; }
|
||||
|
||||
// WI-2: Soft lock - Show special warning for vendor accounts
|
||||
const isVendorAccount = this.currAcc?.kind === RoleIds.PARTNER_SYSTEM_USER;
|
||||
const message = isVendorAccount
|
||||
? Labels.VENDOR_DELETE_CONFIRM_MESSAGE
|
||||
: globals.confirmDeleteThing.replace('#thing#', globals.account);
|
||||
const header = isVendorAccount ? Labels.VENDOR_DELETE_CONFIRM_TITLE : undefined;
|
||||
|
||||
this.confirmSvc.confirm({
|
||||
header: header,
|
||||
message: message,
|
||||
acceptLabel: globals.yes,
|
||||
rejectLabel: globals.no,
|
||||
message: globals.confirmDeleteThing.replace('#thing#', globals.account),
|
||||
accept: () => {
|
||||
this.store.dispatch(new userActions.Delete(this.currAcc));
|
||||
this.currAcc = null;
|
||||
|
||||
@ -7,7 +7,6 @@ import { CheckboxModule } from 'primeng/checkbox';
|
||||
import { AutoCompleteModule } from 'primeng/autocomplete';
|
||||
import { ToolbarModule } from 'primeng/toolbar';
|
||||
import { InputSwitchModule } from 'primeng/inputswitch';
|
||||
import { TooltipModule } from 'primeng/tooltip';
|
||||
|
||||
import { TableModule } from 'primeng/table';
|
||||
import { CalendarModule } from 'primeng/calendar';
|
||||
@ -24,7 +23,7 @@ import { AccountEditComponent } from './account-edit/account-edit.component';
|
||||
import { AccountsGuard } from './account.guard';
|
||||
|
||||
import { AccountEffects } from './effects/account.effects';
|
||||
import { FEATURE_KEY, reducer } from './reducers/users.reducer';
|
||||
import { FEATURE_KEY, reducer } from './reducers/users-reducer';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
@ -38,7 +37,6 @@ import { FEATURE_KEY, reducer } from './reducers/users.reducer';
|
||||
ToolbarModule,
|
||||
SplitButtonModule,
|
||||
TableModule,
|
||||
TooltipModule,
|
||||
|
||||
StoreModule.forFeature(FEATURE_KEY, reducer),
|
||||
EffectsModule.forFeature([AccountEffects]),
|
||||
|
||||
@ -22,12 +22,7 @@ export const CREATE = '[USERS] Create a user';
|
||||
export class Create implements Action {
|
||||
type: typeof CREATE = CREATE;
|
||||
|
||||
constructor(readonly payload: User & {
|
||||
partnerConfig?: {
|
||||
vendorSystemType: string;
|
||||
vendorConfiguration: any;
|
||||
};
|
||||
}) { }
|
||||
constructor(readonly payload: User) { }
|
||||
}
|
||||
export const CREATE_SUCCESS = '[USERS] Create user success';
|
||||
export class CreateSuccess implements Action {
|
||||
@ -44,12 +39,7 @@ export const UPDATE = '[USERS] Update user';
|
||||
export class Update implements Action {
|
||||
type: typeof UPDATE = UPDATE;
|
||||
|
||||
constructor(readonly payload: User & {
|
||||
partnerConfig?: {
|
||||
vendorSystemType: string;
|
||||
vendorConfiguration: any;
|
||||
};
|
||||
}) { }
|
||||
constructor(readonly payload: User) { }
|
||||
}
|
||||
export const UPDATE_SUCCESS = '[USERS] Update user success';
|
||||
export class UpdateSuccess implements Action {
|
||||
@ -59,7 +49,7 @@ export class UpdateSuccess implements Action {
|
||||
}
|
||||
export const UPDATE_FAILED = '[USERS] Update user failed';
|
||||
export class UpdateFailed implements Action {
|
||||
type: typeof UPDATE_FAILED = UPDATE_FAILED;
|
||||
type: typeof UPDATE_FAILED = UPDATE_FAILED;
|
||||
}
|
||||
|
||||
export const DELETE = '[USERS] Delete user';
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Actions, Effect, ofType } from '@ngrx/effects';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { map, switchMap, catchError, repeat } from 'rxjs/operators';
|
||||
import { map, switchMap, catchError } from 'rxjs/operators';
|
||||
|
||||
import { Action } from '@ngrx/store';
|
||||
|
||||
@ -9,18 +9,15 @@ import * as userActions from '../actions/account.actions';
|
||||
import { UserService } from '@app/domain/services/user.service';
|
||||
import { AuthService } from '@app/domain/services/auth.service';
|
||||
import { AppMessageService } from '@app/shared/app-message.service';
|
||||
import { PartnerService } from '@app/partners/services/partner.service';
|
||||
import { PartnerSystemUser } from '@app/accounts/models/user.model';
|
||||
import { RoleIds, globals, KnownPartnerCodes } from '@app/shared/global';
|
||||
import { globals } from '@app/shared/global';
|
||||
|
||||
@Injectable()
|
||||
export class AccountEffects {
|
||||
constructor(
|
||||
constructor(
|
||||
private readonly actions$: Actions,
|
||||
private readonly userSvc: UserService,
|
||||
private readonly authSvc: AuthService,
|
||||
private readonly msgSvc: AppMessageService,
|
||||
private readonly partnerSvc: PartnerService
|
||||
private readonly msgSvc: AppMessageService
|
||||
) {
|
||||
}
|
||||
|
||||
@ -28,250 +25,55 @@ export class AccountEffects {
|
||||
loadUsers$: Observable<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)),
|
||||
catchError(err => {
|
||||
console.error('Partner cleanup failed:', err);
|
||||
// User update succeeded, cleanup failed is not critical
|
||||
return of(new userActions.UpdateSuccess(savedUser));
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Case 2: User WITH partner - use PartnerService workflow completely
|
||||
return this.updatePartnerUserWorkflow(userData, partnerConfig).pipe(
|
||||
map((savedUser) => new userActions.UpdateSuccess(savedUser))
|
||||
);
|
||||
}),
|
||||
catchError(err => this.handleUserOperationError(err, 'save')),
|
||||
repeat()
|
||||
switchMap(({ payload }) =>
|
||||
this.userSvc.saveUser(payload).pipe(
|
||||
map(() => new userActions.UpdateSuccess(payload)),
|
||||
catchError(err => {
|
||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.save).replace('#thing#', globals.account));
|
||||
return of(new userActions.UpdateFailed());
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
@Effect()
|
||||
deleteUser$: Observable<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()
|
||||
switchMap(({ payload }) =>
|
||||
this.userSvc.deleteUser(payload).pipe(
|
||||
map(() => new userActions.DeleteSuccess(payload)),
|
||||
catchError(err => {
|
||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.delete).replace('#thing#', globals.account));
|
||||
return of(new userActions.UpdateFailed())
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Partner user workflow methods - use PartnerService exclusively
|
||||
private createPartnerSystemUser(userData: any, partnerConfig: any): Observable<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);
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { Address } from '@app/domain/models/subscription.model';
|
||||
import { RoleIds, OperationalStatusType } from '@app/shared/global';
|
||||
import { RoleIds } from '@app/shared/global';
|
||||
|
||||
interface RoleArray {
|
||||
[index: number]: string;
|
||||
@ -10,106 +9,24 @@ export interface User {
|
||||
username?: string;
|
||||
password?: string;
|
||||
name?: string;
|
||||
address?: string | null;
|
||||
address?: string;
|
||||
country?: string;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
Country?: any;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
kind: string;
|
||||
roles?: RoleArray;
|
||||
active?: boolean;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
parent?: any;
|
||||
contact?: string;
|
||||
addresses?: Address[];
|
||||
billAddress?;
|
||||
needReview?: boolean;
|
||||
|
||||
// Optional partner system fields (present for partner system users)
|
||||
customer?: string | { _id: string; username: string; name: string; kind: string; };
|
||||
partner?: string | { _id: string; name: string; kind: string; };
|
||||
}
|
||||
|
||||
// PartnerSystemUser extends User with partner-specific fields
|
||||
export interface PartnerSystemUser extends User {
|
||||
// Partner relationships (populated objects from backend via .populate())
|
||||
// NOTE: backend uses .lean() so the 'customer' Mongoose virtual is NOT present.
|
||||
// 'parent' is populated as { _id, username, name, kind } in API responses.
|
||||
partner: {
|
||||
_id: string;
|
||||
name: string;
|
||||
partnerCode?: string;
|
||||
kind: string;
|
||||
};
|
||||
// 'customer' virtual from Mongoose is NOT returned by .lean(). Use 'parent' instead.
|
||||
customer?: {
|
||||
_id: string;
|
||||
username: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
};
|
||||
|
||||
// Partner system credentials
|
||||
partnerUserId?: string; // User ID in partner system
|
||||
partnerUsername?: string; // Username in partner system
|
||||
companyId?: string | null; // Company ID in partner system
|
||||
|
||||
// Access credentials (encrypted in production)
|
||||
apiKey?: string | null;
|
||||
apiSecret?: string | null;
|
||||
|
||||
// Status and metadata
|
||||
lastLoginAt?: Date;
|
||||
lastSyncAt?: Date;
|
||||
syncStatus?: OperationalStatusType;
|
||||
|
||||
// Partner-specific metadata (contains vendor config)
|
||||
metadata?: {
|
||||
vendor?: string;
|
||||
satlocUrl?: string;
|
||||
satlocUsername?: string;
|
||||
satlocPassword?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
// Additional fields from backend response
|
||||
address?: string | null;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
}
|
||||
|
||||
export interface SatlocConnectionResult {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
connectionTime?: number;
|
||||
serverInfo?: {
|
||||
version?: string;
|
||||
capabilities?: string[];
|
||||
};
|
||||
account_info?: SatlocAccountInfo;
|
||||
}
|
||||
|
||||
export interface SatlocAccountInfo {
|
||||
company_name: string;
|
||||
aircraft_count: number;
|
||||
api_version: string;
|
||||
}
|
||||
|
||||
export interface SatlocIntegration {
|
||||
enabled: boolean;
|
||||
status: OperationalStatusType;
|
||||
account_info: SatlocAccountInfo | null;
|
||||
credentials_stored: boolean;
|
||||
last_error: string | null;
|
||||
}
|
||||
|
||||
export const createNewUser = (parentId?: string, kind: String = RoleIds.APP_ADM) => {
|
||||
const user = <User>{
|
||||
_id: '0',
|
||||
kind: kind,
|
||||
active: kind == RoleIds.DEVICE ? false : true,
|
||||
active: true,
|
||||
parent: parentId
|
||||
};
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@ -3,7 +3,7 @@ import {
|
||||
createFeatureSelector,
|
||||
} from '@ngrx/store';
|
||||
|
||||
import * as fromUsers from './users.reducer';
|
||||
import * as fromUsers from './users-reducer';
|
||||
|
||||
/**
|
||||
* The createFeatureSelector function selects a piece of state from the root of the state object.
|
||||
|
||||
@ -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:
|
||||
@ -1,32 +0,0 @@
|
||||
import { Plan, Status } from "@app/domain/models/subscription.model";
|
||||
import { Action } from "@ngrx/store";
|
||||
|
||||
export const FETCH_SUB_PLANS = '[SUB_PLANS] Fetch subscription plans';
|
||||
export class FetchSubPlans implements Action {
|
||||
type: typeof FETCH_SUB_PLANS = FETCH_SUB_PLANS;
|
||||
constructor() { }
|
||||
}
|
||||
|
||||
export const FETCH_SUB_PLANS_SUCCESS = '[SUB_PLANS] Fetch subscription plans success';
|
||||
export class FetchSubPlansSuccess implements Action {
|
||||
type: typeof FETCH_SUB_PLANS_SUCCESS = FETCH_SUB_PLANS_SUCCESS;
|
||||
constructor(readonly payload: Plan) { }
|
||||
}
|
||||
|
||||
export const FETCH_SUB_PLANS_FAILED = '[SUB_PLANS] Fetch subscription plans failed';
|
||||
export class FetchSubPlansFailed implements Action {
|
||||
type: typeof FETCH_SUB_PLANS_FAILED = FETCH_SUB_PLANS_FAILED;
|
||||
constructor(readonly payload: Status) { }
|
||||
}
|
||||
|
||||
export const RESET_SUB_PLANS = '[SUB_PLANS] Reset subscription plans';
|
||||
export class ResetSubPlans implements Action {
|
||||
type: typeof RESET_SUB_PLANS = RESET_SUB_PLANS;
|
||||
constructor() { }
|
||||
}
|
||||
|
||||
export type SubPlansAction =
|
||||
| FetchSubPlans
|
||||
| FetchSubPlansSuccess
|
||||
| FetchSubPlansFailed
|
||||
| ResetSubPlans
|
||||
@ -1,561 +0,0 @@
|
||||
import { UserModel } from '@app/auth/models/user.model';
|
||||
import { BillingInfo, Card, Invoice, StripeSubscription, SubscriptionPackage, RefreshPackage, CreatePaymentMethodPackage, Status, UnpaidPackage, PastDue, Unpaid, SubscriptionIntent, Incomplete, ConfirmPackage, PaidAmount, Coupon, TrialPmtPkg, Trial, PaymentMethod, PMPkgEdit, PMPkgAdd, Plan } from '@app/domain/models/subscription.model';
|
||||
import { Mode } from '@app/profile/common';
|
||||
import { Action } from '@ngrx/store';
|
||||
|
||||
// shared actions
|
||||
export const GOTO_MY_SERVICES = '[SUBSCRIPTION Nav] Navigate to my services page';
|
||||
export class GotoMyServices implements Action {
|
||||
type: typeof GOTO_MY_SERVICES = GOTO_MY_SERVICES;
|
||||
}
|
||||
|
||||
export const GOTO_PAYMENT_HISTORY = '[SUBSCRIPTION Nav] Navigate to payment history page';
|
||||
export class GotoPaymentHistory implements Action {
|
||||
type: typeof GOTO_PAYMENT_HISTORY = GOTO_PAYMENT_HISTORY;
|
||||
}
|
||||
|
||||
export const GOTO_PAYMENT_DETAIL = '[SUBSCRIPTION Nav] Navigate to payment detail page';
|
||||
export class GotoPaymentDetail implements Action {
|
||||
type: typeof GOTO_PAYMENT_DETAIL = GOTO_PAYMENT_DETAIL;
|
||||
constructor(readonly payload: { paymentId: string }) { }
|
||||
}
|
||||
|
||||
export const GOTO_SERVICES = '[SUBSCRIPTION Nav] Navigate to services page';
|
||||
export class GotoServices implements Action {
|
||||
type: typeof GOTO_SERVICES = GOTO_SERVICES;
|
||||
}
|
||||
|
||||
export const GOTO_BILLING_ADDRESS = '[SUBSCRIPTION Nav] Navigate to billing address page';
|
||||
export class GotoBillingAddress implements Action {
|
||||
type: typeof GOTO_BILLING_ADDRESS = GOTO_BILLING_ADDRESS;
|
||||
}
|
||||
|
||||
export const GOTO_CHECK_OUT = '[SUBSCRIPTION Nav] Navigate to checkout page';
|
||||
export class GotoCheckout implements Action {
|
||||
type: typeof GOTO_CHECK_OUT = GOTO_CHECK_OUT;
|
||||
}
|
||||
|
||||
export const GOTO_CHECK_OUT_REVIEW = '[SUBSCRIPTION Nav] Navigate to checkout review page';
|
||||
export class GotoCheckoutReview implements Action {
|
||||
type: typeof GOTO_CHECK_OUT_REVIEW = GOTO_CHECK_OUT_REVIEW;
|
||||
}
|
||||
|
||||
export const GOTO_CHECK_OUT_CONFIRM = '[SUBSCRIPTION Nav] Navigate to checkout confirm page';
|
||||
export class GotoCheckoutConfirm implements Action {
|
||||
type: typeof GOTO_CHECK_OUT_CONFIRM = GOTO_CHECK_OUT_CONFIRM;
|
||||
}
|
||||
|
||||
export const GOTO_HOME = '[SUBSCRIPTION Nav] Navigate to home page';
|
||||
export class GotoHome implements Action {
|
||||
type: typeof GOTO_HOME = GOTO_HOME;
|
||||
}
|
||||
|
||||
export const GOTO_USAGE_DETAIL = '[SUBSCRIPTION Nav] Navigate to usage details';
|
||||
export class GotoUsageDetail implements Action {
|
||||
type: typeof GOTO_USAGE_DETAIL = GOTO_USAGE_DETAIL;
|
||||
}
|
||||
|
||||
export const GOTO_AIRCRAFT_LIST = '[SUBSCRIPTION Nav] Navigate to aircraft list';
|
||||
export class GotoAircraftList implements Action {
|
||||
type: typeof GOTO_AIRCRAFT_LIST = GOTO_AIRCRAFT_LIST;
|
||||
}
|
||||
|
||||
export const COMPOUND = '[SUBSCRIPTION Compound] Execute multiple actions sequentially';
|
||||
export class Compound implements Action {
|
||||
type: typeof COMPOUND = COMPOUND;
|
||||
constructor(readonly payload: Action[]) { }
|
||||
}
|
||||
|
||||
// In session actions
|
||||
export const INIT_SUBSCRIPTION = '[SUBSCRIPTION Manage subscription] Initialize subscriptions contents';
|
||||
export class InitSubscription implements Action {
|
||||
type: typeof INIT_SUBSCRIPTION = INIT_SUBSCRIPTION;
|
||||
constructor(readonly payload: { custId: string }) { }
|
||||
}
|
||||
|
||||
export const START_MY_SERVICES = '[SUBSCRIPTION Making payment intent session] Initialize my services stage';
|
||||
export class StartMyServices implements Action {
|
||||
type: typeof START_MY_SERVICES = START_MY_SERVICES;
|
||||
constructor(readonly payload: { custId: string }) { }
|
||||
}
|
||||
|
||||
export const CREATE_NEW_BILLING = '[SUBSCRIPTION Making payment intent session] Initialize subscription intent with a new account';
|
||||
export class CreateNewBilling implements Action {
|
||||
type: typeof CREATE_NEW_BILLING = CREATE_NEW_BILLING;
|
||||
constructor(readonly payload: SubscriptionIntent) { }
|
||||
}
|
||||
|
||||
export const START_BILLING_INFO = '[SUBSCRIPTION Making payment intent session] Start billing info stage';
|
||||
export class StartBillingInfo implements Action {
|
||||
type: typeof START_BILLING_INFO = START_BILLING_INFO;
|
||||
constructor(readonly payload: any) { }
|
||||
}
|
||||
|
||||
export const START_BILLING_INFO_SUCCESS = '[SUBSCRIPTION Making payment intent session] Start billing info stage, success';
|
||||
export class StartBillingInfoSuccess implements Action {
|
||||
type: typeof START_BILLING_INFO_SUCCESS = START_BILLING_INFO_SUCCESS;
|
||||
constructor(readonly payload: SubscriptionIntent) { }
|
||||
}
|
||||
|
||||
export const CREATE_SUBSCRIPTION_INTENT_FAILED = '[SUBSCRIPTION Making payment intent session] Create subscription intent failed';
|
||||
export class CreateSubscriptionIntentFailed implements Action {
|
||||
type: typeof CREATE_SUBSCRIPTION_INTENT_FAILED = CREATE_SUBSCRIPTION_INTENT_FAILED;
|
||||
constructor(readonly payload: Status) { }
|
||||
}
|
||||
|
||||
export const START_CHECKOUT = '[SUBSCRIPTION Making payment intent session] Start checkout stage';
|
||||
export class StartCheckout implements Action {
|
||||
type: typeof START_CHECKOUT = START_CHECKOUT;
|
||||
constructor(readonly payload: { billingInfo: BillingInfo; subIntentPkg: SubscriptionIntent }) { }
|
||||
}
|
||||
|
||||
export const START_CHECKOUT_SUCCESS = '[SUBSCRIPTION Making payment intent session] Start checkout stage success';
|
||||
export class StartCheckoutSuccess implements Action {
|
||||
type: typeof START_CHECKOUT_SUCCESS = START_CHECKOUT_SUCCESS;
|
||||
constructor(readonly payload: SubscriptionIntent) { }
|
||||
}
|
||||
|
||||
export const UPDATE_BILLING_ADDRESS_SUCCESS = '[SUBSCRIPTION Making payment intent session] Update billing address success';
|
||||
export class UpdateBillingAddressSuccess implements Action {
|
||||
type: typeof UPDATE_BILLING_ADDRESS_SUCCESS = UPDATE_BILLING_ADDRESS_SUCCESS;
|
||||
constructor(readonly payload: BillingInfo) { }
|
||||
}
|
||||
|
||||
export const CREATE_PAYMENT_METHOD = '[SUBSCRIPTION Making payment intent session] Create payment method';
|
||||
export class CreatePaymentMethod implements Action {
|
||||
type: typeof CREATE_PAYMENT_METHOD = CREATE_PAYMENT_METHOD;
|
||||
constructor(readonly payload: CreatePaymentMethodPackage) { }
|
||||
}
|
||||
|
||||
export const CREATE_PAYMENT_METHOD_FAILED = '[SUBSCRIPTION Making payment intent session] Create payment method failed';
|
||||
export class CreatePaymentMethodFailed implements Action {
|
||||
type: typeof CREATE_PAYMENT_METHOD_FAILED = CREATE_PAYMENT_METHOD_FAILED;
|
||||
constructor(readonly payload: Status) { }
|
||||
}
|
||||
|
||||
export const CHECK_OUT = '[SUBSCRIPTION Making payment intent session] Checkout';
|
||||
export class Checkout implements Action {
|
||||
type: typeof CHECK_OUT = CHECK_OUT;
|
||||
constructor(readonly payload: Card) { }
|
||||
}
|
||||
|
||||
export const UPDATE_SUBSCRIPTION = '[SUBSCRIPTION Making payment intent session] Update subscription';
|
||||
export class UpdateSubscription implements Action {
|
||||
type: typeof UPDATE_SUBSCRIPTION = UPDATE_SUBSCRIPTION;
|
||||
constructor(readonly payload: SubscriptionPackage) { }
|
||||
}
|
||||
|
||||
export const CANCEL_SUBSCRIPTION = '[SUBSCRIPTION Making payment intent session] Cancel subscription attempt';
|
||||
export class CancelSubscription implements Action {
|
||||
type: typeof CANCEL_SUBSCRIPTION = CANCEL_SUBSCRIPTION;
|
||||
constructor(readonly payload: SubscriptionIntent) { }
|
||||
}
|
||||
|
||||
export const SET_SUBSCRIPTION_INTENT_PREV_STAGE = '[SUBSCRIPTION Making payment intent session] Set subscription intent previous stage';
|
||||
export class SetSubscriptionIntentPrevStage implements Action {
|
||||
type: typeof SET_SUBSCRIPTION_INTENT_PREV_STAGE = SET_SUBSCRIPTION_INTENT_PREV_STAGE;
|
||||
constructor(readonly payload: string) { }
|
||||
}
|
||||
|
||||
export const UPDATE_SUBSCRIPTION_INTENT_STATUS = '[SUBSCRIPTION Making payment intent session] Update subscription intent status';
|
||||
export class UpdateSubscriptionIntentStatus implements Action {
|
||||
type: typeof UPDATE_SUBSCRIPTION_INTENT_STATUS = UPDATE_SUBSCRIPTION_INTENT_STATUS;
|
||||
constructor(readonly payload: Status) { }
|
||||
}
|
||||
|
||||
export const CLEAR_SUBSCRIPTION_INTENT_STATUS = '[SUBSCRIPTION Making payment intent session] Clear subscription intent status';
|
||||
export class ClearSubscriptionIntentStatus implements Action {
|
||||
type: typeof CLEAR_SUBSCRIPTION_INTENT_STATUS = CLEAR_SUBSCRIPTION_INTENT_STATUS;
|
||||
constructor() { }
|
||||
}
|
||||
|
||||
export const RESET_SUBSCRIPTION_INTENT = '[SUBSCRIPTION Making payment intent session] Reset subscription intent to initial state';
|
||||
export class ResetSubscriptionIntent implements Action {
|
||||
type: typeof RESET_SUBSCRIPTION_INTENT = RESET_SUBSCRIPTION_INTENT;
|
||||
}
|
||||
|
||||
export const CLEAR_PREV_STAGE = '[SUBSCRIPTION Making payment intent session] Set up intent default subIntent stage';
|
||||
export class ClearPrevStage implements Action {
|
||||
type: typeof CLEAR_PREV_STAGE = CLEAR_PREV_STAGE;
|
||||
}
|
||||
|
||||
export const UPDATE_AMOUNT = '[SUBSCRIPTION Making payment intent session] Update payment amount';
|
||||
export class UpdateAmount implements Action {
|
||||
type: typeof UPDATE_AMOUNT = UPDATE_AMOUNT;
|
||||
constructor(readonly payload: PaidAmount) { }
|
||||
}
|
||||
|
||||
export const UPDATE_PROMO_SAVINGS = '[SUBSCRIPTION Making payment intent session] Update promo savings';
|
||||
export class UpdatePromoSavings implements Action {
|
||||
type: typeof UPDATE_PROMO_SAVINGS = UPDATE_PROMO_SAVINGS;
|
||||
constructor(readonly payload: number) { }
|
||||
}
|
||||
|
||||
// Resolving payment session actions
|
||||
export const FETCH_LATEST_SUBSCRIPTION = '[SUBSCRIPTION Resolving payment session] Fetch latest subscription';
|
||||
export class FetchLatestSubscription implements Action {
|
||||
type: typeof FETCH_LATEST_SUBSCRIPTION = FETCH_LATEST_SUBSCRIPTION;
|
||||
constructor(readonly payload: { custId: string }) { }
|
||||
}
|
||||
|
||||
export const FETCH_LATEST_SUBSCRIPTION_SUCCESS = '[SUBSCRIPTION Resolving payment session] Fetch latest subscription success';
|
||||
export class FetchLatestSubscriptionSuccess implements Action {
|
||||
type: typeof FETCH_LATEST_SUBSCRIPTION_SUCCESS = FETCH_LATEST_SUBSCRIPTION_SUCCESS;
|
||||
constructor(readonly payload: Plan) { }
|
||||
}
|
||||
|
||||
export const POLL_UNPAID_SUBSCRIPTION = '[SUBSCRIPTION Resolving payment session] Poll for unpaid subscription';
|
||||
export class PollUnpaidSubscription implements Action {
|
||||
type: typeof POLL_UNPAID_SUBSCRIPTION = POLL_UNPAID_SUBSCRIPTION;
|
||||
constructor(readonly payload: { custId: string }) { }
|
||||
}
|
||||
|
||||
export const POLL_UNPAID_SUBSCRIPTION_SUCCESS = '[SUBSCRIPTION Resolving payment session] Poll for unpaid success';
|
||||
export class PollUnpaidSubscriptionSuccess implements Action {
|
||||
type: typeof POLL_UNPAID_SUBSCRIPTION_SUCCESS = POLL_UNPAID_SUBSCRIPTION_SUCCESS;
|
||||
constructor(readonly payload: Plan) { }
|
||||
}
|
||||
|
||||
export const CANCEL_POLL_SUBSCRIPTION = '[SUBSCRIPTION Resolving payment session] Cancel polling for latest subscriptions';
|
||||
export class CancelPollSubscription implements Action {
|
||||
type: typeof CANCEL_POLL_SUBSCRIPTION = CANCEL_POLL_SUBSCRIPTION;
|
||||
constructor() { }
|
||||
}
|
||||
|
||||
export const RESET_SUBSCRIPTION = '[SUBSCRIPTION Resolving payment session] Reset subscription to initial state';
|
||||
export class ResetSubscription implements Action {
|
||||
type: typeof RESET_SUBSCRIPTION = RESET_SUBSCRIPTION;
|
||||
}
|
||||
|
||||
export const REFRESH_SUBSCRIPTION_INTENT = '[SUBSCRIPTION Resolving payment session] Refesh unresolved subscription';
|
||||
export class RefeshSubscriptionIntent implements Action {
|
||||
type: typeof REFRESH_SUBSCRIPTION_INTENT = REFRESH_SUBSCRIPTION_INTENT;
|
||||
constructor(readonly payload: RefreshPackage) { }
|
||||
}
|
||||
|
||||
export const REFRESH_SUBSCRIPTION_INTENT_SUCCESS = '[SUBSCRIPTION Resolving payment session] Refesh unresolved subscription success';
|
||||
export class RefeshSubscriptionIntentSuccess implements Action {
|
||||
type: typeof REFRESH_SUBSCRIPTION_INTENT_SUCCESS = REFRESH_SUBSCRIPTION_INTENT_SUCCESS;
|
||||
constructor(readonly payload: SubscriptionIntent) { }
|
||||
}
|
||||
|
||||
export const REQUIRE_ACTION = '[SUBSCRIPTION Resolving payment session] Require action on payment method';
|
||||
export class RequireAction implements Action {
|
||||
type: typeof REQUIRE_ACTION = REQUIRE_ACTION;
|
||||
constructor(readonly payload: ConfirmPackage[]) { }
|
||||
}
|
||||
|
||||
export const RESOLVE_PAYMENT = '[SUBSCRIPTION Resolving payment session] Resolve payment outstanding subsriptions';
|
||||
export class ResolvePayment implements Action {
|
||||
type: typeof RESOLVE_PAYMENT = RESOLVE_PAYMENT;
|
||||
}
|
||||
|
||||
export const SHOW_UNPAID_SUBSCRIPTION = '[SUBSCRIPTION Resolving payment session] Show unpaid subscription list';
|
||||
export class ShowUnpaidSubscription implements Action {
|
||||
type: typeof SHOW_UNPAID_SUBSCRIPTION = SHOW_UNPAID_SUBSCRIPTION;
|
||||
constructor() { }
|
||||
}
|
||||
|
||||
export const RESUME_UNPAID_SUBSCRIPTION = '[SUBSCRIPTION Resolving payment session] Fetch unpaid subscriptions';
|
||||
export class ResumeUnpaidSubscription implements Action {
|
||||
type: typeof RESUME_UNPAID_SUBSCRIPTION = RESUME_UNPAID_SUBSCRIPTION;
|
||||
constructor(readonly payload: {
|
||||
unpaidInvoices: Invoice[],
|
||||
name: string,
|
||||
authUser: UserModel
|
||||
}) { }
|
||||
}
|
||||
|
||||
export const RESUME_UNPAID_SUBSCRIPTION_SUCCESS = '[SUBSCRIPTION Resolving payment session] Fetch unpaid subscriptions success';
|
||||
export class ResumeUnpaidSubscriptionSuccess implements Action {
|
||||
type: typeof RESUME_UNPAID_SUBSCRIPTION_SUCCESS = RESUME_UNPAID_SUBSCRIPTION_SUCCESS;
|
||||
constructor(readonly payload: {
|
||||
unpaid: Unpaid,
|
||||
subscriptions: StripeSubscription[],
|
||||
status: Status
|
||||
}) { }
|
||||
}
|
||||
|
||||
export const PAY_UNPAID_SUBSCRIPTION = '[SUBSCRIPTION Resolving payment session] Pay unpaid subscription';
|
||||
export class PayUnpaidSubscription implements Action {
|
||||
type: typeof PAY_UNPAID_SUBSCRIPTION = PAY_UNPAID_SUBSCRIPTION;
|
||||
constructor(readonly payload: UnpaidPackage) { }
|
||||
}
|
||||
|
||||
export const CONFIRM = '[SUBSCRIPTION Resolving payment session] Confirm card payment for incomplete and pastdue payments';
|
||||
export class Confirm implements Action {
|
||||
type: typeof CONFIRM = CONFIRM;
|
||||
constructor(readonly payload: ConfirmPackage) { }
|
||||
}
|
||||
|
||||
export const COMPLETE_PAYMENT = '[SUBSCRIPTION Resolving payment session] Completed resolving unresolved invoices';
|
||||
export class CompletePayment implements Action {
|
||||
type: typeof COMPLETE_PAYMENT = COMPLETE_PAYMENT;
|
||||
}
|
||||
|
||||
export const UPDATE_PAST_DUE = '[SUBSCRIPTION Resolving payment session] Retry past due payment';
|
||||
export class UpdatePastDue implements Action {
|
||||
type: typeof UPDATE_PAST_DUE = UPDATE_PAST_DUE;
|
||||
constructor(readonly payload: PastDue) { }
|
||||
}
|
||||
|
||||
export const UPDATE_INCOMPLETE = '[SUBSCRIPTION Resolving payment session] Retry incomplete Payment';
|
||||
export class UpdateIncomplete implements Action {
|
||||
type: typeof UPDATE_INCOMPLETE = UPDATE_INCOMPLETE;
|
||||
constructor(readonly payload: Incomplete) { }
|
||||
}
|
||||
|
||||
export const UPDATE_UNPAID = '[SUBSCRIPTION Resolving payment session] Update unpaid';
|
||||
export class UpdateUnpaid implements Action {
|
||||
type: typeof UPDATE_UNPAID = UPDATE_UNPAID;
|
||||
constructor(readonly payload: Unpaid) { }
|
||||
}
|
||||
|
||||
export const UPDATE_SUBSCRIPTION_STATUS = '[SUBSCRIPTION] Update subscriptionstatus';
|
||||
export class UpdateSubscriptionStatus implements Action {
|
||||
type: typeof UPDATE_SUBSCRIPTION_STATUS = UPDATE_SUBSCRIPTION_STATUS;
|
||||
constructor(readonly payload: Status) { }
|
||||
}
|
||||
|
||||
export const CLEAR_SUBSCRIPTION_STATUS = '[SUBSCRIPTION] Clear subscriptionstatus';
|
||||
export class ClearSubscriptionStatus implements Action {
|
||||
type: typeof CLEAR_SUBSCRIPTION_STATUS = CLEAR_SUBSCRIPTION_STATUS;
|
||||
constructor() { }
|
||||
}
|
||||
|
||||
export const CLEAR_SUBSCRIPTION = '[SUBSCRIPTION] Clear subscriptions';
|
||||
export class ClearSubscription implements Action {
|
||||
type: typeof CLEAR_SUBSCRIPTION = CLEAR_SUBSCRIPTION;
|
||||
constructor() { }
|
||||
}
|
||||
|
||||
export const LOAD_STRIPE = '[SUBSCRIPTION] load stripe api';
|
||||
export class LoadStripe implements Action {
|
||||
type: typeof LOAD_STRIPE = LOAD_STRIPE;
|
||||
constructor() { }
|
||||
}
|
||||
|
||||
export const LOAD_STRIPE_SUCCESS = '[SUBSCRIPTION] load stripe api success';
|
||||
export class LoadStripeSuccess implements Action {
|
||||
type: typeof LOAD_STRIPE_SUCCESS = LOAD_STRIPE_SUCCESS;
|
||||
constructor() { }
|
||||
}
|
||||
|
||||
export const LOAD_STRIPE_FAILED = '[SUBSCRIPTION] load stripe api failed';
|
||||
export class LoadStripeFailed implements Action {
|
||||
type: typeof LOAD_STRIPE_FAILED = LOAD_STRIPE_FAILED;
|
||||
constructor(readonly payload: Status) { }
|
||||
}
|
||||
|
||||
export const APPLY_DISCOUNT_PREVIEW = '[SUBSCRIPTION] Apply discount preview';
|
||||
export class ApplyDiscountPreview implements Action {
|
||||
type: typeof APPLY_DISCOUNT_PREVIEW = APPLY_DISCOUNT_PREVIEW;
|
||||
constructor(readonly payload: { subIntentPkg: SubscriptionIntent, coupon?: string }) { }
|
||||
}
|
||||
|
||||
export const APPLY_DISCOUNT_PREVIEW_SUCCESS = '[SUBSCRIPTION] Apply discount preview success';
|
||||
export class ApplyDiscountPreviewSuccess implements Action {
|
||||
type: typeof APPLY_DISCOUNT_PREVIEW_SUCCESS = APPLY_DISCOUNT_PREVIEW_SUCCESS;
|
||||
constructor(readonly payload: { coupons: Coupon[], amount: PaidAmount }) { }
|
||||
}
|
||||
|
||||
export const APPLY_DISCOUNT_PREVIEW_FAILED = '[SUBSCRIPTION] Apply discount preview failed';
|
||||
export class ApplyDiscountPreviewFailed implements Action {
|
||||
type: typeof APPLY_DISCOUNT_PREVIEW_FAILED = APPLY_DISCOUNT_PREVIEW_FAILED;
|
||||
constructor(readonly payload: Status) { }
|
||||
}
|
||||
|
||||
|
||||
// Payment method actions
|
||||
export const FETCH_PAYMENT_METHOD_LIST = '[SUBSCRIPTION] Fetch payment methods';
|
||||
export class FetchPaymentMethodList implements Action {
|
||||
type: typeof FETCH_PAYMENT_METHOD_LIST = FETCH_PAYMENT_METHOD_LIST;
|
||||
}
|
||||
|
||||
export const FETCH_PAYMENT_METHOD_LIST_SUCCESS = '[SUBSCRIPTION] Fetch payment methods success';
|
||||
export class FetchPaymentMethodListSuccess implements Action {
|
||||
type: typeof FETCH_PAYMENT_METHOD_LIST_SUCCESS = FETCH_PAYMENT_METHOD_LIST_SUCCESS;
|
||||
constructor(readonly payload: { paymentMethods: PaymentMethod[] }) { }
|
||||
}
|
||||
|
||||
export const FETCH_DEFAULT_PM = '[SUBSCRIPTION] Fetch default payment methods';
|
||||
export class FetchDefaultPm implements Action {
|
||||
type: typeof FETCH_DEFAULT_PM = FETCH_DEFAULT_PM;
|
||||
}
|
||||
|
||||
export const FETCH_DEFAULT_PM_SUCCESS = '[SUBSCRIPTION] Fetch default payment methods success';
|
||||
export class FetchDefaultPmSuccess implements Action {
|
||||
type: typeof FETCH_DEFAULT_PM_SUCCESS = FETCH_DEFAULT_PM_SUCCESS;
|
||||
constructor(readonly payload: { defPM: PaymentMethod }) { }
|
||||
}
|
||||
|
||||
export const EDIT_PM = '[SUBSCRIPTION] Edit payment method';
|
||||
export class EditPM implements Action {
|
||||
type: typeof EDIT_PM = EDIT_PM;
|
||||
constructor(readonly payload: PMPkgEdit) { }
|
||||
}
|
||||
|
||||
export const EDIT_PM_SUCCESS = '[SUBSCRIPTION] Edit payment method success';
|
||||
export class EditPMSuccess implements Action {
|
||||
type: typeof EDIT_PM_SUCCESS = EDIT_PM_SUCCESS;
|
||||
constructor(readonly payload: PaymentMethod) { }
|
||||
}
|
||||
|
||||
export const ADD_PM = '[SUBSCRIPTION] Add payment method';
|
||||
export class AddPM implements Action {
|
||||
type: typeof ADD_PM = ADD_PM;
|
||||
constructor(readonly payload: PMPkgAdd) { }
|
||||
}
|
||||
|
||||
export const ADD_PM_SUCCESS = '[SUBSCRIPTION] Add payment method success';
|
||||
export class AddPMSuccess implements Action {
|
||||
type: typeof ADD_PM_SUCCESS = ADD_PM_SUCCESS;
|
||||
constructor(readonly payload: PaymentMethod) { }
|
||||
}
|
||||
|
||||
export const DELETE_PM = '[SUBSCRIPTION] Delete payment method';
|
||||
export class DeletePM implements Action {
|
||||
type: typeof DELETE_PM = DELETE_PM;
|
||||
constructor(readonly payload: string) { }
|
||||
}
|
||||
|
||||
export const DELETE_PM_SUCCESS = '[SUBSCRIPTION] Delete payment method success';
|
||||
export class DeletePMSuccess implements Action {
|
||||
type: typeof DELETE_PM_SUCCESS = DELETE_PM_SUCCESS;
|
||||
constructor(readonly payload: string) { }
|
||||
}
|
||||
|
||||
export const CHANGE_PM = '[SUBSCRIPTION] Change default payment method';
|
||||
export class ChangePM implements Action {
|
||||
type: typeof CHANGE_PM = CHANGE_PM;
|
||||
constructor(readonly payload: { custId: string, pmId: string }) { }
|
||||
}
|
||||
|
||||
export const CHANGE_PM_SUCCESS = '[SUBSCRIPTION] Change default payment method success';
|
||||
export class ChangePMSuccess implements Action {
|
||||
type: typeof CHANGE_PM_SUCCESS = CHANGE_PM_SUCCESS;
|
||||
constructor(readonly payload: { defPM: PaymentMethod }) { }
|
||||
}
|
||||
|
||||
// Payment success
|
||||
export const CONFIRM_ACTION_SUCCESS = '[SUBSCRIPTION Resolving payment session] Confirm card payment for incomplete and require action success';
|
||||
export class ConfirmActionSuccess implements Action {
|
||||
type: typeof CONFIRM_ACTION_SUCCESS = CONFIRM_ACTION_SUCCESS;
|
||||
constructor(readonly payload: Plan) { }
|
||||
}
|
||||
|
||||
export const CONFIRM_PAYMENT_SUCCESS = '[SUBSCRIPTION Resolving payment session] Confirm card payment for incomplete and require payment method success';
|
||||
export class ConfirmPaymentSuccess implements Action {
|
||||
type: typeof CONFIRM_PAYMENT_SUCCESS = CONFIRM_PAYMENT_SUCCESS;
|
||||
constructor(readonly payload: Plan) { }
|
||||
}
|
||||
|
||||
export const PAY_UNPAID_SUBSCRIPTION_SUCCESS = '[SUBSCRIPTION Resolving payment session] Pay unpaid subscription success';
|
||||
export class PayUnpaidSubscriptionSuccess implements Action {
|
||||
type: typeof PAY_UNPAID_SUBSCRIPTION_SUCCESS = PAY_UNPAID_SUBSCRIPTION_SUCCESS;
|
||||
constructor(readonly payload: Plan) { }
|
||||
}
|
||||
|
||||
export const UPDATE_SUBSCRIPTION_SUCCESS = '[SUBSCRIPTION Making payment intent session] Update subscription success';
|
||||
export class UpdateSubscriptionSuccess implements Action {
|
||||
type: typeof UPDATE_SUBSCRIPTION_SUCCESS = UPDATE_SUBSCRIPTION_SUCCESS;
|
||||
constructor(readonly payload: Plan) { }
|
||||
}
|
||||
|
||||
// trial subscription section
|
||||
export const SET_TRIAL_MODE = '[SUBSCRIPTION Making payment intent session] Starting a trial subscription';
|
||||
export class SetMode implements Action {
|
||||
type: typeof SET_TRIAL_MODE = SET_TRIAL_MODE;
|
||||
constructor(readonly payload: Mode) { }
|
||||
}
|
||||
|
||||
export const UPDATE_TRIAL = '[SUBSCRIPTION] Update trials object';
|
||||
export class UpdateTrial implements Action {
|
||||
type: typeof UPDATE_TRIAL = UPDATE_TRIAL;
|
||||
constructor(readonly payload: Trial) { }
|
||||
}
|
||||
|
||||
export const CHECK_OUT_TRIAL = '[SUBSCRIPTION Making payment intent session] Checkout a trial subscription';
|
||||
export class CheckoutTrial implements Action {
|
||||
type: typeof CHECK_OUT_TRIAL = CHECK_OUT_TRIAL;
|
||||
constructor(readonly payload: TrialPmtPkg) { }
|
||||
}
|
||||
|
||||
export const CHECK_OUT_TRIAL_SUCCESS = '[SUBSCRIPTION Making payment intent session] Checkout a trial subscription success';
|
||||
export class CheckoutTrialSuccess implements Action {
|
||||
type: typeof CHECK_OUT_TRIAL_SUCCESS = CHECK_OUT_TRIAL_SUCCESS;
|
||||
constructor(readonly payload: { card?: Card, subs: StripeSubscription[], amount?: PaidAmount }) { }
|
||||
}
|
||||
|
||||
export type SubscriptionIntentAction =
|
||||
| CompletePayment
|
||||
| ConfirmActionSuccess
|
||||
| ConfirmPaymentSuccess
|
||||
| Checkout
|
||||
| StartBillingInfo
|
||||
| StartBillingInfoSuccess
|
||||
| CreateSubscriptionIntentFailed
|
||||
| CreatePaymentMethodFailed
|
||||
| ClearSubscriptionIntentStatus
|
||||
| GotoBillingAddress
|
||||
| GotoCheckout
|
||||
| GotoCheckoutReview
|
||||
| GotoServices
|
||||
| PayUnpaidSubscriptionSuccess
|
||||
| RefeshSubscriptionIntent
|
||||
| RefeshSubscriptionIntentSuccess
|
||||
| ResetSubscriptionIntent
|
||||
| SetSubscriptionIntentPrevStage
|
||||
| UpdateSubscriptionIntentStatus
|
||||
| StartCheckout
|
||||
| StartCheckoutSuccess
|
||||
| UpdateBillingAddressSuccess
|
||||
| UpdateSubscriptionSuccess
|
||||
| UpdateAmount
|
||||
| UpdatePromoSavings
|
||||
| ClearPrevStage
|
||||
| GotoUsageDetail
|
||||
| LoadStripe
|
||||
| LoadStripeFailed
|
||||
| ApplyDiscountPreviewSuccess
|
||||
| ApplyDiscountPreviewFailed
|
||||
| SetMode
|
||||
| CheckoutTrialSuccess
|
||||
|
||||
export type SubscriptionAction =
|
||||
| CompletePayment
|
||||
| Confirm
|
||||
| ConfirmActionSuccess
|
||||
| ConfirmPaymentSuccess
|
||||
| ClearSubscriptionStatus
|
||||
| ClearSubscription
|
||||
| GotoCheckout
|
||||
| GotoServices
|
||||
| FetchLatestSubscriptionSuccess
|
||||
| PollUnpaidSubscriptionSuccess
|
||||
| PayUnpaidSubscriptionSuccess
|
||||
| UpdateUnpaid
|
||||
| UpdatePastDue
|
||||
| UpdateIncomplete
|
||||
| ResetSubscription
|
||||
| ResumeUnpaidSubscription
|
||||
| ResumeUnpaidSubscriptionSuccess
|
||||
| StartCheckoutSuccess
|
||||
| UpdateSubscriptionStatus
|
||||
| UpdateSubscription
|
||||
| UpdateSubscriptionSuccess
|
||||
| StartBillingInfoSuccess
|
||||
| FetchPaymentMethodList
|
||||
| FetchPaymentMethodListSuccess
|
||||
| FetchDefaultPm
|
||||
| FetchDefaultPmSuccess
|
||||
| EditPM
|
||||
| EditPMSuccess
|
||||
| AddPM
|
||||
| AddPMSuccess
|
||||
| DeletePMSuccess
|
||||
| ChangePMSuccess
|
||||
| CheckoutTrialSuccess
|
||||
| UpdateTrial
|
||||
|
||||
|
||||
@ -1,23 +1,20 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
|
||||
import { PageNotFoundComponent } from './page-not-found.component';
|
||||
import { AuthGuard } from './domain/guards/auth.guard';
|
||||
|
||||
import { DashboardComponent } from './dashboard/dashboard.component';
|
||||
import { ReportComponent } from './report.component';
|
||||
import { AppMainComponent } from './app.main.component';
|
||||
import { AppPreloader } from './app-preloader';
|
||||
import { AppPasswordResetComp } from './pages/app.password-reset.component';
|
||||
import { NotificationRedirectGuard } from './domain/guards/notification-redirect.guard';
|
||||
import { SettingsGuard } from './domain/guards/settings-guard.service';
|
||||
import { MembershipResolver } from './domain/resolvers/membership-resolver';
|
||||
|
||||
const routes: Routes = [
|
||||
{ path: '', redirectTo: '/home', pathMatch: 'full' },
|
||||
{
|
||||
path: '', component: AppMainComponent,
|
||||
resolve: {
|
||||
membership: MembershipResolver
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'home',
|
||||
@ -31,15 +28,16 @@ const routes: Routes = [
|
||||
path: 'customers',
|
||||
loadChildren: () => import('./customers/customer.module').then(m => m.CustomersModule),
|
||||
},
|
||||
{
|
||||
path: 'partners',
|
||||
loadChildren: () => import('./partners/partners.module').then(m => m.PartnersModule),
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
loadChildren: () => import('./profile/profile.module').then((m) => m.ProfileModule),
|
||||
runGuardsAndResolvers: 'always',
|
||||
// runGuardsAndResolvers: "always",
|
||||
},
|
||||
// {
|
||||
// path: 'membership',
|
||||
// loadChildren: () => import('./subscription/membership.module').then((m) => m.MembershipModule),
|
||||
// // runGuardsAndResolvers: "always",
|
||||
// },
|
||||
{
|
||||
path: 'billing',
|
||||
loadChildren: () => import('./billing/billing.module').then(m => m.BillingModule),
|
||||
@ -80,16 +78,6 @@ const routes: Routes = [
|
||||
runGuardsAndResolvers: 'always',
|
||||
data: { preload: true }
|
||||
},
|
||||
{
|
||||
path: 'partner-customers',
|
||||
loadChildren: () => import('./partner-customers/partner-customers.module').then(m => m.PartnerCustomersModule),
|
||||
runGuardsAndResolvers: 'always'
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
loadChildren: () => import('./settings/settings.module').then(m => m.SettingsModule),
|
||||
runGuardsAndResolvers: 'always'
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -115,39 +103,8 @@ const routes: Routes = [
|
||||
roles: null
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'signup',
|
||||
loadChildren: () => import('./signup/signup.module').then(m => m.SignupModule)
|
||||
},
|
||||
{
|
||||
path: 'manage-subscription',
|
||||
component: PageNotFoundComponent,
|
||||
canActivate: [NotificationRedirectGuard],
|
||||
data: {
|
||||
redirectTo: ['profile', 'myservices'],
|
||||
redirectToNoSubs: ['profile', 'services'],
|
||||
loginNotice: $localize`:Login notice for manage-subscription link@@manageSubLoginNotice:Please log in with your Master account to manage your subscriptions.`
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'update-pm',
|
||||
component: PageNotFoundComponent,
|
||||
canActivate: [NotificationRedirectGuard],
|
||||
data: {
|
||||
redirectTo: ['profile', 'payment-method-list'],
|
||||
loginNotice: $localize`:Login notice for update-pm link@@updatePmLoginNotice:Please log in with your Master account to update your payment method.`
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'update-bill-address',
|
||||
component: PageNotFoundComponent,
|
||||
canActivate: [NotificationRedirectGuard],
|
||||
data: {
|
||||
redirectTo: ['profile', 'billing-address'],
|
||||
loginNotice: $localize`:Login notice for update-bill-address link@@updateBillAddrLoginNotice:Please log in with your Master account to update your billing address.`
|
||||
}
|
||||
},
|
||||
{ path: '**', component: PageNotFoundComponent },
|
||||
// { path: '/denied', component: AccessDeniedComponent },
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
@ -166,6 +123,6 @@ const routes: Routes = [
|
||||
exports: [
|
||||
RouterModule
|
||||
],
|
||||
providers: [AppPreloader, MembershipResolver],
|
||||
providers: [AppPreloader],
|
||||
})
|
||||
export class AppRoutingModule { }
|
||||
|
||||
36
Development/client/src/app/app.component.spec.ts
Normal file
36
Development/client/src/app/app.component.spec.ts
Normal file
@ -0,0 +1,36 @@
|
||||
/* tslint:disable:no-unused-variable */
|
||||
|
||||
import { TestBed, async } from '@angular/core/testing';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { AppComponent } from './app.component';
|
||||
import { AppTopbarComponent } from './app.topbar.component';
|
||||
import { AppInlineProfileComponent } from './app.profile.component';
|
||||
import { AppFooterComponent } from './app.footer.component';
|
||||
import { AppBreadcrumbComponent } from './app.breadcrumb.component';
|
||||
import { AppMenuComponent, AppSubMenuComponent } from './app.menu.component';
|
||||
import { BreadcrumbService } from './breadcrumb.service';
|
||||
import { ScrollPanelModule} from 'primeng/primeng';
|
||||
|
||||
describe('AppComponent', () => {
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [ RouterTestingModule, ScrollPanelModule ],
|
||||
declarations: [ AppComponent,
|
||||
AppTopbarComponent,
|
||||
AppMenuComponent,
|
||||
AppSubMenuComponent,
|
||||
AppFooterComponent,
|
||||
AppBreadcrumbComponent,
|
||||
AppInlineProfileComponent
|
||||
],
|
||||
providers: [BreadcrumbService]
|
||||
});
|
||||
TestBed.compileComponents();
|
||||
});
|
||||
|
||||
it('should create the app', async(() => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.debugElement.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
}));
|
||||
});
|
||||
@ -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() {
|
||||
|
||||
@ -28,23 +28,14 @@
|
||||
<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>
|
||||
<router-outlet></router-outlet>
|
||||
<agm-footer *ngIf="showFooter" [showLang]="!isAdmin"></agm-footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,22 +1,14 @@
|
||||
import { Component, AfterViewInit, ElementRef, ViewChild, OnDestroy, OnInit, NgZone, ChangeDetectorRef, AfterViewChecked } from '@angular/core';
|
||||
import { Component, AfterViewInit, ElementRef, ViewChild, OnDestroy, OnInit, NgZone } from '@angular/core';
|
||||
import { MenuService } from './app.menu.service';
|
||||
import { AuthService } from './domain/services/auth.service';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Router } from '@angular/router';
|
||||
import { ConfirmationService } from 'primeng-lts/api';
|
||||
import { DomSanitizer } from '@angular/platform-browser';
|
||||
import { Observable, combineLatest } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
import cloneDeep from 'clone-deep';
|
||||
import { globals } from './shared/global';
|
||||
import { AppConfigService } from './domain/services/app-config.service';
|
||||
import { IAppConfig } from './domain/models/appconfig.model';
|
||||
import { Compound, FetchLatestSubscriptionSuccess, GotoServices, SetMode } from './actions/subscription.actions';
|
||||
import { Mode, SUB } from './profile/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { IMembership, UserModel } from './auth/models/user.model';
|
||||
import { ExpiryWarning } from './domain/models/subscription.model';
|
||||
import { buildExpiryWarningMessage } from './app.profile.component';
|
||||
import * as fromStore from '../../src/app/reducers/index';
|
||||
|
||||
enum MenuOrientation {
|
||||
STATIC,
|
||||
@ -29,7 +21,7 @@ enum MenuOrientation {
|
||||
selector: 'app-main',
|
||||
templateUrl: './app.main.component.html'
|
||||
})
|
||||
export class AppMainComponent implements AfterViewInit, OnDestroy, OnInit, AfterViewChecked {
|
||||
export class AppMainComponent implements AfterViewInit, OnDestroy, OnInit {
|
||||
|
||||
layoutCompact = true;
|
||||
|
||||
@ -74,46 +66,27 @@ export class AppMainComponent implements AfterViewInit, OnDestroy, OnInit, After
|
||||
rippleMouseDownListener: any;
|
||||
|
||||
settings: IAppConfig;
|
||||
membership: IMembership;
|
||||
user$: Observable<UserModel>;
|
||||
expiryWarning$: Observable<ExpiryWarning | null>;
|
||||
|
||||
constructor(
|
||||
|
||||
public readonly zone: NgZone,
|
||||
private router: Router,
|
||||
private readonly sanitizer: DomSanitizer,
|
||||
private readonly menuService: MenuService,
|
||||
private readonly authSvc: AuthService,
|
||||
private readonly appConfSvc: AppConfigService,
|
||||
private readonly confirmSvc: ConfirmationService,
|
||||
private readonly route: ActivatedRoute,
|
||||
private readonly store: Store,
|
||||
private cdr: ChangeDetectorRef
|
||||
) {
|
||||
this.membership = this.route.snapshot.data['membership'];
|
||||
private readonly confirmSvc: ConfirmationService) {
|
||||
|
||||
this.settings = cloneDeep(this.appConfSvc.settings);
|
||||
this.user$ = this.store.select(fromStore.selectAuthUser);
|
||||
this.expiryWarning$ = combineLatest([
|
||||
this.store.select(fromStore.selectExpiryWarning),
|
||||
this.store.select(fromStore.selectNoSubsWarning)
|
||||
]).pipe(map(([expiry, noSubs]) => expiry ?? noSubs));
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.zone.runOutsideAngular(() => { this.bindRipple(); });
|
||||
|
||||
if (/*!this.authSvc.isBillable &&*/ !this.settings.noPopup)
|
||||
if (!this.authSvc.isBillable && !this.settings.noPopup)
|
||||
this.showPaidPopup();
|
||||
}
|
||||
|
||||
getExpiryWarningMessage(warning: ExpiryWarning): string {
|
||||
return buildExpiryWarningMessage(warning);
|
||||
}
|
||||
|
||||
onNavigateToManageSubscription(): void {
|
||||
this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]);
|
||||
}
|
||||
|
||||
bindRipple() {
|
||||
this.rippleInitListener = this.init.bind(this);
|
||||
document.addEventListener('DOMContentLoaded', this.rippleInitListener);
|
||||
@ -229,13 +202,6 @@ export class AppMainComponent implements AfterViewInit, OnDestroy, OnInit, After
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.layoutContainer = this.layourContainerViewChild.nativeElement as HTMLDivElement;
|
||||
if (this.membership) {
|
||||
setTimeout(() => this.store.dispatch(new FetchLatestSubscriptionSuccess({ membership: this.membership })), 100);
|
||||
}
|
||||
}
|
||||
|
||||
ngAfterViewChecked() {
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
|
||||
onLayoutClick() {
|
||||
@ -417,39 +383,20 @@ export class AppMainComponent implements AfterViewInit, OnDestroy, OnInit, After
|
||||
return this.authSvc.isAdmin;
|
||||
}
|
||||
|
||||
get isApplicator() {
|
||||
return this.authSvc.isApplicator;
|
||||
}
|
||||
|
||||
get shouldShowPaidMsg() {
|
||||
return false; // Disable paid notification popup for now
|
||||
return (/*!this.authSvc.isBillable && */!this.authSvc.isAdmin && !this.authSvc.isClientUser && !this.authSvc.isInspector);
|
||||
return (!this.authSvc.isBillable && !this.authSvc.isAdmin && !this.authSvc.isClientUser && !this.authSvc.isInspector);
|
||||
}
|
||||
|
||||
showPaidPopup() {
|
||||
if (!this.shouldShowPaidMsg) return false; // Skip paid notification popup for now
|
||||
if (!this.shouldShowPaidMsg) return; // Skip paid notification popup for now
|
||||
|
||||
// let msgHtml = $localize`:Paid start time notification popup message@@paidInformMsg:<p>Dear Agmission Customers,</p>
|
||||
// <p>Ag-Mission will become a paid service on Saturday, July 15th 2023.
|
||||
// Please contact Ag-Nav Inc. at 1-800-99-AGNAV, or email joset@agnav.com at your earliest convenience.
|
||||
// </p>
|
||||
// <p>Current Ag-Mission users will get the rest of 2023 at the lowest “Unlimited” tier price, and 10% off for 2024; with a special discount for users that subscribe in advance for 2024.</p>
|
||||
// <p>For more information, please <a target="_blank" href="https://www.agnav.com/platinum/wp-content/uploads/2023/06/general-ag-mission-presentation.pdf">CLICK HERE</a> to view the features presentation.</p>
|
||||
// <p>If you have any questions please do not hesitate to call us or email <a target="_blank" href="mailto:general@agnav.com">general@agnav.com</a></p>
|
||||
// `;
|
||||
|
||||
let msgHtml = `<h2 class="message-header">Important Notice</h2>
|
||||
<p>Dear Ag-Mission Users,</p>
|
||||
<p>We sincerely apologize for the recent disruptions in accessing Ag-Mission and retrieving data. Our main server in New Jersey has been experiencing intermittent downtime since Monday, and the automatic rollover to the backup server encountered data migration challenges.</p>
|
||||
<p>Our team is working diligently to resolve these issues and restore data access. We anticipate the process may take a couple of days to complete. We appreciate your patience and understanding as we work to resolve this matter as quickly as possible.</p>
|
||||
<h3 style="padding:0;margin:0">Action Required</h3>
|
||||
<ul style="margin:0; display: table">
|
||||
<li style="padding:0;margin:0">Update your Platinum units to software version 2.21.3 via the AgNav website to ensure continued functionality and compatibility with AgMission.</li>
|
||||
<li style="padding:0;margin:0">If you’re using Aircraft Job Assignment feature, all aircraft must be activated under Entities in your master account provided by AgNav. (The number of aircraft allowed is based on your selected package).</li>
|
||||
</ul>
|
||||
<p style="margin-top:14px">Thank you for your continued support.<br/>
|
||||
Best regards,<br>
|
||||
Ag-Mission Team</p>
|
||||
let msgHtml = $localize`:Paid start time notification popup message@@paidInformMsg:<p>Dear Agmission Customers,</p>
|
||||
<p>Ag-Mission will become a paid service on Saturday, July 15th 2023.
|
||||
Please contact Ag-Nav Inc. at 1-800-99-AGNAV, or email joset@agnav.com at your earliest convenience.
|
||||
</p>
|
||||
<p>Current Ag-Mission users will get the rest of 2023 at the lowest “Unlimited” tier price, and 10% off for 2024; with a special discount for users that subscribe in advance for 2024.</p>
|
||||
<p>For more information, please <a target="_blank" href="https://www.agnav.com/platinum/wp-content/uploads/2023/06/general-ag-mission-presentation.pdf">CLICK HERE</a> to view the features presentation.</p>
|
||||
<p>If you have any questions please do not hesitate to call us or email <a target="_blank" href="mailto:general@agnav.com">general@agnav.com</a></p>
|
||||
`;
|
||||
|
||||
this.confirmSvc.confirm({
|
||||
@ -465,26 +412,4 @@ export class AppMainComponent implements AfterViewInit, OnDestroy, OnInit, After
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
canDisplayTrial() {
|
||||
// this.membership is populated synchronously by the route resolver, before the
|
||||
// NgRx auth store is hydrated (FetchLatestSubscriptionSuccess fires 100ms later).
|
||||
// Using it here prevents the banner from flashing on F5 reload for users who
|
||||
// already have subscriptions (including trialing ones).
|
||||
if (this.membership?.subscriptions?.length > 0) return false;
|
||||
return this.authSvc.canDisplayTrial(this.membership?.trials);
|
||||
}
|
||||
|
||||
accept() {
|
||||
if (this.router.url.includes(SUB.SERVICES)) return this.store.dispatch(new SetMode(Mode.TRIALING));
|
||||
return this.store.dispatch(new Compound([new SetMode(Mode.TRIALING), new GotoServices()]));
|
||||
}
|
||||
|
||||
isTrialDays() {
|
||||
return this.authSvc.isTrialDays(this.membership?.trials);
|
||||
}
|
||||
|
||||
canDisplayAcceptTrial() {
|
||||
return this.authSvc.canAcceptTrial(this.router.url);
|
||||
}
|
||||
}
|
||||
@ -1,12 +1,10 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { AppMainComponent } from './app.main.component';
|
||||
|
||||
import { AuthService } from './domain/services/auth.service';
|
||||
import { RoleIds } from './shared/global';
|
||||
|
||||
import { MenuItem } from 'primeng/api';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { selectSubLimit } from './reducers';
|
||||
import { FetchSubPlans } from './actions/sub-plans.actions';
|
||||
import { SubKeys } from './profile/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-menu',
|
||||
@ -17,225 +15,172 @@ import { SubKeys } from './profile/common';
|
||||
`
|
||||
})
|
||||
export class AppMenuComponent implements OnInit {
|
||||
model: any[] = [];
|
||||
/**
|
||||
Convention: every root item with children must have routerLink for selected check after page reloaded
|
||||
**/
|
||||
model: any[];
|
||||
|
||||
constructor(
|
||||
|
||||
public readonly app: AppMainComponent,
|
||||
private readonly authSvc: AuthService,
|
||||
private readonly store: Store<{}>
|
||||
) { }
|
||||
private readonly authSvc: AuthService) {
|
||||
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
const mItems: MenuItem[] = [
|
||||
{ id: 'dashboard', label: $localize`:@@dashboard:Dashboard`, icon: 'dashboard', routerLink: ['/home'] }
|
||||
];
|
||||
if (this.authSvc.hasRole([RoleIds.ADMIN])) {
|
||||
this.creatAdminMenu()
|
||||
} else if (this.authSvc.isPartner) {
|
||||
this.createPartnerMenu();
|
||||
} else {
|
||||
this.createUserMenu();
|
||||
}
|
||||
}
|
||||
|
||||
creatAdminMenu() {
|
||||
const mItems: MenuItem[] = [
|
||||
{ id: 'dashboard', label: $localize`:@@dashboard:Dashboard`, icon: 'dashboard', routerLink: ['/home'] },
|
||||
{ id: 'customers', label: $localize`:@@customers:Customers`, icon: 'assignment_ind', routerLink: ['/customers'] },
|
||||
{ id: 'partners', label: $localize`:@@partnerMgnt:Partner Management`, icon: 'business', routerLink: ['/partners'] },
|
||||
{ label: $localize`:@@billing:Billing`, icon: 'monetization_on', routerLink: ['/billing'] },
|
||||
{
|
||||
id: 'settings',
|
||||
label: $localize`:@@settings:Settings`, icon: 'settings',
|
||||
routerLink: ['/settings'],
|
||||
items: [
|
||||
{ id: 'subscription', label: $localize`:@@promoManagement:Promo Management`, icon: 'credit_card', routerLink: ['/settings/subscription'] }
|
||||
]
|
||||
},
|
||||
];
|
||||
this.model = mItems;
|
||||
}
|
||||
|
||||
createPartnerMenu() {
|
||||
const mItems: MenuItem[] = [
|
||||
{ id: 'dashboard', label: $localize`:@@dashboard:Dashboard`, icon: 'dashboard', routerLink: ['/home'] },
|
||||
{
|
||||
id: 'partner-customers',
|
||||
label: $localize`:@@partnerCustomers:Partner Customers`,
|
||||
icon: 'business',
|
||||
routerLink: ['/partner-customers']
|
||||
},
|
||||
{
|
||||
id: 'Help',
|
||||
label: $localize`:@@help:Help`, icon: 'help_outline',
|
||||
items: [{
|
||||
label: $localize`:@@trainingVideos:Training Videos`,
|
||||
icon: 'video_library',
|
||||
url: 'https://www.youtube.com/watch?v=QjGZan5QdAo&list=PLSMll_kIgHA3eamxiSH0Dgl95v60okMcV',
|
||||
target: '_blank'
|
||||
}]
|
||||
}
|
||||
];
|
||||
this.model = mItems;
|
||||
}
|
||||
|
||||
createUserMenu() {
|
||||
this.store.select(selectSubLimit).subscribe({
|
||||
next: (subLimit) => {
|
||||
const hasSubLimit = !!subLimit && (Object.keys(subLimit.package || {}).length > 0 || Object.keys(subLimit.addon || {}).length > 0);
|
||||
const mItems: MenuItem[] = [
|
||||
{ id: 'dashboard', label: $localize`:@@dashboard:Dashboard`, icon: 'dashboard', routerLink: ['/home'] }
|
||||
];
|
||||
if (hasSubLimit) {
|
||||
this.addSubItems(mItems, subLimit);
|
||||
}
|
||||
this.model = mItems;
|
||||
},
|
||||
error: (err) => {
|
||||
console.log(err);
|
||||
}
|
||||
});
|
||||
this.store.dispatch(new FetchSubPlans());
|
||||
}
|
||||
|
||||
private addSubItems(mItems: MenuItem[], subLimit: any) {
|
||||
const hasTracking = subLimit?.addon?.[SubKeys.TRACKING]?.airCraft?.numOfVehicle > 0;
|
||||
const hasPackage = Object.keys(subLimit.package || {}).length > 0;
|
||||
const hasOnlyTracking = !hasPackage && hasTracking;
|
||||
const hasOnlyPackage = hasPackage && !hasTracking;
|
||||
|
||||
if (hasOnlyTracking) {
|
||||
this.addOnlyTrackingItems(mItems);
|
||||
} else if (hasOnlyPackage) {
|
||||
this.addOnlyPackageItems(mItems);
|
||||
} else if (hasPackage && hasTracking) {
|
||||
this.addFullAccessItems(mItems);
|
||||
}
|
||||
|
||||
mItems.push(
|
||||
{
|
||||
id: 'Help',
|
||||
label: $localize`:@@help:Help`, icon: 'help_outline',
|
||||
items: [{
|
||||
label: $localize`:@@trainingVideos:Training Videos`,
|
||||
icon: 'video_library',
|
||||
url: 'https://www.youtube.com/watch?v=QjGZan5QdAo&list=PLSMll_kIgHA3eamxiSH0Dgl95v60okMcV',
|
||||
target: '_blank'
|
||||
}]
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private addOnlyTrackingItems(mItems: MenuItem[]) {
|
||||
if (!this.authSvc.hasRole([RoleIds.INSPECTOR])) {
|
||||
mItems.push(
|
||||
{
|
||||
id: 'entities',
|
||||
label: $localize`:@@entities:Entities`, icon: 'library_books',
|
||||
routerLink: ['/entities'],
|
||||
items: [{ label: $localize`:@@aircraft:Aircraft`, icon: 'airplanemode_active', routerLink: ['/entities/aircraft'] }]
|
||||
},
|
||||
{
|
||||
id: 'tools',
|
||||
label: $localize`:@@tools:Tools`, icon: 'extension',
|
||||
routerLink: ['/tools'],
|
||||
...[
|
||||
{ id: 'customers', label: $localize`:@@customers:Customers`, icon: 'assignment_ind', routerLink: ['/customers'] },
|
||||
{ label: $localize`:@@billing:Billing`, icon: 'monetization_on', routerLink: ['/billing'] },
|
||||
// { label: $localize`:@@reports:Reports`, icon: 'print', routerLink: ['/reports'] },
|
||||
]);
|
||||
} else {
|
||||
if (this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM])) {
|
||||
mItems.push({ id: 'accounts', label: $localize`:@@accounts:Accounts`, icon: 'assignment_ind', routerLink: ['/accounts'] });
|
||||
}
|
||||
|
||||
if (!this.authSvc.isClientUser) {
|
||||
mItems.push({ id: 'clients', label: $localize`:@@clients:Clients`, icon: 'people', routerLink: ['/clients'] });
|
||||
}
|
||||
mItems.push({ id: 'jobs', label: $localize`:@@jobs:Jobs`, icon: 'assignment', routerLink: ['/jobs'] });
|
||||
|
||||
if (this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM, RoleIds.CLIENT, RoleIds.PILOT, RoleIds.OFFICER])) {
|
||||
mItems.push({
|
||||
id: 'invoice',
|
||||
label: $localize`:@@invoices:Invoices`, icon: 'receipt_long',
|
||||
routerLink: ['/invoices'],
|
||||
items: [
|
||||
{ id: 'settings', label: $localize`:@@settings:Settings`, icon: 'settings', routerLink: ['/tools/settings'] }
|
||||
]
|
||||
}
|
||||
);
|
||||
}
|
||||
if (!this.authSvc.hasRole([RoleIds.CLIENT, RoleIds.INSPECTOR])) {
|
||||
mItems.push({ id: 'track', label: $localize`:@@tracking:Tracking`, icon: 'track_changes', routerLink: ['/track'] });
|
||||
}
|
||||
}
|
||||
|
||||
private addOnlyPackageItems(mItems: MenuItem[]) {
|
||||
if (this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM])) {
|
||||
mItems.push({ id: 'accounts', label: $localize`:@@accounts:Accounts`, icon: 'assignment_ind', routerLink: ['/accounts'] });
|
||||
}
|
||||
|
||||
if (!this.authSvc.isClientUser) {
|
||||
mItems.push({ id: 'clients', label: $localize`:@@clients:Clients`, icon: 'people', routerLink: ['/clients'] });
|
||||
}
|
||||
mItems.push({ id: 'jobs', label: $localize`:@@jobs:Jobs`, icon: 'assignment', routerLink: ['/jobs'] });
|
||||
|
||||
if (!this.authSvc.hasRole([RoleIds.INSPECTOR])) {
|
||||
this.addEntitiesAndToolsItems(mItems);
|
||||
}
|
||||
|
||||
this.addInvoiceItems(mItems);
|
||||
}
|
||||
|
||||
private addFullAccessItems(mItems: MenuItem[]) {
|
||||
if (this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM])) {
|
||||
mItems.push({ id: 'accounts', label: $localize`:@@accounts:Accounts`, icon: 'assignment_ind', routerLink: ['/accounts'] });
|
||||
}
|
||||
|
||||
if (!this.authSvc.isClientUser) {
|
||||
mItems.push({ id: 'clients', label: $localize`:@@clients:Clients`, icon: 'people', routerLink: ['/clients'] });
|
||||
}
|
||||
mItems.push({ id: 'jobs', label: $localize`:@@jobs:Jobs`, icon: 'assignment', routerLink: ['/jobs'] });
|
||||
|
||||
if (!this.authSvc.hasRole([RoleIds.INSPECTOR])) {
|
||||
this.addEntitiesAndToolsItems(mItems);
|
||||
}
|
||||
if (!this.authSvc.hasRole([RoleIds.CLIENT, RoleIds.INSPECTOR])) {
|
||||
mItems.push({ id: 'track', label: $localize`:@@tracking:Tracking`, icon: 'track_changes', routerLink: ['/track'] });
|
||||
}
|
||||
|
||||
this.addInvoiceItems(mItems);
|
||||
}
|
||||
|
||||
private addEntitiesAndToolsItems(mItems: MenuItem[]) {
|
||||
mItems.push(
|
||||
{
|
||||
id: 'entities',
|
||||
label: $localize`:@@entities:Entities`, icon: 'library_books',
|
||||
routerLink: ['/entities'],
|
||||
items: this.authSvc.isClientUser ?
|
||||
[
|
||||
{ label: $localize`:@@crops:Crops`, icon: 'list', routerLink: ['/entities/crops'] },
|
||||
{ label: $localize`:@@products:Products`, icon: 'widgets', routerLink: ['/entities/products'] },
|
||||
]
|
||||
: [
|
||||
{ label: $localize`:@@aircraft:Aircraft`, icon: 'airplanemode_active', routerLink: ['/entities/aircraft'] },
|
||||
{ label: $localize`:@@crops:Crops`, icon: 'list', routerLink: ['/entities/crops'] },
|
||||
{ label: $localize`:@@products:Products`, icon: 'widgets', routerLink: ['/entities/products'] },
|
||||
{ label: $localize`:@@pilots:Pilots`, icon: 'contacts', routerLink: ['/entities/pilots'] }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'tools',
|
||||
label: $localize`:@@tools:Tools`, icon: 'extension',
|
||||
routerLink: ['/tools'],
|
||||
items: [
|
||||
{ id: 'upload', label: $localize`:@@uploadJobData:Upload Job Data`, icon: 'cloud_upload', routerLink: ['/tools/upload'] },
|
||||
{ id: 'areaLib', label: $localize`:@@manageAreasLib:Manage Areas Library`, icon: 'folder_special', routerLink: ['/tools/areas'] },
|
||||
{ id: 'settings', label: $localize`:@@settings:Settings`, icon: 'settings', routerLink: ['/tools/settings'] }
|
||||
]
|
||||
});
|
||||
}
|
||||
);
|
||||
const invoice = mItems.filter(i => i.id === 'invoice');
|
||||
if (invoice && invoice.length && this.authSvc.hasRole([RoleIds.CLIENT, RoleIds.OFFICER, RoleIds.PILOT])) {
|
||||
invoice[0].items.push(...[
|
||||
{ id: 'view', label: $localize`:@@viewInvoice:View Invoices`, icon: 'list', routerLink: ['/invoices'] }
|
||||
]);
|
||||
}
|
||||
if (invoice && invoice.length && this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM])) {
|
||||
invoice[0].items.push(...[
|
||||
{ id: 'view', label: $localize`:@@viewEditInvoice:View/Edit Invoices`, icon: 'list', routerLink: ['/invoices'] }
|
||||
]);
|
||||
}
|
||||
if (invoice && invoice.length && this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM, RoleIds.OFFICER, RoleIds.PILOT])) {
|
||||
invoice[0].items.push(...[
|
||||
{
|
||||
id: 'costing-item',
|
||||
label: $localize`:@@costingItems:Costing Items`,
|
||||
icon: 'payments',
|
||||
routerLink: ['/invoices/costing-items']
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (invoice && invoice.length && this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM])) {
|
||||
invoice[0].items.push(...[
|
||||
{
|
||||
id: 'settings',
|
||||
label: $localize`:@@invoiceSettings:Invoice Settings`,
|
||||
icon: 'settings',
|
||||
routerLink: ['/invoices/settings']
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (!this.authSvc.hasRole([RoleIds.INSPECTOR])) {
|
||||
mItems.push(
|
||||
...[
|
||||
{
|
||||
id: 'entities',
|
||||
label: $localize`:@@entities:Entities`, icon: 'library_books',
|
||||
routerLink: ['/entities'],
|
||||
items: this.authSvc.isClientUser ?
|
||||
[
|
||||
{ label: $localize`:@@crops:Crops`, icon: 'list', routerLink: ['/entities/crops'] },
|
||||
{ label: $localize`:@@products:Products`, icon: 'widgets', routerLink: ['/entities/products'] },
|
||||
]
|
||||
: [
|
||||
{ label: $localize`:@@aircraft:Aircraft`, icon: 'airplanemode_active', routerLink: ['/entities/aircraft'] },
|
||||
{ label: $localize`:@@crops:Crops`, icon: 'list', routerLink: ['/entities/crops'] },
|
||||
{ label: $localize`:@@products:Products`, icon: 'widgets', routerLink: ['/entities/products'] },
|
||||
{ label: $localize`:@@pilots:Pilots`, icon: 'contacts', routerLink: ['/entities/pilots'] }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'tools',
|
||||
label: $localize`:@@tools:Tools`, icon: 'extension',
|
||||
routerLink: ['/tools'],
|
||||
items: [
|
||||
{ id: 'upload', label: $localize`:@@uploadJobData:Upload Job Data`, icon: 'cloud_upload', routerLink: ['/tools/upload'] },
|
||||
{ id: 'areaLib', label: $localize`:@@manageAreasLib:Manage Areas Library`, icon: 'folder_special', routerLink: ['/tools/areas'] }
|
||||
]
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
if (!this.authSvc.hasRole([RoleIds.CLIENT, RoleIds.INSPECTOR])) {
|
||||
mItems.push({ id: 'track', label: $localize`:@@tracking:Tracking`, icon: 'track_changes', routerLink: ['/track'] });
|
||||
}
|
||||
|
||||
const tools = mItems.filter(i => i.id === 'tools');
|
||||
if (tools && tools.length) {
|
||||
tools[0].items.push({ id: 'settings', label: $localize`:@@settings:Settings`, icon: 'settings', routerLink: ['/tools/settings'] });
|
||||
}
|
||||
|
||||
}
|
||||
this.model = mItems;
|
||||
// let validLinks = [];
|
||||
// this.getValidLinks(this.router.config, this.router.config[0], validLinks);
|
||||
// this.updateMenuItem(this.model, validLinks);
|
||||
}
|
||||
|
||||
private addInvoiceItems(mItems: MenuItem[]) {
|
||||
if (this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM, RoleIds.CLIENT, RoleIds.OFFICER])) {
|
||||
mItems.push({
|
||||
id: 'invoice',
|
||||
label: $localize`:@@invoices:Invoices`, icon: 'receipt_long',
|
||||
routerLink: ['/invoices'],
|
||||
items: []
|
||||
});
|
||||
}
|
||||
const invoice = mItems.find(i => i.id === 'invoice');
|
||||
if (invoice) {
|
||||
if (this.authSvc.hasRole([RoleIds.APP_ADM, RoleIds.CLIENT, RoleIds.OFFICER])) {
|
||||
invoice.items.push({ id: 'view', label: $localize`:@@viewInvoice:View Invoices`, icon: 'list', routerLink: ['/invoices'] });
|
||||
/*
|
||||
private getValidLinks(routes: Route[], parent: Route, links: string[]) {
|
||||
for (const route of routes) {
|
||||
if (!route.children || route.children.length === 0) {
|
||||
if (!route.data || (route.data && (!route.data.roles || this.authService.hasRole(route.data.roles)))) {
|
||||
if (route.path !== '**') {
|
||||
let combinedPath = parent ? `/${parent.path}/${route.path}` : route.path;
|
||||
combinedPath = combinedPath.replace('/:id', '');
|
||||
if (!combinedPath.startsWith('/')) {
|
||||
combinedPath = '/' + combinedPath;
|
||||
}
|
||||
if (combinedPath.length > 1 && combinedPath.endsWith('/')) {
|
||||
combinedPath = combinedPath.slice(0, combinedPath.length - 1);
|
||||
}
|
||||
links.push(combinedPath);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.getValidLinks(route.children, route, links);
|
||||
parent = null;
|
||||
}
|
||||
}
|
||||
if (this.authSvc.hasRole([RoleIds.APP])) {
|
||||
invoice.items.push(
|
||||
{ id: 'view', label: $localize`:@@viewEditInvoice:View/Edit Invoices`, icon: 'list', routerLink: ['/invoices'] },
|
||||
{ id: 'costing-item', label: $localize`:@@costingItems:Costing Items`, icon: 'payments', routerLink: ['/invoices/costing-items'] },
|
||||
{ id: 'settings', label: $localize`:@@invoiceSettings:Invoice Settings`, icon: 'settings', routerLink: ['/invoices/settings'] }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private updateMenuItem(mnuItems: MenuItem[], validLinks: string[]) {
|
||||
for (const it of mnuItems) {
|
||||
if (it.items) {
|
||||
// this.updateMenuItem(it.items, validLinks);
|
||||
let visible = false;
|
||||
// it.items.forEach(el => {
|
||||
// if (!el.hasOwnProperty('visible') || el.visible) {
|
||||
// visible = true;
|
||||
// return;
|
||||
// }
|
||||
// });
|
||||
if (!visible) {
|
||||
it.visible = false; // hide menu item which has none sub-items allowed to be shown
|
||||
}
|
||||
} else {
|
||||
if (it.routerLink && it.routerLink.length > 0) {
|
||||
if (validLinks.indexOf(it.routerLink[0]) === -1) {
|
||||
it.visible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { APP_INITIALIZER, NgModule, TRANSLATIONS, LOCALE_ID, TRANSLATIONS_FORMAT, Injector } from '@angular/core';
|
||||
import { NgModule, TRANSLATIONS, LOCALE_ID, TRANSLATIONS_FORMAT, Injector } from '@angular/core';
|
||||
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||
@ -10,7 +10,6 @@ import { ButtonModule } from 'primeng/button';
|
||||
import { MenuModule } from 'primeng/menu';
|
||||
import { ProgressSpinnerModule } from 'primeng/progressspinner';
|
||||
import { ConfirmDialogModule } from 'primeng/confirmdialog';
|
||||
import { DialogModule } from 'primeng/dialog';
|
||||
import { DynamicDialogRef, DynamicDialogConfig } from 'primeng/dynamicdialog';
|
||||
import { ConfirmationService, MessageService } from 'primeng/api';
|
||||
import { ToastModule } from 'primeng/toast';
|
||||
@ -22,6 +21,7 @@ import { AppMainComponent } from './app.main.component';
|
||||
import { AppMenuComponent } from './app.menu.component';
|
||||
import { AppMenuitemComponent } from './app.menuitem.component';
|
||||
import { AppTopbarComponent } from './app.topbar.component';
|
||||
import { AppFooterComponent } from './app.footer.component';
|
||||
import { AppInlineProfileComponent } from './app.profile.component';
|
||||
|
||||
import { DashboardComponent } from './dashboard/dashboard.component';
|
||||
@ -52,10 +52,8 @@ import { AppConfigService } from './domain/services/app-config.service';
|
||||
import { AuthInterceptor } from './domain/services/auth-interceptor.service';
|
||||
|
||||
import { SettingsGuard } from './domain/guards/settings-guard.service';
|
||||
|
||||
import { LanguageSwicherComponent } from './language-swicher.component';
|
||||
import { AppEffects } from './effects/app.effects';
|
||||
import { SubPlansEffects } from './effects/sub-plans.effects';
|
||||
|
||||
import { Utils } from './shared/utils';
|
||||
import { AppInjector } from './app-injector';
|
||||
import { BaseComp } from './shared/base/base.component';
|
||||
@ -67,11 +65,8 @@ import { GlobalModule } from './shared/global.module';
|
||||
import { HttpCancelService } from './domain/services/httpcancel.service';
|
||||
import { ManageHttpInterceptor } from './domain/services/managehttp.interceptor.service';
|
||||
import { InvoiceService } from '@app/domain/services/invoice.service';
|
||||
|
||||
import '@app/shared/number.extension';
|
||||
import { RoutingEffects } from './effects/routing.effects';
|
||||
import { SubscriptionEffects } from './effects/subscription.effects';
|
||||
import { AppSharedModule } from './shared/app-shared.module';
|
||||
import { GlobalErrorInterceptor } from './domain/services/global-error.interceptor';
|
||||
|
||||
// Use the require method provided by webpack
|
||||
declare const require;
|
||||
@ -83,11 +78,17 @@ export function translationsFactory(locale: string) {
|
||||
return require(`raw-loader!../locale/messages.${locale}.xlf`).default;
|
||||
}
|
||||
|
||||
// export function loadSetting(appInitService: AppConfig) {
|
||||
// return (): Promise<any> => {
|
||||
// return appInitService.load();
|
||||
// }
|
||||
// }
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
BrowserModule, BrowserAnimationsModule, HttpClientModule, GlobalModule,
|
||||
InputTextModule, ButtonModule, MenuModule, ProgressSpinnerModule, ScrollPanelModule,
|
||||
MessagesModule, ToastModule, ConfirmDialogModule, DialogModule, DropdownModule, CheckboxModule, AppSharedModule,
|
||||
MessagesModule, ToastModule, ConfirmDialogModule, DropdownModule, CheckboxModule,
|
||||
// The store that defines our app state
|
||||
StoreModule.forRoot(reducers, {
|
||||
metaReducers,
|
||||
@ -101,7 +102,7 @@ export function translationsFactory(locale: string) {
|
||||
// Must instrument after importing StoreModule
|
||||
StoreDevtoolsModule.instrument({ name: 'AgMission', maxAge: 15, logOnly: environment.production }),
|
||||
AppRoutingModule,
|
||||
EffectsModule.forRoot([AppEffects, SubPlansEffects, RoutingEffects, SubscriptionEffects]),
|
||||
EffectsModule.forRoot([AppEffects]),
|
||||
],
|
||||
declarations: [
|
||||
BaseComp,
|
||||
@ -115,10 +116,14 @@ export function translationsFactory(locale: string) {
|
||||
AppMenuitemComponent,
|
||||
AppInlineProfileComponent,
|
||||
AppTopbarComponent,
|
||||
AppFooterComponent,
|
||||
LanguageSwicherComponent,
|
||||
ReportComponent,
|
||||
AppPasswordResetComp
|
||||
AppPasswordResetComp,
|
||||
],
|
||||
providers: [
|
||||
// AppConfig,
|
||||
// { provide: APP_INITIALIZER, useFactory: loadSetting, deps: [AppConfig], multi: true },
|
||||
{
|
||||
provide: TRANSLATIONS,
|
||||
useFactory: translationsFactory,
|
||||
@ -133,11 +138,6 @@ export function translationsFactory(locale: string) {
|
||||
useClass: AuthInterceptor,
|
||||
multi: true
|
||||
},
|
||||
{
|
||||
provide: HTTP_INTERCEPTORS,
|
||||
useClass: GlobalErrorInterceptor,
|
||||
multi: true
|
||||
},
|
||||
{
|
||||
provide: ActionsSubject, useClass: AppDispatcher
|
||||
},
|
||||
@ -149,8 +149,11 @@ export function translationsFactory(locale: string) {
|
||||
exports: [],
|
||||
entryComponents: []
|
||||
})
|
||||
|
||||
export class AppModule {
|
||||
// Diagnostic only: inspect router configuration
|
||||
// constructor(router: Router) {
|
||||
// console.log('Routes: ', JSON.stringify(router.config, undefined, 2));
|
||||
// }
|
||||
constructor(private readonly injector: Injector) {
|
||||
AppInjector.setInjector(injector);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
@ -1,138 +1,81 @@
|
||||
import { Component, Input, Output, EventEmitter } from '@angular/core';
|
||||
import { of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { globals } from './shared/global';
|
||||
import { UserModel } from './auth/models/user.model';
|
||||
import { UserService } from './domain/services/user.service';
|
||||
import { ExpiryWarning } from './domain/models/subscription.model';
|
||||
import { AppMainComponent } from './app.main.component';
|
||||
import { Component } from '@angular/core';
|
||||
import { trigger, state, transition, style, animate } from '@angular/animations';
|
||||
|
||||
export function buildExpiryWarningMessage(expiryWarning: ExpiryWarning | null): string {
|
||||
if (!expiryWarning) return '';
|
||||
|
||||
if (expiryWarning.noSubs) {
|
||||
return $localize`:No subscription warning@@noSubsWarning:No current AgMission service subscribed` +
|
||||
' - ' + $localize`:Renew@@renewLabel:Renew`;
|
||||
}
|
||||
|
||||
const messages: string[] = [];
|
||||
const daysLabel = (days: number) =>
|
||||
days === 0
|
||||
? $localize`:Expiring today@@today:today`
|
||||
: `${$localize`:In@@in:in`} ${days} ${$localize`:Days@@days:days`}`;
|
||||
|
||||
if (expiryWarning.package) {
|
||||
const pkg = expiryWarning.package;
|
||||
const days = pkg.daysUntilExpiry;
|
||||
const willRenew = pkg.willAutoRenew;
|
||||
const isTrial = pkg.isTrial;
|
||||
const isCanceled = pkg.isCanceled;
|
||||
|
||||
if (isCanceled) {
|
||||
messages.push(`${pkg.name} ${$localize`:Package canceled@@pkgCanceled:canceled - access ended`} - ${$localize`:Renew now@@renewNow:Renew Now`}`);
|
||||
} else if (isTrial) {
|
||||
if (willRenew) {
|
||||
messages.push(`${pkg.name} ${$localize`:Trial renewing@@pkgTrialRenewing:trial ends`} ${daysLabel(days)} - ${$localize`:Will auto-renew@@willAutoRenew:will Auto-Renew`}`);
|
||||
} else {
|
||||
messages.push(`${pkg.name} ${$localize`:Trial expiring@@pkgTrialExpiring:trial ends`} ${daysLabel(days)} - ${$localize`:Renew now@@renewNow:Renew Now`}`);
|
||||
}
|
||||
} else {
|
||||
if (willRenew) {
|
||||
messages.push(`${pkg.name} ${$localize`:Package renewing@@pkgRenewing:renews`} ${daysLabel(days)}`);
|
||||
} else {
|
||||
messages.push(`${pkg.name} ${$localize`:Package expiring@@pkgExpiring:expires`} ${daysLabel(days)} - ${$localize`:Renew now@@renewNow:Renew Now`}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (expiryWarning.addons && expiryWarning.addons.length > 0) {
|
||||
expiryWarning.addons.forEach(addon => {
|
||||
const days = addon.daysUntilExpiry;
|
||||
const willRenew = addon.willAutoRenew;
|
||||
const isTrial = addon.isTrial;
|
||||
const isCanceled = addon.isCanceled;
|
||||
|
||||
if (isCanceled) {
|
||||
messages.push(`${addon.name} ${$localize`:Addon canceled@@addonCanceled:canceled - access ended`} - ${$localize`:Renew now@@renewNow:Renew Now`}`);
|
||||
} else if (isTrial) {
|
||||
if (willRenew) {
|
||||
messages.push(`${addon.name} ${$localize`:Addon trial renewing@@addonTrialRenewing:trial ends`} ${daysLabel(days)} - ${$localize`:Will auto-renew@@willAutoRenew:will Auto-Renew`}`);
|
||||
} else {
|
||||
messages.push(`${addon.name} ${$localize`:Addon trial expiring@@addonTrialExpiring:trial ends`} ${daysLabel(days)} - ${$localize`:Renew now@@renewNow:Renew Now`}`);
|
||||
}
|
||||
} else {
|
||||
if (willRenew) {
|
||||
messages.push(`${addon.name} ${$localize`:Addon renewing@@addonRenewing:renews`} ${daysLabel(days)}`);
|
||||
} else {
|
||||
messages.push(`${addon.name} ${$localize`:Addon expiring@@addonExpiring:expires`} ${daysLabel(days)} - ${$localize`:Renew now@@renewNow:Renew Now`}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return messages.join('; ');
|
||||
}
|
||||
import { Store } from '@ngrx/store';
|
||||
import * as authActions from './auth/actions/auth.actions';
|
||||
|
||||
@Component({
|
||||
selector: "app-inline-profile",
|
||||
templateUrl: "./app.profile.component.html",
|
||||
styleUrls: ['./app.profile.component.css']
|
||||
template: `
|
||||
<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">
|
||||
<i class="material-icons">security</i>
|
||||
<span>Privacy</span>
|
||||
</a>
|
||||
</li>
|
||||
<li role="menuitem">
|
||||
<a href="#" class="ripplelink" [attr.tabindex]="!active ? '-1' : null">
|
||||
<i class="material-icons">settings_application</i>
|
||||
<span>Settings</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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,68 +0,0 @@
|
||||
<div class="topbar clearfix">
|
||||
<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>
|
||||
<a id="menu-button" href="#" (click)="app.onMenuButtonClick($event)">
|
||||
<i></i>
|
||||
</a>
|
||||
|
||||
<a id="topbar-menu-button" href="#" (click)="app.onTopbarMenuButtonClick($event)">
|
||||
<i class="topbar-icon material-icons animated">menu</i>
|
||||
<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 }">
|
||||
<a href="#" (click)="app.onTopbarItemClick($event, profile)">
|
||||
<i class="topbar-icon material-icons">apps</i>
|
||||
<span class="topbar-item-name">Profile</span>
|
||||
</a>
|
||||
|
||||
<ul class="ultima-menu animated fadeInDown">
|
||||
<li role="menuitem">
|
||||
<a href="javascript:void(0)" (click)="updateUserProfile(user._id)">
|
||||
<i class="material-icons">person</i>
|
||||
<span i18n="@@profile">Profile</span>
|
||||
</a>
|
||||
</li>
|
||||
<ng-container *ngIf="app.isApplicator">
|
||||
<li role="menuitem">
|
||||
<a href="javascript:void(0)" (click)="manageServices()">
|
||||
<i class="material-icons">widgets</i>
|
||||
<span i18n="@@services">Services</span>
|
||||
</a>
|
||||
</li>
|
||||
<li role="menuitem">
|
||||
<a href="javascript:void(0)" (click)="manageBilling()">
|
||||
<i class="material-icons">card_membership</i>
|
||||
<span i18n="@@billing">Billing</span>
|
||||
</a>
|
||||
</li>
|
||||
<li role="menuitem">
|
||||
<a href="javascript:void(0)" (click)="manageContact(user)">
|
||||
<i class="material-icons">contact_mail</i>
|
||||
<span i18n="@@contact">Contact</span>
|
||||
</a>
|
||||
</li>
|
||||
</ng-container>
|
||||
<li role="menuitem">
|
||||
<a href="javascript:void(0)" (click)="onLogout($event)">
|
||||
<i class="material-icons">power_settings_new</i>
|
||||
<span i18n="@@signOut">Sign out</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<!-- <li #notifications id="top_notification">
|
||||
<a href="#" *ngIf="app.shouldShowPaidMsg" (click)="app.onTopbarSubItemClick($event, notifications)">
|
||||
<i class="topbar-icon medium material-icons animated swing">notifications</i>
|
||||
<span class="topbar-badge animated">1</span>
|
||||
<span class="topbar-item-name" i18n="@@notifications">Notifications</span>
|
||||
</a>
|
||||
</li> -->
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@ -1,89 +1,91 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { Component, OnDestroy } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { Observable, Subscription, combineLatest } from 'rxjs';
|
||||
import { first, filter, switchMap, map } from 'rxjs/operators';
|
||||
|
||||
import { Subscription } from 'rxjs';
|
||||
import { Store } from '@ngrx/store';
|
||||
|
||||
import { AppMainComponent } from './app.main.component';
|
||||
|
||||
import * as authActions from './auth/actions/auth.actions';
|
||||
|
||||
import * as fromStore from '../../src/app/reducers/index';
|
||||
import { UserModel } from './auth/models/user.model';
|
||||
import { ExpiryWarning } from './domain/models/subscription.model';
|
||||
import { SUB } from './profile/common';
|
||||
import { UserService } from './domain/services/user.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-topbar',
|
||||
templateUrl: './app.topbar.component.html'
|
||||
template: `
|
||||
<div class="topbar clearfix">
|
||||
<div class="topbar-left">
|
||||
<div class="agm-logo"></div>
|
||||
</div>
|
||||
|
||||
<div class="topbar-right">
|
||||
<a id="menu-button" href="#" (click)="app.onMenuButtonClick($event)">
|
||||
<i></i>
|
||||
</a>
|
||||
|
||||
<a id="topbar-menu-button"href="#" (click)="app.onTopbarMenuButtonClick($event)">
|
||||
<!-- <i class="material-icons">menu</i> -->
|
||||
<i class="topbar-icon material-icons animated">menu</i>
|
||||
<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 }">
|
||||
<a href="#" (click)="app.onTopbarItemClick($event, profile)">
|
||||
<i class="topbar-icon material-icons">apps</i>
|
||||
<span class="topbar-item-name">Profile</span>
|
||||
</a>
|
||||
|
||||
<ul class="ultima-menu animated fadeInDown">
|
||||
<li role="menuitem">
|
||||
<a href="javascript:void(0)" (click)="updateUserProfile()">
|
||||
<i class="material-icons">person</i>
|
||||
<span i18n="@@profile">Profile</span>
|
||||
</a>
|
||||
</li>
|
||||
<!-- <li role="menuitem">
|
||||
<a href="javascript:void(0)" (click)="manageServices()">
|
||||
<i class="material-icons">widgets</i>
|
||||
<span i18n="@@services">Services</span>
|
||||
</a>
|
||||
</li>
|
||||
<li role="menuitem">
|
||||
<a href="javascript:void(0)" (click)="manageBilling()">
|
||||
<i class="material-icons">card_membership</i>
|
||||
<span i18n="@@billingNContact">Billing & Contact</span>
|
||||
</a>
|
||||
</li> -->
|
||||
<li role="menuitem">
|
||||
<a href="javascript:void(0)" (click)="onLogout($event)">
|
||||
<i class="material-icons">power_settings_new</i>
|
||||
<span i18n="@@signOut">Sign out</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li #notifications id="top_notification">
|
||||
<a href="#" *ngIf="app.shouldShowPaidMsg" (click)="app.onTopbarSubItemClick($event, notifications)">
|
||||
<i class="topbar-icon medium material-icons animated swing">notifications</i>
|
||||
<span class="topbar-badge animated">1</span>
|
||||
<span class="topbar-item-name" i18n="@@notifications">Notifications</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class AppTopbarComponent implements OnInit, OnDestroy {
|
||||
user$: Observable<UserModel>;
|
||||
expiryWarning$: Observable<ExpiryWarning | null>;
|
||||
private sub$ = new Subscription();
|
||||
export class AppTopbarComponent implements OnDestroy {
|
||||
_user: any;
|
||||
private sub$: Subscription;
|
||||
private user$ = this.store.select(fromStore.selectAuthUser);
|
||||
|
||||
constructor(
|
||||
public readonly app: AppMainComponent,
|
||||
private readonly store: Store<{}>,
|
||||
private readonly router: Router,
|
||||
private readonly userSvc: UserService
|
||||
private router: Router
|
||||
) {
|
||||
this.user$ = this.store.select(fromStore.selectAuthUser);
|
||||
this.expiryWarning$ = combineLatest([
|
||||
this.store.select(fromStore.selectExpiryWarning),
|
||||
this.store.select(fromStore.selectNoSubsWarning)
|
||||
]).pipe(map(([expiry, noSubs]) => expiry ?? noSubs));
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Fetch fresh user data from server on component init (page load/reload)
|
||||
// This ensures header displays current data even if changed externally
|
||||
this.sub$.add(
|
||||
this.user$.pipe(
|
||||
first(), // Only run once on init
|
||||
filter(user => !!user && !!user._id), // Only if user exists
|
||||
switchMap(user => this.userSvc.getUser(user._id, { view: 'profile' }))
|
||||
).subscribe(freshUser => {
|
||||
if (freshUser) {
|
||||
this.store.dispatch(new authActions.RefreshUserData({
|
||||
user: this.mapUserToUserModel(freshUser)
|
||||
}));
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.sub$.unsubscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Map User (from API) to UserModel (for store)
|
||||
* Only maps fields that should be refreshed from server
|
||||
*/
|
||||
private mapUserToUserModel(user: any): UserModel {
|
||||
return {
|
||||
_id: user._id,
|
||||
name: user.name || '',
|
||||
username: user.username || '',
|
||||
roles: user.roles || [],
|
||||
parent: user.parent || '',
|
||||
lang: user.lang || 'en',
|
||||
pre: user.pre || 0,
|
||||
billable: user.billable,
|
||||
membership: user.membership,
|
||||
contact: user.contact || ''
|
||||
};
|
||||
}
|
||||
|
||||
manageServices() {
|
||||
return this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]);
|
||||
}
|
||||
|
||||
manageBilling() {
|
||||
this.router.navigate([SUB.PROFILE, SUB.PM_HISTORY]);
|
||||
}
|
||||
|
||||
manageContact(user) {
|
||||
this.router.navigate([SUB.PROFILE, SUB.BILL_ADR_LIST]);
|
||||
this.sub$ = this.user$.subscribe((user) => (this._user = user));
|
||||
}
|
||||
|
||||
onLogout(e) {
|
||||
@ -91,15 +93,19 @@ export class AppTopbarComponent implements OnInit, OnDestroy {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
updateUserProfile(userId: string) {
|
||||
this.router.navigate([SUB.PROFILE, 'edit', userId]);
|
||||
updateUserProfile() {
|
||||
this.router.navigate(['profile', this._user._id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to manage subscription page
|
||||
* Triggered by subscription expiry notification click
|
||||
*/
|
||||
onNavigateToManageSubscription(): void {
|
||||
this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]);
|
||||
manageServices() {
|
||||
this.router.navigate(['profile/myservices', this._user._id]);
|
||||
}
|
||||
|
||||
manageBilling() {
|
||||
this.router.navigate(['profile/mybills', this._user._id]);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.sub$) this.sub$.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { Action } from '@ngrx/store';
|
||||
import { Authenticate } from '../models/auth.model';
|
||||
import { UserModel } from '../models/user.model';
|
||||
import { Plan } from '@app/domain/models/subscription.model';
|
||||
|
||||
export const LOGIN = '[Login Page] Login';
|
||||
export class Login implements Action {
|
||||
@ -34,14 +33,7 @@ export class Logout implements Action {
|
||||
export const LOGOUT_COMPLETE = '[Auth API] Logout Complete';
|
||||
export class LogoutComplete implements Action {
|
||||
readonly type: typeof LOGOUT_COMPLETE = LOGOUT_COMPLETE;
|
||||
|
||||
}
|
||||
|
||||
export const REFRESH_USER_DATA = '[Auth] Refresh User Data';
|
||||
export class RefreshUserData implements Action {
|
||||
readonly type: typeof REFRESH_USER_DATA = REFRESH_USER_DATA;
|
||||
|
||||
constructor(public payload: { user: UserModel }) { }
|
||||
|
||||
}
|
||||
|
||||
export type All =
|
||||
@ -49,5 +41,4 @@ export type All =
|
||||
| LoginSuccess
|
||||
| LoginFailed
|
||||
| Logout
|
||||
| LogoutComplete
|
||||
| RefreshUserData;
|
||||
| LogoutComplete;
|
||||
|
||||
@ -3,13 +3,16 @@ import { Router } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
import { Actions, Effect, ofType } from '@ngrx/effects';
|
||||
import { map, exhaustMap, catchError, tap } from 'rxjs/operators';
|
||||
|
||||
import { Store } from '@ngrx/store';
|
||||
import * as authActions from '../actions/auth.actions';
|
||||
import * as clientActions from '@app/client/actions/client.actions';
|
||||
|
||||
import { ClientService } from '@app/domain/services/client.service';
|
||||
import { AuthService } from '@app/domain/services/auth.service';
|
||||
import { globals } from '@app/shared/global';
|
||||
|
||||
|
||||
@Injectable()
|
||||
export class AuthEffects {
|
||||
@Effect()
|
||||
@ -17,17 +20,17 @@ export class AuthEffects {
|
||||
.pipe(
|
||||
ofType<authActions.Login>(authActions.LOGIN),
|
||||
map(action => action.payload),
|
||||
exhaustMap(auth => {
|
||||
return this.authSvc.login(auth).pipe(
|
||||
exhaustMap(auth =>
|
||||
this.authSvc.login(auth).pipe(
|
||||
map(user => {
|
||||
return new authActions.LoginSuccess({ user })
|
||||
return new authActions.LoginSuccess({ user: user });
|
||||
}),
|
||||
catchError(err => {
|
||||
const errTag = (err.error && err.error.error) ? err.error.error['.tag'] : err.message || '';
|
||||
return of(new authActions.LoginFailed(globals.apiErrorMsg(errTag)));
|
||||
}),
|
||||
)
|
||||
})
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@Effect({ dispatch: false })
|
||||
@ -50,9 +53,8 @@ export class AuthEffects {
|
||||
|
||||
private navigateDefault(lang) {
|
||||
const hash = (this.router.url.indexOf('#') == -1) ? '/#/' : '/';
|
||||
const returnUrl = this.router.parseUrl(this.router.url).queryParams['returnUrl'] || 'home';
|
||||
// Replace the current page with the next target url => prevent Back to previous
|
||||
window.location.replace((lang === 'en' ? `${hash}` : `/${lang}${hash}`) + returnUrl);
|
||||
// Replace the current page with the next target url => prevent Back to previous
|
||||
window.location.replace((lang === 'en' ? `${hash}` : `/${lang}${hash}`) + 'home');
|
||||
}
|
||||
|
||||
@Effect()
|
||||
|
||||
@ -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>
|
||||
@ -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();
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
import { AGNavSubscription, Trial } from "@app/domain/models/subscription.model";
|
||||
|
||||
export interface UserModel {
|
||||
_id: string;
|
||||
name: string;
|
||||
@ -9,19 +7,11 @@ export interface UserModel {
|
||||
lang: string;
|
||||
pre: number;
|
||||
billable?: boolean;
|
||||
membership?: IMembership,
|
||||
contact: string;
|
||||
country?: string;
|
||||
partner?: string;
|
||||
membership?: IMembership
|
||||
}
|
||||
|
||||
export interface IMembership {
|
||||
custId: string;
|
||||
endOfPeriod?: Number;
|
||||
subscriptions?: AGNavSubscription[];
|
||||
trials?: Trial;
|
||||
customLimits?: {
|
||||
maxVehicles?: number | null;
|
||||
maxAcres?: number | null;
|
||||
};
|
||||
}
|
||||
status: string;
|
||||
endPeriod: number,
|
||||
subTier: string; // 'essential', 'enterprise'
|
||||
}
|
||||
@ -3,7 +3,7 @@
|
||||
<br />
|
||||
<div class="flex-row" style="justify-content: center;">
|
||||
<div>
|
||||
<span style="padding-right: 12px;font-weight: bold;"><ng-container i18n="@@from">From</ng-container></span>
|
||||
<span style="padding-right: 12px;font-weight: bold;">From</span>
|
||||
<p-calendar [showIcon]="true" [(ngModel)]="fromDate" (onSelect)="onDateSelected('from', $event)" [inputStyle]="{'width':'90px'}" placeholder="From Month" dateFormat="mm/yy" view="month" [readonlyInput]="true"></p-calendar>
|
||||
</div>
|
||||
<div>
|
||||
@ -23,7 +23,7 @@
|
||||
<ng-template pTemplate="caption">
|
||||
<div class="ui-g ui-g-nopad">
|
||||
<div class="ui-g-6 ui-g-nopad" style="text-align: left">
|
||||
<span class="table-caption-1" style="line-height: 1.35em;"><ng-container i18n="@@sprayOverview">Customer Spray Overview</ng-container></span>
|
||||
<span class="table-caption-1" style="line-height: 1.35em;">Customer Spray Overview</span>
|
||||
</div>
|
||||
<div class="ui-g-6 ui-g-nopad" style="text-align: right">
|
||||
<div style="display:inline-flex">
|
||||
@ -57,12 +57,12 @@
|
||||
<ng-template pTemplate="footer" let-columns>
|
||||
<tr>
|
||||
<td *ngFor="let col of columns" [ngSwitch]="col.field">
|
||||
<span *ngSwitchCase="'customer'" style="font-weight: bold;"><ng-container i18n="@@totalSpray">Total Spray</ng-container></span>
|
||||
<span *ngSwitchCase="'customer'" style="font-weight: bold;">Total Spray</span>
|
||||
<div *ngSwitchDefault>
|
||||
<span *ngIf="totals[col.field] == 0; else usage">{{totals[col.field]}}</span>
|
||||
<ng-template #usage>
|
||||
<div>{{totals[col.field] | number:'1.1-1':'en'}} ha</div>
|
||||
<div>{{haToAcres(totals[col.field]) | number:'1.1-1':'en'}} ac</div>
|
||||
<div>{{totals[col.field] | number:'1.1-1':'en'}} ha</div>
|
||||
<div>{{haToAcres(totals[col.field]) | number:'1.1-1':'en'}} ac</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@ -17,7 +17,7 @@ import { ToastModule } from 'primeng/toast';
|
||||
import { StoreModule } from '@ngrx/store';
|
||||
import { EffectsModule } from '@ngrx/effects';
|
||||
import { ClientEffects } from './effects/client.effects';
|
||||
import * as fromClients from './reducers/clients.reducer';
|
||||
import * as fromClients from './reducers/clients-reducer';
|
||||
|
||||
import { ClientListComponent } from './client-list/client-list.component';
|
||||
import { ClientsRoutingModule } from './client-routing.module';
|
||||
|
||||
@ -25,7 +25,7 @@ export class ClientEffects {
|
||||
loadClients$: Observable<Action> = this.actions$.pipe(
|
||||
ofType<clientActions.Fetch>(clientActions.FETCH),
|
||||
switchMap(() =>
|
||||
this.clientSvc.loadClients({ byPuid: this.authSvc.user.parent }).pipe(
|
||||
this.clientSvc.loadClients({ byUserId: this.authSvc.user.parent }).pipe(
|
||||
map(clients => new clientActions.FetchSuccess(clients)),
|
||||
catchError(err => {
|
||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.load).replace('#thing#', globals.clients));
|
||||
|
||||
@ -3,64 +3,31 @@ import {
|
||||
createFeatureSelector,
|
||||
} from '@ngrx/store';
|
||||
|
||||
import * as fromClients from './clients.reducer';
|
||||
import * as fromClients from './clients-reducer';
|
||||
|
||||
export const getClientsState = createFeatureSelector<fromClients.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,
|
||||
|
||||
@ -1,128 +0,0 @@
|
||||
.theme-color {
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
ul {
|
||||
padding-inline-start: 20px;
|
||||
}
|
||||
|
||||
/* Partner Selection Integration Styles */
|
||||
|
||||
.partner-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.partner-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.partner-name {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.partner-description {
|
||||
font-size: 0.85em;
|
||||
color: #666;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.partner-selected {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.partner-config-section {
|
||||
margin-top: 20px;
|
||||
padding: 20px;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.partner-config-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.partner-config-header h3 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.partner-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #007bff;
|
||||
padding: 10px;
|
||||
background-color: #e7f3ff;
|
||||
border: 1px solid #b3d9ff;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.partner-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #dc3545;
|
||||
padding: 10px;
|
||||
background-color: #f8d7da;
|
||||
border: 1px solid #f5c6cb;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.satloc-config-section {
|
||||
padding: 15px;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.config-description {
|
||||
margin-bottom: 15px;
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.config-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 15px;
|
||||
background-color: #e8f4fd;
|
||||
border: 1px solid #b3d9ff;
|
||||
border-radius: 4px;
|
||||
color: #0c5aa6;
|
||||
}
|
||||
|
||||
/* Common label span for form fields */
|
||||
.form-label-span {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.partner-config-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.partner-option {
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.partner-config-section {
|
||||
padding: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.partner-selected>span {
|
||||
font-weight: 600;
|
||||
}
|
||||
@ -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,121 +21,22 @@
|
||||
</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>
|
||||
</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>
|
||||
<ng-template #trialing>
|
||||
<ng-container *ngIf="hasTrialSubs(); else noTrialSubs">
|
||||
<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>
|
||||
<ng-container *ngIf="hasLastEndedTrial(); else newTrial">
|
||||
<ng-container [ngTemplateOutlet]="lastTrial"></ng-container>
|
||||
<trial formControlName="trials" [trialDays]="trialDays" [trials]="trials"></trial>
|
||||
</ng-container>
|
||||
<ng-template #newTrial>
|
||||
<trial formControlName="trials" [trialDays]="trialDays" [trials]="trials"></trial>
|
||||
</ng-template>
|
||||
</ng-template>
|
||||
</ng-template>
|
||||
<p-checkbox id="billable" name="billable" formControlName="billable" label="Billable" binary="true"></p-checkbox>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-template #lastTrial>
|
||||
<label class="theme-color">{{SubTexts.lastTrial}}</label>
|
||||
<div>
|
||||
<ul>
|
||||
<li><strong>{{SubTexts.lastStartDate}}: {{toTimestamp(trials.lastStartDate) | tsToDate: lang}}</strong></li>
|
||||
<li><strong>{{SubTexts.lastEndDate}}: {{toTimestamp(trials.lastEndDate) | tsToDate: lang}}</strong></li>
|
||||
</ul>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #fieldSet let-label="label" let-subs="subs">
|
||||
<fieldset>
|
||||
<legend>{{SubTexts.labelSub}}</legend>
|
||||
<div class="ui-g">
|
||||
<div class="ui-g-6">
|
||||
<label class="theme-color">{{label}}</label>
|
||||
<div *ngFor="let sub of subs ">
|
||||
<ng-container *ngIf="sub.items[0].price | subPkg as fullPkg">
|
||||
<ul>
|
||||
<li>
|
||||
{{fullPkg.name}}
|
||||
<div>
|
||||
<strong>
|
||||
{{SubTexts.startDate}}: {{sub.periodStart | tsToDate: lang}}
|
||||
{{SubTexts.endDate}}: {{sub.periodEnd | tsToDate: lang}}
|
||||
</strong>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="hasLastEndedTrial()" class="ui-g-6">
|
||||
<ng-container [ngTemplateOutlet]="lastTrial"></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</ng-template>
|
||||
</div>
|
||||
@ -1,45 +1,33 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { FormGroup, FormBuilder } from '@angular/forms';
|
||||
|
||||
|
||||
|
||||
import { SelectItem } from 'primeng/api';
|
||||
import { Customer, Partner } from '../models/customer.model';
|
||||
|
||||
import { Customer } from '../models/customer.model';
|
||||
import * as customerActions from '../actions/customer.actions';
|
||||
|
||||
import { UserService } from '@app/domain/services/user.service';
|
||||
import { PartnerService } from '@app/partners/services/partner.service';
|
||||
import { BaseComp } from '@app/shared/base/base.component';
|
||||
import { GC, RoleIds, globals, Labels } from '@app/shared/global';
|
||||
import { AGNavSubscription, Trial } from '@app/domain/models/subscription.model';
|
||||
import { SubStripe, SubTexts } from '@app/profile/common';
|
||||
import { IMembership } from '@app/auth/models/user.model';
|
||||
import { DateUtils } from '@app/shared/utils';
|
||||
import { RoleIds, globals } from '@app/shared/global';
|
||||
|
||||
@Component({
|
||||
selector: 'agm-customer-edit',
|
||||
templateUrl: './customer-edit.component.html',
|
||||
styleUrls: ['./customer-edit.component.css']
|
||||
})
|
||||
export class CustomerEditComponent extends BaseComp implements OnInit {
|
||||
export class CustomerEditComponent extends BaseComp implements OnInit, OnDestroy {
|
||||
readonly globals = globals;
|
||||
readonly SubTexts = SubTexts;
|
||||
readonly Labels = Labels;
|
||||
|
||||
form: FormGroup;
|
||||
selectedItem: Customer;
|
||||
|
||||
premiumLevels: SelectItem[];
|
||||
|
||||
msgs = [];
|
||||
|
||||
trialDays: number[];
|
||||
trialSubs: AGNavSubscription[];
|
||||
paidSubs: AGNavSubscription[];
|
||||
trials: Trial;
|
||||
membership: IMembership;
|
||||
lang;
|
||||
|
||||
// Partner Selection Properties
|
||||
partnerOptions: SelectItem[] = [];
|
||||
partnerLoading = false;
|
||||
partnerError: string | null = null;
|
||||
|
||||
private _customer: Customer;
|
||||
get customer(): Customer { return this._customer; }
|
||||
set customer(customer: Customer) {
|
||||
@ -50,12 +38,7 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
|
||||
account: { active: this.selectedItem.active, username: this.selectedItem.username, password: this.selectedItem.password },
|
||||
premium: this.selectedItem.premium,
|
||||
billable: this.selectedItem.billable,
|
||||
trials: this.selectedItem.membership?.trials,
|
||||
partner: this.selectedItem.partner || null
|
||||
});
|
||||
|
||||
// Set partner selection based on customer.partner field, or null if not set
|
||||
// Form control will be updated by loadPartners() method
|
||||
}
|
||||
|
||||
private _isNew: boolean;
|
||||
@ -66,8 +49,8 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
|
||||
constructor(
|
||||
private readonly route: ActivatedRoute,
|
||||
private readonly userSvc: UserService,
|
||||
private readonly partnerSvc: PartnerService,
|
||||
private readonly fb: FormBuilder
|
||||
|
||||
private readonly fb: FormBuilder,
|
||||
) {
|
||||
super();
|
||||
this.premiumLevels = [
|
||||
@ -81,13 +64,7 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
|
||||
account: [],
|
||||
premium: [],
|
||||
billable: [],
|
||||
trials: [],
|
||||
// Partner form control
|
||||
partner: [null]
|
||||
});
|
||||
this.lang = this.authSvc.locale;
|
||||
|
||||
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
@ -97,86 +74,23 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
|
||||
if (customer) {
|
||||
this._isNew = (customer._id === '0');
|
||||
this.customer = customer;
|
||||
this.membership = this.customer?.membership;
|
||||
|
||||
if (this.membership) {
|
||||
this.trials = this.membership.trials;
|
||||
this.trialSubs = this.membership.subscriptions?.filter((sub) => sub.status === SubStripe.TRIALING) || [];
|
||||
this.paidSubs = this.membership.subscriptions?.filter((sub) => sub.status !== SubStripe.TRIALING) || [];
|
||||
}
|
||||
// Load partners from service
|
||||
this.loadPartners();
|
||||
}
|
||||
});
|
||||
|
||||
this.sub$.add(this.appActions.ofTypes([customerActions.CREATE_SUCCESS, customerActions.UPDATE_SUCCESS])
|
||||
.subscribe((action) => {
|
||||
this.store.dispatch(new customerActions.Select(action['payload']));
|
||||
this.goBack();
|
||||
}));
|
||||
|
||||
this.trialDays = this.appConf.settings.trialDays;
|
||||
}
|
||||
|
||||
hasLastEndedTrial() {
|
||||
return this.authSvc.hasLastEndedTrial(this.trials);
|
||||
}
|
||||
|
||||
hasPaidSubs() {
|
||||
return this.paidSubs?.length > 0;
|
||||
}
|
||||
|
||||
hasTrialSubs() {
|
||||
return this.trialSubs?.length > 0;
|
||||
}
|
||||
|
||||
saveCustomer() {
|
||||
if (!this.form || !this.form.value || !this.form.valid) return;
|
||||
|
||||
this.msgs = [];
|
||||
let custObj;
|
||||
|
||||
const updateTrialMembship = (membership?) => {
|
||||
// Get trials value from form control (includes disabled controls via ControlValueAccessor)
|
||||
const trialsControl = this.form.get('trials');
|
||||
const trialsValue = trialsControl ? trialsControl.value : null;
|
||||
|
||||
if (trialsValue?.selected) {
|
||||
const trials: Trial = { ...trialsValue };
|
||||
delete trials.selected;
|
||||
|
||||
// If type is null, but trialDays or byDate exist, set type accordingly
|
||||
if (trials.type == null) {
|
||||
if (trials.trialDays && trials.trialDays > 0) {
|
||||
trials.type = GC.DAYS;
|
||||
} else if (trials.byDate) {
|
||||
trials.type = GC.BYDATE;
|
||||
}
|
||||
}
|
||||
if (trials.type === GC.BYDATE) {
|
||||
trials.trialDays = 0;
|
||||
} else {
|
||||
trials.byDate = null;
|
||||
}
|
||||
trials.startDate = DateUtils.tsToDate(DateUtils.currUTC());
|
||||
return membership
|
||||
? { ...membership, trials }
|
||||
: { trials };
|
||||
} else {
|
||||
return membership
|
||||
? { ...membership, trials: { ...membership.trials, type: null } }
|
||||
: { trials: { type: null } };
|
||||
}
|
||||
}
|
||||
|
||||
custObj = Object.assign(this.selectedItem, this.form.value.profile, this.form.value.account,
|
||||
{ premium: this.form.value.premium || false },
|
||||
{ billable: this.form.value.billable || false },
|
||||
{ partner: this.form.value.partner || null });
|
||||
|
||||
this.membership
|
||||
? custObj = Object.assign(custObj, { membership: updateTrialMembship(this.membership) })
|
||||
: custObj = Object.assign(custObj, { membership: updateTrialMembship() });
|
||||
|
||||
const custObj = Object.assign(this.selectedItem, this.form.value.profile, this.form.value.account,
|
||||
{ premium: this.form.value.premium || false }, { billable: this.form.value.billable || false });
|
||||
this.store.dispatch(this._isNew ? new customerActions.Create(custObj) : new customerActions.Update(custObj));
|
||||
}
|
||||
|
||||
@ -205,64 +119,6 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
|
||||
this.router.navigate(['../', { id: this.customer._id }]);
|
||||
}
|
||||
|
||||
toTimestamp(date: Date): number {
|
||||
return DateUtils.dateToTS(date);
|
||||
}
|
||||
|
||||
// Partner Methods
|
||||
private loadPartners(): void {
|
||||
this.partnerLoading = true;
|
||||
this.partnerError = null;
|
||||
|
||||
this.partnerSvc.getPartners().subscribe({
|
||||
next: (partners: Partner[]) => {
|
||||
// Create dropdown options starting with "None" option for AgNav direct customers
|
||||
this.partnerOptions = [
|
||||
{
|
||||
label: Labels.NONE_AGNAV_DIRECT_CUSTOMER,
|
||||
value: null // null value indicates AgNav direct customer
|
||||
},
|
||||
// Add active partners
|
||||
...partners
|
||||
.filter(partner => partner.active) // Only show active partners
|
||||
.map(partner => ({
|
||||
label: partner.name,
|
||||
value: partner
|
||||
}))
|
||||
];
|
||||
|
||||
// Set selectedPartner based on existing customer partner
|
||||
if (this.customer?.partner && this.partnerOptions.length > 0) {
|
||||
// Find the partner in options that matches the customer's current partner _id
|
||||
const matchingOption = this.partnerOptions.find(option =>
|
||||
option.value && option.value._id === this.customer.partner._id
|
||||
);
|
||||
if (matchingOption) {
|
||||
this.form.patchValue({ partner: matchingOption.value });
|
||||
}
|
||||
} else if (!this.customer?.partner) {
|
||||
// If no partner is set, default to "None" (AgNav direct customer)
|
||||
this.form.patchValue({ partner: null });
|
||||
}
|
||||
this.partnerLoading = false;
|
||||
},
|
||||
error: (error) => {
|
||||
this.partnerError = Labels.FAILED_TO_LOAD_PARTNERS;
|
||||
this.partnerLoading = false;
|
||||
console.error('Error loading partners:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onPartnerChange(selectedPartner: Partner | null): void {
|
||||
this.partnerError = null;
|
||||
|
||||
// Update customer partner field
|
||||
if (this.customer) {
|
||||
this.customer.partner = selectedPartner;
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
super.ngOnDestroy();
|
||||
}
|
||||
|
||||
@ -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>
|
||||
<span class="table-caption-1" i18n="@@customerList">Customer List</span>
|
||||
</ng-template>
|
||||
<ng-template pTemplate="header" let-columns>
|
||||
<tr>
|
||||
@ -26,32 +18,26 @@
|
||||
<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">
|
||||
</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>
|
||||
|
||||
<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>
|
||||
<span *ngSwitchDefault></span>
|
||||
</th>
|
||||
</tr>
|
||||
</ng-template>
|
||||
|
||||
<ng-template pTemplate="body" let-rowData let-columns="columns">
|
||||
<tr [pSelectableRow]="rowData">
|
||||
<td *ngFor="let col of columns" [ngSwitch]="col.field">
|
||||
<span class="ui-column-title">{{col.header}}</span>
|
||||
<span *ngSwitchCase="CREATED">{{rowData[col.field] | date:'shortDate'}}</span>
|
||||
<span *ngSwitchCase="ACTIVE">
|
||||
<p-checkbox [ngModel]="rowData[ACTIVE]" disabled binary="true"></p-checkbox>
|
||||
</span>
|
||||
<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>
|
||||
<ng-template pTemplate="body" let-cust>
|
||||
<tr [pSelectableRow]="cust">
|
||||
<td>{{cust.name}}</td>
|
||||
<td>{{cust.username}}</td>
|
||||
<td>{{cust.contact}}</td>
|
||||
<td>{{cust.totalJobs}}</td>
|
||||
<td>{{cust.createdAt | date:'shortDate' }}</td>
|
||||
<td style="text-align: center">
|
||||
<p-checkbox [ngModel]="cust.billable" disabled binary="true"></p-checkbox>
|
||||
</td>
|
||||
<td style="text-align: center">
|
||||
<p-checkbox [ngModel]="cust.active" disabled binary="true"></p-checkbox>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
|
||||
<ng-template pTemplate="paginatorleft" let-state>
|
||||
{{ state.totalRecords | i18nPlural: totalItems }}
|
||||
</ng-template>
|
||||
|
||||
@ -7,7 +7,7 @@ import { Table } from 'primeng/table';
|
||||
import { Customer } from '../models/customer.model';
|
||||
import * as fromCustomers from '../reducers';
|
||||
import * as customerActions from '../actions/customer.actions';
|
||||
import { globals, OperationalStatus } from '@app/shared/global';
|
||||
import { globals } from '@app/shared/global';
|
||||
|
||||
import { BaseComp } from '@app/shared/base/base.component';
|
||||
|
||||
@ -17,26 +17,19 @@ import { BaseComp } from '@app/shared/base/base.component';
|
||||
styleUrls: ['./customer-list.component.css']
|
||||
})
|
||||
export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy {
|
||||
readonly CREATED = 'createdAt';
|
||||
readonly ACTIVE = OperationalStatus.ACTIVE;
|
||||
readonly BILLABLE = 'billable';
|
||||
readonly PARTNER = 'partner';
|
||||
readonly PARTNER_NAME = 'partnerName';
|
||||
|
||||
customers: Array<Customer>;
|
||||
curCust: Customer;
|
||||
|
||||
@ViewChild("dt") dt: Table;
|
||||
|
||||
statuses: SelectItem[];
|
||||
partners: SelectItem[];
|
||||
statuses: SelectItem[];
|
||||
cols: any[];
|
||||
totalItems;
|
||||
isSelfSignup = false;
|
||||
|
||||
constructor(
|
||||
private readonly route: ActivatedRoute,
|
||||
|
||||
|
||||
) {
|
||||
super();
|
||||
this.totalItems = { '=0': '', '=1': '1 ' + $localize`:@@customer:customer`.toLocaleLowerCase(), 'other': $localize`:@@total#Customers:Total: # customers` };
|
||||
@ -51,52 +44,23 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
{ field: "username", header: globals.userName, filtered: true, filterMatchMode: 'contains' },
|
||||
{ field: "contact", header: globals.contact },
|
||||
{ field: "totalJobs", header: globals.jobs, width: '5%', filtered: false },
|
||||
{ field: this.CREATED, header: globals.from, width: '6%' },
|
||||
{ field: this.BILLABLE, header: "Billable", width: '9%' },
|
||||
{ field: this.ACTIVE, header: globals.active, width: '9%' },
|
||||
{ field: this.PARTNER_NAME, header: globals.partner, width: '9%' }
|
||||
{ field: "createdAt", header: globals.from, width: '6%' },
|
||||
// { field: "email", header: globals.email, filtered: true, filterMatchMode: 'contains' },
|
||||
{ field: "billable", header: "Billable", width: '9%'},
|
||||
{ field: "active", header: globals.active, width: '9%' },
|
||||
];
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
const saved = localStorage.getItem('isSelfSignup');
|
||||
this.isSelfSignup = saved === 'true';
|
||||
|
||||
this.sub$ = this.store.select(fromCustomers.getAllCustomers).subscribe(customers => {
|
||||
this.setCustomersAndPartners(customers);
|
||||
});
|
||||
this.sub$ = this.store.select(fromCustomers.getAllCustomers).subscribe(
|
||||
(customers) => this.customers = customers);
|
||||
|
||||
this.sub$.add(this.store.select(fromCustomers.getSelectedCustomer).subscribe(cust => {
|
||||
this.curCust = cust;
|
||||
}));
|
||||
|
||||
this.store.dispatch(new customerActions.Fetch());
|
||||
}
|
||||
|
||||
private setCustomersAndPartners(customers: Customer[]) {
|
||||
const filtered = this.isSelfSignup ? customers.filter(c => c.selfSignup) : customers;
|
||||
this.customers = filtered.map(c => ({
|
||||
...c,
|
||||
partnerName: c.partner?.name || null
|
||||
}));
|
||||
this.partners = [
|
||||
{ label: globals.all, value: null },
|
||||
...customers
|
||||
.filter(c => c.partner)
|
||||
.map(c => c.partner.name)
|
||||
.filter((v, i, a) => a.indexOf(v) === i)
|
||||
.map(name => ({ label: name, value: name }))
|
||||
];
|
||||
}
|
||||
|
||||
onToggle(event: any): void {
|
||||
this.isSelfSignup = event.checked;
|
||||
localStorage.setItem('isSelfSignup', String(this.isSelfSignup));
|
||||
this.store.select(fromCustomers.getAllCustomers).subscribe(customers => {
|
||||
this.setCustomersAndPartners(customers);
|
||||
});
|
||||
}
|
||||
|
||||
onRowSelect(event) {
|
||||
this.store.dispatch(new customerActions.Select(event.data));
|
||||
}
|
||||
@ -124,6 +88,10 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
});
|
||||
}
|
||||
|
||||
billableOverview() {
|
||||
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
super.ngOnDestroy();
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -19,6 +19,7 @@ const routes: Routes = [
|
||||
roles: [RoleIds.ADMIN]
|
||||
},
|
||||
canActivate: [AuthGuard],
|
||||
// canActivateChild: [AuthGuard],
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
@ -32,7 +33,7 @@ const routes: Routes = [
|
||||
component: CustomerEditComponent,
|
||||
data: {
|
||||
roles: [RoleIds.ADMIN]
|
||||
},
|
||||
}, // canDeactivate: [CanDeactivateGuard],
|
||||
resolve: [CustomerResolver]
|
||||
},
|
||||
]
|
||||
|
||||
@ -17,14 +17,13 @@ import { AppSharedModule } from '../shared/app-shared.module';
|
||||
|
||||
import { StoreModule } from '@ngrx/store';
|
||||
import { EffectsModule } from '@ngrx/effects';
|
||||
import * as fromCustomers from './reducers/customers.reducer';
|
||||
import * as fromCustomers from './reducers/customers-reducer';
|
||||
import { CustomerEffects } from './effects/customer.effects';
|
||||
|
||||
import { CustomerListComponent } from './customer-list/customer-list.component';
|
||||
import { CustomerEditComponent } from './customer-edit/customer-edit.component';
|
||||
import { CustomersRoutingModule } from './customer-routing.module';
|
||||
import { CustomerMgtComponent } from './customer-mgt.component';
|
||||
import { TrialComponent } from './trial/trial.component';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
@ -46,7 +45,7 @@ import { TrialComponent } from './trial/trial.component';
|
||||
EffectsModule.forFeature([CustomerEffects]),
|
||||
CustomersRoutingModule
|
||||
],
|
||||
declarations: [CustomerMgtComponent, CustomerListComponent, CustomerEditComponent, TrialComponent],
|
||||
declarations: [CustomerMgtComponent, CustomerListComponent, CustomerEditComponent],
|
||||
providers: [],
|
||||
schemas: [
|
||||
CUSTOM_ELEMENTS_SCHEMA
|
||||
|
||||
@ -1,31 +1,17 @@
|
||||
import { RoleIds } from '@app/shared/global';
|
||||
import { createNewUser, User } from '@app/accounts/models/user.model';
|
||||
import { IMembership } from '@app/auth/models/user.model';
|
||||
|
||||
export interface Customer extends User {
|
||||
contact?: string;
|
||||
fax?: string;
|
||||
premium: number;
|
||||
billable?: boolean;
|
||||
totalJobs?: number;
|
||||
membership: IMembership,
|
||||
partner?: Partner;
|
||||
selfSignup?: boolean;
|
||||
}
|
||||
|
||||
export interface Partner {
|
||||
_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
kind: string; // Required to match User interface
|
||||
active?: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
totalJobs?: number; // extension field for GUI
|
||||
}
|
||||
|
||||
export const createNewCustomer = () => {
|
||||
const customer = createNewUser(null, RoleIds.APP) as Customer;
|
||||
const customer = <Customer>createNewUser(null, RoleIds.APP);
|
||||
customer.premium = 0;
|
||||
customer.membership = {} as IMembership; // Initialize required membership property
|
||||
return customer;
|
||||
}
|
||||
}
|
||||
@ -3,7 +3,7 @@ import {
|
||||
createFeatureSelector,
|
||||
} from '@ngrx/store';
|
||||
|
||||
import * as fromCustomers from './customers.reducer';
|
||||
import * as fromCustomers from './customers-reducer';
|
||||
|
||||
export const getCustomersState = createFeatureSelector<fromCustomers.State>(fromCustomers.FEATURE_KEY);
|
||||
|
||||
|
||||
@ -1,8 +0,0 @@
|
||||
.trial-row {
|
||||
margin-top: 1em;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
#day-label {
|
||||
padding-top: 2px;
|
||||
}
|
||||
@ -1,36 +0,0 @@
|
||||
<div [formGroup]="form">
|
||||
<div class="ui-g-12 trial-row">
|
||||
<p-checkbox binary="true" label="Trial" formControlName="selected" (onChange)="change()"></p-checkbox>
|
||||
<ng-container *ngIf="form.get('selected').value">
|
||||
<span style="margin-left: 1em; margin-right: 1em;"><p-radioButton [value]="DAYS" label="Number of days" formControlName="type"></p-radioButton></span>
|
||||
<p-radioButton [value]="BYDATE" label="By date" formControlName="type"></p-radioButton>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="form.get('selected').value">
|
||||
<div class="ui-g-12 trial-row">
|
||||
<ng-container *ngIf="form.get('type').value === DAYS; else byDate">
|
||||
<span style="margin-right:12px"><ng-container i18n="@@endInDays">End in days</ng-container>:</span>
|
||||
<p-dropdown formControlName="trialDays" [filter]="true" editable="true" [style]="{'max-width': '100px'}" [options]="dayItems" [showClear]="true" maxlength="6" [panelStyle]="{'width': '50px'}" (keyup.enter)="change()" (onChange)="change()">
|
||||
<ng-template let-item pTemplate="item">
|
||||
<div class="ui-g">
|
||||
<div id="day-label" class="ui-g-8 no-pad">{{item.label}}</div>
|
||||
<div class="ui-g-4 no-pad" style="text-align: center;"><button class="no-pad" style="border: unset; background: none;" (click)="remove(item)"><i class="pi pi-times"></i></button></div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</p-dropdown>
|
||||
<ng-container *ngIf="error">
|
||||
<span class="ui-message ui-messages-error ui-corner-all">{{error}}</span>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #byDate>
|
||||
<span style="margin-right:12px"><ng-container i18n="@@endDate">End date</ng-container>:</span>
|
||||
<p-calendar [(ngModel)]="toDate" [ngModelOptions]="{standalone: true}" [locale]="locale" [dateFormat]="locale.dateFormat" [showIcon]="true" [inputStyle]="{'width':'120px'}" [minDate]="calMinDate" [maxDate]="calMaxDate" [name]="BYDATE" (onSelect)="changeCal()" [disabled]="disable"></p-calendar>
|
||||
<ng-container *ngIf="error">
|
||||
<span class="ui-message ui-messages-error ui-corner-all">{{error}}</span>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
@ -1,201 +0,0 @@
|
||||
import { AfterContentInit, Component, HostListener, Input, OnDestroy, OnInit, forwardRef } from '@angular/core';
|
||||
import { FormBuilder, FormControl, FormGroup, NG_VALIDATORS, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
import { Trial } from '@app/domain/models/subscription.model';
|
||||
import { BaseComp } from '@app/shared/base/base.component';
|
||||
import { GC } from '@app/shared/global';
|
||||
import { DateUtils } from '@app/shared/utils';
|
||||
import { SelectItem } from 'primeng-lts/api';
|
||||
|
||||
const MIN_DAYS = 7;
|
||||
|
||||
@Component({
|
||||
selector: 'trial',
|
||||
templateUrl: './trial.component.html',
|
||||
styleUrls: ['./trial.component.css'],
|
||||
providers: [
|
||||
{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => TrialComponent), multi: true },
|
||||
{ provide: NG_VALIDATORS, useExisting: forwardRef(() => TrialComponent), multi: true }
|
||||
]
|
||||
})
|
||||
export class TrialComponent extends BaseComp implements OnDestroy, OnInit, AfterContentInit {
|
||||
@Input() trialDays: number[];
|
||||
@Input() trials: Trial;
|
||||
@Input() disable: boolean;
|
||||
|
||||
DAYS = GC.DAYS;
|
||||
BYDATE = GC.BYDATE;
|
||||
|
||||
dayItems: SelectItem[];
|
||||
form: FormGroup;
|
||||
toDate: Date;
|
||||
error: string;
|
||||
calMinDate: Date;
|
||||
calMaxDate: Date;
|
||||
onChange: any = () => { };
|
||||
onTouched: any = () => { };
|
||||
|
||||
constructor(
|
||||
private readonly fb: FormBuilder
|
||||
) {
|
||||
super();
|
||||
const ONE_YEAR = 1;
|
||||
this.calMinDate = new Date();
|
||||
this.calMinDate.setDate(this.calMinDate.getDate() + MIN_DAYS);
|
||||
this.calMinDate.setHours(0, 0, 0);
|
||||
this.calMaxDate = new Date();
|
||||
this.calMaxDate.setFullYear(this.calMaxDate.getFullYear() + ONE_YEAR);
|
||||
}
|
||||
|
||||
get value() {
|
||||
// CRITICAL: Use getRawValue() to include disabled controls (selected, type, trialDays)
|
||||
return this.form.getRawValue();
|
||||
}
|
||||
|
||||
set value(val) {
|
||||
this.writeValue(val);
|
||||
this.onChange(val);
|
||||
this.onTouched(val);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.form = this.fb.group({
|
||||
selected: new FormControl({ value: false, disabled: this.disable }),
|
||||
type: new FormControl({ value: '', disabled: this.disable }),
|
||||
startDate: [],
|
||||
lastEndDate: [],
|
||||
lastStartDate: [],
|
||||
trialDays: new FormControl({ value: '', disabled: this.disable }),
|
||||
byDate: []
|
||||
});
|
||||
this.dayItems = this.trialDays?.map((day) => ({ label: `${day}`, value: day }));
|
||||
|
||||
// CRITICAL FIX: Use getRawValue() to include disabled controls in onChange callback
|
||||
this.sub$.add(this.form.valueChanges.subscribe(() => {
|
||||
const rawValue = this.form.getRawValue();
|
||||
this.onChange(rawValue);
|
||||
this.onTouched(rawValue);
|
||||
}));
|
||||
}
|
||||
|
||||
ngAfterContentInit() {
|
||||
// Check if user has valid trial configuration OR component is disabled (has active trial subscriptions)
|
||||
const hasExistingTrial = (this.trials?.type && (this.trials.trialDays >= MIN_DAYS || this.trials.byDate))
|
||||
|| (this.disable && (this.trials?.trialDays >= MIN_DAYS || this.trials?.byDate));
|
||||
|
||||
if (hasExistingTrial) {
|
||||
if (this.trials?.type === this.BYDATE && this.trials.byDate) {
|
||||
this.toDate = new Date(this.trials.byDate);
|
||||
this.form.patchValue({ ...this.trials, selected: true });
|
||||
} else if (this.trials?.type === this.DAYS && this.trials.trialDays >= MIN_DAYS) {
|
||||
this.form.patchValue({ ...this.trials, selected: true });
|
||||
} else if (this.disable && (this.trials?.trialDays >= MIN_DAYS || this.trials?.byDate)) {
|
||||
// User has active trial subscriptions (disable=true) - always set selected=true
|
||||
// This prevents accidental trial disable when admin edits customer with active trials
|
||||
// Determine correct type from trial configuration (byDate takes precedence over trialDays)
|
||||
const trialType = this.trials?.byDate ? this.BYDATE : this.DAYS;
|
||||
if (trialType === this.BYDATE) {
|
||||
this.toDate = new Date(this.trials.byDate);
|
||||
}
|
||||
// When controls are disabled, patchValue() ignores them - must use setValue() directly
|
||||
this.form.get('selected').setValue(true);
|
||||
this.form.get('type').setValue(trialType);
|
||||
this.form.get('trialDays').setValue(this.trials.trialDays);
|
||||
this.form.patchValue({
|
||||
startDate: this.trials.startDate,
|
||||
lastEndDate: this.trials.lastEndDate,
|
||||
lastStartDate: this.trials.lastStartDate,
|
||||
byDate: this.trials.byDate
|
||||
});
|
||||
} else {
|
||||
this.form.patchValue({ ...this.trials });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeValue(val): void {
|
||||
if (val) {
|
||||
this.form.patchValue(val);
|
||||
}
|
||||
}
|
||||
|
||||
registerOnChange(fn: any): void {
|
||||
this.onChange = fn;
|
||||
}
|
||||
|
||||
registerOnTouched(fn: any): void {
|
||||
this.onTouched = fn;
|
||||
}
|
||||
|
||||
validate() {
|
||||
this.checkAndDisplayErr();
|
||||
const isTrial = this.form.value.selected;
|
||||
const isInValid = !this.isValidTrialDays() && !this.isValidBydate();
|
||||
return isTrial ? isInValid ? { trials: { valid: false } } : null : null;
|
||||
}
|
||||
|
||||
isValidTrialDays() {
|
||||
return this.form.value.type === this.DAYS && this.form.value.trialDays && !isNaN(this.form.value.trialDays) && this.form.value.trialDays >= MIN_DAYS;
|
||||
}
|
||||
|
||||
isValidBydate() {
|
||||
return this.form.value.type === this.BYDATE && this.form.value.byDate;
|
||||
}
|
||||
|
||||
change() {
|
||||
const noPrevTrial = !this.form.value.type;
|
||||
if (noPrevTrial) {
|
||||
this.form.patchValue({ type: this.DAYS });
|
||||
}
|
||||
|
||||
const trialDays = this.form.value.trialDays;
|
||||
const notExistItem = this.dayItems?.every((item) => item.value != trialDays);
|
||||
if (this.isValidTrialDays() && notExistItem) {
|
||||
this.dayItems.push({ label: `${trialDays}`, value: Number(trialDays) });
|
||||
this.dayItems.sort((a, b) => a.value - b.value);
|
||||
this.appConf.saveTrialDays(this.dayItems?.map((item) => item.value));
|
||||
}
|
||||
|
||||
this.form.updateValueAndValidity();
|
||||
}
|
||||
|
||||
changeCal() {
|
||||
this.form.patchValue({
|
||||
...this.trials,
|
||||
type: this.BYDATE,
|
||||
byDate: DateUtils.tsToDate(
|
||||
DateUtils.endUtcTS(
|
||||
DateUtils.dateToTS(this.toDate)))
|
||||
});
|
||||
this.form.updateValueAndValidity();
|
||||
}
|
||||
|
||||
checkAndDisplayErr() {
|
||||
this.error = '';
|
||||
if (this.form.value.selected && this.form.value.type === this.BYDATE) {
|
||||
if (!this.toDate) {
|
||||
return this.error = `Please fill in end date MM/DD/YYYY from <the mininum ${MIN_DAYS} days ahead>`;
|
||||
}
|
||||
} else if (this.form.value.selected && this.form.value.type === this.DAYS) {
|
||||
if (!this.form.value.trialDays) {
|
||||
return this.error = 'Please fill in the number of end days';
|
||||
} else if (isNaN(this.form.value.trialDays) || this.form.value.trialDays < MIN_DAYS) {
|
||||
return this.error = `Number of days must be number greater than <the mininum ${MIN_DAYS} days ahead>.`;
|
||||
}
|
||||
}
|
||||
return this.error;
|
||||
}
|
||||
|
||||
remove(item) {
|
||||
this.dayItems = this.dayItems?.filter((day) => day.label !== item.label);
|
||||
this.appConf.saveTrialDays(this.dayItems?.map((item) => item.value));
|
||||
}
|
||||
|
||||
@HostListener('document:keydown.enter', ['$event']) onKeydownHandler(e: KeyboardEvent) {
|
||||
const target = <HTMLInputElement>e.target;
|
||||
if (target.name === this.BYDATE) this.changeCal();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
super.ngOnDestroy();
|
||||
}
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
.pure-white {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
@ -1,9 +1,5 @@
|
||||
<div class="ui-g">
|
||||
<ng-container [ngTemplateOutlet]="disclaimerSection"></ng-container>
|
||||
</div>
|
||||
|
||||
<ng-template #disclaimerSection>
|
||||
<section class="ui-g-12">
|
||||
<div class="ui-g-12">
|
||||
<div class="card card-title">
|
||||
<h2 i18n="Welcome to Agmission header@@welcome">Welcome to AgMission</h2>
|
||||
<br />
|
||||
@ -13,5 +9,5 @@
|
||||
<p>BY ACCESSING AND USING THE APPLICATION, YOU AGREE TO THE TERMS AND CONDITIONS EXPRESSED UPON.</p>
|
||||
</ng-container>
|
||||
</div>
|
||||
</section>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
@ -1,11 +1,15 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'agm-dashboard',
|
||||
templateUrl: './dashboard.component.html',
|
||||
styleUrls: ['./dashboard.component.css']
|
||||
})
|
||||
export class DashboardComponent {
|
||||
export class DashboardComponent implements OnInit {
|
||||
|
||||
constructor() { }
|
||||
|
||||
ngOnInit() {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,137 +1,78 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { CanActivate, Router, ActivatedRouteSnapshot, RouterStateSnapshot, CanActivateChild, Route } from '@angular/router';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { take, map, catchError, switchMap } from 'rxjs/operators';
|
||||
import {
|
||||
CanActivate, Router, ActivatedRouteSnapshot, RouterStateSnapshot, CanActivateChild, Route,
|
||||
} from '@angular/router';
|
||||
|
||||
import { Observable } from 'rxjs';
|
||||
import { take, map } from 'rxjs/operators';
|
||||
|
||||
import { Store } from '@ngrx/store';
|
||||
import * as fromStore from '../../reducers';
|
||||
|
||||
import { AuthService } from '../services/auth.service';
|
||||
import { SUB, SubStripe } from '@app/profile/common';
|
||||
import { FEATURE_KEY } from '@app/profile/reducers';
|
||||
import { Status, StripeSubscription } from '../models/subscription.model';
|
||||
import { SubscriptionService } from '../services/subscription.service';
|
||||
import { AC } from '@app/shared/global';
|
||||
import { RouterUtilsService } from '@app/shared/router-utils.service';
|
||||
import { ClearSubscriptionStatus } from '@app/actions/subscription.actions';
|
||||
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthGuard implements CanActivate, CanActivateChild {
|
||||
|
||||
constructor(
|
||||
private readonly store: Store<{}>,
|
||||
private readonly authSvc: AuthService,
|
||||
private readonly router: Router,
|
||||
private readonly subSvc: SubscriptionService,
|
||||
private readonly routerUtils: RouterUtilsService
|
||||
) { }
|
||||
|
||||
canLoad(route: Route): boolean {
|
||||
return this.checkRoles(route?.data?.roles);
|
||||
private authService: AuthService,
|
||||
private router: Router) {
|
||||
}
|
||||
|
||||
canActivate(route: ActivatedRouteSnapshot, routerState: RouterStateSnapshot): Observable<boolean> {
|
||||
let subs: StripeSubscription[], status: Status;
|
||||
return this.store.select(fromStore.getSubscriptionState).pipe(
|
||||
switchMap((subState) => {
|
||||
subs = subState?.entries;
|
||||
status = subState?.status;
|
||||
return this.store.select(fromStore.selectIsLoggedIn)
|
||||
}),
|
||||
take(1),
|
||||
canLoad(route: Route): boolean {
|
||||
const url = `/${route.path}`;
|
||||
return this.checkRoles(url, route.data.roles || null);
|
||||
}
|
||||
|
||||
canActivate(
|
||||
route: ActivatedRouteSnapshot,
|
||||
routerState: RouterStateSnapshot): Observable<boolean> | Promise<boolean> | boolean {
|
||||
|
||||
return this.checkStoreAuth().pipe(
|
||||
// mergeMap((storeAuth) => {
|
||||
// if (storeAuth)
|
||||
// return of(true);
|
||||
// return this.checkApiAuth();
|
||||
// }),
|
||||
map(storeOrApiAuth => {
|
||||
const LOCAL_TEMP_FLAG = 'requiredSubAttention';
|
||||
const TEMP_FLAG_VALUE = 'true';
|
||||
const hasAllowedRoles = this.checkRoles(route);
|
||||
const hasNotAuth = !storeOrApiAuth;
|
||||
if (hasNotAuth) {
|
||||
if (!storeOrApiAuth) {
|
||||
this.router.navigate(['/login'], { replaceUrl: true });
|
||||
return false;
|
||||
}
|
||||
|
||||
// Early exit for partner users - they bypass all subscription checks
|
||||
if (this.authSvc.isPartner) {
|
||||
return hasAllowedRoles;
|
||||
}
|
||||
|
||||
const requiresResolution = (): boolean => {
|
||||
const hasUnresolvedSubs = this.authSvc.hasSubsWithStatus(SubStripe.INCOMPLETE) || this.authSvc.hasSubsWithStatus(SubStripe.PAST_DUE) || this.authSvc.hasSubsWithStatus(SubStripe.UNPAID) || this.authSvc.hasSubsWithStatus(SubStripe.OVERDUE) || this.subSvc.hasInValTaxLoc(subs);
|
||||
if (hasUnresolvedSubs && hasAllowedRoles) {
|
||||
const hasReqAttentionFlag = localStorage.getItem(LOCAL_TEMP_FLAG) === TEMP_FLAG_VALUE;
|
||||
const isNavToProfile = routerState.url.includes(SUB.PROFILE);
|
||||
|
||||
if (isNavToProfile) {
|
||||
if (hasReqAttentionFlag) {
|
||||
localStorage.removeItem(LOCAL_TEMP_FLAG);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!hasReqAttentionFlag) {
|
||||
const profile = JSON.parse(sessionStorage.getItem(FEATURE_KEY));
|
||||
const isFirstLoggin = !profile?.usage || Object.keys(profile?.usage).length === 0;
|
||||
if (isFirstLoggin) {
|
||||
localStorage.setItem(LOCAL_TEMP_FLAG, TEMP_FLAG_VALUE);
|
||||
this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES], { replaceUrl: true });
|
||||
return true;
|
||||
}
|
||||
const accountNotLocked = this.authSvc.hasSubsWithStatus(SubStripe.PAST_DUE) || this.authSvc.hasSubsWithStatus(SubStripe.OVERDUE) || this.authSvc.hasSubsWithStatus(SubStripe.INCOMPLETE);
|
||||
if (accountNotLocked) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES], { replaceUrl: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.subSvc.isUnderReview(status)) {
|
||||
const fromPath = this.routerUtils.getCurrentUrl().split('/').pop();
|
||||
if (routerState.url.includes(AC)) return true;
|
||||
if (fromPath.includes(AC)) {
|
||||
this.store.dispatch(new ClearSubscriptionStatus());
|
||||
return true;
|
||||
};
|
||||
this.router.navigate(['entities', AC], { replaceUrl: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const shouldNavToServices = () => {
|
||||
const trials = this.authSvc.user?.membership?.trials;
|
||||
|
||||
if (routerState.url.includes(SUB.SERVICES)
|
||||
|| (routerState.url.includes('home') && !!trials?.type)) return true;
|
||||
|
||||
const canNavToServices = !this.authSvc.isAdmin
|
||||
&& !this.authSvc.hasSubs()
|
||||
&& (!trials?.type || (!trials?.byDate && trials?.trialDays === 0));
|
||||
|
||||
if (canNavToServices) {
|
||||
this.router.navigate([SUB.PROFILE, SUB.SERVICES]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const canNav = requiresResolution() || shouldNavToServices() || hasAllowedRoles;
|
||||
return canNav;
|
||||
return this.checkRoles(routerState.url, route);
|
||||
}),
|
||||
catchError(err => {
|
||||
console.log(err);
|
||||
return of(false)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
canActivateChild(childRoute: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean | Observable<boolean> | Promise<boolean> {
|
||||
canActivateChild(
|
||||
childRoute: ActivatedRouteSnapshot,
|
||||
state: RouterStateSnapshot): boolean | Observable<boolean> | Promise<boolean> {
|
||||
return this.canActivate(childRoute, state);
|
||||
}
|
||||
|
||||
checkRoles(route: ActivatedRouteSnapshot): boolean {
|
||||
const hasRoles = !!route?.data?.roles;
|
||||
if (hasRoles) {
|
||||
const hasAllRoles = '*' === route.data.roles;
|
||||
const hasAccessedByRoles = hasAllRoles || this.authSvc.hasRole(route.data.roles);
|
||||
return hasAccessedByRoles;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
checkStoreAuth() {
|
||||
return this.store.select(fromStore.selectIsLoggedIn).pipe(take(1));
|
||||
}
|
||||
|
||||
// checkApiAuth() {
|
||||
// return this.authService.check().pipe(
|
||||
// map(user => !!user),
|
||||
// catchError(() => of(false))
|
||||
// );
|
||||
// }
|
||||
|
||||
checkRoles(url: string, route: ActivatedRouteSnapshot): boolean {
|
||||
if (route.data.roles) {
|
||||
if ('*' === route.data.roles || this.authService.hasRole(route.data.roles)) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -1,25 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import {
|
||||
CanActivate, ActivatedRouteSnapshot
|
||||
} from '@angular/router';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class RoleGuard implements CanActivate {
|
||||
|
||||
constructor(
|
||||
private authService: AuthService) {
|
||||
}
|
||||
|
||||
canActivate(route: ActivatedRouteSnapshot): boolean {
|
||||
if (route.data.roles) {
|
||||
if ('*' === route.data.roles || this.authService.hasRole(route.data.roles)) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,12 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { CanActivate } from '@angular/router';
|
||||
import { SubscriptionService } from '../services/subscription.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class StripeLoadGuard implements CanActivate {
|
||||
constructor(private readonly subSvc: SubscriptionService) { }
|
||||
|
||||
canActivate(): Promise<boolean> {
|
||||
return this.subSvc.loadStripePromise().then(() => true).catch(() => false);
|
||||
}
|
||||
}
|
||||
@ -1,117 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { ActivatedRouteSnapshot, CanActivate, Router } from '@angular/router';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
import { SubscriptionService } from '../services/subscription.service';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { StripeSubscription } from '../models/subscription.model';
|
||||
import { SUB, SubStripe } from '@app/profile/common';
|
||||
import { AppMessageService } from '@app/shared/app-message.service';
|
||||
import { AC, globals } from '@app/shared/global';
|
||||
import { RouterUtilsService } from '@app/shared/router-utils.service';
|
||||
|
||||
/**
|
||||
This guards against unresolved subscriptions when the user is subscribing to a new subscription in the
|
||||
main flow (services <->billing-detail<->checkout<->checkout-review<->checkout-confirm). When the user has an unresolved subscription, the guard forces user to enter the resolving subscription flow.
|
||||
*/
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SubscriptionGuard implements CanActivate {
|
||||
|
||||
constructor(
|
||||
private readonly authSvc: AuthService,
|
||||
private readonly subSvc: SubscriptionService,
|
||||
private readonly router: Router,
|
||||
private readonly msgSvc: AppMessageService,
|
||||
private readonly routerUtils: RouterUtilsService
|
||||
) { }
|
||||
|
||||
canActivate(activatedRoute: ActivatedRouteSnapshot): Observable<boolean> {
|
||||
|
||||
return this.subSvc.fetchSubscriptions(this.authSvc.user?.membership?.custId).pipe(
|
||||
map((subs) => {
|
||||
const fromPath = this.routerUtils.getCurrentUrl().split('/').pop();
|
||||
const toPath = activatedRoute.url[0]?.path;
|
||||
|
||||
const hasLatestSubs = subs?.length > 0;
|
||||
const hasUnresolvedSubs = this.hasUnresolvedSubs(subs);
|
||||
const hasAllActiveSubs = hasLatestSubs && !hasUnresolvedSubs;
|
||||
|
||||
const isSubscribing = (!hasLatestSubs || hasAllActiveSubs)
|
||||
&& (this.isInMainFlow(fromPath, toPath)
|
||||
|| this.isPageReload(fromPath, toPath));
|
||||
const isResolvingSubs = hasUnresolvedSubs
|
||||
&& (this.isInResolvingFlow(fromPath, toPath)
|
||||
|| this.isPageReload(fromPath, toPath));
|
||||
|
||||
const viewPayment = this.isPaymentPage(toPath);
|
||||
const viewPaymentMethod = this.isPaymentMethodPage(toPath);
|
||||
const viewAC = this.isACPage(toPath) && !hasUnresolvedSubs;
|
||||
|
||||
const canNavigate = isSubscribing || viewPayment || viewPaymentMethod || viewAC || true;
|
||||
const shouldNavigateToMyServices = hasUnresolvedSubs && !isResolvingSubs;
|
||||
|
||||
if (canNavigate) {
|
||||
return true;
|
||||
} else if (shouldNavigateToMyServices) {
|
||||
this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]);
|
||||
} else if (isResolvingSubs) {
|
||||
return true;
|
||||
} else {
|
||||
this.router.navigate(['/', SUB.HOME]);
|
||||
}
|
||||
}),
|
||||
catchError(err => {
|
||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.load).replace('#thing#', globals.subscription));
|
||||
return of(false);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private isInMainFlow(fromPath: string, toPath: string): boolean {
|
||||
return (fromPath === SUB.SERVICES && toPath === SUB.BILL_ADR) ||
|
||||
(fromPath === SUB.BILL_ADR && toPath === SUB.CHKOUT) ||
|
||||
(fromPath === SUB.CHKOUT && toPath === SUB.BILL_ADR) ||
|
||||
(fromPath === SUB.CHKOUT && toPath === SUB.CHKOUT_REV) ||
|
||||
(fromPath === SUB.CHKOUT_REV && toPath === SUB.CHKOUT_CONF) ||
|
||||
(fromPath === SUB.CHKOUT_REV && toPath === SUB.CHKOUT) ||
|
||||
this.isTrialFLow(fromPath, toPath);
|
||||
}
|
||||
|
||||
private isInResolvingFlow(fromPath: string, toPath: string): boolean {
|
||||
return (fromPath === SUB.UNPAID_SUB && toPath === SUB.BILL_ADR) ||
|
||||
(fromPath === SUB.BILL_ADR && toPath === SUB.CHKOUT) ||
|
||||
(fromPath === SUB.BILL_ADR && toPath === SUB.UNPAID_SUB) ||
|
||||
(fromPath === SUB.CHKOUT && toPath === SUB.BILL_ADR) ||
|
||||
(fromPath === SUB.CHKOUT && toPath === SUB.CHKOUT_REV) ||
|
||||
(fromPath === SUB.CHKOUT_REV && toPath === SUB.CHKOUT) ||
|
||||
(fromPath === SUB.CHKOUT_REV && toPath === SUB.CHKOUT_CONF) ||
|
||||
(fromPath === SUB.MY_SERVICES && toPath === SUB.CHKOUT_REV) ||
|
||||
(fromPath === SUB.MY_SERVICES && toPath === SUB.UNPAID_SUB);
|
||||
}
|
||||
|
||||
private isTrialFLow(fromPath: string, toPath: string): boolean {
|
||||
return (fromPath === SUB.CHKOUT && toPath === SUB.CHKOUT_CONF) ||
|
||||
(fromPath === SUB.MY_SERVICES && toPath === SUB.BILL_ADR);
|
||||
}
|
||||
|
||||
private isPageReload(fromPath: string, toPath: string): boolean {
|
||||
return (!fromPath && (toPath === SUB.BILL_ADR || toPath === SUB.CHKOUT || toPath === SUB.CHKOUT_REV || toPath === SUB.CHKOUT_CONF));
|
||||
}
|
||||
|
||||
private hasUnresolvedSubs(subs: StripeSubscription[]): boolean {
|
||||
return subs?.some(sub => sub.status === SubStripe.PAST_DUE || sub.status === SubStripe.OVERDUE || sub.status === SubStripe.UNPAID || sub.status === SubStripe.INCOMPLETE);
|
||||
}
|
||||
|
||||
private isPaymentPage(path: string): boolean {
|
||||
return path === SUB.PM_HISTORY || path === SUB.PM_DETAIL;
|
||||
}
|
||||
|
||||
private isPaymentMethodPage(path: string): boolean {
|
||||
return path === SUB.PM_LIST;
|
||||
}
|
||||
|
||||
private isACPage(path: string): boolean {
|
||||
return path === AC;
|
||||
}
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { CanActivate } from '@angular/router';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
import { SubType } from '@app/profile/common';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class UsageDetailGuard implements CanActivate {
|
||||
|
||||
constructor(private authService: AuthService) { }
|
||||
|
||||
canActivate(): boolean {
|
||||
const hasPkg = this.authService.user?.membership?.subscriptions?.some((sub) => sub.type === SubType.PACKAGE);
|
||||
if (hasPkg) return true;
|
||||
}
|
||||
}
|
||||
@ -23,7 +23,4 @@ export interface IAppConfig {
|
||||
};
|
||||
|
||||
noPopup: boolean;
|
||||
trialDays: [number];
|
||||
/** Grace-period days for promo Valid Until (sysadmin only). From PROMO_MIN_EXPIRY_DAYS env. */
|
||||
promoMinExpiryDays?: number;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -1,735 +0,0 @@
|
||||
import { IMembership } from '@app/auth/models/user.model';
|
||||
import { InvType, Mode } from '@app/profile/common';
|
||||
import { StripeCardCvcElement, StripeCardElement, StripeCardExpiryElement, StripeCardNumberElement } from '@stripe/stripe-js';
|
||||
|
||||
export type PriceUsd = string | number;
|
||||
|
||||
export interface Price {
|
||||
type: string;
|
||||
lookupKey: string;
|
||||
priceUSD: PriceUsd,
|
||||
level: number;
|
||||
maxVehicles: number;
|
||||
maxAcres: number;
|
||||
}
|
||||
|
||||
export interface BasePackage {
|
||||
price: PriceUsd;
|
||||
quantity?: number;
|
||||
metadata?: {
|
||||
tier: string;
|
||||
level?: string;
|
||||
maxAcres?: string;
|
||||
maxVehicles?: string;
|
||||
}
|
||||
}
|
||||
|
||||
export interface Addon extends BasePackage {
|
||||
priceId?: string;
|
||||
name?: string;
|
||||
desc: string;
|
||||
lookupKey: string;
|
||||
trialEnd?: number;
|
||||
interval?: string; // Billing interval ('year' or 'month')
|
||||
}
|
||||
|
||||
export interface Package extends BasePackage {
|
||||
priceId?: string;
|
||||
desc: string;
|
||||
maxVehicles?: number;
|
||||
Vehicles?: string;
|
||||
maxAcres?: string;
|
||||
lookupKey: string;
|
||||
level?: number;
|
||||
trialEnd?: number;
|
||||
interval?: string; // Billing interval ('year' or 'month')
|
||||
}
|
||||
|
||||
export interface Address {
|
||||
_id?: string;
|
||||
name?: string;
|
||||
valid?: boolean;
|
||||
city?: string;
|
||||
country: string;
|
||||
line1: string;
|
||||
line2?: string | null;
|
||||
postalCode?: string;
|
||||
state?: string,
|
||||
isBilling?: boolean;
|
||||
}
|
||||
|
||||
export interface Card {
|
||||
pmId?: string;
|
||||
brand: string;
|
||||
country: string;
|
||||
exp_month: number;
|
||||
exp_year: number;
|
||||
last4: string;
|
||||
defaultPM: boolean;
|
||||
}
|
||||
|
||||
export interface BillingInfo {
|
||||
applicatorId: string;
|
||||
name: string;
|
||||
address
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface PaymentMethod {
|
||||
id: string;
|
||||
created: number;
|
||||
card: Card;
|
||||
billing_details: BillingInfo;
|
||||
}
|
||||
|
||||
export interface InvoicePackage {
|
||||
custId: string;
|
||||
package: string;
|
||||
addons: BasePackage[];
|
||||
prorateTS?: number; // Optional: only needed for proration calculations
|
||||
coupon?: string;
|
||||
}
|
||||
|
||||
export interface Line {
|
||||
id: string;
|
||||
amount: number;
|
||||
amount_excluding_tax: number;
|
||||
period: {
|
||||
end: number;
|
||||
start: number
|
||||
};
|
||||
description: string;
|
||||
subscription: string;
|
||||
quantity: number;
|
||||
plan: {
|
||||
id: string;
|
||||
amount: number;
|
||||
}
|
||||
price: {
|
||||
lookup_key: string;
|
||||
}
|
||||
// Issue 4 - Proration credit detection
|
||||
proration?: boolean; // True for proration credits (unused subscription time)
|
||||
type?: string; // 'subscription', 'invoiceitem', etc.
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes a deferred promo that will apply from the next billing period.
|
||||
* Shape is identical to the inline `promoDetails` object on subscriptions,
|
||||
* with two additional discriminant flags. Backend builds this from
|
||||
* subscription.metadata.pending_coupon_id (r975+).
|
||||
*/
|
||||
export interface PendingPromoDetails {
|
||||
isPending: true;
|
||||
appliesToNextPeriod: true;
|
||||
name: string;
|
||||
discountDisplay: string; // 'FREE', '50% OFF', '$9.99 OFF'
|
||||
percentOff: number | null;
|
||||
amountOff: number | null; // cents
|
||||
currency: string | null;
|
||||
duration: string | null; // 'forever' | 'once' | 'repeating'
|
||||
durationInMonths: number | null;
|
||||
expiresAt: null;
|
||||
discountEndsAt: null;
|
||||
daysRemaining: null;
|
||||
daysUntilDiscountEnds: null;
|
||||
isTimeLimited: false;
|
||||
}
|
||||
|
||||
export interface Invoice {
|
||||
id: string;
|
||||
subscription: string;
|
||||
tax: number;
|
||||
total: number;
|
||||
object: string;
|
||||
subtotal_excluding_tax: number;
|
||||
lines?: {
|
||||
data: Line[]
|
||||
}
|
||||
number?: number;
|
||||
subtotal?: number;
|
||||
total_tax_amounts?: Taxable[]
|
||||
subscription_proration_date?: number;
|
||||
total_excluding_tax?: number;
|
||||
created?: number;
|
||||
paid?: boolean;
|
||||
status?: string;
|
||||
amount_due?: number;
|
||||
amount_paid?: number;
|
||||
hosted_invoice_url?: string;
|
||||
invoice_pdf?: string;
|
||||
customer_name?: string;
|
||||
customer_email?: string;
|
||||
customer_address?: {
|
||||
city: string;
|
||||
country: string;
|
||||
line1: string;
|
||||
postal_code: string;
|
||||
state: string;
|
||||
};
|
||||
attempted?: boolean;
|
||||
type: InvType.INVOICE;
|
||||
discount?: {
|
||||
coupon: Coupon
|
||||
};
|
||||
/** Invoice period type: "current" (immediate billing) or "next" (future billing cycle) */
|
||||
period_type?: string;
|
||||
/** Flag indicating if this invoice has promotional pricing applied */
|
||||
has_promo?: boolean;
|
||||
/** @deprecated r975: field no longer populated by backend. Use pendingPromoDetails instead. */
|
||||
promo_coupon?: string;
|
||||
/** Unix timestamp (seconds) — when the next charge is collected. Use * 1000 for a JS Date. (r975+) */
|
||||
next_billing_date?: number;
|
||||
/** Present when a deferred 100% FREE promo is scheduled for next billing period (r975+). */
|
||||
pendingPromoDetails?: PendingPromoDetails;
|
||||
/** Discount amounts applied on this invoice. Each entry is { amount (cents), discount (Stripe discount ID) }. */
|
||||
total_discount_amounts?: { amount: number; discount: string }[];
|
||||
}
|
||||
|
||||
export interface Charge {
|
||||
id: string;
|
||||
object: string;
|
||||
amount: number;
|
||||
amount_refunded: number;
|
||||
created: number;
|
||||
paid: boolean;
|
||||
refunded: boolean;
|
||||
receipt_url: string;
|
||||
invoice: string;
|
||||
type: InvType.CHARGE;
|
||||
}
|
||||
|
||||
export type Payment = Invoice | Charge;
|
||||
|
||||
export interface Taxable {
|
||||
amount: string;
|
||||
taxable_amount: string;
|
||||
}
|
||||
|
||||
export interface PaidAmount {
|
||||
totalExcludingTax: number;
|
||||
totalTax: number;
|
||||
total: number;
|
||||
discount?: Discount;
|
||||
refundAmount?: number;
|
||||
}
|
||||
|
||||
export interface Discount {
|
||||
amountOff: number;
|
||||
percentOff?: number;
|
||||
}
|
||||
|
||||
export interface SubscriptionIntent {
|
||||
applicatorId: string;
|
||||
custId: string;
|
||||
selPkg: Package;
|
||||
selAddons: Addon[];
|
||||
orgPkg?: Package;
|
||||
orgAddons?: Addon[];
|
||||
upcomingInvoices?: Invoice[];
|
||||
billingInfo?: BillingInfo;
|
||||
paymentMethods?: PaymentMethod[];
|
||||
card?: Card;
|
||||
prorateTS?: number;
|
||||
amount?: PaidAmount;
|
||||
isNewAccount?: boolean;
|
||||
coupons?: Coupon[];
|
||||
mode: Mode;
|
||||
subIds?: string[];
|
||||
promoSavings?: number; // Total promo discount in cents (calculated in checkout)
|
||||
}
|
||||
|
||||
export interface SubscriptionPackage {
|
||||
stage?: string;
|
||||
card?: Card;
|
||||
pmId?: string;
|
||||
defaultPM: boolean;
|
||||
package: string;
|
||||
addons: BasePackage[];
|
||||
applicatorId?: string;
|
||||
prorateTS?: number;
|
||||
coupon?: string;
|
||||
trial?: number;
|
||||
}
|
||||
|
||||
export interface CustChargePkg {
|
||||
custId: string;
|
||||
refunded?: boolean;
|
||||
status?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface SubscriptionPaymentMethod {
|
||||
subIds: string[];
|
||||
pmId: string;
|
||||
}
|
||||
|
||||
|
||||
export interface PaymentIntent {
|
||||
id: string;
|
||||
status: string;
|
||||
client_secret: string;
|
||||
customer: string;
|
||||
payment_method: string;
|
||||
source: string;
|
||||
last_payment_error: {
|
||||
payment_method: PaymentMethod;
|
||||
source: Card
|
||||
};
|
||||
}
|
||||
|
||||
export interface LatestInvoice {
|
||||
subscription: string;
|
||||
id: string;
|
||||
object: string;
|
||||
customer_address?: {
|
||||
city: string;
|
||||
country: string;
|
||||
line1: string;
|
||||
line2: string;
|
||||
postal_code: string;
|
||||
state: string;
|
||||
},
|
||||
customer_name?: string;
|
||||
period_start: number;
|
||||
period_end: number;
|
||||
status: string;
|
||||
payment_intent: PaymentIntent;
|
||||
tax: number;
|
||||
total: number;
|
||||
total_excluding_tax: number;
|
||||
subtotal: number;
|
||||
subtotal_excluding_tax: number;
|
||||
type: InvType.INVOICE;
|
||||
last_finalization_error: {
|
||||
code: string;
|
||||
type: string;
|
||||
message: string;
|
||||
};
|
||||
automatic_tax: {
|
||||
enabled: boolean;
|
||||
status: string;
|
||||
}
|
||||
}
|
||||
|
||||
export interface PastDue {
|
||||
invoices: LatestInvoice[];
|
||||
numOfRetries: number;
|
||||
}
|
||||
|
||||
export interface Incomplete {
|
||||
invoices: LatestInvoice[];
|
||||
requiresAction: boolean;
|
||||
requiresPM: boolean;
|
||||
numOfRetries: number;
|
||||
subscriptions?: StripeSubscription[]
|
||||
}
|
||||
|
||||
export interface Unpaid {
|
||||
invoices: Invoice[];
|
||||
numOfRetries: number;
|
||||
}
|
||||
|
||||
export interface StripeSubscription {
|
||||
id: string;
|
||||
status: string;
|
||||
latest_invoice: LatestInvoice;
|
||||
items: {
|
||||
data: {
|
||||
quantity: number;
|
||||
price: {
|
||||
lookup_key: string;
|
||||
metadata?: {
|
||||
maxVehicles?: string;
|
||||
maxAcres?: string;
|
||||
tier?: string;
|
||||
level?: string;
|
||||
};
|
||||
}
|
||||
}[];
|
||||
};
|
||||
current_period_end: number;
|
||||
current_period_start: number;
|
||||
default_payment_method: string;
|
||||
default_source: string;
|
||||
metadata?: {
|
||||
type: string;
|
||||
scheduleId?: string;
|
||||
promoId?: string;
|
||||
};
|
||||
cancel_at_period_end: boolean;
|
||||
discount?: {
|
||||
coupon: Coupon
|
||||
}
|
||||
trial_end?: number;
|
||||
quantity: number;
|
||||
// ✅ r962+ promoDetails enhancement (includes amountOff/percentOff)
|
||||
promoDetails?: {
|
||||
hasPromo: boolean;
|
||||
name: string;
|
||||
discountDisplay: string;
|
||||
expiresAt: string | null;
|
||||
discountEndsAt: string | null;
|
||||
daysRemaining: number | null;
|
||||
daysUntilDiscountEnds: number | null;
|
||||
isTimeLimited: boolean;
|
||||
durationInMonths: number | null;
|
||||
duration: string | null;
|
||||
percentOff: number | null;
|
||||
amountOff: number | null;
|
||||
currency: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Warning notification for subscriptions expiring within 7 days
|
||||
*
|
||||
* Data source: GET /api/subscription?custId={custId}
|
||||
* Verified: 2025-11-10 via /server_test/subscription-data-verification.js
|
||||
*
|
||||
* Use case: Display topbar notification when subscription expires in 1-7 days
|
||||
* Shows: Package name and/or addon names that are expiring with their individual expiry dates
|
||||
*/
|
||||
export interface ExpiryWarning {
|
||||
/** Subscription ID from Stripe */
|
||||
id: string;
|
||||
|
||||
/** Subscription type - 'package', 'addon', or 'both' */
|
||||
type: 'package' | 'addon' | 'both';
|
||||
|
||||
/** Subscription status from Stripe (trialing, active, etc.) */
|
||||
status: string;
|
||||
|
||||
/** Calculated days until earliest subscription expires */
|
||||
daysUntilExpiry: number;
|
||||
|
||||
/** If true, subscription will expire and NOT auto-renew */
|
||||
cancelAtPeriodEnd: boolean;
|
||||
|
||||
/** Unix timestamp when earliest subscription period ends */
|
||||
periodEnd: number;
|
||||
|
||||
/** True if subscription status is 'trialing' */
|
||||
isTrial: boolean;
|
||||
|
||||
/** True if subscription will auto-renew (inverse of cancelAtPeriodEnd) */
|
||||
willAutoRenew: boolean;
|
||||
|
||||
/** Package expiry details if package is expiring */
|
||||
package?: {
|
||||
name: string;
|
||||
lookupKey: string;
|
||||
daysUntilExpiry: number;
|
||||
periodEnd: number;
|
||||
willAutoRenew: boolean;
|
||||
isTrial: boolean;
|
||||
isCanceled: boolean;
|
||||
};
|
||||
|
||||
/** Array of addon expiry details if addons are expiring */
|
||||
addons?: Array<{
|
||||
name: string;
|
||||
lookupKey: string;
|
||||
daysUntilExpiry: number;
|
||||
periodEnd: number;
|
||||
willAutoRenew: boolean;
|
||||
isTrial: boolean;
|
||||
isCanceled: boolean;
|
||||
}>;
|
||||
|
||||
/** True when sub-account has no active subscriptions */
|
||||
noSubs?: boolean;
|
||||
}
|
||||
|
||||
export interface AGNavSubscription {
|
||||
id: string;
|
||||
status: string;
|
||||
items: BasePackage[];
|
||||
periodEnd: number;
|
||||
periodStart: number;
|
||||
type: string;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
trial_end?: number;
|
||||
promoDetails?: {
|
||||
hasPromo: boolean;
|
||||
name: string;
|
||||
discountDisplay: string;
|
||||
expiresAt: string | null;
|
||||
discountEndsAt: string | null;
|
||||
daysRemaining: number | null;
|
||||
daysUntilDiscountEnds: number | null;
|
||||
isTimeLimited: boolean;
|
||||
durationInMonths: number | null;
|
||||
duration: string | null;
|
||||
percentOff: number | null;
|
||||
amountOff: number | null;
|
||||
currency: string | null;
|
||||
};
|
||||
/**
|
||||
* Present when a deferred 100% FREE promo is scheduled for next billing period (r975+).
|
||||
* Built from subscription.metadata.pending_coupon_id by addPromoDetailsToSubscription().
|
||||
* Absent (undefined) when no deferred promo is active or subscription is cancel_at_period_end.
|
||||
*/
|
||||
pendingPromoDetails?: PendingPromoDetails;
|
||||
}
|
||||
|
||||
export interface AGNavSubscriptionShort {
|
||||
id: string;
|
||||
lookupKey: PriceUsd;
|
||||
status: string;
|
||||
periodEnd: number;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
quantity: number;
|
||||
paymentMethod: string;
|
||||
trialEnd?: number;
|
||||
promoDetails?: {
|
||||
hasPromo: boolean;
|
||||
name: string;
|
||||
discountDisplay: string;
|
||||
expiresAt: string | null;
|
||||
discountEndsAt: string | null;
|
||||
daysRemaining: number | null;
|
||||
daysUntilDiscountEnds: number | null;
|
||||
isTimeLimited: boolean;
|
||||
durationInMonths: number | null;
|
||||
duration: string | null;
|
||||
percentOff: number | null;
|
||||
amountOff: number | null;
|
||||
currency: string | null;
|
||||
};
|
||||
/**
|
||||
* Present when a deferred 100% FREE promo is scheduled for next billing period (r975+).
|
||||
* Absent (undefined) when no deferred promo is active or subscription is cancel_at_period_end.
|
||||
*/
|
||||
pendingPromoDetails?: PendingPromoDetails;
|
||||
}
|
||||
|
||||
export interface Status {
|
||||
code: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface RefreshPackage {
|
||||
applicatorId: string;
|
||||
custId: string;
|
||||
prevStage: string;
|
||||
stage: string;
|
||||
card: Card;
|
||||
}
|
||||
|
||||
export interface Unresolved {
|
||||
type: string;
|
||||
reason?: string;
|
||||
numOfRetries: number;
|
||||
invoices: LatestInvoice[];
|
||||
}
|
||||
|
||||
export interface ConfirmPackage {
|
||||
custId: string;
|
||||
stripePkgs: {
|
||||
clientSecret: string;
|
||||
pmId: string;
|
||||
}[];
|
||||
subIds: string[];
|
||||
unresolved: Unresolved;
|
||||
applicatorId: string;
|
||||
stage?: string;
|
||||
}
|
||||
|
||||
export interface CreatePaymentMethodPackage {
|
||||
card: StripeCardElement;
|
||||
billing_details: {
|
||||
name: string;
|
||||
address: Address;
|
||||
};
|
||||
defaultPM: boolean;
|
||||
}
|
||||
|
||||
export interface UnpaidPackage {
|
||||
pmId: string;
|
||||
invIds: string[];
|
||||
unpaid?: Unpaid;
|
||||
card?: Card;
|
||||
custId?: string;
|
||||
applicatorId?: string;
|
||||
}
|
||||
|
||||
export interface UnpaidSubscription {
|
||||
lookupKey: PriceUsd;
|
||||
id: string;
|
||||
tax: number;
|
||||
total: number;
|
||||
subtotal_excluding_tax: number;
|
||||
}
|
||||
|
||||
export interface Aircraft {
|
||||
numOfVehicle: number
|
||||
}
|
||||
|
||||
export interface Acre {
|
||||
currUsage: number;
|
||||
limit: number | null; // null = unlimited acres for current subscription packages
|
||||
overLimit: boolean;
|
||||
}
|
||||
|
||||
export interface SubLimit {
|
||||
package?: { [i: string]: Limit };
|
||||
addon?: { [i: string]: Limit };
|
||||
}
|
||||
|
||||
export interface Plan extends SubLimit {
|
||||
subscriptions?: StripeSubscription[];
|
||||
membership?: IMembership;
|
||||
}
|
||||
|
||||
export interface Limit {
|
||||
acre: Acre;
|
||||
airCraft: Aircraft;
|
||||
}
|
||||
|
||||
export interface BillPeriod {
|
||||
custId: string;
|
||||
periodEnd: number;
|
||||
periodStart: number;
|
||||
subId: string;
|
||||
lookupKey: string;
|
||||
}
|
||||
|
||||
export interface JobUsage {
|
||||
createdAt: string;
|
||||
jobId: number;
|
||||
ttSprArea: number;
|
||||
totalSprayed: number;
|
||||
updateDate: string;
|
||||
}
|
||||
|
||||
export interface Usage {
|
||||
ttArea: number;
|
||||
numOfAC: number;
|
||||
jobUsages: JobUsage[]
|
||||
}
|
||||
|
||||
export interface UsagePackage {
|
||||
byPuid: string;
|
||||
fromTS?: number;
|
||||
toTS?: number;
|
||||
}
|
||||
|
||||
export interface UsageDetail {
|
||||
periodEnd: number;
|
||||
periodStart: number;
|
||||
dayPercentage: number,
|
||||
dayLeft: number;
|
||||
maxAcre: string;
|
||||
ttArea: number;
|
||||
acrePercentage: number;
|
||||
jobUsages?: JobUsage[];
|
||||
billPeriods?: BillPeriod[];
|
||||
}
|
||||
|
||||
export interface TSRange {
|
||||
minTS: number;
|
||||
maxTS: number;
|
||||
fromTS: number;
|
||||
toTS: number;
|
||||
}
|
||||
|
||||
export interface LineItem {
|
||||
description: string;
|
||||
tax: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface TotalLine {
|
||||
totalTax: number;
|
||||
totalAmount: number;
|
||||
lineItems: LineItem[];
|
||||
discount?: Discount;
|
||||
}
|
||||
|
||||
export interface CheckoutPayment {
|
||||
payment: TotalLine;
|
||||
refund?: TotalLine;
|
||||
}
|
||||
|
||||
export interface Coupon {
|
||||
id: string;
|
||||
name: string;
|
||||
amount_off: number;
|
||||
percent_off: number;
|
||||
redeem_by: string;
|
||||
times_redeemed: number;
|
||||
valid: true;
|
||||
}
|
||||
|
||||
export interface Trial {
|
||||
selected?: boolean,
|
||||
type: string;
|
||||
startDate: Date,
|
||||
lastStartDate: Date,
|
||||
lastEndDate: Date,
|
||||
trialDays: number,
|
||||
byDate: Date
|
||||
}
|
||||
|
||||
export interface TrialPmtPkg {
|
||||
package: string;
|
||||
addons: BasePackage[];
|
||||
pmtMethod?: {
|
||||
newPmtMeth?: CreatePaymentMethodPackage;
|
||||
exPmtMeth?: Card
|
||||
},
|
||||
mode: Mode;
|
||||
subIds?: string[];
|
||||
amount?: PaidAmount;
|
||||
}
|
||||
|
||||
export interface TrialItem {
|
||||
description: string;
|
||||
amount: PriceUsd;
|
||||
trialEnd?: number;
|
||||
quantity: number;
|
||||
price?: {
|
||||
lookup_key: string;
|
||||
unit_amount: number;
|
||||
}
|
||||
}
|
||||
|
||||
export interface StripeCard {
|
||||
cardNumber: StripeCardNumberElement;
|
||||
cardExpiry: StripeCardExpiryElement;
|
||||
cardCvc: StripeCardCvcElement;
|
||||
}
|
||||
|
||||
export interface CardExp {
|
||||
expMonth: number;
|
||||
expYear: number;
|
||||
}
|
||||
|
||||
export interface PMPkgEdit {
|
||||
pmId: string,
|
||||
name?: string,
|
||||
card?: CardExp,
|
||||
setDefault?: boolean
|
||||
}
|
||||
|
||||
export interface PMPkgAdd {
|
||||
name: string;
|
||||
card: StripeCardElement;
|
||||
setDefault?: boolean;
|
||||
}
|
||||
|
||||
export interface PkgValid {
|
||||
isValid: boolean;
|
||||
status?: Status;
|
||||
}
|
||||
|
||||
export interface BillingInfoPackage { billingInfo?: BillingInfo, isNewAccount?: boolean }
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -1,27 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Resolve } from '@angular/router';
|
||||
import { Observable } from 'rxjs';
|
||||
import { first, map } from 'rxjs/operators';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
import { CustomerService } from '../services/customer.service';
|
||||
import { IMembership } from '@app/auth/models/user.model';
|
||||
|
||||
@Injectable()
|
||||
export class MembershipResolver implements Resolve<IMembership> {
|
||||
constructor(
|
||||
private readonly custSvc: CustomerService,
|
||||
private readonly authSvc: AuthService
|
||||
) { }
|
||||
|
||||
resolve(): Observable<IMembership> {
|
||||
const id = this.authSvc.user?.parent || this.authSvc.user._id;
|
||||
return this.custSvc.getCustomer(id).pipe(
|
||||
map((cust) => {
|
||||
const membership = cust?.membership;
|
||||
if (membership) {
|
||||
return membership;
|
||||
}
|
||||
}),
|
||||
first())
|
||||
}
|
||||
}
|
||||
@ -1,45 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Router, ActivatedRouteSnapshot, Resolve } from '@angular/router';
|
||||
|
||||
import { Observable, forkJoin, of } from 'rxjs';
|
||||
import { map, first, switchMap } from 'rxjs/operators';
|
||||
|
||||
import { User } from '@app/accounts/models/user.model';
|
||||
import { UserService } from '@app/domain/services/user.service';
|
||||
|
||||
export interface UserWithParentUsername {
|
||||
user: User;
|
||||
parentUsername?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ProfileResolver implements Resolve<UserWithParentUsername> {
|
||||
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()
|
||||
);
|
||||
} else {
|
||||
return of({ user });
|
||||
}
|
||||
}),
|
||||
first()
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,35 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Router, Resolve } from '@angular/router';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map, first, switchMap } from 'rxjs/operators';
|
||||
import { User } from '@app/accounts/models/user.model';
|
||||
import { UserService } from '@app/domain/services/user.service';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { selectAuthUser } from '@app/reducers';
|
||||
import { UserModel } from '@app/auth/models/user.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
|
||||
export class UserResolver implements Resolve<User> {
|
||||
constructor(
|
||||
private readonly userService: UserService,
|
||||
private readonly router: Router,
|
||||
private readonly store: Store<{}>,
|
||||
) { }
|
||||
|
||||
resolve(): Observable<User> {
|
||||
return this.store.select(selectAuthUser).pipe(
|
||||
switchMap((authUser: UserModel) => {
|
||||
return this.userService.getUser(authUser._id, { withAddresses: true })
|
||||
}),
|
||||
map((user: User) => {
|
||||
if (user) {
|
||||
return user;
|
||||
} else {
|
||||
this.router.navigate(['/profile']);
|
||||
}
|
||||
}),
|
||||
first()
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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 || '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -6,7 +6,7 @@ import { environment } from '@environments/environment';
|
||||
import { AppMessageService } from '@app/shared/app-message.service';
|
||||
import { AuthService } from './auth.service';
|
||||
import { MatType } from '@app/shared/global';
|
||||
import { catchError, debounceTime, map } from 'rxjs/operators';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@ -59,23 +59,13 @@ export class AppConfigService {
|
||||
return this.http.get<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);
|
||||
this.appMsgSvc.addFailedMsg('Could not load AppConfig. Please retry or contact Agnav.');
|
||||
return of(false);
|
||||
})
|
||||
);
|
||||
}
|
||||
@ -155,19 +145,4 @@ export class AppConfigService {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
saveTrialDays(trialDays: Number[]) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.http.post("/appConfig", { trialDays }, { params: new HttpParams().set('loader', 'false') }).subscribe({
|
||||
next: (res: { trialDays: Number[] }) => {
|
||||
this.settings = { ...this._settings, trialDays: res.trialDays };
|
||||
resolve(true);
|
||||
},
|
||||
error: (err) => {
|
||||
this.appMsgSvc.addFailedMsg('Could not save AppConfig. Please retry or contact Agnav.');
|
||||
reject(`Could not save AppConfig !': ${JSON.stringify(err)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Injectable, Injector } from '@angular/core';
|
||||
import { HttpEvent, HttpInterceptor, HttpHandler, HttpRequest, HttpResponse, HttpHeaders } from '@angular/common/http';
|
||||
|
||||
import { Observable, throwError } from 'rxjs';
|
||||
import { catchError, finalize, map } from 'rxjs/operators';
|
||||
|
||||
@ -49,11 +50,20 @@ export class AuthInterceptor implements HttpInterceptor {
|
||||
if (showLoading) {
|
||||
this.loaderSvc.show();
|
||||
this.requests.push(authReq);
|
||||
// console.log("Num of loading reqs:", this.requests.length);
|
||||
}
|
||||
|
||||
// Pass on the cloned request instead of the original request.
|
||||
return next.handle(authReq).pipe(
|
||||
map((event: HttpEvent<any>) => {
|
||||
// if (event instanceof HttpResponse) {
|
||||
// const agmTk = event.headers.get('Agm-TK');
|
||||
// if ((agmTk && this.authSvc.token) && (this.authSvc.token.t != agmTk)) {
|
||||
// const newTk = this.authSvc.token;
|
||||
// newTk.t = agmTk;
|
||||
// this.authSvc.token = newTk;
|
||||
// }
|
||||
// }
|
||||
return event; // Should always return the response event untouched as metioned in 'HttpEvents' section at https://angular.io/guide/http
|
||||
}),
|
||||
catchError(err => this.onCatch(err, req)),
|
||||
@ -64,18 +74,12 @@ export class AuthInterceptor implements HttpInterceptor {
|
||||
removeRequest(req: HttpRequest<any>) {
|
||||
const i = this.requests.indexOf(req);
|
||||
(i >= 0) && (this.requests.splice(i, 1));
|
||||
|
||||
const val = Boolean(this.requests.length > 0);
|
||||
this.loaderSvc.loading$.next(val);
|
||||
this.loaderSvc.loading$.next(this.requests.length > 0);
|
||||
// console.log("Num of loading reqs:", this.requests.length);
|
||||
}
|
||||
|
||||
private onCatch(err: any, req: HttpRequest<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);
|
||||
|
||||
@ -2,27 +2,20 @@ import { Injectable, OnDestroy, Inject, LOCALE_ID } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
|
||||
import { Observable, of, throwError, Subscription } from 'rxjs';
|
||||
import { exhaustMap, tap, catchError } from 'rxjs/operators';
|
||||
import { exhaustMap } from 'rxjs/operators';
|
||||
|
||||
import { DateUtils, Utils } from '../../shared/utils';
|
||||
import { Utils } from '../../shared/utils';
|
||||
import { RoleIds } from '../../shared/global';
|
||||
|
||||
import { Store } from '@ngrx/store';
|
||||
import * as fromStore from '../../reducers';
|
||||
import { UserModel } from '../../auth/models/user.model';
|
||||
import { Authenticate } from '../../auth/models/auth.model';
|
||||
import { AGNavSubscription, PriceUsd, Trial } from '../models/subscription.model';
|
||||
import { Mode, SUB, SubStripe, SubType } from '@app/profile/common';
|
||||
import { SubscriptionService } from './subscription.service';
|
||||
import { GAService } from '../../shared/ga.service';
|
||||
import { GAAnalyticsHelpersService } from '../../shared/ga.analytics-helpers.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService implements OnDestroy {
|
||||
private _user: UserModel;
|
||||
private _sessionStartTime: number;
|
||||
|
||||
get user(): UserModel {
|
||||
private _user: any;
|
||||
get user(): any {
|
||||
return this._user;
|
||||
}
|
||||
|
||||
@ -52,9 +45,6 @@ export class AuthService implements OnDestroy {
|
||||
@Inject(LOCALE_ID) private localeId: string,
|
||||
private readonly store: Store<{}>,
|
||||
private readonly http: HttpClient,
|
||||
private subSvc: SubscriptionService,
|
||||
private readonly gaService: GAService,
|
||||
private readonly gaHelpers: GAAnalyticsHelpersService,
|
||||
) {
|
||||
this._locale = Utils.getLang(this.localeId) || 'en';
|
||||
this._tk = JSON.parse(sessionStorage.getItem('cT'));
|
||||
@ -73,10 +63,6 @@ export class AuthService implements OnDestroy {
|
||||
return this.hasRole([RoleIds.APP]);
|
||||
}
|
||||
|
||||
get isAppAdm() {
|
||||
return this.hasRole([RoleIds.APP_ADM]);
|
||||
}
|
||||
|
||||
get isClientUser() {
|
||||
return this.hasRole([RoleIds.CLIENT]);
|
||||
}
|
||||
@ -89,14 +75,6 @@ export class AuthService implements OnDestroy {
|
||||
return this.hasRole([RoleIds.INSPECTOR]);
|
||||
}
|
||||
|
||||
get isPartner(): boolean {
|
||||
return this.hasRole([RoleIds.PARTNER]);
|
||||
}
|
||||
|
||||
hasSubsWithStatus(status: string) {
|
||||
return this.user?.membership?.subscriptions?.some((sub) => sub.status === `${status}`);
|
||||
}
|
||||
|
||||
getAuthHeader(): string {
|
||||
return this.user && this.token ? 'Bearer ' + this.token.t : '';
|
||||
}
|
||||
@ -106,32 +84,7 @@ export class AuthService implements OnDestroy {
|
||||
}
|
||||
|
||||
hasRole(roles: string[]): boolean {
|
||||
return this.loggedIn && (roles && Utils.containsAny(roles, this.user?.roles));
|
||||
}
|
||||
|
||||
hasAppRoleAndSub(): boolean {
|
||||
return this.isApplicator && this.hasSubs();
|
||||
}
|
||||
|
||||
hasSubs() {
|
||||
return this.user?.membership?.subscriptions?.length > 0;
|
||||
}
|
||||
|
||||
getSub(lookupKey: string): AGNavSubscription {
|
||||
if (this.hasSubs) return this.user?.membership?.subscriptions?.find((sub) => sub.items?.some((item) => item.price === lookupKey));
|
||||
}
|
||||
|
||||
getCurLookupKey(type: SubType.PACKAGE | SubType.ADDON): PriceUsd {
|
||||
// Use centralized utility methods
|
||||
const subscriptions = this.user?.membership?.subscriptions;
|
||||
switch (type) {
|
||||
case SubType.PACKAGE:
|
||||
return this.subSvc.getCurrentPackageLookupKey(subscriptions) || '';
|
||||
case SubType.ADDON:
|
||||
return this.subSvc.getCurrentAddonLookupKey(subscriptions) || '';
|
||||
default:
|
||||
throw new Error('Unsupported type');
|
||||
}
|
||||
return this.loggedIn && (roles && Utils.containsAny(roles, this.user.roles));
|
||||
}
|
||||
|
||||
get isPlanner() {
|
||||
@ -142,10 +95,6 @@ export class AuthService implements OnDestroy {
|
||||
return (this.user && this.user.billable);
|
||||
}
|
||||
|
||||
get isCanada(): boolean {
|
||||
return this.user?.country === 'CA';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parent user, to mange items under an applicator user
|
||||
*/
|
||||
@ -169,45 +118,18 @@ export class AuthService implements OnDestroy {
|
||||
throwError('invalid_account');
|
||||
|
||||
// Store username and jwt token in local storage to keep user logged in between page refreshes
|
||||
const user = <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'] || '' };
|
||||
this._user = user;
|
||||
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'] };
|
||||
|
||||
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,110 +142,15 @@ export class AuthService implements OnDestroy {
|
||||
}
|
||||
|
||||
mailPwdReset(ops) {
|
||||
return this.http.post('/users/mailPwdReset', ops).pipe(
|
||||
tap(response => {
|
||||
// Track password reset request
|
||||
this.gaService.trackPasswordResetRequested({
|
||||
request_method: 'forgot_password_page',
|
||||
user_exists: true, // If we get a success response, user exists
|
||||
platform: 'web'
|
||||
});
|
||||
}),
|
||||
catchError(error => {
|
||||
// Track password reset request failure
|
||||
this.gaService.trackPasswordResetRequested({
|
||||
request_method: 'forgot_password_page',
|
||||
user_exists: false, // If we get an error, user may not exist
|
||||
platform: 'web'
|
||||
});
|
||||
return throwError(error);
|
||||
})
|
||||
);
|
||||
return this.http.post('/users/mailPwdReset', ops);
|
||||
}
|
||||
|
||||
validateResetPassword(ops) {
|
||||
return this.http.post('/users/resetPassword/validate', ops);
|
||||
resetPassword(ops) {
|
||||
return this.http.get(`/users/resetPassword/${ops.id}/${ops.token}`);
|
||||
}
|
||||
|
||||
changePassword(ops) {
|
||||
return this.http.post('/users/resetPassword', ops).pipe(
|
||||
tap(response => {
|
||||
// Track password reset completion
|
||||
this.gaService.trackPasswordResetCompleted({
|
||||
success: true,
|
||||
reset_token_age_minutes: 0, // Token age info not available in current implementation
|
||||
platform: 'web'
|
||||
});
|
||||
}),
|
||||
catchError(error => {
|
||||
// Track password reset completion failure
|
||||
this.gaService.trackPasswordResetCompleted({
|
||||
success: false,
|
||||
reset_token_age_minutes: 0, // Token age info not available
|
||||
failure_reason: 'other',
|
||||
platform: 'web'
|
||||
});
|
||||
return throwError(error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
get trials() {
|
||||
return this.user?.membership?.trials;
|
||||
}
|
||||
|
||||
hasActiveTrial(trials: Trial) {
|
||||
if (!trials || !this.hasSubsWithStatus(SubStripe.TRIALING)) return false;
|
||||
return trials.lastStartDate && DateUtils.currUTC() <= DateUtils.dateToTS(new Date(trials.lastEndDate))
|
||||
|| this.hasSubsWithStatus(SubStripe.TRIALING);
|
||||
}
|
||||
|
||||
hasLastEndedTrial(trials: Trial) {
|
||||
if (!trials) return false;
|
||||
return trials.lastStartDate && trials.lastEndDate;
|
||||
}
|
||||
|
||||
isTrialDays(trials: Trial) {
|
||||
return trials?.trialDays > 1;
|
||||
}
|
||||
|
||||
hasValidTrialOffer(trials: Trial) {
|
||||
return !!trials.byDate || this.isTrialDays(trials);
|
||||
}
|
||||
|
||||
validateTrial(trials: Trial) {
|
||||
if (!trials || !trials.type) return false;
|
||||
|
||||
let isWithinTrialPeriod: boolean = false;
|
||||
if (this.hasValidTrialOffer(trials)) {
|
||||
if (trials.byDate) {
|
||||
isWithinTrialPeriod = DateUtils.currUTC() <= DateUtils.dateToTS(new Date(trials.byDate));
|
||||
} else if (this.isTrialDays(trials)) {
|
||||
const trialEndDate = new Date(trials.startDate);
|
||||
trialEndDate.setDate(trialEndDate.getDate() + trials.trialDays);
|
||||
isWithinTrialPeriod = DateUtils.currUTC() <= DateUtils.dateToTS(trialEndDate);
|
||||
}
|
||||
}
|
||||
return this.hasRole([RoleIds.APP])
|
||||
&& !this.hasSubs()
|
||||
&& isWithinTrialPeriod;
|
||||
}
|
||||
|
||||
canDisplayTrial(trials: Trial) {
|
||||
return this.validateTrial(trials);
|
||||
}
|
||||
|
||||
canAcceptTrial(url: string) {
|
||||
return !url.includes(SUB.MY_SERVICES)
|
||||
&& this.subSvc.subMode !== Mode.TRIALING;
|
||||
}
|
||||
|
||||
get canActivateVehicle() {
|
||||
return this.isApplicator;
|
||||
}
|
||||
|
||||
get canAccessInvoice() {
|
||||
return this.isApplicator;
|
||||
return this.http.post('/users/resetPassword', ops);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
|
||||
@ -5,7 +5,7 @@ import { Observable } from 'rxjs';
|
||||
|
||||
import { Store } from '@ngrx/store';
|
||||
import { Client } from '../../client/models/client.model';
|
||||
import { CustomerInvoiceSetting } from '@app/invoices/models/customer-invoice-setting.model';
|
||||
import {CustomerInvoiceSetting} from '@app/invoices/models/customer-invoice-setting.model';
|
||||
|
||||
@Injectable()
|
||||
export class ClientService {
|
||||
@ -46,13 +46,10 @@ export class ClientService {
|
||||
return this.http.delete<Client>(`${this.clientURL}/${client._id}`);
|
||||
}
|
||||
|
||||
searchWithSettings(byPuid: string): Observable<Client[]> {
|
||||
return this.http.post<Client[]>(`${this.clientURL}/searchWithSettings`, { byPuid });
|
||||
}
|
||||
}
|
||||
|
||||
export interface LoadClientOps {
|
||||
byPuid: string;
|
||||
byUserId: string;
|
||||
}
|
||||
|
||||
export interface ClientWithSetting extends Client {
|
||||
|
||||
@ -1,14 +1,19 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { Customer } from '../../customers/models/customer.model';
|
||||
|
||||
import { Store } from '@ngrx/store';
|
||||
|
||||
@Injectable()
|
||||
export class CustomerService {
|
||||
|
||||
private readonly customerURL = '/customers';
|
||||
|
||||
constructor(
|
||||
private store: Store<{}>,
|
||||
private http: HttpClient
|
||||
) {
|
||||
}
|
||||
@ -17,9 +22,8 @@ export class CustomerService {
|
||||
return this.http.get<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> {
|
||||
|
||||
@ -1,166 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import {
|
||||
HttpRequest,
|
||||
HttpHandler,
|
||||
HttpEvent,
|
||||
HttpInterceptor,
|
||||
HttpErrorResponse,
|
||||
HttpResponse
|
||||
} from '@angular/common/http';
|
||||
import { Observable, throwError } from 'rxjs';
|
||||
import { catchError, tap } from 'rxjs/operators';
|
||||
import { AppMessageService } from '@app/shared/app-message.service';
|
||||
import { globals } from '@app/shared/global';
|
||||
import { environment } from '@environments/environment';
|
||||
import { AppInjector } from '@app/app-injector';
|
||||
import { GAService } from '@app/shared/ga.service';
|
||||
|
||||
@Injectable()
|
||||
export class GlobalErrorInterceptor implements HttpInterceptor {
|
||||
private failedAttempts = 0;
|
||||
private gaSvc: GAService;
|
||||
|
||||
constructor(private readonly msgSvc: AppMessageService) {
|
||||
// Use AppInjector to get GAService to avoid circular dependency
|
||||
this.gaSvc = AppInjector.getInjector().get(GAService);
|
||||
}
|
||||
|
||||
intercept(req: HttpRequest<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.failedAttempts = 0; // Reset counter after showing the error
|
||||
}
|
||||
}
|
||||
return throwError(error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -158,7 +158,7 @@ export class InvoiceService {
|
||||
|
||||
return jobCostings.reduce((acc, jobCosting) => {
|
||||
const items = jobCosting?.costings?.items?.map(item => ({
|
||||
job: jobCosting.job,
|
||||
id: jobCosting.job,
|
||||
jobName: jobCosting.name,
|
||||
costingName: item.name,
|
||||
quantity: item.quantity,
|
||||
@ -197,17 +197,15 @@ export class InvoiceService {
|
||||
};
|
||||
|
||||
private calculateSubTotal(client: Client, totalJobAmount?: number): number {
|
||||
const split = client?.split ? +client.split : 100;
|
||||
return totalJobAmount
|
||||
? totalJobAmount * (Number(split) / 100)
|
||||
? totalJobAmount * (Number(client.split) / 100)
|
||||
: client.subTotal
|
||||
? Number(client.subTotal)
|
||||
: 0;
|
||||
}
|
||||
|
||||
private calculateDiscounted(subTotal: number, client: Client): number {
|
||||
const discount = client?.discount ? +client.discount : 0;
|
||||
return subTotal * (discount / 100);
|
||||
return subTotal * (+client.discount / 100);
|
||||
}
|
||||
|
||||
private calculateTotalExcludingTax(subTotal: number, discounted: number): number {
|
||||
@ -215,8 +213,7 @@ export class InvoiceService {
|
||||
}
|
||||
|
||||
private calculateTaxed(totalExcludingTax: number, client: Client): number {
|
||||
const taxRate = client?.taxRate ? +client.taxRate : 0;
|
||||
return totalExcludingTax * (taxRate / 100);
|
||||
return totalExcludingTax * (+client.taxRate / 100);
|
||||
}
|
||||
|
||||
private calculateTotal(totalExcludingTax: number, taxed: number): number {
|
||||
@ -250,6 +247,10 @@ export class InvoiceService {
|
||||
};
|
||||
const payment = this.calculateClientPayment(client, subTotalAfterSplit);
|
||||
|
||||
if (print.client.paymentTerm) {
|
||||
print.paymentTerm = print.client.paymentTerm;
|
||||
}
|
||||
|
||||
return {
|
||||
...print,
|
||||
jobItems,
|
||||
|
||||
@ -4,6 +4,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
import { Store } from '@ngrx/store';
|
||||
import { IJob, IUIJob, JobLog, RptOption, toJob } from '../../job/models/job.model';
|
||||
import { AppFile } from '../models/shared.model';
|
||||
import { UpdateJobOps } from '../../job/actions/job.actions';
|
||||
@ -14,26 +15,13 @@ export class JobService {
|
||||
private readonly jobURL = '/jobs';
|
||||
|
||||
constructor(
|
||||
private store: Store<{}>,
|
||||
private http: HttpClient
|
||||
) {
|
||||
}
|
||||
|
||||
loadJobs(ops: any): Observable<IJob[]> {
|
||||
let _ops = new HttpParams()
|
||||
.set('clientId', ops?.clientId || '')
|
||||
.set('jpo', ops?.jobsByPilot || 'false')
|
||||
.set('status', ops?.status || '');
|
||||
|
||||
if (ops?.byTime?.length === 2) {
|
||||
for (const time of ops.byTime) {
|
||||
if (time) {
|
||||
_ops = _ops.append('byTime', time.toISOString());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_ops = _ops.append('byTime', ops?.byTime[0] || '');
|
||||
}
|
||||
|
||||
const _ops = new HttpParams().set('clientId', ops && ops.clientId).set('jpo', (ops && ops.jobsByPilot) || 'false');
|
||||
return this.http.get<IJob[]>(this.jobURL, { params: _ops });
|
||||
}
|
||||
|
||||
@ -117,10 +105,6 @@ export class JobService {
|
||||
return this.http.post(`${this.jobURL}/deleteAppFile`, options, { params: new HttpParams().set('loader', 'false') });
|
||||
}
|
||||
|
||||
fetchInvReadyJobs(excludeIds?: string[]): Observable<IJob[]> {
|
||||
return this.http.post<IJob[]>(`${this.jobURL}/fetchInvReadyJobs`, { excludeIds });
|
||||
}
|
||||
|
||||
downloadAppFile(fname) {
|
||||
let httpParams = new HttpParams().set('file', fname);
|
||||
return this.http.get('/exports/downloadAppfile', { params: httpParams, responseType: 'arraybuffer' }).pipe(
|
||||
@ -187,24 +171,8 @@ export class JobService {
|
||||
return this.http.post<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 });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Router, NavigationStart } from '@angular/router';
|
||||
import { Router, ActivationEnd } from '@angular/router';
|
||||
import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } from '@angular/common/http';
|
||||
|
||||
import { Observable } from 'rxjs';
|
||||
@ -11,33 +11,18 @@ import { HttpCancelService } from './httpcancel.service';
|
||||
|
||||
@Injectable()
|
||||
export class ManageHttpInterceptor implements HttpInterceptor {
|
||||
private currentUrl: string = '';
|
||||
|
||||
constructor(private readonly router: Router, private readonly httpCancelService: HttpCancelService) {
|
||||
router.events.subscribe(event => {
|
||||
// Only cancel on actual route changes, not during guard/resolver execution
|
||||
if (event instanceof NavigationStart) {
|
||||
// Check if this is actually a new route, not just a reload or guard execution
|
||||
if (this.currentUrl && event.url !== this.currentUrl) {
|
||||
this.httpCancelService.cancelPendingRequests();
|
||||
}
|
||||
this.currentUrl = event.url;
|
||||
// An event triggered at the end of the activation part of the Resolve phase of routing.
|
||||
if (event instanceof ActivationEnd) {
|
||||
// Cancel pending calls
|
||||
this.httpCancelService.cancelPendingRequests();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
intercept<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()))
|
||||
}
|
||||
}
|
||||
@ -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]);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -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,32 +55,9 @@ 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 {
|
||||
byPuid: string;
|
||||
accountType?: number;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
|
||||
import { Observable } from 'rxjs';
|
||||
import { StatusChange, Vehicle } from '../../entities/models/vehicle.model';
|
||||
|
||||
import { Vehicle } from '../../entities/models/vehicle.model';
|
||||
|
||||
@Injectable()
|
||||
export class VehicleService {
|
||||
@ -37,13 +39,10 @@ export class VehicleService {
|
||||
return this.http.delete<Vehicle>(`${this.vehicleURL}/${vehicle._id}`);
|
||||
}
|
||||
|
||||
unitIdExists(unitId: string): Observable<boolean> {
|
||||
unitIdExists(unitId: string): Observable<boolean> {
|
||||
return this.http.post<boolean>(`${this.vehicleURL}/unitIdExists`, { unitId: unitId });
|
||||
}
|
||||
|
||||
updateVehicles(vehicles : Vehicle[]) {
|
||||
return this.http.post<Vehicle[]>(`${this.vehicleURL}/update`, vehicles);
|
||||
}
|
||||
}
|
||||
|
||||
export interface LoadVehicleOptions {
|
||||
|
||||
@ -1,91 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { Action } from '@ngrx/store';
|
||||
import { Effect, Actions, ofType } from '@ngrx/effects';
|
||||
import { Observable } from 'rxjs';
|
||||
import { tap } from 'rxjs/operators';
|
||||
import * as subActions from '@app/actions/subscription.actions';
|
||||
import { SUB } from '../profile/common';
|
||||
import { AC } from '@app/shared/global';
|
||||
|
||||
@Injectable()
|
||||
export class RoutingEffects {
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private actions$: Actions
|
||||
) { }
|
||||
|
||||
@Effect({ dispatch: false })
|
||||
gotoMyservices$: Observable<Action> = this.actions$.pipe(
|
||||
ofType<subActions.GotoMyServices>(subActions.GOTO_MY_SERVICES),
|
||||
tap(() => this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]).then(() => window.location.reload()))
|
||||
);
|
||||
|
||||
@Effect({ dispatch: false })
|
||||
gotoServices$: Observable<Action> = this.actions$.pipe(
|
||||
ofType<subActions.GotoServices>(subActions.GOTO_SERVICES),
|
||||
tap(() => this.router.navigate([SUB.PROFILE, SUB.SERVICES]))
|
||||
);
|
||||
|
||||
@Effect({ dispatch: false })
|
||||
gotPaymentHistory$: Observable<Action> = this.actions$.pipe(
|
||||
ofType<subActions.GotoPaymentHistory>(subActions.GOTO_PAYMENT_HISTORY),
|
||||
tap(() => this.router.navigate([SUB.PROFILE, SUB.PM_HISTORY]))
|
||||
);
|
||||
|
||||
@Effect({ dispatch: false })
|
||||
gotPaymentDetail$: Observable<Action> = this.actions$.pipe(
|
||||
ofType<subActions.GotoPaymentDetail>(subActions.GOTO_PAYMENT_DETAIL),
|
||||
tap((action: subActions.GotoPaymentDetail) => this.router.navigate([SUB.PROFILE, SUB.PM_DETAIL, action.payload.paymentId]))
|
||||
);
|
||||
|
||||
@Effect({ dispatch: false })
|
||||
gotoUnpaidSub$: Observable<Action> = this.actions$.pipe(
|
||||
ofType<subActions.ShowUnpaidSubscription>(subActions.SHOW_UNPAID_SUBSCRIPTION),
|
||||
tap(() => this.router.navigate([SUB.PROFILE, SUB.UNPAID_SUB]))
|
||||
);
|
||||
|
||||
@Effect({ dispatch: false })
|
||||
gotoBillingAddr$: Observable<Action> = this.actions$.pipe(
|
||||
ofType<subActions.StartBillingInfoSuccess | subActions.GotoBillingAddress>(subActions.START_BILLING_INFO_SUCCESS, subActions.GOTO_BILLING_ADDRESS),
|
||||
tap(() => this.router.navigate([SUB.PROFILE, SUB.BILL_ADR]))
|
||||
);
|
||||
|
||||
@Effect({ dispatch: false })
|
||||
gotoCheckout$: Observable<Action> = this.actions$.pipe(
|
||||
ofType<subActions.UpdateBillingAddressSuccess | subActions.GotoCheckout | subActions.StartCheckoutSuccess>(subActions.UPDATE_BILLING_ADDRESS_SUCCESS, subActions.GOTO_CHECK_OUT, subActions.START_CHECKOUT_SUCCESS),
|
||||
tap(() => this.router.navigate([SUB.PROFILE, SUB.CHKOUT]))
|
||||
);
|
||||
|
||||
@Effect({ dispatch: false })
|
||||
gotoCheckoutReview$: Observable<Action> = this.actions$.pipe(
|
||||
ofType<subActions.Checkout | subActions.ResolvePayment | subActions.GotoCheckoutReview>(subActions.CHECK_OUT, subActions.RESOLVE_PAYMENT, subActions.GOTO_CHECK_OUT_REVIEW),
|
||||
tap(() => this.router.navigate([SUB.PROFILE, SUB.CHKOUT_REV]))
|
||||
);
|
||||
|
||||
@Effect({ dispatch: false })
|
||||
gotoCheckoutConfirm$: Observable<Action> = this.actions$.pipe(
|
||||
ofType<subActions.GotoCheckoutConfirm | subActions.PayUnpaidSubscriptionSuccess | subActions.ConfirmActionSuccess | subActions.ConfirmPaymentSuccess | subActions.CheckoutTrialSuccess>(subActions.GOTO_CHECK_OUT_CONFIRM, subActions.PAY_UNPAID_SUBSCRIPTION_SUCCESS, subActions.CONFIRM_ACTION_SUCCESS, subActions.CONFIRM_PAYMENT_SUCCESS,
|
||||
subActions.CHECK_OUT_TRIAL_SUCCESS),
|
||||
tap(() => this.router.navigate([SUB.PROFILE, SUB.CHKOUT_CONF]))
|
||||
);
|
||||
|
||||
@Effect({ dispatch: false })
|
||||
gotoHome$: Observable<Action> = this.actions$.pipe(
|
||||
ofType<subActions.GotoHome>(subActions.GOTO_HOME),
|
||||
tap(() => this.router.navigate(['/', SUB.HOME]))
|
||||
);
|
||||
|
||||
@Effect({ dispatch: false })
|
||||
gotoUsageDetail$: Observable<Action> = this.actions$.pipe(
|
||||
ofType<subActions.GotoUsageDetail>(subActions.GOTO_USAGE_DETAIL),
|
||||
tap(() => this.router.navigate([SUB.PROFILE, SUB.USAGE_DETAIL]))
|
||||
);
|
||||
|
||||
@Effect({ dispatch: false })
|
||||
gotoAircraftList$: Observable<Action> = this.actions$.pipe(
|
||||
ofType<subActions.GotoAircraftList>(subActions.GOTO_AIRCRAFT_LIST),
|
||||
tap(() => this.router.navigate(['entities', AC]))
|
||||
);
|
||||
}
|
||||
@ -1,282 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Action } from '@ngrx/store';
|
||||
import { Actions, Effect, ofType } from '@ngrx/effects';
|
||||
import { SubscriptionService } from '@app/domain/services/subscription.service';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import * as subPlansActions from '@app/actions/sub-plans.actions'
|
||||
import { catchError, delay, exhaustMap, filter, repeat, retryWhen, switchMap, take } from 'rxjs/operators';
|
||||
import { subPlans, SubAppErr, handleErr, SubKeys, TRACKING, PACKAGE_ACTIVE, createSubStatus, SUB, SubType, DELAY, TAKE, EMPTY } from '../profile/common';
|
||||
import { AuthService } from '@app/domain/services/auth.service';
|
||||
import { StripeSubscription, Usage } from '@app/domain/models/subscription.model';
|
||||
import { AppMessageService } from '@app/shared/app-message.service';
|
||||
import { globals } from '@app/shared/global';
|
||||
import { VehicleService } from '@app/domain/services/vehicle.service';
|
||||
import { FetchLatestSubscriptionSuccess, GotoAircraftList, UpdateSubscriptionStatus } from '@app/actions/subscription.actions';
|
||||
import { Vehicle } from '@app/entities/models/vehicle.model';
|
||||
import { CustomerService } from '@app/domain/services/customer.service';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
@Injectable()
|
||||
export class SubPlansEffects {
|
||||
|
||||
constructor(
|
||||
private readonly actions$: Actions,
|
||||
private readonly subSvc: SubscriptionService,
|
||||
private readonly authSvc: AuthService,
|
||||
private readonly msgSvc: AppMessageService,
|
||||
private readonly vehSvc: VehicleService,
|
||||
private readonly custSvc: CustomerService,
|
||||
private readonly router: Router
|
||||
) { }
|
||||
|
||||
@Effect()
|
||||
refreshSubPlans$: Observable<Action> = this.actions$.pipe(
|
||||
ofType<subPlansActions.FetchSubPlans>(subPlansActions.FETCH_SUB_PLANS),
|
||||
exhaustMap((action: subPlansActions.FetchSubPlans) => {
|
||||
let usage: Usage;
|
||||
let subscriptions: StripeSubscription[];
|
||||
let vehicles: Vehicle[];
|
||||
let sortedPrices: any[];
|
||||
return this.subSvc.getPrices().pipe(
|
||||
filter(prices => prices?.length > 0),
|
||||
switchMap(prices => {
|
||||
sortedPrices = [...prices].sort((a, b) => a.level - b.level);
|
||||
|
||||
let lastEffectiveMax = 0;
|
||||
const userSubscriptions = this.authSvc.user?.membership?.subscriptions;
|
||||
const userCustomLimits = this.authSvc.user?.membership?.customLimits;
|
||||
|
||||
// Use centralized utility to get current lookup key
|
||||
const currentLookupKey = this.subSvc.getCurrentPackageLookupKey(userSubscriptions);
|
||||
|
||||
// Get latest package subscription for custom limits checks
|
||||
const latestPackageSub = this.subSvc.getLatestSubscription(userSubscriptions, SubType.PACKAGE);
|
||||
|
||||
sortedPrices.forEach((price, indx) => {
|
||||
if (price) {
|
||||
const plan = subPlans[price.lookupKey] || {};
|
||||
|
||||
const isCurrentSubscription = price.lookupKey === currentLookupKey;
|
||||
|
||||
// Use centralized utility to check for custom limits
|
||||
const hasCustomLimit = isCurrentSubscription &&
|
||||
latestPackageSub &&
|
||||
this.subSvc.hasCustomLimits(latestPackageSub, userCustomLimits);
|
||||
|
||||
// Use centralized utility to get effective vehicle limit
|
||||
// For current subscription: Use API + custom limits
|
||||
// For other packages: Use API data from price.maxVehicles
|
||||
const effectiveMaxVehicles = isCurrentSubscription && latestPackageSub
|
||||
? this.subSvc.getEffectiveVehicleLimit(latestPackageSub, userCustomLimits)
|
||||
: price.maxVehicles;
|
||||
|
||||
// Use centralized utility to get effective acres limit (SAME PATTERN AS VEHICLES)
|
||||
// Treat empty string as null for proper "Unlimited" display
|
||||
const effectiveMaxAcres = isCurrentSubscription && latestPackageSub
|
||||
? this.subSvc.getEffectiveAcresLimit(latestPackageSub, userCustomLimits)
|
||||
: null;
|
||||
|
||||
plan.price = price.priceUSD || plan.price;
|
||||
plan.desc = plan.desc?.replace('#price#', this.subSvc.formatCurrency(price.priceUSD)) || plan.desc;
|
||||
// Current subscription: use effectiveMaxVehicles (respects custom limits).
|
||||
// All other plans: use price.maxVehicles from Stripe API.
|
||||
if (isCurrentSubscription) {
|
||||
if (effectiveMaxVehicles != null) {
|
||||
plan.maxVehicles = effectiveMaxVehicles;
|
||||
}
|
||||
} else if (price.maxVehicles !== null && price.maxVehicles !== undefined) {
|
||||
plan.maxVehicles = Math.abs(price.maxVehicles);
|
||||
}
|
||||
|
||||
// Apply effective acres limit
|
||||
// For current subscription: Use MongoDB session data (effectiveMaxAcres) even if null
|
||||
// For other packages: Use Stripe API data (price.maxAcres)
|
||||
// IMPORTANT: Only update if we have a valid value to prevent race condition
|
||||
if (isCurrentSubscription) {
|
||||
if (effectiveMaxAcres !== null && effectiveMaxAcres !== undefined) {
|
||||
plan.maxAcres = Number(effectiveMaxAcres);
|
||||
}
|
||||
// Don't set to null - preserve existing value to avoid race condition
|
||||
} else {
|
||||
if (price.maxAcres !== null && price.maxAcres !== undefined) {
|
||||
plan.maxAcres = Number(price.maxAcres);
|
||||
}
|
||||
// Don't set to null - preserve existing value to avoid race condition
|
||||
}
|
||||
plan.level = price.level || plan.level;
|
||||
plan.type = price.type || plan.type;
|
||||
|
||||
if (effectiveMaxVehicles) {
|
||||
if (hasCustomLimit && isCurrentSubscription) {
|
||||
plan.Vehicles = `1-${effectiveMaxVehicles}`;
|
||||
lastEffectiveMax = price.maxVehicles;
|
||||
} else {
|
||||
plan.Vehicles = this.subSvc.toVehRange(lastEffectiveMax, effectiveMaxVehicles);
|
||||
lastEffectiveMax = effectiveMaxVehicles;
|
||||
}
|
||||
} else {
|
||||
lastEffectiveMax = price.maxVehicles || effectiveMaxVehicles || 0;
|
||||
}
|
||||
|
||||
subPlans[price.lookupKey] = plan;
|
||||
}
|
||||
});
|
||||
|
||||
const byPuid = this.authSvc.user?.parent || this.authSvc.user?._id;
|
||||
return this.subSvc.retrieveCurrUsage(this.authSvc.user?.membership?.custId, byPuid);
|
||||
}),
|
||||
switchMap(_usage => {
|
||||
usage = _usage;
|
||||
return this.subSvc.fetchSubscriptions(this.authSvc.user?.membership?.custId);
|
||||
}),
|
||||
switchMap(_subs => {
|
||||
subscriptions = _subs;
|
||||
return this.vehSvc.loadVehicles({ byUserId: this.authSvc.user?.parent }).pipe(
|
||||
catchError(() => of([]))
|
||||
);
|
||||
}),
|
||||
switchMap(_vehicles => {
|
||||
vehicles = _vehicles;
|
||||
const id = this.authSvc.user?.parent || this.authSvc.user._id;
|
||||
return this.custSvc.getCustomer(id);
|
||||
}),
|
||||
switchMap(cust => {
|
||||
const curSubPlan = this.subSvc.createSubPlan(subscriptions, cust?.membership, usage);
|
||||
const getNumVehs = (type: string) => vehicles?.filter((veh) => veh[type] === true).length || 0;
|
||||
const trkVehicles = getNumVehs(TRACKING);
|
||||
const pkgActiveVehicles = getNumVehs(PACKAGE_ACTIVE);
|
||||
const needReview = cust?.needReview;
|
||||
|
||||
if (subscriptions?.length === 0) {
|
||||
const actions: Action[] = [
|
||||
new subPlansActions.ResetSubPlans(),
|
||||
new subPlansActions.FetchSubPlansSuccess(curSubPlan)
|
||||
];
|
||||
|
||||
if (cust?.membership) {
|
||||
// Transform membership to preserve trial_end and promoDetails (Case 2C fix)
|
||||
actions.push(new FetchLatestSubscriptionSuccess({
|
||||
subscriptions,
|
||||
membership: this.subSvc.updateMembShip(subscriptions, cust?.membership)
|
||||
}));
|
||||
}
|
||||
|
||||
const currentUrl = this.router.url;
|
||||
const isHome = currentUrl === '/' || currentUrl.includes(`/${SUB.HOME}`);
|
||||
const isProfileRoute = currentUrl.includes(`/${SUB.PROFILE}`);
|
||||
|
||||
if (!isHome && !isProfileRoute) {
|
||||
this.router.navigate([`/${SUB.PROFILE}/${SUB.SERVICES}`]);
|
||||
}
|
||||
|
||||
return of(...actions);
|
||||
}
|
||||
|
||||
// Use centralized utility to get current lookup key from latest subscription
|
||||
const freshSubscriptions = cust?.membership?.subscriptions;
|
||||
const freshCurrentLookupKey = this.subSvc.getCurrentPackageLookupKey(freshSubscriptions) ||
|
||||
this.authSvc.getCurLookupKey(SubType.PACKAGE);
|
||||
const staleCurrentLookupKey = this.authSvc.getCurLookupKey(SubType.PACKAGE);
|
||||
|
||||
// Always update the current plan's maxVehicles with fresh customer data
|
||||
// (first block used stale authSvc cache — customLimits may not have been loaded yet)
|
||||
const freshLatestPackageSub = this.subSvc.getLatestSubscription(freshSubscriptions, SubType.PACKAGE);
|
||||
const freshUserCustomLimits = cust?.membership?.customLimits;
|
||||
if (freshCurrentLookupKey && freshLatestPackageSub && subPlans[freshCurrentLookupKey]) {
|
||||
const freshEffective = this.subSvc.getEffectiveVehicleLimit(freshLatestPackageSub, freshUserCustomLimits);
|
||||
if (freshEffective != null) {
|
||||
subPlans[freshCurrentLookupKey].maxVehicles = freshEffective;
|
||||
}
|
||||
}
|
||||
|
||||
if (freshCurrentLookupKey && freshCurrentLookupKey !== staleCurrentLookupKey) {
|
||||
let lastEffectiveMax = 0;
|
||||
const userCustomLimits = cust?.membership?.customLimits;
|
||||
|
||||
// Get latest package subscription for custom limits checks
|
||||
const latestPackageSub = this.subSvc.getLatestSubscription(freshSubscriptions, SubType.PACKAGE);
|
||||
|
||||
sortedPrices.forEach((price, indx) => {
|
||||
if (price) {
|
||||
const plan = subPlans[price.lookupKey];
|
||||
if (plan) {
|
||||
|
||||
const isCurrentSubscription = price.lookupKey === freshCurrentLookupKey;
|
||||
|
||||
// Use centralized utility to check for custom limits
|
||||
const hasCustomLimit = isCurrentSubscription &&
|
||||
latestPackageSub &&
|
||||
this.subSvc.hasCustomLimits(latestPackageSub, userCustomLimits);
|
||||
|
||||
// Use centralized utility to get effective vehicle limit
|
||||
const effectiveMaxVehicles = isCurrentSubscription && latestPackageSub
|
||||
? this.subSvc.getEffectiveVehicleLimit(latestPackageSub, userCustomLimits)
|
||||
: price.maxVehicles;
|
||||
|
||||
// Current subscription: use effectiveMaxVehicles (respects custom limits).
|
||||
// All other plans: use price.maxVehicles from Stripe API.
|
||||
if (isCurrentSubscription) {
|
||||
if (effectiveMaxVehicles != null) {
|
||||
plan.maxVehicles = effectiveMaxVehicles;
|
||||
}
|
||||
} else if (price.maxVehicles !== null && price.maxVehicles !== undefined) {
|
||||
plan.maxVehicles = Math.abs(price.maxVehicles);
|
||||
}
|
||||
|
||||
if (effectiveMaxVehicles) {
|
||||
if (hasCustomLimit && isCurrentSubscription) {
|
||||
plan.Vehicles = `1-${effectiveMaxVehicles}`;
|
||||
lastEffectiveMax = price.maxVehicles;
|
||||
} else {
|
||||
plan.Vehicles = this.subSvc.toVehRange(lastEffectiveMax, effectiveMaxVehicles);
|
||||
lastEffectiveMax = effectiveMaxVehicles;
|
||||
}
|
||||
} else {
|
||||
lastEffectiveMax = price.maxVehicles || effectiveMaxVehicles || 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const isCurTrkVehAboveLimit = trkVehicles > curSubPlan?.addon?.[SubKeys.TRACKING]?.airCraft?.numOfVehicle;
|
||||
const isCurActiveVehAboveLimit = pkgActiveVehicles > curSubPlan?.package?.[this.authSvc.getCurLookupKey(SubType.PACKAGE)]?.airCraft?.numOfVehicle;
|
||||
|
||||
const actions: Action[] = [
|
||||
new subPlansActions.FetchSubPlansSuccess(curSubPlan)
|
||||
];
|
||||
|
||||
if (cust?.membership) {
|
||||
// Transform membership to preserve trial_end and promoDetails (Case 2C fix)
|
||||
actions.unshift(new FetchLatestSubscriptionSuccess({
|
||||
subscriptions,
|
||||
membership: this.subSvc.updateMembShip(subscriptions, cust?.membership)
|
||||
}));
|
||||
}
|
||||
|
||||
if (isCurActiveVehAboveLimit || isCurTrkVehAboveLimit || needReview) {
|
||||
actions.push(
|
||||
new UpdateSubscriptionStatus(createSubStatus(SUB.AC_REVIEW)),
|
||||
new GotoAircraftList()
|
||||
);
|
||||
}
|
||||
|
||||
return of(...actions);
|
||||
})
|
||||
)
|
||||
}),
|
||||
retryWhen(errors => errors.pipe(
|
||||
delay(DELAY),
|
||||
take(TAKE)
|
||||
)),
|
||||
catchError(err => {
|
||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.load).replace('#thing#', globals.subPlans));
|
||||
return handleErr<Observable<Action>>({
|
||||
error: err, opt: {
|
||||
extra: SubAppErr.FETCH_SUB_PLANS_ERR
|
||||
}
|
||||
});
|
||||
}),
|
||||
repeat()
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,5 @@
|
||||
import { Action } from "@ngrx/store";
|
||||
import { StatusChange, Vehicle } from "../models/vehicle.model";
|
||||
import { Vehicle } from "../models/vehicle.model";
|
||||
|
||||
export const FETCH = '[VEHICLES] Fetch vehilces';
|
||||
export class Fetch implements Action {
|
||||
@ -38,6 +38,7 @@ export class CreateFailed implements Action {
|
||||
export const UPDATE = '[VEHICLES] Update a vehilce';
|
||||
export class Update implements Action {
|
||||
type: typeof UPDATE = UPDATE;
|
||||
|
||||
constructor(readonly payload: Vehicle) { }
|
||||
}
|
||||
export const UPDATE_SUCCESS = '[VEHICLES] Update vehilce success';
|
||||
@ -48,7 +49,7 @@ export class UpdateSuccess implements Action {
|
||||
}
|
||||
export const UPDATE_FAILED = '[VEHICLES] Update vehilce failed';
|
||||
export class UpdateFailed implements Action {
|
||||
type: typeof UPDATE_FAILED = UPDATE_FAILED;
|
||||
type: typeof UPDATE_FAILED = UPDATE_FAILED;
|
||||
}
|
||||
|
||||
export const DELETE = '[VEHICLES] Delete a vehilce';
|
||||
@ -71,35 +72,13 @@ export class DeleteError implements Action {
|
||||
export const SELECT = '[PILOTS] Select a pilot';
|
||||
export class Select implements Action {
|
||||
type: typeof SELECT = SELECT;
|
||||
|
||||
constructor(readonly payload: Vehicle) { }
|
||||
}
|
||||
|
||||
interface UpdateVehiclesPayload {
|
||||
vehicles: Vehicle[];
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export const UPDATE_VEHICLES = '[VEHICLES] Update a vehicles';
|
||||
export class UpdateVehicles implements Action {
|
||||
type: typeof UPDATE_VEHICLES = UPDATE_VEHICLES;
|
||||
constructor(readonly payload: UpdateVehiclesPayload) { }
|
||||
}
|
||||
export const UPDATE_VEHICLES_SUCCESS = '[VEHICLES] Update a vehicles success';
|
||||
export class UpdateVehiclesSuccess implements Action {
|
||||
type: typeof UPDATE_VEHICLES_SUCCESS = UPDATE_VEHICLES_SUCCESS;
|
||||
|
||||
constructor(readonly payload: UpdateVehiclesPayload) { }
|
||||
}
|
||||
export const UPDATE_VEHICLES_FAILED = '[VEHICLES] Update a vehicles failed';
|
||||
export class UpdateVehiclesFailed implements Action {
|
||||
type: typeof UPDATE_VEHICLES_FAILED = UPDATE_VEHICLES_FAILED;
|
||||
}
|
||||
|
||||
export type All =
|
||||
| Fetch | FetchSuccess | FetchError
|
||||
| Create | CreateSuccess | CreateFailed
|
||||
| Update | UpdateSuccess | UpdateFailed
|
||||
| Delete | DeleteSuccess | DeleteError
|
||||
| Select
|
||||
| UpdateVehicles | UpdateVehiclesSuccess | UpdateVehiclesFailed;
|
||||
|
||||
|
||||
@ -23,11 +23,10 @@
|
||||
<ng-template pTemplate="body" let-rowData let-columns="columns">
|
||||
<tr [pSelectableRow]="rowData">
|
||||
<td *ngFor="let col of columns" [ngSwitch]="col.field">
|
||||
<span class="ui-column-title">{{col.header}}</span>
|
||||
<span *ngSwitchCase="'color'">
|
||||
<div *ngSwitchCase="'color'">
|
||||
<div class="color-box" [ngStyle]="{ 'background-color': rowData[col.field] }"></div>
|
||||
<span style="vertical-align:middle; margin-left: .5em">{{ GC.colors[rowData[col.field]] }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<span *ngSwitchDefault>{{ rowData[col.field] }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@ -2,7 +2,9 @@ import { Injectable } from '@angular/core';
|
||||
import { Actions, Effect, ofType } from '@ngrx/effects';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { map, switchMap, catchError } from 'rxjs/operators';
|
||||
|
||||
import { Action } from '@ngrx/store';
|
||||
|
||||
import * as vehicleActions from '../actions/vehicle.actions';
|
||||
import { AuthService } from '@app/domain/services/auth.service';
|
||||
import { VehicleService } from '@app/domain/services/vehicle.service';
|
||||
@ -40,14 +42,7 @@ export class VehicleEffects {
|
||||
this.vehilceSvc.saveVehicle(payload).pipe(
|
||||
map((aircraft) => new vehicleActions.CreateSuccess(aircraft)),
|
||||
catchError(err => {
|
||||
let msg;
|
||||
const subErrs = ['reached_vehicles_limit', 'reached_area_limit', 'subscription_not_found', 'pkg_subscription_not_found'];
|
||||
if (subErrs.some((subErr) => subErr === err['error']['error']['.tag'])) {
|
||||
msg = globals.apiErrorMsg(err['error']['error']['.tag'] || '')
|
||||
} else {
|
||||
msg = globals.doThingsFailed.replace('#do#', globals.save).replace('#thing#', globals.aircraft)
|
||||
}
|
||||
this.msgSvc.addFailedMsg(msg);
|
||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.save).replace('#thing#', globals.aircraft));
|
||||
return of(new vehicleActions.CreateFailed())
|
||||
})
|
||||
)
|
||||
@ -57,19 +52,15 @@ export class VehicleEffects {
|
||||
@Effect()
|
||||
updateVehicle$: Observable<Action> = this.actions$.pipe(
|
||||
ofType<vehicleActions.Update>(vehicleActions.UPDATE),
|
||||
switchMap(({ payload }) => {
|
||||
if (!payload.active) {
|
||||
payload.pkgActive = false;
|
||||
payload.tracking = false;
|
||||
}
|
||||
return this.vehilceSvc.updateVehicles([payload]).pipe(
|
||||
switchMap(({ payload }) =>
|
||||
this.vehilceSvc.saveVehicle(payload).pipe(
|
||||
map(() => new vehicleActions.UpdateSuccess(payload)),
|
||||
catchError(err => {
|
||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.save).replace('#thing#', globals.aircraft));
|
||||
return of(new vehicleActions.UpdateFailed());
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
@Effect()
|
||||
@ -88,18 +79,4 @@ export class VehicleEffects {
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
@Effect()
|
||||
updateVehicles$: Observable<Action> = this.actions$.pipe(
|
||||
ofType<vehicleActions.UpdateVehicles>(vehicleActions.UPDATE_VEHICLES),
|
||||
switchMap(({ payload }) =>
|
||||
this.vehilceSvc.updateVehicles(payload.vehicles).pipe(
|
||||
map(() => new vehicleActions.UpdateVehiclesSuccess(payload)),
|
||||
catchError(err => {
|
||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.save).replace('#thing#', globals.aircraft));
|
||||
return of(new vehicleActions.UpdateVehiclesFailed());
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ import { Routes, RouterModule } from '@angular/router';
|
||||
|
||||
import { AuthGuard } from '../domain/guards/auth.guard';
|
||||
|
||||
import { AC, RoleIds } from '../shared/global';
|
||||
import { RoleIds } from '../shared/global';
|
||||
import { ProductListComponent } from './product/product-list/product-list.component';
|
||||
import { PilotListComponent } from './pilot/pilot-list/pilot-list.component';
|
||||
import { EntitiesMgtComponent } from './entities-mgt.component';
|
||||
@ -14,8 +14,6 @@ import { VehicleEditComponent } from './vehicle/vehicle-edit/vehicle-edit.compon
|
||||
import { VehicleResolver } from './vehicle-resolver.service';
|
||||
import { CropListComponent } from './crop/crop-list/crop-list.component';
|
||||
import { CropsLoadGuard } from '../domain/guards/crops-load.guard';
|
||||
import { SubscriptionGuard } from '@app/domain/guards/subscription.guard';
|
||||
import { UserResolver } from '@app/domain/resolvers/user-resolver';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
@ -55,17 +53,13 @@ const routes: Routes = [
|
||||
]
|
||||
},
|
||||
{
|
||||
path: AC,
|
||||
canActivate: [SubscriptionGuard],
|
||||
path: 'aircraft',
|
||||
data: {
|
||||
roles: [RoleIds.APP, RoleIds.APP_ADM, RoleIds.OFFICER, RoleIds.INSPECTOR, RoleIds.PILOT, RoleIds.CLIENT]
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '', component: VehicleListComponent,
|
||||
resolve: {
|
||||
user: UserResolver
|
||||
}
|
||||
},
|
||||
{
|
||||
path: ':id', component: VehicleEditComponent,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,94 +1,18 @@
|
||||
import { createNewUser, User } from '@app/accounts/models/user.model';
|
||||
import { RoleIds, SourceSystemType, OperationalStatusType, SystemOrPartnerType } from '@app/shared/global';
|
||||
import { RoleIds } from '@app/shared/global';
|
||||
|
||||
export interface Vehicle extends User {
|
||||
vehicleType: number;
|
||||
tailNumber?: string; // Common tail number field for all aircraft
|
||||
unitId?: string;
|
||||
orgUnitId?: string; // used for unique validation at clientSide only
|
||||
model?: string;
|
||||
desc?: string;
|
||||
color?: string;
|
||||
tracking?: boolean;
|
||||
trackonDate?: Date;
|
||||
pkgActive?: boolean;
|
||||
pkgActiveDate?: Date;
|
||||
|
||||
// Partner integration properties (legacy - for frontend compatibility)
|
||||
partnerSystem?: SourceSystemType; // System identifier
|
||||
partnerAircraftId?: string; // Partner system's aircraft ID
|
||||
partnerAircraftData?: PartnerAircraftData;
|
||||
|
||||
// Backend-compatible partner info structure (matches backend schema)
|
||||
partnerInfo?: {
|
||||
partner?: string; // Partner ObjectId reference
|
||||
partnerAircraftId?: string; // Partner aircraft/vehicle ID in external system
|
||||
systemType?: string; // System type for agnav native systems (platinum, titanium, g4, etc.)
|
||||
// NEW: Direct partner identification fields (from assignments_post response)
|
||||
name?: string; // Partner display name (e.g., "satloc")
|
||||
partnerCode?: string; // Partner code identifier (e.g., "SATLOC") - top-level for assignments_post
|
||||
metadata?: {
|
||||
partnerSystem?: string; // Partner system name
|
||||
partnerCode?: string; // Partner code identifier (legacy nested location)
|
||||
aircraftData?: any; // Aircraft data from partner system
|
||||
syncStatus?: OperationalStatusType;
|
||||
lastSync?: string | null; // ISO date string
|
||||
connectionStatus?: OperationalStatusType;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Aircraft Assignment Item interface for job assignment pickList
|
||||
export interface AircraftAssignmentItem {
|
||||
_id: string;
|
||||
name: string;
|
||||
username?: string; // Aircraft account username for AgNav aircraft
|
||||
active: boolean;
|
||||
pkgActive?: boolean;
|
||||
tailNumber?: string;
|
||||
// Partner system information derived from partnerInfo
|
||||
partnerSystem?: SystemOrPartnerType;
|
||||
sourceSystem?: SystemOrPartnerType; // System identifier for UI display and sorting
|
||||
// Partner information
|
||||
partnerId?: string; // Partner ID from partnerInfo.partner
|
||||
partnerName?: string; // Partner name resolved from partner service
|
||||
// Partner authentication validation state
|
||||
authValidation?: {
|
||||
isValidating: boolean;
|
||||
authenticationValid: boolean;
|
||||
accountExists: boolean;
|
||||
validationError: string | null;
|
||||
canMoveToTarget: boolean;
|
||||
};
|
||||
partnerCode?: string; // Partner code for display
|
||||
satlocData?: {
|
||||
satlocId?: string;
|
||||
tailNumber: string;
|
||||
aircraftType?: string;
|
||||
lastSync?: Date;
|
||||
syncStatus: OperationalStatusType;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PartnerAircraftData {
|
||||
id: string;
|
||||
tailNumber: string;
|
||||
partnerSystem: string;
|
||||
syncStatus?: OperationalStatusType;
|
||||
lastSync?: Date;
|
||||
connectionStatus?: OperationalStatusType;
|
||||
}
|
||||
|
||||
export interface StatusChange {
|
||||
ids: { [i: string]: string[] };
|
||||
type: string;
|
||||
deActivate?: { [i: string]: boolean };
|
||||
}
|
||||
|
||||
export const createNewVehicle = (parentId: string) => {
|
||||
const vehicle = <Vehicle>createNewUser(parentId, RoleIds.DEVICE);
|
||||
vehicle.vehicleType = 0;
|
||||
vehicle.tailNumber = '';
|
||||
|
||||
return vehicle;
|
||||
}
|
||||
|
||||
@ -24,7 +24,6 @@
|
||||
<ng-template pTemplate="body" let-rowData let-columns="columns">
|
||||
<tr [pSelectableRow]="rowData">
|
||||
<td *ngFor="let col of columns">
|
||||
<span class="ui-column-title">{{col.header}}</span>
|
||||
{{ resolveFieldData(rowData, col.field) }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { PilotListComponent } from './pilot-list.component';
|
||||
|
||||
describe('PilotListComponent', () => {
|
||||
let component: PilotListComponent;
|
||||
let fixture: ComponentFixture<PilotListComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ PilotListComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(PilotListComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user