first commit (copy of Trunk as of April 22 2026)
This commit is contained in:
parent
0518ade5ae
commit
0836fc0fbc
31
Development/agmission-pm2.json
Normal file
31
Development/agmission-pm2.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"apps" : [{
|
||||
"name" : "agmission-prod",
|
||||
"script" : "server.js",
|
||||
"args" : ["--max-old-space-size 8192"],
|
||||
"watch" : false,
|
||||
"ignore_watch": ["[\/\\]\\./", "node_modules", ".tmp", "job-unzip", "job-uploads", "backup"],
|
||||
"node_args" : [],
|
||||
"merge_logs" : true,
|
||||
"cwd" : "/home/trung/PROD/agmission/",
|
||||
"env": {
|
||||
"NODE_ENV": "production",
|
||||
"AGM_PORT": "7000"
|
||||
},
|
||||
"log_date_format": "YYYY-MM-DD HH:mm:ss"
|
||||
},
|
||||
{
|
||||
"name" : "jsreport",
|
||||
"script" : "server.js",
|
||||
"args" : ["--max-old-space-size 2048"],
|
||||
"watch" : false,
|
||||
"exec_mode" : "fork",
|
||||
"instances" : 1,
|
||||
"cwd" : "../myjsreport",
|
||||
"merge_logs" : true,
|
||||
"env": {
|
||||
"NODE_ENV": "production"
|
||||
},
|
||||
"log_date_format": ""
|
||||
}]
|
||||
}
|
||||
13
Development/client/.editorconfig
Normal file
13
Development/client/.editorconfig
Normal file
@ -0,0 +1,13 @@
|
||||
# Editor configuration, see http://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
6
Development/client/.env
Normal file
6
Development/client/.env
Normal file
@ -0,0 +1,6 @@
|
||||
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/.jshintrc
Normal file
3
Development/client/.jshintrc
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"esversion": 6
|
||||
}
|
||||
13
Development/client/.vscode/launch.json
vendored
Normal file
13
Development/client/.vscode/launch.json
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible Node.js debug attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [{
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"name": "Launch Chrome Debug",
|
||||
"url":"https://localhost:4200",
|
||||
"webRoot":"${workspaceFolder}"
|
||||
}]
|
||||
}
|
||||
11
Development/client/.vscode/settings.json
vendored
Normal file
11
Development/client/.vscode/settings.json
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"html.format.wrapLineLength": 0,
|
||||
"editor.wordWrap": "on",
|
||||
"editor.tabSize": 2,
|
||||
"formate.alignColon": true,
|
||||
"formate.verticalAlignProperties": true,
|
||||
"formate.enable": true,
|
||||
"formate.additionalSpaces": 0,
|
||||
"specstory.cloudSync.enabled": "never"
|
||||
}
|
||||
1038
Development/client/AgMission-BigQuery-Analytics-Mapping.md
Normal file
1038
Development/client/AgMission-BigQuery-Analytics-Mapping.md
Normal file
File diff suppressed because it is too large
Load Diff
221
Development/client/AgMission-GA4-Complete-Reference.csv
Normal file
221
Development/client/AgMission-GA4-Complete-Reference.csv
Normal file
@ -0,0 +1,221 @@
|
||||
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
|
||||
|
28
Development/client/README.md
Normal file
28
Development/client/README.md
Normal file
@ -0,0 +1,28 @@
|
||||
# PrimeCli
|
||||
|
||||
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 1.3.0.
|
||||
|
||||
## Development server
|
||||
|
||||
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files.
|
||||
|
||||
## Code scaffolding
|
||||
|
||||
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
|
||||
|
||||
## Build
|
||||
|
||||
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `-prod` flag for a production build.
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
|
||||
|
||||
## Running end-to-end tests
|
||||
|
||||
Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/).
|
||||
Before running the tests make sure you are serving the app via `ng serve`.
|
||||
|
||||
## Further help
|
||||
|
||||
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md).
|
||||
251
Development/client/angular.json
Normal file
251
Development/client/angular.json
Normal file
@ -0,0 +1,251 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"agmission-client": {
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"projectType": "application",
|
||||
"prefix": "agm",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "css"
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
"sourceLocale": {
|
||||
"code": "en",
|
||||
"baseHref": "/"
|
||||
},
|
||||
"locales": {
|
||||
"es": {
|
||||
"translation": "src/locale/messages.es.xlf"
|
||||
},
|
||||
"pt": {
|
||||
"translation": "src/locale/messages.pt.xlf"
|
||||
}
|
||||
}
|
||||
},
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:browser",
|
||||
"options": {
|
||||
"aot": true,
|
||||
"outputPath": "dist",
|
||||
"index": "src/index.html",
|
||||
"main": "src/main.ts",
|
||||
"polyfills": "src/polyfills.ts",
|
||||
"tsConfig": "src/tsconfig.app.json",
|
||||
"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/",
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "node_modules/leaflet/dist/images",
|
||||
"output": "/assets/images"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"node_modules/leaflet/dist/leaflet.css",
|
||||
"src/assets/js/leaflet-draw/leaflet.draw.css",
|
||||
"src/assets/js/leaflet-measure/leaflet-measure-path.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"
|
||||
],
|
||||
"scripts": [
|
||||
"node_modules/rbush/rbush.min.js",
|
||||
"src/assets/js/turf.min.js"
|
||||
]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"index": {
|
||||
"input": "src/index.prod.html",
|
||||
"output": "index.html"
|
||||
},
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.prod.ts"
|
||||
}
|
||||
],
|
||||
"aot": true,
|
||||
"outputPath": "dist",
|
||||
"optimization": true,
|
||||
"outputHashing": "all",
|
||||
"sourceMap": false,
|
||||
"extractCss": true,
|
||||
"namedChunks": false,
|
||||
"extractLicenses": true,
|
||||
"vendorChunk": false,
|
||||
"buildOptimizer": true,
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "6mb",
|
||||
"maximumError": "10mb"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "12kb",
|
||||
"maximumError": "18kb"
|
||||
}
|
||||
]
|
||||
},
|
||||
"pt": {
|
||||
"aot": true,
|
||||
"localize": [
|
||||
"pt"
|
||||
],
|
||||
"outputPath": "dist/agm-pt/",
|
||||
"i18nLocale": "pt",
|
||||
"i18nMissingTranslation": "error"
|
||||
},
|
||||
"es": {
|
||||
"aot": true,
|
||||
"localize": [
|
||||
"es"
|
||||
],
|
||||
"outputPath": "dist/agm-es/",
|
||||
"i18nLocale": "es",
|
||||
"i18nMissingTranslation": "error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"options": {
|
||||
"browserTarget": "agmission-client:build",
|
||||
"sslKey": "/home/trung/ssl/server.key",
|
||||
"sslCert": "/home/trung/ssl/server.crt"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"browserTarget": "agmission-client:build:production"
|
||||
},
|
||||
"pt": {
|
||||
"browserTarget": "agmission-client:build:pt",
|
||||
"sslKey": "/home/trung/ssl/server.key",
|
||||
"sslCert": "/home/trung/ssl/server.crt"
|
||||
},
|
||||
"es": {
|
||||
"browserTarget": "agmission-client:build:es",
|
||||
"sslKey": "/home/trung/ssl/server.key",
|
||||
"sslCert": "/home/trung/ssl/server.crt"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n",
|
||||
"options": {
|
||||
"browserTarget": "agmission-client:build"
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"options": {
|
||||
"main": "src/test.ts",
|
||||
"polyfills": "src/polyfills.ts",
|
||||
"tsConfig": "src/tsconfig.spec.json",
|
||||
"karmaConfig": "src/karma.conf.js",
|
||||
"scripts": [
|
||||
"node_modules/rbush/rbush.min.js",
|
||||
"src/assets/js/turf.min.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",
|
||||
"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/",
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "node_modules/leaflet/dist/images",
|
||||
"output": "/assets/images"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@angular-devkit/build-angular:tslint",
|
||||
"options": {
|
||||
"tsConfig": [
|
||||
"src/tsconfig.app.json",
|
||||
"src/tsconfig.spec.json"
|
||||
],
|
||||
"exclude": [
|
||||
"**/node_modules/**"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"agmission-client-e2e": {
|
||||
"root": "e2e/",
|
||||
"projectType": "application",
|
||||
"prefix": "",
|
||||
"architect": {
|
||||
"e2e": {
|
||||
"builder": "@angular-devkit/build-angular:protractor",
|
||||
"options": {
|
||||
"protractorConfig": "e2e/protractor.conf.js",
|
||||
"devServerTarget": "agmission-client:serve"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"devServerTarget": "agmission-client:serve:production"
|
||||
}
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@angular-devkit/build-angular:tslint",
|
||||
"options": {
|
||||
"tsConfig": "e2e/tsconfig.e2e.json",
|
||||
"exclude": [
|
||||
"**/node_modules/**"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultProject": "agmission-client",
|
||||
"cli": {
|
||||
"analytics": "a39ac155-50fa-4441-b491-60e55b83e6ff"
|
||||
}
|
||||
}
|
||||
11
Development/client/browserslist
Normal file
11
Development/client/browserslist
Normal file
@ -0,0 +1,11 @@
|
||||
# This file is currently used by autoprefixer to adjust CSS to support the below specified browsers
|
||||
# For additional information regarding the format and rule options, please see:
|
||||
# https://github.com/browserslist/browserslist#queries
|
||||
#
|
||||
# For IE 9-11 support, please remove 'not' from the last line of the file and adjust as needed
|
||||
|
||||
> 0.5%
|
||||
last 2 versions
|
||||
Firefox ESR
|
||||
not dead
|
||||
IE 11
|
||||
382
Development/client/docs/MANAGE_SERVICES_PROMO_DISPLAY.md
Normal file
382
Development/client/docs/MANAGE_SERVICES_PROMO_DISPLAY.md
Normal file
@ -0,0 +1,382 @@
|
||||
# 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.
|
||||
333
Development/client/docs/NOTIFICATION-DEEP-LINKS.md
Normal file
333
Development/client/docs/NOTIFICATION-DEEP-LINKS.md
Normal file
@ -0,0 +1,333 @@
|
||||
# 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.
|
||||
576
Development/client/docs/SUBSCRIPTION-DISPLAY.md
Normal file
576
Development/client/docs/SUBSCRIPTION-DISPLAY.md
Normal file
@ -0,0 +1,576 @@
|
||||
# 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 |
|
||||
113
Development/client/package.json
Normal file
113
Development/client/package.json
Normal file
@ -0,0 +1,113 @@
|
||||
{
|
||||
"name": "agmission-client",
|
||||
"version": "2.6.15",
|
||||
"license": "COMMERCIAL",
|
||||
"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-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",
|
||||
"build-prep": "ng build --aot --localize=false",
|
||||
"test": "ng test",
|
||||
"lint": "ng lint",
|
||||
"e2e": "ng e2e",
|
||||
"bundle-report": "webpack-bundle-analyzer dist/stats.json",
|
||||
"i18n-extract": "npx locl extract -f=xlf -s=dist/**/*.js -o=src/locale/messages.xlf",
|
||||
"i18n-extract-w": "npx locl extract -f=xlf -s=dist\\**\\*.js -o=src\\locale\\messages.xlf",
|
||||
"i18n-merge": "for lang in pt es; do xliffmerge --profile xliffmerge.json en $lang; done",
|
||||
"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"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "9.1.13",
|
||||
"@angular/cdk": "9.2.4",
|
||||
"@angular/common": "9.1.13",
|
||||
"@angular/compiler": "9.1.13",
|
||||
"@angular/core": "9.1.13",
|
||||
"@angular/forms": "9.1.13",
|
||||
"@angular/localize": "^9.1.13",
|
||||
"@angular/platform-browser": "9.1.13",
|
||||
"@angular/platform-browser-dynamic": "9.1.13",
|
||||
"@angular/platform-server": "9.1.13",
|
||||
"@angular/router": "9.1.13",
|
||||
"@asymmetrik/ngx-leaflet": "^7.0.1",
|
||||
"@fullcalendar/core": "^4.4.2",
|
||||
"@fullcalendar/daygrid": "^4.4.2",
|
||||
"@fullcalendar/interaction": "^4.4.2",
|
||||
"@fullcalendar/timegrid": "^4.4.2",
|
||||
"@ngrx/effects": "^9.2.0",
|
||||
"@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",
|
||||
"file-saver": "^1.3.8",
|
||||
"geodesy": "^1.1.3",
|
||||
"intl": "^1.2.5",
|
||||
"leaflet": "^1.9.4",
|
||||
"ngrx-store-localstorage": "^9.0.0",
|
||||
"ngx-captcha": "^8.0.1",
|
||||
"primeng-lts": "^9.2.8",
|
||||
"quill": "^1.3.7",
|
||||
"rbush": "^3.0.1",
|
||||
"rxjs": "^6.5.5",
|
||||
"tslib": "^1.14.1",
|
||||
"zone.js": "~0.10.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "0.901.15",
|
||||
"@angular/cli": "9.1.13",
|
||||
"@angular/compiler-cli": "9.1.13",
|
||||
"@angular/language-service": "9.1.13",
|
||||
"@locl/cli": "^1.0.0",
|
||||
"@types/esri-leaflet": "2.1.9",
|
||||
"@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-draw": "^0.4.14",
|
||||
"@types/node": "12.12.29",
|
||||
"ajv": "6.12.2",
|
||||
"codelyzer": "5.2.1",
|
||||
"jasmine-core": "4.6.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-jasmine": "2.0.1",
|
||||
"karma-jasmine-html-reporter": "1.5.2",
|
||||
"ngx-i18nsupport": "^0.17.1",
|
||||
"protractor": "5.4.3",
|
||||
"rxjs-tslint": "0.1.8",
|
||||
"ts-node": "8.3.0",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
57
Development/client/proxy.config.json
Normal file
57
Development/client/proxy.config.json
Normal file
@ -0,0 +1,57 @@
|
||||
{
|
||||
"/api/*": {
|
||||
"target": "https://127.0.0.1:4100",
|
||||
"secure": false,
|
||||
"changeOrigin": false,
|
||||
"logLevel": "debug"
|
||||
},
|
||||
"/uploads/*": {
|
||||
"target": "https://127.0.0.1:4100",
|
||||
"secure": false,
|
||||
"changeOrigin": false,
|
||||
"logLevel": "debug"
|
||||
},
|
||||
"/es/uploads/*": {
|
||||
"target": "https://127.0.0.1:4100",
|
||||
"pathRewrite": {
|
||||
"/es/uploads/": "/uploads/"
|
||||
},
|
||||
"secure": false,
|
||||
"changeOrigin": false,
|
||||
"logLevel": "debug"
|
||||
},
|
||||
"/pt/uploads/*": {
|
||||
"target": "https://127.0.0.1:4100",
|
||||
"pathRewrite": {
|
||||
"/pt/uploads/": "/uploads/"
|
||||
},
|
||||
"secure": false,
|
||||
"changeOrigin": false,
|
||||
"logLevel": "debug"
|
||||
},
|
||||
"/gmapapi": {
|
||||
"target": "https://maps.googleapis.com/maps/api/",
|
||||
"secure": true,
|
||||
"pathRewrite": {
|
||||
"^/gmapapi": ""
|
||||
},
|
||||
"changeOrigin": true
|
||||
},
|
||||
"/forecast": {
|
||||
"target": "https://api.weatherapi.com/v1/",
|
||||
"secure": true,
|
||||
"pathRewrite": {
|
||||
"^/forecast": ""
|
||||
},
|
||||
"changeOrigin": true
|
||||
},
|
||||
"/track": {
|
||||
"target": "https://127.0.0.1:6101",
|
||||
"secure": false,
|
||||
"changeOrigin": true,
|
||||
"logLevel": "debug",
|
||||
"pathRewrite": {
|
||||
"^/track": "/track"
|
||||
}
|
||||
}
|
||||
}
|
||||
90
Development/client/src/NOTE.txt
Normal file
90
Development/client/src/NOTE.txt
Normal file
@ -0,0 +1,90 @@
|
||||
# ubuntugis package
|
||||
https://launchpad.net/~ubuntugis/+archive/ubuntu/ppa
|
||||
|
||||
|
||||
# update Angular version
|
||||
npm install @angular/{common,compiler,compiler-cli,core,forms,http,platform-browser,platform-browser-dynamic,platform-server,router,animations} typescript@latest --save
|
||||
|
||||
# Fix for NPM installing permission problems
|
||||
https://docs.npmjs.com/getting-started/fixing-npm-permissions
|
||||
|
||||
# Build web app for production
|
||||
ng build --prod --aot --build-optimizer
|
||||
|
||||
//--ssl 1 --ssl-key ../ssl/client-key.pem --ssl-cert ../ssl/client-cert.pem
|
||||
|
||||
|
||||
================app-errorhandler.ts=======================
|
||||
import { Injectable, ErrorHandler } from "@angular/core";
|
||||
|
||||
import { Subject, Observable, empty } from "rxjs";
|
||||
import { catchError } from "rxjs/operators";
|
||||
|
||||
export class APIError {
|
||||
text: string;
|
||||
tag: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AppErrorHandler implements ErrorHandler {
|
||||
|
||||
private _error$: Subject<any>;
|
||||
private _msgs: any;
|
||||
|
||||
constructor(err$, msgs) {
|
||||
this._error$ = err$;
|
||||
this._msgs = msgs;
|
||||
}
|
||||
|
||||
handleError = <T>(source$: Observable<T>) => source$.pipe(
|
||||
catchError(err => {
|
||||
const etag = err.error.error ? err.error.error['.tag'] || '' : '';
|
||||
let etext = 'Unknown Error';
|
||||
if (etag && this._msgs && etag in this._msgs)
|
||||
etext = this._msgs[etag];
|
||||
this._error$.next(<APIError>{ text: etext, tag: etag });
|
||||
return empty();
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Local FF Dev path: ~/.local/share/umake/web/firefox-dev
|
||||
|
||||
#======================================================================
|
||||
Responsive
|
||||
Responsive layout is achieved by applying additional classes to the columns whereas ui-g-* define the default behavior. Four screen sizes are supported with different breakpoints.
|
||||
CODE: SELECT ALL
|
||||
|
||||
Prefix Devices Media Query Example
|
||||
ui-sm-* Phones max-width: 40em (640px) ui-sm-6, ui-sm-4
|
||||
ui-md-* Tablets min-width: 40.063em (641px) ui-md-2, ui-sm-8
|
||||
ui-lg-* Desktops min-width: 64.063em (1025px) ui-lg-6, ui-sm-12
|
||||
ui-xl-* Big screen monitors min-width: 90.063em (1441px) ui-xl-2, ui-sm-10
|
||||
Most of the time, ui-md-* styles are used with default ui-g-* classes, to customize small or large screens apply ui-sm, ui-lg and ui-xl can be utilized.
|
||||
|
||||
# My Dark Sky weather API key:
|
||||
5cfb5e43ff89ab7b40ba08f57f5a7235
|
||||
|
||||
# PrimeNG Issue Template - to report bugs or issues with primeng components
|
||||
https://stackblitz.com/github/primefaces/primeng-issue-template
|
||||
|
||||
# Install this for xliffmerge tool
|
||||
npm install -g ngx-i18nsupport
|
||||
|
||||
|
||||
|
||||
// Angular
|
||||
- Custom form controls are simply components that implement the ControlValueAccessor interface. By implementing this interface, our custom controls can now work with Template and Reactive Forms APIs seamlessly providing a great developer experience to those using our components.
|
||||
|
||||
// ng generate component
|
||||
ng g c /shared/profile-form --selector=user-profile-form --skip-tests -m=app-shared
|
||||
|
||||
// GRecaptcha SiteKey
|
||||
6Le2EAAnAAAAAJHA-UKaFb8IIpaq7wo-TSvvCnQF
|
||||
|
||||
// GRecaptcha SecKey
|
||||
6Le2EAAnAAAAABy_7enAqU0k1M4GiwcXtB14iMMB
|
||||
|
||||
// Resolve peer dependencies unmatched issue:
|
||||
npm i --legacy-peer-deps
|
||||
or npm config set legacy-peer-deps true
|
||||
8
Development/client/src/TODO.txt
Normal file
8
Development/client/src/TODO.txt
Normal file
@ -0,0 +1,8 @@
|
||||
|
||||
|
||||
Fri. 04/21/17:
|
||||
PrimeNG Table Column:
|
||||
- How to format p-colum with class? now only with [style]. i.e: [style]="{'width':'10%','text-align':'center'}"
|
||||
=> The problem: This way will break the responsive feature with 'width' specified
|
||||
|
||||
|
||||
13
Development/client/src/app/@types/agm/index.d.ts
vendored
Normal file
13
Development/client/src/app/@types/agm/index.d.ts
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
import * as L from 'leaflet';
|
||||
// Declare the leaflet module so we can modify it
|
||||
declare module 'leaflet' {
|
||||
class AgmIcon extends L.DivIcon {
|
||||
}
|
||||
|
||||
function agmIcon(options: any);
|
||||
|
||||
class AgmACIcon extends L.DivIcon {
|
||||
}
|
||||
|
||||
function agmACIcon(options: any);
|
||||
}
|
||||
29
Development/client/src/app/@types/leaflet-googlemutant/index.d.ts
vendored
Normal file
29
Development/client/src/app/@types/leaflet-googlemutant/index.d.ts
vendored
Normal file
@ -0,0 +1,29 @@
|
||||
import * as L from 'leaflet';
|
||||
|
||||
// Declare the leaflet module so we can modify it
|
||||
declare module 'leaflet' {
|
||||
|
||||
interface GoogleMutantOptions extends TileLayerOptions {
|
||||
subdomains?: string,
|
||||
errorTileUrl?: string,
|
||||
continuousWorld?: boolean,
|
||||
// 🍂option type: String = 'roadmap'
|
||||
// Google's map type. Valid values are 'roadmap', 'satellite' or 'terrain'. 'hybrid' is not really supported.
|
||||
type: string,
|
||||
noAttr?: boolean,
|
||||
styles: any[any]
|
||||
}
|
||||
|
||||
export namespace GridLayer {
|
||||
class GoogleMutant extends GridLayer {
|
||||
constructor(options: GoogleMutantOptions);
|
||||
setElementSize(e, size);
|
||||
addGoogleLayer(googleLayerName: string, options);
|
||||
removeGoogleLayer(googleLayerName: string);
|
||||
}
|
||||
}
|
||||
|
||||
export namespace gridLayer {
|
||||
function googleMutant(options: GoogleMutantOptions): GridLayer.GoogleMutant
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,410 @@
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,179 @@
|
||||
<div class="ui-g" style="max-width: 1025px;">
|
||||
<div class="ui-g-12">
|
||||
<div class="card card-w-title">
|
||||
<h1 i18n="Account List screen header@@accountList">Account Information</h1>
|
||||
<form [formGroup]="form">
|
||||
<div class="ui-g ui-g-nopad" style="margin-top:40px">
|
||||
<div class="ui-g-12 ui-g-nopad ui-fluid">
|
||||
<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>
|
||||
|
||||
<!-- Error Status -->
|
||||
<span *ngIf="satlocIntegration.status === 'error'" class="connection-status-badge error"
|
||||
i18n-pTooltip="@@connectionError" pTooltip="Connection failed or has errors" tooltipPosition="right">
|
||||
<i class="ui-icon-close"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p-dialog [(visible)]="showSaveBeforeTestDialog" [modal]="true" [responsive]="true" [closable]="false"
|
||||
[style]="{width: '450px'}" [header]="Labels.SAVE_BEFORE_TEST_TITLE">
|
||||
|
||||
<div class="dialog-content">
|
||||
<!-- Info Message -->
|
||||
<p>{{ Labels.SAVE_BEFORE_TEST_MESSAGE }}</p>
|
||||
|
||||
<!-- Warning Message -->
|
||||
<agm-constraint-message [title]="Labels.SAVE_BEFORE_TEST_WARNING_TITLE"
|
||||
[message]="Labels.SAVE_BEFORE_TEST_WARNING_MESSAGE" severity="warning" icon="pi-exclamation-triangle">
|
||||
</agm-constraint-message>
|
||||
</div>
|
||||
|
||||
<p-footer>
|
||||
<button pButton type="button" [label]="Labels.CANCEL_BUTTON" icon="ui-icon-close"
|
||||
(click)="onCancelSaveBeforeTest()" class="ui-button-secondary">
|
||||
</button>
|
||||
<button pButton type="button" [label]="Labels.SAVE_AND_TEST_BUTTON" icon="ui-icon-save"
|
||||
(click)="onConfirmSaveBeforeTest()" class="ui-button-success">
|
||||
</button>
|
||||
</p-footer>
|
||||
</p-dialog>
|
||||
|
||||
<agm-constraint-message *ngIf="(postSaveValidationError && !postSaveValidationInProgress) || satlocError"
|
||||
[message]="postSaveValidationError ? postSaveErrorMessage : satlocError"
|
||||
[title]="Labels.POST_SAVE_VALIDATION_FAILED_TITLE" severity="error" icon="pi-times"
|
||||
class="post-save-message">
|
||||
</agm-constraint-message>
|
||||
|
||||
<div *ngIf="postSaveValidationInProgress" class="validation-progress">
|
||||
<i class="pi pi-spinner pi-spin"></i>
|
||||
<span>{{ Labels.VALIDATING_CREDENTIALS }}</span>
|
||||
</div>
|
||||
|
||||
<agm-constraint-message *ngIf="isNew" [message]="Labels.TEST_CONNECTION_UNAVAILABLE_MESSAGE"
|
||||
[title]="Labels.TEST_CONNECTION_UNAVAILABLE_TITLE" severity="info" icon="pi-info-circle"
|
||||
class="field-message">
|
||||
</agm-constraint-message>
|
||||
|
||||
<div *ngIf="satlocLoading" class="loading-indicator">
|
||||
<i class="pi pi-spinner pi-spin"></i>
|
||||
<span i18n="@@processingRequest">Processing request...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ui-g-12 ui-g-nopad form-row ui-fluid" style="padding-top: 0px; margin-top: 16px;">
|
||||
<agm-account-editor #accountEditor formControlName="account" [isNew]="isNew" [required]="true"
|
||||
[showActive]="true" [isPartnerSystemUser]="isCurrentAccountPartnerSystemUser"
|
||||
[showAccountConstraint]="shouldShowAccountTypeDisabledMessage"
|
||||
[accountConstraintMessage]="accountTypeConstraintMessage"
|
||||
[accountConstraintTitle]="accountTypeConstraintTitle">
|
||||
</agm-account-editor>
|
||||
</div>
|
||||
|
||||
<!-- Account constraint message appears below account-editor (detached content) -->
|
||||
<div *ngIf="shouldShowAccountTypeDisabledMessage" class="ui-g-12">
|
||||
<ng-container *ngTemplateOutlet="accountEditor?.accountConstraint?.detachedContentTemplate"></ng-container>
|
||||
</div>
|
||||
|
||||
<div class="ui-g-12 toolbar padtop1 ui-fluid">
|
||||
<button pButton [disabled]="form.invalid" type="button" style="width:auto"
|
||||
[icon]="isNew ? 'ui-icon-plus' : 'ui-icon-save'" [label]="isNew ? globals.create : globals.save"
|
||||
(click)="saveAccount(); false"></button>
|
||||
<button pButton type="button" style="width:auto" class="amber-btn" icon="ui-icon-arrow-back"
|
||||
(click)="goBack()" [label]="globals.back"></button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,52 @@
|
||||
<div class="ui-g">
|
||||
<div class="ui-g-12">
|
||||
<div class="card">
|
||||
<p-table #dt [columns]="cols" [value]="accounts" [loading]="isLoading" selectionMode="single"
|
||||
(onRowSelect)="onRowSelect($event)" (onRowUnselect)="onRowSelect($event)" [paginator]="true" [rows]="15"
|
||||
[pageLinks]="5" [rowsPerPageOptions]="null" [alwaysShowPaginator]="false" [(selection)]="currAcc" dataKey="_id"
|
||||
[resetPageOnSort]="false" [responsive]="true" stateStorage="session" stateKey="atb-ops">
|
||||
<ng-template pTemplate="caption">
|
||||
<span class="table-caption-1" i18n="@@acountList">Account List</span>
|
||||
</ng-template>
|
||||
<ng-template pTemplate="header" let-columns>
|
||||
<tr>
|
||||
<th *ngFor="let col of columns" [pSortableColumn]="col.field" [width]="col.width">
|
||||
{{col.header}}
|
||||
<p-sortIcon [field]="col.field"></p-sortIcon>
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th *ngFor="let col of columns" [ngSwitch]="col.filtered" class="ui-fluid">
|
||||
<div class="input-with-icon" *ngSwitchCase="true">
|
||||
<i class="ui-icon-search"></i>
|
||||
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)"
|
||||
[value]="dt.filters[col.field]?.value">
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,114 @@
|
||||
import { Component, OnInit, OnDestroy, ViewChild } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
|
||||
import { Table } from 'primeng/table';
|
||||
|
||||
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 { BaseComp } from '@app/shared/base/base.component';
|
||||
import { Utils } from '@app/shared/utils';
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'agm-account-list',
|
||||
templateUrl: './account-list.component.html',
|
||||
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;
|
||||
|
||||
@ViewChild("dt") dt: Table;
|
||||
|
||||
constructor(
|
||||
private readonly route: ActivatedRoute,
|
||||
|
||||
) {
|
||||
super();
|
||||
this.currAcc = null;
|
||||
|
||||
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: 'phone', header: globals.phone + ' ' + $localize`:@@Num:N°`, width: '10%', filtered: true, filterMatchMode: 'contains' },
|
||||
{ field: 'email', header: globals.email, filtered: true, filterMatchMode: 'contains' }
|
||||
];
|
||||
}
|
||||
|
||||
get canWrite(): boolean {
|
||||
return this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM]);
|
||||
}
|
||||
|
||||
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
|
||||
));
|
||||
this.store.dispatch(new userActions.Fetch());
|
||||
}
|
||||
|
||||
onRowSelect(event) {
|
||||
this.store.dispatch(new userActions.Select(this.currAcc));
|
||||
}
|
||||
|
||||
get canAddNew(): boolean {
|
||||
return (this.accounts && this.accounts.length < 10); // Constraint maximum of 10 accounts/customer
|
||||
}
|
||||
|
||||
get canEdit() {
|
||||
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 });
|
||||
}
|
||||
|
||||
editAccount() {
|
||||
this.router.navigate(['account', this.currAcc._id], { relativeTo: this.route });
|
||||
}
|
||||
|
||||
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,
|
||||
accept: () => {
|
||||
this.store.dispatch(new userActions.Delete(this.currAcc));
|
||||
this.currAcc = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
super.ngOnDestroy();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<router-outlet></router-outlet>
|
||||
`
|
||||
})
|
||||
export class AccountMgtComponent { }
|
||||
@ -0,0 +1,40 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import {
|
||||
Router, Resolve, RouterStateSnapshot,
|
||||
ActivatedRouteSnapshot
|
||||
} from '@angular/router';
|
||||
|
||||
import { Observable } from 'rxjs';
|
||||
import { User, createNewUser } from './models/user.model';
|
||||
import { UserService } from '../domain/services/user.service';
|
||||
import { map, first } from 'rxjs/operators';
|
||||
|
||||
@Injectable()
|
||||
export class AccountResolver implements Resolve<User> {
|
||||
constructor(
|
||||
private router: Router,
|
||||
private userService: UserService
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<User> | Promise<User> | User {
|
||||
const id = route.paramMap.get('id');
|
||||
if (id === '0') {
|
||||
return createNewUser();
|
||||
} else {
|
||||
return this.userService.getUser(id).pipe(
|
||||
map((user) => {
|
||||
if (user) {
|
||||
return user;
|
||||
} else { // id not found
|
||||
this.router.navigate(['/accounts']);
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
first()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
|
||||
import { AuthGuard } from '../domain/guards/auth.guard';
|
||||
import { AccountResolver } from './account-resolver.service';
|
||||
|
||||
import { AccountEditComponent } from './account-edit/account-edit.component';
|
||||
import { AccountListComponent } from './account-list/account-list.component';
|
||||
import { AccountMgtComponent } from './account-mgt.component';
|
||||
|
||||
import { RoleIds } from '../shared/global';
|
||||
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: AccountMgtComponent,
|
||||
data: {
|
||||
roles: [RoleIds.APP, RoleIds.APP_ADM, RoleIds.OFFICER]
|
||||
},
|
||||
canActivate: [AuthGuard],
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
component: AccountListComponent,
|
||||
data: {
|
||||
roles: [RoleIds.APP, RoleIds.APP_ADM, RoleIds.OFFICER]
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'account/:id',
|
||||
component: AccountEditComponent,
|
||||
data: {
|
||||
roles: [RoleIds.APP, RoleIds.APP_ADM, RoleIds.OFFICER]
|
||||
},
|
||||
resolve: [AccountResolver]
|
||||
},
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild(routes)
|
||||
],
|
||||
exports: [
|
||||
RouterModule
|
||||
],
|
||||
providers: [
|
||||
AccountResolver
|
||||
]
|
||||
})
|
||||
export class AccountsRoutingModule { }
|
||||
25
Development/client/src/app/accounts/account.guard.ts
Normal file
25
Development/client/src/app/accounts/account.guard.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
|
||||
|
||||
import { Store } from '@ngrx/store';
|
||||
import { Observable } from 'rxjs';
|
||||
import { filter, map, take } from 'rxjs/operators';
|
||||
|
||||
import * as fromUsers from './reducers/';
|
||||
import { Fetch } from './actions/account.actions';
|
||||
|
||||
@Injectable()
|
||||
export class AccountsGuard implements CanActivate {
|
||||
constructor(public store: Store<{}>) { }
|
||||
|
||||
canActivate(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> | Promise<boolean> | boolean {
|
||||
return this.store.select(fromUsers.getIsLoaded).pipe(
|
||||
map(loaded => {
|
||||
if (!loaded)
|
||||
this.store.dispatch(new Fetch());
|
||||
return loaded;
|
||||
}),
|
||||
filter(loaded => loaded),
|
||||
take(1));
|
||||
}
|
||||
}
|
||||
53
Development/client/src/app/accounts/account.module.ts
Normal file
53
Development/client/src/app/accounts/account.module.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
|
||||
|
||||
import { SplitButtonModule } from 'primeng/splitbutton';
|
||||
import { DialogModule } from 'primeng/dialog';
|
||||
import { ConfirmDialogModule } from 'primeng/confirmdialog';
|
||||
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';
|
||||
|
||||
import { StoreModule } from '@ngrx/store';
|
||||
import { EffectsModule } from '@ngrx/effects';
|
||||
|
||||
import { AppSharedModule } from '../shared/app-shared.module';
|
||||
import { AccountsRoutingModule } from './account-routing.module';
|
||||
|
||||
import { AccountMgtComponent } from './account-mgt.component';
|
||||
import { AccountListComponent } from './account-list/account-list.component';
|
||||
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';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
AppSharedModule,
|
||||
DialogModule,
|
||||
ConfirmDialogModule,
|
||||
CheckboxModule,
|
||||
CalendarModule,
|
||||
AutoCompleteModule,
|
||||
InputSwitchModule,
|
||||
ToolbarModule,
|
||||
SplitButtonModule,
|
||||
TableModule,
|
||||
TooltipModule,
|
||||
|
||||
StoreModule.forFeature(FEATURE_KEY, reducer),
|
||||
EffectsModule.forFeature([AccountEffects]),
|
||||
AccountsRoutingModule
|
||||
],
|
||||
declarations: [AccountMgtComponent, AccountListComponent, AccountEditComponent],
|
||||
providers: [AccountsGuard],
|
||||
schemas: [
|
||||
CUSTOM_ELEMENTS_SCHEMA
|
||||
]
|
||||
})
|
||||
export class AccountsModule { }
|
||||
@ -0,0 +1,94 @@
|
||||
import { Action } from "@ngrx/store";
|
||||
import { User } from "../models/user.model";
|
||||
|
||||
export const FETCH = '[USERS] Fetch users';
|
||||
export class Fetch implements Action {
|
||||
type: typeof FETCH = FETCH;
|
||||
}
|
||||
|
||||
export const FETCH_SUCCESS = '[USERS] Fetch users success';
|
||||
export class FetchSuccess implements Action {
|
||||
type: typeof FETCH_SUCCESS = FETCH_SUCCESS;
|
||||
|
||||
constructor(readonly payload: User[]) { }
|
||||
}
|
||||
|
||||
export const FETCH_FAILED = '[USERS] Fetch users failed';
|
||||
export class FetchError implements Action {
|
||||
type: typeof FETCH_FAILED = FETCH_FAILED;
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
}) { }
|
||||
}
|
||||
export const CREATE_SUCCESS = '[USERS] Create user success';
|
||||
export class CreateSuccess implements Action {
|
||||
type: typeof CREATE_SUCCESS = CREATE_SUCCESS;
|
||||
|
||||
constructor(readonly payload: User) { }
|
||||
}
|
||||
export const CREATE_FAILED = '[USERS] Create user failed';
|
||||
export class CreateFailed implements Action {
|
||||
type: typeof CREATE_FAILED = CREATE_FAILED;
|
||||
}
|
||||
|
||||
export const UPDATE = '[USERS] Update user';
|
||||
export class Update implements Action {
|
||||
type: typeof UPDATE = UPDATE;
|
||||
|
||||
constructor(readonly payload: User & {
|
||||
partnerConfig?: {
|
||||
vendorSystemType: string;
|
||||
vendorConfiguration: any;
|
||||
};
|
||||
}) { }
|
||||
}
|
||||
export const UPDATE_SUCCESS = '[USERS] Update user success';
|
||||
export class UpdateSuccess implements Action {
|
||||
type: typeof UPDATE_SUCCESS = UPDATE_SUCCESS;
|
||||
|
||||
constructor(readonly payload: User) { }
|
||||
}
|
||||
export const UPDATE_FAILED = '[USERS] Update user failed';
|
||||
export class UpdateFailed implements Action {
|
||||
type: typeof UPDATE_FAILED = UPDATE_FAILED;
|
||||
}
|
||||
|
||||
export const DELETE = '[USERS] Delete user';
|
||||
export class Delete implements Action {
|
||||
type: typeof DELETE = DELETE;
|
||||
|
||||
constructor(readonly payload: User) { }
|
||||
}
|
||||
export const DELETE_SUCCESS = '[USERS] Delete user success';
|
||||
export class DeleteSuccess implements Action {
|
||||
type: typeof DELETE_SUCCESS = DELETE_SUCCESS;
|
||||
|
||||
constructor(readonly payload: User) { }
|
||||
}
|
||||
export const DELETE_FAILED = '[USERS] Delete user failed';
|
||||
export class DeleteError implements Action {
|
||||
type: typeof DELETE_FAILED = DELETE_FAILED;
|
||||
}
|
||||
|
||||
export const SELECT = '[USERS] Select user';
|
||||
export class Select implements Action {
|
||||
type: typeof SELECT = SELECT;
|
||||
|
||||
constructor(readonly payload: User) { }
|
||||
}
|
||||
|
||||
export type All =
|
||||
| Fetch | FetchSuccess | FetchError
|
||||
| Create | CreateSuccess | CreateFailed
|
||||
| Update | UpdateSuccess | UpdateFailed
|
||||
| Delete | DeleteSuccess | DeleteError
|
||||
| Select
|
||||
277
Development/client/src/app/accounts/effects/account.effects.ts
Normal file
277
Development/client/src/app/accounts/effects/account.effects.ts
Normal file
@ -0,0 +1,277 @@
|
||||
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 { Action } from '@ngrx/store';
|
||||
|
||||
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';
|
||||
|
||||
@Injectable()
|
||||
export class AccountEffects {
|
||||
constructor(
|
||||
private readonly actions$: Actions,
|
||||
private readonly userSvc: UserService,
|
||||
private readonly authSvc: AuthService,
|
||||
private readonly msgSvc: AppMessageService,
|
||||
private readonly partnerSvc: PartnerService
|
||||
) {
|
||||
}
|
||||
|
||||
@Effect()
|
||||
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))
|
||||
)
|
||||
),
|
||||
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()
|
||||
);
|
||||
|
||||
@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()
|
||||
);
|
||||
|
||||
@Effect()
|
||||
deleteUser$: Observable<Action> = this.actions$.pipe(
|
||||
ofType<userActions.Delete>(userActions.DELETE),
|
||||
switchMap(({ payload }) => {
|
||||
// Check if the user is a PARTNER_SYSTEM_USER
|
||||
if (payload.kind === RoleIds.PARTNER_SYSTEM_USER) {
|
||||
// Backend only disables partner system users (sets active=false), it does NOT remove them.
|
||||
// Dispatch UpdateSuccess so the store reflects the disabled state in-place rather than
|
||||
// removing the row — which would cause it to reappear on the next reload.
|
||||
return this.partnerSvc.deleteSystemUser(payload._id).pipe(
|
||||
map(() => new userActions.UpdateSuccess({ ...payload, active: false }))
|
||||
);
|
||||
} else {
|
||||
// Use UserService for regular users
|
||||
return this.userSvc.deleteUser(payload).pipe(
|
||||
map(() => new userActions.DeleteSuccess(payload))
|
||||
);
|
||||
}
|
||||
}),
|
||||
catchError(err => this.handleUserOperationError(err, 'delete')),
|
||||
repeat()
|
||||
);
|
||||
|
||||
// Partner user workflow methods - use PartnerService exclusively
|
||||
private createPartnerSystemUser(userData: any, partnerConfig: any): Observable<Action> {
|
||||
// Get partner ID based on vendor type
|
||||
return this.getPartnerByVendorType(partnerConfig.vendorSystemType).pipe(
|
||||
switchMap(partnerId => {
|
||||
if (!partnerId) {
|
||||
throw new Error(`Failed to get partner for vendor type: ${partnerConfig.vendorSystemType}`);
|
||||
}
|
||||
|
||||
// Create vendor-specific system user data
|
||||
const createData = this.buildPartnerSystemUserData(userData, partnerConfig, partnerId);
|
||||
|
||||
return this.partnerSvc.createSystemUser(createData).pipe(
|
||||
map((systemUser) => {
|
||||
// ✅ FIX: Return the created system user with customerId/partnerId for post-save validation
|
||||
// Merge the saved systemUser data with original userData to preserve all fields
|
||||
return new userActions.CreateSuccess({
|
||||
...userData,
|
||||
...systemUser,
|
||||
// Ensure we have the IDs for post-save validation
|
||||
customer: systemUser.customer || createData.customerId,
|
||||
partner: systemUser.partner || createData.partnerId
|
||||
});
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private updatePartnerUserWorkflow(userData: any, partnerConfig: any): Observable<any> {
|
||||
// Use getSystemUserById to directly fetch the partner system user
|
||||
return this.partnerSvc.getSystemUserById(userData._id).pipe(
|
||||
switchMap(existingSystemUser => {
|
||||
if (existingSystemUser) {
|
||||
// Update existing partner system user with backend-compatible structure
|
||||
const updateData = this.buildPartnerSystemUserData(userData, partnerConfig, existingSystemUser.partner._id);
|
||||
|
||||
return this.partnerSvc.updateSystemUser(existingSystemUser._id!, updateData).pipe(
|
||||
map(() => userData) // Return the user data
|
||||
);
|
||||
} else {
|
||||
// Partner system user doesn't exist, return error
|
||||
throw new Error('Partner system user not found for update');
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build partner system user data structure based on vendor type
|
||||
* This method can be extended to support additional vendors
|
||||
*/
|
||||
private buildPartnerSystemUserData(userData: any, partnerConfig: any, partnerId: string): any {
|
||||
return {
|
||||
partnerId: partnerId,
|
||||
customerId: userData.parent, // AgMission customer (main applicator account)
|
||||
username: userData.username,
|
||||
password: userData.password,
|
||||
name: userData.name,
|
||||
active: userData.active,
|
||||
email: userData.email,
|
||||
address: userData.address,
|
||||
phone: userData.phone,
|
||||
companyId: partnerConfig.vendorConfiguration.companyId || null,
|
||||
apiKey: partnerConfig.vendorConfiguration.apiKey || null,
|
||||
apiSecret: partnerConfig.vendorConfiguration.apiSecret || null
|
||||
// NOTE: metadata intentionally omitted — partner identity is carried by
|
||||
// partnerId (ObjectId). metadata.vendor was a fragile frontend-derived
|
||||
// copy that could silently diverge from the partner document.
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get partner ID by vendor type
|
||||
* This method can be extended to support additional vendors
|
||||
*/
|
||||
private getPartnerByVendorType(vendorType: string): Observable<string | null> {
|
||||
return this.partnerSvc.getPartners().pipe(
|
||||
map((partners: any[]) => {
|
||||
let partner = null;
|
||||
|
||||
switch (vendorType) {
|
||||
case KnownPartnerCodes.SATLOC:
|
||||
partner = partners.find(p =>
|
||||
p.partnerCode === KnownPartnerCodes.SATLOC.toUpperCase() ||
|
||||
p.name?.toLowerCase().includes(KnownPartnerCodes.SATLOC)
|
||||
);
|
||||
break;
|
||||
|
||||
// Add additional vendors here as needed
|
||||
// case 'other_vendor':
|
||||
// partner = partners.find(p =>
|
||||
// p.partnerCode === 'OTHER_VENDOR' ||
|
||||
// p.name?.toLowerCase().includes('other_vendor')
|
||||
// );
|
||||
// break;
|
||||
|
||||
default:
|
||||
// Fallback: try to find partner by name or code matching vendor type
|
||||
partner = partners.find(p =>
|
||||
p.partnerCode?.toLowerCase() === vendorType.toLowerCase() ||
|
||||
p.name?.toLowerCase().includes(vendorType.toLowerCase())
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
return partner ? partner._id : null;
|
||||
}),
|
||||
catchError(() => of(null))
|
||||
);
|
||||
}
|
||||
|
||||
private cleanupPartnerSystemUsers(userId: string): Observable<any> {
|
||||
return this.partnerSvc.getSystemUsersForCustomer(userId).pipe(
|
||||
switchMap((systemUsers: PartnerSystemUser[]) => {
|
||||
if (systemUsers.length === 0) {
|
||||
return of(null);
|
||||
}
|
||||
|
||||
// Delete all system users for this customer
|
||||
const deleteOperations = systemUsers.map(systemUser =>
|
||||
this.partnerSvc.deleteSystemUser(systemUser._id!).pipe(
|
||||
catchError(error => {
|
||||
console.error('Failed to delete partner system user:', error);
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
115
Development/client/src/app/accounts/models/user.model.ts
Normal file
115
Development/client/src/app/accounts/models/user.model.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import { Address } from '@app/domain/models/subscription.model';
|
||||
import { RoleIds, OperationalStatusType } from '@app/shared/global';
|
||||
|
||||
interface RoleArray {
|
||||
[index: number]: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
_id: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
name?: string;
|
||||
address?: string | null;
|
||||
country?: string;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
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,
|
||||
parent: parentId
|
||||
};
|
||||
return user;
|
||||
}
|
||||
57
Development/client/src/app/accounts/reducers/index.ts
Normal file
57
Development/client/src/app/accounts/reducers/index.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import {
|
||||
createSelector,
|
||||
createFeatureSelector,
|
||||
} from '@ngrx/store';
|
||||
|
||||
import * as fromUsers from './users.reducer';
|
||||
|
||||
/**
|
||||
* The createFeatureSelector function selects a piece of state from the root of the state object.
|
||||
* This is used for selecting feature states that are loaded eagerly or lazily.
|
||||
*/
|
||||
export const getUsersState = createFeatureSelector<fromUsers.State>(fromUsers.FEATURE_KEY);
|
||||
|
||||
/**
|
||||
* Every reducer module exports selector functions, however child reducers
|
||||
* have no knowledge of the overall state tree. To make them usable, we
|
||||
* need to make new selectors that wrap them.
|
||||
*
|
||||
* The createSelector function creates very efficient selectors that are memoized and
|
||||
* only recompute when arguments change. The created selectors can also be composed
|
||||
* together to select different pieces of state.
|
||||
*/
|
||||
|
||||
export const getSelectedUserId = createSelector(
|
||||
getUsersState,
|
||||
fromUsers.getSelectedId
|
||||
);
|
||||
|
||||
export const getIsLoading = createSelector(
|
||||
getUsersState,
|
||||
fromUsers.getIsLoading
|
||||
);
|
||||
|
||||
export const getIsLoaded = createSelector(
|
||||
getUsersState,
|
||||
fromUsers.getIsLoaded
|
||||
);
|
||||
|
||||
/**
|
||||
* Adapters created with @ngrx/entity generate commonly used selector functions including
|
||||
* getting all ids in the record set, a dictionary of the records by id, an array of records and
|
||||
* the total number of records. This reduces boilerplate in selecting records from the entity state.
|
||||
*/
|
||||
export const {
|
||||
selectIds: getUsersIds,
|
||||
selectEntities: getUserEntities,
|
||||
selectAll: getAllUsers,
|
||||
selectTotal: getTotalUsers,
|
||||
} = fromUsers.adapter.getSelectors(getUsersState);
|
||||
|
||||
export const getSelectedUser = createSelector(
|
||||
getUserEntities,
|
||||
getSelectedUserId,
|
||||
(entities, selectedId) => {
|
||||
return selectedId && entities[selectedId];
|
||||
}
|
||||
);
|
||||
102
Development/client/src/app/accounts/reducers/users.reducer.ts
Normal file
102
Development/client/src/app/accounts/reducers/users.reducer.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import { createEntityAdapter, EntityAdapter, EntityState } from '@ngrx/entity';
|
||||
import { User } from '../models/user.model';
|
||||
import * as actions from '../actions/account.actions';
|
||||
|
||||
export const FEATURE_KEY = 'Accounts';
|
||||
|
||||
export interface State extends EntityState<User> {
|
||||
loading: boolean;
|
||||
loaded: boolean;
|
||||
selectedId: string;
|
||||
}
|
||||
|
||||
export const adapter: EntityAdapter<User> = createEntityAdapter<User>({
|
||||
selectId: (user: User) => user._id,
|
||||
sortComparer: false
|
||||
});
|
||||
|
||||
export const initialState: State = adapter.getInitialState({
|
||||
loading: false,
|
||||
loaded: false,
|
||||
selectedId: null
|
||||
});
|
||||
|
||||
export function reducer(
|
||||
state = initialState,
|
||||
action: actions.All
|
||||
): State {
|
||||
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:
|
||||
return { ...state, loading: true };
|
||||
|
||||
case actions.SELECT:
|
||||
return {
|
||||
...state,
|
||||
selectedId: action.payload ? action.payload._id : null
|
||||
}
|
||||
|
||||
case actions.FETCH_SUCCESS:
|
||||
return adapter.addMany(action.payload, {
|
||||
...adapter.removeAll(state),
|
||||
loading: false,
|
||||
selectedId: state.selectedId,
|
||||
loaded: true
|
||||
});
|
||||
|
||||
case actions.FETCH_FAILED:
|
||||
return {
|
||||
...state,
|
||||
loading: false
|
||||
};
|
||||
|
||||
case actions.CREATE_SUCCESS:
|
||||
return adapter.upsertOne(action.payload, {
|
||||
...state,
|
||||
loading: false
|
||||
});
|
||||
|
||||
case actions.CREATE_FAILED:
|
||||
return {
|
||||
...state,
|
||||
loading: false
|
||||
};
|
||||
|
||||
case actions.UPDATE_SUCCESS:
|
||||
return adapter.upsertOne(action.payload, {
|
||||
...state,
|
||||
loading: false
|
||||
});
|
||||
|
||||
case actions.UPDATE_FAILED:
|
||||
return {
|
||||
...state,
|
||||
loading: false
|
||||
};
|
||||
|
||||
case actions.DELETE_SUCCESS:
|
||||
return adapter.removeOne(action.payload._id, {
|
||||
...state,
|
||||
loading: false
|
||||
});
|
||||
|
||||
case actions.DELETE_FAILED:
|
||||
return {
|
||||
...state,
|
||||
loading: false
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export const getSelectedId = (state: State) => state.selectedId;
|
||||
export const getIsLoading = (state: State) => state.loading;
|
||||
export const getIsLoaded = (state: State) => state.loaded;
|
||||
26
Development/client/src/app/actions/app.actions.ts
Normal file
26
Development/client/src/app/actions/app.actions.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { Action } from "@ngrx/store";
|
||||
|
||||
export const SEL_LANG = '[APP] Select a language';
|
||||
export class SelLang implements Action {
|
||||
type: typeof SEL_LANG = SEL_LANG;
|
||||
|
||||
constructor(readonly lang: string) { }
|
||||
}
|
||||
|
||||
export const SEL_LANG_SUCCESS = '[APP] Select a language success';
|
||||
export class SelLangSuccess implements Action {
|
||||
type: typeof SEL_LANG_SUCCESS = SEL_LANG_SUCCESS;
|
||||
|
||||
constructor(readonly lang: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export const SEL_LANG_ERROR = '[APP] Select a language failed';
|
||||
export class SelLangError implements Action {
|
||||
type: typeof SEL_LANG_ERROR = SEL_LANG_ERROR;
|
||||
|
||||
}
|
||||
|
||||
|
||||
export type All =
|
||||
| SelLang | SelLangSuccess | SelLangError
|
||||
32
Development/client/src/app/actions/sub-plans.actions.ts
Normal file
32
Development/client/src/app/actions/sub-plans.actions.ts
Normal file
@ -0,0 +1,32 @@
|
||||
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
|
||||
561
Development/client/src/app/actions/subscription.actions.ts
Normal file
561
Development/client/src/app/actions/subscription.actions.ts
Normal file
@ -0,0 +1,561 @@
|
||||
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
|
||||
|
||||
|
||||
36
Development/client/src/app/admin/admin-routing.routes.ts
Normal file
36
Development/client/src/app/admin/admin-routing.routes.ts
Normal file
@ -0,0 +1,36 @@
|
||||
/*
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
import { AuthGuard } from '../domain/guards/auth.guard';
|
||||
|
||||
const adminRoutes: Routes = [
|
||||
{
|
||||
path: 'admin',
|
||||
component: AdminComponent,
|
||||
canActivate: [AuthGuard],
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
canActivateChild: [AuthGuard],
|
||||
children: [
|
||||
{ path: 'accounts', component: ManageCrisesComponent },
|
||||
{ path: 'clients', component: ManageCrisesComponent },
|
||||
{ path: 'pilots', component: ManageHeroesComponent },
|
||||
{ path: 'products', component: AdminDashboardComponent }
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild(adminRoutes)
|
||||
],
|
||||
exports: [
|
||||
RouterModule
|
||||
]
|
||||
})
|
||||
export class AdminRoutingModule { }
|
||||
*/
|
||||
14
Development/client/src/app/admin/admin.module.ts
Normal file
14
Development/client/src/app/admin/admin.module.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
],
|
||||
declarations: [],
|
||||
providers: [],
|
||||
schemas: [
|
||||
CUSTOM_ELEMENTS_SCHEMA
|
||||
]
|
||||
})
|
||||
export class AdminModule { }
|
||||
38
Development/client/src/app/app-actions.ts
Normal file
38
Development/client/src/app/app-actions.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Subject } from 'rxjs';
|
||||
import { filter } from 'rxjs/operators';
|
||||
|
||||
import { Action, ActionsSubject } from '@ngrx/store';
|
||||
|
||||
/**
|
||||
* These helper service is responsible for make it simpler to listen for actions in components
|
||||
* Ref: Listening for Actions in @ngrx/store at https://netbasal.com/listening-for-actions-in-ngrx-store-a699206d2210
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AppActions {
|
||||
_actions = new Subject<Action>();
|
||||
|
||||
ofType(type: string) {
|
||||
return this._actions.pipe(filter((action: Action) => action.type === type));
|
||||
}
|
||||
|
||||
ofTypes(types: string[]) {
|
||||
return this._actions.pipe(filter((action: Action) => Array.isArray(types) && types.indexOf(action.type) !== -1));
|
||||
}
|
||||
|
||||
nextAction(action: Action) {
|
||||
this._actions.next(action);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AppDispatcher extends ActionsSubject {
|
||||
constructor(private actions: AppActions) {
|
||||
super();
|
||||
}
|
||||
|
||||
next(action: Action) {
|
||||
super.next(action);
|
||||
this.actions.nextAction(action);
|
||||
}
|
||||
}
|
||||
13
Development/client/src/app/app-injector.ts
Normal file
13
Development/client/src/app/app-injector.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Injector } from '@angular/core';
|
||||
|
||||
export class AppInjector {
|
||||
private static injector: Injector;
|
||||
|
||||
static setInjector(injector: Injector) {
|
||||
AppInjector.injector = injector;
|
||||
}
|
||||
|
||||
static getInjector(): Injector {
|
||||
return AppInjector.injector;
|
||||
}
|
||||
}
|
||||
9
Development/client/src/app/app-preloader.ts
Normal file
9
Development/client/src/app/app-preloader.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { PreloadingStrategy, Route } from '@angular/router';
|
||||
|
||||
import { Observable, of } from 'rxjs';
|
||||
|
||||
export class AppPreloader implements PreloadingStrategy {
|
||||
preload(route: Route, load: Function): Observable<any> {
|
||||
return route.data && route.data.preload ? load() : of(null);
|
||||
}
|
||||
}
|
||||
171
Development/client/src/app/app-routing.module.ts
Normal file
171
Development/client/src/app/app-routing.module.ts
Normal file
@ -0,0 +1,171 @@
|
||||
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',
|
||||
component: DashboardComponent,
|
||||
data: {
|
||||
roles: null // Only required authenticated user
|
||||
},
|
||||
canActivate: [AuthGuard, SettingsGuard]
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
{
|
||||
path: 'billing',
|
||||
loadChildren: () => import('./billing/billing.module').then(m => m.BillingModule),
|
||||
},
|
||||
{
|
||||
path: 'accounts',
|
||||
loadChildren: () => import('./accounts/account.module').then(m => m.AccountsModule),
|
||||
},
|
||||
{
|
||||
path: 'jobs',
|
||||
loadChildren: () => import('./job/job.module').then(m => m.JobsModule),
|
||||
runGuardsAndResolvers: 'always',
|
||||
},
|
||||
{
|
||||
path: 'clients',
|
||||
loadChildren: () => import('./client/client.module').then(m => m.ClientsModule),
|
||||
data: { preload: true }
|
||||
},
|
||||
{
|
||||
path: 'entities',
|
||||
loadChildren: () => import('./entities/entities.module').then(m => m.EntitiesModule),
|
||||
runGuardsAndResolvers: 'always',
|
||||
data: { preload: true }
|
||||
},
|
||||
{
|
||||
path: 'tools',
|
||||
loadChildren: () => import('./tools/tools.module').then(m => m.ToolsModule),
|
||||
runGuardsAndResolvers: 'always',
|
||||
},
|
||||
{
|
||||
path: 'track',
|
||||
loadChildren: () => import('./track/track.module').then(m => m.TrackModule),
|
||||
runGuardsAndResolvers: 'always',
|
||||
},
|
||||
{
|
||||
path: 'invoices',
|
||||
loadChildren: () => import('./invoices/invoices.module').then(m => m.InvoicesModule),
|
||||
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'
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "login",
|
||||
loadChildren: () => import("./auth/auth.module").then((m) => m.AuthModule),
|
||||
data: { preload: true },
|
||||
},
|
||||
{
|
||||
path: 'password-reset', component: AppPasswordResetComp
|
||||
},
|
||||
{
|
||||
path: 'password-reset/:id/:token', component: AppPasswordResetComp
|
||||
},
|
||||
{
|
||||
path: 'report', component: ReportComponent,
|
||||
data: {
|
||||
roles: null
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'documentation', component: PageNotFoundComponent,
|
||||
data: {
|
||||
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 },
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forRoot(routes,
|
||||
{
|
||||
onSameUrlNavigation: 'reload',
|
||||
preloadingStrategy: AppPreloader,
|
||||
scrollPositionRestoration: 'enabled',
|
||||
initialNavigation: 'enabled',
|
||||
relativeLinkResolution: 'corrected',
|
||||
useHash: true,
|
||||
// enableTracing: true
|
||||
})
|
||||
],
|
||||
exports: [
|
||||
RouterModule
|
||||
],
|
||||
providers: [AppPreloader, MembershipResolver],
|
||||
})
|
||||
export class AppRoutingModule { }
|
||||
0
Development/client/src/app/app.component.css
Normal file
0
Development/client/src/app/app.component.css
Normal file
4
Development/client/src/app/app.component.html
Normal file
4
Development/client/src/app/app.component.html
Normal file
@ -0,0 +1,4 @@
|
||||
<router-outlet></router-outlet>
|
||||
<div>
|
||||
<agm-footer *ngIf="showFooter" [showLang]="showFooter"></agm-footer>
|
||||
</div>
|
||||
0
Development/client/src/app/app.component.scss
Normal file
0
Development/client/src/app/app.component.scss
Normal file
482
Development/client/src/app/app.component.ts
Normal file
482
Development/client/src/app/app.component.ts
Normal file
@ -0,0 +1,482 @@
|
||||
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 { environment } from '@environments/environment';
|
||||
import { BaseComp } from './shared/base/base.component';
|
||||
|
||||
|
||||
// Declare ga as a function to set and sent the events
|
||||
// declare let ga: Function;
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: './app.component.html',
|
||||
})
|
||||
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;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this["name"] = "AppComp";
|
||||
}
|
||||
|
||||
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() {
|
||||
}
|
||||
}
|
||||
25
Development/client/src/app/app.footer.component.ts
Normal file
25
Development/client/src/app/app.footer.component.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'agm-footer',
|
||||
template: `
|
||||
<div class="footer">
|
||||
<div class="card clearfix">
|
||||
<!--div class="ui-g ui-g-12"-->
|
||||
<!--div *ngIf="isLoggedIn" class="ui-g-6 ui-sm-12">
|
||||
<span class="ui-icon ui-icon-copyright"></span><span>Copyright© 2019 AgNav Inc.</span>
|
||||
</div-->
|
||||
<!--div class="ui-g-6 ui-sm-12"-->
|
||||
<agm-language-swicher *ngIf="showLang"></agm-language-swicher>
|
||||
<!--/div-->
|
||||
<!--/div-->
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class AppFooterComponent implements OnInit {
|
||||
@Input() showLang: boolean;
|
||||
|
||||
ngOnInit() {
|
||||
}
|
||||
}
|
||||
55
Development/client/src/app/app.main.component.html
Normal file
55
Development/client/src/app/app.main.component.html
Normal file
@ -0,0 +1,55 @@
|
||||
<agm-loader></agm-loader>
|
||||
<!-- showTransitionOptions="350ms" hideTransitionOptions="300ms" -->
|
||||
<p-toast position="center" life="3000"></p-toast>
|
||||
<p-confirmDialog i18n-header="Confirmation dialog title@@confirmation" header="Confirmation" baseZIndex="3000" i18n-acceptLabel="@@yes" acceptLabel="Yes " i18n-rejectLabel="@@no" rejectLabel="No " icon="ui-icon-warning"></p-confirmDialog>
|
||||
<p-confirmDialog key="okOnly" #cd i18n-header="Confirmation dialog title@@confirmation" header="Confirmation" baseZIndex="3000" [style]="{width: '60vw'}" [position]="'center'">
|
||||
<p-footer>
|
||||
<div class="toolbar lr-row">
|
||||
<div>
|
||||
<p-checkbox id="notShowAgain" name="notShowAgain" [(ngModel)]="settings.noPopup" binary="true" i18n-label="@@notShowAgain" label="Do NOT show this again"></p-checkbox>
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" pButton i18n-label="@@ok" label="OK" (click)="cd.accept()"></button>
|
||||
</div>
|
||||
</div>
|
||||
</p-footer>
|
||||
</p-confirmDialog>
|
||||
|
||||
<div class="layout-wrapper" [ngClass]="{'layout-compact':layoutCompact}" (click)="onLayoutClick()">
|
||||
|
||||
<div #layoutContainer class="layout-container" [ngClass]="{'menu-layout-static': !isOverlay(),
|
||||
'menu-layout-overlay': isOverlay(),
|
||||
'layout-menu-overlay-active': overlayMenuActive,
|
||||
'menu-layout-horizontal': isHorizontal(),
|
||||
'menu-layout-slim': isSlim(),
|
||||
'layout-menu-static-inactive': staticMenuDesktopInactive,
|
||||
'layout-menu-static-active': staticMenuMobileActive}">
|
||||
|
||||
<app-topbar></app-topbar>
|
||||
|
||||
<div class="layout-menu" [ngClass]="{'layout-menu-dark':darkMenu}" (click)="onMenuClick($event)">
|
||||
<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>
|
||||
<agm-footer *ngIf="showFooter" [showLang]="!isAdmin"></agm-footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layout-mask"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
490
Development/client/src/app/app.main.component.ts
Normal file
490
Development/client/src/app/app.main.component.ts
Normal file
@ -0,0 +1,490 @@
|
||||
import { Component, AfterViewInit, ElementRef, ViewChild, OnDestroy, OnInit, NgZone, ChangeDetectorRef, AfterViewChecked } from '@angular/core';
|
||||
import { MenuService } from './app.menu.service';
|
||||
import { AuthService } from './domain/services/auth.service';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { ConfirmationService } from 'primeng-lts/api';
|
||||
import { DomSanitizer } from '@angular/platform-browser';
|
||||
import { Observable, combineLatest } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import cloneDeep from 'clone-deep';
|
||||
import { globals } from './shared/global';
|
||||
import { AppConfigService } from './domain/services/app-config.service';
|
||||
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,
|
||||
OVERLAY,
|
||||
SLIM,
|
||||
HORIZONTAL
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-main',
|
||||
templateUrl: './app.main.component.html'
|
||||
})
|
||||
export class AppMainComponent implements AfterViewInit, OnDestroy, OnInit, AfterViewChecked {
|
||||
|
||||
layoutCompact = true;
|
||||
|
||||
layoutMode: MenuOrientation = MenuOrientation.HORIZONTAL;
|
||||
|
||||
darkMenu = false;
|
||||
|
||||
profileMode = 'inline';
|
||||
|
||||
rotateMenuButton: boolean;
|
||||
|
||||
topbarMenuActive: boolean;
|
||||
|
||||
overlayMenuActive: boolean;
|
||||
|
||||
staticMenuDesktopInactive: boolean;
|
||||
|
||||
staticMenuMobileActive: boolean;
|
||||
|
||||
rightPanelActive: boolean;
|
||||
|
||||
rightPanelClick: boolean;
|
||||
|
||||
layoutContainer: HTMLDivElement;
|
||||
|
||||
menuClick: boolean;
|
||||
|
||||
topbarItemClick: boolean;
|
||||
|
||||
activeTopbarItem: any;
|
||||
|
||||
menuHoverActive: boolean;
|
||||
|
||||
configActive: boolean;
|
||||
|
||||
configClick: boolean;
|
||||
|
||||
@ViewChild('layoutContainer') layourContainerViewChild: ElementRef;
|
||||
|
||||
rippleInitListener: any;
|
||||
|
||||
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'];
|
||||
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)
|
||||
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);
|
||||
}
|
||||
|
||||
init() {
|
||||
this.rippleMouseDownListener = this.rippleMouseDown.bind(this);
|
||||
document.addEventListener('mousedown', this.rippleMouseDownListener, false);
|
||||
}
|
||||
|
||||
rippleMouseDown(e) {
|
||||
const parentNode = 'parentNode';
|
||||
for (let target = e.target; target && target !== this; target = target[parentNode]) {
|
||||
if (!this.isVisible(target)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Element.matches() -> https://developer.mozilla.org/en-US/docs/Web/API/Element/matches
|
||||
if (this.selectorMatches(target, '.ripplelink, .ui-button, .ui-listbox-item, .ui-multiselect-item, .ui-fieldset-toggler')) {
|
||||
const element = target;
|
||||
this.rippleEffect(element, e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selectorMatches(el, selector) {
|
||||
const matches = 'matches';
|
||||
const webkitMatchesSelector = 'webkitMatchesSelector';
|
||||
const mozMatchesSelector = 'mozMatchesSelector';
|
||||
const msMatchesSelector = 'msMatchesSelector';
|
||||
const p = Element.prototype;
|
||||
const f = p[matches] || p[webkitMatchesSelector] || p[mozMatchesSelector] || p[msMatchesSelector] || function (s) {
|
||||
return [].indexOf.call(document.querySelectorAll(s), this) !== -1;
|
||||
};
|
||||
return f.call(el, selector);
|
||||
}
|
||||
|
||||
isVisible(el) {
|
||||
return !!(el.offsetWidth || el.offsetHeight);
|
||||
}
|
||||
|
||||
rippleEffect(element, e) {
|
||||
if (element.querySelector('.ink') === null) {
|
||||
const inkEl = document.createElement('span');
|
||||
this.addClass(inkEl, 'ink');
|
||||
|
||||
if (this.hasClass(element, 'ripplelink') && element.querySelector('span')) {
|
||||
element.querySelector('span').insertAdjacentHTML('afterend', '<span class=\'ink\'></span>');
|
||||
} else {
|
||||
element.appendChild(inkEl);
|
||||
}
|
||||
}
|
||||
|
||||
const ink = element.querySelector('.ink');
|
||||
this.removeClass(ink, 'ripple-animate');
|
||||
|
||||
if (!ink.offsetHeight && !ink.offsetWidth) {
|
||||
const d = Math.max(element.offsetWidth, element.offsetHeight);
|
||||
ink.style.height = d + 'px';
|
||||
ink.style.width = d + 'px';
|
||||
}
|
||||
|
||||
const x = e.pageX - this.getOffset(element).left - (ink.offsetWidth / 2);
|
||||
const y = e.pageY - this.getOffset(element).top - (ink.offsetHeight / 2);
|
||||
|
||||
ink.style.top = y + 'px';
|
||||
ink.style.left = x + 'px';
|
||||
ink.style.pointerEvents = 'none';
|
||||
this.addClass(ink, 'ripple-animate');
|
||||
}
|
||||
hasClass(element, className) {
|
||||
if (element.classList) {
|
||||
return element.classList.contains(className);
|
||||
} else {
|
||||
return new RegExp('(^| )' + className + '( |$)', 'gi').test(element.className);
|
||||
}
|
||||
}
|
||||
|
||||
addClass(element, className) {
|
||||
if (element.classList) {
|
||||
element.classList.add(className);
|
||||
} else {
|
||||
element.className += ' ' + className;
|
||||
}
|
||||
}
|
||||
|
||||
removeClass(element, className) {
|
||||
if (element.classList) {
|
||||
element.classList.remove(className);
|
||||
} else {
|
||||
element.className = element.className.replace(new RegExp('(^|\\b)' + className.split(' ').join('|') + '(\\b|$)', 'gi'), ' ');
|
||||
}
|
||||
}
|
||||
|
||||
getOffset(el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
top: rect.top + (window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0),
|
||||
left: rect.left + (window.pageXOffset || document.documentElement.scrollLeft || document.body.scrollLeft || 0),
|
||||
};
|
||||
}
|
||||
|
||||
unbindRipple() {
|
||||
if (this.rippleInitListener) {
|
||||
document.removeEventListener('DOMContentLoaded', this.rippleInitListener);
|
||||
}
|
||||
if (this.rippleMouseDownListener) {
|
||||
document.removeEventListener('mousedown', this.rippleMouseDownListener);
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
if (!this.topbarItemClick) {
|
||||
this.activeTopbarItem = null;
|
||||
this.topbarMenuActive = false;
|
||||
}
|
||||
|
||||
if (!this.menuClick) {
|
||||
if (this.isHorizontal() || this.isSlim()) {
|
||||
this.menuService.reset();
|
||||
}
|
||||
|
||||
if (this.overlayMenuActive || this.staticMenuMobileActive) {
|
||||
this.hideOverlayMenu();
|
||||
}
|
||||
|
||||
this.menuHoverActive = false;
|
||||
}
|
||||
|
||||
if (!this.rightPanelClick) {
|
||||
this.rightPanelActive = false;
|
||||
}
|
||||
|
||||
if (this.configActive && !this.configClick) {
|
||||
this.configActive = false;
|
||||
}
|
||||
|
||||
this.configClick = false;
|
||||
this.topbarItemClick = false;
|
||||
this.menuClick = false;
|
||||
this.rightPanelClick = false;
|
||||
}
|
||||
|
||||
onMenuButtonClick(event) {
|
||||
this.menuClick = true;
|
||||
this.rotateMenuButton = !this.rotateMenuButton;
|
||||
this.topbarMenuActive = false;
|
||||
|
||||
if (this.layoutMode === MenuOrientation.OVERLAY) {
|
||||
this.overlayMenuActive = !this.overlayMenuActive;
|
||||
} else {
|
||||
if (this.isDesktop()) {
|
||||
this.staticMenuDesktopInactive = !this.staticMenuDesktopInactive;
|
||||
} else {
|
||||
this.staticMenuMobileActive = !this.staticMenuMobileActive;
|
||||
}
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
onMenuClick($event) {
|
||||
this.menuClick = true;
|
||||
}
|
||||
|
||||
onTopbarMenuButtonClick(event) {
|
||||
this.topbarItemClick = true;
|
||||
this.topbarMenuActive = !this.topbarMenuActive;
|
||||
|
||||
this.hideOverlayMenu();
|
||||
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
onTopbarItemClick(event, item) {
|
||||
this.topbarItemClick = true;
|
||||
|
||||
if (this.activeTopbarItem === item) {
|
||||
this.activeTopbarItem = null;
|
||||
} else {
|
||||
this.activeTopbarItem = item;
|
||||
}
|
||||
|
||||
this.onTopMenuClick(item);
|
||||
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
onTopbarSubItemClick(event, item) {
|
||||
|
||||
this.onTopMenuClick(item);
|
||||
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
private onTopMenuClick(item) {
|
||||
if (!item) return;
|
||||
if (item && item.id) {
|
||||
switch (item.id) {
|
||||
case 'top_notification':
|
||||
this.showPaidPopup();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
onRightPanelButtonClick(event) {
|
||||
this.rightPanelClick = true;
|
||||
this.rightPanelActive = !this.rightPanelActive;
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
onRightPanelClick() {
|
||||
this.rightPanelClick = true;
|
||||
}
|
||||
|
||||
onConfigClick(event) {
|
||||
this.configClick = true;
|
||||
}
|
||||
|
||||
hideOverlayMenu() {
|
||||
this.rotateMenuButton = false;
|
||||
this.overlayMenuActive = false;
|
||||
this.staticMenuMobileActive = false;
|
||||
}
|
||||
|
||||
isTablet() {
|
||||
const width = window.innerWidth;
|
||||
return width <= 1024 && width > 640;
|
||||
}
|
||||
|
||||
isDesktop() {
|
||||
return window.innerWidth > 1024;
|
||||
}
|
||||
|
||||
isMobile() {
|
||||
return window.innerWidth <= 640;
|
||||
}
|
||||
|
||||
isOverlay() {
|
||||
return this.layoutMode === MenuOrientation.OVERLAY;
|
||||
}
|
||||
|
||||
isStatic() {
|
||||
return this.layoutMode === MenuOrientation.STATIC;
|
||||
}
|
||||
|
||||
isHorizontal() {
|
||||
return this.layoutMode === MenuOrientation.HORIZONTAL;
|
||||
}
|
||||
|
||||
isSlim() {
|
||||
return this.layoutMode === MenuOrientation.SLIM;
|
||||
}
|
||||
|
||||
changeToStaticMenu() {
|
||||
this.layoutMode = MenuOrientation.STATIC;
|
||||
}
|
||||
|
||||
changeToOverlayMenu() {
|
||||
this.layoutMode = MenuOrientation.OVERLAY;
|
||||
}
|
||||
|
||||
changeToHorizontalMenu() {
|
||||
this.layoutMode = MenuOrientation.HORIZONTAL;
|
||||
}
|
||||
|
||||
changeToSlimMenu() {
|
||||
this.layoutMode = MenuOrientation.SLIM;
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.unbindRipple();
|
||||
}
|
||||
|
||||
get showFooter() {
|
||||
return (
|
||||
!(['/editMap', '/areas', '/track']
|
||||
.filter(it => {
|
||||
const re = new RegExp(it.toLowerCase(), 'i');
|
||||
return this.router.url.toLocaleLowerCase().match(re);
|
||||
})).length
|
||||
);
|
||||
}
|
||||
|
||||
get isAdmin() {
|
||||
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);
|
||||
}
|
||||
|
||||
showPaidPopup() {
|
||||
if (!this.shouldShowPaidMsg) return false; // 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>
|
||||
`;
|
||||
|
||||
this.confirmSvc.confirm({
|
||||
key: 'okOnly',
|
||||
header: globals.attention + '!',
|
||||
rejectVisible: true,
|
||||
message: <any>this.sanitizer.bypassSecurityTrustHtml(msgHtml), // avoid warning in console
|
||||
accept: () => {
|
||||
if (this.settings.noPopup != this.appConfSvc.settings.noPopup) {
|
||||
this.appConfSvc.settings.noPopup = this.settings.noPopup;
|
||||
this.appConfSvc.save({ noPopup: this.settings.noPopup }, true); // Save settings to BE
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
241
Development/client/src/app/app.menu.component.ts
Normal file
241
Development/client/src/app/app.menu.component.ts
Normal file
@ -0,0 +1,241 @@
|
||||
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',
|
||||
template: `
|
||||
<ul class="ultima-menu ultima-main-menu clearfix">
|
||||
<li app-menuitem *ngFor="let item of model; let i = index;" [item]="item" [index]="i" [root]="true"></li>
|
||||
</ul>
|
||||
`
|
||||
})
|
||||
export class AppMenuComponent implements OnInit {
|
||||
model: any[] = [];
|
||||
|
||||
constructor(
|
||||
public readonly app: AppMainComponent,
|
||||
private readonly authSvc: AuthService,
|
||||
private readonly store: Store<{}>
|
||||
) { }
|
||||
|
||||
ngOnInit() {
|
||||
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'],
|
||||
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'] }
|
||||
]
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
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'] });
|
||||
}
|
||||
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'] }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
24
Development/client/src/app/app.menu.service.ts
Normal file
24
Development/client/src/app/app.menu.service.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
@Injectable()
|
||||
export class MenuService {
|
||||
|
||||
private menuSource = new Subject<MenuState>();
|
||||
private resetSource = new Subject();
|
||||
|
||||
menuSource$ = this.menuSource.asObservable();
|
||||
resetSource$ = this.resetSource.asObservable();
|
||||
|
||||
onMenuStateChange(state: MenuState) {
|
||||
this.menuSource.next(state);
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.resetSource.next();
|
||||
}
|
||||
}
|
||||
|
||||
export interface MenuState {
|
||||
key: string, pkey?: string
|
||||
}
|
||||
200
Development/client/src/app/app.menuitem.component.ts
Normal file
200
Development/client/src/app/app.menuitem.component.ts
Normal file
@ -0,0 +1,200 @@
|
||||
import { Component, Input, OnInit, OnDestroy } from '@angular/core';
|
||||
import { Router, NavigationEnd } from '@angular/router';
|
||||
import { trigger, state, style, transition, animate } from '@angular/animations';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { filter } from 'rxjs/operators';
|
||||
import { MenuService, MenuState } from './app.menu.service';
|
||||
import { AppMainComponent } from './app.main.component';
|
||||
|
||||
const DUMP_PKEY = '-1';
|
||||
|
||||
@Component({
|
||||
/* tslint:disable:component-selector */
|
||||
selector: '[app-menuitem]',
|
||||
/* tslint:enable:component-selector */
|
||||
template: `
|
||||
<ng-container>
|
||||
<a [attr.href]="item.url" (click)="itemClick($event)" *ngIf="!item.routerLink || item.items"
|
||||
(mouseenter)="onMouseEnter()" class="ripplelink"
|
||||
[ngClass]="{'active-menuitem-routerlink': selected }"
|
||||
[attr.target]="item.target" [attr.tabindex]="0">
|
||||
<i *ngIf="item.icon" class="material-icons">{{item.icon}}</i>
|
||||
<span>{{item.label}}</span>
|
||||
<span class="menuitem-badge" *ngIf="item.badge">{{item.badge}}</span>
|
||||
<i class="material-icons submenu-icon" *ngIf="item.items">keyboard_arrow_down</i>
|
||||
</a>
|
||||
<a (click)="itemClick($event)" (mouseenter)="onMouseEnter()" *ngIf="item.routerLink && !item.items"
|
||||
[routerLink]="item.routerLink" routerLinkActive="active-menuitem-routerlink" class="ripplelink"
|
||||
[routerLinkActiveOptions]="{exact: true}" [attr.target]="item.target" [attr.tabindex]="0">
|
||||
<i *ngIf="item.icon" class="material-icons">{{item.icon}}</i>
|
||||
<span>{{item.label}}</span>
|
||||
<span class="menuitem-badge" *ngIf="item.badge">{{item.badge}}</span>
|
||||
<i class="material-icons submenu-icon" *ngIf="item.items">keyboard_arrow_down</i>
|
||||
</a>
|
||||
<div class="layout-menu-tooltip">
|
||||
<div class="layout-menu-tooltip-arrow"></div>
|
||||
<div class="layout-menu-tooltip-text">{{item.label}}</div>
|
||||
</div>
|
||||
<ul *ngIf="item.items && active" [@children]="((app.isSlim() || app.isHorizontal()) && root) ? (active ? 'visible' : 'hidden') :
|
||||
(active ? 'visibleAnimated' : 'hiddenAnimated')" >
|
||||
<ng-template ngFor let-child let-i="index" [ngForOf]="item.items">
|
||||
<li app-menuitem [item]="child" [index]="i" [parentKey]="key" [class]="child.badgeClass"></li>
|
||||
</ng-template>
|
||||
</ul>
|
||||
</ng-container>
|
||||
`,
|
||||
host: {
|
||||
'[class.active-menuitem]': 'active'
|
||||
},
|
||||
animations: [
|
||||
trigger('children', [
|
||||
state('void', style({
|
||||
height: '0px'
|
||||
})),
|
||||
state('hiddenAnimated', style({
|
||||
height: '0px'
|
||||
})),
|
||||
state('visibleAnimated', style({
|
||||
height: '*'
|
||||
})),
|
||||
state('visible', style({
|
||||
height: '*',
|
||||
'z-index': 100
|
||||
})),
|
||||
state('hidden', style({
|
||||
height: '0px',
|
||||
'z-index': '*'
|
||||
})),
|
||||
transition('visibleAnimated => hiddenAnimated', animate('400ms cubic-bezier(0.86, 0, 0.07, 1)')),
|
||||
transition('hiddenAnimated => visibleAnimated', animate('400ms cubic-bezier(0.86, 0, 0.07, 1)')),
|
||||
transition('void => visibleAnimated, visibleAnimated => void',
|
||||
animate('400ms cubic-bezier(0.86, 0, 0.07, 1)'))
|
||||
])
|
||||
]
|
||||
})
|
||||
export class AppMenuitemComponent implements OnInit, OnDestroy {
|
||||
|
||||
@Input() item: any;
|
||||
|
||||
@Input() index: number;
|
||||
|
||||
@Input() root: boolean;
|
||||
|
||||
@Input() parentKey: string;
|
||||
|
||||
active: boolean = false;
|
||||
|
||||
menuSourceSubscription: Subscription;
|
||||
|
||||
menuResetSubscription: Subscription;
|
||||
|
||||
key: string;
|
||||
|
||||
// Added: Use to select the parent menu item when any of its children selected
|
||||
selected: boolean = false;
|
||||
|
||||
constructor(public app: AppMainComponent, public router: Router, private menuService: MenuService) {
|
||||
this.menuSourceSubscription = this.menuService.menuSource$.subscribe(state => {
|
||||
const key = state.key;
|
||||
// deactivate current active menu
|
||||
if (this.active && this.key !== key && key.indexOf(this.key) !== 0) {
|
||||
this.active = false;
|
||||
}
|
||||
// Added: When a child menu was selected, check to set the activated link style class to its parent (root) menu
|
||||
if (this.root && (state.pkey && state.pkey != DUMP_PKEY)) {
|
||||
this.selected = (this.key == state.pkey);
|
||||
}
|
||||
});
|
||||
|
||||
this.menuResetSubscription = this.menuService.resetSource$.subscribe(() => {
|
||||
this.active = false;
|
||||
});
|
||||
|
||||
this.router.events.pipe(filter(event => event instanceof NavigationEnd))
|
||||
.subscribe(params => {
|
||||
if (this.app.isHorizontal() || this.app.isSlim()) {
|
||||
this.active = false;
|
||||
} else {
|
||||
if (this.item.routerLink) {
|
||||
this.updateActiveStateFromRoute();
|
||||
} else {
|
||||
this.active = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
if ((!this.app.isHorizontal() || !this.app.isSlim()) && this.item.routerLink) {
|
||||
this.updateActiveStateFromRoute();
|
||||
}
|
||||
|
||||
this.key = this.parentKey ? this.parentKey + '-' + this.index : String(this.index);
|
||||
}
|
||||
|
||||
updateActiveStateFromRoute() {
|
||||
this.active = this.router.isActive(this.item.routerLink[0], this.item.items ? false : true);
|
||||
|
||||
// Added: check and set selected on the root one based on active route in the case of reloaded
|
||||
if (this.active && this.root)
|
||||
this.selected = true;
|
||||
}
|
||||
|
||||
itemClick(event: Event) {
|
||||
// avoid processing disabled items
|
||||
if (this.item.disabled) {
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
|
||||
// navigate with hover in horizontal mode
|
||||
if (this.root) {
|
||||
this.app.menuHoverActive = !this.app.menuHoverActive;
|
||||
}
|
||||
|
||||
// notify other items, Added: do when click on one has no child only
|
||||
if (!this.item.items)
|
||||
this.menuService.onMenuStateChange(<MenuState>{ key: this.key, pkey: this.root ? this.key : this.parentKey });
|
||||
|
||||
// execute command
|
||||
if (this.item.command) {
|
||||
this.item.command({ originalEvent: event, item: this.item });
|
||||
}
|
||||
|
||||
// toggle active state
|
||||
if (this.item.items) {
|
||||
this.active = !this.active;
|
||||
} else {
|
||||
// activate item
|
||||
this.active = true;
|
||||
|
||||
// reset horizontal menu
|
||||
if (this.app.isHorizontal() || this.app.isSlim()) {
|
||||
this.menuService.reset();
|
||||
}
|
||||
|
||||
// hide overlay menus
|
||||
this.app.overlayMenuActive = false;
|
||||
this.app.staticMenuMobileActive = false;
|
||||
this.app.menuHoverActive = !this.app.menuHoverActive;
|
||||
}
|
||||
}
|
||||
|
||||
onMouseEnter() {
|
||||
// activate item on hover
|
||||
if (this.root && this.app.menuHoverActive && (this.app.isHorizontal() || this.app.isSlim()) && this.app.isDesktop()) {
|
||||
this.menuService.onMenuStateChange(<MenuState>{ key: this.key, pkey: DUMP_PKEY });
|
||||
this.active = true;
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
if (this.menuSourceSubscription) {
|
||||
this.menuSourceSubscription.unsubscribe();
|
||||
}
|
||||
|
||||
if (this.menuResetSubscription) {
|
||||
this.menuResetSubscription.unsubscribe();
|
||||
}
|
||||
}
|
||||
}
|
||||
157
Development/client/src/app/app.module.ts
Normal file
157
Development/client/src/app/app.module.ts
Normal file
@ -0,0 +1,157 @@
|
||||
import { APP_INITIALIZER, 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';
|
||||
|
||||
import { MessagesModule } from 'primeng/messages';
|
||||
import { DropdownModule } from 'primeng/dropdown';
|
||||
import { InputTextModule } from 'primeng/inputtext';
|
||||
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';
|
||||
import { ScrollPanelModule } from 'primeng/scrollpanel';
|
||||
import { CheckboxModule } from 'primeng/checkbox';
|
||||
|
||||
import { AppComponent } from './app.component';
|
||||
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 { AppInlineProfileComponent } from './app.profile.component';
|
||||
|
||||
import { DashboardComponent } from './dashboard/dashboard.component';
|
||||
import { ReportComponent } from './report.component';
|
||||
import { PageNotFoundComponent } from './page-not-found.component';
|
||||
|
||||
import { StoreModule, ActionsSubject } from '@ngrx/store';
|
||||
import { EffectsModule } from '@ngrx/effects';
|
||||
import { AppDispatcher } from './app-actions';
|
||||
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
|
||||
import { reducers, metaReducers } from './reducers';
|
||||
|
||||
import { MenuService } from './app.menu.service';
|
||||
import { JobService } from './domain/services/job.service';
|
||||
import { ClientService } from './domain/services/client.service';
|
||||
import { PilotService } from './domain/services/pilot.service';
|
||||
import { ProductService } from './domain/services/product.service';
|
||||
import { VehicleService } from './domain/services/vehicle.service';
|
||||
import { CustomerService } from './domain/services/customer.service';
|
||||
import { UserService } from './domain/services/user.service';
|
||||
import { GeocodeService } from './shared/geocode.service';
|
||||
import { TrackService } from './domain/services/track.service';
|
||||
|
||||
import { AppRoutingModule } from './app-routing.module';
|
||||
|
||||
import { environment } from '@environments/environment';
|
||||
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 { 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';
|
||||
import { MapBaseComp } from './shared/base/map-base.component';
|
||||
import { MapEditBaseComp } from './shared/base/mapedit-base.component';
|
||||
import { BillingService } from './domain/services/billing.service';
|
||||
import { AppPasswordResetComp } from './pages/app.password-reset.component';
|
||||
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;
|
||||
export function translationsFactory(locale: string) {
|
||||
locale = Utils.getLang(locale)
|
||||
if (locale === 'en') // default, does not need to load translation file
|
||||
return '';
|
||||
else
|
||||
return require(`raw-loader!../locale/messages.${locale}.xlf`).default;
|
||||
}
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
BrowserModule, BrowserAnimationsModule, HttpClientModule, GlobalModule,
|
||||
InputTextModule, ButtonModule, MenuModule, ProgressSpinnerModule, ScrollPanelModule,
|
||||
MessagesModule, ToastModule, ConfirmDialogModule, DialogModule, DropdownModule, CheckboxModule, AppSharedModule,
|
||||
// The store that defines our app state
|
||||
StoreModule.forRoot(reducers, {
|
||||
metaReducers,
|
||||
runtimeChecks: {
|
||||
strictStateImmutability: false,
|
||||
strictActionImmutability: false,
|
||||
// disabled until https://github.com/ngrx/platform/issues/2109 is resolved
|
||||
/* strictActionImmutability: true, */
|
||||
},
|
||||
}),
|
||||
// Must instrument after importing StoreModule
|
||||
StoreDevtoolsModule.instrument({ name: 'AgMission', maxAge: 15, logOnly: environment.production }),
|
||||
AppRoutingModule,
|
||||
EffectsModule.forRoot([AppEffects, SubPlansEffects, RoutingEffects, SubscriptionEffects]),
|
||||
],
|
||||
declarations: [
|
||||
BaseComp,
|
||||
MapBaseComp,
|
||||
MapEditBaseComp,
|
||||
PageNotFoundComponent,
|
||||
DashboardComponent,
|
||||
AppComponent,
|
||||
AppMainComponent,
|
||||
AppMenuComponent,
|
||||
AppMenuitemComponent,
|
||||
AppInlineProfileComponent,
|
||||
AppTopbarComponent,
|
||||
ReportComponent,
|
||||
AppPasswordResetComp
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: TRANSLATIONS,
|
||||
useFactory: translationsFactory,
|
||||
deps: [LOCALE_ID]
|
||||
},
|
||||
{ provide: TRANSLATIONS_FORMAT, useValue: 'xlf' },
|
||||
AppConfigService,
|
||||
HttpCancelService,
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: ManageHttpInterceptor, multi: true },
|
||||
{
|
||||
provide: HTTP_INTERCEPTORS,
|
||||
useClass: AuthInterceptor,
|
||||
multi: true
|
||||
},
|
||||
{
|
||||
provide: HTTP_INTERCEPTORS,
|
||||
useClass: GlobalErrorInterceptor,
|
||||
multi: true
|
||||
},
|
||||
{
|
||||
provide: ActionsSubject, useClass: AppDispatcher
|
||||
},
|
||||
MenuService, MessageService, ConfirmationService, DynamicDialogRef, DynamicDialogConfig,
|
||||
SettingsGuard, UserService, CustomerService, ClientService, JobService, ProductService, PilotService, TrackService,
|
||||
VehicleService, GeocodeService, BillingService, InvoiceService
|
||||
],
|
||||
bootstrap: [AppComponent],
|
||||
exports: [],
|
||||
entryComponents: []
|
||||
})
|
||||
|
||||
export class AppModule {
|
||||
constructor(private readonly injector: Injector) {
|
||||
AppInjector.setInjector(injector);
|
||||
}
|
||||
}
|
||||
22
Development/client/src/app/app.profile.component.css
Normal file
22
Development/client/src/app/app.profile.component.css
Normal file
@ -0,0 +1,22 @@
|
||||
.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;
|
||||
}
|
||||
36
Development/client/src/app/app.profile.component.html
Normal file
36
Development/client/src/app/app.profile.component.html
Normal file
@ -0,0 +1,36 @@
|
||||
<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>
|
||||
138
Development/client/src/app/app.profile.component.ts
Normal file
138
Development/client/src/app/app.profile.component.ts
Normal file
@ -0,0 +1,138 @@
|
||||
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';
|
||||
|
||||
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('; ');
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "app-inline-profile",
|
||||
templateUrl: "./app.profile.component.html",
|
||||
styleUrls: ['./app.profile.component.css']
|
||||
})
|
||||
export class AppInlineProfileComponent {
|
||||
readonly globals = globals;
|
||||
|
||||
@Input() user: UserModel;
|
||||
@Input() expiryWarning: ExpiryWarning | null;
|
||||
@Output() navigateToSubscription = new EventEmitter<void>();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
68
Development/client/src/app/app.topbar.component.html
Normal file
68
Development/client/src/app/app.topbar.component.html
Normal file
@ -0,0 +1,68 @@
|
||||
<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>
|
||||
105
Development/client/src/app/app.topbar.component.ts
Normal file
105
Development/client/src/app/app.topbar.component.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { Observable, Subscription, combineLatest } from 'rxjs';
|
||||
import { first, filter, switchMap, map } from 'rxjs/operators';
|
||||
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'
|
||||
})
|
||||
export class AppTopbarComponent implements OnInit, OnDestroy {
|
||||
user$: Observable<UserModel>;
|
||||
expiryWarning$: Observable<ExpiryWarning | null>;
|
||||
private sub$ = new Subscription();
|
||||
|
||||
constructor(
|
||||
public readonly app: AppMainComponent,
|
||||
private readonly store: Store<{}>,
|
||||
private readonly router: Router,
|
||||
private readonly userSvc: UserService
|
||||
) {
|
||||
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]);
|
||||
}
|
||||
|
||||
onLogout(e) {
|
||||
this.store.dispatch(new authActions.Logout());
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
updateUserProfile(userId: string) {
|
||||
this.router.navigate([SUB.PROFILE, 'edit', userId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to manage subscription page
|
||||
* Triggered by subscription expiry notification click
|
||||
*/
|
||||
onNavigateToManageSubscription(): void {
|
||||
this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]);
|
||||
}
|
||||
}
|
||||
53
Development/client/src/app/auth/actions/auth.actions.ts
Normal file
53
Development/client/src/app/auth/actions/auth.actions.ts
Normal file
@ -0,0 +1,53 @@
|
||||
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 {
|
||||
readonly type: typeof LOGIN = LOGIN;
|
||||
|
||||
constructor(public payload: Authenticate) { }
|
||||
}
|
||||
|
||||
export const LOGIN_SUCCESS = '[Auth API] Login success';
|
||||
export class LoginSuccess implements Action {
|
||||
readonly type: typeof LOGIN_SUCCESS = LOGIN_SUCCESS;
|
||||
|
||||
constructor(public payload: { user: UserModel }) { }
|
||||
}
|
||||
|
||||
export const LOGIN_FAILED = '[Auth API] Login failed';
|
||||
export class LoginFailed implements Action {
|
||||
readonly type: typeof LOGIN_FAILED = LOGIN_FAILED;
|
||||
|
||||
constructor(public payload: any) { }
|
||||
}
|
||||
|
||||
export const LOGOUT = '[Auth] Logout';
|
||||
export class Logout implements Action {
|
||||
readonly type: typeof LOGOUT = LOGOUT;
|
||||
|
||||
constructor(public was401: boolean = false) { }
|
||||
}
|
||||
|
||||
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 =
|
||||
| Login
|
||||
| LoginSuccess
|
||||
| LoginFailed
|
||||
| Logout
|
||||
| LogoutComplete
|
||||
| RefreshUserData;
|
||||
21
Development/client/src/app/auth/auth-routing.module.ts
Normal file
21
Development/client/src/app/auth/auth-routing.module.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
|
||||
import { LoginComponent } from './login/login.component';
|
||||
|
||||
const loginRoutes: Routes = [
|
||||
{
|
||||
path: '', component: LoginComponent
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild(loginRoutes)
|
||||
],
|
||||
exports: [
|
||||
RouterModule
|
||||
],
|
||||
providers: []
|
||||
})
|
||||
export class AuthRoutingModule { }
|
||||
24
Development/client/src/app/auth/auth.module.ts
Normal file
24
Development/client/src/app/auth/auth.module.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
|
||||
import { EffectsModule } from '@ngrx/effects';
|
||||
import { AuthEffects } from './effects/auth.effects';
|
||||
|
||||
import { AppSharedModule } from '../shared/app-shared.module';
|
||||
import { LoginComponent } from './login/login.component';
|
||||
import { AuthRoutingModule } from './auth-routing.module';
|
||||
import { NgxCaptchaModule } from 'ngx-captcha';
|
||||
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
AppSharedModule,
|
||||
EffectsModule.forFeature([AuthEffects]),
|
||||
AuthRoutingModule,
|
||||
NgxCaptchaModule
|
||||
],
|
||||
declarations: [
|
||||
LoginComponent,
|
||||
],
|
||||
entryComponents: [LoginComponent]
|
||||
})
|
||||
export class AuthModule { }
|
||||
78
Development/client/src/app/auth/effects/auth.effects.ts
Normal file
78
Development/client/src/app/auth/effects/auth.effects.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
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()
|
||||
login$ = this.actions$
|
||||
.pipe(
|
||||
ofType<authActions.Login>(authActions.LOGIN),
|
||||
map(action => action.payload),
|
||||
exhaustMap(auth => {
|
||||
return this.authSvc.login(auth).pipe(
|
||||
map(user => {
|
||||
return new authActions.LoginSuccess({ 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 })
|
||||
loginRedirect$ = this.actions$
|
||||
.pipe(
|
||||
ofType<authActions.LoginSuccess>(authActions.LOGIN_SUCCESS),
|
||||
tap(({ payload }) => {
|
||||
const uLang = payload.user ? payload.user['lang'] : 'en';
|
||||
if (this.authSvc.isClientUser) {
|
||||
this.clientSvc.getClient(payload.user._id).subscribe(client => {
|
||||
this.store.dispatch(new clientActions.FetchSuccess([client]));
|
||||
this.store.dispatch(new clientActions.Select(client));
|
||||
this.navigateDefault(uLang);
|
||||
});
|
||||
} else {
|
||||
this.navigateDefault(uLang);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@Effect()
|
||||
logout$ = this.actions$
|
||||
.pipe(
|
||||
ofType<authActions.Logout>(authActions.LOGOUT),
|
||||
exhaustMap(
|
||||
_ => this.authSvc.logout().pipe(
|
||||
tap(() => this.router.navigate(['/login'], { replaceUrl: true })),
|
||||
map(() => new authActions.LogoutComplete()),
|
||||
catchError(() => of(new authActions.LogoutComplete())),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
constructor(
|
||||
private readonly store: Store<{}>,
|
||||
private readonly actions$: Actions,
|
||||
private readonly authSvc: AuthService,
|
||||
private readonly clientSvc: ClientService,
|
||||
private readonly router: Router
|
||||
) { }
|
||||
}
|
||||
63
Development/client/src/app/auth/login/login.component.html
Normal file
63
Development/client/src/app/auth/login/login.component.html
Normal file
@ -0,0 +1,63 @@
|
||||
<div class="card login-panel ui-fluid" style="min-width: 400px ;">
|
||||
<div class="ui-g">
|
||||
<div class="ui-g-12">
|
||||
<img src="/assets/images/agnav-logo.png">
|
||||
</div>
|
||||
<form name="form" #f="ngForm" (ngSubmit)="onSubmit()">
|
||||
<div *ngIf="error$ | async" class="ui-g-12 login-error">
|
||||
<span class="ui-message ui-messages-error">{{ error$ | async}}</span>
|
||||
</div>
|
||||
|
||||
<p-messages [(value)]="msgs"></p-messages>
|
||||
|
||||
<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">
|
||||
{{ userValidMsg() }}
|
||||
</span>
|
||||
<label i18n="@@userName">Username</label>
|
||||
</span>
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
<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="">
|
||||
<ng-container i18n="@@forgotPwd">Forgot password</ng-container> ?
|
||||
</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>
|
||||
178
Development/client/src/app/auth/login/login.component.ts
Normal file
178
Development/client/src/app/auth/login/login.component.ts
Normal file
@ -0,0 +1,178 @@
|
||||
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';
|
||||
import * as fromStore from '../../reducers';
|
||||
|
||||
import { StringUtils } from '@app/shared/utils';
|
||||
import { GC, globals } from '@app/shared/global';
|
||||
import { environment } from '@environments/environment';
|
||||
import { BaseComp } from '@app/shared/base/base.component';
|
||||
|
||||
|
||||
@Component({
|
||||
templateUrl: './login.component.html',
|
||||
styleUrls: ['./login.component.css']
|
||||
})
|
||||
export class LoginComponent extends BaseComp implements OnInit, OnDestroy {
|
||||
readonly GC = GC;
|
||||
|
||||
@ViewChild("captchaElem") captchaElem: ReCaptcha2Component; // it is used for getting reaponse (token)
|
||||
public msgs: any[];
|
||||
|
||||
public model: any = {};
|
||||
|
||||
public error$ = this.store.select(fromStore.selectLoginError);
|
||||
public pending$ = this.store.select(fromStore.selectLoginPending);
|
||||
|
||||
public useReCaptcha = !isDevMode();
|
||||
public readonly siteKey = environment.v2sk;
|
||||
public theme: "light" | "dark" = "light";
|
||||
public size: "compact" | "normal" = "normal";
|
||||
public lang = "en";
|
||||
public type: "image" | "audio";
|
||||
|
||||
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 });
|
||||
}
|
||||
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();
|
||||
this.captchaSuccess = false;
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
this.login();
|
||||
}
|
||||
|
||||
login() {
|
||||
this.store.dispatch(new authActions.Login(<Authenticate>{ username: this.model.username, password: this.model.password }));
|
||||
}
|
||||
|
||||
userValidMsg() {
|
||||
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();
|
||||
this.authSvc.siteVerify({ tk: captchaResp }).subscribe(
|
||||
res => {
|
||||
const respAt = Date.now();
|
||||
if (!res || !res['success'] || res['hostname'] !== window.location.hostname || this._lastVerReqAt && (respAt - this._lastVerReqAt) > 2 * 60 * 1000) {
|
||||
this.handleSSVerFailed();
|
||||
} else {
|
||||
this.captchaSuccess = true;
|
||||
}
|
||||
},
|
||||
err => {
|
||||
this.handleSSVerFailed();
|
||||
},
|
||||
() => {
|
||||
this._lastVerReqAt = 0;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private handleSSVerFailed() {
|
||||
this.handleExpire();
|
||||
}
|
||||
|
||||
handleLoad(): void {
|
||||
this.captchaSuccess = false;
|
||||
}
|
||||
|
||||
handleReload(): void {
|
||||
this.captchaElem.reloadCaptcha();
|
||||
this.captchaSuccess = false;
|
||||
}
|
||||
|
||||
handleExpire(): void {
|
||||
this.captchaElem.resetCaptcha();
|
||||
this.captchaSuccess = false;
|
||||
}
|
||||
|
||||
handleReset(): void {
|
||||
this.captchaSuccess = false;
|
||||
}
|
||||
|
||||
handleError(e) {
|
||||
this.handleSSVerFailed();
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
super.ngOnDestroy();
|
||||
}
|
||||
}
|
||||
4
Development/client/src/app/auth/models/auth.model.ts
Normal file
4
Development/client/src/app/auth/models/auth.model.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export interface Authenticate {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
27
Development/client/src/app/auth/models/user.model.ts
Normal file
27
Development/client/src/app/auth/models/user.model.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { AGNavSubscription, Trial } from "@app/domain/models/subscription.model";
|
||||
|
||||
export interface UserModel {
|
||||
_id: string;
|
||||
name: string;
|
||||
username: string;
|
||||
roles: any;
|
||||
parent: any;
|
||||
lang: string;
|
||||
pre: number;
|
||||
billable?: boolean;
|
||||
membership?: IMembership,
|
||||
contact: string;
|
||||
country?: string;
|
||||
partner?: string;
|
||||
}
|
||||
|
||||
export interface IMembership {
|
||||
custId: string;
|
||||
endOfPeriod?: Number;
|
||||
subscriptions?: AGNavSubscription[];
|
||||
trials?: Trial;
|
||||
customLimits?: {
|
||||
maxVehicles?: number | null;
|
||||
maxAcres?: number | null;
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<router-outlet></router-outlet>
|
||||
`
|
||||
})
|
||||
export class BillingMgtComponent { }
|
||||
40
Development/client/src/app/billing/billing-routing.module.ts
Normal file
40
Development/client/src/app/billing/billing-routing.module.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
|
||||
import { AuthGuard } from '../domain/guards/auth.guard';
|
||||
import { RoleIds } from '../shared/global';
|
||||
import { BillingMgtComponent } from './billing-mgt.component';
|
||||
import { UsageListComponent } from './usage-list/usage-list.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: BillingMgtComponent,
|
||||
data: {
|
||||
roles: [RoleIds.ADMIN]
|
||||
},
|
||||
canActivate: [AuthGuard],
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
component: UsageListComponent,
|
||||
data: {
|
||||
roles: [RoleIds.ADMIN]
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild(routes)
|
||||
],
|
||||
exports: [
|
||||
RouterModule
|
||||
],
|
||||
providers: [
|
||||
AuthGuard
|
||||
]
|
||||
})
|
||||
export class BillingRoutingModule { }
|
||||
28
Development/client/src/app/billing/billing.module.ts
Normal file
28
Development/client/src/app/billing/billing.module.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { UsageListComponent } from './usage-list/usage-list.component';
|
||||
import { BillingRoutingModule } from './billing-routing.module';
|
||||
import { BillingMgtComponent } from './billing-mgt.component';
|
||||
import { ButtonModule } from 'primeng/button';
|
||||
import { CalendarModule } from 'primeng/calendar';
|
||||
import { TableModule } from 'primeng/table';
|
||||
import { CheckboxModule } from 'primeng/checkbox';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
BillingMgtComponent,
|
||||
UsageListComponent,
|
||||
],
|
||||
imports: [
|
||||
CommonModule, FormsModule,
|
||||
CalendarModule, ButtonModule, TableModule, CheckboxModule,
|
||||
BillingRoutingModule
|
||||
],
|
||||
schemas: [
|
||||
CUSTOM_ELEMENTS_SCHEMA
|
||||
],
|
||||
})
|
||||
export class BillingModule { }
|
||||
@ -0,0 +1,73 @@
|
||||
<div class="ui-g">
|
||||
<div class="ui-g-12">
|
||||
<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>
|
||||
<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>
|
||||
<span style="padding-right: 12px;font-weight: bold;">To</span>
|
||||
<p-calendar [showIcon]="true" [(ngModel)]="toDate" [minDate]="fromDate" [maxDate]="maxToDate" (onDateSelected)="onDateSelected('to', $event)" [inputStyle]="{'width':'90px'}" placeholder="To Month" dateFormat="mm/yy" view="month" [readonlyInput]="true"></p-calendar>
|
||||
</div>
|
||||
<div>
|
||||
<p-checkbox id="billable" name="billable" label="Billable" [(ngModel)]="billable" binary="true"></p-checkbox>
|
||||
</div>
|
||||
<div>
|
||||
<button pButton icon="ui-icon-search" [disabled]="(!fromDate || !toDate)" (click)="findUsage()"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="usages && usages.length" class="ui-g-12">
|
||||
<p-table [value]="usages" [columns]="cols" [responsive]="true" styleClass="table-w-grid">
|
||||
<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>
|
||||
</div>
|
||||
<div class="ui-g-6 ui-g-nopad" style="text-align: right">
|
||||
<div style="display:inline-flex">
|
||||
<button pButton icon="ui-icon-grid-on" label="Export Detail" (click)="exportUsageDetail()"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
<ng-template pTemplate="header" let-columns>
|
||||
<tr>
|
||||
<th *ngFor="let col of columns" [pSortableColumn]="col.field" [width]="col.width">
|
||||
{{col.header}}
|
||||
<p-sortIcon [field]="col.field"></p-sortIcon>
|
||||
</th>
|
||||
</tr>
|
||||
</ng-template>
|
||||
<ng-template pTemplate="body" let-rowData let-columns="columns">
|
||||
<tr>
|
||||
<td *ngFor="let col of columns" [ngSwitch]="col.field">
|
||||
<span *ngSwitchCase="'customer'">{{rowData[col.field]}}</span>
|
||||
<div *ngSwitchDefault>
|
||||
<span *ngIf="rowData[col.field] == 0; else usage">{{rowData[col.field]}}</span>
|
||||
<ng-template #usage>
|
||||
<div>{{rowData[col.field] | number:'1.1-1':'en'}} ha</div>
|
||||
<div>{{haToAcres(rowData[col.field]) | number:'1.1-1':'en'}} ac</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
<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>
|
||||
<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>
|
||||
</ng-template>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</p-table>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,113 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { DateUtils, UnitUtils } from '@app/shared/utils';
|
||||
import { BillingService } from '@app/domain/services/billing.service';
|
||||
|
||||
import { saveAs } from 'file-saver';
|
||||
|
||||
@Component({
|
||||
selector: 'agm-usage-list',
|
||||
templateUrl: './usage-list.component.html',
|
||||
styleUrls: ['./usage-list.component.css']
|
||||
})
|
||||
export class UsageListComponent implements OnInit {
|
||||
|
||||
fromDate: Date = new Date();
|
||||
toDate: Date = new Date();
|
||||
billable: boolean = true;
|
||||
|
||||
maxToDate: Date;
|
||||
cols: any[];
|
||||
usages: [];
|
||||
totals: any;
|
||||
|
||||
constructor(private billSvc: BillingService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
}
|
||||
|
||||
onDateSelected(src, e) {
|
||||
if (!src || !e) return;
|
||||
switch (src) {
|
||||
case 'from':
|
||||
if (e) {
|
||||
this.maxToDate = new Date(e);
|
||||
this.maxToDate.setMonth(e.getMonth() + 11);
|
||||
if (this.toDate && this.toDate > this.maxToDate)
|
||||
this.toDate = this.maxToDate;
|
||||
}
|
||||
break;
|
||||
case 'to':
|
||||
break;
|
||||
}
|
||||
if (this.fromDate > this.toDate) {
|
||||
this.toDate = new Date();
|
||||
this.toDate.setMonth(this.fromDate.getMonth() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private getFilterOps() {
|
||||
return ({
|
||||
tz: Intl.DateTimeFormat() && Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
from: DateUtils.firstDayOfMonth(this.fromDate).getTime(),
|
||||
to: DateUtils.lastDayOfMonth(this.toDate).getTime(),
|
||||
billable: this.billable
|
||||
});
|
||||
}
|
||||
|
||||
findUsage() {
|
||||
const ops = this.getFilterOps();
|
||||
this.billSvc.loadCustUsage(ops).subscribe(data => {
|
||||
if (data && data.length) {
|
||||
this.updateLstHeaders(data);
|
||||
this.updateTotals(data);
|
||||
this.usages = data;
|
||||
} else {
|
||||
this.cols = [];
|
||||
this.totals = {};
|
||||
this.usages = [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private updateTotals(data) {
|
||||
this.totals = {};
|
||||
if (!(data && data.length)) return;
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
for (let k in data[i]) {
|
||||
if ('customer' != k) {
|
||||
if (this.totals[k])
|
||||
this.totals[k] += data[i][k];
|
||||
else
|
||||
this.totals[k] = data[i][k];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private updateLstHeaders(data) {
|
||||
if (!(data && data.length)) return;
|
||||
|
||||
let col = <any>{ field: 'customer', header: 'Customer Name', width: "15%" };
|
||||
this.cols = [col];
|
||||
|
||||
for (let k in data[0]) {
|
||||
if ('customer' != k) {
|
||||
col = { field: k, header: k };
|
||||
this.cols.push(col);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
haToAcres(val) {
|
||||
return UnitUtils.haToArea(val, true);
|
||||
}
|
||||
|
||||
exportUsageDetail() {
|
||||
this.billSvc.exportUsageDetail(this.getFilterOps())
|
||||
.subscribe(file => {
|
||||
saveAs(file.blob, file.fileName);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
84
Development/client/src/app/client/actions/client.actions.ts
Normal file
84
Development/client/src/app/client/actions/client.actions.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import { Action } from "@ngrx/store";
|
||||
import { Client } from "../models/client.model";
|
||||
|
||||
export const FETCH = '[CLIENTS] Fetch clients';
|
||||
export class Fetch implements Action {
|
||||
type: typeof FETCH = FETCH;
|
||||
}
|
||||
|
||||
export const FETCH_SUCCESS = '[CLIENTS] Fetch clients success';
|
||||
export class FetchSuccess implements Action {
|
||||
type: typeof FETCH_SUCCESS = FETCH_SUCCESS;
|
||||
|
||||
constructor(readonly payload: Client[]) { }
|
||||
}
|
||||
|
||||
export const FETCH_FAILED = '[CLIENTS] Fetch clients failed';
|
||||
export class FetchError implements Action {
|
||||
type: typeof FETCH_FAILED = FETCH_FAILED;
|
||||
}
|
||||
|
||||
export const CREATE = '[CLIENTS] Create a client';
|
||||
export class Create implements Action {
|
||||
type: typeof CREATE = CREATE;
|
||||
|
||||
constructor(readonly payload: Client) { }
|
||||
}
|
||||
export const CREATE_SUCCESS = '[CLIENTS] Create client success';
|
||||
export class CreateSuccess implements Action {
|
||||
type: typeof CREATE_SUCCESS = CREATE_SUCCESS;
|
||||
|
||||
constructor(readonly payload: Client) { }
|
||||
}
|
||||
export const CREATE_FAILED = '[CLIENTS] Create client failed';
|
||||
export class CreateFailed implements Action {
|
||||
type: typeof CREATE_FAILED = CREATE_FAILED;
|
||||
}
|
||||
|
||||
export const UPDATE = '[CLIENTS] Update client';
|
||||
export class Update implements Action {
|
||||
type: typeof UPDATE = UPDATE;
|
||||
|
||||
constructor(readonly payload: Client) { }
|
||||
}
|
||||
export const UPDATE_SUCCESS = '[CLIENTS] Update client success';
|
||||
export class UpdateSuccess implements Action {
|
||||
type: typeof UPDATE_SUCCESS = UPDATE_SUCCESS;
|
||||
|
||||
constructor(readonly payload: Client) { }
|
||||
}
|
||||
export const UPDATE_FAILED = '[CLIENTS] Update client failed';
|
||||
export class UpdateFailed implements Action {
|
||||
type: typeof UPDATE_FAILED = UPDATE_FAILED;
|
||||
}
|
||||
|
||||
export const DELETE = '[CLIENTS] Delete client';
|
||||
export class Delete implements Action {
|
||||
type: typeof DELETE = DELETE;
|
||||
|
||||
constructor(readonly payload: Client) { }
|
||||
}
|
||||
export const DELETE_SUCCESS = '[CLIENTS] Delete client success';
|
||||
export class DeleteSuccess implements Action {
|
||||
type: typeof DELETE_SUCCESS = DELETE_SUCCESS;
|
||||
|
||||
constructor(readonly payload: Client) { }
|
||||
}
|
||||
export const DELETE_FAILED = '[CLIENTS] Delete client failed';
|
||||
export class DeleteError implements Action {
|
||||
type: typeof DELETE_FAILED = DELETE_FAILED;
|
||||
}
|
||||
|
||||
export const SELECT = '[CLIENTS] Select client';
|
||||
export class Select implements Action {
|
||||
type: typeof SELECT = SELECT;
|
||||
|
||||
constructor(readonly payload: Client) { }
|
||||
}
|
||||
|
||||
export type All =
|
||||
| Fetch | FetchSuccess | FetchError
|
||||
| Create | CreateSuccess | CreateFailed
|
||||
| Update | UpdateSuccess | UpdateFailed
|
||||
| Delete | DeleteSuccess | DeleteError
|
||||
| Select
|
||||
@ -0,0 +1,23 @@
|
||||
<div class="ui-g ui-fluid" style="max-width: 1025px;">
|
||||
<div class="ui-g-12">
|
||||
<div class="card card-w-title">
|
||||
<h1 i18n="@@clientInfo">Client Information</h1>
|
||||
<form [formGroup]="form">
|
||||
<div class="ui-g ui-g-nopad" style="margin-top:40px">
|
||||
<user-profile-form formControlName="profile" [focusOnFirst]="isNew" [requireName]="true"></user-profile-form>
|
||||
<div *ngIf="isPlanner" class="ui-g-12 form-row" style="padding-top: 0">
|
||||
<agm-account-editor formControlName="account" [isNew]="isNew" i18n-title="@@accessAccount" title="Access Account">
|
||||
</agm-account-editor>
|
||||
</div>
|
||||
<div class="ui-g-12 toolbar padtop1">
|
||||
<button pButton *ngIf="isNew; else editTpl" [disabled]="form.invalid" type="button" style="width:auto" icon="ui-icon-plus" [label]="globals.create" (click)="saveClient(); false"></button>
|
||||
<ng-template #editTpl>
|
||||
<button *ngIf="!isInspector" class="blue-btn" pButton type="button" style="width:auto" [disabled]="form.invalid" icon="ui-icon-save" [label]="globals.save" (click)="saveClient(); false"></button>
|
||||
</ng-template>
|
||||
<button pButton type="button" style="width:auto" class="amber-btn" icon="ui-icon-arrow-back" (click)="goBack()" i18n-label="@@back" label="Back"></button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,79 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
|
||||
import { Client } from '../models/client.model';
|
||||
import * as clientActions from '../actions/client.actions';
|
||||
|
||||
import { BaseComp } from '@app/shared/base/base.component';
|
||||
import { FormBuilder, FormGroup } from '@angular/forms';
|
||||
import { globals } from '@app/shared/global';
|
||||
|
||||
@Component({
|
||||
selector: 'agm-client-edit',
|
||||
templateUrl: './client-edit.component.html',
|
||||
styleUrls: ['./client-edit.component.css']
|
||||
})
|
||||
export class ClientEditComponent extends BaseComp implements OnInit, OnDestroy {
|
||||
readonly globals = globals;
|
||||
|
||||
form: FormGroup;
|
||||
selectedItem: Client;
|
||||
|
||||
private _client: Client;
|
||||
get client(): Client { return this._client; }
|
||||
set client(client: Client) {
|
||||
this._client = client;
|
||||
this.selectedItem = Object.assign({}, client); // create a clone object to work on the editor
|
||||
this.form.patchValue({
|
||||
profile: this.selectedItem,
|
||||
account: { active: this.selectedItem.active, username: this.selectedItem.username, password: this.selectedItem.password },
|
||||
});
|
||||
}
|
||||
|
||||
private _isNew: boolean;
|
||||
get isNew(): boolean {
|
||||
return this._isNew;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly route: ActivatedRoute,
|
||||
private readonly fb: FormBuilder
|
||||
) {
|
||||
super();
|
||||
|
||||
this.form = this.fb.group({
|
||||
profile: [],
|
||||
account: [],
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.sub$ = this.route.data.subscribe((data) => {
|
||||
const client = data[0] as Client || null;
|
||||
if (client) {
|
||||
this._isNew = (client._id === '0');
|
||||
this.client = client;
|
||||
}
|
||||
});
|
||||
this.sub$.add(this.appActions.ofTypes([clientActions.CREATE_SUCCESS, clientActions.UPDATE_SUCCESS])
|
||||
.subscribe((action) => {
|
||||
this.store.dispatch(new clientActions.Select(action['payload']));
|
||||
this.goBack();
|
||||
}));
|
||||
}
|
||||
|
||||
saveClient() {
|
||||
if (!this.form || !this.form.value || !this.form.valid) return;
|
||||
|
||||
const clientObj = Object.assign(this.selectedItem, this.form.value.profile, this.form.value.account);
|
||||
this.store.dispatch(this._isNew ? new clientActions.Create(clientObj) : new clientActions.Update(clientObj));
|
||||
}
|
||||
|
||||
goBack() {
|
||||
this.router.navigate(['clients', { id: this.client._id }]);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
super.ngOnDestroy();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
/*
|
||||
To disable deselect or reselect an item on a table row
|
||||
Ref:https://stackoverflow.com/questions/48675497/how-to-disable-the-option-to-deselect-a-row-on-turbotable-component*/
|
||||
tr.ui-state-highlight {
|
||||
pointer-events: none;
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
<div class="ui-g">
|
||||
<div class="ui-g-12">
|
||||
<div class="card">
|
||||
<p-table #dt [value]="clients" [columns]="cols" selectionMode="single" (onRowSelect)="onRowSelect($event)" [paginator]="true" [rows]="15" [pageLinks]="5" [rowsPerPageOptions]="[15,30,50]" [alwaysShowPaginator]="false" [(selection)]="currClient" dataKey="_id" [resetPageOnSort]="false" stateStorage="session" stateKey="cltb-ops" [responsive]="true">
|
||||
<ng-template pTemplate="caption">
|
||||
<span class="table-caption-1" i18n="@@clientList">Client List</span>
|
||||
</ng-template>
|
||||
<ng-template pTemplate="header" let-columns>
|
||||
<tr>
|
||||
<th *ngFor="let col of columns" [pSortableColumn]="col.field" [width]="col.width">{{col.header}}<p-sortIcon [field]="col.field"></p-sortIcon>
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th *ngFor="let col of columns" [ngSwitch]="col.filtered" class="ui-fluid">
|
||||
<div class="input-with-icon" *ngSwitchCase="true">
|
||||
<i class="ui-icon-search"></i>
|
||||
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
|
||||
</div>
|
||||
<span *ngSwitchDefault></span>
|
||||
</th>
|
||||
</tr>
|
||||
</ng-template>
|
||||
<ng-template pTemplate="body" let-client let-rowData let-columns>
|
||||
<tr [pSelectableRow]="client">
|
||||
<td *ngFor="let col of cols">
|
||||
<span class="ui-column-title">{{col.header}}</span>
|
||||
{{resolveFieldData(rowData, col.field)}}
|
||||
</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)="newClient()" i18n-label="@@new" label="New"></button>
|
||||
<button type="button" pButton icon="ui-icon-edit" [disabled]="!canEdit" (click)="editClient()" i18n-label="@@detail" label="Detail"></button>
|
||||
<button type="button" *ngIf="canWrite" [disabled]="!canEdit" pButton icon="ui-icon-trash" (click)="deleteClient()" i18n-label="@@delete" label="Delete"></button>
|
||||
<div class="float-right">
|
||||
<button type="button" [disabled]="!canEdit" pButton icon="ui-icon-view-list" (click)="toJobList()" i18n-label="@@viewJobs" label="View Jobs"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,111 @@
|
||||
import { Component, OnInit, OnDestroy, ViewChild } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
|
||||
import { Table } from 'primeng/table';
|
||||
|
||||
import { Client } from '../models/client.model';
|
||||
import * as fromClients from '../reducers';
|
||||
import * as clientActions from '../actions/client.actions';
|
||||
|
||||
import { RoleIds, globals } from '../../shared/global';
|
||||
import { JobService } from '../../domain/services/job.service';
|
||||
import { Utils } from 'src/app/shared/utils';
|
||||
import { BaseComp } from 'src/app/shared/base/base.component';
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'agm-client-list',
|
||||
templateUrl: './client-list.component.html',
|
||||
styleUrls: ['./client-list.component.css']
|
||||
})
|
||||
export class ClientListComponent extends BaseComp implements OnInit, OnDestroy {
|
||||
resolveFieldData = Utils.resolveFieldData;
|
||||
|
||||
clients: Array<Client>;
|
||||
currClient: Client;
|
||||
|
||||
@ViewChild('dt') dt: Table;
|
||||
|
||||
cols: any[];
|
||||
loading$ = this.store.select(fromClients.isLoading);
|
||||
|
||||
get canWrite(): boolean {
|
||||
return this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM, RoleIds.OFFICER]);
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly route: ActivatedRoute,
|
||||
private readonly jobService: JobService,
|
||||
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.cols = [
|
||||
{ field: 'name', header: globals.name, filtered: true, filterMatchMode: 'contains' },
|
||||
{ field: 'username', header: globals.userName, filtered: true, filterMatchMode: 'contains' },
|
||||
{ field: 'address', header: globals.address },
|
||||
{ field: 'contact', header: globals.contact, filtered: true },
|
||||
{ field: 'phone', header: globals.phone + ' ' + $localize`:@@Num:N°`, width: '15%', filtered: true },
|
||||
{ field: 'email', header: globals.email, filtered: true, filterMatchMode: 'contains' }
|
||||
];
|
||||
|
||||
// These reference entity services and logic should apply to logged in logic later
|
||||
this.sub$ = this.store.select(fromClients.getAllClients).subscribe(clients => this.clients = clients);
|
||||
|
||||
this.sub$.add(this.store.select(fromClients.getSelectedClient).subscribe((client) => {
|
||||
this.currClient = client;
|
||||
}));
|
||||
|
||||
this.store.dispatch(new clientActions.Fetch());
|
||||
}
|
||||
|
||||
onRowSelect(event) {
|
||||
this.store.dispatch(new clientActions.Select(event.data));
|
||||
}
|
||||
|
||||
get canEdit() {
|
||||
return (this.currClient && this.currClient._id !== '0');
|
||||
}
|
||||
|
||||
newClient() {
|
||||
this.router.navigate(['client', '0'], { relativeTo: this.route });
|
||||
}
|
||||
|
||||
editClient() {
|
||||
this.router.navigate(['client', this.currClient._id], { relativeTo: this.route });
|
||||
}
|
||||
|
||||
deleteClient() {
|
||||
this.jobService.countByClient(this.currClient._id).subscribe((count) => {
|
||||
return this.confirmDelete(count);
|
||||
});
|
||||
}
|
||||
|
||||
private confirmDelete(count: number) {
|
||||
let msg = globals.confirmDeleteThing.replace('#thing#', globals.client);
|
||||
if (count > 0) {
|
||||
let ref = count === 1 ? $localize`:@@relatedJobSingular:There is #jobs# related job` : $localize`:@@relatedJobPlural:There are #jobs# related jobs`;
|
||||
ref = ref.replace('#jobs#', String(count));
|
||||
msg = ref + '. ' + msg;
|
||||
}
|
||||
|
||||
this.confirmSvc.confirm({
|
||||
message: msg,
|
||||
accept: () => {
|
||||
this.store.dispatch(new clientActions.Delete(this.currClient));
|
||||
this.currClient = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toJobList() {
|
||||
this.router.navigate(['/jobs']);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
super.ngOnDestroy();
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<router-outlet></router-outlet>
|
||||
`
|
||||
})
|
||||
export class ClientMgtComponent { }
|
||||
38
Development/client/src/app/client/client-resolver.service.ts
Normal file
38
Development/client/src/app/client/client-resolver.service.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Router, ActivatedRouteSnapshot, RouterStateSnapshot, Resolve } from '@angular/router';
|
||||
|
||||
import { Observable } from 'rxjs';
|
||||
import { Client, createNewClient } from './models/client.model';
|
||||
import { ClientService } from '../domain/services/client.service';
|
||||
import { AuthService } from '../domain/services/auth.service';
|
||||
import { map, first } from 'rxjs/operators';
|
||||
|
||||
@Injectable()
|
||||
export class ClientResolver implements Resolve<Client> {
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private authService: AuthService,
|
||||
private clientService: ClientService,
|
||||
) { }
|
||||
|
||||
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<Client> | Promise<Client> | Client {
|
||||
const id = route.paramMap.get('id');
|
||||
if (id === '0') {
|
||||
return createNewClient(this.authService.user.parent);
|
||||
} else {
|
||||
return this.clientService.getClient(id).pipe(
|
||||
map((client) => {
|
||||
if (client) {
|
||||
return client;
|
||||
} else { // id not found
|
||||
this.router.navigate(['/clients']);
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
first()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
52
Development/client/src/app/client/client-routing.module.ts
Normal file
52
Development/client/src/app/client/client-routing.module.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
|
||||
import { AuthGuard } from '../domain/guards/auth.guard';
|
||||
|
||||
import { ClientMgtComponent } from './client-mgt.component';
|
||||
import { ClientListComponent } from './client-list/client-list.component';
|
||||
import { ClientEditComponent } from './client-edit/client-edit.component';
|
||||
import { ClientResolver } from './client-resolver.service';
|
||||
|
||||
import { RoleIds } from '../shared/global';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: ClientMgtComponent,
|
||||
data: {
|
||||
roles: [RoleIds.APP, RoleIds.APP_ADM, RoleIds.OFFICER, RoleIds.PILOT, RoleIds.INSPECTOR]
|
||||
},
|
||||
canActivate: [AuthGuard],
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
component: ClientListComponent,
|
||||
data: {
|
||||
roles: [RoleIds.APP, RoleIds.APP_ADM, RoleIds.OFFICER, RoleIds.PILOT, RoleIds.INSPECTOR]
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'client/:id',
|
||||
component: ClientEditComponent,
|
||||
data: {
|
||||
roles: [RoleIds.APP, RoleIds.APP_ADM, RoleIds.OFFICER, RoleIds.PILOT, RoleIds.INSPECTOR]
|
||||
},
|
||||
resolve: [ClientResolver]
|
||||
},
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild(routes)
|
||||
],
|
||||
exports: [
|
||||
RouterModule
|
||||
],
|
||||
providers: [
|
||||
AuthGuard, ClientResolver
|
||||
]
|
||||
})
|
||||
export class ClientsRoutingModule { }
|
||||
42
Development/client/src/app/client/client.module.ts
Normal file
42
Development/client/src/app/client/client.module.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { PaginatorModule } from 'primeng/paginator';
|
||||
import { DialogModule } from 'primeng/dialog';
|
||||
import { ConfirmDialogModule } from 'primeng/confirmdialog';
|
||||
import { MessagesModule } from 'primeng/messages';
|
||||
import { InputTextModule } from 'primeng/inputtext';
|
||||
import { CheckboxModule } from 'primeng/checkbox';
|
||||
import { ToolbarModule } from 'primeng/toolbar';
|
||||
import { ButtonModule } from 'primeng/button';
|
||||
import { DropdownModule } from 'primeng/dropdown';
|
||||
|
||||
import { TableModule } from 'primeng/table';
|
||||
import { ToastModule } from 'primeng/toast';
|
||||
|
||||
import { StoreModule } from '@ngrx/store';
|
||||
import { EffectsModule } from '@ngrx/effects';
|
||||
import { ClientEffects } from './effects/client.effects';
|
||||
import * as fromClients from './reducers/clients.reducer';
|
||||
|
||||
import { ClientListComponent } from './client-list/client-list.component';
|
||||
import { ClientsRoutingModule } from './client-routing.module';
|
||||
import { ClientEditComponent } from './client-edit/client-edit.component';
|
||||
import { ClientMgtComponent } from './client-mgt.component';
|
||||
import { AppSharedModule } from '../shared/app-shared.module';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule, TableModule, PaginatorModule, DialogModule, ConfirmDialogModule, ToastModule, MessagesModule, InputTextModule,
|
||||
CheckboxModule, ToolbarModule, ButtonModule, DropdownModule, AppSharedModule,
|
||||
StoreModule.forFeature(fromClients.FEATURE_KEY, fromClients.reducer),
|
||||
EffectsModule.forFeature([ClientEffects]),
|
||||
ClientsRoutingModule
|
||||
],
|
||||
declarations: [ClientMgtComponent, ClientListComponent, ClientEditComponent],
|
||||
providers: [],
|
||||
schemas: [
|
||||
CUSTOM_ELEMENTS_SCHEMA
|
||||
],
|
||||
})
|
||||
export class ClientsModule { }
|
||||
79
Development/client/src/app/client/effects/client.effects.ts
Normal file
79
Development/client/src/app/client/effects/client.effects.ts
Normal file
@ -0,0 +1,79 @@
|
||||
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 clientActions from '../actions/client.actions';
|
||||
|
||||
import { ClientService } from '@app/domain/services/client.service';
|
||||
import { AuthService } from '@app/domain/services/auth.service';
|
||||
import { AppMessageService } from '@app/shared/app-message.service';
|
||||
import { globals } from '@app/shared/global';
|
||||
|
||||
@Injectable()
|
||||
export class ClientEffects {
|
||||
constructor(
|
||||
private readonly actions$: Actions,
|
||||
private readonly clientSvc: ClientService,
|
||||
private readonly authSvc: AuthService,
|
||||
private readonly msgSvc: AppMessageService
|
||||
) {
|
||||
}
|
||||
|
||||
@Effect()
|
||||
loadClients$: Observable<Action> = this.actions$.pipe(
|
||||
ofType<clientActions.Fetch>(clientActions.FETCH),
|
||||
switchMap(() =>
|
||||
this.clientSvc.loadClients({ byPuid: 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));
|
||||
return of(new clientActions.FetchError());
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
@Effect()
|
||||
createClient$: Observable<Action> = this.actions$.pipe(
|
||||
ofType<clientActions.Create>(clientActions.CREATE),
|
||||
switchMap(({ payload }) =>
|
||||
this.clientSvc.saveClient(payload).pipe(
|
||||
map((client) => new clientActions.CreateSuccess(client)),
|
||||
catchError(err => {
|
||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.create).replace('#thing#', globals.client));
|
||||
return of(new clientActions.CreateFailed())
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
@Effect()
|
||||
updateClient$: Observable<Action> = this.actions$.pipe(
|
||||
ofType<clientActions.Update>(clientActions.UPDATE),
|
||||
switchMap(({ payload }) =>
|
||||
this.clientSvc.saveClient(payload).pipe(
|
||||
map(() => new clientActions.UpdateSuccess(payload)),
|
||||
catchError(err => {
|
||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.save).replace('#thing#', globals.client));
|
||||
return of(new clientActions.UpdateFailed());
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
@Effect()
|
||||
deleteClient$: Observable<Action> = this.actions$.pipe(
|
||||
ofType<clientActions.Delete>(clientActions.DELETE),
|
||||
switchMap(({ payload }) =>
|
||||
this.clientSvc.deleteClient(payload).pipe(
|
||||
map(() => new clientActions.DeleteSuccess(payload)),
|
||||
catchError(err => {
|
||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.delete).replace('#thing#', globals.client));
|
||||
return of(new clientActions.UpdateFailed())
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
11
Development/client/src/app/client/models/client.model.ts
Normal file
11
Development/client/src/app/client/models/client.model.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { createNewUser, User } from '@app/accounts/models/user.model';
|
||||
import { RoleIds } from '@app/shared/global';
|
||||
|
||||
export interface Client extends User {
|
||||
contact?: string;
|
||||
fax?: string;
|
||||
}
|
||||
|
||||
export const createNewClient = (parentId: string) => {
|
||||
return <Client>createNewUser(parentId, RoleIds.CLIENT);
|
||||
}
|
||||
@ -0,0 +1,99 @@
|
||||
import { createEntityAdapter, EntityAdapter, EntityState } from '@ngrx/entity';
|
||||
import { Client } from '../models/client.model';
|
||||
import * as actions from '../actions/client.actions';
|
||||
|
||||
export const FEATURE_KEY = 'Clients';
|
||||
|
||||
export interface State extends EntityState<Client> {
|
||||
loading: boolean;
|
||||
loaded: boolean;
|
||||
selectedId: string;
|
||||
}
|
||||
|
||||
export const adapter: EntityAdapter<Client> = createEntityAdapter<Client>({
|
||||
selectId: (client: Client) => client._id,
|
||||
sortComparer: false
|
||||
});
|
||||
|
||||
export const initialState: State = adapter.getInitialState({
|
||||
loading: false,
|
||||
loaded: false,
|
||||
selectedId: null
|
||||
});
|
||||
|
||||
export function reducer(
|
||||
state = initialState,
|
||||
action: actions.All
|
||||
): State {
|
||||
switch (action.type) {
|
||||
|
||||
case actions.FETCH:
|
||||
case actions.CREATE:
|
||||
case actions.UPDATE:
|
||||
case actions.DELETE:
|
||||
return { ...state, loading: true };
|
||||
|
||||
case actions.SELECT:
|
||||
return {
|
||||
...state,
|
||||
selectedId: action.payload ? action.payload._id : null
|
||||
}
|
||||
|
||||
case actions.FETCH_SUCCESS:
|
||||
return adapter.addMany(action.payload, {
|
||||
...adapter.removeAll(state),
|
||||
loading: false,
|
||||
selectedId: state.selectedId,
|
||||
loaded: true
|
||||
});
|
||||
|
||||
case actions.FETCH_FAILED:
|
||||
return {
|
||||
...state,
|
||||
loading: false
|
||||
};
|
||||
|
||||
case actions.CREATE_SUCCESS:
|
||||
return adapter.upsertOne(action.payload, {
|
||||
...state,
|
||||
loading: false
|
||||
});
|
||||
|
||||
case actions.CREATE_FAILED:
|
||||
return {
|
||||
...state,
|
||||
loading: false
|
||||
};
|
||||
|
||||
case actions.UPDATE_SUCCESS:
|
||||
return adapter.upsertOne(action.payload, {
|
||||
...state,
|
||||
loading: false
|
||||
});
|
||||
|
||||
case actions.UPDATE_FAILED:
|
||||
return {
|
||||
...state,
|
||||
loading: false
|
||||
};
|
||||
|
||||
case actions.DELETE_SUCCESS:
|
||||
return adapter.removeOne(action.payload._id, {
|
||||
...state,
|
||||
loading: false
|
||||
});
|
||||
|
||||
case actions.DELETE_FAILED:
|
||||
return {
|
||||
...state,
|
||||
loading: false
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export const getSelectedId = (state: State) => state.selectedId;
|
||||
export const getIsLoading = (state: State) => state.loading;
|
||||
export const getIsLoaded = (state: State) => state.loaded;
|
||||
77
Development/client/src/app/client/reducers/index.ts
Normal file
77
Development/client/src/app/client/reducers/index.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import {
|
||||
createSelector,
|
||||
createFeatureSelector,
|
||||
} from '@ngrx/store';
|
||||
|
||||
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,
|
||||
fromClients.getSelectedId
|
||||
);
|
||||
|
||||
export const isLoading = createSelector(
|
||||
getClientsStateOrInitial,
|
||||
fromClients.getIsLoading
|
||||
);
|
||||
|
||||
export const isLoaded = createSelector(
|
||||
getClientsStateOrInitial,
|
||||
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 getSelectedClient = createSelector(
|
||||
getClientEntities,
|
||||
getSelectedClientId,
|
||||
(entities, selectedId) => {
|
||||
return selectedId && entities[selectedId];
|
||||
}
|
||||
);
|
||||
|
||||
export const getClientById = (id) => createSelector(
|
||||
getClientEntities,
|
||||
entities => entities[id]
|
||||
);
|
||||
|
||||
@ -0,0 +1,84 @@
|
||||
import { Action } from "@ngrx/store";
|
||||
import { Customer } from "../models/customer.model";
|
||||
|
||||
export const FETCH = '[CUSTOMERS] Fetch customers';
|
||||
export class Fetch implements Action {
|
||||
type: typeof FETCH = FETCH;
|
||||
}
|
||||
|
||||
export const FETCH_SUCCESS = '[CUSTOMERS] Fetch customers success';
|
||||
export class FetchSuccess implements Action {
|
||||
type: typeof FETCH_SUCCESS = FETCH_SUCCESS;
|
||||
|
||||
constructor(readonly payload: Customer[]) { }
|
||||
}
|
||||
|
||||
export const FETCH_FAILED = '[CUSTOMERS] Fetch customers failed';
|
||||
export class FetchError implements Action {
|
||||
type: typeof FETCH_FAILED = FETCH_FAILED;
|
||||
}
|
||||
|
||||
export const CREATE = '[CUSTOMERS] Create a customer';
|
||||
export class Create implements Action {
|
||||
type: typeof CREATE = CREATE;
|
||||
|
||||
constructor(readonly payload: Customer) { }
|
||||
}
|
||||
export const CREATE_SUCCESS = '[CUSTOMERS] Create customer success';
|
||||
export class CreateSuccess implements Action {
|
||||
type: typeof CREATE_SUCCESS = CREATE_SUCCESS;
|
||||
|
||||
constructor(readonly payload: Customer) { }
|
||||
}
|
||||
export const CREATE_FAILED = '[CUSTOMERS] Create customer failed';
|
||||
export class CreateFailed implements Action {
|
||||
type: typeof CREATE_FAILED = CREATE_FAILED;
|
||||
}
|
||||
|
||||
export const UPDATE = '[CUSTOMERS] Update customer';
|
||||
export class Update implements Action {
|
||||
type: typeof UPDATE = UPDATE;
|
||||
|
||||
constructor(readonly payload: Customer) { }
|
||||
}
|
||||
export const UPDATE_SUCCESS = '[CUSTOMERS] Update customer success';
|
||||
export class UpdateSuccess implements Action {
|
||||
type: typeof UPDATE_SUCCESS = UPDATE_SUCCESS;
|
||||
|
||||
constructor(readonly payload: Customer) { }
|
||||
}
|
||||
export const UPDATE_FAILED = '[CUSTOMERS] Update customer failed';
|
||||
export class UpdateFailed implements Action {
|
||||
type: typeof UPDATE_FAILED = UPDATE_FAILED;
|
||||
}
|
||||
|
||||
export const DELETE = '[CUSTOMERS] Delete customer';
|
||||
export class Delete implements Action {
|
||||
type: typeof DELETE = DELETE;
|
||||
|
||||
constructor(readonly payload: Customer) { }
|
||||
}
|
||||
export const DELETE_SUCCESS = '[CUSTOMERS] Delete customer success';
|
||||
export class DeleteSuccess implements Action {
|
||||
type: typeof DELETE_SUCCESS = DELETE_SUCCESS;
|
||||
|
||||
constructor(readonly payload: Customer) { }
|
||||
}
|
||||
export const DELETE_FAILED = '[CUSTOMERS] Delete customer failed';
|
||||
export class DeleteError implements Action {
|
||||
type: typeof DELETE_FAILED = DELETE_FAILED;
|
||||
}
|
||||
|
||||
export const SELECT = '[CUSTOMERS] Select customer';
|
||||
export class Select implements Action {
|
||||
type: typeof SELECT = SELECT;
|
||||
|
||||
constructor(readonly payload: Customer) { }
|
||||
}
|
||||
|
||||
export type All =
|
||||
| Fetch | FetchSuccess | FetchError
|
||||
| Create | CreateSuccess | CreateFailed
|
||||
| Update | UpdateSuccess | UpdateFailed
|
||||
| Delete | DeleteSuccess | DeleteError
|
||||
| Select
|
||||
@ -0,0 +1,128 @@
|
||||
.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;
|
||||
}
|
||||
@ -0,0 +1,143 @@
|
||||
<div class="ui-g" style="max-width: 1025px;">
|
||||
<div class="ui-g-12">
|
||||
<div class="card card-w-title">
|
||||
<h1 i18n="@@customerInfo">Customer Information</h1>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="ui-g-12 ui-md-6 ui-lg-6 form-row">
|
||||
<span class="form-label-span">
|
||||
<ng-container i18n="@@premiumLevel">Premium Level</ng-container>:
|
||||
</span>
|
||||
<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>
|
||||
</span>
|
||||
</ng-template>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
@ -0,0 +1,269 @@
|
||||
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 * 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';
|
||||
|
||||
@Component({
|
||||
selector: 'agm-customer-edit',
|
||||
templateUrl: './customer-edit.component.html',
|
||||
styleUrls: ['./customer-edit.component.css']
|
||||
})
|
||||
export class CustomerEditComponent extends BaseComp implements OnInit {
|
||||
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) {
|
||||
this._customer = customer;
|
||||
this.selectedItem = Object.assign({}, customer); // create a clone object to work on the editor
|
||||
this.form.patchValue({
|
||||
profile: this.selectedItem,
|
||||
account: { active: this.selectedItem.active, username: this.selectedItem.username, password: this.selectedItem.password },
|
||||
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;
|
||||
get isNew(): boolean {
|
||||
return this._isNew;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly route: ActivatedRoute,
|
||||
private readonly userSvc: UserService,
|
||||
private readonly partnerSvc: PartnerService,
|
||||
private readonly fb: FormBuilder
|
||||
) {
|
||||
super();
|
||||
this.premiumLevels = [
|
||||
{ label: "None", value: 0 },
|
||||
{ label: "Level 1", value: 1 },
|
||||
{ label: "Level 2", value: 2 },
|
||||
{ label: "Level 3", value: 3 },
|
||||
];
|
||||
this.form = this.fb.group({
|
||||
profile: [],
|
||||
account: [],
|
||||
premium: [],
|
||||
billable: [],
|
||||
trials: [],
|
||||
// Partner form control
|
||||
partner: [null]
|
||||
});
|
||||
this.lang = this.authSvc.locale;
|
||||
|
||||
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.sub$ = this.route.data
|
||||
.subscribe((data) => {
|
||||
const customer = data[0] as Customer || null;
|
||||
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() });
|
||||
|
||||
this.store.dispatch(this._isNew ? new customerActions.Create(custObj) : new customerActions.Update(custObj));
|
||||
}
|
||||
|
||||
onUserExisted(event) {
|
||||
this.msgs = [];
|
||||
if (event) {
|
||||
this.sub$.add(this.userSvc.getUserDetail(event).subscribe(user => {
|
||||
if (user) {
|
||||
let msg = '';
|
||||
if (user.kind == RoleIds.ADMIN)
|
||||
msg = $localize`:User taken by an AGM Admin@@unameTaken0:Username '#uname#' is reserved for System Admin`;
|
||||
if (user.kind == RoleIds.APP)
|
||||
msg = $localize`:User taken by an Applicator message@@unameTaken1:Username '#uname#' was taken by an Applicator`;
|
||||
else if (user.parent) {
|
||||
msg = $localize`:User taken by an Applicator\'s accounts message@@unameTaken2:Username '#uname#' was taken under Applicator ('#pacc#')'s accounts`;
|
||||
msg = msg.replace('#pacc#', user.parent.username);
|
||||
}
|
||||
if (msg)
|
||||
this.msgs.push({ severity: 'error', summary: 'Error', detail: msg.replace('#uname#', user.username) });
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
goBack() {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,67 @@
|
||||
<div class="ui-g">
|
||||
<div class="ui-g-12">
|
||||
<div class="card">
|
||||
<p-table #dt [value]="customers" [columns]="cols" selectionMode="single" (onRowSelect)="onRowSelect($event)" [paginator]="true" [rows]="15" [pageLinks]="5" [rowsPerPageOptions]="[10, 15, 30]" [alwaysShowPaginator]="true" [(selection)]="curCust" dataKey="_id" [resetPageOnSort]="false" stateStorage="session" stateKey="ctb-ops" [responsive]="true">
|
||||
<ng-template pTemplate="caption">
|
||||
<div class="ui-g ui-g-nopad">
|
||||
<div class="ui-g-6 cc-field-label">
|
||||
<span class="table-caption-1" i18n="@@customerList">Customer List</span>
|
||||
</div>
|
||||
<div class="ui-g-6 cc-field-label">
|
||||
<label style="margin-right: 8px;">Self Signup Accounts {{ isSelfSignup ? 'On' : 'Off' }}</label>
|
||||
<p-inputSwitch [(ngModel)]="isSelfSignup" (onChange)="onToggle($event)"></p-inputSwitch>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
<ng-template pTemplate="header" let-columns>
|
||||
<tr>
|
||||
<th *ngFor="let col of columns" [pSortableColumn]="col.field" [style.width]="col.width">
|
||||
{{col.header}}
|
||||
<p-sortIcon [field]="col.field"></p-sortIcon>
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th *ngFor="let col of columns" [ngSwitch]="col.filtered" class="ui-fluid">
|
||||
<div class="input-with-icon" *ngSwitchCase="true">
|
||||
<i class="ui-icon-search"></i>
|
||||
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
|
||||
</div>
|
||||
<p-dropdown *ngIf="[ACTIVE, BILLABLE].includes(col.field)" [options]="statuses" [style]="{'width':'100%'}" [ngModel]="dt.filters[col.field]?.value" (onChange)="dt.filter($event.value, col.field, 'equals')"></p-dropdown>
|
||||
|
||||
<p-dropdown *ngIf="col.field === PARTNER_NAME" [options]="partners" [style]="{'width':'100%'}" [ngModel]="dt.filters[col.field]?.value" (onChange)="dt.filter($event.value, col.field, 'equals')"></p-dropdown>
|
||||
|
||||
<span *ngSwitchDefault></span>
|
||||
</th>
|
||||
</tr>
|
||||
</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>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
|
||||
<ng-template pTemplate="paginatorleft" let-state>
|
||||
{{ state.totalRecords | i18nPlural: totalItems }}
|
||||
</ng-template>
|
||||
</p-table>
|
||||
<div class="ui-widget-header ui-helper-clearfix toolbar">
|
||||
<button type="button" pButton icon="ui-icon-plus" (click)="newCustomer()" i18n-label="@@new" label="New"></button>
|
||||
<button type="button" [disabled]="!canEdit" pButton icon="ui-icon-edit" (click)="editCustomer()" i18n-label="@@detail" label="Detail"></button>
|
||||
<button type="button" [disabled]="!canEdit" pButton icon="ui-icon-trash" (click)="deleteCustomer()" i18n-label="@@delete" label="Delete"></button>
|
||||
<!-- <button type="button" pButton icon="ui-icon-payment" (click)="billableOverview()" i18n-label="@@billing" label="Billing"></button> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,131 @@
|
||||
import { Component, OnInit, OnDestroy, ViewChild } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
|
||||
import { SelectItem } from 'primeng/api';
|
||||
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 { BaseComp } from '@app/shared/base/base.component';
|
||||
|
||||
@Component({
|
||||
selector: 'agm-customer-list',
|
||||
templateUrl: './customer-list.component.html',
|
||||
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[];
|
||||
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` };
|
||||
|
||||
this.statuses = [
|
||||
{ label: globals.all, value: null },
|
||||
{ label: globals.active, value: true },
|
||||
{ label: globals.notActive, value: false }
|
||||
];
|
||||
this.cols = [
|
||||
{ field: "name", header: globals.name, filtered: true, filterMatchMode: 'contains' },
|
||||
{ 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%' }
|
||||
];
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
const saved = localStorage.getItem('isSelfSignup');
|
||||
this.isSelfSignup = saved === 'true';
|
||||
|
||||
this.sub$ = this.store.select(fromCustomers.getAllCustomers).subscribe(customers => {
|
||||
this.setCustomersAndPartners(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));
|
||||
}
|
||||
|
||||
get canEdit() {
|
||||
return (this.curCust && this.curCust._id !== '0');
|
||||
}
|
||||
|
||||
newCustomer() {
|
||||
this.router.navigate(['customer', '0'], { relativeTo: this.route });
|
||||
}
|
||||
|
||||
editCustomer() {
|
||||
this.router.navigate(['customer', this.curCust._id], { relativeTo: this.route });
|
||||
}
|
||||
|
||||
deleteCustomer() {
|
||||
if (!this.curCust) { return; }
|
||||
this.confirmSvc.confirm({
|
||||
message: globals.confirmDeleteThing.replace('#thing#', globals.customer),
|
||||
accept: () => {
|
||||
this.store.dispatch(new customerActions.Delete(this.curCust));
|
||||
this.curCust = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
super.ngOnDestroy();
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<router-outlet></router-outlet>
|
||||
`
|
||||
})
|
||||
export class CustomerMgtComponent { }
|
||||
@ -0,0 +1,41 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import {
|
||||
Router, Resolve, RouterStateSnapshot,
|
||||
ActivatedRouteSnapshot
|
||||
} from '@angular/router';
|
||||
|
||||
import { Observable } from 'rxjs';
|
||||
import { map, first } from 'rxjs/operators';
|
||||
|
||||
import { createNewCustomer, Customer } from './models/customer.model';
|
||||
import { CustomerService } from '../domain/services/customer.service';
|
||||
|
||||
@Injectable()
|
||||
export class CustomerResolver implements Resolve<Customer> {
|
||||
constructor(
|
||||
private router: Router,
|
||||
private customerService: CustomerService,
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<Customer> | Promise<Customer> | Customer {
|
||||
const id = route.paramMap.get('id');
|
||||
if (id === '0') {
|
||||
return createNewCustomer();
|
||||
} else {
|
||||
return this.customerService.getCustomer(id, 'edit').pipe(
|
||||
map((cust) => {
|
||||
if (cust) {
|
||||
return cust;
|
||||
} else {
|
||||
this.router.navigate(['/customers']);
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
first()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user