Compare commits
8 Commits
feature/su
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 30c4bc3c3e | |||
| a39ce2800f | |||
| cd8f954584 | |||
| 4d39ac2595 | |||
| e1d68734f0 | |||
| 354d468968 | |||
| 3cfb81adfe | |||
| 14f83f0008 |
77
.gitea/workflows/sync-to-svn.yaml
Normal file
77
.gitea/workflows/sync-to-svn.yaml
Normal file
@ -0,0 +1,77 @@
|
||||
# Gitea Actions – Sync git repository to SVN
|
||||
# Equivalent of .circleci/config.yml, translated to GitHub Actions syntax
|
||||
# which Gitea Actions supports natively.
|
||||
#
|
||||
# Prerequisites (set as repository Secrets in Gitea → Settings → Secrets):
|
||||
# SVN_USERNAME – SVN commit username
|
||||
# SVN_PASSWORD – SVN commit password
|
||||
# SVN_REPO_URL – Base SVN repo URL (e.g. https://svn.example.com/repos/myproject)
|
||||
|
||||
name: Sync to SVN
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
push-to-svn:
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- name: Checkout git repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# SVN and rsync are pre-installed in the container image, but the
|
||||
# apt-get call is kept so the workflow works on a stock runner too.
|
||||
- name: Install SVN and rsync
|
||||
run: |
|
||||
if ! command -v svn &>/dev/null || ! command -v rsync &>/dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y subversion rsync
|
||||
fi
|
||||
|
||||
- name: Verify SVN credentials are set
|
||||
run: |
|
||||
if [ -z "${{ secrets.SVN_USERNAME }}" ]; then echo "ERROR: SVN_USERNAME secret is not set" && exit 1; fi
|
||||
if [ -z "${{ secrets.SVN_PASSWORD }}" ]; then echo "ERROR: SVN_PASSWORD secret is not set" && exit 1; fi
|
||||
if [ -z "${{ secrets.SVN_REPO_URL }}" ]; then echo "ERROR: SVN_REPO_URL secret is not set" && exit 1; fi
|
||||
echo "All SVN secrets are set."
|
||||
|
||||
- name: Checkout SVN branch
|
||||
run: |
|
||||
svn checkout \
|
||||
--username "${{ secrets.SVN_USERNAME }}" \
|
||||
--password "${{ secrets.SVN_PASSWORD }}" \
|
||||
--no-auth-cache \
|
||||
--non-interactive \
|
||||
--trust-server-cert \
|
||||
"${{ secrets.SVN_REPO_URL }}/branches/data-export-api-copy" svn-branch
|
||||
|
||||
- name: Sync files to SVN working copy
|
||||
run: |
|
||||
rsync -a --delete \
|
||||
--exclude='.git/' \
|
||||
--exclude='.gitea/' \
|
||||
--exclude='.svn/' \
|
||||
--exclude='svn-branch/' \
|
||||
. svn-branch/
|
||||
- name: Stage and commit to SVN
|
||||
run: |
|
||||
cd svn-branch
|
||||
|
||||
# Add all new/unversioned files and directories in one pass
|
||||
svn add --force .
|
||||
|
||||
# Delete files removed from git (handles paths with spaces).
|
||||
# '|| true' prevents pipefail aborting when grep finds no '!' lines.
|
||||
svn status | grep '^!' | while IFS= read -r line; do
|
||||
svn delete "${line:8}@"
|
||||
done || true
|
||||
|
||||
# Attempt commit; svn commit exits 0 silently when nothing changed
|
||||
svn commit \
|
||||
--username "${{ secrets.SVN_USERNAME }}" \
|
||||
--password "${{ secrets.SVN_PASSWORD }}" \
|
||||
--no-auth-cache \
|
||||
--non-interactive \
|
||||
--trust-server-cert \
|
||||
-m "Gitea CI sync from commit ${{ github.sha }} [ci skip]"
|
||||
64
.githooks/pre-commit
Normal file
64
.githooks/pre-commit
Normal file
@ -0,0 +1,64 @@
|
||||
#!/bin/sh
|
||||
|
||||
branch=$(git rev-parse --abbrev-ref HEAD)
|
||||
|
||||
# Allow master and development
|
||||
if [ "$branch" = "master" ] || [ "$branch" = "development" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Validate feature/* and bugfix/* branches
|
||||
case "$branch" in
|
||||
feature/*|bugfix/*)
|
||||
name="${branch#*/}"
|
||||
|
||||
# Name must not be empty
|
||||
if [ -z "$name" ]; then
|
||||
echo "ERROR: Branch '$branch' has an empty name after the prefix."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Only letters, digits, and dashes allowed
|
||||
if echo "$name" | grep -qE '[^a-zA-Z0-9-]'; then
|
||||
echo "ERROR: Branch '$branch' contains invalid characters."
|
||||
echo " Only letters, digits, and dashes are allowed after the prefix."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# No leading or trailing dashes
|
||||
if echo "$name" | grep -qE '^-|-$'; then
|
||||
echo "ERROR: Branch '$branch' must not start or end with a dash."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# No consecutive dashes
|
||||
if echo "$name" | grep -q -- '--'; then
|
||||
echo "ERROR: Branch '$branch' must not contain consecutive dashes."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Max 25 characters excluding dashes
|
||||
name_no_dashes=$(echo "$name" | tr -d '-')
|
||||
char_count=${#name_no_dashes}
|
||||
if [ "$char_count" -gt 25 ]; then
|
||||
echo "ERROR: Branch '$branch': the name after the prefix is $char_count characters (excluding dashes), max is 25."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "ERROR: Branch name '$branch' does not follow the naming convention."
|
||||
echo ""
|
||||
echo " Allowed formats:"
|
||||
echo " master"
|
||||
echo " development"
|
||||
echo " feature/{name}"
|
||||
echo " bugfix/{name}"
|
||||
echo ""
|
||||
echo " Rules for feature/bugfix names:"
|
||||
echo " - Letters, digits, and dashes only (no spaces)"
|
||||
echo " - No leading, trailing, or consecutive dashes"
|
||||
echo " - Max 20 characters excluding dashes"
|
||||
exit 1
|
||||
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal file
@ -0,0 +1,19 @@
|
||||
# Dependencies
|
||||
**/node_modules/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
*.rlog
|
||||
npm-debug.log*
|
||||
|
||||
# Environment files
|
||||
**/*.env
|
||||
**/environment.env
|
||||
|
||||
# Build output
|
||||
**/dist/
|
||||
**/build/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@ -2,5 +2,5 @@ CURRENT_FILE_PATH=src/locale/messages.xlf
|
||||
TRANSLATED_FILE_PATH_ES=src/locale/messages.es.xlf
|
||||
TRANSLATED_FILE_PATH_PT=src/locale/messages.pt.xlf
|
||||
GOOGLE_LOCATION=global
|
||||
GOOGLE_PROJECT_ID=agmission
|
||||
GOOGLE_PROJECT_ID=predictive-fx-392018
|
||||
GOOGLE_APPLICATION_CREDENTIALS=google-cloud.json
|
||||
3
Development/client/.vscode/settings.json
vendored
3
Development/client/.vscode/settings.json
vendored
@ -6,5 +6,6 @@
|
||||
"formate.alignColon": true,
|
||||
"formate.verticalAlignProperties": true,
|
||||
"formate.enable": true,
|
||||
"formate.additionalSpaces": 0
|
||||
"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
|
||||
|
@ -103,7 +103,8 @@
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "6kb"
|
||||
"maximumWarning": "12kb",
|
||||
"maximumError": "18kb"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
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 |
|
||||
@ -21,7 +21,7 @@
|
||||
"i18n-merge-w": "(for %i in (pt es) do (xliffmerge --profile xliffmerge.json en %i))",
|
||||
"sync-i18n": "npm run build-prep && npm run i18n-extract && npm run i18n-merge",
|
||||
"sync-i18n-w": "npm run build-prep && npm run i18n-extract-w && npm run i18n-merge-w",
|
||||
"pre-translate": "npx --prefix ../shared/translation translation start && npm run sync-i18n && npx --prefix ../shared/translation translation translate && npx --prefix ../shared/translation translation cleanup",
|
||||
"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"
|
||||
},
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -8,10 +8,17 @@
|
||||
<user-profile-form formControlName="profile" [focusOnFirst]="isNew"></user-profile-form>
|
||||
</div>
|
||||
<div class="ui-g-12 ui-md-6 ui-lg-6 form-row">
|
||||
<span style="margin-right:12px">
|
||||
<ng-container i18n="@@accountType">Account Type</ng-container>:
|
||||
</span>
|
||||
<p-dropdown name="type" formControlName="kind" [options]="kinds" [style]="{'min-width': '120px'}">
|
||||
<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>
|
||||
@ -19,13 +26,151 @@
|
||||
</ng-template>
|
||||
</p-dropdown>
|
||||
</div>
|
||||
<div class="ui-g-12 ui-g-nopad form-row ui-fluid" style="padding-top: 0px">
|
||||
<agm-account-editor formControlName="account" [isNew]="isNew" [required]="true" [showActive]="true"></agm-account-editor>
|
||||
|
||||
<!-- 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>
|
||||
[icon]="isNew ? 'ui-icon-plus' : 'ui-icon-save'" [label]="isNew ? globals.create : globals.save"
|
||||
(click)="saveAccount(); false"></button>
|
||||
<button pButton type="button" style="width:auto" class="amber-btn" icon="ui-icon-arrow-back"
|
||||
(click)="goBack()" [label]="globals.back"></button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,10 @@
|
||||
<div class="ui-g">
|
||||
<div class="ui-g-12">
|
||||
<div class="card">
|
||||
<p-table #dt [columns]="cols" [value]="accounts" selectionMode="single" (onRowSelect)="onRowSelect($event)" (onRowUnselect)="onRowSelect($event)" [paginator]="true" [rows]="15" [pageLinks]="5" [rowsPerPageOptions]="null" [alwaysShowPaginator]="false" [(selection)]="currAcc" dataKey="_id" [resetPageOnSort]="false" [responsive]="true" stateStorage="session" stateKey="atb-ops">
|
||||
<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>
|
||||
@ -16,7 +19,8 @@
|
||||
<th *ngFor="let col of columns" [ngSwitch]="col.filtered" class="ui-fluid">
|
||||
<div class="input-with-icon" *ngSwitchCase="true">
|
||||
<i class="ui-icon-search"></i>
|
||||
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
|
||||
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)"
|
||||
[value]="dt.filters[col.field]?.value">
|
||||
</div>
|
||||
<span *ngSwitchDefault></span>
|
||||
</th>
|
||||
@ -27,7 +31,8 @@
|
||||
<td *ngFor="let col of columns" [ngSwitch]="col.field">
|
||||
<span class="ui-column-title">{{col.header}}</span>
|
||||
<span *ngSwitchCase="KIND">{{ resolveFieldData(rowData, col.field) | userType }}</span>
|
||||
<span *ngSwitchCase="ACTIVE"><p-checkbox [ngModel]="rowData[ACTIVE]" disabled binary="true"></p-checkbox></span>
|
||||
<span *ngSwitchCase="ACTIVE"><p-checkbox [ngModel]="rowData[ACTIVE]" disabled
|
||||
binary="true"></p-checkbox></span>
|
||||
<span *ngSwitchDefault>{{ resolveFieldData(rowData, col.field) }}</span>
|
||||
</td>
|
||||
|
||||
@ -35,9 +40,12 @@
|
||||
</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]="!canEdit" *ngIf="canWrite" pButton icon="ui-icon-trash" (click)="deleteAccount()" i18n-label="@@delete" label="Delete"></button>
|
||||
<button type="button" *ngIf="canWrite" pButton icon="ui-icon-plus" (click)="newAccount()" i18n-label="@@new"
|
||||
label="New"></button>
|
||||
<button type="button" [disabled]="!canEdit" pButton icon="ui-icon-edit" (click)="editAccount()"
|
||||
i18n-label="@@detail" label="Detail"></button>
|
||||
<button type="button" [disabled]="!canDelete" *ngIf="canWrite" pButton icon="ui-icon-trash"
|
||||
(click)="deleteAccount()" i18n-label="@@delete" label="Delete"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -7,7 +7,7 @@ import { User } from '../models/user.model';
|
||||
import * as fromUsers from '../reducers';
|
||||
import * as userActions from '../actions/account.actions';
|
||||
|
||||
import { RoleIds, globals } from '@app/shared/global';
|
||||
import { RoleIds, globals, OperationalStatus, Labels } from '@app/shared/global';
|
||||
import { BaseComp } from '@app/shared/base/base.component';
|
||||
import { Utils } from '@app/shared/utils';
|
||||
|
||||
@ -20,8 +20,9 @@ import { Utils } from '@app/shared/utils';
|
||||
export class AccountListComponent extends BaseComp implements OnInit, OnDestroy {
|
||||
readonly resolveFieldData = Utils.resolveFieldData;
|
||||
readonly KIND = 'kind';
|
||||
readonly ACTIVE = 'active';
|
||||
readonly ACTIVE = OperationalStatus.ACTIVE;
|
||||
accounts: Array<User>;
|
||||
isLoading: boolean;
|
||||
currAcc: User;
|
||||
cols: any[];
|
||||
userFilter: string;
|
||||
@ -51,11 +52,10 @@ export class AccountListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
|
||||
ngOnInit() {
|
||||
this.sub$ = this.store.select(fromUsers.getAllUsers).subscribe(users => this.accounts = users);
|
||||
|
||||
this.sub$.add(this.store.select(fromUsers.getIsLoading).subscribe(loading => this.isLoading = loading));
|
||||
this.sub$.add(this.store.select(fromUsers.getSelectedUser).subscribe(
|
||||
(acc) => this.currAcc = acc
|
||||
));
|
||||
// Always fetch the fresh list of accounts
|
||||
this.store.dispatch(new userActions.Fetch());
|
||||
}
|
||||
|
||||
@ -71,6 +71,13 @@ export class AccountListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
return (this.currAcc && this.currAcc._id !== '0');
|
||||
}
|
||||
|
||||
get canDelete() {
|
||||
// WI-2: Soft lock - Allow deletion of all account types including vendor accounts
|
||||
// Previously: blocked PARTNER_SYSTEM_USER accounts
|
||||
// Now: allowed with warning confirmation dialog (see deleteAccount)
|
||||
return this.canEdit;
|
||||
}
|
||||
|
||||
newAccount() {
|
||||
this.router.navigate(['account', '0'], { relativeTo: this.route });
|
||||
}
|
||||
@ -81,8 +88,19 @@ export class AccountListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
|
||||
deleteAccount() {
|
||||
if (!this.currAcc) { return; }
|
||||
|
||||
// WI-2: Soft lock - Show special warning for vendor accounts
|
||||
const isVendorAccount = this.currAcc?.kind === RoleIds.PARTNER_SYSTEM_USER;
|
||||
const message = isVendorAccount
|
||||
? Labels.VENDOR_DELETE_CONFIRM_MESSAGE
|
||||
: globals.confirmDeleteThing.replace('#thing#', globals.account);
|
||||
const header = isVendorAccount ? Labels.VENDOR_DELETE_CONFIRM_TITLE : undefined;
|
||||
|
||||
this.confirmSvc.confirm({
|
||||
message: globals.confirmDeleteThing.replace('#thing#', globals.account),
|
||||
header: header,
|
||||
message: message,
|
||||
acceptLabel: globals.yes,
|
||||
rejectLabel: globals.no,
|
||||
accept: () => {
|
||||
this.store.dispatch(new userActions.Delete(this.currAcc));
|
||||
this.currAcc = null;
|
||||
|
||||
@ -7,6 +7,7 @@ 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';
|
||||
@ -37,6 +38,7 @@ import { FEATURE_KEY, reducer } from './reducers/users.reducer';
|
||||
ToolbarModule,
|
||||
SplitButtonModule,
|
||||
TableModule,
|
||||
TooltipModule,
|
||||
|
||||
StoreModule.forFeature(FEATURE_KEY, reducer),
|
||||
EffectsModule.forFeature([AccountEffects]),
|
||||
|
||||
@ -22,7 +22,12 @@ export const CREATE = '[USERS] Create a user';
|
||||
export class Create implements Action {
|
||||
type: typeof CREATE = CREATE;
|
||||
|
||||
constructor(readonly payload: User) { }
|
||||
constructor(readonly payload: User & {
|
||||
partnerConfig?: {
|
||||
vendorSystemType: string;
|
||||
vendorConfiguration: any;
|
||||
};
|
||||
}) { }
|
||||
}
|
||||
export const CREATE_SUCCESS = '[USERS] Create user success';
|
||||
export class CreateSuccess implements Action {
|
||||
@ -39,7 +44,12 @@ export const UPDATE = '[USERS] Update user';
|
||||
export class Update implements Action {
|
||||
type: typeof UPDATE = UPDATE;
|
||||
|
||||
constructor(readonly payload: User) { }
|
||||
constructor(readonly payload: User & {
|
||||
partnerConfig?: {
|
||||
vendorSystemType: string;
|
||||
vendorConfiguration: any;
|
||||
};
|
||||
}) { }
|
||||
}
|
||||
export const UPDATE_SUCCESS = '[USERS] Update user success';
|
||||
export class UpdateSuccess implements Action {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Actions, Effect, ofType } from '@ngrx/effects';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { map, switchMap, catchError } from 'rxjs/operators';
|
||||
import { map, switchMap, catchError, repeat } from 'rxjs/operators';
|
||||
|
||||
import { Action } from '@ngrx/store';
|
||||
|
||||
@ -9,7 +9,9 @@ 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 { globals } from '@app/shared/global';
|
||||
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 {
|
||||
@ -17,7 +19,8 @@ export class AccountEffects {
|
||||
private readonly actions$: Actions,
|
||||
private readonly userSvc: UserService,
|
||||
private readonly authSvc: AuthService,
|
||||
private readonly msgSvc: AppMessageService
|
||||
private readonly msgSvc: AppMessageService,
|
||||
private readonly partnerSvc: PartnerService
|
||||
) {
|
||||
}
|
||||
|
||||
@ -25,55 +28,250 @@ export class AccountEffects {
|
||||
loadUsers$: Observable<Action> = this.actions$.pipe(
|
||||
ofType<userActions.Fetch>(userActions.FETCH),
|
||||
switchMap(() =>
|
||||
// All account types (including PARTNER_SYSTEM_USER) are returned by the backend
|
||||
// /api/users/search endpoint — no separate /api/partners/systemUsers call needed.
|
||||
this.userSvc.loadUsers({ byPuid: this.authSvc.user.parent }).pipe(
|
||||
map(users => new userActions.FetchSuccess(users)),
|
||||
catchError(err => {
|
||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.load).replace('#thing#', globals.accounts));
|
||||
return of(new userActions.FetchError());
|
||||
})
|
||||
)
|
||||
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 }) =>
|
||||
this.userSvc.saveUser(payload).pipe(
|
||||
map((user) => new userActions.CreateSuccess(user)),
|
||||
catchError(err => {
|
||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.create).replace('#thing#', globals.account));
|
||||
return of(new userActions.CreateFailed())
|
||||
})
|
||||
)
|
||||
)
|
||||
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 }) =>
|
||||
this.userSvc.saveUser(payload).pipe(
|
||||
map(() => new userActions.UpdateSuccess(payload)),
|
||||
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 => {
|
||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.save).replace('#thing#', globals.account));
|
||||
return of(new userActions.UpdateFailed());
|
||||
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 }) =>
|
||||
this.userSvc.deleteUser(payload).pipe(
|
||||
map(() => new userActions.DeleteSuccess(payload)),
|
||||
catchError(err => {
|
||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.delete).replace('#thing#', globals.account));
|
||||
return of(new userActions.UpdateFailed())
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { RoleIds } from '@app/shared/global';
|
||||
import { Address } from '@app/domain/models/subscription.model';
|
||||
import { RoleIds, OperationalStatusType } from '@app/shared/global';
|
||||
|
||||
interface RoleArray {
|
||||
[index: number]: string;
|
||||
@ -9,18 +10,98 @@ export interface User {
|
||||
username?: string;
|
||||
password?: string;
|
||||
name?: string;
|
||||
address?: string;
|
||||
address?: string | null;
|
||||
country?: string;
|
||||
Country?: any;
|
||||
phone?: string;
|
||||
email?: 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) => {
|
||||
|
||||
@ -28,6 +28,9 @@ export function reducer(
|
||||
switch (action.type) {
|
||||
|
||||
case actions.FETCH:
|
||||
// Clear stale entities immediately so the list never shows old data while loading.
|
||||
return adapter.removeAll({ ...state, loading: true });
|
||||
|
||||
case actions.CREATE:
|
||||
case actions.UPDATE:
|
||||
case actions.DELETE:
|
||||
|
||||
@ -185,6 +185,12 @@ export class UpdateAmount implements Action {
|
||||
constructor(readonly payload: PaidAmount) { }
|
||||
}
|
||||
|
||||
export const UPDATE_PROMO_SAVINGS = '[SUBSCRIPTION Making payment intent session] Update promo savings';
|
||||
export class UpdatePromoSavings implements Action {
|
||||
type: typeof UPDATE_PROMO_SAVINGS = UPDATE_PROMO_SAVINGS;
|
||||
constructor(readonly payload: number) { }
|
||||
}
|
||||
|
||||
// Resolving payment session actions
|
||||
export const FETCH_LATEST_SUBSCRIPTION = '[SUBSCRIPTION Resolving payment session] Fetch latest subscription';
|
||||
export class FetchLatestSubscription implements Action {
|
||||
@ -506,6 +512,7 @@ export type SubscriptionIntentAction =
|
||||
| UpdateBillingAddressSuccess
|
||||
| UpdateSubscriptionSuccess
|
||||
| UpdateAmount
|
||||
| UpdatePromoSavings
|
||||
| ClearPrevStage
|
||||
| GotoUsageDetail
|
||||
| LoadStripe
|
||||
|
||||
@ -7,6 +7,7 @@ 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';
|
||||
|
||||
@ -30,6 +31,10 @@ const routes: Routes = [
|
||||
path: 'customers',
|
||||
loadChildren: () => import('./customers/customer.module').then(m => m.CustomersModule),
|
||||
},
|
||||
{
|
||||
path: 'partners',
|
||||
loadChildren: () => import('./partners/partners.module').then(m => m.PartnersModule),
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
loadChildren: () => import('./profile/profile.module').then((m) => m.ProfileModule),
|
||||
@ -75,6 +80,16 @@ const routes: Routes = [
|
||||
runGuardsAndResolvers: 'always',
|
||||
data: { preload: true }
|
||||
},
|
||||
{
|
||||
path: 'partner-customers',
|
||||
loadChildren: () => import('./partner-customers/partner-customers.module').then(m => m.PartnerCustomersModule),
|
||||
runGuardsAndResolvers: 'always'
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
loadChildren: () => import('./settings/settings.module').then(m => m.SettingsModule),
|
||||
runGuardsAndResolvers: 'always'
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -100,6 +115,38 @@ const routes: Routes = [
|
||||
roles: null
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'signup',
|
||||
loadChildren: () => import('./signup/signup.module').then(m => m.SignupModule)
|
||||
},
|
||||
{
|
||||
path: 'manage-subscription',
|
||||
component: PageNotFoundComponent,
|
||||
canActivate: [NotificationRedirectGuard],
|
||||
data: {
|
||||
redirectTo: ['profile', 'myservices'],
|
||||
redirectToNoSubs: ['profile', 'services'],
|
||||
loginNotice: $localize`:Login notice for manage-subscription link@@manageSubLoginNotice:Please log in with your Master account to manage your subscriptions.`
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'update-pm',
|
||||
component: PageNotFoundComponent,
|
||||
canActivate: [NotificationRedirectGuard],
|
||||
data: {
|
||||
redirectTo: ['profile', 'payment-method-list'],
|
||||
loginNotice: $localize`:Login notice for update-pm link@@updatePmLoginNotice:Please log in with your Master account to update your payment method.`
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'update-bill-address',
|
||||
component: PageNotFoundComponent,
|
||||
canActivate: [NotificationRedirectGuard],
|
||||
data: {
|
||||
redirectTo: ['profile', 'billing-address'],
|
||||
loginNotice: $localize`:Login notice for update-bill-address link@@updateBillAddrLoginNotice:Please log in with your Master account to update your billing address.`
|
||||
}
|
||||
},
|
||||
{ path: '**', component: PageNotFoundComponent },
|
||||
];
|
||||
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
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 } from '@angular/router';
|
||||
import { NavigationEnd, NavigationError, NavigationCancel, NavigationStart } from '@angular/router';
|
||||
import { environment } from '@environments/environment';
|
||||
import { BaseComp } from './shared/base/base.component';
|
||||
|
||||
@ -18,6 +19,11 @@ 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;
|
||||
}
|
||||
@ -25,24 +31,450 @@ export class AppComponent extends BaseComp implements OnInit, OnDestroy {
|
||||
constructor() {
|
||||
super();
|
||||
this["name"] = "AppComp";
|
||||
|
||||
// Subscribe to router events and send page views to Google Analytics
|
||||
this.router.events.subscribe(event => {
|
||||
if (event instanceof NavigationEnd) {
|
||||
if (!environment.production)
|
||||
console.log(event.urlAfterRedirects);
|
||||
|
||||
// if (this.authSvc.user && this.authSvc.byPUserId) {
|
||||
// ga('set', 'userId', this.authSvc.byPUserId);
|
||||
// ga('set', 'dimension1', this.authSvc.byPUserId);
|
||||
// ga('set', 'page', event.urlAfterRedirects);
|
||||
// ga('send', 'pageview');
|
||||
// }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
// Initialize GA4 when Angular app is ready
|
||||
this.gaSvc.initialize();
|
||||
|
||||
if (!environment.production) {
|
||||
!environment.production && console.log('GA4 Service initialized:', this.gaSvc.isInitialized());
|
||||
}
|
||||
|
||||
// Track session start
|
||||
this.trackSessionStart();
|
||||
|
||||
// Subscribe to router events for comprehensive navigation tracking
|
||||
this.router.events.subscribe(event => {
|
||||
if (event instanceof NavigationStart) {
|
||||
this.handleNavigationStart(event);
|
||||
} else if (event instanceof NavigationEnd) {
|
||||
this.handleNavigationEnd(event);
|
||||
} else if (event instanceof NavigationError) {
|
||||
this.handleNavigationError(event);
|
||||
} else if (event instanceof NavigationCancel) {
|
||||
this.handleNavigationCancel(event);
|
||||
}
|
||||
});
|
||||
|
||||
// Track initial page load
|
||||
this.pageStartTime = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract page title from URL path for analytics
|
||||
* @param url - The URL path
|
||||
* @returns Human-readable page title
|
||||
*/
|
||||
private getPageTitle(url: string): string {
|
||||
// Remove query parameters and fragments
|
||||
const cleanUrl = url.split('?')[0].split('#')[0];
|
||||
|
||||
// Extract main route segments
|
||||
const segments = cleanUrl.split('/').filter(segment => segment.length > 0);
|
||||
|
||||
if (segments.length === 0) {
|
||||
return 'Dashboard';
|
||||
}
|
||||
|
||||
// Map common routes to readable titles
|
||||
const routeTitleMap: { [key: string]: string } = {
|
||||
'login': 'Login',
|
||||
'dashboard': 'Dashboard',
|
||||
'jobs': 'Jobs',
|
||||
'job': 'Job Details',
|
||||
'clients': 'Clients',
|
||||
'client': 'Client Details',
|
||||
'accounts': 'Accounts',
|
||||
'billing': 'Billing',
|
||||
'profile': 'Profile',
|
||||
'tools': 'Tools',
|
||||
'areas': 'Areas Management',
|
||||
'upload': 'File Upload',
|
||||
'track': 'Tracking',
|
||||
'admin': 'Administration'
|
||||
};
|
||||
|
||||
const mainRoute = segments[0];
|
||||
return routeTitleMap[mainRoute] || this.capitalizeRoute(mainRoute);
|
||||
}
|
||||
|
||||
/**
|
||||
* Capitalize route name for display
|
||||
* @param route - Route string
|
||||
* @returns Capitalized route name
|
||||
*/
|
||||
private capitalizeRoute(route: string): string {
|
||||
return route.charAt(0).toUpperCase() + route.slice(1).replace(/-/g, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle navigation start event
|
||||
* @param event - NavigationStart event
|
||||
*/
|
||||
private handleNavigationStart(event: NavigationStart): void {
|
||||
this.navigationStartTime = Date.now();
|
||||
|
||||
// Track navigation start
|
||||
this.gaSvc.trackEvent('navigation_started', {
|
||||
navigation_type: 'route_change',
|
||||
source_url: this.previousUrl,
|
||||
destination_url: event.url,
|
||||
navigation_method: event.navigationTrigger === 'imperative' ? 'programmatic' : 'router_link',
|
||||
navigation_timing_ms: 0,
|
||||
is_authenticated: !!(this.authSvc.user && this.authSvc.byPUserId),
|
||||
session_page_count: this.sessionPageCount,
|
||||
time_on_previous_page_ms: this.pageStartTime ? Date.now() - this.pageStartTime : 0,
|
||||
user_id: this.authSvc.byPUserId,
|
||||
user_role: this.getUserRole(),
|
||||
referrer: document.referrer,
|
||||
user_agent: navigator.userAgent,
|
||||
viewport_width: window.innerWidth,
|
||||
viewport_height: window.innerHeight,
|
||||
screen_resolution: `${screen.width}x${screen.height}`
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle successful navigation end
|
||||
* @param event - NavigationEnd event
|
||||
*/
|
||||
private handleNavigationEnd(event: NavigationEnd): void {
|
||||
const navigationTime = this.navigationStartTime ? Date.now() - this.navigationStartTime : 0;
|
||||
this.sessionPageCount++;
|
||||
|
||||
if (!environment.production) {
|
||||
console.log('Page navigation:', event.urlAfterRedirects);
|
||||
}
|
||||
|
||||
// Track navigation completion
|
||||
this.gaSvc.trackEvent('navigation_completed', {
|
||||
navigation_type: 'route_change',
|
||||
source_url: this.previousUrl,
|
||||
destination_url: event.urlAfterRedirects,
|
||||
navigation_method: 'router_link',
|
||||
navigation_timing_ms: navigationTime,
|
||||
page_title: this.getPageTitle(event.urlAfterRedirects),
|
||||
previous_page_title: this.previousUrl ? this.getPageTitle(this.previousUrl) : '',
|
||||
is_authenticated: !!(this.authSvc.user && this.authSvc.byPUserId),
|
||||
session_page_count: this.sessionPageCount,
|
||||
time_on_previous_page_ms: this.pageStartTime ? Date.now() - this.pageStartTime : 0,
|
||||
user_id: this.authSvc.byPUserId,
|
||||
user_role: this.getUserRole(),
|
||||
referrer: document.referrer,
|
||||
user_agent: navigator.userAgent,
|
||||
viewport_width: window.innerWidth,
|
||||
viewport_height: window.innerHeight,
|
||||
screen_resolution: `${screen.width}x${screen.height}`,
|
||||
bounce_candidate: this.sessionPageCount === 1
|
||||
});
|
||||
|
||||
// Track traditional page view for backward compatibility
|
||||
this.gaSvc.trackPageView(
|
||||
this.getPageTitle(event.urlAfterRedirects),
|
||||
event.urlAfterRedirects
|
||||
);
|
||||
|
||||
// Set user ID if user is authenticated
|
||||
if (this.authSvc.user && this.authSvc.byPUserId) {
|
||||
this.gaSvc.setUserId(this.authSvc.byPUserId);
|
||||
|
||||
// Set user properties for better segmentation
|
||||
this.gaSvc.setUserProperties({
|
||||
user_type: 'authenticated',
|
||||
client_name: this.authSvc.user.name || 'unknown'
|
||||
});
|
||||
}
|
||||
|
||||
// Update tracking variables
|
||||
this.previousUrl = event.urlAfterRedirects;
|
||||
this.pageStartTime = Date.now();
|
||||
|
||||
// Track slow page loads (threshold: 3 seconds)
|
||||
if (navigationTime > 3000) {
|
||||
this.gaSvc.trackEvent('slow_page_load', {
|
||||
page_title: this.getPageTitle(event.urlAfterRedirects),
|
||||
load_time_ms: navigationTime,
|
||||
connection_type: this.getConnectionType(),
|
||||
device_type: this.getDeviceType(),
|
||||
platform: 'web'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle navigation error
|
||||
* @param event - NavigationError event
|
||||
*/
|
||||
private handleNavigationError(event: NavigationError): void {
|
||||
const navigationTime = this.navigationStartTime ? Date.now() - this.navigationStartTime : 0;
|
||||
|
||||
if (!environment.production) {
|
||||
console.error('Navigation error:', event.error, 'URL:', event.url);
|
||||
}
|
||||
|
||||
// Determine error type based on error message
|
||||
let errorType: 'route_not_found' | 'navigation_cancelled' | 'guard_rejected' | 'resolver_error' | 'timeout' | 'network_error' | 'permission_denied' = 'navigation_cancelled';
|
||||
|
||||
if (event.error?.message?.includes('Cannot match any routes')) {
|
||||
errorType = 'route_not_found';
|
||||
} else if (event.error?.message?.includes('guard')) {
|
||||
errorType = 'guard_rejected';
|
||||
} else if (event.error?.message?.includes('resolver')) {
|
||||
errorType = 'resolver_error';
|
||||
} else if (event.error?.message?.includes('timeout')) {
|
||||
errorType = 'timeout';
|
||||
} else if (event.error?.message?.includes('network')) {
|
||||
errorType = 'network_error';
|
||||
} else if (event.error?.message?.includes('permission')) {
|
||||
errorType = 'permission_denied';
|
||||
}
|
||||
|
||||
// Track navigation error
|
||||
this.gaSvc.trackEvent('navigation_error', {
|
||||
error_type: errorType,
|
||||
error_message: event.error?.message || 'Unknown navigation error',
|
||||
error_code: event.error?.name || 'NavigationError',
|
||||
error_stack: event.error?.stack || '',
|
||||
attempted_url: event.url,
|
||||
source_url: this.previousUrl,
|
||||
navigation_method: 'router_link',
|
||||
error_timestamp: new Date().toISOString(),
|
||||
navigation_timing_ms: navigationTime,
|
||||
is_authenticated: !!(this.authSvc.user && this.authSvc.byPUserId),
|
||||
user_permissions: this.getUserPermissions(),
|
||||
session_duration_ms: this.pageStartTime ? Date.now() - this.pageStartTime : 0,
|
||||
previous_successful_navigation: this.previousUrl,
|
||||
user_id: this.authSvc.byPUserId,
|
||||
user_role: this.getUserRole(),
|
||||
browser_info: navigator.userAgent,
|
||||
device_type: this.getDeviceType(),
|
||||
route_depth: event.url.split('/').length - 1,
|
||||
resolution_action: this.getResolutionAction(errorType),
|
||||
resolution_successful: false,
|
||||
resolution_time_ms: 0
|
||||
});
|
||||
|
||||
// Attempt to resolve the error
|
||||
this.resolveNavigationError(event, errorType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle navigation cancel
|
||||
* @param event - NavigationCancel event
|
||||
*/
|
||||
private handleNavigationCancel(event: NavigationCancel): void {
|
||||
const navigationTime = this.navigationStartTime ? Date.now() - this.navigationStartTime : 0;
|
||||
|
||||
if (!environment.production) {
|
||||
console.log('Navigation cancelled:', event.reason, 'URL:', event.url);
|
||||
}
|
||||
|
||||
// Track navigation cancellation
|
||||
this.gaSvc.trackEvent('navigation_cancelled', {
|
||||
error_type: 'navigation_cancelled',
|
||||
error_message: event.reason || 'Navigation was cancelled',
|
||||
error_code: 'NavigationCancel',
|
||||
attempted_url: event.url,
|
||||
source_url: this.previousUrl,
|
||||
navigation_method: 'router_link',
|
||||
error_timestamp: new Date().toISOString(),
|
||||
navigation_timing_ms: navigationTime,
|
||||
is_authenticated: !!(this.authSvc.user && this.authSvc.byPUserId),
|
||||
user_permissions: this.getUserPermissions(),
|
||||
session_duration_ms: this.pageStartTime ? Date.now() - this.pageStartTime : 0,
|
||||
previous_successful_navigation: this.previousUrl,
|
||||
user_id: this.authSvc.byPUserId,
|
||||
user_role: this.getUserRole(),
|
||||
browser_info: navigator.userAgent,
|
||||
device_type: this.getDeviceType(),
|
||||
route_depth: event.url.split('/').length - 1,
|
||||
resolution_action: 'none',
|
||||
resolution_successful: false,
|
||||
resolution_time_ms: 0
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user role from user model using shared analytics helpers
|
||||
*/
|
||||
private getUserRole(): string {
|
||||
if (!this.authSvc.user?.roles) {
|
||||
return 'anonymous';
|
||||
}
|
||||
|
||||
// Use shared analytics helper through base component convenience method
|
||||
return this.getAnalyticsUserRole();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user permissions from user model
|
||||
*/
|
||||
private getUserPermissions(): string[] {
|
||||
if (!this.authSvc.user?.roles) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const permissions: string[] = [];
|
||||
const roles = this.authSvc.user.roles;
|
||||
|
||||
// Map roles to permissions
|
||||
if (roles.admin) permissions.push('admin', 'full_access');
|
||||
if (roles.officer) permissions.push('officer', 'job_management', 'financial_access');
|
||||
if (roles.pilot) permissions.push('pilot', 'job_execution', 'tracking_access');
|
||||
if (roles.applicator) permissions.push('applicator', 'job_execution', 'tracking_access');
|
||||
if (roles.client) permissions.push('client', 'job_creation', 'report_access');
|
||||
if (roles.inspector) permissions.push('inspector', 'report_access');
|
||||
if (roles.aircraft) permissions.push('aircraft', 'data_upload');
|
||||
|
||||
return permissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine device type based on screen size and user agent
|
||||
*/
|
||||
private getDeviceType(): 'desktop' | 'mobile' | 'tablet' {
|
||||
const userAgent = navigator.userAgent;
|
||||
|
||||
if (/tablet|ipad|playbook|silk/i.test(userAgent)) {
|
||||
return 'tablet';
|
||||
}
|
||||
|
||||
if (/mobile|iphone|ipod|android|blackberry|opera|mini|windows\sce|palm|smartphone|iemobile/i.test(userAgent)) {
|
||||
return 'mobile';
|
||||
}
|
||||
|
||||
return 'desktop';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine connection type based on Network Information API
|
||||
*/
|
||||
private getConnectionType(): 'wifi' | 'cellular' | 'ethernet' | 'unknown' {
|
||||
// Check if Network Information API is available
|
||||
if ('connection' in navigator) {
|
||||
const connection = (navigator as any).connection;
|
||||
const effectiveType = connection?.effectiveType;
|
||||
|
||||
// Map effective connection types to our categories
|
||||
if (effectiveType === 'slow-2g' || effectiveType === '2g' || effectiveType === '3g') {
|
||||
return 'cellular';
|
||||
}
|
||||
if (effectiveType === '4g') {
|
||||
return 'cellular';
|
||||
}
|
||||
|
||||
// Check connection type if available
|
||||
const type = connection?.type;
|
||||
if (type === 'wifi') return 'wifi';
|
||||
if (type === 'ethernet') return 'ethernet';
|
||||
if (type === 'cellular') return 'cellular';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine resolution action based on error type
|
||||
*/
|
||||
private getResolutionAction(errorType: string): 'redirect_to_home' | 'redirect_to_login' | 'show_error_page' | 'retry_navigation' | 'none' {
|
||||
switch (errorType) {
|
||||
case 'route_not_found':
|
||||
return 'redirect_to_home';
|
||||
case 'guard_rejected':
|
||||
case 'permission_denied':
|
||||
return 'redirect_to_login';
|
||||
case 'resolver_error':
|
||||
case 'timeout':
|
||||
case 'network_error':
|
||||
return 'retry_navigation';
|
||||
default:
|
||||
return 'show_error_page';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to resolve navigation errors
|
||||
*/
|
||||
private resolveNavigationError(event: NavigationError, errorType: string): void {
|
||||
const resolutionStartTime = Date.now();
|
||||
const action = this.getResolutionAction(errorType);
|
||||
|
||||
switch (action) {
|
||||
case 'redirect_to_home':
|
||||
this.router.navigate(['/']).then(success => {
|
||||
this.trackResolutionResult(event, action, success, resolutionStartTime);
|
||||
});
|
||||
break;
|
||||
|
||||
case 'redirect_to_login':
|
||||
this.router.navigate(['/login']).then(success => {
|
||||
this.trackResolutionResult(event, action, success, resolutionStartTime);
|
||||
});
|
||||
break;
|
||||
|
||||
case 'retry_navigation':
|
||||
// Retry the original navigation after a brief delay
|
||||
setTimeout(() => {
|
||||
this.router.navigate([event.url]).then(success => {
|
||||
this.trackResolutionResult(event, action, success, resolutionStartTime);
|
||||
});
|
||||
}, 1000);
|
||||
break;
|
||||
|
||||
default:
|
||||
this.trackResolutionResult(event, action, false, resolutionStartTime);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track the result of navigation error resolution
|
||||
*/
|
||||
private trackResolutionResult(event: NavigationError, action: string, success: boolean, startTime: number): void {
|
||||
const resolutionTime = Date.now() - startTime;
|
||||
|
||||
// Update the original navigation error event with resolution results
|
||||
this.gaSvc.trackEvent('navigation_error', {
|
||||
error_type: 'navigation_cancelled',
|
||||
error_message: event.error?.message || 'Navigation error resolved',
|
||||
error_code: event.error?.name || 'NavigationError',
|
||||
attempted_url: event.url,
|
||||
source_url: this.previousUrl,
|
||||
navigation_method: 'router_link',
|
||||
error_timestamp: new Date().toISOString(),
|
||||
is_authenticated: !!(this.authSvc.user && this.authSvc.byPUserId),
|
||||
user_id: this.authSvc.byPUserId,
|
||||
user_role: this.getUserRole(),
|
||||
resolution_action: action as any,
|
||||
resolution_successful: success,
|
||||
resolution_time_ms: resolutionTime
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Track session start event
|
||||
*/
|
||||
private trackSessionStart(): void {
|
||||
// Get current route for entry page
|
||||
const entryPage = this.router.url || '/';
|
||||
|
||||
// Track session start with required parameters
|
||||
this.gaSvc.trackEvent('session_start', {
|
||||
platform: 'web',
|
||||
user_role: this.getUserRole(),
|
||||
entry_page: entryPage,
|
||||
referrer: document.referrer || undefined,
|
||||
session_id: this.generateSessionId(),
|
||||
user_id: this.authSvc.byPUserId
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique session ID
|
||||
*/
|
||||
private generateSessionId(): string {
|
||||
return 'sess_' + Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
|
||||
@ -28,17 +28,23 @@
|
||||
<app-topbar></app-topbar>
|
||||
|
||||
<div class="layout-menu" [ngClass]="{'layout-menu-dark':darkMenu}" (click)="onMenuClick($event)">
|
||||
<app-inline-profile *ngIf="profileMode=='inline'&&!isHorizontal()"></app-inline-profile>
|
||||
<app-menu></app-menu>
|
||||
</div>
|
||||
|
||||
<div class="layout-main">
|
||||
<div class="content-expiry-banner" *ngIf="expiryWarning$ | async as warning">
|
||||
<span class="expiry-warning"
|
||||
[class.warning]="!warning.willAutoRenew"
|
||||
[class.info]="warning.willAutoRenew"
|
||||
(click)="onNavigateToManageSubscription()">
|
||||
{{ getExpiryWarningMessage(warning) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="layout-content">
|
||||
<ng-container *ngIf="canDisplayTrial()">
|
||||
<trial-message [trials]="membership.trials" [isTrialDays]="isTrialDays()" [canDisplayAcceptTrial]="canDisplayAcceptTrial()" (accept)="accept()">
|
||||
</trial-message>
|
||||
</ng-container>
|
||||
<router-outlet></router-outlet>
|
||||
</ng-container> <router-outlet></router-outlet>
|
||||
<agm-footer *ngIf="showFooter" [showLang]="!isAdmin"></agm-footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -4,7 +4,8 @@ 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';
|
||||
@ -12,7 +13,10 @@ 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 } from './auth/models/user.model';
|
||||
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,
|
||||
@ -71,6 +75,8 @@ export class AppMainComponent implements AfterViewInit, OnDestroy, OnInit, After
|
||||
|
||||
settings: IAppConfig;
|
||||
membership: IMembership;
|
||||
user$: Observable<UserModel>;
|
||||
expiryWarning$: Observable<ExpiryWarning | null>;
|
||||
|
||||
constructor(
|
||||
public readonly zone: NgZone,
|
||||
@ -86,6 +92,11 @@ export class AppMainComponent implements AfterViewInit, OnDestroy, OnInit, After
|
||||
) {
|
||||
this.membership = this.route.snapshot.data['membership'];
|
||||
this.settings = cloneDeep(this.appConfSvc.settings);
|
||||
this.user$ = this.store.select(fromStore.selectAuthUser);
|
||||
this.expiryWarning$ = combineLatest([
|
||||
this.store.select(fromStore.selectExpiryWarning),
|
||||
this.store.select(fromStore.selectNoSubsWarning)
|
||||
]).pipe(map(([expiry, noSubs]) => expiry ?? noSubs));
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
@ -95,6 +106,14 @@ export class AppMainComponent implements AfterViewInit, OnDestroy, OnInit, After
|
||||
this.showPaidPopup();
|
||||
}
|
||||
|
||||
getExpiryWarningMessage(warning: ExpiryWarning): string {
|
||||
return buildExpiryWarningMessage(warning);
|
||||
}
|
||||
|
||||
onNavigateToManageSubscription(): void {
|
||||
this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]);
|
||||
}
|
||||
|
||||
bindRipple() {
|
||||
this.rippleInitListener = this.init.bind(this);
|
||||
document.addEventListener('DOMContentLoaded', this.rippleInitListener);
|
||||
@ -448,6 +467,11 @@ export class AppMainComponent implements AfterViewInit, OnDestroy, OnInit, After
|
||||
}
|
||||
|
||||
canDisplayTrial() {
|
||||
// this.membership is populated synchronously by the route resolver, before the
|
||||
// NgRx auth store is hydrated (FetchLatestSubscriptionSuccess fires 100ms later).
|
||||
// Using it here prevents the banner from flashing on F5 reload for users who
|
||||
// already have subscriptions (including trialing ones).
|
||||
if (this.membership?.subscriptions?.length > 0) return false;
|
||||
return this.authSvc.canDisplayTrial(this.membership?.trials);
|
||||
}
|
||||
|
||||
|
||||
@ -28,6 +28,8 @@ export class AppMenuComponent implements OnInit {
|
||||
ngOnInit() {
|
||||
if (this.authSvc.hasRole([RoleIds.ADMIN])) {
|
||||
this.creatAdminMenu()
|
||||
} else if (this.authSvc.isPartner) {
|
||||
this.createPartnerMenu();
|
||||
} else {
|
||||
this.createUserMenu();
|
||||
}
|
||||
@ -37,7 +39,39 @@ export class AppMenuComponent implements OnInit {
|
||||
const mItems: MenuItem[] = [
|
||||
{ id: 'dashboard', label: $localize`:@@dashboard:Dashboard`, icon: 'dashboard', routerLink: ['/home'] },
|
||||
{ id: 'customers', label: $localize`:@@customers:Customers`, icon: 'assignment_ind', routerLink: ['/customers'] },
|
||||
{ id: 'partners', label: $localize`:@@partnerMgnt:Partner Management`, icon: 'business', routerLink: ['/partners'] },
|
||||
{ label: $localize`:@@billing:Billing`, icon: 'monetization_on', routerLink: ['/billing'] },
|
||||
{
|
||||
id: 'settings',
|
||||
label: $localize`:@@settings:Settings`, icon: 'settings',
|
||||
routerLink: ['/settings'],
|
||||
items: [
|
||||
{ id: 'subscription', label: $localize`:@@promoManagement:Promo Management`, icon: 'credit_card', routerLink: ['/settings/subscription'] }
|
||||
]
|
||||
},
|
||||
];
|
||||
this.model = mItems;
|
||||
}
|
||||
|
||||
createPartnerMenu() {
|
||||
const mItems: MenuItem[] = [
|
||||
{ id: 'dashboard', label: $localize`:@@dashboard:Dashboard`, icon: 'dashboard', routerLink: ['/home'] },
|
||||
{
|
||||
id: 'partner-customers',
|
||||
label: $localize`:@@partnerCustomers:Partner Customers`,
|
||||
icon: 'business',
|
||||
routerLink: ['/partner-customers']
|
||||
},
|
||||
{
|
||||
id: 'Help',
|
||||
label: $localize`:@@help:Help`, icon: 'help_outline',
|
||||
items: [{
|
||||
label: $localize`:@@trainingVideos:Training Videos`,
|
||||
icon: 'video_library',
|
||||
url: 'https://www.youtube.com/watch?v=QjGZan5QdAo&list=PLSMll_kIgHA3eamxiSH0Dgl95v60okMcV',
|
||||
target: '_blank'
|
||||
}]
|
||||
}
|
||||
];
|
||||
this.model = mItems;
|
||||
}
|
||||
@ -74,6 +108,19 @@ export class AppMenuComponent implements OnInit {
|
||||
} else if (hasPackage && hasTracking) {
|
||||
this.addFullAccessItems(mItems);
|
||||
}
|
||||
|
||||
mItems.push(
|
||||
{
|
||||
id: 'Help',
|
||||
label: $localize`:@@help:Help`, icon: 'help_outline',
|
||||
items: [{
|
||||
label: $localize`:@@trainingVideos:Training Videos`,
|
||||
icon: 'video_library',
|
||||
url: 'https://www.youtube.com/watch?v=QjGZan5QdAo&list=PLSMll_kIgHA3eamxiSH0Dgl95v60okMcV',
|
||||
target: '_blank'
|
||||
}]
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private addOnlyTrackingItems(mItems: MenuItem[]) {
|
||||
@ -161,7 +208,8 @@ export class AppMenuComponent implements OnInit {
|
||||
routerLink: ['/tools'],
|
||||
items: [
|
||||
{ id: 'upload', label: $localize`:@@uploadJobData:Upload Job Data`, icon: 'cloud_upload', routerLink: ['/tools/upload'] },
|
||||
{ id: 'areaLib', label: $localize`:@@manageAreasLib:Manage Areas Library`, icon: 'folder_special', routerLink: ['/tools/areas'] }
|
||||
{ id: 'areaLib', label: $localize`:@@manageAreasLib:Manage Areas Library`, icon: 'folder_special', routerLink: ['/tools/areas'] },
|
||||
{ id: 'settings', label: $localize`:@@settings:Settings`, icon: 'settings', routerLink: ['/tools/settings'] }
|
||||
]
|
||||
}
|
||||
);
|
||||
|
||||
@ -10,6 +10,7 @@ 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';
|
||||
@ -21,7 +22,6 @@ import { AppMainComponent } from './app.main.component';
|
||||
import { AppMenuComponent } from './app.menu.component';
|
||||
import { AppMenuitemComponent } from './app.menuitem.component';
|
||||
import { AppTopbarComponent } from './app.topbar.component';
|
||||
import { AppFooterComponent } from './app.footer.component';
|
||||
import { AppInlineProfileComponent } from './app.profile.component';
|
||||
|
||||
import { DashboardComponent } from './dashboard/dashboard.component';
|
||||
@ -52,7 +52,6 @@ import { AppConfigService } from './domain/services/app-config.service';
|
||||
import { AuthInterceptor } from './domain/services/auth-interceptor.service';
|
||||
|
||||
import { SettingsGuard } from './domain/guards/settings-guard.service';
|
||||
import { LanguageSwicherComponent } from './language-swicher.component';
|
||||
|
||||
import { AppEffects } from './effects/app.effects';
|
||||
import { SubPlansEffects } from './effects/sub-plans.effects';
|
||||
@ -88,7 +87,7 @@ export function translationsFactory(locale: string) {
|
||||
imports: [
|
||||
BrowserModule, BrowserAnimationsModule, HttpClientModule, GlobalModule,
|
||||
InputTextModule, ButtonModule, MenuModule, ProgressSpinnerModule, ScrollPanelModule,
|
||||
MessagesModule, ToastModule, ConfirmDialogModule, DropdownModule, CheckboxModule, AppSharedModule,
|
||||
MessagesModule, ToastModule, ConfirmDialogModule, DialogModule, DropdownModule, CheckboxModule, AppSharedModule,
|
||||
// The store that defines our app state
|
||||
StoreModule.forRoot(reducers, {
|
||||
metaReducers,
|
||||
@ -116,10 +115,8 @@ export function translationsFactory(locale: string) {
|
||||
AppMenuitemComponent,
|
||||
AppInlineProfileComponent,
|
||||
AppTopbarComponent,
|
||||
AppFooterComponent,
|
||||
LanguageSwicherComponent,
|
||||
ReportComponent,
|
||||
AppPasswordResetComp,
|
||||
AppPasswordResetComp
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
|
||||
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>
|
||||
@ -1,69 +1,138 @@
|
||||
import { AppMainComponent } from './app.main.component';
|
||||
import { Component } from '@angular/core';
|
||||
import { trigger, state, transition, style, animate } from '@angular/animations';
|
||||
import { Component, Input, Output, EventEmitter } from '@angular/core';
|
||||
import { of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { globals } from './shared/global';
|
||||
import { UserModel } from './auth/models/user.model';
|
||||
import { UserService } from './domain/services/user.service';
|
||||
import { ExpiryWarning } from './domain/models/subscription.model';
|
||||
|
||||
import { Store } from '@ngrx/store';
|
||||
import * as authActions from './auth/actions/auth.actions';
|
||||
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",
|
||||
template: `
|
||||
<div class="profile" [ngClass]="{'profile-expanded':active}">
|
||||
<a href="#" (click)="onClick($event)">
|
||||
<img class="profile-image" src="assets/layout/images/avatar.png" />
|
||||
<span class="profile-name">Jane Williams</span>
|
||||
<i class="material-icons">keyboard_arrow_down</i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<ul class="ultima-menu profile-menu" [@menu]="active ? 'visible' : 'hidden'">
|
||||
<li role="menuitem">
|
||||
<a href="#" class="ripplelink" [attr.tabindex]="!active ? '-1' : null">
|
||||
<i class="material-icons">person</i>
|
||||
<span>Profile</span>
|
||||
</a>
|
||||
</li>
|
||||
<li role="menuitem">
|
||||
<a
|
||||
href="#"class="ripplelink" [attr.tabindex]="!active ? '-1' : null" (click)="onLogout($event)"
|
||||
>
|
||||
<i class="material-icons">power_settings_new</i>
|
||||
<span>Logout</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
`,
|
||||
animations: [
|
||||
trigger('menu', [
|
||||
state('hidden', style({
|
||||
height: '0px'
|
||||
})),
|
||||
state('visible', style({
|
||||
height: '*'
|
||||
})),
|
||||
transition('visible => hidden', animate('400ms cubic-bezier(0.86, 0, 0.07, 1)')),
|
||||
transition('hidden => visible', animate('400ms cubic-bezier(0.86, 0, 0.07, 1)'))
|
||||
])
|
||||
],
|
||||
templateUrl: "./app.profile.component.html",
|
||||
styleUrls: ['./app.profile.component.css']
|
||||
})
|
||||
export class AppInlineProfileComponent {
|
||||
active: boolean;
|
||||
readonly globals = globals;
|
||||
|
||||
constructor(
|
||||
private readonly store: Store<{}>,
|
||||
public readonly app: AppMainComponent) {}
|
||||
@Input() user: UserModel;
|
||||
@Input() expiryWarning: ExpiryWarning | null;
|
||||
@Output() navigateToSubscription = new EventEmitter<void>();
|
||||
|
||||
onClick(event) {
|
||||
this.active = !this.active;
|
||||
// setTimeout(() => {
|
||||
// this.app.layoutMenuScrollerViewChild.moveBar();
|
||||
// }, 450);
|
||||
event.preventDefault();
|
||||
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);
|
||||
}
|
||||
|
||||
switchProfile() {}
|
||||
onLogout(e) {
|
||||
this.store.dispatch(new authActions.Logout());
|
||||
e.preventDefault();
|
||||
getWarningMessage(): string {
|
||||
return buildExpiryWarningMessage(this.expiryWarning);
|
||||
}
|
||||
|
||||
onWarningClick(): void {
|
||||
// Always navigate to subscription for all accounts
|
||||
this.navigateToSubscription.emit();
|
||||
// Show master-account info popup only for sub-accounts:
|
||||
// skip if no parent, or parent is the same as this user (self-referencing master)
|
||||
const parentId = this.user?.parent;
|
||||
if (!parentId || parentId === this.user._id) return;
|
||||
|
||||
const now = Date.now();
|
||||
const isFresh = this.masterInfoFetchedAt !== null && (now - this.masterInfoFetchedAt) < this.MASTER_INFO_TTL_MS;
|
||||
if (isFresh) {
|
||||
this.showMasterPopup = true;
|
||||
return;
|
||||
}
|
||||
this.userSvc.getUser(parentId, { view: 'profile' }).pipe(
|
||||
catchError(() => of(null))
|
||||
).subscribe(master => {
|
||||
if (master) {
|
||||
this.masterInfo = {
|
||||
username: master.username ?? '',
|
||||
contact: master.contact,
|
||||
name: master.name,
|
||||
phone: master.phone,
|
||||
email: master.email,
|
||||
};
|
||||
} else {
|
||||
// Fallback: show whatever the parent field holds (may be a populated object)
|
||||
const p = this.user.parent;
|
||||
this.masterInfo = {
|
||||
username: (typeof p === 'object' && p?.username) ? p.username : '',
|
||||
};
|
||||
}
|
||||
this.masterInfoFetchedAt = Date.now();
|
||||
this.showMasterPopup = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,8 +2,9 @@
|
||||
<div class="topbar-left">
|
||||
<div class="agm-logo"></div>
|
||||
</div>
|
||||
<div *ngIf="user$ | async as user" class="topbar-right">
|
||||
|
||||
<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>
|
||||
@ -13,7 +14,8 @@
|
||||
<span *ngIf="!app.isAdmin" class="topbar-badge animated">1</span>
|
||||
</a>
|
||||
<ul class="topbar-items animated fadeInDown" [ngClass]="{ 'topbar-items-visible': app.topbarMenuActive }">
|
||||
<li #profile class="profile-item" *ngIf="app.profileMode === 'top' || app.isHorizontal()" [ngClass]="{ 'active-top-menu': app.activeTopbarItem === profile }">
|
||||
<li #profile class="profile-item" *ngIf="app.profileMode === 'top' || app.isHorizontal()"
|
||||
[ngClass]="{ 'active-top-menu': app.activeTopbarItem === profile }">
|
||||
<a href="#" (click)="app.onTopbarItemClick($event, profile)">
|
||||
<i class="topbar-icon material-icons">apps</i>
|
||||
<span class="topbar-item-name">Profile</span>
|
||||
|
||||
@ -1,27 +1,77 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { Observable } from 'rxjs';
|
||||
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 { Mode, SUB } from './profile/common';
|
||||
import { StartBillingInfo } from './actions/subscription.actions';
|
||||
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 {
|
||||
user$: Observable<UserModel>
|
||||
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 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() {
|
||||
@ -33,7 +83,7 @@ export class AppTopbarComponent {
|
||||
}
|
||||
|
||||
manageContact(user) {
|
||||
this.store.dispatch(new StartBillingInfo({ applicatorId: user?._id, mode: Mode.UPDATE_BIL_ADR }));
|
||||
this.router.navigate([SUB.PROFILE, SUB.BILL_ADR_LIST]);
|
||||
}
|
||||
|
||||
onLogout(e) {
|
||||
@ -44,4 +94,12 @@ export class AppTopbarComponent {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -37,9 +37,17 @@ export class LogoutComplete implements Action {
|
||||
|
||||
}
|
||||
|
||||
export const REFRESH_USER_DATA = '[Auth] Refresh User Data';
|
||||
export class RefreshUserData implements Action {
|
||||
readonly type: typeof REFRESH_USER_DATA = REFRESH_USER_DATA;
|
||||
|
||||
constructor(public payload: { user: UserModel }) { }
|
||||
}
|
||||
|
||||
export type All =
|
||||
| Login
|
||||
| LoginSuccess
|
||||
| LoginFailed
|
||||
| Logout
|
||||
| LogoutComplete;
|
||||
| LogoutComplete
|
||||
| RefreshUserData;
|
||||
|
||||
@ -50,8 +50,9 @@ export class AuthEffects {
|
||||
|
||||
private navigateDefault(lang) {
|
||||
const hash = (this.router.url.indexOf('#') == -1) ? '/#/' : '/';
|
||||
const returnUrl = this.router.parseUrl(this.router.url).queryParams['returnUrl'] || 'home';
|
||||
// Replace the current page with the next target url => prevent Back to previous
|
||||
window.location.replace((lang === 'en' ? `${hash}` : `/${lang}${hash}`) + 'home');
|
||||
window.location.replace((lang === 'en' ? `${hash}` : `/${lang}${hash}`) + returnUrl);
|
||||
}
|
||||
|
||||
@Effect()
|
||||
|
||||
@ -12,8 +12,11 @@
|
||||
|
||||
<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>
|
||||
<span *ngIf="username.invalid && (username.dirty || username.touched)" class="ui-message ui-messages-error ui-corner-all">
|
||||
<input type="text" name="username" [pattern]="GC?.emailRegex" email [(ngModel)]="model.username"
|
||||
#username="ngModel" required autocomplete="on" pInputText
|
||||
(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>
|
||||
@ -21,22 +24,29 @@
|
||||
</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>
|
||||
<span i18n="@@pwdReqVal" *ngIf="password.invalid && (password.dirty || password.touched)" class="ui-message ui-messages-error ui-corner-all">Password is required</span>
|
||||
<input type="password" name="password" agmPwdToggle [(ngModel)]="model.password" #password="ngModel" required
|
||||
autocomplete="on" pInputText
|
||||
(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 #captchaElem [siteKey]="siteKey" [size]="size" [hl]="lang" [theme]="theme" [type]="type"
|
||||
name="recaptcha" data-size="compact" [useGlobalDomain]="false" [(ngModel)]="model.token"
|
||||
#recaptcha="ngModel" (load)="handleLoad()" (reload)="handleReload()" (success)="handleSuccess($event)"
|
||||
(expire)="handleExpire()" (reset)="handleReset()" (error)="handleError($event)">
|
||||
</ngx-recaptcha2>
|
||||
<span i18n="@@reCaptchaReqMsg" *ngIf="(useReCaptcha && !captchaSuccess)" class="ui-message ui-messages-error" style="width: 100%;">You must complete the reCAPTCHA to log in.</span>
|
||||
<span i18n="@@reCaptchaReqMsg" *ngIf="(useReCaptcha && !captchaSuccess)" class="ui-message ui-messages-error"
|
||||
style="width: 100%;">You must complete the reCAPTCHA to log in.</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="ui-g-12">
|
||||
<button type="submit" [disabled]="(!f.valid) || (useReCaptcha && !captchaSuccess) || (pending$ | async)" i18n-label="@@login" label="Login" icon="ui-icon-person" pButton></button>
|
||||
<button type="submit" [disabled]="(!f.valid) || (useReCaptcha && !captchaSuccess) || (pending$ | async)"
|
||||
i18n-label="@@login" label="Login" icon="ui-icon-person" pButton></button>
|
||||
<img *ngIf="pending$ | async"
|
||||
src="data:image/gif;base64,R0lGODlhEAAQAPIAAP///wAAAMLCwkJCQgAAAGJiYoKCgpKSkiH/C05FVFNDQVBFMi4wAwEAAAAh/hpDcmVhdGVkIHdpdGggYWpheGxvYWQuaW5mbwAh+QQJCgAAACwAAAAAEAAQAAADMwi63P4wyklrE2MIOggZnAdOmGYJRbExwroUmcG2LmDEwnHQLVsYOd2mBzkYDAdKa+dIAAAh+QQJCgAAACwAAAAAEAAQAAADNAi63P5OjCEgG4QMu7DmikRxQlFUYDEZIGBMRVsaqHwctXXf7WEYB4Ag1xjihkMZsiUkKhIAIfkECQoAAAAsAAAAABAAEAAAAzYIujIjK8pByJDMlFYvBoVjHA70GU7xSUJhmKtwHPAKzLO9HMaoKwJZ7Rf8AYPDDzKpZBqfvwQAIfkECQoAAAAsAAAAABAAEAAAAzMIumIlK8oyhpHsnFZfhYumCYUhDAQxRIdhHBGqRoKw0R8DYlJd8z0fMDgsGo/IpHI5TAAAIfkECQoAAAAsAAAAABAAEAAAAzIIunInK0rnZBTwGPNMgQwmdsNgXGJUlIWEuR5oWUIpz8pAEAMe6TwfwyYsGo/IpFKSAAAh+QQJCgAAACwAAAAAEAAQAAADMwi6IMKQORfjdOe82p4wGccc4CEuQradylesojEMBgsUc2G7sDX3lQGBMLAJibufbSlKAAAh+QQJCgAAACwAAAAAEAAQAAADMgi63P7wCRHZnFVdmgHu2nFwlWCI3WGc3TSWhUFGxTAUkGCbtgENBMJAEJsxgMLWzpEAACH5BAkKAAAALAAAAAAQABAAAAMyCLrc/jDKSatlQtScKdceCAjDII7HcQ4EMTCpyrCuUBjCYRgHVtqlAiB1YhiCnlsRkAAAOwAAAAAAAAAAAA==" />
|
||||
<a [routerLink]="['/password-reset']" href="">
|
||||
@ -44,5 +54,10 @@
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
<div class="ui-g-12" style="padding-left: 0; padding-right: 0; padding-top: 0;">
|
||||
<a [routerLink]="['/signup']" href="">
|
||||
<ng-container i18n="@@signup">Sign Up for a new Agmission Master Account</ng-container>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -1,5 +1,7 @@
|
||||
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';
|
||||
@ -36,23 +38,57 @@ export class LoginComponent extends BaseComp implements OnInit, OnDestroy {
|
||||
public captchaSuccess = false;
|
||||
private _lastVerReqAt: number = 0;
|
||||
|
||||
// Debounced validation to prevent flash error on Chrome autofill
|
||||
public showUsernameError = false;
|
||||
public showPasswordError = false;
|
||||
private usernameValidation$ = new Subject<boolean>();
|
||||
private passwordValidation$ = new Subject<boolean>();
|
||||
|
||||
constructor(
|
||||
|
||||
) {
|
||||
super();
|
||||
this['name'] = "LoginComp";
|
||||
|
||||
if (this.router.getCurrentNavigation()) {
|
||||
const routeSate = this.router.getCurrentNavigation().extras && this.router.getCurrentNavigation().extras.state;
|
||||
if (routeSate && routeSate.changedPwd) {
|
||||
this.msgs = [{ severity: 'info', summary: '', detail: globals.pwdChangedOk }];
|
||||
const 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();
|
||||
@ -73,6 +109,22 @@ export class LoginComponent extends BaseComp implements OnInit, OnDestroy {
|
||||
return StringUtils.isEmpty(this.model.username) ? globals.usernameReqVal : globals.usernameInvalidVal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits username validation state with debounce to prevent flash on Chrome autofill.
|
||||
* Called on input and blur events.
|
||||
*/
|
||||
onUsernameValidation(invalid: boolean, dirty: boolean, touched: boolean) {
|
||||
this.usernameValidation$.next(invalid && (dirty || touched));
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits password validation state with debounce to prevent flash on Chrome autofill.
|
||||
* Called on input and blur events.
|
||||
*/
|
||||
onPasswordValidation(invalid: boolean, dirty: boolean, touched: boolean) {
|
||||
this.passwordValidation$.next(invalid && (dirty || touched));
|
||||
}
|
||||
|
||||
handleSuccess(captchaResp: string): void {
|
||||
// Verify user reponse token with server side within 2 minutes according to GG Ref: https://developers.google.com/recaptcha/docs/verify
|
||||
this._lastVerReqAt = Date.now();
|
||||
|
||||
@ -9,7 +9,10 @@ export interface UserModel {
|
||||
lang: string;
|
||||
pre: number;
|
||||
billable?: boolean;
|
||||
membership?: IMembership
|
||||
membership?: IMembership,
|
||||
contact: string;
|
||||
country?: string;
|
||||
partner?: string;
|
||||
}
|
||||
|
||||
export interface IMembership {
|
||||
@ -17,4 +20,8 @@ export interface IMembership {
|
||||
endOfPeriod?: Number;
|
||||
subscriptions?: AGNavSubscription[];
|
||||
trials?: Trial;
|
||||
customLimits?: {
|
||||
maxVehicles?: number | null;
|
||||
maxAcres?: number | null;
|
||||
};
|
||||
}
|
||||
@ -7,27 +7,60 @@ import * as fromClients from './clients.reducer';
|
||||
|
||||
export const getClientsState = createFeatureSelector<fromClients.State>(fromClients.FEATURE_KEY);
|
||||
|
||||
export const getSelectedClientId = createSelector(
|
||||
// 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(
|
||||
getClientsState,
|
||||
getClientsStateOrInitial,
|
||||
fromClients.getIsLoading
|
||||
);
|
||||
|
||||
export const isLoaded = createSelector(
|
||||
getClientsState,
|
||||
getClientsStateOrInitial,
|
||||
fromClients.getIsLoaded
|
||||
);
|
||||
|
||||
export const {
|
||||
selectIds: getClientsIds,
|
||||
selectEntities: getClientEntities,
|
||||
selectAll: getAllClients,
|
||||
selectTotal: getTotalClients,
|
||||
} = fromClients.adapter.getSelectors(getClientsState);
|
||||
// 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,
|
||||
|
||||
@ -5,3 +5,124 @@
|
||||
ul {
|
||||
padding-inline-start: 20px;
|
||||
}
|
||||
|
||||
/* Partner Selection Integration Styles */
|
||||
|
||||
.partner-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.partner-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.partner-name {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.partner-description {
|
||||
font-size: 0.85em;
|
||||
color: #666;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.partner-selected {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.partner-config-section {
|
||||
margin-top: 20px;
|
||||
padding: 20px;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.partner-config-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.partner-config-header h3 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.partner-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #007bff;
|
||||
padding: 10px;
|
||||
background-color: #e7f3ff;
|
||||
border: 1px solid #b3d9ff;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.partner-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #dc3545;
|
||||
padding: 10px;
|
||||
background-color: #f8d7da;
|
||||
border: 1px solid #f5c6cb;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.satloc-config-section {
|
||||
padding: 15px;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.config-description {
|
||||
margin-bottom: 15px;
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.config-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 15px;
|
||||
background-color: #e8f4fd;
|
||||
border: 1px solid #b3d9ff;
|
||||
border-radius: 4px;
|
||||
color: #0c5aa6;
|
||||
}
|
||||
|
||||
/* Common label span for form fields */
|
||||
.form-label-span {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.partner-config-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.partner-option {
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.partner-config-section {
|
||||
padding: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.partner-selected>span {
|
||||
font-weight: 600;
|
||||
}
|
||||
@ -5,14 +5,16 @@
|
||||
<div class="ui-g ui-g-nopad" style="margin-top:40px">
|
||||
<form [formGroup]="form">
|
||||
<div class="ui-g-12 ui-g-nopad">
|
||||
<user-profile-form formControlName="profile" [requireName]="true" [focusOnFirst]="isNew" [updateCountry]="true"></user-profile-form>
|
||||
<user-profile-form formControlName="profile" [requireName]="true" [focusOnFirst]="isNew"
|
||||
[updateCountry]="true"></user-profile-form>
|
||||
</div>
|
||||
|
||||
<div class="ui-g-12 ui-md-6 ui-lg-6 form-row">
|
||||
<span style="margin-right:12px">
|
||||
<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'}">
|
||||
<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>
|
||||
@ -21,17 +23,53 @@
|
||||
</p-dropdown>
|
||||
</div>
|
||||
|
||||
<!-- Partner Selection -->
|
||||
<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>
|
||||
<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 [ngTemplateOutlet]="fieldSet"
|
||||
[ngTemplateOutletContext]="{subs: paidSubs, label: SubTexts.paid}"></ng-container>
|
||||
</ng-container>
|
||||
<ng-template #trialing>
|
||||
<ng-container *ngIf="hasTrialSubs(); else noTrialSubs">
|
||||
<ng-container [ngTemplateOutlet]="fieldSet" [ngTemplateOutletContext]="{subs: trialSubs, label: SubTexts.trial}"></ng-container>
|
||||
<ng-container [ngTemplateOutlet]="fieldSet"
|
||||
[ngTemplateOutletContext]="{subs: trialSubs, label: SubTexts.trial}"></ng-container>
|
||||
<trial formControlName="trials" [trialDays]="trialDays" [trials]="trials" [disable]="true"></trial>
|
||||
</ng-container>
|
||||
<ng-template #noTrialSubs>
|
||||
@ -48,12 +86,16 @@
|
||||
|
||||
<div class="ui-g-12">
|
||||
<p-messages [(value)]="msgs" [closable]="false"></p-messages>
|
||||
<agm-account-editor formControlName="account" [isNew]="isNew" (userExisted)="onUserExisted($event)" required="true" i18n-title="@@accessAccount" title="Access Account" showActive="true">
|
||||
<agm-account-editor formControlName="account" [isNew]="isNew" (userExisted)="onUserExisted($event)"
|
||||
required="true" i18n-title="@@accessAccount" title="Access Account" showActive="true">
|
||||
</agm-account-editor>
|
||||
</div>
|
||||
<div class="ui-g-12 toolbar padtop1 ui-fluid">
|
||||
<button pButton [disabled]="form.invalid" type="button" style="width:auto" [icon]="isNew ? 'ui-icon-plus' : 'ui-icon-save'" [label]="isNew ? globals.create : globals.save" (click)="saveCustomer(); false"></button>
|
||||
<button pButton type="button" style="width:auto" class="amber-btn" icon="ui-icon-arrow-back" (click)="goBack()" [label]="globals.back"></button>
|
||||
<button pButton [disabled]="form.invalid || 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>
|
||||
|
||||
@ -2,11 +2,12 @@ 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 } from '../models/customer.model';
|
||||
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 } from '@app/shared/global';
|
||||
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';
|
||||
@ -17,9 +18,10 @@ import { DateUtils } from '@app/shared/utils';
|
||||
templateUrl: './customer-edit.component.html',
|
||||
styleUrls: ['./customer-edit.component.css']
|
||||
})
|
||||
export class CustomerEditComponent extends BaseComp implements OnInit, OnDestroy {
|
||||
export class CustomerEditComponent extends BaseComp implements OnInit {
|
||||
readonly globals = globals;
|
||||
readonly SubTexts = SubTexts;
|
||||
readonly Labels = Labels;
|
||||
|
||||
form: FormGroup;
|
||||
selectedItem: Customer;
|
||||
@ -33,6 +35,11 @@ export class CustomerEditComponent extends BaseComp implements OnInit, OnDestroy
|
||||
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) {
|
||||
@ -43,8 +50,12 @@ export class CustomerEditComponent extends BaseComp implements OnInit, OnDestroy
|
||||
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
|
||||
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;
|
||||
@ -55,6 +66,7 @@ export class CustomerEditComponent extends BaseComp implements OnInit, OnDestroy
|
||||
constructor(
|
||||
private readonly route: ActivatedRoute,
|
||||
private readonly userSvc: UserService,
|
||||
private readonly partnerSvc: PartnerService,
|
||||
private readonly fb: FormBuilder
|
||||
) {
|
||||
super();
|
||||
@ -69,9 +81,13 @@ export class CustomerEditComponent extends BaseComp implements OnInit, OnDestroy
|
||||
account: [],
|
||||
premium: [],
|
||||
billable: [],
|
||||
trials: []
|
||||
trials: [],
|
||||
// Partner form control
|
||||
partner: [null]
|
||||
});
|
||||
this.lang = this.authSvc.locale;
|
||||
|
||||
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
@ -88,6 +104,8 @@ export class CustomerEditComponent extends BaseComp implements OnInit, OnDestroy
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
@ -118,16 +136,33 @@ export class CustomerEditComponent extends BaseComp implements OnInit, OnDestroy
|
||||
let custObj;
|
||||
|
||||
const updateTrialMembship = (membership?) => {
|
||||
if (this.form.value.trials?.selected) {
|
||||
const trials: Trial = { ...this.form.value.trials };
|
||||
// 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;
|
||||
trials.type === GC.BYDATE ? trials.trialDays = 0 : trials.byDate = null;
|
||||
|
||||
// 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
|
||||
return membership
|
||||
? { ...membership, trials }
|
||||
: { trials };
|
||||
} else {
|
||||
return membership = membership
|
||||
return membership
|
||||
? { ...membership, trials: { ...membership.trials, type: null } }
|
||||
: { trials: { type: null } };
|
||||
}
|
||||
@ -135,7 +170,8 @@ export class CustomerEditComponent extends BaseComp implements OnInit, OnDestroy
|
||||
|
||||
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 });
|
||||
{ billable: this.form.value.billable || false },
|
||||
{ partner: this.form.value.partner || null });
|
||||
|
||||
this.membership
|
||||
? custObj = Object.assign(custObj, { membership: updateTrialMembship(this.membership) })
|
||||
@ -173,6 +209,60 @@ export class CustomerEditComponent extends BaseComp implements OnInit, OnDestroy
|
||||
return DateUtils.dateToTS(date);
|
||||
}
|
||||
|
||||
// Partner Methods
|
||||
private loadPartners(): void {
|
||||
this.partnerLoading = true;
|
||||
this.partnerError = null;
|
||||
|
||||
this.partnerSvc.getPartners().subscribe({
|
||||
next: (partners: Partner[]) => {
|
||||
// Create dropdown options starting with "None" option for AgNav direct customers
|
||||
this.partnerOptions = [
|
||||
{
|
||||
label: Labels.NONE_AGNAV_DIRECT_CUSTOMER,
|
||||
value: null // null value indicates AgNav direct customer
|
||||
},
|
||||
// Add active partners
|
||||
...partners
|
||||
.filter(partner => partner.active) // Only show active partners
|
||||
.map(partner => ({
|
||||
label: partner.name,
|
||||
value: partner
|
||||
}))
|
||||
];
|
||||
|
||||
// Set selectedPartner based on existing customer partner
|
||||
if (this.customer?.partner && this.partnerOptions.length > 0) {
|
||||
// Find the partner in options that matches the customer's current partner _id
|
||||
const matchingOption = this.partnerOptions.find(option =>
|
||||
option.value && option.value._id === this.customer.partner._id
|
||||
);
|
||||
if (matchingOption) {
|
||||
this.form.patchValue({ partner: matchingOption.value });
|
||||
}
|
||||
} else if (!this.customer?.partner) {
|
||||
// If no partner is set, default to "None" (AgNav direct customer)
|
||||
this.form.patchValue({ partner: null });
|
||||
}
|
||||
this.partnerLoading = false;
|
||||
},
|
||||
error: (error) => {
|
||||
this.partnerError = Labels.FAILED_TO_LOAD_PARTNERS;
|
||||
this.partnerLoading = false;
|
||||
console.error('Error loading partners:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onPartnerChange(selectedPartner: Partner | null): void {
|
||||
this.partnerError = null;
|
||||
|
||||
// Update customer partner field
|
||||
if (this.customer) {
|
||||
this.customer.partner = selectedPartner;
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
super.ngOnDestroy();
|
||||
}
|
||||
|
||||
@ -3,7 +3,15 @@
|
||||
<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>
|
||||
@ -19,6 +27,9 @@
|
||||
<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>
|
||||
@ -35,6 +46,7 @@
|
||||
<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>
|
||||
|
||||
@ -7,7 +7,7 @@ import { Table } from 'primeng/table';
|
||||
import { Customer } from '../models/customer.model';
|
||||
import * as fromCustomers from '../reducers';
|
||||
import * as customerActions from '../actions/customer.actions';
|
||||
import { globals } from '@app/shared/global';
|
||||
import { globals, OperationalStatus } from '@app/shared/global';
|
||||
|
||||
import { BaseComp } from '@app/shared/base/base.component';
|
||||
|
||||
@ -18,8 +18,10 @@ import { BaseComp } from '@app/shared/base/base.component';
|
||||
})
|
||||
export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy {
|
||||
readonly CREATED = 'createdAt';
|
||||
readonly ACTIVE = 'active';
|
||||
readonly ACTIVE = OperationalStatus.ACTIVE;
|
||||
readonly BILLABLE = 'billable';
|
||||
readonly PARTNER = 'partner';
|
||||
readonly PARTNER_NAME = 'partnerName';
|
||||
|
||||
customers: Array<Customer>;
|
||||
curCust: Customer;
|
||||
@ -27,8 +29,10 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
@ViewChild("dt") dt: Table;
|
||||
|
||||
statuses: SelectItem[];
|
||||
partners: SelectItem[];
|
||||
cols: any[];
|
||||
totalItems;
|
||||
isSelfSignup = false;
|
||||
|
||||
constructor(
|
||||
private readonly route: ActivatedRoute,
|
||||
@ -48,22 +52,51 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
{ field: "contact", header: globals.contact },
|
||||
{ field: "totalJobs", header: globals.jobs, width: '5%', filtered: false },
|
||||
{ field: this.CREATED, header: globals.from, width: '6%' },
|
||||
// { field: "email", header: globals.email, filtered: true, filterMatchMode: 'contains' },
|
||||
{ field: this.BILLABLE, header: "Billable", width: '9%'},
|
||||
{ field: this.BILLABLE, header: "Billable", width: '9%' },
|
||||
{ field: this.ACTIVE, header: globals.active, width: '9%' },
|
||||
{ field: this.PARTNER_NAME, header: globals.partner, width: '9%' }
|
||||
];
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.sub$ = this.store.select(fromCustomers.getAllCustomers).subscribe(
|
||||
(customers) => this.customers = customers);
|
||||
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));
|
||||
}
|
||||
@ -91,10 +124,6 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
});
|
||||
}
|
||||
|
||||
billableOverview() {
|
||||
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
super.ngOnDestroy();
|
||||
}
|
||||
|
||||
@ -24,7 +24,7 @@ export class CustomerResolver implements Resolve<Customer> {
|
||||
if (id === '0') {
|
||||
return createNewCustomer();
|
||||
} else {
|
||||
return this.customerService.getCustomer(id).pipe(
|
||||
return this.customerService.getCustomer(id, 'edit').pipe(
|
||||
map((cust) => {
|
||||
if (cust) {
|
||||
return cust;
|
||||
|
||||
@ -8,11 +8,24 @@ export interface Customer extends User {
|
||||
premium: number;
|
||||
billable?: boolean;
|
||||
totalJobs?: number;
|
||||
membership: IMembership
|
||||
membership: IMembership,
|
||||
partner?: Partner;
|
||||
selfSignup?: boolean;
|
||||
}
|
||||
|
||||
export interface Partner {
|
||||
_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
kind: string; // Required to match User interface
|
||||
active?: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export const createNewCustomer = () => {
|
||||
const customer = <Customer>createNewUser(null, RoleIds.APP);
|
||||
const customer = createNewUser(null, RoleIds.APP) as Customer;
|
||||
customer.premium = 0;
|
||||
customer.membership = {} as IMembership; // Initialize required membership property
|
||||
return customer;
|
||||
}
|
||||
|
||||
@ -47,7 +47,8 @@ export class TrialComponent extends BaseComp implements OnDestroy, OnInit, After
|
||||
}
|
||||
|
||||
get value() {
|
||||
return this.form.value;
|
||||
// CRITICAL: Use getRawValue() to include disabled controls (selected, type, trialDays)
|
||||
return this.form.getRawValue();
|
||||
}
|
||||
|
||||
set value(val) {
|
||||
@ -68,20 +69,43 @@ export class TrialComponent extends BaseComp implements OnDestroy, OnInit, After
|
||||
});
|
||||
this.dayItems = this.trialDays?.map((day) => ({ label: `${day}`, value: day }));
|
||||
|
||||
this.sub$.add(this.form.valueChanges.subscribe((val) => {
|
||||
this.onChange(val);
|
||||
this.onTouched(val);
|
||||
// CRITICAL FIX: Use getRawValue() to include disabled controls in onChange callback
|
||||
this.sub$.add(this.form.valueChanges.subscribe(() => {
|
||||
const rawValue = this.form.getRawValue();
|
||||
this.onChange(rawValue);
|
||||
this.onTouched(rawValue);
|
||||
}));
|
||||
}
|
||||
|
||||
ngAfterContentInit() {
|
||||
const hasExistingTrial = this.trials?.type && (this.trials.trialDays >= MIN_DAYS || this.trials.byDate);
|
||||
// Check if user has valid trial configuration OR component is disabled (has active trial subscriptions)
|
||||
const hasExistingTrial = (this.trials?.type && (this.trials.trialDays >= MIN_DAYS || this.trials.byDate))
|
||||
|| (this.disable && (this.trials?.trialDays >= MIN_DAYS || this.trials?.byDate));
|
||||
|
||||
if (hasExistingTrial) {
|
||||
if (this.trials.type === this.BYDATE && this.trials.byDate) {
|
||||
if (this.trials?.type === this.BYDATE && this.trials.byDate) {
|
||||
this.toDate = new Date(this.trials.byDate);
|
||||
this.form.patchValue({ ...this.trials, selected: true });
|
||||
} else if (this.trials.type === this.DAYS && this.trials.trialDays >= MIN_DAYS) {
|
||||
} else if (this.trials?.type === this.DAYS && this.trials.trialDays >= MIN_DAYS) {
|
||||
this.form.patchValue({ ...this.trials, selected: true });
|
||||
} else if (this.disable && (this.trials?.trialDays >= MIN_DAYS || this.trials?.byDate)) {
|
||||
// User has active trial subscriptions (disable=true) - always set selected=true
|
||||
// This prevents accidental trial disable when admin edits customer with active trials
|
||||
// Determine correct type from trial configuration (byDate takes precedence over trialDays)
|
||||
const trialType = this.trials?.byDate ? this.BYDATE : this.DAYS;
|
||||
if (trialType === this.BYDATE) {
|
||||
this.toDate = new Date(this.trials.byDate);
|
||||
}
|
||||
// When controls are disabled, patchValue() ignores them - must use setValue() directly
|
||||
this.form.get('selected').setValue(true);
|
||||
this.form.get('type').setValue(trialType);
|
||||
this.form.get('trialDays').setValue(this.trials.trialDays);
|
||||
this.form.patchValue({
|
||||
startDate: this.trials.startDate,
|
||||
lastEndDate: this.trials.lastEndDate,
|
||||
lastStartDate: this.trials.lastStartDate,
|
||||
byDate: this.trials.byDate
|
||||
});
|
||||
} else {
|
||||
this.form.patchValue({ ...this.trials });
|
||||
}
|
||||
|
||||
@ -46,6 +46,12 @@ export class AuthGuard implements CanActivate, CanActivateChild {
|
||||
this.router.navigate(['/login'], { replaceUrl: true });
|
||||
return false;
|
||||
}
|
||||
|
||||
// Early exit for partner users - they bypass all subscription checks
|
||||
if (this.authSvc.isPartner) {
|
||||
return hasAllowedRoles;
|
||||
}
|
||||
|
||||
const requiresResolution = (): boolean => {
|
||||
const hasUnresolvedSubs = this.authSvc.hasSubsWithStatus(SubStripe.INCOMPLETE) || this.authSvc.hasSubsWithStatus(SubStripe.PAST_DUE) || this.authSvc.hasSubsWithStatus(SubStripe.UNPAID) || this.authSvc.hasSubsWithStatus(SubStripe.OVERDUE) || this.subSvc.hasInValTaxLoc(subs);
|
||||
if (hasUnresolvedSubs && hasAllowedRoles) {
|
||||
|
||||
@ -0,0 +1,53 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { CanActivate, Router, UrlTree, ActivatedRouteSnapshot } from '@angular/router';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
import { SUB } from '@app/profile/common';
|
||||
|
||||
/**
|
||||
* Generic guard for notification deep-link URLs (e.g. /manage-subscription, /update-pm).
|
||||
* All routing logic is declared in the route's `data` — no new guard file needed per URL.
|
||||
*
|
||||
* Route data shape:
|
||||
* data: {
|
||||
* // Required: where to send an authenticated user
|
||||
* redirectTo: string[];
|
||||
*
|
||||
* // Optional: alternate destination when master account has no subscriptions
|
||||
* redirectToNoSubs?: string[];
|
||||
*
|
||||
* // Optional: i18n message shown in the login <p-messages> bar
|
||||
* loginNotice?: string;
|
||||
* }
|
||||
*
|
||||
* Adding a new notification URL = one route entry, zero new files.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class NotificationRedirectGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly authSvc: AuthService,
|
||||
private readonly router: Router
|
||||
) { }
|
||||
|
||||
canActivate(route: ActivatedRouteSnapshot): UrlTree {
|
||||
const { redirectTo, redirectToNoSubs } = route.data as {
|
||||
redirectTo: string[];
|
||||
redirectToNoSubs?: string[];
|
||||
};
|
||||
|
||||
if (!this.authSvc.loggedIn) {
|
||||
const { loginNotice } = route.data as { loginNotice?: string };
|
||||
return this.router.createUrlTree(['/login'], {
|
||||
queryParams: {
|
||||
returnUrl: route.url.map(s => s.path).join('/'),
|
||||
...(loginNotice ? { loginNotice } : {})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const isMaster = !this.authSvc.user?.parent;
|
||||
if (redirectToNoSubs && isMaster && !this.authSvc.hasSubs()) {
|
||||
return this.router.createUrlTree(redirectToNoSubs);
|
||||
}
|
||||
return this.router.createUrlTree(redirectTo);
|
||||
}
|
||||
}
|
||||
@ -10,7 +10,19 @@ export class SettingsGuard implements CanActivate {
|
||||
constructor(private readonly appCnf: AppConfigService) { }
|
||||
|
||||
canActivate(route: ActivatedRouteSnapshot): Observable<boolean> {
|
||||
console.log('SettingsGuard: canActivate called for route:', route.routeConfig?.path);
|
||||
// Make sure to load the config whenever the module is accessed.
|
||||
return this.appCnf.load();
|
||||
const loadResult = this.appCnf.load();
|
||||
|
||||
loadResult.subscribe({
|
||||
next: (success) => {
|
||||
console.log('SettingsGuard: AppConfig load completed with result:', success);
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('SettingsGuard: AppConfig load error:', error);
|
||||
}
|
||||
});
|
||||
|
||||
return loadResult;
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,4 +24,6 @@ export interface IAppConfig {
|
||||
|
||||
noPopup: boolean;
|
||||
trialDays: [number];
|
||||
/** Grace-period days for promo Valid Until (sysadmin only). From PROMO_MIN_EXPIRY_DAYS env. */
|
||||
promoMinExpiryDays?: number;
|
||||
}
|
||||
|
||||
@ -45,7 +45,7 @@ export class PlayRecord {
|
||||
// Output 3
|
||||
areaName: string;
|
||||
totLnLength: number;
|
||||
applicRate: number; // Applic. Rate: Application rate in Gals/Acre or Liters/Ha. Value is ead from the Q file or the job.
|
||||
applicRate: number; // Applic. Rate: Application rate in Gals/Acre or Liters/Ha. Value is read from the Q file or the job.
|
||||
mappedArea: number;
|
||||
overSprayed: number;
|
||||
pilotName: string;
|
||||
|
||||
@ -30,6 +30,7 @@ export interface Addon extends BasePackage {
|
||||
desc: string;
|
||||
lookupKey: string;
|
||||
trialEnd?: number;
|
||||
interval?: string; // Billing interval ('year' or 'month')
|
||||
}
|
||||
|
||||
export interface Package extends BasePackage {
|
||||
@ -41,6 +42,7 @@ export interface Package extends BasePackage {
|
||||
lookupKey: string;
|
||||
level?: number;
|
||||
trialEnd?: number;
|
||||
interval?: string; // Billing interval ('year' or 'month')
|
||||
}
|
||||
|
||||
export interface Address {
|
||||
@ -51,19 +53,9 @@ export interface Address {
|
||||
country: string;
|
||||
line1: string;
|
||||
line2?: string | null;
|
||||
postal_code?: string;
|
||||
postalCode?: string;
|
||||
state?: string
|
||||
}
|
||||
|
||||
export interface AddressPackage {
|
||||
name: string;
|
||||
city: string;
|
||||
line1: string;
|
||||
line2: string | null;
|
||||
postal_code: string;
|
||||
state: string;
|
||||
country: string;
|
||||
state?: string,
|
||||
isBilling?: boolean;
|
||||
}
|
||||
|
||||
export interface Card {
|
||||
@ -79,7 +71,7 @@ export interface Card {
|
||||
export interface BillingInfo {
|
||||
applicatorId: string;
|
||||
name: string;
|
||||
address: Address
|
||||
address
|
||||
email?: string;
|
||||
}
|
||||
|
||||
@ -94,7 +86,7 @@ export interface InvoicePackage {
|
||||
custId: string;
|
||||
package: string;
|
||||
addons: BasePackage[];
|
||||
prorateTS: number;
|
||||
prorateTS?: number; // Optional: only needed for proration calculations
|
||||
coupon?: string;
|
||||
}
|
||||
|
||||
@ -116,6 +108,32 @@ export interface Line {
|
||||
price: {
|
||||
lookup_key: string;
|
||||
}
|
||||
// Issue 4 - Proration credit detection
|
||||
proration?: boolean; // True for proration credits (unused subscription time)
|
||||
type?: string; // 'subscription', 'invoiceitem', etc.
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes a deferred promo that will apply from the next billing period.
|
||||
* Shape is identical to the inline `promoDetails` object on subscriptions,
|
||||
* with two additional discriminant flags. Backend builds this from
|
||||
* subscription.metadata.pending_coupon_id (r975+).
|
||||
*/
|
||||
export interface PendingPromoDetails {
|
||||
isPending: true;
|
||||
appliesToNextPeriod: true;
|
||||
name: string;
|
||||
discountDisplay: string; // 'FREE', '50% OFF', '$9.99 OFF'
|
||||
percentOff: number | null;
|
||||
amountOff: number | null; // cents
|
||||
currency: string | null;
|
||||
duration: string | null; // 'forever' | 'once' | 'repeating'
|
||||
durationInMonths: number | null;
|
||||
expiresAt: null;
|
||||
discountEndsAt: null;
|
||||
daysRemaining: null;
|
||||
daysUntilDiscountEnds: null;
|
||||
isTimeLimited: false;
|
||||
}
|
||||
|
||||
export interface Invoice {
|
||||
@ -154,6 +172,18 @@ export interface Invoice {
|
||||
discount?: {
|
||||
coupon: Coupon
|
||||
};
|
||||
/** Invoice period type: "current" (immediate billing) or "next" (future billing cycle) */
|
||||
period_type?: string;
|
||||
/** Flag indicating if this invoice has promotional pricing applied */
|
||||
has_promo?: boolean;
|
||||
/** @deprecated r975: field no longer populated by backend. Use pendingPromoDetails instead. */
|
||||
promo_coupon?: string;
|
||||
/** Unix timestamp (seconds) — when the next charge is collected. Use * 1000 for a JS Date. (r975+) */
|
||||
next_billing_date?: number;
|
||||
/** Present when a deferred 100% FREE promo is scheduled for next billing period (r975+). */
|
||||
pendingPromoDetails?: PendingPromoDetails;
|
||||
/** Discount amounts applied on this invoice. Each entry is { amount (cents), discount (Stripe discount ID) }. */
|
||||
total_discount_amounts?: { amount: number; discount: string }[];
|
||||
}
|
||||
|
||||
export interface Charge {
|
||||
@ -181,6 +211,7 @@ export interface PaidAmount {
|
||||
totalTax: number;
|
||||
total: number;
|
||||
discount?: Discount;
|
||||
refundAmount?: number;
|
||||
}
|
||||
|
||||
export interface Discount {
|
||||
@ -205,6 +236,7 @@ export interface SubscriptionIntent {
|
||||
coupons?: Coupon[];
|
||||
mode: Mode;
|
||||
subIds?: string[];
|
||||
promoSavings?: number; // Total promo discount in cents (calculated in checkout)
|
||||
}
|
||||
|
||||
export interface SubscriptionPackage {
|
||||
@ -307,6 +339,12 @@ export interface StripeSubscription {
|
||||
quantity: number;
|
||||
price: {
|
||||
lookup_key: string;
|
||||
metadata?: {
|
||||
maxVehicles?: string;
|
||||
maxAcres?: string;
|
||||
tier?: string;
|
||||
level?: string;
|
||||
};
|
||||
}
|
||||
}[];
|
||||
};
|
||||
@ -314,8 +352,10 @@ export interface StripeSubscription {
|
||||
current_period_start: number;
|
||||
default_payment_method: string;
|
||||
default_source: string;
|
||||
metadata: {
|
||||
metadata?: {
|
||||
type: string;
|
||||
scheduleId?: string;
|
||||
promoId?: string;
|
||||
};
|
||||
cancel_at_period_end: boolean;
|
||||
discount?: {
|
||||
@ -323,6 +363,82 @@ export interface StripeSubscription {
|
||||
}
|
||||
trial_end?: number;
|
||||
quantity: number;
|
||||
// ✅ r962+ promoDetails enhancement (includes amountOff/percentOff)
|
||||
promoDetails?: {
|
||||
hasPromo: boolean;
|
||||
name: string;
|
||||
discountDisplay: string;
|
||||
expiresAt: string | null;
|
||||
discountEndsAt: string | null;
|
||||
daysRemaining: number | null;
|
||||
daysUntilDiscountEnds: number | null;
|
||||
isTimeLimited: boolean;
|
||||
durationInMonths: number | null;
|
||||
duration: string | null;
|
||||
percentOff: number | null;
|
||||
amountOff: number | null;
|
||||
currency: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Warning notification for subscriptions expiring within 7 days
|
||||
*
|
||||
* Data source: GET /api/subscription?custId={custId}
|
||||
* Verified: 2025-11-10 via /server_test/subscription-data-verification.js
|
||||
*
|
||||
* Use case: Display topbar notification when subscription expires in 1-7 days
|
||||
* Shows: Package name and/or addon names that are expiring with their individual expiry dates
|
||||
*/
|
||||
export interface ExpiryWarning {
|
||||
/** Subscription ID from Stripe */
|
||||
id: string;
|
||||
|
||||
/** Subscription type - 'package', 'addon', or 'both' */
|
||||
type: 'package' | 'addon' | 'both';
|
||||
|
||||
/** Subscription status from Stripe (trialing, active, etc.) */
|
||||
status: string;
|
||||
|
||||
/** Calculated days until earliest subscription expires */
|
||||
daysUntilExpiry: number;
|
||||
|
||||
/** If true, subscription will expire and NOT auto-renew */
|
||||
cancelAtPeriodEnd: boolean;
|
||||
|
||||
/** Unix timestamp when earliest subscription period ends */
|
||||
periodEnd: number;
|
||||
|
||||
/** True if subscription status is 'trialing' */
|
||||
isTrial: boolean;
|
||||
|
||||
/** True if subscription will auto-renew (inverse of cancelAtPeriodEnd) */
|
||||
willAutoRenew: boolean;
|
||||
|
||||
/** Package expiry details if package is expiring */
|
||||
package?: {
|
||||
name: string;
|
||||
lookupKey: string;
|
||||
daysUntilExpiry: number;
|
||||
periodEnd: number;
|
||||
willAutoRenew: boolean;
|
||||
isTrial: boolean;
|
||||
isCanceled: boolean;
|
||||
};
|
||||
|
||||
/** Array of addon expiry details if addons are expiring */
|
||||
addons?: Array<{
|
||||
name: string;
|
||||
lookupKey: string;
|
||||
daysUntilExpiry: number;
|
||||
periodEnd: number;
|
||||
willAutoRenew: boolean;
|
||||
isTrial: boolean;
|
||||
isCanceled: boolean;
|
||||
}>;
|
||||
|
||||
/** True when sub-account has no active subscriptions */
|
||||
noSubs?: boolean;
|
||||
}
|
||||
|
||||
export interface AGNavSubscription {
|
||||
@ -333,6 +449,28 @@ export interface AGNavSubscription {
|
||||
periodStart: number;
|
||||
type: string;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
trial_end?: number;
|
||||
promoDetails?: {
|
||||
hasPromo: boolean;
|
||||
name: string;
|
||||
discountDisplay: string;
|
||||
expiresAt: string | null;
|
||||
discountEndsAt: string | null;
|
||||
daysRemaining: number | null;
|
||||
daysUntilDiscountEnds: number | null;
|
||||
isTimeLimited: boolean;
|
||||
durationInMonths: number | null;
|
||||
duration: string | null;
|
||||
percentOff: number | null;
|
||||
amountOff: number | null;
|
||||
currency: string | null;
|
||||
};
|
||||
/**
|
||||
* Present when a deferred 100% FREE promo is scheduled for next billing period (r975+).
|
||||
* Built from subscription.metadata.pending_coupon_id by addPromoDetailsToSubscription().
|
||||
* Absent (undefined) when no deferred promo is active or subscription is cancel_at_period_end.
|
||||
*/
|
||||
pendingPromoDetails?: PendingPromoDetails;
|
||||
}
|
||||
|
||||
export interface AGNavSubscriptionShort {
|
||||
@ -343,6 +481,27 @@ export interface AGNavSubscriptionShort {
|
||||
cancelAtPeriodEnd: boolean;
|
||||
quantity: number;
|
||||
paymentMethod: string;
|
||||
trialEnd?: number;
|
||||
promoDetails?: {
|
||||
hasPromo: boolean;
|
||||
name: string;
|
||||
discountDisplay: string;
|
||||
expiresAt: string | null;
|
||||
discountEndsAt: string | null;
|
||||
daysRemaining: number | null;
|
||||
daysUntilDiscountEnds: number | null;
|
||||
isTimeLimited: boolean;
|
||||
durationInMonths: number | null;
|
||||
duration: string | null;
|
||||
percentOff: number | null;
|
||||
amountOff: number | null;
|
||||
currency: string | null;
|
||||
};
|
||||
/**
|
||||
* Present when a deferred 100% FREE promo is scheduled for next billing period (r975+).
|
||||
* Absent (undefined) when no deferred promo is active or subscription is cancel_at_period_end.
|
||||
*/
|
||||
pendingPromoDetails?: PendingPromoDetails;
|
||||
}
|
||||
|
||||
export interface Status {
|
||||
@ -374,6 +533,7 @@ export interface ConfirmPackage {
|
||||
subIds: string[];
|
||||
unresolved: Unresolved;
|
||||
applicatorId: string;
|
||||
stage?: string;
|
||||
}
|
||||
|
||||
export interface CreatePaymentMethodPackage {
|
||||
@ -408,7 +568,7 @@ export interface Aircraft {
|
||||
|
||||
export interface Acre {
|
||||
currUsage: number;
|
||||
limit: number;
|
||||
limit: number | null; // null = unlimited acres for current subscription packages
|
||||
overLimit: boolean;
|
||||
}
|
||||
|
||||
@ -504,7 +664,7 @@ export interface Coupon {
|
||||
|
||||
export interface Trial {
|
||||
selected?: boolean,
|
||||
type: 'days' | 'byDate';
|
||||
type: string;
|
||||
startDate: Date,
|
||||
lastStartDate: Date,
|
||||
lastEndDate: Date,
|
||||
|
||||
@ -14,7 +14,8 @@ export class MembershipResolver implements Resolve<IMembership> {
|
||||
) { }
|
||||
|
||||
resolve(): Observable<IMembership> {
|
||||
return this.custSvc.getCustomer(this.authSvc.user._id).pipe(
|
||||
const id = this.authSvc.user?.parent || this.authSvc.user._id;
|
||||
return this.custSvc.getCustomer(id).pipe(
|
||||
map((cust) => {
|
||||
const membership = cust?.membership;
|
||||
if (membership) {
|
||||
|
||||
@ -1,30 +1,42 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Router, ActivatedRouteSnapshot, Resolve } from '@angular/router';
|
||||
|
||||
import { Observable } from 'rxjs';
|
||||
import { map, first } from 'rxjs/operators';
|
||||
import { Observable, forkJoin, of } from 'rxjs';
|
||||
import { map, first, switchMap } from 'rxjs/operators';
|
||||
|
||||
import { User } from '@app/accounts/models/user.model';
|
||||
import { UserService } from '@app/domain/services/user.service';
|
||||
|
||||
export interface UserWithParentUsername {
|
||||
user: User;
|
||||
parentUsername?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ProfileResolver implements Resolve<User> {
|
||||
export class ProfileResolver implements Resolve<UserWithParentUsername> {
|
||||
constructor(
|
||||
private readonly router: Router,
|
||||
private readonly userService: UserService
|
||||
) {
|
||||
}
|
||||
) { }
|
||||
|
||||
resolve(route: ActivatedRouteSnapshot): Observable<User> | Promise<User> | User {
|
||||
resolve(route: ActivatedRouteSnapshot): Observable<UserWithParentUsername> {
|
||||
const id = route.paramMap.get('id');
|
||||
|
||||
return this.userService.getUser(id).pipe(
|
||||
map(user => {
|
||||
if (user) {
|
||||
return user;
|
||||
} else {
|
||||
// view:'edit' → backend returns editable profile fields (name, phone, email, contact,
|
||||
// address, kind, active, username, password) but excludes membership/subscription data
|
||||
// which the form never needs and is expensive to populate.
|
||||
return this.userService.getUser(id, { view: 'edit' }).pipe(
|
||||
switchMap(user => {
|
||||
if (!user) {
|
||||
this.router.navigate(['/profile']);
|
||||
return null;
|
||||
return of(null);
|
||||
}
|
||||
if (user.parent) {
|
||||
return this.userService.getUser(user.parent, { view: 'profile' }).pipe(
|
||||
map(parentUser => ({ user, parentUsername: parentUser?.username })),
|
||||
first()
|
||||
);
|
||||
} else {
|
||||
return of({ user });
|
||||
}
|
||||
}),
|
||||
first()
|
||||
|
||||
@ -20,7 +20,7 @@ export class UserResolver implements Resolve<User> {
|
||||
resolve(): Observable<User> {
|
||||
return this.store.select(selectAuthUser).pipe(
|
||||
switchMap((authUser: UserModel) => {
|
||||
return this.userService.getUser(authUser._id)
|
||||
return this.userService.getUser(authUser._id, { withAddresses: true })
|
||||
}),
|
||||
map((user: User) => {
|
||||
if (user) {
|
||||
|
||||
@ -0,0 +1,236 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||
import { Observable, of, BehaviorSubject } from 'rxjs';
|
||||
import { shareReplay, map, catchError, switchMap, tap } from 'rxjs/operators';
|
||||
import { PromoTranslationService } from './promo-translation.service';
|
||||
|
||||
/**
|
||||
* Active Promo interface matching backend GET /api/activePromos response
|
||||
* Note: couponId is intentionally NOT included (server-side only for security)
|
||||
*/
|
||||
export interface ActivePromo {
|
||||
type: 'package' | 'addon';
|
||||
priceKey: string; // e.g., 'ess_1', 'addon_1'
|
||||
validUntil: string; // ISO date string
|
||||
name: string; // Display name (fallback)
|
||||
nameKey?: string; // i18n key e.g., 'PROMO_ADDON_FREE'
|
||||
descriptionKey?: string; // i18n key e.g., 'PROMO_ADDON_FREE_DESC'
|
||||
discountType: 'free' | 'percent' | 'fixed';
|
||||
discountValue: number; // 100 for free, 50 for 50%, 500 for $5.00
|
||||
// Optional expiry fields for time-limited promos (r948+ promoDetails)
|
||||
isTimeLimited?: boolean; // True if promo has expiry date
|
||||
daysRemaining?: number | null; // Days until expiry (null if not time-limited)
|
||||
isRenewalPromo?: boolean; // True if this is a Case 2B renewal offer (subscription has no promo, showing available promo)
|
||||
}
|
||||
|
||||
/**
|
||||
* Response interface for /api/activePromos endpoint
|
||||
* Changed in r949 to include currentMode metadata
|
||||
*/
|
||||
interface ActivePromoResponse {
|
||||
promos: ActivePromo[];
|
||||
currentMode: {
|
||||
mode: 'enabled' | 'disabled';
|
||||
description: string;
|
||||
isActive: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for fetching active subscription promos from the backend.
|
||||
* Used to display promo labels in manage-services and manage-subscription components.
|
||||
*
|
||||
* The backend returns only enabled promos with future validUntil dates,
|
||||
* without exposing sensitive couponId (coupon application happens server-side).
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ActivePromoService {
|
||||
private readonly BASE_URL = '/activePromos';
|
||||
|
||||
// Use BehaviorSubject to trigger fresh API calls when needed
|
||||
private refreshTrigger$ = new BehaviorSubject<number>(0);
|
||||
private readonly activePromos$: Observable<ActivePromo[]>;
|
||||
|
||||
// Store currentMode for components to use (optional feature)
|
||||
private currentModeSubject$ = new BehaviorSubject<{
|
||||
mode: string;
|
||||
description: string;
|
||||
isActive: boolean;
|
||||
} | null>(null);
|
||||
|
||||
constructor(
|
||||
private readonly http: HttpClient,
|
||||
private readonly promoTranslationSvc: PromoTranslationService
|
||||
) {
|
||||
// Create observable that refreshes when refreshTrigger$ emits
|
||||
this.activePromos$ = this.refreshTrigger$.pipe(
|
||||
switchMap(() => {
|
||||
return this.http.get<ActivePromoResponse>(this.BASE_URL).pipe(
|
||||
map(response => {
|
||||
// Store currentMode for components to use
|
||||
this.currentModeSubject$.next(response.currentMode);
|
||||
return response.promos; // Extract promos array
|
||||
}),
|
||||
catchError(error => this.handleActivePromosError(error))
|
||||
);
|
||||
}),
|
||||
shareReplay(1) // Cache until next refresh
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle errors from /api/activePromos endpoint
|
||||
* Returns empty promos with disabled mode to prevent component crashes
|
||||
*
|
||||
* Error Handling:
|
||||
* - 401 Unauthorized: Token expired or invalid (global interceptor handles logout)
|
||||
* - 403 Forbidden: User doesn't have permission
|
||||
* - 0 or 500+: Network or server errors
|
||||
* - Other: Unexpected errors
|
||||
*/
|
||||
private handleActivePromosError(error: HttpErrorResponse): Observable<ActivePromo[]> {
|
||||
// 401 Unauthorized - Token expired or invalid
|
||||
if (error.status === 401) {
|
||||
console.error('[ActivePromoService] Authentication failed (401)', error);
|
||||
// User will be redirected to login by global HTTP interceptor
|
||||
// Return empty promos to prevent component errors
|
||||
this.currentModeSubject$.next(this.getDisabledPromoMode('Authentication required'));
|
||||
return of([]);
|
||||
}
|
||||
|
||||
// 403 Forbidden - User doesn't have permission
|
||||
if (error.status === 403) {
|
||||
console.error('[ActivePromoService] Access denied (403)', error);
|
||||
this.currentModeSubject$.next(this.getDisabledPromoMode('Access denied'));
|
||||
return of([]);
|
||||
}
|
||||
|
||||
// Network errors or server errors
|
||||
if (error.status === 0 || error.status >= 500) {
|
||||
console.error('[ActivePromoService] Network or server error', error);
|
||||
this.currentModeSubject$.next(this.getDisabledPromoMode('Service unavailable'));
|
||||
return of([]);
|
||||
}
|
||||
|
||||
// Other errors - return empty to prevent crashes
|
||||
console.error('[ActivePromoService] Unexpected error', error);
|
||||
this.currentModeSubject$.next(this.getDisabledPromoMode('Promotions unavailable'));
|
||||
return of([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get disabled promo mode object for error states
|
||||
*/
|
||||
private getDisabledPromoMode(description: string): {
|
||||
mode: string;
|
||||
description: string;
|
||||
isActive: boolean;
|
||||
} {
|
||||
return {
|
||||
mode: 'disabled',
|
||||
isActive: false,
|
||||
description: description
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Force refresh of promo data from server
|
||||
* Invalidates cache and makes fresh API call
|
||||
*/
|
||||
refresh(): void {
|
||||
this.refreshTrigger$.next(Date.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active promos (cached until refresh)
|
||||
*/
|
||||
getActivePromos(): Observable<ActivePromo[]> {
|
||||
return this.activePromos$;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get promo for a specific priceKey (e.g., 'ess_1', 'addon_1')
|
||||
*/
|
||||
getPromoForPriceKey(priceKey: string): Observable<ActivePromo | undefined> {
|
||||
return this.activePromos$.pipe(
|
||||
map(promos => promos.find(p => p.priceKey === priceKey))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a priceKey has an active promo
|
||||
*/
|
||||
hasPromo(priceKey: string): Observable<boolean> {
|
||||
return this.activePromos$.pipe(
|
||||
map(promos => promos.some(p => p.priceKey === priceKey))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active promos with translated names (convenience method)
|
||||
*/
|
||||
getActivePromosWithTranslations(): Observable<(ActivePromo & { translatedName: string; translatedDescription: string })[]> {
|
||||
return this.activePromos$.pipe(
|
||||
map(promos => promos.map(promo => ({
|
||||
...promo,
|
||||
translatedName: this.promoTranslationSvc.getPromoName(promo),
|
||||
translatedDescription: this.promoTranslationSvc.getPromoDescription(promo)
|
||||
})))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current promo mode info
|
||||
* Returns null if not yet loaded
|
||||
*
|
||||
* Use this to check if promotions are globally enabled:
|
||||
* - mode='enabled': Promotions active and should be displayed
|
||||
* - mode='disabled': Promotions disabled (hide promo banners)
|
||||
*
|
||||
* @example
|
||||
* // In component:
|
||||
* this.activePromoSvc.getCurrentMode().subscribe(mode => {
|
||||
* if (mode && !mode.isActive) {
|
||||
* this.showPromoBanners = false; // Hide banners when mode='disabled'
|
||||
* }
|
||||
* });
|
||||
*/
|
||||
getCurrentMode(): Observable<{
|
||||
mode: string;
|
||||
description: string;
|
||||
isActive: boolean;
|
||||
} | null> {
|
||||
return this.currentModeSubject$.asObservable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format promo display text with translation support
|
||||
*/
|
||||
formatPromoDisplayText(promo: ActivePromo): string {
|
||||
const translatedName = this.promoTranslationSvc.getPromoName(promo);
|
||||
return `${translatedName} - ${this.formatPromoDiscount(promo)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format promo discount for display
|
||||
* Returns: "FREE", "50% OFF", "$10 OFF"
|
||||
*/
|
||||
formatPromoDiscount(promo: ActivePromo): string {
|
||||
if (!promo) return '';
|
||||
|
||||
switch (promo.discountType) {
|
||||
case 'free':
|
||||
return $localize`:Promo label for free items@@promoFree:FREE`;
|
||||
case 'percent':
|
||||
return `${promo.discountValue}% ` + $localize`:Promo label suffix@@promoOff:OFF`;
|
||||
case 'fixed':
|
||||
// discountValue is in cents, convert to dollars
|
||||
const dollars = promo.discountValue / 100;
|
||||
return `$${dollars} ` + $localize`:Promo label suffix@@promoOff:OFF`;
|
||||
default:
|
||||
return promo.name || '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -59,13 +59,23 @@ export class AppConfigService {
|
||||
return this.http.get<IAppConfig>("/appConfig").pipe(
|
||||
map(res => {
|
||||
if (!environment.production)
|
||||
console.log("App config loaded !");
|
||||
console.log("AppConfigService: App config loaded successfully!", res);
|
||||
this.checkAndSetDefault(res);
|
||||
return true;
|
||||
}),
|
||||
catchError(err => {
|
||||
console.error('AppConfigService: Failed to load app config:', err);
|
||||
|
||||
// Check if request was cancelled
|
||||
if (err.name === 'AbortError' || err.message?.includes('cancel')) {
|
||||
this.appMsgSvc.addFailedMsg('App configuration request was cancelled. Using default settings.');
|
||||
} else {
|
||||
this.appMsgSvc.addFailedMsg('Could not load AppConfig. Please retry or contact Agnav.');
|
||||
return of(false);
|
||||
}
|
||||
|
||||
// Always set defaults and return true to prevent green screen
|
||||
this.checkAndSetDefault(null);
|
||||
return of(true);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@ -70,7 +70,12 @@ export class AuthInterceptor implements HttpInterceptor {
|
||||
}
|
||||
|
||||
private onCatch(err: any, req: HttpRequest<any>): Observable<any> {
|
||||
if ([401, 403].indexOf(err.status) != -1 && !req.url.endsWith('/login')) { // JWT expired or invalid token responded from BE, force logOut
|
||||
// Don't logout on partner API errors - these are partner credential tests, not user session errors
|
||||
const isPartnerApiError = req.url.includes('/partners/systemUsers/testAuth')
|
||||
|| req.url.includes('/partners/aircraft');
|
||||
|
||||
if ([401, 403].indexOf(err.status) != -1 && !req.url.endsWith('/login') && !isPartnerApiError) {
|
||||
// JWT expired or invalid token responded from BE, force logOut
|
||||
this.store.dispatch(new authActions.Logout(true));
|
||||
}
|
||||
return throwError(err);
|
||||
|
||||
@ -2,7 +2,7 @@ import { Injectable, OnDestroy, Inject, LOCALE_ID } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
|
||||
import { Observable, of, throwError, Subscription } from 'rxjs';
|
||||
import { exhaustMap } from 'rxjs/operators';
|
||||
import { exhaustMap, tap, catchError } from 'rxjs/operators';
|
||||
|
||||
import { DateUtils, Utils } from '../../shared/utils';
|
||||
import { RoleIds } from '../../shared/global';
|
||||
@ -14,10 +14,14 @@ import { Authenticate } from '../../auth/models/auth.model';
|
||||
import { AGNavSubscription, PriceUsd, Trial } from '../models/subscription.model';
|
||||
import { Mode, SUB, SubStripe, SubType } from '@app/profile/common';
|
||||
import { SubscriptionService } from './subscription.service';
|
||||
import { GAService } from '../../shared/ga.service';
|
||||
import { GAAnalyticsHelpersService } from '../../shared/ga.analytics-helpers.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService implements OnDestroy {
|
||||
private _user: UserModel;
|
||||
private _sessionStartTime: number;
|
||||
|
||||
get user(): UserModel {
|
||||
return this._user;
|
||||
}
|
||||
@ -49,6 +53,8 @@ export class AuthService implements OnDestroy {
|
||||
private readonly store: Store<{}>,
|
||||
private readonly http: HttpClient,
|
||||
private subSvc: SubscriptionService,
|
||||
private readonly gaService: GAService,
|
||||
private readonly gaHelpers: GAAnalyticsHelpersService,
|
||||
) {
|
||||
this._locale = Utils.getLang(this.localeId) || 'en';
|
||||
this._tk = JSON.parse(sessionStorage.getItem('cT'));
|
||||
@ -83,6 +89,10 @@ export class AuthService implements OnDestroy {
|
||||
return this.hasRole([RoleIds.INSPECTOR]);
|
||||
}
|
||||
|
||||
get isPartner(): boolean {
|
||||
return this.hasRole([RoleIds.PARTNER]);
|
||||
}
|
||||
|
||||
hasSubsWithStatus(status: string) {
|
||||
return this.user?.membership?.subscriptions?.some((sub) => sub.status === `${status}`);
|
||||
}
|
||||
@ -112,18 +122,16 @@ export class AuthService implements OnDestroy {
|
||||
}
|
||||
|
||||
getCurLookupKey(type: SubType.PACKAGE | SubType.ADDON): PriceUsd {
|
||||
let lookupKey: PriceUsd;
|
||||
// Use centralized utility methods
|
||||
const subscriptions = this.user?.membership?.subscriptions;
|
||||
switch (type) {
|
||||
case SubType.PACKAGE:
|
||||
lookupKey = this.user?.membership?.subscriptions?.find((sub) => sub.type === SubType.PACKAGE)?.items?.[0].price || '';
|
||||
break;
|
||||
return this.subSvc.getCurrentPackageLookupKey(subscriptions) || '';
|
||||
case SubType.ADDON:
|
||||
lookupKey = this.user?.membership?.subscriptions?.find((sub) => sub.type === SubType.ADDON)?.items?.[0].price || '';
|
||||
break;
|
||||
return this.subSvc.getCurrentAddonLookupKey(subscriptions) || '';
|
||||
default:
|
||||
throw new Error('Unsupported type');
|
||||
}
|
||||
return lookupKey;
|
||||
}
|
||||
|
||||
get isPlanner() {
|
||||
@ -134,6 +142,10 @@ export class AuthService implements OnDestroy {
|
||||
return (this.user && this.user.billable);
|
||||
}
|
||||
|
||||
get isCanada(): boolean {
|
||||
return this.user?.country === 'CA';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parent user, to mange items under an applicator user
|
||||
*/
|
||||
@ -157,19 +169,45 @@ export class AuthService implements OnDestroy {
|
||||
throwError('invalid_account');
|
||||
|
||||
// Store username and jwt token in local storage to keep user logged in between page refreshes
|
||||
const user = <UserModel>{ _id: res['_id'], username: auth.username, billable: res['billable'], roles: res['roles'], parent: (res['pui'] || ''), lang: res['lang'] || 'en', pre: res['pre'], membership: res['membership'] };
|
||||
const user = <UserModel>{ _id: res['_id'], username: auth.username, billable: res['billable'], roles: res['roles'], parent: (res['pui'] || ''), lang: res['lang'] || 'en', pre: res['pre'], membership: res['membership'], contact: res['contact'] || '', country: res['country'] || '' };
|
||||
this._user = user;
|
||||
this.token = { t: res['token'], rt: res['rt'] };
|
||||
|
||||
// Track session start time
|
||||
this._sessionStartTime = Date.now();
|
||||
|
||||
// Track login event
|
||||
this.gaService.trackLogin({
|
||||
user_id: user._id,
|
||||
user_role: this.gaHelpers.getUserRole(user.roles),
|
||||
method: 'email',
|
||||
platform: 'web'
|
||||
});
|
||||
|
||||
return of(user);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
logout(gotoLogin: boolean = true): Observable<boolean> {
|
||||
// Track logout event before clearing session data
|
||||
if (this._user && this._sessionStartTime) {
|
||||
const sessionDuration = Math.round((Date.now() - this._sessionStartTime) / 60000); // Convert to minutes
|
||||
this.gaService.trackLogout({
|
||||
user_id: this._user._id,
|
||||
user_role: this.gaHelpers.getUserRole(this._user.roles),
|
||||
session_duration_minutes: sessionDuration,
|
||||
logout_method: 'manual',
|
||||
platform: 'web',
|
||||
page_location: window.location.href
|
||||
});
|
||||
}
|
||||
|
||||
sessionStorage.clear();
|
||||
localStorage.removeItem('requiredSubAttention');
|
||||
this._user = null;
|
||||
this._tk = null;
|
||||
this._sessionStartTime = null;
|
||||
return of(true);
|
||||
}
|
||||
|
||||
@ -182,15 +220,52 @@ export class AuthService implements OnDestroy {
|
||||
}
|
||||
|
||||
mailPwdReset(ops) {
|
||||
return this.http.post('/users/mailPwdReset', ops);
|
||||
return this.http.post('/users/mailPwdReset', ops).pipe(
|
||||
tap(response => {
|
||||
// Track password reset request
|
||||
this.gaService.trackPasswordResetRequested({
|
||||
request_method: 'forgot_password_page',
|
||||
user_exists: true, // If we get a success response, user exists
|
||||
platform: 'web'
|
||||
});
|
||||
}),
|
||||
catchError(error => {
|
||||
// Track password reset request failure
|
||||
this.gaService.trackPasswordResetRequested({
|
||||
request_method: 'forgot_password_page',
|
||||
user_exists: false, // If we get an error, user may not exist
|
||||
platform: 'web'
|
||||
});
|
||||
return throwError(error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
resetPassword(ops) {
|
||||
return this.http.get(`/users/resetPassword/${ops.id}/${ops.token}`);
|
||||
validateResetPassword(ops) {
|
||||
return this.http.post('/users/resetPassword/validate', ops);
|
||||
}
|
||||
|
||||
changePassword(ops) {
|
||||
return this.http.post('/users/resetPassword', ops);
|
||||
return this.http.post('/users/resetPassword', ops).pipe(
|
||||
tap(response => {
|
||||
// Track password reset completion
|
||||
this.gaService.trackPasswordResetCompleted({
|
||||
success: true,
|
||||
reset_token_age_minutes: 0, // Token age info not available in current implementation
|
||||
platform: 'web'
|
||||
});
|
||||
}),
|
||||
catchError(error => {
|
||||
// Track password reset completion failure
|
||||
this.gaService.trackPasswordResetCompleted({
|
||||
success: false,
|
||||
reset_token_age_minutes: 0, // Token age info not available
|
||||
failure_reason: 'other',
|
||||
platform: 'web'
|
||||
});
|
||||
return throwError(error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
get trials() {
|
||||
@ -229,14 +304,13 @@ export class AuthService implements OnDestroy {
|
||||
isWithinTrialPeriod = DateUtils.currUTC() <= DateUtils.dateToTS(trialEndDate);
|
||||
}
|
||||
}
|
||||
return !this.hasRole([RoleIds.ADMIN])
|
||||
return this.hasRole([RoleIds.APP])
|
||||
&& !this.hasSubs()
|
||||
&& isWithinTrialPeriod;
|
||||
}
|
||||
|
||||
canDisplayTrial(trials: Trial) {
|
||||
return this.validateTrial(trials)
|
||||
&& this.subSvc.subMode !== Mode.REGULAR;
|
||||
return this.validateTrial(trials);
|
||||
}
|
||||
|
||||
canAcceptTrial(url: string) {
|
||||
|
||||
@ -17,8 +17,9 @@ export class CustomerService {
|
||||
return this.http.get<Customer[]>(this.customerURL);
|
||||
}
|
||||
|
||||
getCustomer(id: string): Observable<Customer> {
|
||||
return this.http.get<Customer>(`${this.customerURL}/${id}`);
|
||||
getCustomer(id: string, view?: string): Observable<Customer> {
|
||||
const url = view ? `${this.customerURL}/${id}?view=${view}` : `${this.customerURL}/${id}`;
|
||||
return this.http.get<Customer>(url);
|
||||
}
|
||||
|
||||
saveCustomer(customer: Customer): Observable<Customer> {
|
||||
|
||||
@ -4,27 +4,52 @@ import {
|
||||
HttpHandler,
|
||||
HttpEvent,
|
||||
HttpInterceptor,
|
||||
HttpErrorResponse
|
||||
HttpErrorResponse,
|
||||
HttpResponse
|
||||
} from '@angular/common/http';
|
||||
import { Observable, throwError } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { catchError, tap } from 'rxjs/operators';
|
||||
import { AppMessageService } from '@app/shared/app-message.service';
|
||||
import { globals } from '@app/shared/global';
|
||||
import { environment } from '@environments/environment';
|
||||
import { AppInjector } from '@app/app-injector';
|
||||
import { GAService } from '@app/shared/ga.service';
|
||||
|
||||
@Injectable()
|
||||
export class GlobalErrorInterceptor implements HttpInterceptor {
|
||||
private failedAttempts = 0;
|
||||
private gaSvc: GAService;
|
||||
|
||||
constructor(private readonly msgSvc: AppMessageService) { }
|
||||
constructor(private readonly msgSvc: AppMessageService) {
|
||||
// Use AppInjector to get GAService to avoid circular dependency
|
||||
this.gaSvc = AppInjector.getInjector().get(GAService);
|
||||
}
|
||||
|
||||
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
||||
const startTime = Date.now();
|
||||
|
||||
return next.handle(req).pipe(
|
||||
tap(event => {
|
||||
// Track successful but slow API responses
|
||||
if (event instanceof HttpResponse) {
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
// Track slow API responses (threshold: 2 seconds)
|
||||
if (responseTime > 2000) {
|
||||
this.trackSlowApiResponse(req, event, responseTime);
|
||||
}
|
||||
}
|
||||
}),
|
||||
catchError((error: HttpErrorResponse) => {
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
// Track HTTP error event
|
||||
this.trackHttpError(error, req, responseTime);
|
||||
|
||||
if (error.status >= 500 && error.status < 600) {
|
||||
this.failedAttempts++;
|
||||
if (this.failedAttempts >= environment.failedRqAttempts) {
|
||||
this.msgSvc.addFailedMsg(globals.backendErr);
|
||||
this.msgSvc.addFailedMsg(globals.server500Err);
|
||||
this.failedAttempts = 0; // Reset counter after showing the error
|
||||
}
|
||||
}
|
||||
@ -32,4 +57,110 @@ export class GlobalErrorInterceptor implements HttpInterceptor {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private trackHttpError(error: HttpErrorResponse, req: HttpRequest<any>, responseTime: number): void {
|
||||
const errorType = this.categorizeError(error);
|
||||
const endpoint = this.extractEndpoint(req.url);
|
||||
|
||||
this.gaSvc.trackEvent('http_error', {
|
||||
platform: 'web',
|
||||
error_type: errorType,
|
||||
http_status_code: error.status || 0,
|
||||
error_message: error.message || 'Unknown HTTP error',
|
||||
request_method: req.method as any,
|
||||
request_url: req.url,
|
||||
request_endpoint: endpoint,
|
||||
response_time_ms: responseTime,
|
||||
affected_feature: this.extractFeature(endpoint)
|
||||
});
|
||||
}
|
||||
|
||||
private trackSlowApiResponse(req: HttpRequest<any>, response: HttpResponse<any>, responseTime: number): void {
|
||||
const endpoint = this.extractEndpoint(req.url);
|
||||
|
||||
this.gaSvc.trackEvent('api_response_slow', {
|
||||
platform: 'web',
|
||||
api_endpoint: endpoint,
|
||||
response_time_ms: responseTime,
|
||||
payload_size: this.getPayloadSize(response),
|
||||
cache_hit: this.isCacheHit(response)
|
||||
});
|
||||
}
|
||||
|
||||
private categorizeError(error: HttpErrorResponse): 'network_error' | 'server_error' | 'client_error' | 'timeout' | 'unknown_error' {
|
||||
if (error.status === 0 || error.status === -1) {
|
||||
return 'network_error';
|
||||
}
|
||||
if (error.status >= 500) {
|
||||
return 'server_error';
|
||||
}
|
||||
if (error.status >= 400 && error.status < 500) {
|
||||
return 'client_error';
|
||||
}
|
||||
if (error.status === 408 || error.message?.includes('timeout')) {
|
||||
return 'timeout';
|
||||
}
|
||||
return 'unknown_error';
|
||||
}
|
||||
|
||||
private extractEndpoint(url: string): string {
|
||||
try {
|
||||
const pathname = new URL(url, window.location.origin).pathname;
|
||||
return pathname.replace(/^\/api/, '').split('/')[1] || 'unknown';
|
||||
} catch {
|
||||
return url.split('/')[1] || 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
private extractFeature(endpoint: string): string {
|
||||
const featureMap: { [key: string]: string } = {
|
||||
'jobs': 'job_management',
|
||||
'invoices': 'billing',
|
||||
'reports': 'reporting',
|
||||
'files': 'file_management',
|
||||
'users': 'user_management',
|
||||
'auth': 'authentication',
|
||||
'customers': 'customer_management',
|
||||
'equipment': 'equipment_management'
|
||||
};
|
||||
return featureMap[endpoint] || 'unknown';
|
||||
}
|
||||
|
||||
private getPayloadSize(response: HttpResponse<any>): number | undefined {
|
||||
try {
|
||||
const contentLength = response.headers.get('content-length');
|
||||
if (contentLength) {
|
||||
return parseInt(contentLength, 10);
|
||||
}
|
||||
|
||||
// Fallback: estimate payload size from response body
|
||||
if (response.body) {
|
||||
const bodyString = JSON.stringify(response.body);
|
||||
return new Blob([bodyString]).size;
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore errors in payload size calculation
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private isCacheHit(response: HttpResponse<any>): boolean | undefined {
|
||||
// Check common cache indicators in response headers
|
||||
const cacheControl = response.headers.get('cache-control');
|
||||
const etag = response.headers.get('etag');
|
||||
const lastModified = response.headers.get('last-modified');
|
||||
const xCache = response.headers.get('x-cache');
|
||||
|
||||
// Check for explicit cache hit indicators
|
||||
if (xCache?.includes('HIT')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If response has cache headers but no explicit hit indicator, likely cached
|
||||
if (cacheControl && (etag || lastModified)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return undefined; // Unknown cache status
|
||||
}
|
||||
}
|
||||
|
||||
@ -187,8 +187,24 @@ export class JobService {
|
||||
return this.http.post<any>(`${this.jobURL}/appFiles`, { jobId: jobId });
|
||||
}
|
||||
|
||||
getFilesData(ids) {
|
||||
return this.http.post<any>(`${this.jobURL}/filesdata`, { fileIds: ids });
|
||||
getFilesData(fileId: string, params?: {
|
||||
limit?: number,
|
||||
startingAfter?: string,
|
||||
endingBefore?: string,
|
||||
returnAll?: boolean
|
||||
}) {
|
||||
const body: any = {
|
||||
fileId: fileId
|
||||
};
|
||||
|
||||
if (params) {
|
||||
if (params.limit !== undefined) body.limit = params.limit;
|
||||
if (params.startingAfter !== undefined) body.startingAfter = params.startingAfter;
|
||||
if (params.endingBefore !== undefined) body.endingBefore = params.endingBefore;
|
||||
if (params.returnAll !== undefined) body.returnAll = params.returnAll;
|
||||
}
|
||||
|
||||
return this.http.post<any>(`${this.jobURL}/filesdata`, body);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Router, ActivationEnd } from '@angular/router';
|
||||
import { Router, NavigationStart } from '@angular/router';
|
||||
import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } from '@angular/common/http';
|
||||
|
||||
import { Observable } from 'rxjs';
|
||||
@ -11,18 +11,33 @@ import { HttpCancelService } from './httpcancel.service';
|
||||
|
||||
@Injectable()
|
||||
export class ManageHttpInterceptor implements HttpInterceptor {
|
||||
private currentUrl: string = '';
|
||||
|
||||
constructor(private readonly router: Router, private readonly httpCancelService: HttpCancelService) {
|
||||
router.events.subscribe(event => {
|
||||
// An event triggered at the end of the activation part of the Resolve phase of routing.
|
||||
if (event instanceof ActivationEnd) {
|
||||
// Cancel pending calls
|
||||
// Only cancel on actual route changes, not during guard/resolver execution
|
||||
if (event instanceof NavigationStart) {
|
||||
// Check if this is actually a new route, not just a reload or guard execution
|
||||
if (this.currentUrl && event.url !== this.currentUrl) {
|
||||
this.httpCancelService.cancelPendingRequests();
|
||||
}
|
||||
this.currentUrl = event.url;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
intercept<T>(req: HttpRequest<T>, next: HttpHandler): Observable<HttpEvent<T>> {
|
||||
// Don't cancel critical configuration and authentication requests
|
||||
const isCriticalRequest = req.url.includes('/appConfig') ||
|
||||
req.url.includes('/login') ||
|
||||
req.url.includes('/auth') ||
|
||||
req.url.includes('/ping');
|
||||
|
||||
if (isCriticalRequest) {
|
||||
// Critical requests should not be cancelled by route changes
|
||||
return next.handle(req);
|
||||
}
|
||||
|
||||
return next.handle(req).pipe(takeUntil(this.httpCancelService.onCancelPendingRequests()))
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { PromoLabels } from 'src/app/profile/common';
|
||||
import { ActivePromo } from './active-promo.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class PromoTranslationService {
|
||||
|
||||
/**
|
||||
* Get translated promo name with fallback to static name
|
||||
*/
|
||||
getPromoName(promo: ActivePromo): string {
|
||||
if (promo.nameKey && PromoLabels[promo.nameKey]) {
|
||||
return PromoLabels[promo.nameKey];
|
||||
}
|
||||
return promo.name; // Fallback to static name
|
||||
}
|
||||
|
||||
/**
|
||||
* Get translated promo description with fallback to static name
|
||||
*/
|
||||
getPromoDescription(promo: ActivePromo): string {
|
||||
if (promo.descriptionKey && PromoLabels[promo.descriptionKey]) {
|
||||
return PromoLabels[promo.descriptionKey];
|
||||
}
|
||||
// Fallback: use name as description if no descriptionKey translation
|
||||
return this.getPromoName(promo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if promo has translation keys available
|
||||
*/
|
||||
hasTranslation(promo: ActivePromo): boolean {
|
||||
return !!(promo.nameKey && PromoLabels[promo.nameKey]);
|
||||
}
|
||||
}
|
||||
@ -1,93 +0,0 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { SubscriptionService } from './subscription.service';
|
||||
import {
|
||||
DisplayPackage,
|
||||
Addon,
|
||||
Package,
|
||||
Price,
|
||||
ConfigRes,
|
||||
Address
|
||||
} from '@app/domain/models/subscription.model';
|
||||
|
||||
|
||||
describe('SubscriptionService', () => {
|
||||
let subSvc: SubscriptionService;
|
||||
let httpTestingController: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule]
|
||||
});
|
||||
subSvc = TestBed.inject(SubscriptionService);
|
||||
httpTestingController = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
const MockedPrices: Price[] = [
|
||||
{type: 'essential', priceUSD: 995, lookupKey: 'ess_1'},
|
||||
{type: 'enterprise', priceUSD: 1495, lookupKey: 'ent_1'},
|
||||
{type: 'addon', priceUSD: 2495, lookupKey: 'addon_2'},
|
||||
{type: 'addon', priceUSD: 1495, lookupKey: 'addon_1'}
|
||||
];
|
||||
|
||||
it('should test createSubscription with prices return Display Packages', () => {
|
||||
const EssPkgs: Package[] = [
|
||||
{ priceId: '1', desc: '', maxVehicles: 1, Vehicles: '1', maxAcres: '50K', price: '$995.00', lookupKey: 'ess_1'},
|
||||
{ priceId: '2', desc: '', maxVehicles: 11, Vehicles: '10+', maxAcres: 'Unlimited', price: 'contact', lookupKey: ''}
|
||||
];
|
||||
|
||||
const EntPkgs: Package[] = [
|
||||
{ priceId: '6', desc: '', maxVehicles: 1, Vehicles: '1', maxAcres: '50K', price: '$1,495.00', lookupKey: 'ent_1'},
|
||||
{ priceId: '7', desc: '', maxVehicles: 11, Vehicles: '10+', maxAcres: 'Unlimited', price: 'contact', lookupKey: ''}
|
||||
];
|
||||
|
||||
const Addons: Addon[] = [
|
||||
{ priceId: '1', name: 'Aircraft Tracking (Per Aircraft)', desc: '', price: '$1,495.00', lookupKey: 'addon_1', quantity: 1},
|
||||
{ priceId: '2', name: 'Aircraft Managing (Per Aircraft)', desc: '', price: '$2,495.00', lookupKey: 'addon_2', quantity: 1}
|
||||
];
|
||||
const expectedDispPkg: DisplayPackage = {
|
||||
essential: EssPkgs,
|
||||
enterprise: EntPkgs,
|
||||
addon: Addons,
|
||||
}
|
||||
expect(subSvc.pricesToPkgs(MockedPrices)).toEqual(expectedDispPkg)
|
||||
});
|
||||
|
||||
it('should test getPrices and return an array of Prices', () => {
|
||||
subSvc.getPrices().subscribe((res) => {
|
||||
expect(res).toEqual(MockedPrices)
|
||||
});
|
||||
const request = httpTestingController.expectOne('/subscription/prices');
|
||||
request.flush(MockedPrices);
|
||||
httpTestingController.verify();
|
||||
});
|
||||
|
||||
it('should test getConfig and return Config object', () => {
|
||||
subSvc.getConfig().subscribe((res: ConfigRes) => {
|
||||
expect(res.config).toEqual('pk_1234')
|
||||
});
|
||||
const request = httpTestingController.expectOne('/subscription/config');
|
||||
request.flush({config: 'pk_1234'});
|
||||
httpTestingController.verify();
|
||||
});
|
||||
|
||||
it('should test convertPostalCode', () => {
|
||||
const address: Address = {
|
||||
_id: '123',
|
||||
valid: true,
|
||||
name: 'justin',
|
||||
city: "Richmond",
|
||||
country: "CA",
|
||||
line1: "4070 Robson Street",
|
||||
line2: null,
|
||||
postal_code: null,
|
||||
postalCode: "V6V 0A4",
|
||||
state: "BC"
|
||||
}
|
||||
const actual = subSvc.convertAddr(address)
|
||||
expect(actual._id).toBeFalsy();
|
||||
expect(actual.postal_code).toEqual('V6V 0A4');
|
||||
expect(actual.postalCode).toBeFalsy();
|
||||
expect(actual.valid).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@ -1,14 +1,16 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, Subject, Subscription } from 'rxjs';
|
||||
import { Price, InvoicePackage, Address, Invoice, SubscriptionPackage, StripeSubscription, PaymentMethod, UnpaidPackage, AddressPackage, SubscriptionPaymentMethod, Charge, PaidAmount, AGNavSubscriptionShort, CustChargePkg, Usage, BillPeriod, UsagePackage, CheckoutPayment, Coupon, PMPkgEdit, PriceUsd, Acre, AGNavSubscription, Plan, Status, BillingInfoPackage, Package, Addon, TrialItem } from '@app/domain/models/subscription.model';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { environment } from '@environments/environment';
|
||||
import { Observable, of, Subject, Subscription, throwError } from 'rxjs';
|
||||
import { Price, InvoicePackage, Address, Invoice, SubscriptionPackage, StripeSubscription, PaymentMethod, UnpaidPackage, SubscriptionPaymentMethod, Charge, PaidAmount, AGNavSubscriptionShort, CustChargePkg, Usage, BillPeriod, UsagePackage, CheckoutPayment, Coupon, PMPkgEdit, PriceUsd, Acre, AGNavSubscription, Plan, Status, BillingInfoPackage, Package, Addon, TrialItem, ExpiryWarning } from '@app/domain/models/subscription.model';
|
||||
import { loadStripe, Stripe, StripeCardElement } from '@stripe/stripe-js';
|
||||
import { DateUtils, UnitUtils, Utils } from '@app/shared/utils';
|
||||
import { Mode, SUB, SubKeys, SubStripe, SubTexts, SubType, subPlans } from '@app/profile/common';
|
||||
import { map, switchMap } from 'rxjs/operators';
|
||||
import { Mode, SUB, SubKeys, SubStripe, SubTexts, SubType, subPlans, UNLIMITED } from '@app/profile/common';
|
||||
import { map, switchMap, tap, catchError } from 'rxjs/operators';
|
||||
import { IMembership } from '@app/auth/models/user.model';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { getSubIntentMode } from '@app/reducers';
|
||||
import { UserService } from './user.service';
|
||||
|
||||
export interface CCFormValues {
|
||||
ccName: string,
|
||||
@ -49,6 +51,7 @@ export class SubscriptionService {
|
||||
constructor(
|
||||
private readonly http: HttpClient,
|
||||
private readonly store: Store<{}>,
|
||||
private userSvc: UserService
|
||||
) {
|
||||
this.sub$ = this.subMode$.subscribe((mode) => this._subMode = mode);
|
||||
}
|
||||
@ -74,7 +77,7 @@ export class SubscriptionService {
|
||||
return this.http.get<Address>(`${BASE_URL}/billAddress/${applicatorId}`);
|
||||
}
|
||||
|
||||
updateBilAdr(applicatorId: string, addrPkg: AddressPackage): Observable<Address> {
|
||||
updateBillAddress(applicatorId: string, addrPkg: Address): Observable<Address> {
|
||||
return this.http.put<Address>(`${BASE_URL}/billAddress/${applicatorId}`, addrPkg);
|
||||
}
|
||||
|
||||
@ -86,6 +89,26 @@ export class SubscriptionService {
|
||||
return this.http.post<StripeSubscription[]>(`${BASE_URL}/update`, subPkg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check subscription status (for polling after 3DS completion per r944)
|
||||
*
|
||||
* @param subscriptionId Stripe subscription ID (sub_xxxxx)
|
||||
* @returns Observable with subscription status from Stripe
|
||||
*/
|
||||
checkSubscriptionStatus(subscriptionId: string): Observable<any> {
|
||||
if (!subscriptionId || !subscriptionId.startsWith('sub_')) {
|
||||
console.error('❌ Invalid subscription ID:', subscriptionId);
|
||||
return throwError(new Error('Invalid subscription ID'));
|
||||
}
|
||||
|
||||
return this.http.get(`${BASE_URL}/status/${subscriptionId}`).pipe(
|
||||
catchError((error) => {
|
||||
console.error('❌ Status check error:', error);
|
||||
return throwError(error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
fetchSubscriptions(custId: string): Observable<StripeSubscription[]> {
|
||||
return this.http.get<StripeSubscription[]>(`${BASE_URL}?custId=${custId}&billInfo=true`);
|
||||
}
|
||||
@ -153,11 +176,77 @@ export class SubscriptionService {
|
||||
editSub(subsSettings: { subId: string, cancelAtPeriodEnd: boolean }[]): Observable<StripeSubscription[]> {
|
||||
return this.http.post<StripeSubscription[]>(`${BASE_URL}/setSubsSettings`, {
|
||||
subsSettings
|
||||
}).pipe(
|
||||
map(subs => this.normalizeSubscriptionStructure(subs))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize simplified backend subscription structure to full Stripe structure
|
||||
* Backend returns simplified format from _toMembershipSubscription():
|
||||
* - items: [] (flat array)
|
||||
* - periodEnd/periodStart instead of current_period_end/current_period_start
|
||||
* - cancelAtPeriodEnd instead of cancel_at_period_end
|
||||
* This method transforms it to match the full Stripe API structure expected by frontend
|
||||
*/
|
||||
private normalizeSubscriptionStructure(subs: any[]): StripeSubscription[] {
|
||||
return subs.map(sub => {
|
||||
// If already in full format, return as-is
|
||||
if (sub.items?.data) {
|
||||
return sub;
|
||||
}
|
||||
|
||||
// Transform simplified format to full Stripe structure
|
||||
return {
|
||||
id: sub.id,
|
||||
object: 'subscription',
|
||||
status: sub.status,
|
||||
current_period_start: sub.periodStart || sub.current_period_start,
|
||||
current_period_end: sub.periodEnd || sub.current_period_end,
|
||||
cancel_at_period_end: sub.cancelAtPeriodEnd !== undefined ? sub.cancelAtPeriodEnd : sub.cancel_at_period_end,
|
||||
cancel_at: sub.cancelAt || sub.cancel_at,
|
||||
trial_end: sub.trialEnd || sub.trial_end,
|
||||
metadata: {
|
||||
type: sub.type,
|
||||
scheduleId: sub.scheduleId,
|
||||
...(sub.metadata || {})
|
||||
},
|
||||
items: {
|
||||
object: 'list',
|
||||
data: (sub.items || []).map(item => ({
|
||||
object: 'subscription_item',
|
||||
price: {
|
||||
lookup_key: typeof item.price === 'string' ? item.price : item.price?.lookup_key,
|
||||
metadata: item.metadata || {},
|
||||
recurring: sub.recurring || { interval: 'month', interval_count: 1 }
|
||||
},
|
||||
quantity: item.quantity || 1
|
||||
}))
|
||||
},
|
||||
// Preserve recurring info
|
||||
plan: sub.recurring ? {
|
||||
interval: sub.recurring.interval,
|
||||
interval_count: sub.recurring.intervalCount || sub.recurring.interval_count
|
||||
} : undefined,
|
||||
// Fill in optional fields that may not be present
|
||||
latest_invoice: undefined,
|
||||
default_payment_method: undefined,
|
||||
default_source: undefined,
|
||||
quantity: undefined
|
||||
} as StripeSubscription;
|
||||
});
|
||||
}
|
||||
|
||||
getCoupon(coupon: string): Observable<Coupon> {
|
||||
return this.http.get<Coupon>(`${BASE_URL}/getCoupon/${coupon}`);
|
||||
getCoupon(coupon: string, priceKeys?: string[]): Observable<Coupon> {
|
||||
let url = `${BASE_URL}/getCoupon/${coupon}`;
|
||||
|
||||
// Add price keys as query params for product restriction validation
|
||||
if (priceKeys && priceKeys.length > 0) {
|
||||
const params = new HttpParams().set('priceKeys', priceKeys.join(','));
|
||||
return this.http.get<Coupon>(url, { params });
|
||||
}
|
||||
|
||||
return this.http.get<Coupon>(url);
|
||||
}
|
||||
|
||||
editPM(custId: string, pkg: PMPkgEdit): Observable<PaymentMethod> {
|
||||
@ -179,7 +268,167 @@ export class SubscriptionService {
|
||||
});
|
||||
}
|
||||
|
||||
// Utils
|
||||
// ============================================================================
|
||||
// SUBSCRIPTION STATE HELPERS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Determine if subscription will cancel at period end.
|
||||
* @param sub - StripeSubscription object
|
||||
* @returns true if subscription will cancel at period end
|
||||
*/
|
||||
willSubscriptionCancel(sub: StripeSubscription): boolean {
|
||||
return sub?.cancel_at_period_end ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cancellation date for a subscription.
|
||||
* @param sub - StripeSubscription object
|
||||
* @returns Date when subscription will cancel, or null if not canceling
|
||||
*/
|
||||
getCancellationDate(sub: StripeSubscription): Date | null {
|
||||
if (sub?.cancel_at_period_end && sub?.current_period_end) {
|
||||
return new Date(sub.current_period_end * 1000);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if subscription has a promo applied.
|
||||
* Checks for promoId in metadata OR discount coupon presence.
|
||||
* @param sub - StripeSubscription object
|
||||
* @returns true if subscription has an active promo
|
||||
*/
|
||||
hasSubscriptionPromo(sub: StripeSubscription): boolean {
|
||||
return !!sub?.metadata?.promoId || !!sub?.discount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get promo display info from subscription promoDetails (r955+).
|
||||
* @param sub - StripeSubscription object
|
||||
* @returns Promo info or null if no promo
|
||||
* @since r955 - Updated to use promoDetails instead of deprecated discount field
|
||||
*/
|
||||
getSubscriptionPromoDiscount(sub: StripeSubscription): { name: string; percentOff?: number; amountOff?: number } | null {
|
||||
if (!sub?.promoDetails?.hasPromo) return null;
|
||||
|
||||
// Parse discount value from discountDisplay (e.g., "50% OFF" or "FREE")
|
||||
const discountDisplay = sub.promoDetails.discountDisplay;
|
||||
const percentMatch = discountDisplay?.match(/(\d+)%/);
|
||||
const percentOff = percentMatch ? parseInt(percentMatch[1]) : (discountDisplay?.includes('FREE') ? 100 : null);
|
||||
|
||||
return {
|
||||
name: sub.promoDetails.name || 'Promo',
|
||||
percentOff: percentOff || undefined,
|
||||
amountOff: undefined // Backend no longer provides amount_off
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total promo savings from line items and active promos.
|
||||
* This is the SINGLE SOURCE OF TRUTH for promo savings calculations.
|
||||
*
|
||||
* CALCULATION ORDER (CRITICAL - WI-2804):
|
||||
* 1. Apply discount at native billing interval (monthly = monthly, annual = annual)
|
||||
* 2. No annualization - show what customer actually pays
|
||||
*
|
||||
* This matches Stripe's actual billing behavior and non-promo display format.
|
||||
* Uses Stripe lineItems (cents-based) for precision and consistency.
|
||||
*
|
||||
* @param lineItems - Stripe invoice line items (payment or refund)
|
||||
* @param promos - Map of lookup_key to ActivePromo objects
|
||||
* @returns Total promo savings in cents (at native billing interval)
|
||||
*
|
||||
* @example
|
||||
* // In checkout component:
|
||||
* const savings = this.subSvc.calculatePromoSavings(
|
||||
* this.chkoutPmt?.payment?.lineItems,
|
||||
* this.paymentPromos
|
||||
* );
|
||||
*
|
||||
* // Example: ESS_2 + Addon + 50% promo
|
||||
* // ESS_2 (annual): $2,495 × 50% = $1,247.50 savings (annual)
|
||||
* // Addon (monthly): $49.95 × 50% = $24.97 savings (monthly, not annualized)
|
||||
* // Total savings: $1,272.47 (mixed interval - show separately)
|
||||
*/
|
||||
calculatePromoSavings(lineItems: any[], promos: Map<string, any>): number {
|
||||
if (!lineItems || lineItems.length === 0 || !promos || promos.size === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let totalSavings = 0;
|
||||
|
||||
lineItems.forEach((item: any) => {
|
||||
// Skip proration credit lines — these are refunds for old quantities where
|
||||
// the user already benefited from the promo on a prior invoice.
|
||||
// Identified by: proration=true AND credited_items is not null.
|
||||
if (item.proration && item.proration_details?.credited_items != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lookupKey = item.price?.lookup_key;
|
||||
const promo = promos.get(lookupKey);
|
||||
|
||||
if (promo && item.price?.unit_amount) {
|
||||
// Get original amount at native billing interval
|
||||
const originalAmount = item.price.unit_amount * (item.quantity || 1);
|
||||
let savings = 0;
|
||||
|
||||
// Calculate savings at native interval (no annualization)
|
||||
if (promo.discountType === 'free' || promo.discountValue === 100) {
|
||||
savings = originalAmount; // 100% off
|
||||
} else if (promo.discountType === 'percent') {
|
||||
savings = Math.round(originalAmount * (promo.discountValue / 100));
|
||||
} else if (promo.discountType === 'fixed') {
|
||||
// discountValue is already in cents (e.g., 15000 = $150.00)
|
||||
// item.price.unit_amount is also in cents
|
||||
// Cap discount at original amount to prevent negative prices
|
||||
savings = Math.min(originalAmount, promo.discountValue);
|
||||
}
|
||||
|
||||
totalSavings += savings;
|
||||
}
|
||||
});
|
||||
|
||||
return totalSavings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate discounted amount for a single item with promo applied
|
||||
* CENTRALIZED METHOD - All components should use this instead of duplicating logic
|
||||
*
|
||||
* @param originalAmount - Original price in cents (e.g., 99500 = $995.00)
|
||||
* @param promo - ActivePromo object
|
||||
* @returns Discounted amount in cents
|
||||
*
|
||||
* @example
|
||||
* const promo = { discountType: 'fixed', discountValue: 15000 }; // $150 OFF
|
||||
* const discounted = calculateDiscountedAmount(99500, promo);
|
||||
* // Returns: 84500 ($845.00)
|
||||
*/
|
||||
calculateDiscountedAmount(originalAmount: number, promo: any): number {
|
||||
if (!promo || !originalAmount) {
|
||||
return originalAmount;
|
||||
}
|
||||
|
||||
// Calculate savings based on promo type
|
||||
if (promo.discountType === 'free' || promo.discountValue === 100) {
|
||||
return 0; // 100% off
|
||||
} else if (promo.discountType === 'percent') {
|
||||
return Math.round(originalAmount * (1 - promo.discountValue / 100));
|
||||
} else if (promo.discountType === 'fixed') {
|
||||
// discountValue is already in cents (e.g., 15000 = $150.00)
|
||||
// Cap discount at original amount to prevent negative prices
|
||||
return Math.max(0, originalAmount - promo.discountValue);
|
||||
}
|
||||
|
||||
return originalAmount;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SUBSCRIPTION STATUS UTILS
|
||||
// ============================================================================
|
||||
|
||||
hasSubsWithStatus(subs: StripeSubscription[], status: string): boolean {
|
||||
return subs?.some((sub) => sub?.status === `${status}`);
|
||||
}
|
||||
@ -189,7 +438,16 @@ export class SubscriptionService {
|
||||
}
|
||||
|
||||
isRequireAction(subs: StripeSubscription[]): boolean {
|
||||
return subs?.some((sub) => sub?.latest_invoice?.payment_intent?.status === SubStripe.REQUIRE_ACTION);
|
||||
// CRITICAL: Backend returns 3DS requirements in multiple possible formats:
|
||||
// 1. Standard Stripe format: latest_invoice.payment_intent.status === 'requires_action'
|
||||
// 2. Pre-3DS state: latest_invoice.payment_intent.status === 'requires_confirmation' (needs confirmation which may trigger 3DS)
|
||||
// 3. Backend's Direct Pattern format: requires_action === true (flat structure with client_secret)
|
||||
// We must check all three to handle 3DS authentication correctly (r942 implementation)
|
||||
return subs?.some((sub) =>
|
||||
sub?.latest_invoice?.payment_intent?.status === SubStripe.REQUIRE_ACTION ||
|
||||
sub?.latest_invoice?.payment_intent?.status === 'requires_confirmation' ||
|
||||
(sub as any)?.requires_action === true
|
||||
);
|
||||
}
|
||||
|
||||
getReqPmSubscription(subs: StripeSubscription[]): StripeSubscription {
|
||||
@ -198,7 +456,12 @@ export class SubscriptionService {
|
||||
|
||||
getReqActionSubscription(subs: StripeSubscription[]): StripeSubscription {
|
||||
SubStripe.REQUIRE_ACTION
|
||||
return subs?.find((sub) => sub?.latest_invoice?.payment_intent?.status === SubStripe.REQUIRE_ACTION);
|
||||
// CRITICAL: Check all formats for 3DS requirement (see isRequireAction comment)
|
||||
return subs?.find((sub) =>
|
||||
sub?.latest_invoice?.payment_intent?.status === SubStripe.REQUIRE_ACTION ||
|
||||
sub?.latest_invoice?.payment_intent?.status === 'requires_confirmation' ||
|
||||
(sub as any)?.requires_action === true
|
||||
);
|
||||
}
|
||||
|
||||
atCheckoutReviewStage(): boolean {
|
||||
@ -273,12 +536,16 @@ export class SubscriptionService {
|
||||
private calcInvoiceWithProrate(invoices: Invoice[], coupon?: Coupon): CheckoutPayment {
|
||||
let lines = [];
|
||||
invoices.map((inv) => lines = lines.concat(inv?.lines?.data?.filter((line) => line?.period?.start === inv?.subscription_proration_date)));
|
||||
const pmtLines = lines.filter((line) => line.amount >= 0);
|
||||
const refLines = lines.filter((line) => line.amount < 0);
|
||||
const isRefundLine = (line: any) =>
|
||||
line?.parent?.subscription_item_details?.proration_details?.credited_items != null ||
|
||||
line?.proration_details?.credited_items != null;
|
||||
|
||||
const rfdLines = lines.filter(isRefundLine);
|
||||
const pmtLines = lines.filter(line => !isRefundLine(line));
|
||||
let pmt: CheckoutPayment;
|
||||
const pmtTotalTax = this.calcTotalAmount(this.extractLineTax(pmtLines));
|
||||
if (refLines.length > 0) {
|
||||
const refTotalTax = this.calcTotalAmount(this.extractLineTax(refLines));
|
||||
if (rfdLines.length > 0) {
|
||||
const refTotalTax = this.calcTotalAmount(this.extractLineTax(rfdLines));
|
||||
pmt = {
|
||||
payment: {
|
||||
lineItems: pmtLines,
|
||||
@ -286,8 +553,8 @@ export class SubscriptionService {
|
||||
totalTax: pmtTotalTax
|
||||
},
|
||||
refund: {
|
||||
lineItems: refLines,
|
||||
totalAmount: this.calcTotalAmount(refLines) + refTotalTax,
|
||||
lineItems: rfdLines,
|
||||
totalAmount: this.calcTotalAmount(rfdLines) + refTotalTax,
|
||||
totalTax: refTotalTax
|
||||
}
|
||||
};
|
||||
@ -309,19 +576,24 @@ export class SubscriptionService {
|
||||
|
||||
calcChkoutPayment(invoices: Invoice[], opt?: Option): CheckoutPayment {
|
||||
if (Utils.isEmptyArray(invoices)) return { payment: { totalAmount: 0, totalTax: 0, lineItems: [] } };
|
||||
const prorateInvs = invoices.filter((inv) => inv?.lines?.data?.some((line) => line?.period?.start === inv?.subscription_proration_date));
|
||||
const hasNoProrate = prorateInvs.length === 0;
|
||||
const hasUnResolvedInvoice = opt?.subscriptions?.some((sub) =>
|
||||
sub.status === SubStripe.UNPAID ||
|
||||
sub.status === SubStripe.INCOMPLETE ||
|
||||
sub.status === SubStripe.PAST_DUE ||
|
||||
sub.status === SubStripe.OVERDUE
|
||||
) || hasNoProrate;
|
||||
if (hasUnResolvedInvoice) {
|
||||
|
||||
const hasUnresolvedSub = opt?.subscriptions?.some((sub) =>
|
||||
sub.status === SubStripe.UNPAID || sub.status === SubStripe.INCOMPLETE ||
|
||||
sub.status === SubStripe.PAST_DUE || sub.status === SubStripe.OVERDUE
|
||||
);
|
||||
if (hasUnresolvedSub) {
|
||||
return this.calcInvoice(invoices, opt?.coupon);
|
||||
}
|
||||
|
||||
const prorateInvs = invoices.filter((inv) =>
|
||||
inv?.lines?.data?.some((line) => line?.period?.start === inv?.subscription_proration_date)
|
||||
);
|
||||
if (prorateInvs.length > 0) {
|
||||
return this.calcInvoiceWithProrate(invoices, opt?.coupon);
|
||||
}
|
||||
// No proration + all subs active = clean upcoming invoice (e.g., deferred promo's Invoice[1])
|
||||
return this.calcInvoice(invoices, opt?.coupon);
|
||||
}
|
||||
|
||||
calcAmount(invoices: Invoice[], opt?: Option): PaidAmount {
|
||||
if (Utils.isEmptyArray(invoices)) return { totalExcludingTax: 0, totalTax: 0, total: 0 };
|
||||
@ -399,8 +671,44 @@ export class SubscriptionService {
|
||||
return DEFAULT_CURRENCY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert maxAcres value to user-friendly display string
|
||||
*
|
||||
* **Zero Value Policy**: In agricultural context, "0 acres" doesn't make literal sense.
|
||||
* Zero is used internally to mean "no restriction on acreage."
|
||||
*
|
||||
* Display Rules:
|
||||
* - 0, null, undefined, empty string → "Unlimited"
|
||||
* - Values < 1000 → Display as-is (e.g., "123")
|
||||
* - Values >= 1000 → Display in thousands (e.g., "50K" for 50000)
|
||||
*
|
||||
* @param maxAcres - Maximum acres value from API (Stripe metadata or custom limits)
|
||||
* @returns Display string ("Unlimited", "123", or "50K")
|
||||
*
|
||||
* @example
|
||||
* convMaxAcre(0) → "Unlimited" // Zero = no restriction
|
||||
* convMaxAcre(null) → "Unlimited" // Not set = unlimited
|
||||
* convMaxAcre(123) → "123" // Small values display as-is
|
||||
* convMaxAcre(50000) → "50K" // Large values in thousands
|
||||
* convMaxAcre('') → "Unlimited" // Empty string = unlimited
|
||||
*
|
||||
* Frontend/Backend Coordination:
|
||||
* - Backend returns literal values from Stripe or custom limits
|
||||
* - Frontend interprets 0 as "Unlimited" for display
|
||||
* - This separation allows backend to store raw data while frontend
|
||||
* provides user-friendly interpretation
|
||||
*
|
||||
* Related Policy:
|
||||
* - maxVehicles: Zero displayed literally ("0 Aircraft" is valid restriction)
|
||||
* - maxAcres: Zero displayed as "Unlimited" (no literal "0 acres" in farming)
|
||||
*
|
||||
* See: Task 02 - Document Zero Handling Policy
|
||||
*/
|
||||
convMaxAcre(maxAcres: number | string): string {
|
||||
if (!maxAcres) return '';
|
||||
// Display "Unlimited" for null, undefined, empty string, or 0
|
||||
if (!maxAcres || maxAcres === 0 || maxAcres === '' || maxAcres === '0') {
|
||||
return UNLIMITED;
|
||||
}
|
||||
const THOUSAND = 1000;
|
||||
const maxAcrToK = +maxAcres / THOUSAND;
|
||||
return maxAcrToK > 0 ? `${maxAcrToK}K` : maxAcres.toString();
|
||||
@ -416,24 +724,67 @@ export class SubscriptionService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer subscription type ('package' or 'addon') from a Stripe API subscription object.
|
||||
* Subscriptions created via the app set `metadata.type` explicitly.
|
||||
* Subscriptions created directly in the Stripe Dashboard have `metadata: {}`, so we
|
||||
* fall back to inspecting the price lookup_key (addon keys start with 'addon_').
|
||||
* This is the single source of truth — used everywhere StripeSubscription type is needed.
|
||||
*/
|
||||
private inferStripeSubType(sub: StripeSubscription): string {
|
||||
if (sub.metadata?.type) return sub.metadata.type;
|
||||
const lookupKey = sub.items?.data?.[0]?.price?.lookup_key ?? '';
|
||||
return lookupKey.startsWith('addon_') ? SubType.ADDON : SubType.PACKAGE;
|
||||
}
|
||||
|
||||
updateMembShip(subscriptions: StripeSubscription[], membership: IMembership): IMembership {
|
||||
if (Utils.isEmptyArray(subscriptions)) return membership;
|
||||
return {
|
||||
...membership,
|
||||
endOfPeriod: subscriptions?.find((sub) => sub.metadata.type === SubType.PACKAGE)?.latest_invoice.period_end ||
|
||||
subscriptions?.find((sub) => sub.metadata.type === SubType.ADDON)?.latest_invoice.period_end,
|
||||
subscriptions: subscriptions?.map((sub) => ({
|
||||
|
||||
// NOTE: This method works with StripeSubscription (from Stripe API) which has different field names
|
||||
// (current_period_end vs periodEnd). The centralized utilities work with AGNavSubscription.
|
||||
// We keep this logic here as it's specific to Stripe API response transformation.
|
||||
|
||||
// Find all package subscriptions and get the latest one by current_period_end
|
||||
const pkgSubs = subscriptions?.filter((sub) => this.inferStripeSubType(sub) === SubType.PACKAGE);
|
||||
const latestPkg = pkgSubs?.reduce((acc, curr) => {
|
||||
return (curr.current_period_end > acc.current_period_end) ? curr : acc;
|
||||
}, pkgSubs?.[0]);
|
||||
|
||||
// Find all addon subscriptions and get the latest one by current_period_end
|
||||
const addonSubs = subscriptions?.filter((sub) => this.inferStripeSubType(sub) === SubType.ADDON);
|
||||
const latestAddon = addonSubs?.reduce((acc, curr) => {
|
||||
return (curr.current_period_end > acc.current_period_end) ? curr : acc;
|
||||
}, addonSubs?.[0]);
|
||||
|
||||
const transformedSubscriptions = subscriptions?.map((sub) => {
|
||||
const transformed = {
|
||||
id: sub.id,
|
||||
periodEnd: sub.current_period_end,
|
||||
periodStart: sub.current_period_start,
|
||||
status: sub.status,
|
||||
items: sub.items.data?.map((item) => ({
|
||||
price: item.price.lookup_key,
|
||||
quantity: item.quantity
|
||||
quantity: item.quantity,
|
||||
metadata: {
|
||||
tier: item.price.metadata?.tier || '', // Ensure tier is always defined
|
||||
level: item.price.metadata?.level,
|
||||
maxAcres: item.price.metadata?.maxAcres,
|
||||
maxVehicles: item.price.metadata?.maxVehicles
|
||||
}
|
||||
})),
|
||||
type: sub.metadata.type,
|
||||
cancelAtPeriodEnd: sub.cancel_at_period_end
|
||||
}))
|
||||
type: this.inferStripeSubType(sub),
|
||||
cancelAtPeriodEnd: sub.cancel_at_period_end,
|
||||
trial_end: sub.trial_end,
|
||||
promoDetails: sub.promoDetails
|
||||
};
|
||||
|
||||
return transformed;
|
||||
});
|
||||
|
||||
return {
|
||||
...membership,
|
||||
endOfPeriod: latestPkg?.latest_invoice.period_end || latestAddon?.latest_invoice.period_end,
|
||||
subscriptions: transformedSubscriptions
|
||||
};
|
||||
}
|
||||
|
||||
@ -444,9 +795,13 @@ export class SubscriptionService {
|
||||
return { subscriptions, membership, package: {}, addon: {} };
|
||||
}
|
||||
|
||||
const getSubscriptionItem = (type: SubType) =>
|
||||
membership.subscriptions.find(sub => sub.type === type
|
||||
&& (sub.status === SubStripe.ACTIVE || sub.status === SubStripe.TRIALING))?.items[0];
|
||||
// Use subscriptions parameter (from Stripe API with custom limits override)
|
||||
// instead of membership.subscriptions (from MongoDB without override)
|
||||
const getSubscriptionItem = (type: SubType) => {
|
||||
const subscription = subscriptions.find(sub => sub.metadata?.type === type
|
||||
&& (sub.status === SubStripe.ACTIVE || sub.status === SubStripe.TRIALING));
|
||||
return subscription?.items?.data?.[0];
|
||||
};
|
||||
|
||||
const createAcrePlan = (currUsage: number, limit: number): Acre => ({
|
||||
currUsage,
|
||||
@ -456,12 +811,28 @@ export class SubscriptionService {
|
||||
|
||||
const pkg = getSubscriptionItem(SubType.PACKAGE);
|
||||
const addon = getSubscriptionItem(SubType.ADDON);
|
||||
const pkgPrice = pkg?.price;
|
||||
const pkgPrice = pkg?.price?.lookup_key;
|
||||
|
||||
const maxAcres = isNaN(+pkg?.metadata?.maxAcres) ? 0 : +pkg?.metadata?.maxAcres;
|
||||
const acre = createAcrePlan(UnitUtils.haToArea(usage.ttArea, true), maxAcres || subPlans[pkgPrice]?.maxAcres);
|
||||
const maxVehicles = isNaN(+pkg?.metadata?.maxVehicles) ? 0 : +pkg?.metadata?.maxVehicles;
|
||||
const pkgNumVeh = maxVehicles || subPlans[pkgPrice]?.maxVehicles || 0;
|
||||
// ✅ FIX (2026-01-27): Read metadata from MongoDB session data instead of Stripe API
|
||||
// MongoDB is source of truth for subscription metadata changes (updated by admin/backend)
|
||||
// Stripe API caches metadata and doesn't sync with MongoDB direct updates
|
||||
// Priority: MongoDB membership.subscriptions > Stripe API subscriptions > hardcoded fallback
|
||||
const mongoSubscription = membership?.subscriptions?.find(sub =>
|
||||
sub.type === SubType.PACKAGE &&
|
||||
(sub.status === 'active' || sub.status === 'trialing')
|
||||
);
|
||||
const mongoMetadata = mongoSubscription?.items?.[0]?.metadata;
|
||||
|
||||
// ✅ FIX (2026-01-27): Use getEffectiveAcresLimit() for consistent empty string handling
|
||||
// Empty string "" in metadata was converting to 0, triggering fallback to hardcoded 50000
|
||||
// getEffectiveAcresLimit() properly handles: "" → null, null → null, "0" → null (all = Unlimited)
|
||||
const effectiveMaxAcres = this.getEffectiveAcresLimit(mongoSubscription, membership?.customLimits);
|
||||
const acre = createAcrePlan(UnitUtils.haToArea(usage.ttArea, true), effectiveMaxAcres);
|
||||
|
||||
// ✅ FIX (2026-01-28): Use getEffectiveVehicleLimit() for consistent customLimits handling
|
||||
// Same pattern as maxAcres fix (2026-01-27) - ensures customLimits override metadata
|
||||
const effectiveMaxVehicles = this.getEffectiveVehicleLimit(mongoSubscription, membership?.customLimits);
|
||||
const pkgNumVeh = effectiveMaxVehicles || 0;
|
||||
const trackNumVeh = addon?.quantity || 0;
|
||||
|
||||
const packagePlan = pkg ? {
|
||||
@ -495,24 +866,35 @@ export class SubscriptionService {
|
||||
|
||||
fmtSubMsg(text: string, key: PriceUsd, vehicle: { trkQuantity?: number, pkgQuantity?: number }): string {
|
||||
return text?.replace('#pkg#', subPlans[key].name)
|
||||
.replace('#quantity#', `${vehicle.trkQuantity}` || '')
|
||||
.replace('#maxAC#', `${vehicle.pkgQuantity}` || '') || '';
|
||||
.replace('#quantity#', `${vehicle.trkQuantity ?? ''}`)
|
||||
.replace('#maxAC#', `${vehicle.pkgQuantity ?? ''}`) || '';
|
||||
}
|
||||
|
||||
toVehRange(precedingMax: number, maxVehicles: number): string {
|
||||
const MIN = 1;
|
||||
const MAX = 10;
|
||||
const lowerRange = precedingMax + MIN;
|
||||
const lowerRange = precedingMax ? (precedingMax + MIN) : MIN;
|
||||
|
||||
// For custom limits (precedingMax === 0), show range from 1 to custom limit
|
||||
if (precedingMax === 0 && maxVehicles > MIN) {
|
||||
return `${MIN}-${maxVehicles}`;
|
||||
}
|
||||
|
||||
// If range collapses to single value (e.g., "2-2"), show just the number
|
||||
if (lowerRange === maxVehicles) {
|
||||
return `${maxVehicles}`;
|
||||
}
|
||||
|
||||
// Normal tier-based range calculation
|
||||
return maxVehicles > MIN && maxVehicles <= MAX
|
||||
? lowerRange ? `${lowerRange}-${maxVehicles}`
|
||||
: `${maxVehicles}`
|
||||
: `${maxVehicles}`;
|
||||
}
|
||||
|
||||
convertAddr(address: Address) {
|
||||
convertAddr(address) {
|
||||
address.postal_code = address?.postalCode;
|
||||
delete address?.postalCode;
|
||||
delete address?._id;
|
||||
delete address?.name;
|
||||
delete address?.valid;
|
||||
return address;
|
||||
@ -521,26 +903,41 @@ export class SubscriptionService {
|
||||
createBillingInfoPackage(applicatorId): Observable<BillingInfoPackage> {
|
||||
let billingInfoPackage: BillingInfoPackage;
|
||||
return this.getBillingAddress(applicatorId).pipe(
|
||||
map((address: Address) => {
|
||||
switchMap((address: Address) => {
|
||||
const hasExistingAdr = address && Object.keys(address)?.some((key) =>
|
||||
key === 'name' ||
|
||||
key === 'postalCode' ||
|
||||
key === 'line1'
|
||||
);
|
||||
if (hasExistingAdr) {
|
||||
billingInfoPackage = {
|
||||
return of(billingInfoPackage = {
|
||||
billingInfo: {
|
||||
applicatorId,
|
||||
name: address.name,
|
||||
address: this.convertAddr(address)
|
||||
}
|
||||
};
|
||||
});
|
||||
} else {
|
||||
billingInfoPackage = {
|
||||
isNewAccount: true
|
||||
};
|
||||
// Fallback to user info if no billing address exists - this is based on the assumption that if there's no billing address in addresses, use the default legacy address.
|
||||
return this.userSvc.getUser(applicatorId, { view: 'billing' }).pipe(
|
||||
map((user) => {
|
||||
return billingInfoPackage = {
|
||||
isNewAccount: true,
|
||||
billingInfo: {
|
||||
applicatorId,
|
||||
name: user.name,
|
||||
address: {
|
||||
line1: user.address,
|
||||
country: user.country,
|
||||
city: '',
|
||||
state: '',
|
||||
postal_code: ''
|
||||
}
|
||||
}
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
return billingInfoPackage;
|
||||
}),
|
||||
);
|
||||
}
|
||||
@ -550,6 +947,7 @@ export class SubscriptionService {
|
||||
description: addon.desc,
|
||||
amount: +addon.price * addon.quantity,
|
||||
quantity: addon.quantity,
|
||||
trialEnd: addon.trialEnd, // Populate trialEnd from addon (for extended trial display)
|
||||
price: {
|
||||
lookup_key: addon.lookupKey,
|
||||
unit_amount: +addon.price
|
||||
@ -561,6 +959,7 @@ export class SubscriptionService {
|
||||
description: selPkg.desc,
|
||||
amount: +selPkg.price,
|
||||
quantity: 1,
|
||||
trialEnd: selPkg.trialEnd, // Populate trialEnd from package (for extended trial display)
|
||||
price: {
|
||||
lookup_key: selPkg.lookupKey,
|
||||
unit_amount: +selPkg.price
|
||||
@ -611,6 +1010,219 @@ export class SubscriptionService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the billing address sequence for a user and returns both the updated address and user.
|
||||
* @param userId The user's id
|
||||
* @param address The address to update
|
||||
* @returns Observable<{ address: Address, user: User }>
|
||||
*/
|
||||
public updateBillingAddressSequence(userId: string, address: Address) {
|
||||
const { isBilling, ...addressWithoutBilling } = address; // Remove isBilling property if it exists
|
||||
return this.updateBillAddress(userId, addressWithoutBilling).pipe(
|
||||
switchMap((updatedAddress: Address) =>
|
||||
this.userSvc.getUser(userId, { withAddresses: true }).pipe(
|
||||
switchMap((user) => {
|
||||
return of({ address: updatedAddress, user })
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SUBSCRIPTION UTILITY METHODS - SINGLE SOURCE OF TRUTH
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Find latest subscription by periodEnd for given type
|
||||
* This is the canonical utility for non-store contexts (services, standalone functions)
|
||||
*
|
||||
* @param subscriptions - Array of AGNav subscriptions
|
||||
* @param type - Subscription type (PACKAGE or ADDON)
|
||||
* @returns Latest subscription or null if none found
|
||||
*
|
||||
* @example
|
||||
* const latest = this.subSvc.getLatestSubscription(user.membership.subscriptions, SubType.PACKAGE);
|
||||
*/
|
||||
getLatestSubscription(
|
||||
subscriptions: AGNavSubscription[],
|
||||
type: SubType
|
||||
): AGNavSubscription | null {
|
||||
if (!subscriptions || subscriptions.length === 0) return null;
|
||||
|
||||
const filtered = subscriptions.filter(sub => sub.type === type);
|
||||
if (filtered.length === 0) return null;
|
||||
|
||||
return filtered.reduce((acc, curr) =>
|
||||
(curr.periodEnd > acc.periodEnd) ? curr : acc, filtered[0]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get lookup key from latest package subscription
|
||||
* Replaces duplicated logic across auth.service, effects, components
|
||||
*
|
||||
* @param subscriptions - Array of AGNav subscriptions
|
||||
* @returns Lookup key (price ID) or null
|
||||
*
|
||||
* @example
|
||||
* const lookupKey = this.subSvc.getCurrentPackageLookupKey(user.membership.subscriptions);
|
||||
*/
|
||||
getCurrentPackageLookupKey(subscriptions: AGNavSubscription[]): PriceUsd | null {
|
||||
const latest = this.getLatestSubscription(subscriptions, SubType.PACKAGE);
|
||||
return latest?.items?.[0]?.price || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get lookup key from latest addon subscription
|
||||
*
|
||||
* @param subscriptions - Array of AGNav subscriptions
|
||||
* @returns Addon lookup key or null
|
||||
*/
|
||||
getCurrentAddonLookupKey(subscriptions: AGNavSubscription[]): PriceUsd | null {
|
||||
const latest = this.getLatestSubscription(subscriptions, SubType.ADDON);
|
||||
return latest?.items?.[0]?.price || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get effective vehicle limit (custom limits override plan limits)
|
||||
* This is the authoritative calculation for max vehicles
|
||||
*
|
||||
* @param subscription - Current subscription
|
||||
* @param customLimits - User custom limits
|
||||
* @returns Effective max vehicles or null
|
||||
*
|
||||
* @example
|
||||
* const maxVehicles = this.subSvc.getEffectiveVehicleLimit(latestSub, user.membership.customLimits);
|
||||
*/
|
||||
getEffectiveVehicleLimit(
|
||||
subscription: AGNavSubscription,
|
||||
customLimits?: { maxVehicles?: number; maxAcres?: number }
|
||||
): number | null {
|
||||
if (!subscription) return null;
|
||||
|
||||
const planMaxVehicles = Math.abs(Number(subscription.items?.[0]?.metadata?.maxVehicles));
|
||||
const customMax = customLimits?.maxVehicles ? Math.abs(customLimits.maxVehicles) : null;
|
||||
|
||||
// Custom limits override plan limits
|
||||
return customMax || planMaxVehicles || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get effective acres limit (custom limits override plan limits)
|
||||
*
|
||||
* @param subscription - Current subscription
|
||||
* @param customLimits - User custom limits
|
||||
* @returns Effective max acres or null
|
||||
*/
|
||||
getEffectiveAcresLimit(
|
||||
subscription: AGNavSubscription,
|
||||
customLimits?: { maxVehicles?: number; maxAcres?: number }
|
||||
): number | null {
|
||||
if (!subscription) return null;
|
||||
|
||||
const planMaxAcres = Number(subscription.items?.[0]?.metadata?.maxAcres);
|
||||
|
||||
// Custom limits override plan limits
|
||||
// Treat empty string as null for proper "Unlimited" display
|
||||
const customLimit = customLimits?.maxAcres;
|
||||
const effectiveCustomLimit = (customLimit !== null && customLimit !== undefined && customLimit !== 0)
|
||||
? customLimit
|
||||
: null;
|
||||
|
||||
const effectivePlanLimit = (planMaxAcres !== null && planMaxAcres !== undefined && planMaxAcres !== 0 && !isNaN(planMaxAcres))
|
||||
? planMaxAcres
|
||||
: null;
|
||||
|
||||
return effectiveCustomLimit || effectivePlanLimit || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if subscription has custom limits applied
|
||||
* Custom limits are considered "applied" when they differ from plan defaults
|
||||
*
|
||||
* @param subscription - Current subscription
|
||||
* @param customLimits - User custom limits
|
||||
* @returns True if custom limits differ from plan limits
|
||||
*
|
||||
* @example
|
||||
* const hasCustom = this.subSvc.hasCustomLimits(latestSub, user.membership.customLimits);
|
||||
*/
|
||||
hasCustomLimits(
|
||||
subscription: AGNavSubscription,
|
||||
customLimits?: { maxVehicles?: number; maxAcres?: number }
|
||||
): boolean {
|
||||
if (!subscription || !customLimits) return false;
|
||||
|
||||
const planMaxVehicles = Number(subscription.items?.[0]?.metadata?.maxVehicles);
|
||||
const planMaxAcres = Number(subscription.items?.[0]?.metadata?.maxAcres);
|
||||
|
||||
// Check if either vehicle or acres custom limits differ from plan
|
||||
const vehicleLimitsDiffer = customLimits.maxVehicles && customLimits.maxVehicles !== planMaxVehicles;
|
||||
const acresLimitsDiffer = customLimits.maxAcres && customLimits.maxAcres !== planMaxAcres;
|
||||
|
||||
return vehicleLimitsDiffer || acresLimitsDiffer;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SUBSCRIPTION EXPIRY WARNING
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Calculate expiry warning from subscription data
|
||||
*
|
||||
* Returns warning if subscription expires in 1-7 days, null otherwise.
|
||||
* Only triggers for package subscriptions (not addons).
|
||||
*
|
||||
* Data Source: GET /api/subscription?custId={custId}
|
||||
* Verified: 2025-11-10 via /server_test/subscription-data-verification.js
|
||||
*
|
||||
* @param subscription - StripeSubscription from /api/subscription endpoint
|
||||
* @returns ExpiryWarning if criteria met, null otherwise
|
||||
*
|
||||
* @example
|
||||
* const subscriptions = await this.getSubscriptions(custId).toPromise();
|
||||
* const warnings = subscriptions
|
||||
* .map(sub => this.calculateExpiryWarning(sub))
|
||||
* .filter(w => w !== null);
|
||||
*/
|
||||
calculateExpiryWarning(subscription: StripeSubscription): ExpiryWarning | null {
|
||||
// Validate required fields (based on Phase 1 verification)
|
||||
if (!subscription?.current_period_end || !subscription?.metadata?.type) {
|
||||
console.warn('calculateExpiryWarning: Missing required fields', {
|
||||
id: subscription?.id,
|
||||
has_period_end: !!subscription?.current_period_end,
|
||||
has_metadata_type: !!subscription?.metadata?.type
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000); // Unix timestamp in seconds
|
||||
const secondsUntilExpiry = subscription.current_period_end - now;
|
||||
const daysUntilExpiry = Math.floor(secondsUntilExpiry / 86400);
|
||||
|
||||
// Only warn for subscriptions expiring in 0-expiryWarningDays days (inclusive of today)
|
||||
if (daysUntilExpiry < 0 || daysUntilExpiry > environment.expiryWarningDays) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only show warnings for package subscriptions (not addons)
|
||||
if (subscription.metadata?.type !== 'package') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: subscription.id,
|
||||
type: subscription.metadata?.type as 'package' | 'addon',
|
||||
status: subscription.status,
|
||||
daysUntilExpiry,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end ?? false,
|
||||
periodEnd: subscription.current_period_end,
|
||||
isTrial: subscription.status === 'trialing',
|
||||
willAutoRenew: !subscription.cancel_at_period_end
|
||||
};
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.sub$) this.sub$.unsubscribe();
|
||||
}
|
||||
|
||||
@ -3,27 +3,36 @@ import { HttpClient } from '@angular/common/http';
|
||||
|
||||
import { Store } from '@ngrx/store';
|
||||
|
||||
import { UserModel } from '../../auth/models/user.model';
|
||||
import { User } from '../../accounts/models/user.model';
|
||||
import { Roles } from '../../shared/global';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class UserService {
|
||||
|
||||
private readonly userURL = '/users';
|
||||
|
||||
constructor(
|
||||
private store: Store<{}>,
|
||||
private http: HttpClient
|
||||
) {
|
||||
constructor(private http: HttpClient) {
|
||||
}
|
||||
|
||||
loadUsers(options: LoadUserOptions): Observable<User[]> {
|
||||
return this.http.post<User[]>(this.userURL + '/search', options);
|
||||
}
|
||||
|
||||
getUser(id: string): Observable<User> {
|
||||
return this.http.get<User>(`${this.userURL}/${id}`);
|
||||
getUser(id: string, ops?: { withAddresses?: boolean; view?: 'profile' | 'edit' | 'billing' }): Observable<User> {
|
||||
let url = `${this.userURL}/${id}`;
|
||||
const params: string[] = [];
|
||||
if (ops?.withAddresses !== undefined) {
|
||||
params.push(`withAddresses=${ops.withAddresses}`);
|
||||
}
|
||||
if (ops?.view) {
|
||||
params.push(`view=${ops.view}`);
|
||||
}
|
||||
if (params.length) {
|
||||
url += `?${params.join('&')}`;
|
||||
}
|
||||
return this.http.get<User>(url);
|
||||
}
|
||||
|
||||
userNameExists(userName: string): Observable<boolean> {
|
||||
@ -55,6 +64,29 @@ export class UserService {
|
||||
return this.http.post<User>(`${this.userURL}/getUserDetail`, { username: username });
|
||||
}
|
||||
|
||||
signup(form: any) {
|
||||
return this.http.post<any>(`${this.userURL}/signup`, form);
|
||||
}
|
||||
|
||||
requestVerifyEmail(email: string) {
|
||||
return this.http.post<any>(`${this.userURL}/signup/requestVerifyEmail`, { email });
|
||||
}
|
||||
|
||||
signupValidate(token: string) {
|
||||
return this.http.post<any>(`${this.userURL}/signup/validate`, { token });
|
||||
}
|
||||
|
||||
getAccountType(user: UserModel | User): string {
|
||||
if (!user) return '';
|
||||
if ('roles' in user && Array.isArray(user.roles) && user.roles.length > 0) {
|
||||
const roleId = user.roles[0];
|
||||
return Roles[roleId] || '';
|
||||
}
|
||||
if ('kind' in user && user.kind && Roles[user.kind]) {
|
||||
return Roles[user.kind];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export interface LoadUserOptions {
|
||||
|
||||
@ -4,10 +4,9 @@ import { Actions, Effect, ofType } from '@ngrx/effects';
|
||||
import { SubscriptionService } from '@app/domain/services/subscription.service';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import * as subPlansActions from '@app/actions/sub-plans.actions'
|
||||
import { catchError, delay, filter, repeat, retryWhen, switchMap, take } from 'rxjs/operators';
|
||||
import { subPlans, SubAppErr, handleErr, SubKeys, TRACKING, PACKAGE_ACTIVE, createSubStatus, SUB, SubType, DELAY, TAKE } from '../profile/common';
|
||||
import { catchError, delay, exhaustMap, filter, repeat, retryWhen, switchMap, take } from 'rxjs/operators';
|
||||
import { subPlans, SubAppErr, handleErr, SubKeys, TRACKING, PACKAGE_ACTIVE, createSubStatus, SUB, SubType, DELAY, TAKE, EMPTY } from '../profile/common';
|
||||
import { AuthService } from '@app/domain/services/auth.service';
|
||||
import { UserService } from '@app/domain/services/user.service';
|
||||
import { StripeSubscription, Usage } from '@app/domain/models/subscription.model';
|
||||
import { AppMessageService } from '@app/shared/app-message.service';
|
||||
import { globals } from '@app/shared/global';
|
||||
@ -15,6 +14,7 @@ import { VehicleService } from '@app/domain/services/vehicle.service';
|
||||
import { FetchLatestSubscriptionSuccess, GotoAircraftList, UpdateSubscriptionStatus } from '@app/actions/subscription.actions';
|
||||
import { Vehicle } from '@app/entities/models/vehicle.model';
|
||||
import { CustomerService } from '@app/domain/services/customer.service';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
@Injectable()
|
||||
export class SubPlansEffects {
|
||||
@ -23,42 +23,107 @@ export class SubPlansEffects {
|
||||
private readonly actions$: Actions,
|
||||
private readonly subSvc: SubscriptionService,
|
||||
private readonly authSvc: AuthService,
|
||||
private readonly userSvc: UserService,
|
||||
private readonly msgSvc: AppMessageService,
|
||||
private readonly vehSvc: VehicleService,
|
||||
private readonly custSvc: CustomerService
|
||||
private readonly custSvc: CustomerService,
|
||||
private readonly router: Router
|
||||
) { }
|
||||
|
||||
@Effect()
|
||||
refreshSubPlans$: Observable<Action> = this.actions$.pipe(
|
||||
ofType<subPlansActions.FetchSubPlans>(subPlansActions.FETCH_SUB_PLANS),
|
||||
switchMap((action: subPlansActions.FetchSubPlans) => {
|
||||
exhaustMap((action: subPlansActions.FetchSubPlans) => {
|
||||
let usage: Usage;
|
||||
let subscriptions: StripeSubscription[];
|
||||
let vehicles: Vehicle[];
|
||||
let sortedPrices: any[];
|
||||
return this.subSvc.getPrices().pipe(
|
||||
filter(prices => prices?.length > 0),
|
||||
switchMap(prices => {
|
||||
const sortedPrices = [...prices].sort((a, b) => a.level - b.level);
|
||||
sortedPrices = [...prices].sort((a, b) => a.level - b.level);
|
||||
|
||||
let lastEffectiveMax = 0;
|
||||
const userSubscriptions = this.authSvc.user?.membership?.subscriptions;
|
||||
const userCustomLimits = this.authSvc.user?.membership?.customLimits;
|
||||
|
||||
// Use centralized utility to get current lookup key
|
||||
const currentLookupKey = this.subSvc.getCurrentPackageLookupKey(userSubscriptions);
|
||||
|
||||
// Get latest package subscription for custom limits checks
|
||||
const latestPackageSub = this.subSvc.getLatestSubscription(userSubscriptions, SubType.PACKAGE);
|
||||
|
||||
sortedPrices.forEach((price, indx) => {
|
||||
if (price) {
|
||||
const plan = subPlans[price.lookupKey] || {};
|
||||
|
||||
const isCurrentSubscription = price.lookupKey === currentLookupKey;
|
||||
|
||||
// Use centralized utility to check for custom limits
|
||||
const hasCustomLimit = isCurrentSubscription &&
|
||||
latestPackageSub &&
|
||||
this.subSvc.hasCustomLimits(latestPackageSub, userCustomLimits);
|
||||
|
||||
// Use centralized utility to get effective vehicle limit
|
||||
// For current subscription: Use API + custom limits
|
||||
// For other packages: Use API data from price.maxVehicles
|
||||
const effectiveMaxVehicles = isCurrentSubscription && latestPackageSub
|
||||
? this.subSvc.getEffectiveVehicleLimit(latestPackageSub, userCustomLimits)
|
||||
: price.maxVehicles;
|
||||
|
||||
// Use centralized utility to get effective acres limit (SAME PATTERN AS VEHICLES)
|
||||
// Treat empty string as null for proper "Unlimited" display
|
||||
const effectiveMaxAcres = isCurrentSubscription && latestPackageSub
|
||||
? this.subSvc.getEffectiveAcresLimit(latestPackageSub, userCustomLimits)
|
||||
: null;
|
||||
|
||||
plan.price = price.priceUSD || plan.price;
|
||||
plan.desc = plan.desc?.replace('#price#', this.subSvc.formatCurrency(price.priceUSD)) || plan.desc;
|
||||
plan.maxVehicles = price.maxVehicles || plan.maxVehicles;
|
||||
plan.maxAcres = price.maxAcres || plan.maxAcres;
|
||||
// Current subscription: use effectiveMaxVehicles (respects custom limits).
|
||||
// All other plans: use price.maxVehicles from Stripe API.
|
||||
if (isCurrentSubscription) {
|
||||
if (effectiveMaxVehicles != null) {
|
||||
plan.maxVehicles = effectiveMaxVehicles;
|
||||
}
|
||||
} else if (price.maxVehicles !== null && price.maxVehicles !== undefined) {
|
||||
plan.maxVehicles = Math.abs(price.maxVehicles);
|
||||
}
|
||||
|
||||
// Apply effective acres limit
|
||||
// For current subscription: Use MongoDB session data (effectiveMaxAcres) even if null
|
||||
// For other packages: Use Stripe API data (price.maxAcres)
|
||||
// IMPORTANT: Only update if we have a valid value to prevent race condition
|
||||
if (isCurrentSubscription) {
|
||||
if (effectiveMaxAcres !== null && effectiveMaxAcres !== undefined) {
|
||||
plan.maxAcres = Number(effectiveMaxAcres);
|
||||
}
|
||||
// Don't set to null - preserve existing value to avoid race condition
|
||||
} else {
|
||||
if (price.maxAcres !== null && price.maxAcres !== undefined) {
|
||||
plan.maxAcres = Number(price.maxAcres);
|
||||
}
|
||||
// Don't set to null - preserve existing value to avoid race condition
|
||||
}
|
||||
plan.level = price.level || plan.level;
|
||||
plan.type = price.type || plan.type;
|
||||
if (price.maxVehicles && indx > 0) {
|
||||
plan.Vehicles = this.subSvc.toVehRange(sortedPrices[indx - 1].maxVehicles, price.maxVehicles);
|
||||
|
||||
if (effectiveMaxVehicles) {
|
||||
if (hasCustomLimit && isCurrentSubscription) {
|
||||
plan.Vehicles = `1-${effectiveMaxVehicles}`;
|
||||
lastEffectiveMax = price.maxVehicles;
|
||||
} else {
|
||||
plan.Vehicles = this.subSvc.toVehRange(lastEffectiveMax, effectiveMaxVehicles);
|
||||
lastEffectiveMax = effectiveMaxVehicles;
|
||||
}
|
||||
} else {
|
||||
lastEffectiveMax = price.maxVehicles || effectiveMaxVehicles || 0;
|
||||
}
|
||||
|
||||
subPlans[price.lookupKey] = plan;
|
||||
}
|
||||
});
|
||||
return this.userSvc.getUser(this.authSvc.user?._id);
|
||||
}),
|
||||
switchMap(profileUser => {
|
||||
return this.subSvc.retrieveCurrUsage(this.authSvc.user?.membership?.custId, profileUser._id);
|
||||
|
||||
const byPuid = this.authSvc.user?.parent || this.authSvc.user?._id;
|
||||
return this.subSvc.retrieveCurrUsage(this.authSvc.user?.membership?.custId, byPuid);
|
||||
}),
|
||||
switchMap(_usage => {
|
||||
usage = _usage;
|
||||
@ -66,21 +131,16 @@ export class SubPlansEffects {
|
||||
}),
|
||||
switchMap(_subs => {
|
||||
subscriptions = _subs;
|
||||
return this.vehSvc.loadVehicles({ byUserId: this.authSvc.user?.parent });
|
||||
return this.vehSvc.loadVehicles({ byUserId: this.authSvc.user?.parent }).pipe(
|
||||
catchError(() => of([]))
|
||||
);
|
||||
}),
|
||||
switchMap((_vehicles) => {
|
||||
switchMap(_vehicles => {
|
||||
vehicles = _vehicles;
|
||||
let id;
|
||||
|
||||
if (this.authSvc.user?.parent) {
|
||||
id = this.authSvc.user?.parent;
|
||||
} else {
|
||||
id = this.authSvc.user._id;
|
||||
}
|
||||
|
||||
const id = this.authSvc.user?.parent || this.authSvc.user._id;
|
||||
return this.custSvc.getCustomer(id);
|
||||
}),
|
||||
switchMap((cust) => {
|
||||
switchMap(cust => {
|
||||
const curSubPlan = this.subSvc.createSubPlan(subscriptions, cust?.membership, usage);
|
||||
const getNumVehs = (type: string) => vehicles?.filter((veh) => veh[type] === true).length || 0;
|
||||
const trkVehicles = getNumVehs(TRACKING);
|
||||
@ -88,8 +148,97 @@ export class SubPlansEffects {
|
||||
const needReview = cust?.needReview;
|
||||
|
||||
if (subscriptions?.length === 0) {
|
||||
return of(new subPlansActions.ResetSubPlans());
|
||||
const actions: Action[] = [
|
||||
new subPlansActions.ResetSubPlans(),
|
||||
new subPlansActions.FetchSubPlansSuccess(curSubPlan)
|
||||
];
|
||||
|
||||
if (cust?.membership) {
|
||||
// Transform membership to preserve trial_end and promoDetails (Case 2C fix)
|
||||
actions.push(new FetchLatestSubscriptionSuccess({
|
||||
subscriptions,
|
||||
membership: this.subSvc.updateMembShip(subscriptions, cust?.membership)
|
||||
}));
|
||||
}
|
||||
|
||||
const currentUrl = this.router.url;
|
||||
const isHome = currentUrl === '/' || currentUrl.includes(`/${SUB.HOME}`);
|
||||
const isProfileRoute = currentUrl.includes(`/${SUB.PROFILE}`);
|
||||
|
||||
if (!isHome && !isProfileRoute) {
|
||||
this.router.navigate([`/${SUB.PROFILE}/${SUB.SERVICES}`]);
|
||||
}
|
||||
|
||||
return of(...actions);
|
||||
}
|
||||
|
||||
// Use centralized utility to get current lookup key from latest subscription
|
||||
const freshSubscriptions = cust?.membership?.subscriptions;
|
||||
const freshCurrentLookupKey = this.subSvc.getCurrentPackageLookupKey(freshSubscriptions) ||
|
||||
this.authSvc.getCurLookupKey(SubType.PACKAGE);
|
||||
const staleCurrentLookupKey = this.authSvc.getCurLookupKey(SubType.PACKAGE);
|
||||
|
||||
// Always update the current plan's maxVehicles with fresh customer data
|
||||
// (first block used stale authSvc cache — customLimits may not have been loaded yet)
|
||||
const freshLatestPackageSub = this.subSvc.getLatestSubscription(freshSubscriptions, SubType.PACKAGE);
|
||||
const freshUserCustomLimits = cust?.membership?.customLimits;
|
||||
if (freshCurrentLookupKey && freshLatestPackageSub && subPlans[freshCurrentLookupKey]) {
|
||||
const freshEffective = this.subSvc.getEffectiveVehicleLimit(freshLatestPackageSub, freshUserCustomLimits);
|
||||
if (freshEffective != null) {
|
||||
subPlans[freshCurrentLookupKey].maxVehicles = freshEffective;
|
||||
}
|
||||
}
|
||||
|
||||
if (freshCurrentLookupKey && freshCurrentLookupKey !== staleCurrentLookupKey) {
|
||||
let lastEffectiveMax = 0;
|
||||
const userCustomLimits = cust?.membership?.customLimits;
|
||||
|
||||
// Get latest package subscription for custom limits checks
|
||||
const latestPackageSub = this.subSvc.getLatestSubscription(freshSubscriptions, SubType.PACKAGE);
|
||||
|
||||
sortedPrices.forEach((price, indx) => {
|
||||
if (price) {
|
||||
const plan = subPlans[price.lookupKey];
|
||||
if (plan) {
|
||||
|
||||
const isCurrentSubscription = price.lookupKey === freshCurrentLookupKey;
|
||||
|
||||
// Use centralized utility to check for custom limits
|
||||
const hasCustomLimit = isCurrentSubscription &&
|
||||
latestPackageSub &&
|
||||
this.subSvc.hasCustomLimits(latestPackageSub, userCustomLimits);
|
||||
|
||||
// Use centralized utility to get effective vehicle limit
|
||||
const effectiveMaxVehicles = isCurrentSubscription && latestPackageSub
|
||||
? this.subSvc.getEffectiveVehicleLimit(latestPackageSub, userCustomLimits)
|
||||
: price.maxVehicles;
|
||||
|
||||
// Current subscription: use effectiveMaxVehicles (respects custom limits).
|
||||
// All other plans: use price.maxVehicles from Stripe API.
|
||||
if (isCurrentSubscription) {
|
||||
if (effectiveMaxVehicles != null) {
|
||||
plan.maxVehicles = effectiveMaxVehicles;
|
||||
}
|
||||
} else if (price.maxVehicles !== null && price.maxVehicles !== undefined) {
|
||||
plan.maxVehicles = Math.abs(price.maxVehicles);
|
||||
}
|
||||
|
||||
if (effectiveMaxVehicles) {
|
||||
if (hasCustomLimit && isCurrentSubscription) {
|
||||
plan.Vehicles = `1-${effectiveMaxVehicles}`;
|
||||
lastEffectiveMax = price.maxVehicles;
|
||||
} else {
|
||||
plan.Vehicles = this.subSvc.toVehRange(lastEffectiveMax, effectiveMaxVehicles);
|
||||
lastEffectiveMax = effectiveMaxVehicles;
|
||||
}
|
||||
} else {
|
||||
lastEffectiveMax = price.maxVehicles || effectiveMaxVehicles || 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const isCurTrkVehAboveLimit = trkVehicles > curSubPlan?.addon?.[SubKeys.TRACKING]?.airCraft?.numOfVehicle;
|
||||
const isCurActiveVehAboveLimit = pkgActiveVehicles > curSubPlan?.package?.[this.authSvc.getCurLookupKey(SubType.PACKAGE)]?.airCraft?.numOfVehicle;
|
||||
|
||||
@ -98,7 +247,11 @@ export class SubPlansEffects {
|
||||
];
|
||||
|
||||
if (cust?.membership) {
|
||||
actions.unshift(new FetchLatestSubscriptionSuccess({ membership: cust?.membership }));
|
||||
// Transform membership to preserve trial_end and promoDetails (Case 2C fix)
|
||||
actions.unshift(new FetchLatestSubscriptionSuccess({
|
||||
subscriptions,
|
||||
membership: this.subSvc.updateMembShip(subscriptions, cust?.membership)
|
||||
}));
|
||||
}
|
||||
|
||||
if (isCurActiveVehAboveLimit || isCurTrkVehAboveLimit || needReview) {
|
||||
@ -116,7 +269,7 @@ export class SubPlansEffects {
|
||||
delay(DELAY),
|
||||
take(TAKE)
|
||||
)),
|
||||
catchError((err) => {
|
||||
catchError(err => {
|
||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.load).replace('#thing#', globals.subPlans));
|
||||
return handleErr<Observable<Action>>({
|
||||
error: err, opt: {
|
||||
|
||||
@ -1,19 +1,21 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { from, interval, Observable, of } from 'rxjs';
|
||||
import { map, switchMap, catchError, take, takeUntil, tap, startWith, debounceTime, concatMap, repeat } from 'rxjs/operators';
|
||||
import { from, interval, Observable, of, forkJoin, throwError } from 'rxjs';
|
||||
import { map, switchMap, catchError, take, takeUntil, tap, startWith, debounceTime, concatMap, repeat, takeWhile } from 'rxjs/operators';
|
||||
import { Actions, Effect, ofType } from '@ngrx/effects';
|
||||
import { Action, Store } from '@ngrx/store';
|
||||
import * as subAction from '@app/actions/subscription.actions';
|
||||
import { SubscriptionService } from '@app/domain/services/subscription.service';
|
||||
import { Addon, Address, ConfirmPackage, Invoice, InvoicePackage, PaymentMethod, StripeSubscription, SubscriptionIntent, AddressPackage, Card, SubscriptionPackage, Coupon, BillingInfo, TrialPmtPkg, BillingInfoPackage } from '@app/domain/models/subscription.model';
|
||||
import { Addon, Address, ConfirmPackage, Invoice, InvoicePackage, PaymentMethod, StripeSubscription, SubscriptionIntent, Card, SubscriptionPackage, Coupon, BillingInfo, TrialPmtPkg, BillingInfoPackage } from '@app/domain/models/subscription.model';
|
||||
import { PaymentIntentResult, PaymentMethodResult } from '@stripe/stripe-js';
|
||||
import { UserModel } from '@app/auth/models/user.model';
|
||||
import { createSubStatus, handleErr, SubAppErr, SUB, SubStripe, Mode } from '@app/profile/common';
|
||||
import { createSubStatus, handleErr, SubAppErr, SUB, SubStripe, Mode, SERVICE_TYPE, PromoErrors } from '@app/profile/common';
|
||||
import { DateUtils, Utils } from '@app/shared/utils'
|
||||
import { AuthService } from '@app/domain/services/auth.service';
|
||||
import { ResetSubPlans } from '@app/actions/sub-plans.actions';
|
||||
import { CustomerService } from '@app/domain/services/customer.service';
|
||||
import { Customer } from '@app/customers/models/customer.model';
|
||||
import { GAService } from '@app/shared/ga.service';
|
||||
import { GAAnalyticsHelpersService } from '@app/shared/ga.analytics-helpers.service';
|
||||
|
||||
interface UnpaidContent {
|
||||
card: Card,
|
||||
@ -34,6 +36,8 @@ export class SubscriptionEffects {
|
||||
private readonly subSvc: SubscriptionService,
|
||||
private readonly authSvc: AuthService,
|
||||
private readonly custSvc: CustomerService,
|
||||
private readonly ga: GAService,
|
||||
private readonly gaHelpers: GAAnalyticsHelpersService,
|
||||
) { }
|
||||
|
||||
// Common effects
|
||||
@ -49,7 +53,38 @@ export class SubscriptionEffects {
|
||||
fetchLatestSub$: Observable<Action> = this.actions$.pipe(
|
||||
ofType<subAction.FetchLatestSubscription>(subAction.FETCH_LATEST_SUBSCRIPTION),
|
||||
switchMap((action: subAction.FetchLatestSubscription) => this.subSvc.fetchSubscriptions(action.payload.custId)),
|
||||
map((subscriptions) => new subAction.FetchLatestSubscriptionSuccess({ subscriptions, membership: this.subSvc.updateMembShip(subscriptions, this.authSvc.user?.membership) })),
|
||||
map((subscriptions) => {
|
||||
// Debug: Log ALL raw backend response data
|
||||
console.log('🔍 fetchLatestSub$ - EFFECT TRIGGERED:', {
|
||||
hasSubscriptions: !!subscriptions,
|
||||
count: subscriptions?.length || 0,
|
||||
allStatuses: subscriptions?.map(sub => ({ id: sub.id, status: sub.status })) || [],
|
||||
firstSubFull: subscriptions?.[0] || null
|
||||
});
|
||||
|
||||
// Debug: Log raw backend response for trial subscriptions
|
||||
const trialSubs = subscriptions?.filter(sub => sub.status === 'trialing');
|
||||
console.log('🔍 fetchLatestSub$ - Trial filter result:', {
|
||||
trialCount: trialSubs?.length || 0,
|
||||
hasTrialSubs: trialSubs && trialSubs.length > 0
|
||||
});
|
||||
|
||||
if (trialSubs && trialSubs.length > 0) {
|
||||
console.log('🔍 fetchLatestSub$ - RAW Backend API Response (trial subscriptions):', {
|
||||
count: trialSubs.length,
|
||||
firstSub: {
|
||||
id: trialSubs[0].id,
|
||||
status: trialSubs[0].status,
|
||||
trial_end: trialSubs[0].trial_end,
|
||||
promoDetails: trialSubs[0].promoDetails,
|
||||
has_trial_end_key: 'trial_end' in trialSubs[0],
|
||||
has_promoDetails_key: 'promoDetails' in trialSubs[0]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return new subAction.FetchLatestSubscriptionSuccess({ subscriptions, membership: this.subSvc.updateMembShip(subscriptions, this.authSvc.user?.membership) });
|
||||
}),
|
||||
catchError((err) => handleErr<Observable<Action>>({ error: err, opt: { extra: SubAppErr.FETCH_SUB_ERR } })),
|
||||
repeat()
|
||||
);
|
||||
@ -114,10 +149,18 @@ export class SubscriptionEffects {
|
||||
);
|
||||
|
||||
private handleStartChkout(payload: { billingInfo: BillingInfo; subIntentPkg: SubscriptionIntent }, type?: StartCheckoutCase) {
|
||||
const addrPkg: AddressPackage = { name: payload.billingInfo?.name, city: payload.billingInfo?.address?.city, line1: payload.billingInfo?.address?.line1, line2: payload.billingInfo?.address?.line2, postal_code: payload.billingInfo?.address?.postal_code, state: payload.billingInfo?.address?.state, country: payload.billingInfo?.address?.country };
|
||||
const addrPkg = {
|
||||
_id: payload.billingInfo?.address?._id,
|
||||
name: payload.billingInfo?.name, city: payload.billingInfo?.address?.city,
|
||||
line1: payload.billingInfo?.address?.line1,
|
||||
line2: payload.billingInfo?.address?.line2,
|
||||
postalCode: payload.billingInfo?.address?.postal_code,
|
||||
state: payload.billingInfo?.address?.state,
|
||||
country: payload.billingInfo?.address?.country
|
||||
};
|
||||
let subIntentPkg: SubscriptionIntent = payload.subIntentPkg;
|
||||
|
||||
let handleDefault = () => this.subSvc.updateBilAdr(payload.billingInfo?.applicatorId, addrPkg).pipe(
|
||||
let handleDefault = () => this.subSvc.updateBillingAddressSequence(payload.billingInfo?.applicatorId, addrPkg).pipe(
|
||||
switchMap(() => {
|
||||
subIntentPkg = { ...subIntentPkg, billingInfo: payload.billingInfo };
|
||||
return this.subSvc.getPaymentMethodList(payload.subIntentPkg?.custId);
|
||||
@ -134,9 +177,9 @@ export class SubscriptionEffects {
|
||||
|
||||
switch (type) {
|
||||
case StartCheckoutCase.NEW_ACC_TRIAL:
|
||||
return this.subSvc.updateBilAdr(payload.billingInfo?.applicatorId, addrPkg).pipe(map((address: Address) => new subAction.UpdateBillingAddressSuccess({ applicatorId: payload.billingInfo?.applicatorId, name: address?.name, address: this.subSvc.convertAddr(address) })));
|
||||
return this.subSvc.updateBillingAddressSequence(payload.billingInfo?.applicatorId, addrPkg).pipe(map((result) => new subAction.UpdateBillingAddressSuccess({ applicatorId: payload.billingInfo?.applicatorId, name: result?.address?.name, address: this.subSvc.convertAddr(result?.address) })));
|
||||
case StartCheckoutCase.NEW_ACC:
|
||||
return this.subSvc.updateBilAdr(payload.billingInfo?.applicatorId, addrPkg).pipe(
|
||||
return this.subSvc.updateBillingAddressSequence(payload.billingInfo?.applicatorId, addrPkg).pipe(
|
||||
switchMap(() => {
|
||||
subIntentPkg = { ...subIntentPkg, billingInfo: payload.billingInfo };
|
||||
return this.subSvc.retrieveUpcomingInvoices({ custId: payload.subIntentPkg?.custId, package: payload.subIntentPkg?.selPkg?.lookupKey, addons: payload.subIntentPkg?.selAddons?.map((addon: Addon) => ({ price: addon?.lookupKey, quantity: addon?.quantity })), prorateTS: payload.subIntentPkg?.prorateTS });
|
||||
@ -147,7 +190,7 @@ export class SubscriptionEffects {
|
||||
})
|
||||
);
|
||||
case StartCheckoutCase.TRIALING:
|
||||
return this.subSvc.updateBilAdr(payload.billingInfo?.applicatorId, addrPkg).pipe(
|
||||
return this.subSvc.updateBillingAddressSequence(payload.billingInfo?.applicatorId, addrPkg).pipe(
|
||||
switchMap(() => {
|
||||
subIntentPkg = { ...subIntentPkg, billingInfo: payload.billingInfo };
|
||||
return this.subSvc.getPaymentMethodList(payload.subIntentPkg?.custId);
|
||||
@ -157,7 +200,7 @@ export class SubscriptionEffects {
|
||||
return new subAction.StartCheckoutSuccess(subIntentPkg);
|
||||
}));
|
||||
case StartCheckoutCase.UNPAID:
|
||||
return this.subSvc.updateBilAdr(payload.billingInfo?.applicatorId, addrPkg).pipe(
|
||||
return this.subSvc.updateBillingAddressSequence(payload.billingInfo?.applicatorId, addrPkg).pipe(
|
||||
map(() => {
|
||||
return new subAction.StartCheckoutSuccess(subIntentPkg);
|
||||
}));
|
||||
@ -205,10 +248,11 @@ export class SubscriptionEffects {
|
||||
private handleContTrial(payload: TrialPmtPkg, type?: TrialChkoutCase) {
|
||||
switch (type) {
|
||||
case TrialChkoutCase.NEW_CARD:
|
||||
const { _id, isBilling, ...address } = payload.pmtMethod?.newPmtMeth?.billing_details?.address;
|
||||
return from(this.subSvc.stripe.createPaymentMethod({
|
||||
type: 'card',
|
||||
card: payload.pmtMethod?.newPmtMeth?.card,
|
||||
billing_details: { name: payload.pmtMethod?.newPmtMeth?.billing_details?.name, address: payload.pmtMethod?.newPmtMeth?.billing_details?.address }
|
||||
billing_details: { name: payload.pmtMethod?.newPmtMeth?.billing_details?.name, address }
|
||||
})).pipe(
|
||||
switchMap((result: PaymentMethodResult) => {
|
||||
const stripeErr = result?.error;
|
||||
@ -219,7 +263,11 @@ export class SubscriptionEffects {
|
||||
switchMap(() => {
|
||||
return this.subSvc.editSub(payload.subIds?.map((subId) => ({ subId, cancelAtPeriodEnd: false })) || []);
|
||||
}),
|
||||
map((subs) => new subAction.CheckoutTrialSuccess({
|
||||
map((subs) => {
|
||||
// Track successful trial checkout
|
||||
this.trackSubscriptionPurchase(subs, { payload });
|
||||
|
||||
return new subAction.CheckoutTrialSuccess({
|
||||
card: {
|
||||
pmId: result?.paymentMethod?.id,
|
||||
brand: result?.paymentMethod?.card?.brand,
|
||||
@ -230,7 +278,8 @@ export class SubscriptionEffects {
|
||||
defaultPM: payload.pmtMethod?.newPmtMeth?.defaultPM
|
||||
},
|
||||
subs, amount: payload.amount
|
||||
}))
|
||||
});
|
||||
})
|
||||
);
|
||||
}),
|
||||
);
|
||||
@ -256,20 +305,44 @@ export class SubscriptionEffects {
|
||||
prorateTS: DateUtils.currUTC()
|
||||
};
|
||||
let subs: StripeSubscription[];
|
||||
|
||||
const handleDefault = () => this.subSvc.updateSubscription(updatePkgPayload).pipe(
|
||||
switchMap((_subs) => {
|
||||
subs = _subs;
|
||||
return this.custSvc.getCustomer(this.authSvc.user._id);
|
||||
}),
|
||||
switchMap((cust: Customer) => {
|
||||
return of(new subAction.CheckoutTrialSuccess({ subs }), new subAction.UpdateTrial(cust.membership.trials))
|
||||
// Track successful trial checkout
|
||||
this.trackSubscriptionPurchase(subs, { payload: updatePkgPayload });
|
||||
|
||||
// Fetch card data from customer's default payment method
|
||||
return this.subSvc.getPaymentMethodList(cust.membership.custId).pipe(
|
||||
map((paymentMethods: PaymentMethod[]) => {
|
||||
const defaultPM = paymentMethods?.find(pm => pm.id === subs?.[0]?.default_payment_method);
|
||||
const card: Card | undefined = defaultPM ? {
|
||||
pmId: defaultPM.id,
|
||||
brand: defaultPM.card?.brand,
|
||||
country: defaultPM.card?.country,
|
||||
exp_month: defaultPM.card?.exp_month,
|
||||
exp_year: defaultPM.card?.exp_year,
|
||||
last4: defaultPM.card?.last4,
|
||||
defaultPM: true
|
||||
} : undefined;
|
||||
|
||||
return [new subAction.CheckoutTrialSuccess({ subs, card }), new subAction.UpdateTrial(cust.membership.trials)];
|
||||
}),
|
||||
switchMap((actions) => of(...actions))
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
switch (type) {
|
||||
case TrialChkoutCase.NEW_CARD:
|
||||
const { _id, isBilling, ...address } = payload.pmtMethod?.newPmtMeth?.billing_details?.address;
|
||||
return from(this.subSvc.stripe.createPaymentMethod({
|
||||
type: 'card', card: payload.pmtMethod?.newPmtMeth?.card,
|
||||
billing_details: { name: payload.pmtMethod?.newPmtMeth?.billing_details?.name, address: payload.pmtMethod?.newPmtMeth?.billing_details?.address }
|
||||
billing_details: { name: payload.pmtMethod?.newPmtMeth?.billing_details?.name, address }
|
||||
})).pipe(
|
||||
switchMap((result: PaymentMethodResult) => {
|
||||
const stripeErr = result?.error;
|
||||
@ -330,7 +403,10 @@ export class SubscriptionEffects {
|
||||
let invoicePkg: InvoicePackage = { custId: action.payload.subIntentPkg.custId, package: action.payload.subIntentPkg.selPkg?.lookupKey, addons: action.payload.subIntentPkg.selAddons?.map((addon: Addon) => ({ price: addon?.lookupKey, quantity: addon?.quantity })), prorateTS: action.payload.subIntentPkg.prorateTS };
|
||||
|
||||
if (action.payload.coupon) {
|
||||
return this.subSvc.getCoupon(action.payload.coupon).pipe(
|
||||
// Extract price keys for product restriction validation
|
||||
const priceKeys = this._collectPriceKeys(action.payload.subIntentPkg);
|
||||
|
||||
return this.subSvc.getCoupon(action.payload.coupon, priceKeys).pipe(
|
||||
switchMap((coupon: Coupon) => {
|
||||
if (!coupon.valid) {
|
||||
return handleErr<Observable<Action>>({ error: '', opt: { extra: SubAppErr.APP_DISCOUNT_PREVIEW_ERR } });
|
||||
@ -346,10 +422,72 @@ export class SubscriptionEffects {
|
||||
map((res) => new subAction.ApplyDiscountPreviewSuccess({ amount: this.subSvc.calcAmount(res), coupons: [] }))
|
||||
);
|
||||
}),
|
||||
catchError((err) => handleErr<Observable<Action>>({ error: err, opt: { extra: SubAppErr.APP_DISCOUNT_PREVIEW_ERR, msg: err?.error?.raw?.message } })),
|
||||
catchError((err) => {
|
||||
// Handle promo_invalid_coupon error specifically
|
||||
// Error structure: err.error.error[".tag"] and err.error.error.message
|
||||
const errorTag = err?.error?.error?.[".tag"];
|
||||
const errorMessage = err?.error?.error?.message;
|
||||
|
||||
if (errorTag === 'promo_invalid_coupon') {
|
||||
// Match specific error message to user-friendly label from PromoErrors
|
||||
let displayMessage = PromoErrors.PROMO_INVALID_COUPON; // Default
|
||||
|
||||
if (errorMessage?.includes('first-time customers')) {
|
||||
displayMessage = PromoErrors.PROMO_FIRST_TIME_ONLY;
|
||||
} else if (errorMessage?.includes('not available for this customer')) {
|
||||
displayMessage = PromoErrors.PROMO_RESTRICTED_CUSTOMER;
|
||||
} else if (errorMessage?.includes('not applicable to the selected products') || errorMessage?.includes('restricted to specific products')) {
|
||||
displayMessage = PromoErrors.PROMO_RESTRICTED_PRODUCT;
|
||||
} else if (errorMessage?.includes('expired')) {
|
||||
displayMessage = PromoErrors.PROMO_EXPIRED;
|
||||
} else if (errorMessage?.includes('maximum redemption') || errorMessage?.includes('reached max')) {
|
||||
displayMessage = PromoErrors.PROMO_MAX_REDEMPTIONS;
|
||||
}
|
||||
|
||||
return handleErr<Observable<Action>>({
|
||||
error: err,
|
||||
opt: {
|
||||
extra: SubAppErr.APP_DISCOUNT_PREVIEW_ERR,
|
||||
msg: displayMessage
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback to generic error handling (for other error types)
|
||||
return handleErr<Observable<Action>>({
|
||||
error: err,
|
||||
opt: {
|
||||
extra: SubAppErr.APP_DISCOUNT_PREVIEW_ERR,
|
||||
msg: err?.error?.message || err?.error?.raw?.message // Try new format first, fallback to old
|
||||
}
|
||||
});
|
||||
}),
|
||||
repeat()
|
||||
);
|
||||
|
||||
/**
|
||||
* Collect price keys from subscription intent package for product validation
|
||||
* @param subIntentPkg Subscription intent package containing selected package and addons
|
||||
* @returns Array of price lookup keys (e.g., ['ess_3', 'addon_1'])
|
||||
*/
|
||||
private _collectPriceKeys(subIntentPkg: any): string[] {
|
||||
const keys: string[] = [];
|
||||
|
||||
// Add selected package
|
||||
if (subIntentPkg?.selPkg?.lookupKey) {
|
||||
keys.push(subIntentPkg.selPkg.lookupKey);
|
||||
}
|
||||
|
||||
// Add selected addons
|
||||
subIntentPkg?.selAddons?.forEach(addon => {
|
||||
if (addon?.lookupKey) {
|
||||
keys.push(addon.lookupKey);
|
||||
}
|
||||
});
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
// Checkout-review stage
|
||||
private finalizeConfirm({ action, results, confirmPkg }) {
|
||||
return switchMap((subscriptions: StripeSubscription[]) => {
|
||||
@ -365,6 +503,26 @@ export class SubscriptionEffects {
|
||||
const lastPaymentErr: any = error?.payment_intent?.last_payment_error;
|
||||
const card: Card = lastPaymentErr?.payment_method?.card || lastPaymentErr?.source;
|
||||
|
||||
// Check for card decline at checkout-review stage
|
||||
const isCardDeclineError = error?.code === 'card_declined' || error?.decline_code === 'generic_decline';
|
||||
const isCheckoutReviewStage = confirmPkg?.stage === SUB.CHKOUT_REV;
|
||||
|
||||
if (isCardDeclineError && isCheckoutReviewStage) {
|
||||
// Stay at checkout-review with error message - no navigation
|
||||
return of(
|
||||
new subAction.UpdateIncomplete({
|
||||
invoices: action.payload.unresolved?.invoices,
|
||||
requiresAction: false,
|
||||
requiresPM: true,
|
||||
numOfRetries: ++action.payload.unresolved.numOfRetries,
|
||||
subscriptions
|
||||
}),
|
||||
new subAction.UpdateSubscriptionStatus(
|
||||
createSubStatus(SubStripe.CARD_DECLINED, { card })
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (isPastdueType) {
|
||||
return of(
|
||||
new subAction.UpdateSubscriptionStatus(createSubStatus(SubAppErr.CONF_ERR, { extra: lastPaymentErr, card })),
|
||||
@ -415,11 +573,54 @@ export class SubscriptionEffects {
|
||||
return of(action.payload).pipe(
|
||||
switchMap((_confirmPkg: ConfirmPackage) => {
|
||||
confirmPkg = _confirmPkg;
|
||||
const confirmations$ = confirmPkg?.stripePkgs?.map((pkg) => Utils.demethodize(this.subSvc.stripe.confirmCardPayment)(pkg?.clientSecret, { payment_method: pkg?.pmId }));
|
||||
const confirmations$ = confirmPkg?.stripePkgs?.map((pkg) => {
|
||||
return Utils.demethodize(this.subSvc.stripe.confirmCardPayment)(pkg?.clientSecret, { payment_method: pkg?.pmId });
|
||||
});
|
||||
const promiseChain = Utils.createPromiseChain<PaymentIntentResult>(confirmations$)
|
||||
return from(promiseChain);
|
||||
}),
|
||||
switchMap((results: PaymentIntentResult[]) => {
|
||||
// Check for errors in 3DS confirmation
|
||||
const hasErrors = results?.some((result) => !!result?.error);
|
||||
if (hasErrors) {
|
||||
// If there are errors, proceed without polling
|
||||
return this.subSvc.fetchSubscriptions(action.payload.custId).pipe(
|
||||
this.finalizeConfirm({ action, results, confirmPkg })
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// NEW: 3DS SUCCESS → START POLLING (r944 requirement)
|
||||
// ============================================================
|
||||
// PaymentIntent is 'succeeded' but subscription still 'incomplete'
|
||||
// Must wait 1-3 seconds for Stripe to charge and activate
|
||||
|
||||
const subscriptionIds = action.payload.subIds;
|
||||
|
||||
if (!subscriptionIds || subscriptionIds.length === 0) {
|
||||
console.error('❌ No subscription IDs provided for polling');
|
||||
return this.subSvc.fetchSubscriptions(action.payload.custId).pipe(
|
||||
this.finalizeConfirm({ action, results, confirmPkg })
|
||||
);
|
||||
}
|
||||
|
||||
// Poll EACH subscription until active
|
||||
const pollingObservables = subscriptionIds.map((subId) =>
|
||||
this.pollSubscriptionStatus(subId, 10, 500)
|
||||
);
|
||||
|
||||
return forkJoin(pollingObservables).pipe(
|
||||
switchMap((polledSubscriptions) => {
|
||||
// All subscriptions activated successfully
|
||||
// Now fetch full subscription data and finalize
|
||||
return this.subSvc.fetchSubscriptions(action.payload.custId).pipe(
|
||||
this.finalizeConfirm({ action, results, confirmPkg })
|
||||
);
|
||||
}),
|
||||
catchError((pollingError) => {
|
||||
console.error('❌ Polling failed:', pollingError);
|
||||
// Even if polling fails, try to fetch subscriptions and proceed
|
||||
// The subscription might have activated despite polling timeout
|
||||
return this.subSvc.fetchSubscriptions(action.payload.custId).pipe(
|
||||
this.finalizeConfirm({ action, results, confirmPkg })
|
||||
);
|
||||
@ -427,8 +628,13 @@ export class SubscriptionEffects {
|
||||
);
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('🔴 CONFIRM EFFECT ERROR', err);
|
||||
return handleErr<Observable<Action>>({ error: err, opt: { extra: SubAppErr.CONF_ERR } });
|
||||
}),
|
||||
catchError((err) => handleErr<Observable<Action>>({ error: err, opt: { extra: SubAppErr.CONF_ERR } })),
|
||||
repeat()
|
||||
);
|
||||
|
||||
@ -450,7 +656,33 @@ export class SubscriptionEffects {
|
||||
if (hasIncompleteSub) {
|
||||
const req3dsVerf = this.subSvc.isRequireAction(subscriptions);
|
||||
const reqPm = this.subSvc.isRequirePaymentMethod(subscriptions);
|
||||
let latestInvoices = subscriptions?.map((sub) => sub?.latest_invoice);
|
||||
|
||||
// CRITICAL: Transform backend's flat 3DS response structure into expected nested format
|
||||
// Backend (r942 Direct Pattern) returns: { requires_action: true, client_secret: 'pi_xxx', payment_intent_id: 'pi_xxx' }
|
||||
// Frontend expects: { latest_invoice: { payment_intent: { status: 'requires_action', client_secret: 'pi_xxx' } } }
|
||||
const transformedSubscriptions = subscriptions?.map(sub => {
|
||||
// If backend returned flat structure with client_secret at top level, transform it
|
||||
if ((sub as any)?.requires_action && (sub as any)?.client_secret && !sub?.latest_invoice?.payment_intent?.client_secret) {
|
||||
return {
|
||||
...sub,
|
||||
latest_invoice: {
|
||||
...(sub.latest_invoice || {} as any),
|
||||
id: sub.latest_invoice?.id || (sub as any).payment_intent_id || `inv_temp_${Date.now()}`,
|
||||
status: 'open',
|
||||
subscription: sub.id,
|
||||
payment_intent: {
|
||||
...(sub.latest_invoice?.payment_intent || {} as any),
|
||||
id: (sub as any).payment_intent_id,
|
||||
status: 'requires_action',
|
||||
client_secret: (sub as any).client_secret
|
||||
} as any
|
||||
} as any
|
||||
};
|
||||
}
|
||||
return sub;
|
||||
});
|
||||
|
||||
let latestInvoices = transformedSubscriptions?.map((sub) => sub?.latest_invoice) as any[];
|
||||
const hasLatestInvoices = latestInvoices?.length > 0;
|
||||
if (hasLatestInvoices) {
|
||||
if (req3dsVerf) {
|
||||
@ -461,6 +693,13 @@ export class SubscriptionEffects {
|
||||
new subAction.UpdateIncomplete({ invoices: latestInvoices, requiresAction: true, requiresPM: false, numOfRetries: 0, subscriptions }),
|
||||
new subAction.UpdateSubscriptionSuccess(this.subSvc.createSubPlan(subscriptions, this.authSvc.user?.membership, usage))
|
||||
);
|
||||
} else {
|
||||
// Not at checkout review stage - return incomplete status without navigation
|
||||
return of(
|
||||
new subAction.UpdateSubscriptionStatus(createSubStatus(SubStripe.REQUIRE_ACTION, { card })),
|
||||
new subAction.UpdateIncomplete({ invoices: latestInvoices, requiresAction: true, requiresPM: false, numOfRetries: 0, subscriptions }),
|
||||
new subAction.UpdateSubscriptionSuccess(this.subSvc.createSubPlan(subscriptions, this.authSvc.user?.membership, usage))
|
||||
);
|
||||
}
|
||||
} else if (reqPm) {
|
||||
const atChkoutRevStage = action.payload.stage === SUB.CHKOUT_REV;
|
||||
@ -470,16 +709,31 @@ export class SubscriptionEffects {
|
||||
new subAction.UpdateIncomplete({ invoices: latestInvoices, requiresAction: false, requiresPM: true, numOfRetries: 0, subscriptions }),
|
||||
new subAction.UpdateSubscriptionSuccess(this.subSvc.createSubPlan(subscriptions, this.authSvc.user?.membership, usage))
|
||||
);
|
||||
} else {
|
||||
// Not at checkout review stage - return incomplete status without navigation
|
||||
return of(
|
||||
new subAction.UpdateSubscriptionStatus(createSubStatus(SubStripe.REQUIRE_PAYMENT_METHOD, { card })),
|
||||
new subAction.UpdateIncomplete({ invoices: latestInvoices, requiresAction: false, requiresPM: true, numOfRetries: 0, subscriptions }),
|
||||
new subAction.UpdateSubscriptionSuccess(this.subSvc.createSubPlan(subscriptions, this.authSvc.user?.membership, usage))
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Incomplete but neither requires action nor payment method - return success
|
||||
return of(
|
||||
new subAction.UpdateSubscriptionSuccess(this.subSvc.createSubPlan(subscriptions, this.authSvc.user?.membership, usage))
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return handleErr<Observable<Action>>({ opt: { extra: SubAppErr.NO_INVOICES_ERR } });
|
||||
}
|
||||
} else {
|
||||
// Track successful subscription purchase/update
|
||||
this.trackSubscriptionPurchase(subscriptions, action);
|
||||
|
||||
return of(new subAction.UpdateSubscriptionSuccess(this.subSvc.createSubPlan(subscriptions, this.authSvc.user?.membership, usage)), new subAction.GotoCheckoutConfirm());
|
||||
}
|
||||
}),
|
||||
catchError((err) => handleErr<Observable<Action>>({ error: err, opt: { card } }))
|
||||
catchError((err) => handleErr<Observable<Action>>({ error: err, opt: { card, extra: SubAppErr.UPDATE_SUB_ERR } }))
|
||||
);
|
||||
}),
|
||||
catchError((err) => handleErr<Observable<Action>>({ error: err, opt: { extra: SubAppErr.UPDATE_SUB_ERR } })),
|
||||
@ -661,7 +915,6 @@ export class SubscriptionEffects {
|
||||
switchMap((action: subAction.InitSubscription) => {
|
||||
return this.subSvc.fetchSubscriptions(action.payload.custId).pipe(
|
||||
switchMap((subscriptions: StripeSubscription[]) => {
|
||||
|
||||
const hasNoSubs = subscriptions?.length === 0;
|
||||
const hasUnpaidSubs = subscriptions?.some((sub) => sub?.status === SubStripe.UNPAID);
|
||||
const hasPastDueSubs = subscriptions?.some((sub) => sub?.status === SubStripe.PAST_DUE) || subscriptions?.some((sub) => sub?.status === SubStripe.OVERDUE);
|
||||
@ -693,6 +946,9 @@ export class SubscriptionEffects {
|
||||
}
|
||||
|
||||
if (hasNoSubs) {
|
||||
// Only reset the subscription-page state. Auth membership is NOT touched here –
|
||||
// auth.reducer RESET_SUBSCRIPTION returns state unchanged so the expiry-warning
|
||||
// banner remains visible when Stripe returns empty (e.g. transient API errors).
|
||||
return of(
|
||||
new ResetSubPlans(),
|
||||
new subAction.ResetSubscription());
|
||||
@ -708,7 +964,8 @@ export class SubscriptionEffects {
|
||||
new subAction.UpdateSubscriptionStatus(createSubStatus(SubStripe.REQ_LOC_INPUT))
|
||||
);
|
||||
}
|
||||
return of(new subAction.FetchLatestSubscriptionSuccess({ subscriptions, membership: this.authSvc.user?.membership }));
|
||||
|
||||
return of(new subAction.FetchLatestSubscriptionSuccess({ subscriptions, membership: this.subSvc.updateMembShip(subscriptions, this.authSvc.user?.membership) }));
|
||||
}))
|
||||
}),
|
||||
catchError((err) => handleErr<Observable<Action>>({ error: err, opt: { extra: SubAppErr.FETCH_SUB_ERR } })),
|
||||
@ -812,7 +1069,8 @@ export class SubscriptionEffects {
|
||||
createPaymentMethod$: Observable<Action> = this.actions$.pipe(
|
||||
ofType<subAction.CreatePaymentMethod>(subAction.CREATE_PAYMENT_METHOD),
|
||||
switchMap((action: subAction.CreatePaymentMethod) => {
|
||||
return from(this.subSvc.stripe.createPaymentMethod({ type: 'card', card: action.payload.card, billing_details: { name: action.payload.billing_details?.name, address: action.payload.billing_details?.address } })).pipe(
|
||||
const { _id, ...address } = action.payload.billing_details?.address;
|
||||
return from(this.subSvc.stripe.createPaymentMethod({ type: 'card', card: action.payload.card, billing_details: { name: action.payload.billing_details?.name, address } })).pipe(
|
||||
switchMap((result: PaymentMethodResult) => {
|
||||
const stripeErr = result?.error;
|
||||
if (stripeErr) {
|
||||
@ -896,4 +1154,164 @@ export class SubscriptionEffects {
|
||||
catchError((err) => handleErr<Observable<Action>>({ error: err, opt: { extra: SubAppErr.CHANGE_PM_ERR } })),
|
||||
repeat()
|
||||
);
|
||||
|
||||
private getPaymentMethod(action: any): 'credit_card' | 'bank_transfer' | 'paypal' | 'invoice' {
|
||||
if (action.payload?.pmId) return 'credit_card';
|
||||
// Add logic to determine other payment methods based on your data structure
|
||||
return 'credit_card'; // Default to credit card
|
||||
}
|
||||
|
||||
private trackSubscriptionPurchase(subscriptions: StripeSubscription[], action: any): void {
|
||||
try {
|
||||
// Get the primary subscription (usually the first one)
|
||||
const primarySub = subscriptions?.[0];
|
||||
if (!primarySub) return;
|
||||
|
||||
const user = this.authSvc.user;
|
||||
if (!user) return;
|
||||
|
||||
// Get subscription details using the centralized analytics helpers service
|
||||
const subscriptionType = this.gaHelpers.getSubscriptionType(primarySub);
|
||||
const subscriptionTier = this.gaHelpers.getSubscriptionTier(primarySub);
|
||||
const serviceType = this.gaHelpers.getServiceType(primarySub);
|
||||
|
||||
// Check for trial status
|
||||
const isTrial = primarySub.status === 'trialing' ||
|
||||
(primarySub.trial_end && new Date() < new Date(primarySub.trial_end * 1000));
|
||||
|
||||
// Extract pricing info from action payload or subscription metadata
|
||||
const packageInfo = action.payload?.package;
|
||||
const subscriptionPrice = packageInfo?.amount ? packageInfo.amount / 100 : 0;
|
||||
const interval = packageInfo?.interval || 'month';
|
||||
|
||||
// Track addon purchases as placeholder
|
||||
if (serviceType === SERVICE_TYPE.ADDON) {
|
||||
// TODO: Implement addon tracking event when requirements are defined
|
||||
return;
|
||||
}
|
||||
|
||||
this.ga.trackSubscriptionPurchased({
|
||||
subscription_type: subscriptionType, // e.g., "AgMission Essentials 1"
|
||||
subscription_duration: interval === 'year' ? 'annual' : 'monthly',
|
||||
subscription_price: subscriptionPrice,
|
||||
previous_subscription_type: action.payload?.previousTier || 'none',
|
||||
payment_method: this.getPaymentMethod(action),
|
||||
billing_frequency: interval === 'year' ? 'annual' : 'monthly',
|
||||
promo_code: action.payload?.coupon?.code,
|
||||
discount_amount: action.payload?.coupon?.amount_off ? action.payload.coupon.amount_off / 100 : 0,
|
||||
subscription_start_date: new Date().toISOString(),
|
||||
auto_renewal: !primarySub.cancel_at_period_end,
|
||||
upgrade_from: action.payload?.previousTier,
|
||||
upgrade_to: subscriptionType, // Use the actual subscription type name
|
||||
trial_conversion: this.gaHelpers.isTrialConversion(primarySub),
|
||||
subscription_value: this.gaHelpers.calculateAnnualValue({ amount: packageInfo?.amount, interval }),
|
||||
user_tenure_days: this.gaHelpers.calculateUserTenure(user),
|
||||
user_id: user._id,
|
||||
user_role: user.roles?.[0] || 'user',
|
||||
subscription_tier: subscriptionTier, // e.g., "1", "2", "3", "4", "5"
|
||||
platform: 'web',
|
||||
service_type: serviceType as 'essential' | 'enterprise' | 'addon', // "essential", "enterprise", or "addon"
|
||||
is_trial: isTrial
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Failed to track subscription purchase:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll subscription status until it becomes 'active' or timeout (r944 requirement)
|
||||
*
|
||||
* After 3DS completion:
|
||||
* - PaymentIntent status becomes 'succeeded' immediately
|
||||
* - Subscription status stays 'incomplete' for 1-3 seconds
|
||||
* - Stripe charges card in background
|
||||
* - This method waits for subscription to become 'active'
|
||||
*
|
||||
* @param subscriptionId Stripe subscription ID (sub_xxxxx)
|
||||
* @param maxAttempts Maximum polling attempts (default 10 = 5 seconds)
|
||||
* @param intervalMs Delay between attempts in milliseconds (default 500ms)
|
||||
* @returns Observable with final subscription status or error
|
||||
*/
|
||||
private pollSubscriptionStatus(
|
||||
subscriptionId: string,
|
||||
maxAttempts: number = 10,
|
||||
intervalMs: number = 500
|
||||
): Observable<any> {
|
||||
let attempts = 0;
|
||||
|
||||
return interval(intervalMs).pipe(
|
||||
startWith(0), // Start immediately (no initial delay)
|
||||
|
||||
switchMap(() => {
|
||||
attempts++;
|
||||
|
||||
return this.subSvc.checkSubscriptionStatus(subscriptionId).pipe(
|
||||
map(response => ({
|
||||
subscription: response,
|
||||
attempts,
|
||||
error: null
|
||||
})),
|
||||
catchError(err => {
|
||||
console.error(`❌ Polling error on attempt ${attempts}:`, err);
|
||||
return of({
|
||||
subscription: null,
|
||||
attempts,
|
||||
error: err
|
||||
});
|
||||
})
|
||||
);
|
||||
}),
|
||||
|
||||
// Evaluation logic - when to stop polling
|
||||
tap(({ subscription, attempts, error }: any) => {
|
||||
if (error) {
|
||||
console.error(`❌ Status check failed:`, error);
|
||||
}
|
||||
}),
|
||||
|
||||
// Stop conditions
|
||||
takeWhile(({ subscription, attempts, error }: any) => {
|
||||
// Stop if subscription is active (SUCCESS)
|
||||
if (subscription?.status === 'active') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Stop if subscription failed or canceled
|
||||
if (subscription?.status === 'incomplete_expired' ||
|
||||
subscription?.status === 'canceled') {
|
||||
console.error(`❌ Subscription ${subscription.status} during polling`);
|
||||
throw new Error(`Subscription ${subscription.status} - cannot proceed`);
|
||||
}
|
||||
|
||||
// Stop if max attempts reached (TIMEOUT)
|
||||
if (attempts >= maxAttempts) {
|
||||
console.error(`❌ Polling timeout after ${attempts} attempts (${attempts * intervalMs}ms)`);
|
||||
throw new Error(
|
||||
`Subscription did not activate within ${maxAttempts * intervalMs / 1000} seconds. ` +
|
||||
`Please check your subscription status in Stripe Dashboard.`
|
||||
);
|
||||
}
|
||||
|
||||
// Stop if API error occurred
|
||||
if (error) {
|
||||
throw new Error(`Status check failed: ${error.message || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
// Continue polling for incomplete or past_due
|
||||
return true;
|
||||
}, true), // inclusive: true - emit the final value before completing
|
||||
|
||||
// Extract subscription from result
|
||||
map(({ subscription }) => subscription),
|
||||
|
||||
// Take only the first successful result (when active) or error
|
||||
take(1),
|
||||
|
||||
// Error handling
|
||||
catchError(err => {
|
||||
console.error(`❌ Polling failed:`, err);
|
||||
return throwError(err);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import { AutoCompleteModule } from 'primeng/autocomplete';
|
||||
import { InputSwitchModule } from 'primeng/inputswitch';
|
||||
import { SplitButtonModule } from 'primeng/splitbutton';
|
||||
import { TableModule } from 'primeng/table';
|
||||
import { MessagesModule } from 'primeng/messages';
|
||||
|
||||
import { StoreModule } from '@ngrx/store';
|
||||
import { EffectsModule } from '@ngrx/effects';
|
||||
@ -16,6 +17,7 @@ import { VehicleEffects } from './effects/vehicle.effects';
|
||||
import { reducers, FEATURE_KEY } from './reducers';
|
||||
|
||||
import { AppSharedModule } from '../shared/app-shared.module';
|
||||
import { PopupTooltipModule } from '../shared/popup-tooltip/popup-tooltip.module';
|
||||
import { EntitiesRoutingModule } from './entities-routing.module';
|
||||
|
||||
import { EntitiesMgtComponent } from './entities-mgt.component';
|
||||
@ -26,6 +28,7 @@ import { PilotEditComponent } from './pilot/pilot-edit/pilot-edit.component';
|
||||
import { PilotService } from '../domain/services/pilot.service';
|
||||
import { PilotResolver } from './pilot-resolver.service';
|
||||
import { VehicleEditComponent } from './vehicle/vehicle-edit/vehicle-edit.component';
|
||||
import { VehiclePartnerIntegrationComponent } from './vehicle/vehicle-partner-integration/vehicle-partner-integration.component';
|
||||
import { VehicleResolver } from './vehicle-resolver.service';
|
||||
import { VehicleService } from '../domain/services/vehicle.service';
|
||||
import { CropEffects } from './effects/crop.effects';
|
||||
@ -35,6 +38,7 @@ import { CropListComponent } from './crop/crop-list/crop-list.component';
|
||||
@NgModule({
|
||||
imports: [
|
||||
AppSharedModule,
|
||||
PopupTooltipModule,
|
||||
DialogModule,
|
||||
ConfirmDialogModule,
|
||||
CheckboxModule,
|
||||
@ -42,12 +46,13 @@ import { CropListComponent } from './crop/crop-list/crop-list.component';
|
||||
InputSwitchModule,
|
||||
SplitButtonModule,
|
||||
TableModule,
|
||||
MessagesModule,
|
||||
|
||||
StoreModule.forFeature(FEATURE_KEY, reducers),
|
||||
EffectsModule.forFeature([PilotEffects, ProductEffects, VehicleEffects, CropEffects]),
|
||||
EntitiesRoutingModule
|
||||
],
|
||||
declarations: [EntitiesMgtComponent, ProductListComponent, PilotListComponent, VehicleListComponent, PilotEditComponent, VehicleEditComponent, CropListComponent],
|
||||
declarations: [EntitiesMgtComponent, ProductListComponent, PilotListComponent, VehicleListComponent, PilotEditComponent, VehicleEditComponent, VehiclePartnerIntegrationComponent, CropListComponent],
|
||||
providers: [PilotService, PilotResolver, VehicleService, CropService, VehicleResolver],
|
||||
schemas: [
|
||||
CUSTOM_ELEMENTS_SCHEMA
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { createNewUser, User } from '@app/accounts/models/user.model';
|
||||
import { RoleIds } from '@app/shared/global';
|
||||
import { RoleIds, SourceSystemType, OperationalStatusType, SystemOrPartnerType } from '@app/shared/global';
|
||||
|
||||
export interface Vehicle extends User {
|
||||
vehicleType: number;
|
||||
tailNumber?: string; // Common tail number field for all aircraft
|
||||
unitId?: string;
|
||||
orgUnitId?: string; // used for unique validation at clientSide only
|
||||
model?: string;
|
||||
@ -12,17 +13,82 @@ export interface Vehicle extends User {
|
||||
trackonDate?: Date;
|
||||
pkgActive?: boolean;
|
||||
pkgActiveDate?: Date;
|
||||
|
||||
// Partner integration properties (legacy - for frontend compatibility)
|
||||
partnerSystem?: SourceSystemType; // System identifier
|
||||
partnerAircraftId?: string; // Partner system's aircraft ID
|
||||
partnerAircraftData?: PartnerAircraftData;
|
||||
|
||||
// Backend-compatible partner info structure (matches backend schema)
|
||||
partnerInfo?: {
|
||||
partner?: string; // Partner ObjectId reference
|
||||
partnerAircraftId?: string; // Partner aircraft/vehicle ID in external system
|
||||
systemType?: string; // System type for agnav native systems (platinum, titanium, g4, etc.)
|
||||
// NEW: Direct partner identification fields (from assignments_post response)
|
||||
name?: string; // Partner display name (e.g., "satloc")
|
||||
partnerCode?: string; // Partner code identifier (e.g., "SATLOC") - top-level for assignments_post
|
||||
metadata?: {
|
||||
partnerSystem?: string; // Partner system name
|
||||
partnerCode?: string; // Partner code identifier (legacy nested location)
|
||||
aircraftData?: any; // Aircraft data from partner system
|
||||
syncStatus?: OperationalStatusType;
|
||||
lastSync?: string | null; // ISO date string
|
||||
connectionStatus?: OperationalStatusType;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Aircraft Assignment Item interface for job assignment pickList
|
||||
export interface AircraftAssignmentItem {
|
||||
_id: string;
|
||||
name: string;
|
||||
username?: string; // Aircraft account username for AgNav aircraft
|
||||
active: boolean;
|
||||
pkgActive?: boolean;
|
||||
tailNumber?: string;
|
||||
// Partner system information derived from partnerInfo
|
||||
partnerSystem?: SystemOrPartnerType;
|
||||
sourceSystem?: SystemOrPartnerType; // System identifier for UI display and sorting
|
||||
// Partner information
|
||||
partnerId?: string; // Partner ID from partnerInfo.partner
|
||||
partnerName?: string; // Partner name resolved from partner service
|
||||
// Partner authentication validation state
|
||||
authValidation?: {
|
||||
isValidating: boolean;
|
||||
authenticationValid: boolean;
|
||||
accountExists: boolean;
|
||||
validationError: string | null;
|
||||
canMoveToTarget: boolean;
|
||||
};
|
||||
partnerCode?: string; // Partner code for display
|
||||
satlocData?: {
|
||||
satlocId?: string;
|
||||
tailNumber: string;
|
||||
aircraftType?: string;
|
||||
lastSync?: Date;
|
||||
syncStatus: OperationalStatusType;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PartnerAircraftData {
|
||||
id: string;
|
||||
tailNumber: string;
|
||||
partnerSystem: string;
|
||||
syncStatus?: OperationalStatusType;
|
||||
lastSync?: Date;
|
||||
connectionStatus?: OperationalStatusType;
|
||||
}
|
||||
|
||||
export interface StatusChange {
|
||||
ids: { [i: string]: string[] };
|
||||
type: string;
|
||||
deActivate?: {[i: string]: boolean};
|
||||
deActivate?: { [i: string]: boolean };
|
||||
}
|
||||
|
||||
export const createNewVehicle = (parentId: string) => {
|
||||
const vehicle = <Vehicle>createNewUser(parentId, RoleIds.DEVICE);
|
||||
vehicle.vehicleType = 0;
|
||||
vehicle.tailNumber = '';
|
||||
|
||||
return vehicle;
|
||||
}
|
||||
|
||||
@ -0,0 +1,155 @@
|
||||
/* Partner Integration Styles */
|
||||
|
||||
.partner-aircraft-section {
|
||||
padding: 12px;
|
||||
border: 1px solid #bdbdbd;
|
||||
/* dividerColor - AgMission borders */
|
||||
border-radius: 4px;
|
||||
background-color: #ffffff;
|
||||
/* contentBgColor - AgMission content background */
|
||||
}
|
||||
|
||||
.partner-aircraft-section h4 {
|
||||
color: #212121;
|
||||
/* textColor - AgMission primary text */
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Tail Number Constraint Message Styling */
|
||||
.md-inputfield+agm-constraint-message {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #03A9F4;
|
||||
/* blue - AgMission info color */
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.loading-indicator i {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.aircraft-id {
|
||||
color: #757575;
|
||||
/* textSecondaryColor - AgMission secondary text */
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.no-aircraft-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
background-color: #E1F5FE;
|
||||
/* Light blue background for info messages */
|
||||
border: 1px solid #03A9F4;
|
||||
/* blue - AgMission info border */
|
||||
border-radius: 4px;
|
||||
color: #0277BD;
|
||||
/* blueHover - AgMission darker blue for text */
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.no-aircraft-message i {
|
||||
margin-right: 6px;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.selected-aircraft-info {
|
||||
padding: 12px;
|
||||
background-color: #ffffff;
|
||||
/* contentBgColor - AgMission content background */
|
||||
border: 1px solid #bdbdbd;
|
||||
/* dividerColor - AgMission borders */
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.selected-aircraft-info h5 {
|
||||
color: #212121;
|
||||
/* textColor - AgMission primary text */
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.status-ready {
|
||||
background-color: #E8F5E8;
|
||||
/* Light green background */
|
||||
color: #2E7D32;
|
||||
/* primaryDarkColor - AgMission dark green */
|
||||
border: 1px solid #4CAF50;
|
||||
/* primaryColor - AgMission main green */
|
||||
}
|
||||
|
||||
.status-error {
|
||||
background-color: #FFEBEE;
|
||||
/* Light red background */
|
||||
color: #C62828;
|
||||
/* redHover - AgMission dark red */
|
||||
border: 1px solid #F44336;
|
||||
/* red - AgMission error color */
|
||||
}
|
||||
|
||||
.status-loading {
|
||||
background-color: #FFF8E1;
|
||||
/* Light amber background */
|
||||
color: #FF8F00;
|
||||
/* amberHover - AgMission dark amber */
|
||||
border: 1px solid #FFC107;
|
||||
/* amber - AgMission warning color */
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
/* Input with inline constraint message */
|
||||
.input-with-inline-constraint {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
/* AgMission standard spacing */
|
||||
}
|
||||
|
||||
.input-with-inline-constraint .md-inputfield {
|
||||
flex: 1;
|
||||
/* Input takes remaining space */
|
||||
}
|
||||
|
||||
/* Inline constraint beside input - vertically aligned with input field center */
|
||||
.input-with-inline-constraint .inline-constraint {
|
||||
margin-top: -2px;
|
||||
/* Shift icon upward to align with input box vertical center */
|
||||
}
|
||||
|
||||
.input-with-inline-constraint .inline-constraint ::ng-deep .agm-constraint-wrapper {
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.partner-aircraft-section {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
/* Stack input and icon vertically on very small screens if needed */
|
||||
.input-with-inline-constraint {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
@ -6,8 +6,10 @@
|
||||
<div class="ui-g ui-g-nopad" style="margin-top:40px">
|
||||
<div class="ui-g-12 ui-lg-6 form-row">
|
||||
<span class="md-inputfield">
|
||||
<input type="text" id="vehicleName" name="vehicleName" #name="ngModel" required [(ngModel)]="selectedItem.name" #vehicleName pInputText maxlength="100">
|
||||
<span i18n="@@aircraftNameReqVal" *ngIf="!name?.valid" class="ui-message ui-messages-error ui-corner-all">Aircraft Name is required</span>
|
||||
<input type="text" id="vehicleName" name="vehicleName" #name="ngModel" required
|
||||
[(ngModel)]="selectedItem.name" #vehicleName pInputText maxlength="100">
|
||||
<span i18n="@@aircraftNameReqVal" *ngIf="!name?.valid"
|
||||
class="ui-message ui-messages-error ui-corner-all">Aircraft Name is required</span>
|
||||
<label i18n="@@name">Name</label>
|
||||
</span>
|
||||
</div>
|
||||
@ -15,7 +17,8 @@
|
||||
<span style="margin-right:12px">
|
||||
<ng-container i18n="@@aircraftType">Aircraft Type</ng-container>:
|
||||
</span>
|
||||
<p-dropdown name="type" [options]="acTypes" [(ngModel)]="selectedItem.vehicleType" [style]="{'width': '120px'}">
|
||||
<p-dropdown name="type" [options]="acTypes" [(ngModel)]="selectedItem.vehicleType"
|
||||
[style]="{'width': '120px'}">
|
||||
<ng-template let-type pTemplate="item">
|
||||
<span>
|
||||
<strong>{{ type.label }}</strong>
|
||||
@ -23,6 +26,12 @@
|
||||
</ng-template>
|
||||
</p-dropdown>
|
||||
</div>
|
||||
<div class="ui-g-12 form-row">
|
||||
<agm-vehicle-partner-integration #partnerIntegration [vehicle]="selectedItem" [isNew]="isNew"
|
||||
[getAccountEditorData]="getAccountEditorData.bind(this)" (partnerDataChange)="onPartnerDataChange($event)"
|
||||
(validationStateChange)="onPartnerValidationStateChange($event)">
|
||||
</agm-vehicle-partner-integration>
|
||||
</div>
|
||||
<div class="ui-g-12 ui-lg-6 form-row">
|
||||
<span class="md-inputfield">
|
||||
<input type="text" id="model" name="model" [(ngModel)]="selectedItem.model" pInputText maxlength="200">
|
||||
@ -31,11 +40,38 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="ui-g-12 ui-lg-6 form-row">
|
||||
<div class="input-with-inline-constraint">
|
||||
<span class="md-inputfield">
|
||||
<input type="text" id="tailNumber" name="tailNumber" [(ngModel)]="selectedItem.tailNumber" pInputText
|
||||
maxlength="20" [disabled]="isPartnerSystemSelected && canEditPartnerFields">
|
||||
<span></span>
|
||||
<label i18n="@@tailNumber">Tail Number</label>
|
||||
</span>
|
||||
|
||||
<!-- Inline icon trigger (detached mode) -->
|
||||
<agm-constraint-message #tailNumberConstraint *ngIf="isPartnerSystemSelected && canEditPartnerFields"
|
||||
[collapsible]="true" [detached]="true" [message]="Labels.TAIL_NUMBER_PARTNER_MANAGED_MESSAGE"
|
||||
[title]="Labels.PARTNER_SYSTEM_MANAGED_TITLE" severity="info" icon="pi-info-circle"
|
||||
class="inline-constraint">
|
||||
</agm-constraint-message>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detached message content (appears below tail number on right side) -->
|
||||
<div class="ui-g-12 ui-lg-6 ui-lg-offset-6" id="tail-number-message-container">
|
||||
<ng-container *ngTemplateOutlet="tailNumberConstraint?.detachedContentTemplate"></ng-container>
|
||||
</div>
|
||||
|
||||
<div class="ui-g-12 ui-lg-6 form-row" *ngIf="!isPartnerSystemSelected">
|
||||
<span class="md-inputfield">
|
||||
<input type="hidden" name="orgUnitId" [ngModel]="orgUnitId">
|
||||
<input autocomplete="off" type="text" id="unitId" name="unitId" #unitId="ngModel" [(ngModel)]="selectedItem.unitId" pInputText maxlength="15" minlength="10" agmUnitIdUnique pKeyFilter="pint" [disabled]="!hasTracking">
|
||||
<span i18n="@@minLenUnitIdMsg" *ngIf="(unitId.dirty || unitId.touched) && unitId.errors?.minlength" class="ui-message ui-messages-error ui-corner-all">UnitId must be 10-15 digits</span>
|
||||
<span *ngIf="(unitId.dirty || unitId.touched) && unitId.errors?.unitIdUnique" class="ui-message ui-messages-error ui-corner-all">
|
||||
<input autocomplete="off" type="text" id="unitId" name="unitId" #unitId="ngModel"
|
||||
[(ngModel)]="selectedItem.unitId" pInputText maxlength="15" minlength="10" agmUnitIdUnique
|
||||
pKeyFilter="pint" [disabled]="!hasTracking">
|
||||
<span i18n="@@minLenUnitIdMsg" *ngIf="(unitId.dirty || unitId.touched) && unitId.errors?.minlength"
|
||||
class="ui-message ui-messages-error ui-corner-all">UnitId must be 10-15 digits</span>
|
||||
<span *ngIf="(unitId.dirty || unitId.touched) && unitId.errors?.unitIdUnique"
|
||||
class="ui-message ui-messages-error ui-corner-all">
|
||||
{{ globals.apiErrorMsg(unitId.errors?.unitIdUnique) }}
|
||||
</span>
|
||||
<label i18n="@@unitId">UnitId</label>
|
||||
@ -53,7 +89,8 @@
|
||||
<span style="margin-right:12px">
|
||||
<ng-container i18n="@@color">Color</ng-container>:
|
||||
</span>
|
||||
<p-dropdown id="color" name="color" [style]="{'width':'120px'}" [options]="acColors" [(ngModel)]="selectedItem.color">
|
||||
<p-dropdown id="color" name="color" [style]="{'width':'120px'}" [options]="acColors"
|
||||
[(ngModel)]="selectedItem.color">
|
||||
<ng-template let-item pTemplate="selectedItem">
|
||||
<div class="color-box" [ngStyle]="{ 'background-color': item.value }"></div>
|
||||
<span style="vertical-align:middle; margin-left: .5em">{{item.label}}</span>
|
||||
@ -66,16 +103,60 @@
|
||||
</ng-template>
|
||||
</p-dropdown>
|
||||
</div>
|
||||
<div class="ui-g-12" style="padding-top: 0">
|
||||
<agm-account-editor #account [isNew]="isNew" [account]="selectedItem" [showActive]="true" [isAircraftAccount]="true" [canActivateVehicle]="canActivateVehicle" i18n-title="@@accessAccInPt" title="Access Account in Guia Platinum">
|
||||
<!-- AgMission Native Account Editor (hidden for partner systems) -->
|
||||
<div class="ui-g-12" *ngIf="!isPartnerSystemSelected" style="padding-top: 0;">
|
||||
<agm-account-editor #account [isNew]="isNew" [account]="selectedItem" [showActive]="true"
|
||||
[isAircraftAccount]="true" [canActivateVehicle]="canActivateVehicle" i18n-title="@@accessAccInPt"
|
||||
title="Access Account in Guia Platinum" [showAccountConstraint]="isAccountIncomplete()"
|
||||
[accountConstraintMessage]="Labels.ACCOUNT_INCOMPLETE_MESSAGE"
|
||||
[accountConstraintTitle]="Labels.ACCOUNT_INCOMPLETE_TITLE">
|
||||
</agm-account-editor>
|
||||
</div>
|
||||
|
||||
<!-- Partner Vehicle Activation Section (edit mode only) -->
|
||||
<div class="ui-g-12" *ngIf="isPartnerSystemSelected && !isNew && canActivateVehicle"
|
||||
style="padding-top: 12px;">
|
||||
<fieldset>
|
||||
<legend>{{ Labels.VEHICLE_ACTIVATION }}</legend>
|
||||
<div class="ui-g-12">
|
||||
<p-checkbox id="partnerVehicleActive" name="active" [label]="Labels.ACTIVE_STATUS"
|
||||
[(ngModel)]="selectedItem.active" [disabled]="!canActivatePartnerVehicle()" binary="true">
|
||||
</p-checkbox>
|
||||
</div>
|
||||
|
||||
<!-- Partner Activation Constraint Message -->
|
||||
<div class="ui-g-12" *ngIf="!canActivatePartnerVehicle()" style="margin-top: 8px;">
|
||||
<agm-constraint-message [message]="getPartnerActivationConstraintMessage()"
|
||||
[title]="Labels.CONSTRAINT_INFO_TITLE" severity="info" icon="pi-info-circle">
|
||||
</agm-constraint-message>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<!-- Account Completion Reminder (detached message stays here, only for native vehicles) -->
|
||||
<div class="ui-g-12" *ngIf="!isPartnerSystemSelected && isAccountIncomplete()">
|
||||
<ng-container *ngTemplateOutlet="accEditor?.accountConstraint?.detachedContentTemplate"></ng-container>
|
||||
</div>
|
||||
|
||||
<!-- Partner Integration Constraint Messages -->
|
||||
<div class="ui-g-12" *ngIf="getPartnerConstraintDetails() as constraintDetails">
|
||||
<agm-constraint-message [collapsible]="getConstraintSeverity(constraintDetails) === 'info'"
|
||||
[message]="constraintDetails.message" [title]="constraintDetails.title"
|
||||
[severity]="getConstraintSeverity(constraintDetails)" [icon]="getConstraintIcon(constraintDetails)">
|
||||
</agm-constraint-message>
|
||||
</div>
|
||||
|
||||
<div class="ui-g-12 toolbar padtop1">
|
||||
<button pButton *ngIf="isNew; else editTpl" [disabled]="!f.valid || !account.valid" type="button" style="width:auto" icon="ui-icon-plus" i18n-label="@@create" label="Create" (click)="saveVehicle(); false"></button>
|
||||
<button pButton *ngIf="isNew; else editTpl"
|
||||
[disabled]="!f.valid || !isAccountValid || !partnerValidationState" type="button" style="width:auto"
|
||||
icon="ui-icon-plus" i18n-label="@@create" label="Create" (click)="saveVehicle(); false"></button>
|
||||
<ng-template #editTpl>
|
||||
<button class="blue-btn" pButton type="button" style="width:auto" [disabled]="!f.valid || !account.valid" icon="ui-icon-save" i18n-label="@@save" label="Save" (click)="saveVehicle(); false"></button>
|
||||
<button class="blue-btn" pButton type="button" style="width:auto"
|
||||
[disabled]="!f.valid || !isAccountValid || !partnerValidationState" icon="ui-icon-save"
|
||||
i18n-label="@@save" label="Save" (click)="saveVehicle(); false"></button>
|
||||
</ng-template>
|
||||
<button pButton type="button" style="width:auto" class="amber-btn" icon="ui-icon-arrow-back" (click)="goBack()" i18n-label="@@back" label="Back"></button>
|
||||
<button pButton type="button" style="width:auto" class="amber-btn" icon="ui-icon-arrow-back"
|
||||
(click)="goBack()" i18n-label="@@back" label="Back"></button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@ -1,59 +1,115 @@
|
||||
import { Component, OnInit, ViewChild, ElementRef, AfterViewInit, OnDestroy } from '@angular/core';
|
||||
import { Component, OnInit, ViewChild, ElementRef, AfterViewInit, OnDestroy, ChangeDetectorRef } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { SelectItem } from 'primeng/api';
|
||||
|
||||
import { Vehicle } from '../../models/vehicle.model';
|
||||
import * as vehicleActions from '../../actions/vehicle.actions';
|
||||
|
||||
import { StringUtils } from '@app/shared/utils';
|
||||
import { AccountEditorComponent } from '@app/shared/account-editor/account-editor.component';
|
||||
import { globals, VehType, vehTypes } from '@app/shared/global';
|
||||
import { SelectItem } from 'primeng/api';
|
||||
import { ConstraintMessageComponent } from '@app/shared/constraint-message/constraint-message.component';
|
||||
import { globals, VehType, vehTypes, SystemTypes, SourceSystem, OperationalStatus, Labels } from '@app/shared/global';
|
||||
import { BaseComp } from '@app/shared/base/base.component';
|
||||
import { selectLimit } from '@app/reducers';
|
||||
import { Limit } from '@app/domain/models/subscription.model';
|
||||
import { SubKeys, SubType } from '@app/profile/common';
|
||||
import { PartnerIntegrationData, VehiclePartnerIntegrationComponent } from '../vehicle-partner-integration/vehicle-partner-integration.component';
|
||||
|
||||
// ============================================================================
|
||||
// COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
@Component({
|
||||
selector: 'agm-vehicle-edit',
|
||||
templateUrl: './vehicle-edit.component.html',
|
||||
styles: []
|
||||
styleUrls: ['./vehicle-edit.component.css']
|
||||
})
|
||||
export class VehicleEditComponent extends BaseComp implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
// ============================================================================
|
||||
// CONSTANTS & READONLY PROPERTIES
|
||||
// ============================================================================
|
||||
|
||||
readonly globals = globals;
|
||||
readonly SourceSystem = SourceSystem;
|
||||
readonly Labels = Labels;
|
||||
|
||||
// ============================================================================
|
||||
// CORE VEHICLE PROPERTIES
|
||||
// ============================================================================
|
||||
|
||||
selectedItem: Vehicle;
|
||||
orgUnitId: string;
|
||||
|
||||
// Core vehicle form options
|
||||
acTypes: SelectItem[];
|
||||
acColors: SelectItem[];
|
||||
|
||||
// Partner integration state (managed by child component)
|
||||
private partnerData: PartnerIntegrationData | null = null;
|
||||
partnerValidationState: boolean = true; // Default to valid for basic aircraft
|
||||
|
||||
// Return message handling from account-edit
|
||||
connectionTestMessage: string | null = null;
|
||||
connectionTestSuccess: boolean | null = null;
|
||||
pendingAuthenticationSuccess: boolean = false; // Flag to update partner auth state after ViewInit
|
||||
|
||||
// ============================================================================
|
||||
// VIEW CHILDREN & UI STATE
|
||||
// ============================================================================
|
||||
|
||||
@ViewChild('vehicleName') vehicleName: ElementRef;
|
||||
@ViewChild('account') accEditor: AccountEditorComponent;
|
||||
|
||||
@ViewChild('partnerIntegration') partnerIntegration: VehiclePartnerIntegrationComponent;
|
||||
@ViewChild('tailNumberConstraint') tailNumberConstraint: ConstraintMessageComponent;
|
||||
hasTracking: boolean;
|
||||
|
||||
private _vehicle: Vehicle;
|
||||
get vehicle(): Vehicle { return this._vehicle; }
|
||||
set vehicle(vehicle: Vehicle) {
|
||||
this._vehicle = vehicle;
|
||||
this.selectedItem = Object.assign({}, vehicle); // create a clone object to work on the editor
|
||||
// ============================================================================
|
||||
// VEHICLE MANAGEMENT PROPERTIES
|
||||
// ============================================================================
|
||||
|
||||
if (!this.isNew && this.selectedItem.unitId)
|
||||
this.orgUnitId = this.selectedItem.unitId;
|
||||
private _vehicle: Vehicle;
|
||||
private _isNew: boolean;
|
||||
|
||||
get vehicle(): Vehicle {
|
||||
return this._vehicle;
|
||||
}
|
||||
|
||||
set vehicle(vehicle: Vehicle) {
|
||||
this._vehicle = vehicle;
|
||||
this.selectedItem = Object.assign({}, vehicle);
|
||||
|
||||
// For new vehicles, ensure active defaults to true
|
||||
// Check vehicle._id directly since _isNew is set later
|
||||
if (vehicle._id === '0') {
|
||||
// For new vehicles, active should always default to true
|
||||
this.selectedItem.active = true;
|
||||
}
|
||||
|
||||
if (!this.isNew && this.selectedItem.unitId) {
|
||||
this.orgUnitId = this.selectedItem.unitId;
|
||||
}
|
||||
}
|
||||
|
||||
private _isNew: boolean;
|
||||
get isNew(): boolean {
|
||||
return this._isNew;
|
||||
}
|
||||
|
||||
get user() {
|
||||
return this.selectedItem.username ? ({ username: this.selectedItem.username, password: this.selectedItem.password }) : null;
|
||||
return this.selectedItem.username ?
|
||||
{ username: this.selectedItem.username, password: this.selectedItem.password } :
|
||||
null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CONSTRUCTOR
|
||||
// ============================================================================
|
||||
|
||||
constructor(
|
||||
private readonly route: ActivatedRoute,
|
||||
private readonly cdr: ChangeDetectorRef
|
||||
) {
|
||||
super();
|
||||
|
||||
this.acTypes = [
|
||||
{ label: vehTypes[VehType.FIXEDSWING], value: VehType.FIXEDSWING },
|
||||
{ label: vehTypes[VehType.HELICOPTER], value: VehType.HELICOPTER }
|
||||
@ -65,36 +121,84 @@ export class VehicleEditComponent extends BaseComp implements OnInit, AfterViewI
|
||||
{ label: globals.lime, value: 'lime' },
|
||||
{ label: globals.yellow, value: 'yellow' },
|
||||
{ label: globals.orange, value: 'orange' },
|
||||
{ label: globals.purple, value: 'purple' },
|
||||
{ label: globals.purple, value: 'purple' }
|
||||
];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LIFECYCLE METHODS
|
||||
// ============================================================================
|
||||
|
||||
ngOnInit() {
|
||||
this.sub$ = this.route.data
|
||||
.subscribe((data) => {
|
||||
// Handle query parameters for return navigation messages
|
||||
this.sub$ = this.route.queryParams.subscribe(params => {
|
||||
if (params['connectionTestResult']) {
|
||||
this.connectionTestSuccess = params['connectionTestResult'] === 'success';
|
||||
this.connectionTestMessage = params['message'];
|
||||
|
||||
if (this.connectionTestSuccess) {
|
||||
console.log('Account authentication successful:', this.connectionTestMessage);
|
||||
// Set flag to update partner auth state after ViewInit
|
||||
this.pendingAuthenticationSuccess = true;
|
||||
// Optionally show success message to user
|
||||
if (this.msgSvc) {
|
||||
this.msgSvc.addSuccessMsg(this.connectionTestMessage);
|
||||
}
|
||||
} else {
|
||||
console.error('Account authentication failed:', this.connectionTestMessage);
|
||||
// Show error message to user
|
||||
if (this.msgSvc) {
|
||||
this.msgSvc.addFailedMsg(this.connectionTestMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear query parameters to prevent message from showing again
|
||||
// Wait longer (15 seconds) to allow partner aircraft API call to complete
|
||||
// Navigation during API call can cancel the HTTP request
|
||||
setTimeout(() => {
|
||||
this.router.navigate([], {
|
||||
relativeTo: this.route,
|
||||
queryParams: {},
|
||||
replaceUrl: true
|
||||
});
|
||||
}, 15000); // Wait 15 seconds for API call to complete
|
||||
}
|
||||
});
|
||||
|
||||
// Route data subscription
|
||||
this.sub$.add(this.route.data.subscribe((data) => {
|
||||
const vehicle = data[0] as Vehicle || null;
|
||||
if (vehicle) {
|
||||
this.vehicle = vehicle;
|
||||
this._isNew = (this.vehicle._id === '0');
|
||||
}
|
||||
});
|
||||
this.sub$.add(this.appActions.ofTypes([vehicleActions.CREATE_SUCCESS, vehicleActions.UPDATE_SUCCESS])
|
||||
.subscribe((action) => {
|
||||
this.store.dispatch(new vehicleActions.Select(action['payload']));
|
||||
this.goBack();
|
||||
}));
|
||||
|
||||
this.sub$.add(this.store.select(selectLimit(SubType.ADDON))
|
||||
// Vehicle actions subscription
|
||||
this.sub$.add(
|
||||
this.appActions.ofTypes([vehicleActions.CREATE_SUCCESS, vehicleActions.UPDATE_SUCCESS])
|
||||
.subscribe((action) => {
|
||||
const savedVehicle = action['payload'];
|
||||
this.store.dispatch(new vehicleActions.Select(savedVehicle));
|
||||
this.goBack(savedVehicle);
|
||||
})
|
||||
);
|
||||
|
||||
// Tracking subscription
|
||||
this.sub$.add(
|
||||
this.store.select(selectLimit(SubType.ADDON))
|
||||
.subscribe((addon) => {
|
||||
const tracking: Limit = addon?.[SubKeys.TRACKING];
|
||||
this.hasTracking = tracking?.airCraft?.numOfVehicle > 0;
|
||||
}));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
// Auto-focus vehicle name field for new vehicles
|
||||
const timer = setInterval(() => {
|
||||
if (this.selectedItem && StringUtils.isEmpty(this.selectedItem.name)) {
|
||||
if (this.vehicleName.nativeElement) {
|
||||
if (this.vehicleName && this.vehicleName.nativeElement) {
|
||||
this.vehicleName.nativeElement.focus();
|
||||
clearInterval(timer);
|
||||
}
|
||||
@ -102,9 +206,153 @@ export class VehicleEditComponent extends BaseComp implements OnInit, AfterViewI
|
||||
clearInterval(timer);
|
||||
}
|
||||
}, 500);
|
||||
setTimeout(() => { clearInterval(timer); }, 1500);
|
||||
setTimeout(() => clearInterval(timer), 1500);
|
||||
|
||||
// Handle pending authentication success from query params
|
||||
if (this.pendingAuthenticationSuccess && this.partnerIntegration) {
|
||||
console.log('Applying pending authentication success to partner integration');
|
||||
this.partnerIntegration.updateAuthenticationSuccess();
|
||||
this.pendingAuthenticationSuccess = false;
|
||||
}
|
||||
|
||||
// Check for stored form data from Account Does Not Exist flow and restore it
|
||||
if (this.partnerIntegration) {
|
||||
const storedFormData = this.partnerIntegration.getStoredFormData();
|
||||
if (storedFormData) {
|
||||
console.log('Restoring vehicle form data after account creation:', storedFormData);
|
||||
this.restoreFormData(storedFormData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
super.ngOnDestroy();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PARTNER INTEGRATION EVENT HANDLERS
|
||||
// ============================================================================
|
||||
|
||||
onPartnerDataChange(partnerData: PartnerIntegrationData): void {
|
||||
this.partnerData = partnerData;
|
||||
|
||||
// Update vehicle tail number if partner aircraft is selected
|
||||
if (partnerData.tailNumber) {
|
||||
this.selectedItem.tailNumber = partnerData.tailNumber;
|
||||
}
|
||||
}
|
||||
|
||||
onPartnerValidationStateChange(isValid: boolean): void {
|
||||
this.partnerValidationState = isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get constraint message details when partner validation is invalid
|
||||
*/
|
||||
getPartnerConstraintDetails(): { title: string; message: string } | null {
|
||||
// Only show partner-specific constraint messages
|
||||
// Basic form validation (like aircraft name) is handled by Angular forms
|
||||
|
||||
// Check partner validation state
|
||||
if (!this.partnerValidationState && this.partnerIntegration) {
|
||||
const integration = this.partnerIntegration;
|
||||
|
||||
// If partner system selected, check integration requirements
|
||||
if (integration.isPartnerSystemSelected) {
|
||||
// If partner validation failed
|
||||
if (!integration.partnerValidation.accountExists || !integration.partnerValidation.authenticationValid) {
|
||||
return null; // These are handled by the partner integration component
|
||||
}
|
||||
|
||||
// If no aircraft selected
|
||||
if (!integration.selectedPartnerAircraft) {
|
||||
return {
|
||||
title: this.Labels.AIRCRAFT_SELECTION_REQUIRED_TITLE,
|
||||
message: this.Labels.AIRCRAFT_SELECTION_REQUIRED_MESSAGE
|
||||
};
|
||||
}
|
||||
|
||||
// If Satloc partner and no system type selected
|
||||
if (integration.isSatlocPartnerSelected && !integration.selectedSystemType) {
|
||||
return {
|
||||
title: this.Labels.SYSTEM_TYPE_REQUIRED_TITLE,
|
||||
message: this.Labels.SYSTEM_TYPE_REQUIRED_MESSAGE
|
||||
};
|
||||
}
|
||||
|
||||
// General integration incomplete message
|
||||
return {
|
||||
title: this.Labels.PARTNER_INTEGRATION_INCOMPLETE_TITLE,
|
||||
message: this.Labels.PARTNER_INTEGRATION_INCOMPLETE_MESSAGE
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if account fields are incomplete (username/password missing)
|
||||
*/
|
||||
isAccountIncomplete(): boolean {
|
||||
if (!this.accEditor) {
|
||||
// If no account editor, consider account incomplete
|
||||
return true;
|
||||
}
|
||||
|
||||
const accountValue = this.accEditor.value;
|
||||
if (!accountValue) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasUsername = accountValue?.username && accountValue.username.trim() !== '';
|
||||
const hasPassword = accountValue?.password && accountValue.password.trim() !== '';
|
||||
const isActive = accountValue?.active === true;
|
||||
|
||||
// Account is incomplete if username, password, or active status is missing
|
||||
return !hasUsername || !hasPassword || !isActive;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get appropriate severity for constraint message
|
||||
*/
|
||||
getConstraintSeverity(constraintDetails: { title: string; message: string }): string {
|
||||
// Account incomplete is informational (allows saving)
|
||||
if (constraintDetails.title === this.Labels.ACCOUNT_INCOMPLETE_TITLE) {
|
||||
return 'info';
|
||||
}
|
||||
// All other constraints are warnings (block saving)
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get appropriate icon for constraint message
|
||||
*/
|
||||
getConstraintIcon(constraintDetails: { title: string; message: string }): string {
|
||||
// Account incomplete uses info icon
|
||||
if (constraintDetails.title === this.Labels.ACCOUNT_INCOMPLETE_TITLE) {
|
||||
return 'pi-info-circle';
|
||||
}
|
||||
// All other constraints use warning icon (using ui-icon-warning as pi-exclamation-triangle has rendering issues)
|
||||
return 'ui-icon-warning';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current account editor data for form data preservation
|
||||
* This method is called by the vehicle-partner-integration component
|
||||
* when navigating to account creation
|
||||
*/
|
||||
getAccountEditorData(): any {
|
||||
if (this.accEditor && this.accEditor.valid) {
|
||||
return this.accEditor.value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// VEHICLE SAVE OPERATIONS
|
||||
// ============================================================================
|
||||
|
||||
saveVehicle() {
|
||||
if (this.accEditor) {
|
||||
const acc = this.accEditor.value;
|
||||
@ -112,21 +360,233 @@ export class VehicleEditComponent extends BaseComp implements OnInit, AfterViewI
|
||||
this.selectedItem.password = acc.password;
|
||||
this.selectedItem.active = acc.active;
|
||||
}
|
||||
|
||||
if (this.selectedItem?.tracking && !this.selectedItem?.unitId) {
|
||||
this.selectedItem.tracking = false;
|
||||
}
|
||||
this.store.dispatch(this._isNew ? new vehicleActions.Create(this.selectedItem) : new vehicleActions.Update(this.selectedItem));
|
||||
|
||||
this.preparePartnerDataForBackend();
|
||||
this.store.dispatch(this._isNew ?
|
||||
new vehicleActions.Create(this.selectedItem) :
|
||||
new vehicleActions.Update(this.selectedItem)
|
||||
);
|
||||
}
|
||||
|
||||
goBack() {
|
||||
this.router.navigate(['/entities/aircraft/', { id: this.vehicle._id }]);
|
||||
private preparePartnerDataForBackend(): void {
|
||||
if (this.partnerData && this.partnerData.selectedPartner && this.partnerData.selectedPartner !== SourceSystem.AGNAV && this.partnerData.selectedPartnerData) {
|
||||
this.selectedItem.partnerInfo = {
|
||||
partner: this.partnerData.selectedPartnerData._id!,
|
||||
partnerAircraftId: this.partnerData.selectedPartnerAircraft || null,
|
||||
systemType: this.partnerData.systemType || SystemTypes.PLATINUM, // Include system type from partner integration
|
||||
metadata: {
|
||||
partnerSystem: this.partnerData.selectedPartnerData.name,
|
||||
partnerCode: this.partnerData.selectedPartnerData.partnerCode,
|
||||
aircraftData: this.partnerData.selectedPartnerAircraftDetails,
|
||||
syncStatus: this.partnerData.selectedPartnerAircraftDetails ? OperationalStatus.PENDING : null,
|
||||
lastSync: null,
|
||||
connectionStatus: OperationalStatus.CONNECTED
|
||||
}
|
||||
};
|
||||
|
||||
if (this.partnerData.selectedPartnerAircraftDetails?.tailNumber) {
|
||||
this.selectedItem.tailNumber = this.partnerData.selectedPartnerAircraftDetails.tailNumber;
|
||||
}
|
||||
|
||||
// Clean up legacy properties
|
||||
delete this.selectedItem.partnerSystem;
|
||||
delete this.selectedItem.partnerAircraftId;
|
||||
delete this.selectedItem.partnerAircraftData;
|
||||
} else {
|
||||
this.selectedItem.partnerInfo = {
|
||||
partner: null,
|
||||
partnerAircraftId: null,
|
||||
systemType: this.partnerData?.systemType || SystemTypes.PLATINUM, // Preserve system type even for AgNav
|
||||
metadata: null
|
||||
};
|
||||
|
||||
// Clean up legacy properties
|
||||
delete this.selectedItem.partnerSystem;
|
||||
delete this.selectedItem.partnerAircraftId;
|
||||
delete this.selectedItem.partnerAircraftData;
|
||||
}
|
||||
}
|
||||
|
||||
goBack(savedVehicle?: any) {
|
||||
// Use savedVehicle if provided (from CREATE_SUCCESS), otherwise use this.vehicle
|
||||
const vehicleToCheck = savedVehicle || this.vehicle;
|
||||
|
||||
// If this was a newly created vehicle that was successfully saved, add query param to show tooltip
|
||||
const vehicleSuccessfullySaved = vehicleToCheck?._id && vehicleToCheck._id !== '0';
|
||||
|
||||
if (this.isNew && vehicleSuccessfullySaved) {
|
||||
this.router.navigate(['/entities/aircraft'], {
|
||||
queryParams: { newVehicleCreated: vehicleToCheck._id }
|
||||
});
|
||||
} else {
|
||||
this.router.navigate(['/entities/aircraft']);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FORM DATA RESTORATION HELPERS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Restore vehicle form data after Account Does Not Exist flow
|
||||
* @param formData The stored form data to restore
|
||||
*/
|
||||
private restoreFormData(formData: any): void {
|
||||
if (!formData || !this.selectedItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Restore basic vehicle properties
|
||||
if (formData.name) this.selectedItem.name = formData.name;
|
||||
if (formData.vehicleType !== undefined) this.selectedItem.vehicleType = formData.vehicleType;
|
||||
if (formData.model) this.selectedItem.model = formData.model;
|
||||
if (formData.tailNumber) this.selectedItem.tailNumber = formData.tailNumber;
|
||||
if (formData.unitId) this.selectedItem.unitId = formData.unitId;
|
||||
if (formData.desc) this.selectedItem.desc = formData.desc;
|
||||
if (formData.color) this.selectedItem.color = formData.color;
|
||||
|
||||
// Restore account credentials to vehicle object (for backward compatibility)
|
||||
if (formData.username) this.selectedItem.username = formData.username;
|
||||
if (formData.password) this.selectedItem.password = formData.password;
|
||||
if (formData.active !== undefined) this.selectedItem.active = formData.active;
|
||||
|
||||
// Restore account editor form data if available and component is ready
|
||||
if (formData.accountEditor && this.accEditor) {
|
||||
// Use a timeout to ensure the account editor is fully initialized
|
||||
setTimeout(() => {
|
||||
if (this.accEditor) {
|
||||
this.accEditor.writeValue(formData.accountEditor);
|
||||
console.log('Restored account editor data:', formData.accountEditor);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Trigger change detection to update the UI
|
||||
this.cdr.detectChanges();
|
||||
|
||||
console.log('Successfully restored vehicle form data');
|
||||
} catch (error) {
|
||||
console.error('Error restoring form data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// COMPUTED PROPERTIES
|
||||
// ============================================================================
|
||||
|
||||
get canActivateVehicle() {
|
||||
return this.authSvc.canActivateVehicle;
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
super.ngOnDestroy();
|
||||
get isPartnerSystemSelected(): boolean {
|
||||
return this.partnerData?.selectedPartner !== null && this.partnerData?.selectedPartner !== SourceSystem.AGNAV;
|
||||
}
|
||||
|
||||
get canEditPartnerFields(): boolean {
|
||||
return this.partnerData?.partnerValidation?.accountExists &&
|
||||
this.partnerData?.partnerValidation?.authenticationValid &&
|
||||
!this.partnerData?.partnerValidation?.isValidating;
|
||||
}
|
||||
|
||||
get canEditBasicFields(): boolean {
|
||||
return !this.partnerData?.partnerValidation?.isValidating;
|
||||
}
|
||||
|
||||
get canSaveVehicle(): boolean {
|
||||
if (!this.selectedItem?.name || this.selectedItem.name.trim() === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const basicFieldsValid = this.selectedItem?.name?.trim() &&
|
||||
this.selectedItem?.model &&
|
||||
this.selectedItem?.vehicleType !== undefined;
|
||||
|
||||
// Check account editor validity
|
||||
const accountValid = !this.accEditor || this.accEditor.form?.valid;
|
||||
|
||||
if (!this.isPartnerSystemSelected) {
|
||||
return basicFieldsValid && accountValid;
|
||||
}
|
||||
|
||||
if (!this.partnerData?.partnerValidation?.accountExists || !this.partnerData?.partnerValidation?.authenticationValid) {
|
||||
return basicFieldsValid && accountValid;
|
||||
}
|
||||
|
||||
return basicFieldsValid && accountValid && !!this.partnerData?.selectedPartnerAircraft;
|
||||
}
|
||||
|
||||
get saveButtonTooltip(): string {
|
||||
if (this.isPartnerSystemSelected) {
|
||||
if (!this.partnerData?.partnerValidation?.accountExists) {
|
||||
return Labels.SAVE_TOOLTIP_NO_ACCOUNT;
|
||||
}
|
||||
if (!this.partnerData?.partnerValidation?.authenticationValid) {
|
||||
return Labels.SAVE_TOOLTIP_AUTH_FAILED;
|
||||
}
|
||||
const partnerName = this.partnerData?.selectedPartnerData?.name || Labels.GENERIC_PARTNER;
|
||||
return `${Labels.SAVE_TOOLTIP_BASE_MESSAGE} ${partnerName} ${Labels.SAVE_TOOLTIP_INTEGRATION_SUFFIX}`;
|
||||
}
|
||||
return Labels.SAVE_TOOLTIP_NATIVE;
|
||||
}
|
||||
|
||||
get partnerSystemName(): string {
|
||||
return this.partnerData?.selectedPartnerData?.name || 'partner system';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if account editor is valid
|
||||
* For partner systems, account editor is hidden, so validation is skipped
|
||||
*/
|
||||
get isAccountValid(): boolean {
|
||||
if (this.isPartnerSystemSelected) {
|
||||
return true; // No account editor for partner systems
|
||||
}
|
||||
return !this.accEditor || this.accEditor.form?.valid;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PARTNER VEHICLE ACTIVATION METHODS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Determines if a partner system vehicle can be activated
|
||||
* Requires: partner account exists, authentication valid, and aircraft selected
|
||||
*/
|
||||
canActivatePartnerVehicle(): boolean {
|
||||
if (!this.isPartnerSystemSelected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.partnerData?.partnerValidation?.accountExists === true &&
|
||||
this.partnerData?.partnerValidation?.authenticationValid === true &&
|
||||
!!this.partnerData?.selectedPartnerAircraft;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns constraint message explaining why partner vehicle cannot be activated
|
||||
*/
|
||||
getPartnerActivationConstraintMessage(): string {
|
||||
if (!this.isPartnerSystemSelected) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!this.partnerData?.partnerValidation?.accountExists) {
|
||||
return Labels.PARTNER_ACCOUNT_REQUIRED_FOR_ACTIVATION;
|
||||
}
|
||||
|
||||
if (!this.partnerData?.partnerValidation?.authenticationValid) {
|
||||
return Labels.PARTNER_AUTH_REQUIRED_FOR_ACTIVATION;
|
||||
}
|
||||
|
||||
if (!this.partnerData?.selectedPartnerAircraft) {
|
||||
return Labels.PARTNER_AIRCRAFT_REQUIRED_FOR_ACTIVATION;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,292 @@
|
||||
.highlight-btn{
|
||||
background-color: green;
|
||||
.highlight-btn {
|
||||
background-color: #4CAF50;
|
||||
/* $primaryColor */
|
||||
}
|
||||
|
||||
/* Custom Aircraft Review Message - Enhanced UX Layout */
|
||||
.aircraft-review-container {
|
||||
background-color: #FFF8E1;
|
||||
/* Warning background - AgMission amber light */
|
||||
border: 1px solid #FFC107;
|
||||
/* $amber */
|
||||
border-radius: 6px;
|
||||
margin: 0.75rem 0;
|
||||
padding: 1rem 1.25rem;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
/* Top-align for better text flow */
|
||||
gap: 0.875rem;
|
||||
/* Optimized spacing for visual hierarchy */
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
/* Subtle depth */
|
||||
}
|
||||
|
||||
.aircraft-review-container .pi-info-circle {
|
||||
color: #FF8F00 !important;
|
||||
/* $amberHover - dark warning color for good contrast */
|
||||
font-size: 1.375em !important;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.125rem;
|
||||
/* Optical alignment with text baseline */
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.aircraft-review-message {
|
||||
color: #212121 !important;
|
||||
/* $textColor - high contrast text */
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
line-height: 1.4;
|
||||
/* Optimized line height for readability */
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
/* Remove default margins for precise control */
|
||||
}
|
||||
|
||||
/* Custom Generic Error Message - Enhanced UX Layout */
|
||||
.generic-error-container {
|
||||
background-color: #FFEBEE;
|
||||
/* Error background - AgMission red light */
|
||||
border: 1px solid #F44336;
|
||||
/* $red */
|
||||
border-radius: 6px;
|
||||
margin: 0.75rem 0;
|
||||
padding: 1rem 1.25rem;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
/* Top-align for better text flow */
|
||||
gap: 0.875rem;
|
||||
/* Consistent spacing with warning */
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
/* Subtle depth */
|
||||
}
|
||||
|
||||
.generic-error-container .pi-exclamation-triangle {
|
||||
color: #C62828 !important;
|
||||
/* $redHover - dark error red for good contrast */
|
||||
font-size: 1.375em !important;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.125rem;
|
||||
/* Optical alignment with text baseline */
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.generic-error-message {
|
||||
color: #C62828 !important;
|
||||
/* $redHover - dark error text for contrast */
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
line-height: 1.4;
|
||||
/* Consistent line height */
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
/* Remove default margins for precise control */
|
||||
}
|
||||
|
||||
/* Generic Message Enhanced Styling */
|
||||
:host ::ng-deep generic-message {
|
||||
display: block;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
:host ::ng-deep generic-message .icon-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
/* Button Styling for Aircraft Review */
|
||||
:host ::ng-deep generic-message .amber-btn {
|
||||
background-color: #FFC107;
|
||||
/* $amber */
|
||||
border-color: #FF8F00;
|
||||
/* $amberHover */
|
||||
color: #212121;
|
||||
/* $textColor for good contrast on yellow */
|
||||
font-weight: 600;
|
||||
padding: 0.75rem 1.5rem;
|
||||
min-height: 44px;
|
||||
/* Accessibility: Touch target size */
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
:host ::ng-deep generic-message .amber-btn:hover {
|
||||
background-color: #FF8F00;
|
||||
/* $amberHover */
|
||||
border-color: #f9a825;
|
||||
/* $accentLightColor */
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* System Type Display Styles */
|
||||
.system-type-display {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
min-height: 44px;
|
||||
/* Accessibility: Larger touch targets */
|
||||
}
|
||||
|
||||
/* Responsive Design - Improved Accessibility */
|
||||
@media (max-width: 768px) {
|
||||
.system-type-display {
|
||||
display: inline-flex;
|
||||
/* Keep inline for proper alignment with ui-column-title */
|
||||
gap: 6px;
|
||||
align-items: flex-start;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.partner-code-badge {
|
||||
font-size: 0.8rem;
|
||||
/* Maintain readability on mobile */
|
||||
padding: 3px 6px;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.auth-status-indicator {
|
||||
font-size: 0.8rem;
|
||||
margin-left: 4px;
|
||||
padding: 3px 6px;
|
||||
min-width: 28px;
|
||||
min-height: 32px;
|
||||
/* Maintain touch target on mobile */
|
||||
}
|
||||
|
||||
.auth-status-indicator i {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.system-type-display {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.partner-badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 6px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.partner-code-badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 5px;
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.auth-status-indicator {
|
||||
font-size: 0.75rem;
|
||||
min-width: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Table Column Specific Styles - Enhanced for Accessibility */
|
||||
.ui-table .ui-table-tbody>tr>td .system-type-display {
|
||||
min-width: 140px;
|
||||
/* Increased for better layout */
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
/* Improved Focus Management for Table Cells */
|
||||
:host ::ng-deep .ui-table .ui-table-tbody>tr>td:focus-within {
|
||||
outline: 2px solid #4CAF50;
|
||||
/* $primaryColor for focus */
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* Dark Theme Support - Enhanced Contrast */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.partner-code-badge {
|
||||
background-color: #757575;
|
||||
/* $grayBgColor */
|
||||
color: #ffffff;
|
||||
/* $primaryTextColor */
|
||||
border-color: #bdbdbd;
|
||||
/* $dividerColor */
|
||||
}
|
||||
|
||||
.partner-code-badge:hover,
|
||||
.partner-code-badge:focus {
|
||||
background-color: #616161;
|
||||
/* Darker gray */
|
||||
border-color: #e8e8e8;
|
||||
/* $hoverBgColor */
|
||||
}
|
||||
|
||||
.auth-status-indicator.auth-valid {
|
||||
color: #A5D6A7;
|
||||
/* $primaryLightColor */
|
||||
background-color: rgba(165, 214, 167, 0.2);
|
||||
border-color: rgba(165, 214, 167, 0.4);
|
||||
}
|
||||
|
||||
.auth-status-indicator.auth-invalid {
|
||||
color: #EF5350;
|
||||
/* Lighter red for dark theme */
|
||||
background-color: rgba(239, 83, 80, 0.2);
|
||||
border-color: rgba(239, 83, 80, 0.4);
|
||||
}
|
||||
|
||||
.auth-status-indicator.auth-validating {
|
||||
color: #f9a825;
|
||||
/* $accentLightColor */
|
||||
background-color: rgba(249, 168, 37, 0.2);
|
||||
border-color: rgba(249, 168, 37, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
PACKAGE ACTIVATION TOOLTIP STYLES
|
||||
============================================================================ */
|
||||
|
||||
/* Highlight effect for package checkbox when tooltip is shown */
|
||||
.package-activation-highlight .p-checkbox-box {
|
||||
border: 2px solid #FFC107 !important;
|
||||
/* $amber */
|
||||
box-shadow: 0 0 8px rgba(255, 193, 7, 0.4) !important;
|
||||
/* $amber with transparency */
|
||||
animation: pulseGlow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulseGlow {
|
||||
0% {
|
||||
box-shadow: 0 0 8px rgba(255, 193, 7, 0.4);
|
||||
/* $amber */
|
||||
}
|
||||
|
||||
50% {
|
||||
box-shadow: 0 0 16px rgba(255, 193, 7, 0.6);
|
||||
/* $amber */
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow: 0 0 8px rgba(255, 193, 7, 0.4);
|
||||
/* $amber */
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
AIRCRAFT REVIEW BANNER – NO-CHANGES CONFIRM BUTTON
|
||||
============================================================================ */
|
||||
|
||||
/* Flex column body: stacks message text + button vertically inside the flex row.
|
||||
Takes the flex:1 growth previously on .aircraft-review-message. */
|
||||
.aircraft-review-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
/* Center message text and button horizontally */
|
||||
text-align: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* No custom CSS needed for the confirm button - uses .highlight-btn
|
||||
(already defined at top of this file) with PrimeNG default button layout.
|
||||
This matches the existing toolbar button pattern (ui-icon-* + pButton). */
|
||||
@ -4,7 +4,8 @@
|
||||
<ng-container *ngIf="isCompLoaded(); else err">
|
||||
<ng-container [ngTemplateOutlet]="listSection"></ng-container>
|
||||
<ng-container [ngTemplateOutlet]="btnSection"></ng-container>
|
||||
<review-aircraft [visible]="displayDialog" [messages]="[{text: dialogMsg}]" (reviewEvt)="reviewAC()"></review-aircraft>
|
||||
<review-aircraft [visible]="displayDialog" [messages]="[{text: dialogMsg}]"
|
||||
(reviewEvt)="reviewAC()"></review-aircraft>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
@ -12,30 +13,53 @@
|
||||
|
||||
<ng-template #msgSection>
|
||||
<section>
|
||||
<generic-message [messages]="[{text: status?.message, style: 'error'}]"></generic-message>
|
||||
<!-- Consolidated Message Display with Appropriate Icons -->
|
||||
<div [ngClass]="isAircraftReviewStatus() ? 'aircraft-review-container' : 'generic-error-container'">
|
||||
<i [ngClass]="isAircraftReviewStatus() ? 'pi pi-info-circle' : 'pi pi-exclamation-triangle'"></i>
|
||||
<!-- Wrapper stacks message text + no-changes button vertically inside the flex row -->
|
||||
<div class="aircraft-review-body">
|
||||
<div [ngClass]="isAircraftReviewStatus() ? 'aircraft-review-message' : 'generic-error-message'">
|
||||
{{ status?.message }}
|
||||
</div>
|
||||
<!-- Confirm-and-continue: only shown in review mode when nothing has changed -->
|
||||
<button *ngIf="isAircraftReviewStatus() && !vehiclesChanged" type="button" pButton icon="ui-icon-check"
|
||||
class="highlight-btn" (click)="noChangesToReview()" i18n-label="@@reviewNoChangesBtn"
|
||||
label="All looks good – Go to My Services">
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #listSection>
|
||||
<section>
|
||||
<p-table #dt [columns]="cols" [value]="vehicles" sortField="name" [paginator]="true" [rows]="15" [pageLinks]="5" [rowsPerPageOptions]="null" [alwaysShowPaginator]="false" [responsive]="true" [dataKey]="ID" compareSelectionBy="equals" selectionMode="single" (onRowSelect)="onRowSelect($event)" [resetPageOnSort]="false" (onRowUnselect)="onRowSelect($event)" [(selection)]="currVehicle" stateStorage="session" stateKey="actb-ops" mutable="false">
|
||||
<p-table #dt [columns]="cols" [value]="vehicles" sortField="name" [paginator]="true" [rows]="15" [pageLinks]="5"
|
||||
[rowsPerPageOptions]="null" [alwaysShowPaginator]="false" [responsive]="true" [dataKey]="ID"
|
||||
compareSelectionBy="equals" selectionMode="single" (onRowSelect)="onRowSelect($event)" [resetPageOnSort]="false"
|
||||
(onRowUnselect)="onRowSelect($event)" [(selection)]="currVehicle" stateStorage="session" stateKey="actb-ops"
|
||||
mutable="false">
|
||||
<ng-template pTemplate="caption">
|
||||
<span class="table-caption-1" i18n="@@aircraftList">Aircraft List</span>
|
||||
<ng-container *ngIf="status" [ngTemplateOutlet]="msgSection"></ng-container>
|
||||
</ng-template>
|
||||
<ng-template pTemplate="header" let-columns>
|
||||
<tr>
|
||||
<th *ngFor="let col of columns" [pSortableColumn]="col.field" [width]="col.width">{{ col.header }}<p-sortIcon [field]="col.field"></p-sortIcon></th>
|
||||
<th *ngFor="let col of columns" [pSortableColumn]="col.field" [width]="col.width">{{ col.header }}<p-sortIcon
|
||||
[field]="col.field"></p-sortIcon></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th *ngFor="let col of columns" [ngSwitch]="col.filtered" class="ui-fluid">
|
||||
<div class="input-with-icon" *ngSwitchCase="true">
|
||||
<i class="ui-icon-search"></i>
|
||||
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
|
||||
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)"
|
||||
[value]="dt.filters[col.field]?.value">
|
||||
</div>
|
||||
<ng-container *ngIf="col.field === TRACKING" [ngTemplateOutlet]="maxVeh" [ngTemplateOutletContext]="{numOfVehicle: trkLimit?.airCraft?.numOfVehicle}"></ng-container>
|
||||
<ng-container *ngIf="col.field === PACKAGE_ACTIVE" [ngTemplateOutlet]="maxVeh" [ngTemplateOutletContext]="{numOfVehicle: pkgLimit?.airCraft?.numOfVehicle || 0}"></ng-container>
|
||||
<p-dropdown *ngIf="col.field === VEHICLE_TYPE" [options]="acTypes" [ngModel]="dt.filters[col.field]?.value" (onChange)="dt.filter($event.value, VEHICLE_TYPE, 'equals')"></p-dropdown>
|
||||
<ng-container *ngIf="col.field === TRACKING" [ngTemplateOutlet]="maxVeh"
|
||||
[ngTemplateOutletContext]="{numOfVehicle: trkLimit?.airCraft?.numOfVehicle}"></ng-container>
|
||||
<ng-container *ngIf="col.field === PACKAGE_ACTIVE" [ngTemplateOutlet]="maxVeh"
|
||||
[ngTemplateOutletContext]="{numOfVehicle: pkgLimit?.airCraft?.numOfVehicle || 0}"></ng-container>
|
||||
<p-dropdown *ngIf="col.field === VEHICLE_TYPE" [options]="acTypes" [ngModel]="dt.filters[col.field]?.value"
|
||||
(onChange)="dt.filter($event.value, VEHICLE_TYPE, 'equals')"></p-dropdown>
|
||||
<span *ngSwitchDefault></span>
|
||||
</th>
|
||||
</tr>
|
||||
@ -47,6 +71,11 @@
|
||||
|
||||
<span *ngSwitchCase="VEHICLE_TYPE">{{ rowData[col.field] | vehicleType }}</span>
|
||||
|
||||
<!-- System Type Column -->
|
||||
<span *ngSwitchCase="SOURCE_SYSTEM">
|
||||
<ng-container *ngTemplateOutlet="systemTypeTemplate; context: { vehicle: rowData }"></ng-container>
|
||||
</span>
|
||||
|
||||
<span *ngSwitchCase="COLOR">
|
||||
<div class="color-box" [ngStyle]="{ 'background-color': resolveFieldData(rowData, col.field) }"></div>
|
||||
</span>
|
||||
@ -55,7 +84,8 @@
|
||||
|
||||
<span *ngSwitchCase="TRACKING">
|
||||
<ng-container *ngIf="rowData[UNIT_ID] && canActivateVehicle; else readonlyStatusTemplate">
|
||||
<p-checkbox [(ngModel)]="rowData.tracking" [binary]="true" (onChange)="vehSelChange(rowData, TRACKING)" [disabled]="isDisabled(rowData, TRACKING)"></p-checkbox>
|
||||
<p-checkbox [(ngModel)]="rowData.tracking" [binary]="true" (onChange)="vehSelChange(rowData, TRACKING)"
|
||||
[disabled]="isDisabled(rowData, TRACKING)"></p-checkbox>
|
||||
</ng-container>
|
||||
<ng-template #readonlyStatusTemplate>
|
||||
<ng-container *ngTemplateOutlet="readonlyStatus; context: { flag: rowData.tracking }"></ng-container>
|
||||
@ -64,7 +94,10 @@
|
||||
|
||||
<span *ngSwitchCase="PACKAGE_ACTIVE">
|
||||
<ng-container *ngIf="canActivateVehicle; else readonlyStatusTemplate">
|
||||
<p-checkbox [(ngModel)]="rowData.pkgActive" [binary]="true" (onChange)="vehSelChange(rowData, PACKAGE_ACTIVE)" [disabled]="isDisabled(rowData, PACKAGE_ACTIVE)"></p-checkbox>
|
||||
<p-checkbox [(ngModel)]="rowData.pkgActive" [binary]="true"
|
||||
(onChange)="vehSelChange(rowData, PACKAGE_ACTIVE)" [disabled]="isDisabled(rowData, PACKAGE_ACTIVE)"
|
||||
[id]="'package-checkbox-' + rowData._id">
|
||||
</p-checkbox>
|
||||
</ng-container>
|
||||
<ng-template #readonlyStatusTemplate>
|
||||
<ng-container *ngTemplateOutlet="readonlyStatus; context: { flag: rowData.pkgActive }"></ng-container>
|
||||
@ -82,7 +115,8 @@
|
||||
|
||||
<span *ngSwitchCase="UNIT_ID">
|
||||
<ng-container *ngIf="!rowData[UNIT_ID] && currVehicle && rowData[ID] == currVehicle[ID]; else uId">
|
||||
<span i18n="@@noUnit" style="color: red;">Unit ID is missing. Please enter a Unit ID to enable the tracking feature.</span>
|
||||
<span i18n="@@noUnit" style="color: red;">Unit ID is missing. Please enter a Unit ID to enable the
|
||||
tracking feature.</span>
|
||||
</ng-container>
|
||||
<ng-template #uId>{{ resolveFieldData(rowData, col.field) }}</ng-template>
|
||||
</span>
|
||||
@ -104,12 +138,16 @@
|
||||
|
||||
<ng-template #btnSection>
|
||||
<section class="ui-widget-header ui-helper-clearfix toolbar">
|
||||
<button type="button" *ngIf="canWrite" pButton icon="ui-icon-plus" (click)="newVehicle()" i18n-label="@@new" label="New"></button>
|
||||
<button type="button" [disabled]="!canEdit" pButton icon="ui-icon-edit" (click)="editVehicle()" i18n-label="@@detail" label="Detail"></button>
|
||||
<button type="button" *ngIf="canWrite" pButton icon="ui-icon-plus" (click)="newVehicle()" i18n-label="@@new"
|
||||
label="New"></button>
|
||||
<button type="button" [disabled]="!canEdit" pButton icon="ui-icon-edit" (click)="editVehicle()"
|
||||
i18n-label="@@detail" label="Detail"></button>
|
||||
<ng-container *ngIf="canActivateVehicle">
|
||||
<button type="button" pButton [disabled]="!vehiclesChanged" icon="ui-icon-save" (click)="update()" #updateBtn i18n-label="@@update" label="Update"></button>
|
||||
<button type="button" pButton [disabled]="!vehiclesChanged" icon="ui-icon-save" (click)="update()" #updateBtn
|
||||
i18n-label="@@update" label="Update"></button>
|
||||
</ng-container>
|
||||
<button type="button" [disabled]="!canEdit" *ngIf="canWrite" pButton icon="ui-icon-trash" (click)="deleteItem()" i18n-label="@@delete" label="Delete"></button>
|
||||
<button type="button" [disabled]="!canEdit" *ngIf="canWrite" pButton icon="ui-icon-trash" (click)="deleteItem()"
|
||||
i18n-label="@@delete" label="Delete"></button>
|
||||
</section>
|
||||
</ng-template>
|
||||
|
||||
@ -117,12 +155,30 @@
|
||||
<ng-container i18n="@@maxVeh">Max vehicles</ng-container>: {{numOfVehicle}}
|
||||
</ng-template>
|
||||
|
||||
<!-- System Type Display Template -->
|
||||
<ng-template #systemTypeTemplate let-vehicle="vehicle">
|
||||
<div class="system-type-display">
|
||||
<!-- Partner Name Badge -->
|
||||
<agm-badge [config]="getPartnerNameBadge(vehicle)"></agm-badge>
|
||||
|
||||
<!-- System Account Authentication Status Indicator -->
|
||||
<agm-badge *ngIf="vehicle.partnerInfo?.partner && vehicle.partnerInfo.partner !== 'AGNAV'"
|
||||
[config]="getAuthStatusBadge(vehicle)">
|
||||
</agm-badge>
|
||||
|
||||
<!-- Partner Code Badge (only for partner systems with codes) -->
|
||||
<agm-badge *ngIf="vehicle.tailNumber" [config]="getPartnerCodeBadge(vehicle)"></agm-badge>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #err>
|
||||
<div class="ui-g">
|
||||
<div class="ui-g-7" style="min-width: 740px; margin: auto;">
|
||||
<div class="ui-g" style="padding: 1em;">
|
||||
<div class="ui-g-12 card in-card-pad" style="margin-bottom: 1em;">
|
||||
<generic-message [icon]="'error'" [iconStyle]="'color: red;'" [messages]="[{text: status?.message || SubTexts.contactSupport, style: 'title sub-messages'}, {text: SubTexts.textBackSub}]" [buttons]="[{label: SubTexts.labelBack}]" (backEvt)="gotoMySubs()"></generic-message>
|
||||
<generic-message [icon]="'error'" [iconStyle]="'color: red;'"
|
||||
[messages]="[{text: status?.message || SubTexts.contactSupport, style: 'title sub-messages'}, {text: SubTexts.textBackSub}]"
|
||||
[buttons]="[{label: SubTexts.labelBack}]" (backEvt)="gotoMySubs()"></generic-message>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -5,18 +5,23 @@ import { ConfirmationService, SelectItem } from 'primeng/api';
|
||||
import { Vehicle } from '../../models/vehicle.model';
|
||||
import * as vehicleActions from '../../actions/vehicle.actions';
|
||||
import * as fromEntity from '../../reducers';
|
||||
import { RoleIds, globals, vehTypes, VehType } from '@app/shared/global';
|
||||
import { RoleIds, globals, vehTypes, VehType, SourceSystem, Labels } from '@app/shared/global';
|
||||
import { DateUtils, Utils } from '@app/shared/utils';
|
||||
import { BaseComp } from '@app/shared/base/base.component';
|
||||
import { PartnerUtilsService } from '@app/shared/services/partner-utils.service';
|
||||
import { BadgeFactoryService } from '@app/shared/services/badge-factory.service';
|
||||
import { BadgeConfig } from '@app/shared/badge/badge-config.model';
|
||||
import { getSubIntentState, getSubscriptionStatus, selectLimit } from '@app/reducers';
|
||||
import { SUB, SubAppErr, SubTexts, SubType, createSubStatus, SubKeys, ACTIVE, TRACKING, hasVendorErr } from '@app/profile/common';
|
||||
import { Limit, Status } from '@app/domain/models/subscription.model';
|
||||
import { map, switchMap, tap } from 'rxjs/operators';
|
||||
import { map, switchMap, take } from 'rxjs/operators';
|
||||
import { ClearSubscriptionStatus, Compound, GotoMyServices } from '@app/actions/subscription.actions';
|
||||
import { SubscriptionService } from '@app/domain/services/subscription.service';
|
||||
import { FetchSubPlans } from '@app/actions/sub-plans.actions';
|
||||
import { User } from '@app/accounts/models/user.model';
|
||||
import { UserService } from '@app/domain/services/user.service';
|
||||
import { PartnerService } from '@app/partners/services/partner.service';
|
||||
import { PopupTooltipService } from '@app/shared/popup-tooltip/popup-tooltip.service';
|
||||
|
||||
const HIGHLIGHT = 'highlight-btn';
|
||||
|
||||
@ -38,6 +43,7 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
|
||||
readonly COLOR = 'color';
|
||||
readonly MODEL = 'model';
|
||||
readonly UNIT_ID = 'unitId';
|
||||
readonly SOURCE_SYSTEM = 'sourceSystem';
|
||||
|
||||
vehicles: Vehicle[] = [];
|
||||
vehiclesChanged = false;
|
||||
@ -64,11 +70,39 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
|
||||
vendorErr: boolean;
|
||||
user: User;
|
||||
|
||||
// Track if we're in review aircraft flow (from checkout/manage-subscription)
|
||||
private isReviewAircraftFlow = false;
|
||||
|
||||
// ============================================================================
|
||||
// NEW VEHICLE TOOLTIP STATE
|
||||
// ============================================================================
|
||||
|
||||
// Track newly created vehicle to show package activation tooltip
|
||||
newVehicleId: string | null = null;
|
||||
packageTooltipShown: boolean = false;
|
||||
|
||||
// ============================================================================
|
||||
// PARTNER AUTHENTICATION STATUS CACHING
|
||||
// ============================================================================
|
||||
|
||||
// Cache authentication status per partner to avoid repeated API calls
|
||||
private partnerAuthCache = new Map<string, {
|
||||
isAuthenticated: boolean;
|
||||
isValidating: boolean;
|
||||
lastChecked: Date;
|
||||
error?: string;
|
||||
}>();
|
||||
|
||||
// Per-partner debounce timers for authentication checks
|
||||
private authCheckTimers = new Map<string, any>();
|
||||
|
||||
BASE_FIELDS = [
|
||||
{ field: 'name', header: globals.name, filtered: true },
|
||||
{ field: 'tailNumber', header: $localize`:@@tailNumber:Tail Number`, filtered: true },
|
||||
{ field: this.VEHICLE_TYPE, header: $localize`:@@type:Type` },
|
||||
{ field: this.MODEL, header: $localize`:@@model:Model` },
|
||||
{ field: this.ACTIVE, header: globals.active, width: '5%' },
|
||||
{ field: this.SOURCE_SYSTEM, header: $localize`:@@systemType:System Type` }, // NEW COLUMN
|
||||
];
|
||||
|
||||
PACKAGE_ACTIVE_FIELDS = [
|
||||
@ -96,6 +130,10 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
|
||||
private readonly confirmService: ConfirmationService,
|
||||
private readonly subSvc: SubscriptionService,
|
||||
private readonly userSvc: UserService,
|
||||
private readonly partnerService: PartnerService,
|
||||
private readonly partnerUtils: PartnerUtilsService,
|
||||
private readonly badgeFactory: BadgeFactoryService,
|
||||
private readonly popupTooltipService: PopupTooltipService
|
||||
) {
|
||||
super();
|
||||
this.acTypes = [
|
||||
@ -108,6 +146,15 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
|
||||
ngOnInit() {
|
||||
this.user = this.route.snapshot.data['user'];
|
||||
this.clearNeedReview();
|
||||
|
||||
// Check for newly created vehicle query parameter
|
||||
this.checkForNewVehicleTooltip();
|
||||
|
||||
// Track if we're in review aircraft flow via query param
|
||||
this.route.queryParams.pipe(take(1)).subscribe((params) => {
|
||||
this.isReviewAircraftFlow = params['reviewFlow'] === 'true';
|
||||
});
|
||||
|
||||
this.initVehList();
|
||||
this.initStatus();
|
||||
this.store.dispatch(new Compound([
|
||||
@ -121,23 +168,363 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
|
||||
this.user.needReview = false;
|
||||
this.userSvc.saveUser(this.user).subscribe({
|
||||
error: (err) => {
|
||||
console.log(err);
|
||||
this.status = createSubStatus(SubAppErr.AC_LIST_ERR);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// NEW VEHICLE TOOLTIP METHODS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check for newly created vehicle and prepare to show package activation tooltip
|
||||
*/
|
||||
checkForNewVehicleTooltip() {
|
||||
this.sub$.add(
|
||||
this.route.queryParams.subscribe(params => {
|
||||
if (params.newVehicleCreated && !this.packageTooltipShown) {
|
||||
this.newVehicleId = params.newVehicleCreated;
|
||||
// Remove the query parameter to clean up the URL
|
||||
this.router.navigate([], {
|
||||
relativeTo: this.route,
|
||||
queryParams: {},
|
||||
replaceUrl: true
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show package activation tooltip for newly created vehicle
|
||||
* Only shown if vehicle meets all eligibility requirements
|
||||
*/
|
||||
showPackageActivationTooltip(vehicleId: string) {
|
||||
if (!this.newVehicleId || this.newVehicleId !== vehicleId || this.packageTooltipShown) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if vehicle is eligible for tooltip
|
||||
if (!this.shouldShowAircraftReadyTooltip(vehicleId)) {
|
||||
// EDGE CASE: Check if partner auth is still validating
|
||||
const vehicle = this.vehicles.find(v => v._id === vehicleId);
|
||||
if (vehicle?.partnerInfo?.partner && !this.partnerUtils.isNativeSystem(vehicle.partnerInfo.partner)) {
|
||||
const authStatus = this.getPartnerAuthStatus(vehicle.partnerInfo.partner);
|
||||
|
||||
if (authStatus.isValidating) {
|
||||
// Wait for auth validation to complete
|
||||
let retryAttempts = 0;
|
||||
const maxRetries = 10; // Max 5 seconds (10 * 500ms)
|
||||
const checkInterval = setInterval(() => {
|
||||
retryAttempts++;
|
||||
const updatedStatus = this.getPartnerAuthStatus(vehicle.partnerInfo.partner);
|
||||
|
||||
if (!updatedStatus.isValidating || retryAttempts >= maxRetries) {
|
||||
clearInterval(checkInterval);
|
||||
|
||||
if (retryAttempts >= maxRetries) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-check eligibility after auth completes
|
||||
if (this.shouldShowAircraftReadyTooltip(vehicleId)) {
|
||||
this.showPackageActivationTooltip(vehicleId);
|
||||
}
|
||||
}
|
||||
}, 500); // Check every 500ms
|
||||
|
||||
return; // Don't show tooltip yet
|
||||
}
|
||||
}
|
||||
|
||||
return; // Don't show tooltip if requirements not met
|
||||
}
|
||||
|
||||
// Set flag immediately to prevent duplicate calls during setTimeout delay
|
||||
this.packageTooltipShown = true;
|
||||
|
||||
// Wait for DOM updates and table rendering
|
||||
setTimeout(() => {
|
||||
// Find the package checkbox using the ID we added to the template
|
||||
const checkboxContainer = document.querySelector(`#package-checkbox-${vehicleId}`) as HTMLElement;
|
||||
|
||||
if (checkboxContainer) {
|
||||
// Get the actual checkbox element for precise positioning
|
||||
const checkboxBox = checkboxContainer.querySelector('.p-checkbox-box') as HTMLElement ||
|
||||
checkboxContainer.querySelector('.ui-chkbox-box') as HTMLElement ||
|
||||
checkboxContainer.querySelector('.p-checkbox') as HTMLElement;
|
||||
|
||||
// Use the visual checkbox box if found, otherwise the container
|
||||
const targetElement = checkboxBox || checkboxContainer;
|
||||
|
||||
if (targetElement) {
|
||||
// Add highlight class for visual emphasis
|
||||
checkboxContainer.classList.add('package-activation-highlight');
|
||||
|
||||
// Position tooltip on the left side of the package active column
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
const preferredPosition = isMobile ? 'bottom' : 'left'; // Changed from 'right' to 'left'
|
||||
|
||||
// Check if package can be activated and customize tooltip accordingly
|
||||
const canActivate = this.canActivatePackageForVehicle(vehicleId);
|
||||
const tooltipConfig = this.getPackageActivationTooltipConfig(vehicleId, canActivate);
|
||||
|
||||
const tooltipRef = this.popupTooltipService.showActionReminder(
|
||||
tooltipConfig.message,
|
||||
tooltipConfig.actionText,
|
||||
targetElement,
|
||||
{
|
||||
position: preferredPosition,
|
||||
autoHide: false, // Keep visible until user takes action
|
||||
title: tooltipConfig.title,
|
||||
severity: tooltipConfig.severity
|
||||
}
|
||||
);
|
||||
|
||||
// Subscribe to action button click
|
||||
if (tooltipRef && tooltipRef.instance) {
|
||||
tooltipRef.instance.actionClicked.subscribe(() => {
|
||||
this.handlePackageActivationAction(vehicleId, canActivate);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 500); // Allow time for DOM rendering
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if package can be activated for the given vehicle
|
||||
*/
|
||||
canActivatePackageForVehicle(vehicleId: string): boolean {
|
||||
if (!this.pkgLimit) {
|
||||
return true; // No limits, can activate
|
||||
}
|
||||
|
||||
const activePackageVehicles = this.getVehicles(this.PACKAGE_ACTIVE, this.vehicles);
|
||||
const maxAllowed = this.pkgLimit?.airCraft?.numOfVehicle || 0;
|
||||
return activePackageVehicles.length < maxAllowed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if vehicle has proper account credentials
|
||||
* Required for showing "Aircraft Ready" tooltip
|
||||
*
|
||||
* Note: Passwords are not returned from backend for security.
|
||||
* For newly created vehicles, we assume password was set because
|
||||
* backend validation would fail without it.
|
||||
*
|
||||
* @param vehicleId Vehicle ID to check
|
||||
* @returns true if vehicle has username and active=true
|
||||
*/
|
||||
hasProperAccountCredentials(vehicleId: string): boolean {
|
||||
const vehicle = this.vehicles.find(v => v._id === vehicleId);
|
||||
|
||||
if (!vehicle) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Partner aircraft: Only require active status (no username/password needed)
|
||||
if (vehicle.partnerInfo?.partner && !this.partnerUtils.isNativeSystem(vehicle.partnerInfo.partner)) {
|
||||
return vehicle.active === true;
|
||||
}
|
||||
|
||||
// Native AgMission aircraft: Require username and active status
|
||||
const hasUsername = vehicle.username && vehicle.username.trim() !== '';
|
||||
const isActive = vehicle.active === true;
|
||||
|
||||
// Note: Password field is not included in backend response for security
|
||||
// For newly created vehicles (via newVehicleCreated query param),
|
||||
// we assume password was provided since backend validates this
|
||||
|
||||
// Both username and active must be present for native aircraft
|
||||
return hasUsername && isActive;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if vehicle has valid partner authentication (if applicable)
|
||||
* For AgNav native systems, returns true immediately
|
||||
* For partner systems, checks cached authentication status
|
||||
*
|
||||
* @param vehicleId Vehicle ID to check
|
||||
* @returns true if partner auth is valid or not required
|
||||
*/
|
||||
hasValidPartnerAuthForVehicle(vehicleId: string): boolean {
|
||||
const vehicle = this.vehicles.find(v => v._id === vehicleId);
|
||||
|
||||
if (!vehicle) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use existing partner authentication validation
|
||||
return this.hasValidPartnerAuth(vehicle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if vehicle is eligible to show "Aircraft Ready" tooltip
|
||||
*
|
||||
* Requirements:
|
||||
* 1. Has proper account credentials (username + active=true)
|
||||
* 2. Package limit has not been reached
|
||||
* 3. Partner authentication is valid (if applicable)
|
||||
*
|
||||
* @param vehicleId Vehicle ID to check
|
||||
* @returns true if all conditions are met
|
||||
*/
|
||||
shouldShowAircraftReadyTooltip(vehicleId: string): boolean {
|
||||
// Requirement 1: Check account credentials
|
||||
const hasCredentials = this.hasProperAccountCredentials(vehicleId);
|
||||
if (!hasCredentials) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Requirement 2: Check package limit
|
||||
const canActivate = this.canActivatePackageForVehicle(vehicleId);
|
||||
if (!canActivate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Requirement 3: Check partner authentication (if applicable)
|
||||
const hasValidAuth = this.hasValidPartnerAuthForVehicle(vehicleId);
|
||||
if (!hasValidAuth) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// All checks passed
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tooltip configuration based on whether package can be activated
|
||||
*/
|
||||
getPackageActivationTooltipConfig(vehicleId: string, canActivate: boolean) {
|
||||
if (canActivate) {
|
||||
return {
|
||||
title: Labels.AIRCRAFT_READY_TITLE,
|
||||
message: Labels.PACKAGE_ACTIVATION_REMINDER,
|
||||
actionText: Labels.ACTIVATE_PACKAGE_ACTION,
|
||||
severity: 'warning' as const
|
||||
};
|
||||
} else {
|
||||
const activeCount = this.getVehicles(this.PACKAGE_ACTIVE, this.vehicles).length;
|
||||
const maxAllowed = this.pkgLimit?.airCraft?.numOfVehicle || 0;
|
||||
|
||||
return {
|
||||
title: Labels.PACKAGE_LIMIT_REACHED_TITLE,
|
||||
message: `${Labels.PACKAGE_LIMIT_REACHED_MESSAGE} (${activeCount}/${maxAllowed})`,
|
||||
actionText: Labels.MANAGE_PACKAGE_LIMIT_ACTION,
|
||||
severity: 'error' as const
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the action button click in the tooltip
|
||||
*/
|
||||
handlePackageActivationAction(vehicleId: string, canActivate: boolean) {
|
||||
if (canActivate) {
|
||||
// Activate package for this vehicle
|
||||
this.activatePackageForVehicle(vehicleId);
|
||||
} else {
|
||||
// Show options for managing package limits
|
||||
this.showPackageLimitOptions(vehicleId); // Pass vehicleId for consistent positioning
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate package for the specified vehicle
|
||||
*/
|
||||
activatePackageForVehicle(vehicleId: string) {
|
||||
const vehicle = this.vehicles.find(v => v._id === vehicleId);
|
||||
if (vehicle && !vehicle.pkgActive) {
|
||||
// Simulate checkbox change
|
||||
vehicle.pkgActive = true;
|
||||
this.vehSelChange(vehicle, this.PACKAGE_ACTIVE);
|
||||
|
||||
// Trigger backend update directly without confirmation dialog
|
||||
this.store.dispatch(new vehicleActions.UpdateVehicles({ vehicles: this.vehicles }));
|
||||
|
||||
// Hide tooltip and show success feedback
|
||||
this.popupTooltipService.hideAll();
|
||||
|
||||
// Show success message after a brief delay to allow for update processing
|
||||
setTimeout(() => {
|
||||
// Use consistent positioning with package activation tooltip
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
const successPosition = isMobile ? 'bottom' : 'left';
|
||||
|
||||
this.popupTooltipService.showSuccess(
|
||||
Labels.PACKAGE_ACTIVATED_SUCCESS,
|
||||
document.querySelector(`#package-checkbox-${vehicleId}`) as HTMLElement,
|
||||
{
|
||||
autoHide: true,
|
||||
autoHideDelay: 3000,
|
||||
title: Labels.SUCCESS_TITLE,
|
||||
position: successPosition // Use consistent positioning
|
||||
}
|
||||
);
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show options for managing package limits
|
||||
*/
|
||||
showPackageLimitOptions(vehicleId: string) {
|
||||
// Hide current tooltip
|
||||
this.popupTooltipService.hideAll();
|
||||
|
||||
// Use consistent positioning with package activation tooltip
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
const warningPosition = isMobile ? 'bottom' : 'left';
|
||||
|
||||
// Find the same target element (checkbox) for consistent positioning
|
||||
const targetElement = document.querySelector(`#package-checkbox-${vehicleId}`) as HTMLElement;
|
||||
|
||||
// For now, show constraint message component or navigate to upgrade
|
||||
// This could be enhanced to show a modal with specific options
|
||||
this.popupTooltipService.showWarning(
|
||||
Labels.PACKAGE_LIMIT_UPGRADE_MESSAGE,
|
||||
targetElement, // Use the same target as other tooltips
|
||||
{
|
||||
autoHide: true,
|
||||
autoHideDelay: 5000,
|
||||
title: Labels.UPGRADE_REQUIRED_TITLE,
|
||||
position: warningPosition // Use consistent positioning
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
initVehList() {
|
||||
this.sub$ = this.store.select(fromEntity.getAllVehicles).pipe(
|
||||
map((vehicles) => {
|
||||
this.vehicles = vehicles;
|
||||
this.vehSelLastUpdated = this.createVehSelections(vehicles);
|
||||
this.vehiclesChanged = this.isVehSelChanged();
|
||||
|
||||
// Show package activation tooltip after vehicles are loaded and view is initialized
|
||||
if (this.newVehicleId && vehicles.length > 0) {
|
||||
setTimeout(() => {
|
||||
this.showPackageActivationTooltip(this.newVehicleId);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// Call resolveVehicleList when vehicles are loaded to ensure aircraft limits are enforced
|
||||
if (vehicles && vehicles.length > 0) {
|
||||
this.resolveVehicleList();
|
||||
|
||||
// Show package activation tooltip for newly created vehicle after table renders
|
||||
if (this.newVehicleId && !this.packageTooltipShown) {
|
||||
// Use setTimeout to ensure the table is fully rendered
|
||||
setTimeout(() => {
|
||||
this.showPackageActivationTooltip(this.newVehicleId!);
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
})
|
||||
).subscribe({
|
||||
error: (err) => {
|
||||
console.log(err);
|
||||
this.status = createSubStatus(SubAppErr.AC_LIST_ERR);
|
||||
}
|
||||
});
|
||||
@ -181,7 +568,6 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
|
||||
}))
|
||||
).subscribe({
|
||||
error: (err) => {
|
||||
console.log(err);
|
||||
this.status = createSubStatus(SubAppErr.AC_LIST_ERR);
|
||||
}
|
||||
}));
|
||||
@ -205,6 +591,21 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
|
||||
rowData[this.PACKAGE_ACTIVE_DATE] = currentUTCDate;
|
||||
}
|
||||
|
||||
// Hide package activation tooltip when user activates package for newly created vehicle
|
||||
if (type === this.PACKAGE_ACTIVE && rowData[this.PACKAGE_ACTIVE] &&
|
||||
this.newVehicleId === rowData._id && this.packageTooltipShown) {
|
||||
this.popupTooltipService.hideAll();
|
||||
|
||||
// Remove highlight class
|
||||
const checkboxContainer = document.querySelector(`#package-checkbox-${rowData._id}`) as HTMLElement;
|
||||
if (checkboxContainer) {
|
||||
checkboxContainer.classList.remove('package-activation-highlight');
|
||||
}
|
||||
|
||||
this.newVehicleId = null; // Reset to prevent showing again
|
||||
this.packageTooltipShown = false;
|
||||
}
|
||||
|
||||
if (!this.vehSelCurrent) this.vehSelCurrent = this.createVehSelections(this.vehicles);
|
||||
this.vehSelCurrent[rowData[this.ID]][type] = rowData[type];
|
||||
this.vehiclesChanged = this.isVehSelChanged();
|
||||
@ -223,11 +624,20 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
|
||||
}
|
||||
|
||||
private resolveVehicleList() {
|
||||
// Ensure we have both vehicles data and limits before proceeding
|
||||
if (!this.vehicles || this.vehicles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.pkgLimit && !this.trkLimit) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trkVehicles = this.getVehicles(this.TRACKING, this.vehicles);
|
||||
const pkgActiveVehs = this.getVehicles(this.PACKAGE_ACTIVE, this.vehicles);
|
||||
|
||||
const isTrkVehicleAboveLimit = trkVehicles.length > this.trkLimit?.airCraft?.numOfVehicle;
|
||||
const isPkgActiveVehicleAboveLimit = pkgActiveVehs.length > this.pkgLimit?.airCraft?.numOfVehicle;
|
||||
const isTrkVehicleAboveLimit = this.trkLimit && trkVehicles.length > this.trkLimit?.airCraft?.numOfVehicle;
|
||||
const isPkgActiveVehicleAboveLimit = this.pkgLimit && pkgActiveVehs.length > this.pkgLimit?.airCraft?.numOfVehicle;
|
||||
|
||||
if (isTrkVehicleAboveLimit || isPkgActiveVehicleAboveLimit) {
|
||||
this.vehicles = this.vehicles.map((veh) => ({
|
||||
@ -235,6 +645,7 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
|
||||
tracking: isTrkVehicleAboveLimit ? trkVehicles.slice(0, this.trkLimit?.airCraft?.numOfVehicle).some((trkVeh) => trkVeh._id === veh._id) : veh.tracking,
|
||||
pkgActive: isPkgActiveVehicleAboveLimit ? pkgActiveVehs.slice(0, this.pkgLimit?.airCraft?.numOfVehicle).some((pkgActiveVeh) => pkgActiveVeh._id === veh._id) : veh.pkgActive
|
||||
}));
|
||||
|
||||
this.store.dispatch(new vehicleActions.UpdateVehicles({ vehicles: this.vehicles, type: SUB.AC_REVIEW }));
|
||||
}
|
||||
}
|
||||
@ -259,7 +670,6 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
|
||||
})
|
||||
).subscribe({
|
||||
error: (err) => {
|
||||
console.log(err);
|
||||
this.status = createSubStatus(SubAppErr.AC_LIST_ERR);
|
||||
}
|
||||
})
|
||||
@ -306,6 +716,284 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
|
||||
this.store.dispatch(new vehicleActions.Select(this.currVehicle));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get partner display name for badge
|
||||
*/
|
||||
getPartnerDisplayName(vehicle: Vehicle): string {
|
||||
// Check if vehicle has partner integration data
|
||||
if (vehicle.partnerInfo?.metadata?.partnerSystem) {
|
||||
return vehicle.partnerInfo.metadata.partnerSystem;
|
||||
}
|
||||
|
||||
// Default to AgNav brand name (non-translatable) if no partner info exists
|
||||
return Labels.AGNAV_BRAND_NAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get source system for vehicle (used for SOURCE_SYSTEM column)
|
||||
*/
|
||||
getSourceSystem(vehicle: Vehicle): string {
|
||||
return vehicle.partnerInfo?.metadata?.partnerSystem || SourceSystem.AGNAV;
|
||||
}
|
||||
|
||||
/**
|
||||
* Badge Configuration Methods (using BadgeFactoryService)
|
||||
* Uses configuration-driven badge component for consistent styling
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get badge configuration for partner name (system type badge)
|
||||
*/
|
||||
getPartnerNameBadge(vehicle: Vehicle): BadgeConfig {
|
||||
const sourceSystem = this.getSourceSystem(vehicle);
|
||||
const partnerName = this.getPartnerDisplayName(vehicle);
|
||||
const badge = this.badgeFactory.createSystemBadge(sourceSystem, partnerName);
|
||||
|
||||
// Override tooltip with component-specific tooltip logic
|
||||
return {
|
||||
...badge,
|
||||
tooltip: this.getPartnerTooltip(vehicle)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get badge configuration for authentication status (icon only)
|
||||
*/
|
||||
getAuthStatusBadge(vehicle: Vehicle): BadgeConfig {
|
||||
const partnerId = vehicle.partnerInfo?.partner;
|
||||
const authStatus = partnerId ? this.getPartnerAuthStatus(partnerId) : null;
|
||||
|
||||
const badge = this.badgeFactory.createAuthStatusBadge(
|
||||
authStatus?.isAuthenticated || false,
|
||||
authStatus?.isValidating || false,
|
||||
partnerId || null
|
||||
);
|
||||
|
||||
// Override tooltip with component-specific tooltip logic
|
||||
return {
|
||||
...badge,
|
||||
tooltip: this.getPartnerAuthTooltip(vehicle)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get badge configuration for partner code (tail number badge)
|
||||
*/
|
||||
getPartnerCodeBadge(vehicle: Vehicle): BadgeConfig {
|
||||
const badge = this.badgeFactory.createPartnerCodeBadge(vehicle.tailNumber || '');
|
||||
|
||||
// Override tooltip with component-specific tooltip logic
|
||||
return {
|
||||
...badge,
|
||||
tooltip: this.getPartnerCodeTooltip(vehicle)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tooltip text for partner information
|
||||
*/
|
||||
getPartnerTooltip(vehicle: Vehicle): string {
|
||||
if (!vehicle.partnerInfo?.metadata?.partnerSystem) {
|
||||
return Labels.AGMISSION_NATIVE_SYSTEM;
|
||||
}
|
||||
|
||||
const partnerName = vehicle.partnerInfo.metadata.partnerSystem;
|
||||
const lastSync = vehicle.partnerInfo.metadata.lastSync
|
||||
? this.formatDate(new Date(vehicle.partnerInfo.metadata.lastSync))
|
||||
: Labels.NEVER;
|
||||
|
||||
return `${partnerName} - ${Labels.LAST_SYNC_PREFIX} ${lastSync}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tooltip text for partner code (tail number)
|
||||
*/
|
||||
getPartnerCodeTooltip(vehicle: Vehicle): string {
|
||||
return `${Labels.TAIL_NUMBER_PREFIX} ${vehicle.tailNumber}`;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PARTNER AUTHENTICATION STATUS METHODS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get authentication status for a partner system (cached)
|
||||
*/
|
||||
getPartnerAuthStatus(partnerId: string): { isAuthenticated: boolean; isValidating: boolean; error?: string } {
|
||||
const cached = this.partnerAuthCache.get(partnerId);
|
||||
|
||||
if (!cached) {
|
||||
// Start validation for this partner if not in cache
|
||||
this.schedulePartnerAuthCheck(partnerId);
|
||||
return { isAuthenticated: false, isValidating: true };
|
||||
}
|
||||
|
||||
// Return cached status
|
||||
return {
|
||||
isAuthenticated: cached.isAuthenticated,
|
||||
isValidating: cached.isValidating,
|
||||
error: cached.error
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule authentication check for a partner (debounced per partner)
|
||||
*/
|
||||
private schedulePartnerAuthCheck(partnerId: string): void {
|
||||
// Mark as validating
|
||||
this.partnerAuthCache.set(partnerId, {
|
||||
isAuthenticated: false,
|
||||
isValidating: true,
|
||||
lastChecked: new Date()
|
||||
});
|
||||
|
||||
// Clear existing timer for this specific partner
|
||||
const existingTimer = this.authCheckTimers.get(partnerId);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
}
|
||||
|
||||
// Schedule check for this specific partner after short delay
|
||||
const timer = setTimeout(() => {
|
||||
this.performPartnerAuthCheck(partnerId);
|
||||
}, 100);
|
||||
|
||||
// Store the timer for this partner
|
||||
this.authCheckTimers.set(partnerId, timer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform authentication check for a specific partner using centralized service method
|
||||
*/
|
||||
private async performPartnerAuthCheck(partnerId: string): Promise<void> {
|
||||
try {
|
||||
const currentCustomerId = this.authSvc.byPUserId;
|
||||
if (!currentCustomerId) {
|
||||
console.warn('No current customer ID available for partner auth check');
|
||||
return;
|
||||
}
|
||||
|
||||
// Use centralized validation method
|
||||
const result = await this.partnerService.validatePartnerAuthentication(
|
||||
currentCustomerId,
|
||||
partnerId
|
||||
);
|
||||
|
||||
// Cache the result with appropriate error mapping
|
||||
this.partnerAuthCache.set(partnerId, {
|
||||
isAuthenticated: result.isValid,
|
||||
isValidating: false,
|
||||
lastChecked: new Date(),
|
||||
error: result.isValid ? undefined : this.mapAuthErrorMessage(result.errorMessage)
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error checking partner ${partnerId} authentication:`, error);
|
||||
|
||||
// Cache the error
|
||||
this.partnerAuthCache.set(partnerId, {
|
||||
isAuthenticated: false,
|
||||
isValidating: false,
|
||||
lastChecked: new Date(),
|
||||
error: error.message || Labels.UNKNOWN_ERROR
|
||||
});
|
||||
} finally {
|
||||
// Clean up the timer for this partner
|
||||
this.authCheckTimers.delete(partnerId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map authentication error messages from centralized service to user-friendly labels
|
||||
*/
|
||||
private mapAuthErrorMessage(errorMessage?: string): string {
|
||||
if (!errorMessage) {
|
||||
return Labels.AUTHENTICATION_FAILED_SHORT;
|
||||
}
|
||||
|
||||
if (errorMessage.includes('No system users found')) {
|
||||
return Labels.NO_SYSTEM_ACCOUNT_FOUND;
|
||||
}
|
||||
|
||||
if (errorMessage.includes('credentials are missing')) {
|
||||
return Labels.MISSING_CREDENTIALS;
|
||||
}
|
||||
|
||||
if (errorMessage.includes('Authentication test failed')) {
|
||||
return Labels.AUTHENTICATION_FAILED_SHORT;
|
||||
}
|
||||
|
||||
// Default to authentication failed for any other error
|
||||
return Labels.AUTHENTICATION_FAILED_SHORT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a partner system has valid authentication
|
||||
*/
|
||||
hasValidPartnerAuth(vehicle: Vehicle): boolean {
|
||||
const partnerId = vehicle.partnerInfo?.partner;
|
||||
if (!partnerId || this.partnerUtils.isNativeSystem(partnerId)) {
|
||||
return true; // AgNav doesn't need external auth
|
||||
}
|
||||
|
||||
const authStatus = this.getPartnerAuthStatus(partnerId);
|
||||
return authStatus.isAuthenticated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a partner system is currently validating authentication
|
||||
*/
|
||||
isPartnerAuthValidating(vehicle: Vehicle): boolean {
|
||||
const partnerId = vehicle.partnerInfo?.partner;
|
||||
if (!partnerId || this.partnerUtils.isNativeSystem(partnerId)) {
|
||||
return false; // AgNav doesn't need validation
|
||||
}
|
||||
|
||||
const authStatus = this.getPartnerAuthStatus(partnerId);
|
||||
return authStatus.isValidating;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authentication status icon class for partner
|
||||
*/
|
||||
getPartnerAuthIconClass(vehicle: Vehicle): string {
|
||||
const partnerId = vehicle.partnerInfo?.partner;
|
||||
if (!partnerId || this.partnerUtils.isNativeSystem(partnerId)) {
|
||||
return 'ui-icon-check'; // AgNav is always valid
|
||||
}
|
||||
|
||||
const authStatus = this.getPartnerAuthStatus(partnerId);
|
||||
|
||||
if (authStatus.isValidating) {
|
||||
return 'pi pi-spin pi-spinner';
|
||||
} else if (authStatus.isAuthenticated) {
|
||||
return 'ui-icon-vpn-key';
|
||||
} else {
|
||||
return 'ui-icon-warning';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authentication status tooltip for partner
|
||||
*/
|
||||
getPartnerAuthTooltip(vehicle: Vehicle): string {
|
||||
const partnerId = vehicle.partnerInfo?.partner;
|
||||
if (!partnerId || this.partnerUtils.isNativeSystem(partnerId)) {
|
||||
return Labels.AGMISSION_NATIVE_SYSTEM;
|
||||
}
|
||||
|
||||
const authStatus = this.getPartnerAuthStatus(partnerId);
|
||||
const partnerName = vehicle.partnerInfo?.metadata?.partnerSystem || Labels.PARTNER_SYSTEM_DEFAULT;
|
||||
|
||||
if (authStatus.isValidating) {
|
||||
return `${partnerName}: ${Labels.VALIDATING_AUTHENTICATION}`;
|
||||
} else if (authStatus.isAuthenticated) {
|
||||
return `${partnerName}: ${Labels.AUTHENTICATION_VALID}`;
|
||||
} else {
|
||||
return `${partnerName}: ${Labels.AUTHENTICATION_FAILED_WITH_ERROR} ${authStatus.error || Labels.UNKNOWN_ERROR}`;
|
||||
}
|
||||
}
|
||||
|
||||
newVehicle() {
|
||||
this.router.navigate(['.', '0'], { relativeTo: this.route });
|
||||
}
|
||||
@ -331,6 +1019,11 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
|
||||
accept: () => {
|
||||
this.store.dispatch(new vehicleActions.UpdateVehicles({ vehicles: this.vehicles }));
|
||||
this.updateBtn?.nativeElement.classList.remove(HIGHLIGHT);
|
||||
|
||||
// Navigate to service overview if in review aircraft flow
|
||||
if (this.isReviewAircraftFlow) {
|
||||
this.router.navigate(['/profile/myservices']);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -350,15 +1043,34 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
|
||||
&& !this.vendorErr;
|
||||
}
|
||||
|
||||
isAircraftReviewStatus(): boolean {
|
||||
return this.subSvc.isStatusMatchingCode(this.status, SUB.AC_REVIEW);
|
||||
}
|
||||
|
||||
gotoMySubs() {
|
||||
this.store.dispatch(new GotoMyServices());
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when user confirms no aircraft changes are needed during review flow.
|
||||
* Navigates directly via Router (no reload) — unlike GotoMyServices which
|
||||
* always calls window.location.reload() after navigation.
|
||||
*/
|
||||
noChangesToReview(): void {
|
||||
this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]);
|
||||
}
|
||||
|
||||
get canActivateVehicle() {
|
||||
return this.authSvc.canActivateVehicle;
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
// Clear all partner-specific authentication check timers
|
||||
this.authCheckTimers.forEach((timer) => {
|
||||
clearTimeout(timer);
|
||||
});
|
||||
this.authCheckTimers.clear();
|
||||
|
||||
this.store.dispatch(new ClearSubscriptionStatus());
|
||||
super.ngOnDestroy();
|
||||
}
|
||||
|
||||
@ -0,0 +1,468 @@
|
||||
/* Partner Integration Component Styles - AgMission Theme Compliance */
|
||||
|
||||
/* Host element typography foundation - AgMission standards */
|
||||
:host {
|
||||
font-family: "Roboto", "Helvetica Neue", sans-serif;
|
||||
/* $fontFamily - AgMission standard */
|
||||
line-height: 1.5;
|
||||
/* $lineHeight - AgMission standard */
|
||||
letter-spacing: 0.25px;
|
||||
/* $letterSpacing - AgMission standard */
|
||||
}
|
||||
|
||||
.partner-validation-section {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
INTEGRATION STEPS INDICATOR - AgMission Project Color Compliance
|
||||
============================================================================ */
|
||||
|
||||
.integration-steps {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 16px 0 24px 0;
|
||||
padding: 12px;
|
||||
background: #ffffff;
|
||||
/* contentBgColor - AgMission content background */
|
||||
border-radius: 3px;
|
||||
/* AgMission standard border radius */
|
||||
border: 1px solid #bdbdbd;
|
||||
/* dividerColor - AgMission borders */
|
||||
}
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.step-indicator {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s ease;
|
||||
border: 2px solid #bdbdbd;
|
||||
/* dividerColor - AgMission neutral border */
|
||||
background: #ffffff;
|
||||
/* contentBgColor - AgMission white background */
|
||||
color: #757575;
|
||||
/* textSecondaryColor - AgMission secondary text */
|
||||
font-family: "Roboto", "Helvetica Neue", sans-serif;
|
||||
/* $fontFamily - AgMission standard */
|
||||
line-height: 1.5;
|
||||
/* $lineHeight - AgMission standard */
|
||||
letter-spacing: 0.25px;
|
||||
/* $letterSpacing - AgMission standard */
|
||||
}
|
||||
|
||||
.step.active .step-indicator {
|
||||
border-color: #03A9F4;
|
||||
/* blue - AgMission info color */
|
||||
background: #03A9F4;
|
||||
/* blue - AgMission info color */
|
||||
color: #ffffff;
|
||||
/* primaryTextColor - white text on colored backgrounds */
|
||||
}
|
||||
|
||||
.step.completed .step-indicator {
|
||||
border-color: #4CAF50;
|
||||
/* primaryColor - AgMission main green */
|
||||
background: #4CAF50;
|
||||
/* primaryColor - AgMission main green */
|
||||
color: #ffffff;
|
||||
/* primaryTextColor - white text on colored backgrounds */
|
||||
}
|
||||
|
||||
.step-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #757575;
|
||||
/* textSecondaryColor - AgMission secondary text */
|
||||
line-height: 1.3;
|
||||
max-width: 100px;
|
||||
font-family: "Roboto", "Helvetica Neue", sans-serif;
|
||||
/* $fontFamily - AgMission standard */
|
||||
letter-spacing: 0.25px;
|
||||
/* $letterSpacing - AgMission standard */
|
||||
}
|
||||
|
||||
.step.active .step-label {
|
||||
color: #03A9F4;
|
||||
/* blue - AgMission info color */
|
||||
}
|
||||
|
||||
.step.completed .step-label {
|
||||
color: #4CAF50;
|
||||
/* primaryColor - AgMission main green */
|
||||
}
|
||||
|
||||
.step-connector {
|
||||
flex: 0 0 auto;
|
||||
height: 2px;
|
||||
width: 40px;
|
||||
background: #bdbdbd;
|
||||
/* dividerColor - AgMission neutral */
|
||||
margin: 0 8px;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.step.completed+.step-connector {
|
||||
background: #4CAF50;
|
||||
/* primaryColor - AgMission main green */
|
||||
}
|
||||
|
||||
.step.active+.step-connector {
|
||||
background: linear-gradient(to right, #4CAF50 50%, #bdbdbd 50%);
|
||||
/* Green to neutral gradient */
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
PARTNER SYSTEM INDICATORS - AgMission Project Color Compliance
|
||||
============================================================================ */
|
||||
|
||||
.partner-selection-with-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.partner-selection-with-indicator p-dropdown {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.partner-selection-with-indicator p-dropdown .ui-dropdown {
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.success-indicator {
|
||||
color: #4CAF50 !important;
|
||||
/* primaryColor - AgMission main green */
|
||||
font-size: 1.2rem;
|
||||
opacity: 1;
|
||||
animation: fadeInScale 0.3s ease-in;
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
color: #03A9F4;
|
||||
/* blue - AgMission info color */
|
||||
font-size: 0.95rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-family: "Roboto", "Helvetica Neue", sans-serif;
|
||||
/* $fontFamily - AgMission standard */
|
||||
letter-spacing: 0.25px;
|
||||
/* $letterSpacing - AgMission standard */
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.loading-indicator i {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
VALIDATION LOADING INDICATOR - AgMission Project Color Compliance
|
||||
============================================================================ */
|
||||
|
||||
.validation-loading-indicator {
|
||||
color: #03A9F4 !important;
|
||||
/* blue - AgMission info color */
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
AIRCRAFT INFORMATION PANEL - AgMission Project Color Compliance
|
||||
============================================================================ */
|
||||
|
||||
.enhanced-aircraft-info-panel {
|
||||
background: linear-gradient(135deg, #E8F5E8 0%, #ffffff 100%);
|
||||
/* Light green to white gradient matching AgMission success styling */
|
||||
border: 1px solid #4CAF50;
|
||||
/* primaryColor - AgMission main green */
|
||||
border-radius: 3px;
|
||||
/* AgMission standard border radius - matches constraint-message */
|
||||
padding: 12px 16px;
|
||||
/* Matches constraint-message content padding */
|
||||
margin: 12px 0;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
/* Matches constraint-message shadow */
|
||||
transition: all 0.3s ease-in-out;
|
||||
/* Matches constraint-message transition */
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.enhanced-aircraft-info-panel:hover {
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
/* Matches constraint-message hover shadow */
|
||||
transform: translateY(-1px);
|
||||
/* Matches constraint-message hover effect */
|
||||
}
|
||||
|
||||
.aircraft-info-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
/* Matches constraint-message content gap */
|
||||
}
|
||||
|
||||
.aircraft-info-icon {
|
||||
font-size: 1.125rem;
|
||||
/* Matches constraint-message icon size: 18px */
|
||||
color: #4CAF50;
|
||||
/* primaryColor - AgMission main green */
|
||||
margin-top: 2px;
|
||||
/* Matches constraint-message icon alignment */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.aircraft-info-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
/* Matches constraint-message text container */
|
||||
}
|
||||
|
||||
.aircraft-info-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.aircraft-info-title {
|
||||
font-size: 0.875rem;
|
||||
/* 14px - matches constraint-message title size */
|
||||
font-weight: 500;
|
||||
/* Matches constraint-message title weight */
|
||||
color: #2E7D32;
|
||||
/* primaryDarkColor - darker green for headers */
|
||||
font-family: "Roboto", "Helvetica Neue", sans-serif;
|
||||
/* $fontFamily - AgMission standard */
|
||||
line-height: 1.5;
|
||||
/* Matches constraint-message line height */
|
||||
letter-spacing: 0.25px;
|
||||
/* $letterSpacing - AgMission standard */
|
||||
margin-bottom: 4px;
|
||||
/* Matches constraint-message title margin */
|
||||
}
|
||||
|
||||
.aircraft-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.8125rem;
|
||||
/* 13px - matches constraint-message description size */
|
||||
line-height: 1.5;
|
||||
/* Matches constraint-message line height */
|
||||
font-family: "Roboto", "Helvetica Neue", sans-serif;
|
||||
/* $fontFamily - AgMission standard */
|
||||
letter-spacing: 0.25px;
|
||||
/* $letterSpacing - AgMission standard */
|
||||
color: #212121;
|
||||
/* textColor - matches constraint-message description */
|
||||
margin: 0;
|
||||
word-wrap: break-word;
|
||||
/* Matches constraint-message description */
|
||||
}
|
||||
|
||||
.detail-row strong {
|
||||
color: #2E7D32;
|
||||
/* primaryDarkColor - darker green for labels */
|
||||
font-weight: 500;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.system-type-row {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.system-type-value {
|
||||
color: #4CAF50;
|
||||
/* primaryColor - AgMission success color for selected system type */
|
||||
font-weight: 600;
|
||||
/* Bold text for emphasis */
|
||||
}
|
||||
|
||||
.system-type-pending {
|
||||
color: #f39c12;
|
||||
/* Warning color */
|
||||
font-style: italic;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
DISABLED PREVIEW STYLING
|
||||
============================================================================ */
|
||||
|
||||
.preview-disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
ANIMATIONS
|
||||
============================================================================ */
|
||||
|
||||
@keyframes fadeInScale {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
RESPONSIVE DESIGN - Mobile and Tablet
|
||||
============================================================================ */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.integration-steps {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px 12px;
|
||||
}
|
||||
|
||||
.step {
|
||||
min-width: auto;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.step-indicator {
|
||||
margin-bottom: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.step-label {
|
||||
font-size: 14px;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.step-connector {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.enhanced-aircraft-info-panel {
|
||||
padding: 10px 12px;
|
||||
/* Matches constraint-message mobile padding */
|
||||
margin: 8px 0;
|
||||
/* Matches constraint-message mobile margin */
|
||||
max-width: 100%;
|
||||
/* Matches constraint-message mobile width */
|
||||
}
|
||||
|
||||
.aircraft-info-content {
|
||||
gap: 10px;
|
||||
/* Matches constraint-message mobile gap */
|
||||
}
|
||||
|
||||
.aircraft-info-icon {
|
||||
font-size: 1rem;
|
||||
/* 16px - matches constraint-message mobile icon size */
|
||||
}
|
||||
|
||||
.aircraft-info-title {
|
||||
font-size: 0.8125rem;
|
||||
/* 13px - matches constraint-message mobile title size */
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
font-size: 0.75rem;
|
||||
/* 12px - matches constraint-message mobile description size */
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.integration-steps {
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
.step-indicator {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.step-label {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.partner-selection-with-indicator {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.enhanced-aircraft-info-panel {
|
||||
padding: 10px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.aircraft-info-icon {
|
||||
font-size: 1rem;
|
||||
/* Consistent with tablet size */
|
||||
}
|
||||
|
||||
.aircraft-info-title {
|
||||
font-size: 0.75rem;
|
||||
/* Smaller for mobile screens */
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
font-size: 0.7rem;
|
||||
/* Smaller for mobile screens */
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
INPUT FIELD WITH INLINE CONSTRAINT - Detached Mode Pattern
|
||||
============================================================================ */
|
||||
|
||||
.input-with-inline-constraint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input-with-inline-constraint label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Position the constraint trigger button closer to dropdown */
|
||||
.input-with-inline-constraint ::ng-deep .agm-constraint-trigger {
|
||||
margin-right: 32px;
|
||||
}
|
||||
@ -0,0 +1,263 @@
|
||||
<div class="ui-g-12 ui-g-nopad">
|
||||
<!-- Partner Selection -->
|
||||
<div class="ui-g-12 ui-lg-6" style="padding-top: 0;">
|
||||
<label for="partner-select" class="field-label" style="margin-right:12px">
|
||||
{{ Labels.PARTNER_SYSTEM }}
|
||||
</label>
|
||||
<div class="partner-selection-with-indicator">
|
||||
<p-dropdown id="partner-select" name="partnerSystem" [options]="partnerOptions" [(ngModel)]="selectedPartner"
|
||||
[placeholder]="Labels.SELECT_PARTNER_SYSTEM" (onChange)="onPartnerChange()" [style]="{'width': '200px'}"
|
||||
[disabled]="partnersLoading">
|
||||
<ng-template let-partner pTemplate="item">
|
||||
<span><strong>{{ partner.label }}</strong></span>
|
||||
</ng-template>
|
||||
</p-dropdown>
|
||||
|
||||
<!-- Success Indicator -->
|
||||
<i class="pi pi-check success-indicator"
|
||||
*ngIf="selectedPartner && selectedPartner !== SourceSystem.AGNAV && !partnerValidation.isValidating && partnerValidation.accountExists && partnerValidation.authenticationValid"
|
||||
[title]="Labels.PARTNER_VALIDATION_SUCCESS + ' - ' + partnerDisplayName">
|
||||
</i>
|
||||
|
||||
<!-- Loading Indicator -->
|
||||
<span *ngIf="partnersLoading" class="loading-indicator">
|
||||
<i class="pi pi-spin pi-spinner"></i> {{ Labels.LOADING_PARTNERS }}
|
||||
</span>
|
||||
|
||||
<!-- Validation Loading Indicator -->
|
||||
<i class="pi pi-spin pi-spinner validation-loading-indicator"
|
||||
*ngIf="selectedPartner && selectedPartner !== SourceSystem.AGNAV && partnerValidation.isValidating"
|
||||
[title]="Labels.VALIDATING_PARTNER_SYSTEM">
|
||||
</i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Partner System Validation Messages -->
|
||||
<div *ngIf="selectedPartner && selectedPartner !== SourceSystem.AGNAV" class="ui-g-12">
|
||||
<div class="partner-validation-section">
|
||||
|
||||
<!-- Validation in Progress -->
|
||||
<div *ngIf="partnerValidation.isValidating" class="ui-g-12 form-row">
|
||||
<agm-constraint-message [collapsible]="true" severity="info" [message]="Labels.VALIDATING_PARTNER_SYSTEM"
|
||||
[title]="Labels.VALIDATING_PARTNER_SYSTEM" icon="pi-spinner">
|
||||
</agm-constraint-message>
|
||||
</div>
|
||||
|
||||
<!-- Account Does Not Exist -->
|
||||
<div *ngIf="!partnerValidation.isValidating && !partnerValidation.accountExists" class="ui-g-12 form-row">
|
||||
<agm-constraint-message severity="warning" [title]="Labels.PARTNER_ACCOUNT_NOT_FOUND"
|
||||
[message]="Labels.PARTNER_ACCOUNT_CREATE_GUIDANCE" icon="pi-exclamation-triangle"
|
||||
[actionLabel]="Labels.CREATE_PARTNER_ACCOUNT" actionIcon="pi-plus"
|
||||
(actionClick)="navigateToAccountCreation()">
|
||||
</agm-constraint-message>
|
||||
</div>
|
||||
|
||||
<!-- Authentication Failed -->
|
||||
<div
|
||||
*ngIf="!partnerValidation.isValidating && partnerValidation.accountExists && !partnerValidation.authenticationValid"
|
||||
class="ui-g-12 form-row">
|
||||
<agm-constraint-message severity="error" [title]="Labels.PARTNER_AUTH_FAILED"
|
||||
[message]="Labels.PARTNER_AUTH_FIX_GUIDANCE" icon="pi-times" [actionLabel]="Labels.FIX_AUTHENTICATION"
|
||||
actionIcon="pi-plus" (actionClick)="navigateToAccountEdit()">
|
||||
</agm-constraint-message>
|
||||
</div>
|
||||
|
||||
<!-- Validation Error -->
|
||||
<div *ngIf="partnerValidation.validationError" class="ui-g-12 form-row">
|
||||
<agm-constraint-message severity="error" [title]="Labels.CONSTRAINT_ERROR_TITLE"
|
||||
[message]="partnerValidation.validationError" icon="pi-times">
|
||||
</agm-constraint-message>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Partner Aircraft Selection (shown when partner is selected and validated) -->
|
||||
<div *ngIf="selectedPartner && selectedPartner !== SourceSystem.AGNAV" class="ui-g-12 partner-aircraft-section">
|
||||
<div class="ui-g-12">
|
||||
<h4 style="margin: 0;">{{ Labels.AIRCRAFT_INTEGRATION }}</h4>
|
||||
|
||||
<!-- Step Progress Indicator - Only show for new vehicles -->
|
||||
<div *ngIf="isNew" class="integration-steps" role="progressbar" [attr.aria-valuenow]="getIntegrationProgress()"
|
||||
aria-valuemin="0" [attr.aria-valuemax]="getMaxIntegrationSteps()"
|
||||
[attr.aria-label]="Labels.INTEGRATION_PROGRESS_LABEL">
|
||||
<!-- Step 1: Select Partner System -->
|
||||
<div class="step" [class.completed]="true" [class.active]="!partnerValidation.isValidating">
|
||||
<div class="step-indicator">
|
||||
<i class="pi pi-check" *ngIf="!partnerValidation.isValidating"></i>
|
||||
<span class="step-number" *ngIf="partnerValidation.isValidating">1</span>
|
||||
</div>
|
||||
<span class="step-label">{{ Labels.SELECT_PARTNER_SYSTEM }}</span>
|
||||
</div>
|
||||
|
||||
<div class="step-connector"></div>
|
||||
|
||||
<!-- Step 2: Validate Partner Account -->
|
||||
<div class="step" [class.completed]="canEditPartnerFields"
|
||||
[class.active]="partnerValidation.isValidating || canEditPartnerFields">
|
||||
<div class="step-indicator">
|
||||
<i class="pi pi-check" *ngIf="canEditPartnerFields"></i>
|
||||
<i class="pi pi-spin pi-spinner" *ngIf="partnerValidation.isValidating"></i>
|
||||
<span class="step-number" *ngIf="!partnerValidation.isValidating && !canEditPartnerFields">2</span>
|
||||
</div>
|
||||
<span class="step-label">{{ Labels.VALIDATE_PARTNER_ACCOUNT }}</span>
|
||||
</div>
|
||||
|
||||
<div class="step-connector"></div>
|
||||
|
||||
<!-- Step 3: Select Partner Aircraft -->
|
||||
<div class="step" [class.completed]="selectedPartnerAircraft"
|
||||
[class.active]="canEditPartnerFields && !partnerAircraftLoading">
|
||||
<div class="step-indicator">
|
||||
<i class="pi pi-check" *ngIf="selectedPartnerAircraft"></i>
|
||||
<i class="pi pi-spin pi-spinner" *ngIf="partnerAircraftLoading"></i>
|
||||
<span class="step-number" *ngIf="!selectedPartnerAircraft && !partnerAircraftLoading">3</span>
|
||||
</div>
|
||||
<span class="step-label">{{ Labels.SELECT_PARTNER_AIRCRAFT }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: Select System Type (Satloc Only) -->
|
||||
<ng-container *ngIf="isSatlocPartnerSelected">
|
||||
<div class="step-connector"></div>
|
||||
|
||||
<div class="step" [class.completed]="isSystemTypeStepCompleted"
|
||||
[class.active]="selectedPartnerAircraft && !selectedSystemType">
|
||||
<div class="step-indicator">
|
||||
<i class="pi pi-check" *ngIf="isSystemTypeStepCompleted"></i>
|
||||
<span class="step-number" *ngIf="!isSystemTypeStepCompleted">4</span>
|
||||
</div>
|
||||
<span class="step-label">
|
||||
{{ Labels.SELECT_SYSTEM_TYPE_PLACEHOLDER }}
|
||||
</span>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Aircraft Selection Section - Always visible when partner is selected -->
|
||||
<div *ngIf="canEditPartnerFields" class="ui-g-12">
|
||||
<!-- Loading indicator -->
|
||||
<div *ngIf="partnerAircraftLoading" class="ui-g-12 form-row">
|
||||
<div class="loading-indicator">
|
||||
<i class="pi pi-spin pi-spinner"></i>
|
||||
<span>{{ Labels.LOADING_AVAILABLE_AIRCRAFT }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Aircraft Selection Dropdown -->
|
||||
<div *ngIf="!partnerAircraftLoading" class="ui-g-12">
|
||||
<div class="ui-g-12 ui-lg-6">
|
||||
<label for="aircraft-select" class="field-label" style="margin-right:12px">
|
||||
{{ Labels.AVAILABLE_AIRCRAFT }}:
|
||||
<span *ngIf="!selectedPartnerAircraft" style="color: #e74c3c; margin-left: 4px;"
|
||||
[title]="Labels.REQUIRED_FOR_PARTNER_INTEGRATION_TOOLTIP">*</span>
|
||||
</label>
|
||||
<p-dropdown id="aircraft-select" name="partnerAircraft" [options]="partnerAircraftOptions"
|
||||
[(ngModel)]="selectedPartnerAircraft" [placeholder]="Labels.SELECT_AIRCRAFT"
|
||||
(onChange)="onPartnerAircraftChange()" [style]="{'width': '250px'}"
|
||||
[disabled]="!partnerAircraftOptions.length" [class.p-invalid]="!selectedPartnerAircraft">
|
||||
<ng-template let-aircraft pTemplate="item">
|
||||
<span><strong>{{ aircraft.label }}</strong> <small class="aircraft-id">({{ aircraft.value
|
||||
}})</small></span>
|
||||
</ng-template>
|
||||
</p-dropdown>
|
||||
</div>
|
||||
|
||||
<!-- System Type Selection for Satloc Partners -->
|
||||
<div *ngIf="isSatlocPartnerSelected" class="ui-g-12 ui-lg-6">
|
||||
<label for="system-type-select" class="field-label" style="margin-right:12px">
|
||||
{{ Labels.SYSTEM_TYPE }}:
|
||||
<span *ngIf="!selectedSystemType" style="color: #e74c3c; margin-left: 4px;"
|
||||
[title]="Labels.REQUIRED_FOR_SATLOC_INTEGRATION_TOOLTIP">*</span>
|
||||
</label>
|
||||
<p-dropdown id="system-type-select" name="systemType" [options]="systemTypeOptions"
|
||||
[(ngModel)]="selectedSystemType" [placeholder]="Labels.SELECT_SYSTEM_TYPE_PLACEHOLDER"
|
||||
(onChange)="onSystemTypeChange()" [style]="{'width': '180px'}"
|
||||
[title]="Labels.SYSTEM_TYPE_SELECTION_TOOLTIP" [disabled]="!selectedPartnerAircraft"
|
||||
[class.p-invalid]="selectedPartnerAircraft && !selectedSystemType">
|
||||
<ng-template let-systemType pTemplate="item">
|
||||
<span><strong>{{ systemType.label }}</strong></span>
|
||||
</ng-template>
|
||||
</p-dropdown>
|
||||
</div>
|
||||
|
||||
<!-- Enhanced Aircraft Information Panel with System Type -->
|
||||
<div *ngIf="selectedPartnerAircraft && selectedPartnerAircraftDetails" class="ui-g-12">
|
||||
<div class="enhanced-aircraft-info-panel" role="alert" aria-live="polite"
|
||||
[attr.aria-label]="Labels.SELECTED_AIRCRAFT_DETAILS + ': ' + selectedPartnerAircraftDetails.id">
|
||||
<div class="aircraft-info-content">
|
||||
<!-- Success Icon -->
|
||||
<i class="pi pi-check aircraft-info-icon" aria-hidden="true"></i>
|
||||
|
||||
<!-- Aircraft Information -->
|
||||
<div class="aircraft-info-text">
|
||||
<div class="aircraft-info-header">
|
||||
<strong class="aircraft-info-title">{{ Labels.SELECTED_AIRCRAFT_DETAILS }}</strong>
|
||||
<agm-badge [config]="getPartnerIntegratedBadge()"></agm-badge>
|
||||
</div>
|
||||
|
||||
<div class="aircraft-details">
|
||||
<div class="detail-row">
|
||||
<strong>{{ Labels.AIRCRAFT_ID }}:</strong> {{ selectedPartnerAircraftDetails.id }}
|
||||
</div>
|
||||
|
||||
<!-- System Type Information for Satloc Partners -->
|
||||
<div *ngIf="isSatlocPartnerSelected" class="detail-row system-type-row">
|
||||
<strong>
|
||||
{{ Labels.SYSTEM_TYPE }}:
|
||||
</strong>
|
||||
<span *ngIf="selectedSystemType" class="system-type-value">
|
||||
{{ getSelectedSystemTypeLabel() }}
|
||||
</span>
|
||||
<span *ngIf="!selectedSystemType" class="system-type-pending">
|
||||
<i class="pi pi-exclamation-triangle" style="color: #f39c12; margin-right: 4px;"></i>
|
||||
<em>{{ Labels.SYSTEM_TYPE_SELECTION_REQUIRED }}</em>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Aircraft Available Message (only show when no error) -->
|
||||
<div *ngIf="!partnerAircraftLoading && !partnerAircraftOptions.length && !partnerAircraftError"
|
||||
class="ui-g-12 form-row">
|
||||
<agm-constraint-message [collapsible]="true" severity="info" [title]="Labels.NO_AIRCRAFT_AVAILABLE_TITLE"
|
||||
[message]="Labels.NO_AVAILABLE_AIRCRAFT_FOUND + ' ' + partnerDisplayName + '.'" icon="pi-info-circle">
|
||||
</agm-constraint-message>
|
||||
</div>
|
||||
|
||||
<!-- Error Display (takes priority over no aircraft message) -->
|
||||
<div *ngIf="partnerAircraftError" class="ui-g-12 form-row">
|
||||
<agm-constraint-message severity="error" [title]="Labels.PARTNER_AIRCRAFT_ERROR_TITLE"
|
||||
[message]="partnerAircraftError" icon="pi-times">
|
||||
</agm-constraint-message>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Aircraft Selection Preview - Shown when partner selected but not validated -->
|
||||
<div *ngIf="!canEditPartnerFields && selectedPartner && selectedPartner !== SourceSystem.AGNAV" class="ui-g-12">
|
||||
<div class="ui-g-6">
|
||||
<div class="input-with-inline-constraint">
|
||||
<label class="field-label" style="margin-right:12px">{{ Labels.AVAILABLE_AIRCRAFT }}:</label>
|
||||
<p-dropdown [placeholder]="Labels.COMPLETE_VALIDATION_FIRST" [disabled]="true" [style]="{'width': '250px'}"
|
||||
class="preview-disabled">
|
||||
</p-dropdown>
|
||||
|
||||
<!-- Partner validation required icon (detached mode) -->
|
||||
<agm-constraint-message #partnerValidationConstraint [collapsible]="true" [collapsed]="true" [detached]="true"
|
||||
[message]="Labels.AIRCRAFT_SELECTION_AVAILABLE_AFTER_VALIDATION"
|
||||
[title]="Labels.PARTNER_VALIDATION_REQUIRED_TITLE" severity="info" icon="pi-info-circle"
|
||||
class="inline-constraint">
|
||||
</agm-constraint-message>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Partner validation constraint message -->
|
||||
<div class="ui-g-12" style="margin-top: 8px;">
|
||||
<ng-container *ngTemplateOutlet="partnerValidationConstraint?.detachedContentTemplate"></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
File diff suppressed because it is too large
Load Diff
15
Development/client/src/app/guards/vendor.guard.ts
Normal file
15
Development/client/src/app/guards/vendor.guard.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree } from '@angular/router';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class VendorGuard implements CanActivate {
|
||||
canActivate(
|
||||
next: ActivatedRouteSnapshot,
|
||||
state: RouterStateSnapshot): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
@ -16,6 +16,7 @@ import { CurrencyPipe } from '@angular/common';
|
||||
import { Dropdown } from 'primeng/dropdown';
|
||||
import { MultiSelect } from 'primeng/multiselect';
|
||||
import { DomUtils } from '@app/shared/dom-util';
|
||||
import { GAService } from '@app/shared/ga.service';
|
||||
|
||||
@Component({
|
||||
selector: 'agm-costing-item',
|
||||
@ -132,6 +133,20 @@ export class CostingItemComponent extends BaseComp implements OnInit, OnDestroy
|
||||
const payload = {
|
||||
...this.selectedItem
|
||||
};
|
||||
|
||||
// Track costing item management
|
||||
this.gaSvc.trackInvoiceCostingItemManaged({
|
||||
item_id: payload._id !== '0' ? payload._id : undefined,
|
||||
item_type: this.mapItemType(payload.type),
|
||||
unit_type: this.mapUnitType(payload.unit),
|
||||
base_rate: payload.price || 0,
|
||||
action_type: payload._id === '0' ? 'created' : 'updated',
|
||||
affects_existing_invoices: payload._id !== '0', // Updates affect existing invoices
|
||||
user_id: this.getAnalyticsUserId(),
|
||||
user_role: this.getAnalyticsUserRole(),
|
||||
platform: 'web'
|
||||
});
|
||||
|
||||
if (payload._id === '0') {
|
||||
this.store.dispatch(new costingItemActions.Create(payload));
|
||||
} else {
|
||||
@ -176,6 +191,19 @@ export class CostingItemComponent extends BaseComp implements OnInit, OnDestroy
|
||||
this.confirmSvc.confirm({
|
||||
message: globals.confirmDeleteThing.replace('#thing#', $localize`:@@costingItem:Costing item`),
|
||||
accept: () => {
|
||||
// Track costing item deletion
|
||||
this.gaSvc.trackInvoiceCostingItemManaged({
|
||||
item_id: item._id,
|
||||
item_type: this.mapItemType(item.type),
|
||||
unit_type: this.mapUnitType(item.unit),
|
||||
base_rate: item.price || 0,
|
||||
action_type: 'deleted',
|
||||
affects_existing_invoices: true, // Deletions affect existing invoices
|
||||
user_id: this.getAnalyticsUserId(),
|
||||
user_role: this.getAnalyticsUserRole(),
|
||||
platform: 'web'
|
||||
});
|
||||
|
||||
this.store.dispatch(new costingItemActions.Delete(item));
|
||||
}
|
||||
});
|
||||
@ -204,4 +232,29 @@ export class CostingItemComponent extends BaseComp implements OnInit, OnDestroy
|
||||
ngOnDestroy() {
|
||||
super.ngOnDestroy();
|
||||
}
|
||||
|
||||
private mapItemType(type: number): 'service' | 'material' | 'equipment' | 'labor' {
|
||||
switch (type) {
|
||||
case CostingItemType.BY_ACRE:
|
||||
case CostingItemType.BY_HA:
|
||||
return 'service';
|
||||
case CostingItemType.BY_AMOUNT:
|
||||
return 'material';
|
||||
default:
|
||||
return 'service';
|
||||
}
|
||||
}
|
||||
|
||||
private mapUnitType(unit: number): 'per_acre' | 'per_hour' | 'flat_rate' | 'per_unit' {
|
||||
switch (unit) {
|
||||
case CostingItemUnit.ACRE:
|
||||
return 'per_acre';
|
||||
case CostingItemUnit.HOUR:
|
||||
return 'per_hour';
|
||||
case CostingItemUnit.HA:
|
||||
return 'per_unit';
|
||||
default:
|
||||
return 'flat_rate';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,13 +2,14 @@ import { AfterViewInit, Component, ElementRef, OnDestroy, OnInit, ViewChild } fr
|
||||
import { BaseComp } from '@app/shared/base/base.component';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { createNewCustomerSetting, CustomerInvoiceSetting } from '@app/invoices/models/customer-invoice-setting.model';
|
||||
import { allowedLogoFormats, globals, maxLogoSize, allowedLogoFileExt } from '@app/shared/global';
|
||||
import { allowedLogoFormats, globals, maxLogoSize, allowedLogoFileExt, RoleIds } from '@app/shared/global';
|
||||
import { select } from '@ngrx/store';
|
||||
import * as fromClients from '@app/client/reducers';
|
||||
import * as SettingActions from '@app/invoices/actions/setting.actions';
|
||||
import { SelectItem } from 'primeng/api';
|
||||
import { InvoiceService } from '@app/domain/services/invoice.service';
|
||||
import { InputNumber } from 'primeng/inputnumber';
|
||||
import { GAService } from '@app/shared/ga.service';
|
||||
|
||||
@Component({
|
||||
selector: 'agm-invoices-customer-settings',
|
||||
@ -233,6 +234,20 @@ export class CustomerSettingsComponent extends BaseComp implements OnInit, After
|
||||
address: this.setting?.address?.trim(),
|
||||
};
|
||||
payload.paymentTerm = this.paymentTerm;
|
||||
|
||||
// Track settings changes
|
||||
const settingsModified = this.getModifiedSettings(payload);
|
||||
this.gaSvc.trackCustomerInvoiceSettingsUpdated({
|
||||
client_id: this.currClient?._id || 'unknown',
|
||||
settings_modified: settingsModified,
|
||||
automation_enabled: this.hasAutomationEnabled(payload),
|
||||
payment_terms_changed: this.hasPaymentTermsChanged(payload),
|
||||
billing_preferences_updated: this.hasBillingPreferencesUpdated(payload),
|
||||
user_id: this.authSvc.user?._id,
|
||||
user_role: this.getUserRole(),
|
||||
platform: 'web'
|
||||
});
|
||||
|
||||
if (this.isNew) {
|
||||
this.store.dispatch(new SettingActions.Create(payload));
|
||||
} else {
|
||||
@ -259,4 +274,50 @@ export class CustomerSettingsComponent extends BaseComp implements OnInit, After
|
||||
ngOnDestroy(): void {
|
||||
super.ngOnDestroy();
|
||||
}
|
||||
|
||||
private getModifiedSettings(payload: CustomerInvoiceSetting): string[] {
|
||||
const modified: string[] = [];
|
||||
|
||||
// Compare with original setting or default values
|
||||
const original = this.isNew ? this.invoiceSvc.defaultSetting : this.setting;
|
||||
|
||||
if (payload.companyName !== original.companyName) modified.push('company_name');
|
||||
if (payload.address !== original.address) modified.push('address');
|
||||
if (payload.taxValue !== original.taxValue) modified.push('tax_value');
|
||||
if (payload.discount !== original.discount) modified.push('discount');
|
||||
if (payload.paymentTerm !== original.paymentTerm) modified.push('payment_term');
|
||||
if (payload.note !== original.note) modified.push('note');
|
||||
if (payload.logo !== original.logo) modified.push('logo');
|
||||
|
||||
return modified;
|
||||
}
|
||||
|
||||
private hasAutomationEnabled(payload: CustomerInvoiceSetting): boolean {
|
||||
// Check if any automation features are enabled
|
||||
return payload.taxValue > 0 || payload.discount > 0 || payload.paymentTerm > 0;
|
||||
}
|
||||
|
||||
private hasPaymentTermsChanged(payload: CustomerInvoiceSetting): boolean {
|
||||
const original = this.isNew ? this.invoiceSvc.defaultSetting : this.setting;
|
||||
return payload.paymentTerm !== original.paymentTerm;
|
||||
}
|
||||
|
||||
private hasBillingPreferencesUpdated(payload: CustomerInvoiceSetting): boolean {
|
||||
const original = this.isNew ? this.invoiceSvc.defaultSetting : this.setting;
|
||||
return payload.companyName !== original.companyName ||
|
||||
payload.address !== original.address ||
|
||||
payload.logo !== original.logo;
|
||||
}
|
||||
|
||||
private getUserRole(): 'admin' | 'applicator' | 'office_admin' | 'client' | 'officer' | 'pilot' | 'inspector' | 'aircraft' {
|
||||
const roles = this.authSvc.user?.roles || [];
|
||||
if (roles.includes(RoleIds.ADMIN)) return 'admin';
|
||||
if (roles.includes(RoleIds.APP)) return 'applicator';
|
||||
if (roles.includes(RoleIds.APP_ADM)) return 'office_admin';
|
||||
if (roles.includes(RoleIds.PILOT)) return 'pilot';
|
||||
if (roles.includes(RoleIds.OFFICER)) return 'officer';
|
||||
if (roles.includes(RoleIds.INSPECTOR)) return 'inspector';
|
||||
if (roles.includes(RoleIds.DEVICE)) return 'aircraft';
|
||||
return 'client';
|
||||
}
|
||||
}
|
||||
|
||||
@ -253,7 +253,7 @@
|
||||
<div class="ui-g ui-g-nopad ui-g-12">
|
||||
<div class="ui-g-nopad ui-g-7">
|
||||
<div *ngIf="printDetail.client.logo" class="ui-g-12 ui-g-nopad" style="margin-bottom: 20px;">
|
||||
<img [src]="printDetail.client.logo" alt="" style="max-width: 90%; max-height: 120px; object-fit: contain" />
|
||||
<!-- <img [src]="printDetail.client.logo" alt="" style="max-width: 90%; max-height: 120px; object-fit: contain" /> -->
|
||||
<div style="font-size: 12px; color: #212121;" i18n="@@poweredByAgnavAlt">Powered by AgMission - AgNav Inc.</div>
|
||||
</div>
|
||||
<div class="ui-g ui-g-nopad ui-g-12">
|
||||
|
||||
@ -170,6 +170,18 @@ export class InvoiceDetailComponent extends BaseComp implements OnInit, OnDestro
|
||||
...c,
|
||||
...this.billToListPriceObject(c)
|
||||
}));
|
||||
|
||||
// Track invoice viewed
|
||||
this.gaSvc.trackInvoiceViewed({
|
||||
invoice_id: invoice._id,
|
||||
invoice_status: invoice.status,
|
||||
invoice_amount: this.calculateInvoiceAmount(invoice),
|
||||
view_source: 'direct_link',
|
||||
client_id: invoice.clients?.[0]?.billTo?._id || 'unknown',
|
||||
user_id: this.getAnalyticsUserId(),
|
||||
user_role: this.getAnalyticsUserRole(),
|
||||
platform: 'web'
|
||||
});
|
||||
} else {
|
||||
this.goBack();
|
||||
}
|
||||
@ -272,6 +284,20 @@ export class InvoiceDetailComponent extends BaseComp implements OnInit, OnDestro
|
||||
delete payload.amountDue;
|
||||
this.invoiceSvc.createLogPayment(payload).subscribe(log => {
|
||||
if (log) {
|
||||
// Track payment logging
|
||||
this.gaSvc.trackInvoicePaymentLogged({
|
||||
invoice_id: this.invoice._id,
|
||||
payment_amount: this.logPaymentForm.amount || 0,
|
||||
payment_method: this.gaHelpers.mapPaymentMethod(this.logPaymentForm.paymentMethod),
|
||||
payment_date: this.logPaymentForm.paymentDate?.toISOString().split('T')[0] || new Date().toISOString().split('T')[0],
|
||||
remaining_balance: this.gaHelpers.calculateRemainingBalance(this.invoice),
|
||||
days_to_payment: this.gaHelpers.calculateDaysToPayment(new Date(this.invoice.openDate), this.logPaymentForm.paymentDate),
|
||||
payment_reference: this.gaHelpers.generatePaymentReference(this.invoice?.code || 'INV'),
|
||||
user_id: this.getAnalyticsUserId(),
|
||||
user_role: this.getAnalyticsUserRole(),
|
||||
platform: 'web'
|
||||
});
|
||||
|
||||
this.logPaymentDlg = false;
|
||||
this.msgSvc.addSuccessMsg($localize`:@@logPaymentSucceeded: Create log payment succeeded`);
|
||||
this.fetchInvoiceDetail(this.invoice._id);
|
||||
@ -305,6 +331,18 @@ export class InvoiceDetailComponent extends BaseComp implements OnInit, OnDestro
|
||||
(res) => {
|
||||
try {
|
||||
saveAs(res, `Agmission_invoice_${this.invoice.code}_${this.datePipe.transform(new Date(), 'yyyy-MM-ddTHH-mm-ss')}.csv`);
|
||||
|
||||
// Track invoice export
|
||||
this.gaSvc.trackInvoiceExported({
|
||||
invoice_id: this.invoice._id,
|
||||
export_format: 'csv',
|
||||
invoice_amount: this.calculateInvoiceAmount(this.invoice),
|
||||
export_method: 'single',
|
||||
includes_job_details: true,
|
||||
user_id: this.getAnalyticsUserId(),
|
||||
user_role: this.getAnalyticsUserRole(),
|
||||
platform: 'web'
|
||||
});
|
||||
} catch (error) {
|
||||
alert('Sorry. Your browser does not support this feature !');
|
||||
}
|
||||
@ -319,6 +357,18 @@ export class InvoiceDetailComponent extends BaseComp implements OnInit, OnDestro
|
||||
(res) => {
|
||||
try {
|
||||
saveAs(res, `Agmission_invoice_${this.invoice.code}_${this.datePipe.transform(new Date(), 'yyyy-MM-ddTHH-mm-ss')}.iif`);
|
||||
|
||||
// Track invoice export
|
||||
this.gaSvc.trackInvoiceExported({
|
||||
invoice_id: this.invoice._id,
|
||||
export_format: 'iif',
|
||||
invoice_amount: this.calculateInvoiceAmount(this.invoice),
|
||||
export_method: 'single',
|
||||
includes_job_details: true,
|
||||
user_id: this.getAnalyticsUserId(),
|
||||
user_role: this.getAnalyticsUserRole(),
|
||||
platform: 'web'
|
||||
});
|
||||
} catch (error) {
|
||||
alert('Sorry. Your browser does not support this feature !');
|
||||
}
|
||||
@ -336,6 +386,14 @@ export class InvoiceDetailComponent extends BaseComp implements OnInit, OnDestro
|
||||
DomUtils.hide(elts)
|
||||
}
|
||||
|
||||
private calculateInvoiceAmount(invoice: any): number {
|
||||
if (!invoice) return 0;
|
||||
if (invoice.status == invoiceStatus.VOID) {
|
||||
return 0;
|
||||
}
|
||||
return Utils.arraySum(invoice?.clients?.map(client => this.billToListPriceObject(client).total) || [0]);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
super.ngOnDestroy();
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
|
||||
import { BaseComp } from '@app/shared/base/base.component';
|
||||
import * as invoiceAction from '../actions/invoice.actions';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { globals, invoiceStatus } from '@app/shared/global';
|
||||
import { globals, invoiceStatus, RoleIds } from '@app/shared/global';
|
||||
import { Invoice } from '@app/invoices/models/invoice.model';
|
||||
import { InvoiceService } from '@app/domain/services/invoice.service';
|
||||
import { SelectItem } from 'primeng/api';
|
||||
@ -16,6 +16,7 @@ import { filter, map, switchMap } from 'rxjs/operators';
|
||||
import { DomUtils } from '@app/shared/dom-util';
|
||||
import { MultiSelect } from 'primeng/multiselect';
|
||||
import { ClientService } from '@app/domain/services/client.service';
|
||||
import { GAService } from '@app/shared/ga.service';
|
||||
|
||||
@Component({
|
||||
selector: 'agm-invoice-edit',
|
||||
@ -845,6 +846,21 @@ export class InvoiceEditComponent extends BaseComp implements OnInit, OnDestroy
|
||||
private handleLogPaymentSuccess(logs: any[], invoice: Invoice) {
|
||||
this.logPaymentList = logs;
|
||||
this.logPaymentDlg = false;
|
||||
|
||||
// Track payment logging
|
||||
this.gaSvc.trackInvoicePaymentLogged({
|
||||
invoice_id: this.invoice._id,
|
||||
payment_amount: this.logPaymentForm.amount || 0,
|
||||
payment_method: this.gaHelpers.mapPaymentMethod(this.logPaymentForm.paymentMethod),
|
||||
payment_date: this.logPaymentForm.paymentDate?.toISOString().split('T')[0] || new Date().toISOString().split('T')[0],
|
||||
remaining_balance: this.gaHelpers.calculateRemainingBalance(invoice),
|
||||
days_to_payment: this.gaHelpers.calculateDaysToPayment(this.invoice?.openDate, this.logPaymentForm.paymentDate),
|
||||
payment_reference: this.gaHelpers.generatePaymentReference(this.invoice?.code),
|
||||
user_id: this.getAnalyticsUserId(),
|
||||
user_role: this.getAnalyticsUserRole(),
|
||||
platform: 'web'
|
||||
});
|
||||
|
||||
this.store.dispatch(new invoiceAction.FetchSuccess([invoice]));
|
||||
}
|
||||
|
||||
@ -862,6 +878,20 @@ export class InvoiceEditComponent extends BaseComp implements OnInit, OnDestroy
|
||||
_id: this.invoice._id,
|
||||
status: invoiceStatus.OPEN
|
||||
};
|
||||
|
||||
// Track invoice status change
|
||||
this.gaSvc.trackInvoiceStatusChanged({
|
||||
invoice_id: this.invoice._id,
|
||||
old_status: this.invoice.status as any,
|
||||
new_status: invoiceStatus.OPEN as any,
|
||||
status_change_reason: 'user_action',
|
||||
total_amount: this.totalTotal || 0,
|
||||
days_in_previous_status: this.calculateDaysInStatus(),
|
||||
user_id: this.authSvc.user?._id,
|
||||
user_role: this.getUserRole(),
|
||||
platform: 'web'
|
||||
});
|
||||
|
||||
this.store.dispatch(new invoiceAction.Update(payload));
|
||||
}
|
||||
|
||||
@ -880,8 +910,36 @@ export class InvoiceEditComponent extends BaseComp implements OnInit, OnDestroy
|
||||
const payload = this.prepareInvoicePayload();
|
||||
if (this.isNew) {
|
||||
this.store.dispatch(new invoiceAction.Create(payload));
|
||||
|
||||
// Track invoice creation
|
||||
this.gaSvc.trackInvoiceCreated({
|
||||
invoice_id: payload._id || 'new',
|
||||
client_id: this.selectedClients?.[0]?.billTo?._id || 'unknown',
|
||||
total_amount: this.totalTotal || 0,
|
||||
currency: 'USD',
|
||||
job_count: this.selectedJobs?.length || 0,
|
||||
creation_method: 'manual',
|
||||
due_date_days: this.calculateDueDateDays(payload.dueDate),
|
||||
payment_terms: String(payload.paymentTerm) || 'net_30',
|
||||
user_id: this.authSvc.user?._id,
|
||||
user_role: this.getUserRole(),
|
||||
platform: 'web'
|
||||
});
|
||||
} else {
|
||||
this.store.dispatch(new invoiceAction.Update(payload));
|
||||
|
||||
// Track invoice update
|
||||
this.gaSvc.trackInvoiceUpdated({
|
||||
invoice_id: this.invoice._id,
|
||||
fields_modified: this.getModifiedFields(),
|
||||
amount_change: this.calculateAmountChange(),
|
||||
previous_status: this._orgInvoice?.status,
|
||||
current_status: this.invoice.status,
|
||||
modification_type: this.determineModificationType(),
|
||||
user_id: this.authSvc.user?._id,
|
||||
user_role: this.getUserRole(),
|
||||
platform: 'web'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -977,6 +1035,22 @@ export class InvoiceEditComponent extends BaseComp implements OnInit, OnDestroy
|
||||
if (invoice) {
|
||||
paymentPayload = paymentPayload.map(i => ({ ...i, invoiceId: invoice._id }));
|
||||
this.invoiceSvc.createListLogPayment(paymentPayload).subscribe(res => {
|
||||
// Track bulk payment logging for new invoice
|
||||
paymentPayload.forEach((payment, index) => {
|
||||
this.gaSvc.trackInvoicePaymentLogged({
|
||||
invoice_id: invoice._id,
|
||||
payment_amount: parseFloat(payment.amount) || 0,
|
||||
payment_method: this.gaHelpers.mapPaymentMethod(payment.paymentMethod),
|
||||
payment_date: payment.paymentDate?.toISOString?.()?.split('T')[0] || new Date().toISOString().split('T')[0],
|
||||
remaining_balance: 0, // Full payment scenario
|
||||
days_to_payment: this.gaHelpers.calculateDaysToPayment(this.invoice?.openDate, payment.paymentDate),
|
||||
payment_reference: `${this.gaHelpers.generatePaymentReference(this.invoice?.code)}-${index + 1}`,
|
||||
user_id: this.getAnalyticsUserId(),
|
||||
user_role: this.getAnalyticsUserRole(),
|
||||
platform: 'web'
|
||||
});
|
||||
});
|
||||
|
||||
this.store.dispatch(new invoiceAction.CreateSuccess(invoice));
|
||||
}, err => {
|
||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.create).replace('#thing#', $localize`:@@logPayment:Log Payment`));
|
||||
@ -992,6 +1066,22 @@ export class InvoiceEditComponent extends BaseComp implements OnInit, OnDestroy
|
||||
if (invoice) {
|
||||
paymentPayload = paymentPayload.map(i => ({ ...i, invoiceId: invoice._id }));
|
||||
this.invoiceSvc.createListLogPayment(paymentPayload).subscribe(res => {
|
||||
// Track bulk payment logging for updated invoice
|
||||
paymentPayload.forEach((payment, index) => {
|
||||
this.gaSvc.trackInvoicePaymentLogged({
|
||||
invoice_id: invoice._id,
|
||||
payment_amount: parseFloat(payment.amount) || 0,
|
||||
payment_method: this.gaHelpers.mapPaymentMethod(payment.paymentMethod),
|
||||
payment_date: payment.paymentDate?.toISOString?.()?.split('T')[0] || new Date().toISOString().split('T')[0],
|
||||
remaining_balance: 0, // Full payment scenario
|
||||
days_to_payment: this.gaHelpers.calculateDaysToPayment(this.invoice?.openDate, payment.paymentDate),
|
||||
payment_reference: `${this.gaHelpers.generatePaymentReference(this.invoice?.code)}-${index + 1}`,
|
||||
user_id: this.getAnalyticsUserId(),
|
||||
user_role: this.getAnalyticsUserRole(),
|
||||
platform: 'web'
|
||||
});
|
||||
});
|
||||
|
||||
this.store.dispatch(new invoiceAction.UpdateSuccess(invoice));
|
||||
}, err => {
|
||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.create).replace('#thing#', $localize`:@@logPayment:Log Payment`));
|
||||
@ -1002,6 +1092,70 @@ export class InvoiceEditComponent extends BaseComp implements OnInit, OnDestroy
|
||||
});
|
||||
}
|
||||
|
||||
// GA4 Analytics Helper Methods
|
||||
private calculateDueDateDays(dueDate: Date): number {
|
||||
if (!dueDate) return 30; // Default to 30 days
|
||||
const today = new Date();
|
||||
const due = new Date(dueDate);
|
||||
const timeDiff = due.getTime() - today.getTime();
|
||||
return Math.ceil(timeDiff / (1000 * 3600 * 24));
|
||||
}
|
||||
|
||||
private getModifiedFields(): string[] {
|
||||
const fields = [];
|
||||
if (!this._orgInvoice) return fields;
|
||||
|
||||
// Compare key fields
|
||||
if (this.invoice.status !== this._orgInvoice.status) fields.push('status');
|
||||
if (this.invoice.dueDate !== this._orgInvoice.dueDate) fields.push('due_date');
|
||||
if (this.invoice.paymentTerm !== this._orgInvoice.paymentTerm) fields.push('payment_terms');
|
||||
if (this.selectedJobs?.length !== this._orgSelectedJobs?.length) fields.push('jobs');
|
||||
if (this.selectedClients?.length !== this._orgSelectedClients?.length) fields.push('clients');
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
private calculateAmountChange(): number {
|
||||
if (!this._orgInvoice) return 0;
|
||||
const currentAmount = this.totalTotal || 0;
|
||||
const originalAmount = this.calculateInvoiceAmount(this._orgInvoice);
|
||||
return currentAmount - originalAmount;
|
||||
}
|
||||
|
||||
private determineModificationType(): 'amount' | 'due_date' | 'jobs' | 'customer' | 'payment_terms' {
|
||||
const modifiedFields = this.getModifiedFields();
|
||||
if (modifiedFields.includes('jobs')) return 'jobs';
|
||||
if (modifiedFields.includes('clients')) return 'customer';
|
||||
if (modifiedFields.includes('payment_terms')) return 'payment_terms';
|
||||
if (modifiedFields.includes('due_date')) return 'due_date';
|
||||
return 'amount';
|
||||
}
|
||||
|
||||
private getUserRole(): 'admin' | 'applicator' | 'office_admin' | 'client' | 'officer' | 'pilot' | 'inspector' | 'aircraft' {
|
||||
const roles = this.authSvc.user?.roles || [];
|
||||
if (roles.includes(RoleIds.ADMIN)) return 'admin';
|
||||
if (roles.includes(RoleIds.APP)) return 'applicator';
|
||||
if (roles.includes(RoleIds.APP_ADM)) return 'office_admin';
|
||||
if (roles.includes(RoleIds.PILOT)) return 'pilot';
|
||||
if (roles.includes(RoleIds.OFFICER)) return 'officer';
|
||||
if (roles.includes(RoleIds.INSPECTOR)) return 'inspector';
|
||||
if (roles.includes(RoleIds.DEVICE)) return 'aircraft';
|
||||
return 'client';
|
||||
}
|
||||
|
||||
private calculateInvoiceAmount(invoice: any): number {
|
||||
if (!invoice) return 0;
|
||||
return invoice.totalAmount || 0;
|
||||
}
|
||||
|
||||
private calculateDaysInStatus(): number {
|
||||
if (!this.invoice?.openDate) return 0;
|
||||
const statusDate = new Date(this.invoice.openDate);
|
||||
const now = new Date();
|
||||
const timeDiff = now.getTime() - statusDate.getTime();
|
||||
return Math.ceil(timeDiff / (1000 * 3600 * 24));
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
super.ngOnDestroy();
|
||||
}
|
||||
|
||||
@ -17,11 +17,11 @@
|
||||
<th *ngFor="let col of columns" [ngSwitch]="col.filtered" class="ui-fluid">
|
||||
<div class="input-with-icon" *ngSwitchCase="true">
|
||||
<i class="ui-icon-search"></i>
|
||||
<input pInputText type="text" (input)="il.filter($event.target.value, col.field, col.filterMatchMode)" [value]="il.filters[col.field]?.value">
|
||||
<input pInputText type="text" (input)="onTextFilter($event, col.field, col.filterMatchMode)" [value]="il.filters[col.field]?.value">
|
||||
</div>
|
||||
<p-calendar #odf *ngIf="col.field == 'openDate'" selectionMode="range" [(ngModel)]="openDateRange" [locale]="locale" placeholder=" " [readonlyInput]="true" [showButtonBar]="true" [showIcon]="true" [dateFormat]="locale.dateFormat" (onSelect)="handleCalDateRange(openDateRange, col.field, openDateFilter)" (onClearClick)="il.filter('', col.field, 'equals')" (onClose)="closeCal(openDateRange, col.field, openDateFilter)"></p-calendar>
|
||||
<p-calendar #ddf *ngIf="col.field == 'dueDate'" selectionMode="range" [(ngModel)]="dueDateRange" [locale]="locale" placeholder=" " [readonlyInput]="true" [showButtonBar]="true" [showIcon]="true" [dateFormat]="locale.dateFormat" (onSelect)="handleCalDateRange(dueDateRange, col.field, dueDateFilter)" (onClearClick)="il.filter('', col.field, 'equals')" (onClose)="closeCal(dueDateRange, col.field, dueDateFilter)"></p-calendar>
|
||||
<p-multiSelect *ngIf="col.field === 'status'" [options]="status" [(ngModel)]="statusFilter" i18n-defaultLabel="@@all" defaultLabel="All" (onChange)="il.filter($event.value, 'status', 'in')"></p-multiSelect>
|
||||
<p-multiSelect *ngIf="col.field === 'status'" [options]="status" [(ngModel)]="statusFilter" i18n-defaultLabel="@@all" defaultLabel="All" (onChange)="onStatusFilter($event)"></p-multiSelect>
|
||||
<span *ngSwitchDefault></span>
|
||||
</th>
|
||||
</tr>
|
||||
@ -30,7 +30,7 @@
|
||||
<tr [pSelectableRow]="invoice" [pSelectableRowIndex]="rowIndex">
|
||||
<td class="table-col-center"><span class="ui-column-title">{{cols[0].header}}</span>{{invoice.totalAmount | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}}</td>
|
||||
<td class="table-col-center"><span class="ui-column-title">{{cols[1].header}}</span>{{invoice.code}}</td>
|
||||
<td class="table-col-center"><span class="ui-column-title">{{cols[2].header}}</span>{{invoice.clients}}</td>
|
||||
<td class="table-col-center"><span class="ui-column-title">{{cols[2].header}}</span>{{invoice.clientsDisplay}}</td>
|
||||
<td class="table-col-center"><span class="ui-column-title">{{cols[3].header}}</span>{{invoice.openDate | date:'shortDate'}}</td>
|
||||
<td class="table-col-center"><span class="ui-column-title">{{cols[4].header}}</span>{{invoice.dueDate | date:'shortDate'}}</td>
|
||||
<td class="table-col-center"><span class="ui-column-title">{{cols[5].header}}</span>{{ (invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1)) | invoiceStatus }}</td>
|
||||
|
||||
@ -15,6 +15,7 @@ import { InvoiceService } from '@app/domain/services/invoice.service';
|
||||
import { FilterUtils } from 'primeng/utils';
|
||||
import { DateUtils, Utils } from '@app/shared/utils';
|
||||
import { RestoreTableState } from '@app/shared/restore-table-state';
|
||||
import { GAService } from '@app/shared/ga.service';
|
||||
|
||||
@Component({
|
||||
selector: 'agm-invoices-list',
|
||||
@ -57,7 +58,7 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
this.cols = [
|
||||
{ field: 'totalAmount', header: $localize`:@@totalAmount:Total Amount`, filtered: false, filterMatchMode: 'contains' },
|
||||
{ field: 'code', header: $localize`:@@invoiceNumber:Invoice Number`, filtered: true, filterMatchMode: 'contains' },
|
||||
{ field: 'clients', header: globals.clients, width: '20%', filtered: true, filterMatchMode: 'contains' },
|
||||
{ field: 'clientsDisplay', header: globals.clients, width: '20%', filtered: true, filterMatchMode: 'contains' },
|
||||
{ field: 'openDate', header: $localize`:@@openDate:Open Date`, filtered: false, filterMatchMode: 'contains' },
|
||||
{ field: 'dueDate', header: $localize`:@@dueDate:Due Date`, filtered: false, filterMatchMode: 'contains' },
|
||||
{ field: 'status', header: $localize`:@@status:Status` },
|
||||
@ -81,8 +82,19 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
this.invoices = invoices
|
||||
.map(i => ({
|
||||
...i,
|
||||
...this.invoiceRowDataFormatter(i)
|
||||
totalAmount: this.calculateInvoiceAmount(i),
|
||||
clientsDisplay: i?.clients?.map(client => client.billTo?.name)?.join(' ; ') || ''
|
||||
}));
|
||||
|
||||
// Track invoice list viewed
|
||||
this.gaSvc.trackInvoiceListViewed({
|
||||
view_type: 'table',
|
||||
total_invoices: this.invoices.length,
|
||||
displayed_invoices: Math.min(this.invoices.length, 10), // Default page size
|
||||
user_id: this.getAnalyticsUserId(),
|
||||
user_role: this.getAnalyticsUserRole(),
|
||||
platform: 'web'
|
||||
});
|
||||
}
|
||||
});
|
||||
this.store.dispatch(new invoiceActions.Fetch());
|
||||
@ -136,7 +148,14 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
&& field
|
||||
&& filterName;
|
||||
|
||||
if (canFilter) return this.dt.filter(range, field, filterName);
|
||||
if (canFilter) {
|
||||
this.dt.filter(range, field, filterName);
|
||||
|
||||
// Track date range filtering
|
||||
setTimeout(() => {
|
||||
this.trackFilterOperation('date_range', range, this.dt.filteredValue?.length || this.invoices.length);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
closeCal(range, field, filterName) {
|
||||
@ -148,7 +167,12 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
|
||||
if (canFilter) {
|
||||
range[1] = range[0];
|
||||
return this.dt.filter(range, field, filterName);
|
||||
this.dt.filter(range, field, filterName);
|
||||
|
||||
// Track date range filtering
|
||||
setTimeout(() => {
|
||||
this.trackFilterOperation('date_range', range, this.dt.filteredValue?.length || this.invoices.length);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
@ -171,17 +195,6 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
return Utils.arraySum(invoice?.clients.map(client => this.calculateSingleClientInvoiceAmount(client).total));
|
||||
}
|
||||
|
||||
invoiceRowDataFormatter(invoice) {
|
||||
const row = {
|
||||
totalAmount: this.calculateInvoiceAmount(invoice),
|
||||
poNumber: invoice?.poNumber,
|
||||
clients: invoice?.clients?.map(i => i.billTo?.name)?.join(' ; '),
|
||||
openDate: invoice?.openDate,
|
||||
dueDate: invoice?.dueDate,
|
||||
};
|
||||
return row;
|
||||
}
|
||||
|
||||
get canEdit(): boolean {
|
||||
return this.authSvc.hasRole([RoleIds.APP]);
|
||||
}
|
||||
@ -195,11 +208,35 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
|
||||
editInvoice(invoice: Invoice) {
|
||||
this.selectInvoice(invoice);
|
||||
|
||||
// Track invoice selection
|
||||
this.gaSvc.trackInvoiceSelected({
|
||||
invoice_id: invoice._id,
|
||||
selection_method: 'edit_button',
|
||||
invoice_status: invoice.status,
|
||||
invoice_amount: this.calculateInvoiceAmount(invoice),
|
||||
user_id: this.getAnalyticsUserId(),
|
||||
user_role: this.getAnalyticsUserRole(),
|
||||
platform: 'web'
|
||||
});
|
||||
|
||||
this.router.navigate([`/invoices/edit/${invoice._id}`]);
|
||||
}
|
||||
|
||||
viewInvoice(invoice: Invoice) {
|
||||
this.selectInvoice(invoice);
|
||||
|
||||
// Track invoice selection
|
||||
this.gaSvc.trackInvoiceSelected({
|
||||
invoice_id: invoice._id,
|
||||
selection_method: 'view_button',
|
||||
invoice_status: invoice.status,
|
||||
invoice_amount: this.calculateInvoiceAmount(invoice),
|
||||
user_id: this.getAnalyticsUserId(),
|
||||
user_role: this.getAnalyticsUserRole(),
|
||||
platform: 'web'
|
||||
});
|
||||
|
||||
this.router.navigate([`./detail/${invoice._id}`], { relativeTo: this.route });
|
||||
}
|
||||
|
||||
@ -220,6 +257,18 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
const payload = {
|
||||
invoiceIds: this.selectedInvoice.map(i => i._id)
|
||||
};
|
||||
|
||||
// Track invoice bulk action
|
||||
this.gaSvc.trackInvoiceBulkAction({
|
||||
action_type: 'delete',
|
||||
invoice_count: this.selectedInvoice.length,
|
||||
invoice_ids: this.selectedInvoice.map(i => i._id),
|
||||
total_amount_affected: this.selectedInvoice.reduce((sum, inv) => sum + this.calculateInvoiceAmount(inv), 0),
|
||||
user_id: this.getAnalyticsUserId(),
|
||||
user_role: this.getAnalyticsUserRole(),
|
||||
platform: 'web'
|
||||
});
|
||||
|
||||
this.store.dispatch(new invoiceActions.Delete(payload));
|
||||
this.selectedInvoice = [];
|
||||
}
|
||||
@ -247,6 +296,18 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
} else {
|
||||
saveAs(res, `Agmission_invoices_${this.datePipe.transform(new Date(), 'yyyy-MM-ddTHH-mm-ss')}.csv`);
|
||||
}
|
||||
|
||||
// Track invoice bulk export
|
||||
this.gaSvc.trackInvoiceBulkAction({
|
||||
action_type: 'export',
|
||||
invoice_count: payload.length,
|
||||
invoice_ids: payload.map(i => i._id),
|
||||
total_amount_affected: payload.reduce((sum, inv) => sum + this.calculateInvoiceAmount(inv), 0),
|
||||
success_rate: 1.0,
|
||||
user_id: this.getAnalyticsUserId(),
|
||||
user_role: this.getAnalyticsUserRole(),
|
||||
platform: 'web'
|
||||
});
|
||||
} catch (error) {
|
||||
alert('Sorry. Your browser does not support this feature !');
|
||||
}
|
||||
@ -265,6 +326,18 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
} else {
|
||||
saveAs(res, `Agmission_invoices_${this.datePipe.transform(new Date(), 'yyyy-MM-ddTHH-mm-ss')}.iif`);
|
||||
}
|
||||
|
||||
// Track invoice bulk export
|
||||
this.gaSvc.trackInvoiceBulkAction({
|
||||
action_type: 'export',
|
||||
invoice_count: payload.length,
|
||||
invoice_ids: payload.map(i => i._id),
|
||||
total_amount_affected: payload.reduce((sum, inv) => sum + this.calculateInvoiceAmount(inv), 0),
|
||||
success_rate: 1.0,
|
||||
user_id: this.getAnalyticsUserId(),
|
||||
user_role: this.getAnalyticsUserRole(),
|
||||
platform: 'web'
|
||||
});
|
||||
} catch (error) {
|
||||
alert('Sorry. Your browser does not support this feature !');
|
||||
}
|
||||
@ -277,4 +350,52 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
ngOnDestroy(): void {
|
||||
super.ngOnDestroy();
|
||||
}
|
||||
|
||||
private trackFilterOperation(filterType: 'status' | 'date_range' | 'client' | 'amount_range' | 'overdue', filterValue: any, resultsAfter: number) {
|
||||
// Track invoice list filtering
|
||||
this.gaSvc.trackInvoiceListFiltered({
|
||||
filter_type: filterType,
|
||||
filter_value: filterValue,
|
||||
results_before: this.invoices.length,
|
||||
results_after: resultsAfter,
|
||||
filter_effectiveness: this.invoices.length > 0 ? resultsAfter / this.invoices.length : 0,
|
||||
multiple_filters_active: this.hasMultipleFiltersActive(),
|
||||
user_id: this.getAnalyticsUserId(),
|
||||
user_role: this.getAnalyticsUserRole(),
|
||||
platform: 'web'
|
||||
});
|
||||
}
|
||||
|
||||
private hasMultipleFiltersActive(): boolean {
|
||||
if (!this.dt?.filters) return false;
|
||||
|
||||
const activeFilters = Object.keys(this.dt.filters)
|
||||
.filter(key => this.dt.filters[key]?.value !== null && this.dt.filters[key]?.value !== undefined && this.dt.filters[key]?.value !== '');
|
||||
|
||||
return activeFilters.length > 1;
|
||||
}
|
||||
|
||||
onTextFilter(event: any, field: string, matchMode: string) {
|
||||
const filterValue = event.target.value;
|
||||
this.dt.filter(filterValue, field, matchMode);
|
||||
|
||||
// Track filtering after a short delay to ensure the table is updated
|
||||
setTimeout(() => {
|
||||
if (filterValue && filterValue.trim()) {
|
||||
const filterType = field === 'clientsDisplay' ? 'client' : field === 'totalAmount' ? 'amount_range' : 'client';
|
||||
this.trackFilterOperation(filterType, filterValue, this.dt.filteredValue?.length || this.invoices.length);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
onStatusFilter(event: any) {
|
||||
this.dt.filter(event.value, 'status', 'in');
|
||||
|
||||
// Track filtering after a short delay to ensure the table is updated
|
||||
setTimeout(() => {
|
||||
if (event.value && event.value.length > 0) {
|
||||
this.trackFilterOperation('status', event.value, this.dt.filteredValue?.length || this.invoices.length);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
@ -51,7 +51,20 @@ export class JobEffects {
|
||||
ofType<jobActions.Create>(jobActions.CREATE),
|
||||
switchMap(({ payload }) =>
|
||||
this.jobSvc.createJob(payload).pipe(
|
||||
map((job) => new jobActions.CreateSuccess(job)),
|
||||
map((job) => {
|
||||
// Track job creation with GA4
|
||||
this.gaSvc.trackJobCreated({
|
||||
user_id: 'system',
|
||||
platform: 'web',
|
||||
job_type: this.normalizeJobType(payload.appType),
|
||||
field_size_acres: payload.ttSprArea || 0,
|
||||
crop_type: payload.crop?.name || 'unknown',
|
||||
client_id: payload.client?._id?.toString() || 'unknown',
|
||||
priority: 'medium' // Default priority
|
||||
});
|
||||
|
||||
return new jobActions.CreateSuccess(job);
|
||||
}),
|
||||
catchError(err => {
|
||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.save).replace('#thing#', globals.job));
|
||||
return of(new jobActions.CreateFailed())
|
||||
@ -63,11 +76,37 @@ export class JobEffects {
|
||||
@Effect()
|
||||
updateJob$: Observable<Action> = this.actions$.pipe(
|
||||
ofType<jobActions.Update>(jobActions.UPDATE),
|
||||
switchMap(({ payload }) =>
|
||||
this.jobSvc.saveJob(payload).pipe(
|
||||
switchMap(({ payload }) => {
|
||||
const oldStatus = payload.job?.status;
|
||||
|
||||
return this.jobSvc.saveJob(payload).pipe(
|
||||
map((data) => {
|
||||
this.gaSvc.gaEvent("JOBS", "CRUD", "U");
|
||||
return new jobActions.UpdateSuccess(toJob(data))
|
||||
const updatedJob = toJob(data);
|
||||
const newStatus = updatedJob.status;
|
||||
|
||||
// Check if status changed during update
|
||||
if (oldStatus !== undefined && oldStatus !== newStatus) {
|
||||
this.gaSvc.trackJobStatusChanged({
|
||||
user_id: 'system',
|
||||
platform: 'web',
|
||||
job_id: payload.job?._id?.toString() || 'unknown',
|
||||
old_status: this.mapStatusToString(oldStatus),
|
||||
new_status: this.mapStatusToString(newStatus),
|
||||
status_change_reason: 'api_update'
|
||||
});
|
||||
}
|
||||
|
||||
// Track general job update
|
||||
this.gaSvc.trackJobUpdated({
|
||||
user_id: 'system',
|
||||
platform: 'web',
|
||||
job_id: payload.job?._id?.toString() || 'unknown',
|
||||
fields_modified: this.detectModifiedFields(payload, updatedJob),
|
||||
change_magnitude: oldStatus !== newStatus ? 'major' : 'minor',
|
||||
save_method: 'manual'
|
||||
});
|
||||
|
||||
return new jobActions.UpdateSuccess(updatedJob)
|
||||
}),
|
||||
catchError(err => {
|
||||
if (err?.error?.error['.tag'] == 'cannot_edit_job_have_invoice_opened') {
|
||||
@ -78,7 +117,7 @@ export class JobEffects {
|
||||
return of(new jobActions.UpdateFailed());
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
);
|
||||
|
||||
@Effect()
|
||||
@ -87,7 +126,19 @@ export class JobEffects {
|
||||
switchMap(({ payload }) =>
|
||||
this.jobSvc.deleteJob(payload).pipe(
|
||||
map(() => {
|
||||
this.gaSvc.gaEvent("JOBS", "CRUD", "D");
|
||||
// Track job deletion with GA4
|
||||
this.gaSvc.trackJobDeleted({
|
||||
user_id: 'system', // Effects don't have direct user context
|
||||
platform: 'web',
|
||||
job_id: payload._id?.toString() || 'unknown',
|
||||
job_type: payload.appType || 'unknown',
|
||||
job_status: payload.status?.toString() || 'unknown',
|
||||
deletion_reason: 'user_action',
|
||||
deletion_method: 'api_call',
|
||||
time_since_creation: payload.createdAt ?
|
||||
Math.floor((new Date().getTime() - new Date(payload.createdAt).getTime()) / (1000 * 60 * 60)) : 0
|
||||
});
|
||||
|
||||
return new jobActions.DeleteSuccess(payload)
|
||||
}),
|
||||
catchError(err => {
|
||||
@ -105,7 +156,17 @@ export class JobEffects {
|
||||
this.jobSvc.assign(payload).pipe(
|
||||
map(() => {
|
||||
this.msgSvc.addSuccessMsg($localize`:@@jobAssigned:Job assigned`);
|
||||
this.gaSvc.gaEvent("JOBS", "DATA", "A");
|
||||
|
||||
// Track job assignment with GA4
|
||||
this.gaSvc.trackJobAssigned({
|
||||
user_id: 'system',
|
||||
platform: 'web',
|
||||
job_id: payload.jobId?.toString() || 'unknown',
|
||||
assignee_id: payload.asUsers?.[0]?._id?.toString() || 'unknown',
|
||||
assignee_role: 'applicator', // Updated to use valid role
|
||||
assignment_method: 'manual'
|
||||
});
|
||||
|
||||
return new jobActions.AssignSuccess({ _id: payload.jobId })
|
||||
}),
|
||||
catchError(err => {
|
||||
@ -132,4 +193,49 @@ export class JobEffects {
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Helper method to normalize job type to GA4 enum values
|
||||
private normalizeJobType(appType: string): 'spraying' | 'seeding' | 'fertilizing' | 'harvesting' {
|
||||
const type = appType?.toLowerCase();
|
||||
if (type?.includes('spray')) return 'spraying';
|
||||
if (type?.includes('seed')) return 'seeding';
|
||||
if (type?.includes('fertiliz')) return 'fertilizing';
|
||||
if (type?.includes('harvest')) return 'harvesting';
|
||||
return 'spraying'; // Default fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Map numeric status to string for GA4 tracking
|
||||
*/
|
||||
private mapStatusToString(status: number): 'new' | 'ready' | 'downloaded' | 'sprayed' | 'archived' {
|
||||
switch (status) {
|
||||
case 0: return 'new';
|
||||
case 1: return 'ready';
|
||||
case 2: return 'downloaded';
|
||||
case 3: return 'sprayed';
|
||||
case 9: return 'archived';
|
||||
default: return 'new';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect which fields were modified in the job update
|
||||
*/
|
||||
private detectModifiedFields(payload: any, updatedJob: any): string[] {
|
||||
const modifiedFields: string[] = [];
|
||||
const originalJob = payload.job;
|
||||
|
||||
if (!originalJob) return ['unknown'];
|
||||
|
||||
// Check common fields that might change
|
||||
if (originalJob.status !== updatedJob.status) modifiedFields.push('status');
|
||||
if (originalJob.name !== updatedJob.name) modifiedFields.push('name');
|
||||
if (originalJob.priority !== updatedJob.priority) modifiedFields.push('priority');
|
||||
if (originalJob.startDate !== updatedJob.startDate) modifiedFields.push('startDate');
|
||||
if (originalJob.endDate !== updatedJob.endDate) modifiedFields.push('endDate');
|
||||
if (originalJob.operator?._id !== updatedJob.operator?._id) modifiedFields.push('operator');
|
||||
if (originalJob.vehicle?._id !== updatedJob.vehicle?._id) modifiedFields.push('vehicle');
|
||||
|
||||
return modifiedFields.length > 0 ? modifiedFields : ['unknown'];
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,393 @@
|
||||
/* Job Assignment Component Styles - AgMission Theme Compliance */
|
||||
|
||||
/* Host element typography foundation - AgMission standards */
|
||||
/* These properties cascade to all child elements, reducing repetition */
|
||||
:host {
|
||||
font-family: "Roboto", "Helvetica Neue", sans-serif;
|
||||
/* $fontFamily - AgMission standard */
|
||||
line-height: 1.5;
|
||||
/* $lineHeight - AgMission standard */
|
||||
letter-spacing: 0.25px;
|
||||
/* $letterSpacing - AgMission standard */
|
||||
}
|
||||
|
||||
.job-assignment-container {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* Aircraft Item Styling */
|
||||
.aircraft-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #bdbdbd;
|
||||
/* $dividerColor */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.aircraft-icon {
|
||||
color: #03A9F4;
|
||||
/* $blue - info states */
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.aircraft-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Aircraft Tooltip Styling */
|
||||
:host ::ng-deep .aircraft-tooltip-enhanced {
|
||||
max-width: 350px;
|
||||
white-space: pre-line;
|
||||
line-height: 1.4;
|
||||
font-size: 13px;
|
||||
background: #2E7D32;
|
||||
/* $primaryDarkColor */
|
||||
color: #ffffff;
|
||||
/* $primaryTextColor */
|
||||
border: 1px solid #4CAF50;
|
||||
/* $primaryColor */
|
||||
border-radius: 3px;
|
||||
/* AgMission standard border radius */
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
:host ::ng-deep .aircraft-tooltip-enhanced .ui-tooltip-text {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:host ::ng-deep .aircraft-tooltip-enhanced .ui-tooltip-arrow::before {
|
||||
border-top-color: #2E7D32;
|
||||
/* $primaryDarkColor */
|
||||
}
|
||||
|
||||
.aircraft-details {
|
||||
margin-top: 4px;
|
||||
font-size: 0.85rem;
|
||||
color: #757575;
|
||||
/* $textSecondaryColor */
|
||||
}
|
||||
|
||||
.sync-status {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* Download Options Info */
|
||||
.download-options-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
font-size: 0.85rem;
|
||||
color: #757575;
|
||||
/* $textSecondaryColor */
|
||||
}
|
||||
|
||||
.download-options-info .pi {
|
||||
margin-right: 4px;
|
||||
color: #4CAF50;
|
||||
/* $primaryColor - success indicator */
|
||||
}
|
||||
|
||||
/* Assignment Status Styling */
|
||||
.assignment-status-section {
|
||||
background: #ffffff;
|
||||
/* $contentBgColor */
|
||||
border-radius: 3px;
|
||||
/* AgMission standard border radius */
|
||||
padding: 16px;
|
||||
border: 1px solid #bdbdbd;
|
||||
/* $dividerColor */
|
||||
}
|
||||
|
||||
.assignment-status-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.assignment-status-header h4 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #212121;
|
||||
/* $textColor - matches other page labels */
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.assignment-header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-control-btn,
|
||||
.clear-status-btn {
|
||||
padding: 8px 12px !important;
|
||||
font-size: 14px !important;
|
||||
min-width: 44px !important;
|
||||
min-height: 44px !important;
|
||||
border-radius: 3px !important;
|
||||
/* AgMission standard border radius */
|
||||
}
|
||||
|
||||
.status-control-btn:focus,
|
||||
.clear-status-btn:focus {
|
||||
outline: 2px solid #03A9F4;
|
||||
/* $blue - info states */
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.polling-status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background-color: #E1F5FE;
|
||||
/* Light blue background for info */
|
||||
border: 1px solid #03A9F4;
|
||||
/* $blue */
|
||||
border-radius: 3px;
|
||||
/* AgMission standard border radius */
|
||||
margin-bottom: 12px;
|
||||
font-size: 0.95rem;
|
||||
color: #0277BD;
|
||||
/* $blueHover */
|
||||
}
|
||||
|
||||
.assignment-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background-color: #FFF8E1;
|
||||
/* Light amber background for progress */
|
||||
border: 1px solid #FFC107;
|
||||
/* $amber */
|
||||
border-radius: 3px;
|
||||
/* AgMission standard border radius */
|
||||
margin-bottom: 12px;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: #FF8F00;
|
||||
/* $amberHover */
|
||||
}
|
||||
|
||||
.assignment-error-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background-color: #FFEBEE;
|
||||
/* Light red background for error */
|
||||
border: 1px solid #F44336;
|
||||
/* $red */
|
||||
border-radius: 3px;
|
||||
/* AgMission standard border radius */
|
||||
margin-bottom: 12px;
|
||||
color: #C62828;
|
||||
/* $redHover */
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* Assignment Status Table Styling */
|
||||
.assignment-status-table {
|
||||
margin-top: 12px;
|
||||
border-radius: 3px;
|
||||
/* AgMission standard border radius */
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.assignment-status-table .ui-table-thead th {
|
||||
background-color: #e8e8e8;
|
||||
/* $hoverBgColor */
|
||||
border-bottom: 2px solid #bdbdbd;
|
||||
/* $dividerColor */
|
||||
color: #212121;
|
||||
/* $textColor */
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
.assignment-status-table .ui-table-tbody>tr {
|
||||
border-left: 4px solid transparent;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.assignment-status-table .ui-table-tbody>tr:hover {
|
||||
background-color: #e8e8e8;
|
||||
/* $hoverBgColor */
|
||||
}
|
||||
|
||||
.assignment-status-table .ui-table-tbody>tr>td {
|
||||
padding: 12px 8px;
|
||||
font-size: 0.9rem;
|
||||
border-bottom: 1px solid #bdbdbd;
|
||||
/* $dividerColor */
|
||||
}
|
||||
|
||||
.assignment-status-table .status-row-new {
|
||||
border-left-color: #4527A0;
|
||||
/* $accentDarkColor - new assignments */
|
||||
}
|
||||
|
||||
.assignment-status-table .status-row-downloaded {
|
||||
border-left-color: #f9a825;
|
||||
/* $accentLightColor - downloaded assignments */
|
||||
}
|
||||
|
||||
.assignment-status-table .status-row-uploaded {
|
||||
border-left-color: #2E7D32;
|
||||
/* $primaryDarkColor - uploaded/completed assignments */
|
||||
}
|
||||
|
||||
.assignment-status-table .status-row-error {
|
||||
border-left-color: #F44336;
|
||||
/* Semantic red - error states */
|
||||
}
|
||||
|
||||
.aircraft-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.aircraft-cell .pi {
|
||||
font-size: 16px;
|
||||
color: #03A9F4;
|
||||
/* $blue - info states */
|
||||
}
|
||||
|
||||
.aircraft-cell .aircraft-name {
|
||||
font-weight: 600;
|
||||
color: #212121;
|
||||
/* $textColor */
|
||||
}
|
||||
|
||||
/* Status message and error details - AgMission Typography */
|
||||
.status-message {
|
||||
font-weight: 500;
|
||||
margin-top: 6px;
|
||||
color: #212121;
|
||||
/* $textColor */
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status-error-details {
|
||||
margin-top: 6px;
|
||||
color: #757575;
|
||||
/* $textSecondaryColor */
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.status-timestamp {
|
||||
font-size: 0.9rem;
|
||||
color: #757575;
|
||||
/* $textSecondaryColor */
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-indicator-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #757575;
|
||||
/* $textSecondaryColor */
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.assignment-action-button {
|
||||
font-size: 12px !important;
|
||||
min-height: 32px !important;
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
color: #757575;
|
||||
/* $textSecondaryColor */
|
||||
font-style: italic;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Screen reader only content */
|
||||
.sr-only {
|
||||
position: absolute !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
padding: 0 !important;
|
||||
margin: -1px !important;
|
||||
overflow: hidden !important;
|
||||
clip: rect(0, 0, 0, 0) !important;
|
||||
white-space: nowrap !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
/* Focus improvements for accessibility */
|
||||
.assignment-status-table tbody tr:focus-within {
|
||||
outline: 2px solid #03A9F4;
|
||||
/* $blue - info states */
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.assignment-status-section {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.assignment-status-table .ui-table-thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.assignment-status-table .ui-table-tbody>tr>td {
|
||||
display: block;
|
||||
border: none;
|
||||
border-bottom: 1px solid #bdbdbd;
|
||||
/* $dividerColor */
|
||||
padding: 12px 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.assignment-status-table .ui-table-tbody>tr>td:before {
|
||||
content: attr(data-label) ": ";
|
||||
font-weight: 600;
|
||||
display: inline-block;
|
||||
width: 120px;
|
||||
color: #212121;
|
||||
/* $textColor */
|
||||
}
|
||||
|
||||
.assignment-header-actions {
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.status-control-btn,
|
||||
.clear-status-btn {
|
||||
padding: 6px 10px !important;
|
||||
font-size: 12px !important;
|
||||
min-width: 40px !important;
|
||||
min-height: 40px !important;
|
||||
}
|
||||
|
||||
.polling-status-indicator {
|
||||
padding: 8px 12px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,205 @@
|
||||
<div class="job-assignment-container">
|
||||
<p-panel i18n-header="@@jobAssignment" header="Job Assignment" [toggleable]="true" [collapsed]="false">
|
||||
<div class="ui-g">
|
||||
<div class="ui-g-12 ui-g-nopad">
|
||||
<p-pickList [source]="srcUsers" [target]="tarUsers" i18n-sourceHeader="@@aircraft"
|
||||
sourceHeader="Available Aircraft" i18n-targetHeader="@@assignedAircraft" targetHeader="Assigned Aircraft"
|
||||
[disabled]="isArchived" dragdrop="true" dragdropScope="users" [responsive]="true"
|
||||
[sourceStyle]="{'height':'180px'}" [targetStyle]="{'height':'180px'}" [showSourceControls]="false"
|
||||
[showTargetControls]="false" [filterBy]="'name'" filterMatchMode="contains"
|
||||
i18n-sourceFilterPlaceholder="@@filterAcByName" sourceFilterPlaceholder="Filter AC by name"
|
||||
i18n-targetFilterPlaceholder="@@filterAcByName" targetFilterPlaceholder="Filter AC by name"
|
||||
(onMoveToSource)="onMoveToSource($event)" (onMoveToTarget)="onMoveToTarget($event)"
|
||||
[pTooltip]="getPickListSourceTooltip()" tooltipPosition="top">
|
||||
|
||||
<ng-template let-aircraft pTemplate="item">
|
||||
<div class="aircraft-item ui-helper-clearfix"
|
||||
[style.color]="!aircraft.active || !aircraft.pkgActive || aircraft.authValidation?.validationError ? 'red' : null"
|
||||
[style.opacity]="aircraft.authValidation?.isValidating ? '0.6' : '1'"
|
||||
style="display: flex; flex-direction: column; align-items: flex-start;"
|
||||
(click)="onAircraftSelect(aircraft, $event)">
|
||||
|
||||
<!-- Main row with icon, name, and badge -->
|
||||
<div style="display: flex; align-items: center; width: 100%; margin-bottom: 4px;">
|
||||
<!-- Aircraft Icon -->
|
||||
<i class="fa" [class.fa-plane]="!aircraft.authValidation?.isValidating"
|
||||
[class.fa-spinner]="aircraft.authValidation?.isValidating"
|
||||
[class.fa-spin]="aircraft.authValidation?.isValidating" class="aircraft-icon"
|
||||
style="vertical-align: middle; margin-right: 8px" aria-hidden="true"></i>
|
||||
|
||||
<!-- Aircraft Name -->
|
||||
<span class="aircraft-name" [pTooltip]="getAircraftTooltip(aircraft)" tooltipPosition="top"
|
||||
[escape]="false" [showDelay]="500" [hideDelay]="300" tooltipStyleClass="aircraft-tooltip-enhanced"
|
||||
style="flex: 1;">
|
||||
{{ aircraft.name }}
|
||||
</span>
|
||||
|
||||
<!-- Source System Badge -->
|
||||
<agm-badge [config]="getAircraftSystemBadge(aircraft)"></agm-badge>
|
||||
</div>
|
||||
|
||||
<!-- Aircraft Details (Sync Status) -->
|
||||
<div class="aircraft-details" style="margin-left: 24px;">
|
||||
<small class="aircraft-details">
|
||||
<!-- Satloc-specific sync status -->
|
||||
<span *ngIf="aircraft.sourceSystem === KnownPartnerCodes.SATLOC && aircraft.satlocData"
|
||||
class="sync-status" [class]="'sync-status-' + aircraft.satlocData.syncStatus">
|
||||
<i class="fa" [class.fa-circle]="aircraft.satlocData.syncStatus === OperationalStatus.ACTIVE"
|
||||
[class.fa-clock-o]="aircraft.satlocData.syncStatus === OperationalStatus.PENDING"
|
||||
[class.fa-exclamation-triangle]="aircraft.satlocData.syncStatus === OperationalStatus.ERROR"></i>
|
||||
</span>
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Package status indicator -->
|
||||
<div *ngIf="!aircraft.pkgActive" class="package-inactive"
|
||||
style="position: absolute; top: 5px; right: 5px;">
|
||||
<i class="fa fa-warning" style="color: orange" [pTooltip]="Labels.PACKAGE_INACTIVE"
|
||||
tooltipPosition="top"></i>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</p-pickList>
|
||||
</div>
|
||||
|
||||
<div class="ui-g-12 ui-g-nopad" style="margin-top: 10px">
|
||||
<div class="ui-g-12 ui-sm-4 ui-md-4 ui-lg-4 ui-xl-3">
|
||||
<label for="dlOps"> <ng-container i18n="@@downloadOptions">Download Options</ng-container>:</label>
|
||||
<div class="download-options-info">
|
||||
<i class="pi pi-info-circle" [pTooltip]="Labels.DOWNLOAD_OPTIONS_AGNAV_ONLY_TOOLTIP"
|
||||
tooltipPosition="top"></i>
|
||||
<span>{{ Labels.AGNAV_BRAND_NAME }} <ng-container i18n="@@aircraftOnlySuffix">Aircraft
|
||||
Only</ng-container></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui-g-12 ui-sm-8 ui-md-6 ui-lg-8 ui-xl-9">
|
||||
<p-dropdown id="dlOps" name="formats" [(ngModel)]="job.dlOp.type" [disabled]="isArchived"
|
||||
[style]="{'minWidth':'120px'}" [options]="dlOps" [pTooltip]="getDownloadOptionsTooltip()"
|
||||
tooltipPosition="top">
|
||||
</p-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ui-g-12 ui-g-nopad" style="margin-top: 10px">
|
||||
<button class="blue-btn" pButton type="button" icon="ui-icon-assignment-ind" i18n-label="@@assign"
|
||||
label="Assign" (click)="assignJob()" [disabled]="isArchived || !canDownload"
|
||||
[pTooltip]="getAssignButtonTooltip()" tooltipPosition="top">
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Assignment Status Display -->
|
||||
<div class="ui-g-12 ui-g-nopad assignment-status-section" style="margin-top: 15px">
|
||||
<div class="assignment-status-header">
|
||||
<h4 id="assignment-status-heading" i18n="@@assignmentStatus"
|
||||
title="Track real-time assignment progress and manage aircraft assignments">Assignment Status</h4>
|
||||
<div class="assignment-header-actions">
|
||||
<!-- Refresh Assignment Status -->
|
||||
<button class="status-control-btn" pButton type="button" icon="ui-icon-refresh"
|
||||
[pTooltip]="getRefreshStatusTooltip()" (click)="refreshAssignmentStatus()" [disabled]="!job || !job._id">
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assignment Status Polling Indicator -->
|
||||
<div *ngIf="isPollingAssignments" class="polling-status-indicator" role="status" aria-live="polite">
|
||||
<i class="pi pi-spin pi-refresh" aria-hidden="true"></i>
|
||||
<span i18n="@@pollingAssignmentStatus">Polling assignment status...</span>
|
||||
<small i18n="@@updatesEvery5Seconds">(Updates every 5 seconds)</small>
|
||||
</div>
|
||||
|
||||
<!-- Overall Assignment Progress -->
|
||||
<div *ngIf="isAssignmentInProgress" class="assignment-progress" role="status" aria-live="assertive">
|
||||
<i class="pi pi-spin pi-spinner" aria-hidden="true"></i>
|
||||
<span i18n="@@assignmentInProgress">Assignment in progress...</span>
|
||||
</div>
|
||||
|
||||
<!-- Assignment Error Summary -->
|
||||
<div *ngIf="assignmentErrorMsg" class="assignment-error-summary" role="alert" aria-live="assertive">
|
||||
<i class="pi pi-exclamation-triangle" aria-hidden="true"></i>
|
||||
<span>{{ assignmentErrorMsg }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Assignment Status Table -->
|
||||
<p-table *ngIf="assignmentStatuses.length > 0" [value]="assignmentStatuses" dataKey="aircraftId"
|
||||
[responsive]="true" [scrollable]="assignmentStatuses.length > 4"
|
||||
[scrollHeight]="assignmentStatuses.length > 4 ? '300px' : 'auto'" styleClass="assignment-status-table"
|
||||
[pTooltip]="getStatusTableTooltip()" tooltipPosition="top">
|
||||
|
||||
<ng-template pTemplate="header">
|
||||
<tr>
|
||||
<th style="width: 25%" i18n="@@aircraft" title="Aircraft name and assignment status indicator">Aircraft
|
||||
</th>
|
||||
<th style="width: 35%" i18n="@@statusMessage" title="Current assignment status and any error messages">
|
||||
Status & Message</th>
|
||||
<th style="width: 25%" i18n="@@assignTime" title="When the assignment was initiated">Assign Time</th>
|
||||
<th style="width: 15%" i18n="@@actions" title="Available actions for this assignment">Actions</th>
|
||||
</tr>
|
||||
</ng-template>
|
||||
|
||||
<ng-template pTemplate="body" let-status>
|
||||
<tr [ngClass]="'status-row-' + getStatusCssClass(status)">
|
||||
<!-- Aircraft Name Column -->
|
||||
<td style="text-align: center;">
|
||||
<span class="ui-column-title" i18n="@@aircraft">Aircraft</span>
|
||||
<div class="aircraft-cell" style="justify-content: center;">
|
||||
<div style="display: flex; flex-direction: column; align-items: center;">
|
||||
<span class="aircraft-name">{{ status.aircraftName }}</span>
|
||||
<agm-badge [config]="getStatusSystemBadge(status.sourceSystem)"
|
||||
style="margin-top: 4px; font-size: 0.75rem;">
|
||||
</agm-badge>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Combined Status & Message Column -->
|
||||
<td>
|
||||
<span class="ui-column-title" i18n="@@statusMessage">Status & Message</span>
|
||||
<div>
|
||||
<agm-badge [config]="getAssignmentStatusBadge(status)"></agm-badge>
|
||||
<div class="status-message">{{ status.message }}</div>
|
||||
<div *ngIf="status.errorDetails" class="status-error-details">
|
||||
<small>{{ status.errorDetails }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Assign Time Column -->
|
||||
<td>
|
||||
<span class="ui-column-title" i18n="@@assignTime">Assign Time</span>
|
||||
<div class="status-timestamp">{{ status.timestamp | date:'short' }}</div>
|
||||
</td>
|
||||
|
||||
<!-- Actions Column -->
|
||||
<td>
|
||||
<span class="ui-column-title" i18n="@@actions">Actions</span>
|
||||
<div class="status-actions">
|
||||
<!-- Unified Slim Split Button for All States -->
|
||||
<p-splitButton *ngIf="!isStatusNew(status)" styleClass="slim assignment-action-button"
|
||||
[model]="getUnifiedActionOptions(status)" [disabled]="isAircraftAssignmentInProgress(status)"
|
||||
i18n-pTooltip="@@assignmentActionsTooltip" pTooltip="Assignment actions">
|
||||
</p-splitButton>
|
||||
|
||||
<!-- Simple indicator for new/pending states -->
|
||||
<span *ngIf="isStatusNew(status)" class="status-indicator-text">
|
||||
<i class="pi pi-spin pi-spinner"></i>
|
||||
<span class="sr-only">{{ Labels.PROCESSING_ASSIGNMENT }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
|
||||
<ng-template pTemplate="emptymessage">
|
||||
<tr>
|
||||
<td colspan="4" role="cell">
|
||||
<div class="empty-message" i18n="@@noAssignmentStatus">
|
||||
No assignment status to display
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</p-table>
|
||||
</div>
|
||||
</div>
|
||||
</p-panel>
|
||||
</div>
|
||||
@ -0,0 +1,822 @@
|
||||
import { Component, OnInit, OnDestroy, Input, Output, EventEmitter, ChangeDetectorRef } from '@angular/core';
|
||||
import { Observable, Subject, timer, interval } from 'rxjs';
|
||||
import { switchMap, takeUntil, retryWhen, delayWhen, startWith } from 'rxjs/operators';
|
||||
import { Store } from '@ngrx/store';
|
||||
|
||||
import { MenuItem, SelectItem } from 'primeng/api';
|
||||
|
||||
import { IUIJob } from '../models/job.model';
|
||||
import * as fromEntity from '@app/entities/reducers';
|
||||
import * as jobActions from '../actions/job.actions';
|
||||
|
||||
import { JobService } from '@app/domain/services/job.service';
|
||||
import { PartnerService } from '@app/partners/services/partner.service';
|
||||
import { PartnerUtilsService } from '@app/shared/services/partner-utils.service';
|
||||
import { BadgeFactoryService } from '@app/shared/services/badge-factory.service';
|
||||
import { AuthService } from '@app/domain/services/auth.service';
|
||||
|
||||
import { AircraftAssignmentItem } from '@app/entities/models/vehicle.model';
|
||||
import { Partner } from '@app/partners/models/partner.model';
|
||||
import { BadgeConfig } from '@app/shared/badge/badge-config.model';
|
||||
|
||||
import { BaseComp } from '@app/shared/base/base.component';
|
||||
import { SourceSystem, OperationalStatus, AssignStatus, AssignStatusType, Labels, globals, KnownPartnerCodes, SystemOrPartnerType } from '@app/shared/global';
|
||||
|
||||
// ============================================================================
|
||||
// INTERFACES
|
||||
// ============================================================================
|
||||
|
||||
// Assignment Status Tracking Interface
|
||||
interface AssignmentStatus {
|
||||
aircraftId: string;
|
||||
aircraftName: string;
|
||||
sourceSystem: SystemOrPartnerType; // Track source system for badge display
|
||||
state: AssignStatusType; // Using AssignStatus values
|
||||
message: string;
|
||||
timestamp: Date;
|
||||
errorDetails?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Job Assignment Component
|
||||
*
|
||||
* Supports both AgNav and partner aircraft assignment to jobs.
|
||||
*
|
||||
* Partner Info Response Structure (from assignments_post):
|
||||
* - AgNav vehicles: No partnerInfo field
|
||||
* - Partner vehicles: { partnerInfo: { name: "satloc", partnerCode: "SATLOC" } }
|
||||
*/
|
||||
@Component({
|
||||
selector: 'agm-job-assignment',
|
||||
templateUrl: './job-assignment.component.html',
|
||||
styleUrls: ['./job-assignment.component.css']
|
||||
})
|
||||
export class JobAssignmentComponent extends BaseComp implements OnInit, OnDestroy {
|
||||
// Template readonly objects for direct usage
|
||||
readonly SourceSystem = SourceSystem;
|
||||
readonly KnownPartnerCodes = KnownPartnerCodes;
|
||||
readonly OperationalStatus = OperationalStatus;
|
||||
readonly Labels = Labels;
|
||||
|
||||
// Inputs from parent component
|
||||
@Input() job: IUIJob;
|
||||
@Input() isArchived: boolean = false;
|
||||
@Input() canDownload: boolean = false;
|
||||
@Input() dlOps: SelectItem[] = [];
|
||||
|
||||
// Outputs to parent component
|
||||
@Output() assignmentComplete = new EventEmitter<any>();
|
||||
|
||||
// Assignment-related properties
|
||||
srcUsers: AircraftAssignmentItem[] = [];
|
||||
tarUsers: AircraftAssignmentItem[] = [];
|
||||
allAircraft: AircraftAssignmentItem[] = []; // Store all aircraft data
|
||||
|
||||
// Assignment Status Tracking
|
||||
assignmentStatuses: AssignmentStatus[] = [];
|
||||
isAssignmentInProgress = false;
|
||||
assignmentErrorMsg: string | null = null;
|
||||
|
||||
// Assignment Status Polling
|
||||
isPollingAssignments = false;
|
||||
private stoppedAssignmentPoll: Subject<boolean>;
|
||||
|
||||
// Partner caching for performance
|
||||
private partnersCache = new Map<string, Partner>();
|
||||
|
||||
constructor(
|
||||
protected store: Store<fromEntity.EntityState>,
|
||||
private jobSvc: JobService,
|
||||
private partnerSvc: PartnerService,
|
||||
private partnerUtils: PartnerUtilsService,
|
||||
private badgeFactory: BadgeFactoryService,
|
||||
protected authSvc: AuthService,
|
||||
private cdr: ChangeDetectorRef
|
||||
) {
|
||||
super(cdr);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.stoppedAssignmentPoll = new Subject();
|
||||
|
||||
// Load partners for aircraft display
|
||||
this.loadPartners();
|
||||
|
||||
// Load aircraft data
|
||||
this.loadAircraftData();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Stop assignment status polling
|
||||
this.stopAssignmentStatusPolling();
|
||||
super.ngOnDestroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load partners and cache them for performance
|
||||
*/
|
||||
private loadPartners(): void {
|
||||
this.partnerSvc.getPartners().subscribe({
|
||||
next: (partners) => {
|
||||
this.partnersCache.clear();
|
||||
partners.forEach(partner => {
|
||||
this.partnersCache.set(partner._id, partner);
|
||||
});
|
||||
|
||||
// Refresh aircraft data now that partners are loaded
|
||||
this.refreshAircraftDisplay();
|
||||
},
|
||||
error: (error) => {
|
||||
console.error(globals.consoleFailedToLoadPartners, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get partner from cache by ID
|
||||
*/
|
||||
private getPartner(partnerId: string): Partner | null {
|
||||
return this.partnersCache.get(partnerId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load aircraft data from backend API for job assignment
|
||||
*/
|
||||
private loadAircraftData(): void {
|
||||
if (!this.job || !this.job._id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use backend API to get assignment data with partnerInfo
|
||||
this.jobSvc.getAssignments({ 'jobId': this.job._id }).subscribe({
|
||||
next: (assignmentData) => {
|
||||
this.updateAircraftAssignmentData(assignmentData);
|
||||
},
|
||||
error: (error) => {
|
||||
console.error(globals.consoleFailedToLoadExistingAssignments, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update aircraft assignment data from backend API response
|
||||
*/
|
||||
private updateAircraftAssignmentData(assignmentData: any): void {
|
||||
// Process available users
|
||||
let availableAircraft: AircraftAssignmentItem[] = [];
|
||||
if (assignmentData.avUsers && assignmentData.avUsers.length > 0) {
|
||||
availableAircraft = assignmentData.avUsers.map(user => this.convertBackendUserToAssignmentItem(user));
|
||||
}
|
||||
|
||||
// Process assigned users
|
||||
let assignedAircraft: AircraftAssignmentItem[] = [];
|
||||
if (assignmentData.asUsers && assignmentData.asUsers.length > 0) {
|
||||
assignedAircraft = assignmentData.asUsers.map(user => this.convertBackendUserToAssignmentItem(user));
|
||||
}
|
||||
|
||||
// Combine all aircraft for sorting
|
||||
this.allAircraft = this.sortAircraftBySource([...availableAircraft, ...assignedAircraft]);
|
||||
|
||||
// Set source users (available) and target users (assigned)
|
||||
this.srcUsers = availableAircraft;
|
||||
this.tarUsers = assignedAircraft;
|
||||
|
||||
// Always update assignment status to reflect current state (including when empty)
|
||||
this.updateAssignmentStatus(assignmentData);
|
||||
|
||||
// Start polling if there are assigned aircraft to track
|
||||
if (assignedAircraft.length > 0 && !this.isPollingAssignments) {
|
||||
this.startAssignmentStatusPolling();
|
||||
} else if (assignedAircraft.length === 0 && this.isPollingAssignments) {
|
||||
// Stop polling if no assignments
|
||||
this.stopAssignmentStatusPolling();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh aircraft display after partners are loaded
|
||||
*/
|
||||
private refreshAircraftDisplay(): void {
|
||||
// Reload aircraft data from backend API now that partners are cached
|
||||
// This ensures partner names are resolved correctly
|
||||
if (!this.job || !this.job._id) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.jobSvc.getAssignments({ 'jobId': this.job._id }).subscribe({
|
||||
next: (assignmentData) => {
|
||||
this.updateAircraftAssignmentData(assignmentData);
|
||||
// Trigger change detection to update the UI with partner names
|
||||
this.cdr.detectChanges();
|
||||
},
|
||||
error: (error) => {
|
||||
console.error(globals.consoleFailedToLoadExistingAssignments, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert backend user object (from assignments API) to AircraftAssignmentItem
|
||||
*/
|
||||
private convertBackendUserToAssignmentItem(user: any): AircraftAssignmentItem {
|
||||
// Determine partner information from partnerInfo
|
||||
let partnerId: string | undefined;
|
||||
let partnerName: string | undefined;
|
||||
let partnerCode: string | undefined;
|
||||
let sourceSystem: SystemOrPartnerType = SourceSystem.AGNAV; // default
|
||||
|
||||
// Check for partnerInfo.partnerCode (from assignments_post response)
|
||||
if (user.partnerInfo?.partnerCode) {
|
||||
partnerCode = user.partnerInfo.partnerCode;
|
||||
partnerName = user.partnerInfo.name;
|
||||
|
||||
// Find partner by partnerCode to get partner ID
|
||||
const partner = Array.from(this.partnersCache.values()).find(p =>
|
||||
p.partnerCode?.toUpperCase() === partnerCode!.toUpperCase()
|
||||
);
|
||||
|
||||
if (partner) {
|
||||
partnerId = partner._id;
|
||||
// Use partner ID as sourceSystem for all partner aircraft
|
||||
sourceSystem = partnerId as SystemOrPartnerType;
|
||||
}
|
||||
}
|
||||
|
||||
const assignmentItem: AircraftAssignmentItem = {
|
||||
_id: user.uid, // Backend returns uid instead of _id
|
||||
name: user.name,
|
||||
active: user.active || false,
|
||||
pkgActive: user.pkgActive || false,
|
||||
tailNumber: user.tailNumber,
|
||||
username: user.username, // Add username for tooltip display
|
||||
partnerSystem: sourceSystem,
|
||||
sourceSystem: sourceSystem,
|
||||
partnerId: partnerId,
|
||||
partnerName: partnerName,
|
||||
partnerCode: partnerCode
|
||||
};
|
||||
|
||||
// Add partner-specific data for all partner aircraft (not just SATLOC)
|
||||
if (!this.partnerUtils.isNativeSystem(sourceSystem)) {
|
||||
const partnerObj = partnerId ? this.getPartner(partnerId) : null;
|
||||
|
||||
// For backward compatibility, keep satlocData for SATLOC aircraft
|
||||
if (partnerObj && this.partnerUtils.isSatlocPartner(partnerObj)) {
|
||||
assignmentItem.satlocData = {
|
||||
tailNumber: user.tailNumber || Labels.N_A,
|
||||
syncStatus: user.partnerInfo?.metadata?.syncStatus || OperationalStatus.PENDING
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return assignmentItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get partner display name for aircraft
|
||||
*/
|
||||
getPartnerDisplayName(aircraft: AircraftAssignmentItem): string {
|
||||
if (aircraft.partnerName) {
|
||||
return aircraft.partnerName;
|
||||
}
|
||||
return Labels.AGNAV_BRAND_NAME; // Use consistent non-translatable brand name
|
||||
}
|
||||
|
||||
/**
|
||||
* Get partner display name from source system (for assignment status table)
|
||||
*/
|
||||
getPartnerDisplayNameFromSource(sourceSystem: SystemOrPartnerType): string {
|
||||
if (this.partnerUtils.isNativeSystem(sourceSystem)) {
|
||||
return Labels.AGNAV_BRAND_NAME;
|
||||
}
|
||||
const partner = this.getPartner(sourceSystem);
|
||||
return partner ? partner.name : sourceSystem.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get simplified tooltip text for aircraft
|
||||
* - For AgNav: name <br> username
|
||||
* - For Partner: partner name <br> tailNumber
|
||||
* - Adds warning if package is not active
|
||||
*/
|
||||
getAircraftTooltip(aircraft: AircraftAssignmentItem): string {
|
||||
let tooltip = '';
|
||||
|
||||
// For AgNav aircraft: show name and username
|
||||
if (aircraft.sourceSystem === SourceSystem.AGNAV) {
|
||||
if (aircraft.username) {
|
||||
tooltip = `${aircraft.name}<br>${aircraft.username}`;
|
||||
} else {
|
||||
tooltip = aircraft.name;
|
||||
}
|
||||
} else {
|
||||
// For Partner aircraft: show partner name and tail number
|
||||
const partnerName = this.getPartnerDisplayName(aircraft);
|
||||
if (aircraft.tailNumber) {
|
||||
tooltip = `${partnerName}<br>${aircraft.tailNumber}`;
|
||||
} else {
|
||||
tooltip = partnerName;
|
||||
}
|
||||
}
|
||||
|
||||
// Add package inactive warning if applicable
|
||||
if (!aircraft.pkgActive) {
|
||||
tooltip += `<br><span style="color: orange;">⚠️ ${Labels.PACKAGE_INACTIVE}</span>`;
|
||||
}
|
||||
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if aircraft can be assigned to job
|
||||
*/
|
||||
canAssignAircraft(aircraft: AircraftAssignmentItem): boolean {
|
||||
// Only check package status - no authentication constraints
|
||||
return aircraft.pkgActive === true;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AIRCRAFT SORTING UTILITIES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Sort aircraft by source system (AgNav first, then all partners alphabetically)
|
||||
*/
|
||||
private sortAircraftBySource(aircraft: AircraftAssignmentItem[]): AircraftAssignmentItem[] {
|
||||
return aircraft.sort((a, b) => {
|
||||
// Primary sort: AgNav first, then all partner systems
|
||||
const aIsNative = this.partnerUtils.isNativeSystem(a.sourceSystem);
|
||||
const bIsNative = this.partnerUtils.isNativeSystem(b.sourceSystem);
|
||||
|
||||
if (aIsNative && !bIsNative) return -1; // AgNav comes first
|
||||
if (!aIsNative && bIsNative) return 1; // AgNav comes first
|
||||
|
||||
// If both are partners or both are native, sort by partner name then aircraft name
|
||||
if (!aIsNative && !bIsNative) {
|
||||
// Both are partners - sort by partner name first
|
||||
const aPartnerName = a.partnerName || Labels.UNKNOWN_PARTNER;
|
||||
const bPartnerName = b.partnerName || Labels.UNKNOWN_PARTNER;
|
||||
const partnerCompare = aPartnerName.localeCompare(bPartnerName);
|
||||
if (partnerCompare !== 0) return partnerCompare;
|
||||
}
|
||||
|
||||
// Secondary sort: Alphabetical by aircraft name within each source group
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Main assignment method - handles both AgNav and partner aircraft
|
||||
*/
|
||||
assignJob(): void {
|
||||
if (!this.job) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Transform aircraft data to backend API format
|
||||
// Backend expects 'uid' property, but frontend model uses '_id'
|
||||
const formattedAsUsers = this.tarUsers.map(aircraft => {
|
||||
// Base assignment data - ALL aircraft need uid for backend
|
||||
const assignmentData: any = {
|
||||
uid: aircraft._id, // Required for all aircraft types - backend uses this for 'user' field
|
||||
name: aircraft.name
|
||||
};
|
||||
|
||||
// Add partner-specific data for Satloc aircraft
|
||||
if (aircraft.sourceSystem === KnownPartnerCodes.SATLOC && aircraft.satlocData) {
|
||||
assignmentData.partnerAircraftId = aircraft.satlocData.satlocId || aircraft._id;
|
||||
assignmentData.notes = `${Labels.SATLOC_AIRCRAFT_PREFIX} ${aircraft.satlocData.tailNumber}`;
|
||||
assignmentData.jobName = this.job.name;
|
||||
}
|
||||
|
||||
return assignmentData;
|
||||
});
|
||||
|
||||
const formattedAvUsers = this.srcUsers.map(aircraft => ({
|
||||
uid: aircraft._id,
|
||||
name: aircraft.name
|
||||
}));
|
||||
|
||||
const assignment: jobActions.AssignInfo = {
|
||||
jobId: this.job._id,
|
||||
dlOp: this.job.dlOp,
|
||||
avUsers: formattedAvUsers,
|
||||
asUsers: formattedAsUsers
|
||||
};
|
||||
|
||||
this.store.dispatch(new jobActions.Assign(assignment));
|
||||
|
||||
// Start polling immediately if assigning aircraft (to track assignment progress)
|
||||
if (formattedAsUsers.length > 0 && !this.isPollingAssignments) {
|
||||
this.startAssignmentStatusPolling();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ASSIGNMENT STATUS POLLING
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Start polling assignment status from backend API
|
||||
*/
|
||||
private startAssignmentStatusPolling(): void {
|
||||
if (this.isPollingAssignments) {
|
||||
return; // Already polling
|
||||
}
|
||||
|
||||
this.isPollingAssignments = true;
|
||||
this.stoppedAssignmentPoll.next(false);
|
||||
|
||||
const polling$ = this.pollAssignmentStatus().subscribe({
|
||||
next: (assignmentData) => {
|
||||
this.updateAssignmentStatus(assignmentData);
|
||||
},
|
||||
error: (error) => {
|
||||
console.error(globals.consoleAssignmentStatusPollingError, error);
|
||||
this.isPollingAssignments = false;
|
||||
// Reset assignment progress flag on polling error to prevent UI from being stuck
|
||||
if (this.isAssignmentInProgress) {
|
||||
this.isAssignmentInProgress = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add subscription to component's subscription manager
|
||||
this.sub$.add(polling$);
|
||||
}
|
||||
|
||||
private stopAssignmentStatusPolling(): void {
|
||||
if (this.stoppedAssignmentPoll) {
|
||||
this.stoppedAssignmentPoll.next(true);
|
||||
this.isPollingAssignments = false;
|
||||
}
|
||||
}
|
||||
|
||||
private pollAssignmentStatus(): Observable<any> {
|
||||
return interval(10000).pipe( // Poll every 10 seconds for real status updates
|
||||
startWith(1000), // Start after 1 second
|
||||
switchMap(() => this.jobSvc.getAssignments({ 'jobId': this.job._id })),
|
||||
takeUntil(this.stoppedAssignmentPoll),
|
||||
retryWhen(errors =>
|
||||
errors.pipe(
|
||||
delayWhen(val => timer(15 * 1000)) // Retry after 15 seconds on error
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private updateAssignmentStatus(assignmentData: any): void {
|
||||
// Clear assignment statuses if no assigned users (all unassigned)
|
||||
if (!assignmentData || !assignmentData.asUsers || assignmentData.asUsers.length === 0) {
|
||||
this.assignmentStatuses = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// Update assignment statuses with real assignment data
|
||||
this.assignmentStatuses = assignmentData.asUsers.map((assignedAircraft) => {
|
||||
const existingStatus = this.assignmentStatuses.find(s => s.aircraftId === assignedAircraft.uid);
|
||||
|
||||
// Use the assignStatus field from the backend data
|
||||
const backendStatus = assignedAircraft.assignStatus !== undefined ? assignedAircraft.assignStatus : AssignStatus.NEW;
|
||||
|
||||
// Generate status object from real assignment data
|
||||
const statusUpdate = this.generateAssignmentStatusFromBackend(assignedAircraft, backendStatus);
|
||||
|
||||
// If there's existing status, preserve timestamp if status hasn't changed
|
||||
if (existingStatus) {
|
||||
return {
|
||||
...statusUpdate,
|
||||
// Preserve timestamp if status hasn't changed
|
||||
timestamp: existingStatus.state === statusUpdate.state ? existingStatus.timestamp : new Date()
|
||||
};
|
||||
}
|
||||
|
||||
return statusUpdate;
|
||||
});
|
||||
|
||||
// Check if all assignments have completed (no longer in NEW/pending status)
|
||||
this.checkAssignmentProgress();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check assignment progress and update isAssignmentInProgress flag
|
||||
*/
|
||||
private checkAssignmentProgress(): void {
|
||||
if (this.assignmentStatuses.length === 0) {
|
||||
// No assignments to track
|
||||
this.isAssignmentInProgress = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if any assignments are still in NEW (pending) status
|
||||
const hasNewAssignments = this.assignmentStatuses.some(status => status.state === AssignStatus.NEW);
|
||||
|
||||
if (!hasNewAssignments && this.isAssignmentInProgress) {
|
||||
// All assignments have completed (no longer in NEW status)
|
||||
this.isAssignmentInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
private generateAssignmentStatusFromBackend(assignedAircraft: any, backendStatus: number): AssignmentStatus {
|
||||
const statusMessages = {
|
||||
[AssignStatus.NEW]: globals.assignmentInProgress,
|
||||
[AssignStatus.DOWNLOADED]: globals.assignmentDownloaded,
|
||||
[AssignStatus.UPLOADED]: globals.assignmentCompleted,
|
||||
[AssignStatus.ERROR]: globals.assignmentFailed
|
||||
};
|
||||
|
||||
// Find the aircraft in tarUsers or allAircraft to get sourceSystem
|
||||
const aircraft = this.tarUsers.find(a => a._id === assignedAircraft.uid) ||
|
||||
this.allAircraft.find(a => a._id === assignedAircraft.uid);
|
||||
const sourceSystem = aircraft?.sourceSystem || SourceSystem.AGNAV;
|
||||
|
||||
return {
|
||||
aircraftId: assignedAircraft.uid,
|
||||
aircraftName: assignedAircraft.name,
|
||||
sourceSystem: sourceSystem,
|
||||
state: backendStatus as AssignStatusType,
|
||||
message: statusMessages[backendStatus] || globals.unknownStatus,
|
||||
timestamp: new Date(),
|
||||
// Use actual error details from backend if available
|
||||
errorDetails: backendStatus === AssignStatus.ERROR ? assignedAircraft.errorDetails : undefined
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AIRCRAFT SELECTION HANDLERS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Handle aircraft selection/click - validate package status only
|
||||
*/
|
||||
async onAircraftSelect(aircraft: AircraftAssignmentItem, event: Event): Promise<void> {
|
||||
// Only validate if this is in the source list (available aircraft)
|
||||
const isInSourceList = this.srcUsers.some(ac => ac._id === aircraft._id);
|
||||
if (!isInSourceList) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check package status (applies to all aircraft)
|
||||
if (!aircraft.pkgActive) {
|
||||
// Package not enabled - visual feedback already provided via red highlighting and tooltip
|
||||
return;
|
||||
}
|
||||
|
||||
// No authentication validation - allow all aircraft with active packages to be assigned
|
||||
}
|
||||
|
||||
/**
|
||||
* Aircraft movement event handlers
|
||||
*/
|
||||
async onMoveToTarget(event: any): Promise<void> {
|
||||
// Get the aircraft that were just moved
|
||||
const movedAircraft = event.items || [];
|
||||
|
||||
// Validate each moved aircraft for package status only
|
||||
for (const aircraft of movedAircraft) {
|
||||
let shouldMoveBack = false;
|
||||
let reason = '';
|
||||
|
||||
// Check package active status - only constraint remaining
|
||||
if (!aircraft.pkgActive) {
|
||||
shouldMoveBack = true;
|
||||
reason = Labels.PACKAGE_NOT_ENABLED_REASON;
|
||||
}
|
||||
|
||||
// Move aircraft back to source if validation failed
|
||||
if (shouldMoveBack) {
|
||||
const aircraftIndex = this.tarUsers.findIndex(ac => ac._id === aircraft._id);
|
||||
if (aircraftIndex !== -1) {
|
||||
this.tarUsers.splice(aircraftIndex, 1);
|
||||
this.srcUsers.push(aircraft);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply sorting to maintain AgNav-first order
|
||||
this.tarUsers = this.sortAircraftBySource(this.tarUsers);
|
||||
this.srcUsers = this.sortAircraftBySource(this.srcUsers);
|
||||
}
|
||||
|
||||
onMoveToSource(event: any): void {
|
||||
// Apply sorting to maintain AgNav-first order
|
||||
this.srcUsers = this.sortAircraftBySource(this.srcUsers);
|
||||
this.tarUsers = this.sortAircraftBySource(this.tarUsers);
|
||||
}
|
||||
|
||||
/**
|
||||
* UI Helper Methods (unified badge system)
|
||||
* Uses BadgeFactoryService to create configuration-driven badges
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get badge configuration for aircraft source system (picklist)
|
||||
*/
|
||||
getAircraftSystemBadge(aircraft: AircraftAssignmentItem): BadgeConfig {
|
||||
return this.badgeFactory.createSystemBadge(
|
||||
aircraft.sourceSystem,
|
||||
this.getPartnerDisplayName(aircraft)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get badge configuration for assignment status source system (status table)
|
||||
*/
|
||||
getStatusSystemBadge(sourceSystem: SystemOrPartnerType): BadgeConfig {
|
||||
return this.badgeFactory.createSystemBadge(
|
||||
sourceSystem,
|
||||
this.getPartnerDisplayNameFromSource(sourceSystem)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get badge configuration for assignment status (status table)
|
||||
*/
|
||||
getAssignmentStatusBadge(status: AssignmentStatus): BadgeConfig {
|
||||
return this.badgeFactory.createAssignmentStatusBadge(
|
||||
status.state,
|
||||
status.message
|
||||
);
|
||||
}
|
||||
|
||||
getRefreshStatusTooltip(): string {
|
||||
return Labels.MANUALLY_REFRESH_ASSIGNMENT_STATUS;
|
||||
}
|
||||
|
||||
getAssignButtonTooltip(): string {
|
||||
if (this.isArchived) {
|
||||
return Labels.ASSIGN_BUTTON_ARCHIVED_TOOLTIP;
|
||||
}
|
||||
if (!this.canDownload) {
|
||||
return Labels.ASSIGN_BUTTON_NO_BOUNDARY_TOOLTIP;
|
||||
}
|
||||
return Labels.ASSIGN_BUTTON_READY_TOOLTIP;
|
||||
}
|
||||
|
||||
getPickListSourceTooltip(): string {
|
||||
return Labels.PICK_LIST_SOURCE_TOOLTIP;
|
||||
}
|
||||
|
||||
getPickListTargetTooltip(): string {
|
||||
return Labels.PICK_LIST_TARGET_TOOLTIP;
|
||||
}
|
||||
|
||||
getDownloadOptionsTooltip(): string {
|
||||
return Labels.DOWNLOAD_OPTIONS_DROPDOWN_TOOLTIP;
|
||||
}
|
||||
|
||||
getStatusTableTooltip(): string {
|
||||
return Labels.ASSIGNMENT_STATUS_TABLE_TOOLTIP;
|
||||
}
|
||||
|
||||
getStatusIconTooltip(status: AssignmentStatus): string {
|
||||
switch (status.state) {
|
||||
case AssignStatus.NEW:
|
||||
return Labels.ASSIGNMENT_STATUS_NEW_TOOLTIP;
|
||||
case AssignStatus.DOWNLOADED:
|
||||
return Labels.ASSIGNMENT_STATUS_DOWNLOADED_TOOLTIP;
|
||||
case AssignStatus.UPLOADED:
|
||||
return Labels.ASSIGNMENT_STATUS_UPLOADED_TOOLTIP;
|
||||
case AssignStatus.ERROR:
|
||||
return Labels.ASSIGNMENT_STATUS_ERROR_TOOLTIP;
|
||||
default:
|
||||
return Labels.ASSIGNMENT_STATUS_NEW_TOOLTIP; // Default to new/pending
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assignment Status UI Methods
|
||||
*/
|
||||
refreshAssignmentStatus(): void {
|
||||
if (!this.job || !this.job._id) {
|
||||
console.warn(globals.consoleCannotRefreshAssignmentStatus);
|
||||
return;
|
||||
}
|
||||
|
||||
this.jobSvc.getAssignments({ 'jobId': this.job._id }).subscribe({
|
||||
next: (assignmentData) => {
|
||||
// Update both assignment status AND aircraft lists
|
||||
this.updateAircraftAssignmentData(assignmentData);
|
||||
},
|
||||
error: (error) => {
|
||||
console.error(globals.consoleFailedToRefreshAssignmentStatus, error);
|
||||
this.msgSvc.addFailedMsg(globals.failedToRefreshAssignmentStatus);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assignment Status Action Methods
|
||||
*/
|
||||
getUnifiedActionOptions(status: AssignmentStatus): MenuItem[] {
|
||||
const commonActions = [
|
||||
{
|
||||
label: globals.clearStatus,
|
||||
icon: 'ui-icon-clear',
|
||||
command: () => this.clearSingleStatus(status.aircraftId)
|
||||
}
|
||||
];
|
||||
|
||||
if (status.state === AssignStatus.ERROR) {
|
||||
return [
|
||||
...commonActions,
|
||||
{
|
||||
separator: true
|
||||
},
|
||||
{
|
||||
label: globals.resetToAvailable,
|
||||
icon: 'ui-icon-arrow-back',
|
||||
command: () => this.resetAircraftToAvailable(status.aircraftId)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
if (status.state === AssignStatus.UPLOADED) {
|
||||
return [
|
||||
...commonActions
|
||||
];
|
||||
}
|
||||
|
||||
// Default actions for any other states
|
||||
return commonActions;
|
||||
}
|
||||
|
||||
clearSingleStatus(aircraftId: string): void {
|
||||
this.assignmentStatuses = this.assignmentStatuses.filter(
|
||||
status => status.aircraftId !== aircraftId
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
resetAircraftToAvailable(aircraftId: string): void {
|
||||
// Find the aircraft in assigned list
|
||||
const aircraft = this.tarUsers.find(a => a._id === aircraftId);
|
||||
if (!aircraft) return;
|
||||
|
||||
// Move aircraft back to available list
|
||||
this.tarUsers = this.tarUsers.filter(a => a._id !== aircraftId);
|
||||
|
||||
// Add to source list only if package active, meets auth requirements, and not already there
|
||||
// Partner aircraft: Only require active package
|
||||
// Native aircraft: Require active package AND credentials (matches backend filter: username: { $nin: [null, ''] })
|
||||
const isPartner = !this.partnerUtils.isNativeSystem(aircraft.sourceSystem);
|
||||
const hasCredentials = aircraft.username && aircraft.username !== '';
|
||||
const meetsAuthRequirements = isPartner || hasCredentials;
|
||||
|
||||
if (aircraft.pkgActive === true && meetsAuthRequirements && !this.srcUsers.find(a => a._id === aircraftId)) {
|
||||
this.srcUsers.push(aircraft);
|
||||
// Apply sorting to maintain AgNav-first order
|
||||
this.srcUsers = this.sortAircraftBySource(this.srcUsers);
|
||||
}
|
||||
|
||||
// Clear the status
|
||||
this.clearSingleStatus(aircraftId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Status helper methods for template
|
||||
*/
|
||||
getStatusIcon(status: AssignmentStatus): string {
|
||||
switch (status.state) {
|
||||
case AssignStatus.NEW:
|
||||
return 'pi-spin pi-spinner';
|
||||
case AssignStatus.DOWNLOADED:
|
||||
return 'pi-download';
|
||||
case AssignStatus.UPLOADED:
|
||||
return 'pi-check-circle';
|
||||
case AssignStatus.ERROR:
|
||||
return 'pi-times-circle';
|
||||
default:
|
||||
return 'pi-question-circle';
|
||||
}
|
||||
}
|
||||
|
||||
isStatusNew(status: AssignmentStatus): boolean {
|
||||
return status.state === AssignStatus.NEW;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific aircraft's assignment is in progress
|
||||
*/
|
||||
isAircraftAssignmentInProgress(status: AssignmentStatus): boolean {
|
||||
return status.state === AssignStatus.NEW;
|
||||
}
|
||||
|
||||
getStatusCssClass(status: AssignmentStatus): string {
|
||||
switch (status.state) {
|
||||
case AssignStatus.NEW:
|
||||
return 'new';
|
||||
case AssignStatus.DOWNLOADED:
|
||||
return 'downloaded';
|
||||
case AssignStatus.UPLOADED:
|
||||
return 'uploaded';
|
||||
case AssignStatus.ERROR:
|
||||
return 'error';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
}
|
||||
@ -1,3 +1,732 @@
|
||||
.sprayed-value {
|
||||
margin-top: .25em;
|
||||
}
|
||||
|
||||
/* Aircraft Item Layout */
|
||||
.aircraft-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 4px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.aircraft-name {
|
||||
flex: 1;
|
||||
margin-right: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.aircraft-icon {
|
||||
color: #007ad9;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Satloc-specific Details */
|
||||
.satloc-details {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.tail-number {
|
||||
background-color: #f5f5f5;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
margin-right: 6px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Sync Status Indicators */
|
||||
.sync-status {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.sync-status-active {
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.sync-status-pending {
|
||||
color: #ff9800;
|
||||
}
|
||||
|
||||
.sync-status-error {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
/* Package Status */
|
||||
.package-inactive {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* Hover Effects */
|
||||
.aircraft-item:hover {
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Aircraft item hover effects are now handled by global badge system */
|
||||
|
||||
/* Download Options Styling */
|
||||
.download-options-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
padding: 4px 8px;
|
||||
background-color: #e8f4fd;
|
||||
border: 1px solid #bbdefb;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
color: #1976d2;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.download-options-info .pi {
|
||||
font-size: 0.9rem;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
/* Responsive adjustments for download options */
|
||||
@media (max-width: 768px) {
|
||||
.download-options-info {
|
||||
font-size: 0.75rem;
|
||||
padding: 3px 6px;
|
||||
}
|
||||
|
||||
.download-options-info .pi {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.download-options-info {
|
||||
margin-top: 2px;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.aircraft-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 6px 4px;
|
||||
}
|
||||
|
||||
.satloc-details {
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Round Split Button Styling with ::ng-deep */
|
||||
:host ::ng-deep .assignment-action-button.slim .ui-splitbutton {
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
}
|
||||
|
||||
:host ::ng-deep .assignment-action-button.slim .ui-splitbutton .ui-button {
|
||||
border-radius: 50% !important;
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
padding: 0 !important;
|
||||
min-width: auto !important;
|
||||
background-color: #6c757d !important;
|
||||
border-color: #6c757d !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
:host ::ng-deep .assignment-action-button.slim .ui-splitbutton .ui-button:hover {
|
||||
background-color: #5a6268 !important;
|
||||
border-color: #545b62 !important;
|
||||
}
|
||||
|
||||
/* Hide the main button, show only dropdown arrow */
|
||||
:host ::ng-deep .assignment-action-button.slim .ui-splitbutton .ui-button:first-child {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Style the dropdown arrow button to be round */
|
||||
:host ::ng-deep .assignment-action-button.slim .ui-splitbutton .ui-splitbutton-menubutton {
|
||||
border-radius: 50% !important;
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
border-left: none !important;
|
||||
padding: 0 !important;
|
||||
background-color: #6c757d !important;
|
||||
border-color: #6c757d !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
:host ::ng-deep .assignment-action-button.slim .ui-splitbutton .ui-splitbutton-menubutton:hover {
|
||||
background-color: #5a6268 !important;
|
||||
border-color: #545b62 !important;
|
||||
}
|
||||
|
||||
/* Override PrimeNG corner classes */
|
||||
:host ::ng-deep .assignment-action-button.slim .ui-corner-right {
|
||||
border-radius: 50% !important;
|
||||
}
|
||||
|
||||
/* Center the dropdown arrow icon */
|
||||
:host ::ng-deep .assignment-action-button.slim .ui-splitbutton .ui-splitbutton-menubutton .ui-button-icon-left {
|
||||
margin: 0 !important;
|
||||
font-size: 0.8rem !important;
|
||||
}
|
||||
|
||||
/* Assignment Status Display Styles (Update 1.1.4a) */
|
||||
.assignment-status-section {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
background-color: #fafafa;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.assignment-status-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.assignment-status-header h4 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.clear-status-btn {
|
||||
padding: 4px 8px !important;
|
||||
min-width: auto !important;
|
||||
font-size: 0.8rem !important;
|
||||
background-color: #ffc107 !important;
|
||||
color: #666 !important;
|
||||
}
|
||||
|
||||
.clear-status-btn:hover {
|
||||
background-color: #e0e0e0 !important;
|
||||
color: #333 !important;
|
||||
}
|
||||
|
||||
.assignment-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
background-color: #e3f2fd;
|
||||
border: 1px solid #bbdefb;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 12px;
|
||||
color: #1976d2;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.assignment-progress .pi-spinner {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
/* Assignment Status Polling Indicator */
|
||||
.polling-status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background-color: #e3f2fd;
|
||||
border: 1px solid #90caf9;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 12px;
|
||||
color: #1565c0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.polling-status-indicator .pi-refresh {
|
||||
font-size: 1rem;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.polling-status-indicator small {
|
||||
margin-left: auto;
|
||||
color: #424242;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.assignment-error-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
background-color: #ffebee;
|
||||
border: 1px solid #ffcdd2;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 12px;
|
||||
color: #c62828;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.assignment-error-summary .pi {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.status-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e0e0e0;
|
||||
background-color: white;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.status-item:hover {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.status-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.status-aircraft-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.status-aircraft-info .aircraft-name {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.status-timestamp {
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
font-size: 0.9rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.status-error-details {
|
||||
font-size: 0.8rem;
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Status actions column centering and sizing */
|
||||
.status-actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center !important;
|
||||
width: 100% !important;
|
||||
margin: 0 auto !important;
|
||||
}
|
||||
|
||||
/* Assignment Status Table Actions column centering */
|
||||
.assignment-status-table .status-actions {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
width: 100% !important;
|
||||
max-width: 56px !important;
|
||||
margin: 0 auto !important;
|
||||
}
|
||||
|
||||
|
||||
/* Ensure the assignment action button container is centered */
|
||||
.assignment-action-button.slim {
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
padding: 6px 12px !important;
|
||||
font-size: 0.8rem !important;
|
||||
min-width: auto !important;
|
||||
background-color: #ff9800 !important;
|
||||
border: 1px solid #f57c00 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.retry-btn:hover:not(:disabled) {
|
||||
background-color: #f57c00 !important;
|
||||
border-color: #ef6c00 !important;
|
||||
}
|
||||
|
||||
.retry-btn:disabled {
|
||||
opacity: 0.6 !important;
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
|
||||
/* Enhanced Status Actions Menu Items */
|
||||
.p-menu .p-menuitem-link {
|
||||
font-size: 0.9rem !important;
|
||||
padding: 8px 12px !important;
|
||||
}
|
||||
|
||||
.p-menu .p-menuitem-icon {
|
||||
margin-right: 8px !important;
|
||||
font-size: 0.85rem !important;
|
||||
}
|
||||
|
||||
/* Split Button Menu Positioning */
|
||||
.status-actions .p-splitbutton .p-menu {
|
||||
min-width: 180px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Position dropdown menu slightly to the left to prevent cutoff at screen edge */
|
||||
:host ::ng-deep .assignment-action-button.slim .ui-menu {
|
||||
transform: translateX(-120px) !important;
|
||||
margin-top: 2px !important;
|
||||
min-width: 180px !important;
|
||||
}
|
||||
|
||||
/* Alternative positioning for PrimeNG p-menu */
|
||||
:host ::ng-deep .assignment-action-button.slim .p-menu {
|
||||
transform: translateX(-120px) !important;
|
||||
margin-top: 2px !important;
|
||||
min-width: 180px !important;
|
||||
}
|
||||
|
||||
/* Responsive adjustments for split buttons */
|
||||
@media (max-width: 768px) {
|
||||
|
||||
.retry-split-button .p-button,
|
||||
.status-split-button .p-button {
|
||||
padding: 4px 8px !important;
|
||||
font-size: 0.75rem !important;
|
||||
}
|
||||
|
||||
.status-actions .p-splitbutton .p-menu {
|
||||
min-width: 160px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Status State Specific Styles */
|
||||
.status-pending .status-icon {
|
||||
color: #ff9800;
|
||||
}
|
||||
|
||||
.status-retrying .status-icon {
|
||||
color: #ff9800;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
border-color: #c8e6c9;
|
||||
background-color: #f1f8e9;
|
||||
}
|
||||
|
||||
.status-success .status-icon {
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
border-color: #ffcdd2;
|
||||
background-color: #ffebee;
|
||||
}
|
||||
|
||||
.status-error .status-icon {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
/* Responsive Design for Assignment Status */
|
||||
@media (max-width: 768px) {
|
||||
.assignment-status-section {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.assignment-status-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.assignment-status-header h4 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
padding: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-aircraft-info {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.status-aircraft-info .aircraft-name {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status-timestamp {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
padding: 4px 8px !important;
|
||||
font-size: 0.75rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.status-item {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.status-actions {
|
||||
align-self: flex-end;
|
||||
max-width: 48px !important;
|
||||
}
|
||||
|
||||
.assignment-progress {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.assignment-error-summary {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Assignment Status Table Styling (Update 1.1.4b) */
|
||||
.assignment-status-table {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.assignment-status-table .ui-table-tbody>tr {
|
||||
border-left: 4px solid transparent;
|
||||
}
|
||||
|
||||
.assignment-status-table .status-row-pending {
|
||||
border-left-color: #2196f3;
|
||||
}
|
||||
|
||||
.assignment-status-table .status-row-success {
|
||||
border-left-color: #4caf50;
|
||||
}
|
||||
|
||||
.assignment-status-table .status-row-error {
|
||||
border-left-color: #f44336;
|
||||
}
|
||||
|
||||
.assignment-status-table .status-row-retrying {
|
||||
border-left-color: #ff9800;
|
||||
}
|
||||
|
||||
.aircraft-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.aircraft-cell .pi {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.status-badge-pending {
|
||||
background-color: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.status-badge-success {
|
||||
background-color: #e8f5e8;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.status-badge-error {
|
||||
background-color: #ffebee;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.status-badge-retrying {
|
||||
background-color: #fff3e0;
|
||||
color: #f57c00;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-error-details {
|
||||
margin-top: 4px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.status-timestamp {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Responsive design for table */
|
||||
@media (max-width: 768px) {
|
||||
.assignment-status-table .ui-table-thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.assignment-status-table .ui-table-tbody>tr>td {
|
||||
display: block;
|
||||
border: none;
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.assignment-status-table .ui-table-tbody>tr>td:before {
|
||||
content: attr(data-label) ": ";
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
width: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Update 1.1.6a: Slim Split Button Actions Styling */
|
||||
|
||||
/* Slim Split Button for Assignment Status Table */
|
||||
.assignment-action-button.slim .ui-splitbutton {
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
}
|
||||
|
||||
.assignment-action-button.slim .ui-splitbutton .ui-button {
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
border-radius: 50% !important;
|
||||
padding: 0 !important;
|
||||
min-width: auto !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
background-color: #6c757d !important;
|
||||
border-color: #6c757d !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.assignment-action-button.slim .ui-splitbutton .ui-button:hover {
|
||||
background-color: #5a6268 !important;
|
||||
border-color: #545b62 !important;
|
||||
}
|
||||
|
||||
.assignment-action-button.slim .ui-splitbutton .ui-button:focus {
|
||||
outline: none !important;
|
||||
box-shadow: 0 0 0 2px rgba(108, 117, 125, 0.5) !important;
|
||||
}
|
||||
|
||||
/* Hide the left button for slim style - enhanced for assignment status */
|
||||
.assignment-action-button.slim .ui-splitbutton .ui-button:first-child {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Style the dropdown arrow button */
|
||||
.assignment-action-button.slim .ui-splitbutton .ui-splitbutton-menubutton {
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
border-radius: 50% !important;
|
||||
border-left: none !important;
|
||||
padding: 0 !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
.assignment-action-button.slim .ui-splitbutton .ui-splitbutton-menubutton .ui-button-icon-primary {
|
||||
margin: 0 !important;
|
||||
font-size: 1rem !important;
|
||||
}
|
||||
|
||||
/* Status indicator text for pending/retrying states */
|
||||
.status-indicator-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.status-indicator-text .pi {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status-indicator-text .pi-spin {
|
||||
color: #ff9800;
|
||||
}
|
||||
|
||||
.status-indicator-text .pi-clock {
|
||||
color: #2196f3;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.assignment-action-button.slim .ui-splitbutton {
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
}
|
||||
|
||||
.assignment-action-button.slim .ui-splitbutton .ui-button,
|
||||
.assignment-action-button.slim .ui-splitbutton .ui-splitbutton-menubutton {
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
}
|
||||
|
||||
.status-indicator-text {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
@ -18,8 +18,7 @@
|
||||
<div class="ui-g">
|
||||
<div class="ui-g-3 ui-sm-4"><strong i18n="@@name">Name</strong></div>
|
||||
<div class="ui-g-9 ui-sm-8">
|
||||
<input type="text" id="jobName" name="jobName" #jobName="ngModel" [pattern]="GC?.itemNameRegex" pInputText [(ngModel)]="selectedItem.name" #jobNameRef maxlength="20"
|
||||
(blur)="selectedItem.name = selectedItem.name.trim()" required>
|
||||
<input type="text" id="jobName" name="jobName" #jobName="ngModel" [pattern]="GC?.itemNameRegex" pInputText [(ngModel)]="selectedItem.name" #jobNameRef maxlength="20" (blur)="selectedItem.name = selectedItem.name.trim()" required>
|
||||
<span i18n="@@invalidJobNamVal" *ngIf="jobName.invalid && (jobName.dirty || jobName.touched)" class="ui-message ui-messages-error ui-corner-all">Job Name is required and must not contains special characters</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -427,31 +426,8 @@
|
||||
</div>
|
||||
<div *ngIf="isPlanner && isEdit" class="ui-g-12 ui-md-12 ui-lg-12">
|
||||
<div class="ui-g-12 ui-g-nopad">
|
||||
<p-panel i18n-header="@@jobAssignment" header="Job Assignment" [toggleable]="true" [collapsed]="false">
|
||||
<div class="ui-g">
|
||||
<div class="ui-g-12 ui-g-nopad">
|
||||
<p-pickList [source]="srcUsers" [target]="tarUsers" i18n-sourceHeader="@@aircraft" sourceHeader="Aircraft" i18n-targetHeader="@@assignedAircraft" [disabled]="isArchived" targetHeader="Assigned Aircraft" dragdrop="true" dragdropScope="users" [responsive]="true" [sourceStyle]="{'height':'180px'}" [targetStyle]="{'height':'180px'}" [showSourceControls]="false" [showTargetControls]="false" (onMoveToSource)="onMoveToActiveList($event.items)">
|
||||
<ng-template let-user pTemplate="item">
|
||||
<div class="ui-helper-clearfix;" [style.color]="!user.active ? 'red' : null">
|
||||
<i class="fa ui-icon-account-circle" style="vertical-align: middle; margin-right: 4px" aria-hidden="true"></i>
|
||||
<span pTooltip="{{ getUserToolTip(user) }}">{{ user.name }}</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
</p-pickList>
|
||||
</div>
|
||||
<div class="ui-g-12 ui-g-nopad" style="margin-top: 10px">
|
||||
<div class="ui-g-12 ui-sm-4 ui-md-4 ui-lg-4 ui-xl-3"><label for="dlOps"> <ng-container i18n="@@downloadOptions">Download Options</ng-container>:</label>
|
||||
</div>
|
||||
<div class="ui-g-12 ui-sm-8 ui-md-6 ui-lg-8 ui-xl-9">
|
||||
<p-dropdown id="dlOps" name="formats" [(ngModel)]="selectedItem.dlOp.type" [disabled]="isArchived" [style]="{'minWidth':'120px'}" [options]="dlOps">
|
||||
</p-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui-g-12 ui-g-nopad" style="margin-top: 10px">
|
||||
<button class="blue-btn" pButton type="button" icon="ui-icon-assignment-ind" i18n-label="@@assign" label="Assign" (click)="assignJob()" [disabled]="isArchived || !canDownload"></button>
|
||||
</div>
|
||||
</div>
|
||||
</p-panel>
|
||||
<agm-job-assignment [job]="selectedItem" [isEdit]="isEdit" [isArchived]="isArchived" [canDownload]="canDownload" [dlOps]="dlOps" (assignmentComplete)="onAssignmentComplete($event)" (assignmentErrorEvent)="onAssignmentError($event)">
|
||||
</agm-job-assignment>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -41,7 +41,6 @@ import { selectLimit } from '@app/reducers';
|
||||
import { Acre } from '@app/domain/models/subscription.model';
|
||||
import { SUB, SubTexts, SubType } from '@app/profile/common';
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'agm-job-edit',
|
||||
templateUrl: './job-edit.component.html',
|
||||
@ -105,9 +104,6 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
|
||||
grpedProds: SelectItemGroup[] = [];
|
||||
|
||||
srcUsers: any[];
|
||||
tarUsers: any[];
|
||||
|
||||
uploadUrl = '/imports/uploadJob';
|
||||
uploadedFiles = [];
|
||||
dlLogs = [];
|
||||
@ -405,9 +401,6 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
this.resetNewEntities();
|
||||
this.checkOKDl();
|
||||
}));
|
||||
this.sub$.add(this.appActions.ofType(jobActions.ASSIGN_SUCCESS).subscribe((action) => {
|
||||
this._job['dlOp'] = this.selectedItem.dlOp;
|
||||
}));
|
||||
|
||||
this.sub$.add(this.store.select(selectLimit(SubType.PACKAGE)).pipe(take(1)).subscribe((pkg) => {
|
||||
this.acre = pkg[this.authSvc.getCurLookupKey(SubType.PACKAGE)]?.acre;
|
||||
@ -441,9 +434,6 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
if (this.isEdit) {
|
||||
this.getUploadedFiles();
|
||||
this.getLogs();
|
||||
if (this.isPlanner) {
|
||||
this.getAssignments();
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
|
||||
@ -487,15 +477,6 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
});
|
||||
}
|
||||
|
||||
private getAssignments() {
|
||||
this.jobSvc.getAssignments({ 'jobId': this.job._id }).subscribe((res) => {
|
||||
if (res) {
|
||||
this.srcUsers = !Utils.isEmptyArray(res.avUsers) ? res.avUsers.filter(u => u.pkgActive) : [];
|
||||
this.tarUsers = res.asUsers;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private getAppRateUnits(isUS: boolean) {
|
||||
if (isUS) {
|
||||
this.rateUnits = [
|
||||
@ -550,19 +531,6 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
return valid;
|
||||
}
|
||||
|
||||
onMoveToActiveList(items) {
|
||||
if (items && items.length) {
|
||||
const inactiveACList = items.filter(i => i.active === false);
|
||||
if (inactiveACList.length) {
|
||||
this.tarUsers = [...this.tarUsers, ...inactiveACList];
|
||||
this.srcUsers = this.srcUsers.filter(u => u.active === true);
|
||||
let errMsg = $localize`:@@cannotUnAssignInactiveVehicles:Cannot unassign inactive Aircraft`;
|
||||
errMsg += ':[ ' + (inactiveACList.map(u => u.name)).join(',') + ' ]';
|
||||
this.msgSvc.addFailedMsg(errMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getUserToolTip(user) {
|
||||
if (Utils.isEmptyObj(user)) return '';
|
||||
let userTT = user.username;
|
||||
@ -686,6 +654,22 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
}
|
||||
|
||||
onStatusChanged(event) {
|
||||
const oldStatus = this.selectedItem.status; // Current status before change
|
||||
const newStatus = event.value; // New status from dropdown
|
||||
|
||||
// Track job status change with GA4
|
||||
this.gaSvc.trackJobStatusChanged({
|
||||
user_id: this.authSvc.user?._id || 'anonymous',
|
||||
platform: 'web',
|
||||
job_id: this.selectedItem._id?.toString() || 'unknown',
|
||||
old_status: this.mapStatusToString(oldStatus),
|
||||
new_status: this.mapStatusToString(newStatus),
|
||||
status_change_reason: 'user_action',
|
||||
completion_time: newStatus === 3 ? new Date().toISOString() : undefined,
|
||||
efficiency_score: this.calculateEfficiencyScore(oldStatus, newStatus)
|
||||
});
|
||||
|
||||
// Existing logic
|
||||
if (this.isEndStatus(event.value)) {
|
||||
if (!this.selectedItem.endDate) {
|
||||
this.selectedItem.endDate = new Date();
|
||||
@ -705,6 +689,38 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
return [1, 2, 3].includes(status) && this.selectedItem.status > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map numeric status to string for GA4 tracking
|
||||
*/
|
||||
private mapStatusToString(status: number): 'new' | 'ready' | 'downloaded' | 'sprayed' | 'archived' {
|
||||
switch (status) {
|
||||
case 0: return 'new';
|
||||
case 1: return 'ready';
|
||||
case 2: return 'downloaded';
|
||||
case 3: return 'sprayed';
|
||||
case 9: return 'archived';
|
||||
default: return 'new';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate efficiency score based on status transition
|
||||
*/
|
||||
private calculateEfficiencyScore(oldStatus: number, newStatus: number): number {
|
||||
// Simple efficiency scoring based on forward progression
|
||||
if (newStatus > oldStatus && newStatus !== 9) {
|
||||
// Forward progression (positive)
|
||||
return Math.min(100, 70 + (newStatus - oldStatus) * 10);
|
||||
} else if (newStatus < oldStatus && oldStatus !== 9) {
|
||||
// Backward progression (less efficient)
|
||||
return Math.max(30, 50 - (oldStatus - newStatus) * 10);
|
||||
} else if (newStatus === 9) {
|
||||
// Archived status
|
||||
return oldStatus >= 3 ? 90 : 60; // High if completed work, lower if archived early
|
||||
}
|
||||
return 50; // Default/neutral score
|
||||
}
|
||||
|
||||
onUnitChanged(event) {
|
||||
if (event) {
|
||||
this.updateUnits(this.selectedItem.measureUnit);
|
||||
@ -739,23 +755,6 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
}
|
||||
}
|
||||
|
||||
downLoadJob(type: number) {
|
||||
this.doDownLoadJob(type);
|
||||
}
|
||||
|
||||
private doDownLoadJob(type) {
|
||||
// TODO: Need to be handled in effects ???
|
||||
this.jobSvc.downloadJob({ jobId: this.selectedItem._id, type: type }).subscribe(
|
||||
(res) => {
|
||||
try {
|
||||
saveAs(res, `${this.selectedItem.name}_${this.selectedItem._id}.zip`);
|
||||
} catch (error) {
|
||||
alert('Sorry. Your browser does not support this feature !');
|
||||
}
|
||||
this.getLogs();
|
||||
});
|
||||
}
|
||||
|
||||
editJobMap(id?: number) {
|
||||
this.router.navigate(
|
||||
[
|
||||
@ -767,6 +766,17 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
onSelectUpload(event) {
|
||||
this.uploadErrorMsg = '';
|
||||
if (this.uploader.hasFiles()) {
|
||||
// Track file upload start using GA4 convention
|
||||
const files = this.uploader.files;
|
||||
files.forEach(file => {
|
||||
this.gaSvc.trackFileUploadStarted({
|
||||
file_type: this.gaHelpers.determineFileType(file.name),
|
||||
file_size_mb: Number((file.size / (1024 * 1024)).toFixed(2)),
|
||||
related_job_id: this.job?._id?.toString(),
|
||||
upload_source: 'manual',
|
||||
platform: 'web'
|
||||
});
|
||||
});
|
||||
this.uploader.upload();
|
||||
}
|
||||
}
|
||||
@ -787,7 +797,19 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
if (res && res['_id']) {
|
||||
this.curAppId = res['_id'];
|
||||
this.checkImportStatus(this.curAppId);
|
||||
this.gaSvc.gaEvent('JOBS', 'DATA', 'U');
|
||||
// Track successful file upload using GA4 convention
|
||||
const fileType = this.uploader.files && this.uploader.files.length > 0
|
||||
? this.gaHelpers.determineFileType(this.uploader.files[0].name)
|
||||
: 'prescription_map'; // Default fallback for job context
|
||||
this.gaSvc.trackFileUploadCompleted({
|
||||
file_size_mb: 0,
|
||||
file_type: fileType,
|
||||
related_job_id: this.job?._id?.toString(),
|
||||
upload_source: 'manual',
|
||||
processing_time_seconds: 0,
|
||||
validation_status: 'passed',
|
||||
platform: 'web'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -796,6 +818,22 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
if (event && event.error) {
|
||||
const resp = event.error;
|
||||
const status = resp.status;
|
||||
|
||||
// Track file upload failure using GA4 convention
|
||||
if (this.uploader.files && this.uploader.files.length > 0) {
|
||||
const file = this.uploader.files[0];
|
||||
this.gaSvc.trackFileUploadFailed({
|
||||
file_type: this.gaHelpers.determineFileType(file.name),
|
||||
file_size_mb: Number((file.size / (1024 * 1024)).toFixed(2)),
|
||||
related_job_id: this.job?._id?.toString(),
|
||||
upload_source: 'manual',
|
||||
error_type: status === 401 ? 'authentication_error' : 'server_error',
|
||||
error_message: resp.error?.['error']?.['.tag'] || 'Upload failed',
|
||||
retry_attempted: false,
|
||||
platform: 'web'
|
||||
});
|
||||
}
|
||||
|
||||
if (status === 401) {
|
||||
this.store.dispatch(new authActions.Logout);
|
||||
} else if (status > 400) {
|
||||
@ -948,6 +986,20 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
this.jobSvc.deleteAppFile({ appId: appFile.id }).subscribe((data) => {
|
||||
if (data['appId']) {
|
||||
this.uploadedFiles = this.uploadedFiles.filter(it => it.id !== data['appId']);
|
||||
|
||||
// Track file deletion using GA4 convention
|
||||
const fileType = appFile.fileName
|
||||
? this.gaHelpers.determineFileType(appFile.fileName)
|
||||
: 'prescription_map'; // Default fallback for job context
|
||||
this.gaSvc.trackFileDeleted({
|
||||
file_type: fileType,
|
||||
file_size_mb: 0, // File size not available in appFile object
|
||||
related_job_id: this.job?._id?.toString(),
|
||||
deletion_reason: 'user_action',
|
||||
file_age_days: appFile.when ? Math.floor((Date.now() - new Date(appFile.when).getTime()) / (1000 * 60 * 60 * 24)) : undefined,
|
||||
confirmation_required: true,
|
||||
platform: 'web'
|
||||
});
|
||||
}
|
||||
this.updateTotalCoverage();
|
||||
});
|
||||
@ -955,18 +1007,51 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
});
|
||||
}
|
||||
|
||||
assignJob() {
|
||||
if (!this.job) {
|
||||
return;
|
||||
// Assignment functionality moved to job-assignment component
|
||||
|
||||
downLoadJob(type: number) {
|
||||
this.doDownLoadJob(type);
|
||||
}
|
||||
|
||||
const assignment = <jobActions.AssignInfo>{
|
||||
jobId: this.job._id,
|
||||
dlOp: this.selectedItem.dlOp,
|
||||
avUsers: this.srcUsers,
|
||||
asUsers: this.tarUsers
|
||||
};
|
||||
this.store.dispatch(new jobActions.Assign(assignment));
|
||||
private doDownLoadJob(type) {
|
||||
// TODO: Need to be handled in effects ???
|
||||
this.jobSvc.downloadJob({ jobId: this.selectedItem._id, type: type }).subscribe(
|
||||
(data) => {
|
||||
this.okDl = true;
|
||||
try {
|
||||
saveAs(data, this.selectedItem.name + '.zip');
|
||||
|
||||
// Track job download using GA4 convention
|
||||
this.gaSvc.trackFileDownloaded({
|
||||
file_type: 'prescription_map',
|
||||
file_size_mb: 0, // Size not available from response
|
||||
related_job_id: this.selectedItem._id?.toString(),
|
||||
download_method: 'button_click',
|
||||
file_format: 'original',
|
||||
download_source: 'job_edit',
|
||||
platform: 'web'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Download failed:', error);
|
||||
alert('Sorry. Your browser does not support this feature !');
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
console.error('Download job failed:', error);
|
||||
this.msgSvc.addFailedMsg('Failed to download job');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Event handlers for job assignment component
|
||||
onAssignmentComplete(event: any): void {
|
||||
console.log('Assignment completed:', event);
|
||||
// Handle assignment completion if needed
|
||||
}
|
||||
|
||||
onAssignmentError(error: any): void {
|
||||
console.error('Assignment error:', error);
|
||||
// Handle assignment error if needed
|
||||
}
|
||||
|
||||
downloadAppfile(data) {
|
||||
@ -974,6 +1059,20 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
(res) => {
|
||||
try {
|
||||
saveAs(res, data.name);
|
||||
|
||||
// Track file download using GA4 convention
|
||||
const fileType = data.name
|
||||
? this.gaHelpers.determineFileType(data.name)
|
||||
: 'prescription_map'; // Default fallback for job context
|
||||
this.gaSvc.trackFileDownloaded({
|
||||
file_type: fileType,
|
||||
file_size_mb: data.size ? this.parseFileSizeToMB(data.size) : 0,
|
||||
related_job_id: this.job?._id?.toString(),
|
||||
download_method: 'button_click',
|
||||
file_format: 'original',
|
||||
download_source: 'job_edit',
|
||||
platform: 'web'
|
||||
});
|
||||
} catch (error) {
|
||||
alert('Sorry. Your browser does not support this feature !');
|
||||
}
|
||||
@ -1274,7 +1373,30 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
this.addingNewCropJob = evt == 1;
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
super.ngOnDestroy();
|
||||
/**
|
||||
* Parse file size string to megabytes for GA4 tracking
|
||||
*/
|
||||
private parseFileSizeToMB(sizeString: string): number {
|
||||
if (!sizeString) return 0;
|
||||
|
||||
// Handle formats like "2.3 MB", "1.5 KB", "500 B"
|
||||
const match = sizeString.match(/(\d+\.?\d*)\s*(B|KB|MB|GB)/i);
|
||||
if (!match) return 0;
|
||||
|
||||
const value = parseFloat(match[1]);
|
||||
const unit = match[2].toUpperCase();
|
||||
|
||||
switch (unit) {
|
||||
case 'B':
|
||||
return value / (1024 * 1024);
|
||||
case 'KB':
|
||||
return value / 1024;
|
||||
case 'MB':
|
||||
return value;
|
||||
case 'GB':
|
||||
return value * 1024;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
<div class="ui-g">
|
||||
<div class="ui-g-12">
|
||||
<div class="card clearfix">
|
||||
<p-table #dt [value]="jobs" [columns]="cols" selectionMode="single" (firstChange)="restoreTableFirst()" (onPage)="onPageChange($event)" (onFilter)="restoreTableFirst()" (onRowSelect)="onRowSelect($event)" (onRowUnselect)="onRowSelect($event)" [paginator]="true" [rows]="rows1Page[0]" [pageLinks]="5" [rowsPerPageOptions]="rows1Page" [alwaysShowPaginator]="true" [(selection)]="currentJob" stateStorage="session" stateKey="jtb-ops" dataKey="_id" mutable="false" [responsive]="true" [resetPageOnSort]="false">
|
||||
<p-table #dt [value]="jobs" [columns]="cols" selectionMode="single" (firstChange)="restoreTableFirst()"
|
||||
(onPage)="onPageChange($event)" (onFilter)="restoreTableFirst()" (onRowSelect)="onRowSelect($event)"
|
||||
(onRowUnselect)="onRowSelect($event)" [paginator]="true" [rows]="rows1Page[0]" [pageLinks]="5"
|
||||
[rowsPerPageOptions]="rows1Page" [alwaysShowPaginator]="true" [(selection)]="currentJob" stateStorage="session"
|
||||
stateKey="jtb-ops" dataKey="_id" mutable="false" [responsive]="true" [resetPageOnSort]="false">
|
||||
<ng-template pTemplate="caption">
|
||||
<div class="ui-g ui-g-nopad">
|
||||
<div class="ui-g-6 ui-g-nopad" style="text-align: left">
|
||||
@ -23,10 +27,13 @@
|
||||
<th *ngFor="let col of columns" [ngSwitch]="col.filtered" class="ui-fluid">
|
||||
<div class="input-with-icon" *ngSwitchCase="true">
|
||||
<i class="ui-icon-search"></i>
|
||||
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
|
||||
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)"
|
||||
[value]="dt.filters[col.field]?.value">
|
||||
</div>
|
||||
<p-dropdown #cl *ngIf="col.field === 'client.name'" name="clients" [options]="clients" optionLabel="label" [ngModel]="currClient" filter="true" [emptyFilterMessage]="globals.emptyFilterMsg"></p-dropdown>
|
||||
<p-dropdown *ngIf="col.field === 'status'" [options]="status" [ngModel]="statusFilter" (onChange)="handleStatusFilter($event.value)"></p-dropdown>
|
||||
<p-dropdown #cl *ngIf="col.field === 'client.name'" name="clients" [options]="clients" optionLabel="label"
|
||||
[ngModel]="currClient" filter="true" [emptyFilterMessage]="globals.emptyFilterMsg"></p-dropdown>
|
||||
<p-dropdown *ngIf="col.field === 'status'" [options]="status" [ngModel]="statusFilter"
|
||||
(onChange)="handleStatusFilter($event.value)"></p-dropdown>
|
||||
<span *ngSwitchDefault></span>
|
||||
</th>
|
||||
</tr>
|
||||
@ -37,8 +44,10 @@
|
||||
<td class="table-col-center"><span class="ui-column-title">{{cols[1].header}}</span>{{job._id}}</td>
|
||||
<td class="table-col-center"><span class="ui-column-title">{{cols[2].header}}</span>{{job.orderNumber}}</td>
|
||||
<td><span class="ui-column-title">{{cols[3].header}}</span>{{job.name}}</td>
|
||||
<td class="table-col-center"><span class="ui-column-title">{{cols[4].header}}</span>{{job.startDate | date:'shortDate'}}</td>
|
||||
<td class="table-col-center"><span class="ui-column-title">{{cols[5].header}}</span>{{job.endDate | date:'shortDate'}}</td>
|
||||
<td class="table-col-center"><span class="ui-column-title">{{cols[4].header}}</span>{{job.startDate |
|
||||
date:'shortDate'}}</td>
|
||||
<td class="table-col-center"><span class="ui-column-title">{{cols[5].header}}</span>{{job.endDate |
|
||||
date:'shortDate'}}</td>
|
||||
<td class="table-col-center">
|
||||
<span class="ui-column-title">{{cols[5].header}}</span>
|
||||
{{ job.status | jobStatus }}
|
||||
@ -52,15 +61,24 @@
|
||||
</p-table>
|
||||
<div class="ui-widget-header ui-helper-clearfix toolbar">
|
||||
<span class="ui-g ui-g-10 ui-sm-12 no-pad">
|
||||
<button type="button" pButton icon="ui-icon-plus" *ngIf="canWrite" [disabled]="currClient.value === null || !acre" (click)="newJob()" i18n-label="@@new" label="New"></button>
|
||||
<button type="button" *ngIf="canWrite" [disabled]="!canEdit() || currClient.value === null || !acre" pButton icon="ui-icon-control-point-duplicate" (click)="duplicateJob()" i18n-label="@@duplicate" label="Duplicate"></button>
|
||||
<button type="button" [disabled]="!canEdit()" pButton icon="ui-icon-edit" (click)="editJob()" i18n-label="@@detail" label="Detail"></button>
|
||||
<button type="button" [disabled]="!canEdit()" pButton icon="ui-icon-map" (click)="editJobMap()" i18n-label="@@map" label="Map"></button>
|
||||
<button type="button" [disabled]="!canEdit()" *ngIf="canWrite" pButton icon="ui-icon-trash" (click)="deleteJob()" i18n-label="@@delete" label="Delete"></button>
|
||||
<button type="button" [disabled]="!canCreateInvoice()" *ngIf="canWriteInvoice" pButton icon="ui-icon-add" (click)="createInvoice()" i18n-label="@@createInvoice" label="Create Invoice"></button>
|
||||
<!-- Note: !acre checks if subscription package loaded (not acre limits, packages have unlimited acres) -->
|
||||
<button type="button" pButton icon="ui-icon-plus" *ngIf="canWrite"
|
||||
[disabled]="currClient.value === null || !acre" (click)="newJob()" i18n-label="@@new" label="New"></button>
|
||||
<button type="button" *ngIf="canWrite" [disabled]="!canEdit() || currClient.value === null || !acre" pButton
|
||||
icon="ui-icon-control-point-duplicate" (click)="duplicateJob()" i18n-label="@@duplicate"
|
||||
label="Duplicate"></button>
|
||||
<button type="button" [disabled]="!canEdit()" pButton icon="ui-icon-edit" (click)="editJob()"
|
||||
i18n-label="@@detail" label="Detail"></button>
|
||||
<button type="button" [disabled]="!canEdit()" pButton icon="ui-icon-map" (click)="editJobMap()"
|
||||
i18n-label="@@map" label="Map"></button>
|
||||
<button type="button" [disabled]="!canEdit()" *ngIf="canWrite" pButton icon="ui-icon-trash"
|
||||
(click)="deleteJob()" i18n-label="@@delete" label="Delete"></button>
|
||||
<button type="button" [disabled]="!canCreateInvoice()" *ngIf="canWriteInvoice" pButton icon="ui-icon-add"
|
||||
(click)="createInvoice()" i18n-label="@@createInvoice" label="Create Invoice"></button>
|
||||
</span>
|
||||
<span class="ui-g-2 ui-sm-12 no-pad" *ngIf="!isClientUser">
|
||||
<button pButton type="button" class="amber-btn" icon="ui-icon-arrow-back" (click)="gotoClients()" style="float:right" i18n-label="@@clientList" label="Client List"></button>
|
||||
<button pButton type="button" class="amber-btn" icon="ui-icon-arrow-back" (click)="gotoClients()"
|
||||
style="float:right" i18n-label="@@clientList" label="Client List"></button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -73,22 +91,28 @@
|
||||
<div class="ui-g">
|
||||
<div class="ui-g-12">
|
||||
<span i18n="@@filtJobsByCreatedDate">Filter Jobs By Created Date</span>
|
||||
<p-calendar #calendar [(ngModel)]="selCalDate" selectionMode="range" [readonlyInput]="true" [showButtonBar]="true" [showIcon]="true" (onClose)="onCalClose()"></p-calendar>
|
||||
<p-calendar #calendar [(ngModel)]="selCalDate" selectionMode="range" [readonlyInput]="true"
|
||||
[showButtonBar]="true" [showIcon]="true" (onClose)="onCalClose()"></p-calendar>
|
||||
</div>
|
||||
</div>
|
||||
<p-dropdown [style]="dropdownStyle" [options]="dateOptions" [(ngModel)]="selDate" (onChange)="onDropdownChange($event)">
|
||||
<p-dropdown [style]="dropdownStyle" [options]="dateOptions" [(ngModel)]="selDate"
|
||||
(onChange)="onDropdownChange($event)">
|
||||
<ng-template let-item pTemplate="item">
|
||||
<div class="ui-g">
|
||||
<div [ngClass]="isShowXBtn(item) ? 'ui-g-8' : 'ui-g-12'" class="ui-g-nopad">{{ item.label }}</div>
|
||||
<div *ngIf="isShowXBtn(item)" class="ui-g-4 ui-g-nopad" style="text-align: center;"><button style="border: unset; background: none; cursor: pointer;" class="pi pi-times" (click)="onCalClick()"></button></div>
|
||||
<div *ngIf="isShowXBtn(item)" class="ui-g-4 ui-g-nopad" style="text-align: center;"><button
|
||||
style="border: unset; background: none; cursor: pointer;" class="pi pi-times"
|
||||
(click)="onCalClick()"></button></div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</p-dropdown>
|
||||
</div>
|
||||
<div class="ui-g-4 ui-lg-4 ui-md-12 ui-sm-12 inline-flex-end">
|
||||
<p-dropdown [options]="reloadOps" [style]="dropdownStyle" [(ngModel)]="reloadBy" (onChange)="reloadChanged($event.value)">
|
||||
<p-dropdown [options]="reloadOps" [style]="dropdownStyle" [(ngModel)]="reloadBy"
|
||||
(onChange)="reloadChanged($event.value)">
|
||||
</p-dropdown>
|
||||
<button pButton type="button" style="margin-left:6px" icon="ui-icon-refresh" class="ui-button-secondary" (click)="reloadJobs()"></button>
|
||||
<button pButton type="button" style="margin-left:6px" icon="ui-icon-refresh" class="ui-button-secondary"
|
||||
(click)="reloadJobs()"></button>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
@ -27,6 +27,7 @@ import { SUB, SubTexts, SubType } from '@app/profile/common';
|
||||
import { InvoiceService } from '@app/domain/services/invoice.service';
|
||||
import { RestoreTableState } from '@app/shared/restore-table-state';
|
||||
import { SubscriptionService } from '@app/domain/services/subscription.service';
|
||||
import { GAService } from '@app/shared/ga.service';
|
||||
|
||||
|
||||
@Component({
|
||||
@ -84,7 +85,8 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
private readonly datePipe: DatePipe,
|
||||
private readonly invoiceSvc: InvoiceService,
|
||||
private readonly restoreTableSvc: RestoreTableState,
|
||||
private readonly subscriptionService: SubscriptionService
|
||||
private readonly subscriptionService: SubscriptionService,
|
||||
private readonly gaService: GAService
|
||||
) {
|
||||
super();
|
||||
this.currClient = ({ label: globals.all, value: null });
|
||||
@ -133,6 +135,7 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
// Initialize subscriptions first to get accurate data
|
||||
this.sub$ = this.store.pipe(select(fromClients.getAllClients)).subscribe(clients => {
|
||||
if (Utils.isEmptyArray(clients)) {
|
||||
return;
|
||||
@ -151,8 +154,7 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
} else {
|
||||
this.currClient = ({ label: globals.all, value: null });
|
||||
}
|
||||
}));
|
||||
this.sub$.add(this.store.pipe(select(fromJobs.getJobsByClient)).subscribe(jobs => {
|
||||
})); this.sub$.add(this.store.pipe(select(fromJobs.getJobsByClient)).subscribe(jobs => {
|
||||
this.jobs = jobs;
|
||||
}));
|
||||
this.sub$.add(this.store.pipe(select(fromJobs.getSelectedJob)).subscribe((job) => {
|
||||
@ -160,11 +162,27 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
}));
|
||||
|
||||
this.sub$.add(this.store.select(selectLimit(SubType.PACKAGE)).subscribe((pkg) => {
|
||||
if (pkg) this.acre = pkg[this.authSvc.getCurLookupKey(SubType.PACKAGE)]?.acre;
|
||||
if (pkg) {
|
||||
const lookupKey = this.authSvc.getCurLookupKey(SubType.PACKAGE);
|
||||
|
||||
// If lookup key is empty (user data not loaded yet), find first package key
|
||||
let effectiveLookupKey = lookupKey;
|
||||
if (!lookupKey && pkg) {
|
||||
const packageKeys = Object.keys(pkg);
|
||||
if (packageKeys.length > 0) {
|
||||
effectiveLookupKey = packageKeys[0]; // Use first available package
|
||||
}
|
||||
}
|
||||
|
||||
this.acre = pkg[effectiveLookupKey]?.acre;
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
// Track job list viewed ONCE when component is fully initialized
|
||||
this.trackJobListViewedEvent();
|
||||
|
||||
const listFilter = sessionStorage.getItem('jtb-ops') ? JSON.parse(sessionStorage.getItem('jtb-ops')) : null;
|
||||
|
||||
if (listFilter?.filters) {
|
||||
@ -204,6 +222,21 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
}, 100);
|
||||
}
|
||||
|
||||
private trackJobListViewedEvent(): void {
|
||||
// Track agricultural business intelligence (complements automatic page_view)
|
||||
this.gaService.trackJobListViewed({
|
||||
user_id: this.authSvc.user?._id || 'anonymous',
|
||||
platform: 'web',
|
||||
view_type: 'table',
|
||||
total_jobs: this.jobs?.length || 0,
|
||||
displayed_jobs: this.jobs?.length || 0,
|
||||
sort_by: this.dt?.sortField || null,
|
||||
filter_count: this.getActiveFilterCount(),
|
||||
client_filter_applied: !!this.currClient?.value,
|
||||
reload_interval: this.reloadBy
|
||||
});
|
||||
}
|
||||
|
||||
restoreStatusState(status, invoiced) {
|
||||
const statusMap = {
|
||||
0: jobListStatus.NEW,
|
||||
@ -251,9 +284,27 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
|
||||
onRowSelect(event) {
|
||||
this.store.dispatch(new jobActions.Select(this.currentJob));
|
||||
|
||||
// Track job selection
|
||||
if (this.currentJob) {
|
||||
const positionInList = this.jobs.findIndex(job => job._id === this.currentJob._id) + 1;
|
||||
|
||||
this.gaService.trackJobSelected({
|
||||
user_id: this.authSvc.user?._id || 'anonymous',
|
||||
platform: 'web',
|
||||
job_id: this.currentJob._id.toString(),
|
||||
selection_method: 'row_click',
|
||||
position_in_list: positionInList,
|
||||
job_type: this.currentJob.appType || 'unknown',
|
||||
job_status: this.currentJob.status?.toString() || 'unknown'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get canAddNew(): boolean {
|
||||
// Check subscription package loaded (!!this.acre) and not over limit
|
||||
// Note: With unlimited acres (limit: null), overLimit will always be false,
|
||||
// but keep this check for defensive programming in case limited plans return
|
||||
return !!this.acre && !this.acre.overLimit;
|
||||
}
|
||||
|
||||
@ -276,6 +327,16 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
|
||||
duplicateJob() {
|
||||
if (this.canAddNew) {
|
||||
// Track bulk action (duplicate)
|
||||
this.gaService.trackJobBulkAction({
|
||||
user_id: this.authSvc.user?._id || 'anonymous',
|
||||
platform: 'web',
|
||||
action_type: 'duplicate',
|
||||
job_count: 1,
|
||||
job_ids: [this.currentJob._id.toString()],
|
||||
success_rate: 1.0
|
||||
});
|
||||
|
||||
return this.router.navigate([`./${this.currentJob._id}/edit`, { dup: true }], { relativeTo: this.route });
|
||||
}
|
||||
return this.displaySubDia();
|
||||
@ -302,7 +363,26 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
}
|
||||
|
||||
reloadJobs() {
|
||||
const startTime = performance.now();
|
||||
|
||||
this.fetchJobsByClient(this.currClient && this.currClient.value);
|
||||
|
||||
// Track job list reload
|
||||
setTimeout(() => {
|
||||
const endTime = performance.now();
|
||||
this.gaService.trackJobListViewed({
|
||||
user_id: this.authSvc.user?._id || 'anonymous',
|
||||
platform: 'web',
|
||||
view_type: 'table',
|
||||
total_jobs: this.jobs?.length || 0,
|
||||
displayed_jobs: this.jobs?.length || 0,
|
||||
sort_by: this.dt?.sortField || null,
|
||||
filter_count: this.getActiveFilterCount(),
|
||||
load_time_ms: Math.round(endTime - startTime),
|
||||
client_filter_applied: !!this.currClient?.value,
|
||||
reload_interval: this.reloadBy
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
reloadChanged(value) {
|
||||
@ -357,6 +437,8 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
}
|
||||
|
||||
handleStatusFilter(value) {
|
||||
const previousCount = this.jobs?.length || 0;
|
||||
|
||||
switch (value) {
|
||||
case jobListStatus.ALL:
|
||||
this.dt.filter(null, 'status', 'equals');
|
||||
@ -389,6 +471,20 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
this.statusFilter = jobListStatus.INVOICED;
|
||||
break;
|
||||
}
|
||||
|
||||
// Track filter usage
|
||||
setTimeout(() => {
|
||||
const currentCount = this.jobs?.length || 0;
|
||||
this.gaService.trackJobListFiltered({
|
||||
user_id: this.authSvc.user?._id || 'anonymous',
|
||||
platform: 'web',
|
||||
filter_type: 'status',
|
||||
filter_value: value,
|
||||
results_before: previousCount,
|
||||
results_after: currentCount,
|
||||
filter_effectiveness: previousCount > 0 ? (currentCount / previousCount) : 0
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
private setJobListSelDate(dateSelection): void {
|
||||
@ -410,15 +506,34 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
}
|
||||
|
||||
onDropdownChange(evt): void {
|
||||
const previousCount = this.jobs?.length || 0;
|
||||
|
||||
if (evt.value === this.customeDate) {
|
||||
setTimeout(() => this.showCal());
|
||||
} else {
|
||||
this.setJobListSelDate({ selDate: evt.value, selCalDate: null });
|
||||
this.reloadJobs();
|
||||
|
||||
// Track date filter usage
|
||||
setTimeout(() => {
|
||||
const currentCount = this.jobs?.length || 0;
|
||||
this.gaService.trackJobListFiltered({
|
||||
user_id: this.authSvc.user?._id || 'anonymous',
|
||||
platform: 'web',
|
||||
filter_type: 'date',
|
||||
filter_value: evt.value,
|
||||
results_before: previousCount,
|
||||
results_after: currentCount,
|
||||
filter_effectiveness: previousCount > 0 ? (currentCount / previousCount) : 0,
|
||||
date_filter_type: this.getDateFilterType(evt.value)
|
||||
});
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
onCalClose(): void {
|
||||
const previousCount = this.jobs?.length || 0;
|
||||
|
||||
this.setCustomDateLabel();
|
||||
if (this.selCalDate) {
|
||||
this.setJobListSelDate({ selDate: null, selCalDate: this.selCalDate });
|
||||
@ -427,6 +542,27 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
this.setJobListSelDate({ selDate: this.selDate, selCalDate: null });
|
||||
}
|
||||
this.reloadJobs();
|
||||
|
||||
// Track custom date filter usage
|
||||
if (this.selCalDate) {
|
||||
setTimeout(() => {
|
||||
const currentCount = this.jobs?.length || 0;
|
||||
this.gaService.trackJobListFiltered({
|
||||
user_id: this.authSvc.user?._id || 'anonymous',
|
||||
platform: 'web',
|
||||
filter_type: 'date',
|
||||
filter_value: 'custom_date_range',
|
||||
results_before: previousCount,
|
||||
results_after: currentCount,
|
||||
filter_effectiveness: previousCount > 0 ? (currentCount / previousCount) : 0,
|
||||
date_filter_type: 'custom',
|
||||
custom_date_range: [
|
||||
this.selCalDate[0]?.toISOString().split('T')[0],
|
||||
this.selCalDate[1]?.toISOString().split('T')[0]
|
||||
]
|
||||
});
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
onCalClick() {
|
||||
@ -441,6 +577,48 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
return item?.value == this.customeDate && this.selDate == this.customeDate
|
||||
}
|
||||
|
||||
// Helper method to count active filters
|
||||
private getActiveFilterCount(): number {
|
||||
let count = 0;
|
||||
|
||||
// Check status filter
|
||||
if (this.statusFilter && this.statusFilter !== jobListStatus.ALL) {
|
||||
count++;
|
||||
}
|
||||
|
||||
// Check client filter
|
||||
if (this.currClient?.value) {
|
||||
count++;
|
||||
}
|
||||
|
||||
// Check date filter
|
||||
if (this.selDate && this.selDate !== this.dateOptions[0]?.value) {
|
||||
count++;
|
||||
}
|
||||
|
||||
// Check table column filters
|
||||
if (this.dt?.filters) {
|
||||
Object.keys(this.dt.filters).forEach(key => {
|
||||
const filter = this.dt.filters[key];
|
||||
if (filter && filter.value && filter.value !== '') {
|
||||
count++;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
// Helper method to determine date filter type
|
||||
private getDateFilterType(value: string): 'today' | 'week' | 'month' | 'quarter' | 'custom' {
|
||||
if (value === this.customeDate) return 'custom';
|
||||
if (value?.includes('today')) return 'today';
|
||||
if (value?.includes('week')) return 'week';
|
||||
if (value?.includes('month')) return 'month';
|
||||
if (value?.includes('quarter')) return 'quarter';
|
||||
return 'custom';
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
super.ngOnDestroy();
|
||||
if (this.reload$) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user